@adobe/data 0.9.60 → 0.9.62

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/dist/ecs/archetype/archetype.d.ts +9 -2
  2. package/dist/ecs/archetype/archetype.js.map +1 -1
  3. package/dist/ecs/archetype/create-archetype.js +5 -2
  4. package/dist/ecs/archetype/create-archetype.js.map +1 -1
  5. package/dist/ecs/database/combine-plugins.d.ts +5 -1
  6. package/dist/ecs/database/combine-plugins.js +3 -3
  7. package/dist/ecs/database/combine-plugins.js.map +1 -1
  8. package/dist/ecs/database/concurrency/concurrency-strategy.d.ts +86 -0
  9. package/dist/ecs/database/concurrency/concurrency-strategy.js +3 -0
  10. package/dist/ecs/database/concurrency/concurrency-strategy.js.map +1 -0
  11. package/dist/ecs/database/concurrency/immediate-concurrency.d.ts +22 -0
  12. package/dist/ecs/database/concurrency/immediate-concurrency.js +74 -0
  13. package/dist/ecs/database/concurrency/immediate-concurrency.js.map +1 -0
  14. package/dist/ecs/database/concurrency/index.d.ts +4 -0
  15. package/dist/ecs/database/concurrency/index.js +5 -0
  16. package/dist/ecs/database/concurrency/index.js.map +1 -0
  17. package/dist/ecs/database/concurrency/rebase-replay-concurrency.d.ts +20 -0
  18. package/dist/ecs/database/concurrency/rebase-replay-concurrency.js +34 -0
  19. package/dist/ecs/database/concurrency/rebase-replay-concurrency.js.map +1 -0
  20. package/dist/ecs/database/concurrency/roll-forward-concurrency.d.ts +33 -0
  21. package/dist/ecs/database/concurrency/roll-forward-concurrency.js +115 -0
  22. package/dist/ecs/database/concurrency/roll-forward-concurrency.js.map +1 -0
  23. package/dist/ecs/database/concurrency/roll-forward-concurrency.test.d.ts +1 -0
  24. package/dist/ecs/database/concurrency/roll-forward-concurrency.test.js +136 -0
  25. package/dist/ecs/database/concurrency/roll-forward-concurrency.test.js.map +1 -0
  26. package/dist/ecs/database/create-plugin.d.ts +17 -14
  27. package/dist/ecs/database/create-plugin.js +2 -1
  28. package/dist/ecs/database/create-plugin.js.map +1 -1
  29. package/dist/ecs/database/database.d.ts +61 -11
  30. package/dist/ecs/database/database.index.performance.test.d.ts +1 -0
  31. package/dist/ecs/database/database.index.performance.test.js +417 -0
  32. package/dist/ecs/database/database.index.performance.test.js.map +1 -0
  33. package/dist/ecs/database/database.index.test.d.ts +1 -0
  34. package/dist/ecs/database/database.index.test.js +1142 -0
  35. package/dist/ecs/database/database.index.test.js.map +1 -0
  36. package/dist/ecs/database/database.index.type-test.d.ts +1 -0
  37. package/dist/ecs/database/database.index.type-test.js +275 -0
  38. package/dist/ecs/database/database.index.type-test.js.map +1 -0
  39. package/dist/ecs/database/database.js.map +1 -1
  40. package/dist/ecs/database/database.reset.test.js +2 -1
  41. package/dist/ecs/database/database.reset.test.js.map +1 -1
  42. package/dist/ecs/database/deep-extends-chain.type-test.d.ts +256 -256
  43. package/dist/ecs/database/index-api-proof.type-test.d.ts +1 -0
  44. package/dist/ecs/database/index-api-proof.type-test.js +88 -0
  45. package/dist/ecs/database/index-api-proof.type-test.js.map +1 -0
  46. package/dist/ecs/database/index-registry/create-index-registry.d.ts +53 -0
  47. package/dist/ecs/database/index-registry/create-index-registry.js +174 -0
  48. package/dist/ecs/database/index-registry/create-index-registry.js.map +1 -0
  49. package/dist/ecs/database/index-registry/create-index.d.ts +60 -0
  50. package/dist/ecs/database/index-registry/create-index.js +539 -0
  51. package/dist/ecs/database/index-registry/create-index.js.map +1 -0
  52. package/dist/ecs/database/index-registry/index.d.ts +4 -0
  53. package/dist/ecs/database/index-registry/index.js +4 -0
  54. package/dist/ecs/database/index-registry/index.js.map +1 -0
  55. package/dist/ecs/database/index.d.ts +1 -0
  56. package/dist/ecs/database/index.js +1 -0
  57. package/dist/ecs/database/index.js.map +1 -1
  58. package/dist/ecs/database/observe-index-entities.d.ts +26 -0
  59. package/dist/ecs/database/observe-index-entities.js +66 -0
  60. package/dist/ecs/database/observe-index-entities.js.map +1 -0
  61. package/dist/ecs/database/observed/create-observed-database.js +1 -1
  62. package/dist/ecs/database/observed/create-observed-database.js.map +1 -1
  63. package/dist/ecs/database/observed/observed-database.d.ts +1 -1
  64. package/dist/ecs/database/public/create-database.d.ts +12 -27
  65. package/dist/ecs/database/public/create-database.js +252 -10
  66. package/dist/ecs/database/public/create-database.js.map +1 -1
  67. package/dist/ecs/database/public/create-database.test.js +8 -7
  68. package/dist/ecs/database/public/create-database.test.js.map +1 -1
  69. package/dist/ecs/database/public/create-transaction-dispatcher.d.ts +4 -2
  70. package/dist/ecs/database/public/create-transaction-dispatcher.js +2 -8
  71. package/dist/ecs/database/public/create-transaction-dispatcher.js.map +1 -1
  72. package/dist/ecs/database/reconciling/create-rebase-replay-applier.d.ts +26 -0
  73. package/dist/ecs/database/reconciling/create-rebase-replay-applier.js +121 -0
  74. package/dist/ecs/database/reconciling/create-rebase-replay-applier.js.map +1 -0
  75. package/dist/ecs/database/reconciling/create-reconciling-database.d.ts +11 -0
  76. package/dist/ecs/database/reconciling/create-reconciling-database.js +28 -137
  77. package/dist/ecs/database/reconciling/create-reconciling-database.js.map +1 -1
  78. package/dist/ecs/database/transactional-store/create-transactional-store.js.map +1 -1
  79. package/dist/ecs/database/transactional-store/transactional-store.d.ts +4 -3
  80. package/dist/ecs/entity-location-table/create-entity-location-table.js +2 -2
  81. package/dist/ecs/entity-location-table/create-entity-location-table.js.map +1 -1
  82. package/dist/ecs/entity-location-table/entity-location-table.d.ts +7 -1
  83. package/dist/ecs/plugins/scheduler/scheduler.d.ts +1 -0
  84. package/dist/ecs/store/core/core.d.ts +7 -1
  85. package/dist/ecs/store/core/create-core.js +3 -3
  86. package/dist/ecs/store/core/create-core.js.map +1 -1
  87. package/dist/ecs/store/index-types.d.ts +142 -0
  88. package/dist/ecs/store/index-types.js +3 -0
  89. package/dist/ecs/store/index-types.js.map +1 -0
  90. package/dist/ecs/store/public/create-store.js +206 -4
  91. package/dist/ecs/store/public/create-store.js.map +1 -1
  92. package/dist/ecs/store/public/create-store.test.js +25 -0
  93. package/dist/ecs/store/public/create-store.test.js.map +1 -1
  94. package/dist/ecs/store/store.d.ts +38 -14
  95. package/dist/ecs/store/store.js +5 -4
  96. package/dist/ecs/store/store.js.map +1 -1
  97. package/dist/ecs/store/transaction-functions.d.ts +4 -3
  98. package/dist/schema/fractional-index/between-n.d.ts +2 -0
  99. package/dist/schema/fractional-index/between-n.js +34 -0
  100. package/dist/schema/fractional-index/between-n.js.map +1 -0
  101. package/dist/schema/fractional-index/between-n.test.d.ts +1 -0
  102. package/dist/schema/fractional-index/between-n.test.js +36 -0
  103. package/dist/schema/fractional-index/between-n.test.js.map +1 -0
  104. package/dist/schema/fractional-index/between.d.ts +2 -0
  105. package/dist/schema/fractional-index/between.js +3 -0
  106. package/dist/schema/fractional-index/between.js.map +1 -0
  107. package/dist/schema/fractional-index/between.test.d.ts +1 -0
  108. package/dist/schema/fractional-index/between.test.js +68 -0
  109. package/dist/schema/fractional-index/between.test.js.map +1 -0
  110. package/dist/schema/fractional-index/decrement-integer.d.ts +1 -0
  111. package/dist/schema/fractional-index/decrement-integer.js +32 -0
  112. package/dist/schema/fractional-index/decrement-integer.js.map +1 -0
  113. package/dist/schema/fractional-index/decrement-integer.test.d.ts +1 -0
  114. package/dist/schema/fractional-index/decrement-integer.test.js +31 -0
  115. package/dist/schema/fractional-index/decrement-integer.test.js.map +1 -0
  116. package/dist/schema/fractional-index/digits.d.ts +1 -0
  117. package/dist/schema/fractional-index/digits.js +3 -0
  118. package/dist/schema/fractional-index/digits.js.map +1 -0
  119. package/dist/schema/fractional-index/fractional-index.d.ts +4 -0
  120. package/dist/schema/fractional-index/fractional-index.js +2 -0
  121. package/dist/schema/fractional-index/fractional-index.js.map +1 -0
  122. package/dist/schema/fractional-index/increment-integer.d.ts +1 -0
  123. package/dist/schema/fractional-index/increment-integer.js +32 -0
  124. package/dist/schema/fractional-index/increment-integer.js.map +1 -0
  125. package/dist/schema/fractional-index/increment-integer.test.d.ts +1 -0
  126. package/dist/schema/fractional-index/increment-integer.test.js +34 -0
  127. package/dist/schema/fractional-index/increment-integer.test.js.map +1 -0
  128. package/dist/schema/fractional-index/initial.d.ts +2 -0
  129. package/dist/schema/fractional-index/initial.js +2 -0
  130. package/dist/schema/fractional-index/initial.js.map +1 -0
  131. package/dist/schema/fractional-index/integer-length.d.ts +1 -0
  132. package/dist/schema/fractional-index/integer-length.js +9 -0
  133. package/dist/schema/fractional-index/integer-length.js.map +1 -0
  134. package/dist/schema/fractional-index/integer-part.d.ts +1 -0
  135. package/dist/schema/fractional-index/integer-part.js +9 -0
  136. package/dist/schema/fractional-index/integer-part.js.map +1 -0
  137. package/dist/schema/fractional-index/key-between.d.ts +2 -0
  138. package/dist/schema/fractional-index/key-between.js +39 -0
  139. package/dist/schema/fractional-index/key-between.js.map +1 -0
  140. package/dist/schema/fractional-index/midpoint.d.ts +1 -0
  141. package/dist/schema/fractional-index/midpoint.js +23 -0
  142. package/dist/schema/fractional-index/midpoint.js.map +1 -0
  143. package/dist/schema/fractional-index/midpoint.test.d.ts +1 -0
  144. package/dist/schema/fractional-index/midpoint.test.js +83 -0
  145. package/dist/schema/fractional-index/midpoint.test.js.map +1 -0
  146. package/dist/schema/fractional-index/public.d.ts +4 -0
  147. package/dist/schema/fractional-index/public.js +6 -0
  148. package/dist/schema/fractional-index/public.js.map +1 -0
  149. package/dist/schema/fractional-index/schema.d.ts +4 -0
  150. package/dist/schema/fractional-index/schema.js +5 -0
  151. package/dist/schema/fractional-index/schema.js.map +1 -0
  152. package/dist/schema/fractional-index/smallest-integer.d.ts +1 -0
  153. package/dist/schema/fractional-index/smallest-integer.js +3 -0
  154. package/dist/schema/fractional-index/smallest-integer.js.map +1 -0
  155. package/dist/schema/fractional-index/validate-integer.d.ts +1 -0
  156. package/dist/schema/fractional-index/validate-integer.js +7 -0
  157. package/dist/schema/fractional-index/validate-integer.js.map +1 -0
  158. package/dist/schema/fractional-index/validate-order-key.d.ts +1 -0
  159. package/dist/schema/fractional-index/validate-order-key.js +11 -0
  160. package/dist/schema/fractional-index/validate-order-key.js.map +1 -0
  161. package/dist/schema/guid/create.d.ts +2 -0
  162. package/dist/schema/guid/create.js +10 -0
  163. package/dist/schema/guid/create.js.map +1 -0
  164. package/dist/schema/guid/create.test.d.ts +1 -0
  165. package/dist/schema/guid/create.test.js +39 -0
  166. package/dist/schema/guid/create.test.js.map +1 -0
  167. package/dist/schema/guid/equals.d.ts +2 -0
  168. package/dist/schema/guid/equals.js +3 -0
  169. package/dist/schema/guid/equals.js.map +1 -0
  170. package/dist/schema/guid/equals.test.d.ts +1 -0
  171. package/dist/schema/guid/equals.test.js +30 -0
  172. package/dist/schema/guid/equals.test.js.map +1 -0
  173. package/dist/schema/guid/from-uuid.d.ts +2 -0
  174. package/dist/schema/guid/from-uuid.js +15 -0
  175. package/dist/schema/guid/from-uuid.js.map +1 -0
  176. package/dist/schema/guid/from-uuid.test.d.ts +1 -0
  177. package/dist/schema/guid/from-uuid.test.js +39 -0
  178. package/dist/schema/guid/from-uuid.test.js.map +1 -0
  179. package/dist/schema/guid/guid.performance.test.d.ts +1 -0
  180. package/dist/schema/guid/guid.performance.test.js +297 -0
  181. package/dist/schema/guid/guid.performance.test.js.map +1 -0
  182. package/dist/schema/guid/index.d.ts +4 -0
  183. package/dist/schema/guid/index.js +3 -0
  184. package/dist/schema/guid/index.js.map +1 -0
  185. package/dist/schema/guid/layout.d.ts +1 -0
  186. package/dist/schema/guid/layout.js +5 -0
  187. package/dist/schema/guid/layout.js.map +1 -0
  188. package/dist/schema/guid/nil.d.ts +2 -0
  189. package/dist/schema/guid/nil.js +3 -0
  190. package/dist/schema/guid/nil.js.map +1 -0
  191. package/dist/schema/guid/public.d.ts +8 -0
  192. package/dist/schema/guid/public.js +10 -0
  193. package/dist/schema/guid/public.js.map +1 -0
  194. package/dist/schema/guid/schema.d.ts +12 -0
  195. package/dist/schema/guid/schema.js +10 -0
  196. package/dist/schema/guid/schema.js.map +1 -0
  197. package/dist/schema/guid/schema.test.d.ts +1 -0
  198. package/dist/schema/guid/schema.test.js +35 -0
  199. package/dist/schema/guid/schema.test.js.map +1 -0
  200. package/dist/schema/guid/to-unserializable-key.d.ts +2 -0
  201. package/dist/schema/guid/to-unserializable-key.js +10 -0
  202. package/dist/schema/guid/to-unserializable-key.js.map +1 -0
  203. package/dist/schema/guid/to-uuid.d.ts +2 -0
  204. package/dist/schema/guid/to-uuid.js +15 -0
  205. package/dist/schema/guid/to-uuid.js.map +1 -0
  206. package/dist/schema/guid/to-uuid.test.d.ts +1 -0
  207. package/dist/schema/guid/to-uuid.test.js +31 -0
  208. package/dist/schema/guid/to-uuid.test.js.map +1 -0
  209. package/dist/schema/index.d.ts +2 -0
  210. package/dist/schema/index.js +2 -0
  211. package/dist/schema/index.js.map +1 -1
  212. package/dist/table/select-rows.d.ts +7 -4
  213. package/dist/table/select-rows.js.map +1 -1
  214. package/dist/test-setup.js +2 -2
  215. package/dist/test-setup.js.map +1 -1
  216. package/dist/tsconfig.tsbuildinfo +1 -1
  217. package/package.json +3 -1
  218. package/references/data-lit/package.json +1 -1
  219. package/references/data-lit-tictactoe/package.json +1 -1
  220. package/references/data-react/package.json +1 -1
  221. package/references/data-react-hello/package.json +1 -1
  222. package/references/data-react-pixie/package.json +1 -1
@@ -0,0 +1,1142 @@
1
+ // © 2026 Adobe. MIT License. See /LICENSE for details.
2
+ import { describe, it, expect } from "vitest";
3
+ import { Database } from "./database.js";
4
+ // ============================================================================
5
+ // Catalogue pattern coverage — each describe corresponds to one of the 12
6
+ // patterns in `packages/data/src/ecs/README.md`. The tests assert runtime
7
+ // behavior. The type-level surface is covered by `index-api-proof.type-test.ts`
8
+ // and `database.index.type-test.ts`.
9
+ // ============================================================================
10
+ describe("Pattern 1 — single-column unique lookup (byEmail)", () => {
11
+ const plugin = () => Database.Plugin.create({
12
+ components: { email: { type: "string" } },
13
+ archetypes: { User: ["email"] },
14
+ indexes: {
15
+ byEmail: { key: "email", unique: true },
16
+ },
17
+ transactions: {
18
+ add: (t, email) => t.archetypes.User.insert({ email }),
19
+ },
20
+ });
21
+ it("get returns the entity for a known scalar key", () => {
22
+ const db = Database.create(plugin());
23
+ const e = db.transactions.add("alice@a.com");
24
+ expect(db.indexes.byEmail.get("alice@a.com")).toBe(e);
25
+ });
26
+ it("get returns null for an unknown key (known absent)", () => {
27
+ const db = Database.create(plugin());
28
+ expect(db.indexes.byEmail.get("missing@x.com")).toBeNull();
29
+ });
30
+ it("duplicate insert throws atomically — original row stays intact", () => {
31
+ const db = Database.create(plugin());
32
+ const first = db.transactions.add("shared@x.com");
33
+ expect(() => db.transactions.add("shared@x.com")).toThrow(/Unique index conflict/);
34
+ expect(db.indexes.byEmail.get("shared@x.com")).toBe(first);
35
+ });
36
+ });
37
+ describe("Pattern 2 — multi-column compound unique (playerSlot)", () => {
38
+ const plugin = () => Database.Plugin.create({
39
+ components: {
40
+ team: { type: "number" },
41
+ position: { type: "string" },
42
+ name: { type: "string" },
43
+ },
44
+ archetypes: { Player: ["team", "position", "name"] },
45
+ indexes: {
46
+ playerSlot: { key: ["team", "position"], unique: true },
47
+ },
48
+ transactions: {
49
+ add: (t, args) => t.archetypes.Player.insert(args),
50
+ },
51
+ });
52
+ it("get takes the column-keyed object and returns the entity", () => {
53
+ const db = Database.create(plugin());
54
+ const e = db.transactions.add({ team: 1, position: "qb", name: "alice" });
55
+ expect(db.indexes.playerSlot.get({ team: 1, position: "qb" })).toBe(e);
56
+ expect(db.indexes.playerSlot.get({ team: 1, position: "rb" })).toBeNull();
57
+ });
58
+ it("same component values in different teams do not collide", () => {
59
+ const db = Database.create(plugin());
60
+ const a = db.transactions.add({ team: 1, position: "qb", name: "alice" });
61
+ const b = db.transactions.add({ team: 2, position: "qb", name: "bob" });
62
+ expect(db.indexes.playerSlot.get({ team: 1, position: "qb" })).toBe(a);
63
+ expect(db.indexes.playerSlot.get({ team: 2, position: "qb" })).toBe(b);
64
+ });
65
+ });
66
+ describe("Pattern 3 — non-unique by single column (childrenOf)", () => {
67
+ const plugin = () => Database.Plugin.create({
68
+ components: {
69
+ parent: { type: "number" },
70
+ label: { type: "string" },
71
+ },
72
+ archetypes: { Child: ["parent", "label"] },
73
+ indexes: {
74
+ childrenOf: { key: "parent" },
75
+ },
76
+ transactions: {
77
+ add: (t, args) => t.archetypes.Child.insert(args),
78
+ delete: (t, e) => t.delete(e),
79
+ move: (t, args) => t.update(args.entity, { parent: args.newParent }),
80
+ },
81
+ });
82
+ it("find returns every entity sharing the key", () => {
83
+ const db = Database.create(plugin());
84
+ const a = db.transactions.add({ parent: 7, label: "a" });
85
+ const b = db.transactions.add({ parent: 7, label: "b" });
86
+ db.transactions.add({ parent: 9, label: "c" });
87
+ expect([...db.indexes.childrenOf.find(7)].sort()).toEqual([a, b].sort());
88
+ });
89
+ it("delete removes the entity from the bucket", () => {
90
+ const db = Database.create(plugin());
91
+ const e = db.transactions.add({ parent: 7, label: "a" });
92
+ expect(db.indexes.childrenOf.find(7)).toEqual([e]);
93
+ db.transactions.delete(e);
94
+ expect(db.indexes.childrenOf.find(7)).toEqual([]);
95
+ });
96
+ it("update moves the entity between buckets", () => {
97
+ const db = Database.create(plugin());
98
+ const e = db.transactions.add({ parent: 7, label: "a" });
99
+ db.transactions.move({ entity: e, newParent: 9 });
100
+ expect(db.indexes.childrenOf.find(7)).toEqual([]);
101
+ expect(db.indexes.childrenOf.find(9)).toEqual([e]);
102
+ });
103
+ it("`get` is absent on non-unique handles at runtime and types", () => {
104
+ const db = Database.create(plugin());
105
+ expect("get" in db.indexes.childrenOf).toBe(false);
106
+ });
107
+ });
108
+ describe("Pattern 4 — sorted children (orderedChildrenOf)", () => {
109
+ const plugin = () => Database.Plugin.create({
110
+ components: {
111
+ parent: { type: "number" },
112
+ fractIndex: { type: "string" },
113
+ },
114
+ archetypes: { Child: ["parent", "fractIndex"] },
115
+ indexes: {
116
+ orderedChildrenOf: {
117
+ key: "parent",
118
+ order: { by: ["fractIndex"] },
119
+ },
120
+ },
121
+ transactions: {
122
+ add: (t, args) => t.archetypes.Child.insert(args),
123
+ move: (t, args) => t.update(args.entity, { fractIndex: args.fractIndex }),
124
+ delete: (t, e) => t.delete(e),
125
+ },
126
+ });
127
+ it("returns entities sorted by the order key (asc by default)", () => {
128
+ const db = Database.create(plugin());
129
+ const c = db.transactions.add({ parent: 7, fractIndex: "c" });
130
+ const a = db.transactions.add({ parent: 7, fractIndex: "a" });
131
+ const b = db.transactions.add({ parent: 7, fractIndex: "b" });
132
+ expect(db.indexes.orderedChildrenOf.find(7)).toEqual([a, b, c]);
133
+ });
134
+ it("update on the sort key repositions the entity in place", () => {
135
+ const db = Database.create(plugin());
136
+ const a = db.transactions.add({ parent: 7, fractIndex: "a" });
137
+ const b = db.transactions.add({ parent: 7, fractIndex: "b" });
138
+ const c = db.transactions.add({ parent: 7, fractIndex: "c" });
139
+ db.transactions.move({ entity: b, fractIndex: "z" });
140
+ expect(db.indexes.orderedChildrenOf.find(7)).toEqual([a, c, b]);
141
+ db.transactions.move({ entity: a, fractIndex: "d" });
142
+ expect(db.indexes.orderedChildrenOf.find(7)).toEqual([c, a, b]);
143
+ });
144
+ it("delete preserves sort order of the remaining entities", () => {
145
+ const db = Database.create(plugin());
146
+ const a = db.transactions.add({ parent: 7, fractIndex: "a" });
147
+ const b = db.transactions.add({ parent: 7, fractIndex: "b" });
148
+ const c = db.transactions.add({ parent: 7, fractIndex: "c" });
149
+ db.transactions.delete(b);
150
+ expect(db.indexes.orderedChildrenOf.find(7)).toEqual([a, c]);
151
+ });
152
+ it("populates and sorts when the index is registered after data exists", () => {
153
+ const base = Database.Plugin.create({
154
+ components: {
155
+ parent: { type: "number" },
156
+ fractIndex: { type: "string" },
157
+ },
158
+ archetypes: { Child: ["parent", "fractIndex"] },
159
+ transactions: {
160
+ add: (t, args) => t.archetypes.Child.insert(args),
161
+ },
162
+ });
163
+ const db = Database.create(base);
164
+ const c = db.transactions.add({ parent: 5, fractIndex: "c" });
165
+ const a = db.transactions.add({ parent: 5, fractIndex: "a" });
166
+ const b = db.transactions.add({ parent: 5, fractIndex: "b" });
167
+ const indexed = Database.Plugin.create({
168
+ extends: base,
169
+ indexes: {
170
+ orderedChildrenOf: { key: "parent", order: { by: ["fractIndex"] } },
171
+ },
172
+ });
173
+ const ext = db.extend(indexed);
174
+ expect(ext.indexes.orderedChildrenOf.find(5)).toEqual([a, b, c]);
175
+ });
176
+ });
177
+ describe("Pattern 4 — observe(arg): reactive sorted bucket view", () => {
178
+ const plugin = () => Database.Plugin.create({
179
+ components: {
180
+ parent: { type: "number" },
181
+ fractIndex: { type: "string" },
182
+ },
183
+ archetypes: { Child: ["parent", "fractIndex"] },
184
+ indexes: {
185
+ orderedChildrenOf: {
186
+ key: "parent",
187
+ order: { by: ["fractIndex"] },
188
+ },
189
+ },
190
+ transactions: {
191
+ add: (t, args) => t.archetypes.Child.insert(args),
192
+ move: (t, args) => t.update(args.entity, { fractIndex: args.fractIndex }),
193
+ reparent: (t, args) => t.update(args.entity, { parent: args.parent }),
194
+ delete: (t, e) => t.delete(e),
195
+ },
196
+ });
197
+ it("emits the initial sorted list synchronously on subscribe", () => {
198
+ const db = Database.create(plugin());
199
+ const c = db.transactions.add({ parent: 7, fractIndex: "c" });
200
+ const a = db.transactions.add({ parent: 7, fractIndex: "a" });
201
+ const b = db.transactions.add({ parent: 7, fractIndex: "b" });
202
+ const emissions = [];
203
+ const unsub = db.indexes.orderedChildrenOf.observe(7)((entities) => { emissions.push([...entities]); });
204
+ expect(emissions).toEqual([[a, b, c]]);
205
+ unsub();
206
+ });
207
+ it("emits the re-sorted list when an entity is inserted into the bucket", async () => {
208
+ const db = Database.create(plugin());
209
+ const a = db.transactions.add({ parent: 7, fractIndex: "a" });
210
+ const c = db.transactions.add({ parent: 7, fractIndex: "c" });
211
+ const emissions = [];
212
+ const unsub = db.indexes.orderedChildrenOf.observe(7)((entities) => { emissions.push([...entities]); });
213
+ const b = db.transactions.add({ parent: 7, fractIndex: "b" });
214
+ await Promise.resolve();
215
+ expect(emissions[0]).toEqual([a, c]);
216
+ expect(emissions[emissions.length - 1]).toEqual([a, b, c]);
217
+ unsub();
218
+ });
219
+ it("emits a reorder when only the sort key changes (regression case)", async () => {
220
+ const db = Database.create(plugin());
221
+ const a = db.transactions.add({ parent: 7, fractIndex: "a" });
222
+ const b = db.transactions.add({ parent: 7, fractIndex: "b" });
223
+ const c = db.transactions.add({ parent: 7, fractIndex: "c" });
224
+ const emissions = [];
225
+ const unsub = db.indexes.orderedChildrenOf.observe(7)((entities) => { emissions.push([...entities]); });
226
+ expect(emissions[0]).toEqual([a, b, c]);
227
+ // Reorder-only transaction: parent (the bucket key) is untouched, so a
228
+ // `where`-only observe.select would never see this. The index does.
229
+ db.transactions.move({ entity: b, fractIndex: "z" });
230
+ await Promise.resolve();
231
+ expect(emissions[emissions.length - 1]).toEqual([a, c, b]);
232
+ unsub();
233
+ });
234
+ it("emits the shrunken list when an entity is deleted from the bucket", async () => {
235
+ const db = Database.create(plugin());
236
+ const a = db.transactions.add({ parent: 7, fractIndex: "a" });
237
+ const b = db.transactions.add({ parent: 7, fractIndex: "b" });
238
+ const emissions = [];
239
+ const unsub = db.indexes.orderedChildrenOf.observe(7)((entities) => { emissions.push([...entities]); });
240
+ db.transactions.delete(b);
241
+ await Promise.resolve();
242
+ expect(emissions[emissions.length - 1]).toEqual([a]);
243
+ unsub();
244
+ });
245
+ it("does NOT emit when an unrelated bucket's entity changes", async () => {
246
+ const db = Database.create(plugin());
247
+ db.transactions.add({ parent: 7, fractIndex: "a" });
248
+ const other = db.transactions.add({ parent: 9, fractIndex: "a" });
249
+ const emissions = [];
250
+ const unsub = db.indexes.orderedChildrenOf.observe(7)((entities) => { emissions.push([...entities]); });
251
+ expect(emissions).toHaveLength(1);
252
+ // A child of a *different* parent moves. Same component (`fractIndex`)
253
+ // changes, so the observer is woken and recomputes — but its own
254
+ // bucket is unchanged, so no second emission.
255
+ db.transactions.move({ entity: other, fractIndex: "z" });
256
+ await Promise.resolve();
257
+ expect(emissions).toHaveLength(1);
258
+ unsub();
259
+ });
260
+ it("emits on both buckets when an entity reparents between them", async () => {
261
+ const db = Database.create(plugin());
262
+ const a = db.transactions.add({ parent: 7, fractIndex: "a" });
263
+ const m = db.transactions.add({ parent: 7, fractIndex: "b" });
264
+ const fromSeven = [];
265
+ const toNine = [];
266
+ const unsub7 = db.indexes.orderedChildrenOf.observe(7)((e) => fromSeven.push([...e]));
267
+ const unsub9 = db.indexes.orderedChildrenOf.observe(9)((e) => toNine.push([...e]));
268
+ db.transactions.reparent({ entity: m, parent: 9 });
269
+ await Promise.resolve();
270
+ expect(fromSeven[fromSeven.length - 1]).toEqual([a]);
271
+ expect(toNine[toNine.length - 1]).toEqual([m]);
272
+ unsub7();
273
+ unsub9();
274
+ });
275
+ it("unsubscribe stops further emissions", async () => {
276
+ const db = Database.create(plugin());
277
+ db.transactions.add({ parent: 7, fractIndex: "a" });
278
+ const emissions = [];
279
+ const unsub = db.indexes.orderedChildrenOf.observe(7)((entities) => { emissions.push([...entities]); });
280
+ expect(emissions).toHaveLength(1);
281
+ unsub();
282
+ db.transactions.add({ parent: 7, fractIndex: "b" });
283
+ await Promise.resolve();
284
+ expect(emissions).toHaveLength(1);
285
+ });
286
+ });
287
+ describe("observe(arg) — additional edge cases", () => {
288
+ const sortedPlugin = () => Database.Plugin.create({
289
+ components: {
290
+ parent: { type: "number" },
291
+ fractIndex: { type: "string" },
292
+ },
293
+ archetypes: { Child: ["parent", "fractIndex"] },
294
+ indexes: {
295
+ orderedChildrenOf: { key: "parent", order: { by: ["fractIndex"] } },
296
+ childrenOf: { key: "parent" },
297
+ },
298
+ transactions: {
299
+ add: (t, args) => t.archetypes.Child.insert(args),
300
+ move: (t, args) => t.update(args.entity, { fractIndex: args.fractIndex }),
301
+ delete: (t, e) => t.delete(e),
302
+ },
303
+ });
304
+ it("emits [] for an initially-empty bucket, then the entity once populated", async () => {
305
+ const db = Database.create(sortedPlugin());
306
+ const emissions = [];
307
+ const unsub = db.indexes.orderedChildrenOf.observe(42)((e) => emissions.push([...e]));
308
+ expect(emissions).toEqual([[]]);
309
+ const e = db.transactions.add({ parent: 42, fractIndex: "a" });
310
+ await Promise.resolve();
311
+ expect(emissions[emissions.length - 1]).toEqual([e]);
312
+ unsub();
313
+ });
314
+ it("emits [] when the last entity leaves the bucket", async () => {
315
+ const db = Database.create(sortedPlugin());
316
+ const only = db.transactions.add({ parent: 7, fractIndex: "a" });
317
+ const emissions = [];
318
+ const unsub = db.indexes.orderedChildrenOf.observe(7)((e) => emissions.push([...e]));
319
+ db.transactions.delete(only);
320
+ await Promise.resolve();
321
+ expect(emissions[emissions.length - 1]).toEqual([]);
322
+ unsub();
323
+ });
324
+ it("notifies every subscriber of the same bucket", async () => {
325
+ const db = Database.create(sortedPlugin());
326
+ const a = db.transactions.add({ parent: 7, fractIndex: "a" });
327
+ const first = [];
328
+ const second = [];
329
+ const unsub1 = db.indexes.orderedChildrenOf.observe(7)((e) => first.push([...e]));
330
+ const unsub2 = db.indexes.orderedChildrenOf.observe(7)((e) => second.push([...e]));
331
+ const b = db.transactions.add({ parent: 7, fractIndex: "b" });
332
+ await Promise.resolve();
333
+ expect(first[first.length - 1]).toEqual([a, b]);
334
+ expect(second[second.length - 1]).toEqual([a, b]);
335
+ unsub1();
336
+ unsub2();
337
+ });
338
+ it("coalesces several transactions before the microtask into one emission", async () => {
339
+ const db = Database.create(sortedPlugin());
340
+ const a = db.transactions.add({ parent: 7, fractIndex: "a" });
341
+ const emissions = [];
342
+ const unsub = db.indexes.orderedChildrenOf.observe(7)((e) => emissions.push([...e]));
343
+ // Three synchronous transactions, no awaits in between.
344
+ const c = db.transactions.add({ parent: 7, fractIndex: "c" });
345
+ const b = db.transactions.add({ parent: 7, fractIndex: "b" });
346
+ db.transactions.move({ entity: c, fractIndex: "d" });
347
+ await Promise.resolve();
348
+ // Initial emit + exactly one coalesced emit reflecting the final state.
349
+ expect(emissions).toHaveLength(2);
350
+ expect(emissions[1]).toEqual([a, b, c]);
351
+ unsub();
352
+ });
353
+ it("does not re-emit when a touched bucket ends a transaction unchanged", async () => {
354
+ const db = Database.create(sortedPlugin());
355
+ const a = db.transactions.add({ parent: 7, fractIndex: "a" });
356
+ const emissions = [];
357
+ const unsub = db.indexes.orderedChildrenOf.observe(7)((e) => emissions.push([...e]));
358
+ // Move the only child to a new sort key and back within the same
359
+ // microtask window: the bucket is woken but its final sequence is
360
+ // identical, so no second emission.
361
+ db.transactions.move({ entity: a, fractIndex: "z" });
362
+ db.transactions.move({ entity: a, fractIndex: "a" });
363
+ await Promise.resolve();
364
+ expect(emissions).toHaveLength(1);
365
+ unsub();
366
+ });
367
+ it("works on a non-sorted index — membership changes still emit", async () => {
368
+ const db = Database.create(sortedPlugin());
369
+ const a = db.transactions.add({ parent: 7, fractIndex: "a" });
370
+ const emissions = [];
371
+ const unsub = db.indexes.childrenOf.observe(7)((e) => emissions.push([...e].sort()));
372
+ expect(emissions[0]).toEqual([a]);
373
+ const b = db.transactions.add({ parent: 7, fractIndex: "b" });
374
+ await Promise.resolve();
375
+ expect(emissions[emissions.length - 1]).toEqual([a, b].sort());
376
+ unsub();
377
+ });
378
+ const priorityPlugin = () => Database.Plugin.create({
379
+ components: {
380
+ owner: { type: "number" },
381
+ priority: { type: "number" },
382
+ due: { type: "number" },
383
+ },
384
+ archetypes: { Task: ["owner", "priority", "due"] },
385
+ indexes: {
386
+ tasksByPriority: {
387
+ key: "owner",
388
+ order: {
389
+ by: ["priority", "due"],
390
+ compare: (a, b) => b.priority - a.priority || a.due - b.due,
391
+ },
392
+ },
393
+ },
394
+ transactions: {
395
+ add: (t, args) => t.archetypes.Task.insert(args),
396
+ setPriority: (t, args) => t.update(args.entity, { priority: args.priority }),
397
+ setDue: (t, args) => t.update(args.entity, { due: args.due }),
398
+ },
399
+ });
400
+ it("re-sorts under a custom comparator when the primary sort key changes", async () => {
401
+ const db = Database.create(priorityPlugin());
402
+ const low = db.transactions.add({ owner: 1, priority: 1, due: 100 });
403
+ const high = db.transactions.add({ owner: 1, priority: 3, due: 50 });
404
+ const emissions = [];
405
+ const unsub = db.indexes.tasksByPriority.observe(1)((e) => emissions.push([...e]));
406
+ // priority desc → [high, low].
407
+ expect(emissions[0]).toEqual([high, low]);
408
+ db.transactions.setPriority({ entity: low, priority: 5 });
409
+ await Promise.resolve();
410
+ expect(emissions[emissions.length - 1]).toEqual([low, high]);
411
+ unsub();
412
+ });
413
+ it("re-sorts when only the secondary (tie-break) sort component changes", async () => {
414
+ const db = Database.create(priorityPlugin());
415
+ // Equal priority → ordered by `due` asc.
416
+ const early = db.transactions.add({ owner: 1, priority: 2, due: 10 });
417
+ const late = db.transactions.add({ owner: 1, priority: 2, due: 20 });
418
+ const emissions = [];
419
+ const unsub = db.indexes.tasksByPriority.observe(1)((e) => emissions.push([...e]));
420
+ expect(emissions[0]).toEqual([early, late]);
421
+ // Push `early`'s due past `late`'s — flips the tie-break order.
422
+ db.transactions.setDue({ entity: early, due: 30 });
423
+ await Promise.resolve();
424
+ expect(emissions[emissions.length - 1]).toEqual([late, early]);
425
+ unsub();
426
+ });
427
+ });
428
+ describe("Pattern 5 — multi-value (array column → fan-out): tasksByAssignee", () => {
429
+ const plugin = () => Database.Plugin.create({
430
+ components: {
431
+ title: { type: "string" },
432
+ assigned: { type: "array", items: { type: "string" } },
433
+ },
434
+ archetypes: { Task: ["title", "assigned"] },
435
+ indexes: {
436
+ tasksByAssignee: { key: "assigned" },
437
+ },
438
+ transactions: {
439
+ add: (t, args) => t.archetypes.Task.insert(args),
440
+ reassign: (t, args) => t.update(args.entity, { assigned: args.assigned }),
441
+ delete: (t, e) => t.delete(e),
442
+ },
443
+ });
444
+ it("each array element becomes its own bucket entry", () => {
445
+ const db = Database.create(plugin());
446
+ const e = db.transactions.add({ title: "ship", assigned: ["joe", "bob"] });
447
+ expect(db.indexes.tasksByAssignee.find("joe")).toEqual([e]);
448
+ expect(db.indexes.tasksByAssignee.find("bob")).toEqual([e]);
449
+ expect(db.indexes.tasksByAssignee.find("carol")).toEqual([]);
450
+ });
451
+ it("multiple entities sharing an element are both returned", () => {
452
+ const db = Database.create(plugin());
453
+ const a = db.transactions.add({ title: "A", assigned: ["joe", "bob"] });
454
+ const b = db.transactions.add({ title: "B", assigned: ["joe", "carol"] });
455
+ db.transactions.add({ title: "C", assigned: ["diane"] });
456
+ expect([...db.indexes.tasksByAssignee.find("joe")].sort()).toEqual([a, b].sort());
457
+ });
458
+ it("empty array contributes no bucket entries", () => {
459
+ const db = Database.create(plugin());
460
+ db.transactions.add({ title: "Unassigned", assigned: [] });
461
+ expect(db.indexes.tasksByAssignee.find("joe")).toEqual([]);
462
+ });
463
+ it("update set-diffs the bucket membership", () => {
464
+ const db = Database.create(plugin());
465
+ const e = db.transactions.add({ title: "T", assigned: ["joe", "bob"] });
466
+ db.transactions.reassign({ entity: e, assigned: ["joe", "carol"] });
467
+ expect(db.indexes.tasksByAssignee.find("joe")).toEqual([e]);
468
+ expect(db.indexes.tasksByAssignee.find("bob")).toEqual([]);
469
+ expect(db.indexes.tasksByAssignee.find("carol")).toEqual([e]);
470
+ });
471
+ it("delete drops the entity from every bucket it was in", () => {
472
+ const db = Database.create(plugin());
473
+ const e = db.transactions.add({ title: "T", assigned: ["joe", "bob"] });
474
+ db.transactions.delete(e);
475
+ expect(db.indexes.tasksByAssignee.find("joe")).toEqual([]);
476
+ expect(db.indexes.tasksByAssignee.find("bob")).toEqual([]);
477
+ });
478
+ });
479
+ describe("Pattern 6 — computed scalar (byEmailCi)", () => {
480
+ const plugin = () => Database.Plugin.create({
481
+ components: { email: { type: "string" } },
482
+ archetypes: { User: ["email"] },
483
+ indexes: {
484
+ byEmailCi: { key: (email) => email.toLowerCase(), components: ["email"] },
485
+ },
486
+ transactions: {
487
+ add: (t, email) => t.archetypes.User.insert({ email }),
488
+ rename: (t, args) => t.update(args.entity, { email: args.email }),
489
+ delete: (t, e) => t.delete(e),
490
+ },
491
+ });
492
+ it("looks up by the computed (lowercased) key", () => {
493
+ const db = Database.create(plugin());
494
+ const e = db.transactions.add("Alice@Example.com");
495
+ expect(db.indexes.byEmailCi.find("alice@example.com")).toEqual([e]);
496
+ // The original-case input is not a key.
497
+ expect(db.indexes.byEmailCi.find("Alice@Example.com")).toEqual([]);
498
+ });
499
+ it("update re-derives the key", () => {
500
+ const db = Database.create(plugin());
501
+ const e = db.transactions.add("alice@a.com");
502
+ expect(db.indexes.byEmailCi.find("alice@a.com")).toEqual([e]);
503
+ db.transactions.rename({ entity: e, email: "alice@b.com" });
504
+ expect(db.indexes.byEmailCi.find("alice@a.com")).toEqual([]);
505
+ expect(db.indexes.byEmailCi.find("alice@b.com")).toEqual([e]);
506
+ });
507
+ it("delete removes the computed bucket entry", () => {
508
+ const db = Database.create(plugin());
509
+ const e = db.transactions.add("alice@a.com");
510
+ db.transactions.delete(e);
511
+ expect(db.indexes.byEmailCi.find("alice@a.com")).toEqual([]);
512
+ });
513
+ });
514
+ describe("Pattern 7 — multi-value computed (docsByKeyword)", () => {
515
+ const plugin = () => Database.Plugin.create({
516
+ components: { body: { type: "string" } },
517
+ archetypes: { Doc: ["body"] },
518
+ indexes: {
519
+ docsByKeyword: {
520
+ key: (body) => body.toLowerCase().split(/\s+/).filter((s) => s.length > 0),
521
+ components: ["body"],
522
+ },
523
+ },
524
+ transactions: {
525
+ add: (t, body) => t.archetypes.Doc.insert({ body }),
526
+ },
527
+ });
528
+ it("fans out each computed array element into its own bucket", () => {
529
+ const db = Database.create(plugin());
530
+ const d = db.transactions.add("the quick brown fox");
531
+ expect(db.indexes.docsByKeyword.find("quick")).toEqual([d]);
532
+ expect(db.indexes.docsByKeyword.find("brown")).toEqual([d]);
533
+ expect(db.indexes.docsByKeyword.find("missing")).toEqual([]);
534
+ });
535
+ it("findRange supports operator filters on the scalar element", () => {
536
+ const db = Database.create(plugin());
537
+ const d = db.transactions.add("alpha beta gamma");
538
+ const inRange = [...db.indexes.docsByKeyword.findRange({ ">=": "b", "<": "z" })].sort();
539
+ expect(inRange).toEqual([d]);
540
+ });
541
+ });
542
+ describe("Pattern 8 — compound from nested data (playerByRoster)", () => {
543
+ const plugin = () => Database.Plugin.create({
544
+ components: {
545
+ roster: {
546
+ type: "object",
547
+ properties: {
548
+ team: { type: "number" },
549
+ position: { type: "string" },
550
+ },
551
+ required: ["team", "position"],
552
+ },
553
+ name: { type: "string" },
554
+ },
555
+ archetypes: { Player: ["roster", "name"] },
556
+ indexes: {
557
+ playerByRoster: {
558
+ key: {
559
+ team: (r) => r.team,
560
+ position: (r) => r.position,
561
+ },
562
+ unique: true,
563
+ components: ["roster"],
564
+ },
565
+ },
566
+ transactions: {
567
+ add: (t, args) => t.archetypes.Player.insert(args),
568
+ },
569
+ });
570
+ it("get takes a slot-keyed object and returns the entity", () => {
571
+ const db = Database.create(plugin());
572
+ const e = db.transactions.add({
573
+ roster: { team: 1, position: "qb" },
574
+ name: "alice",
575
+ });
576
+ expect(db.indexes.playerByRoster.get({ team: 1, position: "qb" })).toBe(e);
577
+ expect(db.indexes.playerByRoster.get({ team: 1, position: "rb" })).toBeNull();
578
+ });
579
+ });
580
+ describe("Pattern 10 — mixed identity + derived slots (playerByTeamRole)", () => {
581
+ const plugin = () => Database.Plugin.create({
582
+ components: {
583
+ team: { type: "number" },
584
+ roster: {
585
+ type: "object",
586
+ properties: { role: { type: "string" } },
587
+ required: ["role"],
588
+ },
589
+ name: { type: "string" },
590
+ },
591
+ archetypes: { Player: ["team", "roster", "name"] },
592
+ indexes: {
593
+ playerByTeamRole: {
594
+ key: {
595
+ team: "team",
596
+ role: (r) => r.role,
597
+ },
598
+ unique: true,
599
+ components: ["roster"],
600
+ },
601
+ },
602
+ transactions: {
603
+ add: (t, args) => t.archetypes.Player.insert(args),
604
+ },
605
+ });
606
+ it("identity-slot reads the column directly; derived-slot computes from nested data", () => {
607
+ const db = Database.create(plugin());
608
+ const e = db.transactions.add({
609
+ team: 1,
610
+ roster: { role: "qb" },
611
+ name: "alice",
612
+ });
613
+ expect(db.indexes.playerByTeamRole.get({ team: 1, role: "qb" })).toBe(e);
614
+ expect(db.indexes.playerByTeamRole.get({ team: 1, role: "rb" })).toBeNull();
615
+ expect(db.indexes.playerByTeamRole.get({ team: 2, role: "qb" })).toBeNull();
616
+ });
617
+ });
618
+ describe("Pattern 12 — custom comparator (tasksByPriority)", () => {
619
+ const plugin = () => Database.Plugin.create({
620
+ components: {
621
+ owner: { type: "number" },
622
+ priority: { type: "number" },
623
+ due: { type: "number" },
624
+ },
625
+ archetypes: { Task: ["owner", "priority", "due"] },
626
+ indexes: {
627
+ tasksByPriority: {
628
+ key: "owner",
629
+ order: {
630
+ by: ["priority", "due"],
631
+ // priority desc, due asc tie-breaker. `compare`'s `a` / `b`
632
+ // types are not contextually inferred today (the literal
633
+ // `by` tuple does not flow back into the method
634
+ // signature's `Pick<C, By[number]>`), so we annotate
635
+ // explicitly. The compiler still validates that the
636
+ // annotation is a subset of `Pick<C, By[number]>`.
637
+ compare: (a, b) => b.priority - a.priority || a.due - b.due,
638
+ },
639
+ },
640
+ },
641
+ transactions: {
642
+ add: (t, args) => t.archetypes.Task.insert(args),
643
+ },
644
+ });
645
+ it("applies the custom comparator within each bucket", () => {
646
+ const db = Database.create(plugin());
647
+ const e1 = db.transactions.add({ owner: 1, priority: 1, due: 100 });
648
+ const e3 = db.transactions.add({ owner: 1, priority: 3, due: 50 });
649
+ const e2a = db.transactions.add({ owner: 1, priority: 2, due: 10 });
650
+ const e2b = db.transactions.add({ owner: 1, priority: 2, due: 20 });
651
+ // Priority desc, then due asc.
652
+ expect(db.indexes.tasksByPriority.find(1)).toEqual([e3, e2a, e2b, e1]);
653
+ });
654
+ });
655
+ // ============================================================================
656
+ // Cross-cutting concerns
657
+ // ============================================================================
658
+ describe("findRange — operator filters on the bucket key", () => {
659
+ it("filters by range operators on a tuple-keyed index", () => {
660
+ const plugin = Database.Plugin.create({
661
+ components: {
662
+ name: { type: "string" },
663
+ score: { type: "number" },
664
+ },
665
+ archetypes: { User: ["name", "score"] },
666
+ indexes: {
667
+ byNameScore: { key: ["name", "score"] },
668
+ },
669
+ transactions: {
670
+ add: (t, args) => t.archetypes.User.insert(args),
671
+ },
672
+ });
673
+ const db = Database.create(plugin);
674
+ db.transactions.add({ name: "alice", score: 10 });
675
+ const high = db.transactions.add({ name: "alice", score: 20 });
676
+ const higher = db.transactions.add({ name: "alice", score: 50 });
677
+ const hits = [...db.indexes.byNameScore.findRange({ score: { ">=": 20 } })].sort();
678
+ expect(hits).toEqual([high, higher].sort());
679
+ });
680
+ });
681
+ describe("auto-routing of db.select", () => {
682
+ const plugin = () => Database.Plugin.create({
683
+ components: {
684
+ name: { type: "string" },
685
+ email: { type: "string" },
686
+ active: { type: "boolean" },
687
+ },
688
+ archetypes: { User: ["name", "email", "active"] },
689
+ indexes: {
690
+ byName: { key: "name" },
691
+ },
692
+ transactions: {
693
+ add: (t, args) => t.archetypes.User.insert(args),
694
+ },
695
+ });
696
+ it("routes pure equality where to a matching index", () => {
697
+ const db = Database.create(plugin());
698
+ const e1 = db.transactions.add({ name: "alice", email: "a@a.com", active: true });
699
+ const e2 = db.transactions.add({ name: "alice", email: "a@b.com", active: true });
700
+ db.transactions.add({ name: "bob", email: "b@a.com", active: true });
701
+ const idx = db.indexes.byName;
702
+ const original = idx.find;
703
+ let calls = 0;
704
+ idx.find = (v) => { calls += 1; return original(v); };
705
+ try {
706
+ const result = db.select(["name"], { where: { name: "alice" } });
707
+ expect([...result].sort()).toEqual([e1, e2].sort());
708
+ expect(calls).toBe(1);
709
+ }
710
+ finally {
711
+ idx.find = original;
712
+ }
713
+ });
714
+ it("falls back to scan when where keys don't match any index", () => {
715
+ const db = Database.create(plugin());
716
+ db.transactions.add({ name: "alice", email: "a@a.com", active: true });
717
+ db.transactions.add({ name: "alice", email: "a@b.com", active: false });
718
+ const result = db.select(["active"], { where: { active: false } });
719
+ expect(result).toHaveLength(1);
720
+ });
721
+ it("falls back to scan for a non-equality operator (>= on indexed col)", () => {
722
+ const db = Database.create(plugin());
723
+ db.transactions.add({ name: "alice", email: "a@a.com", active: true });
724
+ db.transactions.add({ name: "bob", email: "b@a.com", active: true });
725
+ const idx = db.indexes.byName;
726
+ const original = idx.find;
727
+ let calls = 0;
728
+ idx.find = (v) => { calls += 1; return original(v); };
729
+ try {
730
+ const result = db.select(["name"], { where: { name: { ">=": "b" } } });
731
+ expect(result).toHaveLength(1);
732
+ expect(calls).toBe(0);
733
+ }
734
+ finally {
735
+ idx.find = original;
736
+ }
737
+ });
738
+ it("does not auto-route to a computed (function-key) or slot-map index", () => {
739
+ const computedPlugin = Database.Plugin.create({
740
+ components: { name: { type: "string" } },
741
+ archetypes: { U: ["name"] },
742
+ indexes: {
743
+ byLowerName: {
744
+ key: (name) => name.toLowerCase(),
745
+ components: ["name"],
746
+ },
747
+ },
748
+ transactions: {
749
+ add: (t, name) => t.archetypes.U.insert({ name }),
750
+ },
751
+ });
752
+ const db = Database.create(computedPlugin);
753
+ db.transactions.add("Alice");
754
+ const idx = db.indexes.byLowerName;
755
+ const original = idx.find;
756
+ let calls = 0;
757
+ idx.find = (v) => { calls += 1; return original(v); };
758
+ try {
759
+ // Raw-where equality is in source-column space, not derived-key space.
760
+ const result = db.select(["name"], { where: { name: "Alice" } });
761
+ expect(result).toHaveLength(1);
762
+ expect(calls).toBe(0);
763
+ }
764
+ finally {
765
+ idx.find = original;
766
+ }
767
+ });
768
+ });
769
+ describe("auto-routing of db.observe.select", () => {
770
+ const plugin = () => Database.Plugin.create({
771
+ components: { name: { type: "string" } },
772
+ archetypes: { U: ["name"] },
773
+ indexes: {
774
+ byName: { key: "name" },
775
+ },
776
+ transactions: {
777
+ add: (t, name) => t.archetypes.U.insert({ name }),
778
+ },
779
+ });
780
+ it("routes the initial snapshot through the index", () => {
781
+ const db = Database.create(plugin());
782
+ const e1 = db.transactions.add("alice");
783
+ const e2 = db.transactions.add("alice");
784
+ db.transactions.add("bob");
785
+ const idx = db.indexes.byName;
786
+ const original = idx.find;
787
+ let calls = 0;
788
+ idx.find = (v) => { calls += 1; return original(v); };
789
+ try {
790
+ let emitted = [];
791
+ const unsub = db.observe.select(["name"], { where: { name: "alice" } })((entities) => { emitted = entities; });
792
+ expect([...emitted].sort()).toEqual([e1, e2].sort());
793
+ expect(calls).toBe(1);
794
+ unsub();
795
+ }
796
+ finally {
797
+ idx.find = original;
798
+ }
799
+ });
800
+ it("routes the requery after a relevant transaction", async () => {
801
+ const db = Database.create(plugin());
802
+ const e1 = db.transactions.add("alice");
803
+ db.transactions.add("bob");
804
+ const idx = db.indexes.byName;
805
+ const original = idx.find;
806
+ let calls = 0;
807
+ idx.find = (v) => { calls += 1; return original(v); };
808
+ try {
809
+ const emissions = [];
810
+ const unsub = db.observe.select(["name"], { where: { name: "alice" } })((entities) => { emissions.push([...entities]); });
811
+ expect(calls).toBe(1);
812
+ const e3 = db.transactions.add("alice");
813
+ await Promise.resolve();
814
+ expect(calls).toBe(2);
815
+ expect([...emissions[emissions.length - 1]].sort()).toEqual([e1, e3].sort());
816
+ unsub();
817
+ }
818
+ finally {
819
+ idx.find = original;
820
+ }
821
+ });
822
+ });
823
+ describe("auto-routing of ordered select through a sorted index", () => {
824
+ // Two indexes on the same key, distinct shapes: one unsorted, one sorted
825
+ // ascending by `priority` (default comparator). An ordered query must route
826
+ // to the *sorted* one (never the unsorted one), and a query whose order the
827
+ // index can't serve must fall back to the scan.
828
+ const plugin = () => Database.Plugin.create({
829
+ components: {
830
+ owner: { type: "number" },
831
+ priority: { type: "number" },
832
+ due: { type: "number" },
833
+ title: { type: "string" },
834
+ },
835
+ archetypes: { Task: ["owner", "priority", "due", "title"] },
836
+ indexes: {
837
+ byOwner: { key: "owner" },
838
+ byOwnerSorted: { key: "owner", order: { by: ["priority"] } },
839
+ },
840
+ transactions: {
841
+ add: (t, a) => t.archetypes.Task.insert(a),
842
+ },
843
+ });
844
+ const spyFind = (handle) => {
845
+ const original = handle.find;
846
+ const state = { calls: 0 };
847
+ handle.find = (v) => { state.calls += 1; return original(v); };
848
+ return { state, restore: () => { handle.find = original; } };
849
+ };
850
+ const seed = (db) => {
851
+ const mid = db.transactions.add({ owner: 1, priority: 2, due: 10, title: "mid" });
852
+ const low = db.transactions.add({ owner: 1, priority: 1, due: 20, title: "low" });
853
+ const high = db.transactions.add({ owner: 1, priority: 3, due: 30, title: "high" });
854
+ db.transactions.add({ owner: 2, priority: 1, due: 40, title: "other" });
855
+ return { low, mid, high };
856
+ };
857
+ it("routes an ascending ordered query to the sorted index (and returns it sorted)", () => {
858
+ const db = Database.create(plugin());
859
+ const { low, mid, high } = seed(db);
860
+ const sorted = spyFind(db.indexes.byOwnerSorted);
861
+ try {
862
+ const result = db.select(["owner", "priority"], { where: { owner: 1 }, order: { priority: true } });
863
+ // GREEN: the ordered query was served by the sorted index...
864
+ expect(sorted.state.calls).toBe(1);
865
+ // ...and the result is in the index's ascending priority order.
866
+ expect([...result]).toEqual([low, mid, high]);
867
+ }
868
+ finally {
869
+ sorted.restore();
870
+ }
871
+ });
872
+ it("routes the ordered query to the sorted index, never the unsorted one on the same key", () => {
873
+ const db = Database.create(plugin());
874
+ seed(db);
875
+ const unsorted = spyFind(db.indexes.byOwner);
876
+ const sorted = spyFind(db.indexes.byOwnerSorted);
877
+ try {
878
+ db.select(["owner", "priority"], { where: { owner: 1 }, order: { priority: true } });
879
+ expect(sorted.state.calls).toBe(1);
880
+ expect(unsorted.state.calls).toBe(0);
881
+ }
882
+ finally {
883
+ unsorted.restore();
884
+ sorted.restore();
885
+ }
886
+ });
887
+ it("falls back to scan for a descending order (sorted index only materializes ascending)", () => {
888
+ const db = Database.create(plugin());
889
+ const { low, mid, high } = seed(db);
890
+ const sorted = spyFind(db.indexes.byOwnerSorted);
891
+ try {
892
+ const result = db.select(["owner", "priority"], { where: { owner: 1 }, order: { priority: false } });
893
+ // Not routed — the index can't serve descending without re-sorting.
894
+ expect(sorted.state.calls).toBe(0);
895
+ // The scan still produces a correct descending result.
896
+ expect([...result]).toEqual([high, mid, low]);
897
+ }
898
+ finally {
899
+ sorted.restore();
900
+ }
901
+ });
902
+ it("falls back to scan when the requested order column is not the index's sort column", () => {
903
+ const db = Database.create(plugin());
904
+ seed(db);
905
+ const sorted = spyFind(db.indexes.byOwnerSorted);
906
+ try {
907
+ // Index sorts by `priority`; this asks for `due`.
908
+ const result = db.select(["owner", "due"], { where: { owner: 1 }, order: { due: true } });
909
+ expect(sorted.state.calls).toBe(0);
910
+ expect(result).toHaveLength(3);
911
+ }
912
+ finally {
913
+ sorted.restore();
914
+ }
915
+ });
916
+ it("still routes a plain (no-order) equality query — unaffected by the order support", () => {
917
+ const db = Database.create(plugin());
918
+ seed(db);
919
+ // No order: the first matching key index (byOwner) serves it.
920
+ const unsorted = spyFind(db.indexes.byOwner);
921
+ try {
922
+ const result = db.select(["owner"], { where: { owner: 1 } });
923
+ expect(unsorted.state.calls).toBe(1);
924
+ expect(result).toHaveLength(3);
925
+ }
926
+ finally {
927
+ unsorted.restore();
928
+ }
929
+ });
930
+ it("routes the ordered query through db.observe.select too (initial snapshot + requery)", async () => {
931
+ const db = Database.create(plugin());
932
+ const { low, mid, high } = seed(db);
933
+ const sorted = spyFind(db.indexes.byOwnerSorted);
934
+ try {
935
+ const emissions = [];
936
+ const unsub = db.observe.select(["owner", "priority"], { where: { owner: 1 }, order: { priority: true } })((entities) => { emissions.push([...entities]); });
937
+ // Initial snapshot routed and sorted.
938
+ expect(sorted.state.calls).toBe(1);
939
+ expect(emissions[0]).toEqual([low, mid, high]);
940
+ // A new task for owner 1 with the lowest priority sorts to the front.
941
+ const lowest = db.transactions.add({ owner: 1, priority: 0, due: 5, title: "lowest" });
942
+ await Promise.resolve();
943
+ expect(sorted.state.calls).toBe(2);
944
+ expect(emissions[emissions.length - 1]).toEqual([lowest, low, mid, high]);
945
+ unsub();
946
+ }
947
+ finally {
948
+ sorted.restore();
949
+ }
950
+ });
951
+ });
952
+ describe("t.indexes — eager maintenance inside transactions", () => {
953
+ const plugin = () => Database.Plugin.create({
954
+ components: {
955
+ name: { type: "string" },
956
+ email: { type: "string" },
957
+ },
958
+ archetypes: { User: ["name", "email"] },
959
+ indexes: {
960
+ byName: { key: "name" },
961
+ uniqueByEmail: { key: "email", unique: true },
962
+ },
963
+ transactions: {
964
+ add: (t, args) => t.archetypes.User.insert(args),
965
+ addIfNew: (t, args) => {
966
+ const existing = t.indexes.uniqueByEmail.get(args.email);
967
+ if (existing !== null)
968
+ return existing;
969
+ return t.archetypes.User.insert(args);
970
+ },
971
+ renameAndLookup: (t, args) => {
972
+ t.update(args.entity, { name: args.newName });
973
+ const found = t.indexes.byName.find(args.newName);
974
+ if (!found.includes(args.entity)) {
975
+ throw new Error("name index stale after t.update");
976
+ }
977
+ },
978
+ deleteAndLookup: (t, args) => {
979
+ t.delete(args.entity);
980
+ const stillThere = t.indexes.byName.find(args.name);
981
+ if (stillThere.includes(args.entity)) {
982
+ throw new Error("index still references deleted entity");
983
+ }
984
+ },
985
+ updateEmail: (t, args) => t.update(args.entity, { email: args.newEmail }),
986
+ },
987
+ });
988
+ it("get on a unique index sees a row inserted earlier in the same transaction", () => {
989
+ const db = Database.create(plugin());
990
+ const e = db.transactions.addIfNew({ name: "alice", email: "a@a.com" });
991
+ // A second call finds the just-inserted row.
992
+ const same = db.transactions.addIfNew({ name: "alice2", email: "a@a.com" });
993
+ expect(same).toBe(e);
994
+ });
995
+ it("find sees an update made earlier in the same transaction body", () => {
996
+ const db = Database.create(plugin());
997
+ const e = db.transactions.add({ name: "alice", email: "a@a.com" });
998
+ expect(() => db.transactions.renameAndLookup({ entity: e, newName: "alex" })).not.toThrow();
999
+ expect(db.indexes.byName.find("alex")).toEqual([e]);
1000
+ expect(db.indexes.byName.find("alice")).toEqual([]);
1001
+ });
1002
+ it("find does not see a row deleted earlier in the same transaction body", () => {
1003
+ const db = Database.create(plugin());
1004
+ const e = db.transactions.add({ name: "alice", email: "a@a.com" });
1005
+ expect(() => db.transactions.deleteAndLookup({ entity: e, name: "alice" })).not.toThrow();
1006
+ expect(db.indexes.byName.find("alice")).toEqual([]);
1007
+ });
1008
+ it("unique conflict on insert is caught up-front — no partial store or index mutation", () => {
1009
+ const db = Database.create(plugin());
1010
+ const first = db.transactions.add({ name: "alice", email: "shared@x.com" });
1011
+ // Snapshot pre-throw state so we can assert no drift.
1012
+ const beforeRows = db.select(["email"]);
1013
+ expect(() => db.transactions.add({ name: "alex", email: "shared@x.com" })).toThrow(/Unique index conflict/);
1014
+ // The unique-key bucket still points at the original entity.
1015
+ expect(db.indexes.uniqueByEmail.get("shared@x.com")).toBe(first);
1016
+ // No phantom row landed in either index or store. byName.find("alex")
1017
+ // returning [] proves the secondary index never saw the row;
1018
+ // db.select asserts the store itself never grew.
1019
+ expect(db.indexes.byName.find("alex")).toEqual([]);
1020
+ const afterRows = db.select(["email"]);
1021
+ expect([...afterRows].sort()).toEqual([...beforeRows].sort());
1022
+ });
1023
+ it("unique conflict on update is caught up-front — both stores stay consistent", () => {
1024
+ const db = Database.create(plugin());
1025
+ const e1 = db.transactions.add({ name: "alice", email: "a@a.com" });
1026
+ const e2 = db.transactions.add({ name: "bob", email: "b@b.com" });
1027
+ // Snapshot pre-throw state.
1028
+ const e1BeforeRead = db.read(e1);
1029
+ const e2BeforeRead = db.read(e2);
1030
+ expect(() => db.transactions.updateEmail({ entity: e1, newEmail: "b@b.com" })).toThrow(/Unique index conflict/);
1031
+ // Index untouched on both keys.
1032
+ expect(db.indexes.uniqueByEmail.get("a@a.com")).toBe(e1);
1033
+ expect(db.indexes.uniqueByEmail.get("b@b.com")).toBe(e2);
1034
+ // Underlying store untouched — e1's email is still "a@a.com",
1035
+ // proving the pre-check fired before `core.update` ran.
1036
+ expect(db.read(e1)).toEqual(e1BeforeRead);
1037
+ expect(db.read(e2)).toEqual(e2BeforeRead);
1038
+ });
1039
+ });
1040
+ describe("registration rules", () => {
1041
+ it("extending twice with the same plugin instance is a no-op", () => {
1042
+ const plugin = Database.Plugin.create({
1043
+ components: { name: { type: "string" } },
1044
+ archetypes: { N: ["name"] },
1045
+ indexes: { byName: { key: "name" } },
1046
+ transactions: { add: (t, name) => t.archetypes.N.insert({ name }) },
1047
+ });
1048
+ const db = Database.create(plugin);
1049
+ expect(() => db.extend(plugin)).not.toThrow();
1050
+ expect(Object.keys(db.indexes)).toEqual(["byName"]);
1051
+ });
1052
+ it("combining plugins via Plugin.combine with a shared index decl registers once", () => {
1053
+ const components = { email: { type: "string" } };
1054
+ const shared = { key: "email", unique: true };
1055
+ const a = Database.Plugin.create({ components, indexes: { byEmail: shared } });
1056
+ const b = Database.Plugin.create({ components, indexes: { byEmail: shared } });
1057
+ const combined = Database.Plugin.combine(a, b);
1058
+ const db = Database.create(combined);
1059
+ expect(Object.keys(db.indexes)).toEqual(["byEmail"]);
1060
+ });
1061
+ it("distinct names with distinct shapes both register", () => {
1062
+ const plugin = Database.Plugin.create({
1063
+ components: {
1064
+ name: { type: "string" },
1065
+ email: { type: "string" },
1066
+ },
1067
+ indexes: {
1068
+ byName: { key: "name" },
1069
+ byEmail: { key: "email" },
1070
+ },
1071
+ });
1072
+ const db = Database.create(plugin);
1073
+ expect(Object.keys(db.indexes).sort()).toEqual(["byEmail", "byName"]);
1074
+ });
1075
+ it("same name with different declaration objects across plugins throws", () => {
1076
+ const components = { email: { type: "string" } };
1077
+ const a = Database.Plugin.create({
1078
+ components,
1079
+ indexes: { byEmail: { key: "email", unique: true } },
1080
+ });
1081
+ const b = Database.Plugin.create({
1082
+ components,
1083
+ // Structurally identical, but a different object identity.
1084
+ indexes: { byEmail: { key: "email", unique: true } },
1085
+ });
1086
+ const db = Database.create(a);
1087
+ expect(() => db.extend(b)).toThrow(/different declaration object/);
1088
+ });
1089
+ it("different names with identical shape across plugins throws", () => {
1090
+ const components = { email: { type: "string" } };
1091
+ const a = Database.Plugin.create({
1092
+ components,
1093
+ indexes: { byEmail: { key: "email", unique: true } },
1094
+ });
1095
+ const b = Database.Plugin.create({
1096
+ components,
1097
+ indexes: { lookupByEmail: { key: "email", unique: true } },
1098
+ });
1099
+ const db = Database.create(a);
1100
+ expect(() => db.extend(b)).toThrow(/identical shape/);
1101
+ });
1102
+ it("identical shape inside a single plugin throws", () => {
1103
+ const plugin = Database.Plugin.create({
1104
+ components: { email: { type: "string" } },
1105
+ indexes: {
1106
+ byEmail: { key: "email", unique: true },
1107
+ lookupByEmail: { key: "email", unique: true },
1108
+ },
1109
+ });
1110
+ expect(() => Database.create(plugin)).toThrow(/identical shape/);
1111
+ });
1112
+ it("same key tuple with different `unique` flag is NOT a structural duplicate", () => {
1113
+ const plugin = Database.Plugin.create({
1114
+ components: { email: { type: "string" } },
1115
+ indexes: {
1116
+ byEmailLookup: { key: "email" },
1117
+ uniqueByEmail: { key: "email", unique: true },
1118
+ },
1119
+ });
1120
+ const db = Database.create(plugin);
1121
+ expect(Object.keys(db.indexes).sort()).toEqual(["byEmailLookup", "uniqueByEmail"]);
1122
+ });
1123
+ });
1124
+ describe("registry maintenance", () => {
1125
+ it("populates from existing entities when a plugin registers an index later", () => {
1126
+ const base = Database.Plugin.create({
1127
+ components: { name: { type: "string" } },
1128
+ archetypes: { N: ["name"] },
1129
+ transactions: { add: (t, name) => t.archetypes.N.insert({ name }) },
1130
+ });
1131
+ const indexed = Database.Plugin.create({
1132
+ extends: base,
1133
+ indexes: { byName: { key: "name" } },
1134
+ });
1135
+ const db = Database.create(base);
1136
+ const e1 = db.transactions.add("alice");
1137
+ const e2 = db.transactions.add("alice");
1138
+ const ext = db.extend(indexed);
1139
+ expect([...ext.indexes.byName.find("alice")].sort()).toEqual([e1, e2].sort());
1140
+ });
1141
+ });
1142
+ //# sourceMappingURL=database.index.test.js.map