@reventlessdev/reventless-local 3.0.0-alpha.234 → 3.0.0-alpha.236

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,29 @@
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.236 (2026-08-23)
7
+
8
+ **Note:** Version bump only for package @reventlessdev/reventless-local
9
+
10
+
11
+
12
+
13
+
14
+ # 3.0.0-alpha.235 (2026-08-22)
15
+
16
+ * feat(core)!: a slice that has given up says so ([f5559ab](https://github.com/ReventlessDev/reventless-core/commit/f5559abede3a9cf2c9a48aadc69a666c46cc75bf))
17
+
18
+ ### BREAKING CHANGES
19
+
20
+ * `onExhausted` is required on the Translation and Automation
21
+ module types. ReScript has no optional module-type fields, so the alternative
22
+ was a PPX-injected default — and defaulting this one to silence is the bug.
23
+ A slice now states what abandonment means for its domain, `None` included.
24
+ GeocodeCustomerAddress answers with MarkAddressUnresolvable; the others say
25
+ nothing, on purpose.
26
+
27
+
28
+
6
29
  # 3.0.0-alpha.234 (2026-08-21)
7
30
 
8
31
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.234",
3
+ "version": "3.0.0-alpha.236",
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
  "bin": {
@@ -37,16 +37,16 @@
37
37
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
38
38
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
39
39
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
40
- "@reventlessdev/rescript-node": "2.0.0-alpha.8",
41
40
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
41
+ "@reventlessdev/rescript-node": "2.0.0-alpha.8",
42
42
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
43
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.94",
44
- "@reventlessdev/reventless-core": "3.0.0-alpha.246",
45
- "@reventlessdev/reventless-infra": "3.0.0-alpha.149",
46
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.110",
47
- "@reventlessdev/reventless-seed": "1.0.0-alpha.17",
48
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.193",
49
- "@reventlessdev/reventless-spec": "3.0.0-alpha.121"
43
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.96",
44
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.195",
45
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.150",
46
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.112",
47
+ "@reventlessdev/reventless-core": "3.0.0-alpha.248",
48
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.122",
49
+ "@reventlessdev/reventless-seed": "1.0.0-alpha.17"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.0",
@@ -574,3 +574,153 @@ describe("QueryDb by-index resolver", () => {
574
574
  }
575
575
  })
576
576
  })
577
+
578
+ // ─────────────────────────────────────────────────────────────
579
+ // Sub-id view — the `{name}Items` door
580
+ // ─────────────────────────────────────────────────────────────
581
+
582
+ // The door only exists when a view declares a sub-id, and no fixture declared one
583
+ // alongside a union field, so it was the last door in the mechanism plan's table
584
+ // carrying a union nowhere.
585
+
586
+ @schema
587
+ type lineState = {
588
+ orderId: @s.matches(Reventless.DcbTag.string) string,
589
+ lineNo: string,
590
+ label: string,
591
+ geolocation?: geolocation,
592
+ }
593
+
594
+ let lineStateSchemaWithAnnotations =
595
+ lineStateSchema->S.Metadata.set(
596
+ ~id=Reventless.StateAnnotations.stateAnnotationsId,
597
+ {
598
+ ids: ["orderId"],
599
+ compositeIds: [],
600
+ subIds: ["lineNo"],
601
+ compositeSubIds: [],
602
+ indexes: [],
603
+ hidden: [],
604
+ summary: [],
605
+ drillTargets: [],
606
+ drillTargetKeys: [],
607
+ collapsed: [],
608
+ scan: [],
609
+ scanSort: [],
610
+ semantic: [],
611
+ metric: [],
612
+ lifecycle: None,
613
+ groupBy: None,
614
+ visibility: None,
615
+ live: None,
616
+ retired: None,
617
+ },
618
+ )
619
+
620
+ let buildSubIdFixture = async (~name: string) => {
621
+ module Bus = LocalBus.Make()
622
+ module Storage = LocalQueryDbStorage.Make(Bus)
623
+ module Resolvers = QueryDbResolvers_GraphQL.Make(Bus)
624
+
625
+ module Spec = {
626
+ module Id = Reventless.Id.StringPure
627
+ let name = name
628
+ let moduleUrl: string = %raw(`import.meta.url`)
629
+ @schema
630
+ type state = lineState
631
+ let config = Reventless.ReadModel.config()
632
+ let subIdConfig = Some({
633
+ Reventless.ReadModel.subIdField: "lineNo",
634
+ getSubId: (state: lineState) => state.lineNo,
635
+ })
636
+ let authorization: Reventless.Authorization.permission = AllowAuthenticated
637
+ let visibility: Reventless.Visibility.t = Public
638
+ }
639
+
640
+ module QDbResolversAdapter = ReventlessCore.QueryDb_Adapter.NoResolvers(Storage)
641
+ module Maker = ReventlessCore.QueryDb_Builder.Make(Spec, Storage, QDbResolversAdapter)
642
+
643
+ ReventlessCore.Plugin_Helpers.queryFieldNamesRegistry->Dict.set(
644
+ name,
645
+ {
646
+ singleFieldName: name,
647
+ listFieldName: name ++ "s",
648
+ returnTypeName: name,
649
+ pluralTypeName: "[" ++ name ++ "]",
650
+ includeIdParam: true,
651
+ connectionSpec: true,
652
+ },
653
+ )
654
+ ReventlessCore.Plugin_Helpers.stateSchemaRegistry->Dict.set(
655
+ name,
656
+ lineStateSchemaWithAnnotations->S.castToUnknown,
657
+ )
658
+
659
+ let queryDb = Maker.make(~api=(), ~apiRole=())
660
+ let ops = await queryDb->ReventlessCore.Component.operations->TestRunner.resolve
661
+
662
+ let _: ReventlessCore.QueryDb_Adapter.resolvers = Resolvers.make(
663
+ ~name,
664
+ ~api=(),
665
+ ~apiRole=(),
666
+ ~dataSourceName=""->Pulumi.Output.make,
667
+ ~indexes=[],
668
+ ~subIdField=Some("lineNo"),
669
+ ~idResolverConfigs=[],
670
+ ~idsResolverConfigs=[],
671
+ ~authorization=Reventless.Authorization.AllowAuthenticated,
672
+ ~opts=({}: Pulumi.CustomResourceOptions.t),
673
+ )
674
+
675
+ // Two lines under one order: the first carries the union, the second leaves the
676
+ // optional field absent.
677
+ let _ = await ops.save(
678
+ "o-1",
679
+ {orderId: "o-1", lineNo: "l-1", label: "First", geolocation: Located({lat: 48.2082, lng: 16.3738})},
680
+ Init,
681
+ None,
682
+ )
683
+ let _ = await ops.save("o-1", {orderId: "o-1", lineNo: "l-2", label: "Second"}, Init, None)
684
+
685
+ switch DomainGraphQL_Server.getQueryResolver(name ++ "Items") {
686
+ | Some(r) => r
687
+ | None => JsError.throwWithMessage("items resolver not registered: " ++ name ++ "Items")
688
+ }
689
+ }
690
+
691
+ describe("QueryDb sub-id resolver — the Items door", () => {
692
+ beforeEach(() => {
693
+ DomainGraphQL_Server.reset()
694
+ })
695
+
696
+ testPromise("answers the sub-items of one id, as edges", async () => {
697
+ let resolver = await buildSubIdFixture(~name="ItemA")
698
+ let response = await resolver(JSON.Encode.null, argsOf([("id", strJson("o-1"))]), emptyCtx)
699
+ let edges = getEdges(response)
700
+ expect(edges->Array.length)->toBe(2)
701
+ expect(edgeNodeField(edges->Array.getUnsafe(0), "lineNo"))->toBe("l-1")
702
+ })
703
+
704
+ // The gap this fixture exists to close, mirroring the by-index case above: a
705
+ // union reaching a caller through the Items door with the member type the
706
+ // write-time stamp put on it. Without it the member resolves to null and takes
707
+ // its non-nullable parent, so the row leaves the connection rather than erroring.
708
+ testPromise("carries a union field's member type through", async () => {
709
+ let resolver = await buildSubIdFixture(~name="ItemB")
710
+ let response = await resolver(JSON.Encode.null, argsOf([("id", strJson("o-1"))]), emptyCtx)
711
+ let first = getEdges(response)->Array.getUnsafe(0)
712
+ let geo = edgeNodeJson(first, "geolocation")->Option.flatMap(JSON.Decode.object)
713
+ expect(geo->Option.flatMap(o => o->Dict.get("__typename"))->Option.flatMap(JSON.Decode.string))
714
+ ->toEqual(Some("GeolocationLocated"))
715
+ expect(geo->Option.flatMap(o => o->Dict.get("lat"))->Option.flatMap(JSON.Decode.float))
716
+ ->toEqual(Some(48.2082))
717
+ })
718
+
719
+ testPromise("leaves an absent union absent", async () => {
720
+ let resolver = await buildSubIdFixture(~name="ItemC")
721
+ let response = await resolver(JSON.Encode.null, argsOf([("id", strJson("o-1"))]), emptyCtx)
722
+ let second = getEdges(response)->Array.getUnsafe(1)
723
+ expect(edgeNodeField(second, "label"))->toBe("Second")
724
+ expect(edgeNodeJson(second, "geolocation")->Option.isNone)->toBe(true)
725
+ })
726
+ })
@@ -648,6 +648,136 @@ globalThis.describe("QueryDb by-index resolver", () => {
648
648
  });
649
649
  });
650
650
 
651
+ let lineStateSchema = Sury.$schema(s => ({
652
+ orderId: s.m(DcbTag$Reventless.string),
653
+ lineNo: s.m(Sury.string),
654
+ label: s.m(Sury.string),
655
+ geolocation: s.m(Sury.$option(geolocationSchema$1))
656
+ }));
657
+
658
+ let lineStateSchemaWithAnnotations = Sury.$Metadata_set(lineStateSchema, StateAnnotations$Reventless.stateAnnotationsId, {
659
+ ids: ["orderId"],
660
+ compositeIds: [],
661
+ subIds: ["lineNo"],
662
+ compositeSubIds: [],
663
+ indexes: [],
664
+ hidden: [],
665
+ summary: [],
666
+ drillTargets: [],
667
+ drillTargetKeys: [],
668
+ collapsed: [],
669
+ scan: [],
670
+ scanSort: [],
671
+ semantic: [],
672
+ metric: [],
673
+ lifecycle: undefined,
674
+ groupBy: undefined,
675
+ visibility: undefined,
676
+ live: undefined,
677
+ retired: undefined
678
+ });
679
+
680
+ async function buildSubIdFixture(name) {
681
+ let Bus = LocalBus$ReventlessLocal.Make({});
682
+ let Storage = LocalQueryDbStorage$ReventlessLocal.Make(Bus);
683
+ let Resolvers = QueryDbResolvers_GraphQL$ReventlessLocal.Make(Bus);
684
+ let moduleUrl = import.meta.url;
685
+ let config = ReadModel$Reventless.config(undefined, undefined, undefined);
686
+ let subIdConfig = {
687
+ subIdField: "lineNo",
688
+ getSubId: state => state.lineNo
689
+ };
690
+ let QDbResolversAdapter = QueryDb_Adapter$ReventlessCore.NoResolvers({
691
+ make: Storage.make
692
+ });
693
+ let Maker = QueryDb_Builder$ReventlessCore.Make({
694
+ Id: {
695
+ schema: Id$Reventless.StringPure.schema,
696
+ make: prim => prim,
697
+ makeFromString: prim => prim,
698
+ toString: prim => prim,
699
+ cmp: Id$Reventless.StringPure.cmp
700
+ },
701
+ name: name,
702
+ moduleUrl: moduleUrl,
703
+ stateSchema: lineStateSchema,
704
+ config: config,
705
+ subIdConfig: subIdConfig,
706
+ authorization: "AllowAuthenticated",
707
+ visibility: "Public"
708
+ })({
709
+ make: Storage.make
710
+ })(QDbResolversAdapter);
711
+ Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry[name] = {
712
+ singleFieldName: name,
713
+ listFieldName: name + "s",
714
+ returnTypeName: name,
715
+ pluralTypeName: "[" + name + "]",
716
+ includeIdParam: true,
717
+ connectionSpec: true
718
+ };
719
+ Plugin_Helpers$ReventlessCore.stateSchemaRegistry[name] = lineStateSchemaWithAnnotations;
720
+ let queryDb = Maker.make(undefined, undefined, undefined, undefined, undefined);
721
+ let ops = await TestRunner$ReventlessLocal.resolve(Component$ReventlessCore.operations(queryDb));
722
+ Resolvers.make(name, undefined, undefined, Pulumi.output(""), [], "lineNo", [], [], "AllowAuthenticated", {});
723
+ await ops.save("o-1", {
724
+ orderId: "o-1",
725
+ lineNo: "l-1",
726
+ label: "First",
727
+ geolocation: {
728
+ TAG: "Located",
729
+ lat: 48.2082,
730
+ lng: 16.3738
731
+ }
732
+ }, "Init", undefined);
733
+ await ops.save("o-1", {
734
+ orderId: "o-1",
735
+ lineNo: "l-2",
736
+ label: "Second"
737
+ }, "Init", undefined);
738
+ let r = DomainGraphQL_Server$ReventlessLocal.getQueryResolver(name + "Items");
739
+ if (r !== undefined) {
740
+ return r;
741
+ } else {
742
+ return Stdlib_JsError.throwWithMessage("items resolver not registered: " + name + "Items");
743
+ }
744
+ }
745
+
746
+ globalThis.describe("QueryDb sub-id resolver — the Items door", () => {
747
+ globalThis.beforeEach(() => DomainGraphQL_Server$ReventlessLocal.reset());
748
+ globalThis.test("answers the sub-items of one id, as edges", async () => {
749
+ let resolver = await buildSubIdFixture("ItemA");
750
+ let response = await resolver(null, Object.fromEntries([[
751
+ "id",
752
+ "o-1"
753
+ ]]), emptyCtx);
754
+ let edges = getEdges(response);
755
+ globalThis.expect(edges.length).toBe(2);
756
+ globalThis.expect(edgeNodeField(edges[0], "lineNo")).toBe("l-1");
757
+ });
758
+ globalThis.test("carries a union field's member type through", async () => {
759
+ let resolver = await buildSubIdFixture("ItemB");
760
+ let response = await resolver(null, Object.fromEntries([[
761
+ "id",
762
+ "o-1"
763
+ ]]), emptyCtx);
764
+ let first = getEdges(response)[0];
765
+ let geo = Stdlib_Option.flatMap(edgeNodeJson(first, "geolocation"), Stdlib_JSON.Decode.object);
766
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(geo, o => o["__typename"]), Stdlib_JSON.Decode.string)).toEqual("GeolocationLocated");
767
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(geo, o => o["lat"]), Stdlib_JSON.Decode.float)).toEqual(48.2082);
768
+ });
769
+ globalThis.test("leaves an absent union absent", async () => {
770
+ let resolver = await buildSubIdFixture("ItemC");
771
+ let response = await resolver(null, Object.fromEntries([[
772
+ "id",
773
+ "o-1"
774
+ ]]), emptyCtx);
775
+ let second = getEdges(response)[1];
776
+ globalThis.expect(edgeNodeField(second, "label")).toBe("Second");
777
+ globalThis.expect(Stdlib_Option.isNone(edgeNodeJson(second, "geolocation"))).toBe(true);
778
+ });
779
+ });
780
+
651
781
  export {
652
782
  geolocationSchema$1 as geolocationSchema,
653
783
  rowStateSchema,
@@ -667,5 +797,8 @@ export {
667
797
  pageInfoBool,
668
798
  pageInfoString,
669
799
  buildFixture,
800
+ lineStateSchema,
801
+ lineStateSchemaWithAnnotations,
802
+ buildSubIdFixture,
670
803
  }
671
804
  /* Not a pure module */
@@ -179,6 +179,70 @@ describe("AutomationSlice Callback (mixed-source)", () => {
179
179
  })
180
180
  })
181
181
 
182
+ // A row used to leave the retry budget by falling out of phase2's filter, so
183
+ // `Failed` meant both "will be retried" and "never again" and only the count
184
+ // told them apart — against a ceiling that lives in the Spec, not in the row.
185
+ describe("retry exhaustion", () => {
186
+ let failingPublish: ReventlessInfra.CommandTopic.publishJsons = async _cmds =>
187
+ JsError.throwWithMessage("publish failed")
188
+
189
+ let collectOne = () =>
190
+ Callback.phase1(
191
+ [encodeShipOrderEvent(OrderPlaced({orderId: "ord-1", address: "123 Main St"}))],
192
+ testContext,
193
+ )
194
+
195
+ let statusOf = id => (Callback.todoItems->Dict.get(id)->Option.getOrThrow).status
196
+
197
+ testPromise("the attempt that spends the budget writes Abandoned, not Failed", async () => {
198
+ collectOne()
199
+ // ShipOrderSpec.maxRetries is 3: attempts at retryCount 0, 1 and 2 leave the
200
+ // row retriable, and the third failure is the one that ends it.
201
+ await Callback.phase2(failingPublish)
202
+ expect(statusOf("ord-1"))->toBe(Failed)
203
+ await Callback.phase2(failingPublish)
204
+ expect(statusOf("ord-1"))->toBe(Failed)
205
+ await Callback.phase2(failingPublish)
206
+ expect(statusOf("ord-1"))->toBe(Abandoned)
207
+ expect((Callback.todoItems->Dict.get("ord-1")->Option.getOrThrow).retryCount)->toBe(3)
208
+ })
209
+
210
+ testPromise("an abandoned row is not picked up again", async () => {
211
+ collectOne()
212
+ await Callback.phase2(failingPublish)
213
+ await Callback.phase2(failingPublish)
214
+ await Callback.phase2(failingPublish)
215
+ expect(statusOf("ord-1"))->toBe(Abandoned)
216
+
217
+ // A pass that would have succeeded: the row must still not be attempted,
218
+ // which is what makes the status a decision rather than a label.
219
+ let published = ref([])
220
+ await Callback.phase2(async cmds => published := cmds)
221
+ expect(published.contents->Array.length)->toBe(0)
222
+ expect(statusOf("ord-1"))->toBe(Abandoned)
223
+ expect((Callback.todoItems->Dict.get("ord-1")->Option.getOrThrow).retryCount)->toBe(3)
224
+ })
225
+
226
+ testPromise("a row left stranded at the ceiling is normalised on the next pass", async () => {
227
+ collectOne()
228
+ // What an older build wrote, and what a Spec that lowered maxRetries leaves
229
+ // behind: Failed at the ceiling, which no pass would ever pick up or mark.
230
+ let row = Callback.todoItems->Dict.get("ord-1")->Option.getOrThrow
231
+ Callback.todoItems->Dict.set("ord-1", {...row, status: Failed, retryCount: 3})
232
+
233
+ let published = ref([])
234
+ await Callback.phase2(async cmds => published := cmds)
235
+ expect(published.contents->Array.length)->toBe(0)
236
+ expect(statusOf("ord-1"))->toBe(Abandoned)
237
+ })
238
+
239
+ testPromise("the row carries the ceiling its count is racing", async () => {
240
+ collectOne()
241
+ expect((Callback.todoItems->Dict.get("ord-1")->Option.getOrThrow).maxRetries)
242
+ ->toEqual(Some(ShipOrderSpec.maxRetries))
243
+ })
244
+ })
245
+
182
246
  describe("full lifecycle", () => {
183
247
  testPromise("collect → process → resolve completes the TODO item", async () => {
184
248
  Callback.phase1(
@@ -192,6 +192,61 @@ globalThis.describe("AutomationSlice Callback (mixed-source)", () => {
192
192
  globalThis.expect(row2.status).toBe("Processing");
193
193
  });
194
194
  });
195
+ globalThis.describe("retry exhaustion", () => {
196
+ let failingPublish = async _cmds => Stdlib_JsError.throwWithMessage("publish failed");
197
+ let collectOne = () => Callback.phase1([AutomationSliceFixtures$ReventlessLocal.encodeShipOrderEvent({
198
+ TAG: "OrderPlaced",
199
+ orderId: "ord-1",
200
+ address: "123 Main St"
201
+ })], AutomationSliceFixtures$ReventlessLocal.testContext);
202
+ let statusOf = id => Stdlib_Option.getOrThrow(Callback.todoItems[id], undefined).status;
203
+ globalThis.test("the attempt that spends the budget writes Abandoned, not Failed", async () => {
204
+ collectOne();
205
+ await Callback.phase2(failingPublish);
206
+ globalThis.expect(statusOf("ord-1")).toBe("Failed");
207
+ await Callback.phase2(failingPublish);
208
+ globalThis.expect(statusOf("ord-1")).toBe("Failed");
209
+ await Callback.phase2(failingPublish);
210
+ globalThis.expect(statusOf("ord-1")).toBe("Abandoned");
211
+ globalThis.expect(Stdlib_Option.getOrThrow(Callback.todoItems["ord-1"], undefined).retryCount).toBe(3);
212
+ });
213
+ globalThis.test("an abandoned row is not picked up again", async () => {
214
+ collectOne();
215
+ await Callback.phase2(failingPublish);
216
+ await Callback.phase2(failingPublish);
217
+ await Callback.phase2(failingPublish);
218
+ globalThis.expect(statusOf("ord-1")).toBe("Abandoned");
219
+ let published = {
220
+ contents: []
221
+ };
222
+ await Callback.phase2(async cmds => {
223
+ published.contents = cmds;
224
+ });
225
+ globalThis.expect(published.contents.length).toBe(0);
226
+ globalThis.expect(statusOf("ord-1")).toBe("Abandoned");
227
+ globalThis.expect(Stdlib_Option.getOrThrow(Callback.todoItems["ord-1"], undefined).retryCount).toBe(3);
228
+ });
229
+ globalThis.test("a row left stranded at the ceiling is normalised on the next pass", async () => {
230
+ collectOne();
231
+ let row = Stdlib_Option.getOrThrow(Callback.todoItems["ord-1"], undefined);
232
+ let newrecord = {...row};
233
+ newrecord.retryCount = 3;
234
+ newrecord.status = "Failed";
235
+ Callback.todoItems["ord-1"] = newrecord;
236
+ let published = {
237
+ contents: []
238
+ };
239
+ await Callback.phase2(async cmds => {
240
+ published.contents = cmds;
241
+ });
242
+ globalThis.expect(published.contents.length).toBe(0);
243
+ globalThis.expect(statusOf("ord-1")).toBe("Abandoned");
244
+ });
245
+ globalThis.test("the row carries the ceiling its count is racing", async () => {
246
+ collectOne();
247
+ globalThis.expect(Stdlib_Option.getOrThrow(Callback.todoItems["ord-1"], undefined).maxRetries).toEqual(AutomationSliceFixtures$ReventlessLocal.ShipOrderSpec.maxRetries);
248
+ });
249
+ });
195
250
  globalThis.describe("full lifecycle", () => {
196
251
  globalThis.test("collect → process → resolve completes the TODO item", async () => {
197
252
  Callback.phase1([AutomationSliceFixtures$ReventlessLocal.encodeShipOrderEvent({
@@ -108,6 +108,7 @@ module ShipOrderAutomation: Reventless.AutomationSlice.Automation
108
108
  with module Spec := ShipOrderSpec = {
109
109
  let process = (id, _item: ShipOrderSpec.todoItem) =>
110
110
  Some((id, ShipOrderSpec.CreateShipment({orderId: id})))
111
+ let onExhausted = (_id, _item: ShipOrderSpec.todoItem) => None
111
112
  let moduleUrl: string = %raw(`import.meta.url`)
112
113
  module M = Reventless.AutomationSlice.Mappings.Make(ShipOrderSpec)
113
114
  module type Mapping = M.Mapping
@@ -117,6 +118,7 @@ module ShipOrderAutomation: Reventless.AutomationSlice.Automation
117
118
  module SkipProcessAutomation: Reventless.AutomationSlice.Automation
118
119
  with module Spec := SkipProcessSpec = {
119
120
  let process = (_id, _item: SkipProcessSpec.todoItem) => None
121
+ let onExhausted = (_id, _item: SkipProcessSpec.todoItem) => None
120
122
  let moduleUrl: string = %raw(`import.meta.url`)
121
123
  module M = Reventless.AutomationSlice.Mappings.Make(SkipProcessSpec)
122
124
  module type Mapping = M.Mapping
@@ -168,6 +168,10 @@ function process(id, _item) {
168
168
  ];
169
169
  }
170
170
 
171
+ function onExhausted(_id, _item) {
172
+
173
+ }
174
+
171
175
  let moduleUrl$2 = import.meta.url;
172
176
 
173
177
  AutomationSlice$Reventless.Mappings.Make(ShipOrderSpec);
@@ -176,6 +180,7 @@ let mappings = [ShipOrderMapping];
176
180
 
177
181
  let ShipOrderAutomation = {
178
182
  process: process,
183
+ onExhausted: onExhausted,
179
184
  moduleUrl: moduleUrl$2,
180
185
  mappings: mappings
181
186
  };
@@ -184,6 +189,10 @@ function process$1(_id, _item) {
184
189
 
185
190
  }
186
191
 
192
+ function onExhausted$1(_id, _item) {
193
+
194
+ }
195
+
187
196
  let moduleUrl$3 = import.meta.url;
188
197
 
189
198
  AutomationSlice$Reventless.Mappings.Make(SkipProcessSpec);
@@ -192,6 +201,7 @@ let mappings$1 = [SkipProcessMapping];
192
201
 
193
202
  let SkipProcessAutomation = {
194
203
  process: process$1,
204
+ onExhausted: onExhausted$1,
195
205
  moduleUrl: moduleUrl$3,
196
206
  mappings: mappings$1
197
207
  };
@@ -158,6 +158,7 @@ module FromDcb = AutomationSlice.Mapping.Make(
158
158
  module AutoShipAutomation: AutomationSlice.Automation with module Spec := AutoShipSpec = {
159
159
  let process = (id, _item: AutoShipSpec.todoItem) =>
160
160
  Some((id, AutoShipSpec.Ship({orderId: id})))
161
+ let onExhausted = (_id, _item: AutoShipSpec.todoItem) => None
161
162
  let moduleUrl: string = %raw(`import.meta.url`)
162
163
  module M = AutomationSlice.Mappings.Make(AutoShipSpec)
163
164
  module type Mapping = M.Mapping
@@ -264,6 +264,10 @@ function process(id, _item) {
264
264
  ];
265
265
  }
266
266
 
267
+ function onExhausted(_id, _item) {
268
+
269
+ }
270
+
267
271
  let moduleUrl$5 = import.meta.url;
268
272
 
269
273
  AutomationSlice$Reventless.Mappings.Make(AutoShipSpec);
@@ -272,6 +276,7 @@ let mappings = [FromDcb];
272
276
 
273
277
  let AutoShipAutomation = {
274
278
  process: process,
279
+ onExhausted: onExhausted,
275
280
  moduleUrl: moduleUrl$5,
276
281
  mappings: mappings
277
282
  };
@@ -117,6 +117,7 @@ module AutoFulfillAutomation: AutomationSlice.Automation
117
117
  with module Spec := AutoFulfillSpec = {
118
118
  let process = (id, item: AutoFulfillSpec.todoItem) =>
119
119
  Some((id, AutoFulfillSpec.MarkFulfilled({orderId: item.orderId, productId: item.productId})))
120
+ let onExhausted = (_id, _item: AutoFulfillSpec.todoItem) => None
120
121
  let moduleUrl: string = %raw(`import.meta.url`)
121
122
  module M = AutomationSlice.Mappings.Make(AutoFulfillSpec)
122
123
  module type Mapping = M.Mapping
@@ -149,6 +149,10 @@ function process(id, item) {
149
149
  ];
150
150
  }
151
151
 
152
+ function onExhausted(_id, _item) {
153
+
154
+ }
155
+
152
156
  let moduleUrl$1 = import.meta.url;
153
157
 
154
158
  AutomationSlice$Reventless.Mappings.Make(AutoFulfillSpec);
@@ -160,6 +164,7 @@ let mappings = [
160
164
 
161
165
  let AutoFulfillAutomation = {
162
166
  process: process,
167
+ onExhausted: onExhausted,
163
168
  moduleUrl: moduleUrl$1,
164
169
  mappings: mappings
165
170
  };
@@ -6,11 +6,13 @@ open OutboundTranslationSliceFixtures
6
6
  module SendTrackingEmailTranslation = {
7
7
  let collect = SendTrackingEmailSpec.collect
8
8
  let translate = SendTrackingEmailSpec.translate
9
+ let onExhausted = SendTrackingEmailSpec.onExhausted
9
10
  let moduleUrl = SendTrackingEmailSpec.moduleUrl
10
11
  }
11
12
  module ProcessPaymentTranslation = {
12
13
  let collect = ProcessPaymentSpec.collect
13
14
  let translate = ProcessPaymentSpec.translate
15
+ let onExhausted = ProcessPaymentSpec.onExhausted
14
16
  let moduleUrl = ProcessPaymentSpec.moduleUrl
15
17
  }
16
18
  module FireForgetCallback = ReventlessCore.OutboundTranslationSlice_Callback.Make(
@@ -31,6 +33,8 @@ describe("OutboundTranslationSlice Callback", () => {
31
33
  // Reset the translate function to its default
32
34
  ProcessPaymentSpec.translateFn :=
33
35
  (async (id, _item) => Ok(Some((id, ProcessPaymentSpec.ConfirmPayment({orderId: id})))))
36
+ // Default is a slice with nothing to say when its budget runs out.
37
+ ProcessPaymentSpec.onExhaustedFn := ((_id, _item, _lastError) => None)
34
38
  })
35
39
 
36
40
  describe("Phase 1 — collect", () => {
@@ -157,11 +161,17 @@ describe("OutboundTranslationSlice Callback", () => {
157
161
  CommandBackCallback.phase1([("evt", PaymentReceived({orderId: "ord-1", amount: 50.0}))])
158
162
  let mockPublish: ReventlessInfra.CommandTopic.publishJsons = async _cmds => ()
159
163
 
160
- // Fail twice
164
+ // Fail once — still retriable, and the status says so.
161
165
  await CommandBackCallback.phase2(mockPublish, ~capabilities=Reventless.Capabilities.none)
166
+ expect((CommandBackCallback.todoItems->Dict.get("ord-1")->Option.getOrThrow).status)
167
+ ->toBe(Failed)
168
+
169
+ // The second failure spends the budget, and is marked at that moment rather
170
+ // than by falling out of the next pass's filter.
162
171
  await CommandBackCallback.phase2(mockPublish, ~capabilities=Reventless.Capabilities.none)
163
172
  let row = CommandBackCallback.todoItems->Dict.get("ord-1")->Option.getOrThrow
164
173
  expect(row.retryCount)->toBe(2)
174
+ expect(row.status)->toBe(Abandoned)
165
175
 
166
176
  // Third attempt should not be tried
167
177
  let publishedCommands = ref([])
@@ -173,6 +183,130 @@ describe("OutboundTranslationSlice Callback", () => {
173
183
  let row2 = CommandBackCallback.todoItems->Dict.get("ord-1")->Option.getOrThrow
174
184
  // retryCount should still be 2 — not retried
175
185
  expect(row2.retryCount)->toBe(2)
186
+ expect(row2.status)->toBe(Abandoned)
187
+ })
188
+
189
+ // What an older build wrote, and what a Spec that lowers maxRetries leaves
190
+ // behind: Failed at the ceiling, a status promising a retry no pass will make.
191
+ testPromise("a row left stranded at the ceiling is normalised on the next pass", async () => {
192
+ ProcessPaymentSpec.translateFn := (async (_id, _item) => Error("timeout"))
193
+ CommandBackCallback.phase1([("evt", PaymentReceived({orderId: "ord-1", amount: 50.0}))])
194
+ let row = CommandBackCallback.todoItems->Dict.get("ord-1")->Option.getOrThrow
195
+ CommandBackCallback.todoItems->Dict.set("ord-1", {...row, status: Failed, retryCount: 2})
196
+
197
+ await CommandBackCallback.phase2(
198
+ async _cmds => (),
199
+ ~capabilities=Reventless.Capabilities.none,
200
+ )
201
+ expect((CommandBackCallback.todoItems->Dict.get("ord-1")->Option.getOrThrow).status)
202
+ ->toBe(Abandoned)
203
+ })
204
+
205
+ testPromise("the last error survives the transition to Abandoned", async () => {
206
+ ProcessPaymentSpec.translateFn := (async (_id, _item) => Error("gateway down"))
207
+ CommandBackCallback.phase1([("evt", PaymentReceived({orderId: "ord-1", amount: 50.0}))])
208
+ let publish: ReventlessInfra.CommandTopic.publishJsons = async _cmds => ()
209
+ await CommandBackCallback.phase2(publish, ~capabilities=Reventless.Capabilities.none)
210
+ await CommandBackCallback.phase2(publish, ~capabilities=Reventless.Capabilities.none)
211
+ let row = CommandBackCallback.todoItems->Dict.get("ord-1")->Option.getOrThrow
212
+ expect(row.status)->toBe(Abandoned)
213
+ expect(row.lastError)->toEqual(Some("gateway down"))
214
+ })
215
+
216
+ testPromise("the row carries the ceiling its count is racing", async () => {
217
+ CommandBackCallback.phase1([("evt", PaymentReceived({orderId: "ord-1", amount: 50.0}))])
218
+ expect((CommandBackCallback.todoItems->Dict.get("ord-1")->Option.getOrThrow).maxRetries)
219
+ ->toEqual(Some(ProcessPaymentSpec.maxRetries))
220
+ })
221
+
222
+ // Abandonment is an outcome, and a slice that has one to report gets to say
223
+ // so — the row going quiet is otherwise the only trace.
224
+ testPromise("a slice that answers onExhausted has its command published", async () => {
225
+ ProcessPaymentSpec.translateFn := (async (_id, _item) => Error("gateway down"))
226
+ ProcessPaymentSpec.onExhaustedFn :=
227
+ ((id, _item, _lastError) => Some((id, ProcessPaymentSpec.ConfirmPayment({orderId: id}))))
228
+
229
+ CommandBackCallback.phase1([("evt", PaymentReceived({orderId: "ord-1", amount: 50.0}))])
230
+ let published = ref([])
231
+ let publish: ReventlessInfra.CommandTopic.publishJsons = async cmds =>
232
+ published := Array.concat(published.contents, cmds)
233
+
234
+ await CommandBackCallback.phase2(publish, ~capabilities=Reventless.Capabilities.none)
235
+ expect(published.contents->Array.length)->toBe(0) // still retriable
236
+ await CommandBackCallback.phase2(publish, ~capabilities=Reventless.Capabilities.none)
237
+
238
+ expect((CommandBackCallback.todoItems->Dict.get("ord-1")->Option.getOrThrow).status)
239
+ ->toBe(Abandoned)
240
+ expect(published.contents->Array.length)->toBe(1)
241
+ expect((published.contents->Array.getUnsafe(0)).id)->toBe("ord-1")
242
+ // The target's name, not the slice's — the same rule the success path follows.
243
+ expect((published.contents->Array.getUnsafe(0)).meta.service)->toBe("ConfirmPayment")
244
+ })
245
+
246
+ testPromise("the hook is handed the error that ended it", async () => {
247
+ ProcessPaymentSpec.translateFn := (async (_id, _item) => Error("gateway down"))
248
+ let seen = ref(None)
249
+ ProcessPaymentSpec.onExhaustedFn :=
250
+ ((_id, _item, lastError) => {
251
+ seen := lastError
252
+ None
253
+ })
254
+
255
+ CommandBackCallback.phase1([("evt", PaymentReceived({orderId: "ord-1", amount: 50.0}))])
256
+ let publish: ReventlessInfra.CommandTopic.publishJsons = async _cmds => ()
257
+ await CommandBackCallback.phase2(publish, ~capabilities=Reventless.Capabilities.none)
258
+ await CommandBackCallback.phase2(publish, ~capabilities=Reventless.Capabilities.none)
259
+ expect(seen.contents)->toEqual(Some("gateway down"))
260
+ })
261
+
262
+ testPromise("a silent slice publishes nothing, and the row is still Abandoned", async () => {
263
+ ProcessPaymentSpec.translateFn := (async (_id, _item) => Error("gateway down"))
264
+ // onExhaustedFn stays at its default None (reset in beforeEach).
265
+ CommandBackCallback.phase1([("evt", PaymentReceived({orderId: "ord-1", amount: 50.0}))])
266
+ let published = ref([])
267
+ let publish: ReventlessInfra.CommandTopic.publishJsons = async cmds =>
268
+ published := Array.concat(published.contents, cmds)
269
+ await CommandBackCallback.phase2(publish, ~capabilities=Reventless.Capabilities.none)
270
+ await CommandBackCallback.phase2(publish, ~capabilities=Reventless.Capabilities.none)
271
+ expect(published.contents->Array.length)->toBe(0)
272
+ expect((CommandBackCallback.todoItems->Dict.get("ord-1")->Option.getOrThrow).status)
273
+ ->toBe(Abandoned)
274
+ })
275
+
276
+ // The budget is what ran out, so there is nothing left to retry with — a row
277
+ // must not fall back to Failed and re-enter the sweep on a publish error.
278
+ testPromise("a failed announcement leaves the row Abandoned", async () => {
279
+ ProcessPaymentSpec.translateFn := (async (_id, _item) => Error("gateway down"))
280
+ ProcessPaymentSpec.onExhaustedFn :=
281
+ ((id, _item, _lastError) => Some((id, ProcessPaymentSpec.ConfirmPayment({orderId: id}))))
282
+
283
+ CommandBackCallback.phase1([("evt", PaymentReceived({orderId: "ord-1", amount: 50.0}))])
284
+ let failOnAbandonment: ReventlessInfra.CommandTopic.publishJsons = async _cmds =>
285
+ JsError.throwWithMessage("topic unavailable")
286
+ await CommandBackCallback.phase2(failOnAbandonment, ~capabilities=Reventless.Capabilities.none)
287
+ await CommandBackCallback.phase2(failOnAbandonment, ~capabilities=Reventless.Capabilities.none)
288
+ let row = CommandBackCallback.todoItems->Dict.get("ord-1")->Option.getOrThrow
289
+ expect(row.status)->toBe(Abandoned)
290
+ expect(row.retryCount)->toBe(2)
291
+ })
292
+
293
+ // A row an older build stranded reaches the hook too — it is being abandoned
294
+ // now, as far as anything downstream is concerned.
295
+ testPromise("a stranded row announces when it is normalised", async () => {
296
+ ProcessPaymentSpec.onExhaustedFn :=
297
+ ((id, _item, _lastError) => Some((id, ProcessPaymentSpec.ConfirmPayment({orderId: id}))))
298
+ CommandBackCallback.phase1([("evt", PaymentReceived({orderId: "ord-1", amount: 50.0}))])
299
+ let row = CommandBackCallback.todoItems->Dict.get("ord-1")->Option.getOrThrow
300
+ CommandBackCallback.todoItems->Dict.set("ord-1", {...row, status: Failed, retryCount: 2})
301
+
302
+ let published = ref([])
303
+ await CommandBackCallback.phase2(
304
+ async cmds => published := cmds,
305
+ ~capabilities=Reventless.Capabilities.none,
306
+ )
307
+ expect(published.contents->Array.length)->toBe(1)
308
+ expect((CommandBackCallback.todoItems->Dict.get("ord-1")->Option.getOrThrow).status)
309
+ ->toBe(Abandoned)
176
310
  })
177
311
 
178
312
  testPromise("individual item failure does not affect other items", async () => {
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as Stdlib_Dict from "@rescript/runtime/lib/es6/Stdlib_Dict.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
5
6
  import * as Capabilities$Reventless from "@reventlessdev/reventless-spec/src/semantic/Capabilities.res.mjs";
6
7
  import * as OutboundTranslationSliceFixtures$ReventlessLocal from "./OutboundTranslationSliceFixtures.res.mjs";
7
8
  import * as OutboundTranslationSlice_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/OutboundTranslationSlice/OutboundTranslationSlice_Callback.res.mjs";
@@ -10,11 +11,14 @@ let collect = OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmail
10
11
 
11
12
  let translate = OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.translate;
12
13
 
14
+ let onExhausted = OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.onExhausted;
15
+
13
16
  let moduleUrl = OutboundTranslationSliceFixtures$ReventlessLocal.SendTrackingEmailSpec.moduleUrl;
14
17
 
15
18
  let SendTrackingEmailTranslation = {
16
19
  collect: collect,
17
20
  translate: translate,
21
+ onExhausted: onExhausted,
18
22
  moduleUrl: moduleUrl
19
23
  };
20
24
 
@@ -22,11 +26,14 @@ let collect$1 = OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentS
22
26
 
23
27
  let translate$1 = OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.translate;
24
28
 
29
+ let onExhausted$1 = OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.onExhausted;
30
+
25
31
  let moduleUrl$1 = OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.moduleUrl;
26
32
 
27
33
  let ProcessPaymentTranslation = {
28
34
  collect: collect$1,
29
35
  translate: translate$1,
36
+ onExhausted: onExhausted$1,
30
37
  moduleUrl: moduleUrl$1
31
38
  };
32
39
 
@@ -70,6 +77,7 @@ globalThis.describe("OutboundTranslationSlice Callback", () => {
70
77
  }
71
78
  ]
72
79
  });
80
+ OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.onExhaustedFn.contents = (_id, _item, _lastError) => {};
73
81
  });
74
82
  globalThis.describe("Phase 1 — collect", () => {
75
83
  globalThis.test("OrderShipped event creates a pending outbound item", async () => {
@@ -276,9 +284,11 @@ globalThis.describe("OutboundTranslationSlice Callback", () => {
276
284
  ]]);
277
285
  let mockPublish = async _cmds => {};
278
286
  await CommandBackCallback.phase2(mockPublish, Capabilities$Reventless.none);
287
+ globalThis.expect(Stdlib_Option.getOrThrow(CommandBackCallback.todoItems["ord-1"], undefined).status).toBe("Failed");
279
288
  await CommandBackCallback.phase2(mockPublish, Capabilities$Reventless.none);
280
289
  let row = Stdlib_Option.getOrThrow(CommandBackCallback.todoItems["ord-1"], undefined);
281
290
  globalThis.expect(row.retryCount).toBe(2);
291
+ globalThis.expect(row.status).toBe("Abandoned");
282
292
  let publishedCommands = {
283
293
  contents: []
284
294
  };
@@ -289,6 +299,198 @@ globalThis.describe("OutboundTranslationSlice Callback", () => {
289
299
  globalThis.expect(publishedCommands.contents.length).toBe(0);
290
300
  let row2 = Stdlib_Option.getOrThrow(CommandBackCallback.todoItems["ord-1"], undefined);
291
301
  globalThis.expect(row2.retryCount).toBe(2);
302
+ globalThis.expect(row2.status).toBe("Abandoned");
303
+ });
304
+ globalThis.test("a row left stranded at the ceiling is normalised on the next pass", async () => {
305
+ OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.translateFn.contents = async (_id, _item) => ({
306
+ TAG: "Error",
307
+ _0: "timeout"
308
+ });
309
+ CommandBackCallback.phase1([[
310
+ "evt",
311
+ {
312
+ TAG: "PaymentReceived",
313
+ orderId: "ord-1",
314
+ amount: 50.0
315
+ }
316
+ ]]);
317
+ let row = Stdlib_Option.getOrThrow(CommandBackCallback.todoItems["ord-1"], undefined);
318
+ let newrecord = {...row};
319
+ newrecord.retryCount = 2;
320
+ newrecord.status = "Failed";
321
+ CommandBackCallback.todoItems["ord-1"] = newrecord;
322
+ await CommandBackCallback.phase2(async _cmds => {}, Capabilities$Reventless.none);
323
+ globalThis.expect(Stdlib_Option.getOrThrow(CommandBackCallback.todoItems["ord-1"], undefined).status).toBe("Abandoned");
324
+ });
325
+ globalThis.test("the last error survives the transition to Abandoned", async () => {
326
+ OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.translateFn.contents = async (_id, _item) => ({
327
+ TAG: "Error",
328
+ _0: "gateway down"
329
+ });
330
+ CommandBackCallback.phase1([[
331
+ "evt",
332
+ {
333
+ TAG: "PaymentReceived",
334
+ orderId: "ord-1",
335
+ amount: 50.0
336
+ }
337
+ ]]);
338
+ let publish = async _cmds => {};
339
+ await CommandBackCallback.phase2(publish, Capabilities$Reventless.none);
340
+ await CommandBackCallback.phase2(publish, Capabilities$Reventless.none);
341
+ let row = Stdlib_Option.getOrThrow(CommandBackCallback.todoItems["ord-1"], undefined);
342
+ globalThis.expect(row.status).toBe("Abandoned");
343
+ globalThis.expect(row.lastError).toEqual("gateway down");
344
+ });
345
+ globalThis.test("the row carries the ceiling its count is racing", async () => {
346
+ CommandBackCallback.phase1([[
347
+ "evt",
348
+ {
349
+ TAG: "PaymentReceived",
350
+ orderId: "ord-1",
351
+ amount: 50.0
352
+ }
353
+ ]]);
354
+ globalThis.expect(Stdlib_Option.getOrThrow(CommandBackCallback.todoItems["ord-1"], undefined).maxRetries).toEqual(OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.maxRetries);
355
+ });
356
+ globalThis.test("a slice that answers onExhausted has its command published", async () => {
357
+ OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.translateFn.contents = async (_id, _item) => ({
358
+ TAG: "Error",
359
+ _0: "gateway down"
360
+ });
361
+ OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.onExhaustedFn.contents = (id, _item, _lastError) => [
362
+ id,
363
+ {
364
+ TAG: "ConfirmPayment",
365
+ orderId: id
366
+ }
367
+ ];
368
+ CommandBackCallback.phase1([[
369
+ "evt",
370
+ {
371
+ TAG: "PaymentReceived",
372
+ orderId: "ord-1",
373
+ amount: 50.0
374
+ }
375
+ ]]);
376
+ let published = {
377
+ contents: []
378
+ };
379
+ let publish = async cmds => {
380
+ published.contents = published.contents.concat(cmds);
381
+ };
382
+ await CommandBackCallback.phase2(publish, Capabilities$Reventless.none);
383
+ globalThis.expect(published.contents.length).toBe(0);
384
+ await CommandBackCallback.phase2(publish, Capabilities$Reventless.none);
385
+ globalThis.expect(Stdlib_Option.getOrThrow(CommandBackCallback.todoItems["ord-1"], undefined).status).toBe("Abandoned");
386
+ globalThis.expect(published.contents.length).toBe(1);
387
+ globalThis.expect(published.contents[0].id).toBe("ord-1");
388
+ globalThis.expect(published.contents[0].meta.service).toBe("ConfirmPayment");
389
+ });
390
+ globalThis.test("the hook is handed the error that ended it", async () => {
391
+ OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.translateFn.contents = async (_id, _item) => ({
392
+ TAG: "Error",
393
+ _0: "gateway down"
394
+ });
395
+ let seen = {
396
+ contents: undefined
397
+ };
398
+ OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.onExhaustedFn.contents = (_id, _item, lastError) => {
399
+ seen.contents = lastError;
400
+ };
401
+ CommandBackCallback.phase1([[
402
+ "evt",
403
+ {
404
+ TAG: "PaymentReceived",
405
+ orderId: "ord-1",
406
+ amount: 50.0
407
+ }
408
+ ]]);
409
+ let publish = async _cmds => {};
410
+ await CommandBackCallback.phase2(publish, Capabilities$Reventless.none);
411
+ await CommandBackCallback.phase2(publish, Capabilities$Reventless.none);
412
+ globalThis.expect(seen.contents).toEqual("gateway down");
413
+ });
414
+ globalThis.test("a silent slice publishes nothing, and the row is still Abandoned", async () => {
415
+ OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.translateFn.contents = async (_id, _item) => ({
416
+ TAG: "Error",
417
+ _0: "gateway down"
418
+ });
419
+ CommandBackCallback.phase1([[
420
+ "evt",
421
+ {
422
+ TAG: "PaymentReceived",
423
+ orderId: "ord-1",
424
+ amount: 50.0
425
+ }
426
+ ]]);
427
+ let published = {
428
+ contents: []
429
+ };
430
+ let publish = async cmds => {
431
+ published.contents = published.contents.concat(cmds);
432
+ };
433
+ await CommandBackCallback.phase2(publish, Capabilities$Reventless.none);
434
+ await CommandBackCallback.phase2(publish, Capabilities$Reventless.none);
435
+ globalThis.expect(published.contents.length).toBe(0);
436
+ globalThis.expect(Stdlib_Option.getOrThrow(CommandBackCallback.todoItems["ord-1"], undefined).status).toBe("Abandoned");
437
+ });
438
+ globalThis.test("a failed announcement leaves the row Abandoned", async () => {
439
+ OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.translateFn.contents = async (_id, _item) => ({
440
+ TAG: "Error",
441
+ _0: "gateway down"
442
+ });
443
+ OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.onExhaustedFn.contents = (id, _item, _lastError) => [
444
+ id,
445
+ {
446
+ TAG: "ConfirmPayment",
447
+ orderId: id
448
+ }
449
+ ];
450
+ CommandBackCallback.phase1([[
451
+ "evt",
452
+ {
453
+ TAG: "PaymentReceived",
454
+ orderId: "ord-1",
455
+ amount: 50.0
456
+ }
457
+ ]]);
458
+ let failOnAbandonment = async _cmds => Stdlib_JsError.throwWithMessage("topic unavailable");
459
+ await CommandBackCallback.phase2(failOnAbandonment, Capabilities$Reventless.none);
460
+ await CommandBackCallback.phase2(failOnAbandonment, Capabilities$Reventless.none);
461
+ let row = Stdlib_Option.getOrThrow(CommandBackCallback.todoItems["ord-1"], undefined);
462
+ globalThis.expect(row.status).toBe("Abandoned");
463
+ globalThis.expect(row.retryCount).toBe(2);
464
+ });
465
+ globalThis.test("a stranded row announces when it is normalised", async () => {
466
+ OutboundTranslationSliceFixtures$ReventlessLocal.ProcessPaymentSpec.onExhaustedFn.contents = (id, _item, _lastError) => [
467
+ id,
468
+ {
469
+ TAG: "ConfirmPayment",
470
+ orderId: id
471
+ }
472
+ ];
473
+ CommandBackCallback.phase1([[
474
+ "evt",
475
+ {
476
+ TAG: "PaymentReceived",
477
+ orderId: "ord-1",
478
+ amount: 50.0
479
+ }
480
+ ]]);
481
+ let row = Stdlib_Option.getOrThrow(CommandBackCallback.todoItems["ord-1"], undefined);
482
+ let newrecord = {...row};
483
+ newrecord.retryCount = 2;
484
+ newrecord.status = "Failed";
485
+ CommandBackCallback.todoItems["ord-1"] = newrecord;
486
+ let published = {
487
+ contents: []
488
+ };
489
+ await CommandBackCallback.phase2(async cmds => {
490
+ published.contents = cmds;
491
+ }, Capabilities$Reventless.none);
492
+ globalThis.expect(published.contents.length).toBe(1);
493
+ globalThis.expect(Stdlib_Option.getOrThrow(CommandBackCallback.todoItems["ord-1"], undefined).status).toBe("Abandoned");
292
494
  });
293
495
  globalThis.test("individual item failure does not affect other items", async () => {
294
496
  let callCount = {
@@ -36,6 +36,7 @@ module SendTrackingEmailSpec = {
36
36
  }
37
37
 
38
38
  let translate = async (_id, _item, ~capabilities as _) => Ok(None)
39
+ let onExhausted = (_id, _item, ~lastError as _) => None
39
40
 
40
41
  let maxRetries = 3
41
42
  let heartbeatInterval = 60
@@ -77,6 +78,13 @@ module ProcessPaymentSpec = {
77
78
  // one restate a type it does not use.
78
79
  let translate = (id, item, ~capabilities as _) => translateFn.contents(id, item)
79
80
 
81
+ // Overridable like `translateFn`, so a test can assert both answers a slice may
82
+ // give when its budget runs out: say nothing, or tell the domain.
83
+ let onExhaustedFn: ref<(string, outboundItem, option<string>) => option<(string, inboundCommand)>> = ref(
84
+ (_id, _item, _lastError) => None
85
+ )
86
+ let onExhausted = (id, item, ~lastError) => onExhaustedFn.contents(id, item, lastError)
87
+
80
88
  let maxRetries = 2
81
89
  let heartbeatInterval = 30
82
90
  let targetName = Some("ConfirmPayment")
@@ -56,6 +56,10 @@ async function translate(_id, _item, param) {
56
56
  };
57
57
  }
58
58
 
59
+ function onExhausted(_id, _item, param) {
60
+
61
+ }
62
+
59
63
  let sourceNames = [];
60
64
 
61
65
  let SendTrackingEmailSpec = {
@@ -66,6 +70,7 @@ let SendTrackingEmailSpec = {
66
70
  inboundCommandSchema: inboundCommandSchema,
67
71
  collect: collect,
68
72
  translate: translate,
73
+ onExhausted: onExhausted,
69
74
  maxRetries: 3,
70
75
  heartbeatInterval: 60,
71
76
  targetName: undefined,
@@ -119,6 +124,14 @@ function translate$1(id, item, param) {
119
124
  return translateFn.contents(id, item);
120
125
  }
121
126
 
127
+ let onExhaustedFn = {
128
+ contents: (_id, _item, _lastError) => {}
129
+ };
130
+
131
+ function onExhausted$1(id, item, lastError) {
132
+ return onExhaustedFn.contents(id, item, lastError);
133
+ }
134
+
122
135
  let sourceNames$1 = [];
123
136
 
124
137
  let ProcessPaymentSpec_targetName = "ConfirmPayment";
@@ -132,6 +145,8 @@ let ProcessPaymentSpec = {
132
145
  collect: collect$1,
133
146
  translateFn: translateFn,
134
147
  translate: translate$1,
148
+ onExhaustedFn: onExhaustedFn,
149
+ onExhausted: onExhausted$1,
135
150
  maxRetries: 2,
136
151
  heartbeatInterval: 30,
137
152
  targetName: ProcessPaymentSpec_targetName,
@@ -111,6 +111,8 @@ module SendConfirmTranslation: OutboundTranslationSlice.Translation
111
111
  externalCalls->Array.push(item.orderId)
112
112
  Ok(None)
113
113
  }
114
+
115
+ let onExhausted = (_id, _item: SendConfirmSpec.outboundItem, ~lastError as _) => None
114
116
  }
115
117
 
116
118
  // ─────────────────────────────────────────────────────────────
@@ -141,9 +141,14 @@ async function translate(_id, item, param) {
141
141
  };
142
142
  }
143
143
 
144
+ function onExhausted(_id, _item, param) {
145
+
146
+ }
147
+
144
148
  let SendConfirmTranslation = {
145
149
  collect: collect,
146
150
  translate: translate,
151
+ onExhausted: onExhausted,
147
152
  moduleUrl: moduleUrl$3
148
153
  };
149
154