@reventlessdev/reventless-local 3.0.0-alpha.114 → 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 (28) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +7 -7
  3. package/src/Platform.res +25 -50
  4. package/src/Platform.res.mjs +25 -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
package/CHANGELOG.md CHANGED
@@ -3,6 +3,17 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.115 (2026-07-02)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **core,local:** projection/publisher/bus correctness (plan A5,A7) ([86e27a5](https://github.com/ReventlessDev/reventless-core/commit/86e27a5d68873d7dd9e746f9b4126f4d5bad63c9))
11
+ * **local:** conflict classification, rollback masking, MCP pagination (plan A6) ([b3e6d24](https://github.com/ReventlessDev/reventless-core/commit/b3e6d242ddf2df066ca94b0db2266275f1453fbf))
12
+ * **local:** InMemory QueryDb TTL parity + backend-aware UIFragment store (plan A6) ([c31727f](https://github.com/ReventlessDev/reventless-core/commit/c31727fa3fe98b4f15b55300fdf8b80221fabbee))
13
+ * **local:** QueryDb count returns a running total, not the increment (plan A6) ([4ac3822](https://github.com/ReventlessDev/reventless-core/commit/4ac3822ac310d62bb4b1c4c021a85f45883f4785))
14
+ * **local:** wire standalone DCB log through the backend-aware storage (plan A6) ([40734e9](https://github.com/ReventlessDev/reventless-core/commit/40734e902436dfce623061416e9edeafa822f1d3))
15
+
16
+
6
17
  # 3.0.0-alpha.114 (2026-06-29)
7
18
 
8
19
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.114",
3
+ "version": "3.0.0-alpha.115",
4
4
  "description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -31,15 +31,15 @@
31
31
  "graphql-yoga": "^5.21.0",
32
32
  "sury": "11.0.0-alpha.4",
33
33
  "ws": "^8.18.0",
34
- "@reventlessdev/rescript-effect": "0.1.0-alpha.23",
34
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.24",
35
35
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.17",
36
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.5",
36
37
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.16",
37
38
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
38
- "@reventlessdev/rescript-jest": "1.0.0-alpha.3",
39
- "@reventlessdev/reventless-core": "3.0.0-alpha.135",
40
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.75",
41
- "@reventlessdev/reventless-infra": "3.0.0-alpha.82",
42
- "@reventlessdev/reventless-spec": "3.0.0-alpha.62"
39
+ "@reventlessdev/reventless-core": "3.0.0-alpha.136",
40
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.76",
41
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.83",
42
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.63"
43
43
  },
44
44
  "devDependencies": {
45
45
  "rescript": "^12.3.0",
package/src/Platform.res CHANGED
@@ -279,10 +279,18 @@ module MakeWithConfig = (
279
279
  ])->JSON.Encode.object
280
280
 
281
281
  let paginate = (events: array<JSON.t>, getPosition: JSON.t => option<string>) => {
282
+ // Positions are numeric strings; compare them as ints, not lexically
283
+ // ("10" > "9" is false as strings) — otherwise cursor pages skip or
284
+ // duplicate events once positions cross a digit boundary.
285
+ let positionGt = (p, afterPos) =>
286
+ switch (Int.fromString(p), Int.fromString(afterPos)) {
287
+ | (Some(pi), Some(ai)) => pi > ai
288
+ | _ => p > afterPos
289
+ }
282
290
  let filtered = switch after {
283
291
  | Some(afterPos) =>
284
292
  let idx =
285
- events->Array.findIndex(e => getPosition(e)->Option.mapOr(false, p => p > afterPos))
293
+ events->Array.findIndex(e => getPosition(e)->Option.mapOr(false, p => positionGt(p, afterPos)))
286
294
  if idx >= 0 {
287
295
  events->Array.slice(~start=idx, ~end=events->Array.length)
288
296
  } else {
@@ -749,60 +757,27 @@ module MakeWithConfig = (
749
757
  // UIFragmentRegistry QueryDb store ops — initialized lazily on first seed call.
750
758
  let uiFragmentQueryDbOpsRef: ref<option<ReventlessCore.QueryDb_Adapter.operations>> = ref(None)
751
759
 
760
+ // Backend-aware storage for the UIFragment registry — the same functor every
761
+ // other read model uses. Replaces a hand-rolled memory-only store that ignored
762
+ // BackendState, so the registry now persists under SQLite too.
763
+ module UIFragmentStorage = QueryDbStorage_InMemory.Make(Bus)
764
+
752
765
  let ensureUIFragmentRegistryQueryDbStore = () => {
753
766
  switch uiFragmentQueryDbOpsRef.contents {
754
767
  | Some(ops) => ops
755
768
  | None =>
756
769
  let name = ReventlessCore.UIFragmentRegistryReadModelSpec.name
757
- let store: ref<dict<array<JSON.t>>> = ref(Dict.make())
758
- let allItems: ref<array<JSON.t>> = ref([])
759
- let syncAll = () => {
760
- allItems.contents =
761
- store.contents
762
- ->Dict.toArray
763
- ->Array.flatMap(((id, items)) =>
764
- items->Array.map(item => {
765
- let obj = item->JSON.Decode.object->Option.getOr(Dict.make())
766
- if !(obj->Dict.get("id")->Option.isSome) {
767
- let copy = Dict.make()
768
- obj->Dict.toArray->Array.forEach(((k, v)) => copy->Dict.set(k, v))
769
- copy->Dict.set("id", JSON.Encode.string(id))
770
- JSON.Encode.object(copy)
771
- } else {
772
- item
773
- }
774
- })
775
- )
776
- }
777
- let ops: ReventlessCore.QueryDb_Adapter.operations = {
778
- load: async id => Ok(store.contents->Dict.get(id)->Option.getOr([])),
779
- loadStream: id =>
780
- store.contents->Dict.get(id)->Option.getOr([])->Stream.fromIterable,
781
- save: async (id, state, _, _) => {
782
- store.contents->Dict.set(id, [state])
783
- syncAll()
784
- Ok()
785
- },
786
- saveBatch: async batch => {
787
- batch->Array.forEach(((id, state, _)) => store.contents->Dict.set(id, [state]))
788
- syncAll()
789
- Ok()
790
- },
791
- count: async (_, _, inc) => Ok(inc),
792
- delete: async (id, _) => {
793
- store.contents->Dict.delete(id)
794
- syncAll()
795
- Ok()
796
- },
797
- deleteBatch: async ids => {
798
- ids->Array.forEach(((id, _)) => store.contents->Dict.delete(id))
799
- syncAll()
800
- Ok()
801
- },
802
- }
803
- Bus.registerQueryDb(name, ops)
804
- Bus.registerQueryDbScan(name, () => allItems.contents)
805
- Bus.registerQueryDbStream(name, () => allItems.contents->Stream.fromIterable)
770
+ // `make` registers the ops (and scan/stream) with the Bus for the active
771
+ // backend; retrieve them synchronously via getQueryDb rather than resolving
772
+ // the Pulumi Output.
773
+ let _ = UIFragmentStorage.make(
774
+ ~name,
775
+ ~indexes=[],
776
+ ~api=(),
777
+ ~apiRole=(),
778
+ ~opts=({}: Pulumi.CustomResourceOptions.t),
779
+ )
780
+ let ops = Bus.getQueryDb(name)->Option.getOrThrow
806
781
  uiFragmentQueryDbOpsRef := Some(ops)
807
782
  ops
808
783
  }
@@ -4,14 +4,12 @@ import * as S from "sury/src/S.res.mjs";
4
4
  import * as Stream from "@reventlessdev/rescript-effect/src/Stream.res.mjs";
5
5
  import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
6
6
  import * as Stdlib_Bool from "@rescript/runtime/lib/es6/Stdlib_Bool.js";
7
- import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
8
7
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
9
8
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
10
9
  import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
11
10
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
12
11
  import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
13
12
  import * as Effect from "effect/Effect";
14
- import * as Stream$1 from "effect/Stream";
15
13
  import * as Pulumi from "@pulumi/pulumi";
16
14
  import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
17
15
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
@@ -63,6 +61,7 @@ import * as AutomationSlice_Builder$ReventlessLocal from "./components/Automatio
63
61
  import * as LocalRuntimeEnvironment$ReventlessLocal from "./adapter/Runtime/LocalRuntimeEnvironment.res.mjs";
64
62
  import * as LocalScheduledPublisher$ReventlessLocal from "./adapter/Scheduler/LocalScheduledPublisher.res.mjs";
65
63
  import * as Platform_Admin_Structure$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_Admin_Structure.res.mjs";
64
+ import * as QueryDbStorage_InMemory$ReventlessLocal from "./adapter/QueryDb/QueryDbStorage_InMemory.res.mjs";
66
65
  import * as LocalCommandTopicChannel$ReventlessLocal from "./adapter/CommandTopic/LocalCommandTopicChannel.res.mjs";
67
66
  import * as LocalEventTopicPublisher$ReventlessLocal from "./adapter/EventTopic/LocalEventTopicPublisher.res.mjs";
68
67
  import * as StateChangeSlice_Builder$ReventlessLocal from "./components/StateChangeSlice_Builder.res.mjs";
@@ -268,7 +267,15 @@ function MakeWithConfig(Config) {
268
267
  let paginate = (events, getPosition) => {
269
268
  let filtered;
270
269
  if (after !== undefined) {
271
- let idx = events.findIndex(e => Stdlib_Option.mapOr(getPosition(e), false, p => p > after));
270
+ let idx = events.findIndex(e => Stdlib_Option.mapOr(getPosition(e), false, p => {
271
+ let match = Stdlib_Int.fromString(p, undefined);
272
+ let match$1 = Stdlib_Int.fromString(after, undefined);
273
+ if (match !== undefined && match$1 !== undefined) {
274
+ return match > match$1;
275
+ } else {
276
+ return p > after;
277
+ }
278
+ }));
272
279
  filtered = idx >= 0 ? events.slice(idx, events.length) : [];
273
280
  } else {
274
281
  filtered = events;
@@ -704,89 +711,14 @@ function MakeWithConfig(Config) {
704
711
  let uiFragmentQueryDbOpsRef = {
705
712
  contents: undefined
706
713
  };
714
+ let UIFragmentStorage = QueryDbStorage_InMemory$ReventlessLocal.Make(Bus);
707
715
  let ensureUIFragmentRegistryQueryDbStore = () => {
708
716
  let ops = uiFragmentQueryDbOpsRef.contents;
709
717
  if (ops !== undefined) {
710
718
  return ops;
711
719
  }
712
- let store = {
713
- contents: {}
714
- };
715
- let allItems = {
716
- contents: []
717
- };
718
- let syncAll = () => {
719
- allItems.contents = Object.entries(store.contents).flatMap(param => {
720
- let id = param[0];
721
- return param[1].map(item => {
722
- let obj = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(item), {});
723
- if (Stdlib_Option.isSome(obj["id"])) {
724
- return item;
725
- }
726
- let copy = {};
727
- Object.entries(obj).forEach(param => {
728
- copy[param[0]] = param[1];
729
- });
730
- copy["id"] = id;
731
- return copy;
732
- });
733
- });
734
- };
735
- let ops_load = async id => ({
736
- TAG: "Ok",
737
- _0: Stdlib_Option.getOr(store.contents[id], [])
738
- });
739
- let ops_loadStream = id => Stream$1.fromIterable(Stdlib_Option.getOr(store.contents[id], []));
740
- let ops_save = async (id, state, param, param$1) => {
741
- store.contents[id] = [state];
742
- syncAll();
743
- return {
744
- TAG: "Ok",
745
- _0: undefined
746
- };
747
- };
748
- let ops_saveBatch = async batch => {
749
- batch.forEach(param => {
750
- store.contents[param[0]] = [param[1]];
751
- });
752
- syncAll();
753
- return {
754
- TAG: "Ok",
755
- _0: undefined
756
- };
757
- };
758
- let ops_count = async (param, param$1, inc) => ({
759
- TAG: "Ok",
760
- _0: inc
761
- });
762
- let ops_delete = async (id, param) => {
763
- Stdlib_Dict.$$delete(store.contents, id);
764
- syncAll();
765
- return {
766
- TAG: "Ok",
767
- _0: undefined
768
- };
769
- };
770
- let ops_deleteBatch = async ids => {
771
- ids.forEach(param => Stdlib_Dict.$$delete(store.contents, param[0]));
772
- syncAll();
773
- return {
774
- TAG: "Ok",
775
- _0: undefined
776
- };
777
- };
778
- let ops$1 = {
779
- load: ops_load,
780
- loadStream: ops_loadStream,
781
- save: ops_save,
782
- saveBatch: ops_saveBatch,
783
- count: ops_count,
784
- delete: ops_delete,
785
- deleteBatch: ops_deleteBatch
786
- };
787
- Bus.registerQueryDb(UIFragmentRegistryReadModelSpec$ReventlessCore.name, ops$1);
788
- Bus.registerQueryDbScan(UIFragmentRegistryReadModelSpec$ReventlessCore.name, () => allItems.contents);
789
- Bus.registerQueryDbStream(UIFragmentRegistryReadModelSpec$ReventlessCore.name, () => Stream$1.fromIterable(allItems.contents));
720
+ UIFragmentStorage.make(UIFragmentRegistryReadModelSpec$ReventlessCore.name, [], undefined, undefined, undefined, undefined, {});
721
+ let ops$1 = Stdlib_Option.getOrThrow(Bus.getQueryDb(UIFragmentRegistryReadModelSpec$ReventlessCore.name), undefined);
790
722
  uiFragmentQueryDbOpsRef.contents = ops$1;
791
723
  return ops$1;
792
724
  };
@@ -1921,7 +1853,15 @@ function Make($star) {
1921
1853
  let paginate = (events, getPosition) => {
1922
1854
  let filtered;
1923
1855
  if (after !== undefined) {
1924
- let idx = events.findIndex(e => Stdlib_Option.mapOr(getPosition(e), false, p => p > after));
1856
+ let idx = events.findIndex(e => Stdlib_Option.mapOr(getPosition(e), false, p => {
1857
+ let match = Stdlib_Int.fromString(p, undefined);
1858
+ let match$1 = Stdlib_Int.fromString(after, undefined);
1859
+ if (match !== undefined && match$1 !== undefined) {
1860
+ return match > match$1;
1861
+ } else {
1862
+ return p > after;
1863
+ }
1864
+ }));
1925
1865
  filtered = idx >= 0 ? events.slice(idx, events.length) : [];
1926
1866
  } else {
1927
1867
  filtered = events;
@@ -2356,89 +2296,14 @@ function Make($star) {
2356
2296
  let uiFragmentQueryDbOpsRef = {
2357
2297
  contents: undefined
2358
2298
  };
2299
+ let UIFragmentStorage = QueryDbStorage_InMemory$ReventlessLocal.Make(Bus);
2359
2300
  let ensureUIFragmentRegistryQueryDbStore = () => {
2360
2301
  let ops = uiFragmentQueryDbOpsRef.contents;
2361
2302
  if (ops !== undefined) {
2362
2303
  return ops;
2363
2304
  }
2364
- let store = {
2365
- contents: {}
2366
- };
2367
- let allItems = {
2368
- contents: []
2369
- };
2370
- let syncAll = () => {
2371
- allItems.contents = Object.entries(store.contents).flatMap(param => {
2372
- let id = param[0];
2373
- return param[1].map(item => {
2374
- let obj = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(item), {});
2375
- if (Stdlib_Option.isSome(obj["id"])) {
2376
- return item;
2377
- }
2378
- let copy = {};
2379
- Object.entries(obj).forEach(param => {
2380
- copy[param[0]] = param[1];
2381
- });
2382
- copy["id"] = id;
2383
- return copy;
2384
- });
2385
- });
2386
- };
2387
- let ops_load = async id => ({
2388
- TAG: "Ok",
2389
- _0: Stdlib_Option.getOr(store.contents[id], [])
2390
- });
2391
- let ops_loadStream = id => Stream$1.fromIterable(Stdlib_Option.getOr(store.contents[id], []));
2392
- let ops_save = async (id, state, param, param$1) => {
2393
- store.contents[id] = [state];
2394
- syncAll();
2395
- return {
2396
- TAG: "Ok",
2397
- _0: undefined
2398
- };
2399
- };
2400
- let ops_saveBatch = async batch => {
2401
- batch.forEach(param => {
2402
- store.contents[param[0]] = [param[1]];
2403
- });
2404
- syncAll();
2405
- return {
2406
- TAG: "Ok",
2407
- _0: undefined
2408
- };
2409
- };
2410
- let ops_count = async (param, param$1, inc) => ({
2411
- TAG: "Ok",
2412
- _0: inc
2413
- });
2414
- let ops_delete = async (id, param) => {
2415
- Stdlib_Dict.$$delete(store.contents, id);
2416
- syncAll();
2417
- return {
2418
- TAG: "Ok",
2419
- _0: undefined
2420
- };
2421
- };
2422
- let ops_deleteBatch = async ids => {
2423
- ids.forEach(param => Stdlib_Dict.$$delete(store.contents, param[0]));
2424
- syncAll();
2425
- return {
2426
- TAG: "Ok",
2427
- _0: undefined
2428
- };
2429
- };
2430
- let ops$1 = {
2431
- load: ops_load,
2432
- loadStream: ops_loadStream,
2433
- save: ops_save,
2434
- saveBatch: ops_saveBatch,
2435
- count: ops_count,
2436
- delete: ops_delete,
2437
- deleteBatch: ops_deleteBatch
2438
- };
2439
- Bus.registerQueryDb(UIFragmentRegistryReadModelSpec$ReventlessCore.name, ops$1);
2440
- Bus.registerQueryDbScan(UIFragmentRegistryReadModelSpec$ReventlessCore.name, () => allItems.contents);
2441
- Bus.registerQueryDbStream(UIFragmentRegistryReadModelSpec$ReventlessCore.name, () => Stream$1.fromIterable(allItems.contents));
2305
+ UIFragmentStorage.make(UIFragmentRegistryReadModelSpec$ReventlessCore.name, [], undefined, undefined, undefined, undefined, {});
2306
+ let ops$1 = Stdlib_Option.getOrThrow(Bus.getQueryDb(UIFragmentRegistryReadModelSpec$ReventlessCore.name), undefined);
2442
2307
  uiFragmentQueryDbOpsRef.contents = ops$1;
2443
2308
  return ops$1;
2444
2309
  };
@@ -306,7 +306,16 @@ let makeStorage = (
306
306
  result.contents
307
307
  } catch {
308
308
  | Failure(msg) => Error(msg)
309
- | _ => Error("conflict")
309
+ | exn =>
310
+ // The deliberate append-condition failure above throws
311
+ // Failure("conflict: …"); a lost race on the computed position shows up here
312
+ // as a PRIMARY KEY / UNIQUE violation — also a genuine conflict. Every other
313
+ // failure (disk full, SQL error, locked db) must surface as a real error,
314
+ // not the retryable "conflict" sentinel that would be retried forever.
315
+ let msg = exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("")
316
+ msg->String.includes("constraint failed")
317
+ ? Error("conflict")
318
+ : Error(msg == "" ? "storage error" : msg)
310
319
  }
311
320
  }
312
321
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as S from "sury/src/S.res.mjs";
4
4
  import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
5
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
5
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
7
  import * as Effect from "effect/Effect";
7
8
  import * as Stream from "effect/Stream";
@@ -262,11 +263,18 @@ function makeStorage(db, bus, publishToTopic, name, param, param$1, param$2) {
262
263
  TAG: "Error",
263
264
  _0: msg._1
264
265
  };
265
- } else {
266
+ }
267
+ let msg$1 = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(msg), Stdlib_JsExn.message), "");
268
+ if (msg$1.includes("constraint failed")) {
266
269
  return {
267
270
  TAG: "Error",
268
271
  _0: "conflict"
269
272
  };
273
+ } else {
274
+ return {
275
+ TAG: "Error",
276
+ _0: msg$1 === "" ? "storage error" : msg$1
277
+ };
270
278
  }
271
279
  }
272
280
  };
@@ -17,7 +17,7 @@ let makeMemoryStorage = (~name as _name, ~opts as _) => {
17
17
  let existing = events->Dict.get(id)->Option.getOr([])
18
18
  let currentCount = existing->Array.length
19
19
  if seqNr != currentCount {
20
- (Error("conflict"), events)
20
+ (Error(ReventlessCore.EventLog.Conflict), events)
21
21
  } else {
22
22
  events->Dict.set(id, existing->Array.concat(jsons))
23
23
  (Ok(), events)
@@ -20,7 +20,7 @@ function makeMemoryStorage(_name, param) {
20
20
  return [
21
21
  {
22
22
  TAG: "Error",
23
- _0: "conflict"
23
+ _0: "Conflict"
24
24
  },
25
25
  events
26
26
  ];
@@ -88,8 +88,19 @@ let makeStorage = (~db: SqliteDriver.t, ~name: string, ~opts as _) => {
88
88
  })
89
89
  Ok()
90
90
  } catch {
91
- | Failure(msg) => Error(msg)
92
- | _ => Error("conflict")
91
+ // The deliberate seq_nr check above throws Failure("conflict"); a lost race
92
+ // shows up as a PRIMARY KEY violation on (log_name, aggregate_id, seq_nr)
93
+ // also a genuine OCC conflict. Every OTHER failure (disk full, SQL error,
94
+ // locked db) is a real StorageFailure, not the retryable Conflict sentinel.
95
+ | Failure(msg) =>
96
+ msg == "conflict"
97
+ ? Error(ReventlessCore.EventLog.Conflict)
98
+ : Error(ReventlessCore.EventLog.StorageFailure(msg))
99
+ | exn =>
100
+ let msg = exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("")
101
+ msg->String.includes("constraint failed")
102
+ ? Error(ReventlessCore.EventLog.Conflict)
103
+ : Error(ReventlessCore.EventLog.StorageFailure(msg == "" ? "storage error" : msg))
93
104
  }
94
105
  }
95
106
 
@@ -115,7 +126,9 @@ let makeStorage = (~db: SqliteDriver.t, ~name: string, ~opts as _) => {
115
126
  | Ok() =>
116
127
  seqNrRef := currentSeq + 1
117
128
  Effect.succeed()
118
- | Error(msg) => Effect.fail(msg)
129
+ // appendStream's error channel is a string; map the typed append error.
130
+ | Error(ReventlessCore.EventLog.Conflict) => Effect.fail("conflict")
131
+ | Error(StorageFailure(msg)) => Effect.fail(msg)
119
132
  }
120
133
  )
121
134
  })
@@ -1,5 +1,7 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stdlib_JsExn from "@rescript/runtime/lib/es6/Stdlib_JsExn.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
3
5
  import * as Effect from "effect/Effect";
4
6
  import * as Stream from "effect/Stream";
5
7
  import * as Pulumi from "@pulumi/pulumi";
@@ -84,14 +86,35 @@ function makeStorage(db, name, param) {
84
86
  } catch (raw_msg) {
85
87
  let msg = Primitive_exceptions.internalToException(raw_msg);
86
88
  if (msg.RE_EXN_ID === "Failure") {
89
+ let msg$1 = msg._1;
90
+ if (msg$1 === "conflict") {
91
+ return {
92
+ TAG: "Error",
93
+ _0: "Conflict"
94
+ };
95
+ } else {
96
+ return {
97
+ TAG: "Error",
98
+ _0: {
99
+ TAG: "StorageFailure",
100
+ _0: msg$1
101
+ }
102
+ };
103
+ }
104
+ }
105
+ let msg$2 = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_JsExn.fromException(msg), Stdlib_JsExn.message), "");
106
+ if (msg$2.includes("constraint failed")) {
87
107
  return {
88
108
  TAG: "Error",
89
- _0: msg._1
109
+ _0: "Conflict"
90
110
  };
91
111
  } else {
92
112
  return {
93
113
  TAG: "Error",
94
- _0: "conflict"
114
+ _0: {
115
+ TAG: "StorageFailure",
116
+ _0: msg$2 === "" ? "storage error" : msg$2
117
+ }
95
118
  };
96
119
  }
97
120
  }
@@ -109,11 +132,16 @@ function makeStorage(db, name, param) {
109
132
  return Stream.runForEach(stream, json => {
110
133
  let currentSeq = seqNrRef.contents;
111
134
  return Effect.flatMap(Effect.promise(() => append(currentSeq, id, [json])), result => {
112
- if (result.TAG !== "Ok") {
113
- return Effect.fail(result._0);
135
+ if (result.TAG === "Ok") {
136
+ seqNrRef.contents = currentSeq + 1 | 0;
137
+ return Effect.succeed();
138
+ }
139
+ let msg = result._0;
140
+ if (typeof msg !== "object") {
141
+ return Effect.fail("conflict");
142
+ } else {
143
+ return Effect.fail(msg._0);
114
144
  }
115
- seqNrRef.contents = currentSeq + 1 | 0;
116
- return Effect.succeed();
117
145
  });
118
146
  });
119
147
  };
@@ -152,4 +180,4 @@ export {
152
180
  makeStorage,
153
181
  Make,
154
182
  }
155
- /* effect/Effect Not a pure module */
183
+ /* Stdlib_JsExn Not a pure module */
@@ -265,7 +265,19 @@ module Impl = (C: BusConfig): T => {
265
265
  let drainLoop = Effect.scoped(
266
266
  PubSub.subscribe(hub)->Effect.flatMap(queue =>
267
267
  Stream.fromQueue(queue)->Stream.runForEach(msg =>
268
- Effect.promise(() => handler(msg.service, msg.meta, msg.json))->Effect.zipRight(msg.done_)
268
+ // A throwing/rejecting handler must not (a) leave `done_` uncalled —
269
+ // the publisher's countdown would never reach zero and every publish
270
+ // on this topic would hang — nor (b) kill this drain fiber, which
271
+ // would stop consuming while the subscriber stays counted, hanging all
272
+ // future publishes too. So: convert a rejection into a typed error
273
+ // (`tryPromise`), log-and-recover (`catchAll`) so the stream keeps
274
+ // draining, and run `done_` via `ensuring` so the countdown always
275
+ // advances regardless of outcome.
276
+ Effect.tryPromise(~catch=e => e, () => handler(msg.service, msg.meta, msg.json))
277
+ ->Effect.catchAll(err =>
278
+ Effect.sync(() => Console.error2("[LocalBus] subscriber handler failed:", err))
279
+ )
280
+ ->Effect.ensuring(msg.done_)
269
281
  )
270
282
  ),
271
283
  )
@@ -372,7 +384,11 @@ module Impl = (C: BusConfig): T => {
372
384
  let pending = queue.contents
373
385
  queue.contents = []
374
386
  pending->Array.forEach(json => {
375
- let _ = handler(json, ())
387
+ let _ =
388
+ handler(json, ())->Promise.catch(e => {
389
+ Console.error2("[LocalBus] parked command handler failed:", e)
390
+ Promise.resolve()
391
+ })
376
392
  })
377
393
  | None => ()
378
394
  }
@@ -414,7 +430,11 @@ module Impl = (C: BusConfig): T => {
414
430
  // and done_ is called immediately. This prevents a deadlock where publishEvent on the
415
431
  // EP topic would block until the downstream aggregate command chain completes.
416
432
  let makeFireAndForgetHandler = handler => (_, _, json) => {
417
- let _ = handler(json, ())
433
+ let _ =
434
+ handler(json, ())->Promise.catch(e => {
435
+ Console.error2("[LocalBus] fire-and-forget handler failed:", e)
436
+ Promise.resolve()
437
+ })
418
438
  Promise.resolve()
419
439
  }
420
440