@reventlessdev/reventless-aws 3.0.0-alpha.281 → 3.0.0-alpha.283

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,23 @@
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.283 (2026-08-11)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **api:** one id contract across the read-side query doors ([c52fac2](https://github.com/ReventlessDev/reventless-core/commit/c52fac2bee87b3dd1fa33b4970a74f3cd3866280))
11
+ ### Features
12
+
13
+ * **api:** infer a queryable's key field and publish its provenance ([c835a42](https://github.com/ReventlessDev/reventless-core/commit/c835a42a0da07cdc4a3f010212e1f340a4a0ca27))
14
+
15
+
16
+ # 3.0.0-alpha.282 (2026-08-10)
17
+
18
+ ### Bug Fixes
19
+
20
+ * **aws:** load query-interceptor extensions in init, and let it log ([c93612b](https://github.com/ReventlessDev/reventless-core/commit/c93612b3d2208cdc3f1c4b4dbef615b974c5e74a))
21
+
22
+
6
23
  # 3.0.0-alpha.281 (2026-08-10)
7
24
 
8
25
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.281",
3
+ "version": "3.0.0-alpha.283",
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
- "@reventlessdev/rescript-node": "2.0.0-alpha.3",
18
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.69",
17
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.70",
18
+ "@reventlessdev/rescript-node": "2.0.0-alpha.4",
19
19
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
20
20
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
21
- "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
22
- "@reventlessdev/reventless-core": "3.0.0-alpha.223",
23
- "@reventlessdev/reventless-infra": "3.0.0-alpha.133",
21
+ "@reventlessdev/reventless-core": "3.0.0-alpha.224",
24
22
  "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
25
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.87",
26
- "@reventlessdev/reventless-spec": "3.0.0-alpha.107"
23
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.88",
24
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.108",
25
+ "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
26
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.134"
27
27
  },
28
28
  "devDependencies": {
29
29
  "rescript": "12.3.0",
@@ -165,18 +165,39 @@ let dispatch = async (
165
165
  switch payload.kind {
166
166
  | "getById" =>
167
167
  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
168
+ let loadKey = async key =>
169
+ switch await binding.ops.load(key) {
170
+ | Ok(items) => items->Array.get(0)
171
+ | Error(_) => None
173
172
  }
174
- | Error(_) => JSON.Encode.null
173
+ // A caller holding a row's Relay global id must reach the row through this
174
+ // door too. Raw key first — it is what this door has always taken, and a
175
+ // key that merely looks like base64 must keep resolving to its own row.
176
+ let (resolvedKey, found) = switch (
177
+ await loadKey(id),
178
+ ReventlessCore.Api_Ids.alternateKey(id),
179
+ ) {
180
+ | (None, Some(localId)) => (localId, await loadKey(localId))
181
+ | (found, _) => (id, found)
182
+ }
183
+ switch found {
184
+ | Some(item) => binding.includeIdParam ? withId(item, resolvedKey) : item
185
+ | None => JSON.Encode.null
175
186
  }
176
187
 
177
188
  | "byIds" =>
178
189
  let ids = payload.arguments->argStrs("ids")
179
- JSON.Encode.array(await binding.pushdowns.byIds(~readModelName=rm, ids))
190
+ let found = await binding.pushdowns.byIds(~readModelName=rm, ids)
191
+ // Only pay for the second lookup when the first came up short, and only for
192
+ // the ids that are global ones.
193
+ let missing =
194
+ found->Array.length < ids->Array.length
195
+ ? ids->Array.filterMap(ReventlessCore.Api_Ids.alternateKey)
196
+ : []
197
+ let extra = missing->Array.length > 0
198
+ ? await binding.pushdowns.byIds(~readModelName=rm, missing)
199
+ : []
200
+ JSON.Encode.array(Array.concat(found, extra))
180
201
 
181
202
  | "items" =>
182
203
  // Sub-id connection: {single}Items(id, filter, first/after/last/before).
@@ -243,16 +264,14 @@ let dispatch = async (
243
264
  | Some(conn) => conn
244
265
  | None =>
245
266
  // 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).
267
+ // shared spec over the materialised model, which decodes Relay global
268
+ // ids the same way every other door now does.
249
269
  let items = await binding.pushdowns.scanAll(~readModelName=rm)
250
270
  ReventlessCore.QueryDbListQuery.run(
251
271
  ~items,
252
272
  ~argsDict,
253
273
  ~capability=binding.capability,
254
274
  ~labelField=binding.labelField,
255
- ~decodeLocalId=_ => None,
256
275
  )
257
276
  }
258
277
 
@@ -6,6 +6,7 @@ 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 Api_Ids$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Ids.res.mjs";
9
10
  import * as Authorization$Reventless from "@reventlessdev/reventless-spec/src/types/Authorization.res.mjs";
10
11
  import * as QueryDbListQuery$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/QueryDbListQuery.res.mjs";
11
12
  import * as QueryDb_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Callback.res.mjs";
@@ -74,19 +75,33 @@ async function dispatch(binding, lookupBindingOpt, payload) {
74
75
  switch (other) {
75
76
  case "byIds" :
76
77
  let ids = argStrs(payload.arguments, "ids");
77
- return await binding.pushdowns.byIds(rm, ids);
78
+ let found = await binding.pushdowns.byIds(rm, ids);
79
+ let missing = found.length < ids.length ? Stdlib_Array.filterMap(ids, Api_Ids$ReventlessCore.alternateKey) : [];
80
+ let extra = missing.length !== 0 ? await binding.pushdowns.byIds(rm, missing) : [];
81
+ return found.concat(extra);
78
82
  case "getById" :
79
83
  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) {
84
+ let loadKey = async key => {
85
+ let items = await binding.ops.load(key);
86
+ if (items.TAG === "Ok") {
87
+ return items._0[0];
88
+ }
89
+ };
90
+ let match$1 = await loadKey(id);
91
+ let match$2 = Api_Ids$ReventlessCore.alternateKey(id);
92
+ let match$3 = match$1 !== undefined || match$2 === undefined ? [
93
+ id,
94
+ match$1
95
+ ] : [
96
+ match$2,
97
+ await loadKey(match$2)
98
+ ];
99
+ let found$1 = match$3[1];
100
+ if (found$1 !== undefined) {
86
101
  if (binding.includeIdParam) {
87
- return withId(item, id);
102
+ return withId(found$1, match$3[0]);
88
103
  } else {
89
- return item;
104
+ return found$1;
90
105
  }
91
106
  } else {
92
107
  return null;
@@ -95,14 +110,14 @@ async function dispatch(binding, lookupBindingOpt, payload) {
95
110
  let indexName = Stdlib_Option.getOr(payload.index, "");
96
111
  let field = Stdlib_Option.getOr(Stdlib_Option.flatMap(binding.indexes.find(ic => ic.index === indexName), ic => ic.idField), indexName);
97
112
  let value = Stdlib_Option.getOr(argStr(payload.arguments, indexName), "");
98
- let match$1 = payload.authGroup;
99
- let match$2 = payload.authTable;
113
+ let match$4 = payload.authGroup;
114
+ let match$5 = payload.authTable;
100
115
  let authorized;
101
- if (match$1 !== undefined && match$2 !== undefined && Identity$Reventless.hasGroup(payload.identity, match$1)) {
102
- let authBinding = lookupBinding(match$2);
116
+ if (match$4 !== undefined && match$5 !== undefined && Identity$Reventless.hasGroup(payload.identity, match$4)) {
117
+ let authBinding = lookupBinding(match$5);
103
118
  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;
119
+ let items = await authBinding.ops.load(value);
120
+ 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
121
  } else {
107
122
  authorized = false;
108
123
  }
@@ -127,8 +142,8 @@ async function dispatch(binding, lookupBindingOpt, payload) {
127
142
  if (conn !== undefined) {
128
143
  return conn;
129
144
  }
130
- let items$2 = await binding.pushdowns.scanAll(rm);
131
- return QueryDbListQuery$ReventlessCore.run(items$2, argsDict, binding.capability, binding.labelField, param => {});
145
+ let items$1 = await binding.pushdowns.scanAll(rm);
146
+ return QueryDbListQuery$ReventlessCore.run(items$1, argsDict, binding.capability, binding.labelField, undefined);
132
147
  case "resolveMany" :
133
148
  let target = Stdlib_Option.getOr(payload.target, rm);
134
149
  let source = Stdlib_Option.getOr(payload.source, null);
@@ -139,30 +154,30 @@ async function dispatch(binding, lookupBindingOpt, payload) {
139
154
  let source$1 = Stdlib_Option.getOr(payload.source, null);
140
155
  let key = Stdlib_Option.getOr(argStr(source$1, Stdlib_Option.getOr(payload.sourceIdField, "")), "");
141
156
  let ix = payload.targetIndex;
142
- let items$3;
157
+ let items$2;
143
158
  if (ix !== undefined) {
144
- items$3 = await binding.pushdowns.indexLookup(target$1, Stdlib_Option.getOr(payload.targetIndexIdField, ix), key);
159
+ items$2 = await binding.pushdowns.indexLookup(target$1, Stdlib_Option.getOr(payload.targetIndexIdField, ix), key);
145
160
  } else {
146
- let items$4 = await binding.ops.load(key);
147
- items$3 = items$4.TAG === "Ok" ? items$4._0 : [];
161
+ let items$3 = await binding.ops.load(key);
162
+ items$2 = items$3.TAG === "Ok" ? items$3._0 : [];
148
163
  }
149
- let match$3 = payload.sourceSubId;
150
- let match$4 = binding.subIdField;
164
+ let match$6 = payload.sourceSubId;
165
+ let match$7 = binding.subIdField;
151
166
  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);
167
+ if (match$6 !== undefined && match$7 !== undefined) {
168
+ let name = match$6.name;
169
+ let tmp = match$6.kind === "arg" ? argStr(payload.arguments, name) : argStr(source$1, name);
155
170
  let subVal = Stdlib_Option.getOr(tmp, "");
156
- filtered = items$3.filter(it => Stdlib_Option.getOr(argStr(it, match$4), "") === subVal);
171
+ filtered = items$2.filter(it => Stdlib_Option.getOr(argStr(it, match$7), "") === subVal);
157
172
  } else {
158
- filtered = items$3;
173
+ filtered = items$2;
159
174
  }
160
175
  if (Stdlib_Option.getOr(payload.multi, false)) {
161
176
  return filtered;
162
177
  }
163
- let item$1 = filtered[0];
164
- if (item$1 !== undefined) {
165
- return item$1;
178
+ let item = filtered[0];
179
+ if (item !== undefined) {
180
+ return item;
166
181
  } else {
167
182
  return null;
168
183
  }
@@ -172,8 +187,8 @@ async function dispatch(binding, lookupBindingOpt, payload) {
172
187
  }
173
188
  } else {
174
189
  let isMulti = Stdlib_Option.getOr(payload.multi, false);
175
- let match$5 = payload.kind;
176
- switch (match$5) {
190
+ let match$8 = payload.kind;
191
+ switch (match$8) {
177
192
  case "getById" :
178
193
  return null;
179
194
  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)
@@ -91,7 +91,7 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
91
91
  let resolverByIdMultiple = includeIdParam ? Stdlib_Option.map(subIdField, sortField => makeQueryResolver(Stdlib_String.capitalize(fieldNameForSingle) + "Items", fieldNameForSingle + "Items", AppSync_Resolver_Functions$PulumiAws.queryItemsWithSortConditions(sortField))) : undefined;
92
92
  let labelField = registryEntry !== undefined ? Stdlib_Option.getOr(registryEntry.labelField, "id") : "id";
93
93
  let stateSchemaOpt = Plugin_Helpers$ReventlessCore.stateSchemaRegistry[name$1];
94
- let capability = stateSchemaOpt !== undefined ? GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(stateSchemaOpt) : GraphQL_FragmentGenerator$ReventlessCore.emptyCapability;
94
+ let capability = stateSchemaOpt !== undefined ? GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(name$1, stateSchemaOpt) : GraphQL_FragmentGenerator$ReventlessCore.emptyCapability;
95
95
  let filterFieldNames = capability.filterFields.map(f => f.name);
96
96
  let rangeFieldNames = Stdlib_Array.filterMap(capability.filterFields, f => {
97
97
  if (f.range) {
@@ -2,18 +2,24 @@
2
2
  // Query resolver — one pipeline step whose only job is to consult the hook.
3
3
  //
4
4
  // The hook is a module-level `ref`, and in a deployed runtime the only thing that
5
- // ever fills it is a `RuntimeExtension`'s `onColdStart`. Every other runtime
6
- // reaches those through a compiled entry shell that awaits `runtimeExtensionsReady`
7
- // before it serves anything; this handler is not built by a shell, so it awaits
8
- // the same promise itself. Without it the extensions the archive already carries
9
- // are never imported, the hook stays `None`, and interception degrades to a
10
- // passthrough that costs an invocation on every read and observes nothing — the
11
- // one failure mode this path cannot afford, because it is silent and its whole
12
- // price has already been paid.
5
+ // ever fills it is a `RuntimeExtension`'s `onColdStart`. So the handler awaits
6
+ // `runtimeExtensionsReady` before reading it. Without that the extensions the
7
+ // archive already carries are never imported, the hook stays `None`, and
8
+ // interception degrades to a passthrough that costs an invocation on every read
9
+ // and observes nothing the one failure mode this path cannot afford, because it
10
+ // is silent and its whole price has already been paid.
11
+ //
12
+ // The await is kept here even though `QueryInterceptorEntryPoint.mjs` now awaits
13
+ // the same promise at top level, because the two are answering different
14
+ // questions. The shell's await decides WHEN the seam fires — init rather than
15
+ // invoke, which is what keeps the load off the read path. This one is the
16
+ // module's own contract: it does not consult a hook it has not waited for,
17
+ // however it was entered. On the path they share, the second await is a settled
18
+ // promise and costs a microtask.
13
19
  //
14
20
  // Awaited from the small module rather than from `HandlerFactoryHelpers`, which
15
21
  // re-exports the same binding behind the Effect runtime and the DynamoDB clients:
16
- // this runtime is sized for a decision, not for work.
22
+ // there is no reason to pull that graph in to reach one promise.
17
23
 
18
24
  type payload = {
19
25
  readModelName: string,
@@ -64,23 +64,56 @@ let buildInterceptor = (~api: Types.AppSync.api, ~opts) => {
64
64
  ),
65
65
  ])
66
66
  let {code, sourceCodeHash} = Util_Bundle.buildCodeArchive(
67
- ~entryPointModule="@reventlessdev/reventless-aws/src/adapter/QueryDb/QueryInterceptor_Lambda.res.mjs",
67
+ ~entryPointModule="@reventlessdev/reventless-aws/src/adapter/Runtime/QueryInterceptorEntryPoint.mjs",
68
68
  ~packageDirs,
69
69
  )
70
70
 
71
- // Sits in front of every read, so it is sized for a decision rather than for
72
- // work: small memory, short timeout. A hook that needs longer than this is
73
- // doing something a read path cannot afford anyway.
71
+ // Framework default sizing, deliberately, and it took an outage to get here.
72
+ //
73
+ // What this runtime DECIDES is trivial — consult a hook, return a verdict — and
74
+ // it was once sized for exactly that: 256MB and a 10-second timeout, on the
75
+ // reasoning that a read path cannot afford anything more. But a runtime is not
76
+ // sized for what it decides, it is sized for what it LOADS, and this one loads
77
+ // whatever module graph the registered extensions drag behind them. An
78
+ // accounting extension reaching a DynamoDB client is enough to put the whole
79
+ // cloud SDK in that graph. Lambda CPU scales with memory, so at 256MB the load
80
+ // ran at a fraction of a core and did not finish inside ten seconds — and
81
+ // because an unfinished import neither completes nor throws, the failure was a
82
+ // bare timeout on EVERY read, with an empty log group to explain it.
83
+ //
84
+ // The defaults are also close to cost-neutral for the thing being paid for
85
+ // here. Lambda bills memory×duration, so a decision that fits in single-digit
86
+ // milliseconds costs about the same at either size; what changes is whether the
87
+ // cold start fits. The entry shell spends it in init (see its own docstring),
88
+ // where the timeout is headroom for the retry Lambda performs when init
89
+ // overruns its own budget, rather than a ceiling on the read.
74
90
  let runtime = RuntimeEnvironment_Lambda.makeFromCodeAsset(
75
91
  ~name,
76
92
  ~unitKind=ReventlessCore.Monitoring.Other("QueryInterceptor"),
77
93
  ~componentKind=ReventlessCore.ComponentType.Plugin,
78
94
  ~code,
79
95
  ~sourceCodeHash,
80
- ~memorySize=256,
81
- ~timeout=10,
82
96
  ~opts=componentOpts,
83
97
  )
98
+
99
+ // The execution role's logging grant, which `makeFromCodeAsset` does NOT give
100
+ // it: every runtime builder in the framework attaches this at its own call
101
+ // site, and this one — hand-rolled rather than grown from a builder — was the
102
+ // one that forgot. The result was a component sitting in front of every read on
103
+ // the platform whose log group never had a single stream, so the timeout above
104
+ // could only be found by invoking the function by hand and reading the tail of
105
+ // a response. Silence on the read path is not a small defect; it is the thing
106
+ // that decides how long the next outage lasts.
107
+ let _logging = IAM.RolePolicy.make(
108
+ ~name=name ++ "Logging",
109
+ ~args={
110
+ IAM.RolePolicy.policy: PulumiAws.Lambda.defaultLoggingPolicyDocument
111
+ ->PolicyDocument.toJsonString
112
+ ->Pulumi.Input.make,
113
+ role: runtime.parts.lambdaRole.id->Pulumi.Output.asInput,
114
+ },
115
+ ~opts,
116
+ )
84
117
  let lambdaArn = runtime.parts.lambda->Pulumi.Output.flatMap(lambda => lambda.arn)
85
118
 
86
119
  let dataSourceRole = IAM.Role.makeWithDefaultPolicy(
@@ -5,6 +5,7 @@ import * as IAM$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/IAM.r
5
5
  import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output.res.mjs";
6
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
7
  import * as Pulumi from "@pulumi/pulumi";
8
+ import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
8
9
  import * as AWS$ReventlessAws from "../AWS.res.mjs";
9
10
  import * as AWS_Tags$ReventlessAws from "../AWS_Tags.res.mjs";
10
11
  import * as PolicyDocument$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/IAM/PolicyDocument.res.mjs";
@@ -38,11 +39,15 @@ function buildInterceptor(api, opts) {
38
39
  "@reventlessdev/reventless-aws",
39
40
  Util_Bundle$ReventlessAws.resolvePackageRoot(undefined, "@reventlessdev/reventless-aws")
40
41
  ]]);
41
- let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/QueryDb/QueryInterceptor_Lambda.res.mjs", packageDirs, undefined, undefined);
42
+ let match = Util_Bundle$ReventlessAws.buildCodeArchive("@reventlessdev/reventless-aws/src/adapter/Runtime/QueryInterceptorEntryPoint.mjs", packageDirs, undefined, undefined);
42
43
  let runtime = RuntimeEnvironment_Lambda$ReventlessAws.makeFromCodeAsset(name, {
43
44
  TAG: "Other",
44
45
  _0: "QueryInterceptor"
45
- }, "Plugin", match.code, match.sourceCodeHash, undefined, 256, 10, undefined, undefined, undefined, undefined, undefined, componentOpts);
46
+ }, "Plugin", match.code, match.sourceCodeHash, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined, componentOpts);
47
+ new (Aws.iam.RolePolicy)(name + "Logging", {
48
+ policy: PolicyDocument$PulumiAws.toJsonString(Lambda$PulumiAws.defaultLoggingPolicyDocument),
49
+ role: runtime.parts.lambdaRole.id
50
+ }, opts);
46
51
  let lambdaArn = Output$Pulumi.flatMap(runtime.parts.lambda, lambda => lambda.arn);
47
52
  let dataSourceRole = IAM$PulumiAws.Role.makeWithDefaultPolicy(name + "DataSource", Pulumi.output(AWS$ReventlessAws.AppSync.principal), AWS_Tags$ReventlessAws.make(name + "DataSource", "Plugin", "Identity", "Plugin", undefined, undefined, undefined, undefined), opts);
48
53
  Pulumi.all([
@@ -144,6 +144,7 @@ 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,
@@ -69,7 +69,7 @@ function registerBinding(pushdowns, pgConnection, entry, spec) {
69
69
  pushdowns: pushdowns,
70
70
  indexes: indexes,
71
71
  subIdField: subIdField,
72
- capability: GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(spec.stateSchema),
72
+ capability: GraphQL_FragmentGenerator$ReventlessCore.deriveServerCapability(entry.readModelName, spec.stateSchema),
73
73
  labelField: entry.labelField,
74
74
  includeIdParam: entry.includeIdParam,
75
75
  authorization: spec.authorization
@@ -0,0 +1,29 @@
1
+ // Query-interceptor Lambda entry point.
2
+ //
3
+ // One job, and it is a scheduling one: pull the runtime-extension cold start
4
+ // into the INIT phase.
5
+ //
6
+ // `runtimeExtensionsReady` is an async IIFE started at module load and awaited
7
+ // by nobody, so evaluating a module that merely imports it does not wait for it.
8
+ // Awaiting it from the handler instead — which is what this runtime did before
9
+ // there was a shell — leaves the whole extension load in the INVOKE phase, where
10
+ // it runs without the init CPU boost and against the function timeout. An
11
+ // extension whose module graph reaches a cloud SDK does not finish inside a
12
+ // budget sized for a decision, and the way that fails is the worst shape
13
+ // available: the load neither completes nor throws, so every read times out
14
+ // having logged nothing at all.
15
+ //
16
+ // A top-level await is what moves it. It propagates to the generated `index.mjs`
17
+ // re-export, so init is not finished until the seam has fired — which is also
18
+ // the only way the handler's own await can be a formality rather than the place
19
+ // the work happens.
20
+ //
21
+ // It cannot reject: the seam catches and reports each extension's load failure
22
+ // individually, and resolves having skipped it. So a broken extension still
23
+ // costs interception nothing more than the counting it was going to do.
24
+
25
+ import { runtimeExtensionsReady } from "./RuntimeExtensionsReady.mjs";
26
+
27
+ await runtimeExtensionsReady;
28
+
29
+ export { handler } from "../QueryDb/QueryInterceptor_Lambda.res.mjs";
@@ -365,3 +365,93 @@ describe("PgQueryResolver_Lambda.dispatch", () => {
365
365
  expect(threw)->toBe(true)
366
366
  })
367
367
  })
368
+
369
+ // A client holding a row reads its `id` and passes it back. On the local platform
370
+ // that `id` is a Relay global id; the typed doors used to take only the storage
371
+ // key and answered null, with no error to point at. These pin the either-form
372
+ // rule at the door, so the same client call works whichever form it holds.
373
+ describe("id form — the typed doors take either", () => {
374
+ let globalIdFor = localId => ReventlessCore.Api_Ids.encode(~typeName="Thing", ~localId)
375
+
376
+ testPromise("getById resolves a Relay global id", async () => {
377
+ let r = await PgQueryResolver_Lambda.dispatch(
378
+ ~binding=makeBinding(),
379
+ ~payload=mkPayload(
380
+ ~kind="getById",
381
+ ~args=objArgs([("id", JSON.Encode.string(globalIdFor("p-2")))]),
382
+ (),
383
+ ),
384
+ )
385
+ expect(r->str("name"))->toEqual(Some("Bravo"))
386
+ })
387
+
388
+ // The row's `id` must come back as the storage key it was found under — echoing
389
+ // the argument would hand back an id nothing else accepts.
390
+ testPromise("getById reports the storage key it resolved to", async () => {
391
+ let r = await PgQueryResolver_Lambda.dispatch(
392
+ ~binding=makeBinding(),
393
+ ~payload=mkPayload(
394
+ ~kind="getById",
395
+ ~args=objArgs([("id", JSON.Encode.string(globalIdFor("p-2")))]),
396
+ (),
397
+ ),
398
+ )
399
+ expect(r->str("id"))->toEqual(Some("p-2"))
400
+ })
401
+
402
+ testPromise("getById still resolves a plain storage key", async () => {
403
+ let r = await PgQueryResolver_Lambda.dispatch(
404
+ ~binding=makeBinding(),
405
+ ~payload=mkPayload(~kind="getById", ~args=objArgs([("id", JSON.Encode.string("p-2"))]), ()),
406
+ )
407
+ expect(r->str("name"))->toEqual(Some("Bravo"))
408
+ })
409
+
410
+ testPromise("getById on a global id for a row that does not exist is still null", async () => {
411
+ let r = await PgQueryResolver_Lambda.dispatch(
412
+ ~binding=makeBinding(),
413
+ ~payload=mkPayload(
414
+ ~kind="getById",
415
+ ~args=objArgs([("id", JSON.Encode.string(globalIdFor("absent")))]),
416
+ (),
417
+ ),
418
+ )
419
+ expect(r)->toBe(JSON.Encode.null)
420
+ })
421
+
422
+ testPromise("byIds accepts the two forms mixed in one call", async () => {
423
+ let r = await PgQueryResolver_Lambda.dispatch(
424
+ ~binding=makeBinding(),
425
+ ~payload=mkPayload(
426
+ ~kind="byIds",
427
+ ~args=objArgs([
428
+ (
429
+ "ids",
430
+ JSON.Encode.array([
431
+ JSON.Encode.string("p-1"),
432
+ JSON.Encode.string(globalIdFor("p-3")),
433
+ ]),
434
+ ),
435
+ ]),
436
+ (),
437
+ ),
438
+ )
439
+ expect(r->ids)->toEqual(["p-1", "p-3"])
440
+ })
441
+
442
+ // The fallback lookup must not fire when the first pass already answered — an
443
+ // all-raw call is the common path and pays for one round trip, not two.
444
+ testPromise("byIds with every id found does not double-count", async () => {
445
+ let r = await PgQueryResolver_Lambda.dispatch(
446
+ ~binding=makeBinding(),
447
+ ~payload=mkPayload(
448
+ ~kind="byIds",
449
+ ~args=objArgs([
450
+ ("ids", JSON.Encode.array(["p-1", "p-3"]->Array.map(JSON.Encode.string))),
451
+ ]),
452
+ (),
453
+ ),
454
+ )
455
+ expect(r->ids)->toEqual(["p-1", "p-3"])
456
+ })
457
+ })
@@ -6,6 +6,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
7
7
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
8
8
  import * as Identity$Reventless from "@reventlessdev/reventless-spec/src/types/Identity.res.mjs";
9
+ import * as Api_Ids$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/Api_Ids.res.mjs";
9
10
  import * as QueryDb_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Callback.res.mjs";
10
11
  import * as PgQueryResolver_Lambda$ReventlessAws from "../src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs";
11
12
 
@@ -452,6 +453,63 @@ globalThis.describe("PgQueryResolver_Lambda.dispatch", () => {
452
453
  });
453
454
  });
454
455
 
456
+ globalThis.describe("id form — the typed doors take either", () => {
457
+ globalThis.test("getById resolves a Relay global id", async () => {
458
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
459
+ "id",
460
+ Api_Ids$ReventlessCore.encode("Thing", "p-2")
461
+ ]]), undefined));
462
+ globalThis.expect(str(r, "name")).toEqual("Bravo");
463
+ });
464
+ globalThis.test("getById reports the storage key it resolved to", async () => {
465
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
466
+ "id",
467
+ Api_Ids$ReventlessCore.encode("Thing", "p-2")
468
+ ]]), undefined));
469
+ globalThis.expect(str(r, "id")).toEqual("p-2");
470
+ });
471
+ globalThis.test("getById still resolves a plain storage key", async () => {
472
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
473
+ "id",
474
+ "p-2"
475
+ ]]), undefined));
476
+ globalThis.expect(str(r, "name")).toEqual("Bravo");
477
+ });
478
+ globalThis.test("getById on a global id for a row that does not exist is still null", async () => {
479
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("getById", undefined, Object.fromEntries([[
480
+ "id",
481
+ Api_Ids$ReventlessCore.encode("Thing", "absent")
482
+ ]]), undefined));
483
+ globalThis.expect(r).toBe(null);
484
+ });
485
+ globalThis.test("byIds accepts the two forms mixed in one call", async () => {
486
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("byIds", undefined, Object.fromEntries([[
487
+ "ids",
488
+ [
489
+ "p-1",
490
+ Api_Ids$ReventlessCore.encode("Thing", "p-3")
491
+ ]
492
+ ]]), undefined));
493
+ globalThis.expect(ids(r)).toEqual([
494
+ "p-1",
495
+ "p-3"
496
+ ]);
497
+ });
498
+ globalThis.test("byIds with every id found does not double-count", async () => {
499
+ let r = await PgQueryResolver_Lambda$ReventlessAws.dispatch(makeBinding(undefined, undefined, undefined), undefined, mkPayload("byIds", undefined, Object.fromEntries([[
500
+ "ids",
501
+ [
502
+ "p-1",
503
+ "p-3"
504
+ ].map(prim => prim)
505
+ ]]), undefined));
506
+ globalThis.expect(ids(r)).toEqual([
507
+ "p-1",
508
+ "p-3"
509
+ ]);
510
+ });
511
+ });
512
+
455
513
  export {
456
514
  mk,
457
515
  store,