@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
@@ -1,13 +1,20 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stream from "@reventlessdev/rescript-effect/src/Stream.res.mjs";
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_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
7
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
3
8
  import * as Effect from "effect/Effect";
4
- import * as Stream from "effect/Stream";
9
+ import * as Stream$1 from "effect/Stream";
5
10
  import * as Pulumi from "@pulumi/pulumi";
6
11
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
7
12
  import * as SqliteDriver$ReventlessLocal from "../SqliteDriver.res.mjs";
13
+ import * as ProjectionPending$ReventlessLocal from "../ProjectionPending.res.mjs";
8
14
 
9
15
  function ensureSchema(db) {
10
16
  SqliteDriver$ReventlessLocal.exec(db, "CREATE TABLE IF NOT EXISTS event_log (log_name TEXT NOT NULL, aggregate_id TEXT NOT NULL, seq_nr INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (log_name, aggregate_id, seq_nr))");
17
+ SqliteDriver$ReventlessLocal.exec(db, "CREATE TABLE IF NOT EXISTS snapshot (log_name TEXT NOT NULL, aggregate_id TEXT NOT NULL, seq_nr INTEGER NOT NULL, state TEXT NOT NULL, schema_hash TEXT NOT NULL, PRIMARY KEY (log_name, aggregate_id))");
11
18
  }
12
19
 
13
20
  function decodePayload(row) {
@@ -26,7 +33,7 @@ function decodePayload(row) {
26
33
  }
27
34
 
28
35
  function countAll(db) {
29
- SqliteDriver$ReventlessLocal.exec(db, "CREATE TABLE IF NOT EXISTS event_log (log_name TEXT NOT NULL, aggregate_id TEXT NOT NULL, seq_nr INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (log_name, aggregate_id, seq_nr))");
36
+ ensureSchema(db);
30
37
  let row = SqliteDriver$ReventlessLocal.get(SqliteDriver$ReventlessLocal.prepare(db, "SELECT COUNT(*) AS c FROM event_log"), []);
31
38
  if (row === undefined) {
32
39
  return 0;
@@ -40,12 +47,15 @@ function countAll(db) {
40
47
  }
41
48
 
42
49
  function makeStorage(db, name, param) {
43
- SqliteDriver$ReventlessLocal.exec(db, "CREATE TABLE IF NOT EXISTS event_log (log_name TEXT NOT NULL, aggregate_id TEXT NOT NULL, seq_nr INTEGER NOT NULL, payload TEXT NOT NULL, PRIMARY KEY (log_name, aggregate_id, seq_nr))");
50
+ ensureSchema(db);
44
51
  let insertStmt = SqliteDriver$ReventlessLocal.prepare(db, "INSERT INTO event_log(log_name, aggregate_id, seq_nr, payload) VALUES(?,?,?,?)");
45
- let countStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT COUNT(*) AS c FROM event_log WHERE log_name=? AND aggregate_id=?");
46
- let selectByIdStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT payload FROM event_log WHERE log_name=? AND aggregate_id=? ORDER BY seq_nr ASC");
47
- let currentCount = id => {
48
- let row = SqliteDriver$ReventlessLocal.get(countStmt, [
52
+ let nextSeqStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT COALESCE(MAX(seq_nr), -1) + 1 AS c FROM event_log WHERE log_name=? AND aggregate_id=?");
53
+ let selectByIdStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT payload FROM event_log WHERE log_name=? AND aggregate_id=? AND seq_nr >= ? ORDER BY seq_nr ASC");
54
+ let lastRowidStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT last_insert_rowid() AS r");
55
+ let upsertSnapshotStmt = SqliteDriver$ReventlessLocal.prepare(db, "INSERT OR REPLACE INTO snapshot(log_name, aggregate_id, seq_nr, state, schema_hash) VALUES(?,?,?,?,?)");
56
+ let selectSnapshotStmt = SqliteDriver$ReventlessLocal.prepare(db, "SELECT seq_nr, state, schema_hash FROM snapshot WHERE log_name=? AND aggregate_id=?");
57
+ let expectedNextSeq = id => {
58
+ let row = SqliteDriver$ReventlessLocal.get(nextSeqStmt, [
49
59
  name,
50
60
  id
51
61
  ]);
@@ -59,10 +69,10 @@ function makeStorage(db, name, param) {
59
69
  return 0;
60
70
  }
61
71
  };
62
- let append = async (seqNr, id, jsons) => {
72
+ let appendTracked = async (track, seqNr, id, jsons) => {
63
73
  try {
64
- SqliteDriver$ReventlessLocal.transaction(db, () => {
65
- let existing = currentCount(id);
74
+ let lastRowid = SqliteDriver$ReventlessLocal.transaction(db, () => {
75
+ let existing = expectedNextSeq(id);
66
76
  if (seqNr !== existing) {
67
77
  throw {
68
78
  RE_EXN_ID: "Failure",
@@ -76,7 +86,24 @@ function makeStorage(db, name, param) {
76
86
  seqNr + i | 0,
77
87
  JSON.stringify(json)
78
88
  ]));
89
+ let row = SqliteDriver$ReventlessLocal.get(lastRowidStmt, []);
90
+ if (row === undefined) {
91
+ return 0;
92
+ }
93
+ let match = row["r"];
94
+ if (typeof match === "number") {
95
+ return match | 0;
96
+ } else {
97
+ return 0;
98
+ }
79
99
  });
100
+ if (track && lastRowid > 0) {
101
+ let count = jsons.length;
102
+ ProjectionPending$ReventlessLocal.trackAppended("Aggregate", Stdlib_Array.filterMap(jsons.map((json, i) => Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), d => d["msgId"]), Stdlib_JSON.Decode.string), msgId => [
103
+ msgId,
104
+ ((lastRowid - count | 0) + 1 | 0) + i | 0
105
+ ])), x => x));
106
+ }
80
107
  return {
81
108
  TAG: "Ok",
82
109
  _0: undefined
@@ -84,39 +111,120 @@ function makeStorage(db, name, param) {
84
111
  } catch (raw_msg) {
85
112
  let msg = Primitive_exceptions.internalToException(raw_msg);
86
113
  if (msg.RE_EXN_ID === "Failure") {
114
+ let msg$1 = msg._1;
115
+ if (msg$1 === "conflict") {
116
+ return {
117
+ TAG: "Error",
118
+ _0: "Conflict"
119
+ };
120
+ } else {
121
+ return {
122
+ TAG: "Error",
123
+ _0: {
124
+ TAG: "StorageFailure",
125
+ _0: msg$1
126
+ }
127
+ };
128
+ }
129
+ }
130
+ let msg$2 = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(msg), Stdlib_JsExn.message), "");
131
+ if (msg$2.includes("constraint failed")) {
87
132
  return {
88
133
  TAG: "Error",
89
- _0: msg._1
134
+ _0: "Conflict"
90
135
  };
91
136
  } else {
92
137
  return {
93
138
  TAG: "Error",
94
- _0: "conflict"
139
+ _0: {
140
+ TAG: "StorageFailure",
141
+ _0: msg$2 === "" ? "storage error" : msg$2
142
+ }
95
143
  };
96
144
  }
97
145
  }
98
146
  };
99
- let replayArray = id => SqliteDriver$ReventlessLocal.all(selectByIdStmt, [
100
- name,
101
- id
102
- ]).map(decodePayload);
103
- let replay = replayArray;
104
- let replayStream = id => Stream.fromIterable(replayArray(id));
105
- let appendStream = (startingSeqNr, id, stream) => {
106
- let seqNrRef = {
107
- contents: startingSeqNr
108
- };
109
- return Stream.runForEach(stream, json => {
110
- let currentSeq = seqNrRef.contents;
111
- return Effect.flatMap(Effect.promise(() => append(currentSeq, id, [json])), result => {
112
- if (result.TAG !== "Ok") {
113
- return Effect.fail(result._0);
114
- }
115
- seqNrRef.contents = currentSeq + 1 | 0;
116
- return Effect.succeed();
117
- });
118
- });
147
+ let append = (seqNr, id, jsons) => appendTracked(true, seqNr, id, jsons);
148
+ let replayArray = (id, fromSeqOpt) => {
149
+ let fromSeq = fromSeqOpt !== undefined ? fromSeqOpt : 0;
150
+ return SqliteDriver$ReventlessLocal.all(selectByIdStmt, [
151
+ name,
152
+ id,
153
+ fromSeq
154
+ ]).map(decodePayload);
119
155
  };
156
+ let replay = async id => replayArray(id, undefined);
157
+ let replayStream = (id, fromSeq) => Stream$1.fromIterable(replayArray(id, fromSeq));
158
+ let latestSnapshot = async id => {
159
+ try {
160
+ let row = SqliteDriver$ReventlessLocal.get(selectSnapshotStmt, [
161
+ name,
162
+ id
163
+ ]);
164
+ if (row === undefined) {
165
+ return {
166
+ TAG: "Ok",
167
+ _0: undefined
168
+ };
169
+ }
170
+ let match = row["seq_nr"];
171
+ let match$1 = row["state"];
172
+ let match$2 = row["schema_hash"];
173
+ if (typeof match === "number" && typeof match$1 === "string" && typeof match$2 === "string") {
174
+ return {
175
+ TAG: "Ok",
176
+ _0: {
177
+ seqNr: match | 0,
178
+ state: JSON.parse(match$1),
179
+ schemaHash: match$2
180
+ }
181
+ };
182
+ } else {
183
+ return {
184
+ TAG: "Error",
185
+ _0: "snapshot row has unexpected shape"
186
+ };
187
+ }
188
+ } catch (raw_exn) {
189
+ let exn = Primitive_exceptions.internalToException(raw_exn);
190
+ return {
191
+ TAG: "Error",
192
+ _0: Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "snapshot read error")
193
+ };
194
+ }
195
+ };
196
+ let writeSnapshot = async (id, snap) => {
197
+ try {
198
+ SqliteDriver$ReventlessLocal.run(upsertSnapshotStmt, [
199
+ name,
200
+ id,
201
+ snap.seqNr,
202
+ JSON.stringify(snap.state),
203
+ snap.schemaHash
204
+ ]);
205
+ return {
206
+ TAG: "Ok",
207
+ _0: undefined
208
+ };
209
+ } catch (raw_exn) {
210
+ let exn = Primitive_exceptions.internalToException(raw_exn);
211
+ return {
212
+ TAG: "Error",
213
+ _0: Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(exn), Stdlib_JsExn.message), "snapshot write error")
214
+ };
215
+ }
216
+ };
217
+ let appendStream = (startingSeqNr, id, stream) => Effect.flatMap(Stream.runCollect(stream), jsons => Effect.flatMap(Effect.promise(() => appendTracked(false, startingSeqNr, id, jsons)), result => {
218
+ if (result.TAG === "Ok") {
219
+ return Effect.succeed();
220
+ }
221
+ let msg = result._0;
222
+ if (typeof msg !== "object") {
223
+ return Effect.fail("conflict");
224
+ } else {
225
+ return Effect.fail(msg._0);
226
+ }
227
+ }));
120
228
  return [
121
229
  name,
122
230
  replay,
@@ -126,7 +234,9 @@ function makeStorage(db, name, param) {
126
234
  append: append,
127
235
  replay: replay,
128
236
  replayStream: replayStream,
129
- appendStream: appendStream
237
+ appendStream: appendStream,
238
+ latestSnapshot: latestSnapshot,
239
+ writeSnapshot: writeSnapshot
130
240
  })
131
241
  }
132
242
  ];
@@ -152,4 +262,4 @@ export {
152
262
  makeStorage,
153
263
  Make,
154
264
  }
155
- /* effect/Effect Not a pure module */
265
+ /* Stream Not a pure module */
@@ -154,6 +154,33 @@ module type T = {
154
154
  // Used by LocalQueryEngine to honour ~limit via Stream.take without loading all items.
155
155
  let registerQueryDbStream: (string, unit => Stream.t<JSON.t, string, unit>) => unit
156
156
  let getQueryDbStream: string => option<unit => Stream.t<JSON.t, string, unit>>
157
+ // Indexed equality lookup: (field, value) → matching items. The SQLite backend
158
+ // registers a closure that pushes the predicate down to a `json_extract` GSI
159
+ // index instead of materialising + parsing the whole table; the in-memory
160
+ // backend registers a scan+filter. The `{name}By{Index}` resolvers use this
161
+ // when present and fall back to `getQueryDbScan` otherwise.
162
+ let registerQueryDbIndexLookup: (string, (string, string) => array<JSON.t>) => unit
163
+ let getQueryDbIndexLookup: string => option<(string, string) => array<JSON.t>>
164
+ // Connection-list push-down: given the GraphQL args + derived capability +
165
+ // label field, a backend may return a ready Relay connection object computed
166
+ // entirely in storage (SQLite: json_extract predicates + ORDER BY … LIMIT), or
167
+ // `None` when it can't serve that query shape — the resolver then falls back to
168
+ // materialise + `QueryDbListQuery.run`. Only the SQLite backend registers one.
169
+ let registerQueryDbListPage: (
170
+ string,
171
+ (
172
+ ~argsDict: dict<JSON.t>,
173
+ ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
174
+ ~labelField: string,
175
+ ) => option<JSON.t>,
176
+ ) => unit
177
+ let getQueryDbListPage: string => option<
178
+ (
179
+ ~argsDict: dict<JSON.t>,
180
+ ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
181
+ ~labelField: string,
182
+ ) => option<JSON.t>,
183
+ >
157
184
 
158
185
  // Event log replay registry: aggregate EventLog name → replay function (entityId → events)
159
186
  let registerEventLogReplay: (string, string => promise<array<JSON.t>>) => unit
@@ -196,6 +223,15 @@ module type T = {
196
223
  let registerEventCollectorHandler: (string, (JSON.t, unit) => promise<unit>) => unit
197
224
  let subscribeEventCollectorToTopic: (string, string) => unit
198
225
 
226
+ // Projection catch-up registry: collector name → its resolved JSON event
227
+ // handler, registered only by projection-type collectors (the ReadModel
228
+ // builders, via LocalEventCollectorChannel.MakeProjection). The SQLite
229
+ // backend's startup catch-up (ProjectionCheckpoint.runCatchup) replays missed
230
+ // stored events into these handlers; automation/translation collectors are
231
+ // deliberately NOT in this registry so catch-up can never re-run side effects.
232
+ let registerProjectionCatchupHandler: (string, (JSON.t, unit) => promise<unit>) => unit
233
+ let projectionCatchupHandlers: unit => array<(string, (JSON.t, unit) => promise<unit>)>
234
+
199
235
  let reset: unit => unit
200
236
  }
201
237
 
@@ -227,6 +263,16 @@ module Impl = (C: BusConfig): T => {
227
263
  let queryDbRegistry: ref<dict<ReventlessCore.QueryDb_Adapter.operations>> = ref(Dict.make())
228
264
  let queryDbScanRegistry: ref<dict<unit => array<JSON.t>>> = ref(Dict.make())
229
265
  let queryDbStreamRegistry: ref<dict<unit => Stream.t<JSON.t, string, unit>>> = ref(Dict.make())
266
+ let queryDbIndexLookupRegistry: ref<dict<(string, string) => array<JSON.t>>> = ref(Dict.make())
267
+ let queryDbListPageRegistry: ref<
268
+ dict<
269
+ (
270
+ ~argsDict: dict<JSON.t>,
271
+ ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
272
+ ~labelField: string,
273
+ ) => option<JSON.t>,
274
+ >,
275
+ > = ref(Dict.make())
230
276
  let eventLogReplayRegistry: ref<dict<string => promise<array<JSON.t>>>> = ref(Dict.make())
231
277
  let dcbEventLogReadRegistry: ref<
232
278
  dict<
@@ -265,7 +311,19 @@ module Impl = (C: BusConfig): T => {
265
311
  let drainLoop = Effect.scoped(
266
312
  PubSub.subscribe(hub)->Effect.flatMap(queue =>
267
313
  Stream.fromQueue(queue)->Stream.runForEach(msg =>
268
- Effect.promise(() => handler(msg.service, msg.meta, msg.json))->Effect.zipRight(msg.done_)
314
+ // A throwing/rejecting handler must not (a) leave `done_` uncalled —
315
+ // the publisher's countdown would never reach zero and every publish
316
+ // on this topic would hang — nor (b) kill this drain fiber, which
317
+ // would stop consuming while the subscriber stays counted, hanging all
318
+ // future publishes too. So: convert a rejection into a typed error
319
+ // (`tryPromise`), log-and-recover (`catchAll`) so the stream keeps
320
+ // draining, and run `done_` via `ensuring` so the countdown always
321
+ // advances regardless of outcome.
322
+ Effect.tryPromise(~catch=e => e, () => handler(msg.service, msg.meta, msg.json))
323
+ ->Effect.catchAll(err =>
324
+ Effect.sync(() => Console.error2("[LocalBus] subscriber handler failed:", err))
325
+ )
326
+ ->Effect.ensuring(msg.done_)
269
327
  )
270
328
  ),
271
329
  )
@@ -372,7 +430,11 @@ module Impl = (C: BusConfig): T => {
372
430
  let pending = queue.contents
373
431
  queue.contents = []
374
432
  pending->Array.forEach(json => {
375
- let _ = handler(json, ())
433
+ let _ =
434
+ handler(json, ())->Promise.catch(e => {
435
+ Console.error2("[LocalBus] parked command handler failed:", e)
436
+ Promise.resolve()
437
+ })
376
438
  })
377
439
  | None => ()
378
440
  }
@@ -385,6 +447,12 @@ module Impl = (C: BusConfig): T => {
385
447
  let registerQueryDbStream = (name, streamFn) =>
386
448
  queryDbStreamRegistry.contents->Dict.set(name, streamFn)
387
449
  let getQueryDbStream = name => queryDbStreamRegistry.contents->Dict.get(name)
450
+ let registerQueryDbIndexLookup = (name, lookup) =>
451
+ queryDbIndexLookupRegistry.contents->Dict.set(name, lookup)
452
+ let getQueryDbIndexLookup = name => queryDbIndexLookupRegistry.contents->Dict.get(name)
453
+ let registerQueryDbListPage = (name, listPage) =>
454
+ queryDbListPageRegistry.contents->Dict.set(name, listPage)
455
+ let getQueryDbListPage = name => queryDbListPageRegistry.contents->Dict.get(name)
388
456
 
389
457
  let registerEventLogReplay = (name, replay) =>
390
458
  eventLogReplayRegistry.contents->Dict.set(name, replay)
@@ -414,7 +482,11 @@ module Impl = (C: BusConfig): T => {
414
482
  // and done_ is called immediately. This prevents a deadlock where publishEvent on the
415
483
  // EP topic would block until the downstream aggregate command chain completes.
416
484
  let makeFireAndForgetHandler = handler => (_, _, json) => {
417
- let _ = handler(json, ())
485
+ let _ =
486
+ handler(json, ())->Promise.catch(e => {
487
+ Console.error2("[LocalBus] fire-and-forget handler failed:", e)
488
+ Promise.resolve()
489
+ })
418
490
  Promise.resolve()
419
491
  }
420
492
 
@@ -440,6 +512,13 @@ module Impl = (C: BusConfig): T => {
440
512
  }
441
513
  }
442
514
 
515
+ let projectionCatchupRegistry: ref<dict<(JSON.t, unit) => promise<unit>>> = ref(Dict.make())
516
+
517
+ let registerProjectionCatchupHandler = (name, handler) =>
518
+ projectionCatchupRegistry.contents->Dict.set(name, handler)
519
+
520
+ let projectionCatchupHandlers = () => projectionCatchupRegistry.contents->Dict.toArray
521
+
443
522
  let reset = () => {
444
523
  // Shut down all hubs — PubSub.shutdown interrupts Stream.fromQueue consumers,
445
524
  // causing Stream.runForEach to complete and Effect.scoped to close the subscription.
@@ -457,11 +536,14 @@ module Impl = (C: BusConfig): T => {
457
536
  queryDbRegistry := Dict.make()
458
537
  queryDbScanRegistry := Dict.make()
459
538
  queryDbStreamRegistry := Dict.make()
539
+ queryDbIndexLookupRegistry := Dict.make()
540
+ queryDbListPageRegistry := Dict.make()
460
541
  eventLogReplayRegistry := Dict.make()
461
542
  dcbEventLogReadRegistry := Dict.make()
462
543
  stateChangeListeners := Dict.make()
463
544
  eventCollectorHandlers := Dict.make()
464
545
  eventCollectorPendingTopics := Dict.make()
546
+ projectionCatchupRegistry := Dict.make()
465
547
  }
466
548
  }
467
549