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

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 (27) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +11 -11
  3. package/src/Platform.res +1 -1
  4. package/src/adapter/EventLog/EventLogStorage_Sqlite.res +1 -1
  5. package/src/adapter/LocalBus.res +3 -0
  6. package/src/adapter/LocalStateChangeDescriptor.res +46 -2
  7. package/src/adapter/LocalStateChangeDescriptor.res.mjs +16 -4
  8. package/src/adapter/QueryDb/LocalQueryDbStorage.res +1 -1
  9. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +67 -6
  10. package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +45 -8
  11. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res +2 -0
  12. package/src/adapter/QueryDb/QueryDbStorage_InMemory.res.mjs +2 -2
  13. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +37 -0
  14. package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +14 -3
  15. package/src/reset/LocalSeedReset.res +23 -6
  16. package/src/reset/LocalSeedReset.res.mjs +2 -19
  17. package/tests/adapter/DcbEventLogStorageSqliteTest.res +1 -1
  18. package/tests/adapter/DcbEventLogStorageTest.res +1 -1
  19. package/tests/adapter/EventLogSnapshotParityTest.res +1 -1
  20. package/tests/adapter/GraphQL_SchemaInspectorTest.res +7 -4
  21. package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +10 -7
  22. package/tests/adapter/GraphQL_SubscriptionResolversTest.res.mjs +2 -2
  23. package/tests/adapter/QueryDbListPushdownParityTest.res +82 -10
  24. package/tests/adapter/QueryDbListPushdownParityTest.res.mjs +96 -26
  25. package/tests/adapter/QueryDbListResolverTest.res +2 -1
  26. package/tests/adapter/QueryDbListResolverTest.res.mjs +3 -2
  27. package/tests/components/readmodel/DcbReadModelE2EFixtures.res +1 -1
@@ -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.`);
@@ -94,7 +94,7 @@ describe("DcbEventLogStorage_Sqlite", () => {
94
94
  // Backend contract: an empty tag value (an absent composite-partition member) is
95
95
  // legitimate — it appends, is recorded verbatim, and keeps matching through both
96
96
  // the partition read and a composite read. Held identically by the in-memory and
97
- // DynamoDB backends. See docs/plans/dcb-empty-tag-values-break-append.md.
97
+ // DynamoDB backends. See docs/plans/done/dcb-empty-tag-values-break-append.md.
98
98
  testPromise("an empty tag value appends and stays readable", async () => {
99
99
  await runUnderSqlite(async () => {
100
100
  module TestBus = LocalBus.Make()
@@ -145,7 +145,7 @@ describe("DcbEventLogStorage_InMemory", () => {
145
145
  // An empty tag value is a legitimate model state — an absent member of a
146
146
  // composite partition key. Every backend must accept it, record it verbatim,
147
147
  // and keep matching the event through its partition and composite reads. See
148
- // docs/plans/dcb-empty-tag-values-break-append.md.
148
+ // docs/plans/done/dcb-empty-tag-values-break-append.md.
149
149
  testPromise("an empty tag value appends and stays readable", async () => {
150
150
  let storage = makeStorage()
151
151
  let ops = await storage.operations->TestRunner.resolve
@@ -1,5 +1,5 @@
1
1
  // Backend-parity tests for the EventLog snapshot ops
2
- // (docs/plans/aggregate-snapshotting.md): the in-memory and SQLite backends
2
+ // (docs/plans/done/aggregate-snapshotting.md): the in-memory and SQLite backends
3
3
  // must behave identically for latestSnapshot / writeSnapshot (keep-one) and
4
4
  // the replayStream ~fromSeq delta read, so GWT/local tests of
5
5
  // snapshot-enabled aggregates give the same results under either backend.
@@ -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