@reventlessdev/reventless-core 3.0.0-alpha.245 → 3.0.0-alpha.247

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.247 (2026-08-22)
7
+
8
+ * feat(core)!: a slice that has given up says so ([f5559ab](https://github.com/ReventlessDev/reventless-core/commit/f5559abede3a9cf2c9a48aadc69a666c46cc75bf))
9
+
10
+ ### BREAKING CHANGES
11
+
12
+ * `onExhausted` is required on the Translation and Automation
13
+ module types. ReScript has no optional module-type fields, so the alternative
14
+ was a PPX-injected default — and defaulting this one to silence is the bug.
15
+ A slice now states what abandonment means for its domain, `None` included.
16
+ GeocodeCustomerAddress answers with MarkAddressUnresolvable; the others say
17
+ nothing, on purpose.
18
+
19
+
20
+
21
+ # 3.0.0-alpha.246 (2026-08-21)
22
+
23
+ ### Bug Fixes
24
+
25
+ * **core:** make @resolves/[@resolves](https://github.com/resolves)Many work end to end ([4c52957](https://github.com/ReventlessDev/reventless-core/commit/4c5295759dcb4b3eda4f0f4f2c1bc387fed88fcb))
26
+ * **deps:** update sury to 11.0.0-rc.2 to fix unreachable union constructors ([fa5744f](https://github.com/ReventlessDev/reventless-core/commit/fa5744fed8de975e2f14725c856c6e5ce7d04a74))
27
+
28
+
6
29
  # 3.0.0-alpha.245 (2026-08-20)
7
30
 
8
31
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.245",
3
+ "version": "3.0.0-alpha.247",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -26,7 +26,7 @@
26
26
  }
27
27
  },
28
28
  "dependencies": {
29
- "sury": "11.0.0-rc.1",
29
+ "sury": "11.0.0-rc.2",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
32
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
@@ -36,13 +36,13 @@
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
37
37
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.8",
38
38
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.148",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.120",
41
- "@reventlessdev/reventless-interop": "3.0.0-alpha.32"
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.150",
40
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.33",
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.122"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
45
- "sury-ppx": "11.0.0-rc.1",
45
+ "sury-ppx": "11.0.0-rc.2",
46
46
  "@reventlessdev/reventless-ppx": "1.0.0-alpha.72"
47
47
  },
48
48
  "peerDependencies": {
@@ -128,6 +128,64 @@ let queryFieldNamesForReadModel = (~plugin: string, ~name: string, ~connectionSp
128
128
  }
129
129
  }
130
130
 
131
+ // The GraphQL type a queryable is exposed as, from its spec name alone. Both
132
+ // `queryFieldNamesFor*` above name it this way, so a `@resolves` target reached
133
+ // by spec name lands on the same type whichever kind declares it.
134
+ let returnTypeNameFor = (~plugin: string, ~name: string) =>
135
+ `${plugin}_${singularize(stripViewSuffix(name))}`
136
+
137
+ /**
138
+ The cross-table fields `@resolves` / `@resolvesMany` add to a queryable's object
139
+ type, named for the SDL. `plugin` owns the declaring view.
140
+
141
+ A target in another plugin is refused rather than emitted: under merged-API
142
+ composition each plugin's document must be valid standalone, so a field
143
+ returning a type the plugin does not define fails the merge — and on AWS the
144
+ target table's `dynamodb:*` grant is attached to the *declaring* plugin's API
145
+ role, so even a schema that merged would be refused at read time.
146
+ */
147
+ let resolvedFieldsOfConfig = (
148
+ ~plugin: string,
149
+ config: Reventless.ReadModel.config,
150
+ ): array<ReventlessInfra.Api.resolvedFieldEntry> => {
151
+ let assertSamePlugin = (~pluginName: option<string>, ~tableName: string, ~fieldName: string) =>
152
+ switch pluginName {
153
+ | Some(p) if p != plugin =>
154
+ JsError.throwWithMessage(
155
+ `Field \`${fieldName}\` resolves to "${tableName}" in plugin "${p}", but cross-plugin ` ++
156
+ `resolvers are not supported: a plugin's GraphQL document must be valid standalone, and ` ++
157
+ `"${p}"'s table is not readable by "${plugin}"'s API role. Query the other view directly, ` ++
158
+ `or project the fields you need into this one.`,
159
+ )
160
+ | _ => ()
161
+ }
162
+ let single = config.idResolvers->Array.map(({source, target}) => {
163
+ let (fieldName, multi) = switch source.resolvedField {
164
+ | Single(f) => (f, false)
165
+ | Multi(f) => (f, true)
166
+ }
167
+ assertSamePlugin(~pluginName=target.pluginName, ~tableName=target.tableName, ~fieldName)
168
+ {
169
+ ReventlessInfra.Api.fieldName,
170
+ typeName: returnTypeNameFor(~plugin, ~name=target.tableName),
171
+ multi,
172
+ }
173
+ })
174
+ let many = config.idsResolvers->Array.map(({source, target}) => {
175
+ assertSamePlugin(
176
+ ~pluginName=target.pluginName,
177
+ ~tableName=target.tableName,
178
+ ~fieldName=source.resolvedField,
179
+ )
180
+ {
181
+ ReventlessInfra.Api.fieldName: source.resolvedField,
182
+ typeName: returnTypeNameFor(~plugin, ~name=target.tableName),
183
+ multi: true,
184
+ }
185
+ })
186
+ Array.concat(single, many)
187
+ }
188
+
131
189
  let queryFieldNamesForStateView = (~plugin: string, ~viewName: string, ~connectionSpec: bool=true): queryNames => {
132
190
  let entity = stripViewSuffix(viewName)
133
191
  let singular = singularize(entity)
@@ -101,6 +101,48 @@ function queryFieldNamesForReadModel(plugin, name, connectionSpecOpt) {
101
101
  };
102
102
  }
103
103
 
104
+ function returnTypeNameFor(plugin, name) {
105
+ return plugin + `_` + singularize(stripViewSuffix(name));
106
+ }
107
+
108
+ function resolvedFieldsOfConfig(plugin, config) {
109
+ let assertSamePlugin = (pluginName, tableName, fieldName) => {
110
+ if (pluginName !== undefined && pluginName !== plugin) {
111
+ return Stdlib_JsError.throwWithMessage(`Field \`` + fieldName + `\` resolves to "` + tableName + `" in plugin "` + pluginName + `", but cross-plugin resolvers are not supported: a plugin's GraphQL document must be valid standalone, and ` + (`"` + pluginName + `"'s table is not readable by "` + plugin + `"'s API role. Query the other view directly, `) + `or project the fields you need into this one.`);
112
+ }
113
+ };
114
+ let single = config.idResolvers.map(param => {
115
+ let target = param.target;
116
+ let f = param.source.resolvedField;
117
+ let match;
118
+ match = f.TAG === "Single" ? [
119
+ f._0,
120
+ false
121
+ ] : [
122
+ f._0,
123
+ true
124
+ ];
125
+ let fieldName = match[0];
126
+ assertSamePlugin(target.pluginName, target.tableName, fieldName);
127
+ return {
128
+ fieldName: fieldName,
129
+ typeName: returnTypeNameFor(plugin, target.tableName),
130
+ multi: match[1]
131
+ };
132
+ });
133
+ let many = config.idsResolvers.map(param => {
134
+ let target = param.target;
135
+ let source = param.source;
136
+ assertSamePlugin(target.pluginName, target.tableName, source.resolvedField);
137
+ return {
138
+ fieldName: source.resolvedField,
139
+ typeName: returnTypeNameFor(plugin, target.tableName),
140
+ multi: true
141
+ };
142
+ });
143
+ return single.concat(many);
144
+ }
145
+
104
146
  function queryFieldNamesForStateView(plugin, viewName, connectionSpecOpt) {
105
147
  let connectionSpec = connectionSpecOpt !== undefined ? connectionSpecOpt : true;
106
148
  let entity = stripViewSuffix(viewName);
@@ -147,6 +189,8 @@ export {
147
189
  sliceMutationFieldFor,
148
190
  canonicalPlural,
149
191
  queryFieldNamesForReadModel,
192
+ returnTypeNameFor,
193
+ resolvedFieldsOfConfig,
150
194
  queryFieldNamesForStateView,
151
195
  queryFieldNamesForSliceQueryDb,
152
196
  adminField,
@@ -170,10 +170,18 @@ let reportUnclassifiedUnions = (~typeName: string, schema: S.t<unknown>): unit =
170
170
  )
171
171
  )
172
172
 
173
+ // The SDL line for one `@resolves` / `@resolvesMany` field. Both forms are
174
+ // nullable: the single form has no row when the foreign key names one that was
175
+ // never written, and the batch form drops missing ids rather than padding with
176
+ // nulls, so the list is shorter — never null inside.
177
+ let resolvedFieldSdl = ({fieldName, typeName, multi}: resolvedFieldEntry): string =>
178
+ multi ? ` ${fieldName}: [${typeName}!]` : ` ${fieldName}: ${typeName}`
179
+
173
180
  let deriveObjectTypeWithNested = (
174
181
  ~typeName: string,
175
182
  ~excludeFields: array<string>=[],
176
183
  ~includeIdParam: bool=true,
184
+ ~resolvedFields: array<resolvedFieldEntry>=[],
177
185
  schema: S.t<unknown>,
178
186
  ): array<string> => {
179
187
  reportUnclassifiedUnions(~typeName, schema)
@@ -219,7 +227,24 @@ let deriveObjectTypeWithNested = (
219
227
  } else {
220
228
  mainType
221
229
  }
222
- Array.concat(collectedTypes, [mainTypeWithId])
230
+ // A cross-table field shares the type with the state record's own fields, so
231
+ // a name already taken would emit the field twice and fail the whole document
232
+ // — for every plugin in the merge, not just this one. Refuse at build time,
233
+ // naming both halves.
234
+ resolvedFields->Array.forEach(({fieldName}) =>
235
+ if filteredFields->Dict.get(fieldName)->Option.isSome || (includeIdParam && fieldName == "id") {
236
+ JsError.throwWithMessage(
237
+ `${typeName}.${fieldName} is declared by @resolves/@resolvesMany and is already a field of the state record. Name the resolved field something the state does not use.`,
238
+ )
239
+ }
240
+ )
241
+ let mainTypeComplete = if resolvedFields->Array.length == 0 {
242
+ mainTypeWithId
243
+ } else {
244
+ let body = mainTypeWithId->String.slice(~start=0, ~end=mainTypeWithId->String.length - 1)
245
+ body ++ resolvedFields->Array.map(resolvedFieldSdl)->Array.join("\n") ++ "\n}"
246
+ }
247
+ Array.concat(collectedTypes, [mainTypeComplete])
223
248
  | None => []
224
249
  }
225
250
  }
@@ -763,6 +788,40 @@ let generate = (
763
788
  }
764
789
  })
765
790
 
791
+ // Two things a cross-table field has to satisfy, both checked before anything
792
+ // is emitted so the report names the declaration rather than the merge:
793
+ //
794
+ // The type it returns has to be one this fragment defines. A plugin's document
795
+ // is validated standalone before it is merged, and a field returning a name
796
+ // nothing declares fails the whole document rather than just the field.
797
+ //
798
+ // And the target has to be guarded the way the parent is. A nested field is
799
+ // reached through its parent, so it answers under the parent's authorization —
800
+ // a target that guards itself differently would be handed over by the wider
801
+ // door, which is the kind of hole nothing downstream can see. Equal rules pass,
802
+ // and so does a target open to everyone, which can only narrow.
803
+ let permissionOf = (entry: querySchemaEntry) =>
804
+ entry.permission->Option.getOr(Reventless.Authorization.AllowAuthenticated)
805
+ queryEntries->Array.forEach(entry =>
806
+ entry.resolvedFields
807
+ ->Option.getOr([])
808
+ ->Array.forEach(({fieldName, typeName}) =>
809
+ switch queryEntries->Array.find(e => e.returnTypeName == typeName) {
810
+ | None =>
811
+ JsError.throwWithMessage(
812
+ `${entry.returnTypeName}.${fieldName} is declared by @resolves/@resolvesMany and resolves to "${typeName}", which this plugin does not expose as a queryable. The target must be a ReadModel or StateViewSlice of the same plugin, named by its spec name.`,
813
+ )
814
+ | Some(target) =>
815
+ let targetPermission = permissionOf(target)
816
+ if targetPermission != permissionOf(entry) && targetPermission != AllowAnonymous {
817
+ JsError.throwWithMessage(
818
+ `${entry.returnTypeName}.${fieldName} resolves to "${typeName}", which declares a different authorization. A cross-table field is read through its parent and would answer under ${entry.returnTypeName}'s rule, handing over rows ${typeName}'s own door withholds. Give the two views the same authorization, or query ${typeName} directly.`,
819
+ )
820
+ }
821
+ }
822
+ )
823
+ )
824
+
766
825
  queryEntries->Array.forEach(entry => {
767
826
  let includeIdParam = entry.includeIdParam->Option.getOr(true)
768
827
  let connectionSpec = entry.connectionSpec->Option.getOr(true)
@@ -776,6 +835,7 @@ let generate = (
776
835
  ~typeName=entry.returnTypeName,
777
836
  ~excludeFields,
778
837
  ~includeIdParam,
838
+ ~resolvedFields=entry.resolvedFields->Option.getOr([]),
779
839
  entry.stateSchema,
780
840
  )
781
841
  nestedTypes->Array.forEach(t => {
@@ -3,6 +3,8 @@
3
3
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
6
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
7
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
6
8
  import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
7
9
  import * as Api_Naming$ReventlessCore from "./Api_Naming.res.mjs";
8
10
  import * as SchemaType$ReventlessCore from "./SchemaType.res.mjs";
@@ -129,9 +131,20 @@ function reportUnclassifiedUnions(typeName, schema) {
129
131
  SchemaType$ReventlessCore.unclassifiedUnions(schema).forEach(param => log.warn("GraphQL_FragmentGenerator", undefined, typeName + `.` + param.path + ` is emitted as String: ` + param.reason));
130
132
  }
131
133
 
132
- function deriveObjectTypeWithNested(typeName, excludeFieldsOpt, includeIdParamOpt, schema) {
134
+ function resolvedFieldSdl(param) {
135
+ let typeName = param.typeName;
136
+ let fieldName = param.fieldName;
137
+ if (param.multi) {
138
+ return ` ` + fieldName + `: [` + typeName + `!]`;
139
+ } else {
140
+ return ` ` + fieldName + `: ` + typeName;
141
+ }
142
+ }
143
+
144
+ function deriveObjectTypeWithNested(typeName, excludeFieldsOpt, includeIdParamOpt, resolvedFieldsOpt, schema) {
133
145
  let excludeFields = excludeFieldsOpt !== undefined ? excludeFieldsOpt : [];
134
146
  let includeIdParam = includeIdParamOpt !== undefined ? includeIdParamOpt : true;
147
+ let resolvedFields = resolvedFieldsOpt !== undefined ? resolvedFieldsOpt : [];
135
148
  reportUnclassifiedUnions(typeName, schema);
136
149
  let fields = SchemaType$ReventlessCore.fromSuryObject(typeName, schema);
137
150
  if (fields === undefined) {
@@ -158,7 +171,20 @@ function deriveObjectTypeWithNested(typeName, excludeFieldsOpt, includeIdParamOp
158
171
  seenTypes.add(typeName);
159
172
  let mainType = objectRefToGraphQL(undefined, typeName, filteredFields, collectedTypes, seenTypes);
160
173
  let mainTypeWithId = includeIdParam ? mainType.replace(`type ` + typeName + ` {\n`, `type ` + typeName + ` implements Node {\n id: ID!\n`) : mainType;
161
- return collectedTypes.concat([mainTypeWithId]);
174
+ resolvedFields.forEach(param => {
175
+ let fieldName = param.fieldName;
176
+ if (Stdlib_Option.isSome(filteredFields[fieldName]) || includeIdParam && fieldName === "id") {
177
+ return Stdlib_JsError.throwWithMessage(typeName + `.` + fieldName + ` is declared by @resolves/@resolvesMany and is already a field of the state record. Name the resolved field something the state does not use.`);
178
+ }
179
+ });
180
+ let mainTypeComplete;
181
+ if (resolvedFields.length === 0) {
182
+ mainTypeComplete = mainTypeWithId;
183
+ } else {
184
+ let body = mainTypeWithId.slice(0, mainTypeWithId.length - 1 | 0);
185
+ mainTypeComplete = body + resolvedFields.map(resolvedFieldSdl).join("\n") + "\n}";
186
+ }
187
+ return collectedTypes.concat([mainTypeComplete]);
162
188
  }
163
189
 
164
190
  function derivePluralWrapperType(pluralTypeName, singularTypeName) {
@@ -504,6 +530,20 @@ function generate(mutationEntries, queryEntries) {
504
530
  return;
505
531
  }
506
532
  });
533
+ queryEntries.forEach(entry => {
534
+ Stdlib_Option.getOr(entry.resolvedFields, []).forEach(param => {
535
+ let typeName = param.typeName;
536
+ let fieldName = param.fieldName;
537
+ let target = queryEntries.find(e => e.returnTypeName === typeName);
538
+ if (target === undefined) {
539
+ return Stdlib_JsError.throwWithMessage(entry.returnTypeName + `.` + fieldName + ` is declared by @resolves/@resolvesMany and resolves to "` + typeName + `", which this plugin does not expose as a queryable. The target must be a ReadModel or StateViewSlice of the same plugin, named by its spec name.`);
540
+ }
541
+ let targetPermission = Stdlib_Option.getOr(target.permission, "AllowAuthenticated");
542
+ if (Primitive_object.notequal(targetPermission, Stdlib_Option.getOr(entry.permission, "AllowAuthenticated")) && targetPermission !== "AllowAnonymous") {
543
+ return Stdlib_JsError.throwWithMessage(entry.returnTypeName + `.` + fieldName + ` resolves to "` + typeName + `", which declares a different authorization. A cross-table field is read through its parent and would answer under ` + entry.returnTypeName + `'s rule, handing over rows ` + typeName + `'s own door withholds. Give the two views the same authorization, or query ` + typeName + ` directly.`);
544
+ }
545
+ });
546
+ });
507
547
  queryEntries.forEach(entry => {
508
548
  let includeIdParam = Stdlib_Option.getOr(entry.includeIdParam, true);
509
549
  let connectionSpec = Stdlib_Option.getOr(entry.connectionSpec, true);
@@ -511,7 +551,7 @@ function generate(mutationEntries, queryEntries) {
511
551
  seenTypes.add(entry.returnTypeName);
512
552
  let fields = entry.excludeFields;
513
553
  let excludeFields = fields !== undefined ? fields : [];
514
- let nestedTypes = deriveObjectTypeWithNested(entry.returnTypeName, excludeFields, includeIdParam, entry.stateSchema);
554
+ let nestedTypes = deriveObjectTypeWithNested(entry.returnTypeName, excludeFields, includeIdParam, Stdlib_Option.getOr(entry.resolvedFields, []), entry.stateSchema);
515
555
  nestedTypes.forEach(t => {
516
556
  types.push(t);
517
557
  });
@@ -606,6 +646,7 @@ export {
606
646
  objectRefToGraphQL,
607
647
  deriveFieldType,
608
648
  reportUnclassifiedUnions,
649
+ resolvedFieldSdl,
609
650
  deriveObjectTypeWithNested,
610
651
  derivePluralWrapperType,
611
652
  deriveConnectionTypes,
@@ -13,7 +13,7 @@ function inspectScalar(schema) {
13
13
  }
14
14
 
15
15
  function inspectObjectType(typeName, schema) {
16
- let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested(typeName, undefined, undefined, schema);
16
+ let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested(typeName, undefined, undefined, undefined, schema);
17
17
  return types[types.length - 1 | 0];
18
18
  }
19
19
 
@@ -42,7 +42,7 @@ function inspectMutationFields(fieldPrefix, commandSchema) {
42
42
  }
43
43
 
44
44
  function inspectQueryFields(name, typeName, stateSchema) {
45
- let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested(typeName, undefined, undefined, stateSchema);
45
+ let types = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectTypeWithNested(typeName, undefined, undefined, undefined, stateSchema);
46
46
  let typeDef = types[types.length - 1 | 0];
47
47
  let singleQuery = GraphQL_FragmentGenerator$ReventlessCore.deriveObjectQueryField(name, typeName, undefined, undefined);
48
48
  let pluralName = name + "s";
@@ -18,7 +18,12 @@ type todoStatus =
18
18
  | Pending
19
19
  | Processing
20
20
  | Completed
21
+ /** Failed and will be tried again. */
21
22
  | Failed
23
+ /** Failed for the last time — the retry budget is spent and no pass will pick
24
+ this row up again. Distinct from `Failed` because the two are opposite
25
+ instructions to whoever is reading: wait, or intervene. */
26
+ | Abandoned
22
27
 
23
28
  @schema
24
29
  type todoRow = {
@@ -28,6 +33,11 @@ type todoRow = {
28
33
  processedAt?: string,
29
34
  completedAt?: string,
30
35
  retryCount: int,
36
+ /** The ceiling `retryCount` is racing, copied from the Spec when the row is
37
+ created. Optional because rows written before this field existed are
38
+ rehydrated through `S.parseOrThrow`, and a required field would fail that
39
+ decode — silently, since the restore swallows it per row. */
40
+ maxRetries?: int,
31
41
  }
32
42
 
33
43
  /** The item schema seen through the `JSON.t` the row actually stores.
@@ -58,6 +68,7 @@ let todoRowSchemaFor = (itemSchema: S.t<'item>): S.t<todoRow> =>
58
68
  processedAt: ?s.matches(S.option(S.string)),
59
69
  completedAt: ?s.matches(S.option(S.string)),
60
70
  retryCount: s.matches(S.int),
71
+ maxRetries: ?s.matches(S.option(S.int)),
61
72
  })
62
73
 
63
74
  /**
@@ -122,6 +133,39 @@ module Make = (
122
133
  // the slice here makes the target's own events unmatchable, silently.
123
134
  let makeMeta = (): Reventless.Message.meta => Message.generateMeta(~service=Spec.targetName)
124
135
 
136
+ /** Record a failed attempt. The row is marked `Abandoned` at the moment the
137
+ budget runs out rather than being left to fall out of the next pass's
138
+ filter, so a reader never has to compare `retryCount` against a constant
139
+ that lives only in this Spec. */
140
+ let afterFailedAttempt = (row: todoRow): todoRow => {
141
+ let retryCount = row.retryCount + 1
142
+ {...row, status: retryCount >= Spec.maxRetries ? Abandoned : Failed, retryCount}
143
+ }
144
+
145
+ /** The command a slice wants published now that this item will never be
146
+ processed again, if it wants one. The row is `Abandoned` either way. */
147
+ let abandonmentCommand = (~id: string, ~row: todoRow): option<Reventless.Message.commandJson> =>
148
+ switch row.item->Reventless.Util_Sury.fromJson(Spec.todoItemSchema) {
149
+ | item =>
150
+ Automation.onExhausted(id, item)->Option.flatMap(((targetId, command)) =>
151
+ switch command->Reventless.Util_Sury.toJson(Spec.commandSchema) {
152
+ | commandJson => Some({Reventless.Message.id: targetId, meta: makeMeta(), commandJson})
153
+ | exception _ =>
154
+ EffectLogger.logError(
155
+ ~comp=`AutomationSlice(${Spec.name})`,
156
+ `retries exhausted for ${id}, and the abandonment command could not be encoded`,
157
+ )->Effect.runSync
158
+ None
159
+ }
160
+ )
161
+ | exception _ =>
162
+ EffectLogger.logError(
163
+ ~comp=`AutomationSlice(${Spec.name})`,
164
+ `retries exhausted for ${id}, and its item could not be decoded to announce it`,
165
+ )->Effect.runSync
166
+ None
167
+ }
168
+
125
169
  // Per-source erased dispatch — pre-compile decoders once at module init.
126
170
  type dispatch = {
127
171
  sourceName: string,
@@ -146,6 +190,7 @@ module Make = (
146
190
  status: Pending,
147
191
  createdAt: now(),
148
192
  retryCount: 0,
193
+ maxRetries: Spec.maxRetries,
149
194
  }
150
195
  todoItems->Dict.set(id, row)
151
196
  }
@@ -187,6 +232,20 @@ module Make = (
187
232
  }
188
233
 
189
234
  let phase2 = async (publishJsons: ReventlessInfra.CommandTopic.publishJsons) => {
235
+ // A `Failed` row at or past the ceiling is one an older build left stranded,
236
+ // or one whose Spec lowered `maxRetries` under it. Either way it is abandoned
237
+ // in fact, so say so rather than letting it sit in a status that promises a
238
+ // retry no pass will make.
239
+ let newlyAbandoned: array<string> = []
240
+ todoItems
241
+ ->Dict.toArray
242
+ ->Array.forEach(((id, row)) =>
243
+ if row.status == Failed && row.retryCount >= Spec.maxRetries {
244
+ todoItems->Dict.set(id, {...row, status: Abandoned})
245
+ newlyAbandoned->Array.push(id)
246
+ }
247
+ )
248
+
190
249
  let pending =
191
250
  todoItems
192
251
  ->Dict.toArray
@@ -222,10 +281,11 @@ module Make = (
222
281
  ~comp=`AutomationSlice(${Spec.name})`,
223
282
  `failed to encode command: ${errMsg}`,
224
283
  )->Effect.runSync
225
- todoItems->Dict.set(
226
- id,
227
- {...row, status: Failed, retryCount: row.retryCount + 1},
228
- )
284
+ let updated = row->afterFailedAttempt
285
+ todoItems->Dict.set(id, updated)
286
+ if updated.status == Abandoned {
287
+ newlyAbandoned->Array.push(id)
288
+ }
229
289
  None
230
290
  }
231
291
  commandJson->Option.forEach(
@@ -256,14 +316,34 @@ module Make = (
256
316
  pending->Array.forEach(((id, row)) => {
257
317
  switch todoItems->Dict.get(id) {
258
318
  | Some(current) if current.status == Processing =>
259
- todoItems->Dict.set(
260
- id,
261
- {...row, status: Failed, retryCount: row.retryCount + 1},
262
- )
319
+ let updated = row->afterFailedAttempt
320
+ todoItems->Dict.set(id, updated)
321
+ if updated.status == Abandoned {
322
+ newlyAbandoned->Array.push(id)
323
+ }
263
324
  | _ => ()
264
325
  }
265
326
  })
266
327
  }
267
328
  }
329
+
330
+ // Announced after the batch, so a publish failure above has already had its
331
+ // say on which rows ran out of budget. A failure here cannot mark anything for
332
+ // retry — the budget is what ran out — so the rows stay `Abandoned` regardless.
333
+ let abandonmentCommands =
334
+ newlyAbandoned->Array.filterMap(id =>
335
+ todoItems->Dict.get(id)->Option.flatMap(row => abandonmentCommand(~id, ~row))
336
+ )
337
+ if abandonmentCommands->Array.length > 0 {
338
+ try await publishJsons(abandonmentCommands) catch {
339
+ | exn =>
340
+ let errMsg =
341
+ exn->JsExn.fromException->Option.flatMap(JsExn.message)->Option.getOr("unknown")
342
+ EffectLogger.logError(
343
+ ~comp=`AutomationSlice(${Spec.name})`,
344
+ `failed to publish abandonment commands: ${errMsg}`,
345
+ )->Effect.runSync
346
+ }
347
+ }
268
348
  }
269
349
  }