@reventlessdev/reventless-aws 3.0.0-alpha.278 → 3.0.0-alpha.280

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.280 (2026-08-10)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** keep the interceptor data source out of an option ([d908126](https://github.com/ReventlessDev/reventless-core/commit/d90812626d64b5c408c8185ac58a63db448e4082))
11
+
12
+
13
+ # 3.0.0-alpha.279 (2026-08-10)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **aws:** load runtime extensions in the query interceptor, not just in entry shells ([d686407](https://github.com/ReventlessDev/reventless-core/commit/d686407239136c7370f5200c45b462109f94fdbe))
18
+ * **aws:** stop an unknown view table from deleting the collector's ESMs ([569e0b9](https://github.com/ReventlessDev/reventless-core/commit/569e0b9c86adca4286d82286ca58e096e62ffee5))
19
+ ### Features
20
+
21
+ * **aws:** admit non-read-model tables to the state-topic relay ([9f7084f](https://github.com/ReventlessDev/reventless-core/commit/9f7084f4eb4040cafaead1c27044ae99e703dd97))
22
+ * **aws:** route every top-level Query field through the interceptor, not only the generated ones ([db709a9](https://github.com/ReventlessDev/reventless-core/commit/db709a9bf2f665642b5d48ef4a83e18978e9d9d7))
23
+
24
+
6
25
  # 3.0.0-alpha.278 (2026-08-09)
7
26
 
8
27
  **Note:** Version bump only for package @reventlessdev/reventless-aws
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.278",
3
+ "version": "3.0.0-alpha.280",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -12,17 +12,17 @@
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
16
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
16
- "@reventlessdev/rescript-node": "2.0.0-alpha.3",
17
17
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
18
- "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.6",
18
+ "@reventlessdev/rescript-node": "2.0.0-alpha.3",
19
19
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.69",
20
20
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
21
21
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
22
- "@reventlessdev/reventless-core": "3.0.0-alpha.221",
22
+ "@reventlessdev/reventless-core": "3.0.0-alpha.222",
23
23
  "@reventlessdev/reventless-infra": "3.0.0-alpha.132",
24
24
  "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
25
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.85",
25
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.86",
26
26
  "@reventlessdev/reventless-spec": "3.0.0-alpha.106"
27
27
  },
28
28
  "devDependencies": {
@@ -118,21 +118,29 @@ let connectLambda = (
118
118
  resources: array<ReventlessInfra.Adapter.resource>,
119
119
  opts: Pulumi.CustomResourceOptions.t,
120
120
  ) => {
121
- let _ =
122
- (
123
- eventTopics->toResources,
124
- queues->Array.map(queue => queue.arn)->Pulumi.Output.all,
125
- resources->ReventlessCore.Adapter.resourcesToResolvedOutput,
126
- )
121
+ // The three input sets are resolved SEPARATELY, and each resource is gated on
122
+ // the narrowest one it actually needs.
123
+ //
124
+ // They used to share one `Pulumi.Output.all3` apply that created the role policy
125
+ // and every EventSourceMapping. An apply whose inputs are unknown does not run
126
+ // during preview, and a resource the program never registers reads as a DELETE —
127
+ // so an unknown in ANY of the three deleted ALL of them, including mappings that
128
+ // do not depend on it. `targetResources` (the collector's own view tables) is the
129
+ // one that goes unknown in practice: switching a plugin's slices to the stream
130
+ // builder gives each view table a computed `streamArn`, which Pulumi cannot know
131
+ // in the preview that enables it, so the plugin's event-log mapping and role
132
+ // policy — neither of which reads a view table — vanished from that preview.
133
+ let eventTopicResourcesOutput = eventTopics->toResources
134
+ let queueArnsOutput = queues->Array.map(queue => queue.arn)->Pulumi.Output.all
135
+ let targetResourcesOutput = resources->ReventlessCore.Adapter.resourcesToResolvedOutput
136
+
137
+ // Only the policy DOCUMENT needs all three. The RolePolicy resource itself is
138
+ // registered at top level with the document as an Output input, so an unknown
139
+ // input previews as "update, value unknown" instead of removing the policy.
140
+ let lambdaPolicyJson =
141
+ (eventTopicResourcesOutput, queueArnsOutput, targetResourcesOutput)
127
142
  ->Pulumi.Output.all3
128
- // Provisions the collector's role, policies and event-source mappings, so it
129
- // carries the plugin's attribution across the apply — by the time this runs
130
- // the builder's construct has returned and the ambient context is empty.
131
- ->ReventlessCore.ResourceAttribution_Deploytime.applyAttributed(((
132
- eventTopicResources,
133
- queueArns,
134
- resources,
135
- )) => {
143
+ ->Pulumi.Output.flatMap(((eventTopicResources, queueArns, resources)) => {
136
144
  log.debug(
137
145
  ~comp="EventCollector",
138
146
  `connectLambda ${name}: ${eventTopicResources->Array.length->Int.toString} topic resource(s), ${resources->Array.length->Int.toString} resource(s)`,
@@ -246,27 +254,42 @@ let connectLambda = (
246
254
  )
247
255
  : None
248
256
 
249
- let _lambdaPolicy = PulumiAws.IAM.RolePolicy.make(
250
- ~name,
251
- ~args={
252
- policy: PulumiAws.PolicyDocument.mergePolicyDocuments(
253
- name ++ "LambdaPolicy",
254
- [
255
- Some(PulumiAws.Lambda.defaultLoggingPolicyDocument),
256
- allowLambdaReceiveSQS,
257
- allowLambdaReadDynamoDbStream,
258
- allowLambdaReadWriteDynamoDb,
259
- allowLambdaPublishSNS,
260
- allowLambdaSendSQS,
261
- ]->Array.keepSome,
262
- )->Pulumi.Output.asInput,
263
- role: lambdaRole.id->Pulumi.Output.asInput,
264
- },
265
- ~opts,
257
+ PulumiAws.PolicyDocument.mergePolicyDocuments(
258
+ name ++ "LambdaPolicy",
259
+ [
260
+ Some(PulumiAws.Lambda.defaultLoggingPolicyDocument),
261
+ allowLambdaReceiveSQS,
262
+ allowLambdaReadDynamoDbStream,
263
+ allowLambdaReadWriteDynamoDb,
264
+ allowLambdaPublishSNS,
265
+ allowLambdaSendSQS,
266
+ ]->Array.keepSome,
266
267
  )
268
+ })
267
269
 
268
- let _eventSourceMappings =
269
- dynamoDbStreamResources->Array.map(dynamoDbStreamResource =>
270
+ let _lambdaPolicy = PulumiAws.IAM.RolePolicy.make(
271
+ ~name,
272
+ ~args={
273
+ policy: lambdaPolicyJson->Pulumi.Output.asInput,
274
+ role: lambdaRole.id->Pulumi.Output.asInput,
275
+ },
276
+ ~opts,
277
+ )
278
+
279
+ // The mappings read nothing but the event topics, so that is all they wait for.
280
+ // They cannot be hoisted out of an apply the way the policy can: an ESM's Pulumi
281
+ // name is derived from the resolved source name, so a brand-new event log still
282
+ // previews without them — inherent, and no longer reachable from a view table.
283
+ //
284
+ // Attribution is captured at THIS call, not inside the callback: by the time an
285
+ // apply runs, the builder's construct has returned and the ambient context is
286
+ // empty. (The top-level policy above is created while it is still ambient.)
287
+ let _ =
288
+ eventTopicResourcesOutput->ReventlessCore.ResourceAttribution_Deploytime.applyAttributed(
289
+ eventTopicResources =>
290
+ eventTopicResources
291
+ ->dynamoDbStreamResources
292
+ ->Array.map(dynamoDbStreamResource =>
270
293
  Util_EventSourceMapping.subscribe(
271
294
  ~lambda,
272
295
  ~targetName=name,
@@ -278,8 +301,8 @@ let connectLambda = (
278
301
  ),
279
302
  ~opts,
280
303
  )
281
- )
282
- })
304
+ ),
305
+ )
283
306
 
284
307
  let subscriptionResources = queues->Array.mapWithIndex((queue, idx) => {
285
308
  let esmName = queues->Array.length > 1 ? `${name}Sqs${Int.toString(idx)}` : name
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as Aws from "@pulumi/aws";
4
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
+ import * as Output$Pulumi from "@reventlessdev/rescript-pulumi-pulumi/src/Output.res.mjs";
5
6
  import * as Pulumi from "@pulumi/pulumi";
6
7
  import * as Lambda$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/Lambda/Lambda.res.mjs";
7
8
  import * as AWS$ReventlessAws from "../AWS.res.mjs";
@@ -77,10 +78,13 @@ function esmTags(name, esmName) {
77
78
  }
78
79
 
79
80
  function connectLambda(lambda, name, lambdaRole, queues, eventTopics, resources, opts) {
80
- ResourceAttribution_Deploytime$ReventlessCore.applyAttributed(Pulumi.all([
81
- toResources(eventTopics),
82
- Pulumi.all(queues.map(queue => queue.arn)),
83
- Adapter$ReventlessCore.resourcesToResolvedOutput(resources)
81
+ let eventTopicResourcesOutput = toResources(eventTopics);
82
+ let queueArnsOutput = Pulumi.all(queues.map(queue => queue.arn));
83
+ let targetResourcesOutput = Adapter$ReventlessCore.resourcesToResolvedOutput(resources);
84
+ let lambdaPolicyJson = Output$Pulumi.flatMap(Pulumi.all([
85
+ eventTopicResourcesOutput,
86
+ queueArnsOutput,
87
+ targetResourcesOutput
84
88
  ]), param => {
85
89
  let resources = param[2];
86
90
  let queueArns = param[1];
@@ -138,19 +142,20 @@ function connectLambda(lambda, name, lambdaRole, queues, eventTopics, resources,
138
142
  ],
139
143
  Resource: queueArns
140
144
  }]) : undefined;
141
- new (Aws.iam.RolePolicy)(name, {
142
- policy: PolicyDocument$PulumiAws.mergePolicyDocuments(name + "LambdaPolicy", Stdlib_Array.keepSome([
143
- Lambda$PulumiAws.defaultLoggingPolicyDocument,
144
- allowLambdaReceiveSQS,
145
- allowLambdaReadDynamoDbStream,
146
- allowLambdaReadWriteDynamoDb,
147
- allowLambdaPublishSNS,
148
- allowLambdaSendSQS
149
- ])),
150
- role: lambdaRole.id
151
- }, opts);
152
- dynamoDbStreamResources.map(dynamoDbStreamResource => Util_EventSourceMapping$ReventlessAws.subscribe(undefined, lambda, name, dynamoDbStreamResource.name, AdapterDeploytime$ReventlessCore.resolvedToResource(dynamoDbStreamResource), esmTags(name, Util$ReventlessCore.baseName(dynamoDbStreamResource.name) + ("2" + name)), opts));
145
+ return PolicyDocument$PulumiAws.mergePolicyDocuments(name + "LambdaPolicy", Stdlib_Array.keepSome([
146
+ Lambda$PulumiAws.defaultLoggingPolicyDocument,
147
+ allowLambdaReceiveSQS,
148
+ allowLambdaReadDynamoDbStream,
149
+ allowLambdaReadWriteDynamoDb,
150
+ allowLambdaPublishSNS,
151
+ allowLambdaSendSQS
152
+ ]));
153
153
  });
154
+ new (Aws.iam.RolePolicy)(name, {
155
+ policy: lambdaPolicyJson,
156
+ role: lambdaRole.id
157
+ }, opts);
158
+ ResourceAttribution_Deploytime$ReventlessCore.applyAttributed(eventTopicResourcesOutput, eventTopicResources => Adapter_Helpers$ReventlessAws.dynamoDbStreamResources(eventTopicResources).map(dynamoDbStreamResource => Util_EventSourceMapping$ReventlessAws.subscribe(undefined, lambda, name, dynamoDbStreamResource.name, AdapterDeploytime$ReventlessCore.resolvedToResource(dynamoDbStreamResource), esmTags(name, Util$ReventlessCore.baseName(dynamoDbStreamResource.name) + ("2" + name)), opts)));
154
159
  return queues.map((queue, idx) => {
155
160
  let esmName = queues.length > 1 ? name + `Sqs` + idx.toString() : name;
156
161
  return Util_EventSourceMapping$ReventlessAws.subscribeSqs(lambda, esmName, queue, undefined, esmTags(name, esmName), opts);
@@ -96,13 +96,38 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
96
96
  | None => true
97
97
  }
98
98
 
99
+ // The interceptor step for one Query resolver, or `None` when this deployment
100
+ // did not switch interception on. Provisioning it is the framework's job — see
101
+ // `QueryInterceptor_Provisioning`; the first Query resolver under a plugin
102
+ // creates the data source and the rest reuse it.
103
+ //
104
+ // **Every top-level Query field on this read model gets one when interception
105
+ // is on, whatever its access path.** The hook reports `readModelName`, so all
106
+ // of them resolve the SAME read model and a consumer counting resolutions must
107
+ // see all of them or its number is a function of which access paths a model
108
+ // happens to expose — two models with identical traffic would report different
109
+ // totals because one is queried through an index. A partial pipeline here would
110
+ // make that skew invisible rather than absent.
111
+ let interceptorFunction = (~resolverName) =>
112
+ switch QueryInterceptor_Provisioning.dataSourceName(~api, ~opts) {
113
+ | Off => None
114
+ | On(interceptorDsName) =>
115
+ Some(
116
+ Function.makeJs(
117
+ ~name=resolverName ++ "Interceptor",
118
+ ~api,
119
+ ~dataSource=interceptorDsName,
120
+ ~code=interceptorCode(name),
121
+ ~opts,
122
+ ),
123
+ )
124
+ }
125
+
99
126
  // Creates either a unit resolver (no interceptor) or a pipeline resolver
100
- // (interceptor Lambda → DynamoDB query), depending on whether an extension
101
- // switched interception on for this deployment. Provisioning the interceptor
102
- // is the framework's job — see `QueryInterceptor_Provisioning`; the first
103
- // Query resolver under a plugin creates it and the rest reuse it.
127
+ // (interceptor Lambda → DynamoDB query), for the Query fields that have no
128
+ // second step of their own.
104
129
  let makeQueryResolver = (~resolverName, ~field, ~code) =>
105
- switch QueryInterceptor_Provisioning.dataSourceName(~api, ~opts) {
130
+ switch interceptorFunction(~resolverName) {
106
131
  | None =>
107
132
  Resolver.makeUnitJsResolver(
108
133
  ~name=resolverName,
@@ -113,14 +138,7 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
113
138
  ~code,
114
139
  ~opts,
115
140
  )
116
- | Some(interceptorDsName) =>
117
- let interceptorFn = Function.makeJs(
118
- ~name=resolverName ++ "Interceptor",
119
- ~api,
120
- ~dataSource=interceptorDsName,
121
- ~code=interceptorCode(name),
122
- ~opts,
123
- )
141
+ | Some(interceptorFn) =>
124
142
  let queryFn = Function.makeJs(
125
143
  ~name=resolverName ++ "Query",
126
144
  ~api,
@@ -258,18 +276,14 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
258
276
  let idField = indexConfig.idField->Option.getOr(index)
259
277
  switch indexConfig.authorization {
260
278
  | None =>
261
- Resolver.makeUnitJsResolver(
262
- ~name=resolverName,
263
- ~api,
264
- ~dataSourceName,
265
- ~type_="Query"->Pulumi.Input.make,
279
+ makeQueryResolver(
280
+ ~resolverName,
266
281
  ~field=fieldName->Pulumi.Input.make,
267
282
  ~code=switch indexConfig.subIdField {
268
283
  | Some(sortField) =>
269
284
  Resolver.Functions.queryByIndexSortFiltered(~index, ~idField, ~sortField)
270
285
  | None => Resolver.Functions.queryByIndexFiltered(~index, ~idField)
271
286
  },
272
- ~opts,
273
287
  )
274
288
  | Some({tableName, group}) =>
275
289
  let authDataSource = DataSource.makeDynamoDBDataSourceWithTableName(
@@ -297,13 +311,25 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
297
311
  ~code=Resolver.Functions.queryByIndexFiltered(~index, ~idField),
298
312
  ~opts,
299
313
  )
314
+ // The interceptor leads the chain, as it does in every other Query
315
+ // pipeline. That position means an attempt refused by the row-level index
316
+ // authorization below has still been counted — the same "attempts, not
317
+ // outcomes" bound the hook has everywhere, since it fires before the read
318
+ // and never learns how it ended. Putting it after the auth step would buy
319
+ // outcome-accurate counts and cost the property that matters more: the
320
+ // interceptor is the one place a read can be REFUSED, so it has to be
321
+ // reachable before the pipeline spends a second table read deciding
322
+ // whether this caller may use the index.
300
323
  Resolver.makePipelineJsResolver(
301
324
  ~name=resolverName,
302
325
  ~api,
303
326
  ~type_="Query"->Pulumi.Input.make,
304
327
  ~field=fieldName->Pulumi.Input.make,
305
328
  ~code=Resolver.Functions.pipelinePassThrough,
306
- ~functions=[authFunction, queryFunction],
329
+ ~functions=switch interceptorFunction(~resolverName) {
330
+ | None => [authFunction, queryFunction]
331
+ | Some(interceptorFn) => [interceptorFn, authFunction, queryFunction]
332
+ },
307
333
  ~opts,
308
334
  )
309
335
  }
@@ -66,12 +66,19 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
66
66
  let fieldNameForSingle = registryEntry !== undefined ? registryEntry.singleFieldName : AppSync_Resolver_Functions$PulumiAws.uncapitalize(name$1);
67
67
  let includeIdParam = registryEntry !== undefined ? registryEntry.includeIdParam : true;
68
68
  let connectionSpec = registryEntry !== undefined ? registryEntry.connectionSpec : true;
69
- let makeQueryResolver = (resolverName, field, code) => {
69
+ let interceptorFunction = resolverName => {
70
70
  let interceptorDsName = QueryInterceptor_Provisioning$ReventlessAws.dataSourceName(api, opts);
71
- if (interceptorDsName === undefined) {
71
+ if (typeof interceptorDsName !== "object") {
72
+ return;
73
+ } else {
74
+ return AppSync_Function$PulumiAws.makeJs(resolverName + "Interceptor", api, interceptorDsName._0, interceptorCode(name$1), opts);
75
+ }
76
+ };
77
+ let makeQueryResolver = (resolverName, field, code) => {
78
+ let interceptorFn = interceptorFunction(resolverName);
79
+ if (interceptorFn === undefined) {
72
80
  return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(resolverName, api, dataSourceName, "Query", field, code, opts);
73
81
  }
74
- let interceptorFn = AppSync_Function$PulumiAws.makeJs(resolverName + "Interceptor", api, interceptorDsName, interceptorCode(name$1), opts);
75
82
  let queryFn = AppSync_Function$PulumiAws.makeJs(resolverName + "Query", api, dataSourceName, code, opts);
76
83
  return AppSync_Resolver_Retrying$ReventlessAws.makePipelineJsResolver(resolverName, api, "Query", field, AppSync_Resolver_Functions$PulumiAws.pipelinePassThrough, [
77
84
  interceptorFn,
@@ -117,13 +124,18 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
117
124
  let authDataSource = AppSync_DataSource$PulumiAws.makeDynamoDBDataSourceWithTableName(resolverName + "Auth", api, Util_DynamoDb$ReventlessAws.findResource(Util_QueryDb$ReventlessCore.getLocalStorageResources(allQueryDbs, match.tableName)).name, apiRole, opts);
118
125
  let authFunction = AppSync_Function$PulumiAws.makeJs(resolverName + "Auth", api, authDataSource.name, AppSync_Resolver_Functions$PulumiAws.authorizeIndexedAccess(index, match.group), opts);
119
126
  let queryFunction = AppSync_Function$PulumiAws.makeJs(resolverName, api, dataSourceName, AppSync_Resolver_Functions$PulumiAws.queryByIndexFiltered(index, idField), opts);
120
- return AppSync_Resolver_Retrying$ReventlessAws.makePipelineJsResolver(resolverName, api, "Query", fieldName, AppSync_Resolver_Functions$PulumiAws.pipelinePassThrough, [
121
- authFunction,
122
- queryFunction
123
- ], opts);
127
+ let interceptorFn = interceptorFunction(resolverName);
128
+ return AppSync_Resolver_Retrying$ReventlessAws.makePipelineJsResolver(resolverName, api, "Query", fieldName, AppSync_Resolver_Functions$PulumiAws.pipelinePassThrough, interceptorFn !== undefined ? [
129
+ interceptorFn,
130
+ authFunction,
131
+ queryFunction
132
+ ] : [
133
+ authFunction,
134
+ queryFunction
135
+ ], opts);
124
136
  }
125
137
  let sortField = indexConfig.subIdField;
126
- return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(resolverName, api, dataSourceName, "Query", fieldName, sortField !== undefined ? AppSync_Resolver_Functions$PulumiAws.queryByIndexSortFiltered(index, idField, sortField) : AppSync_Resolver_Functions$PulumiAws.queryByIndexFiltered(index, idField), opts);
138
+ return makeQueryResolver(resolverName, fieldName, sortField !== undefined ? AppSync_Resolver_Functions$PulumiAws.queryByIndexSortFiltered(index, idField, sortField) : AppSync_Resolver_Functions$PulumiAws.queryByIndexFiltered(index, idField));
127
139
  });
128
140
  let storageResource = (pluginName, tableName) => Adapter$ReventlessCore.outputToResource(Util_DynamoDb$ReventlessAws.findResourceInOutput(Plugin_Helpers$ReventlessCore.getStorageResources(allQueryDbs, pluginName, tableName)));
129
141
  let generateCode = (storageResource, template) => storageResource.name.apply(realTableName => template(realTableName));
@@ -1,10 +1,31 @@
1
+ // The runtime that `QueryInterception.use` puts in front of every DynamoDB-backed
2
+ // Query resolver — one pipeline step whose only job is to consult the hook.
3
+ //
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.
13
+ //
14
+ // Awaited from the small module rather than from `HandlerFactoryHelpers`, which
15
+ // re-exports the same binding behind the Effect runtime and the DynamoDB clients:
16
+ // this runtime is sized for a decision, not for work.
17
+
1
18
  type payload = {
2
19
  readModelName: string,
3
20
  arguments: JSON.t,
4
21
  identity: Reventless.Identity.t,
5
22
  }
6
23
 
24
+ @module("../Runtime/RuntimeExtensionsReady.mjs")
25
+ external runtimeExtensionsReady: promise<unit> = "runtimeExtensionsReady"
26
+
7
27
  let handler = async (event: payload, _context) => {
28
+ await runtimeExtensionsReady
8
29
  switch ReventlessCore.QueryDb_Callback.queryInterceptorHook.contents {
9
30
  | None => true
10
31
  | Some(interceptor) =>
@@ -2,8 +2,12 @@
2
2
 
3
3
  import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
4
4
  import * as QueryDb_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/QueryDb/QueryDb_Callback.res.mjs";
5
+ import * as RuntimeExtensionsReadyMjs from "../Runtime/RuntimeExtensionsReady.mjs";
6
+
7
+ let runtimeExtensionsReady = RuntimeExtensionsReadyMjs.runtimeExtensionsReady;
5
8
 
6
9
  async function handler(event, _context) {
10
+ await runtimeExtensionsReady;
7
11
  let interceptor = QueryDb_Callback$ReventlessCore.queryInterceptorHook.contents;
8
12
  if (interceptor === undefined) {
9
13
  return true;
@@ -17,6 +21,7 @@ async function handler(event, _context) {
17
21
  }
18
22
 
19
23
  export {
24
+ runtimeExtensionsReady,
20
25
  handler,
21
26
  }
22
- /* No side effect */
27
+ /* runtimeExtensionsReady Not a pure module */
@@ -138,18 +138,34 @@ let buildInterceptor = (~api: Types.AppSync.api, ~opts) => {
138
138
  dataSource.name->Pulumi.Output.asInput
139
139
  }
140
140
 
141
- /** The data source a Query resolver should put in front of its DynamoDB read, or
142
- `None` when interception is off in which case the caller builds the unit
143
- resolver it always did and nothing here is provisioned. */
144
- let dataSourceName = (~api: Types.AppSync.api, ~opts): option<Pulumi.Input.t<string>> =>
141
+ /** Whether a Query resolver fronts its DynamoDB read with the interceptor, and
142
+ the data source to front it with. `Off` means interception is switched off
143
+ the caller builds the unit resolver it always did and nothing here is
144
+ provisioned.
145
+
146
+ A variant rather than an `option`, and that is load-bearing. The payload is a
147
+ Pulumi `Output`, which is a JS `Proxy` answering *every* property read with a
148
+ derived `Output`. The generic option runtime unwraps by probing the payload
149
+ for `BS_PRIVATE_NESTED_SOME_NONE`; on a `Proxy` that probe never reads
150
+ `undefined`, so it takes the nested-option branch and yields a wrapper object
151
+ where a data-source name belongs. AppSync then rejects the resource with
152
+ "Attribute must be a single value, not a map" — at deploy time, naming a
153
+ field the source never mentions. A variant keeps that runtime off the path
154
+ entirely, so no caller can reintroduce the fault by reaching for
155
+ `Option.map`. */
156
+ type t =
157
+ | Off
158
+ | On(Pulumi.Input.t<string>)
159
+
160
+ let dataSourceName = (~api: Types.AppSync.api, ~opts): t =>
145
161
  if !ReventlessCore.QueryInterception.isEnabled() {
146
- None
162
+ Off
147
163
  } else {
148
164
  switch byApi->Map.get(api) {
149
- | Some(existing) => Some(existing)
165
+ | Some(existing) => On(existing)
150
166
  | None =>
151
167
  let created = buildInterceptor(~api, ~opts)
152
168
  byApi->Map.set(api, created)
153
- Some(created)
169
+ On(created)
154
170
  }
155
171
  }
@@ -71,15 +71,21 @@ function buildInterceptor(api, opts) {
71
71
 
72
72
  function dataSourceName(api, opts) {
73
73
  if (!QueryInterception$ReventlessCore.isEnabled()) {
74
- return;
74
+ return "Off";
75
75
  }
76
76
  let existing = byApi.get(api);
77
77
  if (existing !== undefined) {
78
- return existing;
78
+ return {
79
+ TAG: "On",
80
+ _0: existing
81
+ };
79
82
  }
80
83
  let created = buildInterceptor(api, opts);
81
84
  byApi.set(api, created);
82
- return created;
85
+ return {
86
+ TAG: "On",
87
+ _0: created
88
+ };
83
89
  }
84
90
 
85
91
  export {
@@ -8,11 +8,11 @@ import {
8
8
  } from "@reventlessdev/reventless-core/src/RequestContext.res.mjs";
9
9
  import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
10
10
  import { DynamoDBDocumentClient, ScanCommand } from "@aws-sdk/lib-dynamodb";
11
- import {
12
- parseConfig as parseRuntimeExtensionConfig,
13
- fire as fireRuntimeExtensions,
14
- reportLoadFailure as reportRuntimeExtensionLoadFailure,
15
- } from "./RuntimeExtensionEntryPoint_Ops.res.mjs";
11
+
12
+ // Re-exported, not defined here: the seam must fire once per process, and a
13
+ // runtime too small to want this module's other imports awaits the same binding
14
+ // straight from its own module. See RuntimeExtensionsReady.mjs.
15
+ export { runtimeExtensionsReady } from "./RuntimeExtensionsReady.mjs";
16
16
 
17
17
  // ─── Structured logging from the .mjs entry-point shims ─────────────────────
18
18
  // Mirrors ReScript's Logger.t API and Logger.emit's JSON sink shape (time,
@@ -197,48 +197,6 @@ export function extractRetryCount(records) {
197
197
  return Number.isFinite(count) && count > 0 ? count : 1;
198
198
  }
199
199
 
200
- // ─── Runtime extension cold start ───────────────────────────────────────────
201
- // Extension packages are bundled into the code archive next to the spec and
202
- // behavior packages, so they resolve from /var/task/node_modules by the same
203
- // absolute path every shell uses for user modules.
204
- const dynamicImport = (specifier) => import("/var/task/node_modules/" + specifier);
205
-
206
- // The framework ships four runtime callback hooks (command interception, query
207
- // interception, before/after publish) whose registrars are module-level refs.
208
- // They are only reachable if something calls them in this process before the
209
- // first request, and the entry shells import framework and domain modules only —
210
- // so an out-of-tree extension had no way in. RUNTIME_EXTENSIONS closes that: it
211
- // names the extension modules the archive carries (see
212
- // ReventlessCore.RuntimeExtension and Util_Bundle.addRuntimeExtensionPackages),
213
- // and each one's `onColdStart` runs here, once, before any handler is built.
214
- //
215
- // This is the seam's one untyped step: the modules are named at deploy time and
216
- // their types are unknowable here. Parsing and invocation live in the typed
217
- // RuntimeExtensionEntryPoint_Ops.res, so the call's arity is compiler-checked.
218
- //
219
- // Started at module load and exported as a promise every entry shell awaits in
220
- // its own cold-start init — that is what makes "before the first request" a fact
221
- // rather than a hope, and it holds for the shells that build handlers lazily.
222
- // Absent env var ⇒ resolves immediately, having done nothing.
223
- export const runtimeExtensionsReady = (async () => {
224
- const config = parseRuntimeExtensionConfig(process.env["RUNTIME_EXTENSIONS"]);
225
- if (config === undefined) return;
226
- const hooks = [];
227
- for (const specifier of config.modules) {
228
- try {
229
- const mod = await dynamicImport(specifier);
230
- if (typeof mod.onColdStart === "function") {
231
- hooks.push(mod.onColdStart);
232
- } else {
233
- reportRuntimeExtensionLoadFailure(specifier, "module exports no onColdStart function");
234
- }
235
- } catch (err) {
236
- reportRuntimeExtensionLoadFailure(specifier, err?.message ?? String(err));
237
- }
238
- }
239
- fireRuntimeExtensions(config, hooks);
240
- })();
241
-
242
200
  // Patch a spec module's Id field to use IdString if undefined.
243
201
  // Workaround for `module Id = Id.String` not producing an ESM export.
244
202
  export const patchSpecId = (specModule) => ({ ...specModule, Id: specModule.Id || IdString });
@@ -0,0 +1,59 @@
1
+ // The runtime-extension cold start, on its own so a runtime can await it without
2
+ // pulling the rest of the entry-point toolkit in behind it.
3
+ //
4
+ // It lived in HandlerFactoryHelpers because every compiled entry shell wants it
5
+ // alongside the Effect runtime, the DynamoDB clients and the logging shims it
6
+ // already imports from there. The query interceptor is the first runtime that
7
+ // wants ONLY this: it sits in front of every read and is sized for a decision
8
+ // rather than for work, so paying that whole import graph on each cold start to
9
+ // reach one promise is the wrong trade.
10
+ //
11
+ // A module-level singleton, which is why HandlerFactoryHelpers re-exports this
12
+ // binding rather than keeping a copy: two definitions would fire the seam twice
13
+ // in any runtime that reached both.
14
+
15
+ import {
16
+ parseConfig as parseRuntimeExtensionConfig,
17
+ fire as fireRuntimeExtensions,
18
+ reportLoadFailure as reportRuntimeExtensionLoadFailure,
19
+ } from "./RuntimeExtensionEntryPoint_Ops.res.mjs";
20
+
21
+ // Extension packages ride in the code archive rather than beside this module, so
22
+ // they resolve under /var/task and not relative to here.
23
+ const dynamicImport = (specifier) => import("/var/task/node_modules/" + specifier);
24
+
25
+ // The framework ships four runtime callback hooks (command interception, query
26
+ // interception, before/after publish) whose registrars are module-level refs.
27
+ // They are only reachable if something calls them in this process before the
28
+ // first request, and the entry shells import framework and domain modules only —
29
+ // so an out-of-tree extension had no way in. RUNTIME_EXTENSIONS closes that: it
30
+ // names the extension modules the archive carries (see
31
+ // ReventlessCore.RuntimeExtension and Util_Bundle.addRuntimeExtensionPackages),
32
+ // and each one's `onColdStart` runs here, once, before any handler is built.
33
+ //
34
+ // This is the seam's one untyped step: the modules are named at deploy time and
35
+ // their types are unknowable here. Parsing and invocation live in the typed
36
+ // RuntimeExtensionEntryPoint_Ops.res, so the call's arity is compiler-checked.
37
+ //
38
+ // Started at module load and exported as a promise every entry shell awaits in
39
+ // its own cold-start init — that is what makes "before the first request" a fact
40
+ // rather than a hope, and it holds for the shells that build handlers lazily.
41
+ // Absent env var ⇒ resolves immediately, having done nothing.
42
+ export const runtimeExtensionsReady = (async () => {
43
+ const config = parseRuntimeExtensionConfig(process.env["RUNTIME_EXTENSIONS"]);
44
+ if (config === undefined) return;
45
+ const hooks = [];
46
+ for (const specifier of config.modules) {
47
+ try {
48
+ const mod = await dynamicImport(specifier);
49
+ if (typeof mod.onColdStart === "function") {
50
+ hooks.push(mod.onColdStart);
51
+ } else {
52
+ reportRuntimeExtensionLoadFailure(specifier, "module exports no onColdStart function");
53
+ }
54
+ } catch (err) {
55
+ reportRuntimeExtensionLoadFailure(specifier, err?.message ?? String(err));
56
+ }
57
+ }
58
+ fireRuntimeExtensions(config, hooks);
59
+ })();