Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-pallets-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"effect-app": patch
---

Derive scoped dependency reads and writes from typed query filters and annotated model relationships, including previous aliases.
5 changes: 5 additions & 0 deletions .changeset/live-query-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@effect-app/vue": minor
---

Add query-owned live invalidation with recorded dependency filtering, connect-before-fetch coordination, race buffering, and configurable client-side coalescing.
1 change: 1 addition & 0 deletions packages/effect-app/src/Model.ts
Original file line number Diff line number Diff line change
@@ -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"
1 change: 1 addition & 0 deletions packages/effect-app/src/Model/Repository.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
32 changes: 32 additions & 0 deletions packages/effect-app/src/Model/Repository/dependency.ts
Original file line number Diff line number Diff line change
@@ -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 extends S.Top>(schema: Schema): Schema["Rebuild"] =>
S.annotateEncoded({ [RepositoryDependencyAnnotation]: true })(schema)

/** @internal */
export const repositoryDependencyPaths = (schema: S.Schema<unknown>): 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), []))]
}
62 changes: 51 additions & 11 deletions packages/effect-app/src/Model/Repository/internal/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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<T[IdKey]>) =>
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<string, unknown>)[head], tail.join("."))
: []
}
const dependencyIds = (item: T): NonEmptyReadonlyArray<string> => {
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<T[IdKey]>) =>
DataDependencies.write(entityDependency(ids))
Expand All @@ -166,12 +207,6 @@ export function makeRepoInternal<
DataDependencies.write,
{ discard: true }
)
const recordAdditionalItemWrites = (items: ReadonlyArray<T>) =>
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)
Expand Down Expand Up @@ -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])
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -485,6 +521,10 @@ export function makeRepoInternal<
): Effect.Effect<readonly A[], never, Exclude<R, RCtx>>
} = (<A, R, EncodedRefined extends Encoded = Encoded>(q: Q.QAll<Encoded, EncodedRefined, A, R>) => {
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.
Expand Down Expand Up @@ -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 }
Expand Down
85 changes: 76 additions & 9 deletions packages/infra/test/repository-ext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -16,6 +16,16 @@ class BatchItem extends S.Class<BatchItem>("BatchItem")({
label: S.String
}) {}

class DependencyItem extends S.Class<DependencyItem>("DependencyItem")({
id: S.String,
label: repositoryDependency(S.StringId)
}) {}

class NestedDependencyItem extends S.Class<NestedDependencyItem>("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 })
Expand Down Expand Up @@ -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())
Expand All @@ -227,24 +237,81 @@ 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(
setupRequestContextFromCurrent(),
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*() {
Expand Down
Loading