@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 +14 -0
- package/package.json +6 -6
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +21 -0
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +9 -1
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +67 -31
- package/tests/QueryDbResolvers_AppSyncTest.res +40 -0
- package/tests/QueryDbResolvers_AppSyncTest.res.mjs +35 -0
- package/tests/integration/DcbCommandTopicEntryPoint_IntegrationTest.res +105 -0
- package/tests/integration/DcbCommandTopicEntryPoint_IntegrationTest.res.mjs +98 -0
- package/tests/integration/DcbIntegrationHarness.res +48 -0
- package/tests/integration/DcbIntegrationHarness.res.mjs +60 -1
- package/tests/integration/EpCompositeSlice.res +39 -0
- package/tests/integration/EpCompositeSlice.res.mjs +46 -0
- package/tests/integration/EpCompositeSliceBehavior.res +22 -0
- package/tests/integration/EpCompositeSliceBehavior.res.mjs +36 -0
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.
|
|
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.
|
|
18
|
-
"@reventlessdev/
|
|
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.
|
|
21
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.
|
|
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
|
|
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
|
-
/*
|
|
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 */
|