@reventlessdev/reventless-local 3.0.0-alpha.199 → 3.0.0-alpha.201

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 (40) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +10 -9
  3. package/rescript.json +2 -1
  4. package/src/Platform.res +137 -86
  5. package/src/Platform.res.mjs +57 -44
  6. package/src/adapter/BackendState.res +13 -0
  7. package/src/adapter/BackendState.res.mjs +17 -1
  8. package/src/adapter/DcbEventLog/LocalDcbEventLogStorage.res.mjs +1 -1
  9. package/src/adapter/DomainGraphQL_Server.res +5 -5
  10. package/src/adapter/DomainGraphQL_Server.res.mjs +1 -1
  11. package/src/adapter/EventLog/LocalEventLogStorage.res.mjs +1 -1
  12. package/src/adapter/LocalBus.res +6 -46
  13. package/src/adapter/LocalBus.res.mjs +0 -29
  14. package/src/adapter/LocalStateChangeDescriptor.res +93 -0
  15. package/src/adapter/LocalStateChangeDescriptor.res.mjs +61 -0
  16. package/src/adapter/LocalUploadResolvers.res +16 -3
  17. package/src/adapter/LocalUploadResolvers.res.mjs +6 -4
  18. package/src/adapter/ObjectStore/LocalObjectStore.res +157 -0
  19. package/src/adapter/ObjectStore/LocalObjectStore.res.mjs +129 -0
  20. package/src/adapter/ObjectStore/ObjectStoreStorage_FileSystem.res +215 -0
  21. package/src/adapter/ObjectStore/ObjectStoreStorage_FileSystem.res.mjs +286 -0
  22. package/src/adapter/ObjectStore/ObjectStoreStorage_InMemory.res +33 -0
  23. package/src/adapter/ObjectStore/ObjectStoreStorage_InMemory.res.mjs +47 -0
  24. package/src/adapter/QueryDb/LocalQueryDbStorage.res.mjs +1 -1
  25. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +4 -2
  26. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +3 -3
  27. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +4 -2
  28. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +3 -3
  29. package/src/reset/LocalSeedReset.res +552 -0
  30. package/src/reset/LocalSeedReset.res.mjs +533 -0
  31. package/tests/adapter/BackendParityTest.res +51 -0
  32. package/tests/adapter/BackendParityTest.res.mjs +44 -0
  33. package/tests/adapter/GraphQL_SubscriptionResolversTest.res +6 -4
  34. package/tests/adapter/GraphQL_SubscriptionResolversTest.res.mjs +4 -3
  35. package/tests/adapter/ObjectStorePersistenceTest.res +243 -0
  36. package/tests/adapter/ObjectStorePersistenceTest.res.mjs +159 -0
  37. package/tests/reset/LocalSeedResetTest.res +252 -0
  38. package/tests/reset/LocalSeedResetTest.res.mjs +280 -0
  39. package/src/adapter/LocalObjectStore.res +0 -70
  40. package/src/adapter/LocalObjectStore.res.mjs +0 -60
@@ -0,0 +1,533 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodepath from "node:path";
5
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Offload$Reventless from "@reventlessdev/reventless-spec/src/semantic/Offload.res.mjs";
8
+ import * as Backend$ReventlessLocal from "../adapter/Backend.res.mjs";
9
+ import * as PluginSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginSpec.res.mjs";
10
+ import * as Seed_Prompt$ReventlessSeed from "@reventlessdev/reventless-seed/src/Seed_Prompt.res.mjs";
11
+ import * as UiFragments$ReventlessCore from "@reventlessdev/reventless-core/src/admin/UiFragmentRegistry/StateViewSlice/UiFragments.res.mjs";
12
+ import * as ComponentType$ReventlessCore from "@reventlessdev/reventless-core/src/ComponentType.res.mjs";
13
+ import * as SqliteDriver$ReventlessLocal from "../adapter/SqliteDriver.res.mjs";
14
+ import * as LocalObjectStore$ReventlessLocal from "../adapter/ObjectStore/LocalObjectStore.res.mjs";
15
+ import * as PluginsReadModelSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs";
16
+ import * as ObjectStoreStorage_FileSystem$ReventlessLocal from "../adapter/ObjectStore/ObjectStoreStorage_FileSystem.res.mjs";
17
+
18
+ function scopeLabel(s) {
19
+ if (typeof s === "object") {
20
+ return s._0;
21
+ }
22
+ switch (s) {
23
+ case "Domain" :
24
+ return "domain";
25
+ case "Platform" :
26
+ return "platform";
27
+ case "Everything" :
28
+ return "everything";
29
+ }
30
+ }
31
+
32
+ let platformQueryables = [
33
+ PluginsReadModelSpec$ReventlessCore.name,
34
+ UiFragments$ReventlessCore.name
35
+ ];
36
+
37
+ let platformWritables = [PluginSpec$ReventlessCore.name];
38
+
39
+ function qdbTableName(name) {
40
+ return "qdb_" + name.replaceAll("-", "_");
41
+ }
42
+
43
+ function aggregateLogName(name) {
44
+ return ComponentType$ReventlessCore.name(ComponentType$ReventlessCore.name(name, "Aggregate"), "EventLog");
45
+ }
46
+
47
+ function checkpointComponent(readModel) {
48
+ let withoutDcb = readModel.startsWith("dcb:") ? readModel.slice("dcb:".length, readModel.length) : readModel;
49
+ let withoutColl = withoutDcb.endsWith("EventColl") ? withoutDcb.slice(0, withoutDcb.length - "EventColl".length | 0) : withoutDcb;
50
+ if (withoutColl.endsWith("ReadModel")) {
51
+ return withoutColl.slice(0, withoutColl.length - "ReadModel".length | 0);
52
+ } else {
53
+ return withoutColl;
54
+ }
55
+ }
56
+
57
+ function tableExists(db, name) {
58
+ return Stdlib_Option.isSome(SqliteDriver$ReventlessLocal.get(SqliteDriver$ReventlessLocal.prepare(db, "SELECT name FROM sqlite_master WHERE type='table' AND name = ?"), [name]));
59
+ }
60
+
61
+ function strings(rows, column) {
62
+ return Stdlib_Array.filterMap(rows, row => {
63
+ let match = row[column];
64
+ if (typeof match === "string") {
65
+ return match;
66
+ }
67
+ });
68
+ }
69
+
70
+ function qdbTables(db) {
71
+ return strings(SqliteDriver$ReventlessLocal.all(SqliteDriver$ReventlessLocal.prepare(db, "SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'qdb\\_%' ESCAPE '\\' ORDER BY name"), []), "name");
72
+ }
73
+
74
+ function distinct(db, table, column) {
75
+ if (tableExists(db, table)) {
76
+ return strings(SqliteDriver$ReventlessLocal.all(SqliteDriver$ReventlessLocal.prepare(db, `SELECT DISTINCT ` + column + ` AS v FROM ` + table + ` ORDER BY v`), []), "v");
77
+ } else {
78
+ return [];
79
+ }
80
+ }
81
+
82
+ function countWhere(db, table, column, value) {
83
+ if (!tableExists(db, table)) {
84
+ return 0;
85
+ }
86
+ let row = SqliteDriver$ReventlessLocal.get(SqliteDriver$ReventlessLocal.prepare(db, `SELECT COUNT(*) AS c FROM ` + table + ` WHERE ` + column + ` = ?`), [value]);
87
+ if (row === undefined) {
88
+ return 0;
89
+ }
90
+ let match = row["c"];
91
+ if (typeof match === "number") {
92
+ return match | 0;
93
+ } else {
94
+ return 0;
95
+ }
96
+ }
97
+
98
+ function countAll(db, table) {
99
+ if (!tableExists(db, table)) {
100
+ return 0;
101
+ }
102
+ let row = SqliteDriver$ReventlessLocal.get(SqliteDriver$ReventlessLocal.prepare(db, `SELECT COUNT(*) AS c FROM ` + table), []);
103
+ if (row === undefined) {
104
+ return 0;
105
+ }
106
+ let match = row["c"];
107
+ if (typeof match === "number") {
108
+ return match | 0;
109
+ } else {
110
+ return 0;
111
+ }
112
+ }
113
+
114
+ function namesIn(structure, field) {
115
+ let match = structure[field];
116
+ if (match !== undefined) {
117
+ if (Array.isArray(match)) {
118
+ return Stdlib_Array.filterMap(match, item => {
119
+ if (typeof item !== "object" || item === null || Array.isArray(item)) {
120
+ return;
121
+ }
122
+ let match = item["name"];
123
+ if (typeof match === "string") {
124
+ return match;
125
+ }
126
+ });
127
+ } else {
128
+ return [];
129
+ }
130
+ } else {
131
+ return [];
132
+ }
133
+ }
134
+
135
+ function resolveStructure(root, json) {
136
+ if (typeof json !== "object" || json === null || Array.isArray(json)) {
137
+ return;
138
+ }
139
+ let match = json[Offload$Reventless.sentinelKey];
140
+ if (match === undefined) {
141
+ return json;
142
+ }
143
+ if (typeof match !== "object" || match === null || Array.isArray(match)) {
144
+ return json;
145
+ }
146
+ let match$1 = match["key"];
147
+ if (typeof match$1 === "string") {
148
+ return Stdlib_Option.flatMap(ObjectStoreStorage_FileSystem$ReventlessLocal.getOffload(root, match$1), bytes => {
149
+ let resolved;
150
+ try {
151
+ resolved = JSON.parse(bytes);
152
+ } catch (exn) {
153
+ resolved = null;
154
+ }
155
+ if (typeof resolved === "object" && resolved !== null && !Array.isArray(resolved)) {
156
+ return resolved;
157
+ }
158
+ });
159
+ }
160
+ }
161
+
162
+ function readPlugins(db, root) {
163
+ let table = qdbTableName(PluginsReadModelSpec$ReventlessCore.name);
164
+ if (tableExists(db, table)) {
165
+ return Stdlib_Array.filterMap(SqliteDriver$ReventlessLocal.all(SqliteDriver$ReventlessLocal.prepare(db, `SELECT partition_key, item FROM ` + table), []), row => {
166
+ let match = row["partition_key"];
167
+ let match$1 = row["item"];
168
+ if (match === undefined) {
169
+ return;
170
+ }
171
+ if (typeof match !== "string") {
172
+ return;
173
+ }
174
+ if (match$1 === undefined) {
175
+ return;
176
+ }
177
+ if (typeof match$1 !== "string") {
178
+ return;
179
+ }
180
+ let o;
181
+ try {
182
+ o = JSON.parse(match$1);
183
+ } catch (exn) {
184
+ o = null;
185
+ }
186
+ if (typeof o === "object" && o !== null && !Array.isArray(o)) {
187
+ return Stdlib_Option.map(Stdlib_Option.flatMap(o["structure"], s => resolveStructure(root, s)), structure => {
188
+ let match$2 = structure["requiredStores"];
189
+ let tmp;
190
+ tmp = match$2 !== undefined ? (
191
+ Array.isArray(match$2) ? Stdlib_Array.filterMap(match$2, i => {
192
+ if (typeof i === "string") {
193
+ return i;
194
+ }
195
+ }) : []
196
+ ) : [];
197
+ return {
198
+ plugin: match,
199
+ queryables: namesIn(structure, "readModels").concat(namesIn(structure, "stateViewSlices")),
200
+ writables: namesIn(structure, "aggregates").concat(namesIn(structure, "stateChangeSlices")),
201
+ stores: tmp
202
+ };
203
+ });
204
+ }
205
+ });
206
+ } else {
207
+ return [];
208
+ }
209
+ }
210
+
211
+ function total(p) {
212
+ return Stdlib_Array.reduce(p.items, 0, (sum, i) => sum + i.count | 0);
213
+ }
214
+
215
+ function deleteWhere(db, table, column, value) {
216
+ if (tableExists(db, table)) {
217
+ return SqliteDriver$ReventlessLocal.run(SqliteDriver$ReventlessLocal.prepare(db, `DELETE FROM ` + table + ` WHERE ` + column + ` = ?`), [value]);
218
+ }
219
+ }
220
+
221
+ function clearTable(db, table) {
222
+ if (tableExists(db, table)) {
223
+ return SqliteDriver$ReventlessLocal.exec(db, `DELETE FROM ` + table);
224
+ }
225
+ }
226
+
227
+ function build(db, root, scope) {
228
+ let plugins = readPlugins(db, root);
229
+ let platformQdb = platformQueryables.map(qdbTableName);
230
+ let platformLogs = platformWritables.map(aggregateLogName);
231
+ let selectedPlugins;
232
+ if (typeof scope !== "object") {
233
+ switch (scope) {
234
+ case "Platform" :
235
+ selectedPlugins = [];
236
+ break;
237
+ case "Domain" :
238
+ case "Everything" :
239
+ selectedPlugins = plugins;
240
+ break;
241
+ }
242
+ } else {
243
+ let name = scope._0;
244
+ selectedPlugins = plugins.filter(p => p.plugin === name);
245
+ }
246
+ let claimsQdb = table => {
247
+ if (typeof scope === "object") {
248
+ return selectedPlugins.some(p => p.queryables.some(q => qdbTableName(q) === table));
249
+ }
250
+ switch (scope) {
251
+ case "Domain" :
252
+ return !platformQdb.includes(table);
253
+ case "Platform" :
254
+ return platformQdb.includes(table);
255
+ case "Everything" :
256
+ return true;
257
+ }
258
+ };
259
+ let claimsLog = log => {
260
+ if (typeof scope === "object") {
261
+ return selectedPlugins.some(p => p.writables.some(w => {
262
+ if (aggregateLogName(w) === log) {
263
+ return true;
264
+ } else {
265
+ return w === log;
266
+ }
267
+ }));
268
+ }
269
+ switch (scope) {
270
+ case "Domain" :
271
+ return !platformLogs.includes(log);
272
+ case "Platform" :
273
+ return platformLogs.includes(log);
274
+ case "Everything" :
275
+ return true;
276
+ }
277
+ };
278
+ let claimedQdb = qdbTables(db).filter(claimsQdb);
279
+ let claimedComponents = claimedQdb.map(t => t.slice("qdb_".length, t.length));
280
+ let items = [];
281
+ claimedQdb.forEach(table => {
282
+ items.push({
283
+ label: table,
284
+ count: countAll(db, table),
285
+ run: () => clearTable(db, table)
286
+ });
287
+ });
288
+ [
289
+ "event_log",
290
+ "snapshot"
291
+ ].forEach(table => {
292
+ distinct(db, table, "log_name").filter(claimsLog).forEach(log => {
293
+ items.push({
294
+ label: table + ` (` + log + `)`,
295
+ count: countWhere(db, table, "log_name", log),
296
+ run: () => deleteWhere(db, table, "log_name", log)
297
+ });
298
+ });
299
+ });
300
+ [
301
+ "dcb_event",
302
+ "dcb_tag"
303
+ ].forEach(table => {
304
+ distinct(db, table, "log_name").filter(claimsLog).forEach(log => {
305
+ items.push({
306
+ label: table + ` (` + log + `)`,
307
+ count: countWhere(db, table, "log_name", log),
308
+ run: () => deleteWhere(db, table, "log_name", log)
309
+ });
310
+ });
311
+ });
312
+ distinct(db, "projection_checkpoint", "read_model").filter(rm => claimedComponents.includes(checkpointComponent(rm))).forEach(rm => {
313
+ items.push({
314
+ label: `projection_checkpoint (` + rm + `)`,
315
+ count: countWhere(db, "projection_checkpoint", "read_model", rm),
316
+ run: () => deleteWhere(db, "projection_checkpoint", "read_model", rm)
317
+ });
318
+ });
319
+ let claimedPrefixes;
320
+ if (typeof scope !== "object") {
321
+ switch (scope) {
322
+ case "Platform" :
323
+ claimedPrefixes = [];
324
+ break;
325
+ case "Domain" :
326
+ case "Everything" :
327
+ claimedPrefixes = ObjectStoreStorage_FileSystem$ReventlessLocal.topLevelPrefixes(root);
328
+ break;
329
+ }
330
+ } else {
331
+ claimedPrefixes = selectedPlugins.flatMap(p => p.stores.map(LocalObjectStore$ReventlessLocal.localPrefixFor));
332
+ }
333
+ claimedPrefixes.forEach(prefix => {
334
+ let count = ObjectStoreStorage_FileSystem$ReventlessLocal.keysUnder(root, prefix).length;
335
+ if (count > 0) {
336
+ items.push({
337
+ label: `objects/` + prefix + `/`,
338
+ count: count,
339
+ run: () => {
340
+ ObjectStoreStorage_FileSystem$ReventlessLocal.deleteUnder(root, prefix);
341
+ }
342
+ });
343
+ return;
344
+ }
345
+ });
346
+ let exit = 0;
347
+ if (typeof scope !== "object") {
348
+ switch (scope) {
349
+ case "Domain" :
350
+ break;
351
+ case "Platform" :
352
+ case "Everything" :
353
+ exit = 1;
354
+ break;
355
+ }
356
+ }
357
+ if (exit === 1) {
358
+ let count = ObjectStoreStorage_FileSystem$ReventlessLocal.offloadKeys(root).length;
359
+ if (count > 0) {
360
+ items.push({
361
+ label: "offload/",
362
+ count: count,
363
+ run: () => {
364
+ ObjectStoreStorage_FileSystem$ReventlessLocal.deleteOffloadAll(root);
365
+ }
366
+ });
367
+ }
368
+ }
369
+ let unattributed;
370
+ if (typeof scope !== "object") {
371
+ unattributed = [];
372
+ } else {
373
+ let claimedByAnyPlugin = plugins.flatMap(p => p.queryables.map(qdbTableName));
374
+ unattributed = qdbTables(db).filter(t => {
375
+ if (platformQdb.includes(t)) {
376
+ return false;
377
+ } else {
378
+ return !claimedByAnyPlugin.includes(t);
379
+ }
380
+ });
381
+ }
382
+ return {
383
+ scope: scope,
384
+ items: items,
385
+ unattributed: unattributed
386
+ };
387
+ }
388
+
389
+ function describe(p) {
390
+ console.log("");
391
+ console.log(`Reset scope: ` + scopeLabel(p.scope));
392
+ console.log("");
393
+ if (p.items.length === 0) {
394
+ console.log(" (nothing — this scope already reads empty)");
395
+ } else {
396
+ p.items.forEach(i => {
397
+ console.log(` ` + i.count.toString().padStart(7, " ") + ` ` + i.label);
398
+ });
399
+ }
400
+ if (p.unattributed.length !== 0) {
401
+ console.log("");
402
+ console.log(` Left alone — no connected plugin's structure claims them (widen the scope to include them):`);
403
+ p.unattributed.forEach(t => {
404
+ console.log(` ` + t);
405
+ });
406
+ }
407
+ console.log("");
408
+ }
409
+
410
+ function execute(db, p) {
411
+ SqliteDriver$ReventlessLocal.transaction(db, () => {
412
+ p.items.forEach(i => i.run());
413
+ });
414
+ }
415
+
416
+ function scopeOptions(plugins) {
417
+ return [[
418
+ "domain",
419
+ "Domain"
420
+ ]].concat(plugins.map(p => [
421
+ p,
422
+ {
423
+ TAG: "OnePlugin",
424
+ _0: p
425
+ }
426
+ ])).concat([
427
+ [
428
+ "platform",
429
+ "Platform"
430
+ ],
431
+ [
432
+ "everything",
433
+ "Everything"
434
+ ]
435
+ ]);
436
+ }
437
+
438
+ function run(dbPath) {
439
+ let go = async () => {
440
+ let resolved;
441
+ if (dbPath !== undefined) {
442
+ resolved = dbPath;
443
+ } else {
444
+ let match = Backend$ReventlessLocal.fromEnv();
445
+ if (typeof match !== "object") {
446
+ console.log("Nothing to reset — REVENTLESS_LOCAL_BACKEND selects an in-memory store, which a restart already empties.");
447
+ resolved = undefined;
448
+ } else if (match.TAG === "Sqlite") {
449
+ let path = match.path;
450
+ if (path !== ":memory:") {
451
+ resolved = path;
452
+ } else {
453
+ console.log("Nothing to reset — REVENTLESS_LOCAL_BACKEND selects an in-memory store, which a restart already empties.");
454
+ resolved = undefined;
455
+ }
456
+ } else {
457
+ console.log("Nothing to reset here — the Postgres backend keeps its event logs off this machine. Reset it against the database.");
458
+ resolved = undefined;
459
+ }
460
+ }
461
+ if (resolved === undefined) {
462
+ return;
463
+ }
464
+ if (Nodefs.existsSync(resolved)) {
465
+ let root = Nodepath.dirname(resolved);
466
+ let db = SqliteDriver$ReventlessLocal.openDb(resolved);
467
+ let plugins = readPlugins(db, root).map(p => p.plugin);
468
+ let scope = await Seed_Prompt$ReventlessSeed.select("Reset scope:", scopeOptions(plugins), "SEED_RESET_SCOPE");
469
+ let plan = build(db, root, scope);
470
+ describe(plan);
471
+ if (total(plan) === 0) {
472
+ console.log("Nothing to do.");
473
+ } else {
474
+ let match$1 = Seed_Prompt$ReventlessSeed.envValue("SEED_RESET_CONFIRM");
475
+ let confirmed;
476
+ let exit = 0;
477
+ if (match$1 !== undefined) {
478
+ switch (match$1) {
479
+ case "1" :
480
+ case "yes" :
481
+ confirmed = true;
482
+ break;
483
+ default:
484
+ exit = 1;
485
+ }
486
+ } else {
487
+ exit = 1;
488
+ }
489
+ if (exit === 1) {
490
+ let answer = await Seed_Prompt$ReventlessSeed.ask(`Empty ` + total(plan).toString() + ` row(s)/object(s) in the "` + scopeLabel(plan.scope) + `" scope? [y/N]: `);
491
+ confirmed = answer.trim().toLowerCase() === "y";
492
+ }
493
+ if (confirmed) {
494
+ execute(db, plan);
495
+ console.log(`Reset complete — the "` + scopeLabel(plan.scope) + `" scope reads empty and is re-seedable.`);
496
+ } else {
497
+ console.log("Nothing was deleted.");
498
+ }
499
+ }
500
+ Seed_Prompt$ReventlessSeed.close();
501
+ return SqliteDriver$ReventlessLocal.close(db);
502
+ }
503
+ console.log(`Nothing to reset — no store at ` + resolved + `.`);
504
+ };
505
+ go();
506
+ }
507
+
508
+ export {
509
+ scopeLabel,
510
+ platformQueryables,
511
+ platformWritables,
512
+ qdbTableName,
513
+ aggregateLogName,
514
+ checkpointComponent,
515
+ tableExists,
516
+ strings,
517
+ qdbTables,
518
+ distinct,
519
+ countWhere,
520
+ countAll,
521
+ namesIn,
522
+ resolveStructure,
523
+ readPlugins,
524
+ total,
525
+ deleteWhere,
526
+ clearTable,
527
+ build,
528
+ describe,
529
+ execute,
530
+ scopeOptions,
531
+ run,
532
+ }
533
+ /* node:fs Not a pure module */
@@ -205,6 +205,57 @@ describe("Backend parity (Memory vs Sqlite)", () => {
205
205
  await runUnderSqlite(scenario)
206
206
  })
207
207
 
208
+ testPromise("QueryDb: a descriptor carries the saved row and a rising seq under both", async () => {
209
+ let scenario = async () => {
210
+ module TestBus = LocalBus.Make()
211
+ module Storage = LocalQueryDbStorage.Make(TestBus)
212
+ let s = Storage.make(
213
+ ~name="parity-payload",
214
+ ~indexes=[],
215
+ ~api=(),
216
+ ~apiRole=(),
217
+ ~owner=None,
218
+ ~opts,
219
+ )
220
+ let ops = await s.operations->TestRunner.resolve
221
+
222
+ let descriptors = []
223
+ TestBus.subscribeToStateChanges("parity-payload", d => descriptors->Array.push(d))
224
+
225
+ let field = (d, key) => d->JSON.Decode.object->Option.flatMap(o => o->Dict.get(key))
226
+ let item = (k, v) =>
227
+ JSON.Encode.object(
228
+ Dict.fromArray([("id", JSON.Encode.string(k)), ("v", JSON.Encode.string(v))]),
229
+ )
230
+ let stateAt = i => descriptors->Array.get(i)->Option.flatMap(d => field(d, "state"))
231
+
232
+ let _ = await ops.save("p1", item("p1", "one"), ReventlessCore.QueryDb.Any, None)
233
+ let _ = await ops.save("p1", item("p1", "two"), ReventlessCore.QueryDb.Any, None)
234
+ let _ = await ops.delete("p1", None)
235
+
236
+ // The point of the payload: a subscriber can apply the row it was handed
237
+ // instead of spending a round-trip to fetch what the platform already had.
238
+ expect(stateAt(0))->toEqual(Some(item("p1", "one")))
239
+ expect(stateAt(1))->toEqual(Some(item("p1", "two")))
240
+ // A delete has no new row to carry.
241
+ expect(stateAt(2))->toEqual(None)
242
+
243
+ // seq only has to rise — it is monotonic, not consecutive, so a client can
244
+ // reject a stale payload but cannot count gaps.
245
+ let seqs =
246
+ descriptors->Array.filterMap(d =>
247
+ field(d, "seq")->Option.flatMap(JSON.Decode.string)->Option.flatMap(Float.fromString)
248
+ )
249
+ expect(seqs->Array.length)->toBe(3)
250
+ let rising =
251
+ seqs->Array.everyWithIndex((v, i) => i == 0 || v > seqs->Array.getUnsafe(i - 1))
252
+ expect(rising)->toBe(true)
253
+ }
254
+
255
+ await runUnderMemory(scenario)
256
+ await runUnderSqlite(scenario)
257
+ })
258
+
208
259
  testPromise("QueryDb: saveBatch reports Added then Updated for a repeated key under both", async () => {
209
260
  let scenario = async () => {
210
261
  module TestBus = LocalBus.Make()
@@ -2,6 +2,8 @@
2
2
 
3
3
  import * as Stream from "@reventlessdev/rescript-effect/src/Stream.res.mjs";
4
4
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
+ import * as Stdlib_Float from "@rescript/runtime/lib/es6/Stdlib_Float.js";
5
7
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
8
  import * as Effect from "effect/Effect";
7
9
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
@@ -173,6 +175,48 @@ globalThis.describe("Backend parity (Memory vs Sqlite)", () => {
173
175
  await runUnderMemory(scenario);
174
176
  return await runUnderSqlite(scenario);
175
177
  });
178
+ globalThis.test("QueryDb: a descriptor carries the saved row and a rising seq under both", async () => {
179
+ let scenario = async () => {
180
+ let TestBus = LocalBus$ReventlessLocal.Make({});
181
+ let Storage = LocalQueryDbStorage$ReventlessLocal.Make(TestBus);
182
+ let s = Storage.make("parity-payload", [], undefined, undefined, undefined, undefined, undefined, opts);
183
+ let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
184
+ let descriptors = [];
185
+ TestBus.subscribeToStateChanges("parity-payload", d => {
186
+ descriptors.push(d);
187
+ });
188
+ let field = (d, key) => Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(d), o => o[key]);
189
+ let item = (k, v) => Object.fromEntries([
190
+ [
191
+ "id",
192
+ k
193
+ ],
194
+ [
195
+ "v",
196
+ v
197
+ ]
198
+ ]);
199
+ let stateAt = i => Stdlib_Option.flatMap(descriptors[i], d => field(d, "state"));
200
+ await ops.save("p1", item("p1", "one"), "Any", undefined);
201
+ await ops.save("p1", item("p1", "two"), "Any", undefined);
202
+ await ops.delete("p1", undefined);
203
+ globalThis.expect(stateAt(0)).toEqual(item("p1", "one"));
204
+ globalThis.expect(stateAt(1)).toEqual(item("p1", "two"));
205
+ globalThis.expect(stateAt(2)).toEqual(undefined);
206
+ let seqs = Stdlib_Array.filterMap(descriptors, d => Stdlib_Option.flatMap(Stdlib_Option.flatMap(field(d, "seq"), Stdlib_JSON.Decode.string), Stdlib_Float.fromString));
207
+ globalThis.expect(seqs.length).toBe(3);
208
+ let rising = seqs.every((v, i) => {
209
+ if (i === 0) {
210
+ return true;
211
+ } else {
212
+ return v > seqs[i - 1 | 0];
213
+ }
214
+ });
215
+ globalThis.expect(rising).toBe(true);
216
+ };
217
+ await runUnderMemory(scenario);
218
+ return await runUnderSqlite(scenario);
219
+ });
176
220
  globalThis.test("QueryDb: saveBatch reports Added then Updated for a repeated key under both", async () => {
177
221
  let scenario = async () => {
178
222
  let TestBus = LocalBus$ReventlessLocal.Make({});
@@ -77,8 +77,8 @@ describe("LocalGraphQL_SubscriptionResolvers", () => {
77
77
  let consumerPromise = startConsumer(iter, 1000)
78
78
  await yieldTick() // ensure listener is registered before publish
79
79
 
80
- // Bus payload is now the change descriptor (Phase 8) matching the
81
- // AWS StateTopic Lambda output shape: {changeKind, id, sortKeyValue?}.
80
+ // Bus payload is the change descriptor matching the AWS StateTopic Lambda
81
+ // output shape: {changeKind, id, sortKeyValue?, seq, state?}.
82
82
  let state = JSON.Encode.object(
83
83
  Dict.fromArray([
84
84
  ("id", JSON.Encode.string("prod-1")),
@@ -86,10 +86,11 @@ describe("LocalGraphQL_SubscriptionResolvers", () => {
86
86
  ("updatedAt", JSON.Encode.string("2026-05-19T12:00:00Z")),
87
87
  ]),
88
88
  )
89
- let descriptor = LocalBus.makeStateChangeDescriptor(
89
+ let descriptor = LocalStateChangeDescriptor.make(
90
90
  ~changeKind="Updated",
91
91
  ~id="prod-1",
92
92
  ~state=Some(state),
93
+ ~seq=LocalStateChangeDescriptor.nextSequence(),
93
94
  )
94
95
  TestBus.publishStateChange(~name="Product", ~descriptor)
95
96
 
@@ -115,10 +116,11 @@ describe("LocalGraphQL_SubscriptionResolvers", () => {
115
116
  await yieldTick()
116
117
 
117
118
  // Publish for a DIFFERENT ReadModel — should NOT reach our topic.
118
- let foreignDescriptor = LocalBus.makeStateChangeDescriptor(
119
+ let foreignDescriptor = LocalStateChangeDescriptor.make(
119
120
  ~changeKind="Updated",
120
121
  ~id="cat-1",
121
122
  ~state=Some(JSON.Encode.object(Dict.fromArray([("id", JSON.Encode.string("cat-1"))]))),
123
+ ~seq=LocalStateChangeDescriptor.nextSequence(),
122
124
  )
123
125
  TestBus.publishStateChange(~name="Category", ~descriptor=foreignDescriptor)
124
126