From 86510d893ac50992c448d473916b0349effdaada Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 15:39:40 +0000 Subject: [PATCH 1/4] feat(repository): own dependency read scopes Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- .changeset/calm-pallets-listen.md | 5 +++ .../src/Model/Repository/internal/internal.ts | 14 +++----- .../src/Model/Repository/service.ts | 5 +++ packages/infra/test/repository-ext.test.ts | 34 ++++++++++++++++++- 4 files changed, 48 insertions(+), 10 deletions(-) create mode 100644 .changeset/calm-pallets-listen.md diff --git a/.changeset/calm-pallets-listen.md b/.changeset/calm-pallets-listen.md new file mode 100644 index 000000000..5bc36ea0e --- /dev/null +++ b/.changeset/calm-pallets-listen.md @@ -0,0 +1,5 @@ +--- +"effect-app": patch +--- + +Add repository-owned dependency read scopes and record previous relationship aliases on writes. diff --git a/packages/effect-app/src/Model/Repository/internal/internal.ts b/packages/effect-app/src/Model/Repository/internal/internal.ts index b0c3d4c87..1bf1e4bb5 100644 --- a/packages/effect-app/src/Model/Repository/internal/internal.ts +++ b/packages/effect-app/src/Model/Repository/internal/internal.ts @@ -166,12 +166,6 @@ export function makeRepoInternal< DataDependencies.write, { discard: true } ) - const recordAdditionalItemWrites = (items: ReadonlyArray) => - Effect.forEach( - DataDependencies.merge(new Set(items.flatMap((item) => args.additionalWriteDependencies?.(item) ?? []))), - DataDependencies.write, - { discard: true } - ) const cms = Effect.map(getContextMap.pipe(Effect.orDie), (_) => ({ get: (id: string) => _.get(`${name}.${id}`), set: (id: string, etag: string | undefined) => _.set(`${name}.${id}`, etag) @@ -353,7 +347,7 @@ export function makeRepoInternal< const it = Chunk.fromIterable(items) if (Chunk.isNonEmpty(it)) { const values = Chunk.toReadonlyArray(it) - const previous = args.additionalWriteDependencies + const previous = args.additionalWriteDependencies || args.dependencyIds ? yield* loadExistingItems(values.map((item) => item[idKey])) : [] yield* recordItemWrite([values[0], ...values.slice(1), ...previous]) @@ -414,8 +408,9 @@ export function makeRepoInternal< return } yield* recordEntityWrite(ids) - if (args.additionalWriteDependencies) { - yield* loadExistingItems(ids).pipe(Effect.flatMap(recordAdditionalItemWrites)) + if (args.additionalWriteDependencies || args.dependencyIds) { + const previous = yield* loadExistingItems(ids) + if (Array.isReadonlyArrayNonEmpty(previous)) yield* recordItemWrite(previous) } const { set } = yield* cms const eids = yield* Effect.forEach(ids, (_) => encodeIdOnly(_ as any)).pipe(Effect.orDie) @@ -647,6 +642,7 @@ export function makeRepoInternal< idKey, find, all, + withReadScope: (ids: NonEmptyReadonlyArray) => DataDependencies.withRepoReadScope(name, ids), saveAndPublish, removeAndPublish, removeById, diff --git a/packages/effect-app/src/Model/Repository/service.ts b/packages/effect-app/src/Model/Repository/service.ts index 875a0cd17..4a475c69d 100644 --- a/packages/effect-app/src/Model/Repository/service.ts +++ b/packages/effect-app/src/Model/Repository/service.ts @@ -1,5 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type * as Scope from "effect/Scope" +import type { NonEmptyReadonlyArray } from "../../Array.ts" import type { DatabaseError, InvalidStateError, NotFoundError, OptimisticConcurrencyException } from "../../client/errors.ts" import type * as Effect from "../../Effect.ts" import type * as Option from "../../Option.ts" @@ -51,6 +52,10 @@ export interface Repository< readonly idKey: IdKey readonly find: (id: T[IdKey]) => Effect.Effect, DatabaseError, RSchema> readonly all: Effect.Effect + /** Scope repository reads to relationship IDs used by dependency invalidation. */ + readonly withReadScope: ( + ids: NonEmptyReadonlyArray + ) => (self: Effect.Effect) => Effect.Effect readonly saveAndPublish: ( items: Iterable, events?: Iterable diff --git a/packages/infra/test/repository-ext.test.ts b/packages/infra/test/repository-ext.test.ts index e5f1201af..53e3ba91a 100644 --- a/packages/infra/test/repository-ext.test.ts +++ b/packages/infra/test/repository-ext.test.ts @@ -231,7 +231,7 @@ describe("repository ext save/remove batching", () => { dependencyIds: (item) => [item.id, `alias-${item.id}`] }) yield* repo.save(new BatchItem({ id: "1", label: "one" })) - yield* repo.all.pipe(DataDependencies.withRepoReadScope("DependencyItem", ["alias-1"])) + yield* repo.all.pipe(repo.withReadScope(["alias-1"])) }) .pipe(Effect.provideService(DataDependencies.DataDependencyRecorder, recorder)) @@ -245,6 +245,38 @@ describe("repository ext save/remove batching", () => { Effect.provide(TestStoreLive) )) + it.effect("records previous and next relationship aliases", () => + Effect + .gen(function*() { + const readsRef = yield* Ref.make(DataDependencies.empty()) + const writesRef = yield* Ref.make(DataDependencies.empty()) + const recorder = DataDependencies.makeDataDependencyRecorder(readsRef, writesRef) + + yield* Effect + .gen(function*() { + const repo = yield* makeRepo("DependencyItem", BatchItem, { + dependencyIds: (item) => [item.id, `label-${item.label}`] + }) + yield* repo.save(new BatchItem({ id: "1", label: "old" })) + yield* recorder.drainWrites + + yield* repo.save(new BatchItem({ id: "1", label: "new" })) + expect(yield* recorder.drainWrites).toEqual( + new Set([DataDependencies.repo("DependencyItem", ["1", "label-new", "label-old"])]) + ) + + yield* repo.removeById("1") + expect(yield* recorder.drainWrites).toEqual( + new Set([DataDependencies.repo("DependencyItem", ["1", "label-new"])]) + ) + }) + .pipe(Effect.provideService(DataDependencies.DataDependencyRecorder, recorder)) + }) + .pipe( + setupRequestContextFromCurrent(), + Effect.provide(TestStoreLive) + )) + it.effect("records repository and affected-query write dependencies", () => Effect .gen(function*() { From f2f2eb31fee3d496176f44cbd5e6088d1ae741ac Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:57:47 +0000 Subject: [PATCH 2/4] feat(repository): derive dependency scopes from queries Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- .changeset/calm-pallets-listen.md | 2 +- .../src/Model/Repository/internal/internal.ts | 55 +++++++++++++++++-- .../src/Model/Repository/makeRepo.ts | 11 ++++ packages/infra/test/repository-ext.test.ts | 44 +++++++++++++-- 4 files changed, 102 insertions(+), 10 deletions(-) diff --git a/.changeset/calm-pallets-listen.md b/.changeset/calm-pallets-listen.md index 5bc36ea0e..94dc17f0b 100644 --- a/.changeset/calm-pallets-listen.md +++ b/.changeset/calm-pallets-listen.md @@ -2,4 +2,4 @@ "effect-app": patch --- -Add repository-owned dependency read scopes and record previous relationship aliases on writes. +Derive scoped dependency reads from typed query filters and repository relationship paths, and record previous aliases on writes. diff --git a/packages/effect-app/src/Model/Repository/internal/internal.ts b/packages/effect-app/src/Model/Repository/internal/internal.ts index 1bf1e4bb5..ade8c1336 100644 --- a/packages/effect-app/src/Model/Repository/internal/internal.ts +++ b/packages/effect-app/src/Model/Repository/internal/internal.ts @@ -25,7 +25,9 @@ import { type Codec, NonNegativeInt } from "../../../Schema.ts" import * as SchemaAST from "../../../SchemaAST.ts" import { setupRequestContextFromCurrent } from "../../../setupRequest.ts" import { type FilterArgs, getContextMap, type PersistenceModelType, type StoreConfig, storeId, StoreMaker } from "../../../Store.ts" +import type { FilterResult } from "../../filter/filterApi.ts" import type { FieldValues } from "../../filter/types.ts" +import type { FieldPath } from "../../filter/types/path/eager.ts" import * as Q from "../../query.ts" import type { ChangeFeed, ChangeFeedEvent, Repository } from "../service.ts" import { ValidationError, ValidationResult } from "../validation.ts" @@ -115,6 +117,7 @@ export function makeRepoInternal< schemaContext?: Context.Context makeInitial?: Effect.Effect | undefined dependencyIds?: (item: T) => NonEmptyReadonlyArray + dependencyPaths?: ReadonlyArray> additionalWriteDependencies?: (item: T) => ReadonlyArray config?: Omit, "partitionValue"> & { partitionValue?: (e?: Encoded) => string @@ -125,6 +128,7 @@ export function makeRepoInternal< publishEvents: (evt: NonEmptyReadonlyArray) => Effect.Effect makeInitial?: Effect.Effect | undefined dependencyIds?: (item: T) => NonEmptyReadonlyArray + dependencyPaths?: ReadonlyArray> additionalWriteDependencies?: (item: T) => ReadonlyArray config?: Omit, "partitionValue"> & { partitionValue?: (e?: Encoded) => string @@ -150,13 +154,52 @@ export function makeRepoInternal< const recordRead = DataDependencies.readRepo(name) const entityDependency = (ids: NonEmptyReadonlyArray) => DataDependencies.repo(name, [String(ids[0]), ...ids.slice(1).map(String)]) + const valuesAtPath = (value: unknown, path: string): readonly unknown[] => { + if (path.length === 0) return [value] + const [head, ...tail] = path.split(".") + if (head === undefined) return [value] + if (head === "-1") { + return globalThis.Array.isArray(value) + ? value.flatMap((item) => valuesAtPath(item, tail.join("."))) + : [] + } + return typeof value === "object" && value !== null + ? valuesAtPath((value as Record)[head], tail.join(".")) + : [] + } + const dependencyIds = (item: T): NonEmptyReadonlyArray => { + const configured = args + .dependencyPaths + ?.flatMap((path) => valuesAtPath(item, String(path))) + .filter((value): value is string => typeof value === "string") ?? [] + const ids = args.dependencyIds?.(item) ?? [String(item[idKey]), ...configured] + return [ids[0], ...ids.slice(1)] + } const itemDependencies = (item: T) => { - const ids = args.dependencyIds?.(item) ?? [String(item[idKey])] + const ids = dependencyIds(item) return [ DataDependencies.repo(name, ids), ...(args.additionalWriteDependencies?.(item) ?? []) ] } + const queryDependencyIds = (filter: readonly FilterResult[] | undefined): readonly string[] => { + if (!filter) return [] + const paths = new Set([String(idKey), ...(args.dependencyPaths?.map(String) ?? [])]) + const visit = (items: readonly FilterResult[]): readonly string[] => { + if (items.some((item) => item.t === "or" || item.t === "or-scope")) return [] + return items.flatMap((item) => { + if ("result" in item) return visit(item.result) + if (!paths.has(item.path)) return [] + const value: unknown = item.value + if (item.op === "eq" && typeof value === "string") return [value] + if (item.op === "in" && globalThis.Array.isArray(value)) { + return value.filter((entry): entry is string => typeof entry === "string") + } + return [] + }) + } + return [...new Set(visit(filter))] + } const recordEntityRead = (id: T[IdKey]) => DataDependencies.read(entityDependency([id])) const recordEntityWrite = (ids: NonEmptyReadonlyArray) => DataDependencies.write(entityDependency(ids)) @@ -347,7 +390,7 @@ export function makeRepoInternal< const it = Chunk.fromIterable(items) if (Chunk.isNonEmpty(it)) { const values = Chunk.toReadonlyArray(it) - const previous = args.additionalWriteDependencies || args.dependencyIds + const previous = args.additionalWriteDependencies || args.dependencyIds || args.dependencyPaths ? yield* loadExistingItems(values.map((item) => item[idKey])) : [] yield* recordItemWrite([values[0], ...values.slice(1), ...previous]) @@ -408,7 +451,7 @@ export function makeRepoInternal< return } yield* recordEntityWrite(ids) - if (args.additionalWriteDependencies || args.dependencyIds) { + if (args.additionalWriteDependencies || args.dependencyIds || args.dependencyPaths) { const previous = yield* loadExistingItems(ids) if (Array.isReadonlyArrayNonEmpty(previous)) yield* recordItemWrite(previous) } @@ -480,6 +523,10 @@ export function makeRepoInternal< ): Effect.Effect> } = ((q: Q.QAll) => { const a = Q.toFilter(q, schema) + const scopedIds = queryDependencyIds(a.filter) + const recordQueryRead = Array.isReadonlyArrayNonEmpty(scopedIds) + ? DataDependencies.read(DataDependencies.repo(name, scopedIds)) + : recordRead // Mode dispatch — see `Q.project` JSDoc for the contract: // aggregate: GROUP BY + aggregate functions at DB level; decode raw rows with schema; SchemaError surfaces. // project : decode raw encoded rows with schema; no PM reverse-mapping; SchemaError surfaces. @@ -558,7 +605,7 @@ export function makeRepoInternal< "db.response.returned_rows": Array.isArray(r) ? r.length : 1 }) ), - Effect.tap(() => recordRead), + Effect.tap(() => recordQueryRead), Effect.withSpan("Repository.query", { kind: "client", attributes: { "app.entity": name } diff --git a/packages/effect-app/src/Model/Repository/makeRepo.ts b/packages/effect-app/src/Model/Repository/makeRepo.ts index b2b5af115..424b3a102 100644 --- a/packages/effect-app/src/Model/Repository/makeRepo.ts +++ b/packages/effect-app/src/Model/Repository/makeRepo.ts @@ -14,6 +14,7 @@ import * as Effect from "../../Effect.ts" import type * as S from "../../Schema.ts" import type { StoreConfig, StoreMaker } from "../../Store.ts" import type { FieldValues } from "../filter/types.ts" +import type { FieldPath, FieldPathValue } from "../filter/types/path/eager.ts" import { type ExtendedRepository, extendRepo } from "./ext.ts" import { makeRepoInternal } from "./internal/internal.ts" import { RepositoryRegistry } from "./Registry.ts" @@ -62,6 +63,16 @@ export interface RepositoryOptions< /** IDs, including aliases, that identify an entity for dependency invalidation. */ dependencyIds?: (item: T) => NonEmptyReadonlyArray + /** + * Model paths whose values identify an entity for dependency invalidation. + * Matching equality predicates in repository queries are scoped automatically. + */ + dependencyPaths?: ReadonlyArray< + { + [Path in FieldPath]: FieldPathValue extends string ? Path : never + }[FieldPath] + > + /** * Additional derived queries or resources affected by writing an item. * Saves invalidate dependencies derived from both the previous and next item; diff --git a/packages/infra/test/repository-ext.test.ts b/packages/infra/test/repository-ext.test.ts index 53e3ba91a..a0195eeaf 100644 --- a/packages/infra/test/repository-ext.test.ts +++ b/packages/infra/test/repository-ext.test.ts @@ -16,6 +16,11 @@ class BatchItem extends S.Class("BatchItem")({ label: S.String }) {} +class NestedDependencyItem extends S.Class("NestedDependencyItem")({ + id: S.String, + parts: S.Array(S.Struct({ id: S.String })) +}) {} + const TestStoreLive = Layer.merge(MemoryStoreLive, RepositoryRegistryLive) const A = S.TaggedStruct("A", { id: S.String }) @@ -218,7 +223,7 @@ describe("repository ext save/remove batching", () => { .toEqual(DataDependencies.repo("DependencyItem")) }) - it.effect("matches an explicit query scope to a write alias", () => + it.effect("derives matching read and write scopes from a dependency path", () => Effect .gen(function*() { const readsRef = yield* Ref.make(DataDependencies.empty()) @@ -228,16 +233,45 @@ describe("repository ext save/remove batching", () => { yield* Effect .gen(function*() { const repo = yield* makeRepo("DependencyItem", BatchItem, { - dependencyIds: (item) => [item.id, `alias-${item.id}`] + dependencyPaths: ["label"] }) yield* repo.save(new BatchItem({ id: "1", label: "one" })) - yield* repo.all.pipe(repo.withReadScope(["alias-1"])) + yield* repo.query(Q.where("label", "one")) }) .pipe(Effect.provideService(DataDependencies.DataDependencyRecorder, recorder)) - expect(yield* Ref.get(readsRef)).toEqual(new Set([DataDependencies.repo("DependencyItem", ["alias-1"])])) + expect(yield* Ref.get(readsRef)).toEqual(new Set([DataDependencies.repo("DependencyItem", ["one"])])) + expect(yield* Ref.get(writesRef)).toEqual( + new Set([DataDependencies.repo("DependencyItem", ["1", "one"])]) + ) + }) + .pipe( + setupRequestContextFromCurrent(), + Effect.provide(TestStoreLive) + )) + + it.effect("derives a nested relationship scope from whereSome", () => + Effect + .gen(function*() { + const readsRef = yield* Ref.make(DataDependencies.empty()) + const writesRef = yield* Ref.make(DataDependencies.empty()) + const recorder = DataDependencies.makeDataDependencyRecorder(readsRef, writesRef) + + yield* Effect + .gen(function*() { + const repo = yield* makeRepo("NestedDependencyItem", NestedDependencyItem, { + dependencyPaths: ["parts.-1.id"] + }) + yield* repo.save(new NestedDependencyItem({ id: "root", parts: [{ id: "part-1" }] })) + yield* repo.query(Q.whereSome("parts", Q.where("id", "part-1"))) + }) + .pipe(Effect.provideService(DataDependencies.DataDependencyRecorder, recorder)) + + expect(yield* Ref.get(readsRef)).toEqual( + new Set([DataDependencies.repo("NestedDependencyItem", ["part-1"])]) + ) expect(yield* Ref.get(writesRef)).toEqual( - new Set([DataDependencies.repo("DependencyItem", ["1", "alias-1"])]) + new Set([DataDependencies.repo("NestedDependencyItem", ["root", "part-1"])]) ) }) .pipe( From c431a65633f5fb7e144c8852ae0539c501a70705 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:12:54 +0000 Subject: [PATCH 3/4] refactor(repository): derive dependencies from schema annotations Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- .changeset/calm-pallets-listen.md | 2 +- packages/effect-app/src/Model.ts | 1 + packages/effect-app/src/Model/Repository.ts | 1 + .../src/Model/Repository/dependency.ts | 32 +++++++++++++++++++ .../src/Model/Repository/internal/internal.ts | 16 ++++------ .../src/Model/Repository/makeRepo.ts | 11 ------- packages/infra/test/repository-ext.test.ts | 29 +++++++++-------- 7 files changed, 57 insertions(+), 35 deletions(-) create mode 100644 packages/effect-app/src/Model/Repository/dependency.ts diff --git a/.changeset/calm-pallets-listen.md b/.changeset/calm-pallets-listen.md index 94dc17f0b..d015de043 100644 --- a/.changeset/calm-pallets-listen.md +++ b/.changeset/calm-pallets-listen.md @@ -2,4 +2,4 @@ "effect-app": patch --- -Derive scoped dependency reads from typed query filters and repository relationship paths, and record previous aliases on writes. +Derive scoped dependency reads and writes from typed query filters and annotated model relationships, including previous aliases. diff --git a/packages/effect-app/src/Model.ts b/packages/effect-app/src/Model.ts index 5696dee3a..fdfa6b568 100644 --- a/packages/effect-app/src/Model.ts +++ b/packages/effect-app/src/Model.ts @@ -1,4 +1,5 @@ export * from "./Model/dsl.ts" export * as Q from "./Model/query.ts" export { makeRepo } from "./Model/Repository.ts" +export { repositoryDependency } from "./Model/Repository.ts" export { type RegisteredRepository, RepositoryRegistry, RepositoryRegistryLive } from "./Model/Repository.ts" diff --git a/packages/effect-app/src/Model/Repository.ts b/packages/effect-app/src/Model/Repository.ts index 93a7dbf6f..f68b0b3e0 100644 --- a/packages/effect-app/src/Model/Repository.ts +++ b/packages/effect-app/src/Model/Repository.ts @@ -1,3 +1,4 @@ +export { repositoryDependency } from "./Repository/dependency.ts" export * from "./Repository/ext.ts" export * from "./Repository/legacy.ts" export { makeRepo } from "./Repository/makeRepo.ts" diff --git a/packages/effect-app/src/Model/Repository/dependency.ts b/packages/effect-app/src/Model/Repository/dependency.ts new file mode 100644 index 000000000..adc7e64d9 --- /dev/null +++ b/packages/effect-app/src/Model/Repository/dependency.ts @@ -0,0 +1,32 @@ +import * as S from "../../Schema.ts" +import * as SchemaAST from "../../SchemaAST.ts" + +const RepositoryDependencyAnnotation = "effect-app/Repository/dependency" + +/** Marks an encoded model field as an identity alias for repository invalidation. */ +export const repositoryDependency = (schema: Schema): Schema["Rebuild"] => + S.annotateEncoded({ [RepositoryDependencyAnnotation]: true })(schema) + +/** @internal */ +export const repositoryDependencyPaths = (schema: S.Schema): readonly string[] => { + const visit = (ast: SchemaAST.AST, path: readonly string[]): readonly string[] => { + const annotations = ast.checks?.at(-1)?.annotations ?? ast.annotations + if (annotations?.[RepositoryDependencyAnnotation] === true) return [path.join(".")] + if (SchemaAST.isDeclaration(ast)) return ast.typeParameters.flatMap((parameter) => visit(parameter, path)) + if (SchemaAST.isUnion(ast)) return ast.types.flatMap((member) => visit(member, path)) + if (SchemaAST.isObjects(ast)) { + return ast.propertySignatures.flatMap((property) => + typeof property.name === "string" ? visit(property.type, [...path, property.name]) : [] + ) + } + if (SchemaAST.isArrays(ast)) { + return [ + ...ast.elements.flatMap((element, index) => visit(element, [...path, String(index)])), + ...ast.rest.flatMap((element) => visit(element, [...path, "-1"])) + ] + } + return [] + } + + return [...new Set(visit(SchemaAST.toEncoded(schema.ast), []))] +} diff --git a/packages/effect-app/src/Model/Repository/internal/internal.ts b/packages/effect-app/src/Model/Repository/internal/internal.ts index ade8c1336..792c5b1d9 100644 --- a/packages/effect-app/src/Model/Repository/internal/internal.ts +++ b/packages/effect-app/src/Model/Repository/internal/internal.ts @@ -27,8 +27,8 @@ import { setupRequestContextFromCurrent } from "../../../setupRequest.ts" import { type FilterArgs, getContextMap, type PersistenceModelType, type StoreConfig, storeId, StoreMaker } from "../../../Store.ts" import type { FilterResult } from "../../filter/filterApi.ts" import type { FieldValues } from "../../filter/types.ts" -import type { FieldPath } from "../../filter/types/path/eager.ts" import * as Q from "../../query.ts" +import { repositoryDependencyPaths } from "../dependency.ts" import type { ChangeFeed, ChangeFeedEvent, Repository } from "../service.ts" import { ValidationError, ValidationResult } from "../validation.ts" @@ -117,7 +117,6 @@ export function makeRepoInternal< schemaContext?: Context.Context makeInitial?: Effect.Effect | undefined dependencyIds?: (item: T) => NonEmptyReadonlyArray - dependencyPaths?: ReadonlyArray> additionalWriteDependencies?: (item: T) => ReadonlyArray config?: Omit, "partitionValue"> & { partitionValue?: (e?: Encoded) => string @@ -128,7 +127,6 @@ export function makeRepoInternal< publishEvents: (evt: NonEmptyReadonlyArray) => Effect.Effect makeInitial?: Effect.Effect | undefined dependencyIds?: (item: T) => NonEmptyReadonlyArray - dependencyPaths?: ReadonlyArray> additionalWriteDependencies?: (item: T) => ReadonlyArray config?: Omit, "partitionValue"> & { partitionValue?: (e?: Encoded) => string @@ -151,6 +149,7 @@ export function makeRepoInternal< ) const store = yield* mkStore(args.makeInitial, args.config) + const dependencyPaths = repositoryDependencyPaths(schema) const recordRead = DataDependencies.readRepo(name) const entityDependency = (ids: NonEmptyReadonlyArray) => DataDependencies.repo(name, [String(ids[0]), ...ids.slice(1).map(String)]) @@ -168,9 +167,8 @@ export function makeRepoInternal< : [] } const dependencyIds = (item: T): NonEmptyReadonlyArray => { - const configured = args - .dependencyPaths - ?.flatMap((path) => valuesAtPath(item, String(path))) + const configured = dependencyPaths + .flatMap((path) => valuesAtPath(item, String(path))) .filter((value): value is string => typeof value === "string") ?? [] const ids = args.dependencyIds?.(item) ?? [String(item[idKey]), ...configured] return [ids[0], ...ids.slice(1)] @@ -184,7 +182,7 @@ export function makeRepoInternal< } const queryDependencyIds = (filter: readonly FilterResult[] | undefined): readonly string[] => { if (!filter) return [] - const paths = new Set([String(idKey), ...(args.dependencyPaths?.map(String) ?? [])]) + const paths = new Set([String(idKey), ...dependencyPaths]) const visit = (items: readonly FilterResult[]): readonly string[] => { if (items.some((item) => item.t === "or" || item.t === "or-scope")) return [] return items.flatMap((item) => { @@ -390,7 +388,7 @@ export function makeRepoInternal< const it = Chunk.fromIterable(items) if (Chunk.isNonEmpty(it)) { const values = Chunk.toReadonlyArray(it) - const previous = args.additionalWriteDependencies || args.dependencyIds || args.dependencyPaths + const previous = args.additionalWriteDependencies || args.dependencyIds || dependencyPaths.length > 0 ? yield* loadExistingItems(values.map((item) => item[idKey])) : [] yield* recordItemWrite([values[0], ...values.slice(1), ...previous]) @@ -451,7 +449,7 @@ export function makeRepoInternal< return } yield* recordEntityWrite(ids) - if (args.additionalWriteDependencies || args.dependencyIds || args.dependencyPaths) { + if (args.additionalWriteDependencies || args.dependencyIds || dependencyPaths.length > 0) { const previous = yield* loadExistingItems(ids) if (Array.isReadonlyArrayNonEmpty(previous)) yield* recordItemWrite(previous) } diff --git a/packages/effect-app/src/Model/Repository/makeRepo.ts b/packages/effect-app/src/Model/Repository/makeRepo.ts index 424b3a102..b2b5af115 100644 --- a/packages/effect-app/src/Model/Repository/makeRepo.ts +++ b/packages/effect-app/src/Model/Repository/makeRepo.ts @@ -14,7 +14,6 @@ import * as Effect from "../../Effect.ts" import type * as S from "../../Schema.ts" import type { StoreConfig, StoreMaker } from "../../Store.ts" import type { FieldValues } from "../filter/types.ts" -import type { FieldPath, FieldPathValue } from "../filter/types/path/eager.ts" import { type ExtendedRepository, extendRepo } from "./ext.ts" import { makeRepoInternal } from "./internal/internal.ts" import { RepositoryRegistry } from "./Registry.ts" @@ -63,16 +62,6 @@ export interface RepositoryOptions< /** IDs, including aliases, that identify an entity for dependency invalidation. */ dependencyIds?: (item: T) => NonEmptyReadonlyArray - /** - * Model paths whose values identify an entity for dependency invalidation. - * Matching equality predicates in repository queries are scoped automatically. - */ - dependencyPaths?: ReadonlyArray< - { - [Path in FieldPath]: FieldPathValue extends string ? Path : never - }[FieldPath] - > - /** * Additional derived queries or resources affected by writing an item. * Saves invalidate dependencies derived from both the previous and next item; diff --git a/packages/infra/test/repository-ext.test.ts b/packages/infra/test/repository-ext.test.ts index a0195eeaf..ea7958bff 100644 --- a/packages/infra/test/repository-ext.test.ts +++ b/packages/infra/test/repository-ext.test.ts @@ -3,7 +3,7 @@ import * as DataDependencies from "effect-app/DataDependencies" import * as Effect from "effect-app/Effect" import * as Layer from "effect-app/Layer" import { Q } from "effect-app/Model" -import { makeRepo } from "effect-app/Model/Repository" +import { makeRepo, repositoryDependency } from "effect-app/Model/Repository" import { RepositoryRegistryLive } from "effect-app/Model/Repository/Registry" import * as S from "effect-app/Schema" import { setupRequestContextFromCurrent } from "effect-app/setupRequest" @@ -16,9 +16,14 @@ class BatchItem extends S.Class("BatchItem")({ label: S.String }) {} +class DependencyItem extends S.Class("DependencyItem")({ + id: S.String, + label: repositoryDependency(S.StringId) +}) {} + class NestedDependencyItem extends S.Class("NestedDependencyItem")({ id: S.String, - parts: S.Array(S.Struct({ id: S.String })) + parts: S.Array(S.Struct({ id: repositoryDependency(S.String) })) }) {} const TestStoreLive = Layer.merge(MemoryStoreLive, RepositoryRegistryLive) @@ -223,7 +228,7 @@ describe("repository ext save/remove batching", () => { .toEqual(DataDependencies.repo("DependencyItem")) }) - it.effect("derives matching read and write scopes from a dependency path", () => + it.effect("derives matching read and write scopes from a schema annotation", () => Effect .gen(function*() { const readsRef = yield* Ref.make(DataDependencies.empty()) @@ -232,17 +237,15 @@ describe("repository ext save/remove batching", () => { yield* Effect .gen(function*() { - const repo = yield* makeRepo("DependencyItem", BatchItem, { - dependencyPaths: ["label"] - }) - yield* repo.save(new BatchItem({ id: "1", label: "one" })) - yield* repo.query(Q.where("label", "one")) + const repo = yield* makeRepo("DependencyItem", DependencyItem, {}) + yield* repo.save(new DependencyItem({ id: "1", label: S.StringId("label-one") })) + yield* repo.query(Q.where("label", "label-one")) }) .pipe(Effect.provideService(DataDependencies.DataDependencyRecorder, recorder)) - expect(yield* Ref.get(readsRef)).toEqual(new Set([DataDependencies.repo("DependencyItem", ["one"])])) + expect(yield* Ref.get(readsRef)).toEqual(new Set([DataDependencies.repo("DependencyItem", ["label-one"])])) expect(yield* Ref.get(writesRef)).toEqual( - new Set([DataDependencies.repo("DependencyItem", ["1", "one"])]) + new Set([DataDependencies.repo("DependencyItem", ["1", "label-one"])]) ) }) .pipe( @@ -250,7 +253,7 @@ describe("repository ext save/remove batching", () => { Effect.provide(TestStoreLive) )) - it.effect("derives a nested relationship scope from whereSome", () => + it.effect("derives a nested annotated relationship scope from whereSome", () => Effect .gen(function*() { const readsRef = yield* Ref.make(DataDependencies.empty()) @@ -259,9 +262,7 @@ describe("repository ext save/remove batching", () => { yield* Effect .gen(function*() { - const repo = yield* makeRepo("NestedDependencyItem", NestedDependencyItem, { - dependencyPaths: ["parts.-1.id"] - }) + const repo = yield* makeRepo("NestedDependencyItem", NestedDependencyItem, {}) yield* repo.save(new NestedDependencyItem({ id: "root", parts: [{ id: "part-1" }] })) yield* repo.query(Q.whereSome("parts", Q.where("id", "part-1"))) }) From 5e6c0eababeea39368b07686807350f6b60ef2dd Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:19:45 +0000 Subject: [PATCH 4/4] refactor(repository): remove manual read scope API Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- .../effect-app/src/Model/Repository/internal/internal.ts | 1 - packages/effect-app/src/Model/Repository/service.ts | 5 ----- 2 files changed, 6 deletions(-) diff --git a/packages/effect-app/src/Model/Repository/internal/internal.ts b/packages/effect-app/src/Model/Repository/internal/internal.ts index 792c5b1d9..c53f3eadc 100644 --- a/packages/effect-app/src/Model/Repository/internal/internal.ts +++ b/packages/effect-app/src/Model/Repository/internal/internal.ts @@ -687,7 +687,6 @@ export function makeRepoInternal< idKey, find, all, - withReadScope: (ids: NonEmptyReadonlyArray) => DataDependencies.withRepoReadScope(name, ids), saveAndPublish, removeAndPublish, removeById, diff --git a/packages/effect-app/src/Model/Repository/service.ts b/packages/effect-app/src/Model/Repository/service.ts index 4a475c69d..875a0cd17 100644 --- a/packages/effect-app/src/Model/Repository/service.ts +++ b/packages/effect-app/src/Model/Repository/service.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type * as Scope from "effect/Scope" -import type { NonEmptyReadonlyArray } from "../../Array.ts" import type { DatabaseError, InvalidStateError, NotFoundError, OptimisticConcurrencyException } from "../../client/errors.ts" import type * as Effect from "../../Effect.ts" import type * as Option from "../../Option.ts" @@ -52,10 +51,6 @@ export interface Repository< readonly idKey: IdKey readonly find: (id: T[IdKey]) => Effect.Effect, DatabaseError, RSchema> readonly all: Effect.Effect - /** Scope repository reads to relationship IDs used by dependency invalidation. */ - readonly withReadScope: ( - ids: NonEmptyReadonlyArray - ) => (self: Effect.Effect) => Effect.Effect readonly saveAndPublish: ( items: Iterable, events?: Iterable