@reventlessdev/reventless-aws 3.0.0-alpha.277 → 3.0.0-alpha.279

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,26 @@
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.279 (2026-08-10)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **aws:** load runtime extensions in the query interceptor, not just in entry shells ([d686407](https://github.com/ReventlessDev/reventless-core/commit/d686407239136c7370f5200c45b462109f94fdbe))
11
+ * **aws:** stop an unknown view table from deleting the collector's ESMs ([569e0b9](https://github.com/ReventlessDev/reventless-core/commit/569e0b9c86adca4286d82286ca58e096e62ffee5))
12
+ ### Features
13
+
14
+ * **aws:** admit non-read-model tables to the state-topic relay ([9f7084f](https://github.com/ReventlessDev/reventless-core/commit/9f7084f4eb4040cafaead1c27044ae99e703dd97))
15
+ * **aws:** route every top-level Query field through the interceptor, not only the generated ones ([db709a9](https://github.com/ReventlessDev/reventless-core/commit/db709a9bf2f665642b5d48ef4a83e18978e9d9d7))
16
+
17
+
18
+ # 3.0.0-alpha.278 (2026-08-09)
19
+
20
+ **Note:** Version bump only for package @reventlessdev/reventless-aws
21
+
22
+
23
+
24
+
25
+
6
26
  # 3.0.0-alpha.277 (2026-08-09)
7
27
 
8
28
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.277",
3
+ "version": "3.0.0-alpha.279",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -13,17 +13,17 @@
13
13
  "sury": "11.0.0-alpha.4",
14
14
  "uuid": "^13.0.0",
15
15
  "@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.6",
16
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
17
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.69",
18
16
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
19
17
  "@reventlessdev/rescript-node": "2.0.0-alpha.3",
18
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
19
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.69",
20
20
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
21
- "@reventlessdev/reventless-core": "3.0.0-alpha.220",
22
21
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
23
- "@reventlessdev/reventless-infra": "3.0.0-alpha.131",
22
+ "@reventlessdev/reventless-core": "3.0.0-alpha.222",
24
23
  "@reventlessdev/reventless-interop": "3.0.0-alpha.30",
25
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.84",
26
- "@reventlessdev/reventless-spec": "3.0.0-alpha.105"
24
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.86",
25
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.106",
26
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.132"
27
27
  },
28
28
  "devDependencies": {
29
29
  "rescript": "12.3.0",
@@ -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,34 @@ 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
+ QueryInterceptor_Provisioning.dataSourceName(~api, ~opts)->Option.map(interceptorDsName =>
113
+ Function.makeJs(
114
+ ~name=resolverName ++ "Interceptor",
115
+ ~api,
116
+ ~dataSource=interceptorDsName,
117
+ ~code=interceptorCode(name),
118
+ ~opts,
119
+ )
120
+ )
121
+
99
122
  // 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.
123
+ // (interceptor Lambda → DynamoDB query), for the Query fields that have no
124
+ // second step of their own.
104
125
  let makeQueryResolver = (~resolverName, ~field, ~code) =>
105
- switch QueryInterceptor_Provisioning.dataSourceName(~api, ~opts) {
126
+ switch interceptorFunction(~resolverName) {
106
127
  | None =>
107
128
  Resolver.makeUnitJsResolver(
108
129
  ~name=resolverName,
@@ -113,14 +134,7 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
113
134
  ~code,
114
135
  ~opts,
115
136
  )
116
- | Some(interceptorDsName) =>
117
- let interceptorFn = Function.makeJs(
118
- ~name=resolverName ++ "Interceptor",
119
- ~api,
120
- ~dataSource=interceptorDsName,
121
- ~code=interceptorCode(name),
122
- ~opts,
123
- )
137
+ | Some(interceptorFn) =>
124
138
  let queryFn = Function.makeJs(
125
139
  ~name=resolverName ++ "Query",
126
140
  ~api,
@@ -258,18 +272,14 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
258
272
  let idField = indexConfig.idField->Option.getOr(index)
259
273
  switch indexConfig.authorization {
260
274
  | None =>
261
- Resolver.makeUnitJsResolver(
262
- ~name=resolverName,
263
- ~api,
264
- ~dataSourceName,
265
- ~type_="Query"->Pulumi.Input.make,
275
+ makeQueryResolver(
276
+ ~resolverName,
266
277
  ~field=fieldName->Pulumi.Input.make,
267
278
  ~code=switch indexConfig.subIdField {
268
279
  | Some(sortField) =>
269
280
  Resolver.Functions.queryByIndexSortFiltered(~index, ~idField, ~sortField)
270
281
  | None => Resolver.Functions.queryByIndexFiltered(~index, ~idField)
271
282
  },
272
- ~opts,
273
283
  )
274
284
  | Some({tableName, group}) =>
275
285
  let authDataSource = DataSource.makeDynamoDBDataSourceWithTableName(
@@ -297,13 +307,25 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
297
307
  ~code=Resolver.Functions.queryByIndexFiltered(~index, ~idField),
298
308
  ~opts,
299
309
  )
310
+ // The interceptor leads the chain, as it does in every other Query
311
+ // pipeline. That position means an attempt refused by the row-level index
312
+ // authorization below has still been counted — the same "attempts, not
313
+ // outcomes" bound the hook has everywhere, since it fires before the read
314
+ // and never learns how it ended. Putting it after the auth step would buy
315
+ // outcome-accurate counts and cost the property that matters more: the
316
+ // interceptor is the one place a read can be REFUSED, so it has to be
317
+ // reachable before the pipeline spends a second table read deciding
318
+ // whether this caller may use the index.
300
319
  Resolver.makePipelineJsResolver(
301
320
  ~name=resolverName,
302
321
  ~api,
303
322
  ~type_="Query"->Pulumi.Input.make,
304
323
  ~field=fieldName->Pulumi.Input.make,
305
324
  ~code=Resolver.Functions.pipelinePassThrough,
306
- ~functions=[authFunction, queryFunction],
325
+ ~functions=switch interceptorFunction(~resolverName) {
326
+ | None => [authFunction, queryFunction]
327
+ | Some(interceptorFn) => [interceptorFn, authFunction, queryFunction]
328
+ },
307
329
  ~opts,
308
330
  )
309
331
  }
@@ -66,12 +66,12 @@ 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 interceptorFunction = resolverName => Stdlib_Option.map(QueryInterceptor_Provisioning$ReventlessAws.dataSourceName(api, opts), interceptorDsName => AppSync_Function$PulumiAws.makeJs(resolverName + "Interceptor", api, interceptorDsName, interceptorCode(name$1), opts));
69
70
  let makeQueryResolver = (resolverName, field, code) => {
70
- let interceptorDsName = QueryInterceptor_Provisioning$ReventlessAws.dataSourceName(api, opts);
71
- if (interceptorDsName === undefined) {
71
+ let interceptorFn = interceptorFunction(resolverName);
72
+ if (interceptorFn === undefined) {
72
73
  return AppSync_Resolver_Retrying$ReventlessAws.makeUnitJsResolver(resolverName, api, dataSourceName, "Query", field, code, opts);
73
74
  }
74
- let interceptorFn = AppSync_Function$PulumiAws.makeJs(resolverName + "Interceptor", api, interceptorDsName, interceptorCode(name$1), opts);
75
75
  let queryFn = AppSync_Function$PulumiAws.makeJs(resolverName + "Query", api, dataSourceName, code, opts);
76
76
  return AppSync_Resolver_Retrying$ReventlessAws.makePipelineJsResolver(resolverName, api, "Query", field, AppSync_Resolver_Functions$PulumiAws.pipelinePassThrough, [
77
77
  interceptorFn,
@@ -117,13 +117,18 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
117
117
  let authDataSource = AppSync_DataSource$PulumiAws.makeDynamoDBDataSourceWithTableName(resolverName + "Auth", api, Util_DynamoDb$ReventlessAws.findResource(Util_QueryDb$ReventlessCore.getLocalStorageResources(allQueryDbs, match.tableName)).name, apiRole, opts);
118
118
  let authFunction = AppSync_Function$PulumiAws.makeJs(resolverName + "Auth", api, authDataSource.name, AppSync_Resolver_Functions$PulumiAws.authorizeIndexedAccess(index, match.group), opts);
119
119
  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);
120
+ let interceptorFn = interceptorFunction(resolverName);
121
+ return AppSync_Resolver_Retrying$ReventlessAws.makePipelineJsResolver(resolverName, api, "Query", fieldName, AppSync_Resolver_Functions$PulumiAws.pipelinePassThrough, interceptorFn !== undefined ? [
122
+ interceptorFn,
123
+ authFunction,
124
+ queryFunction
125
+ ] : [
126
+ authFunction,
127
+ queryFunction
128
+ ], opts);
124
129
  }
125
130
  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);
131
+ return makeQueryResolver(resolverName, fieldName, sortField !== undefined ? AppSync_Resolver_Functions$PulumiAws.queryByIndexSortFiltered(index, idField, sortField) : AppSync_Resolver_Functions$PulumiAws.queryByIndexFiltered(index, idField));
127
132
  });
128
133
  let storageResource = (pluginName, tableName) => Adapter$ReventlessCore.outputToResource(Util_DynamoDb$ReventlessAws.findResourceInOutput(Plugin_Helpers$ReventlessCore.getStorageResources(allQueryDbs, pluginName, tableName)));
129
134
  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 */
@@ -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
+ })();