@reventlessdev/reventless-local 3.0.0-alpha.113 → 3.0.0-alpha.115

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 (35) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/package.json +8 -8
  3. package/src/Platform.res +25 -50
  4. package/src/Platform.res.mjs +33 -160
  5. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res +10 -1
  6. package/src/adapter/DcbEventLog/DcbEventLogStorage_Sqlite.res.mjs +9 -1
  7. package/src/adapter/EventLog/EventLogStorage_InMemory.res +1 -1
  8. package/src/adapter/EventLog/EventLogStorage_InMemory.res.mjs +1 -1
  9. package/src/adapter/EventLog/EventLogStorage_Sqlite.res +16 -3
  10. package/src/adapter/EventLog/EventLogStorage_Sqlite.res.mjs +35 -7
  11. package/src/adapter/LocalBus.res +23 -3
  12. package/src/adapter/LocalBus.res.mjs +100 -66
  13. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +99 -7
  14. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +97 -13
  15. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +24 -1
  16. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +19 -4
  17. package/src/adapter/SqliteDriver.res +6 -1
  18. package/src/adapter/SqliteDriver.res.mjs +5 -1
  19. package/src/components/DcbEventLog_Builder.res +7 -1
  20. package/src/components/DcbEventLog_Builder.res.mjs +3 -3
  21. package/src/test/Mocks/MockEventLogStorage.res +1 -1
  22. package/src/test/Mocks/MockEventLogStorage.res.mjs +4 -1
  23. package/tests/adapter/BackendParityTest.res +64 -0
  24. package/tests/adapter/BackendParityTest.res.mjs +45 -0
  25. package/tests/adapter/EventLogStorageSqliteTest.res +4 -2
  26. package/tests/adapter/EventLogStorageSqliteTest.res.mjs +6 -1
  27. package/tests/adapter/LocalBusPubSubTest.res +31 -0
  28. package/tests/adapter/LocalBusPubSubTest.res.mjs +28 -0
  29. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res +2 -0
  30. package/tests/components/inboundtranslationslice/InboundTranslationSliceCallbackTest.res.mjs +3 -0
  31. package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res +1 -0
  32. package/tests/components/inboundtranslationslice/InboundTranslationSliceFixtures.res.mjs +1 -0
  33. package/tests/components/outboundtranslationslice/OutboundTranslationSliceCallbackTest.res.mjs +4 -2
  34. package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res +2 -0
  35. package/tests/components/outboundtranslationslice/OutboundTranslationSliceFixtures.res.mjs +4 -2
@@ -76,6 +76,58 @@ function Make(Bus) {
76
76
  let syncAll = () => {
77
77
  allItems.contents = flattenStore(store.contents);
78
78
  };
79
+ let expiries = {
80
+ contents: {}
81
+ };
82
+ let recordExpiry = (id, subKey, ttl) => {
83
+ if (ttl !== undefined) {
84
+ let m = expiries.contents[id];
85
+ let subExp;
86
+ if (m !== undefined) {
87
+ subExp = m;
88
+ } else {
89
+ let m$1 = {};
90
+ expiries.contents[id] = m$1;
91
+ subExp = m$1;
92
+ }
93
+ subExp[subKey] = ttl;
94
+ return;
95
+ }
96
+ let m$2 = expiries.contents[id];
97
+ if (m$2 !== undefined) {
98
+ return Stdlib_Dict.$$delete(m$2, subKey);
99
+ }
100
+ };
101
+ let purgeExpired = () => {
102
+ let now = Date.now() / 1000.0;
103
+ let removedAny = {
104
+ contents: false
105
+ };
106
+ Object.entries(expiries.contents).forEach(param => {
107
+ let subExp = param[1];
108
+ let id = param[0];
109
+ Object.entries(subExp).forEach(param => {
110
+ if (param[1] > now) {
111
+ return;
112
+ }
113
+ let subKey = param[0];
114
+ let sm = store.contents[id];
115
+ if (sm !== undefined) {
116
+ if (Stdlib_Option.isSome(sm[subKey])) {
117
+ Stdlib_Dict.$$delete(sm, subKey);
118
+ removedAny.contents = true;
119
+ }
120
+ if (Object.keys(sm).length === 0) {
121
+ Stdlib_Dict.$$delete(store.contents, id);
122
+ }
123
+ }
124
+ Stdlib_Dict.$$delete(subExp, subKey);
125
+ });
126
+ });
127
+ if (removedAny.contents) {
128
+ return syncAll();
129
+ }
130
+ };
79
131
  let getOrCreateSubMap = partitionKey => {
80
132
  let m = store.contents[partitionKey];
81
133
  if (m !== undefined) {
@@ -85,11 +137,17 @@ function Make(Bus) {
85
137
  store.contents[partitionKey] = m$1;
86
138
  return m$1;
87
139
  };
88
- let load = async id => ({
89
- TAG: "Ok",
90
- _0: sortedItems(store.contents, id)
91
- });
92
- let loadStream = id => Stream.fromIterable(sortedItems(store.contents, id));
140
+ let load = async id => {
141
+ purgeExpired();
142
+ return {
143
+ TAG: "Ok",
144
+ _0: sortedItems(store.contents, id)
145
+ };
146
+ };
147
+ let loadStream = id => {
148
+ purgeExpired();
149
+ return Stream.fromIterable(sortedItems(store.contents, id));
150
+ };
93
151
  let entityKeyFor = (id, subKey) => {
94
152
  if (subIdField !== undefined) {
95
153
  return id + "-" + subKey;
@@ -106,10 +164,11 @@ function Make(Bus) {
106
164
  let descriptor = LocalBus$ReventlessLocal.makeStateChangeDescriptor("Removed", entityKeyFor(id, subKey), undefined);
107
165
  Bus.publishStateChange(name, descriptor);
108
166
  };
109
- let save = async (id, state, _saveMode, _ttl) => {
167
+ let save = async (id, state, _saveMode, ttl) => {
110
168
  let subKey = getSubKey(state, subIdField);
111
169
  let subMap = getOrCreateSubMap(id);
112
170
  subMap[subKey] = state;
171
+ recordExpiry(id, subKey, ttl);
113
172
  syncAll();
114
173
  publishUpdated(id, state);
115
174
  return {
@@ -120,9 +179,11 @@ function Make(Bus) {
120
179
  let saveBatch = async batch => {
121
180
  batch.forEach(param => {
122
181
  let state = param[1];
182
+ let id = param[0];
123
183
  let subKey = getSubKey(state, subIdField);
124
- let subMap = getOrCreateSubMap(param[0]);
184
+ let subMap = getOrCreateSubMap(id);
125
185
  subMap[subKey] = state;
186
+ recordExpiry(id, subKey, param[2]);
126
187
  });
127
188
  syncAll();
128
189
  batch.forEach(param => publishUpdated(param[0], param[1]));
@@ -131,10 +192,27 @@ function Make(Bus) {
131
192
  _0: undefined
132
193
  };
133
194
  };
134
- let count = async (_id, _fieldName, inc) => ({
135
- TAG: "Ok",
136
- _0: inc
137
- });
195
+ let count = async (id, fieldName, inc) => {
196
+ let subMap = getOrCreateSubMap(id);
197
+ let existing = Stdlib_Option.flatMap(subMap[""], Stdlib_JSON.Decode.object);
198
+ let current = Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(existing, o => o[fieldName]), Stdlib_JSON.Decode.float), 0, prim => prim | 0);
199
+ let next = current + inc | 0;
200
+ let obj = {};
201
+ if (existing !== undefined) {
202
+ Object.entries(existing).forEach(param => {
203
+ obj[param[0]] = param[1];
204
+ });
205
+ }
206
+ obj["id"] = id;
207
+ obj[fieldName] = next;
208
+ subMap[""] = obj;
209
+ syncAll();
210
+ publishUpdated(id, obj);
211
+ return {
212
+ TAG: "Ok",
213
+ _0: next
214
+ };
215
+ };
138
216
  let $$delete = async (id, subIdOpt) => {
139
217
  if (subIdOpt !== undefined) {
140
218
  let subValue = subIdOpt[1];
@@ -209,8 +287,14 @@ function Make(Bus) {
209
287
  deleteBatch: deleteBatch
210
288
  };
211
289
  Bus.registerQueryDb(name, ops);
212
- Bus.registerQueryDbScan(name, () => allItems.contents);
213
- Bus.registerQueryDbStream(name, () => Stream.fromIterable(allItems.contents));
290
+ Bus.registerQueryDbScan(name, () => {
291
+ purgeExpired();
292
+ return allItems.contents;
293
+ });
294
+ Bus.registerQueryDbStream(name, () => {
295
+ purgeExpired();
296
+ return Stream.fromIterable(allItems.contents);
297
+ });
214
298
  return {
215
299
  resources: [],
216
300
  dataSourceName: Pulumi.output(""),
@@ -260,7 +260,30 @@ let makeStorage = (
260
260
  Ok()
261
261
  }
262
262
 
263
- let count: QueryDb.count<string> = async (_id, _fieldName, inc) => Ok(inc)
263
+ // Mirror DynamoDB's `ADD #fieldName :inc` on key {id}: read the counter field
264
+ // on the partition-key item (counters are single-state, sub_key=""), add `inc`,
265
+ // upsert, and return the NEW total. The previous `Ok(inc)` echoed the increment
266
+ // and never persisted, so the total was wrong and `loadStream` never saw it.
267
+ let count: QueryDb.count<string> = async (id, fieldName, inc) => {
268
+ let existing = rowsFor(id)->Array.get(0)->Option.flatMap(JSON.Decode.object)
269
+ let current =
270
+ existing
271
+ ->Option.flatMap(o => o->Dict.get(fieldName))
272
+ ->Option.flatMap(JSON.Decode.float)
273
+ ->Option.mapOr(0, Float.toInt)
274
+ let next = current + inc
275
+ let obj = Dict.make()
276
+ switch existing {
277
+ | Some(o) => o->Dict.toArray->Array.forEach(((k, v)) => obj->Dict.set(k, v))
278
+ | None => ()
279
+ }
280
+ obj->Dict.set("id", JSON.Encode.string(id))
281
+ obj->Dict.set(fieldName, JSON.Encode.int(next))
282
+ let newItem = JSON.Encode.object(obj)
283
+ saveOne(id, newItem, None)
284
+ publishUpdated(id, newItem)
285
+ Ok(next)
286
+ }
264
287
 
265
288
  let deleteOne = (id: string, subIdOpt) =>
266
289
  switch subIdOpt {
@@ -209,10 +209,25 @@ function makeStorage(db, bus, name, indexes, subIdField) {
209
209
  _0: undefined
210
210
  };
211
211
  };
212
- let count = async (_id, _fieldName, inc) => ({
213
- TAG: "Ok",
214
- _0: inc
215
- });
212
+ let count = async (id, fieldName, inc) => {
213
+ let existing = Stdlib_Option.flatMap(rowsFor(id)[0], Stdlib_JSON.Decode.object);
214
+ let current = Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(existing, o => o[fieldName]), Stdlib_JSON.Decode.float), 0, prim => prim | 0);
215
+ let next = current + inc | 0;
216
+ let obj = {};
217
+ if (existing !== undefined) {
218
+ Object.entries(existing).forEach(param => {
219
+ obj[param[0]] = param[1];
220
+ });
221
+ }
222
+ obj["id"] = id;
223
+ obj[fieldName] = next;
224
+ saveOne(id, obj, undefined);
225
+ publishUpdated(id, obj);
226
+ return {
227
+ TAG: "Ok",
228
+ _0: next
229
+ };
230
+ };
216
231
  let deleteOne = (id, subIdOpt) => {
217
232
  if (subIdOpt !== undefined) {
218
233
  return SqliteDriver$ReventlessLocal.run(deleteBySubKeyStmt, [
@@ -46,7 +46,12 @@ let transaction = (db, fn) => {
46
46
  result
47
47
  } catch {
48
48
  | exn =>
49
- exec(db, "ROLLBACK")
49
+ // Roll back, but never let a failing ROLLBACK replace the original error —
50
+ // the original is the diagnostic one; a rollback failure (e.g. no active
51
+ // transaction) would otherwise mask it.
52
+ try exec(db, "ROLLBACK") catch {
53
+ | _ => ()
54
+ }
50
55
  throw(exn)
51
56
  }
52
57
  }
@@ -57,7 +57,11 @@ function transaction(db, fn) {
57
57
  db.exec("COMMIT");
58
58
  return result;
59
59
  } catch (exn) {
60
- db.exec("ROLLBACK");
60
+ try {
61
+ db.exec("ROLLBACK");
62
+ } catch (exn$1) {
63
+
64
+ }
61
65
  throw exn;
62
66
  }
63
67
  }
@@ -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
@@ -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))
@@ -21,7 +21,10 @@ function make($staropt$star, $staropt$star$1) {
21
21
  failNextAppends.contents = failNextAppends.contents - 1 | 0;
22
22
  return {
23
23
  TAG: "Error",
24
- _0: "mock append failure"
24
+ _0: {
25
+ TAG: "StorageFailure",
26
+ _0: "mock append failure"
27
+ }
25
28
  };
26
29
  }
27
30
  let existing = Stdlib_Option.getOr(events.contents[id], []);
@@ -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()
@@ -1,6 +1,8 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
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_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
6
  import * as Effect from "effect/Effect";
5
7
  import * as LocalBus$ReventlessLocal from "../../src/adapter/LocalBus.res.mjs";
6
8
  import * as TestRunner$ReventlessLocal from "../../src/test/TestRunner.res.mjs";
@@ -65,6 +67,49 @@ globalThis.describe("Backend parity (Memory vs Sqlite)", () => {
65
67
  await runUnderMemory(scenario);
66
68
  return await runUnderSqlite(scenario);
67
69
  });
70
+ globalThis.test("QueryDb: count returns a running total and loadStream reflects it under both", async () => {
71
+ let scenario = async () => {
72
+ let TestBus = LocalBus$ReventlessLocal.Make({});
73
+ let Storage = QueryDbStorage_InMemory$ReventlessLocal.Make(TestBus);
74
+ let s = Storage.make("parity-count", [], undefined, undefined, undefined, undefined, opts);
75
+ let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
76
+ let r1 = await ops.count("prod-1", "orderCount", 3);
77
+ globalThis.expect(r1).toEqual({
78
+ TAG: "Ok",
79
+ _0: 3
80
+ });
81
+ let r2 = await ops.count("prod-1", "orderCount", 2);
82
+ globalThis.expect(r2).toEqual({
83
+ TAG: "Ok",
84
+ _0: 5
85
+ });
86
+ let items = await Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream("prod-1")), param => Effect.succeed([])));
87
+ globalThis.expect(items.length).toBe(1);
88
+ let field = Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(items[0]), o => o["orderCount"]), Stdlib_JSON.Decode.float), 0, prim => prim | 0);
89
+ globalThis.expect(field).toBe(5);
90
+ };
91
+ await runUnderMemory(scenario);
92
+ return await runUnderSqlite(scenario);
93
+ });
94
+ globalThis.test("QueryDb: an expired-TTL item is filtered from loadStream under both", async () => {
95
+ let scenario = async () => {
96
+ let TestBus = LocalBus$ReventlessLocal.Make({});
97
+ let Storage = QueryDbStorage_InMemory$ReventlessLocal.Make(TestBus);
98
+ let s = Storage.make("parity-ttl", [], undefined, undefined, undefined, undefined, opts);
99
+ let ops = await TestRunner$ReventlessLocal.resolve(s.operations);
100
+ let item = k => Object.fromEntries([[
101
+ "id",
102
+ k
103
+ ]]);
104
+ let readCount = async k => (await Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(k)), param => Effect.succeed([])))).length;
105
+ await ops.save("live", item("live"), "Any", undefined);
106
+ await ops.save("dead", item("dead"), "Any", 1);
107
+ globalThis.expect(await readCount("live")).toBe(1);
108
+ globalThis.expect(await readCount("dead")).toBe(0);
109
+ };
110
+ await runUnderMemory(scenario);
111
+ return await runUnderSqlite(scenario);
112
+ });
68
113
  globalThis.test("EventLog: conflict detection works under both", async () => {
69
114
  let scenario = async () => {
70
115
  let TestBus = LocalBus$ReventlessLocal.Make({});
@@ -51,10 +51,12 @@ 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
  })
@@ -65,7 +65,12 @@ globalThis.describe("EventLogStorage_Sqlite", () => {
65
65
  globalThis.expect("expected conflict").toBe("actual Ok");
66
66
  return;
67
67
  }
68
- globalThis.expect(true).toBe(true);
68
+ let msg = result._0;
69
+ if (typeof msg !== "object") {
70
+ globalThis.expect(true).toBe(true);
71
+ return;
72
+ }
73
+ globalThis.expect("expected Conflict, got StorageFailure").toBe(msg._0);
69
74
  });
70
75
  globalThis.test("replay returns empty array for unknown id", async () => {
71
76
  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 {
@@ -140,6 +140,7 @@ describe("InboundTranslationSlice Callback", () => {
140
140
  })
141
141
 
142
142
  let targetName = "ConfirmPayment"
143
+ let externalSystem = None
143
144
 
144
145
  let translate = (input: externalInput) =>
145
146
  Ok(
@@ -206,6 +207,7 @@ describe("InboundTranslationSlice Callback", () => {
206
207
  })
207
208
 
208
209
  let targetName = "ConfirmPayment"
210
+ let externalSystem = None
209
211
 
210
212
  let translate = (_input: externalInput) => Ok([])
211
213
 
@@ -22,6 +22,7 @@ let Callback = InboundTranslationSlice_Callback$ReventlessCore.Make({
22
22
  externalInputSchema: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalInputSchema,
23
23
  commandSchema: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandSchema,
24
24
  targetName: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.targetName,
25
+ externalSystem: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.externalSystem,
25
26
  commandAuthorization: InboundTranslationSliceFixtures$ReventlessLocal.PaymentWebhookSpec.commandAuthorization
26
27
  })(PaymentWebhookTranslation);
27
28
 
@@ -153,6 +154,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
153
154
  externalInputSchema: externalInputSchema,
154
155
  commandSchema: commandSchema,
155
156
  targetName: "ConfirmPayment",
157
+ externalSystem: undefined,
156
158
  commandAuthorization: commandAuthorization
157
159
  })(MultiTranslation);
158
160
  let publishedCommands = {
@@ -210,6 +212,7 @@ globalThis.describe("InboundTranslationSlice Callback", () => {
210
212
  externalInputSchema: externalInputSchema,
211
213
  commandSchema: commandSchema,
212
214
  targetName: "ConfirmPayment",
215
+ externalSystem: undefined,
213
216
  commandAuthorization: commandAuthorization
214
217
  })(EmptyTranslation);
215
218
  let publishedCommands = {
@@ -26,6 +26,7 @@ module PaymentWebhookSpec = {
26
26
  type command = ConfirmPayment({orderId: @s.matches(Reventless.DcbTag.string) string, paymentId: string})
27
27
 
28
28
  let targetName = "ConfirmPayment"
29
+ let externalSystem = None
29
30
 
30
31
  let translate = (input: externalInput) =>
31
32
  switch input.status {
@@ -62,6 +62,7 @@ let PaymentWebhookSpec = {
62
62
  externalInputSchema: externalInputSchema,
63
63
  commandSchema: commandSchema,
64
64
  targetName: "ConfirmPayment",
65
+ externalSystem: undefined,
65
66
  translate: translate,
66
67
  commandAuthorization: commandAuthorization
67
68
  };
@@ -37,7 +37,8 @@ let FireForgetCallback = OutboundTranslationSlice_Callback$ReventlessCore.Make({
37
37
  inboundCommandSchema: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.inboundCommandSchema,
38
38
  maxRetries: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.maxRetries,
39
39
  heartbeatInterval: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.heartbeatInterval,
40
- targetName: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.targetName
40
+ targetName: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.targetName,
41
+ externalSystem: OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.externalSystem
41
42
  })(SendTrackingEmailTranslation);
42
43
 
43
44
  let CommandBackCallback = OutboundTranslationSlice_Callback$ReventlessCore.Make({
@@ -48,7 +49,8 @@ let CommandBackCallback = OutboundTranslationSlice_Callback$ReventlessCore.Make(
48
49
  inboundCommandSchema: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.inboundCommandSchema,
49
50
  maxRetries: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.maxRetries,
50
51
  heartbeatInterval: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.heartbeatInterval,
51
- targetName: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.targetName
52
+ targetName: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.targetName,
53
+ externalSystem: OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.externalSystem
52
54
  })(ProcessPaymentTranslation);
53
55
 
54
56
  globalThis.describe("OutboundTranslationSlice Callback", () => {
@@ -40,6 +40,7 @@ module SendTrackingEmailSpec = {
40
40
  let maxRetries = 3
41
41
  let heartbeatInterval = 60
42
42
  let targetName = None
43
+ let externalSystem = None
43
44
  }
44
45
 
45
46
  // ─────────────────────────────────────────────────────────────
@@ -75,4 +76,5 @@ module ProcessPaymentSpec = {
75
76
  let maxRetries = 2
76
77
  let heartbeatInterval = 30
77
78
  let targetName = Some("ConfirmPayment")
79
+ let externalSystem = None
78
80
  }
@@ -64,7 +64,8 @@ let SendTrackingEmailSpec = {
64
64
  translate: translate,
65
65
  maxRetries: 3,
66
66
  heartbeatInterval: 60,
67
- targetName: undefined
67
+ targetName: undefined,
68
+ externalSystem: undefined
68
69
  };
69
70
 
70
71
  let moduleUrl$2 = import.meta.url;
@@ -126,7 +127,8 @@ let ProcessPaymentSpec = {
126
127
  translate: translate$1,
127
128
  maxRetries: 2,
128
129
  heartbeatInterval: 30,
129
- targetName: ProcessPaymentSpec_targetName
130
+ targetName: ProcessPaymentSpec_targetName,
131
+ externalSystem: undefined
130
132
  };
131
133
 
132
134
  export {