|
|
@@ -13,8 +13,8 @@ const Job = Layout.struct({
|
|
|
})
|
|
|
|
|
|
const Jobs = Layout.collection(Job, ({ members, first }) => ({
|
|
|
- labels: members((job) => job.labels),
|
|
|
- nextRetry: first((job) => job.status === "retrying"),
|
|
|
+ labels: members(["labels"], (job) => job.labels),
|
|
|
+ nextRetry: first(["status"], (job) => job.status === "retrying"),
|
|
|
}))
|
|
|
|
|
|
describe("Layout", () => {
|
|
|
@@ -37,12 +37,17 @@ describe("Layout", () => {
|
|
|
"Keyed layout must declare exactly one key field",
|
|
|
)
|
|
|
expect(() =>
|
|
|
- Layout.compile(Layout.struct({ left: Layout.key(Layout.number), right: Layout.key(Layout.number) })),
|
|
|
+ Layout.compile(
|
|
|
+ Layout.struct({
|
|
|
+ left: Layout.key(Layout.number),
|
|
|
+ right: Layout.key(Layout.number),
|
|
|
+ }),
|
|
|
+ ),
|
|
|
).toThrow("Keyed layout must declare exactly one key field")
|
|
|
})
|
|
|
|
|
|
it("generates the same trusted equivalence as the closure backend", () => {
|
|
|
- const closure = Layout.compile(Item)
|
|
|
+ const closure = Layout.compile(Item, { backend: "closure" })
|
|
|
const generated = Layout.compile(Item, { backend: "generated" })
|
|
|
const values = [
|
|
|
{ id: 1, label: "one" },
|
|
|
@@ -53,12 +58,70 @@ describe("Layout", () => {
|
|
|
values.forEach((left) => {
|
|
|
values.forEach((right) => {
|
|
|
expect(generated.equivalent(left, right)).toBe(closure.equivalent(left, right))
|
|
|
+ expect(generated.diff(left, right) === 0).toBe(generated.equivalent(left, right))
|
|
|
+ expect(closure.diff(left, right) === 0).toBe(closure.equivalent(left, right))
|
|
|
})
|
|
|
})
|
|
|
})
|
|
|
|
|
|
+ it("honors custom primitive comparators in generated plans", () => {
|
|
|
+ const CaseInsensitive = {
|
|
|
+ ...Layout.string,
|
|
|
+ foldCase: true,
|
|
|
+ equivalent(left: string, right: string) {
|
|
|
+ return this.foldCase ? left.toLowerCase() === right.toLowerCase() : left === right
|
|
|
+ },
|
|
|
+ }
|
|
|
+ const Value = Layout.struct({ id: Layout.key(Layout.number), value: CaseInsensitive })
|
|
|
+ const closure = Layout.compile(Value, { backend: "closure" })
|
|
|
+ const generated = Layout.compile(Value, { backend: "generated" })
|
|
|
+ const left = { id: 1, value: "same" }
|
|
|
+ const right = { id: 1, value: "SAME" }
|
|
|
+
|
|
|
+ expect(closure.equivalent(left, right)).toBe(true)
|
|
|
+ expect(generated.equivalent(left, right)).toBe(true)
|
|
|
+ expect(generated.diff(left, right)).toBe(0)
|
|
|
+ })
|
|
|
+
|
|
|
+ it("keeps generated nested keyed unions independent when they share variants", () => {
|
|
|
+ const variants = {
|
|
|
+ yes: Layout.struct({ value: Layout.string }),
|
|
|
+ no: Layout.struct({ value: Layout.string }),
|
|
|
+ }
|
|
|
+ const Left = Layout.keyedUnion({
|
|
|
+ key: Layout.key("leftID", Layout.number),
|
|
|
+ tag: "leftType",
|
|
|
+ variants,
|
|
|
+ })
|
|
|
+ const Right = Layout.keyedUnion({
|
|
|
+ key: Layout.key("rightID", Layout.number),
|
|
|
+ tag: "rightType",
|
|
|
+ variants,
|
|
|
+ })
|
|
|
+ const Root = Layout.struct({
|
|
|
+ id: Layout.key(Layout.number),
|
|
|
+ left: Left,
|
|
|
+ right: Right,
|
|
|
+ })
|
|
|
+ const value: Layout.Type<typeof Root> = {
|
|
|
+ id: 1,
|
|
|
+ left: { leftID: 1, leftType: "yes", value: "same" },
|
|
|
+ right: { rightID: 1, rightType: "yes", value: "same" },
|
|
|
+ }
|
|
|
+ const copy = structuredClone(value)
|
|
|
+ const closure = Layout.compile(Root, { backend: "closure" })
|
|
|
+ const generated = Layout.compile(Root, { backend: "generated" })
|
|
|
+
|
|
|
+ expect(closure.equivalent(value, copy)).toBe(true)
|
|
|
+ expect(generated.equivalent(value, copy)).toBe(true)
|
|
|
+ expect(generated.diff(value, copy)).toBe(0)
|
|
|
+ })
|
|
|
+
|
|
|
it("compiles nested discriminated unions and skips immutable fields", () => {
|
|
|
- const Ref = Layout.struct({ messageID: Layout.string, partID: Layout.string })
|
|
|
+ const Ref = Layout.struct({
|
|
|
+ messageID: Layout.string,
|
|
|
+ partID: Layout.string,
|
|
|
+ })
|
|
|
const Row = Layout.keyedUnion({
|
|
|
key: Layout.key("id", Layout.string),
|
|
|
tag: "type",
|
|
|
@@ -82,7 +145,7 @@ describe("Layout", () => {
|
|
|
}),
|
|
|
},
|
|
|
})
|
|
|
- const plan = Layout.compile(Row)
|
|
|
+ const plan = Layout.compile(Row, { backend: "closure" })
|
|
|
const generated = Layout.compile(Row, { backend: "generated" })
|
|
|
const group = {
|
|
|
id: "group-1",
|
|
|
@@ -94,9 +157,19 @@ describe("Layout", () => {
|
|
|
completed: false,
|
|
|
}
|
|
|
|
|
|
- expect(plan.equivalent(group, { ...group, origin: { messageID: "ignored", partID: "ignored" } })).toBe(true)
|
|
|
+ expect(
|
|
|
+ plan.equivalent(group, {
|
|
|
+ ...group,
|
|
|
+ origin: { messageID: "ignored", partID: "ignored" },
|
|
|
+ }),
|
|
|
+ ).toBe(true)
|
|
|
expect(plan.equivalent(group, { ...group, completed: true })).toBe(false)
|
|
|
- expect(plan.equivalent(group, { ...group, pending: [{ messageID: "assistant-1", partID: "read-1" }] })).toBe(false)
|
|
|
+ expect(
|
|
|
+ plan.equivalent(group, {
|
|
|
+ ...group,
|
|
|
+ pending: [{ messageID: "assistant-1", partID: "read-1" }],
|
|
|
+ }),
|
|
|
+ ).toBe(false)
|
|
|
expect(
|
|
|
plan.equivalent(group, {
|
|
|
id: "group-1",
|
|
|
@@ -127,8 +200,14 @@ describe("Layout", () => {
|
|
|
})
|
|
|
|
|
|
it("includes nested keys in structural equivalence", () => {
|
|
|
- const Child = Layout.struct({ id: Layout.key(Layout.number), value: Layout.string })
|
|
|
- const Parent = Layout.struct({ id: Layout.key(Layout.number), child: Child })
|
|
|
+ const Child = Layout.struct({
|
|
|
+ id: Layout.key(Layout.number),
|
|
|
+ value: Layout.string,
|
|
|
+ })
|
|
|
+ const Parent = Layout.struct({
|
|
|
+ id: Layout.key(Layout.number),
|
|
|
+ child: Child,
|
|
|
+ })
|
|
|
const parent = Layout.compile(Parent)
|
|
|
|
|
|
expect(
|
|
|
@@ -184,6 +263,158 @@ describe("Layout", () => {
|
|
|
expect(jobs.first("nextRetry")?.().id).toBe("four")
|
|
|
})
|
|
|
|
|
|
+ it("skips index projection when the change is disjoint from its declared fields", () => {
|
|
|
+ let extractions = 0
|
|
|
+ let matchChecks = 0
|
|
|
+ const IndexedJobs = Layout.collection(Job, ({ members, first }) => ({
|
|
|
+ labels: members(["labels"], (job) => {
|
|
|
+ extractions++
|
|
|
+ return job.labels
|
|
|
+ }),
|
|
|
+ nextRetry: first(["status"], (job) => {
|
|
|
+ matchChecks++
|
|
|
+ return job.status === "retrying"
|
|
|
+ }),
|
|
|
+ }))
|
|
|
+ const jobs = IndexedJobs.make([{ id: "one", labels: ["billing"], status: "running" }])
|
|
|
+ const labels = jobs.get("one")!().labels
|
|
|
+ const baseline = { extractions, matchChecks }
|
|
|
+
|
|
|
+ // Status-only change: labels extraction reads only `labels`, so it skips.
|
|
|
+ jobs.update({ id: "one", labels, status: "retrying" })
|
|
|
+ expect(extractions).toBe(baseline.extractions)
|
|
|
+ expect(matchChecks).toBe(baseline.matchChecks + 1)
|
|
|
+ expect(jobs.first("nextRetry")?.().id).toBe("one")
|
|
|
+
|
|
|
+ // Labels-only change: the first-match check reads only `status`, so it skips.
|
|
|
+ jobs.update({ id: "one", labels: ["urgent"], status: "retrying" })
|
|
|
+ expect(extractions).toBe(baseline.extractions + 1)
|
|
|
+ expect(matchChecks).toBe(baseline.matchChecks + 1)
|
|
|
+ expect(jobs.hasMember("labels", "urgent")).toBe(true)
|
|
|
+ expect(jobs.hasMember("labels", "billing")).toBe(false)
|
|
|
+
|
|
|
+ // Equivalent update: nothing runs.
|
|
|
+ jobs.update({ id: "one", labels: ["urgent"], status: "retrying" })
|
|
|
+ expect(extractions).toBe(baseline.extractions + 1)
|
|
|
+ expect(matchChecks).toBe(baseline.matchChecks + 1)
|
|
|
+ })
|
|
|
+
|
|
|
+ it("normalizes numeric dependency names", () => {
|
|
|
+ let extractions = 0
|
|
|
+ const Numeric = Layout.collection(
|
|
|
+ Layout.struct({ id: Layout.key(Layout.string), 0: Layout.string, other: Layout.string }),
|
|
|
+ ({ members }) => ({
|
|
|
+ zero: members([0], (value) => {
|
|
|
+ extractions++
|
|
|
+ return [value[0]]
|
|
|
+ }),
|
|
|
+ }),
|
|
|
+ )
|
|
|
+ const values = Numeric.make([{ id: "one", 0: "zero", other: "before" }])
|
|
|
+ const baseline = extractions
|
|
|
+
|
|
|
+ values.update({ id: "one", 0: "zero", other: "after" })
|
|
|
+
|
|
|
+ expect(extractions).toBe(baseline)
|
|
|
+ expect(values.hasMember("zero", "zero")).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it("reuses precomputed field diffs during direct collection mutations", () => {
|
|
|
+ let comparisons = 0
|
|
|
+ const counted: Layout.Field<string> = {
|
|
|
+ equivalent(left, right) {
|
|
|
+ comparisons++
|
|
|
+ return left === right
|
|
|
+ },
|
|
|
+ }
|
|
|
+ const CountedJob = Layout.struct({
|
|
|
+ id: Layout.key(Layout.string),
|
|
|
+ value: counted,
|
|
|
+ status: Layout.string,
|
|
|
+ })
|
|
|
+ const CountedJobs = Layout.collection(CountedJob, ({ first }) => ({
|
|
|
+ changed: first(["value"], (job) => job.value === "after"),
|
|
|
+ }))
|
|
|
+ const one = { id: "one", value: "before", status: "idle" }
|
|
|
+ const two = { id: "two", value: "before", status: "idle" }
|
|
|
+ const jobs = CountedJobs.make([one, two])
|
|
|
+ comparisons = 0
|
|
|
+
|
|
|
+ jobs.set([
|
|
|
+ { ...one },
|
|
|
+ { ...two, value: "after" },
|
|
|
+ ])
|
|
|
+
|
|
|
+ expect(comparisons).toBe(4)
|
|
|
+ expect(jobs.get("one")?.()).toBe(one)
|
|
|
+ expect(jobs.first("changed")?.().id).toBe("two")
|
|
|
+
|
|
|
+ comparisons = 0
|
|
|
+ jobs.update({ ...jobs.get("one")!(), status: "busy" })
|
|
|
+ expect(comparisons).toBe(1)
|
|
|
+
|
|
|
+ comparisons = 0
|
|
|
+ jobs.modify("two", (job) => ({ ...job, value: "done" }))
|
|
|
+ expect(comparisons).toBe(1)
|
|
|
+ expect(jobs.first("changed")).toBeUndefined()
|
|
|
+ })
|
|
|
+
|
|
|
+ it("refreshes indexes that read a changed union discriminant", () => {
|
|
|
+ const Row = Layout.keyedUnion({
|
|
|
+ key: Layout.key("id", Layout.number),
|
|
|
+ tag: "type",
|
|
|
+ variants: {
|
|
|
+ waiting: Layout.struct({ value: Layout.string }),
|
|
|
+ ready: Layout.struct({ value: Layout.string }),
|
|
|
+ },
|
|
|
+ })
|
|
|
+ const Rows = Layout.collection(Row, ({ members, first }) => ({
|
|
|
+ types: members(["type"], (row) => [row.type]),
|
|
|
+ firstReady: first(["type"], (row) => row.type === "ready"),
|
|
|
+ }))
|
|
|
+ const rows = Rows.make([{ id: 1, type: "waiting", value: "same" }])
|
|
|
+
|
|
|
+ rows.update({ id: 1, type: "ready", value: "same" })
|
|
|
+
|
|
|
+ expect(rows.get(1)?.().type).toBe("ready")
|
|
|
+ expect(rows.hasMember("types", "waiting")).toBe(false)
|
|
|
+ expect(rows.hasMember("types", "ready")).toBe(true)
|
|
|
+ expect(rows.first("firstReady")?.().type).toBe("ready")
|
|
|
+ })
|
|
|
+
|
|
|
+ it("tracks lazy member iterables while they are consumed", () => {
|
|
|
+ const LazyJobs = Layout.collection(Job, ({ members }) => ({
|
|
|
+ statuses: members(function* (job) {
|
|
|
+ yield job.status
|
|
|
+ }),
|
|
|
+ }))
|
|
|
+ const jobs = LazyJobs.make([{ id: "one", labels: [], status: "running" }])
|
|
|
+
|
|
|
+ expect(jobs.hasMember("statuses", "running")).toBe(true)
|
|
|
+ jobs.update({ id: "one", labels: [], status: "retrying" })
|
|
|
+ expect(jobs.hasMember("statuses", "running")).toBe(false)
|
|
|
+ expect(jobs.hasMember("statuses", "retrying")).toBe(true)
|
|
|
+ })
|
|
|
+
|
|
|
+ it("passes actual frozen values to identity and reflection projections", () => {
|
|
|
+ const actual = Object.freeze({ id: "one", labels: [] as readonly string[], status: "running" })
|
|
|
+ const accepted = new WeakSet<object>([actual])
|
|
|
+ const EnumeratedJobs = Layout.collection(Job, ({ members, first }) => ({
|
|
|
+ properties: members((job) => Object.keys(job)),
|
|
|
+ selves: members((job) => [job]),
|
|
|
+ frozen: members((job) => [Object.isFrozen(job)]),
|
|
|
+ accepted: first((job) => accepted.has(job)),
|
|
|
+ }))
|
|
|
+ const jobs = EnumeratedJobs.make([actual])
|
|
|
+
|
|
|
+ expect(jobs.hasMember("properties", "id")).toBe(true)
|
|
|
+ expect(jobs.hasMember("properties", "labels")).toBe(true)
|
|
|
+ expect(jobs.hasMember("properties", "status")).toBe(true)
|
|
|
+ expect(jobs.hasMember("selves", actual)).toBe(true)
|
|
|
+ expect(jobs.hasMember("frozen", true)).toBe(true)
|
|
|
+ expect(jobs.first("accepted")?.()).toBe(actual)
|
|
|
+ })
|
|
|
+
|
|
|
it("applies explicit member deltas without re-extracting unchanged membership", () => {
|
|
|
let extractions = 0
|
|
|
const IndexedJobs = Layout.collection(Job, ({ members }) => ({
|
|
|
@@ -206,12 +437,20 @@ describe("Layout", () => {
|
|
|
expect(jobs.hasMember("labels", "billing")).toBe(true)
|
|
|
expect(jobs.hasMember("labels", "urgent")).toBe(true)
|
|
|
|
|
|
- jobs.modify("two", (job) => ({ ...job, labels: [] }), { members: { labels: { remove: ["billing"] } } })
|
|
|
+ jobs.modify("two", (job) => ({ ...job, labels: [] }), {
|
|
|
+ members: { labels: { remove: ["billing"] } },
|
|
|
+ })
|
|
|
expect(jobs.hasMember("labels", "billing")).toBe(false)
|
|
|
|
|
|
if (false) {
|
|
|
- // @ts-expect-error Member deltas require arrays so string members are not split into characters.
|
|
|
- jobs.modify("one", (job) => job, { members: { labels: { add: "urgent" } } })
|
|
|
+ jobs.modify("one", (job) => job, {
|
|
|
+ members: {
|
|
|
+ labels: {
|
|
|
+ // @ts-expect-error Member deltas require arrays so strings are not split into characters.
|
|
|
+ add: "urgent",
|
|
|
+ },
|
|
|
+ },
|
|
|
+ })
|
|
|
}
|
|
|
})
|
|
|
|
|
|
@@ -238,16 +477,67 @@ describe("Layout", () => {
|
|
|
)
|
|
|
})
|
|
|
|
|
|
+ it("restores pending comparison state when publication throws", () => {
|
|
|
+ const LabeledJob = Layout.struct({
|
|
|
+ id: Layout.key(Layout.string),
|
|
|
+ label: Layout.string,
|
|
|
+ status: Layout.string,
|
|
|
+ })
|
|
|
+ const IndexedJobs = Layout.collection(LabeledJob, ({ first }) => ({
|
|
|
+ retry: first(["status"], (job) => job.status === "retrying"),
|
|
|
+ }))
|
|
|
+ const jobs = IndexedJobs.make([{ id: "one", label: "before", status: "running" }])
|
|
|
+ const slot = jobs.get("one")!
|
|
|
+ const dispose = slot.subscribe(() => {
|
|
|
+ throw new Error("listener failed")
|
|
|
+ })
|
|
|
+
|
|
|
+ expect(() => jobs.update({ id: "one", label: "after", status: "running" })).toThrow("listener failed")
|
|
|
+ dispose()
|
|
|
+ const equivalent = { ...slot() }
|
|
|
+
|
|
|
+ expect(jobs.set([equivalent])).toBe(false)
|
|
|
+ expect(slot()).not.toBe(equivalent)
|
|
|
+ })
|
|
|
+
|
|
|
+ it("does not leak a pending comparison into reentrant publication", () => {
|
|
|
+ const LabeledJob = Layout.struct({
|
|
|
+ id: Layout.key(Layout.string),
|
|
|
+ label: Layout.string,
|
|
|
+ status: Layout.string,
|
|
|
+ })
|
|
|
+ const IndexedJobs = Layout.collection(LabeledJob, ({ first }) => ({
|
|
|
+ retry: first(["status"], (job) => job.status === "retrying"),
|
|
|
+ }))
|
|
|
+ const jobs = IndexedJobs.make([{ id: "one", label: "before", status: "running" }])
|
|
|
+ const slot = jobs.get("one")!
|
|
|
+ let reentered = false
|
|
|
+ let changed: boolean | undefined
|
|
|
+ const dispose = slot.subscribe(() => {
|
|
|
+ if (reentered) return
|
|
|
+ reentered = true
|
|
|
+ changed = jobs.set([{ ...slot() }])
|
|
|
+ })
|
|
|
+
|
|
|
+ jobs.update({ id: "one", label: "after", status: "running" })
|
|
|
+
|
|
|
+ expect(changed).toBe(false)
|
|
|
+ dispose()
|
|
|
+ })
|
|
|
+
|
|
|
it("tracks first matches whose key is undefined", () => {
|
|
|
const undefinedField: Layout.Field<undefined> = { equivalent: Object.is }
|
|
|
const OptionalJobs = Layout.collection(
|
|
|
Layout.struct({ id: Layout.key(undefinedField), status: Layout.string }),
|
|
|
- ({ first }) => ({ retry: first((job) => job.status === "retrying") }),
|
|
|
+ ({ first }) => ({ retry: first(["status"], (job) => job.status === "retrying") }),
|
|
|
)
|
|
|
const jobs = OptionalJobs.make([{ id: undefined, status: "running" }])
|
|
|
|
|
|
jobs.update({ id: undefined, status: "retrying" })
|
|
|
|
|
|
- expect(jobs.first("retry")?.()).toEqual({ id: undefined, status: "retrying" })
|
|
|
+ expect(jobs.first("retry")?.()).toEqual({
|
|
|
+ id: undefined,
|
|
|
+ status: "retrying",
|
|
|
+ })
|
|
|
})
|
|
|
})
|