@reventlessdev/reventless-local 3.0.0-alpha.223 → 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,27 @@
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
+
20
+ # 3.0.0-alpha.224 (2026-08-15)
21
+
22
+ ### Features
23
+
24
+ * **aws:** serve one baked manifest per journey ([16d6307](https://github.com/ReventlessDev/reventless-core/commit/16d63071f059cf1fc7025f9be9206dda51f71053))
25
+
26
+
6
27
  # 3.0.0-alpha.223 (2026-08-15)
7
28
 
8
29
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.223",
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": {
@@ -35,23 +35,23 @@
35
35
  "sury": "11.0.0-alpha.4",
36
36
  "ws": "^8.18.0",
37
37
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
38
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
39
- "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
40
38
  "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
41
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
39
+ "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
40
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
42
41
  "@reventlessdev/rescript-node": "2.0.0-alpha.7",
43
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.83",
44
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.182",
45
- "@reventlessdev/reventless-infra": "3.0.0-alpha.142",
46
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.99",
47
- "@reventlessdev/reventless-core": "3.0.0-alpha.235",
48
- "@reventlessdev/reventless-seed": "1.0.0-alpha.14",
49
- "@reventlessdev/reventless-spec": "3.0.0-alpha.114"
42
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
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"
@@ -9,50 +9,24 @@
9
9
 
10
10
  let log = ReventlessCore.Logger.fromEnv()
11
11
 
12
- /**
13
- A declared bake writes the file or fails loudly. Both failure modes it can hit
14
- are the deployment's own mistake — a name that matches no component, or a shell
15
- package that is not installed — and both produce the same symptom if swallowed:
16
- a shop that renders nothing, with no line in the log saying why.
17
- */
18
- let toSelections = (
19
- components: array<ReventlessInfra.Platform.bakedManifestSelection>,
20
- ): array<ReventlessCore.Platform_BakedManifest.selection> =>
21
- components->Array.map((s): ReventlessCore.Platform_BakedManifest.selection => {
22
- plugin: s.plugin,
23
- views: s.views,
24
- commands: s.commands,
25
- derived: s.derived,
26
- })
27
-
28
12
  /**
29
13
  Every file this declaration produces, as (key, selections) pairs.
30
14
 
31
- The default journey first, then one per declared journey. Separate from the
32
- write so the whole set is reachable from a test the same reason
33
- `ShellConfig.overlay` exists apart from its write.
15
+ The resolution itself is `Platform_BakedManifest.files`, shared with the deploy
16
+ for the reason the curation is: the key a file is written under and the URL a
17
+ shell fetches it from have to be the same string on either platform.
34
18
  */
35
19
  let files = (
36
20
  ~config: ReventlessInfra.Platform.bakedManifest,
37
- ): array<(string, array<ReventlessCore.Platform_BakedManifest.selection>)> => {
38
- let base = [
39
- (
40
- config.key->Option.getOr(ReventlessCore.Platform_BakedManifest.defaultKey),
41
- toSelections(config.components),
42
- ),
43
- ]
44
- switch config.journeys {
45
- | None => base
46
- | Some(journeys) =>
47
- base->Array.concat(
48
- journeys->Array.map(j => (
49
- j.key->Option.getOr(ReventlessCore.Platform_BakedManifest.journeyKey(~group=j.group)),
50
- toSelections(j.components),
51
- )),
52
- )
53
- }
54
- }
21
+ ): array<(string, array<ReventlessCore.Platform_BakedManifest.selection>)> =>
22
+ ReventlessCore.Platform_BakedManifest.files(~config)
55
23
 
24
+ /**
25
+ A declared bake writes the files or fails loudly. Both failure modes it can hit
26
+ are the deployment's own mistake — a name that matches no component, or a shell
27
+ package that is not installed — and both produce the same symptom if swallowed:
28
+ a shop that renders nothing, with no line in the log saying why.
29
+ */
56
30
  let emit = (
57
31
  ~structures: array<(string, Reventless.Plugin.pluginStructure)>,
58
32
  ~config: ReventlessInfra.Platform.bakedManifest,
@@ -2,7 +2,6 @@
2
2
 
3
3
  import * as Nodefs from "node:fs";
4
4
  import * as Nodepath from "node:path";
5
- import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
5
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
7
6
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
8
7
  import * as HostShellDist$ReventlessLocal from "./HostShellDist.res.mjs";
@@ -10,33 +9,10 @@ import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventles
10
9
 
11
10
  let log = Logger$ReventlessCore.fromEnv();
12
11
 
13
- function toSelections(components) {
14
- return components.map(s => ({
15
- plugin: s.plugin,
16
- views: s.views,
17
- commands: s.commands,
18
- derived: s.derived
19
- }));
20
- }
21
-
22
- function files(config) {
23
- let base = [[
24
- Stdlib_Option.getOr(config.key, Platform_BakedManifest$ReventlessCore.defaultKey),
25
- toSelections(config.components)
26
- ]];
27
- let journeys = config.journeys;
28
- if (journeys !== undefined) {
29
- return base.concat(journeys.map(j => [
30
- Stdlib_Option.getOr(j.key, Platform_BakedManifest$ReventlessCore.journeyKey(j.group)),
31
- toSelections(j.components)
32
- ]));
33
- } else {
34
- return base;
35
- }
36
- }
12
+ let files = Platform_BakedManifest$ReventlessCore.files;
37
13
 
38
14
  function emit(structures, config) {
39
- let outputs = files(config);
15
+ let outputs = Platform_BakedManifest$ReventlessCore.files(config);
40
16
  let curated = outputs.map(param => {
41
17
  let e = Platform_BakedManifest$ReventlessCore.curate(structures, param[1]);
42
18
  let tmp;
@@ -62,7 +38,6 @@ function emit(structures, config) {
62
38
 
63
39
  export {
64
40
  log,
65
- toSelections,
66
41
  files,
67
42
  emit,
68
43
  }
@@ -61,26 +61,16 @@ let overlay = (
61
61
  // Omitted entirely when nothing is declared, so a single-audience deployment
62
62
  // writes the same key set it always did and a shell that has never heard of
63
63
  // journeys sees no new keys.
64
- switch config.journeys {
65
- | None => ()
66
- | Some([]) => ()
67
- | Some(journeys) =>
68
- let map = Dict.make()
69
- journeys->Array.forEach(j =>
70
- map->Dict.set(
71
- j.group,
72
- JSON.Encode.string(
73
- ReventlessCore.Platform_BakedManifest.urlForKey(
74
- Some(
75
- j.key->Option.getOr(
76
- ReventlessCore.Platform_BakedManifest.journeyKey(~group=j.group),
77
- ),
78
- ),
79
- ),
80
- ),
81
- )
64
+ switch ReventlessCore.Platform_BakedManifest.journeyUrls(~config) {
65
+ | [] => ()
66
+ | urls =>
67
+ out->Dict.set(
68
+ journeyManifestsKey,
69
+ urls
70
+ ->Array.map(((group, url)) => (group, JSON.Encode.string(url)))
71
+ ->Dict.fromArray
72
+ ->JSON.Encode.object,
82
73
  )
83
- out->Dict.set(journeyManifestsKey, map->JSON.Encode.object)
84
74
  }
85
75
  })
86
76
 
@@ -31,18 +31,14 @@ function overlay(bakedManifest, shellConfig) {
31
31
  let out = {};
32
32
  Stdlib_Option.forEach(bakedManifest, config => {
33
33
  out["manifestUrl"] = Platform_BakedManifest$ReventlessCore.urlForKey(config.key);
34
- let journeys = config.journeys;
35
- if (journeys === undefined) {
34
+ let urls = Platform_BakedManifest$ReventlessCore.journeyUrls(config);
35
+ if (urls.length !== 0) {
36
+ out[journeyManifestsKey] = Object.fromEntries(urls.map(param => [
37
+ param[0],
38
+ param[1]
39
+ ]));
36
40
  return;
37
41
  }
38
- if (journeys.length === 0) {
39
- return;
40
- }
41
- let map = {};
42
- journeys.forEach(j => {
43
- map[j.group] = Platform_BakedManifest$ReventlessCore.urlForKey(Stdlib_Option.getOr(j.key, Platform_BakedManifest$ReventlessCore.journeyKey(j.group)));
44
- });
45
- out[journeyManifestsKey] = map;
46
42
  });
47
43
  Stdlib_Option.forEach(shellConfig, extra => {
48
44
  let collisions = Object.keys(extra).filter(k => computedKeys.includes(k));
@@ -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
  }