@reventlessdev/reventless-local 3.0.0-alpha.224 → 3.0.0-alpha.225

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,20 @@
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.225 (2026-08-16)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **seed:** let seed:reset take the yes it asks for ([1f646f6](https://github.com/ReventlessDev/reventless-core/commit/1f646f6ce13a4238e92b7047ee31650ccc4de4f3))
11
+ ### Features
12
+
13
+ * **core:** exclude retired rows from reads a caller may not widen ([662f31a](https://github.com/ReventlessDev/reventless-core/commit/662f31abb717bda5154199d349da6dcf8e2d3e78))
14
+ * **core:** let [@retired](https://github.com/retired) name a lifecycle state, not only a boolean ([6bb346b](https://github.com/ReventlessDev/reventless-core/commit/6bb346b4f6a5f33826fc24537953482a76067177))
15
+ * **core:** mark the state that retires a row, and allow more than one ([cb1461f](https://github.com/ReventlessDev/reventless-core/commit/cb1461f024d3ca3b53fd9c8b010a054e3fcc4555))
16
+ * **core:** withhold a retired row's payload from the live channel ([82c62db](https://github.com/ReventlessDev/reventless-core/commit/82c62db00b87f7135378496e36601b544d4d2b62))
17
+ * **spec:** [@retired](https://github.com/retired) state-field annotation and its schema emission ([2d8234b](https://github.com/ReventlessDev/reventless-core/commit/2d8234b6b3dd8f479031a67eb5b4b47b5c0c2ff9))
18
+
19
+
6
20
  # 3.0.0-alpha.224 (2026-08-15)
7
21
 
8
22
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.224",
3
+ "version": "3.0.0-alpha.225",
4
4
  "description": "Local platform for Reventless (in-memory or SQLite backend, for development and testing without AWS)",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -36,22 +36,22 @@
36
36
  "ws": "^8.18.0",
37
37
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
38
38
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
39
- "@reventlessdev/rescript-node": "2.0.0-alpha.7",
40
39
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
41
40
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
41
+ "@reventlessdev/rescript-node": "2.0.0-alpha.7",
42
42
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
43
- "@reventlessdev/reventless-core": "3.0.0-alpha.236",
44
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.84",
45
- "@reventlessdev/reventless-infra": "3.0.0-alpha.142",
46
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.183",
47
- "@reventlessdev/reventless-seed": "1.0.0-alpha.14",
48
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.100",
49
- "@reventlessdev/reventless-spec": "3.0.0-alpha.114"
43
+ "@reventlessdev/reventless-core": "3.0.0-alpha.237",
44
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.143",
45
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.85",
46
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.184",
47
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.101",
48
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.115",
49
+ "@reventlessdev/reventless-seed": "1.0.0-alpha.15"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.0",
53
53
  "sury-ppx": "11.0.0-alpha.2",
54
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.66"
54
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.67"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "rescript": "12.3.0"
@@ -133,6 +133,7 @@ module type T = {
133
133
  ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
134
134
  ~labelField: string,
135
135
  ~ownerScope: (string, string)=?,
136
+ ~retiredScope: Reventless.OwnerScope.retiredScope=?,
136
137
  ) => option<JSON.t>,
137
138
  ) => unit
138
139
  let getQueryDbListPage: string => option<
@@ -141,6 +142,7 @@ module type T = {
141
142
  ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
142
143
  ~labelField: string,
143
144
  ~ownerScope: (string, string)=?,
145
+ ~retiredScope: Reventless.OwnerScope.retiredScope=?,
144
146
  ) => option<JSON.t>,
145
147
  >
146
148
 
@@ -238,6 +240,7 @@ module Impl = (C: BusConfig): T => {
238
240
  ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
239
241
  ~labelField: string,
240
242
  ~ownerScope: (string, string)=?,
243
+ ~retiredScope: Reventless.OwnerScope.retiredScope=?,
241
244
  ) => option<JSON.t>,
242
245
  >,
243
246
  > = ref(Dict.make())
@@ -27,6 +27,19 @@
27
27
  tell it nothing. */
28
28
  let maxStateChars = 60 * 1024
29
29
 
30
+ /** The `@retired` field of a registered read model, or `None`.
31
+
32
+ Looked up by view name rather than passed down from the storage's
33
+ construction, for the reason the resolvers look it up per request: a storage
34
+ is built before every plugin's state schema is necessarily registered, and a
35
+ lookup that missed at construction would publish payloads for the rest of
36
+ the process. */
37
+ let retiredSpecFor = (name: string): option<Reventless.StateAnnotations.retiredSpec> =>
38
+ ReventlessCore.Plugin_Helpers.stateSchemaRegistry
39
+ ->Dict.get(name)
40
+ ->Option.flatMap(Reventless.StateAnnotations.getSpec)
41
+ ->Option.flatMap(spec => spec.retired)
42
+
30
43
  let pickSortKeyValue = (state: JSON.t): option<string> =>
31
44
  switch state->JSON.Decode.object {
32
45
  | Some(obj) =>
@@ -68,16 +81,47 @@ let nextSequence = (): string => {
68
81
  - `state`: the resulting row for save(); `None` for delete(), which has no new
69
82
  row — the descriptor then omits both `state` and `sortKeyValue`.
70
83
  - `seq`: monotonic ordering token, from `nextSequence`. */
71
- let make = (~changeKind: string, ~id: string, ~state: option<JSON.t>, ~seq: string): JSON.t => {
84
+ let make = (
85
+ ~changeKind: string,
86
+ ~id: string,
87
+ ~state: option<JSON.t>,
88
+ ~seq: string,
89
+ ~retiredField: option<string>=?,
90
+ ~retiredValues: option<array<string>>=?,
91
+ ): JSON.t => {
72
92
  let descriptor = Dict.make()
73
93
  descriptor->Dict.set("changeKind", JSON.Encode.string(changeKind))
74
94
  descriptor->Dict.set("id", JSON.Encode.string(id))
75
- switch state->Option.flatMap(pickSortKeyValue) {
95
+ // Whether the row this descriptor announces has been withdrawn from ordinary
96
+ // reads. A publish cannot be scoped per subscriber — the channel is keyed by
97
+ // list field and entity, and everyone watching the view receives it — so a
98
+ // payload here would hand the row to exactly the callers the resolvers just
99
+ // refused it to.
100
+ // Both forms of `@retired` are the one question `isRetiredValue` answers, so
101
+ // nothing here branches on which the view declared.
102
+ let isRetired = switch (retiredField, state) {
103
+ | (Some(field), Some(s)) =>
104
+ {Reventless.OwnerScope.field, values: retiredValues}->Reventless.OwnerScope.isRetiredValue(
105
+ s->JSON.Decode.object->Option.flatMap(d => d->Dict.get(field)),
106
+ )
107
+ | _ => false
108
+ }
109
+ // Dropped along with the payload, not just the payload: the sort value is a
110
+ // timestamp off a row the subscriber may not read, and a subscriber without
111
+ // one is conservative rather than wrong — it refetches instead of deciding
112
+ // which page the row belongs on.
113
+ switch isRetired ? None : state->Option.flatMap(pickSortKeyValue) {
76
114
  | Some(v) => descriptor->Dict.set("sortKeyValue", JSON.Encode.string(v))
77
115
  | None => ()
78
116
  }
79
117
  descriptor->Dict.set("seq", JSON.Encode.string(seq))
80
118
  switch state {
119
+ // `Updated` with no payload is the shape this already emits for an oversized
120
+ // row, and it means the same thing to a client: refetch and let the query
121
+ // layer answer. Deliberately not `Removed` — the row is not gone, and saying
122
+ // so would be false for an elevated subscriber reading with `includeRetired`
123
+ // and false again the moment the row is restored.
124
+ | Some(_) if isRetired => ()
81
125
  | Some(s) =>
82
126
  let encoded = s->JSON.stringify
83
127
  if encoded->String.length <= maxStateChars {
@@ -2,6 +2,13 @@
2
2
 
3
3
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
6
+ import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
7
+ import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
8
+
9
+ function retiredSpecFor(name) {
10
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(Plugin_Helpers$ReventlessCore.stateSchemaRegistry[name], StateAnnotations$Reventless.getSpec), spec => spec.retired);
11
+ }
5
12
 
6
13
  function pickSortKeyValue(state) {
7
14
  let obj = Stdlib_JSON.Decode.object(state);
@@ -29,16 +36,20 @@ function nextSequence() {
29
36
  return next.toString();
30
37
  }
31
38
 
32
- function make(changeKind, id, state, seq) {
39
+ function make(changeKind, id, state, seq, retiredField, retiredValues) {
33
40
  let descriptor = {};
34
41
  descriptor["changeKind"] = changeKind;
35
42
  descriptor["id"] = id;
36
- let v = Stdlib_Option.flatMap(state, pickSortKeyValue);
43
+ let isRetired = retiredField !== undefined && state !== undefined ? OwnerScope$Reventless.isRetiredValue({
44
+ field: retiredField,
45
+ values: retiredValues
46
+ }, Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(state), d => d[retiredField])) : false;
47
+ let v = isRetired ? undefined : Stdlib_Option.flatMap(state, pickSortKeyValue);
37
48
  if (v !== undefined) {
38
49
  descriptor["sortKeyValue"] = v;
39
50
  }
40
51
  descriptor["seq"] = seq;
41
- if (state !== undefined) {
52
+ if (state !== undefined && !isRetired) {
42
53
  let encoded = JSON.stringify(state);
43
54
  if (encoded.length <= 61440) {
44
55
  descriptor["state"] = state;
@@ -53,9 +64,10 @@ let maxStateChars = 61440;
53
64
 
54
65
  export {
55
66
  maxStateChars,
67
+ retiredSpecFor,
56
68
  pickSortKeyValue,
57
69
  lastSequence,
58
70
  nextSequence,
59
71
  make,
60
72
  }
61
- /* No side effect */
73
+ /* OwnerScope-Reventless Not a pure module */
@@ -180,6 +180,54 @@ module Make = (Bus: LocalBus.T) => {
180
180
  ->Option.mapOr(false, v => v == required)
181
181
  }
182
182
 
183
+ // ── Retirement narrowing ─────────────────────────────────────────────────
184
+ // The same two forms as owner scoping above, for the same reason: a door
185
+ // that narrows the page and a door that hands back one row both have to ask.
186
+ // Read per request for the same reason too.
187
+ let retiredSpecOf = () =>
188
+ Plugin_Helpers.stateSchemaRegistry
189
+ ->Dict.get(name)
190
+ ->Option.flatMap(Reventless.StateAnnotations.getSpec)
191
+ ->Option.flatMap(spec => spec.retired)
192
+
193
+ // `includeRetired` is the caller asking; `decideRetired` decides whether the
194
+ // asking counts. Read here rather than inside the decision so the argument
195
+ // stays an argument — the door reports what was asked, the classifier says
196
+ // who may be answered.
197
+ let askedForRetired = (~args) =>
198
+ args
199
+ ->JSON.Decode.object
200
+ ->Option.flatMap(d => d->Dict.get("includeRetired"))
201
+ ->Option.flatMap(JSON.Decode.bool)
202
+ ->Option.getOr(false)
203
+
204
+ let retiredDecision = (~ctx, ~args) =>
205
+ extractIdentity(ctx)->Reventless.OwnerScope.decideRetired(
206
+ ~retiredField=retiredSpecOf()->Option.map(r => r.field),
207
+ ~retiredValues=?retiredSpecOf()->Option.flatMap(r => r.values),
208
+ ~asked=askedForRetired(~args),
209
+ )
210
+
211
+ let retiredScopeFor = (~ctx, ~args) =>
212
+ retiredDecision(~ctx, ~args)->Reventless.OwnerScope.retiredScopeOf
213
+
214
+ // Post-read form. The single-row and by-ids doors are where a filtered list
215
+ // would otherwise be walked around: a pasted URL, or a reference resolved
216
+ // from another row, reaches the entity directly.
217
+ let retiredAllows = (~ctx, ~args, item: JSON.t) =>
218
+ switch retiredScopeFor(~ctx, ~args) {
219
+ | None => true
220
+ // Absent keeps the row, matching `QueryDbListQuery`: a row written before
221
+ // the annotation existed is not retired. Which of the two forms decides
222
+ // that is `isRetiredValue`'s question, not this door's.
223
+ | Some(scope) =>
224
+ !(
225
+ scope->Reventless.OwnerScope.isRetiredValue(
226
+ item->JSON.Decode.object->Option.flatMap(d => d->Dict.get(scope.field)),
227
+ )
228
+ )
229
+ }
230
+
183
231
  let cap = s => s->String.charAt(0)->String.toUpperCase ++ s->String.slice(~start=1)
184
232
 
185
233
  // Resolve query field names: check registry first, fall back to safe defaults.
@@ -260,7 +308,8 @@ module Make = (Bus: LocalBus.T) => {
260
308
  // A row the caller does not own answers as though it were not there.
261
309
  // Distinguishing "not yours" from "not found" here would turn this door
262
310
  // into an oracle for which ids exist.
263
- | Some(item) if !ownerAllows(~ctx, item) => JSON.Encode.null
311
+ | Some(item) if !ownerAllows(~ctx, item) || !retiredAllows(~ctx, ~args, item) =>
312
+ JSON.Encode.null
264
313
  | Some(item) =>
265
314
  if includeIdParam {
266
315
  // Copied — `JSON.Decode.object` hands back the stored object itself,
@@ -327,7 +376,7 @@ module Make = (Bus: LocalBus.T) => {
327
376
  loaded
328
377
  ->Array.filterMap(((id, opt)) =>
329
378
  opt
330
- ->Option.filter(item => ownerAllows(~ctx, item))
379
+ ->Option.filter(item => ownerAllows(~ctx, item) && retiredAllows(~ctx, ~args, item))
331
380
  ->Option.map(item => {
332
381
  let obj = item->JSON.Decode.object->Option.mapOr(Dict.make(), Dict.copy)
333
382
  obj->Dict.set("id", JSON.Encode.string(id))
@@ -411,6 +460,11 @@ module Make = (Bus: LocalBus.T) => {
411
460
  | RefuseOwned => emptyConnection
412
461
  | decision =>
413
462
  let ownerScope = Reventless.OwnerScope.scopeOf(decision)
463
+ // Same both-arms rule as `ownerScope`, and the same reason it is
464
+ // spelled out: this predicate decides what the caller may see, so a
465
+ // push-down that did not receive it would serve the archive to
466
+ // everyone on the path that is hardest to notice.
467
+ let retiredScope = retiredScopeFor(~ctx, ~args)
414
468
  let argsDict = args->JSON.Decode.object->Option.getOr(Dict.make())
415
469
  // Prefer a backend list push-down (SQLite builds json_extract predicates
416
470
  // + ORDER BY … LIMIT so it never materialises the whole read model). When
@@ -427,7 +481,7 @@ module Make = (Bus: LocalBus.T) => {
427
481
  DomainGraphQL_Server.decodeGlobalId(id)->Option.map(((_, lid)) => lid)
428
482
  switch Bus.getQueryDbListPage(name) {
429
483
  | Some(listPage) =>
430
- switch listPage(~argsDict, ~capability, ~labelField, ~ownerScope?) {
484
+ switch listPage(~argsDict, ~capability, ~labelField, ~ownerScope?, ~retiredScope?) {
431
485
  | Some(conn) => conn
432
486
  | None =>
433
487
  let items = await fetchAllItems()
@@ -438,6 +492,7 @@ module Make = (Bus: LocalBus.T) => {
438
492
  ~labelField,
439
493
  ~decodeLocalId,
440
494
  ~ownerScope?,
495
+ ~retiredScope?,
441
496
  )
442
497
  }
443
498
  | None =>
@@ -449,6 +504,7 @@ module Make = (Bus: LocalBus.T) => {
449
504
  ~labelField,
450
505
  ~decodeLocalId,
451
506
  ~ownerScope?,
507
+ ~retiredScope?,
452
508
  )
453
509
  }
454
510
  }
@@ -466,7 +522,8 @@ module Make = (Bus: LocalBus.T) => {
466
522
  // The legacy shape has no push-down to reach, so the narrowing is a plain
467
523
  // filter here. `scannedCount` counts what is returned, not what was read:
468
524
  // the pre-scoping total would tell a caller how many rows they may not see.
469
- let items = all->Array.filter(item => ownerAllows(~ctx, item))
525
+ let items =
526
+ all->Array.filter(item => ownerAllows(~ctx, item) && retiredAllows(~ctx, ~args, item))
470
527
  Obj.magic({"nextToken": Nullable.null, "scannedCount": items->Array.length, "items": items})
471
528
  }
472
529
  }
@@ -531,7 +588,10 @@ module Make = (Bus: LocalBus.T) => {
531
588
  // Narrowed here, before the cursor window and the sort-key filter, so
532
589
  // every page this door emits is a page of rows the caller owns. Doing
533
590
  // it after would hand back short pages with valid cursors.
534
- let allItems = loaded->Array.filter(item => ownerAllows(~ctx, item))
591
+ let allItems =
592
+ loaded->Array.filter(item =>
593
+ ownerAllows(~ctx, item) && retiredAllows(~ctx, ~args, item)
594
+ )
535
595
 
536
596
  // Cursor-keyed filtering: exclude items on the cursor side of the boundary
537
597
  let cursorFiltered = if isBackward {
@@ -624,7 +684,8 @@ module Make = (Bus: LocalBus.T) => {
624
684
  // push-down and the scan are two ways to reach the same rows, and a
625
685
  // narrowing that lives in only one is a hole that appears when a
626
686
  // backend gains or loses an index.
627
- let scoped = rows => rows->Array.filter(item => ownerAllows(~ctx, item))
687
+ let scoped = rows =>
688
+ rows->Array.filter(item => ownerAllows(~ctx, item) && retiredAllows(~ctx, ~args, item))
628
689
  // Prefer the pushed-down equality lookup (SQLite rides the GSI index;
629
690
  // in-memory reuses its lazy snapshot). Fall back to scan+filter only
630
691
  // if no lookup is registered for this QueryDb.
@@ -11,6 +11,7 @@ import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/I
11
11
  import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
12
12
  import * as Api_Ids$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Ids.res.mjs";
13
13
  import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
14
+ import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
14
15
  import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
15
16
  import * as SortKey_Filter$ReventlessLocal from "./SortKey_Filter.res.mjs";
16
17
  import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
@@ -100,6 +101,17 @@ function Make(Bus) {
100
101
  let field = match._0;
101
102
  return Stdlib_Option.mapOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[field]), Stdlib_JSON.Decode.string), false, v => v === required);
102
103
  };
104
+ let retiredSpecOf = () => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Plugin_Helpers$ReventlessCore.stateSchemaRegistry[name], StateAnnotations$Reventless.getSpec), spec => spec.retired);
105
+ let askedForRetired = args => Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d["includeRetired"]), Stdlib_JSON.Decode.bool), false);
106
+ let retiredDecision = (ctx, args) => OwnerScope$Reventless.decideRetired(extractIdentity(ctx), Stdlib_Option.map(retiredSpecOf(), r => r.field), Stdlib_Option.flatMap(retiredSpecOf(), r => r.values), askedForRetired(args), undefined);
107
+ let retiredAllows = (ctx, args, item) => {
108
+ let scope = OwnerScope$Reventless.retiredScopeOf(retiredDecision(ctx, args));
109
+ if (scope !== undefined) {
110
+ return !OwnerScope$Reventless.isRetiredValue(scope, Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[scope.field]));
111
+ } else {
112
+ return true;
113
+ }
114
+ };
103
115
  let cap = s => s.charAt(0).toUpperCase() + s.slice(1);
104
116
  let registryEntry = Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry[name];
105
117
  let singleQueryName = registryEntry !== undefined ? registryEntry.singleFieldName : name.charAt(0).toLowerCase() + name.slice(1);
@@ -139,7 +151,7 @@ function Make(Bus) {
139
151
  if (item === undefined) {
140
152
  return null;
141
153
  }
142
- if (!ownerAllows(ctx, item)) {
154
+ if (!ownerAllows(ctx, item) || !retiredAllows(ctx, args, item)) {
143
155
  return null;
144
156
  }
145
157
  if (!includeIdParam) {
@@ -186,7 +198,13 @@ function Make(Bus) {
186
198
  }));
187
199
  return Stdlib_Array.filterMap(loaded, param => {
188
200
  let id = param[0];
189
- return Stdlib_Option.map(Stdlib_Option.filter(param[1], item => ownerAllows(ctx, item)), item => {
201
+ return Stdlib_Option.map(Stdlib_Option.filter(param[1], item => {
202
+ if (ownerAllows(ctx, item)) {
203
+ return retiredAllows(ctx, args, item);
204
+ } else {
205
+ return false;
206
+ }
207
+ }), item => {
190
208
  let obj = Stdlib_Option.mapOr(Stdlib_JSON.Decode.object(item), {}, prim => Object.assign({}, prim));
191
209
  obj["id"] = id;
192
210
  return obj;
@@ -242,19 +260,20 @@ function Make(Bus) {
242
260
  return emptyConnection;
243
261
  }
244
262
  let ownerScope = OwnerScope$Reventless.scopeOf(decision);
263
+ let retiredScope = OwnerScope$Reventless.retiredScopeOf(retiredDecision(ctx, args));
245
264
  let argsDict = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(args), {});
246
265
  let decodeLocalId = id => Stdlib_Option.map(DomainGraphQL_Server$ReventlessLocal.decodeGlobalId(id), param => param[1]);
247
266
  let listPage = Bus.getQueryDbListPage(name);
248
267
  if (listPage !== undefined) {
249
- let conn = listPage(argsDict, capability, labelField, ownerScope);
268
+ let conn = listPage(argsDict, capability, labelField, ownerScope, retiredScope);
250
269
  if (conn !== undefined) {
251
270
  return conn;
252
271
  }
253
272
  let items = await fetchAllItems();
254
- return QueryDbListQuery$ReventlessCore.run(items, argsDict, capability, labelField, decodeLocalId, ownerScope);
273
+ return QueryDbListQuery$ReventlessCore.run(items, argsDict, capability, labelField, decodeLocalId, ownerScope, retiredScope);
255
274
  }
256
275
  let items$1 = await fetchAllItems();
257
- return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, capability, labelField, decodeLocalId, ownerScope);
276
+ return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, capability, labelField, decodeLocalId, ownerScope, retiredScope);
258
277
  };
259
278
  match = [
260
279
  sdl,
@@ -272,7 +291,13 @@ function Make(Bus) {
272
291
  };
273
292
  }
274
293
  let all = await fetchAllItems();
275
- let items = all.filter(item => ownerAllows(ctx, item));
294
+ let items = all.filter(item => {
295
+ if (ownerAllows(ctx, item)) {
296
+ return retiredAllows(ctx, args, item);
297
+ } else {
298
+ return false;
299
+ }
300
+ });
276
301
  return {
277
302
  nextToken: null,
278
303
  scannedCount: items.length,
@@ -331,7 +356,13 @@ function Make(Bus) {
331
356
  return emptyConn;
332
357
  }
333
358
  let loaded = await Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(id)), param => Effect.succeed([])));
334
- let allItems = loaded.filter(item => ownerAllows(ctx, item));
359
+ let allItems = loaded.filter(item => {
360
+ if (ownerAllows(ctx, item)) {
361
+ return retiredAllows(ctx, args, item);
362
+ } else {
363
+ return false;
364
+ }
365
+ });
335
366
  let cursorFiltered;
336
367
  if (isBackward) {
337
368
  let beforeKey = Stdlib_Option.map(before, decodeCursor);
@@ -408,7 +439,13 @@ function Make(Bus) {
408
439
  return [];
409
440
  }
410
441
  let value = Stdlib_Option.getOr(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(args), d => d[index]), Stdlib_JSON.Decode.string), "");
411
- let scoped = rows => rows.filter(item => ownerAllows(ctx, item));
442
+ let scoped = rows => rows.filter(item => {
443
+ if (ownerAllows(ctx, item)) {
444
+ return retiredAllows(ctx, args, item);
445
+ } else {
446
+ return false;
447
+ }
448
+ });
412
449
  let lookup = Bus.getQueryDbIndexLookup(name);
413
450
  if (lookup !== undefined) {
414
451
  return scoped(lookup(filterField, value));
@@ -198,6 +198,8 @@ module Make = (Bus: LocalBus.T) => {
198
198
  ~id=entityKeyFor(id, subKey),
199
199
  ~state=Some(state),
200
200
  ~seq=LocalStateChangeDescriptor.nextSequence(),
201
+ ~retiredField=?LocalStateChangeDescriptor.retiredSpecFor(name)->Option.map(r => r.field),
202
+ ~retiredValues=?LocalStateChangeDescriptor.retiredSpecFor(name)->Option.flatMap(r => r.values),
201
203
  )
202
204
  Bus.publishStateChange(~name, ~descriptor)
203
205
  }
@@ -167,11 +167,11 @@ function Make(Bus) {
167
167
  };
168
168
  let publishSaved = (changeKind, id, state) => {
169
169
  let subKey = getSubKey(state, subIdField);
170
- let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make(changeKind, entityKeyFor(id, subKey), state, LocalStateChangeDescriptor$ReventlessLocal.nextSequence());
170
+ let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make(changeKind, entityKeyFor(id, subKey), state, LocalStateChangeDescriptor$ReventlessLocal.nextSequence(), Stdlib_Option.map(LocalStateChangeDescriptor$ReventlessLocal.retiredSpecFor(name), r => r.field), Stdlib_Option.flatMap(LocalStateChangeDescriptor$ReventlessLocal.retiredSpecFor(name), r => r.values));
171
171
  Bus.publishStateChange(name, descriptor);
172
172
  };
173
173
  let publishRemoved = (id, subKey) => {
174
- let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make("Removed", entityKeyFor(id, subKey), undefined, LocalStateChangeDescriptor$ReventlessLocal.nextSequence());
174
+ let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make("Removed", entityKeyFor(id, subKey), undefined, LocalStateChangeDescriptor$ReventlessLocal.nextSequence(), undefined, undefined);
175
175
  Bus.publishStateChange(name, descriptor);
176
176
  };
177
177
  let save = async (id, state, _saveMode, ttl) => {
@@ -147,6 +147,7 @@ type busCallbacks = {
147
147
  ~capability: GraphQL_FragmentGenerator.serverCapability,
148
148
  ~labelField: string,
149
149
  ~ownerScope: (string, string)=?,
150
+ ~retiredScope: Reventless.OwnerScope.retiredScope=?,
150
151
  ) => option<JSON.t>,
151
152
  ) => unit,
152
153
  }
@@ -263,6 +264,8 @@ let makeStorage = (
263
264
  ~id=entityKeyFor(id, subKey),
264
265
  ~state=Some(state),
265
266
  ~seq=LocalStateChangeDescriptor.nextSequence(),
267
+ ~retiredField=?LocalStateChangeDescriptor.retiredSpecFor(name)->Option.map(r => r.field),
268
+ ~retiredValues=?LocalStateChangeDescriptor.retiredSpecFor(name)->Option.flatMap(r => r.values),
266
269
  )
267
270
  bus.publishStateChange(~name, ~descriptor)
268
271
  }
@@ -428,6 +431,7 @@ let makeStorage = (
428
431
  ~capability: GraphQL_FragmentGenerator.serverCapability,
429
432
  ~labelField as _,
430
433
  ~ownerScope: option<(string, string)>=?,
434
+ ~retiredScope: option<Reventless.OwnerScope.retiredScope>=?,
431
435
  ): option<JSON.t> => {
432
436
  let filterDict =
433
437
  argsDict->Dict.get("filter")->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
@@ -451,6 +455,39 @@ let makeStorage = (
451
455
  params->Array.push(JSON.Encode.string(required))
452
456
  | None => ()
453
457
  }
458
+ // `IS NOT 1`, not `= 0`, and on the raw extraction rather than through
459
+ // `jsonText`. SQLite renders a JSON true as the integer 1 and a missing
460
+ // path as NULL, and `IS NOT` is the comparison that treats NULL as a value
461
+ // rather than poisoning the predicate. Three cases have to land the way the
462
+ // in-memory spec lands them: absent keeps the row (a row written before the
463
+ // annotation is not retired), false keeps it, and a value that is not a
464
+ // JSON boolean keeps it too — which `= 0` would get wrong, since a string
465
+ // `'true'` compares unequal to 0 and the row would vanish on this backend
466
+ // only. `QueryDbListPushdownParityTest` is what holds the two together.
467
+ //
468
+ // The state form compares against the states' text instead of 1, and the
469
+ // three cases land identically: absent is NULL, a state outside the set
470
+ // compares unequal to every member, and a non-string value is none of them
471
+ // either.
472
+ //
473
+ // Written as a conjunction of `IS NOT` rather than as `NOT IN`: `IN` is
474
+ // ordinary equality, so a NULL path makes the predicate NULL and the row
475
+ // vanishes — the absent case, which has to keep the row. `IS NOT` is the
476
+ // comparison that treats NULL as a value, and it does not have an `IN`
477
+ // spelling. A set naming nothing pushes no predicate down, which is the
478
+ // same answer `isRetiredValue` gives it.
479
+ switch retiredScope {
480
+ | Some(scope) =>
481
+ let path = `json_extract(item, '$.${scope.field->String.replaceAll("'", "''")}')`
482
+ switch scope.values {
483
+ | None => whereParts->Array.push(`${path} IS NOT 1`)
484
+ | Some(states) =>
485
+ states->Array.forEach(state =>
486
+ whereParts->Array.push(`${path} IS NOT '${state->String.replaceAll("'", "''")}'`)
487
+ )
488
+ }
489
+ | None => ()
490
+ }
454
491
  let valString = v =>
455
492
  switch v->JSON.Decode.string {
456
493
  | Some(s) => Some(s)
@@ -194,7 +194,7 @@ function makeStorage(db, bus, name, indexes, subIdField) {
194
194
  };
195
195
  let publishSaved = (changeKind, id, state) => {
196
196
  let subKey = computeSubKey(state, subIdField);
197
- let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make(changeKind, entityKeyFor(id, subKey), state, LocalStateChangeDescriptor$ReventlessLocal.nextSequence());
197
+ let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make(changeKind, entityKeyFor(id, subKey), state, LocalStateChangeDescriptor$ReventlessLocal.nextSequence(), Stdlib_Option.map(LocalStateChangeDescriptor$ReventlessLocal.retiredSpecFor(name), r => r.field), Stdlib_Option.flatMap(LocalStateChangeDescriptor$ReventlessLocal.retiredSpecFor(name), r => r.values));
198
198
  bus.publishStateChange(name, descriptor);
199
199
  };
200
200
  let saveKind = (id, state) => {
@@ -209,7 +209,7 @@ function makeStorage(db, bus, name, indexes, subIdField) {
209
209
  }
210
210
  };
211
211
  let publishRemoved = (id, subKey) => {
212
- let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make("Removed", entityKeyFor(id, subKey), undefined, LocalStateChangeDescriptor$ReventlessLocal.nextSequence());
212
+ let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make("Removed", entityKeyFor(id, subKey), undefined, LocalStateChangeDescriptor$ReventlessLocal.nextSequence(), undefined, undefined);
213
213
  bus.publishStateChange(name, descriptor);
214
214
  };
215
215
  let rowKeysForPartition = id => SqliteDriver$ReventlessLocal.all(selectByPartitionStmt, [id]).map(row => {
@@ -337,7 +337,7 @@ function makeStorage(db, bus, name, indexes, subIdField) {
337
337
  };
338
338
  let jsonText = field => `CAST(json_extract(item, '$.` + field.replaceAll("'", "''") + `') AS TEXT)`;
339
339
  let idExpr = "COALESCE(json_extract(item, '$.id'), partition_key)";
340
- let listPage = (argsDict, capability, param, ownerScope) => {
340
+ let listPage = (argsDict, capability, param, ownerScope, retiredScope) => {
341
341
  let filterDict = Stdlib_Option.getOr(Stdlib_Option.flatMap(argsDict["filter"], Stdlib_JSON.Decode.object), {});
342
342
  let strNonEmpty = k => Stdlib_Option.mapOr(Stdlib_Option.flatMap(filterDict[k], Stdlib_JSON.Decode.string), false, s => s.length > 0);
343
343
  let hasIds = Stdlib_Option.mapOr(Stdlib_Option.flatMap(filterDict["ids"], Stdlib_JSON.Decode.array), false, a => a.length !== 0);
@@ -352,6 +352,17 @@ function makeStorage(db, bus, name, indexes, subIdField) {
352
352
  whereParts.push(jsonText(ownerScope[0]) + ` = ?`);
353
353
  params.push(ownerScope[1]);
354
354
  }
355
+ if (retiredScope !== undefined) {
356
+ let path = `json_extract(item, '$.` + retiredScope.field.replaceAll("'", "''") + `')`;
357
+ let states = retiredScope.values;
358
+ if (states !== undefined) {
359
+ states.forEach(state => {
360
+ whereParts.push(path + ` IS NOT '` + state.replaceAll("'", "''") + `'`);
361
+ });
362
+ } else {
363
+ whereParts.push(path + ` IS NOT 1`);
364
+ }
365
+ }
355
366
  let valString = v => {
356
367
  let s = Stdlib_JSON.Decode.string(v);
357
368
  if (s !== undefined) {
@@ -11,9 +11,20 @@
11
11
  // unlink leaves it on the orphaned inode still serving every row it had, the
12
12
  // delete looks like it worked, and the seed then fails against the untouched
13
13
  // server with "the target store is not empty". Deleting ROWS through a second
14
- // connection is visible to the running server immediately (its reads go to
15
- // these tables; there is no in-process cache), so a reset needs no restart and
16
- // no stopping of the platform.
14
+ // connection is what works instead a query issued after the delete reads
15
+ // the emptied tables, so the platform need not be STOPPED for the reset.
16
+ //
17
+ // It does need RESTARTING before the re-seed, and this comment claimed
18
+ // otherwise for a while on the strength of "there is no in-process cache".
19
+ // There is: a DCB slice's decision state is held per partition in the
20
+ // running process, and a delete behind its back does not invalidate it. The
21
+ // slice goes on refusing writes for ids whose state it still remembers, so
22
+ // the re-seed fails with `…AlreadyExists` naming rows the store visibly does
23
+ // not hold — verified by the same id being refused before a restart and
24
+ // accepted after, against byte-identical tables.
25
+ //
26
+ // So: reset with the platform up, then restart it, then seed. Emptying the
27
+ // store and emptying the process are two steps, and only the first is here.
17
28
  //
18
29
  // • It is SCOPED. Wiping domain data leaves the plugin registry intact, so a
19
30
  // re-seed just works — the same reason the deployed default is `domain`.
@@ -543,15 +554,21 @@ let run = (~dbPath: option<string>=?): unit => {
543
554
  if total(plan) == 0 {
544
555
  Console.log("Nothing to do.")
545
556
  } else {
557
+ // Set at all ⇒ it is the answer, affirmative or not. Falling through to
558
+ // the prompt on an unrecognised value would ask for a TTY the runs this
559
+ // variable exists for do not have, so `SEED_RESET_CONFIRM=no` would
560
+ // throw where it plainly means "don't". Both arms read the reply
561
+ // through one predicate, which is what keeps the typed `y` and the
562
+ // env var speaking the same language.
546
563
  let confirmed = switch Seed.Prompt.envValue("SEED_RESET_CONFIRM") {
547
- | Some("1") | Some("yes") => true
548
- | _ =>
564
+ | Some(answer) => Seed.Prompt.isAffirmative(answer)
565
+ | None =>
549
566
  let answer = await Seed.Prompt.ask(
550
567
  `Empty ${total(
551
568
  plan,
552
569
  )->Int.toString} row(s)/object(s) in the "${plan.scope->scopeLabel}" scope? [y/N]: `,
553
570
  )
554
- answer->String.trim->String.toLowerCase == "y"
571
+ Seed.Prompt.isAffirmative(answer)
555
572
  }
556
573
  if confirmed {
557
574
  execute(db, plan)
@@ -499,25 +499,8 @@ function run(dbPath) {
499
499
  if (total(plan) === 0) {
500
500
  console.log("Nothing to do.");
501
501
  } else {
502
- let match$1 = Seed_Prompt$ReventlessSeed.envValue("SEED_RESET_CONFIRM");
503
- let confirmed;
504
- let exit = 0;
505
- if (match$1 !== undefined) {
506
- switch (match$1) {
507
- case "1" :
508
- case "yes" :
509
- confirmed = true;
510
- break;
511
- default:
512
- exit = 1;
513
- }
514
- } else {
515
- exit = 1;
516
- }
517
- if (exit === 1) {
518
- let answer = await Seed_Prompt$ReventlessSeed.ask(`Empty ` + total(plan).toString() + ` row(s)/object(s) in the "` + scopeLabel(plan.scope) + `" scope? [y/N]: `);
519
- confirmed = answer.trim().toLowerCase() === "y";
520
- }
502
+ let answer = Seed_Prompt$ReventlessSeed.envValue("SEED_RESET_CONFIRM");
503
+ let confirmed = answer !== undefined ? Seed_Prompt$ReventlessSeed.isAffirmative(answer) : Seed_Prompt$ReventlessSeed.isAffirmative(await Seed_Prompt$ReventlessSeed.ask(`Empty ` + total(plan).toString() + ` row(s)/object(s) in the "` + scopeLabel(plan.scope) + `" scope? [y/N]: `));
521
504
  if (confirmed) {
522
505
  execute(db, plan);
523
506
  console.log(`Reset complete — the "` + scopeLabel(plan.scope) + `" scope reads empty and is re-seedable.`);
@@ -109,10 +109,11 @@ let indexedStateSchemaWithAnnotations = indexedStateSchema->S.Metadata.set(
109
109
  scanSort: [],
110
110
  semantic: [],
111
111
  metric: [],
112
- status: None,
112
+ lifecycle: None,
113
113
  groupBy: None,
114
114
  visibility: None,
115
115
  live: None,
116
+ retired: None,
116
117
  },
117
118
  )
118
119
 
@@ -139,10 +140,11 @@ let orderedStateSchemaWithAnnotations = orderedStateSchema->S.Metadata.set(
139
140
  scanSort: [],
140
141
  semantic: [],
141
142
  metric: [],
142
- status: None,
143
+ lifecycle: None,
143
144
  groupBy: None,
144
145
  visibility: None,
145
146
  live: None,
147
+ retired: None,
146
148
  },
147
149
  )
148
150
 
@@ -170,10 +172,11 @@ let scanStateSchemaWithAnnotations = scanStateSchema->S.Metadata.set(
170
172
  scanSort: ["name"],
171
173
  semantic: [],
172
174
  metric: [],
173
- status: None,
175
+ lifecycle: None,
174
176
  groupBy: None,
175
177
  visibility: None,
176
178
  live: None,
179
+ retired: None,
177
180
  },
178
181
  )
179
182
 
@@ -429,7 +432,7 @@ describe("GraphQL_SchemaInspector", () => {
429
432
  // Query field should use filter + first/after/last/before args (Phase 4)
430
433
  expect(
431
434
  sdl->String.includes(
432
- "Relay_Products(filter: RelayProductFilter, first: Int, after: String, last: Int, before: String): RelayProductConnection!",
435
+ "Relay_Products(filter: RelayProductFilter, first: Int, after: String, last: Int, before: String, includeRetired: Boolean): RelayProductConnection!",
433
436
  ),
434
437
  )->toBe(true)
435
438
  // Connection filter input with search/searchPrefix/ids
@@ -114,10 +114,11 @@ let indexedStateSchemaWithAnnotations = S.Metadata.set(indexedStateSchema, State
114
114
  scanSort: [],
115
115
  semantic: [],
116
116
  metric: [],
117
- status: undefined,
117
+ lifecycle: undefined,
118
118
  groupBy: undefined,
119
119
  visibility: undefined,
120
- live: undefined
120
+ live: undefined,
121
+ retired: undefined
121
122
  });
122
123
 
123
124
  let orderedStateSchema = S.schema(s => ({
@@ -141,10 +142,11 @@ let orderedStateSchemaWithAnnotations = S.Metadata.set(orderedStateSchema, State
141
142
  scanSort: [],
142
143
  semantic: [],
143
144
  metric: [],
144
- status: undefined,
145
+ lifecycle: undefined,
145
146
  groupBy: undefined,
146
147
  visibility: undefined,
147
- live: undefined
148
+ live: undefined,
149
+ retired: undefined
148
150
  });
149
151
 
150
152
  let scanStateSchema = S.schema(s => ({
@@ -169,10 +171,11 @@ let scanStateSchemaWithAnnotations = S.Metadata.set(scanStateSchema, StateAnnota
169
171
  scanSort: ["name"],
170
172
  semantic: [],
171
173
  metric: [],
172
- status: undefined,
174
+ lifecycle: undefined,
173
175
  groupBy: undefined,
174
176
  visibility: undefined,
175
- live: undefined
177
+ live: undefined,
178
+ retired: undefined
176
179
  });
177
180
 
178
181
  globalThis.describe("GraphQL_SchemaInspector", () => {
@@ -350,7 +353,7 @@ globalThis.describe("GraphQL_SchemaInspector", () => {
350
353
  globalThis.expect(sdl.includes("edges: [RelayProductEdge!]!")).toBe(true);
351
354
  globalThis.expect(sdl.includes("pageInfo: PageInfo!")).toBe(true);
352
355
  globalThis.expect(sdl.includes("totalCount")).toBe(false);
353
- globalThis.expect(sdl.includes("Relay_Products(filter: RelayProductFilter, first: Int, after: String, last: Int, before: String): RelayProductConnection!")).toBe(true);
356
+ globalThis.expect(sdl.includes("Relay_Products(filter: RelayProductFilter, first: Int, after: String, last: Int, before: String, includeRetired: Boolean): RelayProductConnection!")).toBe(true);
354
357
  globalThis.expect(sdl.includes("input RelayProductFilter")).toBe(true);
355
358
  globalThis.expect(sdl.includes("search: String")).toBe(true);
356
359
  globalThis.expect(sdl.includes("searchPrefix: String")).toBe(true);
@@ -69,7 +69,7 @@ globalThis.describe("LocalGraphQL_SubscriptionResolvers", () => {
69
69
  "2026-05-19T12:00:00Z"
70
70
  ]
71
71
  ]);
72
- let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make("Updated", "prod-1", state, LocalStateChangeDescriptor$ReventlessLocal.nextSequence());
72
+ let descriptor = LocalStateChangeDescriptor$ReventlessLocal.make("Updated", "prod-1", state, LocalStateChangeDescriptor$ReventlessLocal.nextSequence(), undefined, undefined);
73
73
  TestBus.publishStateChange("Product", descriptor);
74
74
  let received = await consumerPromise;
75
75
  if (received !== null) {
@@ -88,7 +88,7 @@ globalThis.describe("LocalGraphQL_SubscriptionResolvers", () => {
88
88
  let foreignDescriptor = LocalStateChangeDescriptor$ReventlessLocal.make("Updated", "cat-1", Object.fromEntries([[
89
89
  "id",
90
90
  "cat-1"
91
- ]]), LocalStateChangeDescriptor$ReventlessLocal.nextSequence());
91
+ ]]), LocalStateChangeDescriptor$ReventlessLocal.nextSequence(), undefined, undefined);
92
92
  TestBus.publishStateChange("Category", foreignDescriptor);
93
93
  let received = await consumerPromise;
94
94
  globalThis.expect(received === null ? undefined : Primitive_option.some(received)).toEqual(undefined);
@@ -21,13 +21,21 @@ let capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability = {
21
21
  sortFields: ["name", "qty", "status"],
22
22
  }
23
23
 
24
- let mk = (id, status, name, qty, owner) => {
24
+ let mk = (id, status, name, qty, owner, archived) => {
25
25
  let o = Dict.make()
26
26
  o->Dict.set("id", JSON.Encode.string(id))
27
27
  o->Dict.set("status", JSON.Encode.string(status))
28
28
  o->Dict.set("name", JSON.Encode.string(name))
29
29
  o->Dict.set("qty", JSON.Encode.int(qty))
30
30
  o->Dict.set("owner", JSON.Encode.string(owner))
31
+ // `archived` is absent on p-5, not false. A row written before the annotation
32
+ // existed has no such attribute, and "absent means not retired" is the rule
33
+ // both the spec and every push-down have to land on — get it wrong and the
34
+ // view empties the day someone adds `@retired`.
35
+ switch archived {
36
+ | Some(b) => o->Dict.set("archived", JSON.Encode.bool(b))
37
+ | None => ()
38
+ }
31
39
  JSON.Encode.object(o)
32
40
  }
33
41
 
@@ -40,11 +48,11 @@ let mk = (id, status, name, qty, owner) => {
40
48
  // to work on a field the client-visible filter surface does not admit, which is
41
49
  // the normal case and the reason the predicate travels separately.
42
50
  let rows = [
43
- ("p-1", "active", "Charlie", 3, "u-a"),
44
- ("p-2", "active", "Alpha", 1, "u-b"),
45
- ("p-3", "inactive", "Echo", 5, "u-a"),
46
- ("p-4", "active", "Bravo", 2, "u-c"),
47
- ("p-5", "inactive", "Delta", 4, "u-a"),
51
+ ("p-1", "active", "Charlie", 3, "u-a", Some(false)),
52
+ ("p-2", "active", "Alpha", 1, "u-b", Some(true)),
53
+ ("p-3", "inactive", "Echo", 5, "u-a", Some(false)),
54
+ ("p-4", "active", "Bravo", 2, "u-c", Some(true)),
55
+ ("p-5", "inactive", "Delta", 4, "u-a", None),
48
56
  ]
49
57
 
50
58
  type setup = {
@@ -53,6 +61,7 @@ type setup = {
53
61
  ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
54
62
  ~labelField: string,
55
63
  ~ownerScope: (string, string)=?,
64
+ ~retiredScope: Reventless.OwnerScope.retiredScope=?,
56
65
  ) => option<JSON.t>,
57
66
  fullScan: unit => array<JSON.t>,
58
67
  }
@@ -66,8 +75,13 @@ let build = async (): setup => {
66
75
  let s = Storage.make(~name="items", ~indexes=[], ~api=(), ~apiRole=(), ~owner=None, ~opts)
67
76
  let ops = await s.operations->TestRunner.resolve
68
77
  for i in 0 to rows->Array.length - 1 {
69
- let (id, status, name, qty, owner) = rows->Array.getUnsafe(i)
70
- let _ = await ops.save(id, mk(id, status, name, qty, owner), ReventlessCore.QueryDb.Any, None)
78
+ let (id, status, name, qty, owner, archived) = rows->Array.getUnsafe(i)
79
+ let _ = await ops.save(
80
+ id,
81
+ mk(id, status, name, qty, owner, archived),
82
+ ReventlessCore.QueryDb.Any,
83
+ None,
84
+ )
71
85
  }
72
86
  {
73
87
  listPage: TestBus.getQueryDbListPage("items")->Option.getOrThrow,
@@ -118,7 +132,7 @@ let orderBy = (f, dir) =>
118
132
  let cur = ReventlessCore.QueryDbListQuery.encodeCursor
119
133
 
120
134
  // Assert the push-down serves this shape AND matches the spec exactly.
121
- let checkPushed = async (~label, ~ownerScope=?, args) => {
135
+ let checkPushed = async (~label, ~ownerScope=?, ~retiredScope=?, args) => {
122
136
  let s = await build()
123
137
  let argsDict = argsOf(args)
124
138
  let expected =
@@ -129,8 +143,9 @@ let checkPushed = async (~label, ~ownerScope=?, args) => {
129
143
  ~labelField="name",
130
144
  ~decodeLocalId=_ => None,
131
145
  ~ownerScope?,
146
+ ~retiredScope?,
132
147
  )->norm
133
- switch s.listPage(~argsDict, ~capability, ~labelField="name", ~ownerScope?) {
148
+ switch s.listPage(~argsDict, ~capability, ~labelField="name", ~ownerScope?, ~retiredScope?) {
134
149
  | Some(actual) => expect(actual->norm)->toEqual(expected)
135
150
  | None => expect("push-down for " ++ label)->toBe("returned None")
136
151
  }
@@ -142,6 +157,15 @@ let checkFallback = async args => {
142
157
  expect(s.listPage(~argsDict=argsOf(args), ~capability, ~labelField="name")->Option.isSome)->toBe(false)
143
158
  }
144
159
 
160
+ // The ids a retirement-narrowed read returns, from the push-down.
161
+ let liveIds = async args => {
162
+ let s = await build()
163
+ switch s.listPage(~argsDict=argsOf(args), ~capability, ~labelField="name", ~retiredScope={field: "archived", values: None}) {
164
+ | Some(conn) => (conn->norm).edges->Array.map(e => e.id)
165
+ | None => []
166
+ }
167
+ }
168
+
145
169
  // The ids a scoped read actually returns, from the push-down.
146
170
  let scopedIds = async (~owner, args) => {
147
171
  let s = await build()
@@ -218,6 +242,54 @@ describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery spec)", ()
218
242
  checkFallback([("last", JSON.Encode.int(2)), ("before", JSON.Encode.string(cur("p-4")))])
219
243
  )
220
244
 
245
+ // ── Retirement narrowing ──────────────────────────────────────────────────
246
+ // Same parity argument as owner scoping below: the push-down is the path a
247
+ // deployment takes and the spec is the path the tests reach, so a predicate
248
+ // implemented in one of them is a hole every fallback-based test calls green.
249
+ describe("retirement narrowing", () => {
250
+ testPromise("bare page, narrowed", () =>
251
+ checkPushed(~label="retired-bare", ~retiredScope={field: "archived", values: None}, [])
252
+ )
253
+ testPromise("narrowed + orderBy", () =>
254
+ checkPushed(
255
+ ~label="retired-order",
256
+ ~retiredScope={field: "archived", values: None},
257
+ [("orderBy", orderBy("name", "DESC"))],
258
+ )
259
+ )
260
+ testPromise("narrowed + the caller's own filter", () =>
261
+ checkPushed(
262
+ ~label="retired-filter",
263
+ ~retiredScope={field: "archived", values: None},
264
+ [("filter", filterOf([("statusEq", JSON.Encode.string("active"))]))],
265
+ )
266
+ )
267
+ testPromise("narrowed + owner-scoped together", () =>
268
+ checkPushed(
269
+ ~label="retired-owner",
270
+ ~ownerScope=("owner", "u-a"),
271
+ ~retiredScope={field: "archived", values: None},
272
+ [],
273
+ )
274
+ )
275
+
276
+ // The rows, not just the parity: p-2 and p-4 are archived, p-5 carries no
277
+ // flag at all. Parity alone would pass if BOTH implementations were wrong
278
+ // the same way.
279
+ testPromise("drops the archived rows and keeps the one with no flag", async () => {
280
+ let ids = await liveIds([])
281
+ expect(ids)->toEqual(["p-1", "p-3", "p-5"])
282
+ })
283
+
284
+ // The reason the predicate is pushed into the SQL rather than applied to the
285
+ // page: `first: 2` must yield two LIVE rows, not two rows of which some were
286
+ // filtered away afterwards.
287
+ testPromise("a page of first:2 is two live rows, not two rows minus the archived", async () => {
288
+ let ids = await liveIds([("first", JSON.Encode.int(2))])
289
+ expect(ids)->toEqual(["p-1", "p-3"])
290
+ })
291
+ })
292
+
221
293
  // ── Owner scoping ─────────────────────────────────────────────────────────
222
294
  // Parity matters more here than anywhere else in this file. The push-down is
223
295
  // the path a deployment actually takes; the spec is the path the tests most
@@ -36,13 +36,16 @@ let capability = {
36
36
  sortFields: capability_sortFields
37
37
  };
38
38
 
39
- function mk(id, status, name, qty, owner) {
39
+ function mk(id, status, name, qty, owner, archived) {
40
40
  let o = {};
41
41
  o["id"] = id;
42
42
  o["status"] = status;
43
43
  o["name"] = name;
44
44
  o["qty"] = qty;
45
45
  o["owner"] = owner;
46
+ if (archived !== undefined) {
47
+ o["archived"] = archived;
48
+ }
46
49
  return o;
47
50
  }
48
51
 
@@ -52,35 +55,40 @@ let rows = [
52
55
  "active",
53
56
  "Charlie",
54
57
  3,
55
- "u-a"
58
+ "u-a",
59
+ false
56
60
  ],
57
61
  [
58
62
  "p-2",
59
63
  "active",
60
64
  "Alpha",
61
65
  1,
62
- "u-b"
66
+ "u-b",
67
+ true
63
68
  ],
64
69
  [
65
70
  "p-3",
66
71
  "inactive",
67
72
  "Echo",
68
73
  5,
69
- "u-a"
74
+ "u-a",
75
+ false
70
76
  ],
71
77
  [
72
78
  "p-4",
73
79
  "active",
74
80
  "Bravo",
75
81
  2,
76
- "u-c"
82
+ "u-c",
83
+ true
77
84
  ],
78
85
  [
79
86
  "p-5",
80
87
  "inactive",
81
88
  "Delta",
82
89
  4,
83
- "u-a"
90
+ "u-a",
91
+ undefined
84
92
  ]
85
93
  ];
86
94
 
@@ -96,7 +104,7 @@ async function build() {
96
104
  for (let i = 0, i_finish = rows.length; i < i_finish; ++i) {
97
105
  let match = rows[i];
98
106
  let id = match[0];
99
- await ops.save(id, mk(id, match[1], match[2], match[3], match[4]), "Any", undefined);
107
+ await ops.save(id, mk(id, match[1], match[2], match[3], match[4], match[5]), "Any", undefined);
100
108
  }
101
109
  return {
102
110
  listPage: Stdlib_Option.getOrThrow(TestBus.getQueryDbListPage("items"), undefined),
@@ -148,11 +156,11 @@ function orderBy(f, dir) {
148
156
  ]);
149
157
  }
150
158
 
151
- async function checkPushed(label, ownerScope, args) {
159
+ async function checkPushed(label, ownerScope, retiredScope, args) {
152
160
  let s = await build();
153
161
  let argsDict = Object.fromEntries(args);
154
- let expected = norm(QueryDbListQuery$ReventlessCore.run(s.fullScan(), argsDict, capability, "name", param => {}, ownerScope));
155
- let actual = s.listPage(argsDict, capability, "name", ownerScope);
162
+ let expected = norm(QueryDbListQuery$ReventlessCore.run(s.fullScan(), argsDict, capability, "name", param => {}, ownerScope, retiredScope));
163
+ let actual = s.listPage(argsDict, capability, "name", ownerScope, retiredScope);
156
164
  if (actual !== undefined) {
157
165
  globalThis.expect(norm(actual)).toEqual(expected);
158
166
  } else {
@@ -162,7 +170,20 @@ async function checkPushed(label, ownerScope, args) {
162
170
 
163
171
  async function checkFallback(args) {
164
172
  let s = await build();
165
- globalThis.expect(Stdlib_Option.isSome(s.listPage(Object.fromEntries(args), capability, "name", undefined))).toBe(false);
173
+ globalThis.expect(Stdlib_Option.isSome(s.listPage(Object.fromEntries(args), capability, "name", undefined, undefined))).toBe(false);
174
+ }
175
+
176
+ async function liveIds(args) {
177
+ let s = await build();
178
+ let conn = s.listPage(Object.fromEntries(args), capability, "name", undefined, {
179
+ field: "archived",
180
+ values: undefined
181
+ });
182
+ if (conn !== undefined) {
183
+ return norm(conn).edges.map(e => e.id);
184
+ } else {
185
+ return [];
186
+ }
166
187
  }
167
188
 
168
189
  async function scopedIds(owner, args) {
@@ -170,7 +191,7 @@ async function scopedIds(owner, args) {
170
191
  let conn = s.listPage(Object.fromEntries(args), capability, "name", [
171
192
  "owner",
172
193
  owner
173
- ]);
194
+ ], undefined);
174
195
  if (conn !== undefined) {
175
196
  return norm(conn).edges.map(e => e.id);
176
197
  } else {
@@ -179,12 +200,12 @@ async function scopedIds(owner, args) {
179
200
  }
180
201
 
181
202
  globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery spec)", () => {
182
- globalThis.test("bare page", () => checkPushed("bare", undefined, []));
183
- globalThis.test("first:2", () => checkPushed("first", undefined, [[
203
+ globalThis.test("bare page", () => checkPushed("bare", undefined, undefined, []));
204
+ globalThis.test("first:2", () => checkPushed("first", undefined, undefined, [[
184
205
  "first",
185
206
  2
186
207
  ]]));
187
- globalThis.test("first:2 + after", () => checkPushed("after", undefined, [
208
+ globalThis.test("first:2 + after", () => checkPushed("after", undefined, undefined, [
188
209
  [
189
210
  "first",
190
211
  2
@@ -194,14 +215,14 @@ globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery
194
215
  QueryDbListQuery$ReventlessCore.encodeCursor("p-2")
195
216
  ]
196
217
  ]));
197
- globalThis.test("statusEq active", () => checkPushed("eq", undefined, [[
218
+ globalThis.test("statusEq active", () => checkPushed("eq", undefined, undefined, [[
198
219
  "filter",
199
220
  Object.fromEntries([[
200
221
  "statusEq",
201
222
  "active"
202
223
  ]])
203
224
  ]]));
204
- globalThis.test("statusEq active + first:2", () => checkPushed("eq+first", undefined, [
225
+ globalThis.test("statusEq active + first:2", () => checkPushed("eq+first", undefined, undefined, [
205
226
  [
206
227
  "filter",
207
228
  Object.fromEntries([[
@@ -214,15 +235,15 @@ globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery
214
235
  2
215
236
  ]
216
237
  ]));
217
- globalThis.test("orderBy name ASC", () => checkPushed("order-asc", undefined, [[
238
+ globalThis.test("orderBy name ASC", () => checkPushed("order-asc", undefined, undefined, [[
218
239
  "orderBy",
219
240
  orderBy("name", "ASC")
220
241
  ]]));
221
- globalThis.test("orderBy name DESC", () => checkPushed("order-desc", undefined, [[
242
+ globalThis.test("orderBy name DESC", () => checkPushed("order-desc", undefined, undefined, [[
222
243
  "orderBy",
223
244
  orderBy("name", "DESC")
224
245
  ]]));
225
- globalThis.test("orderBy name DESC + first:2 + after", () => checkPushed("order-desc-after", undefined, [
246
+ globalThis.test("orderBy name DESC + first:2 + after", () => checkPushed("order-desc-after", undefined, undefined, [
226
247
  [
227
248
  "orderBy",
228
249
  orderBy("name", "DESC")
@@ -236,11 +257,11 @@ globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery
236
257
  QueryDbListQuery$ReventlessCore.encodeCursor("Delta")
237
258
  ]
238
259
  ]));
239
- globalThis.test("orderBy status ASC (tiebreak by id)", () => checkPushed("order-tiebreak", undefined, [[
260
+ globalThis.test("orderBy status ASC (tiebreak by id)", () => checkPushed("order-tiebreak", undefined, undefined, [[
240
261
  "orderBy",
241
262
  orderBy("status", "ASC")
242
263
  ]]));
243
- globalThis.test("qty range From/To (numeric-as-string)", () => checkPushed("range", undefined, [[
264
+ globalThis.test("qty range From/To (numeric-as-string)", () => checkPushed("range", undefined, undefined, [[
244
265
  "filter",
245
266
  Object.fromEntries([
246
267
  [
@@ -253,7 +274,7 @@ globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery
253
274
  ]
254
275
  ])
255
276
  ]]));
256
- globalThis.test("orderBy qty ASC (numeric-as-string sort)", () => checkPushed("order-qty", undefined, [[
277
+ globalThis.test("orderBy qty ASC (numeric-as-string sort)", () => checkPushed("order-qty", undefined, undefined, [[
257
278
  "orderBy",
258
279
  orderBy("qty", "ASC")
259
280
  ]]));
@@ -288,22 +309,70 @@ globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery
288
309
  QueryDbListQuery$ReventlessCore.encodeCursor("p-4")
289
310
  ]
290
311
  ]));
312
+ globalThis.describe("retirement narrowing", () => {
313
+ globalThis.test("bare page, narrowed", () => checkPushed("retired-bare", undefined, {
314
+ field: "archived",
315
+ values: undefined
316
+ }, []));
317
+ globalThis.test("narrowed + orderBy", () => checkPushed("retired-order", undefined, {
318
+ field: "archived",
319
+ values: undefined
320
+ }, [[
321
+ "orderBy",
322
+ orderBy("name", "DESC")
323
+ ]]));
324
+ globalThis.test("narrowed + the caller's own filter", () => checkPushed("retired-filter", undefined, {
325
+ field: "archived",
326
+ values: undefined
327
+ }, [[
328
+ "filter",
329
+ Object.fromEntries([[
330
+ "statusEq",
331
+ "active"
332
+ ]])
333
+ ]]));
334
+ globalThis.test("narrowed + owner-scoped together", () => checkPushed("retired-owner", [
335
+ "owner",
336
+ "u-a"
337
+ ], {
338
+ field: "archived",
339
+ values: undefined
340
+ }, []));
341
+ globalThis.test("drops the archived rows and keeps the one with no flag", async () => {
342
+ let ids = await liveIds([]);
343
+ globalThis.expect(ids).toEqual([
344
+ "p-1",
345
+ "p-3",
346
+ "p-5"
347
+ ]);
348
+ });
349
+ globalThis.test("a page of first:2 is two live rows, not two rows minus the archived", async () => {
350
+ let ids = await liveIds([[
351
+ "first",
352
+ 2
353
+ ]]);
354
+ globalThis.expect(ids).toEqual([
355
+ "p-1",
356
+ "p-3"
357
+ ]);
358
+ });
359
+ });
291
360
  globalThis.describe("owner scoping", () => {
292
361
  globalThis.test("bare page, scoped", () => checkPushed("owner-bare", [
293
362
  "owner",
294
363
  "u-a"
295
- ], []));
364
+ ], undefined, []));
296
365
  globalThis.test("scoped + orderBy", () => checkPushed("owner-order", [
297
366
  "owner",
298
367
  "u-a"
299
- ], [[
368
+ ], undefined, [[
300
369
  "orderBy",
301
370
  orderBy("name", "DESC")
302
371
  ]]));
303
372
  globalThis.test("scoped + client filter compose", () => checkPushed("owner-and-filter", [
304
373
  "owner",
305
374
  "u-a"
306
- ], [[
375
+ ], undefined, [[
307
376
  "filter",
308
377
  Object.fromEntries([[
309
378
  "statusEq",
@@ -361,6 +430,7 @@ export {
361
430
  cur,
362
431
  checkPushed,
363
432
  checkFallback,
433
+ liveIds,
364
434
  scopedIds,
365
435
  }
366
436
  /* Not a pure module */
@@ -37,10 +37,11 @@ let rowStateSchemaWithAnnotations =
37
37
  scanSort: ["name"],
38
38
  semantic: [],
39
39
  metric: [],
40
- status: None,
40
+ lifecycle: None,
41
41
  groupBy: None,
42
42
  visibility: None,
43
43
  live: None,
44
+ retired: None,
44
45
  },
45
46
  )
46
47
 
@@ -45,10 +45,11 @@ let rowStateSchemaWithAnnotations = S.Metadata.set(rowStateSchema, StateAnnotati
45
45
  scanSort: ["name"],
46
46
  semantic: [],
47
47
  metric: [],
48
- status: undefined,
48
+ lifecycle: undefined,
49
49
  groupBy: undefined,
50
50
  visibility: undefined,
51
- live: undefined
51
+ live: undefined,
52
+ retired: undefined
52
53
  });
53
54
 
54
55
  let newrecord = {...Identity$Reventless.anonymous};