@adobe/data 0.9.61 → 0.9.63
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.
- package/dist/ecs/archetype/archetype.d.ts +9 -2
- package/dist/ecs/archetype/archetype.js.map +1 -1
- package/dist/ecs/archetype/create-archetype.js +5 -2
- package/dist/ecs/archetype/create-archetype.js.map +1 -1
- package/dist/ecs/database/concurrency/concurrency-strategy.d.ts +86 -0
- package/dist/ecs/database/concurrency/concurrency-strategy.js +3 -0
- package/dist/ecs/database/concurrency/concurrency-strategy.js.map +1 -0
- package/dist/ecs/database/concurrency/immediate-concurrency.d.ts +22 -0
- package/dist/ecs/database/concurrency/immediate-concurrency.js +74 -0
- package/dist/ecs/database/concurrency/immediate-concurrency.js.map +1 -0
- package/dist/ecs/database/concurrency/index.d.ts +4 -0
- package/dist/ecs/database/concurrency/index.js +5 -0
- package/dist/ecs/database/concurrency/index.js.map +1 -0
- package/dist/ecs/database/concurrency/rebase-replay-concurrency.d.ts +20 -0
- package/dist/ecs/database/concurrency/rebase-replay-concurrency.js +34 -0
- package/dist/ecs/database/concurrency/rebase-replay-concurrency.js.map +1 -0
- package/dist/ecs/database/concurrency/roll-forward-concurrency.d.ts +33 -0
- package/dist/ecs/database/concurrency/roll-forward-concurrency.js +115 -0
- package/dist/ecs/database/concurrency/roll-forward-concurrency.js.map +1 -0
- package/dist/ecs/database/concurrency/roll-forward-concurrency.test.d.ts +1 -0
- package/dist/ecs/database/concurrency/roll-forward-concurrency.test.js +136 -0
- package/dist/ecs/database/concurrency/roll-forward-concurrency.test.js.map +1 -0
- package/dist/ecs/database/create-plugin.d.ts +1 -1
- package/dist/ecs/database/database.d.ts +29 -7
- package/dist/ecs/database/database.index.performance.test.js +5 -5
- package/dist/ecs/database/database.index.performance.test.js.map +1 -1
- package/dist/ecs/database/database.index.test.js +556 -51
- package/dist/ecs/database/database.index.test.js.map +1 -1
- package/dist/ecs/database/database.index.type-test.js +34 -10
- package/dist/ecs/database/database.index.type-test.js.map +1 -1
- package/dist/ecs/database/database.js.map +1 -1
- package/dist/ecs/database/database.reset.test.js +2 -1
- package/dist/ecs/database/database.reset.test.js.map +1 -1
- package/dist/ecs/database/index-registry/create-index-registry.d.ts +38 -9
- package/dist/ecs/database/index-registry/create-index-registry.js +67 -20
- package/dist/ecs/database/index-registry/create-index-registry.js.map +1 -1
- package/dist/ecs/database/index-registry/create-index.d.ts +10 -2
- package/dist/ecs/database/index-registry/create-index.js +44 -47
- package/dist/ecs/database/index-registry/create-index.js.map +1 -1
- package/dist/ecs/database/index.d.ts +1 -0
- package/dist/ecs/database/index.js +1 -0
- package/dist/ecs/database/index.js.map +1 -1
- package/dist/ecs/database/observe-index-entities.d.ts +26 -0
- package/dist/ecs/database/observe-index-entities.js +66 -0
- package/dist/ecs/database/observe-index-entities.js.map +1 -0
- package/dist/ecs/database/observed/create-observed-database.js +1 -1
- package/dist/ecs/database/observed/create-observed-database.js.map +1 -1
- package/dist/ecs/database/observed/observed-database.d.ts +1 -1
- package/dist/ecs/database/public/create-database.d.ts +12 -27
- package/dist/ecs/database/public/create-database.js +131 -37
- package/dist/ecs/database/public/create-database.js.map +1 -1
- package/dist/ecs/database/public/create-database.test.js +8 -7
- package/dist/ecs/database/public/create-database.test.js.map +1 -1
- package/dist/ecs/database/public/create-transaction-dispatcher.d.ts +4 -2
- package/dist/ecs/database/public/create-transaction-dispatcher.js +2 -8
- package/dist/ecs/database/public/create-transaction-dispatcher.js.map +1 -1
- package/dist/ecs/database/reconciling/create-rebase-replay-applier.d.ts +26 -0
- package/dist/ecs/database/reconciling/create-rebase-replay-applier.js +121 -0
- package/dist/ecs/database/reconciling/create-rebase-replay-applier.js.map +1 -0
- package/dist/ecs/database/reconciling/create-reconciling-database.d.ts +11 -0
- package/dist/ecs/database/reconciling/create-reconciling-database.js +28 -137
- package/dist/ecs/database/reconciling/create-reconciling-database.js.map +1 -1
- package/dist/ecs/database/transactional-store/create-transactional-store.js.map +1 -1
- package/dist/ecs/database/transactional-store/transactional-store.d.ts +1 -1
- package/dist/ecs/entity-location-table/create-entity-location-table.js +2 -2
- package/dist/ecs/entity-location-table/create-entity-location-table.js.map +1 -1
- package/dist/ecs/entity-location-table/entity-location-table.d.ts +7 -1
- package/dist/ecs/store/core/core.d.ts +7 -1
- package/dist/ecs/store/core/create-core.js +3 -3
- package/dist/ecs/store/core/create-core.js.map +1 -1
- package/dist/ecs/store/core/select-entities.js +6 -3
- package/dist/ecs/store/core/select-entities.js.map +1 -1
- package/dist/ecs/store/index-types.d.ts +53 -17
- package/dist/ecs/store/public/create-store.js +32 -6
- package/dist/ecs/store/public/create-store.js.map +1 -1
- package/dist/ecs/store/public/create-store.test.js +25 -0
- package/dist/ecs/store/public/create-store.test.js.map +1 -1
- package/dist/ecs/store/store.d.ts +10 -3
- package/dist/ecs/store/store.js.map +1 -1
- package/dist/functions/compare.d.ts +13 -0
- package/dist/functions/compare.js +15 -0
- package/dist/functions/compare.js.map +1 -0
- package/dist/functions/compare.test.d.ts +1 -0
- package/dist/functions/compare.test.js +24 -0
- package/dist/functions/compare.test.js.map +1 -0
- package/dist/functions/index.d.ts +1 -0
- package/dist/functions/index.js +1 -0
- package/dist/functions/index.js.map +1 -1
- package/dist/test-setup.js +2 -2
- package/dist/test-setup.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -1
- package/references/data-lit/package.json +1 -1
- package/references/data-lit-tictactoe/package.json +1 -1
- package/references/data-react/package.json +1 -1
- package/references/data-react-hello/package.json +1 -1
- package/references/data-react-pixie/package.json +1 -1
|
@@ -21,17 +21,17 @@ describe("Pattern 1 — single-column unique lookup (byEmail)", () => {
|
|
|
21
21
|
it("get returns the entity for a known scalar key", () => {
|
|
22
22
|
const db = Database.create(plugin());
|
|
23
23
|
const e = db.transactions.add("alice@a.com");
|
|
24
|
-
expect(db.indexes.byEmail.get("alice@a.com")).toBe(e);
|
|
24
|
+
expect(db.indexes.byEmail.get({ email: "alice@a.com" })).toBe(e);
|
|
25
25
|
});
|
|
26
26
|
it("get returns null for an unknown key (known absent)", () => {
|
|
27
27
|
const db = Database.create(plugin());
|
|
28
|
-
expect(db.indexes.byEmail.get("missing@x.com")).toBeNull();
|
|
28
|
+
expect(db.indexes.byEmail.get({ email: "missing@x.com" })).toBeNull();
|
|
29
29
|
});
|
|
30
30
|
it("duplicate insert throws atomically — original row stays intact", () => {
|
|
31
31
|
const db = Database.create(plugin());
|
|
32
32
|
const first = db.transactions.add("shared@x.com");
|
|
33
33
|
expect(() => db.transactions.add("shared@x.com")).toThrow(/Unique index conflict/);
|
|
34
|
-
expect(db.indexes.byEmail.get("shared@x.com")).toBe(first);
|
|
34
|
+
expect(db.indexes.byEmail.get({ email: "shared@x.com" })).toBe(first);
|
|
35
35
|
});
|
|
36
36
|
});
|
|
37
37
|
describe("Pattern 2 — multi-column compound unique (playerSlot)", () => {
|
|
@@ -84,21 +84,21 @@ describe("Pattern 3 — non-unique by single column (childrenOf)", () => {
|
|
|
84
84
|
const a = db.transactions.add({ parent: 7, label: "a" });
|
|
85
85
|
const b = db.transactions.add({ parent: 7, label: "b" });
|
|
86
86
|
db.transactions.add({ parent: 9, label: "c" });
|
|
87
|
-
expect([...db.indexes.childrenOf.find(7)].sort()).toEqual([a, b].sort());
|
|
87
|
+
expect([...db.indexes.childrenOf.find({ parent: 7 })].sort()).toEqual([a, b].sort());
|
|
88
88
|
});
|
|
89
89
|
it("delete removes the entity from the bucket", () => {
|
|
90
90
|
const db = Database.create(plugin());
|
|
91
91
|
const e = db.transactions.add({ parent: 7, label: "a" });
|
|
92
|
-
expect(db.indexes.childrenOf.find(7)).toEqual([e]);
|
|
92
|
+
expect(db.indexes.childrenOf.find({ parent: 7 })).toEqual([e]);
|
|
93
93
|
db.transactions.delete(e);
|
|
94
|
-
expect(db.indexes.childrenOf.find(7)).toEqual([]);
|
|
94
|
+
expect(db.indexes.childrenOf.find({ parent: 7 })).toEqual([]);
|
|
95
95
|
});
|
|
96
96
|
it("update moves the entity between buckets", () => {
|
|
97
97
|
const db = Database.create(plugin());
|
|
98
98
|
const e = db.transactions.add({ parent: 7, label: "a" });
|
|
99
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]);
|
|
100
|
+
expect(db.indexes.childrenOf.find({ parent: 7 })).toEqual([]);
|
|
101
|
+
expect(db.indexes.childrenOf.find({ parent: 9 })).toEqual([e]);
|
|
102
102
|
});
|
|
103
103
|
it("`get` is absent on non-unique handles at runtime and types", () => {
|
|
104
104
|
const db = Database.create(plugin());
|
|
@@ -129,7 +129,7 @@ describe("Pattern 4 — sorted children (orderedChildrenOf)", () => {
|
|
|
129
129
|
const c = db.transactions.add({ parent: 7, fractIndex: "c" });
|
|
130
130
|
const a = db.transactions.add({ parent: 7, fractIndex: "a" });
|
|
131
131
|
const b = db.transactions.add({ parent: 7, fractIndex: "b" });
|
|
132
|
-
expect(db.indexes.orderedChildrenOf.find(7)).toEqual([a, b, c]);
|
|
132
|
+
expect(db.indexes.orderedChildrenOf.find({ parent: 7 })).toEqual([a, b, c]);
|
|
133
133
|
});
|
|
134
134
|
it("update on the sort key repositions the entity in place", () => {
|
|
135
135
|
const db = Database.create(plugin());
|
|
@@ -137,9 +137,9 @@ describe("Pattern 4 — sorted children (orderedChildrenOf)", () => {
|
|
|
137
137
|
const b = db.transactions.add({ parent: 7, fractIndex: "b" });
|
|
138
138
|
const c = db.transactions.add({ parent: 7, fractIndex: "c" });
|
|
139
139
|
db.transactions.move({ entity: b, fractIndex: "z" });
|
|
140
|
-
expect(db.indexes.orderedChildrenOf.find(7)).toEqual([a, c, b]);
|
|
140
|
+
expect(db.indexes.orderedChildrenOf.find({ parent: 7 })).toEqual([a, c, b]);
|
|
141
141
|
db.transactions.move({ entity: a, fractIndex: "d" });
|
|
142
|
-
expect(db.indexes.orderedChildrenOf.find(7)).toEqual([c, a, b]);
|
|
142
|
+
expect(db.indexes.orderedChildrenOf.find({ parent: 7 })).toEqual([c, a, b]);
|
|
143
143
|
});
|
|
144
144
|
it("delete preserves sort order of the remaining entities", () => {
|
|
145
145
|
const db = Database.create(plugin());
|
|
@@ -147,7 +147,7 @@ describe("Pattern 4 — sorted children (orderedChildrenOf)", () => {
|
|
|
147
147
|
const b = db.transactions.add({ parent: 7, fractIndex: "b" });
|
|
148
148
|
const c = db.transactions.add({ parent: 7, fractIndex: "c" });
|
|
149
149
|
db.transactions.delete(b);
|
|
150
|
-
expect(db.indexes.orderedChildrenOf.find(7)).toEqual([a, c]);
|
|
150
|
+
expect(db.indexes.orderedChildrenOf.find({ parent: 7 })).toEqual([a, c]);
|
|
151
151
|
});
|
|
152
152
|
it("populates and sorts when the index is registered after data exists", () => {
|
|
153
153
|
const base = Database.Plugin.create({
|
|
@@ -171,7 +171,258 @@ describe("Pattern 4 — sorted children (orderedChildrenOf)", () => {
|
|
|
171
171
|
},
|
|
172
172
|
});
|
|
173
173
|
const ext = db.extend(indexed);
|
|
174
|
-
expect(ext.indexes.orderedChildrenOf.find(5)).toEqual([a, b, c]);
|
|
174
|
+
expect(ext.indexes.orderedChildrenOf.find({ parent: 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({ parent: 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({ parent: 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({ parent: 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({ parent: 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({ parent: 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({ parent: 7 })((e) => fromSeven.push([...e]));
|
|
267
|
+
const unsub9 = db.indexes.orderedChildrenOf.observe({ parent: 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({ parent: 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({ parent: 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({ parent: 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({ parent: 7 })((e) => first.push([...e]));
|
|
330
|
+
const unsub2 = db.indexes.orderedChildrenOf.observe({ parent: 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({ parent: 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({ parent: 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({ parent: 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({ owner: 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({ owner: 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();
|
|
175
426
|
});
|
|
176
427
|
});
|
|
177
428
|
describe("Pattern 5 — multi-value (array column → fan-out): tasksByAssignee", () => {
|
|
@@ -193,36 +444,36 @@ describe("Pattern 5 — multi-value (array column → fan-out): tasksByAssignee"
|
|
|
193
444
|
it("each array element becomes its own bucket entry", () => {
|
|
194
445
|
const db = Database.create(plugin());
|
|
195
446
|
const e = db.transactions.add({ title: "ship", assigned: ["joe", "bob"] });
|
|
196
|
-
expect(db.indexes.tasksByAssignee.find("joe")).toEqual([e]);
|
|
197
|
-
expect(db.indexes.tasksByAssignee.find("bob")).toEqual([e]);
|
|
198
|
-
expect(db.indexes.tasksByAssignee.find("carol")).toEqual([]);
|
|
447
|
+
expect(db.indexes.tasksByAssignee.find({ assigned: "joe" })).toEqual([e]);
|
|
448
|
+
expect(db.indexes.tasksByAssignee.find({ assigned: "bob" })).toEqual([e]);
|
|
449
|
+
expect(db.indexes.tasksByAssignee.find({ assigned: "carol" })).toEqual([]);
|
|
199
450
|
});
|
|
200
451
|
it("multiple entities sharing an element are both returned", () => {
|
|
201
452
|
const db = Database.create(plugin());
|
|
202
453
|
const a = db.transactions.add({ title: "A", assigned: ["joe", "bob"] });
|
|
203
454
|
const b = db.transactions.add({ title: "B", assigned: ["joe", "carol"] });
|
|
204
455
|
db.transactions.add({ title: "C", assigned: ["diane"] });
|
|
205
|
-
expect([...db.indexes.tasksByAssignee.find("joe")].sort()).toEqual([a, b].sort());
|
|
456
|
+
expect([...db.indexes.tasksByAssignee.find({ assigned: "joe" })].sort()).toEqual([a, b].sort());
|
|
206
457
|
});
|
|
207
458
|
it("empty array contributes no bucket entries", () => {
|
|
208
459
|
const db = Database.create(plugin());
|
|
209
460
|
db.transactions.add({ title: "Unassigned", assigned: [] });
|
|
210
|
-
expect(db.indexes.tasksByAssignee.find("joe")).toEqual([]);
|
|
461
|
+
expect(db.indexes.tasksByAssignee.find({ assigned: "joe" })).toEqual([]);
|
|
211
462
|
});
|
|
212
463
|
it("update set-diffs the bucket membership", () => {
|
|
213
464
|
const db = Database.create(plugin());
|
|
214
465
|
const e = db.transactions.add({ title: "T", assigned: ["joe", "bob"] });
|
|
215
466
|
db.transactions.reassign({ entity: e, assigned: ["joe", "carol"] });
|
|
216
|
-
expect(db.indexes.tasksByAssignee.find("joe")).toEqual([e]);
|
|
217
|
-
expect(db.indexes.tasksByAssignee.find("bob")).toEqual([]);
|
|
218
|
-
expect(db.indexes.tasksByAssignee.find("carol")).toEqual([e]);
|
|
467
|
+
expect(db.indexes.tasksByAssignee.find({ assigned: "joe" })).toEqual([e]);
|
|
468
|
+
expect(db.indexes.tasksByAssignee.find({ assigned: "bob" })).toEqual([]);
|
|
469
|
+
expect(db.indexes.tasksByAssignee.find({ assigned: "carol" })).toEqual([e]);
|
|
219
470
|
});
|
|
220
471
|
it("delete drops the entity from every bucket it was in", () => {
|
|
221
472
|
const db = Database.create(plugin());
|
|
222
473
|
const e = db.transactions.add({ title: "T", assigned: ["joe", "bob"] });
|
|
223
474
|
db.transactions.delete(e);
|
|
224
|
-
expect(db.indexes.tasksByAssignee.find("joe")).toEqual([]);
|
|
225
|
-
expect(db.indexes.tasksByAssignee.find("bob")).toEqual([]);
|
|
475
|
+
expect(db.indexes.tasksByAssignee.find({ assigned: "joe" })).toEqual([]);
|
|
476
|
+
expect(db.indexes.tasksByAssignee.find({ assigned: "bob" })).toEqual([]);
|
|
226
477
|
});
|
|
227
478
|
});
|
|
228
479
|
describe("Pattern 6 — computed scalar (byEmailCi)", () => {
|
|
@@ -230,7 +481,7 @@ describe("Pattern 6 — computed scalar (byEmailCi)", () => {
|
|
|
230
481
|
components: { email: { type: "string" } },
|
|
231
482
|
archetypes: { User: ["email"] },
|
|
232
483
|
indexes: {
|
|
233
|
-
byEmailCi: { key: (
|
|
484
|
+
byEmailCi: { key: { email: (c) => c.email.toLowerCase() }, components: ["email"] },
|
|
234
485
|
},
|
|
235
486
|
transactions: {
|
|
236
487
|
add: (t, email) => t.archetypes.User.insert({ email }),
|
|
@@ -241,23 +492,23 @@ describe("Pattern 6 — computed scalar (byEmailCi)", () => {
|
|
|
241
492
|
it("looks up by the computed (lowercased) key", () => {
|
|
242
493
|
const db = Database.create(plugin());
|
|
243
494
|
const e = db.transactions.add("Alice@Example.com");
|
|
244
|
-
expect(db.indexes.byEmailCi.find("alice@example.com")).toEqual([e]);
|
|
495
|
+
expect(db.indexes.byEmailCi.find({ email: "alice@example.com" })).toEqual([e]);
|
|
245
496
|
// The original-case input is not a key.
|
|
246
|
-
expect(db.indexes.byEmailCi.find("Alice@Example.com")).toEqual([]);
|
|
497
|
+
expect(db.indexes.byEmailCi.find({ email: "Alice@Example.com" })).toEqual([]);
|
|
247
498
|
});
|
|
248
499
|
it("update re-derives the key", () => {
|
|
249
500
|
const db = Database.create(plugin());
|
|
250
501
|
const e = db.transactions.add("alice@a.com");
|
|
251
|
-
expect(db.indexes.byEmailCi.find("alice@a.com")).toEqual([e]);
|
|
502
|
+
expect(db.indexes.byEmailCi.find({ email: "alice@a.com" })).toEqual([e]);
|
|
252
503
|
db.transactions.rename({ entity: e, email: "alice@b.com" });
|
|
253
|
-
expect(db.indexes.byEmailCi.find("alice@a.com")).toEqual([]);
|
|
254
|
-
expect(db.indexes.byEmailCi.find("alice@b.com")).toEqual([e]);
|
|
504
|
+
expect(db.indexes.byEmailCi.find({ email: "alice@a.com" })).toEqual([]);
|
|
505
|
+
expect(db.indexes.byEmailCi.find({ email: "alice@b.com" })).toEqual([e]);
|
|
255
506
|
});
|
|
256
507
|
it("delete removes the computed bucket entry", () => {
|
|
257
508
|
const db = Database.create(plugin());
|
|
258
509
|
const e = db.transactions.add("alice@a.com");
|
|
259
510
|
db.transactions.delete(e);
|
|
260
|
-
expect(db.indexes.byEmailCi.find("alice@a.com")).toEqual([]);
|
|
511
|
+
expect(db.indexes.byEmailCi.find({ email: "alice@a.com" })).toEqual([]);
|
|
261
512
|
});
|
|
262
513
|
});
|
|
263
514
|
describe("Pattern 7 — multi-value computed (docsByKeyword)", () => {
|
|
@@ -266,7 +517,9 @@ describe("Pattern 7 — multi-value computed (docsByKeyword)", () => {
|
|
|
266
517
|
archetypes: { Doc: ["body"] },
|
|
267
518
|
indexes: {
|
|
268
519
|
docsByKeyword: {
|
|
269
|
-
key:
|
|
520
|
+
key: {
|
|
521
|
+
keyword: (c) => c.body.toLowerCase().split(/\s+/).filter((s) => s.length > 0),
|
|
522
|
+
},
|
|
270
523
|
components: ["body"],
|
|
271
524
|
},
|
|
272
525
|
},
|
|
@@ -277,14 +530,14 @@ describe("Pattern 7 — multi-value computed (docsByKeyword)", () => {
|
|
|
277
530
|
it("fans out each computed array element into its own bucket", () => {
|
|
278
531
|
const db = Database.create(plugin());
|
|
279
532
|
const d = db.transactions.add("the quick brown fox");
|
|
280
|
-
expect(db.indexes.docsByKeyword.find("quick")).toEqual([d]);
|
|
281
|
-
expect(db.indexes.docsByKeyword.find("brown")).toEqual([d]);
|
|
282
|
-
expect(db.indexes.docsByKeyword.find("missing")).toEqual([]);
|
|
533
|
+
expect(db.indexes.docsByKeyword.find({ keyword: "quick" })).toEqual([d]);
|
|
534
|
+
expect(db.indexes.docsByKeyword.find({ keyword: "brown" })).toEqual([d]);
|
|
535
|
+
expect(db.indexes.docsByKeyword.find({ keyword: "missing" })).toEqual([]);
|
|
283
536
|
});
|
|
284
537
|
it("findRange supports operator filters on the scalar element", () => {
|
|
285
538
|
const db = Database.create(plugin());
|
|
286
539
|
const d = db.transactions.add("alpha beta gamma");
|
|
287
|
-
const inRange = [...db.indexes.docsByKeyword.findRange({ ">=": "b", "<": "z" })].sort();
|
|
540
|
+
const inRange = [...db.indexes.docsByKeyword.findRange({ keyword: { ">=": "b", "<": "z" } })].sort();
|
|
288
541
|
expect(inRange).toEqual([d]);
|
|
289
542
|
});
|
|
290
543
|
});
|
|
@@ -305,8 +558,8 @@ describe("Pattern 8 — compound from nested data (playerByRoster)", () => {
|
|
|
305
558
|
indexes: {
|
|
306
559
|
playerByRoster: {
|
|
307
560
|
key: {
|
|
308
|
-
team: (
|
|
309
|
-
position: (
|
|
561
|
+
team: (c) => c.roster.team,
|
|
562
|
+
position: (c) => c.roster.position,
|
|
310
563
|
},
|
|
311
564
|
unique: true,
|
|
312
565
|
components: ["roster"],
|
|
@@ -342,7 +595,7 @@ describe("Pattern 10 — mixed identity + derived slots (playerByTeamRole)", ()
|
|
|
342
595
|
playerByTeamRole: {
|
|
343
596
|
key: {
|
|
344
597
|
team: "team",
|
|
345
|
-
role: (
|
|
598
|
+
role: (c) => c.roster.role,
|
|
346
599
|
},
|
|
347
600
|
unique: true,
|
|
348
601
|
components: ["roster"],
|
|
@@ -398,12 +651,135 @@ describe("Pattern 12 — custom comparator (tasksByPriority)", () => {
|
|
|
398
651
|
const e2a = db.transactions.add({ owner: 1, priority: 2, due: 10 });
|
|
399
652
|
const e2b = db.transactions.add({ owner: 1, priority: 2, due: 20 });
|
|
400
653
|
// Priority desc, then due asc.
|
|
401
|
-
expect(db.indexes.tasksByPriority.find(1)).toEqual([e3, e2a, e2b, e1]);
|
|
654
|
+
expect(db.indexes.tasksByPriority.find({ owner: 1 })).toEqual([e3, e2a, e2b, e1]);
|
|
402
655
|
});
|
|
403
656
|
});
|
|
404
657
|
// ============================================================================
|
|
405
658
|
// Cross-cutting concerns
|
|
406
659
|
// ============================================================================
|
|
660
|
+
describe("string ordering is by code point, never locale", () => {
|
|
661
|
+
const plugin = () => Database.Plugin.create({
|
|
662
|
+
components: { group: { type: "number" }, label: { type: "string" } },
|
|
663
|
+
archetypes: { Row: ["group", "label"] },
|
|
664
|
+
indexes: { byGroupSorted: { key: "group", order: { by: ["label"] } } },
|
|
665
|
+
transactions: {
|
|
666
|
+
add: (t, a) => t.archetypes.Row.insert(a),
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
it("default comparator sorts strings by ASCII code point (uppercase before lowercase)", () => {
|
|
670
|
+
const db = Database.create(plugin());
|
|
671
|
+
const a = db.transactions.add({ group: 1, label: "a" }); // 'a' = 97
|
|
672
|
+
const Z = db.transactions.add({ group: 1, label: "Z" }); // 'Z' = 90
|
|
673
|
+
const zero = db.transactions.add({ group: 1, label: "0" }); // '0' = 48
|
|
674
|
+
const A = db.transactions.add({ group: 1, label: "A" }); // 'A' = 65
|
|
675
|
+
// Code point: '0' < 'A' < 'Z' < 'a'. A locale-aware comparator would
|
|
676
|
+
// interleave case (lowercase 'a' before 'Z'), so this fails if the
|
|
677
|
+
// index ever sorts via localeCompare — the no-locale guard.
|
|
678
|
+
expect(db.indexes.byGroupSorted.find({ group: 1 })).toEqual([zero, A, Z, a]);
|
|
679
|
+
});
|
|
680
|
+
it("ordered db.select sorts strings by code point (not numeric subtraction, not locale)", () => {
|
|
681
|
+
const db = Database.create(plugin());
|
|
682
|
+
const a = db.transactions.add({ group: 1, label: "a" });
|
|
683
|
+
const Z = db.transactions.add({ group: 1, label: "Z" });
|
|
684
|
+
const zero = db.transactions.add({ group: 1, label: "0" });
|
|
685
|
+
const A = db.transactions.add({ group: 1, label: "A" });
|
|
686
|
+
// No `where`, so this hits the archetype-scan sort in selectEntities —
|
|
687
|
+
// which previously used `a - b` (NaN for strings). Now code-point.
|
|
688
|
+
expect(db.select(["label"], { order: { label: true } })).toEqual([zero, A, Z, a]);
|
|
689
|
+
});
|
|
690
|
+
});
|
|
691
|
+
describe("archetype-scoped index", () => {
|
|
692
|
+
// Task and Note both have `parent`; the index is scoped to Task only.
|
|
693
|
+
const plugin = () => Database.Plugin.create({
|
|
694
|
+
components: {
|
|
695
|
+
parent: { type: "number" },
|
|
696
|
+
priority: { type: "number" },
|
|
697
|
+
body: { type: "string" },
|
|
698
|
+
},
|
|
699
|
+
archetypes: {
|
|
700
|
+
Task: ["parent", "priority"],
|
|
701
|
+
Note: ["parent", "body"],
|
|
702
|
+
},
|
|
703
|
+
indexes: {
|
|
704
|
+
tasksByParent: { key: "parent", archetype: "Task" },
|
|
705
|
+
},
|
|
706
|
+
transactions: {
|
|
707
|
+
addTask: (t, a) => t.archetypes.Task.insert(a),
|
|
708
|
+
addNote: (t, a) => t.archetypes.Note.insert(a),
|
|
709
|
+
delete: (t, e) => t.delete(e),
|
|
710
|
+
},
|
|
711
|
+
});
|
|
712
|
+
it("indexes only the scoped archetype, excluding others that share the key column", () => {
|
|
713
|
+
const db = Database.create(plugin());
|
|
714
|
+
const t1 = db.transactions.addTask({ parent: 7, priority: 1 });
|
|
715
|
+
const t2 = db.transactions.addTask({ parent: 7, priority: 2 });
|
|
716
|
+
db.transactions.addNote({ parent: 7, body: "shares parent 7 but is a Note" });
|
|
717
|
+
// Without scoping the Note (parent === 7) would appear here too.
|
|
718
|
+
expect([...db.indexes.tasksByParent.find({ parent: 7 })].sort()).toEqual([t1, t2].sort());
|
|
719
|
+
});
|
|
720
|
+
it("seeds from only the scoped archetype when registered after data exists", () => {
|
|
721
|
+
const base = Database.Plugin.create({
|
|
722
|
+
components: {
|
|
723
|
+
parent: { type: "number" },
|
|
724
|
+
priority: { type: "number" },
|
|
725
|
+
body: { type: "string" },
|
|
726
|
+
},
|
|
727
|
+
archetypes: { Task: ["parent", "priority"], Note: ["parent", "body"] },
|
|
728
|
+
transactions: {
|
|
729
|
+
addTask: (t, a) => t.archetypes.Task.insert(a),
|
|
730
|
+
addNote: (t, a) => t.archetypes.Note.insert(a),
|
|
731
|
+
},
|
|
732
|
+
});
|
|
733
|
+
const db = Database.create(base);
|
|
734
|
+
const t = db.transactions.addTask({ parent: 5, priority: 1 });
|
|
735
|
+
db.transactions.addNote({ parent: 5, body: "note" });
|
|
736
|
+
const ext = db.extend(Database.Plugin.create({
|
|
737
|
+
extends: base,
|
|
738
|
+
indexes: { tasksByParent: { key: "parent", archetype: "Task" } },
|
|
739
|
+
}));
|
|
740
|
+
expect(ext.indexes.tasksByParent.find({ parent: 5 })).toEqual([t]);
|
|
741
|
+
});
|
|
742
|
+
it("throws when scoped to an unknown archetype", () => {
|
|
743
|
+
const bad = Database.Plugin.create({
|
|
744
|
+
components: { parent: { type: "number" } },
|
|
745
|
+
archetypes: { Task: ["parent"] },
|
|
746
|
+
// `archetype` typed loosely here via `as any` to reach the runtime guard.
|
|
747
|
+
indexes: { x: { key: "parent", archetype: "Nonexistent" } },
|
|
748
|
+
});
|
|
749
|
+
expect(() => Database.create(bad)).toThrow(/unknown archetype/i);
|
|
750
|
+
});
|
|
751
|
+
});
|
|
752
|
+
describe("archetype-changing update keeps indexes correct (dispatch union)", () => {
|
|
753
|
+
// `byTag` only applies to archetypes that have `tag`. Adding/removing the
|
|
754
|
+
// `tag` component moves the entity between archetypes, so the entity must
|
|
755
|
+
// enter / leave the index accordingly — this exercises the registry's
|
|
756
|
+
// union(from, to) dispatch for updates that change archetype.
|
|
757
|
+
const plugin = () => Database.Plugin.create({
|
|
758
|
+
components: { tag: { type: "string" }, name: { type: "string" } },
|
|
759
|
+
archetypes: { Tagged: ["tag", "name"], Named: ["name"] },
|
|
760
|
+
indexes: { byTag: { key: "tag" } },
|
|
761
|
+
transactions: {
|
|
762
|
+
addTagged: (t, a) => t.archetypes.Tagged.insert(a),
|
|
763
|
+
addNamed: (t, a) => t.archetypes.Named.insert(a),
|
|
764
|
+
setTag: (t, a) => t.update(a.entity, { tag: a.tag }),
|
|
765
|
+
removeTag: (t, e) => t.update(e, { tag: undefined }),
|
|
766
|
+
},
|
|
767
|
+
});
|
|
768
|
+
it("removes the entity from the index when an update drops the indexed component", () => {
|
|
769
|
+
const db = Database.create(plugin());
|
|
770
|
+
const e = db.transactions.addTagged({ tag: "x", name: "n" });
|
|
771
|
+
expect(db.indexes.byTag.find({ tag: "x" })).toEqual([e]);
|
|
772
|
+
db.transactions.removeTag(e); // Tagged -> Named: byTag no longer applies
|
|
773
|
+
expect(db.indexes.byTag.find({ tag: "x" })).toEqual([]);
|
|
774
|
+
});
|
|
775
|
+
it("adds the entity to the index when an update introduces the indexed component", () => {
|
|
776
|
+
const db = Database.create(plugin());
|
|
777
|
+
const e = db.transactions.addNamed({ name: "n" });
|
|
778
|
+
expect(db.indexes.byTag.find({ tag: "y" })).toEqual([]);
|
|
779
|
+
db.transactions.setTag({ entity: e, tag: "y" }); // Named -> Tagged: enters byTag
|
|
780
|
+
expect(db.indexes.byTag.find({ tag: "y" })).toEqual([e]);
|
|
781
|
+
});
|
|
782
|
+
});
|
|
407
783
|
describe("findRange — operator filters on the bucket key", () => {
|
|
408
784
|
it("filters by range operators on a tuple-keyed index", () => {
|
|
409
785
|
const plugin = Database.Plugin.create({
|
|
@@ -490,7 +866,7 @@ describe("auto-routing of db.select", () => {
|
|
|
490
866
|
archetypes: { U: ["name"] },
|
|
491
867
|
indexes: {
|
|
492
868
|
byLowerName: {
|
|
493
|
-
key: (
|
|
869
|
+
key: { name: (c) => c.name.toLowerCase() },
|
|
494
870
|
components: ["name"],
|
|
495
871
|
},
|
|
496
872
|
},
|
|
@@ -569,6 +945,135 @@ describe("auto-routing of db.observe.select", () => {
|
|
|
569
945
|
}
|
|
570
946
|
});
|
|
571
947
|
});
|
|
948
|
+
describe("auto-routing of ordered select through a sorted index", () => {
|
|
949
|
+
// Two indexes on the same key, distinct shapes: one unsorted, one sorted
|
|
950
|
+
// ascending by `priority` (default comparator). An ordered query must route
|
|
951
|
+
// to the *sorted* one (never the unsorted one), and a query whose order the
|
|
952
|
+
// index can't serve must fall back to the scan.
|
|
953
|
+
const plugin = () => Database.Plugin.create({
|
|
954
|
+
components: {
|
|
955
|
+
owner: { type: "number" },
|
|
956
|
+
priority: { type: "number" },
|
|
957
|
+
due: { type: "number" },
|
|
958
|
+
title: { type: "string" },
|
|
959
|
+
},
|
|
960
|
+
archetypes: { Task: ["owner", "priority", "due", "title"] },
|
|
961
|
+
indexes: {
|
|
962
|
+
byOwner: { key: "owner" },
|
|
963
|
+
byOwnerSorted: { key: "owner", order: { by: ["priority"] } },
|
|
964
|
+
},
|
|
965
|
+
transactions: {
|
|
966
|
+
add: (t, a) => t.archetypes.Task.insert(a),
|
|
967
|
+
},
|
|
968
|
+
});
|
|
969
|
+
const spyFind = (handle) => {
|
|
970
|
+
const original = handle.find;
|
|
971
|
+
const state = { calls: 0 };
|
|
972
|
+
handle.find = (v) => { state.calls += 1; return original(v); };
|
|
973
|
+
return { state, restore: () => { handle.find = original; } };
|
|
974
|
+
};
|
|
975
|
+
const seed = (db) => {
|
|
976
|
+
const mid = db.transactions.add({ owner: 1, priority: 2, due: 10, title: "mid" });
|
|
977
|
+
const low = db.transactions.add({ owner: 1, priority: 1, due: 20, title: "low" });
|
|
978
|
+
const high = db.transactions.add({ owner: 1, priority: 3, due: 30, title: "high" });
|
|
979
|
+
db.transactions.add({ owner: 2, priority: 1, due: 40, title: "other" });
|
|
980
|
+
return { low, mid, high };
|
|
981
|
+
};
|
|
982
|
+
it("routes an ascending ordered query to the sorted index (and returns it sorted)", () => {
|
|
983
|
+
const db = Database.create(plugin());
|
|
984
|
+
const { low, mid, high } = seed(db);
|
|
985
|
+
const sorted = spyFind(db.indexes.byOwnerSorted);
|
|
986
|
+
try {
|
|
987
|
+
const result = db.select(["owner", "priority"], { where: { owner: 1 }, order: { priority: true } });
|
|
988
|
+
// GREEN: the ordered query was served by the sorted index...
|
|
989
|
+
expect(sorted.state.calls).toBe(1);
|
|
990
|
+
// ...and the result is in the index's ascending priority order.
|
|
991
|
+
expect([...result]).toEqual([low, mid, high]);
|
|
992
|
+
}
|
|
993
|
+
finally {
|
|
994
|
+
sorted.restore();
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
it("routes the ordered query to the sorted index, never the unsorted one on the same key", () => {
|
|
998
|
+
const db = Database.create(plugin());
|
|
999
|
+
seed(db);
|
|
1000
|
+
const unsorted = spyFind(db.indexes.byOwner);
|
|
1001
|
+
const sorted = spyFind(db.indexes.byOwnerSorted);
|
|
1002
|
+
try {
|
|
1003
|
+
db.select(["owner", "priority"], { where: { owner: 1 }, order: { priority: true } });
|
|
1004
|
+
expect(sorted.state.calls).toBe(1);
|
|
1005
|
+
expect(unsorted.state.calls).toBe(0);
|
|
1006
|
+
}
|
|
1007
|
+
finally {
|
|
1008
|
+
unsorted.restore();
|
|
1009
|
+
sorted.restore();
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
it("falls back to scan for a descending order (sorted index only materializes ascending)", () => {
|
|
1013
|
+
const db = Database.create(plugin());
|
|
1014
|
+
const { low, mid, high } = seed(db);
|
|
1015
|
+
const sorted = spyFind(db.indexes.byOwnerSorted);
|
|
1016
|
+
try {
|
|
1017
|
+
const result = db.select(["owner", "priority"], { where: { owner: 1 }, order: { priority: false } });
|
|
1018
|
+
// Not routed — the index can't serve descending without re-sorting.
|
|
1019
|
+
expect(sorted.state.calls).toBe(0);
|
|
1020
|
+
// The scan still produces a correct descending result.
|
|
1021
|
+
expect([...result]).toEqual([high, mid, low]);
|
|
1022
|
+
}
|
|
1023
|
+
finally {
|
|
1024
|
+
sorted.restore();
|
|
1025
|
+
}
|
|
1026
|
+
});
|
|
1027
|
+
it("falls back to scan when the requested order column is not the index's sort column", () => {
|
|
1028
|
+
const db = Database.create(plugin());
|
|
1029
|
+
seed(db);
|
|
1030
|
+
const sorted = spyFind(db.indexes.byOwnerSorted);
|
|
1031
|
+
try {
|
|
1032
|
+
// Index sorts by `priority`; this asks for `due`.
|
|
1033
|
+
const result = db.select(["owner", "due"], { where: { owner: 1 }, order: { due: true } });
|
|
1034
|
+
expect(sorted.state.calls).toBe(0);
|
|
1035
|
+
expect(result).toHaveLength(3);
|
|
1036
|
+
}
|
|
1037
|
+
finally {
|
|
1038
|
+
sorted.restore();
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
it("still routes a plain (no-order) equality query — unaffected by the order support", () => {
|
|
1042
|
+
const db = Database.create(plugin());
|
|
1043
|
+
seed(db);
|
|
1044
|
+
// No order: the first matching key index (byOwner) serves it.
|
|
1045
|
+
const unsorted = spyFind(db.indexes.byOwner);
|
|
1046
|
+
try {
|
|
1047
|
+
const result = db.select(["owner"], { where: { owner: 1 } });
|
|
1048
|
+
expect(unsorted.state.calls).toBe(1);
|
|
1049
|
+
expect(result).toHaveLength(3);
|
|
1050
|
+
}
|
|
1051
|
+
finally {
|
|
1052
|
+
unsorted.restore();
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
it("routes the ordered query through db.observe.select too (initial snapshot + requery)", async () => {
|
|
1056
|
+
const db = Database.create(plugin());
|
|
1057
|
+
const { low, mid, high } = seed(db);
|
|
1058
|
+
const sorted = spyFind(db.indexes.byOwnerSorted);
|
|
1059
|
+
try {
|
|
1060
|
+
const emissions = [];
|
|
1061
|
+
const unsub = db.observe.select(["owner", "priority"], { where: { owner: 1 }, order: { priority: true } })((entities) => { emissions.push([...entities]); });
|
|
1062
|
+
// Initial snapshot routed and sorted.
|
|
1063
|
+
expect(sorted.state.calls).toBe(1);
|
|
1064
|
+
expect(emissions[0]).toEqual([low, mid, high]);
|
|
1065
|
+
// A new task for owner 1 with the lowest priority sorts to the front.
|
|
1066
|
+
const lowest = db.transactions.add({ owner: 1, priority: 0, due: 5, title: "lowest" });
|
|
1067
|
+
await Promise.resolve();
|
|
1068
|
+
expect(sorted.state.calls).toBe(2);
|
|
1069
|
+
expect(emissions[emissions.length - 1]).toEqual([lowest, low, mid, high]);
|
|
1070
|
+
unsub();
|
|
1071
|
+
}
|
|
1072
|
+
finally {
|
|
1073
|
+
sorted.restore();
|
|
1074
|
+
}
|
|
1075
|
+
});
|
|
1076
|
+
});
|
|
572
1077
|
describe("t.indexes — eager maintenance inside transactions", () => {
|
|
573
1078
|
const plugin = () => Database.Plugin.create({
|
|
574
1079
|
components: {
|
|
@@ -583,21 +1088,21 @@ describe("t.indexes — eager maintenance inside transactions", () => {
|
|
|
583
1088
|
transactions: {
|
|
584
1089
|
add: (t, args) => t.archetypes.User.insert(args),
|
|
585
1090
|
addIfNew: (t, args) => {
|
|
586
|
-
const existing = t.indexes.uniqueByEmail.get(args.email);
|
|
1091
|
+
const existing = t.indexes.uniqueByEmail.get({ email: args.email });
|
|
587
1092
|
if (existing !== null)
|
|
588
1093
|
return existing;
|
|
589
1094
|
return t.archetypes.User.insert(args);
|
|
590
1095
|
},
|
|
591
1096
|
renameAndLookup: (t, args) => {
|
|
592
1097
|
t.update(args.entity, { name: args.newName });
|
|
593
|
-
const found = t.indexes.byName.find(args.newName);
|
|
1098
|
+
const found = t.indexes.byName.find({ name: args.newName });
|
|
594
1099
|
if (!found.includes(args.entity)) {
|
|
595
1100
|
throw new Error("name index stale after t.update");
|
|
596
1101
|
}
|
|
597
1102
|
},
|
|
598
1103
|
deleteAndLookup: (t, args) => {
|
|
599
1104
|
t.delete(args.entity);
|
|
600
|
-
const stillThere = t.indexes.byName.find(args.name);
|
|
1105
|
+
const stillThere = t.indexes.byName.find({ name: args.name });
|
|
601
1106
|
if (stillThere.includes(args.entity)) {
|
|
602
1107
|
throw new Error("index still references deleted entity");
|
|
603
1108
|
}
|
|
@@ -616,14 +1121,14 @@ describe("t.indexes — eager maintenance inside transactions", () => {
|
|
|
616
1121
|
const db = Database.create(plugin());
|
|
617
1122
|
const e = db.transactions.add({ name: "alice", email: "a@a.com" });
|
|
618
1123
|
expect(() => db.transactions.renameAndLookup({ entity: e, newName: "alex" })).not.toThrow();
|
|
619
|
-
expect(db.indexes.byName.find("alex")).toEqual([e]);
|
|
620
|
-
expect(db.indexes.byName.find("alice")).toEqual([]);
|
|
1124
|
+
expect(db.indexes.byName.find({ name: "alex" })).toEqual([e]);
|
|
1125
|
+
expect(db.indexes.byName.find({ name: "alice" })).toEqual([]);
|
|
621
1126
|
});
|
|
622
1127
|
it("find does not see a row deleted earlier in the same transaction body", () => {
|
|
623
1128
|
const db = Database.create(plugin());
|
|
624
1129
|
const e = db.transactions.add({ name: "alice", email: "a@a.com" });
|
|
625
1130
|
expect(() => db.transactions.deleteAndLookup({ entity: e, name: "alice" })).not.toThrow();
|
|
626
|
-
expect(db.indexes.byName.find("alice")).toEqual([]);
|
|
1131
|
+
expect(db.indexes.byName.find({ name: "alice" })).toEqual([]);
|
|
627
1132
|
});
|
|
628
1133
|
it("unique conflict on insert is caught up-front — no partial store or index mutation", () => {
|
|
629
1134
|
const db = Database.create(plugin());
|
|
@@ -632,11 +1137,11 @@ describe("t.indexes — eager maintenance inside transactions", () => {
|
|
|
632
1137
|
const beforeRows = db.select(["email"]);
|
|
633
1138
|
expect(() => db.transactions.add({ name: "alex", email: "shared@x.com" })).toThrow(/Unique index conflict/);
|
|
634
1139
|
// The unique-key bucket still points at the original entity.
|
|
635
|
-
expect(db.indexes.uniqueByEmail.get("shared@x.com")).toBe(first);
|
|
1140
|
+
expect(db.indexes.uniqueByEmail.get({ email: "shared@x.com" })).toBe(first);
|
|
636
1141
|
// No phantom row landed in either index or store. byName.find("alex")
|
|
637
1142
|
// returning [] proves the secondary index never saw the row;
|
|
638
1143
|
// db.select asserts the store itself never grew.
|
|
639
|
-
expect(db.indexes.byName.find("alex")).toEqual([]);
|
|
1144
|
+
expect(db.indexes.byName.find({ name: "alex" })).toEqual([]);
|
|
640
1145
|
const afterRows = db.select(["email"]);
|
|
641
1146
|
expect([...afterRows].sort()).toEqual([...beforeRows].sort());
|
|
642
1147
|
});
|
|
@@ -649,8 +1154,8 @@ describe("t.indexes — eager maintenance inside transactions", () => {
|
|
|
649
1154
|
const e2BeforeRead = db.read(e2);
|
|
650
1155
|
expect(() => db.transactions.updateEmail({ entity: e1, newEmail: "b@b.com" })).toThrow(/Unique index conflict/);
|
|
651
1156
|
// Index untouched on both keys.
|
|
652
|
-
expect(db.indexes.uniqueByEmail.get("a@a.com")).toBe(e1);
|
|
653
|
-
expect(db.indexes.uniqueByEmail.get("b@b.com")).toBe(e2);
|
|
1157
|
+
expect(db.indexes.uniqueByEmail.get({ email: "a@a.com" })).toBe(e1);
|
|
1158
|
+
expect(db.indexes.uniqueByEmail.get({ email: "b@b.com" })).toBe(e2);
|
|
654
1159
|
// Underlying store untouched — e1's email is still "a@a.com",
|
|
655
1160
|
// proving the pre-check fired before `core.update` ran.
|
|
656
1161
|
expect(db.read(e1)).toEqual(e1BeforeRead);
|
|
@@ -756,7 +1261,7 @@ describe("registry maintenance", () => {
|
|
|
756
1261
|
const e1 = db.transactions.add("alice");
|
|
757
1262
|
const e2 = db.transactions.add("alice");
|
|
758
1263
|
const ext = db.extend(indexed);
|
|
759
|
-
expect([...ext.indexes.byName.find("alice")].sort()).toEqual([e1, e2].sort());
|
|
1264
|
+
expect([...ext.indexes.byName.find({ name: "alice" })].sort()).toEqual([e1, e2].sort());
|
|
760
1265
|
});
|
|
761
1266
|
});
|
|
762
1267
|
//# sourceMappingURL=database.index.test.js.map
|