layout.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. import { describe, expect, it } from "bun:test"
  2. import { Layout } from "../src"
  3. const Item = Layout.struct({
  4. id: Layout.key(Layout.number),
  5. label: Layout.string,
  6. })
  7. const Job = Layout.struct({
  8. id: Layout.key(Layout.string),
  9. labels: Layout.array(Layout.string),
  10. status: Layout.string,
  11. })
  12. const Jobs = Layout.collection(Job, ({ members, first }) => ({
  13. labels: members(["labels"], (job) => job.labels),
  14. nextRetry: first(["status"], (job) => job.status === "retrying"),
  15. }))
  16. describe("Layout", () => {
  17. it("compiles a keyed collection from trusted structural metadata", () => {
  18. const plan = Layout.compile(Item)
  19. const original = { id: 1, label: "one" }
  20. const values = plan.make([original])
  21. const slot = values.slots()[0]
  22. expect(plan.key).toBe("id")
  23. expect(plan.fields).toBe(Item.fields)
  24. expect(values.update({ id: 1, label: "one" })).toBe(false)
  25. expect(slot()).toBe(original)
  26. expect(values.update({ id: 1, label: "ONE" })).toBe(true)
  27. expect(slot()).toEqual({ id: 1, label: "ONE" })
  28. })
  29. it("requires exactly one key field", () => {
  30. expect(() => Layout.compile(Layout.struct({ value: Layout.number }))).toThrow(
  31. "Keyed layout must declare exactly one key field",
  32. )
  33. expect(() =>
  34. Layout.compile(
  35. Layout.struct({
  36. left: Layout.key(Layout.number),
  37. right: Layout.key(Layout.number),
  38. }),
  39. ),
  40. ).toThrow("Keyed layout must declare exactly one key field")
  41. })
  42. it("generates the same trusted equivalence as the closure backend", () => {
  43. const closure = Layout.compile(Item, { backend: "closure" })
  44. const generated = Layout.compile(Item, { backend: "generated" })
  45. const values = [
  46. { id: 1, label: "one" },
  47. { id: 1, label: "ONE" },
  48. { id: 2, label: "one" },
  49. ]
  50. values.forEach((left) => {
  51. values.forEach((right) => {
  52. expect(generated.equivalent(left, right)).toBe(closure.equivalent(left, right))
  53. expect(generated.diff(left, right) === 0).toBe(generated.equivalent(left, right))
  54. expect(closure.diff(left, right) === 0).toBe(closure.equivalent(left, right))
  55. })
  56. })
  57. })
  58. it("honors custom primitive comparators in generated plans", () => {
  59. const CaseInsensitive = {
  60. ...Layout.string,
  61. foldCase: true,
  62. equivalent(left: string, right: string) {
  63. return this.foldCase ? left.toLowerCase() === right.toLowerCase() : left === right
  64. },
  65. }
  66. const Value = Layout.struct({ id: Layout.key(Layout.number), value: CaseInsensitive })
  67. const closure = Layout.compile(Value, { backend: "closure" })
  68. const generated = Layout.compile(Value, { backend: "generated" })
  69. const left = { id: 1, value: "same" }
  70. const right = { id: 1, value: "SAME" }
  71. expect(closure.equivalent(left, right)).toBe(true)
  72. expect(generated.equivalent(left, right)).toBe(true)
  73. expect(generated.diff(left, right)).toBe(0)
  74. })
  75. it("keeps generated nested keyed unions independent when they share variants", () => {
  76. const variants = {
  77. yes: Layout.struct({ value: Layout.string }),
  78. no: Layout.struct({ value: Layout.string }),
  79. }
  80. const Left = Layout.keyedUnion({
  81. key: Layout.key("leftID", Layout.number),
  82. tag: "leftType",
  83. variants,
  84. })
  85. const Right = Layout.keyedUnion({
  86. key: Layout.key("rightID", Layout.number),
  87. tag: "rightType",
  88. variants,
  89. })
  90. const Root = Layout.struct({
  91. id: Layout.key(Layout.number),
  92. left: Left,
  93. right: Right,
  94. })
  95. const value: Layout.Type<typeof Root> = {
  96. id: 1,
  97. left: { leftID: 1, leftType: "yes", value: "same" },
  98. right: { rightID: 1, rightType: "yes", value: "same" },
  99. }
  100. const copy = structuredClone(value)
  101. const closure = Layout.compile(Root, { backend: "closure" })
  102. const generated = Layout.compile(Root, { backend: "generated" })
  103. expect(closure.equivalent(value, copy)).toBe(true)
  104. expect(generated.equivalent(value, copy)).toBe(true)
  105. expect(generated.diff(value, copy)).toBe(0)
  106. })
  107. it("compiles nested discriminated unions and skips immutable fields", () => {
  108. const Ref = Layout.struct({
  109. messageID: Layout.string,
  110. partID: Layout.string,
  111. })
  112. const Row = Layout.keyedUnion({
  113. key: Layout.key("id", Layout.string),
  114. tag: "type",
  115. variants: {
  116. message: Layout.struct({ messageID: Layout.string }),
  117. group: Layout.union({
  118. tag: "kind",
  119. variants: {
  120. reasoning: Layout.struct({
  121. origin: Layout.immutable(Ref),
  122. refs: Layout.array(Ref),
  123. completed: Layout.boolean,
  124. }),
  125. exploration: Layout.struct({
  126. origin: Layout.immutable(Ref),
  127. refs: Layout.array(Ref),
  128. pending: Layout.array(Ref),
  129. completed: Layout.boolean,
  130. }),
  131. },
  132. }),
  133. },
  134. })
  135. const plan = Layout.compile(Row, { backend: "closure" })
  136. const generated = Layout.compile(Row, { backend: "generated" })
  137. const group = {
  138. id: "group-1",
  139. type: "group" as const,
  140. kind: "exploration" as const,
  141. origin: { messageID: "assistant-1", partID: "read-1" },
  142. refs: [{ messageID: "assistant-1", partID: "read-1" }],
  143. pending: [] as Array<{ messageID: string; partID: string }>,
  144. completed: false,
  145. }
  146. expect(
  147. plan.equivalent(group, {
  148. ...group,
  149. origin: { messageID: "ignored", partID: "ignored" },
  150. }),
  151. ).toBe(true)
  152. expect(plan.equivalent(group, { ...group, completed: true })).toBe(false)
  153. expect(
  154. plan.equivalent(group, {
  155. ...group,
  156. pending: [{ messageID: "assistant-1", partID: "read-1" }],
  157. }),
  158. ).toBe(false)
  159. expect(
  160. plan.equivalent(group, {
  161. id: "group-1",
  162. type: "group",
  163. kind: "reasoning",
  164. origin: group.origin,
  165. refs: group.refs,
  166. completed: false,
  167. }),
  168. ).toBe(false)
  169. const candidates = [
  170. group,
  171. { ...group, origin: { messageID: "ignored", partID: "ignored" } },
  172. { ...group, completed: true },
  173. { ...group, pending: [{ messageID: "assistant-1", partID: "read-1" }] },
  174. {
  175. id: "group-1",
  176. type: "group" as const,
  177. kind: "reasoning" as const,
  178. origin: group.origin,
  179. refs: group.refs,
  180. completed: false,
  181. },
  182. ]
  183. candidates.forEach((left) => {
  184. candidates.forEach((right) => expect(generated.equivalent(left, right)).toBe(plan.equivalent(left, right)))
  185. })
  186. })
  187. it("includes nested keys in structural equivalence", () => {
  188. const Child = Layout.struct({
  189. id: Layout.key(Layout.number),
  190. value: Layout.string,
  191. })
  192. const Parent = Layout.struct({
  193. id: Layout.key(Layout.number),
  194. child: Child,
  195. })
  196. const parent = Layout.compile(Parent)
  197. expect(
  198. parent.equivalent({ id: 1, child: { id: 1, value: "same" } }, { id: 1, child: { id: 2, value: "same" } }),
  199. ).toBe(false)
  200. })
  201. it("composes indexed collections without domain-specific behavior", () => {
  202. const jobs = Jobs.make([
  203. { id: "one", labels: ["billing", "urgent"], status: "running" },
  204. { id: "two", labels: ["billing"], status: "retrying" },
  205. { id: "three", labels: [], status: "retrying" },
  206. ])
  207. expect(jobs.hasMember("labels", "billing")).toBe(true)
  208. expect(jobs.hasMember("labels", "missing")).toBe(false)
  209. expect(jobs.first("nextRetry")()?.id).toBe("two")
  210. expect(jobs.before("two")?.().id).toBe("one")
  211. expect(jobs.after("two")?.().id).toBe("three")
  212. jobs.modify("one", (job) => ({ ...job, labels: [], status: "retrying" }))
  213. expect(jobs.hasMember("labels", "urgent")).toBe(false)
  214. expect(jobs.hasMember("labels", "billing")).toBe(true)
  215. expect(jobs.first("nextRetry")()?.id).toBe("one")
  216. jobs.remove("two")
  217. expect(jobs.hasMember("labels", "billing")).toBe(false)
  218. jobs.move("three", { before: "one" })
  219. expect(jobs.first("nextRetry")()?.id).toBe("three")
  220. })
  221. it("publishes first-index changes to subscribers", () => {
  222. const jobs = Jobs.make([
  223. { id: "one", labels: [], status: "running" },
  224. { id: "two", labels: [], status: "retrying" },
  225. ])
  226. const seen: (string | undefined)[] = []
  227. const dispose = jobs.first("nextRetry").subscribe((job) => seen.push(job?.id))
  228. // Identity change: a match earlier in slot order becomes the first.
  229. jobs.modify("one", (job) => ({ ...job, status: "retrying" }))
  230. // Value change of the current first match publishes through the slot.
  231. jobs.modify("one", (job) => ({ ...job, labels: ["late"] }))
  232. // The first match stops matching; the next one takes over.
  233. jobs.modify("one", (job) => ({ ...job, status: "done" }))
  234. // No match left.
  235. jobs.remove("two")
  236. expect(seen).toEqual(["one", "one", "two", undefined])
  237. dispose()
  238. })
  239. it("keeps indexes synchronized across inserts, updates, and replacement", () => {
  240. const jobs = Jobs.make([{ id: "one", labels: ["one"], status: "running" }])
  241. jobs.insert({ id: "three", labels: ["shared"], status: "retrying" })
  242. jobs.insert({ id: "two", labels: ["shared"], status: "retrying" }, { before: "three" })
  243. expect(jobs.first("nextRetry")()?.id).toBe("two")
  244. jobs.update({ id: "two", labels: [], status: "done" })
  245. expect(jobs.first("nextRetry")()?.id).toBe("three")
  246. expect(jobs.hasMember("labels", "shared")).toBe(true)
  247. jobs.remove("three")
  248. expect(jobs.first("nextRetry")()).toBeUndefined()
  249. expect(jobs.hasMember("labels", "shared")).toBe(false)
  250. jobs.set([
  251. { id: "four", labels: ["replacement"], status: "retrying" },
  252. { id: "five", labels: [], status: "running" },
  253. ])
  254. expect(jobs.hasMember("labels", "replacement")).toBe(true)
  255. expect(jobs.hasMember("labels", "one")).toBe(false)
  256. expect(jobs.first("nextRetry")()?.id).toBe("four")
  257. })
  258. it("skips index projection when the change is disjoint from its declared fields", () => {
  259. let extractions = 0
  260. let matchChecks = 0
  261. const IndexedJobs = Layout.collection(Job, ({ members, first }) => ({
  262. labels: members(["labels"], (job) => {
  263. extractions++
  264. return job.labels
  265. }),
  266. nextRetry: first(["status"], (job) => {
  267. matchChecks++
  268. return job.status === "retrying"
  269. }),
  270. }))
  271. const jobs = IndexedJobs.make([{ id: "one", labels: ["billing"], status: "running" }])
  272. const labels = jobs.get("one")!().labels
  273. const baseline = { extractions, matchChecks }
  274. // Status-only change: labels extraction reads only `labels`, so it skips.
  275. jobs.update({ id: "one", labels, status: "retrying" })
  276. expect(extractions).toBe(baseline.extractions)
  277. expect(matchChecks).toBe(baseline.matchChecks + 1)
  278. expect(jobs.first("nextRetry")()?.id).toBe("one")
  279. // Labels-only change: the first-match check reads only `status`, so it skips.
  280. jobs.update({ id: "one", labels: ["urgent"], status: "retrying" })
  281. expect(extractions).toBe(baseline.extractions + 1)
  282. expect(matchChecks).toBe(baseline.matchChecks + 1)
  283. expect(jobs.hasMember("labels", "urgent")).toBe(true)
  284. expect(jobs.hasMember("labels", "billing")).toBe(false)
  285. // Equivalent update: nothing runs.
  286. jobs.update({ id: "one", labels: ["urgent"], status: "retrying" })
  287. expect(extractions).toBe(baseline.extractions + 1)
  288. expect(matchChecks).toBe(baseline.matchChecks + 1)
  289. })
  290. it("normalizes numeric dependency names", () => {
  291. let extractions = 0
  292. const Numeric = Layout.collection(
  293. Layout.struct({ id: Layout.key(Layout.string), 0: Layout.string, other: Layout.string }),
  294. ({ members }) => ({
  295. zero: members([0], (value) => {
  296. extractions++
  297. return [value[0]]
  298. }),
  299. }),
  300. )
  301. const values = Numeric.make([{ id: "one", 0: "zero", other: "before" }])
  302. const baseline = extractions
  303. values.update({ id: "one", 0: "zero", other: "after" })
  304. expect(extractions).toBe(baseline)
  305. expect(values.hasMember("zero", "zero")).toBe(true)
  306. })
  307. it("reuses precomputed field diffs during direct collection mutations", () => {
  308. let comparisons = 0
  309. const counted: Layout.Field<string> = {
  310. equivalent(left, right) {
  311. comparisons++
  312. return left === right
  313. },
  314. }
  315. const CountedJob = Layout.struct({
  316. id: Layout.key(Layout.string),
  317. value: counted,
  318. status: Layout.string,
  319. })
  320. const CountedJobs = Layout.collection(CountedJob, ({ first }) => ({
  321. changed: first(["value"], (job) => job.value === "after"),
  322. }))
  323. const one = { id: "one", value: "before", status: "idle" }
  324. const two = { id: "two", value: "before", status: "idle" }
  325. const jobs = CountedJobs.make([one, two])
  326. comparisons = 0
  327. jobs.set([{ ...one }, { ...two, value: "after" }])
  328. expect(comparisons).toBe(4)
  329. expect(jobs.get("one")?.()).toBe(one)
  330. expect(jobs.first("changed")()?.id).toBe("two")
  331. comparisons = 0
  332. jobs.update({ ...jobs.get("one")!(), status: "busy" })
  333. expect(comparisons).toBe(1)
  334. comparisons = 0
  335. jobs.modify("two", (job) => ({ ...job, value: "done" }))
  336. expect(comparisons).toBe(1)
  337. expect(jobs.first("changed")()).toBeUndefined()
  338. })
  339. it("refreshes indexes that read a changed union discriminant", () => {
  340. const Row = Layout.keyedUnion({
  341. key: Layout.key("id", Layout.number),
  342. tag: "type",
  343. variants: {
  344. waiting: Layout.struct({ value: Layout.string }),
  345. ready: Layout.struct({ value: Layout.string }),
  346. },
  347. })
  348. const Rows = Layout.collection(Row, ({ members, first }) => ({
  349. types: members(["type"], (row) => [row.type]),
  350. firstReady: first(["type"], (row) => row.type === "ready"),
  351. }))
  352. const rows = Rows.make([{ id: 1, type: "waiting", value: "same" }])
  353. rows.update({ id: 1, type: "ready", value: "same" })
  354. expect(rows.get(1)?.().type).toBe("ready")
  355. expect(rows.hasMember("types", "waiting")).toBe(false)
  356. expect(rows.hasMember("types", "ready")).toBe(true)
  357. expect(rows.first("firstReady")()?.type).toBe("ready")
  358. })
  359. it("tracks lazy member iterables while they are consumed", () => {
  360. const LazyJobs = Layout.collection(Job, ({ members }) => ({
  361. statuses: members(function* (job) {
  362. yield job.status
  363. }),
  364. }))
  365. const jobs = LazyJobs.make([{ id: "one", labels: [], status: "running" }])
  366. expect(jobs.hasMember("statuses", "running")).toBe(true)
  367. jobs.update({ id: "one", labels: [], status: "retrying" })
  368. expect(jobs.hasMember("statuses", "running")).toBe(false)
  369. expect(jobs.hasMember("statuses", "retrying")).toBe(true)
  370. })
  371. it("passes actual frozen values to identity and reflection projections", () => {
  372. const actual = Object.freeze({ id: "one", labels: [] as readonly string[], status: "running" })
  373. const accepted = new WeakSet<object>([actual])
  374. const EnumeratedJobs = Layout.collection(Job, ({ members, first }) => ({
  375. properties: members((job) => Object.keys(job)),
  376. selves: members((job) => [job]),
  377. frozen: members((job) => [Object.isFrozen(job)]),
  378. accepted: first((job) => accepted.has(job)),
  379. }))
  380. const jobs = EnumeratedJobs.make([actual])
  381. expect(jobs.hasMember("properties", "id")).toBe(true)
  382. expect(jobs.hasMember("properties", "labels")).toBe(true)
  383. expect(jobs.hasMember("properties", "status")).toBe(true)
  384. expect(jobs.hasMember("selves", actual)).toBe(true)
  385. expect(jobs.hasMember("frozen", true)).toBe(true)
  386. expect(jobs.first("accepted")?.()).toBe(actual)
  387. })
  388. it("applies explicit member deltas without re-extracting unchanged membership", () => {
  389. let extractions = 0
  390. const IndexedJobs = Layout.collection(Job, ({ members }) => ({
  391. labels: members((job) => {
  392. extractions++
  393. return job.labels
  394. }),
  395. }))
  396. const jobs = IndexedJobs.make([
  397. { id: "one", labels: ["billing"], status: "running" },
  398. { id: "two", labels: ["billing"], status: "running" },
  399. ])
  400. const before = extractions
  401. jobs.modify("one", (job) => ({ ...job, labels: ["urgent"] }), {
  402. members: { labels: { add: ["urgent"], remove: ["billing"] } },
  403. })
  404. expect(extractions).toBe(before)
  405. expect(jobs.hasMember("labels", "billing")).toBe(true)
  406. expect(jobs.hasMember("labels", "urgent")).toBe(true)
  407. jobs.modify("two", (job) => ({ ...job, labels: [] }), {
  408. members: { labels: { remove: ["billing"] } },
  409. })
  410. expect(jobs.hasMember("labels", "billing")).toBe(false)
  411. if (false) {
  412. jobs.modify("one", (job) => job, {
  413. members: {
  414. labels: {
  415. // @ts-expect-error Member deltas require arrays so strings are not split into characters.
  416. add: "urgent",
  417. },
  418. },
  419. })
  420. }
  421. })
  422. it("does not commit mutations when an index callback throws", () => {
  423. const ThrowingJobs = Layout.collection(Job, ({ members, first }) => ({
  424. labels: members((job) => {
  425. if (job.labels.includes("boom")) throw new Error("boom")
  426. return job.labels
  427. }),
  428. nextRetry: first((job) => job.status === "retrying"),
  429. }))
  430. const jobs = ThrowingJobs.make([{ id: "one", labels: ["safe"], status: "running" }])
  431. expect(() => jobs.update({ id: "one", labels: ["boom"], status: "retrying" })).toThrow("boom")
  432. expect(() => jobs.set([{ id: "two", labels: ["boom"], status: "retrying" }])).toThrow("boom")
  433. expect(jobs.values()).toEqual([{ id: "one", labels: ["safe"], status: "running" }])
  434. expect(jobs.hasMember("labels", "safe")).toBe(true)
  435. expect(jobs.hasMember("labels", "boom")).toBe(false)
  436. expect(jobs.first("nextRetry")()).toBeUndefined()
  437. expect(() => jobs.insert({ id: "two", labels: ["boom"], status: "retrying" }, { before: "missing" })).toThrow(
  438. "Keyed value does not exist: missing",
  439. )
  440. })
  441. it("restores pending comparison state when publication throws", () => {
  442. const LabeledJob = Layout.struct({
  443. id: Layout.key(Layout.string),
  444. label: Layout.string,
  445. status: Layout.string,
  446. })
  447. const IndexedJobs = Layout.collection(LabeledJob, ({ first }) => ({
  448. retry: first(["status"], (job) => job.status === "retrying"),
  449. }))
  450. const jobs = IndexedJobs.make([{ id: "one", label: "before", status: "running" }])
  451. const slot = jobs.get("one")!
  452. const dispose = slot.subscribe(() => {
  453. throw new Error("listener failed")
  454. })
  455. expect(() => jobs.update({ id: "one", label: "after", status: "running" })).toThrow("listener failed")
  456. dispose()
  457. const equivalent = { ...slot() }
  458. expect(jobs.set([equivalent])).toBe(false)
  459. expect(slot()).not.toBe(equivalent)
  460. })
  461. it("does not leak a pending comparison into reentrant publication", () => {
  462. const LabeledJob = Layout.struct({
  463. id: Layout.key(Layout.string),
  464. label: Layout.string,
  465. status: Layout.string,
  466. })
  467. const IndexedJobs = Layout.collection(LabeledJob, ({ first }) => ({
  468. retry: first(["status"], (job) => job.status === "retrying"),
  469. }))
  470. const jobs = IndexedJobs.make([{ id: "one", label: "before", status: "running" }])
  471. const slot = jobs.get("one")!
  472. let reentered = false
  473. let changed: boolean | undefined
  474. const dispose = slot.subscribe(() => {
  475. if (reentered) return
  476. reentered = true
  477. changed = jobs.set([{ ...slot() }])
  478. })
  479. jobs.update({ id: "one", label: "after", status: "running" })
  480. expect(changed).toBe(false)
  481. dispose()
  482. })
  483. it("tracks first matches whose key is undefined", () => {
  484. const undefinedField: Layout.Field<undefined> = { equivalent: Object.is }
  485. const OptionalJobs = Layout.collection(
  486. Layout.struct({ id: Layout.key(undefinedField), status: Layout.string }),
  487. ({ first }) => ({ retry: first(["status"], (job) => job.status === "retrying") }),
  488. )
  489. const jobs = OptionalJobs.make([{ id: undefined, status: "running" }])
  490. jobs.update({ id: undefined, status: "retrying" })
  491. expect(jobs.first("retry")?.()).toEqual({
  492. id: undefined,
  493. status: "retrying",
  494. })
  495. })
  496. })