@reventlessdev/reventless-aws 3.0.0-alpha.188 → 3.0.0-alpha.190

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,22 @@
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.190 (2026-07-10)
7
+
8
+ **Note:** Version bump only for package @reventlessdev/reventless-aws
9
+
10
+
11
+
12
+
13
+
14
+ # 3.0.0-alpha.189 (2026-07-09)
15
+
16
+ **Note:** Version bump only for package @reventlessdev/reventless-aws
17
+
18
+
19
+
20
+
21
+
6
22
  # 3.0.0-alpha.188 (2026-07-09)
7
23
 
8
24
  **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.188",
3
+ "version": "3.0.0-alpha.190",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -10,15 +10,15 @@
10
10
  "uuid": "^13.0.0",
11
11
  "@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.20",
12
12
  "@reventlessdev/rescript-jest": "1.0.0-alpha.6",
13
+ "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
13
14
  "@reventlessdev/rescript-effect": "0.1.0-alpha.25",
14
15
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.47",
15
- "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
16
16
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.14",
17
- "@reventlessdev/reventless-core": "3.0.0-alpha.148",
18
- "@reventlessdev/reventless-infra": "3.0.0-alpha.91",
19
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.12",
20
- "@reventlessdev/reventless-spec": "3.0.0-alpha.69",
21
- "@reventlessdev/reventless-interop": "3.0.0-alpha.24"
17
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.92",
18
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.24",
19
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.14",
20
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.70",
21
+ "@reventlessdev/reventless-core": "3.0.0-alpha.150"
22
22
  },
23
23
  "devDependencies": {
24
24
  "rescript": "^12.3.0",
@@ -9,11 +9,13 @@ import { tag as requestContextTag } from "@reventlessdev/reventless-core/src/Req
9
9
  import { Make as eventLogOperationsMake } from "@reventlessdev/reventless-core/src/components/EventLog/EventLog_Operations.res.mjs";
10
10
  import { Make as aggregateCallbackMake } from "@reventlessdev/reventless-core/src/components/Aggregate/Aggregate_Callback.res.mjs";
11
11
  import { Make as commandTopicCallbackMake } from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic_Callback.res.mjs";
12
- import { makeGenerateCommand } from "@reventlessdev/reventless-core/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs";
12
+ import { makeCommandGenerator } from "./CommandGeneratorEntryPoint_Ops.res.mjs";
13
13
  import { commandOutcomeToJson, runInlineAndCollect } from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic_Helpers.res.mjs";
14
- import { append, replay, replayStream, appendStream } from "@reventlessdev/reventless-aws/src/adapter/EventLog/EventLogStorage_DynamoDb_Runtime.res.mjs";
15
- import { opsFor as pgEventLogOpsFor } from "@reventlessdev/reventless-aws/src/adapter/EventLog/EventLogStorage_Postgres_Runtime.res.mjs";
16
14
  import { handleQueueEvent, publishJsons as sqsPublishJsons } from "@reventlessdev/reventless-aws/src/adapter/CommandTopic/CommandTopicChannel_SQS_Runtime.res.mjs";
15
+ // Typed cold-start core — EventLog storage-ops selection/wiring, compiler-checked
16
+ // against the framework signatures (see the module header and
17
+ // docs/plans/minimize-lambda-entrypoint-mjs-shell.md).
18
+ import { makeStorageOps } from "./AggregateEntryPoint_Ops.res.mjs";
17
19
  import { DynamoDBClient, ScanCommand } from "@aws-sdk/client-dynamodb";
18
20
  import { unmarshall } from "@aws-sdk/util-dynamodb";
19
21
 
@@ -127,24 +129,10 @@ async function checkPluginStatus(event) {
127
129
  // (all aggregates share one Postgres table, unlike DynamoDB's table-per-aggregate).
128
130
  function buildAggregateParts(specModule, behaviorModule, eventLogTableName, queueUrl, pgConnection) {
129
131
  const patchedSpec = patchSpecId(specModule);
130
- let rawStorageOps;
131
- if (pgConnection) {
132
- const pgOps = pgEventLogOpsFor(pgConnection, eventLogTableName);
133
- rawStorageOps = {
134
- append: pgOps.append,
135
- replay: pgOps.replay,
136
- replayStream: pgOps.replayStream,
137
- appendStream: pgOps.appendStream,
138
- };
139
- } else {
140
- const resolvedTable = { name: eventLogTableName };
141
- rawStorageOps = {
142
- append: append(resolvedTable),
143
- replay: replay(resolvedTable),
144
- replayStream: replayStream(resolvedTable),
145
- appendStream: appendStream(resolvedTable),
146
- };
147
- }
132
+ // Typed core: backend-specific EventLog storage ops (Postgres when pgConnection
133
+ // is present, else DynamoDB). Returns the full 6-field EventLog_Adapter.operations
134
+ // the shell previously dropped latestSnapshot/writeSnapshot.
135
+ const rawStorageOps = makeStorageOps(eventLogTableName, pgConnection);
148
136
  const eventLogOps = eventLogOperationsMake(patchedSpec)({
149
137
  Spec: patchedSpec,
150
138
  EventTopic: { Spec: patchedSpec },
@@ -178,13 +166,11 @@ function buildCommandGeneratorHandler(parts, dispatchMode) {
178
166
  const publishJsonsAndWait = dispatchMode === "async"
179
167
  ? undefined
180
168
  : (jsons) => runInlineAndCollect(jsons, handleJsonCommands);
181
- // Positional args after ReScript compiles labeled params:
182
- // (publishJsons, publishJsonsAndWait, serviceName, commandSchema, componentKind, stripIdFromParams)
183
- // The `undefined` for `stripIdFromParams` must be passed explicitly here
184
- // omitting it shifts everything left, sending the schema object through the
185
- // serviceName slot (string-concat then throws "Cannot convert object to
186
- // primitive value" once the log line tries to print "CommandGenerator(<schema>)").
187
- const generateCommand = makeGenerateCommand(publishJsons, publishJsonsAndWait, patchedSpec.name, patchedSpec.commandSchema, "Aggregate", undefined);
169
+ // Typed core (CommandGeneratorEntryPoint_Ops.res) pins the arg order and the
170
+ // componentKind/stripIdFromParams positions against the framework signature.
171
+ // stripIdFromParams=true matches the framework default the old shell relied on
172
+ // by passing `undefined`.
173
+ const generateCommand = makeCommandGenerator(publishJsons, publishJsonsAndWait, patchedSpec.name, patchedSpec.commandSchema, "Aggregate", true);
188
174
  return (event, _context) => {
189
175
  // Add identity fallback: use payload.identity if present, otherwise construct from meta.user.
190
176
  const identity = (event.identity != null && typeof event.identity === 'object')
@@ -256,21 +242,32 @@ function extractCorrelationId(records) {
256
242
  // variant (Aggregate_Builder_Single_Async) sets DISPATCH_MODE=async.
257
243
  const DISPATCH_MODE = process.env["DISPATCH_MODE"] === "async" ? "async" : "sync";
258
244
 
259
- async function buildAllHandlers() {
260
- const configStr = process.env["HANDLER_CONFIG"] || '{"handlers":[]}';
261
- const config = JSON.parse(configStr);
245
+ // Exported for tests: build the [cmdTopicHandlers, cmdGenHandlers] pair from an
246
+ // already-parsed `HANDLER_CONFIG` shape, with an injectable module loader. In
247
+ // production, `buildAllHandlers` wraps this with the env config + the Lambda's
248
+ // `/var/task/node_modules` dynamic import; the integration test injects a loader
249
+ // returning pre-imported spec/behavior modules so it can drive the real
250
+ // cmdGenHandler end-to-end against DynamoDB Local. (Mirrors the DCB entry point.)
251
+ export async function buildHandlersForConfig(config, opts = {}) {
252
+ const loadModule = opts.loadModule ?? dynamicImport;
253
+ const dispatchMode = opts.dispatchMode ?? DISPATCH_MODE;
262
254
  const cmdTopicHandlers = {};
263
255
  const cmdGenHandlers = {};
264
- await Promise.all(config.handlers.map(async h => {
265
- const specModule = await dynamicImport(h.specModule);
266
- const behaviorModule = await dynamicImport(h.behaviorModule);
256
+ await Promise.all((config.handlers || []).map(async h => {
257
+ const specModule = await loadModule(h.specModule);
258
+ const behaviorModule = await loadModule(h.behaviorModule);
267
259
  const parts = buildAggregateParts(specModule, behaviorModule, h.eventLogTable, h.queueUrl, h.pgConnection);
268
260
  cmdTopicHandlers[h.queueArn] = parts.sqsHandler;
269
- cmdGenHandlers[specModule.name] = buildCommandGeneratorHandler(parts, DISPATCH_MODE);
261
+ cmdGenHandlers[specModule.name] = buildCommandGeneratorHandler(parts, dispatchMode);
270
262
  }));
271
263
  return [cmdTopicHandlers, cmdGenHandlers];
272
264
  }
273
265
 
266
+ async function buildAllHandlers() {
267
+ const configStr = process.env["HANDLER_CONFIG"] || '{"handlers":[]}';
268
+ return buildHandlersForConfig(JSON.parse(configStr));
269
+ }
270
+
274
271
  const initPromise = buildAllHandlers();
275
272
 
276
273
  export async function handler(event, context) {
@@ -0,0 +1,48 @@
1
+ // Typed cold-start core for the Aggregate Lambda entry point.
2
+ //
3
+ // The "typed core, thin shell" split (docs/plans/minimize-lambda-entrypoint-mjs-shell.md).
4
+ // The `.mjs` shell owns the untyped seams — reading `HANDLER_CONFIG`, the dynamic
5
+ // `import()` of user Spec/Behavior modules, and the functor wiring that consumes
6
+ // those runtime-loaded modules (`EventLog_Operations.Make`, `Aggregate_Callback.Make`,
7
+ // `CommandTopic_Callback.Make`). This module owns the one piece that is pure
8
+ // typed framework wiring: selecting and building the EventLog storage operations.
9
+ //
10
+ // The `.mjs` previously built these with positional `append(resolvedTable)` /
11
+ // `replay(...)` calls into the compiled runtime, and — crucially — assembled only
12
+ // FOUR of the six `EventLog_Adapter.operations` fields, silently dropping
13
+ // `latestSnapshot` / `writeSnapshot` on both the DynamoDB and Postgres paths. The
14
+ // deploy-time adapter (EventLogStorage_DynamoDb.res) wires all six. Typing the
15
+ // return as `EventLog_Adapter.operations` forces parity: the Lambda now matches
16
+ // the deploy-time adapter exactly. Snapshot ops are a no-op when snapshotting is
17
+ // disabled (the default) and correct when it is enabled — where the old shell
18
+ // would have called `undefined`.
19
+
20
+ // The `pgConnection` object as it arrives in `HANDLER_CONFIG` — the resolved
21
+ // `PgConnection.connectionConfig` ({host,port,database,username,secretArn}).
22
+ // Present iff this aggregate is Postgres-backed; `tableName` doubles as the
23
+ // Postgres `event_log.log_name` (all aggregates share one Postgres table).
24
+ type pgConnectionJson
25
+ external asConnectionConfig: pgConnectionJson => PgConnection.connectionConfig = "%identity"
26
+
27
+ let makeStorageOps = (
28
+ ~tableName: string,
29
+ ~pgConnection: option<pgConnectionJson>,
30
+ ): ReventlessCore.EventLog_Adapter.operations =>
31
+ switch pgConnection {
32
+ | Some(pg) => EventLogStorage_Postgres_Runtime.opsFor(pg->asConnectionConfig, ~logName=tableName)
33
+ | None =>
34
+ let table: Util_DynamoDb_Runtime.resolvedTable = {
35
+ id: "",
36
+ name: tableName,
37
+ arn: "",
38
+ hashKey: "id",
39
+ }
40
+ {
41
+ ReventlessCore.EventLog_Adapter.append: EventLogStorage_DynamoDb_Runtime.append(table),
42
+ replay: EventLogStorage_DynamoDb_Runtime.replay(table),
43
+ replayStream: EventLogStorage_DynamoDb_Runtime.replayStream(table),
44
+ appendStream: EventLogStorage_DynamoDb_Runtime.appendStream(table),
45
+ latestSnapshot: EventLogStorage_DynamoDb_Runtime.latestSnapshot(table),
46
+ writeSnapshot: EventLogStorage_DynamoDb_Runtime.writeSnapshot(table),
47
+ }
48
+ }
@@ -0,0 +1,30 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
4
+ import * as EventLogStorage_DynamoDb_Runtime$ReventlessAws from "../EventLog/EventLogStorage_DynamoDb_Runtime.res.mjs";
5
+ import * as EventLogStorage_Postgres_Runtime$ReventlessAws from "../EventLog/EventLogStorage_Postgres_Runtime.res.mjs";
6
+
7
+ function makeStorageOps(tableName, pgConnection) {
8
+ if (pgConnection !== undefined) {
9
+ return EventLogStorage_Postgres_Runtime$ReventlessAws.opsFor(Primitive_option.valFromOption(pgConnection), tableName);
10
+ }
11
+ let table = {
12
+ id: "",
13
+ name: tableName,
14
+ arn: "",
15
+ hashKey: "id"
16
+ };
17
+ return {
18
+ append: EventLogStorage_DynamoDb_Runtime$ReventlessAws.append(table),
19
+ replay: EventLogStorage_DynamoDb_Runtime$ReventlessAws.replay(table),
20
+ replayStream: EventLogStorage_DynamoDb_Runtime$ReventlessAws.replayStream(table),
21
+ appendStream: EventLogStorage_DynamoDb_Runtime$ReventlessAws.appendStream(table),
22
+ latestSnapshot: EventLogStorage_DynamoDb_Runtime$ReventlessAws.latestSnapshot(table),
23
+ writeSnapshot: EventLogStorage_DynamoDb_Runtime$ReventlessAws.writeSnapshot(table)
24
+ };
25
+ }
26
+
27
+ export {
28
+ makeStorageOps,
29
+ }
30
+ /* EventLogStorage_DynamoDb_Runtime-ReventlessAws Not a pure module */
@@ -0,0 +1,33 @@
1
+ // Typed cold-start core shared by the two command-path entry-point shells
2
+ // (AggregateEntryPoint.mjs, DcbCommandTopicEntryPoint.mjs).
3
+ //
4
+ // Both shells build their AppSync CommandGenerator handler by calling the
5
+ // compiled `CommandGenerator_Callback.makeGenerateCommand` positionally — a call
6
+ // their comments flagged as fragile: omitting the trailing `stripIdFromParams`
7
+ // shifts every argument left, sending the schema object through the `serviceName`
8
+ // slot and throwing "Cannot convert object to primitive value" at the first log
9
+ // line. This wrapper pins that correspondence: the arg order, the
10
+ // `commandComponentKind` variant, and an explicit (non-defaulted)
11
+ // `stripIdFromParams` are all compiler-checked against the framework signature,
12
+ // so a signature change is a build error rather than a silent runtime shift.
13
+ //
14
+ // The shell still calls this positionally from JS, but the wrapper's OWN call
15
+ // into the framework is typed — this file is the stable contract the shells pin
16
+ // to, far less likely to drift than the framework internal.
17
+
18
+ let makeCommandGenerator = (
19
+ ~publishJsons,
20
+ ~publishJsonsAndWait: option<_>,
21
+ ~serviceName,
22
+ ~commandSchema,
23
+ ~componentKind: ReventlessCore.CommandGenerator_Callback.commandComponentKind,
24
+ ~stripIdFromParams: bool,
25
+ ): ReventlessCore.CommandGenerator.commandGenerator =>
26
+ ReventlessCore.CommandGenerator_Callback.makeGenerateCommand(
27
+ ~publishJsons,
28
+ ~publishJsonsAndWait?,
29
+ ~serviceName,
30
+ ~commandSchema,
31
+ ~componentKind,
32
+ ~stripIdFromParams,
33
+ )
@@ -0,0 +1,12 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as CommandGenerator_Callback$ReventlessCore from "@reventlessdev/reventless-core/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs";
4
+
5
+ function makeCommandGenerator(publishJsons, publishJsonsAndWait, serviceName, commandSchema, componentKind, stripIdFromParams) {
6
+ return CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(publishJsons, publishJsonsAndWait, serviceName, commandSchema, componentKind, stripIdFromParams);
7
+ }
8
+
9
+ export {
10
+ makeCommandGenerator,
11
+ }
12
+ /* CommandGenerator_Callback-ReventlessCore Not a pure module */
@@ -7,22 +7,17 @@ import * as Chunk from "effect/Chunk";
7
7
  import * as Effect from "effect/Effect";
8
8
  import * as Stream from "effect/Stream";
9
9
  import { patchSpecId, makeQueueRef, log, pluginName } from "./HandlerFactoryHelpers.mjs";
10
- import { decodeCommand$p as decodeCommandPrime } from "@reventlessdev/reventless-core/src/Message.res.mjs";
11
10
  import { tag as requestContextTag } from "@reventlessdev/reventless-core/src/RequestContext.res.mjs";
12
- import {
13
- extractVariantNames,
14
- deriveEffectiveScope,
15
- derivePartitionTag,
16
- } from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
17
- import { $$String as IdString } from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
18
11
  import { Make as dcbEventLogOperationsMake } from "@reventlessdev/reventless-core/src/components/DcbEventLog/DcbEventLog_Operations.res.mjs";
19
- import { Make as stateChangeSliceCallbackMake } from "@reventlessdev/reventless-core/src/components/StateChangeSlice/StateChangeSlice_Callback.res.mjs";
20
- import { makeGenerateCommand } from "@reventlessdev/reventless-core/src/components/CommandGenerator/CommandGenerator_Callback.res.mjs";
12
+ import { makeCommandGenerator } from "./CommandGeneratorEntryPoint_Ops.res.mjs";
21
13
  import { commandOutcomeToJson, runInlineAndCollect } from "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic_Helpers.res.mjs";
22
14
  import { json as jsonSchema } from "sury/src/S.res.mjs";
23
- import { read, append, readStream } from "@reventlessdev/reventless-aws/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs";
24
- import { opsFor as pgDcbEventLogOpsFor } from "@reventlessdev/reventless-aws/src/adapter/DcbEventLog/DcbEventLogStorage_Postgres_Runtime.res.mjs";
25
15
  import { handleQueueEvent, publishJsons as sqsPublishJsons } from "@reventlessdev/reventless-aws/src/adapter/CommandTopic/CommandTopicChannel_SQS_Runtime.res.mjs";
16
+ // Typed cold-start core — scope/partitionTag derivation, storage-ops wiring, and
17
+ // per-slice handler building (functor + decode + handleCommands), all
18
+ // compiler-checked against the framework signatures (see the module header and
19
+ // docs/plans/minimize-lambda-entrypoint-mjs-shell.md).
20
+ import { deriveScope, commandTypeNames, makeStorageOps, buildSliceHandler } from "./DcbCommandTopicEntryPoint_Ops.res.mjs";
26
21
 
27
22
  const dynamicImport = (specifier) => import('/var/task/node_modules/' + specifier);
28
23
 
@@ -78,10 +73,6 @@ function extractCorrelationId(records) {
78
73
  return undefined;
79
74
  }
80
75
 
81
- function getIdStringSchema() {
82
- return IdString.schema;
83
- }
84
-
85
76
  // Exported for tests: build the [sqsHandler, cmdGenHandler] pair from an
86
77
  // already-parsed `HANDLER_CONFIG` shape, with an injectable module loader.
87
78
  // In production, `buildHandler` below wraps this with the env-config + the
@@ -91,94 +82,33 @@ function getIdStringSchema() {
91
82
  export async function buildHandlersForConfig(config, opts = {}) {
92
83
  const loadModule = opts.loadModule ?? dynamicImport;
93
84
 
94
- // Load all spec/behavior pairs first. Both the decision-query scope
95
- // (crossPartitionTagKeys / tagKeysByEventType) AND the storage `partitionTag`
96
- // are derived from the produced event schemas, and the DynamoDB storage ops
97
- // below must be built with them — so slices load before anything else.
85
+ // Untyped boundary: dynamically import + id-patch the user Spec/Behavior
86
+ // modules. Their types are unknowable here — this is the one seam the typed
87
+ // core (below) cannot own, so it stays in the shell.
98
88
  const loadedSlices = await Promise.all(
99
89
  config.stateChangeSliceModules.map(async ({ spec, behavior }) => {
100
90
  const specModule = await loadModule(spec);
101
91
  const behaviorModule = await loadModule(behavior);
102
- const patchedSpec = patchSpecId(specModule);
103
- return { patchedSpec, behaviorModule };
92
+ return { patchedSpec: patchSpecId(specModule), behaviorModule };
104
93
  })
105
94
  );
106
95
 
107
- // Single source of truth with Dcb_Builder.res: prefer slice-graph inference
108
- // (which classifies cross-partition `@ref` reference reads), falling back to
109
- // annotations only on ambiguity. Re-deriving from annotations alone here
110
- // silently dropped inferred cross-partition reads, so every reference-guarded
111
- // command was rejected on the deployed path — see
112
- // docs/analysis/dcb-runtime-scope-annotation-drift.md.
113
- const { crossPartitionTagKeys, tagKeysByEventType } = deriveEffectiveScope(
114
- loadedSlices.map(({ patchedSpec }) => ({
115
- name: patchedSpec.name,
116
- commandSchema: patchedSpec.commandSchema,
117
- consumedEventSchema: patchedSpec.consumedEventSchema,
118
- eventSchema: patchedSpec.eventSchema,
119
- }))
96
+ // Typed core: scope (crossPartitionTagKeys / tagKeysByEventType) + storage
97
+ // `partitionTag` derivation and backend-specific storage-ops wiring, all
98
+ // compiler-checked against the framework signatures the single source of
99
+ // truth shared with Dcb_Builder.res. The two prod incidents this prevents are
100
+ // documented in DcbCommandTopicEntryPoint_Ops.res. `pgConnection`, when present
101
+ // in HANDLER_CONFIG, selects the Postgres backend (dcbEventLogTableName doubles
102
+ // as `dcb_event.log_name`); absence keeps the DynamoDB path byte-identical.
103
+ const specs = loadedSlices.map(({ patchedSpec }) => patchedSpec);
104
+ const scope = deriveScope(specs);
105
+ const { crossPartitionTagKeys, tagKeysByEventType } = scope;
106
+ const rawStorageOps = makeStorageOps(
107
+ config.dcbEventLogTableName,
108
+ config.pgConnection,
109
+ scope,
120
110
  );
121
111
 
122
- // Derive the storage partition tag the SAME way Dcb_Builder.res does at
123
- // deploy time (over the produced event schemas). This MUST reach the DynamoDB
124
- // `append` — without it `partitionTag` defaults to `None`, the composite-fence
125
- // collapse (gated on `Composite`) never fires, and a `@compositePartitionTag`
126
- // slice writes one fence PER MEMBER instead of one synthetic composite fence.
127
- // A deploy-time fan-out sharing low-cardinality members (environment, plugin,
128
- // …) then turns those member fences hot → TransactionConflict → retries
129
- // exhausted. The deploy-time adapter (DcbEventLogStorage_DynamoDb.make) already
130
- // threads it; the deployed Lambda dropped it. See
131
- // docs/plans/dcb-composite-fence-residual-burst-contention.md.
132
- // `derivePartitionTag` throws only on a misconfigured spec, which the deploy
133
- // would already have rejected; guard defensively so a runtime edge case
134
- // degrades to the (old) untagged behaviour rather than crashing cold start.
135
- let partitionTag = undefined;
136
- try {
137
- partitionTag = derivePartitionTag(
138
- loadedSlices.map(({ patchedSpec }) => [
139
- patchedSpec.name,
140
- patchedSpec.moduleUrl ?? patchedSpec.name,
141
- patchedSpec.eventSchema,
142
- ])
143
- );
144
- } catch (err) {
145
- log.warn("could not derive partitionTag; falling back to untagged fences: " + (err && err.message), { comp: "DcbCommandTopicRuntime" });
146
- }
147
-
148
- // `pgConnection`, when present in HANDLER_CONFIG, selects the Postgres DCB
149
- // runtime (dcbEventLogTableName doubles as the `dcb_event.log_name`); absence
150
- // keeps the DynamoDB path byte-identical. NB: this swaps only the *storage*
151
- // ops — event propagation (within-plugin projections + cross-plugin SNS) is
152
- // stream-driven on AWS and is handled separately by the change-feed relay
153
- // (see docs/plans/aws-postgres-change-feed-bridge.md), not by this Lambda.
154
- const rawStorageOps = config.pgConnection
155
- ? (() => {
156
- // C2: `lockStrategy` ("AdvisoryLocks" | "RowLocks") is the polyvariant's
157
- // string name; `opsFor` defaults to "AdvisoryLocks" when it's absent.
158
- const pgOps = pgDcbEventLogOpsFor(
159
- config.pgConnection,
160
- config.dcbEventLogTableName,
161
- config.pgConnection.lockStrategy,
162
- );
163
- return { read: pgOps.read, append: pgOps.append, readStream: pgOps.readStream };
164
- })()
165
- : (() => {
166
- const resolvedTable = { name: config.dcbEventLogTableName };
167
- // Positional args match the ReScript-compiled signatures:
168
- // read(table, crossPartitionTagKeys)
169
- // append(table, partitionTag, crossPartitionTagKeys)
170
- // readStream(table, crossPartitionTagKeys)
171
- // Threading partitionTag activates the composite-fence collapse; threading
172
- // crossPartitionTagKeys routes single-tag `@crossPartition` reads to the
173
- // per-tag GSI and bumps their fences on every carrier — matching the
174
- // deploy-time adapter's storage ops exactly.
175
- return {
176
- read: read(resolvedTable, crossPartitionTagKeys),
177
- append: append(resolvedTable, partitionTag, crossPartitionTagKeys),
178
- readStream: readStream(resolvedTable, crossPartitionTagKeys),
179
- };
180
- })();
181
-
182
112
  const handlersByType = {};
183
113
  const sharedDcbEventLogOps = dcbEventLogOperationsMake({
184
114
  name: config.pluginName,
@@ -191,35 +121,20 @@ export async function buildHandlersForConfig(config, opts = {}) {
191
121
  });
192
122
 
193
123
  loadedSlices.forEach(({ patchedSpec, behaviorModule }) => {
194
- const sliceCallback = stateChangeSliceCallbackMake(patchedSpec)(behaviorModule);
195
- const commandSchema = patchedSpec.commandSchema;
196
- const typeNames = extractVariantNames(commandSchema);
197
-
198
- const jsonHandler = stream => {
199
- const decodedStream = Stream.flatMap(
200
- Stream.mapEffect(stream, topicItem => Effect.sync(() => {
201
- try {
202
- const decoded = decodeCommandPrime(topicItem.command, getIdStringSchema(), commandSchema);
203
- return { TAG: "Some", _0: { command: decoded, reference: topicItem.reference } };
204
- } catch (_) {
205
- return { TAG: "None", _0: 0 };
206
- }
207
- })),
208
- opt => opt.TAG === "Some" ? Stream.make(opt._0) : Stream.empty
209
- );
210
- // Positional args match StateChangeSlice_Callback.res's compiled signature:
211
- // (tagKeysByEventTypeOpt, crossPartitionTagKeysOpt, dcbEventLog, stream).
212
- return sliceCallback.handleCommands(
213
- tagKeysByEventType,
214
- crossPartitionTagKeys,
215
- sharedDcbEventLogOps,
216
- decodedStream,
217
- );
218
- };
219
-
220
- typeNames.forEach(typeName => {
124
+ // Typed core: builds the slice callback (functor), the JSON→command decode,
125
+ // and the handleCommands invocation — all compiler-checked (was the fragile
126
+ // positional call this file's line-146 comment warned about; see the
127
+ // 2026-06-21 arity-drift incident referenced in the _Ops header).
128
+ const jsonHandler = buildSliceHandler(
129
+ patchedSpec,
130
+ behaviorModule,
131
+ tagKeysByEventType,
132
+ crossPartitionTagKeys,
133
+ sharedDcbEventLogOps,
134
+ );
135
+ for (const typeName of commandTypeNames(patchedSpec)) {
221
136
  handlersByType[typeName] = jsonHandler;
222
- });
137
+ }
223
138
  });
224
139
 
225
140
  // Composite handler used by Route 2 (SQS event source) AND inline dispatch
@@ -251,20 +166,19 @@ export async function buildHandlersForConfig(config, opts = {}) {
251
166
 
252
167
  // Sync (default): inline-dispatch the command via the same composite handler
253
168
  // that Route 2 uses, so the AppSync resolver gets a typed Accepted/Rejected
254
- // outcome. Async: undefined → makeGenerateCommand falls back to publishJsons
169
+ // outcome. Async: undefined → makeCommandGenerator falls back to publishJsons
255
170
  // and returns Pending. The schema is permissive (S.json) because AppSync has
256
171
  // already validated input against the SDL — per-slice schemas reapply inside
257
- // the slice handlers via decodeCommandPrime.
172
+ // the typed core's buildSliceHandler decode.
258
173
  const publishJsonsAndWait = DISPATCH_MODE === "async"
259
174
  ? undefined
260
175
  : (jsons) => runInlineAndCollect(jsons, compositeJsonCommandsHandler);
261
176
 
262
- // Positional args match CommandGenerator_Callback.makeGenerateCommand's
263
- // ReScript-compiled signature: (publishJsons, publishJsonsAndWait,
264
- // serviceName, commandSchema, componentKind, stripIdFromParams).
265
- // stripIdFromParams=false: DCB slices may declare a literal `id` field as part
266
- // of the command schema (composite partition keys etc.) — don't strip it.
267
- const generateCommand = makeGenerateCommand(
177
+ // Typed core (CommandGeneratorEntryPoint_Ops.res) pins the arg order against
178
+ // the framework signature. stripIdFromParams=false: DCB slices may declare a
179
+ // literal `id` field as part of the command schema (composite partition keys
180
+ // etc.) don't strip it.
181
+ const generateCommand = makeCommandGenerator(
268
182
  publishJsons,
269
183
  publishJsonsAndWait,
270
184
  config.pluginName,