@reventlessdev/reventless-aws 3.0.0-alpha.185 → 3.0.0-alpha.186
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 +7 -0
- package/package.json +6 -6
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +6 -1
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +2 -4
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +1 -2
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +33 -0
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res.mjs +37 -0
- package/tests/integration/DcbCommandTopicEntryPoint_IntegrationTest.res +55 -2
- package/tests/integration/DcbCommandTopicEntryPoint_IntegrationTest.res.mjs +27 -3
- package/tests/integration/DcbIntegrationHarness.res +15 -0
- package/tests/integration/DcbIntegrationHarness.res.mjs +8 -0
- package/tests/integration/EpCompositeSlice.res +33 -11
- package/tests/integration/EpCompositeSlice.res.mjs +43 -19
- package/tests/integration/EpCompositeSliceBehavior.res +20 -7
- package/tests/integration/EpCompositeSliceBehavior.res.mjs +21 -5
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
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.186 (2026-07-08)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **reventless-core:** composite DCB slices can read back their own events ([4604a91](https://github.com/ReventlessDev/reventless-core/commit/4604a9159fb8bf59b2191ad69fee6613c7f75cd9))
|
|
11
|
+
|
|
12
|
+
|
|
6
13
|
# 3.0.0-alpha.185 (2026-07-08)
|
|
7
14
|
|
|
8
15
|
### 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.186",
|
|
4
4
|
"description": "AWS adapters for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"dependencies": {
|
|
@@ -9,16 +9,16 @@
|
|
|
9
9
|
"sury": "11.0.0-alpha.4",
|
|
10
10
|
"uuid": "^13.0.0",
|
|
11
11
|
"@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.20",
|
|
12
|
-
"@reventlessdev/rescript-effect": "0.1.0-alpha.25",
|
|
13
12
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.6",
|
|
13
|
+
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.47",
|
|
14
14
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
|
|
15
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.25",
|
|
15
16
|
"@reventlessdev/rescript-uuid": "1.1.0-alpha.14",
|
|
16
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.
|
|
17
|
-
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.47",
|
|
17
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.147",
|
|
18
18
|
"@reventlessdev/reventless-infra": "3.0.0-alpha.91",
|
|
19
19
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.24",
|
|
20
|
-
"@reventlessdev/reventless-
|
|
21
|
-
"@reventlessdev/reventless-
|
|
20
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.69",
|
|
21
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.11"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"rescript": "^12.3.0",
|
|
@@ -124,7 +124,12 @@ let toItem = (
|
|
|
124
124
|
item->Dict.set(attrName, tag.value->JSON.Encode.string)
|
|
125
125
|
})
|
|
126
126
|
|
|
127
|
-
// Add composite tag attribute if multiple tags
|
|
127
|
+
// Add composite tag attribute if multiple tags. The tag set here is exactly the
|
|
128
|
+
// event's *entity* tags (no framework provenance tag is smuggled in — see
|
|
129
|
+
// docs/plans/done/dcb-composite-query-clause-fence-contention.md), so the stored
|
|
130
|
+
// `tag_composite` key is byte-identical to the key a composite decision read
|
|
131
|
+
// builds from the command's entity tags — the write/read match a composite
|
|
132
|
+
// slice's OCC depends on.
|
|
128
133
|
if event.tags->Array.length > 1 {
|
|
129
134
|
let composite = compositeTagKey(event.tags)
|
|
130
135
|
item->Dict.set("tag_composite", composite->JSON.Encode.string)
|
|
@@ -18,8 +18,7 @@ open PulumiAws
|
|
|
18
18
|
// ── Handler code ─────────────────────────────────────────────────────────────
|
|
19
19
|
//
|
|
20
20
|
// Receives SNS-via-SQS records (rawMessageDelivery=true → body IS the event JSON).
|
|
21
|
-
// Publishes {position, eventType, payload
|
|
22
|
-
// The "originatorSlice" tag is injected by StateChangeSlice_Callback.encodeEvent.
|
|
21
|
+
// Publishes {position, eventType, payload} to AppSync Events channel.
|
|
23
22
|
|
|
24
23
|
let makeHandlerCode = (~topicName: string): string => {
|
|
25
24
|
// Mirrors StateTopic_AppSync.pathSegment: AppSync Events channel segments
|
|
@@ -72,8 +71,7 @@ export async function handler(event) {
|
|
|
72
71
|
console.error("EventLogSubscription: failed to parse record body", record.body, e);
|
|
73
72
|
continue;
|
|
74
73
|
}
|
|
75
|
-
const
|
|
76
|
-
const payload = { position: body.position, eventType: body.eventType, payload: body.data, originatorSlice: originatorSlice ?? null };
|
|
74
|
+
const payload = { position: body.position, eventType: body.eventType, payload: body.data };
|
|
77
75
|
const reqBody = JSON.stringify({ id: record.messageId, channel: CHANNEL, events: [JSON.stringify(payload)] });
|
|
78
76
|
const auth = await signedHeaders(url.hostname, "/event", reqBody);
|
|
79
77
|
const res = await fetch(APPSYNC_ENDPOINT + "/event", {
|
|
@@ -61,8 +61,7 @@ export async function handler(event) {
|
|
|
61
61
|
console.error("EventLogSubscription: failed to parse record body", record.body, e);
|
|
62
62
|
continue;
|
|
63
63
|
}
|
|
64
|
-
const
|
|
65
|
-
const payload = { position: body.position, eventType: body.eventType, payload: body.data, originatorSlice: originatorSlice ?? null };
|
|
64
|
+
const payload = { position: body.position, eventType: body.eventType, payload: body.data };
|
|
66
65
|
const reqBody = JSON.stringify({ id: record.messageId, channel: CHANNEL, events: [JSON.stringify(payload)] });
|
|
67
66
|
const auth = await signedHeaders(url.hostname, "/event", reqBody);
|
|
68
67
|
const res = await fetch(APPSYNC_ENDPOINT + "/event", {
|
|
@@ -526,6 +526,39 @@ describe("Runtime.buildConditionalTransactItems — composite partition fences o
|
|
|
526
526
|
})
|
|
527
527
|
})
|
|
528
528
|
|
|
529
|
+
describe("Runtime.toItem — tag_composite is keyed on the event's entity tags", () => {
|
|
530
|
+
// Guards the composite write/read key alignment a composite-partition slice's OCC
|
|
531
|
+
// depends on: the stored `tag_composite` must equal the key a composite decision
|
|
532
|
+
// read builds from the command's entity tags (`compositeTagKey`). Historically an
|
|
533
|
+
// `originatorSlice` provenance tag was smuggled into the tag set and polluted this
|
|
534
|
+
// key, so a composite slice could never read back its own events. See
|
|
535
|
+
// docs/analysis/dcb-event-provenance-and-metadata.md and
|
|
536
|
+
// docs/plans/done/dcb-composite-query-clause-fence-contention.md.
|
|
537
|
+
let tagOf = (key, value): Reventless.DcbTag.tag => {key, value}
|
|
538
|
+
let entityTags = [
|
|
539
|
+
tagOf("environment", "prod"),
|
|
540
|
+
tagOf("platformName", "plat"),
|
|
541
|
+
tagOf("pluginName", "plug"),
|
|
542
|
+
tagOf("componentName", "compA"),
|
|
543
|
+
tagOf("resourceName", "r1"),
|
|
544
|
+
]
|
|
545
|
+
let storedEvent: ReventlessCore.DcbEventLog_Adapter.rawStoredEvent = {
|
|
546
|
+
eventType: "ResourceAdded",
|
|
547
|
+
data: JSON.Object(Dict.make()),
|
|
548
|
+
tags: entityTags,
|
|
549
|
+
meta: testMeta(),
|
|
550
|
+
}
|
|
551
|
+
let storedComposite =
|
|
552
|
+
Runtime.toItem("100", storedEvent, ~recordedAt="2026-07-08T00:00:00Z")
|
|
553
|
+
->JSON.Decode.object
|
|
554
|
+
->Option.flatMap(o => o->Dict.get("tag_composite"))
|
|
555
|
+
->Option.flatMap(JSON.Decode.string)
|
|
556
|
+
|
|
557
|
+
testSync("stored tag_composite equals the key a composite read would query", () => {
|
|
558
|
+
expect(storedComposite)->toEqual(Some(Runtime.compositeTagKey(entityTags)))
|
|
559
|
+
})
|
|
560
|
+
})
|
|
561
|
+
|
|
529
562
|
describe("Runtime.buildConditionalTransactItems — folded create guard (after=None)", () => {
|
|
530
563
|
let event = (eventType, tags): ReventlessCore.DcbEventLog_Adapter.rawStoredEvent => {
|
|
531
564
|
eventType,
|
|
@@ -653,6 +653,43 @@ globalThis.describe("Runtime.buildConditionalTransactItems — composite partiti
|
|
|
653
653
|
});
|
|
654
654
|
});
|
|
655
655
|
|
|
656
|
+
globalThis.describe("Runtime.toItem — tag_composite is keyed on the event's entity tags", () => {
|
|
657
|
+
let entityTags = [
|
|
658
|
+
{
|
|
659
|
+
key: "environment",
|
|
660
|
+
value: "prod"
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
key: "platformName",
|
|
664
|
+
value: "plat"
|
|
665
|
+
},
|
|
666
|
+
{
|
|
667
|
+
key: "pluginName",
|
|
668
|
+
value: "plug"
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
key: "componentName",
|
|
672
|
+
value: "compA"
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
key: "resourceName",
|
|
676
|
+
value: "r1"
|
|
677
|
+
}
|
|
678
|
+
];
|
|
679
|
+
let storedEvent_data = {};
|
|
680
|
+
let storedEvent_meta = testMeta();
|
|
681
|
+
let storedEvent = {
|
|
682
|
+
eventType: "ResourceAdded",
|
|
683
|
+
data: storedEvent_data,
|
|
684
|
+
tags: entityTags,
|
|
685
|
+
meta: storedEvent_meta
|
|
686
|
+
};
|
|
687
|
+
let storedComposite = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.toItem("100", storedEvent, undefined, "2026-07-08T00:00:00Z")), o => o["tag_composite"]), Stdlib_JSON.Decode.string);
|
|
688
|
+
globalThis.test("stored tag_composite equals the key a composite read would query", () => {
|
|
689
|
+
globalThis.expect(storedComposite).toEqual(DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.compositeTagKey(entityTags));
|
|
690
|
+
});
|
|
691
|
+
});
|
|
692
|
+
|
|
656
693
|
globalThis.describe("Runtime.buildConditionalTransactItems — folded create guard (after=None)", () => {
|
|
657
694
|
let event = (eventType, tags) => ({
|
|
658
695
|
eventType: eventType,
|
|
@@ -98,7 +98,7 @@ let runOneCompositeEvent: (string, JSON.t) => promise<JSON.t> = %raw(`
|
|
|
98
98
|
}
|
|
99
99
|
`)
|
|
100
100
|
|
|
101
|
-
let
|
|
101
|
+
let buildCompositeCommandEvent = (~command, ~environment, ~resourceName): JSON.t => {
|
|
102
102
|
// `command` is the command type name as a plain string — the shape AppSync's
|
|
103
103
|
// direct-invoke resolver sends and `CommandGenerator.payload.command: string`
|
|
104
104
|
// expects.
|
|
@@ -111,12 +111,15 @@ let buildAddResourceEvent = (~environment, ~resourceName): JSON.t => {
|
|
|
111
111
|
("ip", JSON.Encode.null),
|
|
112
112
|
])
|
|
113
113
|
Dict.fromArray([
|
|
114
|
-
("command",
|
|
114
|
+
("command", command->JSON.Encode.string),
|
|
115
115
|
("arguments", arguments->JSON.Encode.object),
|
|
116
116
|
("meta", meta->JSON.Encode.object),
|
|
117
117
|
])->JSON.Encode.object
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
let buildAddResourceEvent = (~environment, ~resourceName): JSON.t =>
|
|
121
|
+
buildCompositeCommandEvent(~command="AddResource", ~environment, ~resourceName)
|
|
122
|
+
|
|
120
123
|
let buildAppSyncEvent = (widgetId): JSON.t => {
|
|
121
124
|
// CommandGenerator payload: `{command, arguments, meta, identity?}` — the
|
|
122
125
|
// exact shape AppSync's direct-invoke resolver sends (matches the resolver
|
|
@@ -203,4 +206,54 @@ describe("DcbCommandTopicEntryPoint integration", () => {
|
|
|
203
206
|
await H.deleteTable(table)
|
|
204
207
|
},
|
|
205
208
|
)
|
|
209
|
+
|
|
210
|
+
// Composite read-back invariant
|
|
211
|
+
// (docs/plans/done/dcb-composite-query-clause-fence-contention.md).
|
|
212
|
+
//
|
|
213
|
+
// A composite-partition slice must be able to READ BACK its own events. The
|
|
214
|
+
// stored `tag_composite` key is computed from the event's tags; if the
|
|
215
|
+
// framework `originatorSlice` provenance tag (appended by
|
|
216
|
+
// StateChangeSlice_Callback.encodeEvent) leaks into that key, it diverges from
|
|
217
|
+
// the read key (built from the command's entity tags only) and the composite
|
|
218
|
+
// GSI read never matches — so a follow-up command reads empty state.
|
|
219
|
+
//
|
|
220
|
+
// `TouchResource` requires state `Added` (i.e. it must observe the prior
|
|
221
|
+
// `ResourceAdded`) to succeed. With the bug the read misses → state `Absent` →
|
|
222
|
+
// `NotFound` → CommandRejected. With the fix the read hits → `ResourceTouched`
|
|
223
|
+
// is produced → CommandAccepted and a second event persists.
|
|
224
|
+
testAsync(
|
|
225
|
+
"composite read-back: a follow-up command sees the slice's own prior event",
|
|
226
|
+
async () => {
|
|
227
|
+
let table = await H.createDcbTableWithTagKeys(
|
|
228
|
+
"EpCompositeRB_" ++ Date.now()->Float.toString,
|
|
229
|
+
["environment", "resourceName"],
|
|
230
|
+
)
|
|
231
|
+
let env = "prod"
|
|
232
|
+
let res = "res-rb"
|
|
233
|
+
|
|
234
|
+
let addOutcome = await runOneCompositeEvent(
|
|
235
|
+
table.name,
|
|
236
|
+
buildCompositeCommandEvent(~command="AddResource", ~environment=env, ~resourceName=res),
|
|
237
|
+
)
|
|
238
|
+
expect(
|
|
239
|
+
addOutcome->JSON.stringifyAny->Option.getOr("")->String.includes("CommandAccepted"),
|
|
240
|
+
)->toBe(true)
|
|
241
|
+
|
|
242
|
+
let touchOutcome = await runOneCompositeEvent(
|
|
243
|
+
table.name,
|
|
244
|
+
buildCompositeCommandEvent(~command="TouchResource", ~environment=env, ~resourceName=res),
|
|
245
|
+
)
|
|
246
|
+
// The read must have observed the ResourceAdded — otherwise TouchResource
|
|
247
|
+
// rejects NotFound (the originatorSlice-in-tag_composite regression).
|
|
248
|
+
expect(
|
|
249
|
+
touchOutcome->JSON.stringifyAny->Option.getOr("")->String.includes("CommandAccepted"),
|
|
250
|
+
)->toBe(true)
|
|
251
|
+
|
|
252
|
+
// Both events persisted: ResourceAdded + ResourceTouched.
|
|
253
|
+
let events = await H.scanEventTypes(table)
|
|
254
|
+
expect(events->Array.toSorted(String.compare))->toEqual(["ResourceAdded", "ResourceTouched"])
|
|
255
|
+
|
|
256
|
+
await H.deleteTable(table)
|
|
257
|
+
},
|
|
258
|
+
)
|
|
206
259
|
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
4
|
+
import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
4
5
|
import * as DcbIntegrationHarness$ReventlessAws from "./DcbIntegrationHarness.res.mjs";
|
|
5
6
|
import * as DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws from "../../src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs";
|
|
6
7
|
|
|
@@ -70,7 +71,7 @@ let runOneCompositeEvent = (async (tableName, event) => {
|
|
|
70
71
|
return commandOutcomeToJson(outcome);
|
|
71
72
|
});
|
|
72
73
|
|
|
73
|
-
function
|
|
74
|
+
function buildCompositeCommandEvent(command, environment, resourceName) {
|
|
74
75
|
let $$arguments = Object.fromEntries([
|
|
75
76
|
[
|
|
76
77
|
"environment",
|
|
@@ -94,7 +95,7 @@ function buildAddResourceEvent(environment, resourceName) {
|
|
|
94
95
|
return Object.fromEntries([
|
|
95
96
|
[
|
|
96
97
|
"command",
|
|
97
|
-
|
|
98
|
+
command
|
|
98
99
|
],
|
|
99
100
|
[
|
|
100
101
|
"arguments",
|
|
@@ -107,6 +108,10 @@ function buildAddResourceEvent(environment, resourceName) {
|
|
|
107
108
|
]);
|
|
108
109
|
}
|
|
109
110
|
|
|
111
|
+
function buildAddResourceEvent(environment, resourceName) {
|
|
112
|
+
return buildCompositeCommandEvent("AddResource", environment, resourceName);
|
|
113
|
+
}
|
|
114
|
+
|
|
110
115
|
function buildAppSyncEvent(widgetId) {
|
|
111
116
|
let command = Object.fromEntries([[
|
|
112
117
|
"AddWidget",
|
|
@@ -162,7 +167,7 @@ globalThis.describe("DcbCommandTopicEntryPoint integration", () => {
|
|
|
162
167
|
"res-d",
|
|
163
168
|
"res-e"
|
|
164
169
|
];
|
|
165
|
-
let outcomes = await Promise.all(resourceNames.map(resourceName => runOneCompositeEvent(table.name,
|
|
170
|
+
let outcomes = await Promise.all(resourceNames.map(resourceName => runOneCompositeEvent(table.name, buildCompositeCommandEvent("AddResource", "prod", resourceName))));
|
|
166
171
|
outcomes.forEach(outcomeJson => {
|
|
167
172
|
let s = Stdlib_Option.getOr(JSON.stringify(outcomeJson), "<unserializable>");
|
|
168
173
|
globalThis.expect(s.includes("CommandAccepted")).toBe(true);
|
|
@@ -175,6 +180,24 @@ globalThis.describe("DcbCommandTopicEntryPoint integration", () => {
|
|
|
175
180
|
});
|
|
176
181
|
return await DcbIntegrationHarness$ReventlessAws.deleteTable(table);
|
|
177
182
|
});
|
|
183
|
+
globalThis.test("composite read-back: a follow-up command sees the slice's own prior event", async () => {
|
|
184
|
+
let table = await DcbIntegrationHarness$ReventlessAws.createDcbTableWithTagKeys("EpCompositeRB_" + Date.now().toString(), [
|
|
185
|
+
"environment",
|
|
186
|
+
"resourceName"
|
|
187
|
+
]);
|
|
188
|
+
let env = "prod";
|
|
189
|
+
let res = "res-rb";
|
|
190
|
+
let addOutcome = await runOneCompositeEvent(table.name, buildCompositeCommandEvent("AddResource", env, res));
|
|
191
|
+
globalThis.expect(Stdlib_Option.getOr(JSON.stringify(addOutcome), "").includes("CommandAccepted")).toBe(true);
|
|
192
|
+
let touchOutcome = await runOneCompositeEvent(table.name, buildCompositeCommandEvent("TouchResource", env, res));
|
|
193
|
+
globalThis.expect(Stdlib_Option.getOr(JSON.stringify(touchOutcome), "").includes("CommandAccepted")).toBe(true);
|
|
194
|
+
let events = await DcbIntegrationHarness$ReventlessAws.scanEventTypes(table);
|
|
195
|
+
globalThis.expect(events.toSorted(Primitive_string.compare)).toEqual([
|
|
196
|
+
"ResourceAdded",
|
|
197
|
+
"ResourceTouched"
|
|
198
|
+
]);
|
|
199
|
+
return await DcbIntegrationHarness$ReventlessAws.deleteTable(table);
|
|
200
|
+
});
|
|
178
201
|
});
|
|
179
202
|
|
|
180
203
|
let H;
|
|
@@ -183,6 +206,7 @@ export {
|
|
|
183
206
|
H,
|
|
184
207
|
runOneAppSyncEvent,
|
|
185
208
|
runOneCompositeEvent,
|
|
209
|
+
buildCompositeCommandEvent,
|
|
186
210
|
buildAddResourceEvent,
|
|
187
211
|
buildAppSyncEvent,
|
|
188
212
|
}
|
|
@@ -121,6 +121,21 @@ let scanFenceIds = async (table: Util_DynamoDb_Runtime.resolvedTable): array<str
|
|
|
121
121
|
)
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
// Returns the `event` attribute of every stored event row (fence sentinels carry
|
|
125
|
+
// no `event` attribute and are skipped). Lets a test assert which events actually
|
|
126
|
+
// persisted — the signal that a composite slice could read back its own state.
|
|
127
|
+
let scanEventTypes = async (table: Util_DynamoDb_Runtime.resolvedTable): array<string> => {
|
|
128
|
+
let items = await Util_DynamoDb_Runtime.scanStream({tableName: table.name})
|
|
129
|
+
->Stream.runCollect
|
|
130
|
+
->Effect.runPromise
|
|
131
|
+
items->Array.filterMap(item =>
|
|
132
|
+
item
|
|
133
|
+
->JSON.Decode.object
|
|
134
|
+
->Option.flatMap(obj => obj->Dict.get("event"))
|
|
135
|
+
->Option.flatMap(JSON.Decode.string)
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
124
139
|
let deleteTable = async (table: Util_DynamoDb_Runtime.resolvedTable) => {
|
|
125
140
|
let input = Dict.fromArray([("TableName", s(table.name))])->JSON.Encode.object
|
|
126
141
|
let _ = await send(deleteTableCommand(input))
|
|
@@ -168,6 +168,13 @@ async function scanFenceIds(table) {
|
|
|
168
168
|
}));
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
async function scanEventTypes(table) {
|
|
172
|
+
let items = await Effect.runPromise(Stream.runCollect(Util_DynamoDb_Runtime$ReventlessAws.scanStream({
|
|
173
|
+
TableName: table.name
|
|
174
|
+
})));
|
|
175
|
+
return Stdlib_Array.filterMap(items, item => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(item), obj => obj["event"]), Stdlib_JSON.Decode.string));
|
|
176
|
+
}
|
|
177
|
+
|
|
171
178
|
async function deleteTable(table) {
|
|
172
179
|
let input = Object.fromEntries([[
|
|
173
180
|
"TableName",
|
|
@@ -213,6 +220,7 @@ export {
|
|
|
213
220
|
createDcbTable,
|
|
214
221
|
createDcbTableWithTagKeys,
|
|
215
222
|
scanFenceIds,
|
|
223
|
+
scanEventTypes,
|
|
216
224
|
deleteTable,
|
|
217
225
|
counter,
|
|
218
226
|
freshTable,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Composite-partition slice fixture for `DcbCommandTopicEntryPoint_IntegrationTest`.
|
|
2
2
|
//
|
|
3
3
|
// Reproduces the deploy-sync workload shape from
|
|
4
|
-
// docs/plans/dcb-composite-fence-residual-burst-contention.md: a
|
|
4
|
+
// docs/plans/done/dcb-composite-fence-residual-burst-contention.md: a
|
|
5
5
|
// `@compositePartitionTag` over a low-cardinality prefix (`environment`) plus a
|
|
6
6
|
// high-cardinality tail (`resourceName`). Distinct resources sharing the same
|
|
7
7
|
// `environment` must NOT contend — but only if the entry point threads
|
|
@@ -9,27 +9,49 @@
|
|
|
9
9
|
// single-composite-fence collapse. Without that thread the slice writes one
|
|
10
10
|
// fence per member and the shared `environment` member goes hot.
|
|
11
11
|
//
|
|
12
|
+
// It ALSO guards the composite read-back invariant from
|
|
13
|
+
// docs/plans/done/dcb-composite-query-clause-fence-contention.md: `TouchResource`
|
|
14
|
+
// requires the slice to READ its own prior `ResourceAdded` (state must be
|
|
15
|
+
// `Added`), which only works if the stored `tag_composite` key matches the read
|
|
16
|
+
// key — i.e. the framework `originatorSlice` provenance tag is excluded from the
|
|
17
|
+
// composite key. With that bug present the read misses, state stays `Absent`, and
|
|
18
|
+
// `TouchResource` is rejected `NotFound`.
|
|
19
|
+
//
|
|
12
20
|
// Explicit `@s.matches(Reventless.DcbTag.compositePartitionMember(...))` form
|
|
13
21
|
// (not the `@compositePartitionTag` PPX shorthand) because reventless-ppx is not
|
|
14
22
|
// wired into reventless-aws's rescript.json — same reason as EpTestSlice.
|
|
15
23
|
|
|
16
24
|
@schema
|
|
17
|
-
type consumedEvent =
|
|
25
|
+
type consumedEvent =
|
|
26
|
+
| ResourceAdded({environment: string, resourceName: string})
|
|
27
|
+
| ResourceTouched({environment: string, resourceName: string})
|
|
18
28
|
|
|
19
29
|
@schema
|
|
20
|
-
type command =
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
30
|
+
type command =
|
|
31
|
+
| AddResource({
|
|
32
|
+
environment: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=0, ~sep="/")) string,
|
|
33
|
+
resourceName: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=1, ~sep="/")) string,
|
|
34
|
+
})
|
|
35
|
+
| TouchResource({
|
|
36
|
+
environment: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=0, ~sep="/")) string,
|
|
37
|
+
resourceName: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=1, ~sep="/")) string,
|
|
38
|
+
})
|
|
24
39
|
|
|
25
40
|
@schema
|
|
26
|
-
type error =
|
|
41
|
+
type error =
|
|
42
|
+
| AlreadyAdded
|
|
43
|
+
| NotFound
|
|
27
44
|
|
|
28
45
|
@schema
|
|
29
|
-
type event =
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
46
|
+
type event =
|
|
47
|
+
| ResourceAdded({
|
|
48
|
+
environment: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=0, ~sep="/")) string,
|
|
49
|
+
resourceName: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=1, ~sep="/")) string,
|
|
50
|
+
})
|
|
51
|
+
| ResourceTouched({
|
|
52
|
+
environment: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=0, ~sep="/")) string,
|
|
53
|
+
resourceName: @s.matches(Reventless.DcbTag.compositePartitionMember(~position=1, ~sep="/")) string,
|
|
54
|
+
})
|
|
33
55
|
|
|
34
56
|
let name = "EpCompositeSlice"
|
|
35
57
|
let moduleUrl = "ep-test://EpCompositeSlice"
|
|
@@ -3,25 +3,49 @@
|
|
|
3
3
|
import * as S from "sury/src/S.res.mjs";
|
|
4
4
|
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
5
5
|
|
|
6
|
-
let consumedEventSchema = S.
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}))
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}))
|
|
6
|
+
let consumedEventSchema = S.union([
|
|
7
|
+
S.schema(s => ({
|
|
8
|
+
TAG: "ResourceAdded",
|
|
9
|
+
environment: s.m(S.string),
|
|
10
|
+
resourceName: s.m(S.string)
|
|
11
|
+
})),
|
|
12
|
+
S.schema(s => ({
|
|
13
|
+
TAG: "ResourceTouched",
|
|
14
|
+
environment: s.m(S.string),
|
|
15
|
+
resourceName: s.m(S.string)
|
|
16
|
+
}))
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
let commandSchema = S.union([
|
|
20
|
+
S.schema(s => ({
|
|
21
|
+
TAG: "AddResource",
|
|
22
|
+
environment: s.m(DcbTag$Reventless.compositePartitionMember(0, "/")),
|
|
23
|
+
resourceName: s.m(DcbTag$Reventless.compositePartitionMember(1, "/"))
|
|
24
|
+
})),
|
|
25
|
+
S.schema(s => ({
|
|
26
|
+
TAG: "TouchResource",
|
|
27
|
+
environment: s.m(DcbTag$Reventless.compositePartitionMember(0, "/")),
|
|
28
|
+
resourceName: s.m(DcbTag$Reventless.compositePartitionMember(1, "/"))
|
|
29
|
+
}))
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
let errorSchema = S.union([
|
|
33
|
+
S.literal("AlreadyAdded"),
|
|
34
|
+
S.literal("NotFound")
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
let eventSchema = S.union([
|
|
38
|
+
S.schema(s => ({
|
|
39
|
+
TAG: "ResourceAdded",
|
|
40
|
+
environment: s.m(DcbTag$Reventless.compositePartitionMember(0, "/")),
|
|
41
|
+
resourceName: s.m(DcbTag$Reventless.compositePartitionMember(1, "/"))
|
|
42
|
+
})),
|
|
43
|
+
S.schema(s => ({
|
|
44
|
+
TAG: "ResourceTouched",
|
|
45
|
+
environment: s.m(DcbTag$Reventless.compositePartitionMember(0, "/")),
|
|
46
|
+
resourceName: s.m(DcbTag$Reventless.compositePartitionMember(1, "/"))
|
|
47
|
+
}))
|
|
48
|
+
]);
|
|
25
49
|
|
|
26
50
|
function commandAuthorization(param) {
|
|
27
51
|
return "AllowAnonymous";
|
|
@@ -1,22 +1,35 @@
|
|
|
1
1
|
// Behavior pair for `EpCompositeSlice`. Append-on-empty / reject-on-seen, per
|
|
2
2
|
// composite entity: the decision read is scoped to one `{environment,
|
|
3
|
-
// resourceName}` composite, so a fresh resource sees
|
|
4
|
-
//
|
|
3
|
+
// resourceName}` composite, so a fresh resource sees `Absent` and appends, while
|
|
4
|
+
// a duplicate of the same composite key sees `Added` and is rejected.
|
|
5
|
+
//
|
|
6
|
+
// `TouchResource` is the read-back probe: it requires the slice to have already
|
|
7
|
+
// observed its own `ResourceAdded` (state `Added`) to succeed, so it exercises
|
|
8
|
+
// the composite read-matching-its-own-write invariant.
|
|
5
9
|
|
|
6
|
-
type state =
|
|
10
|
+
type state =
|
|
11
|
+
| Absent
|
|
12
|
+
| Added
|
|
7
13
|
|
|
8
|
-
let initialState =
|
|
14
|
+
let initialState = Absent
|
|
9
15
|
|
|
10
|
-
let evolve = (_state,
|
|
16
|
+
let evolve = (_state, event: EpCompositeSlice.consumedEvent) =>
|
|
17
|
+
switch event {
|
|
18
|
+
| ResourceAdded(_) => Added
|
|
19
|
+
| ResourceTouched(_) => Added
|
|
20
|
+
}
|
|
11
21
|
|
|
12
22
|
let decide = (state, command: EpCompositeSlice.command): result<
|
|
13
23
|
array<EpCompositeSlice.event>,
|
|
14
24
|
EpCompositeSlice.error,
|
|
15
25
|
> =>
|
|
16
26
|
switch (state, command) {
|
|
17
|
-
| (
|
|
18
|
-
| (
|
|
27
|
+
| (Added, AddResource(_)) => Error(AlreadyAdded)
|
|
28
|
+
| (Absent, AddResource({environment, resourceName})) =>
|
|
19
29
|
Ok([ResourceAdded({environment, resourceName})])
|
|
30
|
+
| (Added, TouchResource({environment, resourceName})) =>
|
|
31
|
+
Ok([ResourceTouched({environment, resourceName})])
|
|
32
|
+
| (Absent, TouchResource(_)) => Error(NotFound)
|
|
20
33
|
}
|
|
21
34
|
|
|
22
35
|
let moduleUrl = "ep-test://EpCompositeSliceBehavior"
|
|
@@ -1,12 +1,28 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
|
|
4
|
-
function evolve(_state,
|
|
5
|
-
return
|
|
4
|
+
function evolve(_state, event) {
|
|
5
|
+
return "Added";
|
|
6
6
|
}
|
|
7
7
|
|
|
8
8
|
function decide(state, command) {
|
|
9
|
-
if (state) {
|
|
9
|
+
if (state === "Absent") {
|
|
10
|
+
if (command.TAG === "AddResource") {
|
|
11
|
+
return {
|
|
12
|
+
TAG: "Ok",
|
|
13
|
+
_0: [{
|
|
14
|
+
TAG: "ResourceAdded",
|
|
15
|
+
environment: command.environment,
|
|
16
|
+
resourceName: command.resourceName
|
|
17
|
+
}]
|
|
18
|
+
};
|
|
19
|
+
} else {
|
|
20
|
+
return {
|
|
21
|
+
TAG: "Error",
|
|
22
|
+
_0: "NotFound"
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
} else if (command.TAG === "AddResource") {
|
|
10
26
|
return {
|
|
11
27
|
TAG: "Error",
|
|
12
28
|
_0: "AlreadyAdded"
|
|
@@ -15,7 +31,7 @@ function decide(state, command) {
|
|
|
15
31
|
return {
|
|
16
32
|
TAG: "Ok",
|
|
17
33
|
_0: [{
|
|
18
|
-
TAG: "
|
|
34
|
+
TAG: "ResourceTouched",
|
|
19
35
|
environment: command.environment,
|
|
20
36
|
resourceName: command.resourceName
|
|
21
37
|
}]
|
|
@@ -23,7 +39,7 @@ function decide(state, command) {
|
|
|
23
39
|
}
|
|
24
40
|
}
|
|
25
41
|
|
|
26
|
-
let initialState =
|
|
42
|
+
let initialState = "Absent";
|
|
27
43
|
|
|
28
44
|
let moduleUrl = "ep-test://EpCompositeSliceBehavior";
|
|
29
45
|
|