diff --git a/.changeset/calm-pallets-listen.md b/.changeset/calm-pallets-listen.md new file mode 100644 index 000000000..d015de043 --- /dev/null +++ b/.changeset/calm-pallets-listen.md @@ -0,0 +1,5 @@ +--- +"effect-app": patch +--- + +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 b0c3d4c87..c53f3eadc 100644 --- a/packages/effect-app/src/Model/Repository/internal/internal.ts +++ b/packages/effect-app/src/Model/Repository/internal/internal.ts @@ -25,8 +25,10 @@ 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 * as Q from "../../query.ts" +import { repositoryDependencyPaths } from "../dependency.ts" import type { ChangeFeed, ChangeFeedEvent, Repository } from "../service.ts" import { ValidationError, ValidationResult } from "../validation.ts" @@ -147,16 +149,55 @@ 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)]) + 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 = 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), ...dependencyPaths]) + 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)) @@ -166,12 +207,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 +388,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 || dependencyPaths.length > 0 ? yield* loadExistingItems(values.map((item) => item[idKey])) : [] yield* recordItemWrite([values[0], ...values.slice(1), ...previous]) @@ -414,8 +449,9 @@ export function makeRepoInternal< return } yield* recordEntityWrite(ids) - if (args.additionalWriteDependencies) { - yield* loadExistingItems(ids).pipe(Effect.flatMap(recordAdditionalItemWrites)) + if (args.additionalWriteDependencies || args.dependencyIds || dependencyPaths.length > 0) { + 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) @@ -485,6 +521,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. @@ -563,7 +603,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/infra/test/repository-ext.test.ts b/packages/infra/test/repository-ext.test.ts index e5f1201af..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,6 +16,16 @@ 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: repositoryDependency(S.String) })) +}) {} + const TestStoreLive = Layer.merge(MemoryStoreLive, RepositoryRegistryLive) const A = S.TaggedStruct("A", { id: S.String }) @@ -218,7 +228,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 schema annotation", () => Effect .gen(function*() { const readsRef = yield* Ref.make(DataDependencies.empty()) @@ -227,17 +237,42 @@ describe("repository ext save/remove batching", () => { yield* Effect .gen(function*() { - const repo = yield* makeRepo("DependencyItem", BatchItem, { - 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"])) + 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", ["alias-1"])])) + 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", "label-one"])]) + ) + }) + .pipe( + setupRequestContextFromCurrent(), + Effect.provide(TestStoreLive) + )) + + it.effect("derives a nested annotated 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, {}) + 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( @@ -245,6 +280,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*() {