@reventlessdev/reventless-local 3.0.0-alpha.211 → 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 +18 -0
- package/package.json +11 -11
- 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/DomainGraphQL_Server.res +6 -18
- package/src/adapter/DomainGraphQL_Server.res.mjs +5 -22
- package/src/adapter/LocalBus.res +3 -0
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res +167 -50
- package/src/adapter/QueryDb/QueryDbResolvers_GraphQL.res.mjs +85 -30
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res +11 -0
- package/src/adapter/QueryDb/QueryDbStorage_Sqlite.res.mjs +5 -1
- package/tests/adapter/GraphQL_SchemaInspectorTest.res +47 -6
- package/tests/adapter/GraphQL_SchemaInspectorTest.res.mjs +29 -3
- package/tests/adapter/QueryDbListPushdownParityTest.res +92 -11
- package/tests/adapter/QueryDbListPushdownParityTest.res.mjs +96 -22
|
@@ -85,6 +85,13 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
85
85
|
|
|
86
86
|
// Register the Relay node resolver callback once per QueryDb (domain only).
|
|
87
87
|
// Scans all QueryDb instances to resolve node(id: ID!) queries.
|
|
88
|
+
// `node` is the one door that speaks Relay global ids, on both sides: it needs
|
|
89
|
+
// the `<Type>:` prefix to know which read model to load, so a storage key alone
|
|
90
|
+
// could not be resolved here. Every other door — the list, `X(id:)`,
|
|
91
|
+
// `XsByIds`, `filter.ids` — reports the storage key, which is what a client
|
|
92
|
+
// reads off a row and passes back. Callers that hold a global id are still
|
|
93
|
+
// served by the typed doors (see `Api_Ids.alternateKey`); the reverse is not
|
|
94
|
+
// possible, which is why this door is opt-in rather than the default form.
|
|
88
95
|
switch relay {
|
|
89
96
|
| Some(r) =>
|
|
90
97
|
r.registerNodeResolverCallback(async (~typeName, ~localId) => {
|
|
@@ -101,7 +108,10 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
101
108
|
->Effect.runPromise
|
|
102
109
|
switch items->Array.get(0) {
|
|
103
110
|
| Some(item) =>
|
|
104
|
-
|
|
111
|
+
// Copied: `JSON.Decode.object` hands back the stored object itself, so
|
|
112
|
+
// setting a field here would rewrite the row inside the QueryDb — the
|
|
113
|
+
// in-memory backend keeps the very object it returns.
|
|
114
|
+
let obj = item->JSON.Decode.object->Option.mapOr(Dict.make(), Dict.copy)
|
|
105
115
|
obj->Dict.set("__typename", JSON.Encode.string(typeName))
|
|
106
116
|
obj->Dict.set("id", r.encodeGlobalId(~typeName, ~localId)->JSON.Encode.string)
|
|
107
117
|
Some(JSON.Encode.object(obj))
|
|
@@ -127,6 +137,49 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
127
137
|
}
|
|
128
138
|
}
|
|
129
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
|
+
|
|
130
183
|
let cap = s => s->String.charAt(0)->String.toUpperCase ++ s->String.slice(~start=1)
|
|
131
184
|
|
|
132
185
|
// Resolve query field names: check registry first, fall back to safe defaults.
|
|
@@ -169,11 +222,6 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
169
222
|
}
|
|
170
223
|
}
|
|
171
224
|
|
|
172
|
-
let encodeId = switch relay {
|
|
173
|
-
| Some(r) => (~typeName, ~localId) => r.encodeGlobalId(~typeName, ~localId)
|
|
174
|
-
| None => (~typeName as _, ~localId) => localId
|
|
175
|
-
}
|
|
176
|
-
|
|
177
225
|
// -- Main query: getById ---------------------------------------------------
|
|
178
226
|
let byIdSdl = if includeIdParam {
|
|
179
227
|
switch subIdField {
|
|
@@ -191,16 +239,38 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
191
239
|
args->JSON.Decode.object->Option.flatMap(d => d->Dict.get("id"))->Option.flatMap(JSON.Decode.string)->Option.getOr("")
|
|
192
240
|
switch Bus.getQueryDb(name) {
|
|
193
241
|
| Some(ops) =>
|
|
194
|
-
let
|
|
195
|
-
|
|
242
|
+
let load = key =>
|
|
243
|
+
ops.loadStream(key)
|
|
196
244
|
->Stream.runCollect
|
|
197
245
|
->Effect.catchAll(_ => Effect.succeed([]))
|
|
198
246
|
->Effect.runPromise
|
|
247
|
+
let firstAttempt = await load(id)
|
|
248
|
+
// The row advertises a Relay global id, so `X(id: row.id)` arrives here
|
|
249
|
+
// as one. Retried rather than decoded up front: the raw key is what this
|
|
250
|
+
// door has always taken, and a key that merely looks like base64 must
|
|
251
|
+
// keep resolving to its own row.
|
|
252
|
+
let (resolvedKey, items) = switch (
|
|
253
|
+
firstAttempt->Array.get(0),
|
|
254
|
+
Api_Ids.alternateKey(id),
|
|
255
|
+
) {
|
|
256
|
+
| (None, Some(localId)) => (localId, await load(localId))
|
|
257
|
+
| _ => (id, firstAttempt)
|
|
258
|
+
}
|
|
199
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
|
|
200
264
|
| Some(item) =>
|
|
201
265
|
if includeIdParam {
|
|
202
|
-
|
|
203
|
-
|
|
266
|
+
// Copied — `JSON.Decode.object` hands back the stored object itself,
|
|
267
|
+
// so setting a field here would rewrite the row inside the QueryDb.
|
|
268
|
+
let obj = item->JSON.Decode.object->Option.mapOr(Dict.make(), Dict.copy)
|
|
269
|
+
// The storage key, which is what the list answers and what this door
|
|
270
|
+
// takes back. `resolvedKey`, not the argument: a caller who passed a
|
|
271
|
+
// Relay global id gets the raw key returned, so a round trip through
|
|
272
|
+
// this door converges on the one form instead of alternating.
|
|
273
|
+
obj->Dict.set("id", JSON.Encode.string(resolvedKey))
|
|
204
274
|
JSON.Encode.object(obj)
|
|
205
275
|
} else {
|
|
206
276
|
item
|
|
@@ -237,21 +307,30 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
237
307
|
->Array.filterMap(JSON.Decode.string)
|
|
238
308
|
switch Bus.getQueryDb(name) {
|
|
239
309
|
| Some(ops) =>
|
|
240
|
-
let
|
|
241
|
-
ops.loadStream(
|
|
310
|
+
let load = key =>
|
|
311
|
+
ops.loadStream(key)
|
|
242
312
|
->Stream.runCollect
|
|
243
313
|
->Effect.catchAll(_ => Effect.succeed([]))
|
|
244
314
|
->Effect.runPromise
|
|
245
|
-
|
|
315
|
+
// Same either-form rule as the single-id door: raw key first, the
|
|
316
|
+
// key inside a Relay global id only on a miss.
|
|
317
|
+
let loaded = await ids->Array.map(async id =>
|
|
318
|
+
switch (await load(id))->Array.get(0) {
|
|
319
|
+
| Some(item) => (id, Some(item))
|
|
320
|
+
| None =>
|
|
321
|
+
switch Api_Ids.alternateKey(id) {
|
|
322
|
+
| Some(localId) => (localId, (await load(localId))->Array.get(0))
|
|
323
|
+
| None => (id, None)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
246
326
|
)->Promise.all
|
|
247
327
|
loaded
|
|
248
328
|
->Array.filterMap(((id, opt)) =>
|
|
249
|
-
opt
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
)
|
|
329
|
+
opt
|
|
330
|
+
->Option.filter(item => ownerAllows(~ctx, item))
|
|
331
|
+
->Option.map(item => {
|
|
332
|
+
let obj = item->JSON.Decode.object->Option.mapOr(Dict.make(), Dict.copy)
|
|
333
|
+
obj->Dict.set("id", JSON.Encode.string(id))
|
|
255
334
|
JSON.Encode.object(obj)
|
|
256
335
|
})
|
|
257
336
|
)
|
|
@@ -279,7 +358,7 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
279
358
|
// so the resolver derives the same serverCapability the FragmentGenerator emitted.
|
|
280
359
|
let stateSchemaOpt = Plugin_Helpers.stateSchemaRegistry->Dict.get(name)
|
|
281
360
|
let capability = switch stateSchemaOpt {
|
|
282
|
-
| Some(s) => GraphQL_FragmentGenerator.deriveServerCapability(s)
|
|
361
|
+
| Some(s) => GraphQL_FragmentGenerator.deriveServerCapability(~entityName=name, s)
|
|
283
362
|
| None => GraphQL_FragmentGenerator.emptyCapability
|
|
284
363
|
}
|
|
285
364
|
|
|
@@ -315,39 +394,63 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
315
394
|
~hasOrderBy,
|
|
316
395
|
),
|
|
317
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
|
+
})
|
|
318
406
|
let resolver: ReventlessGraphqlServer.GraphQL_ServerInstance.resolverFn = async (_root, args, ctx) => {
|
|
319
407
|
switch await runInterceptor(~ctx, ~args) {
|
|
320
|
-
| Deny(_) =>
|
|
321
|
-
Obj.magic({
|
|
322
|
-
"edges": [],
|
|
323
|
-
"pageInfo": {
|
|
324
|
-
"hasNextPage": false,
|
|
325
|
-
"hasPreviousPage": false,
|
|
326
|
-
"startCursor": Nullable.null,
|
|
327
|
-
"endCursor": Nullable.null,
|
|
328
|
-
},
|
|
329
|
-
})
|
|
408
|
+
| Deny(_) => emptyConnection
|
|
330
409
|
| Allow =>
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
+
}
|
|
344
443
|
| None =>
|
|
345
444
|
let items = await fetchAllItems()
|
|
346
|
-
QueryDbListQuery.run(
|
|
445
|
+
QueryDbListQuery.run(
|
|
446
|
+
~items,
|
|
447
|
+
~argsDict,
|
|
448
|
+
~capability,
|
|
449
|
+
~labelField,
|
|
450
|
+
~decodeLocalId,
|
|
451
|
+
~ownerScope?,
|
|
452
|
+
)
|
|
347
453
|
}
|
|
348
|
-
| None =>
|
|
349
|
-
let items = await fetchAllItems()
|
|
350
|
-
QueryDbListQuery.run(~items, ~argsDict, ~capability, ~labelField, ~decodeLocalId)
|
|
351
454
|
}
|
|
352
455
|
}
|
|
353
456
|
}
|
|
@@ -359,7 +462,11 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
359
462
|
switch await runInterceptor(~ctx, ~args) {
|
|
360
463
|
| Deny(_) => Obj.magic({"nextToken": Nullable.null, "scannedCount": 0, "items": []})
|
|
361
464
|
| Allow =>
|
|
362
|
-
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))
|
|
363
470
|
Obj.magic({"nextToken": Nullable.null, "scannedCount": items->Array.length, "items": items})
|
|
364
471
|
}
|
|
365
472
|
}
|
|
@@ -416,11 +523,15 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
416
523
|
switch Bus.getQueryDb(name) {
|
|
417
524
|
| None => emptyConn
|
|
418
525
|
| Some(ops) =>
|
|
419
|
-
let
|
|
526
|
+
let loaded =
|
|
420
527
|
await ops.loadStream(id)
|
|
421
528
|
->Stream.runCollect
|
|
422
529
|
->Effect.catchAll(_ => Effect.succeed([]))
|
|
423
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))
|
|
424
535
|
|
|
425
536
|
// Cursor-keyed filtering: exclude items on the cursor side of the boundary
|
|
426
537
|
let cursorFiltered = if isBackward {
|
|
@@ -509,11 +620,16 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
509
620
|
| Allow =>
|
|
510
621
|
let value =
|
|
511
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))
|
|
512
628
|
// Prefer the pushed-down equality lookup (SQLite rides the GSI index;
|
|
513
629
|
// in-memory reuses its lazy snapshot). Fall back to scan+filter only
|
|
514
630
|
// if no lookup is registered for this QueryDb.
|
|
515
631
|
switch Bus.getQueryDbIndexLookup(name) {
|
|
516
|
-
| Some(lookup) => lookup(filterField, value)->JSON.Encode.array
|
|
632
|
+
| Some(lookup) => lookup(filterField, value)->scoped->JSON.Encode.array
|
|
517
633
|
| None =>
|
|
518
634
|
switch Bus.getQueryDbScan(name) {
|
|
519
635
|
| Some(scanAll) =>
|
|
@@ -526,6 +642,7 @@ module Make = (Bus: LocalBus.T) => {
|
|
|
526
642
|
->Option.map(v => v == value)
|
|
527
643
|
->Option.getOr(false)
|
|
528
644
|
)
|
|
645
|
+
->scoped
|
|
529
646
|
->JSON.Encode.array
|
|
530
647
|
| None => []->JSON.Encode.array
|
|
531
648
|
}
|
|
@@ -6,13 +6,17 @@ 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";
|
|
12
|
+
import * as Api_Ids$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Ids.res.mjs";
|
|
10
13
|
import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
|
|
11
14
|
import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
|
|
12
15
|
import * as SortKey_Filter$ReventlessLocal from "./SortKey_Filter.res.mjs";
|
|
13
16
|
import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
|
|
14
17
|
import * as QueryDb_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Callback.res.mjs";
|
|
15
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";
|
|
16
20
|
import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
|
|
17
21
|
|
|
18
22
|
function encodeCursor(value) {
|
|
@@ -63,7 +67,7 @@ function Make(Bus) {
|
|
|
63
67
|
if (item === undefined) {
|
|
64
68
|
return;
|
|
65
69
|
}
|
|
66
|
-
let obj = Stdlib_Option.
|
|
70
|
+
let obj = Stdlib_Option.mapOr(Stdlib_JSON.Decode.object(item), {}, prim => Object.assign({}, prim));
|
|
67
71
|
obj["__typename"] = typeName;
|
|
68
72
|
obj["id"] = relay.encodeGlobalId(typeName, localId);
|
|
69
73
|
return obj;
|
|
@@ -84,6 +88,18 @@ function Make(Bus) {
|
|
|
84
88
|
return "Allow";
|
|
85
89
|
}
|
|
86
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
|
+
};
|
|
87
103
|
let cap = s => s.charAt(0).toUpperCase() + s.slice(1);
|
|
88
104
|
let registryEntry = Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry[name];
|
|
89
105
|
let singleQueryName = registryEntry !== undefined ? registryEntry.singleFieldName : name.charAt(0).toLowerCase() + name.slice(1);
|
|
@@ -95,7 +111,6 @@ function Make(Bus) {
|
|
|
95
111
|
if (includeIdParam && relay !== undefined) {
|
|
96
112
|
relay.registerNodeType(returnTypeName, name);
|
|
97
113
|
}
|
|
98
|
-
let encodeId = relay !== undefined ? (typeName, localId) => relay.encodeGlobalId(typeName, localId) : (param, localId) => localId;
|
|
99
114
|
let byIdSdl = includeIdParam ? (
|
|
100
115
|
subIdField !== undefined ? ` ` + singleQueryName + `(id: ID!, ` + subIdField + `: String): ` + returnTypeName : ` ` + singleQueryName + `(id: ID!): ` + returnTypeName
|
|
101
116
|
) : ` ` + singleQueryName + `: ` + returnTypeName;
|
|
@@ -109,16 +124,29 @@ function Make(Bus) {
|
|
|
109
124
|
if (ops === undefined) {
|
|
110
125
|
return null;
|
|
111
126
|
}
|
|
112
|
-
let
|
|
113
|
-
let
|
|
127
|
+
let load = key => Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(key)), param => Effect.succeed([])));
|
|
128
|
+
let firstAttempt = await load(id);
|
|
129
|
+
let match$1 = firstAttempt[0];
|
|
130
|
+
let match$2 = Api_Ids$ReventlessCore.alternateKey(id);
|
|
131
|
+
let match$3 = match$1 !== undefined || match$2 === undefined ? [
|
|
132
|
+
id,
|
|
133
|
+
firstAttempt
|
|
134
|
+
] : [
|
|
135
|
+
match$2,
|
|
136
|
+
await load(match$2)
|
|
137
|
+
];
|
|
138
|
+
let item = match$3[1][0];
|
|
114
139
|
if (item === undefined) {
|
|
115
140
|
return null;
|
|
116
141
|
}
|
|
142
|
+
if (!ownerAllows(ctx, item)) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
117
145
|
if (!includeIdParam) {
|
|
118
146
|
return item;
|
|
119
147
|
}
|
|
120
|
-
let obj = Stdlib_Option.
|
|
121
|
-
obj["id"] =
|
|
148
|
+
let obj = Stdlib_Option.mapOr(Stdlib_JSON.Decode.object(item), {}, prim => Object.assign({}, prim));
|
|
149
|
+
obj["id"] = match$3[0];
|
|
122
150
|
return obj;
|
|
123
151
|
};
|
|
124
152
|
let byIdsSdl = includeIdParam && subIdField === undefined ? [GraphQL_FragmentGenerator$ReventlessCore.deriveByIdsQueryField(listQueryName, returnTypeName)] : [];
|
|
@@ -134,15 +162,33 @@ function Make(Bus) {
|
|
|
134
162
|
if (ops === undefined) {
|
|
135
163
|
return [];
|
|
136
164
|
}
|
|
137
|
-
let
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
165
|
+
let load = key => Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(key)), param => Effect.succeed([])));
|
|
166
|
+
let loaded = await Promise.all(ids.map(async id => {
|
|
167
|
+
let item = (await load(id))[0];
|
|
168
|
+
if (item !== undefined) {
|
|
169
|
+
return [
|
|
170
|
+
id,
|
|
171
|
+
item
|
|
172
|
+
];
|
|
173
|
+
}
|
|
174
|
+
let localId = Api_Ids$ReventlessCore.alternateKey(id);
|
|
175
|
+
if (localId !== undefined) {
|
|
176
|
+
return [
|
|
177
|
+
localId,
|
|
178
|
+
(await load(localId))[0]
|
|
179
|
+
];
|
|
180
|
+
} else {
|
|
181
|
+
return [
|
|
182
|
+
id,
|
|
183
|
+
undefined
|
|
184
|
+
];
|
|
185
|
+
}
|
|
186
|
+
}));
|
|
141
187
|
return Stdlib_Array.filterMap(loaded, param => {
|
|
142
188
|
let id = param[0];
|
|
143
|
-
return Stdlib_Option.map(param[1], item => {
|
|
144
|
-
let obj = Stdlib_Option.
|
|
145
|
-
obj["id"] =
|
|
189
|
+
return Stdlib_Option.map(Stdlib_Option.filter(param[1], item => ownerAllows(ctx, item)), item => {
|
|
190
|
+
let obj = Stdlib_Option.mapOr(Stdlib_JSON.Decode.object(item), {}, prim => Object.assign({}, prim));
|
|
191
|
+
obj["id"] = id;
|
|
146
192
|
return obj;
|
|
147
193
|
});
|
|
148
194
|
});
|
|
@@ -156,7 +202,7 @@ function Make(Bus) {
|
|
|
156
202
|
}
|
|
157
203
|
let labelField = registryEntry !== undefined ? Stdlib_Option.getOr(registryEntry.labelField, "id") : "id";
|
|
158
204
|
let stateSchemaOpt = Plugin_Helpers$ReventlessCore.stateSchemaRegistry[name];
|
|
159
|
-
let capability = stateSchemaOpt !== undefined ? GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(stateSchemaOpt) : GraphQL_FragmentGenerator$ReventlessCore.emptyCapability;
|
|
205
|
+
let capability = stateSchemaOpt !== undefined ? GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(name, stateSchemaOpt) : GraphQL_FragmentGenerator$ReventlessCore.emptyCapability;
|
|
160
206
|
let fetchAllItems = async () => {
|
|
161
207
|
let makeStream = Bus.getQueryDbStream(name);
|
|
162
208
|
if (makeStream !== undefined) {
|
|
@@ -177,32 +223,38 @@ function Make(Bus) {
|
|
|
177
223
|
let typesToRegister = [GraphQL_FragmentGenerator$ReventlessCore.deriveConnectionFilterType(filterTypeName, capability)].concat(orderByTypes);
|
|
178
224
|
server.registerTypes(typesToRegister);
|
|
179
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
|
+
};
|
|
180
235
|
let resolver$1 = async (_root, args, ctx) => {
|
|
181
236
|
let match = await runInterceptor(ctx, args);
|
|
182
237
|
if (typeof match === "object") {
|
|
183
|
-
return
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
startCursor: null,
|
|
189
|
-
endCursor: null
|
|
190
|
-
}
|
|
191
|
-
};
|
|
238
|
+
return emptyConnection;
|
|
239
|
+
}
|
|
240
|
+
let decision = ownerDecision(ctx);
|
|
241
|
+
if (typeof decision !== "object" && decision !== "Unscoped") {
|
|
242
|
+
return emptyConnection;
|
|
192
243
|
}
|
|
244
|
+
let ownerScope = OwnerScope$Reventless.scopeOf(decision);
|
|
193
245
|
let argsDict = Stdlib_Option.getOr(Stdlib_JSON.Decode.object(args), {});
|
|
194
246
|
let decodeLocalId = id => Stdlib_Option.map(DomainGraphQL_Server$ReventlessLocal.decodeGlobalId(id), param => param[1]);
|
|
195
247
|
let listPage = Bus.getQueryDbListPage(name);
|
|
196
248
|
if (listPage !== undefined) {
|
|
197
|
-
let conn = listPage(argsDict, capability, labelField);
|
|
249
|
+
let conn = listPage(argsDict, capability, labelField, ownerScope);
|
|
198
250
|
if (conn !== undefined) {
|
|
199
251
|
return conn;
|
|
200
252
|
}
|
|
201
253
|
let items = await fetchAllItems();
|
|
202
|
-
return QueryDbListQuery$ReventlessCore.run(items, argsDict, capability, labelField, decodeLocalId);
|
|
254
|
+
return QueryDbListQuery$ReventlessCore.run(items, argsDict, capability, labelField, decodeLocalId, ownerScope);
|
|
203
255
|
}
|
|
204
256
|
let items$1 = await fetchAllItems();
|
|
205
|
-
return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, capability, labelField, decodeLocalId);
|
|
257
|
+
return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, capability, labelField, decodeLocalId, ownerScope);
|
|
206
258
|
};
|
|
207
259
|
match = [
|
|
208
260
|
sdl,
|
|
@@ -219,7 +271,8 @@ function Make(Bus) {
|
|
|
219
271
|
items: []
|
|
220
272
|
};
|
|
221
273
|
}
|
|
222
|
-
let
|
|
274
|
+
let all = await fetchAllItems();
|
|
275
|
+
let items = all.filter(item => ownerAllows(ctx, item));
|
|
223
276
|
return {
|
|
224
277
|
nextToken: null,
|
|
225
278
|
scannedCount: items.length,
|
|
@@ -277,7 +330,8 @@ function Make(Bus) {
|
|
|
277
330
|
if (ops === undefined) {
|
|
278
331
|
return emptyConn;
|
|
279
332
|
}
|
|
280
|
-
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));
|
|
281
335
|
let cursorFiltered;
|
|
282
336
|
if (isBackward) {
|
|
283
337
|
let beforeKey = Stdlib_Option.map(before, decodeCursor);
|
|
@@ -354,13 +408,14 @@ function Make(Bus) {
|
|
|
354
408
|
return [];
|
|
355
409
|
}
|
|
356
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));
|
|
357
412
|
let lookup = Bus.getQueryDbIndexLookup(name);
|
|
358
413
|
if (lookup !== undefined) {
|
|
359
|
-
return lookup(filterField, value);
|
|
414
|
+
return scoped(lookup(filterField, value));
|
|
360
415
|
}
|
|
361
416
|
let scanAll = Bus.getQueryDbScan(name);
|
|
362
417
|
if (scanAll !== undefined) {
|
|
363
|
-
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)));
|
|
364
419
|
} else {
|
|
365
420
|
return [];
|
|
366
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) {
|