@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 +8 -0
- package/package.json +10 -10
- package/src/BakedManifest.res +72 -0
- package/src/BakedManifest.res.mjs +52 -0
- package/src/Platform.res +66 -20
- package/src/Platform.res.mjs +55 -2
- package/src/adapter/LocalBus.res +3 -0
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +118 -31
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +44 -17
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +11 -0
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +5 -1
- package/tests/adapter/QueryDbListPushdownParityTest.res +92 -11
- package/tests/adapter/QueryDbListPushdownParityTest.res.mjs +96 -22
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.
|
|
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-
|
|
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-
|
|
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.
|
|
44
|
-
"@reventlessdev/reventless-graphql-server": "1.0.0-alpha.
|
|
45
|
-
"@reventlessdev/reventless-gwt": "1.0.0-alpha.
|
|
46
|
-
"@reventlessdev/reventless-
|
|
47
|
-
"@reventlessdev/reventless-
|
|
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.
|
|
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
|
-
|
|
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>=?,
|
package/src/Platform.res.mjs
CHANGED
|
@@ -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);
|
package/src/adapter/LocalBus.res
CHANGED
|
@@ -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
|
|
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
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
|
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 */
|