@reventlessdev/reventless-aws 3.0.0-alpha.185 → 3.0.0-alpha.187
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 +4 -4
- 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/src/adapter/Runtime/StateViewSliceEntryPoint.mjs +7 -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/tests/integration/StateViewSliceEntryPoint_IntegrationTest.res +123 -0
- package/tests/integration/StateViewSliceEntryPoint_IntegrationTest.res.mjs +131 -0
- package/tests/integration/SvsTestSlice.res +32 -0
- package/tests/integration/SvsTestSlice.res.mjs +28 -0
- package/tests/integration/SvsTestSlice_Projection.res +24 -0
- package/tests/integration/SvsTestSlice_Projection.res.mjs +25 -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.187 (2026-07-09)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **reventless-aws:** thread subIdConfig into deployed StateViewSlice projection runtime ([78ebf80](https://github.com/ReventlessDev/reventless-core/commit/78ebf8086406327a6d665d5dcf034630650c6803))
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
# 3.0.0-alpha.186 (2026-07-08)
|
|
14
|
+
|
|
15
|
+
### Bug Fixes
|
|
16
|
+
|
|
17
|
+
* **reventless-core:** composite DCB slices can read back their own events ([4604a91](https://github.com/ReventlessDev/reventless-core/commit/4604a9159fb8bf59b2191ad69fee6613c7f75cd9))
|
|
18
|
+
|
|
19
|
+
|
|
6
20
|
# 3.0.0-alpha.185 (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.187",
|
|
4
4
|
"description": "AWS adapters for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"dependencies": {
|
|
@@ -11,13 +11,13 @@
|
|
|
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.47",
|
|
14
15
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
|
|
16
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.147",
|
|
15
17
|
"@reventlessdev/rescript-uuid": "1.1.0-alpha.14",
|
|
16
|
-
"@reventlessdev/reventless-core": "3.0.0-alpha.146",
|
|
17
|
-
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.47",
|
|
18
18
|
"@reventlessdev/reventless-infra": "3.0.0-alpha.91",
|
|
19
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.11",
|
|
19
20
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.24",
|
|
20
|
-
"@reventlessdev/reventless-postgres": "3.0.0-alpha.10",
|
|
21
21
|
"@reventlessdev/reventless-spec": "3.0.0-alpha.69"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
@@ -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", {
|
|
@@ -45,7 +45,7 @@ function groupBySource(records) {
|
|
|
45
45
|
// `pgConnection`, when present, selects the Postgres QueryDb runtime for this
|
|
46
46
|
// slice's view table (`queryDbTableName` is then the slice spec name, the shared
|
|
47
47
|
// `qdb_<name>` discriminator). Absent → the DynamoDB path is byte-identical.
|
|
48
|
-
function buildJsonEventsHandler(specModule, projectionModule, queryDbTableName, pgConnection, stateTopicName) {
|
|
48
|
+
export function buildJsonEventsHandler(specModule, projectionModule, queryDbTableName, pgConnection, stateTopicName) {
|
|
49
49
|
let queryDbOps;
|
|
50
50
|
if (pgConnection) {
|
|
51
51
|
const indexes = (specModule.config && specModule.config.indexes) || [];
|
|
@@ -92,7 +92,12 @@ function buildJsonEventsHandler(specModule, projectionModule, queryDbTableName,
|
|
|
92
92
|
actions => Stream.fromIterable(actions)
|
|
93
93
|
),
|
|
94
94
|
action => Effect.map(
|
|
95
|
-
|
|
95
|
+
// Thread the slice's `subIdConfig` (generated from `@subId`) so sub-id-dependent
|
|
96
|
+
// actions like `UpdateMultiState` can resolve the sort key. Dropping it here made
|
|
97
|
+
// every such action hit the `MissingSubIdConfig` guard and silently write nothing,
|
|
98
|
+
// while the test-harness callback path (which threads it) stayed green. `undefined`
|
|
99
|
+
// for slices without an `@subId` is correct — those never emit sub-id actions.
|
|
100
|
+
Effect.promise(() => handleAction(action, queryDbOps, specModule.subIdConfig)),
|
|
96
101
|
_ => {}
|
|
97
102
|
)
|
|
98
103
|
);
|
|
@@ -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
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Integration regression guard for the StateViewSlice projection Lambda entry
|
|
2
|
+
// point (`StateViewSliceEntryPoint.mjs`), run against DynamoDB Local.
|
|
3
|
+
//
|
|
4
|
+
// Covers the 2026-07-09 incident: the deployed runtime built its projection
|
|
5
|
+
// loop with `handleAction(action, queryDbOps, undefined)` — hardcoding
|
|
6
|
+
// `subIdConfig` to `undefined` instead of threading the slice's
|
|
7
|
+
// `specModule.subIdConfig`. Every sub-id-dependent action (`UpdateMultiState`)
|
|
8
|
+
// then hit the `MissingSubIdConfig` guard and silently wrote nothing, while the
|
|
9
|
+
// in-memory GWT/callback path (which threads `subIdConfig`) stayed green. This
|
|
10
|
+
// is a deployed-runtime vs test-harness parity gap: a unit test on the callback
|
|
11
|
+
// path cannot catch it, so this test drives the *real* `.mjs` entry point that
|
|
12
|
+
// production uses.
|
|
13
|
+
//
|
|
14
|
+
// The fixture `SvsTestSlice` is an `@subId productId` view slice whose only
|
|
15
|
+
// projection path is `UpdateMultiState`. Before the fix both appends no-op and
|
|
16
|
+
// the table stays empty; after the fix each product is a distinct row.
|
|
17
|
+
//
|
|
18
|
+
// Boots via the same `pnpm run test:integration` Docker-gated suite as the DCB
|
|
19
|
+
// entry-point test; reuses `DcbIntegrationHarness` for table lifecycle commands.
|
|
20
|
+
|
|
21
|
+
open JestGlobals
|
|
22
|
+
|
|
23
|
+
module H = DcbIntegrationHarness
|
|
24
|
+
|
|
25
|
+
let s = JSON.Encode.string
|
|
26
|
+
|
|
27
|
+
// A view table for a `@subId` slice is a composite-key table: HASH `id`
|
|
28
|
+
// (primary id attribute injected by the QueryDb runtime) + RANGE `productId`
|
|
29
|
+
// (the slice's `subIdField`, carried as a plain attribute of each state row).
|
|
30
|
+
let createViewTable = async (tableName): Util_DynamoDb_Runtime.resolvedTable => {
|
|
31
|
+
let attrDef = name =>
|
|
32
|
+
Dict.fromArray([("AttributeName", s(name)), ("AttributeType", s("S"))])->JSON.Encode.object
|
|
33
|
+
let keyEl = (name, keyType) =>
|
|
34
|
+
Dict.fromArray([("AttributeName", s(name)), ("KeyType", s(keyType))])->JSON.Encode.object
|
|
35
|
+
let input =
|
|
36
|
+
Dict.fromArray([
|
|
37
|
+
("TableName", s(tableName)),
|
|
38
|
+
("AttributeDefinitions", [attrDef("id"), attrDef("productId")]->JSON.Encode.array),
|
|
39
|
+
("KeySchema", [keyEl("id", "HASH"), keyEl("productId", "RANGE")]->JSON.Encode.array),
|
|
40
|
+
("BillingMode", s("PAY_PER_REQUEST")),
|
|
41
|
+
])->JSON.Encode.object
|
|
42
|
+
let _ = await H.send(H.createTableCommand(input))
|
|
43
|
+
{
|
|
44
|
+
Util_DynamoDb_Runtime.id: tableName,
|
|
45
|
+
name: tableName,
|
|
46
|
+
arn: `arn:aws:dynamodb:local:000000000000:table/${tableName}`,
|
|
47
|
+
hashKey: "id",
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Drives the real entry point: builds the handler via its exported
|
|
52
|
+
// `buildJsonEventsHandler` factory (DynamoDB path — `pgConnection`/`stateTopicName`
|
|
53
|
+
// undefined), feeds one encoded event through the effect Stream, and runs the
|
|
54
|
+
// resulting Effect through the request context the production handler supplies.
|
|
55
|
+
let runOneEvent: (string, JSON.t) => promise<unit> = %raw(`
|
|
56
|
+
async (tableName, eventJson) => {
|
|
57
|
+
const { buildJsonEventsHandler } = await import(
|
|
58
|
+
"@reventlessdev/reventless-aws/src/adapter/Runtime/StateViewSliceEntryPoint.mjs"
|
|
59
|
+
);
|
|
60
|
+
const specModule = await import("./SvsTestSlice.res.mjs");
|
|
61
|
+
const projectionModule = await import("./SvsTestSlice_Projection.res.mjs");
|
|
62
|
+
const Effect = await import("effect/Effect");
|
|
63
|
+
const Stream = await import("effect/Stream");
|
|
64
|
+
const { tag: requestContextTag } = await import(
|
|
65
|
+
"@reventlessdev/reventless-core/src/RequestContext.res.mjs"
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const handler = buildJsonEventsHandler(specModule, projectionModule, tableName, undefined, undefined);
|
|
69
|
+
const stream = Stream.fromIterable([eventJson]);
|
|
70
|
+
const effect = handler(stream)
|
|
71
|
+
.pipe(Effect.provideService(requestContextTag, { correlationId: "svs-test" }));
|
|
72
|
+
await Effect.runPromise(effect);
|
|
73
|
+
}
|
|
74
|
+
`)
|
|
75
|
+
|
|
76
|
+
// Encode the consumed event exactly as the runtime decodes it: the entry point
|
|
77
|
+
// calls `parseJsonOrThrow(eventJson, consumedEventSchema)`, so we produce the
|
|
78
|
+
// JSON via the same schema to guarantee a faithful round-trip.
|
|
79
|
+
let encodeEvent = (event): JSON.t =>
|
|
80
|
+
event->S.reverseConvertToJsonOrThrow(SvsTestSlice.consumedEventSchema)
|
|
81
|
+
|
|
82
|
+
describe("StateViewSliceEntryPoint integration", () => {
|
|
83
|
+
testAsync(
|
|
84
|
+
"UpdateMultiState on an @subId view slice persists a row per product (was silent no-op)",
|
|
85
|
+
async () => {
|
|
86
|
+
let table = await createViewTable("SvsIt_" ++ Date.now()->Float.toString)
|
|
87
|
+
|
|
88
|
+
// Two products added to the same cart → two distinct sub-id rows under the
|
|
89
|
+
// same primary id. The second append also reads back the first row, so a
|
|
90
|
+
// dropped `subIdConfig` (the bug) leaves the table empty at both steps.
|
|
91
|
+
await runOneEvent(
|
|
92
|
+
table.name,
|
|
93
|
+
encodeEvent(ItemAddedToCart({cartId: "cart-1", productId: "prod-a", qty: 2})),
|
|
94
|
+
)
|
|
95
|
+
await runOneEvent(
|
|
96
|
+
table.name,
|
|
97
|
+
encodeEvent(ItemAddedToCart({cartId: "cart-1", productId: "prod-b", qty: 5})),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
let rows = switch await QueryDbStorage_DynamoDb_Runtime.load(table)("cart-1") {
|
|
101
|
+
| Ok(rows) => rows
|
|
102
|
+
| Error(_) => []
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Before the fix: 0 rows (both UpdateMultiState actions no-op with
|
|
106
|
+
// `MissingSubIdConfig`). After the fix: one row per product.
|
|
107
|
+
expect(rows->Array.length)->toBe(2)
|
|
108
|
+
|
|
109
|
+
let productIds =
|
|
110
|
+
rows
|
|
111
|
+
->Array.filterMap(row =>
|
|
112
|
+
row
|
|
113
|
+
->JSON.Decode.object
|
|
114
|
+
->Option.flatMap(o => o->Dict.get("productId"))
|
|
115
|
+
->Option.flatMap(JSON.Decode.string)
|
|
116
|
+
)
|
|
117
|
+
->Array.toSorted(String.compare)
|
|
118
|
+
expect(productIds)->toEqual(["prod-a", "prod-b"])
|
|
119
|
+
|
|
120
|
+
await H.deleteTable(table)
|
|
121
|
+
},
|
|
122
|
+
)
|
|
123
|
+
})
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.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 Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
|
|
8
|
+
import * as ClientDynamodb from "@aws-sdk/client-dynamodb";
|
|
9
|
+
import * as SvsTestSlice$ReventlessAws from "./SvsTestSlice.res.mjs";
|
|
10
|
+
import * as DcbIntegrationHarness$ReventlessAws from "./DcbIntegrationHarness.res.mjs";
|
|
11
|
+
import * as QueryDbStorage_DynamoDb_Runtime$ReventlessAws from "../../src/adapter/QueryDb/QueryDbStorage_DynamoDb_Runtime.res.mjs";
|
|
12
|
+
|
|
13
|
+
function s(prim) {
|
|
14
|
+
return prim;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function createViewTable(tableName) {
|
|
18
|
+
let attrDef = name => Object.fromEntries([
|
|
19
|
+
[
|
|
20
|
+
"AttributeName",
|
|
21
|
+
name
|
|
22
|
+
],
|
|
23
|
+
[
|
|
24
|
+
"AttributeType",
|
|
25
|
+
"S"
|
|
26
|
+
]
|
|
27
|
+
]);
|
|
28
|
+
let keyEl = (name, keyType) => Object.fromEntries([
|
|
29
|
+
[
|
|
30
|
+
"AttributeName",
|
|
31
|
+
name
|
|
32
|
+
],
|
|
33
|
+
[
|
|
34
|
+
"KeyType",
|
|
35
|
+
keyType
|
|
36
|
+
]
|
|
37
|
+
]);
|
|
38
|
+
let input = Object.fromEntries([
|
|
39
|
+
[
|
|
40
|
+
"TableName",
|
|
41
|
+
tableName
|
|
42
|
+
],
|
|
43
|
+
[
|
|
44
|
+
"AttributeDefinitions",
|
|
45
|
+
[
|
|
46
|
+
attrDef("id"),
|
|
47
|
+
attrDef("productId")
|
|
48
|
+
]
|
|
49
|
+
],
|
|
50
|
+
[
|
|
51
|
+
"KeySchema",
|
|
52
|
+
[
|
|
53
|
+
keyEl("id", "HASH"),
|
|
54
|
+
keyEl("productId", "RANGE")
|
|
55
|
+
]
|
|
56
|
+
],
|
|
57
|
+
[
|
|
58
|
+
"BillingMode",
|
|
59
|
+
"PAY_PER_REQUEST"
|
|
60
|
+
]
|
|
61
|
+
]);
|
|
62
|
+
await DcbIntegrationHarness$ReventlessAws.send(new ClientDynamodb.CreateTableCommand(input));
|
|
63
|
+
return {
|
|
64
|
+
id: tableName,
|
|
65
|
+
name: tableName,
|
|
66
|
+
arn: `arn:aws:dynamodb:local:000000000000:table/` + tableName,
|
|
67
|
+
hashKey: "id"
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let runOneEvent = (async (tableName, eventJson) => {
|
|
72
|
+
const { buildJsonEventsHandler } = await import(
|
|
73
|
+
"@reventlessdev/reventless-aws/src/adapter/Runtime/StateViewSliceEntryPoint.mjs"
|
|
74
|
+
);
|
|
75
|
+
const specModule = await import("./SvsTestSlice.res.mjs");
|
|
76
|
+
const projectionModule = await import("./SvsTestSlice_Projection.res.mjs");
|
|
77
|
+
const Effect = await import("effect/Effect");
|
|
78
|
+
const Stream = await import("effect/Stream");
|
|
79
|
+
const { tag: requestContextTag } = await import(
|
|
80
|
+
"@reventlessdev/reventless-core/src/RequestContext.res.mjs"
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
const handler = buildJsonEventsHandler(specModule, projectionModule, tableName, undefined, undefined);
|
|
84
|
+
const stream = Stream.fromIterable([eventJson]);
|
|
85
|
+
const effect = handler(stream)
|
|
86
|
+
.pipe(Effect.provideService(requestContextTag, { correlationId: "svs-test" }));
|
|
87
|
+
await Effect.runPromise(effect);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
function encodeEvent(event) {
|
|
91
|
+
return S.reverseConvertToJsonOrThrow(event, SvsTestSlice$ReventlessAws.consumedEventSchema);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
globalThis.describe("StateViewSliceEntryPoint integration", () => {
|
|
95
|
+
globalThis.test("UpdateMultiState on an @subId view slice persists a row per product (was silent no-op)", async () => {
|
|
96
|
+
let table = await createViewTable("SvsIt_" + Date.now().toString());
|
|
97
|
+
await runOneEvent(table.name, S.reverseConvertToJsonOrThrow({
|
|
98
|
+
TAG: "ItemAddedToCart",
|
|
99
|
+
cartId: "cart-1",
|
|
100
|
+
productId: "prod-a",
|
|
101
|
+
qty: 2
|
|
102
|
+
}, SvsTestSlice$ReventlessAws.consumedEventSchema));
|
|
103
|
+
await runOneEvent(table.name, S.reverseConvertToJsonOrThrow({
|
|
104
|
+
TAG: "ItemAddedToCart",
|
|
105
|
+
cartId: "cart-1",
|
|
106
|
+
productId: "prod-b",
|
|
107
|
+
qty: 5
|
|
108
|
+
}, SvsTestSlice$ReventlessAws.consumedEventSchema));
|
|
109
|
+
let rows = await QueryDbStorage_DynamoDb_Runtime$ReventlessAws.load(table)("cart-1");
|
|
110
|
+
let rows$1;
|
|
111
|
+
rows$1 = rows.TAG === "Ok" ? rows._0 : [];
|
|
112
|
+
globalThis.expect(rows$1.length).toBe(2);
|
|
113
|
+
let productIds = Stdlib_Array.filterMap(rows$1, row => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(row), o => o["productId"]), Stdlib_JSON.Decode.string)).toSorted(Primitive_string.compare);
|
|
114
|
+
globalThis.expect(productIds).toEqual([
|
|
115
|
+
"prod-a",
|
|
116
|
+
"prod-b"
|
|
117
|
+
]);
|
|
118
|
+
return await DcbIntegrationHarness$ReventlessAws.deleteTable(table);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
let H;
|
|
123
|
+
|
|
124
|
+
export {
|
|
125
|
+
H,
|
|
126
|
+
s,
|
|
127
|
+
createViewTable,
|
|
128
|
+
runOneEvent,
|
|
129
|
+
encodeEvent,
|
|
130
|
+
}
|
|
131
|
+
/* runOneEvent Not a pure module */
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Tiny StateViewSlice spec exercised by
|
|
2
|
+
// `StateViewSliceEntryPoint_IntegrationTest`. Standalone schema — does not
|
|
3
|
+
// depend on the example apps — so the fixture's shape can drift independently
|
|
4
|
+
// of any shipped slice.
|
|
5
|
+
//
|
|
6
|
+
// Hand-written explicit form rather than the `@@reventless.spec` PPX shorthand:
|
|
7
|
+
// the reventless-ppx is only wired into reventless-core's rescript.json, not
|
|
8
|
+
// reventless-aws's. So `consumedEventSchema`, `subIdConfig` etc. are declared
|
|
9
|
+
// directly.
|
|
10
|
+
//
|
|
11
|
+
// The slice models a per-cart, per-product line: primary id = `cartId`,
|
|
12
|
+
// sub-id = `productId`. Its projection uses `UpdateMultiState`, the action that
|
|
13
|
+
// hits the runtime's `subIdConfig` guard — the exact path the deployed entry
|
|
14
|
+
// point used to no-op by hardcoding `subIdConfig = undefined`.
|
|
15
|
+
|
|
16
|
+
@schema
|
|
17
|
+
type consumedEvent = ItemAddedToCart({cartId: string, productId: string, qty: int})
|
|
18
|
+
|
|
19
|
+
@schema
|
|
20
|
+
type state = {
|
|
21
|
+
cartId: string,
|
|
22
|
+
productId: string,
|
|
23
|
+
qty: int,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// `Some({subIdField, getSubId})` — the compiled per-slice spec that a real
|
|
27
|
+
// `@subId productId` view slice carries. `subIdField` is the DynamoDB range-key
|
|
28
|
+
// attribute; `getSubId` extracts it from a projected state row.
|
|
29
|
+
let subIdConfig: option<Reventless.ReadModel.subIdConfig<state>> = Some({
|
|
30
|
+
subIdField: "productId",
|
|
31
|
+
getSubId: state => state.productId,
|
|
32
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
|
|
5
|
+
let consumedEventSchema = S.schema(s => ({
|
|
6
|
+
TAG: "ItemAddedToCart",
|
|
7
|
+
cartId: s.m(S.string),
|
|
8
|
+
productId: s.m(S.string),
|
|
9
|
+
qty: s.m(S.int)
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
let stateSchema = S.schema(s => ({
|
|
13
|
+
cartId: s.m(S.string),
|
|
14
|
+
productId: s.m(S.string),
|
|
15
|
+
qty: s.m(S.int)
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
let subIdConfig = {
|
|
19
|
+
subIdField: "productId",
|
|
20
|
+
getSubId: state => state.productId
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export {
|
|
24
|
+
consumedEventSchema,
|
|
25
|
+
stateSchema,
|
|
26
|
+
subIdConfig,
|
|
27
|
+
}
|
|
28
|
+
/* consumedEventSchema Not a pure module */
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Projection for `SvsTestSlice`. Hand-written `open` (no `@@reventless.projection`
|
|
2
|
+
// PPX in reventless-aws): `open Reventless.Projection` brings the action
|
|
3
|
+
// constructors (`UpdateMultiState`, …) into scope; `open SvsTestSlice` brings the
|
|
4
|
+
// consumed-event/state types.
|
|
5
|
+
//
|
|
6
|
+
// Every event upserts the cart's line for one product via `UpdateMultiState`,
|
|
7
|
+
// keyed by `productId` (the sub-id). This is the multi-state path that silently
|
|
8
|
+
// no-oped in the deployed runtime before `subIdConfig` was threaded.
|
|
9
|
+
|
|
10
|
+
open Reventless.Projection
|
|
11
|
+
open SvsTestSlice
|
|
12
|
+
|
|
13
|
+
let project = event =>
|
|
14
|
+
switch event {
|
|
15
|
+
| ItemAddedToCart({cartId, productId, qty}) => [
|
|
16
|
+
UpdateMultiState(
|
|
17
|
+
cartId,
|
|
18
|
+
states => {
|
|
19
|
+
let others = states->Array.filter(s => s.productId != productId)
|
|
20
|
+
others->Array.concat([{cartId, productId, qty}])
|
|
21
|
+
},
|
|
22
|
+
),
|
|
23
|
+
]
|
|
24
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
function project(event) {
|
|
5
|
+
let qty = event.qty;
|
|
6
|
+
let productId = event.productId;
|
|
7
|
+
let cartId = event.cartId;
|
|
8
|
+
return [{
|
|
9
|
+
TAG: "UpdateMultiState",
|
|
10
|
+
_0: cartId,
|
|
11
|
+
_1: states => {
|
|
12
|
+
let others = states.filter(s => s.productId !== productId);
|
|
13
|
+
return others.concat([{
|
|
14
|
+
cartId: cartId,
|
|
15
|
+
productId: productId,
|
|
16
|
+
qty: qty
|
|
17
|
+
}]);
|
|
18
|
+
}
|
|
19
|
+
}];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
project,
|
|
24
|
+
}
|
|
25
|
+
/* No side effect */
|