@reventlessdev/reventless-aws 3.0.0-alpha.302 → 3.0.0-alpha.304

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 (26) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +8 -8
  3. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res +21 -0
  4. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res.mjs +20 -0
  5. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +41 -3
  6. package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +27 -6
  7. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +34 -4
  8. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +16 -5
  9. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +12 -0
  10. package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res +10 -0
  11. package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res.mjs +4 -1
  12. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res +16 -2
  13. package/src/adapter/Runtime/StateChangeSliceRuntime_Builder_Single.res.mjs +4 -2
  14. package/src/adapter/Runtime/StateTopicPublish.mjs +38 -5
  15. package/src/adapter/StateTopic/StateTopic_AppSync.res +49 -1
  16. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +28 -3
  17. package/src/adapter/StateTopic/StateTopic_AppSync_Ops.res +73 -2
  18. package/src/adapter/StateTopic/StateTopic_AppSync_Ops.res.mjs +39 -4
  19. package/tests/PgQueryResolver_LambdaTest.res +16 -1
  20. package/tests/PgQueryResolver_LambdaTest.res.mjs +46 -32
  21. package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res +65 -0
  22. package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res.mjs +36 -0
  23. package/tests/QueryDbResolvers_AppSyncTest.res.mjs +3 -3
  24. package/tests/StateChangeDescriptorParityTest.res +82 -1
  25. package/tests/StateChangeDescriptorParityTest.res.mjs +97 -1
  26. package/tests/stateChangeDescriptorParity.mjs +72 -3
package/CHANGELOG.md CHANGED
@@ -3,6 +3,23 @@
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.304 (2026-08-16)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** ship the DCB slice registry in the archive, not the environment ([3cda4cb](https://github.com/ReventlessDev/reventless-core/commit/3cda4cb780848598a436ab8e80843cf15a4d22b6))
11
+
12
+
13
+ # 3.0.0-alpha.303 (2026-08-16)
14
+
15
+ ### Features
16
+
17
+ * **core:** exclude retired rows from reads a caller may not widen ([662f31a](https://github.com/ReventlessDev/reventless-core/commit/662f31abb717bda5154199d349da6dcf8e2d3e78))
18
+ * **core:** let [@retired](https://github.com/retired) name a lifecycle state, not only a boolean ([6bb346b](https://github.com/ReventlessDev/reventless-core/commit/6bb346b4f6a5f33826fc24537953482a76067177))
19
+ * **core:** mark the state that retires a row, and allow more than one ([cb1461f](https://github.com/ReventlessDev/reventless-core/commit/cb1461f024d3ca3b53fd9c8b010a054e3fcc4555))
20
+ * **core:** withhold a retired row's payload from the live channel ([82c62db](https://github.com/ReventlessDev/reventless-core/commit/82c62db00b87f7135378496e36601b544d4d2b62))
21
+
22
+
6
23
  # 3.0.0-alpha.302 (2026-08-15)
7
24
 
8
25
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.302",
3
+ "version": "3.0.0-alpha.304",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -12,18 +12,18 @@
12
12
  "@aws-sdk/s3-request-presigner": "3.970.0",
13
13
  "sury": "11.0.0-alpha.4",
14
14
  "uuid": "^13.0.0",
15
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
16
15
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
- "@reventlessdev/rescript-node": "2.0.0-alpha.7",
16
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
18
17
  "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.11",
19
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.76",
18
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.77",
19
+ "@reventlessdev/rescript-node": "2.0.0-alpha.7",
20
20
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
21
+ "@reventlessdev/reventless-core": "3.0.0-alpha.237",
21
22
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
22
- "@reventlessdev/reventless-infra": "3.0.0-alpha.142",
23
- "@reventlessdev/reventless-core": "3.0.0-alpha.236",
23
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.143",
24
24
  "@reventlessdev/reventless-interop": "3.0.0-alpha.31",
25
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.100",
26
- "@reventlessdev/reventless-spec": "3.0.0-alpha.114"
25
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.101",
26
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.115"
27
27
  },
28
28
  "devDependencies": {
29
29
  "rescript": "12.3.0",
@@ -90,6 +90,24 @@ let mapMembers = (obj: dict<JSON.t>, key: string, f: dict<JSON.t> => unit): unit
90
90
  )
91
91
  }
92
92
 
93
+ // Read-path shim for the `statusField` → `lifecycleField` rename. A structure
94
+ // persisted before the rename carries only the old key, and this handler serves
95
+ // raw JSON, so the resolver would find no `lifecycleField` and answer null until
96
+ // the plugin re-registers. That is not a crash — the SDL declares a nullable
97
+ // `String`, not a `[T!]!` that would null-propagate to the root — but it is a
98
+ // silent degradation: command-menu filtering, board columns, group sections,
99
+ // progress trackers and state diagrams go quiet while lists keep rendering.
100
+ //
101
+ // Delete a release later, once no structure predating the rename can be reached.
102
+ // It closes a window; it does not support two names.
103
+ let fillLifecycleField = (component: dict<JSON.t>): unit =>
104
+ switch (component->Dict.get("lifecycleField"), component->Dict.get("statusField")) {
105
+ | (None | Some(JSON.Null), Some(legacy)) => component->Dict.set("lifecycleField", legacy)
106
+ | _ => ()
107
+ }
108
+
109
+ let readSideCollections = ["readModels", "stateViewSlices"]
110
+
93
111
  let healStructure = (structure: dict<JSON.t>): dict<JSON.t> => {
94
112
  let out = Dict.fromArray(structure->Dict.toArray)
95
113
  // The collections themselves are `[T!]!` too, so an absent one is the same
@@ -103,6 +121,9 @@ let healStructure = (structure: dict<JSON.t>): dict<JSON.t> => {
103
121
  )
104
122
  })
105
123
  )
124
+ readSideCollections->Array.forEach(collection =>
125
+ out->mapMembers(collection, fillLifecycleField)
126
+ )
106
127
  out
107
128
  }
108
129
 
@@ -148,6 +148,23 @@ function mapMembers(obj, key, f) {
148
148
  }
149
149
  }
150
150
 
151
+ function fillLifecycleField(component) {
152
+ let match = component["lifecycleField"];
153
+ let match$1 = component["statusField"];
154
+ if (match !== undefined && match !== null) {
155
+ return;
156
+ }
157
+ if (match$1 !== undefined) {
158
+ component["lifecycleField"] = match$1;
159
+ return;
160
+ }
161
+ }
162
+
163
+ let readSideCollections = [
164
+ "readModels",
165
+ "stateViewSlices"
166
+ ];
167
+
151
168
  function healStructure(structure) {
152
169
  let out = Object.fromEntries(Object.entries(structure));
153
170
  fillLists(out, healByCollection.map(param => param[0]));
@@ -161,6 +178,7 @@ function healStructure(structure) {
161
178
  });
162
179
  });
163
180
  });
181
+ readSideCollections.forEach(collection => mapMembers(out, collection, fillLifecycleField));
164
182
  return out;
165
183
  }
166
184
 
@@ -483,6 +501,8 @@ export {
483
501
  healByCollection,
484
502
  fillLists,
485
503
  mapMembers,
504
+ fillLifecycleField,
505
+ readSideCollections,
486
506
  healStructure,
487
507
  isPublicQueryable,
488
508
  filterStructure,
@@ -68,6 +68,7 @@ type pushdowns = {
68
68
  ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
69
69
  ~labelField: string,
70
70
  ~ownerScope: (string, string)=?,
71
+ ~retiredScope: Reventless.OwnerScope.retiredScope=?,
71
72
  ) => promise<option<JSON.t>>,
72
73
  // Sub-id connection ({single}Items) — keyset over sub_key within a partition.
73
74
  itemsPage: (
@@ -76,6 +77,7 @@ type pushdowns = {
76
77
  ~id: string,
77
78
  ~argsDict: dict<JSON.t>,
78
79
  ~ownerScope: (string, string)=?,
80
+ ~retiredScope: Reventless.OwnerScope.retiredScope=?,
79
81
  ) => promise<JSON.t>,
80
82
  // Full materialisation for the list fallback (shapes listPage declines).
81
83
  scanAll: (~readModelName: string) => promise<array<JSON.t>>,
@@ -95,6 +97,8 @@ type binding = {
95
97
  from the same schema `capability` comes from, so the two cannot disagree
96
98
  about which fields this read model has. */
97
99
  ownerField: option<string>,
100
+ retiredField: option<string>,
101
+ retiredValues: option<array<string>>,
98
102
  }
99
103
 
100
104
  // -- arg helpers -------------------------------------------------------------
@@ -178,6 +182,35 @@ let dispatch = async (
178
182
  | ScopeTo(field, required) =>
179
183
  item->argStr(field)->Option.mapOr(false, v => v == required)
180
184
  }
185
+ // The caller's request to see the archive, honoured only where the
186
+ // classification says it counts.
187
+ let askedForRetired =
188
+ payload.arguments
189
+ ->argObj
190
+ ->Dict.get("includeRetired")
191
+ ->Option.flatMap(JSON.Decode.bool)
192
+ ->Option.getOr(false)
193
+ let retiredScope =
194
+ payload.identity
195
+ ->Reventless.OwnerScope.decideRetired(
196
+ ~retiredField=binding.retiredField,
197
+ ~retiredValues=?binding.retiredValues,
198
+ ~asked=askedForRetired,
199
+ )
200
+ ->Reventless.OwnerScope.retiredScopeOf
201
+ // Absent keeps the row, as in `QueryDbListQuery`. The two forms of `@retired`
202
+ // differ only inside `isRetiredValue`, so nothing here branches on which one
203
+ // the view declared.
204
+ let retiredAllows = (item: JSON.t) =>
205
+ switch retiredScope {
206
+ | None => true
207
+ | Some(scope) =>
208
+ !(
209
+ scope->Reventless.OwnerScope.isRetiredValue(
210
+ item->JSON.Decode.object->Option.flatMap(d => d->Dict.get(scope.field)),
211
+ )
212
+ )
213
+ }
181
214
  switch payload.kind {
182
215
  | "getById" =>
183
216
  let id = payload.arguments->argStr("id")->Option.getOr("")
@@ -199,7 +232,7 @@ let dispatch = async (
199
232
  switch found {
200
233
  // A row the caller does not own answers as "not found". Saying "not yours"
201
234
  // instead would make this door an oracle for which ids exist.
202
- | Some(item) if !ownerAllows(item) => JSON.Encode.null
235
+ | Some(item) if !ownerAllows(item) || !retiredAllows(item) => JSON.Encode.null
203
236
  | Some(item) => binding.includeIdParam ? withId(item, resolvedKey) : item
204
237
  | None => JSON.Encode.null
205
238
  }
@@ -216,7 +249,9 @@ let dispatch = async (
216
249
  let extra = missing->Array.length > 0
217
250
  ? await binding.pushdowns.byIds(~readModelName=rm, missing)
218
251
  : []
219
- JSON.Encode.array(Array.concat(found, extra)->Array.filter(ownerAllows))
252
+ JSON.Encode.array(
253
+ Array.concat(found, extra)->Array.filter(item => ownerAllows(item) && retiredAllows(item)),
254
+ )
220
255
 
221
256
  | "items" =>
222
257
  // Sub-id connection: {single}Items(id, filter, first/after/last/before).
@@ -233,6 +268,7 @@ let dispatch = async (
233
268
  ~id,
234
269
  ~argsDict=payload.arguments->argObj,
235
270
  ~ownerScope=?Reventless.OwnerScope.scopeOf(decision),
271
+ ~retiredScope?,
236
272
  )
237
273
  }
238
274
  | None => emptyConnection()
@@ -274,7 +310,7 @@ let dispatch = async (
274
310
  if authorized {
275
311
  JSON.Encode.array(
276
312
  (await binding.pushdowns.indexLookup(~readModelName=rm, field, value))->Array.filter(
277
- ownerAllows,
313
+ item => ownerAllows(item) && retiredAllows(item),
278
314
  ),
279
315
  )
280
316
  } else {
@@ -293,6 +329,7 @@ let dispatch = async (
293
329
  ~capability=binding.capability,
294
330
  ~labelField=binding.labelField,
295
331
  ~ownerScope?,
332
+ ~retiredScope?,
296
333
  ) {
297
334
  | Some(conn) => conn
298
335
  | None =>
@@ -306,6 +343,7 @@ let dispatch = async (
306
343
  ~capability=binding.capability,
307
344
  ~labelField=binding.labelField,
308
345
  ~ownerScope?,
346
+ ~retiredScope?,
309
347
  )
310
348
  }
311
349
  }
@@ -80,6 +80,15 @@ async function dispatch(binding, lookupBindingOpt, payload) {
80
80
  let required = match._1;
81
81
  return Stdlib_Option.mapOr(argStr(item, match._0), false, v => v === required);
82
82
  };
83
+ let askedForRetired = Stdlib_Option.getOr(Stdlib_Option.flatMap(argObj(payload.arguments)["includeRetired"], Stdlib_JSON.Decode.bool), false);
84
+ let retiredScope = OwnerScope$Reventless.retiredScopeOf(OwnerScope$Reventless.decideRetired(payload.identity, binding.retiredField, binding.retiredValues, askedForRetired, undefined));
85
+ let retiredAllows = item => {
86
+ if (retiredScope !== undefined) {
87
+ return !OwnerScope$Reventless.isRetiredValue(retiredScope, Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[retiredScope.field]));
88
+ } else {
89
+ return true;
90
+ }
91
+ };
83
92
  let other = payload.kind;
84
93
  switch (other) {
85
94
  case "byIds" :
@@ -87,7 +96,13 @@ async function dispatch(binding, lookupBindingOpt, payload) {
87
96
  let found = await binding.pushdowns.byIds(rm, ids);
88
97
  let missing = found.length < ids.length ? Stdlib_Array.filterMap(ids, Api_Ids$ReventlessCore.alternateKey) : [];
89
98
  let extra = missing.length !== 0 ? await binding.pushdowns.byIds(rm, missing) : [];
90
- return found.concat(extra).filter(ownerAllows);
99
+ return found.concat(extra).filter(item => {
100
+ if (ownerAllows(item)) {
101
+ return retiredAllows(item);
102
+ } else {
103
+ return false;
104
+ }
105
+ });
91
106
  case "getById" :
92
107
  let id = Stdlib_Option.getOr(argStr(payload.arguments, "id"), "");
93
108
  let loadKey = async key => {
@@ -106,7 +121,7 @@ async function dispatch(binding, lookupBindingOpt, payload) {
106
121
  await loadKey(match$2)
107
122
  ];
108
123
  let found$1 = match$3[1];
109
- if (found$1 !== undefined && ownerAllows(found$1)) {
124
+ if (found$1 !== undefined && ownerAllows(found$1) && retiredAllows(found$1)) {
110
125
  if (binding.includeIdParam) {
111
126
  return withId(found$1, match$3[0]);
112
127
  } else {
@@ -134,7 +149,13 @@ async function dispatch(binding, lookupBindingOpt, payload) {
134
149
  authorized = true;
135
150
  }
136
151
  if (authorized) {
137
- return (await binding.pushdowns.indexLookup(rm, field, value)).filter(ownerAllows);
152
+ return (await binding.pushdowns.indexLookup(rm, field, value)).filter(item => {
153
+ if (ownerAllows(item)) {
154
+ return retiredAllows(item);
155
+ } else {
156
+ return false;
157
+ }
158
+ });
138
159
  } else {
139
160
  return [];
140
161
  }
@@ -148,7 +169,7 @@ async function dispatch(binding, lookupBindingOpt, payload) {
148
169
  if (typeof decision !== "object" && decision !== "Unscoped") {
149
170
  return emptyConnection();
150
171
  }
151
- return await binding.pushdowns.itemsPage(rm, subIdField, id$1, argObj(payload.arguments), OwnerScope$Reventless.scopeOf(decision));
172
+ return await binding.pushdowns.itemsPage(rm, subIdField, id$1, argObj(payload.arguments), OwnerScope$Reventless.scopeOf(decision), retiredScope);
152
173
  break;
153
174
  case "list" :
154
175
  let argsDict = argObj(payload.arguments);
@@ -157,12 +178,12 @@ async function dispatch(binding, lookupBindingOpt, payload) {
157
178
  return emptyConnection();
158
179
  }
159
180
  let ownerScope = OwnerScope$Reventless.scopeOf(decision$1);
160
- let conn = await binding.pushdowns.listPage(rm, argsDict, binding.capability, binding.labelField, ownerScope);
181
+ let conn = await binding.pushdowns.listPage(rm, argsDict, binding.capability, binding.labelField, ownerScope, retiredScope);
161
182
  if (conn !== undefined) {
162
183
  return conn;
163
184
  }
164
185
  let items$1 = await binding.pushdowns.scanAll(rm);
165
- return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, binding.capability, binding.labelField, undefined, ownerScope);
186
+ return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, binding.capability, binding.labelField, undefined, ownerScope, retiredScope);
166
187
  break;
167
188
  case "resolveMany" :
168
189
  let target = Stdlib_Option.getOr(payload.target, rm);
@@ -256,12 +256,12 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
256
256
  ~view=name,
257
257
  ~ownerField,
258
258
  )
259
+ let isIndexed = f =>
260
+ indexes->Array.some(ic => ic.idField->Option.getOr(ic.index) == f) ||
261
+ subIdField->Option.getOr("") == f
259
262
  switch ownerField {
260
263
  | Some(f) =>
261
- let indexed =
262
- indexes->Array.some(ic => ic.idField->Option.getOr(ic.index) == f) ||
263
- subIdField->Option.getOr("") == f
264
- if !indexed {
264
+ if !isIndexed(f) {
265
265
  log.warn(
266
266
  ~comp="QueryDbResolvers_AppSync",
267
267
  `${name}: @owner field "${f}" is not the key of any index on this table. ` ++
@@ -271,6 +271,34 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
271
271
  }
272
272
  | None => ()
273
273
  }
274
+ let retiredSpec =
275
+ stateSchemaOpt
276
+ ->Option.flatMap(Reventless.StateAnnotations.getSpec)
277
+ ->Option.flatMap(spec => spec.retired)
278
+ let retiredField = retiredSpec->Option.map(r => r.field)
279
+ let retiredValues = retiredSpec->Option.flatMap(r => r.values)
280
+ // The same class of "works, but scans" mistake as the owner warning above,
281
+ // and the retirement case degrades the same way: the FilterExpression is
282
+ // applied after the page is read, so pages shrink as the archive's share of
283
+ // the table grows.
284
+ //
285
+ // `@scan` deliberately does not satisfy this. It adds no index and removes
286
+ // no read unit — it only widens the client's filter surface — so accepting it
287
+ // here would make the warning dismissible by an annotation that changes
288
+ // nothing about the cost being warned about.
289
+ switch retiredField {
290
+ | Some(f) =>
291
+ if !isIndexed(f) {
292
+ log.warn(
293
+ ~comp="QueryDbResolvers_AppSync",
294
+ `${name}: @retired field "${f}" is not the key of any index on this table. ` ++
295
+ "Reads that exclude retired rows will Scan and filter, so pages shrink as " ++
296
+ "the archive's share of the rows grows. Add an @index on that field before " ++
297
+ "this read model grows.",
298
+ )
299
+ }
300
+ | None => ()
301
+ }
274
302
  let resolverAll = makeQueryResolver(
275
303
  ~resolverName=fieldNameForAll->String.capitalize,
276
304
  ~field=fieldNameForAll->Pulumi.Input.make,
@@ -283,6 +311,8 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
283
311
  ~requireAttribute?,
284
312
  ~ownerField?,
285
313
  ~elevatedGroups=Reventless.OwnerScope.elevatedGroups(),
314
+ ~retiredField?,
315
+ ~retiredValues?,
286
316
  )
287
317
  } else {
288
318
  Resolver.Functions.listAllItems
@@ -9,6 +9,7 @@ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types
9
9
  import * as Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Adapter.res.mjs";
10
10
  import * as AppSync_Function$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/AppSync/AppSync_Function.res.mjs";
11
11
  import * as Util_AppSync$ReventlessAws from "../../util/Util_AppSync.res.mjs";
12
+ import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
12
13
  import * as Util_DynamoDb$ReventlessAws from "../../util/Util_DynamoDb.res.mjs";
13
14
  import * as Util_QueryDb$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_QueryDb.res.mjs";
14
15
  import * as AppSync_DataSource$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/AppSync/AppSync_DataSource.res.mjs";
@@ -111,13 +112,23 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
111
112
  let requireAttribute = internalRowRequiredAttr(name$1);
112
113
  let ownerField = stateSchemaOpt !== undefined ? Owner$Reventless.fieldNames(stateSchemaOpt)[0] : undefined;
113
114
  OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("QueryDbResolvers_AppSync", name$1, ownerField);
114
- if (ownerField !== undefined) {
115
- let indexed = indexes.some(ic => Stdlib_Option.getOr(ic.idField, ic.index) === ownerField) || Stdlib_Option.getOr(subIdField, "") === ownerField;
116
- if (!indexed) {
117
- log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @owner field "` + ownerField + `" is not the key of any index on this table. ` + "Owner-scoped reads will Scan and filter, so pages shrink as the caller's share of the rows falls. Add an @index on that field before this read model grows.");
115
+ let isIndexed = f => {
116
+ if (indexes.some(ic => Stdlib_Option.getOr(ic.idField, ic.index) === f)) {
117
+ return true;
118
+ } else {
119
+ return Stdlib_Option.getOr(subIdField, "") === f;
118
120
  }
121
+ };
122
+ if (ownerField !== undefined && !isIndexed(ownerField)) {
123
+ log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @owner field "` + ownerField + `" is not the key of any index on this table. ` + "Owner-scoped reads will Scan and filter, so pages shrink as the caller's share of the rows falls. Add an @index on that field before this read model grows.");
124
+ }
125
+ let retiredSpec = Stdlib_Option.flatMap(Stdlib_Option.flatMap(stateSchemaOpt, StateAnnotations$Reventless.getSpec), spec => spec.retired);
126
+ let retiredField = Stdlib_Option.map(retiredSpec, r => r.field);
127
+ let retiredValues = Stdlib_Option.flatMap(retiredSpec, r => r.values);
128
+ if (retiredField !== undefined && !isIndexed(retiredField)) {
129
+ log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @retired field "` + retiredField + `" is not the key of any index on this table. ` + "Reads that exclude retired rows will Scan and filter, so pages shrink as the archive's share of the rows grows. Add an @index on that field before this read model grows.");
119
130
  }
120
- let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute, ownerField, OwnerScope$Reventless.elevatedGroups()) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
131
+ let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute, ownerField, OwnerScope$Reventless.elevatedGroups(), retiredField, retiredValues) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
121
132
  let resolversByIndex = indexes.map(indexConfig => {
122
133
  let index = indexConfig.index;
123
134
  let stripLeadingBy = s => {
@@ -3,6 +3,8 @@
3
3
  // builds shared DcbEventLog operations and per-slice handlers keyed by command type name.
4
4
  // Dual routing: AppSync direct invocation and SQS CommandTopic events.
5
5
 
6
+ import { readFileSync } from "node:fs";
7
+ import { join } from "node:path";
6
8
  import * as Chunk from "effect/Chunk";
7
9
  import * as Effect from "effect/Effect";
8
10
  import * as Stream from "effect/Stream";
@@ -245,6 +247,16 @@ async function buildHandler() {
245
247
  // Lambda always sets HANDLER_CONFIG; the production path is unchanged.
246
248
  if (!configStr) return [null, null, undefined, undefined];
247
249
  const config = JSON.parse(configStr);
250
+ // The slice registry rides in the archive, not the environment — two module
251
+ // specifiers per slice outgrow Lambda's 4KB env-var ceiling (see the asset in
252
+ // StateChangeSliceRuntime_Builder_Single). An inline registry still wins, so a
253
+ // caller that supplies one — every test that drives a config directly — needs
254
+ // no asset on disk. process.cwd() is /var/task, where the archive unpacks.
255
+ if (config.stateChangeSliceModules === undefined) {
256
+ config.stateChangeSliceModules = JSON.parse(
257
+ readFileSync(join(process.cwd(), "sliceModules.json"), "utf-8"),
258
+ );
259
+ }
248
260
  return buildHandlersForConfig(config, { loadModule: dynamicImport });
249
261
  }
250
262
 
@@ -153,6 +153,16 @@ let registerBinding = (
153
153
  // From the same schema `capability` is derived from, one line above, so the
154
154
  // two cannot end up disagreeing about this read model's fields.
155
155
  ownerField: Reventless.Owner.fieldNames(spec.stateSchema)->Array.get(0),
156
+ retiredField: spec.stateSchema
157
+ ->Reventless.StateAnnotations.getSpec
158
+ ->Option.flatMap(a => a.retired)
159
+ ->Option.map(r => r.field),
160
+ // The state form's other half. Carried beside the field rather than
161
+ // re-derived at predicate time, so one place decides which rows are retired.
162
+ retiredValues: spec.stateSchema
163
+ ->Reventless.StateAnnotations.getSpec
164
+ ->Option.flatMap(a => a.retired)
165
+ ->Option.flatMap(r => r.values),
156
166
  },
157
167
  )
158
168
  logDebug("registered resolver binding for " ++ entry.readModelName, {comp: "PgQueryResolver"})
@@ -6,6 +6,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
7
7
  import * as PgRuntime$ReventlessAws from "../Postgres/PgRuntime.res.mjs";
8
8
  import * as HandlerFactoryHelpersMjs from "./HandlerFactoryHelpers.mjs";
9
+ import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
9
10
  import * as PgQueryResolver_Lambda$ReventlessAws from "../QueryDb/PgQueryResolver_Lambda.res.mjs";
10
11
  import * as ProjectionEntryPoint_Ops$ReventlessAws from "./ProjectionEntryPoint_Ops.res.mjs";
11
12
  import * as QueryEnginePostgres$ReventlessPostgres from "@reventlessdev/reventless-postgres/src/QueryEnginePostgres.res.mjs";
@@ -74,7 +75,9 @@ function registerBinding(pushdowns, pgConnection, entry, spec) {
74
75
  labelField: entry.labelField,
75
76
  includeIdParam: entry.includeIdParam,
76
77
  authorization: spec.authorization,
77
- ownerField: Owner$Reventless.fieldNames(spec.stateSchema)[0]
78
+ ownerField: Owner$Reventless.fieldNames(spec.stateSchema)[0],
79
+ retiredField: Stdlib_Option.map(Stdlib_Option.flatMap(StateAnnotations$Reventless.getSpec(spec.stateSchema), a => a.retired), r => r.field),
80
+ retiredValues: Stdlib_Option.flatMap(Stdlib_Option.flatMap(StateAnnotations$Reventless.getSpec(spec.stateSchema), a => a.retired), r => r.values)
78
81
  });
79
82
  HandlerFactoryHelpersMjs.log.debug("registered resolver binding for " + entry.readModelName, {
80
83
  comp: "PgQueryResolver"
@@ -113,9 +113,16 @@ let forDcbCommandTopic = (
113
113
  envVars->Dict.set("LOG_LEVEL", level->Pulumi.Output.make->Pulumi.Output.asInput)
114
114
  )
115
115
 
116
- // Build HANDLER_CONFIG JSON: array of {spec, behavior} objects so the entry point
116
+ // The slice registry: an array of {spec, behavior} objects so the entry point
117
117
  // can dynamically import both modules and apply the curried StateChangeSlice_Callback.Make
118
118
  // functor (Make(Spec)(Behavior)).
119
+ //
120
+ // Shipped as a `sliceModules.json` asset rather than inline in HANDLER_CONFIG.
121
+ // Two absolute module specifiers per slice is the one term here that grows
122
+ // with the plugin, and a plugin with a dozen-odd slices carries enough of
123
+ // them to pass Lambda's 4KB UpdateFunctionConfiguration ceiling on the whole
124
+ // environment — the same wall `pluginDefinition` hit in PluginRuntime_Builder,
125
+ // and it fails at deploy with the registry already committed to the stack.
119
126
  let sliceModulesJson =
120
127
  slicePaths
121
128
  ->Array.map(((specPath, behaviorPath)) => {
@@ -172,7 +179,7 @@ let forDcbCommandTopic = (
172
179
  Pulumi.Output.all4((dcbTableName, queue.id, pgConnectionFragment, inboundFragment))
173
180
  ->Pulumi.Output.apply(((table, queueUrl, pgFragment, inbFragment)) => {
174
181
  let pluginNameJson = pluginName->JSON.stringifyAny->Option.getOr(`""`)
175
- `{"dcbEventLogTableName":"${table}","queueUrl":"${queueUrl}","pluginName":${pluginNameJson},"stateChangeSliceModules":[${sliceModulesJson}]${pgFragment}${inbFragment}}`
182
+ `{"dcbEventLogTableName":"${table}","queueUrl":"${queueUrl}","pluginName":${pluginNameJson}${pgFragment}${inbFragment}}`
176
183
  })
177
184
  envVars->Dict.set("HANDLER_CONFIG", handlerConfigJson->Pulumi.Output.asInput)
178
185
 
@@ -205,9 +212,16 @@ let forDcbCommandTopic = (
205
212
  Util_Bundle.resolvePackageRoot("@reventlessdev/reventless-core"),
206
213
  )
207
214
 
215
+ // Carries no Pulumi Output, so the archive is still built synchronously —
216
+ // the audit table names that do are in the inbound fragment, which stays in
217
+ // HANDLER_CONFIG.
218
+ let extraStringAssets = Dict.make()
219
+ extraStringAssets->Dict.set("sliceModules.json", `[${sliceModulesJson}]`)
220
+
208
221
  let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
209
222
  ~entryPointModule="@reventlessdev/reventless-aws/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs",
210
223
  ~packageDirs,
224
+ ~extraStringAssets,
211
225
  )
212
226
 
213
227
  cfg.sqsBatchSize->Option.forEach(CommandTopicChannel.SQS.setBatchSize)
@@ -79,7 +79,7 @@ function forDcbCommandTopic(slicePaths, inboundSlicesOpt, dcbTableName, pluginNa
79
79
  inboundFragment
80
80
  ]).apply(param => {
81
81
  let pluginNameJson = Stdlib_Option.getOr(JSON.stringify(pluginName), `""`);
82
- return `{"dcbEventLogTableName":"` + param[0] + `","queueUrl":"` + param[1] + `","pluginName":` + pluginNameJson + `,"stateChangeSliceModules":[` + sliceModulesJson + `]` + param[2] + param[3] + `}`;
82
+ return `{"dcbEventLogTableName":"` + param[0] + `","queueUrl":"` + param[1] + `","pluginName":` + pluginNameJson + param[2] + param[3] + `}`;
83
83
  });
84
84
  envVars["HANDLER_CONFIG"] = handlerConfigJson;
85
85
  let packageDirs = {};
@@ -97,7 +97,9 @@ function forDcbCommandTopic(slicePaths, inboundSlicesOpt, dcbTableName, pluginNa
97
97
  });
98
98
  packageDirs["@reventlessdev/reventless-aws"] = Util_Bundle$ReventlessAws.resolvePackageRoot(undefined, "@reventlessdev/reventless-aws");
99
99
  packageDirs["@reventlessdev/reventless-core"] = Util_Bundle$ReventlessAws.resolvePackageRoot(undefined, "@reventlessdev/reventless-core");
100
- let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs", packageDirs, undefined, undefined);
100
+ let extraStringAssets = {};
101
+ extraStringAssets["sliceModules.json"] = `[` + sliceModulesJson + `]`;
102
+ let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs", packageDirs, extraStringAssets, undefined);
101
103
  Stdlib_Option.forEach(cfg.sqsBatchSize, CommandTopicChannel_SQS$ReventlessAws.setBatchSize);
102
104
  let vpcConfig = pgSelection !== undefined ? pgSelection.securityGroupId.apply(sgId => ({
103
105
  subnetIds: pgSelection.subnetIds,
@@ -98,14 +98,43 @@ export function nextSequence() {
98
98
  // implementations. Over the size cap the state is dropped and the downgrade logged:
99
99
  // a metadata-only descriptor still tells the client to refetch, where a publish
100
100
  // rejected for size would tell it nothing.
101
- export function makeDescriptor({ changeKind, entityKey, state, seq }) {
101
+ //
102
+ // `retiredField` names the row's `@retired` flag, when the read model declares
103
+ // one. A row whose flag is true publishes as metadata only — no state, and no
104
+ // sortKeyValue either, since that is a timestamp off a row the subscriber may
105
+ // not read. The channel is shared by every subscriber of the view and cannot be
106
+ // scoped per caller, so a payload here would hand the row to exactly the callers
107
+ // the resolvers refuse it to. `Updated` with no state is the shape an oversized
108
+ // row already takes, and it asks the client to do the one thing that enforces
109
+ // the rule: refetch, and let the query layer answer.
110
+ //
111
+ // `retiredValues` is the state form: the row is retired when the field holds any
112
+ // of those states. Absent is the boolean form, where the flag is `true`. This
113
+ // mirrors `OwnerScope.isRetiredValue`, which the other two implementations call
114
+ // — this one cannot, being hand-written JS outside the ReScript graph, so the
115
+ // three are held together by StateChangeDescriptorParityTest instead. The strict
116
+ // readings matter and are the ones that file asserts: a non-boolean is not
117
+ // `true`, a non-string is in no set, and an absent field is neither.
118
+ export function makeDescriptor({ changeKind, entityKey, state, seq, retiredField, retiredValues }) {
102
119
  const descriptor = { changeKind, id: entityKey };
103
- if (state !== undefined) {
120
+ const cell =
121
+ retiredField !== undefined &&
122
+ retiredField !== null &&
123
+ state !== undefined &&
124
+ state !== null &&
125
+ typeof state === "object"
126
+ ? state[retiredField]
127
+ : undefined;
128
+ const retired =
129
+ retiredValues === undefined || retiredValues === null
130
+ ? cell === true
131
+ : typeof cell === "string" && retiredValues.indexOf(cell) >= 0;
132
+ if (state !== undefined && !retired) {
104
133
  const sortKeyValue = pickSortKeyValue(state);
105
134
  if (sortKeyValue !== undefined) descriptor.sortKeyValue = sortKeyValue;
106
135
  }
107
136
  descriptor.seq = seq;
108
- if (state !== undefined) {
137
+ if (state !== undefined && !retired) {
109
138
  const encoded = JSON.stringify(state);
110
139
  if (encoded.length <= MAX_STATE_CHARS) {
111
140
  descriptor.state = state;
@@ -126,12 +155,16 @@ export function makeDescriptor({ changeKind, entityKey, state, seq }) {
126
155
  // - changeKind: "Updated" (save) | "Removed" (delete).
127
156
  // - state: the full new row for a save; omitted for a delete.
128
157
  // - dedupeId: AppSync publish `id` (idempotency hint).
129
- export async function publishStateChange({ endpoint, region, topicName, entityKey, changeKind, state, dedupeId }) {
158
+ // - retiredField: the row's `@retired` flag name, when the read model declares
159
+ // one; a retired row publishes metadata-only.
160
+ // - retiredValues: the states that retire the row, for the state form. Absent is
161
+ // the boolean form, where the flag is `true`.
162
+ export async function publishStateChange({ endpoint, region, topicName, entityKey, changeKind, state, dedupeId, retiredField, retiredValues }) {
130
163
  if (!endpoint || !topicName) return; // not live-enabled — no-op
131
164
  try {
132
165
  const url = new URL(endpoint);
133
166
  const channel = "/default/" + pathSegment(topicName) + "/" + pathSegment(entityKey);
134
- const descriptor = makeDescriptor({ changeKind, entityKey, state, seq: nextSequence() });
167
+ const descriptor = makeDescriptor({ changeKind, entityKey, state, seq: nextSequence(), retiredField, retiredValues });
135
168
  const body = JSON.stringify({
136
169
  id: dedupeId || (topicName + ":" + entityKey + ":" + changeKind),
137
170
  channel,