@reventlessdev/reventless-aws 3.0.0-alpha.184 → 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 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.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
+
13
+ # 3.0.0-alpha.185 (2026-07-08)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **reventless-aws:** thread partitionTag into deployed DCB command Lambda append ([68f0859](https://github.com/ReventlessDev/reventless-core/commit/68f0859d3d5e44f6217a5dc0d8537cfec3d21194))
18
+
19
+
6
20
  # 3.0.0-alpha.184 (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.184",
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",
14
13
  "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.47",
15
14
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
15
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.25",
16
16
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.14",
17
- "@reventlessdev/reventless-core": "3.0.0-alpha.145",
18
- "@reventlessdev/reventless-infra": "3.0.0-alpha.90",
17
+ "@reventlessdev/reventless-core": "3.0.0-alpha.147",
18
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.91",
19
19
  "@reventlessdev/reventless-interop": "3.0.0-alpha.24",
20
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.9",
21
- "@reventlessdev/reventless-spec": "3.0.0-alpha.68"
20
+ "@reventlessdev/reventless-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, originatorSlice} to AppSync Events channel.
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 originatorSlice = body.tags?.find(t => t.key === "originatorSlice")?.value;
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 originatorSlice = body.tags?.find(t => t.key === "originatorSlice")?.value;
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", {
@@ -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;
@@ -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,
@@ -59,6 +59,67 @@ 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 buildCompositeCommandEvent = (~command, ~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", command->JSON.Encode.string),
115
+ ("arguments", arguments->JSON.Encode.object),
116
+ ("meta", meta->JSON.Encode.object),
117
+ ])->JSON.Encode.object
118
+ }
119
+
120
+ let buildAddResourceEvent = (~environment, ~resourceName): JSON.t =>
121
+ buildCompositeCommandEvent(~command="AddResource", ~environment, ~resourceName)
122
+
62
123
  let buildAppSyncEvent = (widgetId): JSON.t => {
63
124
  // CommandGenerator payload: `{command, arguments, meta, identity?}` — the
64
125
  // exact shape AppSync's direct-invoke resolver sends (matches the resolver
@@ -98,4 +159,101 @@ describe("DcbCommandTopicEntryPoint integration", () => {
98
159
  expect(s->String.includes("CommandAccepted"))->toBe(true)
99
160
  },
100
161
  )
162
+
163
+ // Residual composite-fence burst contention
164
+ // (docs/plans/dcb-composite-fence-residual-burst-contention.md).
165
+ //
166
+ // The entry point must derive `partitionTag = Composite(...)` and thread it
167
+ // into the DynamoDB `append`, so a `@compositePartitionTag` slice collapses to
168
+ // ONE synthetic composite fence per entity. Without the thread (the bug),
169
+ // `partitionTag` defaults to None, the collapse never fires, and each append
170
+ // fences on its individual members — the shared low-cardinality `environment`
171
+ // member then serialises/rejects distinct resources.
172
+ testAsync(
173
+ "composite-partition burst: distinct resources sharing a prefix all commit, only composite fences written",
174
+ async () => {
175
+ let table = await H.createDcbTableWithTagKeys(
176
+ "EpComposite_" ++ Date.now()->Float.toString,
177
+ ["environment", "resourceName"],
178
+ )
179
+ let env = "prod"
180
+ let resourceNames = ["res-a", "res-b", "res-c", "res-d", "res-e"]
181
+
182
+ // Concurrent burst — simulates the deploy-sync fan-out that surfaced the
183
+ // `retries exhausted` cascade on alpha.
184
+ let outcomes =
185
+ await resourceNames
186
+ ->Array.map(resourceName =>
187
+ runOneCompositeEvent(table.name, buildAddResourceEvent(~environment=env, ~resourceName))
188
+ )
189
+ ->Promise.all
190
+
191
+ // Every distinct composite entity must commit — none may conflict on the
192
+ // shared `environment` prefix.
193
+ outcomes->Array.forEach(outcomeJson => {
194
+ let s = outcomeJson->JSON.stringifyAny->Option.getOr("<unserializable>")
195
+ expect(s->String.includes("CommandAccepted"))->toBe(true)
196
+ })
197
+
198
+ // Mechanism-level proof: the only fence rows are the synthetic composite
199
+ // fences (`fence#__dcb_composite__:…`) — one per entity — with NO
200
+ // per-member `fence#environment:…` / `fence#resourceName:…` rows.
201
+ let fenceIds = await H.scanFenceIds(table)
202
+ let compositeFencePrefix = "fence#" ++ DcbEventLogStorage_DynamoDb_Runtime.compositeFenceTagKey
203
+ expect(fenceIds->Array.length)->toBe(resourceNames->Array.length)
204
+ fenceIds->Array.forEach(id => expect(id->String.startsWith(compositeFencePrefix))->toBe(true))
205
+
206
+ await H.deleteTable(table)
207
+ },
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
+ )
101
259
  })
@@ -1,7 +1,9 @@
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";
6
+ import * as DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws from "../../src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs";
5
7
 
6
8
  let runOneAppSyncEvent = (async (tableName, event) => {
7
9
  const { buildHandlersForConfig } = await import(
@@ -36,6 +38,80 @@ let runOneAppSyncEvent = (async (tableName, event) => {
36
38
  return commandOutcomeToJson(outcome);
37
39
  });
38
40
 
41
+ let runOneCompositeEvent = (async (tableName, event) => {
42
+ const { buildHandlersForConfig } = await import(
43
+ "@reventlessdev/reventless-aws/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs"
44
+ );
45
+ const Effect = await import("effect/Effect");
46
+ const { tag: requestContextTag } = await import(
47
+ "@reventlessdev/reventless-core/src/RequestContext.res.mjs"
48
+ );
49
+ const { commandOutcomeToJson } = await import(
50
+ "@reventlessdev/reventless-core/src/components/CommandTopic/CommandTopic_Helpers.res.mjs"
51
+ );
52
+
53
+ const loadModule = async (specifier) => {
54
+ if (specifier === "ep-composite://spec") return await import("./EpCompositeSlice.res.mjs");
55
+ if (specifier === "ep-composite://behavior") return await import("./EpCompositeSliceBehavior.res.mjs");
56
+ throw new Error("unknown test specifier: " + specifier);
57
+ };
58
+
59
+ const config = {
60
+ pluginName: "EpCompositePlugin",
61
+ dcbEventLogTableName: tableName,
62
+ stateChangeSliceModules: [{ spec: "ep-composite://spec", behavior: "ep-composite://behavior" }],
63
+ queueUrl: "https://sqs.eu-west-1.amazonaws.com/000000000000/ep-composite-queue",
64
+ };
65
+
66
+ const [, cmdGenHandler] = await buildHandlersForConfig(config, { loadModule });
67
+
68
+ const effect = cmdGenHandler(event)
69
+ .pipe(Effect.provideService(requestContextTag, { correlationId: "ep-composite" }));
70
+ const outcome = await Effect.runPromise(effect);
71
+ return commandOutcomeToJson(outcome);
72
+ });
73
+
74
+ function buildCompositeCommandEvent(command, environment, resourceName) {
75
+ let $$arguments = Object.fromEntries([
76
+ [
77
+ "environment",
78
+ environment
79
+ ],
80
+ [
81
+ "resourceName",
82
+ resourceName
83
+ ]
84
+ ]);
85
+ let meta = Object.fromEntries([
86
+ [
87
+ "user",
88
+ "ep-composite"
89
+ ],
90
+ [
91
+ "ip",
92
+ null
93
+ ]
94
+ ]);
95
+ return Object.fromEntries([
96
+ [
97
+ "command",
98
+ command
99
+ ],
100
+ [
101
+ "arguments",
102
+ $$arguments
103
+ ],
104
+ [
105
+ "meta",
106
+ meta
107
+ ]
108
+ ]);
109
+ }
110
+
111
+ function buildAddResourceEvent(environment, resourceName) {
112
+ return buildCompositeCommandEvent("AddResource", environment, resourceName);
113
+ }
114
+
39
115
  function buildAppSyncEvent(widgetId) {
40
116
  let command = Object.fromEntries([[
41
117
  "AddWidget",
@@ -79,6 +155,49 @@ globalThis.describe("DcbCommandTopicEntryPoint integration", () => {
79
155
  let s = Stdlib_Option.getOr(JSON.stringify(outcomeJson), "<unserializable>");
80
156
  globalThis.expect(s.includes("CommandAccepted")).toBe(true);
81
157
  });
158
+ globalThis.test("composite-partition burst: distinct resources sharing a prefix all commit, only composite fences written", async () => {
159
+ let table = await DcbIntegrationHarness$ReventlessAws.createDcbTableWithTagKeys("EpComposite_" + Date.now().toString(), [
160
+ "environment",
161
+ "resourceName"
162
+ ]);
163
+ let resourceNames = [
164
+ "res-a",
165
+ "res-b",
166
+ "res-c",
167
+ "res-d",
168
+ "res-e"
169
+ ];
170
+ let outcomes = await Promise.all(resourceNames.map(resourceName => runOneCompositeEvent(table.name, buildCompositeCommandEvent("AddResource", "prod", resourceName))));
171
+ outcomes.forEach(outcomeJson => {
172
+ let s = Stdlib_Option.getOr(JSON.stringify(outcomeJson), "<unserializable>");
173
+ globalThis.expect(s.includes("CommandAccepted")).toBe(true);
174
+ });
175
+ let fenceIds = await DcbIntegrationHarness$ReventlessAws.scanFenceIds(table);
176
+ let compositeFencePrefix = "fence#" + DcbEventLogStorage_DynamoDb_Runtime$ReventlessAws.compositeFenceTagKey;
177
+ globalThis.expect(fenceIds.length).toBe(resourceNames.length);
178
+ fenceIds.forEach(id => {
179
+ globalThis.expect(id.startsWith(compositeFencePrefix)).toBe(true);
180
+ });
181
+ return await DcbIntegrationHarness$ReventlessAws.deleteTable(table);
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
+ });
82
201
  });
83
202
 
84
203
  let H;
@@ -86,6 +205,9 @@ let H;
86
205
  export {
87
206
  H,
88
207
  runOneAppSyncEvent,
208
+ runOneCompositeEvent,
209
+ buildCompositeCommandEvent,
210
+ buildAddResourceEvent,
89
211
  buildAppSyncEvent,
90
212
  }
91
213
  /* runOneAppSyncEvent Not a pure module */
@@ -73,6 +73,69 @@ 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
+
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
+
76
139
  let deleteTable = async (table: Util_DynamoDb_Runtime.resolvedTable) => {
77
140
  let input = Dict.fromArray([("TableName", s(table.name))])->JSON.Encode.object
78
141
  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,65 @@ 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
+
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
+
114
178
  async function deleteTable(table) {
115
179
  let input = Object.fromEntries([[
116
180
  "TableName",
@@ -154,9 +218,12 @@ export {
154
218
  gsi,
155
219
  gsiNames,
156
220
  createDcbTable,
221
+ createDcbTableWithTagKeys,
222
+ scanFenceIds,
223
+ scanEventTypes,
157
224
  deleteTable,
158
225
  counter,
159
226
  freshTable,
160
227
  setFence,
161
228
  }
162
- /* @aws-sdk/client-dynamodb Not a pure module */
229
+ /* Stream Not a pure module */
@@ -0,0 +1,61 @@
1
+ // Composite-partition slice fixture for `DcbCommandTopicEntryPoint_IntegrationTest`.
2
+ //
3
+ // Reproduces the deploy-sync workload shape from
4
+ // docs/plans/done/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
+ // 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
+ //
20
+ // Explicit `@s.matches(Reventless.DcbTag.compositePartitionMember(...))` form
21
+ // (not the `@compositePartitionTag` PPX shorthand) because reventless-ppx is not
22
+ // wired into reventless-aws's rescript.json — same reason as EpTestSlice.
23
+
24
+ @schema
25
+ type consumedEvent =
26
+ | ResourceAdded({environment: string, resourceName: string})
27
+ | ResourceTouched({environment: string, resourceName: string})
28
+
29
+ @schema
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
+ })
39
+
40
+ @schema
41
+ type error =
42
+ | AlreadyAdded
43
+ | NotFound
44
+
45
+ @schema
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
+ })
55
+
56
+ let name = "EpCompositeSlice"
57
+ let moduleUrl = "ep-test://EpCompositeSlice"
58
+ let commandAuthorization = (_: command): Reventless.Authorization.permission => AllowAnonymous
59
+ let readConsistency = Reventless.ReadConsistency.EscalateOnRetry
60
+
61
+ // `module Id = Reventless.Id.String` — patched in by `patchSpecId` at runtime.
@@ -0,0 +1,70 @@
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.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
+ ]);
49
+
50
+ function commandAuthorization(param) {
51
+ return "AllowAnonymous";
52
+ }
53
+
54
+ let name = "EpCompositeSlice";
55
+
56
+ let moduleUrl = "ep-test://EpCompositeSlice";
57
+
58
+ let readConsistency = "EscalateOnRetry";
59
+
60
+ export {
61
+ consumedEventSchema,
62
+ commandSchema,
63
+ errorSchema,
64
+ eventSchema,
65
+ name,
66
+ moduleUrl,
67
+ commandAuthorization,
68
+ readConsistency,
69
+ }
70
+ /* consumedEventSchema Not a pure module */
@@ -0,0 +1,35 @@
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 `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.
9
+
10
+ type state =
11
+ | Absent
12
+ | Added
13
+
14
+ let initialState = Absent
15
+
16
+ let evolve = (_state, event: EpCompositeSlice.consumedEvent) =>
17
+ switch event {
18
+ | ResourceAdded(_) => Added
19
+ | ResourceTouched(_) => Added
20
+ }
21
+
22
+ let decide = (state, command: EpCompositeSlice.command): result<
23
+ array<EpCompositeSlice.event>,
24
+ EpCompositeSlice.error,
25
+ > =>
26
+ switch (state, command) {
27
+ | (Added, AddResource(_)) => Error(AlreadyAdded)
28
+ | (Absent, AddResource({environment, resourceName})) =>
29
+ Ok([ResourceAdded({environment, resourceName})])
30
+ | (Added, TouchResource({environment, resourceName})) =>
31
+ Ok([ResourceTouched({environment, resourceName})])
32
+ | (Absent, TouchResource(_)) => Error(NotFound)
33
+ }
34
+
35
+ let moduleUrl = "ep-test://EpCompositeSliceBehavior"
@@ -0,0 +1,52 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+
4
+ function evolve(_state, event) {
5
+ return "Added";
6
+ }
7
+
8
+ function decide(state, command) {
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") {
26
+ return {
27
+ TAG: "Error",
28
+ _0: "AlreadyAdded"
29
+ };
30
+ } else {
31
+ return {
32
+ TAG: "Ok",
33
+ _0: [{
34
+ TAG: "ResourceTouched",
35
+ environment: command.environment,
36
+ resourceName: command.resourceName
37
+ }]
38
+ };
39
+ }
40
+ }
41
+
42
+ let initialState = "Absent";
43
+
44
+ let moduleUrl = "ep-test://EpCompositeSliceBehavior";
45
+
46
+ export {
47
+ initialState,
48
+ evolve,
49
+ decide,
50
+ moduleUrl,
51
+ }
52
+ /* No side effect */