@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
@@ -51,14 +51,62 @@ describe("EventLogStorage_Sqlite", () => {
51
51
  let e = JSON.Encode.object(Dict.fromArray([("t", JSON.Encode.string("X"))]))
52
52
  let _ = await ops.append(0, "id-c", [e])
53
53
 
54
- // Caller re-uses seqNr=0 — conflict.
54
+ // Caller re-uses seqNr=0 — a genuine conflict must be the typed Conflict
55
+ // sentinel (not a StorageFailure), so the core retry loop treats it as OCC.
55
56
  let result = await ops.append(0, "id-c", [e])
56
57
  switch result {
57
- | Error(_) => expect(true)->toBe(true)
58
+ | Error(ReventlessCore.EventLog.Conflict) => expect(true)->toBe(true)
59
+ | Error(StorageFailure(msg)) => expect("expected Conflict, got StorageFailure")->toBe(msg)
58
60
  | Ok() => expect("expected conflict")->toBe("actual Ok")
59
61
  }
60
62
  })
61
63
 
64
+ testPromise("multi-event append advances the expected seq via MAX(seq_nr)+1", async () => {
65
+ module TestBus = LocalBus.Make()
66
+ module DbProvider = {
67
+ let db = makeFreshDb()
68
+ }
69
+ module Storage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
70
+ let s = Storage.make(~name="agg", ~opts)
71
+ let ops = await s.operations->TestRunner.resolve
72
+
73
+ let ev = i => JSON.Encode.object(Dict.fromArray([("i", JSON.Encode.int(i))]))
74
+ // Batch of three at seq 0 → occupies seq 0,1,2.
75
+ let r1 = await ops.append(0, "id-m", [ev(0), ev(1), ev(2)])
76
+ expect(r1)->toEqual(Ok())
77
+ // A stale seq (2) is a conflict; the correct next seq is 3.
78
+ let stale = await ops.append(2, "id-m", [ev(9)])
79
+ switch stale {
80
+ | Error(ReventlessCore.EventLog.Conflict) => expect(true)->toBe(true)
81
+ | _ => expect("expected Conflict")->toBe("other")
82
+ }
83
+ let r2 = await ops.append(3, "id-m", [ev(3)])
84
+ expect(r2)->toEqual(Ok())
85
+ let replayed = await ops.replay("id-m")
86
+ expect(replayed->Array.length)->toBe(4)
87
+ })
88
+
89
+ testPromise("appendStream persists the whole batch in order from the starting seq", async () => {
90
+ module TestBus = LocalBus.Make()
91
+ module DbProvider = {
92
+ let db = makeFreshDb()
93
+ }
94
+ module Storage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
95
+ let s = Storage.make(~name="agg", ~opts)
96
+ let ops = await s.operations->TestRunner.resolve
97
+
98
+ let ev = i => JSON.Encode.object(Dict.fromArray([("i", JSON.Encode.int(i))]))
99
+ // Seed one event, then stream three more starting at seq 1.
100
+ let _ = await ops.append(0, "id-s", [ev(0)])
101
+ let _ =
102
+ await ops.appendStream(1, "id-s", Stream.fromIterable([ev(1), ev(2), ev(3)]))
103
+ ->Effect.runPromise
104
+ let replayed = await ops.replay("id-s")
105
+ expect(replayed->Array.length)->toBe(4)
106
+ expect(replayed->Array.getUnsafe(0))->toEqual(ev(0))
107
+ expect(replayed->Array.getUnsafe(3))->toEqual(ev(3))
108
+ })
109
+
62
110
  testPromise("replay returns empty array for unknown id", async () => {
63
111
  module TestBus = LocalBus.Make()
64
112
  module DbProvider = {
@@ -73,6 +121,88 @@ describe("EventLogStorage_Sqlite", () => {
73
121
  expect(replayed->Array.length)->toBe(0)
74
122
  })
75
123
 
124
+ testPromise("replayStream ~fromSeq reads only the delta", async () => {
125
+ module TestBus = LocalBus.Make()
126
+ module DbProvider = {
127
+ let db = makeFreshDb()
128
+ }
129
+ module Storage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
130
+ let s = Storage.make(~name="agg", ~opts)
131
+ let ops = await s.operations->TestRunner.resolve
132
+
133
+ let ev = i => JSON.Encode.object(Dict.fromArray([("i", JSON.Encode.int(i))]))
134
+ let _ = await ops.append(0, "id-d", [ev(0), ev(1), ev(2), ev(3)])
135
+ let delta = await ops.replayStream("id-d", ~fromSeq=2)->Stream.runCollect->Effect.runPromise
136
+ expect(delta)->toEqual([ev(2), ev(3)])
137
+ // Omitted fromSeq still replays from 0 (unchanged default).
138
+ let full = await ops.replayStream("id-d")->Stream.runCollect->Effect.runPromise
139
+ expect(full->Array.length)->toBe(4)
140
+ })
141
+
142
+ testPromise("snapshot round-trip with keep-one overwrite", async () => {
143
+ module TestBus = LocalBus.Make()
144
+ module DbProvider = {
145
+ let db = makeFreshDb()
146
+ }
147
+ module Storage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
148
+ let s = Storage.make(~name="agg", ~opts)
149
+ let ops = await s.operations->TestRunner.resolve
150
+
151
+ let none = await ops.latestSnapshot("id-snap")
152
+ expect(none)->toEqual(Ok(None))
153
+
154
+ let state = JSON.Encode.object(Dict.fromArray([("names", JSON.Encode.int(2))]))
155
+ let w1 = await ops.writeSnapshot(
156
+ "id-snap",
157
+ {ReventlessCore.EventLog.seqNr: 2, state, schemaHash: "h1"},
158
+ )
159
+ expect(w1)->toEqual(Ok())
160
+ let r1 = await ops.latestSnapshot("id-snap")
161
+ expect(r1)->toEqual(Ok(Some({ReventlessCore.EventLog.seqNr: 2, state, schemaHash: "h1"})))
162
+
163
+ // Keep-one: a later write replaces the row, it does not accumulate.
164
+ let state5 = JSON.Encode.object(Dict.fromArray([("names", JSON.Encode.int(5))]))
165
+ let _ = await ops.writeSnapshot(
166
+ "id-snap",
167
+ {ReventlessCore.EventLog.seqNr: 5, state: state5, schemaHash: "h2"},
168
+ )
169
+ let r2 = await ops.latestSnapshot("id-snap")
170
+ expect(r2)->toEqual(
171
+ Ok(Some({ReventlessCore.EventLog.seqNr: 5, state: state5, schemaHash: "h2"})),
172
+ )
173
+ })
174
+
175
+ testPromise("snapshots persist across a reopen of the database file", async () => {
176
+ let path = `/tmp/reventless-test-snapshot-${Float.toString(Date.now())}.db`
177
+ let state = JSON.Encode.object(Dict.fromArray([("v", JSON.Encode.int(7))]))
178
+
179
+ {
180
+ module TestBus = LocalBus.Make()
181
+ module DbProvider = {
182
+ let db = SqliteDriver.openDb(~path)
183
+ }
184
+ module Storage = EventLogStorage_Sqlite.Make(TestBus, DbProvider)
185
+ let s = Storage.make(~name="persist", ~opts)
186
+ let ops = await s.operations->TestRunner.resolve
187
+ let _ = await ops.writeSnapshot(
188
+ "id-sp",
189
+ {ReventlessCore.EventLog.seqNr: 3, state, schemaHash: "h"},
190
+ )
191
+ DbProvider.db->SqliteDriver.close
192
+ }
193
+
194
+ module TestBus2 = LocalBus.Make()
195
+ module DbProvider2 = {
196
+ let db = SqliteDriver.openDb(~path)
197
+ }
198
+ module Storage2 = EventLogStorage_Sqlite.Make(TestBus2, DbProvider2)
199
+ let s2 = Storage2.make(~name="persist", ~opts)
200
+ let ops2 = await s2.operations->TestRunner.resolve
201
+ let r = await ops2.latestSnapshot("id-sp")
202
+ expect(r)->toEqual(Ok(Some({ReventlessCore.EventLog.seqNr: 3, state, schemaHash: "h"})))
203
+ DbProvider2.db->SqliteDriver.close
204
+ })
205
+
76
206
  testPromise("events persist across a reopen of the database file", async () => {
77
207
  let path = `/tmp/reventless-test-eventlog-${Float.toString(Date.now())}.db`
78
208
 
@@ -1,5 +1,8 @@
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 Effect from "effect/Effect";
5
+ import * as Stream$1 from "effect/Stream";
3
6
  import * as LocalBus$ReventlessLocal from "../../src/adapter/LocalBus.res.mjs";
4
7
  import * as TestRunner$ReventlessLocal from "../../src/test/TestRunner.res.mjs";
5
8
  import * as SqliteDriver$ReventlessLocal from "../../src/adapter/SqliteDriver.res.mjs";
@@ -65,7 +68,77 @@ globalThis.describe("EventLogStorage_Sqlite", () => {
65
68
  globalThis.expect("expected conflict").toBe("actual Ok");
66
69
  return;
67
70
  }
68
- globalThis.expect(true).toBe(true);
71
+ let msg = result._0;
72
+ if (typeof msg !== "object") {
73
+ globalThis.expect(true).toBe(true);
74
+ return;
75
+ }
76
+ globalThis.expect("expected Conflict, got StorageFailure").toBe(msg._0);
77
+ });
78
+ globalThis.test("multi-event append advances the expected seq via MAX(seq_nr)+1", async () => {
79
+ let TestBus = LocalBus$ReventlessLocal.Make({});
80
+ let db = SqliteDriver$ReventlessLocal.openDb(":memory:");
81
+ let DbProvider = {
82
+ db: db
83
+ };
84
+ let Storage = EventLogStorage_Sqlite$ReventlessLocal.Make(TestBus)(DbProvider);
85
+ let s = Storage.make("agg", opts);
86
+ let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
87
+ let ev = i => Object.fromEntries([[
88
+ "i",
89
+ i
90
+ ]]);
91
+ let r1 = await ops.append(0, "id-m", [
92
+ ev(0),
93
+ ev(1),
94
+ ev(2)
95
+ ]);
96
+ globalThis.expect(r1).toEqual({
97
+ TAG: "Ok",
98
+ _0: undefined
99
+ });
100
+ let stale = await ops.append(2, "id-m", [ev(9)]);
101
+ if (stale.TAG === "Ok") {
102
+ globalThis.expect("expected Conflict").toBe("other");
103
+ } else {
104
+ let tmp = stale._0;
105
+ if (typeof tmp !== "object") {
106
+ globalThis.expect(true).toBe(true);
107
+ } else {
108
+ globalThis.expect("expected Conflict").toBe("other");
109
+ }
110
+ }
111
+ let r2 = await ops.append(3, "id-m", [ev(3)]);
112
+ globalThis.expect(r2).toEqual({
113
+ TAG: "Ok",
114
+ _0: undefined
115
+ });
116
+ let replayed = await ops.replay("id-m");
117
+ globalThis.expect(replayed.length).toBe(4);
118
+ });
119
+ globalThis.test("appendStream persists the whole batch in order from the starting seq", async () => {
120
+ let TestBus = LocalBus$ReventlessLocal.Make({});
121
+ let db = SqliteDriver$ReventlessLocal.openDb(":memory:");
122
+ let DbProvider = {
123
+ db: db
124
+ };
125
+ let Storage = EventLogStorage_Sqlite$ReventlessLocal.Make(TestBus)(DbProvider);
126
+ let s = Storage.make("agg", opts);
127
+ let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
128
+ let ev = i => Object.fromEntries([[
129
+ "i",
130
+ i
131
+ ]]);
132
+ await ops.append(0, "id-s", [ev(0)]);
133
+ await Effect.runPromise(ops.appendStream(1, "id-s", Stream$1.fromIterable([
134
+ ev(1),
135
+ ev(2),
136
+ ev(3)
137
+ ])));
138
+ let replayed = await ops.replay("id-s");
139
+ globalThis.expect(replayed.length).toBe(4);
140
+ globalThis.expect(replayed[0]).toEqual(ev(0));
141
+ globalThis.expect(replayed[3]).toEqual(ev(3));
69
142
  });
70
143
  globalThis.test("replay returns empty array for unknown id", async () => {
71
144
  let TestBus = LocalBus$ReventlessLocal.Make({});
@@ -79,6 +152,127 @@ globalThis.describe("EventLogStorage_Sqlite", () => {
79
152
  let replayed = await ops.replay("not-there");
80
153
  globalThis.expect(replayed.length).toBe(0);
81
154
  });
155
+ globalThis.test("replayStream ~fromSeq reads only the delta", async () => {
156
+ let TestBus = LocalBus$ReventlessLocal.Make({});
157
+ let db = SqliteDriver$ReventlessLocal.openDb(":memory:");
158
+ let DbProvider = {
159
+ db: db
160
+ };
161
+ let Storage = EventLogStorage_Sqlite$ReventlessLocal.Make(TestBus)(DbProvider);
162
+ let s = Storage.make("agg", opts);
163
+ let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
164
+ let ev = i => Object.fromEntries([[
165
+ "i",
166
+ i
167
+ ]]);
168
+ await ops.append(0, "id-d", [
169
+ ev(0),
170
+ ev(1),
171
+ ev(2),
172
+ ev(3)
173
+ ]);
174
+ let delta = await Effect.runPromise(Stream.runCollect(ops.replayStream("id-d", 2)));
175
+ globalThis.expect(delta).toEqual([
176
+ ev(2),
177
+ ev(3)
178
+ ]);
179
+ let full = await Effect.runPromise(Stream.runCollect(ops.replayStream("id-d", undefined)));
180
+ globalThis.expect(full.length).toBe(4);
181
+ });
182
+ globalThis.test("snapshot round-trip with keep-one overwrite", async () => {
183
+ let TestBus = LocalBus$ReventlessLocal.Make({});
184
+ let db = SqliteDriver$ReventlessLocal.openDb(":memory:");
185
+ let DbProvider = {
186
+ db: db
187
+ };
188
+ let Storage = EventLogStorage_Sqlite$ReventlessLocal.Make(TestBus)(DbProvider);
189
+ let s = Storage.make("agg", opts);
190
+ let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
191
+ let none = await ops.latestSnapshot("id-snap");
192
+ globalThis.expect(none).toEqual({
193
+ TAG: "Ok",
194
+ _0: undefined
195
+ });
196
+ let state = Object.fromEntries([[
197
+ "names",
198
+ 2
199
+ ]]);
200
+ let w1 = await ops.writeSnapshot("id-snap", {
201
+ seqNr: 2,
202
+ state: state,
203
+ schemaHash: "h1"
204
+ });
205
+ globalThis.expect(w1).toEqual({
206
+ TAG: "Ok",
207
+ _0: undefined
208
+ });
209
+ let r1 = await ops.latestSnapshot("id-snap");
210
+ globalThis.expect(r1).toEqual({
211
+ TAG: "Ok",
212
+ _0: {
213
+ seqNr: 2,
214
+ state: state,
215
+ schemaHash: "h1"
216
+ }
217
+ });
218
+ let state5 = Object.fromEntries([[
219
+ "names",
220
+ 5
221
+ ]]);
222
+ await ops.writeSnapshot("id-snap", {
223
+ seqNr: 5,
224
+ state: state5,
225
+ schemaHash: "h2"
226
+ });
227
+ let r2 = await ops.latestSnapshot("id-snap");
228
+ globalThis.expect(r2).toEqual({
229
+ TAG: "Ok",
230
+ _0: {
231
+ seqNr: 5,
232
+ state: state5,
233
+ schemaHash: "h2"
234
+ }
235
+ });
236
+ });
237
+ globalThis.test("snapshots persist across a reopen of the database file", async () => {
238
+ let path = `/tmp/reventless-test-snapshot-` + Date.now().toString() + `.db`;
239
+ let state = Object.fromEntries([[
240
+ "v",
241
+ 7
242
+ ]]);
243
+ let TestBus = LocalBus$ReventlessLocal.Make({});
244
+ let db = SqliteDriver$ReventlessLocal.openDb(path);
245
+ let DbProvider = {
246
+ db: db
247
+ };
248
+ let Storage = EventLogStorage_Sqlite$ReventlessLocal.Make(TestBus)(DbProvider);
249
+ let s = Storage.make("persist", opts);
250
+ let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
251
+ await ops.writeSnapshot("id-sp", {
252
+ seqNr: 3,
253
+ state: state,
254
+ schemaHash: "h"
255
+ });
256
+ SqliteDriver$ReventlessLocal.close(db);
257
+ let TestBus2 = LocalBus$ReventlessLocal.Make({});
258
+ let db$1 = SqliteDriver$ReventlessLocal.openDb(path);
259
+ let DbProvider2 = {
260
+ db: db$1
261
+ };
262
+ let Storage2 = EventLogStorage_Sqlite$ReventlessLocal.Make(TestBus2)(DbProvider2);
263
+ let s2 = Storage2.make("persist", opts);
264
+ let ops2 = await TestRunner$ReventlessLocal.resolve(s2.operations);
265
+ let r = await ops2.latestSnapshot("id-sp");
266
+ globalThis.expect(r).toEqual({
267
+ TAG: "Ok",
268
+ _0: {
269
+ seqNr: 3,
270
+ state: state,
271
+ schemaHash: "h"
272
+ }
273
+ });
274
+ return SqliteDriver$ReventlessLocal.close(db$1);
275
+ });
82
276
  globalThis.test("events persist across a reopen of the database file", async () => {
83
277
  let path = `/tmp/reventless-test-eventlog-` + Date.now().toString() + `.db`;
84
278
  let TestBus = LocalBus$ReventlessLocal.Make({});
@@ -78,4 +78,35 @@ describe("LocalBus PubSub (Phase F)", () => {
78
78
  expect(countB.contents)->toBe(0)
79
79
  })
80
80
  })
81
+
82
+ // A5: a throwing/rejecting subscriber must not (a) hang publishEvent (its
83
+ // done_ countdown never reaching zero) nor (b) kill the drain fiber so the
84
+ // topic stops working. If either regressed, these tests would time out.
85
+ describe("failing subscriber", () => {
86
+ testPromise("a throwing subscriber does not hang publish; healthy siblings still receive it", async () => {
87
+ module TestBus = LocalBus.Make()
88
+ let healthy = ref(0)
89
+ TestBus.subscribeToEvents("T", async (_, _, _) => JsError.throwWithMessage("boom"))
90
+ TestBus.subscribeToEvents("T", async (_, _, _) => {
91
+ healthy := healthy.contents + 1
92
+ })
93
+ // Must resolve — not hang — even though one subscriber threw.
94
+ await TestBus.publishEvent("T", "svc", defaultMeta, JSON.Null)
95
+ expect(healthy.contents)->toBe(1)
96
+ })
97
+
98
+ testPromise("the topic keeps working after a subscriber throws (drain fiber survives)", async () => {
99
+ module TestBus = LocalBus.Make()
100
+ let healthy = ref(0)
101
+ TestBus.subscribeToEvents("T", async (_, _, _) => JsError.throwWithMessage("boom"))
102
+ TestBus.subscribeToEvents("T", async (_, _, _) => {
103
+ healthy := healthy.contents + 1
104
+ })
105
+ await TestBus.publishEvent("T", "svc", defaultMeta, JSON.Null)
106
+ // Second publish on the same topic must also complete and be delivered —
107
+ // proving the dead-fiber cascade is gone.
108
+ await TestBus.publishEvent("T", "svc", defaultMeta, JSON.Null)
109
+ expect(healthy.contents)->toBe(2)
110
+ })
111
+ })
81
112
  })
@@ -1,5 +1,6 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
3
4
  import * as LocalBus$ReventlessLocal from "../../src/adapter/LocalBus.res.mjs";
4
5
  import * as TestRunner$ReventlessLocal from "../../src/test/TestRunner.res.mjs";
5
6
 
@@ -73,6 +74,33 @@ globalThis.describe("LocalBus PubSub (Phase F)", () => {
73
74
  globalThis.expect(countB.contents).toBe(0);
74
75
  });
75
76
  });
77
+ globalThis.describe("failing subscriber", () => {
78
+ globalThis.test("a throwing subscriber does not hang publish; healthy siblings still receive it", async () => {
79
+ let TestBus = LocalBus$ReventlessLocal.Make({});
80
+ let healthy = {
81
+ contents: 0
82
+ };
83
+ TestBus.subscribeToEvents("T", async (param, param$1, param$2) => Stdlib_JsError.throwWithMessage("boom"));
84
+ TestBus.subscribeToEvents("T", async (param, param$1, param$2) => {
85
+ healthy.contents = healthy.contents + 1 | 0;
86
+ });
87
+ await TestBus.publishEvent("T", "svc", defaultMeta, null);
88
+ globalThis.expect(healthy.contents).toBe(1);
89
+ });
90
+ globalThis.test("the topic keeps working after a subscriber throws (drain fiber survives)", async () => {
91
+ let TestBus = LocalBus$ReventlessLocal.Make({});
92
+ let healthy = {
93
+ contents: 0
94
+ };
95
+ TestBus.subscribeToEvents("T", async (param, param$1, param$2) => Stdlib_JsError.throwWithMessage("boom"));
96
+ TestBus.subscribeToEvents("T", async (param, param$1, param$2) => {
97
+ healthy.contents = healthy.contents + 1 | 0;
98
+ });
99
+ await TestBus.publishEvent("T", "svc", defaultMeta, null);
100
+ await TestBus.publishEvent("T", "svc", defaultMeta, null);
101
+ globalThis.expect(healthy.contents).toBe(2);
102
+ });
103
+ });
76
104
  });
77
105
 
78
106
  export {