@reventlessdev/reventless-aws 3.0.0-alpha.183 → 3.0.0-alpha.185

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,20 @@
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.185 (2026-07-08)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **reventless-aws:** thread partitionTag into deployed DCB command Lambda append ([68f0859](https://github.com/ReventlessDev/reventless-core/commit/68f0859d3d5e44f6217a5dc0d8537cfec3d21194))
11
+
12
+
13
+ # 3.0.0-alpha.184 (2026-07-08)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **reventless-aws:** exclude internal rows from Platform_Plugins connection scan ([df14af2](https://github.com/ReventlessDev/reventless-core/commit/df14af2cbd70d49062f2afc03418cdbf18d151d6))
18
+
19
+
6
20
  # 3.0.0-alpha.183 (2026-07-08)
7
21
 
8
22
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.183",
3
+ "version": "3.0.0-alpha.185",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -11,14 +11,14 @@
11
11
  "@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.20",
12
12
  "@reventlessdev/rescript-effect": "0.1.0-alpha.25",
13
13
  "@reventlessdev/rescript-jest": "1.0.0-alpha.6",
14
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.46",
15
14
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
16
15
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.14",
17
- "@reventlessdev/reventless-core": "3.0.0-alpha.145",
18
- "@reventlessdev/reventless-infra": "3.0.0-alpha.90",
16
+ "@reventlessdev/reventless-core": "3.0.0-alpha.146",
17
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.47",
18
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.91",
19
19
  "@reventlessdev/reventless-interop": "3.0.0-alpha.24",
20
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.9",
21
- "@reventlessdev/reventless-spec": "3.0.0-alpha.68"
20
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.10",
21
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.69"
22
22
  },
23
23
  "devDependencies": {
24
24
  "rescript": "^12.3.0",
@@ -57,6 +57,17 @@ export function response(ctx) {
57
57
  }
58
58
  `->Pulumi.Input.make
59
59
 
60
+ /** The discriminator attribute whose absence marks an internal bookkeeping row for a
61
+ read model whose physical DynamoDB table co-hosts rows written outside the projection.
62
+ Only the Plugins admin RM has this: its table also holds `deploy-schema:*` /
63
+ `plugin-info:*` / `deploy-schema-hash:*` rows (Platform.res preResolversSchemaHook)
64
+ that carry no `name`. Returning `Some(attr)` makes the Connection Scan emit an
65
+ `attribute_exists(#attr)` filter so those rows never reach the non-null GraphQL schema.
66
+ `name` is the already-capitalized read-model name (spec name, e.g. "Plugins").
67
+ See docs/plans/platform-plugins-admin-connection-null-rows.md. */
68
+ let internalRowRequiredAttr = (name: string): option<string> =>
69
+ name == "Plugins" ? Some("name") : None
70
+
60
71
  let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
61
72
  ~name: string,
62
73
  ~api: api,
@@ -217,6 +228,15 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
217
228
  )->Array.forEach(msg => log.warn(~comp="QueryDbResolvers_AppSync", msg))
218
229
  | None => ()
219
230
  }
231
+ // The Plugins admin RM's DynamoDB table co-hosts deploy-time infra rows
232
+ // (`deploy-schema:<name>`, `plugin-info:<name>`, `deploy-schema-hash:<apiId>`)
233
+ // written directly by the platform (Platform.res preResolversSchemaHook), not by
234
+ // the projection. Those rows carry no `name` attribute, so an unfiltered Scan
235
+ // resolves `name: String!` to null → non-null violation that nulls the entire
236
+ // Platform_PluginConnection. Exclude them with an `attribute_exists(#name)` filter
237
+ // (prefix-agnostic: real plugin rows always carry `name`, internal rows never do).
238
+ // See docs/plans/platform-plugins-admin-connection-null-rows.md.
239
+ let requireAttribute = internalRowRequiredAttr(name)
220
240
  let resolverAll = makeQueryResolver(
221
241
  ~resolverName=fieldNameForAll->String.capitalize,
222
242
  ~field=fieldNameForAll->Pulumi.Input.make,
@@ -226,6 +246,7 @@ let make: ReventlessCore.QueryDb_Adapter.resolversMaker<api, role> = (
226
246
  ~filterFields=filterFieldNames,
227
247
  ~rangeFields=rangeFieldNames,
228
248
  ~sortFields=sortFieldNames,
249
+ ~requireAttribute?,
229
250
  )
230
251
  } else {
231
252
  Resolver.Functions.listAllItems
@@ -58,6 +58,12 @@ export function response(ctx) {
58
58
  `;
59
59
  }
60
60
 
61
+ function internalRowRequiredAttr(name) {
62
+ if (name === "Plugins") {
63
+ return "name";
64
+ }
65
+ }
66
+
61
67
  function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolverConfigs, idsResolverConfigs, param, opts) {
62
68
  let name$1 = Stdlib_String.capitalize(name);
63
69
  let registryEntry = Plugin_Helpers$ReventlessCore.queryFieldNamesRegistry[name$1];
@@ -100,7 +106,8 @@ function make(name, api, apiRole, dataSourceName, indexes, subIdField, idResolve
100
106
  ).concat(Stdlib_Array.filterMap(indexes, param => param.subIdField));
101
107
  GraphQL_FragmentGenerator$ReventlessCore.validateScanSortAlignment(stateSchemaOpt, name$1, knownSortFields).forEach(msg => log.warn("QueryDbResolvers_AppSync", undefined, msg));
102
108
  }
103
- let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
109
+ let requireAttribute = internalRowRequiredAttr(name$1);
110
+ let resolverAll = makeQueryResolver(Stdlib_String.capitalize(fieldNameForAll), fieldNameForAll, connectionSpec ? AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection(labelField, filterFieldNames, rangeFieldNames, sortFieldNames, requireAttribute) : AppSync_Resolver_Functions$PulumiAws.listAllItems);
104
111
  let resolversByIndex = indexes.map(indexConfig => {
105
112
  let index = indexConfig.index;
106
113
  let stripLeadingBy = s => {
@@ -238,6 +245,7 @@ export {
238
245
  log,
239
246
  queryInterceptorConfig,
240
247
  interceptorCode,
248
+ internalRowRequiredAttr,
241
249
  make,
242
250
  }
243
251
  /* log Not a pure module */
@@ -12,6 +12,7 @@ import { tag as requestContextTag } from "@reventlessdev/reventless-core/src/Req
12
12
  import {
13
13
  extractVariantNames,
14
14
  deriveEffectiveScope,
15
+ derivePartitionTag,
15
16
  } from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
16
17
  import { $$String as IdString } from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
17
18
  import { Make as dcbEventLogOperationsMake } from "@reventlessdev/reventless-core/src/components/DcbEventLog/DcbEventLog_Operations.res.mjs";
@@ -89,6 +90,61 @@ function getIdStringSchema() {
89
90
  // can drive the real `cmdGenHandler` end-to-end against DynamoDB Local.
90
91
  export async function buildHandlersForConfig(config, opts = {}) {
91
92
  const loadModule = opts.loadModule ?? dynamicImport;
93
+
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.
98
+ const loadedSlices = await Promise.all(
99
+ config.stateChangeSliceModules.map(async ({ spec, behavior }) => {
100
+ const specModule = await loadModule(spec);
101
+ const behaviorModule = await loadModule(behavior);
102
+ const patchedSpec = patchSpecId(specModule);
103
+ return { patchedSpec, behaviorModule };
104
+ })
105
+ );
106
+
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
+ }))
120
+ );
121
+
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
+
92
148
  // `pgConnection`, when present in HANDLER_CONFIG, selects the Postgres DCB
93
149
  // runtime (dcbEventLogTableName doubles as the `dcb_event.log_name`); absence
94
150
  // keeps the DynamoDB path byte-identical. NB: this swaps only the *storage*
@@ -108,10 +164,18 @@ export async function buildHandlersForConfig(config, opts = {}) {
108
164
  })()
109
165
  : (() => {
110
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.
111
175
  return {
112
- read: read(resolvedTable),
113
- append: append(resolvedTable),
114
- readStream: readStream(resolvedTable),
176
+ read: read(resolvedTable, crossPartitionTagKeys),
177
+ append: append(resolvedTable, partitionTag, crossPartitionTagKeys),
178
+ readStream: readStream(resolvedTable, crossPartitionTagKeys),
115
179
  };
116
180
  })();
117
181
 
@@ -126,34 +190,6 @@ export async function buildHandlersForConfig(config, opts = {}) {
126
190
  publishJson: async (_name, _meta, _json) => {},
127
191
  });
128
192
 
129
- // Load all spec/behavior pairs first, then derive the per-plugin
130
- // tagKeysByEventType / crossPartitionTagKeys across the produced event
131
- // schemas — mirroring Dcb_Builder.res's build-time derivation — so the
132
- // runtime slice callback receives the same values it would in-process.
133
- const loadedSlices = await Promise.all(
134
- config.stateChangeSliceModules.map(async ({ spec, behavior }) => {
135
- const specModule = await loadModule(spec);
136
- const behaviorModule = await loadModule(behavior);
137
- const patchedSpec = patchSpecId(specModule);
138
- return { patchedSpec, behaviorModule };
139
- })
140
- );
141
-
142
- // Single source of truth with Dcb_Builder.res: prefer slice-graph inference
143
- // (which classifies cross-partition `@ref` reference reads), falling back to
144
- // annotations only on ambiguity. Re-deriving from annotations alone here
145
- // silently dropped inferred cross-partition reads, so every reference-guarded
146
- // command was rejected on the deployed path — see
147
- // docs/analysis/dcb-runtime-scope-annotation-drift.md.
148
- const { crossPartitionTagKeys, tagKeysByEventType } = deriveEffectiveScope(
149
- loadedSlices.map(({ patchedSpec }) => ({
150
- name: patchedSpec.name,
151
- commandSchema: patchedSpec.commandSchema,
152
- consumedEventSchema: patchedSpec.consumedEventSchema,
153
- eventSchema: patchedSpec.eventSchema,
154
- }))
155
- );
156
-
157
193
  loadedSlices.forEach(({ patchedSpec, behaviorModule }) => {
158
194
  const sliceCallback = stateChangeSliceCallbackMake(patchedSpec)(behaviorModule);
159
195
  const commandSchema = patchedSpec.commandSchema;
@@ -0,0 +1,40 @@
1
+ open JestGlobals
2
+
3
+ // Regression guard for docs/plans/platform-plugins-admin-connection-null-rows.md:
4
+ // the Plugins admin RM shares its DynamoDB table with internal bookkeeping rows
5
+ // (`deploy-schema:*`, `plugin-info:*`, `deploy-schema-hash:*`) that carry no `name`.
6
+ // The auto-generated Connection Scan must exclude them, or `name: String!` resolves
7
+ // to null and nulls the entire Platform_PluginConnection ("No data" on the page).
8
+
9
+ // `Pulumi.Input.make` is `%identity`, so a generated resolver's `Pulumi.Input.t<string>`
10
+ // IS the underlying JS string — recover it with Obj.magic for assertion.
11
+ let codeOf = (input: Pulumi.Input.t<string>): string => Obj.magic(input)
12
+
13
+ describe("QueryDbResolvers_AppSync.internalRowRequiredAttr", () => {
14
+ testSync("Plugins RM requires the `name` discriminator", () => {
15
+ expect(QueryDbResolvers_AppSync.internalRowRequiredAttr("Plugins"))->toEqual(Some("name"))
16
+ })
17
+
18
+ testSync("ordinary read models require nothing (unchanged behaviour)", () => {
19
+ expect(QueryDbResolvers_AppSync.internalRowRequiredAttr("Products"))->toEqual(None)
20
+ expect(QueryDbResolvers_AppSync.internalRowRequiredAttr("Orders"))->toEqual(None)
21
+ })
22
+ })
23
+
24
+ describe("AppSync_Resolver_Retrying.Functions.listAllItemsConnection", () => {
25
+ testSync("with ~requireAttribute emits an attribute_exists filter that excludes internal rows", () => {
26
+ let code =
27
+ AppSync_Resolver_Retrying.Functions.listAllItemsConnection(
28
+ ~labelField="name",
29
+ ~requireAttribute="name",
30
+ )->codeOf
31
+ expect(code->String.includes("attribute_exists(#name)"))->toBe(true)
32
+ expect(code->String.includes("names['#name'] = 'name'"))->toBe(true)
33
+ })
34
+
35
+ testSync("without ~requireAttribute emits no attribute_exists filter (default read models unchanged)", () => {
36
+ let code =
37
+ AppSync_Resolver_Retrying.Functions.listAllItemsConnection(~labelField="name")->codeOf
38
+ expect(code->String.includes("attribute_exists"))->toBe(false)
39
+ })
40
+ })
@@ -0,0 +1,35 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as AppSync_Resolver_Functions$PulumiAws from "@reventlessdev/rescript-pulumi-aws/src/AppSync/AppSync_Resolver_Functions.res.mjs";
4
+ import * as QueryDbResolvers_AppSync$ReventlessAws from "../src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs";
5
+
6
+ function codeOf(input) {
7
+ return input;
8
+ }
9
+
10
+ globalThis.describe("QueryDbResolvers_AppSync.internalRowRequiredAttr", () => {
11
+ globalThis.test("Plugins RM requires the `name` discriminator", () => {
12
+ globalThis.expect(QueryDbResolvers_AppSync$ReventlessAws.internalRowRequiredAttr("Plugins")).toEqual("name");
13
+ });
14
+ globalThis.test("ordinary read models require nothing (unchanged behaviour)", () => {
15
+ globalThis.expect(QueryDbResolvers_AppSync$ReventlessAws.internalRowRequiredAttr("Products")).toEqual(undefined);
16
+ globalThis.expect(QueryDbResolvers_AppSync$ReventlessAws.internalRowRequiredAttr("Orders")).toEqual(undefined);
17
+ });
18
+ });
19
+
20
+ globalThis.describe("AppSync_Resolver_Retrying.Functions.listAllItemsConnection", () => {
21
+ globalThis.test("with ~requireAttribute emits an attribute_exists filter that excludes internal rows", () => {
22
+ let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, "name");
23
+ globalThis.expect(code.includes("attribute_exists(#name)")).toBe(true);
24
+ globalThis.expect(code.includes("names['#name'] = 'name'")).toBe(true);
25
+ });
26
+ globalThis.test("without ~requireAttribute emits no attribute_exists filter (default read models unchanged)", () => {
27
+ let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined);
28
+ globalThis.expect(code.includes("attribute_exists")).toBe(false);
29
+ });
30
+ });
31
+
32
+ export {
33
+ codeOf,
34
+ }
35
+ /* Not a pure module */
@@ -59,6 +59,64 @@ let runOneAppSyncEvent: (string, JSON.t) => promise<JSON.t> = %raw(`
59
59
  }
60
60
  `)
61
61
 
62
+ // Same as `runOneAppSyncEvent` but wires the composite-partition fixture
63
+ // (EpCompositeSlice) through the real entry point. Proves the deployed path
64
+ // derives and threads `partitionTag = Composite(...)` into the DynamoDB
65
+ // `append` — the fix for the residual burst-contention bug.
66
+ let runOneCompositeEvent: (string, JSON.t) => promise<JSON.t> = %raw(`
67
+ async (tableName, event) => {
68
+ const { buildHandlersForConfig } = await import(
69
+ "@reventlessdev/reventless-aws/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs"
70
+ );
71
+ const Effect = await import("effect/Effect");
72
+ const { tag: requestContextTag } = await import(
73
+ "@reventlessdev/reventless-core/src/RequestContext.res.mjs"
74
+ );
75
+ const { commandOutcomeToJson } = await import(
76
+ "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic_Helpers.res.mjs"
77
+ );
78
+
79
+ const loadModule = async (specifier) => {
80
+ if (specifier === "ep-composite://spec") return await import("./EpCompositeSlice.res.mjs");
81
+ if (specifier === "ep-composite://behavior") return await import("./EpCompositeSliceBehavior.res.mjs");
82
+ throw new Error("unknown test specifier: " + specifier);
83
+ };
84
+
85
+ const config = {
86
+ pluginName: "EpCompositePlugin",
87
+ dcbEventLogTableName: tableName,
88
+ stateChangeSliceModules: [{ spec: "ep-composite://spec", behavior: "ep-composite://behavior" }],
89
+ queueUrl: "https://sqs.eu-west-1.amazonaws.com/000000000000/ep-composite-queue",
90
+ };
91
+
92
+ const [, cmdGenHandler] = await buildHandlersForConfig(config, { loadModule });
93
+
94
+ const effect = cmdGenHandler(event)
95
+ .pipe(Effect.provideService(requestContextTag, { correlationId: "ep-composite" }));
96
+ const outcome = await Effect.runPromise(effect);
97
+ return commandOutcomeToJson(outcome);
98
+ }
99
+ `)
100
+
101
+ let buildAddResourceEvent = (~environment, ~resourceName): JSON.t => {
102
+ // `command` is the command type name as a plain string — the shape AppSync's
103
+ // direct-invoke resolver sends and `CommandGenerator.payload.command: string`
104
+ // expects.
105
+ let arguments = Dict.fromArray([
106
+ ("environment", environment->JSON.Encode.string),
107
+ ("resourceName", resourceName->JSON.Encode.string),
108
+ ])
109
+ let meta = Dict.fromArray([
110
+ ("user", "ep-composite"->JSON.Encode.string),
111
+ ("ip", JSON.Encode.null),
112
+ ])
113
+ Dict.fromArray([
114
+ ("command", "AddResource"->JSON.Encode.string),
115
+ ("arguments", arguments->JSON.Encode.object),
116
+ ("meta", meta->JSON.Encode.object),
117
+ ])->JSON.Encode.object
118
+ }
119
+
62
120
  let buildAppSyncEvent = (widgetId): JSON.t => {
63
121
  // CommandGenerator payload: `{command, arguments, meta, identity?}` — the
64
122
  // exact shape AppSync's direct-invoke resolver sends (matches the resolver
@@ -98,4 +156,51 @@ describe("DcbCommandTopicEntryPoint integration", () => {
98
156
  expect(s->String.includes("CommandAccepted"))->toBe(true)
99
157
  },
100
158
  )
159
+
160
+ // Residual composite-fence burst contention
161
+ // (docs/plans/dcb-composite-fence-residual-burst-contention.md).
162
+ //
163
+ // The entry point must derive `partitionTag = Composite(...)` and thread it
164
+ // into the DynamoDB `append`, so a `@compositePartitionTag` slice collapses to
165
+ // ONE synthetic composite fence per entity. Without the thread (the bug),
166
+ // `partitionTag` defaults to None, the collapse never fires, and each append
167
+ // fences on its individual members — the shared low-cardinality `environment`
168
+ // member then serialises/rejects distinct resources.
169
+ testAsync(
170
+ "composite-partition burst: distinct resources sharing a prefix all commit, only composite fences written",
171
+ async () => {
172
+ let table = await H.createDcbTableWithTagKeys(
173
+ "EpComposite_" ++ Date.now()->Float.toString,
174
+ ["environment", "resourceName"],
175
+ )
176
+ let env = "prod"
177
+ let resourceNames = ["res-a", "res-b", "res-c", "res-d", "res-e"]
178
+
179
+ // Concurrent burst — simulates the deploy-sync fan-out that surfaced the
180
+ // `retries exhausted` cascade on alpha.
181
+ let outcomes =
182
+ await resourceNames
183
+ ->Array.map(resourceName =>
184
+ runOneCompositeEvent(table.name, buildAddResourceEvent(~environment=env, ~resourceName))
185
+ )
186
+ ->Promise.all
187
+
188
+ // Every distinct composite entity must commit — none may conflict on the
189
+ // shared `environment` prefix.
190
+ outcomes->Array.forEach(outcomeJson => {
191
+ let s = outcomeJson->JSON.stringifyAny->Option.getOr("<unserializable>")
192
+ expect(s->String.includes("CommandAccepted"))->toBe(true)
193
+ })
194
+
195
+ // Mechanism-level proof: the only fence rows are the synthetic composite
196
+ // fences (`fence#__dcb_composite__:…`) — one per entity — with NO
197
+ // per-member `fence#environment:…` / `fence#resourceName:…` rows.
198
+ let fenceIds = await H.scanFenceIds(table)
199
+ let compositeFencePrefix = "fence#" ++ DcbEventLogStorage_DynamoDb_Runtime.compositeFenceTagKey
200
+ expect(fenceIds->Array.length)->toBe(resourceNames->Array.length)
201
+ fenceIds->Array.forEach(id => expect(id->String.startsWith(compositeFencePrefix))->toBe(true))
202
+
203
+ await H.deleteTable(table)
204
+ },
205
+ )
101
206
  })
@@ -2,6 +2,7 @@
2
2
 
3
3
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
4
  import * as DcbIntegrationHarness$ReventlessAws from "./DcbIntegrationHarness.res.mjs";
5
+ import * as DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws from "../../src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs";
5
6
 
6
7
  let runOneAppSyncEvent = (async (tableName, event) => {
7
8
  const { buildHandlersForConfig } = await import(
@@ -36,6 +37,76 @@ let runOneAppSyncEvent = (async (tableName, event) => {
36
37
  return commandOutcomeToJson(outcome);
37
38
  });
38
39
 
40
+ let runOneCompositeEvent = (async (tableName, event) => {
41
+ const { buildHandlersForConfig } = await import(
42
+ "@reventlessdev/reventless-aws/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs"
43
+ );
44
+ const Effect = await import("effect/Effect");
45
+ const { tag: requestContextTag } = await import(
46
+ "@reventlessdev/reventless-core/src/RequestContext.res.mjs"
47
+ );
48
+ const { commandOutcomeToJson } = await import(
49
+ "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic_Helpers.res.mjs"
50
+ );
51
+
52
+ const loadModule = async (specifier) => {
53
+ if (specifier === "ep-composite://spec") return await import("./EpCompositeSlice.res.mjs");
54
+ if (specifier === "ep-composite://behavior") return await import("./EpCompositeSliceBehavior.res.mjs");
55
+ throw new Error("unknown test specifier: " + specifier);
56
+ };
57
+
58
+ const config = {
59
+ pluginName: "EpCompositePlugin",
60
+ dcbEventLogTableName: tableName,
61
+ stateChangeSliceModules: [{ spec: "ep-composite://spec", behavior: "ep-composite://behavior" }],
62
+ queueUrl: "https://sqs.eu-west-1.amazonaws.com/000000000000/ep-composite-queue",
63
+ };
64
+
65
+ const [, cmdGenHandler] = await buildHandlersForConfig(config, { loadModule });
66
+
67
+ const effect = cmdGenHandler(event)
68
+ .pipe(Effect.provideService(requestContextTag, { correlationId: "ep-composite" }));
69
+ const outcome = await Effect.runPromise(effect);
70
+ return commandOutcomeToJson(outcome);
71
+ });
72
+
73
+ function buildAddResourceEvent(environment, resourceName) {
74
+ let $$arguments = Object.fromEntries([
75
+ [
76
+ "environment",
77
+ environment
78
+ ],
79
+ [
80
+ "resourceName",
81
+ resourceName
82
+ ]
83
+ ]);
84
+ let meta = Object.fromEntries([
85
+ [
86
+ "user",
87
+ "ep-composite"
88
+ ],
89
+ [
90
+ "ip",
91
+ null
92
+ ]
93
+ ]);
94
+ return Object.fromEntries([
95
+ [
96
+ "command",
97
+ "AddResource"
98
+ ],
99
+ [
100
+ "arguments",
101
+ $$arguments
102
+ ],
103
+ [
104
+ "meta",
105
+ meta
106
+ ]
107
+ ]);
108
+ }
109
+
39
110
  function buildAppSyncEvent(widgetId) {
40
111
  let command = Object.fromEntries([[
41
112
  "AddWidget",
@@ -79,6 +150,31 @@ globalThis.describe("DcbCommandTopicEntryPoint integration", () => {
79
150
  let s = Stdlib_Option.getOr(JSON.stringify(outcomeJson), "<unserializable>");
80
151
  globalThis.expect(s.includes("CommandAccepted")).toBe(true);
81
152
  });
153
+ globalThis.test("composite-partition burst: distinct resources sharing a prefix all commit, only composite fences written", async () => {
154
+ let table = await DcbIntegrationHarness$ReventlessAws.createDcbTableWithTagKeys("EpComposite_" + Date.now().toString(), [
155
+ "environment",
156
+ "resourceName"
157
+ ]);
158
+ let resourceNames = [
159
+ "res-a",
160
+ "res-b",
161
+ "res-c",
162
+ "res-d",
163
+ "res-e"
164
+ ];
165
+ let outcomes = await Promise.all(resourceNames.map(resourceName => runOneCompositeEvent(table.name, buildAddResourceEvent("prod", resourceName))));
166
+ outcomes.forEach(outcomeJson => {
167
+ let s = Stdlib_Option.getOr(JSON.stringify(outcomeJson), "<unserializable>");
168
+ globalThis.expect(s.includes("CommandAccepted")).toBe(true);
169
+ });
170
+ let fenceIds = await DcbIntegrationHarness$ReventlessAws.scanFenceIds(table);
171
+ let compositeFencePrefix = "fence#" + DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.compositeFenceTagKey;
172
+ globalThis.expect(fenceIds.length).toBe(resourceNames.length);
173
+ fenceIds.forEach(id => {
174
+ globalThis.expect(id.startsWith(compositeFencePrefix)).toBe(true);
175
+ });
176
+ return await DcbIntegrationHarness$ReventlessAws.deleteTable(table);
177
+ });
82
178
  });
83
179
 
84
180
  let H;
@@ -86,6 +182,8 @@ let H;
86
182
  export {
87
183
  H,
88
184
  runOneAppSyncEvent,
185
+ runOneCompositeEvent,
186
+ buildAddResourceEvent,
89
187
  buildAppSyncEvent,
90
188
  }
91
189
  /* runOneAppSyncEvent Not a pure module */
@@ -73,6 +73,54 @@ let createDcbTable = async (tableName): Util_DynamoDb_Runtime.resolvedTable => {
73
73
  }
74
74
  }
75
75
 
76
+ // Like `createDcbTable` but with a caller-supplied set of `tag_<key>` GSI names
77
+ // plus `tag_composite`. Used by the composite-partition scenarios whose member
78
+ // keys (e.g. `environment`, `resourceName`) are not in the default online-shop
79
+ // `gsiNames` superset.
80
+ let createDcbTableWithTagKeys = async (
81
+ tableName,
82
+ tagKeys: array<string>,
83
+ ): Util_DynamoDb_Runtime.resolvedTable => {
84
+ let indexNames =
85
+ tagKeys->Array.map(k => `tag_${k}`)->Array.concat(["tag_composite"])
86
+ let attributeDefinitions = Array.concat(
87
+ [attrDef("id"), attrDef("position")],
88
+ indexNames->Array.map(attrDef),
89
+ )
90
+ let input =
91
+ Dict.fromArray([
92
+ ("TableName", s(tableName)),
93
+ ("AttributeDefinitions", attributeDefinitions->JSON.Encode.array),
94
+ ("KeySchema", [keyEl("id", "HASH"), keyEl("position", "RANGE")]->JSON.Encode.array),
95
+ ("GlobalSecondaryIndexes", indexNames->Array.map(gsi)->JSON.Encode.array),
96
+ ("BillingMode", s("PAY_PER_REQUEST")),
97
+ ])->JSON.Encode.object
98
+ let _ = await send(createTableCommand(input))
99
+ {
100
+ Util_DynamoDb_Runtime.id: tableName,
101
+ name: tableName,
102
+ arn: `arn:aws:dynamodb:local:000000000000:table/${tableName}`,
103
+ hashKey: "id",
104
+ }
105
+ }
106
+
107
+ // Returns the `id`s of every fence sentinel row in the table (`id` begins with
108
+ // `fence#`). Lets a test assert the fence *shape* directly — the mechanism-level
109
+ // signal that the composite collapse fired (one `fence#__dcb_composite__:…` per
110
+ // entity) rather than per-member `fence#<member>:…` rows.
111
+ let scanFenceIds = async (table: Util_DynamoDb_Runtime.resolvedTable): array<string> => {
112
+ let items = await Util_DynamoDb_Runtime.scanStream({tableName: table.name})
113
+ ->Stream.runCollect
114
+ ->Effect.runPromise
115
+ items->Array.filterMap(item =>
116
+ item
117
+ ->JSON.Decode.object
118
+ ->Option.flatMap(obj => obj->Dict.get("id"))
119
+ ->Option.flatMap(JSON.Decode.string)
120
+ ->Option.flatMap(id => id->String.startsWith("fence#") ? Some(id) : None)
121
+ )
122
+ }
123
+
76
124
  let deleteTable = async (table: Util_DynamoDb_Runtime.resolvedTable) => {
77
125
  let input = Dict.fromArray([("TableName", s(table.name))])->JSON.Encode.object
78
126
  let _ = await send(deleteTableCommand(input))
@@ -1,5 +1,10 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Stream from "@reventlessdev/rescript-effect/src/Stream.res.mjs";
4
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
+ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Effect from "effect/Effect";
3
8
  import * as ClientDynamodb from "@aws-sdk/client-dynamodb";
4
9
  import * as DynamoDb_DynamoDb$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_DynamoDb.res.mjs";
5
10
  import * as Util_DynamoDb_Runtime$ReventlessAws from "../../src/util/Util_DynamoDb_Runtime.res.mjs";
@@ -111,6 +116,58 @@ async function createDcbTable(tableName) {
111
116
  };
112
117
  }
113
118
 
119
+ async function createDcbTableWithTagKeys(tableName, tagKeys) {
120
+ let indexNames = tagKeys.map(k => `tag_` + k).concat(["tag_composite"]);
121
+ let attributeDefinitions = [
122
+ attrDef("id"),
123
+ attrDef("position")
124
+ ].concat(indexNames.map(attrDef));
125
+ let input = Object.fromEntries([
126
+ [
127
+ "TableName",
128
+ tableName
129
+ ],
130
+ [
131
+ "AttributeDefinitions",
132
+ attributeDefinitions
133
+ ],
134
+ [
135
+ "KeySchema",
136
+ [
137
+ keyEl("id", "HASH"),
138
+ keyEl("position", "RANGE")
139
+ ]
140
+ ],
141
+ [
142
+ "GlobalSecondaryIndexes",
143
+ indexNames.map(gsi)
144
+ ],
145
+ [
146
+ "BillingMode",
147
+ "PAY_PER_REQUEST"
148
+ ]
149
+ ]);
150
+ let cmd = new ClientDynamodb.CreateTableCommand(input);
151
+ await DynamoDb_DynamoDb$AwsSdk.client().send(cmd);
152
+ return {
153
+ id: tableName,
154
+ name: tableName,
155
+ arn: `arn:aws:dynamodb:local:000000000000:table/` + tableName,
156
+ hashKey: "id"
157
+ };
158
+ }
159
+
160
+ async function scanFenceIds(table) {
161
+ let items = await Effect.runPromise(Stream.runCollect(Util_DynamoDb_Runtime$ReventlessAws.scanStream({
162
+ TableName: table.name
163
+ })));
164
+ return Stdlib_Array.filterMap(items, item => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), obj => obj["id"]), Stdlib_JSON.Decode.string), id => {
165
+ if (id.startsWith("fence#")) {
166
+ return id;
167
+ }
168
+ }));
169
+ }
170
+
114
171
  async function deleteTable(table) {
115
172
  let input = Object.fromEntries([[
116
173
  "TableName",
@@ -154,9 +211,11 @@ export {
154
211
  gsi,
155
212
  gsiNames,
156
213
  createDcbTable,
214
+ createDcbTableWithTagKeys,
215
+ scanFenceIds,
157
216
  deleteTable,
158
217
  counter,
159
218
  freshTable,
160
219
  setFence,
161
220
  }
162
- /* @aws-sdk/client-dynamodb Not a pure module */
221
+ /* Stream Not a pure module */
@@ -0,0 +1,39 @@
1
+ // Composite-partition slice fixture for `DcbCommandTopicEntryPoint_IntegrationTest`.
2
+ //
3
+ // Reproduces the deploy-sync workload shape from
4
+ // docs/plans/dcb-composite-fence-residual-burst-contention.md: a
5
+ // `@compositePartitionTag` over a low-cardinality prefix (`environment`) plus a
6
+ // high-cardinality tail (`resourceName`). Distinct resources sharing the same
7
+ // `environment` must NOT contend — but only if the entry point threads
8
+ // `partitionTag = Composite(...)` into the DynamoDB `append`, activating the
9
+ // single-composite-fence collapse. Without that thread the slice writes one
10
+ // fence per member and the shared `environment` member goes hot.
11
+ //
12
+ // Explicit `@s.matches(Reventless.DcbTag.compositePartitionMember(...))` form
13
+ // (not the `@compositePartitionTag` PPX shorthand) because reventless-ppx is not
14
+ // wired into reventless-aws's rescript.json — same reason as EpTestSlice.
15
+
16
+ @schema
17
+ type consumedEvent = ResourceAdded({environment: string, resourceName: string})
18
+
19
+ @schema
20
+ type command = AddResource({
21
+ environment: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=0, ~sep="/")) string,
22
+ resourceName: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=1, ~sep="/")) string,
23
+ })
24
+
25
+ @schema
26
+ type error = AlreadyAdded
27
+
28
+ @schema
29
+ type event = ResourceAdded({
30
+ environment: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=0, ~sep="/")) string,
31
+ resourceName: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=1, ~sep="/")) string,
32
+ })
33
+
34
+ let name = "EpCompositeSlice"
35
+ let moduleUrl = "ep-test://EpCompositeSlice"
36
+ let commandAuthorization = (_: command): Reventless.Authorization.permission => AllowAnonymous
37
+ let readConsistency = Reventless.ReadConsistency.EscalateOnRetry
38
+
39
+ // `module Id = Reventless.Id.String` — patched in by `patchSpecId` at runtime.
@@ -0,0 +1,46 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as S from "sury/src/S.res.mjs";
4
+ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
5
+
6
+ let consumedEventSchema = S.schema(s => ({
7
+ TAG: "ResourceAdded",
8
+ environment: s.m(S.string),
9
+ resourceName: s.m(S.string)
10
+ }));
11
+
12
+ let commandSchema = S.schema(s => ({
13
+ TAG: "AddResource",
14
+ environment: s.m(DcbTag$Reventless.compositePartitionMember(0, "/")),
15
+ resourceName: s.m(DcbTag$Reventless.compositePartitionMember(1, "/"))
16
+ }));
17
+
18
+ let errorSchema = S.literal("AlreadyAdded");
19
+
20
+ let eventSchema = S.schema(s => ({
21
+ TAG: "ResourceAdded",
22
+ environment: s.m(DcbTag$Reventless.compositePartitionMember(0, "/")),
23
+ resourceName: s.m(DcbTag$Reventless.compositePartitionMember(1, "/"))
24
+ }));
25
+
26
+ function commandAuthorization(param) {
27
+ return "AllowAnonymous";
28
+ }
29
+
30
+ let name = "EpCompositeSlice";
31
+
32
+ let moduleUrl = "ep-test://EpCompositeSlice";
33
+
34
+ let readConsistency = "EscalateOnRetry";
35
+
36
+ export {
37
+ consumedEventSchema,
38
+ commandSchema,
39
+ errorSchema,
40
+ eventSchema,
41
+ name,
42
+ moduleUrl,
43
+ commandAuthorization,
44
+ readConsistency,
45
+ }
46
+ /* consumedEventSchema Not a pure module */
@@ -0,0 +1,22 @@
1
+ // Behavior pair for `EpCompositeSlice`. Append-on-empty / reject-on-seen, per
2
+ // composite entity: the decision read is scoped to one `{environment,
3
+ // resourceName}` composite, so a fresh resource sees empty state and appends,
4
+ // while a duplicate of the same composite key sees `true` and is rejected.
5
+
6
+ type state = bool
7
+
8
+ let initialState = false
9
+
10
+ let evolve = (_state, _event: EpCompositeSlice.consumedEvent) => true
11
+
12
+ let decide = (state, command: EpCompositeSlice.command): result<
13
+ array<EpCompositeSlice.event>,
14
+ EpCompositeSlice.error,
15
+ > =>
16
+ switch (state, command) {
17
+ | (true, _) => Error(AlreadyAdded)
18
+ | (false, AddResource({environment, resourceName})) =>
19
+ Ok([ResourceAdded({environment, resourceName})])
20
+ }
21
+
22
+ let moduleUrl = "ep-test://EpCompositeSliceBehavior"
@@ -0,0 +1,36 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ function evolve(_state, _event) {
5
+ return true;
6
+ }
7
+
8
+ function decide(state, command) {
9
+ if (state) {
10
+ return {
11
+ TAG: "Error",
12
+ _0: "AlreadyAdded"
13
+ };
14
+ } else {
15
+ return {
16
+ TAG: "Ok",
17
+ _0: [{
18
+ TAG: "ResourceAdded",
19
+ environment: command.environment,
20
+ resourceName: command.resourceName
21
+ }]
22
+ };
23
+ }
24
+ }
25
+
26
+ let initialState = false;
27
+
28
+ let moduleUrl = "ep-test://EpCompositeSliceBehavior";
29
+
30
+ export {
31
+ initialState,
32
+ evolve,
33
+ decide,
34
+ moduleUrl,
35
+ }
36
+ /* No side effect */