@reventlessdev/reventless-local 3.0.0-alpha.212 → 3.0.0-alpha.213

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,14 @@
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.213 (2026-08-12)
7
+
8
+ ### Features
9
+
10
+ * **platform:** bake a curated component manifest as a static asset ([4f30265](https://github.com/ReventlessDev/reventless-core/commit/4f30265a51fc2c59e69afd8074cf1b2534c06378))
11
+ * **queries:** narrow owner-bearing reads to the caller ([ba9cc3d](https://github.com/ReventlessDev/reventless-core/commit/ba9cc3d58d7914a9e4827bda90a704a74b1b82dd))
12
+
13
+
6
14
  # 3.0.0-alpha.212 (2026-08-11)
7
15
 
8
16
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-local",
3
- "version": "3.0.0-alpha.212",
3
+ "version": "3.0.0-alpha.213",
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": {
@@ -34,19 +34,19 @@
34
34
  "graphql-yoga": "^5.21.0",
35
35
  "sury": "11.0.0-alpha.4",
36
36
  "ws": "^8.18.0",
37
- "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
38
- "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
37
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
39
38
  "@reventlessdev/rescript-mcp-sdk": "1.0.0-alpha.21",
40
39
  "@reventlessdev/rescript-node": "2.0.0-alpha.4",
41
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
40
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
41
+ "@reventlessdev/rescript-graphql-yoga": "1.0.0-alpha.27",
42
42
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
43
- "@reventlessdev/reventless-core": "3.0.0-alpha.224",
44
- "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.72",
45
- "@reventlessdev/reventless-gwt": "1.0.0-alpha.171",
46
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.88",
47
- "@reventlessdev/reventless-infra": "3.0.0-alpha.134",
43
+ "@reventlessdev/reventless-core": "3.0.0-alpha.225",
44
+ "@reventlessdev/reventless-graphql-server": "1.0.0-alpha.73",
45
+ "@reventlessdev/reventless-gwt": "1.0.0-alpha.172",
46
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.135",
47
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.89",
48
48
  "@reventlessdev/reventless-seed": "1.0.0-alpha.11",
49
- "@reventlessdev/reventless-spec": "3.0.0-alpha.108"
49
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.109"
50
50
  },
51
51
  "devDependencies": {
52
52
  "rescript": "12.3.0",
@@ -0,0 +1,72 @@
1
+ // Writes the curated component manifest where the local host shell serves its
2
+ // static assets from, so `manifestUrl` discovery works in dev exactly as it does
3
+ // in a deployment.
4
+ //
5
+ // Locally there is no deploy and no bucket: `reventless-host-shell` serves its
6
+ // own `dist/` (that is where the shipped `config.json` and `ui-hints.json` come
7
+ // from), so that is where the file goes. Rewritten on every boot, which also
8
+ // makes it self-healing — a `pnpm install` that replaces the package directory
9
+ // costs one restart, not a debugging session.
10
+ //
11
+ // The curation itself lives in `ReventlessCore.Platform_BakedManifest`, shared
12
+ // with any other platform that bakes: only the destination is local.
13
+
14
+ let hostShellPackage = "@reventlessdev/reventless-host-shell"
15
+
16
+ let log = ReventlessCore.Logger.fromEnv()
17
+
18
+ // Resolved from the running project rather than from this framework module: the
19
+ // bundle is a deploy input the project names in its own package.json, and a
20
+ // framework-rooted lookup would skip that pin (the same distinction
21
+ // `Util_Bundle.resolvePackageRoot(~fromPulumiProject)` draws on AWS).
22
+ let hostShellDistDir = (): option<string> =>
23
+ try {
24
+ Some(
25
+ NodePath.dirname(
26
+ NodeModule.createRequire(NodeProcess.cwd() ++ "/index.js")->NodeModule.requireResolve(
27
+ hostShellPackage ++ "/package.json",
28
+ ),
29
+ ) ++ "/dist",
30
+ )
31
+ } catch {
32
+ | _ => None
33
+ }
34
+
35
+ let defaultKey = "component-manifest.json"
36
+
37
+ /**
38
+ A declared bake writes the file or fails loudly. Both failure modes it can hit
39
+ are the deployment's own mistake — a name that matches no component, or a shell
40
+ package that is not installed — and both produce the same symptom if swallowed:
41
+ a shop that renders nothing, with no line in the log saying why.
42
+ */
43
+ let emit = (
44
+ ~structures: array<(string, Reventless.Plugin.pluginStructure)>,
45
+ ~config: ReventlessInfra.Platform.bakedManifest,
46
+ ) => {
47
+ let selections =
48
+ config.components->Array.map((
49
+ s
50
+ ): ReventlessCore.Platform_BakedManifest.selection => {
51
+ plugin: s.plugin,
52
+ views: s.views,
53
+ commands: s.commands,
54
+ })
55
+ switch ReventlessCore.Platform_BakedManifest.curate(~structures, ~selections) {
56
+ | Error(e) => JsError.throwWithMessage(ReventlessCore.Platform_BakedManifest.describe(e))
57
+ | Ok(manifest) =>
58
+ switch hostShellDistDir() {
59
+ | None =>
60
+ JsError.throwWithMessage(
61
+ `baked manifest: cannot resolve ${hostShellPackage} from ${NodeProcess.cwd()} — ` ++
62
+ `the local shell serves the file from that package's dist/, so declaring a bake ` ++
63
+ `without the package installed would write nothing and render an empty shell.`,
64
+ )
65
+ | Some(dir) =>
66
+ let key = config.key->Option.getOr(defaultKey)
67
+ let path = NodePath.join([dir, key])
68
+ NodeFs.writeFileSync(path, JSON.stringify(manifest, ~space=2))
69
+ log.info(~comp="BakedManifest", `wrote ${key} for ${selections->Array.length->Int.toString} plugin(s): ${path}`)
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,52 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Nodefs from "node:fs";
4
+ import * as Nodepath from "node:path";
5
+ import * as Nodemodule from "node:module";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
8
+ import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
9
+ import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
10
+
11
+ let hostShellPackage = "@reventlessdev/reventless-host-shell";
12
+
13
+ let log = Logger$ReventlessCore.fromEnv();
14
+
15
+ function hostShellDistDir() {
16
+ try {
17
+ return Nodepath.dirname(Nodemodule.createRequire(process.cwd() + "/index.js").resolve(hostShellPackage + "/package.json")) + "/dist";
18
+ } catch (exn) {
19
+ return;
20
+ }
21
+ }
22
+
23
+ let defaultKey = "component-manifest.json";
24
+
25
+ function emit(structures, config) {
26
+ let selections = config.components.map(s => ({
27
+ plugin: s.plugin,
28
+ views: s.views,
29
+ commands: s.commands
30
+ }));
31
+ let e = Platform_BakedManifest$ReventlessCore.curate(structures, selections);
32
+ if (e.TAG !== "Ok") {
33
+ return Stdlib_JsError.throwWithMessage(Platform_BakedManifest$ReventlessCore.describe(e._0));
34
+ }
35
+ let dir = hostShellDistDir();
36
+ if (dir === undefined) {
37
+ return Stdlib_JsError.throwWithMessage(`baked manifest: cannot resolve ` + hostShellPackage + ` from ` + process.cwd() + ` — the local shell serves the file from that package's dist/, so declaring a bake without the package installed would write nothing and render an empty shell.`);
38
+ }
39
+ let key = Stdlib_Option.getOr(config.key, defaultKey);
40
+ let path = Nodepath.join(dir, key);
41
+ Nodefs.writeFileSync(path, JSON.stringify(e._0, undefined, 2), "utf8");
42
+ log.info("BakedManifest", undefined, `wrote ` + key + ` for ` + selections.length.toString() + ` plugin(s): ` + path);
43
+ }
44
+
45
+ export {
46
+ hostShellPackage,
47
+ log,
48
+ hostShellDistDir,
49
+ defaultKey,
50
+ emit,
51
+ }
52
+ /* log Not a pure module */
package/src/Platform.res CHANGED
@@ -874,6 +874,35 @@ module MakeWithConfig = (
874
874
  // In-memory store for plugin structures, keyed by plugin ID.
875
875
  let pluginStructuresStore: ref<dict<Reventless.Plugin.pluginStructure>> = ref(Dict.make())
876
876
 
877
+ // Bake the curated manifest from the plugin components directly rather than
878
+ // from `pluginStructuresStore`: a structure only exists once its Output
879
+ // resolves, so a synchronous read of that store at the end of `makePlatform`
880
+ // sees an empty dict. Emitting once the last plugin's structure has landed is
881
+ // both the earliest correct moment and the only one that cannot bake a
882
+ // half-registered platform.
883
+ let bakeManifest = (
884
+ ~pluginComponents: array<ReventlessCore.Plugin.component>,
885
+ ~config: ReventlessInfra.Platform.bakedManifest,
886
+ ) => {
887
+ let resolved: array<(string, Reventless.Plugin.pluginStructure)> = []
888
+ let expected = pluginComponents->Array.length
889
+ pluginComponents->Array.forEach(plugin => {
890
+ let outputs: ReventlessInfra.Plugin.outputs = plugin->ReventlessCore.Component.outputs
891
+ let _ =
892
+ (outputs.id, outputs.pluginStructure)
893
+ ->Pulumi.Output.all2
894
+ ->Pulumi.Output.apply(((id, ps)) => {
895
+ switch ps {
896
+ | Some(def) => resolved->Array.push((id, def))
897
+ | None => ()
898
+ }
899
+ if resolved->Array.length === expected {
900
+ BakedManifest.emit(~structures=resolved, ~config)
901
+ }
902
+ })
903
+ })
904
+ }
905
+
877
906
  // The object stores a plugin's fields declared, as `{plugin}.{store}` keys.
878
907
  let declaredStoresOf = (structure: Reventless.Plugin.pluginStructure) =>
879
908
  structure.requiredStores->Option.getOr([])
@@ -1399,7 +1428,35 @@ module MakeWithConfig = (
1399
1428
  }
1400
1429
  })
1401
1430
 
1402
- let makePlatform = (~version, ~plugins: array<module(PluginMaker)>) => {
1431
+ // In-memory ignores most of `~hostUiBundle` the host shell is served by
1432
+ // `vite dev` against the running in-process GraphQL server, not from a CDN,
1433
+ // including `uiHintsFile` (the AWS deploy writes it as a BucketObject; local
1434
+ // dev serves `public/ui-hints.json` directly). `bakedManifest` is the
1435
+ // exception: it is generated content, so there is no hand-authored file for
1436
+ // local dev to serve instead.
1437
+ type hostUiBundleConfig = {
1438
+ assetsDir?: string,
1439
+ bundleVersion?: string,
1440
+ uiHintsFile?: string,
1441
+ // Honoured here, unlike the rest: `makePlatform` writes the curated manifest
1442
+ // where the local host-shell serves its static assets from.
1443
+ bakedManifest?: ReventlessInfra.Platform.bakedManifest,
1444
+ // AWS host-ui deploy knobs — carried to satisfy the shared Platform.T
1445
+ // signature; the in-memory platform provisions no infrastructure and
1446
+ // ignores them.
1447
+ geocoderPlaceIndex?: ReventlessInfra.Platform.geocoderIndex,
1448
+ uploadBucket?: ReventlessInfra.Platform.objectStore,
1449
+ // Same terms: the AWS deploy writes these into the config.json it hosts, and
1450
+ // local dev has no config.json of its own to write — the host-shell package
1451
+ // serves `public/config.json`, which is where local turns a view mode on.
1452
+ viewModes?: array<ReventlessInfra.Platform.viewMode>,
1453
+ shellConfig?: dict<JSON.t>,
1454
+ }
1455
+ let makePlatform = (
1456
+ ~version,
1457
+ ~plugins: array<module(PluginMaker)>,
1458
+ ~hostUiBundle: option<hostUiBundleConfig>=?,
1459
+ ) => {
1403
1460
  log.info(~comp="Platform", `v${version}`)
1404
1461
  log.info(
1405
1462
  ~comp="Platform",
@@ -1549,6 +1606,14 @@ module MakeWithConfig = (
1549
1606
  connectPlugin(~pluginComponents=plugins)
1550
1607
  seedPluginStructuresStore(~pluginComponents=plugins)
1551
1608
  }
1609
+ // Not inside `seedAdminStores`: the bake describes what this deployment
1610
+ // offers, which is settled at composition and owes nothing to projection
1611
+ // catch-up. Every plugin passed to this call is connected by construction
1612
+ // locally, so there is no status or version dedup to apply.
1613
+ switch hostUiBundle->Option.flatMap(cfg => cfg.bakedManifest) {
1614
+ | None => ()
1615
+ | Some(cfg) => bakeManifest(~pluginComponents=plugins, ~config=cfg)
1616
+ }
1552
1617
  switch (projectionCatchup, pgProjectionCatchup) {
1553
1618
  | (Some((db, upperBound, dcbUpperBound)), _) =>
1554
1619
  let _ =
@@ -2031,25 +2096,6 @@ module MakeWithConfig = (
2031
2096
 
2032
2097
  }
2033
2098
 
2034
- // In-memory ignores `~hostUiBundle` — the host shell is served by `vite dev`
2035
- // against the running in-process GraphQL server, not from a CDN — including
2036
- // `uiHintsFile` (the AWS deploy writes it as a BucketObject; local dev serves
2037
- // `public/ui-hints.json` directly).
2038
- type hostUiBundleConfig = {
2039
- assetsDir?: string,
2040
- bundleVersion?: string,
2041
- uiHintsFile?: string,
2042
- // AWS host-ui deploy knobs — carried to satisfy the shared Platform.T
2043
- // signature; the in-memory platform provisions no infrastructure and
2044
- // ignores them.
2045
- geocoderPlaceIndex?: ReventlessInfra.Platform.geocoderIndex,
2046
- uploadBucket?: ReventlessInfra.Platform.objectStore,
2047
- // Same terms: the AWS deploy writes these into the config.json it hosts, and
2048
- // local dev has no config.json of its own to write — the host-shell package
2049
- // serves `public/config.json`, which is where local turns a view mode on.
2050
- viewModes?: array<ReventlessInfra.Platform.viewMode>,
2051
- shellConfig?: dict<JSON.t>,
2052
- }
2053
2099
  let deployPlatform = (
2054
2100
  ~version,
2055
2101
  ~hostUiBundle as _: option<hostUiBundleConfig>=?,
@@ -31,6 +31,7 @@ import * as BackendState$ReventlessLocal from "./adapter/BackendState.res.mjs";
31
31
  import * as ComponentType$ReventlessCore from "@reventlessdev/reventless-core/src/ComponentType.res.mjs";
32
32
  import * as SqliteDriver$ReventlessLocal from "./adapter/SqliteDriver.res.mjs";
33
33
  import * as Task_Builder$ReventlessLocal from "./components/Task_Builder.res.mjs";
34
+ import * as BakedManifest$ReventlessLocal from "./BakedManifest.res.mjs";
34
35
  import * as Platform_Admin$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_Admin.res.mjs";
35
36
  import * as PluginBehavior$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/lifecycle/PluginBehavior.res.mjs";
36
37
  import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
@@ -832,6 +833,28 @@ function MakeWithConfig(Config) {
832
833
  let pluginStructuresStore = {
833
834
  contents: {}
834
835
  };
836
+ let bakeManifest = (pluginComponents, config) => {
837
+ let resolved = [];
838
+ let expected = pluginComponents.length;
839
+ pluginComponents.forEach(plugin => {
840
+ let outputs = Component$ReventlessCore.outputs(plugin);
841
+ Pulumi.all([
842
+ outputs.id,
843
+ outputs.pluginStructure
844
+ ]).apply(param => {
845
+ let ps = param[1];
846
+ if (ps !== undefined) {
847
+ resolved.push([
848
+ param[0],
849
+ ps
850
+ ]);
851
+ }
852
+ if (resolved.length === expected) {
853
+ return BakedManifest$ReventlessLocal.emit(resolved, config);
854
+ }
855
+ });
856
+ });
857
+ };
835
858
  let seedPluginStructuresStore = pluginComponents => {
836
859
  pluginComponents.forEach(plugin => {
837
860
  let outputs = Component$ReventlessCore.outputs(plugin);
@@ -1183,7 +1206,7 @@ function MakeWithConfig(Config) {
1183
1206
  }
1184
1207
  });
1185
1208
  };
1186
- let makePlatform = (version, plugins) => {
1209
+ let makePlatform = (version, plugins, hostUiBundle) => {
1187
1210
  log.info("Platform", undefined, `v` + version);
1188
1211
  log.info("Platform", undefined, `silent: ` + Stdlib_Bool.toString(Config.silent) + `, splitApi: ` + Stdlib_Bool.toString(Config.splitApi) + `, cloner: ` + Stdlib_Bool.toString(Config.cloner));
1189
1212
  let match = Config.backend;
@@ -1262,6 +1285,10 @@ function MakeWithConfig(Config) {
1262
1285
  adminResources: []
1263
1286
  });
1264
1287
  subscribeToPluginEvents();
1288
+ let cfg = Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest);
1289
+ if (cfg !== undefined) {
1290
+ bakeManifest(plugins$1, cfg);
1291
+ }
1265
1292
  if (projectionCatchup !== undefined) {
1266
1293
  Stdlib_Promise.$$catch(ProjectionCheckpoint$ReventlessLocal.startupCatchup(projectionCatchup[0], projectionCatchup[1], projectionCatchup[2], () => Bus.projectionCatchupHandlers()), e => {
1267
1294
  console.error("[Platform] projection catch-up failed:", e);
@@ -2529,6 +2556,28 @@ function Make($star) {
2529
2556
  let pluginStructuresStore = {
2530
2557
  contents: {}
2531
2558
  };
2559
+ let bakeManifest = (pluginComponents, config) => {
2560
+ let resolved = [];
2561
+ let expected = pluginComponents.length;
2562
+ pluginComponents.forEach(plugin => {
2563
+ let outputs = Component$ReventlessCore.outputs(plugin);
2564
+ Pulumi.all([
2565
+ outputs.id,
2566
+ outputs.pluginStructure
2567
+ ]).apply(param => {
2568
+ let ps = param[1];
2569
+ if (ps !== undefined) {
2570
+ resolved.push([
2571
+ param[0],
2572
+ ps
2573
+ ]);
2574
+ }
2575
+ if (resolved.length === expected) {
2576
+ return BakedManifest$ReventlessLocal.emit(resolved, config);
2577
+ }
2578
+ });
2579
+ });
2580
+ };
2532
2581
  let seedPluginStructuresStore = pluginComponents => {
2533
2582
  pluginComponents.forEach(plugin => {
2534
2583
  let outputs = Component$ReventlessCore.outputs(plugin);
@@ -2876,7 +2925,7 @@ function Make($star) {
2876
2925
  }
2877
2926
  });
2878
2927
  };
2879
- let makePlatform = (version, plugins) => {
2928
+ let makePlatform = (version, plugins, hostUiBundle) => {
2880
2929
  log.info("Platform", undefined, `v` + version);
2881
2930
  log.info("Platform", undefined, `silent: ` + Stdlib_Bool.toString(false) + `, splitApi: ` + Stdlib_Bool.toString(true) + `, cloner: ` + Stdlib_Bool.toString(false));
2882
2931
  let tmp;
@@ -2954,6 +3003,10 @@ function Make($star) {
2954
3003
  adminResources: []
2955
3004
  });
2956
3005
  subscribeToPluginEvents();
3006
+ let cfg = Stdlib_Option.flatMap(hostUiBundle, cfg => cfg.bakedManifest);
3007
+ if (cfg !== undefined) {
3008
+ bakeManifest(plugins$1, cfg);
3009
+ }
2957
3010
  if (projectionCatchup !== undefined) {
2958
3011
  Stdlib_Promise.$$catch(ProjectionCheckpoint$ReventlessLocal.startupCatchup(projectionCatchup[0], projectionCatchup[1], projectionCatchup[2], () => Bus.projectionCatchupHandlers()), e => {
2959
3012
  console.error("[Platform] projection catch-up failed:", e);
@@ -132,6 +132,7 @@ module type T = {
132
132
  ~argsDict: dict<JSON.t>,
133
133
  ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
134
134
  ~labelField: string,
135
+ ~ownerScope: (string, string)=?,
135
136
  ) => option<JSON.t>,
136
137
  ) => unit
137
138
  let getQueryDbListPage: string => option<
@@ -139,6 +140,7 @@ module type T = {
139
140
  ~argsDict: dict<JSON.t>,
140
141
  ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
141
142
  ~labelField: string,
143
+ ~ownerScope: (string, string)=?,
142
144
  ) => option<JSON.t>,
143
145
  >
144
146
 
@@ -235,6 +237,7 @@ module Impl = (C: BusConfig): T => {
235
237
  ~argsDict: dict<JSON.t>,
236
238
  ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
237
239
  ~labelField: string,
240
+ ~ownerScope: (string, string)=?,
238
241
  ) => option<JSON.t>,
239
242
  >,
240
243
  > = ref(Dict.make())
@@ -137,6 +137,49 @@ module Make = (Bus: LocalBus.T) => {
137
137
  }
138
138
  }
139
139
 
140
+ // ── Owner scoping ────────────────────────────────────────────────────────
141
+ // What a caller may see of a view whose state declares an `@owner` field.
142
+ // Sits beside `runInterceptor` on purpose: these are the two questions every
143
+ // door has to ask, and a door that forgets one of them is a hole rather than
144
+ // a degradation. Answering "may they read this at all" and "which rows" in
145
+ // the same place is what makes the omission visible when reading a resolver.
146
+ // Read per request rather than captured at registration: a resolver is built
147
+ // before every plugin's state schema is necessarily registered, and a lookup
148
+ // that missed here would leave the view unscoped rather than erroring.
149
+ let ownerFieldOf = () =>
150
+ Plugin_Helpers.stateSchemaRegistry
151
+ ->Dict.get(name)
152
+ ->Option.flatMap(s => Reventless.Owner.fieldNames(s)->Array.get(0))
153
+
154
+ // An owner-scoped view with no elevated groups configured scopes EVERYONE,
155
+ // administrators included. That is the safe direction to be wrong in and it
156
+ // is still wrong, and it is invisible from outside — an operator's empty list
157
+ // looks exactly like an operator who owns nothing. Said once per view at
158
+ // registration, because the alternative is finding out from a support ticket.
159
+ OwnerScopeDiagnostics.warnIfNoElevatedGroups(
160
+ ~comp="QueryDbResolvers_GraphQL",
161
+ ~view=name,
162
+ ~ownerField=ownerFieldOf(),
163
+ )
164
+
165
+ let ownerDecision = (~ctx) =>
166
+ extractIdentity(ctx)->Reventless.OwnerScope.decide(~ownerField=ownerFieldOf())
167
+
168
+ // Post-read form, for the single-row and by-index doors where there is no
169
+ // page to narrow — the row is already in hand and either belongs to the
170
+ // caller or does not.
171
+ let ownerAllows = (~ctx, item: JSON.t) =>
172
+ switch ownerDecision(~ctx) {
173
+ | Unscoped => true
174
+ | RefuseOwned => false
175
+ | ScopeTo(field, required) =>
176
+ item
177
+ ->JSON.Decode.object
178
+ ->Option.flatMap(d => d->Dict.get(field))
179
+ ->Option.flatMap(JSON.Decode.string)
180
+ ->Option.mapOr(false, v => v == required)
181
+ }
182
+
140
183
  let cap = s => s->String.charAt(0)->String.toUpperCase ++ s->String.slice(~start=1)
141
184
 
142
185
  // Resolve query field names: check registry first, fall back to safe defaults.
@@ -214,6 +257,10 @@ module Make = (Bus: LocalBus.T) => {
214
257
  | _ => (id, firstAttempt)
215
258
  }
216
259
  switch items->Array.get(0) {
260
+ // A row the caller does not own answers as though it were not there.
261
+ // Distinguishing "not yours" from "not found" here would turn this door
262
+ // into an oracle for which ids exist.
263
+ | Some(item) if !ownerAllows(~ctx, item) => JSON.Encode.null
217
264
  | Some(item) =>
218
265
  if includeIdParam {
219
266
  // Copied — `JSON.Decode.object` hands back the stored object itself,
@@ -279,7 +326,9 @@ module Make = (Bus: LocalBus.T) => {
279
326
  )->Promise.all
280
327
  loaded
281
328
  ->Array.filterMap(((id, opt)) =>
282
- opt->Option.map(item => {
329
+ opt
330
+ ->Option.filter(item => ownerAllows(~ctx, item))
331
+ ->Option.map(item => {
283
332
  let obj = item->JSON.Decode.object->Option.mapOr(Dict.make(), Dict.copy)
284
333
  obj->Dict.set("id", JSON.Encode.string(id))
285
334
  JSON.Encode.object(obj)
@@ -345,39 +394,63 @@ module Make = (Bus: LocalBus.T) => {
345
394
  ~hasOrderBy,
346
395
  ),
347
396
  ]
397
+ let emptyConnection = Obj.magic({
398
+ "edges": [],
399
+ "pageInfo": {
400
+ "hasNextPage": false,
401
+ "hasPreviousPage": false,
402
+ "startCursor": Nullable.null,
403
+ "endCursor": Nullable.null,
404
+ },
405
+ })
348
406
  let resolver: ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn = async (_root, args, ctx) => {
349
407
  switch await runInterceptor(~ctx, ~args) {
350
- | Deny(_) =>
351
- Obj.magic({
352
- "edges": [],
353
- "pageInfo": {
354
- "hasNextPage": false,
355
- "hasPreviousPage": false,
356
- "startCursor": Nullable.null,
357
- "endCursor": Nullable.null,
358
- },
359
- })
408
+ | Deny(_) => emptyConnection
360
409
  | Allow =>
361
- let argsDict = args->JSON.Decode.object->Option.getOr(Dict.make())
362
- // Prefer a backend list push-down (SQLite builds json_extract predicates
363
- // + ORDER BY … LIMIT so it never materialises the whole read model). When
364
- // the backend can't serve this query shape it returns None and we fall
365
- // back to materialising the full model and running the shared
366
- // `QueryDbListQuery` spec over it (the same code the in-memory backend and
367
- // the push-down are tested against).
368
- let decodeLocalId = id =>
369
- DomainGraphQL_Server.decodeGlobalId(id)->Option.map(((_, lid)) => lid)
370
- switch Bus.getQueryDbListPage(name) {
371
- | Some(listPage) =>
372
- switch listPage(~argsDict, ~capability, ~labelField) {
373
- | Some(conn) => conn
410
+ switch ownerDecision(~ctx) {
411
+ | RefuseOwned => emptyConnection
412
+ | decision =>
413
+ let ownerScope = Reventless.OwnerScope.scopeOf(decision)
414
+ let argsDict = args->JSON.Decode.object->Option.getOr(Dict.make())
415
+ // Prefer a backend list push-down (SQLite builds json_extract predicates
416
+ // + ORDER BY … LIMIT so it never materialises the whole read model). When
417
+ // the backend can't serve this query shape it returns None and we fall
418
+ // back to materialising the full model and running the shared
419
+ // `QueryDbListQuery` spec over it (the same code the in-memory backend and
420
+ // the push-down are tested against).
421
+ //
422
+ // `ownerScope` goes to BOTH arms. Passing it only to the fallback would
423
+ // scope the exceptional path and leave the normal one — the push-down —
424
+ // returning everything, which is the worst possible place for the gap
425
+ // because the fallback is what the tests most easily exercise.
426
+ let decodeLocalId = id =>
427
+ DomainGraphQL_Server.decodeGlobalId(id)->Option.map(((_, lid)) => lid)
428
+ switch Bus.getQueryDbListPage(name) {
429
+ | Some(listPage) =>
430
+ switch listPage(~argsDict, ~capability, ~labelField, ~ownerScope?) {
431
+ | Some(conn) => conn
432
+ | None =>
433
+ let items = await fetchAllItems()
434
+ QueryDbListQuery.run(
435
+ ~items,
436
+ ~argsDict,
437
+ ~capability,
438
+ ~labelField,
439
+ ~decodeLocalId,
440
+ ~ownerScope?,
441
+ )
442
+ }
374
443
  | None =>
375
444
  let items = await fetchAllItems()
376
- QueryDbListQuery.run(~items, ~argsDict, ~capability, ~labelField, ~decodeLocalId)
445
+ QueryDbListQuery.run(
446
+ ~items,
447
+ ~argsDict,
448
+ ~capability,
449
+ ~labelField,
450
+ ~decodeLocalId,
451
+ ~ownerScope?,
452
+ )
377
453
  }
378
- | None =>
379
- let items = await fetchAllItems()
380
- QueryDbListQuery.run(~items, ~argsDict, ~capability, ~labelField, ~decodeLocalId)
381
454
  }
382
455
  }
383
456
  }
@@ -389,7 +462,11 @@ module Make = (Bus: LocalBus.T) => {
389
462
  switch await runInterceptor(~ctx, ~args) {
390
463
  | Deny(_) => Obj.magic({"nextToken": Nullable.null, "scannedCount": 0, "items": []})
391
464
  | Allow =>
392
- let items = await fetchAllItems()
465
+ let all = await fetchAllItems()
466
+ // The legacy shape has no push-down to reach, so the narrowing is a plain
467
+ // filter here. `scannedCount` counts what is returned, not what was read:
468
+ // 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))
393
470
  Obj.magic({"nextToken": Nullable.null, "scannedCount": items->Array.length, "items": items})
394
471
  }
395
472
  }
@@ -446,11 +523,15 @@ module Make = (Bus: LocalBus.T) => {
446
523
  switch Bus.getQueryDb(name) {
447
524
  | None => emptyConn
448
525
  | Some(ops) =>
449
- let allItems =
526
+ let loaded =
450
527
  await ops.loadStream(id)
451
528
  ->Stream.runCollect
452
529
  ->Effect.catchAll(_ => Effect.succeed([]))
453
530
  ->Effect.runPromise
531
+ // Narrowed here, before the cursor window and the sort-key filter, so
532
+ // every page this door emits is a page of rows the caller owns. Doing
533
+ // it after would hand back short pages with valid cursors.
534
+ let allItems = loaded->Array.filter(item => ownerAllows(~ctx, item))
454
535
 
455
536
  // Cursor-keyed filtering: exclude items on the cursor side of the boundary
456
537
  let cursorFiltered = if isBackward {
@@ -539,11 +620,16 @@ module Make = (Bus: LocalBus.T) => {
539
620
  | Allow =>
540
621
  let value =
541
622
  args->JSON.Decode.object->Option.flatMap(d => d->Dict.get(index))->Option.flatMap(JSON.Decode.string)->Option.getOr("")
623
+ // Applied to whichever arm answers, rather than inside one of them: the
624
+ // push-down and the scan are two ways to reach the same rows, and a
625
+ // narrowing that lives in only one is a hole that appears when a
626
+ // backend gains or loses an index.
627
+ let scoped = rows => rows->Array.filter(item => ownerAllows(~ctx, item))
542
628
  // Prefer the pushed-down equality lookup (SQLite rides the GSI index;
543
629
  // in-memory reuses its lazy snapshot). Fall back to scan+filter only
544
630
  // if no lookup is registered for this QueryDb.
545
631
  switch Bus.getQueryDbIndexLookup(name) {
546
- | Some(lookup) => lookup(filterField, value)->JSON.Encode.array
632
+ | Some(lookup) => lookup(filterField, value)->scoped->JSON.Encode.array
547
633
  | None =>
548
634
  switch Bus.getQueryDbScan(name) {
549
635
  | Some(scanAll) =>
@@ -556,6 +642,7 @@ module Make = (Bus: LocalBus.T) => {
556
642
  ->Option.map(v => v == value)
557
643
  ->Option.getOr(false)
558
644
  )
645
+ ->scoped
559
646
  ->JSON.Encode.array
560
647
  | None => []->JSON.Encode.array
561
648
  }
@@ -6,7 +6,9 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
7
  import * as Effect from "effect/Effect";
8
8
  import * as Stdlib_Nullable from "@rescript/runtime/lib/es6/Stdlib_Nullable.js";
9
+ import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
9
10
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
11
+ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
10
12
  import * as Api_Ids$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Ids.res.mjs";
11
13
  import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
12
14
  import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
@@ -14,6 +16,7 @@ import * as SortKey_Filter$ReventlessLocal from "./SortKey_Filter.res.mjs";
14
16
  import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
15
17
  import * as QueryDb_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Callback.res.mjs";
16
18
  import * as DomainGraphQL_Server$ReventlessLocal from "../DomainGraphQL_Server.res.mjs";
19
+ import * as OwnerScopeDiagnostics$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/OwnerScopeDiagnostics.res.mjs";
17
20
  import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
18
21
 
19
22
  function encodeCursor(value) {
@@ -85,6 +88,18 @@ function Make(Bus) {
85
88
  return "Allow";
86
89
  }
87
90
  };
91
+ let ownerFieldOf = () => Stdlib_Option.flatMap(Plugin_Helpers$ReventlessCore.stateSchemaRegistry[name], s => Owner$Reventless.fieldNames(s)[0]);
92
+ OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("QueryDbResolvers_GraphQL", name, ownerFieldOf());
93
+ let ownerDecision = ctx => OwnerScope$Reventless.decide(extractIdentity(ctx), ownerFieldOf(), undefined);
94
+ let ownerAllows = (ctx, item) => {
95
+ let match = ownerDecision(ctx);
96
+ if (typeof match !== "object") {
97
+ return match === "Unscoped";
98
+ }
99
+ let required = match._1;
100
+ let field = match._0;
101
+ 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
+ };
88
103
  let cap = s => s.charAt(0).toUpperCase() + s.slice(1);
89
104
  let registryEntry = Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry[name];
90
105
  let singleQueryName = registryEntry !== undefined ? registryEntry.singleFieldName : name.charAt(0).toLowerCase() + name.slice(1);
@@ -124,6 +139,9 @@ function Make(Bus) {
124
139
  if (item === undefined) {
125
140
  return null;
126
141
  }
142
+ if (!ownerAllows(ctx, item)) {
143
+ return null;
144
+ }
127
145
  if (!includeIdParam) {
128
146
  return item;
129
147
  }
@@ -168,7 +186,7 @@ function Make(Bus) {
168
186
  }));
169
187
  return Stdlib_Array.filterMap(loaded, param => {
170
188
  let id = param[0];
171
- return Stdlib_Option.map(param[1], item => {
189
+ return Stdlib_Option.map(Stdlib_Option.filter(param[1], item => ownerAllows(ctx, item)), item => {
172
190
  let obj = Stdlib_Option.mapOr(Stdlib_JSON.Decode.object(item), {}, prim => Object.assign({}, prim));
173
191
  obj["id"] = id;
174
192
  return obj;
@@ -205,32 +223,38 @@ function Make(Bus) {
205
223
  let typesToRegister = [GraphQL_FragmentGenerator$ReventlessCore.deriveConnectionFilterType(filterTypeName, capability)].concat(orderByTypes);
206
224
  server.registerTypes(typesToRegister);
207
225
  let sdl = [GraphQL_FragmentGenerator$ReventlessCore.deriveConnectionQueryField(listQueryName, returnTypeName, filterTypeName, hasOrderBy)];
226
+ let emptyConnection = {
227
+ edges: [],
228
+ pageInfo: {
229
+ hasNextPage: false,
230
+ hasPreviousPage: false,
231
+ startCursor: null,
232
+ endCursor: null
233
+ }
234
+ };
208
235
  let resolver$1 = async (_root, args, ctx) => {
209
236
  let match = await runInterceptor(ctx, args);
210
237
  if (typeof match === "object") {
211
- return {
212
- edges: [],
213
- pageInfo: {
214
- hasNextPage: false,
215
- hasPreviousPage: false,
216
- startCursor: null,
217
- endCursor: null
218
- }
219
- };
238
+ return emptyConnection;
239
+ }
240
+ let decision = ownerDecision(ctx);
241
+ if (typeof decision !== "object" && decision !== "Unscoped") {
242
+ return emptyConnection;
220
243
  }
244
+ let ownerScope = OwnerScope$Reventless.scopeOf(decision);
221
245
  let argsDict = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(args), {});
222
246
  let decodeLocalId = id => Stdlib_Option.map(DomainGraphQL_Server$ReventlessLocal.decodeGlobalId(id), param => param[1]);
223
247
  let listPage = Bus.getQueryDbListPage(name);
224
248
  if (listPage !== undefined) {
225
- let conn = listPage(argsDict, capability, labelField);
249
+ let conn = listPage(argsDict, capability, labelField, ownerScope);
226
250
  if (conn !== undefined) {
227
251
  return conn;
228
252
  }
229
253
  let items = await fetchAllItems();
230
- return QueryDbListQuery$ReventlessCore.run(items, argsDict, capability, labelField, decodeLocalId);
254
+ return QueryDbListQuery$ReventlessCore.run(items, argsDict, capability, labelField, decodeLocalId, ownerScope);
231
255
  }
232
256
  let items$1 = await fetchAllItems();
233
- return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, capability, labelField, decodeLocalId);
257
+ return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, capability, labelField, decodeLocalId, ownerScope);
234
258
  };
235
259
  match = [
236
260
  sdl,
@@ -247,7 +271,8 @@ function Make(Bus) {
247
271
  items: []
248
272
  };
249
273
  }
250
- let items = await fetchAllItems();
274
+ let all = await fetchAllItems();
275
+ let items = all.filter(item => ownerAllows(ctx, item));
251
276
  return {
252
277
  nextToken: null,
253
278
  scannedCount: items.length,
@@ -305,7 +330,8 @@ function Make(Bus) {
305
330
  if (ops === undefined) {
306
331
  return emptyConn;
307
332
  }
308
- let allItems = await Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(id)), param => Effect.succeed([])));
333
+ let loaded = await Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(id)), param => Effect.succeed([])));
334
+ let allItems = loaded.filter(item => ownerAllows(ctx, item));
309
335
  let cursorFiltered;
310
336
  if (isBackward) {
311
337
  let beforeKey = Stdlib_Option.map(before, decodeCursor);
@@ -382,13 +408,14 @@ function Make(Bus) {
382
408
  return [];
383
409
  }
384
410
  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));
385
412
  let lookup = Bus.getQueryDbIndexLookup(name);
386
413
  if (lookup !== undefined) {
387
- return lookup(filterField, value);
414
+ return scoped(lookup(filterField, value));
388
415
  }
389
416
  let scanAll = Bus.getQueryDbScan(name);
390
417
  if (scanAll !== undefined) {
391
- return scanAll().filter(item => Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[filterField]), Stdlib_JSON.Decode.string), v => v === value), false));
418
+ return scoped(scanAll().filter(item => Stdlib_Option.getOr(Stdlib_Option.map(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), d => d[filterField]), Stdlib_JSON.Decode.string), v => v === value), false)));
392
419
  } else {
393
420
  return [];
394
421
  }
@@ -146,6 +146,7 @@ type busCallbacks = {
146
146
  ~argsDict: dict<JSON.t>,
147
147
  ~capability: GraphQL_FragmentGenerator.serverCapability,
148
148
  ~labelField: string,
149
+ ~ownerScope: (string, string)=?,
149
150
  ) => option<JSON.t>,
150
151
  ) => unit,
151
152
  }
@@ -426,6 +427,7 @@ let makeStorage = (
426
427
  ~argsDict: dict<JSON.t>,
427
428
  ~capability: GraphQL_FragmentGenerator.serverCapability,
428
429
  ~labelField as _,
430
+ ~ownerScope: option<(string, string)>=?,
429
431
  ): option<JSON.t> => {
430
432
  let filterDict =
431
433
  argsDict->Dict.get("filter")->Option.flatMap(JSON.Decode.object)->Option.getOr(Dict.make())
@@ -440,6 +442,15 @@ let makeStorage = (
440
442
  } else {
441
443
  let whereParts = [notExpiredClause]
442
444
  let params = []
445
+ // Pushed into the SQL rather than applied to the returned page, because the
446
+ // LIMIT below is what makes a page: narrowing afterwards would return fewer
447
+ // rows than asked for while still reporting a next page.
448
+ switch ownerScope {
449
+ | Some((field, required)) =>
450
+ whereParts->Array.push(`${jsonText(field)} = ?`)
451
+ params->Array.push(JSON.Encode.string(required))
452
+ | None => ()
453
+ }
443
454
  let valString = v =>
444
455
  switch v->JSON.Decode.string {
445
456
  | Some(s) => Some(s)
@@ -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) => {
340
+ let listPage = (argsDict, capability, param, ownerScope) => {
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);
@@ -348,6 +348,10 @@ function makeStorage(db, bus, name, indexes, subIdField) {
348
348
  }
349
349
  let whereParts = [notExpiredClause];
350
350
  let params = [];
351
+ if (ownerScope !== undefined) {
352
+ whereParts.push(jsonText(ownerScope[0]) + ` = ?`);
353
+ params.push(ownerScope[1]);
354
+ }
351
355
  let valString = v => {
352
356
  let s = Stdlib_JSON.Decode.string(v);
353
357
  if (s !== undefined) {
@@ -21,23 +21,30 @@ let capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability = {
21
21
  sortFields: ["name", "qty", "status"],
22
22
  }
23
23
 
24
- let mk = (id, status, name, qty) => {
24
+ let mk = (id, status, name, qty, owner) => {
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
+ o->Dict.set("owner", JSON.Encode.string(owner))
30
31
  JSON.Encode.object(o)
31
32
  }
32
33
 
33
34
  // Names differ from id-order; status has duplicates (tiebreak); qty exercises the
34
- // numeric-field-as-string comparison the push-down must match.
35
+ // numeric-field-as-string comparison the push-down must match. `owner` is split
36
+ // so that no owner holds all the rows and none holds none — a scoped read that
37
+ // returned everything and one that returned nothing would both stand out.
38
+ //
39
+ // Note `owner` is deliberately absent from `capability` below: owner scoping has
40
+ // to work on a field the client-visible filter surface does not admit, which is
41
+ // the normal case and the reason the predicate travels separately.
35
42
  let rows = [
36
- ("p-1", "active", "Charlie", 3),
37
- ("p-2", "active", "Alpha", 1),
38
- ("p-3", "inactive", "Echo", 5),
39
- ("p-4", "active", "Bravo", 2),
40
- ("p-5", "inactive", "Delta", 4),
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"),
41
48
  ]
42
49
 
43
50
  type setup = {
@@ -45,6 +52,7 @@ type setup = {
45
52
  ~argsDict: dict<JSON.t>,
46
53
  ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
47
54
  ~labelField: string,
55
+ ~ownerScope: (string, string)=?,
48
56
  ) => option<JSON.t>,
49
57
  fullScan: unit => array<JSON.t>,
50
58
  }
@@ -58,8 +66,8 @@ let build = async (): setup => {
58
66
  let s = Storage.make(~name="items", ~indexes=[], ~api=(), ~apiRole=(), ~owner=None, ~opts)
59
67
  let ops = await s.operations->TestRunner.resolve
60
68
  for i in 0 to rows->Array.length - 1 {
61
- let (id, status, name, qty) = rows->Array.getUnsafe(i)
62
- let _ = await ops.save(id, mk(id, status, name, qty), ReventlessCore.QueryDb.Any, None)
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)
63
71
  }
64
72
  {
65
73
  listPage: TestBus.getQueryDbListPage("items")->Option.getOrThrow,
@@ -110,7 +118,7 @@ let orderBy = (f, dir) =>
110
118
  let cur = ReventlessCore.QueryDbListQuery.encodeCursor
111
119
 
112
120
  // Assert the push-down serves this shape AND matches the spec exactly.
113
- let checkPushed = async (~label, args) => {
121
+ let checkPushed = async (~label, ~ownerScope=?, args) => {
114
122
  let s = await build()
115
123
  let argsDict = argsOf(args)
116
124
  let expected =
@@ -120,8 +128,9 @@ let checkPushed = async (~label, args) => {
120
128
  ~capability,
121
129
  ~labelField="name",
122
130
  ~decodeLocalId=_ => None,
131
+ ~ownerScope?,
123
132
  )->norm
124
- switch s.listPage(~argsDict, ~capability, ~labelField="name") {
133
+ switch s.listPage(~argsDict, ~capability, ~labelField="name", ~ownerScope?) {
125
134
  | Some(actual) => expect(actual->norm)->toEqual(expected)
126
135
  | None => expect("push-down for " ++ label)->toBe("returned None")
127
136
  }
@@ -133,6 +142,20 @@ let checkFallback = async args => {
133
142
  expect(s.listPage(~argsDict=argsOf(args), ~capability, ~labelField="name")->Option.isSome)->toBe(false)
134
143
  }
135
144
 
145
+ // The ids a scoped read actually returns, from the push-down.
146
+ let scopedIds = async (~owner, args) => {
147
+ let s = await build()
148
+ switch s.listPage(
149
+ ~argsDict=argsOf(args),
150
+ ~capability,
151
+ ~labelField="name",
152
+ ~ownerScope=("owner", owner),
153
+ ) {
154
+ | Some(conn) => (conn->norm).edges->Array.map(e => e.id)
155
+ | None => []
156
+ }
157
+ }
158
+
136
159
  describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery spec)", () => {
137
160
  testPromise("bare page", () => checkPushed(~label="bare", []))
138
161
  testPromise("first:2", () => checkPushed(~label="first", [("first", JSON.Encode.int(2))]))
@@ -194,4 +217,62 @@ describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery spec)", ()
194
217
  testPromise("backward (last/before) → fallback", () =>
195
218
  checkFallback([("last", JSON.Encode.int(2)), ("before", JSON.Encode.string(cur("p-4")))])
196
219
  )
220
+
221
+ // ── Owner scoping ─────────────────────────────────────────────────────────
222
+ // Parity matters more here than anywhere else in this file. The push-down is
223
+ // the path a deployment actually takes; the spec is the path the tests most
224
+ // easily reach. A predicate implemented in only one of them is a hole that
225
+ // every fallback-based test would still call green.
226
+ describe("owner scoping", () => {
227
+ testPromise("bare page, scoped", () =>
228
+ checkPushed(~label="owner-bare", ~ownerScope=("owner", "u-a"), [])
229
+ )
230
+ testPromise("scoped + orderBy", () =>
231
+ checkPushed(
232
+ ~label="owner-order",
233
+ ~ownerScope=("owner", "u-a"),
234
+ [("orderBy", orderBy("name", "DESC"))],
235
+ )
236
+ )
237
+ // The client's own filter must survive alongside the scope, not be replaced
238
+ // by it — a scoped read is still a filtered read.
239
+ testPromise("scoped + client filter compose", () =>
240
+ checkPushed(
241
+ ~label="owner-and-filter",
242
+ ~ownerScope=("owner", "u-a"),
243
+ [("filter", filterOf([("statusEq", JSON.Encode.string("inactive"))]))],
244
+ )
245
+ )
246
+
247
+ testPromise("a scoped read returns exactly that owner's rows", async () =>
248
+ expect(await scopedIds(~owner="u-a", []))->toEqual(["p-1", "p-3", "p-5"])
249
+ )
250
+
251
+ // The control: a different owner sees a different, also non-empty, set. An
252
+ // implementation that scoped to nothing would pass an "is not everything"
253
+ // assertion and fail this one.
254
+ testPromise("a second owner sees their own rows, not the first's", async () =>
255
+ expect(await scopedIds(~owner="u-b", []))->toEqual(["p-2"])
256
+ )
257
+
258
+ // The case that catches a predicate applied AFTER the page rather than
259
+ // inside the SQL: u-a owns 3 of 5 rows, so a LIMIT 2 taken before scoping
260
+ // would return p-1 alone (p-2 belongs to u-b and would be dropped), not the
261
+ // two rows actually asked for.
262
+ testPromise("paging a scoped read fills each page from owned rows only", async () =>
263
+ expect(await scopedIds(~owner="u-a", [("first", JSON.Encode.int(2))]))->toEqual([
264
+ "p-1",
265
+ "p-3",
266
+ ])
267
+ )
268
+
269
+ testPromise("the second page continues the scoped sequence", async () =>
270
+ expect(
271
+ await scopedIds(
272
+ ~owner="u-a",
273
+ [("first", JSON.Encode.int(2)), ("after", JSON.Encode.string(cur("p-3")))],
274
+ ),
275
+ )->toEqual(["p-5"])
276
+ )
277
+ })
197
278
  })
@@ -36,12 +36,13 @@ let capability = {
36
36
  sortFields: capability_sortFields
37
37
  };
38
38
 
39
- function mk(id, status, name, qty) {
39
+ function mk(id, status, name, qty, owner) {
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
+ o["owner"] = owner;
45
46
  return o;
46
47
  }
47
48
 
@@ -50,31 +51,36 @@ let rows = [
50
51
  "p-1",
51
52
  "active",
52
53
  "Charlie",
53
- 3
54
+ 3,
55
+ "u-a"
54
56
  ],
55
57
  [
56
58
  "p-2",
57
59
  "active",
58
60
  "Alpha",
59
- 1
61
+ 1,
62
+ "u-b"
60
63
  ],
61
64
  [
62
65
  "p-3",
63
66
  "inactive",
64
67
  "Echo",
65
- 5
68
+ 5,
69
+ "u-a"
66
70
  ],
67
71
  [
68
72
  "p-4",
69
73
  "active",
70
74
  "Bravo",
71
- 2
75
+ 2,
76
+ "u-c"
72
77
  ],
73
78
  [
74
79
  "p-5",
75
80
  "inactive",
76
81
  "Delta",
77
- 4
82
+ 4,
83
+ "u-a"
78
84
  ]
79
85
  ];
80
86
 
@@ -90,7 +96,7 @@ async function build() {
90
96
  for (let i = 0, i_finish = rows.length; i < i_finish; ++i) {
91
97
  let match = rows[i];
92
98
  let id = match[0];
93
- await ops.save(id, mk(id, match[1], match[2], match[3]), "Any", undefined);
99
+ await ops.save(id, mk(id, match[1], match[2], match[3], match[4]), "Any", undefined);
94
100
  }
95
101
  return {
96
102
  listPage: Stdlib_Option.getOrThrow(TestBus.getQueryDbListPage("items"), undefined),
@@ -142,11 +148,11 @@ function orderBy(f, dir) {
142
148
  ]);
143
149
  }
144
150
 
145
- async function checkPushed(label, args) {
151
+ async function checkPushed(label, ownerScope, args) {
146
152
  let s = await build();
147
153
  let argsDict = Object.fromEntries(args);
148
- let expected = norm(QueryDbListQuery$ReventlessCore.run(s.fullScan(), argsDict, capability, "name", param => {}));
149
- let actual = s.listPage(argsDict, capability, "name");
154
+ let expected = norm(QueryDbListQuery$ReventlessCore.run(s.fullScan(), argsDict, capability, "name", param => {}, ownerScope));
155
+ let actual = s.listPage(argsDict, capability, "name", ownerScope);
150
156
  if (actual !== undefined) {
151
157
  globalThis.expect(norm(actual)).toEqual(expected);
152
158
  } else {
@@ -156,16 +162,29 @@ async function checkPushed(label, args) {
156
162
 
157
163
  async function checkFallback(args) {
158
164
  let s = await build();
159
- globalThis.expect(Stdlib_Option.isSome(s.listPage(Object.fromEntries(args), capability, "name"))).toBe(false);
165
+ globalThis.expect(Stdlib_Option.isSome(s.listPage(Object.fromEntries(args), capability, "name", undefined))).toBe(false);
166
+ }
167
+
168
+ async function scopedIds(owner, args) {
169
+ let s = await build();
170
+ let conn = s.listPage(Object.fromEntries(args), capability, "name", [
171
+ "owner",
172
+ owner
173
+ ]);
174
+ if (conn !== undefined) {
175
+ return norm(conn).edges.map(e => e.id);
176
+ } else {
177
+ return [];
178
+ }
160
179
  }
161
180
 
162
181
  globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery spec)", () => {
163
- globalThis.test("bare page", () => checkPushed("bare", []));
164
- globalThis.test("first:2", () => checkPushed("first", [[
182
+ globalThis.test("bare page", () => checkPushed("bare", undefined, []));
183
+ globalThis.test("first:2", () => checkPushed("first", undefined, [[
165
184
  "first",
166
185
  2
167
186
  ]]));
168
- globalThis.test("first:2 + after", () => checkPushed("after", [
187
+ globalThis.test("first:2 + after", () => checkPushed("after", undefined, [
169
188
  [
170
189
  "first",
171
190
  2
@@ -175,14 +194,14 @@ globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery
175
194
  QueryDbListQuery$ReventlessCore.encodeCursor("p-2")
176
195
  ]
177
196
  ]));
178
- globalThis.test("statusEq active", () => checkPushed("eq", [[
197
+ globalThis.test("statusEq active", () => checkPushed("eq", undefined, [[
179
198
  "filter",
180
199
  Object.fromEntries([[
181
200
  "statusEq",
182
201
  "active"
183
202
  ]])
184
203
  ]]));
185
- globalThis.test("statusEq active + first:2", () => checkPushed("eq+first", [
204
+ globalThis.test("statusEq active + first:2", () => checkPushed("eq+first", undefined, [
186
205
  [
187
206
  "filter",
188
207
  Object.fromEntries([[
@@ -195,15 +214,15 @@ globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery
195
214
  2
196
215
  ]
197
216
  ]));
198
- globalThis.test("orderBy name ASC", () => checkPushed("order-asc", [[
217
+ globalThis.test("orderBy name ASC", () => checkPushed("order-asc", undefined, [[
199
218
  "orderBy",
200
219
  orderBy("name", "ASC")
201
220
  ]]));
202
- globalThis.test("orderBy name DESC", () => checkPushed("order-desc", [[
221
+ globalThis.test("orderBy name DESC", () => checkPushed("order-desc", undefined, [[
203
222
  "orderBy",
204
223
  orderBy("name", "DESC")
205
224
  ]]));
206
- globalThis.test("orderBy name DESC + first:2 + after", () => checkPushed("order-desc-after", [
225
+ globalThis.test("orderBy name DESC + first:2 + after", () => checkPushed("order-desc-after", undefined, [
207
226
  [
208
227
  "orderBy",
209
228
  orderBy("name", "DESC")
@@ -217,11 +236,11 @@ globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery
217
236
  QueryDbListQuery$ReventlessCore.encodeCursor("Delta")
218
237
  ]
219
238
  ]));
220
- globalThis.test("orderBy status ASC (tiebreak by id)", () => checkPushed("order-tiebreak", [[
239
+ globalThis.test("orderBy status ASC (tiebreak by id)", () => checkPushed("order-tiebreak", undefined, [[
221
240
  "orderBy",
222
241
  orderBy("status", "ASC")
223
242
  ]]));
224
- globalThis.test("qty range From/To (numeric-as-string)", () => checkPushed("range", [[
243
+ globalThis.test("qty range From/To (numeric-as-string)", () => checkPushed("range", undefined, [[
225
244
  "filter",
226
245
  Object.fromEntries([
227
246
  [
@@ -234,7 +253,7 @@ globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery
234
253
  ]
235
254
  ])
236
255
  ]]));
237
- globalThis.test("orderBy qty ASC (numeric-as-string sort)", () => checkPushed("order-qty", [[
256
+ globalThis.test("orderBy qty ASC (numeric-as-string sort)", () => checkPushed("order-qty", undefined, [[
238
257
  "orderBy",
239
258
  orderBy("qty", "ASC")
240
259
  ]]));
@@ -269,6 +288,60 @@ globalThis.describe("QueryDb list push-down parity (SQLite ≡ QueryDbListQuery
269
288
  QueryDbListQuery$ReventlessCore.encodeCursor("p-4")
270
289
  ]
271
290
  ]));
291
+ globalThis.describe("owner scoping", () => {
292
+ globalThis.test("bare page, scoped", () => checkPushed("owner-bare", [
293
+ "owner",
294
+ "u-a"
295
+ ], []));
296
+ globalThis.test("scoped + orderBy", () => checkPushed("owner-order", [
297
+ "owner",
298
+ "u-a"
299
+ ], [[
300
+ "orderBy",
301
+ orderBy("name", "DESC")
302
+ ]]));
303
+ globalThis.test("scoped + client filter compose", () => checkPushed("owner-and-filter", [
304
+ "owner",
305
+ "u-a"
306
+ ], [[
307
+ "filter",
308
+ Object.fromEntries([[
309
+ "statusEq",
310
+ "inactive"
311
+ ]])
312
+ ]]));
313
+ globalThis.test("a scoped read returns exactly that owner's rows", async () => {
314
+ globalThis.expect(await scopedIds("u-a", [])).toEqual([
315
+ "p-1",
316
+ "p-3",
317
+ "p-5"
318
+ ]);
319
+ });
320
+ globalThis.test("a second owner sees their own rows, not the first's", async () => {
321
+ globalThis.expect(await scopedIds("u-b", [])).toEqual(["p-2"]);
322
+ });
323
+ globalThis.test("paging a scoped read fills each page from owned rows only", async () => {
324
+ globalThis.expect(await scopedIds("u-a", [[
325
+ "first",
326
+ 2
327
+ ]])).toEqual([
328
+ "p-1",
329
+ "p-3"
330
+ ]);
331
+ });
332
+ globalThis.test("the second page continues the scoped sequence", async () => {
333
+ globalThis.expect(await scopedIds("u-a", [
334
+ [
335
+ "first",
336
+ 2
337
+ ],
338
+ [
339
+ "after",
340
+ QueryDbListQuery$ReventlessCore.encodeCursor("p-3")
341
+ ]
342
+ ])).toEqual(["p-5"]);
343
+ });
344
+ });
272
345
  });
273
346
 
274
347
  let cur = QueryDbListQuery$ReventlessCore.encodeCursor;
@@ -288,5 +361,6 @@ export {
288
361
  cur,
289
362
  checkPushed,
290
363
  checkFallback,
364
+ scopedIds,
291
365
  }
292
366
  /* Not a pure module */