@reventlessdev/reventless-local 3.0.0-alpha.115 → 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 (59) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +6 -6
  3. package/src/Platform.res +91 -17
  4. package/src/Platform.res.mjs +89 -30
  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 +113 -39
  8. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +87 -23
  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 +23 -2
  12. package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +20 -3
  13. package/src/adapter/EventLog/EventLogStorage_Sqlite.res +132 -24
  14. package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +119 -37
  15. package/src/adapter/LocalBus.res +62 -0
  16. package/src/adapter/LocalBus.res.mjs +120 -0
  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 +32 -6
  26. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +30 -14
  27. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +163 -11
  28. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +101 -3
  29. package/src/adapter/SqliteDriver.res +11 -1
  30. package/src/adapter/SqliteDriver.res.mjs +5 -1
  31. package/src/components/ReadModel_Builder.res +3 -1
  32. package/src/components/ReadModel_Builder.res.mjs +13 -4
  33. package/src/components/StateViewSlice_Builder.res +4 -1
  34. package/src/components/StateViewSlice_Builder.res.mjs +9 -3
  35. package/src/test/Mocks/MockEventLogStorage.res +19 -2
  36. package/src/test/Mocks/MockEventLogStorage.res.mjs +21 -2
  37. package/tests/PluginEventDecodeTest.res +51 -0
  38. package/tests/PluginEventDecodeTest.res.mjs +63 -0
  39. package/tests/adapter/DcbEventLogStorageSqliteTest.res +73 -0
  40. package/tests/adapter/DcbEventLogStorageSqliteTest.res.mjs +86 -0
  41. package/tests/adapter/DcbEventLogStorageTest.res +46 -0
  42. package/tests/adapter/DcbEventLogStorageTest.res.mjs +63 -0
  43. package/tests/adapter/EventLogSnapshotParityTest.res +81 -0
  44. package/tests/adapter/EventLogSnapshotParityTest.res.mjs +141 -0
  45. package/tests/adapter/EventLogStorageSqliteTest.res +128 -0
  46. package/tests/adapter/EventLogStorageSqliteTest.res.mjs +189 -0
  47. package/tests/adapter/ProjectionCheckpointTest.res +422 -0
  48. package/tests/adapter/ProjectionCheckpointTest.res.mjs +401 -0
  49. package/tests/adapter/QueryDbGsiTtlTest.res +78 -0
  50. package/tests/adapter/QueryDbGsiTtlTest.res.mjs +77 -0
  51. package/tests/adapter/QueryDbListPushdownParityTest.res +197 -0
  52. package/tests/adapter/QueryDbListPushdownParityTest.res.mjs +292 -0
  53. package/tests/adapter/QueryDbListResolverTest.res +76 -0
  54. package/tests/adapter/QueryDbListResolverTest.res.mjs +84 -0
  55. package/tests/components/aggregate/AggregateFixtures.res +4 -0
  56. package/tests/components/aggregate/AggregateFixtures.res.mjs +1 -0
  57. package/tests/components/eventlog/EventLogAppendStreamTest.res.mjs +4 -4
  58. package/tests/components/eventlog/EventLogStreamTest.res.mjs +8 -8
  59. package/tests/plugin/PluginBehavior_GWT.res.mjs +1 -0
@@ -69,6 +69,8 @@ module Make = (Bus: LocalBus.T) => {
69
69
  registerQueryDb: Bus.registerQueryDb,
70
70
  registerQueryDbScan: Bus.registerQueryDbScan,
71
71
  registerQueryDbStream: Bus.registerQueryDbStream,
72
+ registerQueryDbIndexLookup: Bus.registerQueryDbIndexLookup,
73
+ registerQueryDbListPage: Bus.registerQueryDbListPage,
72
74
  }
73
75
 
74
76
  let makeMemory: QueryDb_Adapter.storageMaker<unit, unit> = (
@@ -81,10 +83,20 @@ module Make = (Bus: LocalBus.T) => {
81
83
  ~opts as _,
82
84
  ) => {
83
85
  let store: ref<dict<dict<JSON.t>>> = ref(Dict.make())
84
- let allItems: ref<array<JSON.t>> = ref([])
85
-
86
- let syncAll = () => {
87
- allItems.contents = flattenStore(store.contents)
86
+ // Lazy flattened snapshot for the registered scan/stream closures. The old
87
+ // shape reflattened+resorted the *entire* store on every single-row write
88
+ // (`syncAll`), so a projection replaying N events was O(). Instead each
89
+ // mutation just marks the snapshot dirty; the scan closure reflattens once,
90
+ // on demand, and only when something changed since the last read.
91
+ let snapshot: ref<array<JSON.t>> = ref([])
92
+ let dirty = ref(false)
93
+ let syncAll = () => dirty := true
94
+ let currentItems = () => {
95
+ if dirty.contents {
96
+ snapshot.contents = flattenStore(store.contents)
97
+ dirty := false
98
+ }
99
+ snapshot.contents
88
100
  }
89
101
 
90
102
  // TTL parity with the SQLite backend (which filters on `expires_at`). We
@@ -303,11 +315,25 @@ module Make = (Bus: LocalBus.T) => {
303
315
  Bus.registerQueryDb(name, ops)
304
316
  Bus.registerQueryDbScan(name, () => {
305
317
  purgeExpired()
306
- allItems.contents
318
+ currentItems()
307
319
  })
308
320
  Bus.registerQueryDbStream(name, () => {
309
321
  purgeExpired()
310
- allItems.contents->Stream.fromIterable
322
+ currentItems()->Stream.fromIterable
323
+ })
324
+ // Equality lookup on an indexed field. No real in-memory index (dev scale),
325
+ // but reusing the lazy snapshot avoids reflattening per call and keeps result
326
+ // parity with the SQLite `json_extract` push-down (both match string fields).
327
+ Bus.registerQueryDbIndexLookup(name, (field, value) => {
328
+ purgeExpired()
329
+ currentItems()->Array.filter(item =>
330
+ item
331
+ ->JSON.Decode.object
332
+ ->Option.flatMap(d => d->Dict.get(field))
333
+ ->Option.flatMap(JSON.Decode.string)
334
+ ->Option.map(v => v == value)
335
+ ->Option.getOr(false)
336
+ )
311
337
  })
312
338
 
313
339
  {
@@ -60,21 +60,32 @@ function Make(Bus) {
60
60
  let sqliteBusCallbacks_registerQueryDb = Bus.registerQueryDb;
61
61
  let sqliteBusCallbacks_registerQueryDbScan = Bus.registerQueryDbScan;
62
62
  let sqliteBusCallbacks_registerQueryDbStream = Bus.registerQueryDbStream;
63
+ let sqliteBusCallbacks_registerQueryDbIndexLookup = Bus.registerQueryDbIndexLookup;
64
+ let sqliteBusCallbacks_registerQueryDbListPage = Bus.registerQueryDbListPage;
63
65
  let sqliteBusCallbacks = {
64
66
  publishStateChange: sqliteBusCallbacks_publishStateChange,
65
67
  registerQueryDb: sqliteBusCallbacks_registerQueryDb,
66
68
  registerQueryDbScan: sqliteBusCallbacks_registerQueryDbScan,
67
- registerQueryDbStream: sqliteBusCallbacks_registerQueryDbStream
69
+ registerQueryDbStream: sqliteBusCallbacks_registerQueryDbStream,
70
+ registerQueryDbIndexLookup: sqliteBusCallbacks_registerQueryDbIndexLookup,
71
+ registerQueryDbListPage: sqliteBusCallbacks_registerQueryDbListPage
68
72
  };
69
73
  let makeMemory = (name, param, subIdField, param$1, param$2, param$3, param$4) => {
70
74
  let store = {
71
75
  contents: {}
72
76
  };
73
- let allItems = {
77
+ let snapshot = {
74
78
  contents: []
75
79
  };
76
- let syncAll = () => {
77
- allItems.contents = flattenStore(store.contents);
80
+ let dirty = {
81
+ contents: false
82
+ };
83
+ let currentItems = () => {
84
+ if (dirty.contents) {
85
+ snapshot.contents = flattenStore(store.contents);
86
+ dirty.contents = false;
87
+ }
88
+ return snapshot.contents;
78
89
  };
79
90
  let expiries = {
80
91
  contents: {}
@@ -125,7 +136,8 @@ function Make(Bus) {
125
136
  });
126
137
  });
127
138
  if (removedAny.contents) {
128
- return syncAll();
139
+ dirty.contents = true;
140
+ return;
129
141
  }
130
142
  };
131
143
  let getOrCreateSubMap = partitionKey => {
@@ -169,7 +181,7 @@ function Make(Bus) {
169
181
  let subMap = getOrCreateSubMap(id);
170
182
  subMap[subKey] = state;
171
183
  recordExpiry(id, subKey, ttl);
172
- syncAll();
184
+ dirty.contents = true;
173
185
  publishUpdated(id, state);
174
186
  return {
175
187
  TAG: "Ok",
@@ -185,7 +197,7 @@ function Make(Bus) {
185
197
  subMap[subKey] = state;
186
198
  recordExpiry(id, subKey, param[2]);
187
199
  });
188
- syncAll();
200
+ dirty.contents = true;
189
201
  batch.forEach(param => publishUpdated(param[0], param[1]));
190
202
  return {
191
203
  TAG: "Ok",
@@ -206,7 +218,7 @@ function Make(Bus) {
206
218
  obj["id"] = id;
207
219
  obj[fieldName] = next;
208
220
  subMap[""] = obj;
209
- syncAll();
221
+ dirty.contents = true;
210
222
  publishUpdated(id, obj);
211
223
  return {
212
224
  TAG: "Ok",
@@ -220,18 +232,18 @@ function Make(Bus) {
220
232
  if (subMap !== undefined) {
221
233
  let hadIt = Stdlib_Option.isSome(subMap[subValue]);
222
234
  Stdlib_Dict.$$delete(subMap, subValue);
223
- syncAll();
235
+ dirty.contents = true;
224
236
  if (hadIt) {
225
237
  publishRemoved(id, subValue);
226
238
  }
227
239
  } else {
228
- syncAll();
240
+ dirty.contents = true;
229
241
  }
230
242
  } else {
231
243
  let subMap$1 = store.contents[id];
232
244
  let subKeys = subMap$1 !== undefined ? Object.keys(subMap$1) : [];
233
245
  Stdlib_Dict.$$delete(store.contents, id);
234
- syncAll();
246
+ dirty.contents = true;
235
247
  subKeys.forEach(subKey => publishRemoved(id, subKey));
236
248
  }
237
249
  return {
@@ -270,7 +282,7 @@ function Make(Bus) {
270
282
  }
271
283
  Stdlib_Dict.$$delete(store.contents, id);
272
284
  });
273
- syncAll();
285
+ dirty.contents = true;
274
286
  removed.forEach(param => publishRemoved(param[0], param[1]));
275
287
  return {
276
288
  TAG: "Ok",
@@ -289,11 +301,15 @@ function Make(Bus) {
289
301
  Bus.registerQueryDb(name, ops);
290
302
  Bus.registerQueryDbScan(name, () => {
291
303
  purgeExpired();
292
- return allItems.contents;
304
+ return currentItems();
293
305
  });
294
306
  Bus.registerQueryDbStream(name, () => {
295
307
  purgeExpired();
296
- return Stream.fromIterable(allItems.contents);
308
+ return Stream.fromIterable(currentItems());
309
+ });
310
+ Bus.registerQueryDbIndexLookup(name, (field, value) => {
311
+ purgeExpired();
312
+ return currentItems().filter(item => Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[field]), Stdlib_JSON.Decode.string), v => v === value), false));
297
313
  });
298
314
  return {
299
315
  resources: [],
@@ -139,6 +139,15 @@ type busCallbacks = {
139
139
  registerQueryDb: (string, QueryDb_Adapter.operations) => unit,
140
140
  registerQueryDbScan: (string, unit => array<JSON.t>) => unit,
141
141
  registerQueryDbStream: (string, unit => Stream.t<JSON.t, string, unit>) => unit,
142
+ registerQueryDbIndexLookup: (string, (string, string) => array<JSON.t>) => unit,
143
+ registerQueryDbListPage: (
144
+ string,
145
+ (
146
+ ~argsDict: dict<JSON.t>,
147
+ ~capability: GraphQL_FragmentGenerator.serverCapability,
148
+ ~labelField: string,
149
+ ) => option<JSON.t>,
150
+ ) => unit,
142
151
  }
143
152
 
144
153
  // SQL fragment that excludes expired rows. Uses unix-epoch seconds via
@@ -178,17 +187,38 @@ let makeStorage = (
178
187
  ->SqliteDriver.all([JSON.Encode.string(id)])
179
188
  ->Array.map(decodeItem)
180
189
 
181
- let allRows = (): array<JSON.t> =>
182
- scanAllStmt
183
- ->SqliteDriver.all([])
184
- ->Array.map(row => {
185
- let item = decodeItem(row)
186
- let partition = switch row->Dict.get("partition_key") {
187
- | Some(JSON.String(s)) => s
188
- | _ => ""
189
- }
190
- withId(item, partition)
191
- })
190
+ let rowToItem = (row: dict<JSON.t>): JSON.t => {
191
+ let item = decodeItem(row)
192
+ let partition = switch row->Dict.get("partition_key") {
193
+ | Some(JSON.String(s)) => s
194
+ | _ => ""
195
+ }
196
+ withId(item, partition)
197
+ }
198
+
199
+ let allRows = (): array<JSON.t> => scanAllStmt->SqliteDriver.all([])->Array.map(rowToItem)
200
+
201
+ // Equality lookup on an indexed JSON field, pushed down to SQLite so the query
202
+ // rides the `json_extract` GSI index (`ensureIndexes`) instead of scanning +
203
+ // parsing every row in the resolver. Statements are prepared once per field and
204
+ // cached. String comparison mirrors the resolver's JS path (which only matched
205
+ // string-typed fields), so results are identical.
206
+ let indexLookupStmts: Dict.t<SqliteDriver.statement> = Dict.make()
207
+ let lookupByField = (field: string, value: string): array<JSON.t> => {
208
+ let stmt = switch indexLookupStmts->Dict.get(field) {
209
+ | Some(s) => s
210
+ | None =>
211
+ // Escape single quotes in the JSON path literal so an odd field name can't
212
+ // break out of the string (bound `?` already covers the value).
213
+ let escapedField = field->String.replaceAll("'", "''")
214
+ let s = db->SqliteDriver.prepare(
215
+ `SELECT partition_key, sub_key, item FROM ${table} WHERE json_extract(item, '$.${escapedField}') = ? AND ${notExpiredClause}`,
216
+ )
217
+ indexLookupStmts->Dict.set(field, s)
218
+ s
219
+ }
220
+ stmt->SqliteDriver.all([JSON.Encode.string(value)])->Array.map(rowToItem)
221
+ }
192
222
 
193
223
  let saveOne = (id: string, state: JSON.t, ttl: option<int>) => {
194
224
  let subKey = computeSubKey(state, subIdField)
@@ -331,6 +361,126 @@ let makeStorage = (
331
361
  bus.registerQueryDb(name, ops)
332
362
  bus.registerQueryDbScan(name, () => allRows())
333
363
  bus.registerQueryDbStream(name, () => allRows()->Stream.fromIterable)
364
+ bus.registerQueryDbIndexLookup(name, lookupByField)
365
+
366
+ // Connection-list push-down. Builds `json_extract` predicates + `ORDER BY …
367
+ // LIMIT pageSize+1` so a page fetch reads only what it returns rather than the
368
+ // whole read model. Reproduces `QueryDbListQuery` semantics exactly for the
369
+ // shapes it handles; returns None (→ the resolver falls back to that shared
370
+ // spec over a full scan) for shapes not bit-exact in SQL: case-insensitive
371
+ // search/searchPrefix, Relay-global-id `ids`, and backward pagination
372
+ // (last/before). Cursor values, edges, and pageInfo are built by the SAME
373
+ // `QueryDbListQuery` helpers the fallback uses, so only the WHERE/ORDER/LIMIT
374
+ // needs to match — which the parity harness asserts across a data + args
375
+ // matrix. Comparisons use TEXT (CAST) to mirror the JS string-comparison
376
+ // semantics; BINARY collation agrees with JS `<` for the ASCII id/status/name
377
+ // fields used as sort/filter keys.
378
+ let listStmtCache: Dict.t<SqliteDriver.statement> = Dict.make()
379
+ let preparedList = (sql: string): SqliteDriver.statement =>
380
+ switch listStmtCache->Dict.get(sql) {
381
+ | Some(s) => s
382
+ | None =>
383
+ let s = db->SqliteDriver.prepare(sql)
384
+ listStmtCache->Dict.set(sql, s)
385
+ s
386
+ }
387
+ let jsonText = (field: string): string =>
388
+ `CAST(json_extract(item, '$.${field->String.replaceAll("'", "''")}') AS TEXT)`
389
+ let idExpr = "COALESCE(json_extract(item, '$.id'), partition_key)"
390
+ let listPage = (
391
+ ~argsDict: dict<JSON.t>,
392
+ ~capability: GraphQL_FragmentGenerator.serverCapability,
393
+ ~labelField as _,
394
+ ): option<JSON.t> => {
395
+ let filterDict =
396
+ argsDict->Dict.get("filter")->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
397
+ let strNonEmpty = k =>
398
+ filterDict->Dict.get(k)->Option.flatMap(JSON.Decode.string)->Option.mapOr(false, s => s->String.length > 0)
399
+ let hasIds =
400
+ filterDict->Dict.get("ids")->Option.flatMap(JSON.Decode.array)->Option.mapOr(false, a => a->Array.length > 0)
401
+ let last = argsDict->Dict.get("last")->Option.flatMap(JSON.Decode.float)
402
+ let before = argsDict->Dict.get("before")->Option.flatMap(JSON.Decode.string)
403
+ if strNonEmpty("search") || strNonEmpty("searchPrefix") || hasIds || last->Option.isSome || before->Option.isSome {
404
+ None
405
+ } else {
406
+ let whereParts = [notExpiredClause]
407
+ let params = []
408
+ let valString = v =>
409
+ switch v->JSON.Decode.string {
410
+ | Some(s) => Some(s)
411
+ | None => v->JSON.Decode.float->Option.map(f => Float.toString(f))
412
+ }
413
+ capability.filterFields->Array.forEach(f => {
414
+ switch filterDict->Dict.get(f.name ++ "Eq") {
415
+ | Some(v) when v != JSON.Encode.null =>
416
+ whereParts->Array.push(`${jsonText(f.name)} = ?`)
417
+ params->Array.push(JSON.Encode.string(valString(v)->Option.getOr("")))
418
+ | _ => ()
419
+ }
420
+ if f.range {
421
+ switch filterDict->Dict.get(f.name ++ "From") {
422
+ | Some(v) when v != JSON.Encode.null =>
423
+ whereParts->Array.push(`${jsonText(f.name)} >= ?`)
424
+ params->Array.push(JSON.Encode.string(valString(v)->Option.getOr("")))
425
+ | _ => ()
426
+ }
427
+ switch filterDict->Dict.get(f.name ++ "To") {
428
+ | Some(v) when v != JSON.Encode.null =>
429
+ whereParts->Array.push(`${jsonText(f.name)} <= ?`)
430
+ params->Array.push(JSON.Encode.string(valString(v)->Option.getOr("")))
431
+ | _ => ()
432
+ }
433
+ }
434
+ })
435
+ let orderByDict = argsDict->Dict.get("orderBy")->Option.flatMap(JSON.Decode.object)
436
+ let orderByField =
437
+ orderByDict->Option.flatMap(ob => ob->Dict.get("field"))->Option.flatMap(JSON.Decode.string)
438
+ let isDesc =
439
+ orderByDict
440
+ ->Option.flatMap(ob => ob->Dict.get("direction"))
441
+ ->Option.flatMap(JSON.Decode.string)
442
+ ->Option.getOr("ASC") == "DESC"
443
+ let cursorExpr = switch orderByField {
444
+ | Some(f) => jsonText(f)
445
+ | None => idExpr
446
+ }
447
+ switch argsDict->Dict.get("after")->Option.flatMap(JSON.Decode.string) {
448
+ | Some(c) =>
449
+ whereParts->Array.push(`${cursorExpr} ${isDesc ? "<" : ">"} ?`)
450
+ params->Array.push(JSON.Encode.string(QueryDbListQuery.decodeCursor(c)))
451
+ | None => ()
452
+ }
453
+ let orderClause = switch orderByField {
454
+ | Some(f) => `${jsonText(f)} ${isDesc ? "DESC" : "ASC"}, ${idExpr} ASC`
455
+ | None => `${idExpr} ASC`
456
+ }
457
+ let pageSize =
458
+ argsDict
459
+ ->Dict.get("first")
460
+ ->Option.flatMap(JSON.Decode.float)
461
+ ->Option.map(Float.toInt)
462
+ ->Option.getOr(QueryDbListQuery.defaultListPageSize)
463
+ let sql = `SELECT partition_key, item FROM ${table} WHERE ${whereParts->Array.join(" AND ")} ORDER BY ${orderClause} LIMIT ?`
464
+ let rows =
465
+ preparedList(sql)
466
+ ->SqliteDriver.all(params->Array.concat([JSON.Encode.int(pageSize + 1)]))
467
+ ->Array.map(rowToItem)
468
+ let hasMore = rows->Array.length > pageSize
469
+ let pageItems = rows->Array.slice(~start=0, ~end=pageSize)
470
+ let cursorField = orderByField->Option.getOr("id")
471
+ let cursorValueOf = item =>
472
+ QueryDbListQuery.getFieldString(item, cursorField)->Option.getOr(QueryDbListQuery.getId(item))
473
+ Some(
474
+ QueryDbListQuery.buildConnection(
475
+ ~pageItems,
476
+ ~hasNextPage=hasMore,
477
+ ~hasPreviousPage=false,
478
+ ~cursorValueOf,
479
+ ),
480
+ )
481
+ }
482
+ }
483
+ bus.registerQueryDbListPage(name, listPage)
334
484
 
335
485
  {
336
486
  resources: [],
@@ -348,6 +498,8 @@ module Make = (Bus: LocalBus.T, DbProvider: {let db: SqliteDriver.t}) => {
348
498
  registerQueryDb: Bus.registerQueryDb,
349
499
  registerQueryDbScan: Bus.registerQueryDbScan,
350
500
  registerQueryDbStream: Bus.registerQueryDbStream,
501
+ registerQueryDbIndexLookup: Bus.registerQueryDbIndexLookup,
502
+ registerQueryDbListPage: Bus.registerQueryDbListPage,
351
503
  }
352
504
 
353
505
  let make: QueryDb_Adapter.storageMaker<unit, unit> = (
@@ -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;
@@ -287,6 +304,83 @@ function makeStorage(db, bus, name, indexes, subIdField) {
287
304
  bus.registerQueryDb(name, ops);
288
305
  bus.registerQueryDbScan(name, () => allRows());
289
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);
290
384
  return {
291
385
  resources: [],
292
386
  dataSourceName: Pulumi.output(""),
@@ -300,11 +394,15 @@ function Make(Bus) {
300
394
  let busCallbacks_registerQueryDb = Bus.registerQueryDb;
301
395
  let busCallbacks_registerQueryDbScan = Bus.registerQueryDbScan;
302
396
  let busCallbacks_registerQueryDbStream = Bus.registerQueryDbStream;
397
+ let busCallbacks_registerQueryDbIndexLookup = Bus.registerQueryDbIndexLookup;
398
+ let busCallbacks_registerQueryDbListPage = Bus.registerQueryDbListPage;
303
399
  let busCallbacks = {
304
400
  publishStateChange: busCallbacks_publishStateChange,
305
401
  registerQueryDb: busCallbacks_registerQueryDb,
306
402
  registerQueryDbScan: busCallbacks_registerQueryDbScan,
307
- registerQueryDbStream: busCallbacks_registerQueryDbStream
403
+ registerQueryDbStream: busCallbacks_registerQueryDbStream,
404
+ registerQueryDbIndexLookup: busCallbacks_registerQueryDbIndexLookup,
405
+ registerQueryDbListPage: busCallbacks_registerQueryDbListPage
308
406
  };
309
407
  let make = (name, indexes, subIdField, param, param$1, param$2, param$3) => makeStorage(DbProvider.db, busCallbacks, name, indexes, subIdField);
310
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)
@@ -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) {
@@ -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 {