@reventlessdev/reventless-aws 3.0.0-alpha.282 → 3.0.0-alpha.284

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
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.284 (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
+ * **spec:** let the environment name the groups exempt from owner scoping ([2de5c51](https://github.com/ReventlessDev/reventless-core/commit/2de5c5118347a46998f9ab603308fca09addf00b))
13
+
14
+
15
+ # 3.0.0-alpha.283 (2026-08-11)
16
+
17
+ ### Bug Fixes
18
+
19
+ * **api:** one id contract across the read-side query doors ([c52fac2](https://github.com/ReventlessDev/reventless-core/commit/c52fac2bee87b3dd1fa33b4970a74f3cd3866280))
20
+ ### Features
21
+
22
+ * **api:** infer a queryable's key field and publish its provenance ([c835a42](https://github.com/ReventlessDev/reventless-core/commit/c835a42a0da07cdc4a3f010212e1f340a4a0ca27))
23
+
24
+
6
25
  # 3.0.0-alpha.282 (2026-08-10)
7
26
 
8
27
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.282",
3
+ "version": "3.0.0-alpha.284",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -12,18 +12,18 @@
12
12
  "@aws-sdk/s3-request-presigner": "3.970.0",
13
13
  "sury": "11.0.0-alpha.4",
14
14
  "uuid": "^13.0.0",
15
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.6",
15
+ "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.7",
16
16
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
17
17
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
18
- "@reventlessdev/rescript-node": "2.0.0-alpha.3",
19
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.69",
20
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
18
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.71",
19
+ "@reventlessdev/rescript-node": "2.0.0-alpha.4",
21
20
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
22
- "@reventlessdev/reventless-infra": "3.0.0-alpha.133",
23
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.87",
24
- "@reventlessdev/reventless-spec": "3.0.0-alpha.107",
25
- "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
26
- "@reventlessdev/reventless-core": "3.0.0-alpha.223"
21
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
22
+ "@reventlessdev/reventless-core": "3.0.0-alpha.225",
23
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.135",
24
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.89",
25
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.109",
26
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.30"
27
27
  },
28
28
  "devDependencies": {
29
29
  "rescript": "12.3.0",
package/src/Platform.res CHANGED
@@ -1124,18 +1124,6 @@ module MakeWithConfig = (
1124
1124
 
1125
1125
  // In split mode, create a dedicated platform AppSync API and push the platform schema.
1126
1126
  // In unified mode, makePlatform is a no-op (schema stitching handled by events).
1127
- let makePlatform = (~version, ~plugins: array<module(PluginMaker)>) => {
1128
- log.info(~comp="Platform", `v${version}`)
1129
- let _ = plugins
1130
- // Retired with the merged-API cutover: on AWS the platform deploys with
1131
- // deployPlatform (staged) and each plugin with deployPlugin — makePlatform
1132
- // predates the merge path and never gained merged wiring.
1133
- failwith(
1134
- "makePlatform is not supported on AWS — deploy the platform with deployPlatform and " ++
1135
- "each plugin with deployPlugin (merged-API composition).",
1136
- )
1137
- }
1138
-
1139
1127
  // Optional host UI shell bundle: a static SPA (e.g. reventless-ui's host-shell)
1140
1128
  // hosted on the platform's CloudFront-fronted S3 bucket. The platform writes a
1141
1129
  // `config.json` next to `index.html` so the shell discovers `apiEndpoint` and
@@ -1154,6 +1142,10 @@ module MakeWithConfig = (
1154
1142
  // written; the shell treats the resulting 404 as "no hints" and boots
1155
1143
  // unchanged. Single-mode shape: one origin-relative file per deployment.
1156
1144
  uiHintsFile?: string,
1145
+ // Optional baked component manifest. Declared here so a deployment can state
1146
+ // it once; the emission that writes the file is not wired on this platform
1147
+ // yet, and a declaration without it writes nothing.
1148
+ bakedManifest?: ReventlessInfra.Platform.bakedManifest,
1157
1149
  // Optional geocoding place index, as returned by
1158
1150
  // `Capability_Geocoding_AwsLocation.make`. When set, the deploy provisions the
1159
1151
  // capability's client door — a Cognito-authenticated `Query.geocode` resolver
@@ -1198,6 +1190,23 @@ module MakeWithConfig = (
1198
1190
  shellConfig?: dict<JSON.t>,
1199
1191
  }
1200
1192
 
1193
+ let makePlatform = (
1194
+ ~version,
1195
+ ~plugins: array<module(PluginMaker)>,
1196
+ ~hostUiBundle as _: option<hostUiBundleConfig>=?,
1197
+ ) => {
1198
+ log.info(~comp="Platform", `v${version}`)
1199
+ let _ = plugins
1200
+ // Retired with the merged-API cutover: on AWS the platform deploys with
1201
+ // deployPlatform (staged) and each plugin with deployPlugin — makePlatform
1202
+ // predates the merge path and never gained merged wiring.
1203
+ failwith(
1204
+ "makePlatform is not supported on AWS — deploy the platform with deployPlatform and " ++
1205
+ "each plugin with deployPlugin (merged-API composition).",
1206
+ )
1207
+ }
1208
+
1209
+
1201
1210
  // Merged-mode outputs of deployPlatform — the merged API(s), plus the
1202
1211
  // deploy-time merge gates (Outputs that resolve on MERGE_SUCCESS and fail
1203
1212
  // the deploy on MERGE_FAILED; folded into the ARN exports so they are
@@ -753,7 +753,7 @@ function MakeWithConfig(Config) {
753
753
  return;
754
754
  }
755
755
  };
756
- let makePlatform = (version, plugins) => {
756
+ let makePlatform = (version, plugins, param) => {
757
757
  log.info("Platform", undefined, `v` + version);
758
758
  return Pervasives.failwith("makePlatform is not supported on AWS — deploy the platform with deployPlatform and each plugin with deployPlugin (merged-API composition).");
759
759
  };
@@ -2009,7 +2009,7 @@ function Make($star) {
2009
2009
  return;
2010
2010
  }
2011
2011
  };
2012
- let makePlatform = (version, plugins) => {
2012
+ let makePlatform = (version, plugins, param) => {
2013
2013
  log.info("Platform", undefined, `v` + version);
2014
2014
  return Pervasives.failwith("makePlatform is not supported on AWS — deploy the platform with deployPlatform and each plugin with deployPlugin (merged-API composition).");
2015
2015
  };
@@ -67,6 +67,7 @@ type pushdowns = {
67
67
  ~argsDict: dict<JSON.t>,
68
68
  ~capability: ReventlessCore.GraphQL_FragmentGenerator.serverCapability,
69
69
  ~labelField: string,
70
+ ~ownerScope: (string, string)=?,
70
71
  ) => promise<option<JSON.t>>,
71
72
  // Sub-id connection ({single}Items) — keyset over sub_key within a partition.
72
73
  itemsPage: (
@@ -74,6 +75,7 @@ type pushdowns = {
74
75
  ~subIdField: string,
75
76
  ~id: string,
76
77
  ~argsDict: dict<JSON.t>,
78
+ ~ownerScope: (string, string)=?,
77
79
  ) => promise<JSON.t>,
78
80
  // Full materialisation for the list fallback (shapes listPage declines).
79
81
  scanAll: (~readModelName: string) => promise<array<JSON.t>>,
@@ -89,6 +91,10 @@ type binding = {
89
91
  labelField: string,
90
92
  includeIdParam: bool,
91
93
  authorization: Reventless.Authorization.permission,
94
+ /** The state's `@owner` field, when it declares one. Derived at Lambda init
95
+ from the same schema `capability` comes from, so the two cannot disagree
96
+ about which fields this read model has. */
97
+ ownerField: option<string>,
92
98
  }
93
99
 
94
100
  // -- arg helpers -------------------------------------------------------------
@@ -162,21 +168,55 @@ let dispatch = async (
162
168
  | _ => JSON.Encode.array([])
163
169
  }
164
170
  | Allow =>
171
+ // Post-read narrowing for the doors that hand back rows rather than a page.
172
+ // Same decision as the list door takes; applied per row because there is no
173
+ // query to push it into.
174
+ let ownerAllows = (item: JSON.t) =>
175
+ switch payload.identity->Reventless.OwnerScope.decide(~ownerField=binding.ownerField) {
176
+ | Unscoped => true
177
+ | RefuseOwned => false
178
+ | ScopeTo(field, required) =>
179
+ item->argStr(field)->Option.mapOr(false, v => v == required)
180
+ }
165
181
  switch payload.kind {
166
182
  | "getById" =>
167
183
  let id = payload.arguments->argStr("id")->Option.getOr("")
168
- switch await binding.ops.load(id) {
169
- | Ok(items) =>
170
- switch items->Array.get(0) {
171
- | Some(item) => binding.includeIdParam ? withId(item, id) : item
172
- | None => JSON.Encode.null
184
+ let loadKey = async key =>
185
+ switch await binding.ops.load(key) {
186
+ | Ok(items) => items->Array.get(0)
187
+ | Error(_) => None
173
188
  }
174
- | Error(_) => JSON.Encode.null
189
+ // A caller holding a row's Relay global id must reach the row through this
190
+ // door too. Raw key first — it is what this door has always taken, and a
191
+ // key that merely looks like base64 must keep resolving to its own row.
192
+ let (resolvedKey, found) = switch (
193
+ await loadKey(id),
194
+ ReventlessCore.Api_Ids.alternateKey(id),
195
+ ) {
196
+ | (None, Some(localId)) => (localId, await loadKey(localId))
197
+ | (found, _) => (id, found)
198
+ }
199
+ switch found {
200
+ // A row the caller does not own answers as "not found". Saying "not yours"
201
+ // instead would make this door an oracle for which ids exist.
202
+ | Some(item) if !ownerAllows(item) => JSON.Encode.null
203
+ | Some(item) => binding.includeIdParam ? withId(item, resolvedKey) : item
204
+ | None => JSON.Encode.null
175
205
  }
176
206
 
177
207
  | "byIds" =>
178
208
  let ids = payload.arguments->argStrs("ids")
179
- JSON.Encode.array(await binding.pushdowns.byIds(~readModelName=rm, ids))
209
+ let found = await binding.pushdowns.byIds(~readModelName=rm, ids)
210
+ // Only pay for the second lookup when the first came up short, and only for
211
+ // the ids that are global ones.
212
+ let missing =
213
+ found->Array.length < ids->Array.length
214
+ ? ids->Array.filterMap(ReventlessCore.Api_Ids.alternateKey)
215
+ : []
216
+ let extra = missing->Array.length > 0
217
+ ? await binding.pushdowns.byIds(~readModelName=rm, missing)
218
+ : []
219
+ JSON.Encode.array(Array.concat(found, extra)->Array.filter(ownerAllows))
180
220
 
181
221
  | "items" =>
182
222
  // Sub-id connection: {single}Items(id, filter, first/after/last/before).
@@ -184,12 +224,17 @@ let dispatch = async (
184
224
  switch binding.subIdField {
185
225
  | Some(subIdField) =>
186
226
  let id = payload.arguments->argStr("id")->Option.getOr("")
187
- await binding.pushdowns.itemsPage(
188
- ~readModelName=rm,
189
- ~subIdField,
190
- ~id,
191
- ~argsDict=payload.arguments->argObj,
192
- )
227
+ switch payload.identity->Reventless.OwnerScope.decide(~ownerField=binding.ownerField) {
228
+ | RefuseOwned => emptyConnection()
229
+ | decision =>
230
+ await binding.pushdowns.itemsPage(
231
+ ~readModelName=rm,
232
+ ~subIdField,
233
+ ~id,
234
+ ~argsDict=payload.arguments->argObj,
235
+ ~ownerScope=?Reventless.OwnerScope.scopeOf(decision),
236
+ )
237
+ }
193
238
  | None => emptyConnection()
194
239
  }
195
240
 
@@ -227,33 +272,42 @@ let dispatch = async (
227
272
  | _ => true
228
273
  }
229
274
  if authorized {
230
- JSON.Encode.array(await binding.pushdowns.indexLookup(~readModelName=rm, field, value))
275
+ JSON.Encode.array(
276
+ (await binding.pushdowns.indexLookup(~readModelName=rm, field, value))->Array.filter(
277
+ ownerAllows,
278
+ ),
279
+ )
231
280
  } else {
232
281
  JSON.Encode.array([])
233
282
  }
234
283
 
235
284
  | "list" =>
236
285
  let argsDict = payload.arguments->argObj
237
- switch await binding.pushdowns.listPage(
238
- ~readModelName=rm,
239
- ~argsDict,
240
- ~capability=binding.capability,
241
- ~labelField=binding.labelField,
242
- ) {
243
- | Some(conn) => conn
244
- | None =>
245
- // Shapes listPage declines (search/searchPrefix/ids/backward) → run the
246
- // shared spec over the materialised model. No Relay global-id decoding
247
- // in the Lambda, so `decodeLocalId` is a no-op (the `ids` filter still
248
- // matches raw item ids).
249
- let items = await binding.pushdowns.scanAll(~readModelName=rm)
250
- ReventlessCore.QueryDbListQuery.run(
251
- ~items,
286
+ switch payload.identity->Reventless.OwnerScope.decide(~ownerField=binding.ownerField) {
287
+ | RefuseOwned => emptyConnection()
288
+ | decision =>
289
+ let ownerScope = Reventless.OwnerScope.scopeOf(decision)
290
+ switch await binding.pushdowns.listPage(
291
+ ~readModelName=rm,
252
292
  ~argsDict,
253
293
  ~capability=binding.capability,
254
294
  ~labelField=binding.labelField,
255
- ~decodeLocalId=_ => None,
256
- )
295
+ ~ownerScope?,
296
+ ) {
297
+ | Some(conn) => conn
298
+ | None =>
299
+ // Shapes listPage declines (search/searchPrefix/ids/backward) → run the
300
+ // shared spec over the materialised model, which decodes Relay global
301
+ // ids the same way every other door now does.
302
+ let items = await binding.pushdowns.scanAll(~readModelName=rm)
303
+ ReventlessCore.QueryDbListQuery.run(
304
+ ~items,
305
+ ~argsDict,
306
+ ~capability=binding.capability,
307
+ ~labelField=binding.labelField,
308
+ ~ownerScope?,
309
+ )
310
+ }
257
311
  }
258
312
 
259
313
  // Cross-table single-ID field resolver (@resolves). `binding` is the TARGET
@@ -6,6 +6,8 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
7
7
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
8
8
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
9
+ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
10
+ import * as Api_Ids$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Ids.res.mjs";
9
11
  import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
10
12
  import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
11
13
  import * as QueryDb_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Callback.res.mjs";
@@ -70,23 +72,45 @@ async function dispatch(binding, lookupBindingOpt, payload) {
70
72
  let rm = payload.readModelName;
71
73
  let match = await runInterceptor(binding, payload);
72
74
  if (typeof match !== "object") {
75
+ let ownerAllows = item => {
76
+ let match = OwnerScope$Reventless.decide(payload.identity, binding.ownerField, undefined);
77
+ if (typeof match !== "object") {
78
+ return match === "Unscoped";
79
+ }
80
+ let required = match._1;
81
+ return Stdlib_Option.mapOr(argStr(item, match._0), false, v => v === required);
82
+ };
73
83
  let other = payload.kind;
74
84
  switch (other) {
75
85
  case "byIds" :
76
86
  let ids = argStrs(payload.arguments, "ids");
77
- return await binding.pushdowns.byIds(rm, ids);
87
+ let found = await binding.pushdowns.byIds(rm, ids);
88
+ let missing = found.length < ids.length ? Stdlib_Array.filterMap(ids, Api_Ids$ReventlessCore.alternateKey) : [];
89
+ let extra = missing.length !== 0 ? await binding.pushdowns.byIds(rm, missing) : [];
90
+ return found.concat(extra).filter(ownerAllows);
78
91
  case "getById" :
79
92
  let id = Stdlib_Option.getOr(argStr(payload.arguments, "id"), "");
80
- let items = await binding.ops.load(id);
81
- if (items.TAG !== "Ok") {
82
- return null;
83
- }
84
- let item = items._0[0];
85
- if (item !== undefined) {
93
+ let loadKey = async key => {
94
+ let items = await binding.ops.load(key);
95
+ if (items.TAG === "Ok") {
96
+ return items._0[0];
97
+ }
98
+ };
99
+ let match$1 = await loadKey(id);
100
+ let match$2 = Api_Ids$ReventlessCore.alternateKey(id);
101
+ let match$3 = match$1 !== undefined || match$2 === undefined ? [
102
+ id,
103
+ match$1
104
+ ] : [
105
+ match$2,
106
+ await loadKey(match$2)
107
+ ];
108
+ let found$1 = match$3[1];
109
+ if (found$1 !== undefined && ownerAllows(found$1)) {
86
110
  if (binding.includeIdParam) {
87
- return withId(item, id);
111
+ return withId(found$1, match$3[0]);
88
112
  } else {
89
- return item;
113
+ return found$1;
90
114
  }
91
115
  } else {
92
116
  return null;
@@ -95,14 +119,14 @@ async function dispatch(binding, lookupBindingOpt, payload) {
95
119
  let indexName = Stdlib_Option.getOr(payload.index, "");
96
120
  let field = Stdlib_Option.getOr(Stdlib_Option.flatMap(binding.indexes.find(ic => ic.index === indexName), ic => ic.idField), indexName);
97
121
  let value = Stdlib_Option.getOr(argStr(payload.arguments, indexName), "");
98
- let match$1 = payload.authGroup;
99
- let match$2 = payload.authTable;
122
+ let match$4 = payload.authGroup;
123
+ let match$5 = payload.authTable;
100
124
  let authorized;
101
- if (match$1 !== undefined && match$2 !== undefined && Identity$Reventless.hasGroup(payload.identity, match$1)) {
102
- let authBinding = lookupBinding(match$2);
125
+ if (match$4 !== undefined && match$5 !== undefined && Identity$Reventless.hasGroup(payload.identity, match$4)) {
126
+ let authBinding = lookupBinding(match$5);
103
127
  if (authBinding !== undefined) {
104
- let items$1 = await authBinding.ops.load(value);
105
- authorized = items$1.TAG === "Ok" ? Stdlib_Option.mapOr(Stdlib_Option.flatMap(items$1._0[0], row => argStr(row, authIdField(match$1))), false, owner => owner === payload.identity.username) : false;
128
+ let items = await authBinding.ops.load(value);
129
+ authorized = items.TAG === "Ok" ? Stdlib_Option.mapOr(Stdlib_Option.flatMap(items._0[0], row => argStr(row, authIdField(match$4))), false, owner => owner === payload.identity.username) : false;
106
130
  } else {
107
131
  authorized = false;
108
132
  }
@@ -110,7 +134,7 @@ async function dispatch(binding, lookupBindingOpt, payload) {
110
134
  authorized = true;
111
135
  }
112
136
  if (authorized) {
113
- return await binding.pushdowns.indexLookup(rm, field, value);
137
+ return (await binding.pushdowns.indexLookup(rm, field, value)).filter(ownerAllows);
114
138
  } else {
115
139
  return [];
116
140
  }
@@ -120,15 +144,26 @@ async function dispatch(binding, lookupBindingOpt, payload) {
120
144
  return emptyConnection();
121
145
  }
122
146
  let id$1 = Stdlib_Option.getOr(argStr(payload.arguments, "id"), "");
123
- return await binding.pushdowns.itemsPage(rm, subIdField, id$1, argObj(payload.arguments));
147
+ let decision = OwnerScope$Reventless.decide(payload.identity, binding.ownerField, undefined);
148
+ if (typeof decision !== "object" && decision !== "Unscoped") {
149
+ return emptyConnection();
150
+ }
151
+ return await binding.pushdowns.itemsPage(rm, subIdField, id$1, argObj(payload.arguments), OwnerScope$Reventless.scopeOf(decision));
152
+ break;
124
153
  case "list" :
125
154
  let argsDict = argObj(payload.arguments);
126
- let conn = await binding.pushdowns.listPage(rm, argsDict, binding.capability, binding.labelField);
155
+ let decision$1 = OwnerScope$Reventless.decide(payload.identity, binding.ownerField, undefined);
156
+ if (typeof decision$1 !== "object" && decision$1 !== "Unscoped") {
157
+ return emptyConnection();
158
+ }
159
+ let ownerScope = OwnerScope$Reventless.scopeOf(decision$1);
160
+ let conn = await binding.pushdowns.listPage(rm, argsDict, binding.capability, binding.labelField, ownerScope);
127
161
  if (conn !== undefined) {
128
162
  return conn;
129
163
  }
130
- let items$2 = await binding.pushdowns.scanAll(rm);
131
- return QueryDbListQuery$ReventlessCore.run(items$2, argsDict, binding.capability, binding.labelField, param => {});
164
+ let items$1 = await binding.pushdowns.scanAll(rm);
165
+ return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, binding.capability, binding.labelField, undefined, ownerScope);
166
+ break;
132
167
  case "resolveMany" :
133
168
  let target = Stdlib_Option.getOr(payload.target, rm);
134
169
  let source = Stdlib_Option.getOr(payload.source, null);
@@ -139,30 +174,30 @@ async function dispatch(binding, lookupBindingOpt, payload) {
139
174
  let source$1 = Stdlib_Option.getOr(payload.source, null);
140
175
  let key = Stdlib_Option.getOr(argStr(source$1, Stdlib_Option.getOr(payload.sourceIdField, "")), "");
141
176
  let ix = payload.targetIndex;
142
- let items$3;
177
+ let items$2;
143
178
  if (ix !== undefined) {
144
- items$3 = await binding.pushdowns.indexLookup(target$1, Stdlib_Option.getOr(payload.targetIndexIdField, ix), key);
179
+ items$2 = await binding.pushdowns.indexLookup(target$1, Stdlib_Option.getOr(payload.targetIndexIdField, ix), key);
145
180
  } else {
146
- let items$4 = await binding.ops.load(key);
147
- items$3 = items$4.TAG === "Ok" ? items$4._0 : [];
181
+ let items$3 = await binding.ops.load(key);
182
+ items$2 = items$3.TAG === "Ok" ? items$3._0 : [];
148
183
  }
149
- let match$3 = payload.sourceSubId;
150
- let match$4 = binding.subIdField;
184
+ let match$6 = payload.sourceSubId;
185
+ let match$7 = binding.subIdField;
151
186
  let filtered;
152
- if (match$3 !== undefined && match$4 !== undefined) {
153
- let name = match$3.name;
154
- let tmp = match$3.kind === "arg" ? argStr(payload.arguments, name) : argStr(source$1, name);
187
+ if (match$6 !== undefined && match$7 !== undefined) {
188
+ let name = match$6.name;
189
+ let tmp = match$6.kind === "arg" ? argStr(payload.arguments, name) : argStr(source$1, name);
155
190
  let subVal = Stdlib_Option.getOr(tmp, "");
156
- filtered = items$3.filter(it => Stdlib_Option.getOr(argStr(it, match$4), "") === subVal);
191
+ filtered = items$2.filter(it => Stdlib_Option.getOr(argStr(it, match$7), "") === subVal);
157
192
  } else {
158
- filtered = items$3;
193
+ filtered = items$2;
159
194
  }
160
195
  if (Stdlib_Option.getOr(payload.multi, false)) {
161
196
  return filtered;
162
197
  }
163
- let item$1 = filtered[0];
164
- if (item$1 !== undefined) {
165
- return item$1;
198
+ let item = filtered[0];
199
+ if (item !== undefined) {
200
+ return item;
166
201
  } else {
167
202
  return null;
168
203
  }
@@ -172,8 +207,8 @@ async function dispatch(binding, lookupBindingOpt, payload) {
172
207
  }
173
208
  } else {
174
209
  let isMulti = Stdlib_Option.getOr(payload.multi, false);
175
- let match$5 = payload.kind;
176
- switch (match$5) {
210
+ let match$8 = payload.kind;
211
+ switch (match$8) {
177
212
  case "getById" :
178
213
  return null;
179
214
  case "items" :
@@ -205,7 +205,7 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
205
205
  // with the SDL emitted by GraphQL_FragmentGenerator at runtime.
206
206
  let stateSchemaOpt = ReventlessCore.Plugin_Helpers.stateSchemaRegistry->Dict.get(name)
207
207
  let capability = switch stateSchemaOpt {
208
- | Some(s) => ReventlessCore.GraphQL_FragmentGenerator.deriveServerCapability(s)
208
+ | Some(s) => ReventlessCore.GraphQL_FragmentGenerator.deriveServerCapability(~entityName=name, s)
209
209
  | None => ReventlessCore.GraphQL_FragmentGenerator.emptyCapability
210
210
  }
211
211
  let filterFieldNames = capability.filterFields->Array.map(f => f.name)
@@ -238,6 +238,39 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
238
238
  // (prefix-agnostic: real plugin rows always carry `name`, internal rows never do).
239
239
  // See docs/plans/platform-plugins-admin-connection-null-rows.md.
240
240
  let requireAttribute = internalRowRequiredAttr(name)
241
+ // Derived from the same schema `capability` came from, so the two cannot
242
+ // disagree about this read model's fields.
243
+ let ownerField = switch stateSchemaOpt {
244
+ | Some(s) => Reventless.Owner.fieldNames(s)->Array.get(0)
245
+ | None => None
246
+ }
247
+ // A DynamoDB FilterExpression is applied AFTER the page is read, so a scoped
248
+ // list over a table with no index on the owner field returns short pages —
249
+ // correct, but pathological once a caller owns a small fraction of the rows.
250
+ // Warned rather than refused: the resolver does serve the query, and a
251
+ // deployment may legitimately accept the cost on a small table. Mirrors the
252
+ // `@scanSort` alignment warning above, which exists for the same class of
253
+ // "works, but scans" mistake.
254
+ ReventlessCore.OwnerScopeDiagnostics.warnIfNoElevatedGroups(
255
+ ~comp="QueryDbResolvers_AppSync",
256
+ ~view=name,
257
+ ~ownerField,
258
+ )
259
+ switch ownerField {
260
+ | Some(f) =>
261
+ let indexed =
262
+ indexes->Array.some(ic => ic.idField->Option.getOr(ic.index) == f) ||
263
+ subIdField->Option.getOr("") == f
264
+ if !indexed {
265
+ log.warn(
266
+ ~comp="QueryDbResolvers_AppSync",
267
+ `${name}: @owner field "${f}" is not the key of any index on this table. ` ++
268
+ "Owner-scoped reads will Scan and filter, so pages shrink as the caller's " ++
269
+ "share of the rows falls. Add an @index on that field before this read model grows.",
270
+ )
271
+ }
272
+ | None => ()
273
+ }
241
274
  let resolverAll = makeQueryResolver(
242
275
  ~resolverName=fieldNameForAll->String.capitalize,
243
276
  ~field=fieldNameForAll->Pulumi.Input.make,
@@ -248,6 +281,8 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
248
281
  ~rangeFields=rangeFieldNames,
249
282
  ~sortFields=sortFieldNames,
250
283
  ~requireAttribute?,
284
+ ~ownerField?,
285
+ ~elevatedGroups=Reventless.OwnerScope.elevatedGroups(),
251
286
  )
252
287
  } else {
253
288
  Resolver.Functions.listAllItems
@@ -3,7 +3,9 @@
3
3
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
6
+ import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
6
7
  import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
8
+ import * as OwnerScope$Reventless from "@reventlessdev/reventless-spec/src/types/OwnerScope.res.mjs";
7
9
  import * as Adapter$ReventlessCore from "@reventlessdev/reventless-core/src/adapter/Adapter.res.mjs";
8
10
  import * as AppSync_Function$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/AppSync/AppSync_Function.res.mjs";
9
11
  import * as Util_AppSync$ReventlessAws from "../../util/Util_AppSync.res.mjs";
@@ -12,6 +14,7 @@ import * as Util_QueryDb$ReventlessCore from "@reventlessdev/reventless-core/src
12
14
  import * as AppSync_DataSource$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/AppSync/AppSync_DataSource.res.mjs";
13
15
  import * as Plugin_Helpers$ReventlessCore from "@reventlessdev/reventless-core/src/plugin/component/Plugin_Helpers.res.mjs";
14
16
  import * as AppSync_Resolver_Functions$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/AppSync/AppSync_Resolver_Functions.res.mjs";
17
+ import * as OwnerScopeDiagnostics$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/OwnerScopeDiagnostics.res.mjs";
15
18
  import * as AppSync_Resolver_Retrying$ReventlessAws from "../Api/AppSync_Resolver_Retrying.res.mjs";
16
19
  import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
17
20
  import * as QueryInterceptor_Provisioning$ReventlessAws from "./QueryInterceptor_Provisioning.res.mjs";
@@ -91,7 +94,7 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
91
94
  let resolverByIdMultiple = includeIdParam ? Stdlib_Option.map(subIdField, sortField => makeQueryResolver(Stdlib_String.capitalize(fieldNameForSingle) + "Items", fieldNameForSingle + "Items", AppSync_Resolver_Functions$PulumiAws.queryItemsWithSortConditions(sortField))) : undefined;
92
95
  let labelField = registryEntry !== undefined ? Stdlib_Option.getOr(registryEntry.labelField, "id") : "id";
93
96
  let stateSchemaOpt = Plugin_Helpers$ReventlessCore.stateSchemaRegistry[name$1];
94
- let capability = stateSchemaOpt !== undefined ? GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(stateSchemaOpt) : GraphQL_FragmentGenerator$ReventlessCore.emptyCapability;
97
+ let capability = stateSchemaOpt !== undefined ? GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(name$1, stateSchemaOpt) : GraphQL_FragmentGenerator$ReventlessCore.emptyCapability;
95
98
  let filterFieldNames = capability.filterFields.map(f => f.name);
96
99
  let rangeFieldNames = Stdlib_Array.filterMap(capability.filterFields, f => {
97
100
  if (f.range) {
@@ -106,7 +109,15 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
106
109
  GraphQL_FragmentGenerator$ReventlessCore.validateScanSortAlignment(stateSchemaOpt, name$1, knownSortFields).forEach(msg => log.warn("QueryDbResolvers_AppSync", undefined, msg));
107
110
  }
108
111
  let requireAttribute = internalRowRequiredAttr(name$1);
109
- let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
112
+ let ownerField = stateSchemaOpt !== undefined ? Owner$Reventless.fieldNames(stateSchemaOpt)[0] : undefined;
113
+ OwnerScopeDiagnostics$ReventlessCore.warnIfNoElevatedGroups("QueryDbResolvers_AppSync", name$1, ownerField);
114
+ if (ownerField !== undefined) {
115
+ let indexed = indexes.some(ic => Stdlib_Option.getOr(ic.idField, ic.index) === ownerField) || Stdlib_Option.getOr(subIdField, "") === ownerField;
116
+ if (!indexed) {
117
+ log.warn("QueryDbResolvers_AppSync", undefined, name$1 + `: @owner field "` + ownerField + `" is not the key of any index on this table. ` + "Owner-scoped reads will Scan and filter, so pages shrink as the caller's share of the rows falls. Add an @index on that field before this read model grows.");
118
+ }
119
+ }
120
+ let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute, ownerField, OwnerScope$Reventless.elevatedGroups()) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
110
121
  let resolversByIndex = indexes.map(indexConfig => {
111
122
  let index = indexConfig.index;
112
123
  let stripLeadingBy = s => {
@@ -144,11 +144,15 @@ let registerBinding = (
144
144
  indexes,
145
145
  subIdField,
146
146
  capability: ReventlessCore.GraphQL_FragmentGenerator.deriveServerCapability(
147
+ ~entityName=entry.readModelName,
147
148
  spec.stateSchema,
148
149
  ),
149
150
  labelField: entry.labelField,
150
151
  includeIdParam: entry.includeIdParam,
151
152
  authorization: spec.authorization,
153
+ // From the same schema `capability` is derived from, one line above, so the
154
+ // two cannot end up disagreeing about this read model's fields.
155
+ ownerField: Reventless.Owner.fieldNames(spec.stateSchema)->Array.get(0),
152
156
  },
153
157
  )
154
158
  logDebug("registered resolver binding for " ++ entry.readModelName, {comp: "PgQueryResolver"})
@@ -3,6 +3,7 @@
3
3
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
6
7
  import * as PgRuntime$ReventlessAws from "../Postgres/PgRuntime.res.mjs";
7
8
  import * as HandlerFactoryHelpersMjs from "./HandlerFactoryHelpers.mjs";
8
9
  import * as PgQueryResolver_Lambda$ReventlessAws from "../QueryDb/PgQueryResolver_Lambda.res.mjs";
@@ -69,10 +70,11 @@ function registerBinding(pushdowns, pgConnection, entry, spec) {
69
70
  pushdowns: pushdowns,
70
71
  indexes: indexes,
71
72
  subIdField: subIdField,
72
- capability: GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(spec.stateSchema),
73
+ capability: GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(entry.readModelName, spec.stateSchema),
73
74
  labelField: entry.labelField,
74
75
  includeIdParam: entry.includeIdParam,
75
- authorization: spec.authorization
76
+ authorization: spec.authorization,
77
+ ownerField: Owner$Reventless.fieldNames(spec.stateSchema)[0]
76
78
  });
77
79
  HandlerFactoryHelpersMjs.log.debug("registered resolver binding for " + entry.readModelName, {
78
80
  comp: "PgQueryResolver"
@@ -91,4 +93,4 @@ export {
91
93
  makePushdowns,
92
94
  registerBinding,
93
95
  }
94
- /* PgRuntime-ReventlessAws Not a pure module */
96
+ /* Owner-Reventless Not a pure module */