@reventlessdev/reventless-local 3.0.0-alpha.114 → 3.0.0-alpha.116

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 (65) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/package.json +8 -8
  3. package/src/Platform.res +116 -67
  4. package/src/Platform.res.mjs +114 -190
  5. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res +102 -17
  6. package/src/adapter/DcbEventLog/DcbEventLogStorage_InMemory.res.mjs +105 -22
  7. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +123 -40
  8. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +96 -24
  9. package/src/adapter/EventCollector/LocalEventCollectorChannel.res +33 -0
  10. package/src/adapter/EventCollector/LocalEventCollectorChannel.res.mjs +44 -0
  11. package/src/adapter/EventLog/EventLogStorage_InMemory.res +24 -3
  12. package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +21 -4
  13. package/src/adapter/EventLog/EventLogStorage_Sqlite.res +148 -27
  14. package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +144 -34
  15. package/src/adapter/LocalBus.res +85 -3
  16. package/src/adapter/LocalBus.res.mjs +220 -66
  17. package/src/adapter/ProjectionCheckpoint.res +350 -0
  18. package/src/adapter/ProjectionCheckpoint.res.mjs +327 -0
  19. package/src/adapter/ProjectionPending.res +60 -0
  20. package/src/adapter/ProjectionPending.res.mjs +64 -0
  21. package/src/adapter/QueryDb/QueryDbListQuery.res +261 -0
  22. package/src/adapter/QueryDb/QueryDbListQuery.res.mjs +212 -0
  23. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +54 -286
  24. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +28 -190
  25. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +128 -10
  26. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +123 -23
  27. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +187 -12
  28. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +120 -7
  29. package/src/adapter/SqliteDriver.res +17 -2
  30. package/src/adapter/SqliteDriver.res.mjs +10 -2
  31. package/src/components/DcbEventLog_Builder.res +7 -1
  32. package/src/components/DcbEventLog_Builder.res.mjs +3 -3
  33. package/src/components/ReadModel_Builder.res +3 -1
  34. package/src/components/ReadModel_Builder.res.mjs +13 -4
  35. package/src/components/StateViewSlice_Builder.res +4 -1
  36. package/src/components/StateViewSlice_Builder.res.mjs +9 -3
  37. package/src/test/Mocks/MockEventLogStorage.res +20 -3
  38. package/src/test/Mocks/MockEventLogStorage.res.mjs +25 -3
  39. package/tests/PluginEventDecodeTest.res +51 -0
  40. package/tests/PluginEventDecodeTest.res.mjs +63 -0
  41. package/tests/adapter/BackendParityTest.res +64 -0
  42. package/tests/adapter/BackendParityTest.res.mjs +45 -0
  43. package/tests/adapter/DcbEventLogStorageSqliteTest.res +73 -0
  44. package/tests/adapter/DcbEventLogStorageSqliteTest.res.mjs +86 -0
  45. package/tests/adapter/DcbEventLogStorageTest.res +46 -0
  46. package/tests/adapter/DcbEventLogStorageTest.res.mjs +63 -0
  47. package/tests/adapter/EventLogSnapshotParityTest.res +81 -0
  48. package/tests/adapter/EventLogSnapshotParityTest.res.mjs +141 -0
  49. package/tests/adapter/EventLogStorageSqliteTest.res +132 -2
  50. package/tests/adapter/EventLogStorageSqliteTest.res.mjs +195 -1
  51. package/tests/adapter/LocalBusPubSubTest.res +31 -0
  52. package/tests/adapter/LocalBusPubSubTest.res.mjs +28 -0
  53. package/tests/adapter/ProjectionCheckpointTest.res +422 -0
  54. package/tests/adapter/ProjectionCheckpointTest.res.mjs +401 -0
  55. package/tests/adapter/QueryDbGsiTtlTest.res +78 -0
  56. package/tests/adapter/QueryDbGsiTtlTest.res.mjs +77 -0
  57. package/tests/adapter/QueryDbListPushdownParityTest.res +197 -0
  58. package/tests/adapter/QueryDbListPushdownParityTest.res.mjs +292 -0
  59. package/tests/adapter/QueryDbListResolverTest.res +76 -0
  60. package/tests/adapter/QueryDbListResolverTest.res.mjs +84 -0
  61. package/tests/components/aggregate/AggregateFixtures.res +4 -0
  62. package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -0
  63. package/tests/components/eventlog/EventLogAppendStreamTest.res.mjs +4 -4
  64. package/tests/components/eventlog/EventLogStreamTest.res.mjs +8 -8
  65. package/tests/plugin/PluginBehavior_GWT.res.mjs +1 -0
@@ -4,8 +4,10 @@ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Stream from "effect/Stream";
6
6
  import * as Pulumi from "@pulumi/pulumi";
7
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
7
8
  import * as LocalBus$ReventlessLocal from "../LocalBus.res.mjs";
8
9
  import * as SqliteDriver$ReventlessLocal from "../SqliteDriver.res.mjs";
10
+ import * as QueryDbListQuery$ReventlessLocal from "./QueryDbListQuery.res.mjs";
9
11
 
10
12
  function tableName(name) {
11
13
  return "qdb_" + name.replaceAll("-", "_");
@@ -146,12 +148,27 @@ function makeStorage(db, bus, name, indexes, subIdField) {
146
148
  let deleteBySubKeyStmt = SqliteDriver$ReventlessLocal.prepare(db, `DELETE FROM ` + table + ` WHERE partition_key = ? AND sub_key = ?`);
147
149
  let scanAllStmt = SqliteDriver$ReventlessLocal.prepare(db, `SELECT partition_key, sub_key, item FROM ` + table + ` WHERE ` + notExpiredClause + ` ORDER BY partition_key, sub_key`);
148
150
  let rowsFor = id => SqliteDriver$ReventlessLocal.all(selectByPartitionStmt, [id]).map(decodeItem);
149
- let allRows = () => SqliteDriver$ReventlessLocal.all(scanAllStmt, []).map(row => {
151
+ let rowToItem = row => {
150
152
  let item = decodeItem(row);
151
153
  let match = row["partition_key"];
152
154
  let partition = typeof match === "string" ? match : "";
153
155
  return withId(item, partition);
154
- });
156
+ };
157
+ let allRows = () => SqliteDriver$ReventlessLocal.all(scanAllStmt, []).map(rowToItem);
158
+ let indexLookupStmts = {};
159
+ let lookupByField = (field, value) => {
160
+ let s = indexLookupStmts[field];
161
+ let stmt;
162
+ if (s !== undefined) {
163
+ stmt = Primitive_option.valFromOption(s);
164
+ } else {
165
+ let escapedField = field.replaceAll("'", "''");
166
+ let s$1 = SqliteDriver$ReventlessLocal.prepare(db, `SELECT partition_key, sub_key, item FROM ` + table + ` WHERE json_extract(item, '$.` + escapedField + `') = ? AND ` + notExpiredClause);
167
+ indexLookupStmts[field] = s$1;
168
+ stmt = s$1;
169
+ }
170
+ return SqliteDriver$ReventlessLocal.all(stmt, [value]).map(rowToItem);
171
+ };
155
172
  let saveOne = (id, state, ttl) => {
156
173
  let subKey = computeSubKey(state, subIdField);
157
174
  let expiresAt = ttl !== undefined ? ttl : null;
@@ -209,10 +226,25 @@ function makeStorage(db, bus, name, indexes, subIdField) {
209
226
  _0: undefined
210
227
  };
211
228
  };
212
- let count = async (_id, _fieldName, inc) => ({
213
- TAG: "Ok",
214
- _0: inc
215
- });
229
+ let count = async (id, fieldName, inc) => {
230
+ let existing = Stdlib_Option.flatMap(rowsFor(id)[0], Stdlib_JSON.Decode.object);
231
+ let current = Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(existing, o => o[fieldName]), Stdlib_JSON.Decode.float), 0, prim => prim | 0);
232
+ let next = current + inc | 0;
233
+ let obj = {};
234
+ if (existing !== undefined) {
235
+ Object.entries(existing).forEach(param => {
236
+ obj[param[0]] = param[1];
237
+ });
238
+ }
239
+ obj["id"] = id;
240
+ obj[fieldName] = next;
241
+ saveOne(id, obj, undefined);
242
+ publishUpdated(id, obj);
243
+ return {
244
+ TAG: "Ok",
245
+ _0: next
246
+ };
247
+ };
216
248
  let deleteOne = (id, subIdOpt) => {
217
249
  if (subIdOpt !== undefined) {
218
250
  return SqliteDriver$ReventlessLocal.run(deleteBySubKeyStmt, [
@@ -272,6 +304,83 @@ function makeStorage(db, bus, name, indexes, subIdField) {
272
304
  bus.registerQueryDb(name, ops);
273
305
  bus.registerQueryDbScan(name, () => allRows());
274
306
  bus.registerQueryDbStream(name, () => Stream.fromIterable(allRows()));
307
+ bus.registerQueryDbIndexLookup(name, lookupByField);
308
+ let listStmtCache = {};
309
+ let preparedList = sql => {
310
+ let s = listStmtCache[sql];
311
+ if (s !== undefined) {
312
+ return Primitive_option.valFromOption(s);
313
+ }
314
+ let s$1 = SqliteDriver$ReventlessLocal.prepare(db, sql);
315
+ listStmtCache[sql] = s$1;
316
+ return s$1;
317
+ };
318
+ let jsonText = field => `CAST(json_extract(item, '$.` + field.replaceAll("'", "''") + `') AS TEXT)`;
319
+ let idExpr = "COALESCE(json_extract(item, '$.id'), partition_key)";
320
+ let listPage = (argsDict, capability, param) => {
321
+ let filterDict = Stdlib_Option.getOr(Stdlib_Option.flatMap(argsDict["filter"], Stdlib_JSON.Decode.object), {});
322
+ let strNonEmpty = k => Stdlib_Option.mapOr(Stdlib_Option.flatMap(filterDict[k], Stdlib_JSON.Decode.string), false, s => s.length > 0);
323
+ let hasIds = Stdlib_Option.mapOr(Stdlib_Option.flatMap(filterDict["ids"], Stdlib_JSON.Decode.array), false, a => a.length !== 0);
324
+ let last = Stdlib_Option.flatMap(argsDict["last"], Stdlib_JSON.Decode.float);
325
+ let before = Stdlib_Option.flatMap(argsDict["before"], Stdlib_JSON.Decode.string);
326
+ if (strNonEmpty("search") || strNonEmpty("searchPrefix") || hasIds || Stdlib_Option.isSome(last) || Stdlib_Option.isSome(before)) {
327
+ return;
328
+ }
329
+ let whereParts = [notExpiredClause];
330
+ let params = [];
331
+ let valString = v => {
332
+ let s = Stdlib_JSON.Decode.string(v);
333
+ if (s !== undefined) {
334
+ return s;
335
+ } else {
336
+ return Stdlib_Option.map(Stdlib_JSON.Decode.float(v), f => f.toString());
337
+ }
338
+ };
339
+ capability.filterFields.forEach(f => {
340
+ let v = filterDict[f.name + "Eq"];
341
+ if (v !== undefined && v !== null) {
342
+ whereParts.push(jsonText(f.name) + ` = ?`);
343
+ params.push(Stdlib_Option.getOr(valString(v), ""));
344
+ }
345
+ if (!f.range) {
346
+ return;
347
+ }
348
+ let v$1 = filterDict[f.name + "From"];
349
+ if (v$1 !== undefined && v$1 !== null) {
350
+ whereParts.push(jsonText(f.name) + ` >= ?`);
351
+ params.push(Stdlib_Option.getOr(valString(v$1), ""));
352
+ }
353
+ let v$2 = filterDict[f.name + "To"];
354
+ if (v$2 !== undefined && v$2 !== null) {
355
+ whereParts.push(jsonText(f.name) + ` <= ?`);
356
+ params.push(Stdlib_Option.getOr(valString(v$2), ""));
357
+ return;
358
+ }
359
+ });
360
+ let orderByDict = Stdlib_Option.flatMap(argsDict["orderBy"], Stdlib_JSON.Decode.object);
361
+ let orderByField = Stdlib_Option.flatMap(Stdlib_Option.flatMap(orderByDict, ob => ob["field"]), Stdlib_JSON.Decode.string);
362
+ let isDesc = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(orderByDict, ob => ob["direction"]), Stdlib_JSON.Decode.string), "ASC") === "DESC";
363
+ let cursorExpr = orderByField !== undefined ? jsonText(orderByField) : idExpr;
364
+ let c = Stdlib_Option.flatMap(argsDict["after"], Stdlib_JSON.Decode.string);
365
+ if (c !== undefined) {
366
+ whereParts.push(cursorExpr + ` ` + (
367
+ isDesc ? "<" : ">"
368
+ ) + ` ?`);
369
+ params.push(QueryDbListQuery$ReventlessLocal.decodeCursor(c));
370
+ }
371
+ let orderClause = orderByField !== undefined ? jsonText(orderByField) + ` ` + (
372
+ isDesc ? "DESC" : "ASC"
373
+ ) + `, ` + idExpr + ` ASC` : idExpr + ` ASC`;
374
+ let pageSize = Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(argsDict["first"], Stdlib_JSON.Decode.float), prim => prim | 0), QueryDbListQuery$ReventlessLocal.defaultListPageSize);
375
+ let sql = `SELECT partition_key, item FROM ` + table + ` WHERE ` + whereParts.join(" AND ") + ` ORDER BY ` + orderClause + ` LIMIT ?`;
376
+ let rows = SqliteDriver$ReventlessLocal.all(preparedList(sql), params.concat([pageSize + 1 | 0])).map(rowToItem);
377
+ let hasMore = rows.length > pageSize;
378
+ let pageItems = rows.slice(0, pageSize);
379
+ let cursorField = Stdlib_Option.getOr(orderByField, "id");
380
+ let cursorValueOf = item => Stdlib_Option.getOr(QueryDbListQuery$ReventlessLocal.getFieldString(item, cursorField), QueryDbListQuery$ReventlessLocal.getId(item));
381
+ return QueryDbListQuery$ReventlessLocal.buildConnection(pageItems, hasMore, false, cursorValueOf);
382
+ };
383
+ bus.registerQueryDbListPage(name, listPage);
275
384
  return {
276
385
  resources: [],
277
386
  dataSourceName: Pulumi.output(""),
@@ -285,11 +394,15 @@ function Make(Bus) {
285
394
  let busCallbacks_registerQueryDb = Bus.registerQueryDb;
286
395
  let busCallbacks_registerQueryDbScan = Bus.registerQueryDbScan;
287
396
  let busCallbacks_registerQueryDbStream = Bus.registerQueryDbStream;
397
+ let busCallbacks_registerQueryDbIndexLookup = Bus.registerQueryDbIndexLookup;
398
+ let busCallbacks_registerQueryDbListPage = Bus.registerQueryDbListPage;
288
399
  let busCallbacks = {
289
400
  publishStateChange: busCallbacks_publishStateChange,
290
401
  registerQueryDb: busCallbacks_registerQueryDb,
291
402
  registerQueryDbScan: busCallbacks_registerQueryDbScan,
292
- registerQueryDbStream: busCallbacks_registerQueryDbStream
403
+ registerQueryDbStream: busCallbacks_registerQueryDbStream,
404
+ registerQueryDbIndexLookup: busCallbacks_registerQueryDbIndexLookup,
405
+ registerQueryDbListPage: busCallbacks_registerQueryDbListPage
293
406
  };
294
407
  let make = (name, indexes, subIdField, param, param$1, param$2, param$3) => makeStorage(DbProvider.db, busCallbacks, name, indexes, subIdField);
295
408
  return {
@@ -27,7 +27,17 @@ let openDb = (~path) => {
27
27
  if path !== ":memory:" {
28
28
  mkdirSync(dirname(path), {recursive: true})
29
29
  }
30
- openDatabaseSync(path)
30
+ let db = openDatabaseSync(path)
31
+ // Connection pragmas. WAL lets readers proceed concurrently with a writer and
32
+ // batches fsyncs into the write-ahead log (a no-op for `:memory:`, which stays
33
+ // on its MEMORY journal); `synchronous=NORMAL` is the safe WAL companion —
34
+ // durable across app crashes, only a power-loss window remains — and cuts the
35
+ // per-commit fsync cost that dominated replay-heavy dev sessions;
36
+ // `busy_timeout` makes a momentarily-locked db wait rather than throw SQLITE_BUSY.
37
+ _exec(db, "PRAGMA journal_mode=WAL")
38
+ _exec(db, "PRAGMA synchronous=NORMAL")
39
+ _exec(db, "PRAGMA busy_timeout=5000")
40
+ db
31
41
  }
32
42
  let close = db => _close(db)
33
43
  let exec = (db, sql) => _exec(db, sql)
@@ -46,7 +56,12 @@ let transaction = (db, fn) => {
46
56
  result
47
57
  } catch {
48
58
  | exn =>
49
- exec(db, "ROLLBACK")
59
+ // Roll back, but never let a failing ROLLBACK replace the original error —
60
+ // the original is the diagnostic one; a rollback failure (e.g. no active
61
+ // transaction) would otherwise mask it.
62
+ try exec(db, "ROLLBACK") catch {
63
+ | _ => ()
64
+ }
50
65
  throw(exn)
51
66
  }
52
67
  }
@@ -19,7 +19,11 @@ function openDb(path) {
19
19
  recursive: true
20
20
  });
21
21
  }
22
- return new Nodesqlite.DatabaseSync(path);
22
+ let db = new Nodesqlite.DatabaseSync(path);
23
+ db.exec("PRAGMA journal_mode=WAL");
24
+ db.exec("PRAGMA synchronous=NORMAL");
25
+ db.exec("PRAGMA busy_timeout=5000");
26
+ return db;
23
27
  }
24
28
 
25
29
  function close(db) {
@@ -57,7 +61,11 @@ function transaction(db, fn) {
57
61
  db.exec("COMMIT");
58
62
  return result;
59
63
  } catch (exn) {
60
- db.exec("ROLLBACK");
64
+ try {
65
+ db.exec("ROLLBACK");
66
+ } catch (exn$1) {
67
+
68
+ }
61
69
  throw exn;
62
70
  }
63
71
  }
@@ -3,7 +3,13 @@
3
3
  module Make = (Bus: LocalBus.T) => {
4
4
  module EventTopicPublisher = LocalEventTopicPublisher.Make(Bus)
5
5
 
6
- module Inner = ReventlessCore.DcbEventLog_Builder.Make(DcbEventLogStorage_InMemory, EventTopicPublisher)
6
+ // Use the backend-aware storage functor (as Platform does), not the plain
7
+ // module: the functor consults BackendState so a standalone DCB log honours
8
+ // REVENTLESS_LOCAL_BACKEND=sqlite, and it registers the read with the Bus so
9
+ // `Bus.getDcbEventLogRead` can see it. The plain module did neither — the log
10
+ // silently stayed in memory under SQLite and was invisible to bus readers.
11
+ module Storage = DcbEventLogStorage_InMemory.Make(Bus)
12
+ module Inner = ReventlessCore.DcbEventLog_Builder.Make(Storage, EventTopicPublisher)
7
13
  type component = ReventlessInfra.DcbEventLog.component
8
14
  let make: (
9
15
  ~name: string,
@@ -7,11 +7,11 @@ import * as DcbEventLogStorage_InMemory$ReventlessLocal from "../adapter/DcbEven
7
7
 
8
8
  function Make(Bus) {
9
9
  let EventTopicPublisher = LocalEventTopicPublisher$ReventlessLocal.Make(Bus);
10
- let Inner = DcbEventLog_Builder$ReventlessCore.Make({
11
- make: DcbEventLogStorage_InMemory$ReventlessLocal.make
12
- })(EventTopicPublisher);
10
+ let Storage = DcbEventLogStorage_InMemory$ReventlessLocal.Make(Bus);
11
+ let Inner = DcbEventLog_Builder$ReventlessCore.Make(Storage)(EventTopicPublisher);
13
12
  return {
14
13
  EventTopicPublisher: EventTopicPublisher,
14
+ Storage: Storage,
15
15
  Inner: Inner,
16
16
  make: Inner.make,
17
17
  operations: Component$ReventlessInfra.operations
@@ -2,7 +2,9 @@
2
2
 
3
3
  module Make = (Bus: LocalBus.T) => {
4
4
  module RuntimeEnvironment = LocalRuntimeEnvironment
5
- module EventCollectorChannel = LocalEventCollectorChannel.Make(Bus)
5
+ // MakeProjection (not Make): also registers the resolved handler for the
6
+ // SQLite backend's startup projection catch-up (ProjectionCheckpoint).
7
+ module EventCollectorChannel = LocalEventCollectorChannel.MakeProjection(Bus)
6
8
  module EventCollectorRuntimeBuilder = LocalEventCollectorRuntime_Builder.Make(
7
9
  Bus,
8
10
  EventCollectorChannel,
@@ -9,8 +9,11 @@ import * as LocalEventCollectorChannel$ReventlessLocal from "../adapter/EventCol
9
9
  import * as LocalEventCollectorRuntime_Builder$ReventlessLocal from "../adapter/Runtime/LocalEventCollectorRuntime_Builder.res.mjs";
10
10
 
11
11
  function Make(Bus) {
12
- let EventCollectorChannel = LocalEventCollectorChannel$ReventlessLocal.Make(Bus);
13
- let EventCollectorRuntimeBuilder = LocalEventCollectorRuntime_Builder$ReventlessLocal.Make(Bus)(EventCollectorChannel);
12
+ let EventCollectorChannel = LocalEventCollectorChannel$ReventlessLocal.MakeProjection(Bus);
13
+ let EventCollectorRuntimeBuilder = LocalEventCollectorRuntime_Builder$ReventlessLocal.Make(Bus)({
14
+ make: EventCollectorChannel.make,
15
+ connect: EventCollectorChannel.connect
16
+ });
14
17
  let QueryDbStorage = QueryDbStorage_InMemory$ReventlessLocal.Make(Bus);
15
18
  let QueryDbResolvers = QueryDbResolvers_GraphQL$ReventlessLocal.Make(Bus);
16
19
  let Make$1 = Spec => (Mappings => ReadModel_Builder$ReventlessCore.Make(Spec)(Mappings)({
@@ -23,7 +26,10 @@ function Make(Bus) {
23
26
  make: QueryDbStorage.make
24
27
  })({
25
28
  make: QueryDbResolvers.make
26
- })(EventCollectorChannel)(EventCollectorRuntimeBuilder));
29
+ })({
30
+ make: EventCollectorChannel.make,
31
+ connect: EventCollectorChannel.connect
32
+ })(EventCollectorRuntimeBuilder));
27
33
  let NoResolvers = QueryDb_Adapter$ReventlessCore.NoResolvers({
28
34
  make: QueryDbStorage.make
29
35
  });
@@ -35,7 +41,10 @@ function Make(Bus) {
35
41
  asEffectHandler: prim => prim
36
42
  })({
37
43
  make: QueryDbStorage.make
38
- })(NoResolvers)(EventCollectorChannel)(EventCollectorRuntimeBuilder));
44
+ })(NoResolvers)({
45
+ make: EventCollectorChannel.make,
46
+ connect: EventCollectorChannel.connect
47
+ })(EventCollectorRuntimeBuilder));
39
48
  return {
40
49
  RuntimeEnvironment: undefined,
41
50
  EventCollectorChannel: EventCollectorChannel,
@@ -3,7 +3,10 @@
3
3
 
4
4
  module Make = (Bus: LocalBus.T) => {
5
5
  module RuntimeEnvironment = LocalRuntimeEnvironment
6
- module EventCollectorChannel = LocalEventCollectorChannel.Make(Bus)
6
+ // MakeProjection (not Make): also registers the resolved handler for the
7
+ // SQLite backend's startup projection catch-up (ProjectionCheckpoint) —
8
+ // StateViewSlices are pure projections fed by DCB events.
9
+ module EventCollectorChannel = LocalEventCollectorChannel.MakeProjection(Bus)
7
10
  module EventCollectorRuntimeBuilder = LocalEventCollectorRuntime_Builder.Make(
8
11
  Bus,
9
12
  EventCollectorChannel,
@@ -9,8 +9,11 @@ import * as LocalEventCollectorChannel$ReventlessLocal from "../adapter/EventCol
9
9
  import * as LocalEventCollectorRuntime_Builder$ReventlessLocal from "../adapter/Runtime/LocalEventCollectorRuntime_Builder.res.mjs";
10
10
 
11
11
  function Make(Bus) {
12
- let EventCollectorChannel = LocalEventCollectorChannel$ReventlessLocal.Make(Bus);
13
- let EventCollectorRuntimeBuilder = LocalEventCollectorRuntime_Builder$ReventlessLocal.Make(Bus)(EventCollectorChannel);
12
+ let EventCollectorChannel = LocalEventCollectorChannel$ReventlessLocal.MakeProjection(Bus);
13
+ let EventCollectorRuntimeBuilder = LocalEventCollectorRuntime_Builder$ReventlessLocal.Make(Bus)({
14
+ make: EventCollectorChannel.make,
15
+ connect: EventCollectorChannel.connect
16
+ });
14
17
  let QueryDbStorage = QueryDbStorage_InMemory$ReventlessLocal.Make(Bus);
15
18
  let QueryDbResolvers = QueryDbResolvers_GraphQL$ReventlessLocal.Make(Bus);
16
19
  let api = () => {};
@@ -29,7 +32,10 @@ function Make(Bus) {
29
32
  make: QueryDbStorage.make
30
33
  })({
31
34
  make: QueryDbResolvers.make
32
- })(EventCollectorChannel)(EventCollectorRuntimeBuilder)(Api);
35
+ })({
36
+ make: EventCollectorChannel.make,
37
+ connect: EventCollectorChannel.connect
38
+ })(EventCollectorRuntimeBuilder)(Api);
33
39
  let Make$1 = Spec => (Projection => {
34
40
  let Inner = CoreMaker.Make(Spec)(Projection);
35
41
  return {
@@ -15,7 +15,7 @@ let make = (~name as _="mock-event-log", ~opts as _: Pulumi.CustomResourceOption
15
15
  let append: ReventlessCore.EventLog.append<string, JSON.t> = async (_seqNr, id, jsons) => {
16
16
  if failNextAppends.contents > 0 {
17
17
  failNextAppends := failNextAppends.contents - 1
18
- Error("mock append failure")
18
+ Error(ReventlessCore.EventLog.StorageFailure("mock append failure"))
19
19
  } else {
20
20
  let existing = events.contents->Dict.get(id)->Option.getOr([])
21
21
  events.contents->Dict.set(id, existing->Array.concat(jsons))
@@ -27,8 +27,22 @@ let make = (~name as _="mock-event-log", ~opts as _: Pulumi.CustomResourceOption
27
27
  events.contents->Dict.get(id)->Option.getOr([])
28
28
  }
29
29
 
30
- let replayStream: string => Stream.t<JSON.t, string, unit> = id =>
31
- events.contents->Dict.get(id)->Option.getOr([])->Stream.fromIterable
30
+ let replayStream = (id, ~fromSeq=0) =>
31
+ events.contents
32
+ ->Dict.get(id)
33
+ ->Option.getOr([])
34
+ ->Array.filterWithIndex((_, i) => i >= fromSeq)
35
+ ->Stream.fromIterable
36
+
37
+ let snapshots: dict<ReventlessCore.EventLog.snapshot> = Dict.make()
38
+
39
+ let latestSnapshot: ReventlessCore.EventLog.latestSnapshot<string> = async id =>
40
+ Ok(snapshots->Dict.get(id))
41
+
42
+ let writeSnapshot: ReventlessCore.EventLog.writeSnapshot<string> = async (id, snap) => {
43
+ snapshots->Dict.set(id, snap)
44
+ Ok()
45
+ }
32
46
 
33
47
  let appendStream: ReventlessCore.EventLog.appendStream<string, JSON.t> = (_startingSeqNr, id, stream) =>
34
48
  stream->Stream.runForEach(json =>
@@ -45,11 +59,14 @@ let make = (~name as _="mock-event-log", ~opts as _: Pulumi.CustomResourceOption
45
59
  replay,
46
60
  replayStream,
47
61
  appendStream,
62
+ latestSnapshot,
63
+ writeSnapshot,
48
64
  }),
49
65
  }
50
66
 
51
67
  let reset = () => {
52
68
  events := Dict.make()
69
+ snapshots->Dict.forEachWithKey((_, k) => snapshots->Dict.delete(k))
53
70
  failNextAppends := 0
54
71
  }
55
72
 
@@ -1,5 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
3
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
5
  import * as Effect from "effect/Effect";
5
6
  import * as Stream from "effect/Stream";
@@ -21,7 +22,10 @@ function make($staropt$star, $staropt$star$1) {
21
22
  failNextAppends.contents = failNextAppends.contents - 1 | 0;
22
23
  return {
23
24
  TAG: "Error",
24
- _0: "mock append failure"
25
+ _0: {
26
+ TAG: "StorageFailure",
27
+ _0: "mock append failure"
28
+ }
25
29
  };
26
30
  }
27
31
  let existing = Stdlib_Option.getOr(events.contents[id], []);
@@ -32,7 +36,22 @@ function make($staropt$star, $staropt$star$1) {
32
36
  };
33
37
  };
34
38
  let replay = async id => Stdlib_Option.getOr(events.contents[id], []);
35
- let replayStream = id => Stream.fromIterable(Stdlib_Option.getOr(events.contents[id], []));
39
+ let replayStream = (id, fromSeqOpt) => {
40
+ let fromSeq = fromSeqOpt !== undefined ? fromSeqOpt : 0;
41
+ return Stream.fromIterable(Stdlib_Option.getOr(events.contents[id], []).filter((param, i) => i >= fromSeq));
42
+ };
43
+ let snapshots = {};
44
+ let latestSnapshot = async id => ({
45
+ TAG: "Ok",
46
+ _0: snapshots[id]
47
+ });
48
+ let writeSnapshot = async (id, snap) => {
49
+ snapshots[id] = snap;
50
+ return {
51
+ TAG: "Ok",
52
+ _0: undefined
53
+ };
54
+ };
36
55
  let appendStream = (_startingSeqNr, id, stream) => Stream.runForEach(stream, json => Effect.sync(() => {
37
56
  let existing = Stdlib_Option.getOr(events.contents[id], []);
38
57
  events.contents[id] = existing.concat([json]);
@@ -42,7 +61,9 @@ function make($staropt$star, $staropt$star$1) {
42
61
  append: append,
43
62
  replay: replay,
44
63
  replayStream: replayStream,
45
- appendStream: appendStream
64
+ appendStream: appendStream,
65
+ latestSnapshot: latestSnapshot,
66
+ writeSnapshot: writeSnapshot
46
67
  });
47
68
  let storage = {
48
69
  resources: storage_resources,
@@ -50,6 +71,7 @@ function make($staropt$star, $staropt$star$1) {
50
71
  };
51
72
  let reset = () => {
52
73
  events.contents = {};
74
+ Stdlib_Dict.forEachWithKey(snapshots, (param, k) => Stdlib_Dict.$$delete(snapshots, k));
53
75
  failNextAppends.contents = 0;
54
76
  };
55
77
  return {
@@ -0,0 +1,51 @@
1
+ // Regression: subscribeToPluginEvents' Source-C emission decoded the WHOLE bus
2
+ // envelope {id, meta, event} with the bare Plugin event schema — convert mode
3
+ // "succeeded" and then threw a TypeError on every variant's payload access, so
4
+ // the onPluginStatusChange / onUIFragmentChange subscription emissions never
5
+ // fired on the local platform. decodePluginEventEnvelope must decode the
6
+ // payload under "event" and degrade to None on anything malformed.
7
+
8
+ open JestGlobals
9
+
10
+ let _ = TestRunner.setup()
11
+
12
+ let meta = ReventlessCore.Message.generateMeta(~service=ReventlessCore.PluginSpec.name)
13
+
14
+ let envelopeOf = (event: ReventlessCore.PluginSpec.event) =>
15
+ ReventlessCore.Message.composeEventJson'(
16
+ "Catalog",
17
+ meta,
18
+ event->S.reverseConvertToJsonOrThrow(ReventlessCore.PluginSpec.eventSchema),
19
+ )
20
+
21
+ describe("Platform.decodePluginEventEnvelope", () => {
22
+ testSync("decodes a VersionConnected event from the published envelope", () => {
23
+ let envelope = envelopeOf(VersionConnected(Plugin_Fixtures.pluginDefinition))
24
+ switch Platform.decodePluginEventEnvelope(envelope) {
25
+ | Some(VersionConnected(def)) => expect(def.name)->toBe(Plugin_Fixtures.pluginDefinition.name)
26
+ | _ => expect("VersionConnected")->toBe("something else")
27
+ }
28
+ })
29
+
30
+ testSync("decodes a payload-light variant (VersionDetected)", () => {
31
+ let envelope = envelopeOf(VersionDetected("2.0.0"))
32
+ expect(Platform.decodePluginEventEnvelope(envelope))->toEqual(
33
+ Some(ReventlessCore.PluginSpec.VersionDetected("2.0.0")),
34
+ )
35
+ })
36
+
37
+ testSync("returns None for a bare event JSON (no envelope) and malformed input", () => {
38
+ // The old bug class: input that is not the {id, meta, event} envelope.
39
+ let bare =
40
+ ReventlessCore.PluginSpec.VersionDetected("2.0.0")->S.reverseConvertToJsonOrThrow(
41
+ ReventlessCore.PluginSpec.eventSchema,
42
+ )
43
+ expect(Platform.decodePluginEventEnvelope(bare))->toEqual(None)
44
+ expect(Platform.decodePluginEventEnvelope(JSON.Encode.string("junk")))->toEqual(None)
45
+ expect(
46
+ Platform.decodePluginEventEnvelope(
47
+ JSON.Encode.object(Dict.fromArray([("event", JSON.Encode.object(Dict.make()))])),
48
+ ),
49
+ )->toEqual(None)
50
+ })
51
+ })
@@ -0,0 +1,63 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as Message$ReventlessCore from "@reventlessdev/reventless-core/src/Message.res.mjs";
5
+ import * as Platform$ReventlessLocal from "../src/Platform.res.mjs";
6
+ import * as PluginSpec$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginSpec.res.mjs";
7
+ import * as TestRunner$ReventlessLocal from "../src/test/TestRunner.res.mjs";
8
+ import * as Plugin_Fixtures$ReventlessLocal from "./plugin/Plugin_Fixtures.res.mjs";
9
+
10
+ TestRunner$ReventlessLocal.setup();
11
+
12
+ let meta = Message$ReventlessCore.generateMeta(PluginSpec$ReventlessCore.name, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
13
+
14
+ function envelopeOf(event) {
15
+ return Message$ReventlessCore.composeEventJson$p("Catalog", meta, S.reverseConvertToJsonOrThrow(event, PluginSpec$ReventlessCore.eventSchema));
16
+ }
17
+
18
+ globalThis.describe("Platform.decodePluginEventEnvelope", () => {
19
+ globalThis.test("decodes a VersionConnected event from the published envelope", () => {
20
+ let envelope = envelopeOf({
21
+ TAG: "VersionConnected",
22
+ _0: Plugin_Fixtures$ReventlessLocal.pluginDefinition
23
+ });
24
+ let match = Platform$ReventlessLocal.decodePluginEventEnvelope(envelope);
25
+ if (match !== undefined) {
26
+ if (match.TAG === "VersionConnected") {
27
+ globalThis.expect(match._0.name).toBe(Plugin_Fixtures$ReventlessLocal.pluginDefinition.name);
28
+ return;
29
+ }
30
+ globalThis.expect("VersionConnected").toBe("something else");
31
+ return;
32
+ }
33
+ globalThis.expect("VersionConnected").toBe("something else");
34
+ });
35
+ globalThis.test("decodes a payload-light variant (VersionDetected)", () => {
36
+ let envelope = envelopeOf({
37
+ TAG: "VersionDetected",
38
+ _0: "2.0.0"
39
+ });
40
+ globalThis.expect(Platform$ReventlessLocal.decodePluginEventEnvelope(envelope)).toEqual({
41
+ TAG: "VersionDetected",
42
+ _0: "2.0.0"
43
+ });
44
+ });
45
+ globalThis.test("returns None for a bare event JSON (no envelope) and malformed input", () => {
46
+ let bare = S.reverseConvertToJsonOrThrow({
47
+ TAG: "VersionDetected",
48
+ _0: "2.0.0"
49
+ }, PluginSpec$ReventlessCore.eventSchema);
50
+ globalThis.expect(Platform$ReventlessLocal.decodePluginEventEnvelope(bare)).toEqual(undefined);
51
+ globalThis.expect(Platform$ReventlessLocal.decodePluginEventEnvelope("junk")).toEqual(undefined);
52
+ globalThis.expect(Platform$ReventlessLocal.decodePluginEventEnvelope(Object.fromEntries([[
53
+ "event",
54
+ {}
55
+ ]]))).toEqual(undefined);
56
+ });
57
+ });
58
+
59
+ export {
60
+ meta,
61
+ envelopeOf,
62
+ }
63
+ /* Not a pure module */
@@ -70,6 +70,70 @@ describe("Backend parity (Memory vs Sqlite)", () => {
70
70
  await runUnderSqlite(scenario)
71
71
  })
72
72
 
73
+ testPromise("QueryDb: count returns a running total and loadStream reflects it under both", async () => {
74
+ let scenario = async () => {
75
+ module TestBus = LocalBus.Make()
76
+ module Storage = QueryDbStorage_InMemory.Make(TestBus)
77
+ let s = Storage.make(~name="parity-count", ~indexes=[], ~api=(), ~apiRole=(), ~opts)
78
+ let ops = await s.operations->TestRunner.resolve
79
+
80
+ // First increment creates the counter item and returns the new total.
81
+ let r1 = await ops.count("prod-1", "orderCount", 3)
82
+ expect(r1)->toEqual(Ok(3))
83
+ // Second increment accumulates (not just echoes the increment).
84
+ let r2 = await ops.count("prod-1", "orderCount", 2)
85
+ expect(r2)->toEqual(Ok(5))
86
+
87
+ // loadStream must see the persisted counter — count is not a side channel.
88
+ let items =
89
+ await ops.loadStream("prod-1")
90
+ ->Stream.runCollect
91
+ ->Effect.catchAll(_ => Effect.succeed([]))
92
+ ->Effect.runPromise
93
+ expect(items->Array.length)->toBe(1)
94
+ let field =
95
+ items
96
+ ->Array.getUnsafe(0)
97
+ ->JSON.Decode.object
98
+ ->Option.flatMap(o => o->Dict.get("orderCount"))
99
+ ->Option.flatMap(JSON.Decode.float)
100
+ ->Option.mapOr(0, Float.toInt)
101
+ expect(field)->toBe(5)
102
+ }
103
+
104
+ await runUnderMemory(scenario)
105
+ await runUnderSqlite(scenario)
106
+ })
107
+
108
+ testPromise("QueryDb: an expired-TTL item is filtered from loadStream under both", async () => {
109
+ let scenario = async () => {
110
+ module TestBus = LocalBus.Make()
111
+ module Storage = QueryDbStorage_InMemory.Make(TestBus)
112
+ let s = Storage.make(~name="parity-ttl", ~indexes=[], ~api=(), ~apiRole=(), ~opts)
113
+ let ops = await s.operations->TestRunner.resolve
114
+
115
+ let item = k => JSON.Encode.object(Dict.fromArray([("id", JSON.Encode.string(k))]))
116
+ let readCount = async k =>
117
+ (
118
+ await ops.loadStream(k)
119
+ ->Stream.runCollect
120
+ ->Effect.catchAll(_ => Effect.succeed([]))
121
+ ->Effect.runPromise
122
+ )->Array.length
123
+
124
+ // A live item (no TTL) and one whose absolute expiry (epoch second 1) is
125
+ // long past — the expired one must not surface under either backend.
126
+ let _ = await ops.save("live", item("live"), ReventlessCore.QueryDb.Any, None)
127
+ let _ = await ops.save("dead", item("dead"), ReventlessCore.QueryDb.Any, Some(1))
128
+
129
+ expect(await readCount("live"))->toBe(1)
130
+ expect(await readCount("dead"))->toBe(0)
131
+ }
132
+
133
+ await runUnderMemory(scenario)
134
+ await runUnderSqlite(scenario)
135
+ })
136
+
73
137
  testPromise("EventLog: conflict detection works under both", async () => {
74
138
  let scenario = async () => {
75
139
  module TestBus = LocalBus.Make()