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

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,16 @@
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.303 (2026-08-16)
7
+
8
+ ### Features
9
+
10
+ * **core:** exclude retired rows from reads a caller may not widen ([662f31a](https://github.com/ReventlessDev/reventless-core/commit/662f31abb717bda5154199d349da6dcf8e2d3e78))
11
+ * **core:** let [@retired](https://github.com/retired) name a lifecycle state, not only a boolean ([6bb346b](https://github.com/ReventlessDev/reventless-core/commit/6bb346b4f6a5f33826fc24537953482a76067177))
12
+ * **core:** mark the state that retires a row, and allow more than one ([cb1461f](https://github.com/ReventlessDev/reventless-core/commit/cb1461f024d3ca3b53fd9c8b010a054e3fcc4555))
13
+ * **core:** withhold a retired row's payload from the live channel ([82c62db](https://github.com/ReventlessDev/reventless-core/commit/82c62db00b87f7135378496e36601b544d4d2b62))
14
+
15
+
6
16
  # 3.0.0-alpha.302 (2026-08-15)
7
17
 
8
18
  ### 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.303",
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",
15
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.11",
16
16
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
17
18
  "@reventlessdev/rescript-node": "2.0.0-alpha.7",
18
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.11",
19
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.76",
20
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
19
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.77",
21
20
  "@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",
21
+ "@reventlessdev/reventless-core": "3.0.0-alpha.237",
22
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.143",
24
23
  "@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"
24
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
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 => {
@@ -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"
@@ -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,
@@ -92,6 +92,10 @@ type streamEntry = {
92
92
  tableName: Pulumi.Output.t<string>,
93
93
  streamArn: Pulumi.Output.t<string>,
94
94
  topicName: string,
95
+ // The read model's `@retired` field, resolved at deploy time because the relay
96
+ // Lambda has no plugin registry in process to resolve it at request time.
97
+ retiredField: option<string>,
98
+ retiredValues: option<array<string>>,
95
99
  }
96
100
 
97
101
  // One registry per events API, keyed by `eventsApi.name` (the static Pulumi
@@ -134,6 +138,8 @@ let makeForTable = (
134
138
  ~streamArn: Pulumi.Output.t<string>,
135
139
  ~partitionKeyName: Pulumi.Output.t<string>,
136
140
  ~topicName: string,
141
+ ~retiredField: option<string>=?,
142
+ ~retiredValues: option<array<string>>=?,
137
143
  ~eventsApi: AppSync_EventsApi.t,
138
144
  ~opts as _: Pulumi.CustomResourceOptions.t,
139
145
  ) => {
@@ -150,7 +156,12 @@ let makeForTable = (
150
156
 
151
157
  let key = eventsApi.name
152
158
  let entries = registry->Dict.get(key)->Option.getOr([])
153
- registry->Dict.set(key, entries->Array.concat([{tableName: checkedTableName, streamArn, topicName}]))
159
+ registry->Dict.set(
160
+ key,
161
+ entries->Array.concat([
162
+ {tableName: checkedTableName, streamArn, topicName, retiredField, retiredValues},
163
+ ]),
164
+ )
154
165
  }
155
166
 
156
167
  let make = (
@@ -176,6 +187,18 @@ let make = (
176
187
  // `makeForTable` and can be keyed anything.
177
188
  ~partitionKeyName=StateTopic_AppSync_Helpers.entityKeyPartitionAttribute->Pulumi.Output.make,
178
189
  ~topicName,
190
+ // Resolved here, where the read model's name is known and its spec is
191
+ // registered in this process. The relay Lambda has neither.
192
+ ~retiredField=?ReventlessCore.Plugin_Helpers.stateSchemaRegistry
193
+ ->Dict.get(readModelName)
194
+ ->Option.flatMap(Reventless.StateAnnotations.getSpec)
195
+ ->Option.flatMap(spec => spec.retired)
196
+ ->Option.map(r => r.field),
197
+ ~retiredValues=?ReventlessCore.Plugin_Helpers.stateSchemaRegistry
198
+ ->Dict.get(readModelName)
199
+ ->Option.flatMap(Reventless.StateAnnotations.getSpec)
200
+ ->Option.flatMap(spec => spec.retired)
201
+ ->Option.flatMap(r => r.values),
179
202
  ~eventsApi,
180
203
  ~opts,
181
204
  )
@@ -270,6 +293,30 @@ let finish = (
270
293
  dict->JSON.Encode.object->JSON.stringify
271
294
  })
272
295
 
296
+ // STATE_RETIRED_MAP env var — `{ <tableName>: {field, values?} }`, carrying
297
+ // only the tables that declare retirement. Built from the same awaited table
298
+ // names as the topic map so the two cannot describe different sets of tables.
299
+ // The whole predicate travels: the relay Lambda has no plugin registry, so a
300
+ // field without its states would leave it unable to tell the two forms apart.
301
+ let retiredMapJson =
302
+ entries
303
+ ->Array.map(e => e.tableName)
304
+ ->Pulumi.Output.all
305
+ ->Pulumi.Output.apply(tableNames => {
306
+ let dict = Dict.make()
307
+ tableNames->Array.forEachWithIndex((tableName, i) => {
308
+ let entry = entries->Array.getUnsafe(i)
309
+ entry.retiredField->Option.forEach(f => {
310
+ let obj = Dict.fromArray([("field", f->JSON.Encode.string)])
311
+ entry.retiredValues->Option.forEach(vs =>
312
+ obj->Dict.set("values", vs->Array.map(JSON.Encode.string)->JSON.Encode.array)
313
+ )
314
+ dict->Dict.set(tableName, obj->JSON.Encode.object)
315
+ })
316
+ })
317
+ dict->JSON.Encode.object->JSON.stringify
318
+ })
319
+
273
320
  // Shared Lambda — the compiled `_Ops` handler is identical for every
274
321
  // stream-enabled RM; routing is per-record via STATE_TOPIC_MAP env var.
275
322
  // Bundle reventless-aws (the handler + its node:crypto signer + the
@@ -325,6 +372,7 @@ let finish = (
325
372
  ("Environment", Pulumi.Pulumi.getStackName()->Pulumi.Input.make),
326
373
  ("APPSYNC_ENDPOINT", appsyncEndpoint->Pulumi.Output.asInput),
327
374
  ("STATE_TOPIC_MAP", topicMapJson->Pulumi.Output.asInput),
375
+ ("STATE_RETIRED_MAP", retiredMapJson->Pulumi.Output.asInput),
328
376
  ("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make),
329
377
  ("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make),
330
378
  Util_LambdaLogging.logLevelEntry(),