@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.
@@ -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
- let obj = item->JSON.Decode.object->Option.getOr(Dict.make())
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 items =
195
- await ops.loadStream(id)
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
- let obj = item->JSON.Decode.object->Option.getOr(Dict.make())
203
- obj->Dict.set("id", encodeId(~typeName=returnTypeName, ~localId=id)->JSON.Encode.string)
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 loaded = await ids->Array.map(id =>
241
- ops.loadStream(id)
310
+ let load = key =>
311
+ ops.loadStream(key)
242
312
  ->Stream.runCollect
243
313
  ->Effect.catchAll(_ => Effect.succeed([]))
244
314
  ->Effect.runPromise
245
- ->Promise.thenResolve(items => (id, items->Array.get(0)))
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->Option.map(item => {
250
- let obj = item->JSON.Decode.object->Option.getOr(Dict.make())
251
- obj->Dict.set(
252
- "id",
253
- encodeId(~typeName=returnTypeName, ~localId=id)->JSON.Encode.string,
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
- let argsDict = args->JSON.Decode.object->Option.getOr(Dict.make())
332
- // Prefer a backend list push-down (SQLite builds json_extract predicates
333
- // + ORDER BY … LIMIT so it never materialises the whole read model). When
334
- // the backend can't serve this query shape it returns None and we fall
335
- // back to materialising the full model and running the shared
336
- // `QueryDbListQuery` spec over it (the same code the in-memory backend and
337
- // the push-down are tested against).
338
- let decodeLocalId = id =>
339
- DomainGraphQL_Server.decodeGlobalId(id)->Option.map(((_, lid)) => lid)
340
- switch Bus.getQueryDbListPage(name) {
341
- | Some(listPage) =>
342
- switch listPage(~argsDict, ~capability, ~labelField) {
343
- | Some(conn) => conn
410
+ switch ownerDecision(~ctx) {
411
+ | RefuseOwned => emptyConnection
412
+ | decision =>
413
+ let ownerScope = Reventless.OwnerScope.scopeOf(decision)
414
+ let argsDict = args->JSON.Decode.object->Option.getOr(Dict.make())
415
+ // Prefer a backend list push-down (SQLite builds json_extract predicates
416
+ // + ORDER BY … LIMIT so it never materialises the whole read model). When
417
+ // the backend can't serve this query shape it returns None and we fall
418
+ // back to materialising the full model and running the shared
419
+ // `QueryDbListQuery` spec over it (the same code the in-memory backend and
420
+ // the push-down are tested against).
421
+ //
422
+ // `ownerScope` goes to BOTH arms. Passing it only to the fallback would
423
+ // scope the exceptional path and leave the normal one — the push-down —
424
+ // returning everything, which is the worst possible place for the gap
425
+ // because the fallback is what the tests most easily exercise.
426
+ let decodeLocalId = id =>
427
+ DomainGraphQL_Server.decodeGlobalId(id)->Option.map(((_, lid)) => lid)
428
+ switch Bus.getQueryDbListPage(name) {
429
+ | Some(listPage) =>
430
+ switch listPage(~argsDict, ~capability, ~labelField, ~ownerScope?) {
431
+ | Some(conn) => conn
432
+ | None =>
433
+ let items = await fetchAllItems()
434
+ QueryDbListQuery.run(
435
+ ~items,
436
+ ~argsDict,
437
+ ~capability,
438
+ ~labelField,
439
+ ~decodeLocalId,
440
+ ~ownerScope?,
441
+ )
442
+ }
344
443
  | None =>
345
444
  let items = await fetchAllItems()
346
- QueryDbListQuery.run(~items, ~argsDict, ~capability, ~labelField, ~decodeLocalId)
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 items = await fetchAllItems()
465
+ let all = await fetchAllItems()
466
+ // The legacy shape has no push-down to reach, so the narrowing is a plain
467
+ // filter here. `scannedCount` counts what is returned, not what was read:
468
+ // the pre-scoping total would tell a caller how many rows they may not see.
469
+ let items = all->Array.filter(item => ownerAllows(~ctx, item))
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 allItems =
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.getOr(Stdlib_JSON.Decode.object(item), {});
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 items = await Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(id)), param => Effect.succeed([])));
113
- let item = items[0];
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.getOr(Stdlib_JSON.Decode.object(item), {});
121
- obj["id"] = encodeId(returnTypeName, 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 loaded = await Promise.all(ids.map(id => Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(id)), param => Effect.succeed([]))).then(items => [
138
- id,
139
- items[0]
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.getOr(Stdlib_JSON.Decode.object(item), {});
145
- obj["id"] = encodeId(returnTypeName, 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
- edges: [],
185
- pageInfo: {
186
- hasNextPage: false,
187
- hasPreviousPage: false,
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 items = await fetchAllItems();
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 allItems = await Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(id)), param => Effect.succeed([])));
333
+ let loaded = await Effect.runPromise(Effect.catchAll(Stream.runCollect(ops.loadStream(id)), param => Effect.succeed([])));
334
+ let allItems = loaded.filter(item => ownerAllows(ctx, item));
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) {