@reventlessdev/reventless-aws 3.0.0-alpha.346 → 3.0.0-alpha.347

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.
Files changed (80) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/package.json +17 -13
  3. package/rescript.json +2 -1
  4. package/run-bake-manifest.mjs +3 -0
  5. package/run-deploy-app.mjs +3 -0
  6. package/scripts/BakeManifest.res +395 -0
  7. package/scripts/BakeManifest.res.mjs +487 -0
  8. package/scripts/DeployApp.res +708 -0
  9. package/scripts/DeployApp.res.mjs +1015 -0
  10. package/scripts/DeployManifest.res +84 -0
  11. package/scripts/DeployManifest.res.mjs +112 -0
  12. package/scripts/ProvisionAccounts.res +60 -46
  13. package/scripts/ProvisionAccounts.res.mjs +51 -37
  14. package/scripts/PulumiCli.res +43 -0
  15. package/scripts/PulumiCli.res.mjs +106 -0
  16. package/src/Platform.res +19 -2
  17. package/src/Platform.res.mjs +12 -4
  18. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +1 -5
  19. package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +1 -1
  20. package/src/adapter/Api/Platform_UIFragments_Lambda.res +1 -5
  21. package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +1 -2
  22. package/src/adapter/Auth/Auth_ActiveRoleStore.res +1 -5
  23. package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +1 -2
  24. package/src/adapter/Auth/Auth_ActiveRoleTrigger.res +1 -5
  25. package/src/adapter/Auth/Auth_ActiveRoleTrigger.res.mjs +1 -2
  26. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +52 -31
  27. package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs +75 -44
  28. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +1 -5
  29. package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +1 -2
  30. package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res +1 -5
  31. package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res.mjs +1 -2
  32. package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res +5 -4
  33. package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res.mjs +1 -1
  34. package/src/adapter/Postgres/PgMigration_Builder.res +1 -1
  35. package/src/adapter/Postgres/PgMigration_Builder.res.mjs +1 -2
  36. package/src/adapter/Runtime/CommandGeneratorEntryPoint_Ops.res +2 -0
  37. package/src/adapter/Runtime/CommandGeneratorEntryPoint_Ops.res.mjs +2 -2
  38. package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +15 -6
  39. package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res +51 -30
  40. package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res.mjs +31 -14
  41. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +1 -5
  42. package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +1 -1
  43. package/src/adapter/StateTopic/StateTopic_AppSync.res +1 -5
  44. package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +1 -1
  45. package/src/adapter/Task/TaskBucket_S3.res +1 -3
  46. package/src/adapter/Task/TaskBucket_S3.res.mjs +1 -1
  47. package/src/adapter/Upload/Upload_Claim_S3.res +1 -5
  48. package/src/adapter/Upload/Upload_Claim_S3.res.mjs +1 -1
  49. package/src/adapter/Upload/Upload_Presign_S3.res +1 -5
  50. package/src/adapter/Upload/Upload_Presign_S3.res.mjs +1 -2
  51. package/src/capability/Capability_Geocoding_AwsLocation.res +5 -1
  52. package/src/capability/Capability_Geocoding_AwsLocation.res.mjs +7 -1
  53. package/src/components/Api/AppSync_SdlDecorate.res +4 -2
  54. package/src/components/Api/AppSync_SdlDecorate.res.mjs +1 -1
  55. package/src/plugin/cloner/ClonerRunner_Fargate.res +1 -5
  56. package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +1 -1
  57. package/src/plugin/stack/Plugin_Stack.res +4 -0
  58. package/src/plugin/stack/Plugin_Stack.res.mjs +2 -0
  59. package/src/util/Util_DeadLetterQueue.res +1 -5
  60. package/src/util/Util_DeadLetterQueue.res.mjs +1 -2
  61. package/src/util/Util_StoreLayout.res +15 -2
  62. package/src/util/Util_StoreLayout.res.mjs +11 -3
  63. package/tests/BakeManifestTest.res +204 -0
  64. package/tests/BakeManifestTest.res.mjs +259 -0
  65. package/tests/DcbColdStartPartitionTest.res +32 -0
  66. package/tests/DcbColdStartPartitionTest.res.mjs +33 -0
  67. package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +88 -0
  68. package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res.mjs +112 -0
  69. package/tests/DcbUnresolvedPartitionSlice.res +28 -0
  70. package/tests/DcbUnresolvedPartitionSlice.res.mjs +38 -0
  71. package/tests/DeployAppTest.res +90 -0
  72. package/tests/DeployAppTest.res.mjs +133 -0
  73. package/tests/LambdaLayerLookupTest.res +104 -0
  74. package/tests/LambdaLayerLookupTest.res.mjs +117 -0
  75. package/tests/ProvisionAccountsTest.res +26 -0
  76. package/tests/ProvisionAccountsTest.res.mjs +31 -0
  77. package/tests/Util_StoreLayoutTest.res +6 -0
  78. package/tests/Util_StoreLayoutTest.res.mjs +7 -4
  79. package/tests/dcbColdStartPartition.mjs +66 -0
  80. package/tests/setup/layerArn.cjs +5 -0
@@ -3,6 +3,8 @@
3
3
  import * as Stdlib_Int from "@rescript/runtime/lib/es6/Stdlib_Int.js";
4
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
+ import * as Pulumi from "@pulumi/pulumi";
7
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
6
8
  import * as StoreLayout$ReventlessCore from "@reventlessdev/reventless-core/src/util/StoreLayout.res.mjs";
7
9
 
8
10
  let defaultEphemeralPrefixes = ["pr-"];
@@ -15,15 +17,20 @@ function layoutFor(stack, prodStacks) {
15
17
  }
16
18
  }
17
19
 
18
- function protectionFor(stack, ephemeralPrefixesOpt) {
20
+ function protectionFor(stack, disposableOpt, ephemeralPrefixesOpt) {
21
+ let disposable = disposableOpt !== undefined ? disposableOpt : false;
19
22
  let ephemeralPrefixes = ephemeralPrefixesOpt !== undefined ? ephemeralPrefixesOpt : defaultEphemeralPrefixes;
20
- if (ephemeralPrefixes.some(p => stack.startsWith(p))) {
23
+ if (disposable || ephemeralPrefixes.some(p => stack.startsWith(p))) {
21
24
  return "Unprotected";
22
25
  } else {
23
26
  return "Protected";
24
27
  }
25
28
  }
26
29
 
30
+ function protectionOfDeployedStack() {
31
+ return protectionFor(Pulumi.getStack(), Primitive_object.equal(new Pulumi.Config("reventless").get("disposable"), "true"), undefined);
32
+ }
33
+
27
34
  function bucketNameFor(layout, stack, plugin, store) {
28
35
  if (layout === "PerStore") {
29
36
  return plugin + `-` + store;
@@ -84,10 +91,11 @@ export {
84
91
  defaultEphemeralPrefixes,
85
92
  layoutFor,
86
93
  protectionFor,
94
+ protectionOfDeployedStack,
87
95
  bucketNameFor,
88
96
  keyPrefixFor,
89
97
  servingFor,
90
98
  coverageFor,
91
99
  pendingExpiryFor,
92
100
  }
93
- /* No side effect */
101
+ /* @pulumi/pulumi Not a pure module */
@@ -0,0 +1,204 @@
1
+ open JestGlobals
2
+
3
+ // What the command decides without AWS: which stacks it reads, what it asks the
4
+ // bake function, how it reads the answer, and when it stops asking.
5
+
6
+ module Bake = BakeManifest
7
+
8
+ let json = text => JSON.parseOrThrow(text)
9
+
10
+ describe("DeployManifest", () => {
11
+ let text = `
12
+ region: eu-west-1
13
+ platform:
14
+ path: platform-aws
15
+ name: shop-platform
16
+ stack-defaults:
17
+ platform:messagingEmailProvider: log
18
+ plugins:
19
+ - name: catalog
20
+ path: catalog-aws
21
+ depends-on: []
22
+ - name: ordering
23
+ path: ordering-aws
24
+ `
25
+
26
+ testSync("resolves every folder against the manifest's own folder", () => {
27
+ let resolved =
28
+ DeployManifest.parseString(text)->Result.map(
29
+ m => m->DeployManifest.resolve(~file="/repo/shop/deploy-manifest.yaml"),
30
+ )
31
+ expect(resolved)->toEqual(
32
+ Ok({
33
+ DeployManifest.file: "/repo/shop/deploy-manifest.yaml",
34
+ region: Some("eu-west-1"),
35
+ platform: {
36
+ name: "shop-platform",
37
+ dir: "/repo/shop/platform-aws",
38
+ stackDefaults: Dict.fromArray([("platform:messagingEmailProvider", "log")]),
39
+ },
40
+ plugins: [
41
+ {name: "catalog", dir: "/repo/shop/catalog-aws", stackDefaults: Dict.make()},
42
+ {name: "ordering", dir: "/repo/shop/ordering-aws", stackDefaults: Dict.make()},
43
+ ],
44
+ }),
45
+ )
46
+ })
47
+
48
+ testSync("refuses a manifest without a platform", () =>
49
+ expect(DeployManifest.parseString("plugins: []")->Result.isError)->toBe(true)
50
+ )
51
+ })
52
+
53
+ describe("BakeManifest.parseArgs", () => {
54
+ testSync("reads the stack, the manifest and the deploy's start", () =>
55
+ expect(
56
+ Bake.parseArgs([
57
+ "--stack",
58
+ "alpha",
59
+ "--manifest",
60
+ "m.yaml",
61
+ "--since",
62
+ "2026-09-16T10:00:00Z",
63
+ ]),
64
+ )->toEqual(
65
+ Ok({
66
+ Bake.manifest: Some("m.yaml"),
67
+ stack: Some("alpha"),
68
+ since: Some("2026-09-16T10:00:00Z"),
69
+ help: false,
70
+ }),
71
+ )
72
+ )
73
+
74
+ // CI passes the start through an output that is empty on a hand-run workflow.
75
+ testSync("an empty --since is no instant", () =>
76
+ expect(Bake.parseArgs(["--since", ""])->Result.map(a => a.since))->toEqual(Ok(None))
77
+ )
78
+
79
+ testSync("an unknown flag refuses", () =>
80
+ expect(Bake.parseArgs(["--stak", "alpha"]))->toEqual(Error(`unknown argument "--stak"`))
81
+ )
82
+ })
83
+
84
+ describe("BakeManifest reads the stacks", () => {
85
+ testSync("a platform without a bake function declares no bake", () =>
86
+ expect(
87
+ Bake.targetOf(
88
+ json(`{"bakedManifestBucket": "b", "bakedManifestFunction": ""}`)
89
+ ->JSON.Decode.object
90
+ ->Option.getOr(Dict.make()),
91
+ ),
92
+ )->toEqual(None)
93
+ )
94
+
95
+ testSync("a platform with one names the function, bucket and key", () =>
96
+ expect(
97
+ Bake.targetOf(
98
+ json(`{"bakedManifestFunction": "fn", "bakedManifestBucket": "b", "bakedManifestKey": "manifest.json"}`)
99
+ ->JSON.Decode.object
100
+ ->Option.getOr(Dict.make()),
101
+ ),
102
+ )->toEqual(Some({Bake.functionName: "fn", bucket: "b", key: "manifest.json"}))
103
+ )
104
+
105
+ testSync("a plugin stack's structure key is read from pluginStructureRef", () =>
106
+ expect(
107
+ Bake.structureRefOf(
108
+ json(`{"pluginStructureRef": {"plugin": "Catalog", "key": "sha256/abc"}}`)
109
+ ->JSON.Decode.object
110
+ ->Option.getOr(Dict.make()),
111
+ ),
112
+ )->toEqual(Some(("Catalog", "sha256/abc")))
113
+ )
114
+
115
+ testSync("the payload carries the deploy's start only when there is one", () => {
116
+ let target = {Bake.functionName: "fn", bucket: "b", key: "k"}
117
+ let expect_ = Dict.fromArray([("Catalog", "sha256/abc")])
118
+ expect(
119
+ Bake.payload(~target, ~expect=expect_, ~since=None)->JSON.stringify,
120
+ )->toBe(`{"bake":true,"bucket":"b","key":"k","expect":{"Catalog":"sha256/abc"}}`)
121
+ expect(
122
+ Bake.payload(~target, ~expect=expect_, ~since=Some("2026-09-16T10:00:00Z"))->JSON.stringify,
123
+ )->toBe(`{"bake":true,"bucket":"b","key":"k","expect":{"Catalog":"sha256/abc"},"since":"2026-09-16T10:00:00Z"}`)
124
+ })
125
+ })
126
+
127
+ let pending = state =>
128
+ json(
129
+ `[{"baked": false, "pending": ["Catalog"], "registrations": [
130
+ {"plugin": "Catalog", "state": "${state}", "expected": "sha256/new", "found": "sha256/old", "writtenAt": null}
131
+ ]}]`,
132
+ )
133
+
134
+ let baked = json(`[
135
+ {"baked": true, "bucket": "b", "key": "manifest.json", "plugins": 2, "bytes": 512},
136
+ {"summary": true, "plugins": 1, "registered": 1, "unchanged": 0, "matched": 0, "registrations": [
137
+ {"plugin": "Catalog", "state": "registered", "expected": "sha256/new", "found": "sha256/new", "writtenAt": "2026-09-16T10:01:00Z"}
138
+ ]}
139
+ ]`)
140
+
141
+ describe("BakeManifest.readAnswer", () => {
142
+ testSync("a written manifest reports its files and what convergence proved", () =>
143
+ switch Bake.readAnswer(baked) {
144
+ | Ok(Baked({written, summary})) =>
145
+ expect((
146
+ written->Array.map(w => (w.key, w.bytes)),
147
+ summary->Option.map(s => s.registered),
148
+ ))->toEqual(([("manifest.json", 512)], Some(1)))
149
+ | _ => expect("not baked")->toBe("baked")
150
+ }
151
+ )
152
+
153
+ testSync("a pending answer names the plugins not yet arrived", () =>
154
+ switch Bake.readAnswer(pending("behind")) {
155
+ | Ok(Pending({pending})) => expect(pending)->toEqual(["Catalog"])
156
+ | _ => expect("not pending")->toBe("pending")
157
+ }
158
+ )
159
+
160
+ testSync("the report line carries both keys and the row's date", () =>
161
+ expect(
162
+ Bake.registrationLine({
163
+ plugin: "Catalog",
164
+ state: "missing",
165
+ expected: "sha256/new",
166
+ found: None,
167
+ writtenAt: None,
168
+ }),
169
+ )->toBe(" Catalog: missing — expected sha256/new, row holds no row (written never)")
170
+ )
171
+ })
172
+
173
+ describe("BakeManifest.converge", () => {
174
+ let run = async (answers: array<JSON.t>, ~attempts) => {
175
+ let asked = ref(0)
176
+ let invoke = async () => {
177
+ let answer = answers->Array.getUnsafe(Math.Int.min(asked.contents, answers->Array.length - 1))
178
+ asked := asked.contents + 1
179
+ Ok(answer)
180
+ }
181
+ let outcome = await Bake.converge(~invoke, ~sleep=async () => (), ~log=_ => (), ~attempts)
182
+ (outcome->Result.isOk, asked.contents)
183
+ }
184
+
185
+ test("asks again while a plugin is behind, then succeeds", async () =>
186
+ expect(await run([pending("behind"), pending("behind"), baked], ~attempts=5))->toEqual((
187
+ true,
188
+ 3,
189
+ ))
190
+ )
191
+
192
+ // A first deploy has no row for any plugin until its registration lands.
193
+ test("waits for a missing registration", async () =>
194
+ expect(await run([pending("missing"), baked], ~attempts=5))->toEqual((true, 2))
195
+ )
196
+
197
+ test("stops at once on a registration that diverged", async () =>
198
+ expect(await run([pending("diverged"), baked], ~attempts=5))->toEqual((false, 1))
199
+ )
200
+
201
+ test("gives up when the attempts run out", async () =>
202
+ expect(await run([pending("behind")], ~attempts=3))->toEqual((false, 3))
203
+ )
204
+ })
@@ -0,0 +1,259 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
+ import * as Stdlib_Result from "@rescript/runtime/lib/es6/Stdlib_Result.js";
6
+ import * as BakeManifest$ReventlessAws from "../scripts/BakeManifest.res.mjs";
7
+ import * as DeployManifest$ReventlessAws from "../scripts/DeployManifest.res.mjs";
8
+
9
+ function json(text) {
10
+ return JSON.parse(text);
11
+ }
12
+
13
+ globalThis.describe("DeployManifest", () => {
14
+ globalThis.test("resolves every folder against the manifest's own folder", () => {
15
+ let resolved = Stdlib_Result.map(DeployManifest$ReventlessAws.parseString(`
16
+ region: eu-west-1
17
+ platform:
18
+ path: platform-aws
19
+ name: shop-platform
20
+ stack-defaults:
21
+ platform:messagingEmailProvider: log
22
+ plugins:
23
+ - name: catalog
24
+ path: catalog-aws
25
+ depends-on: []
26
+ - name: ordering
27
+ path: ordering-aws
28
+ `), m => DeployManifest$ReventlessAws.resolve(m, "/repo/shop/deploy-manifest.yaml"));
29
+ globalThis.expect(resolved).toEqual({
30
+ TAG: "Ok",
31
+ _0: {
32
+ file: "/repo/shop/deploy-manifest.yaml",
33
+ region: "eu-west-1",
34
+ platform: {
35
+ name: "shop-platform",
36
+ dir: "/repo/shop/platform-aws",
37
+ stackDefaults: Object.fromEntries([[
38
+ "platform:messagingEmailProvider",
39
+ "log"
40
+ ]])
41
+ },
42
+ plugins: [
43
+ {
44
+ name: "catalog",
45
+ dir: "/repo/shop/catalog-aws",
46
+ stackDefaults: {}
47
+ },
48
+ {
49
+ name: "ordering",
50
+ dir: "/repo/shop/ordering-aws",
51
+ stackDefaults: {}
52
+ }
53
+ ]
54
+ }
55
+ });
56
+ });
57
+ globalThis.test("refuses a manifest without a platform", () => {
58
+ globalThis.expect(Stdlib_Result.isError(DeployManifest$ReventlessAws.parseString("plugins: []"))).toBe(true);
59
+ });
60
+ });
61
+
62
+ globalThis.describe("BakeManifest.parseArgs", () => {
63
+ globalThis.test("reads the stack, the manifest and the deploy's start", () => {
64
+ globalThis.expect(BakeManifest$ReventlessAws.parseArgs([
65
+ "--stack",
66
+ "alpha",
67
+ "--manifest",
68
+ "m.yaml",
69
+ "--since",
70
+ "2026-09-16T10:00:00Z"
71
+ ])).toEqual({
72
+ TAG: "Ok",
73
+ _0: {
74
+ manifest: "m.yaml",
75
+ stack: "alpha",
76
+ since: "2026-09-16T10:00:00Z",
77
+ help: false
78
+ }
79
+ });
80
+ });
81
+ globalThis.test("an empty --since is no instant", () => {
82
+ globalThis.expect(Stdlib_Result.map(BakeManifest$ReventlessAws.parseArgs([
83
+ "--since",
84
+ ""
85
+ ]), a => a.since)).toEqual({
86
+ TAG: "Ok",
87
+ _0: undefined
88
+ });
89
+ });
90
+ globalThis.test("an unknown flag refuses", () => {
91
+ globalThis.expect(BakeManifest$ReventlessAws.parseArgs([
92
+ "--stak",
93
+ "alpha"
94
+ ])).toEqual({
95
+ TAG: "Error",
96
+ _0: `unknown argument "--stak"`
97
+ });
98
+ });
99
+ });
100
+
101
+ globalThis.describe("BakeManifest reads the stacks", () => {
102
+ globalThis.test("a platform without a bake function declares no bake", () => {
103
+ globalThis.expect(BakeManifest$ReventlessAws.targetOf(Stdlib_Option.getOr(Stdlib_JSON.Decode.object(JSON.parse(`{"bakedManifestBucket": "b", "bakedManifestFunction": ""}`)), {}))).toEqual(undefined);
104
+ });
105
+ globalThis.test("a platform with one names the function, bucket and key", () => {
106
+ globalThis.expect(BakeManifest$ReventlessAws.targetOf(Stdlib_Option.getOr(Stdlib_JSON.Decode.object(JSON.parse(`{"bakedManifestFunction": "fn", "bakedManifestBucket": "b", "bakedManifestKey": "manifest.json"}`)), {}))).toEqual({
107
+ functionName: "fn",
108
+ bucket: "b",
109
+ key: "manifest.json"
110
+ });
111
+ });
112
+ globalThis.test("a plugin stack's structure key is read from pluginStructureRef", () => {
113
+ globalThis.expect(BakeManifest$ReventlessAws.structureRefOf(Stdlib_Option.getOr(Stdlib_JSON.Decode.object(JSON.parse(`{"pluginStructureRef": {"plugin": "Catalog", "key": "sha256/abc"}}`)), {}))).toEqual([
114
+ "Catalog",
115
+ "sha256/abc"
116
+ ]);
117
+ });
118
+ globalThis.test("the payload carries the deploy's start only when there is one", () => {
119
+ let target = {
120
+ functionName: "fn",
121
+ bucket: "b",
122
+ key: "k"
123
+ };
124
+ let expect_ = Object.fromEntries([[
125
+ "Catalog",
126
+ "sha256/abc"
127
+ ]]);
128
+ globalThis.expect(JSON.stringify(BakeManifest$ReventlessAws.payload(target, expect_, undefined))).toBe(`{"bake":true,"bucket":"b","key":"k","expect":{"Catalog":"sha256/abc"}}`);
129
+ globalThis.expect(JSON.stringify(BakeManifest$ReventlessAws.payload(target, expect_, "2026-09-16T10:00:00Z"))).toBe(`{"bake":true,"bucket":"b","key":"k","expect":{"Catalog":"sha256/abc"},"since":"2026-09-16T10:00:00Z"}`);
130
+ });
131
+ });
132
+
133
+ function pending(state) {
134
+ return JSON.parse(`[{"baked": false, "pending": ["Catalog"], "registrations": [
135
+ {"plugin": "Catalog", "state": "` + state + `", "expected": "sha256/new", "found": "sha256/old", "writtenAt": null}
136
+ ]}]`);
137
+ }
138
+
139
+ let baked = JSON.parse(`[
140
+ {"baked": true, "bucket": "b", "key": "manifest.json", "plugins": 2, "bytes": 512},
141
+ {"summary": true, "plugins": 1, "registered": 1, "unchanged": 0, "matched": 0, "registrations": [
142
+ {"plugin": "Catalog", "state": "registered", "expected": "sha256/new", "found": "sha256/new", "writtenAt": "2026-09-16T10:01:00Z"}
143
+ ]}
144
+ ]`);
145
+
146
+ globalThis.describe("BakeManifest.readAnswer", () => {
147
+ globalThis.test("a written manifest reports its files and what convergence proved", () => {
148
+ let match = BakeManifest$ReventlessAws.readAnswer(baked);
149
+ if (match.TAG === "Ok") {
150
+ let match$1 = match._0;
151
+ if (match$1.TAG === "Baked") {
152
+ globalThis.expect([
153
+ match$1.written.map(w => [
154
+ w.key,
155
+ w.bytes
156
+ ]),
157
+ Stdlib_Option.map(match$1.summary, s => s.registered)
158
+ ]).toEqual([
159
+ [[
160
+ "manifest.json",
161
+ 512
162
+ ]],
163
+ 1
164
+ ]);
165
+ return;
166
+ }
167
+ globalThis.expect("not baked").toBe("baked");
168
+ return;
169
+ }
170
+ globalThis.expect("not baked").toBe("baked");
171
+ });
172
+ globalThis.test("a pending answer names the plugins not yet arrived", () => {
173
+ let match = BakeManifest$ReventlessAws.readAnswer(pending("behind"));
174
+ if (match.TAG === "Ok") {
175
+ let match$1 = match._0;
176
+ if (match$1.TAG === "Baked") {
177
+ globalThis.expect("not pending").toBe("pending");
178
+ return;
179
+ }
180
+ globalThis.expect(match$1.pending).toEqual(["Catalog"]);
181
+ return;
182
+ }
183
+ globalThis.expect("not pending").toBe("pending");
184
+ });
185
+ globalThis.test("the report line carries both keys and the row's date", () => {
186
+ globalThis.expect(BakeManifest$ReventlessAws.registrationLine({
187
+ plugin: "Catalog",
188
+ state: "missing",
189
+ expected: "sha256/new",
190
+ found: undefined,
191
+ writtenAt: undefined
192
+ })).toBe(" Catalog: missing — expected sha256/new, row holds no row (written never)");
193
+ });
194
+ });
195
+
196
+ globalThis.describe("BakeManifest.converge", () => {
197
+ let run = async (answers, attempts) => {
198
+ let asked = {
199
+ contents: 0
200
+ };
201
+ let invoke = async () => {
202
+ let answer = answers[Math.min(asked.contents, answers.length - 1 | 0)];
203
+ asked.contents = asked.contents + 1 | 0;
204
+ return {
205
+ TAG: "Ok",
206
+ _0: answer
207
+ };
208
+ };
209
+ let outcome = await BakeManifest$ReventlessAws.converge(invoke, async () => {}, param => {}, attempts);
210
+ return [
211
+ Stdlib_Result.isOk(outcome),
212
+ asked.contents
213
+ ];
214
+ };
215
+ globalThis.test("asks again while a plugin is behind, then succeeds", async () => {
216
+ globalThis.expect(await run([
217
+ pending("behind"),
218
+ pending("behind"),
219
+ baked
220
+ ], 5)).toEqual([
221
+ true,
222
+ 3
223
+ ]);
224
+ });
225
+ globalThis.test("waits for a missing registration", async () => {
226
+ globalThis.expect(await run([
227
+ pending("missing"),
228
+ baked
229
+ ], 5)).toEqual([
230
+ true,
231
+ 2
232
+ ]);
233
+ });
234
+ globalThis.test("stops at once on a registration that diverged", async () => {
235
+ globalThis.expect(await run([
236
+ pending("diverged"),
237
+ baked
238
+ ], 5)).toEqual([
239
+ false,
240
+ 1
241
+ ]);
242
+ });
243
+ globalThis.test("gives up when the attempts run out", async () => {
244
+ globalThis.expect(await run([pending("behind")], 3)).toEqual([
245
+ false,
246
+ 3
247
+ ]);
248
+ });
249
+ });
250
+
251
+ let Bake;
252
+
253
+ export {
254
+ Bake,
255
+ json,
256
+ pending,
257
+ baked,
258
+ }
259
+ /* Not a pure module */
@@ -0,0 +1,32 @@
1
+ // The DCB command Lambda refuses to start when its partition keys cannot be
2
+ // derived, and says why on every invocation.
3
+ //
4
+ // Deploy derives the same keys from the same slices, so this only fails when the
5
+ // framework in the Lambda layer differs from the one deploy used. Carrying on
6
+ // would file events under the wrong keys; refusing sends messages to the DLQ.
7
+
8
+ open JestGlobals
9
+
10
+ type afterFailedStartup = {messages: array<string>, unhandled: array<string>}
11
+
12
+ @module("./dcbColdStartPartition.mjs")
13
+ external invokeAfterFailedStartup: unit => promise<afterFailedStartup> = "invokeAfterFailedStartup"
14
+
15
+ @module("./dcbColdStartPartition.mjs")
16
+ external startWithUnresolvedSlice: unit => promise<string> = "startWithUnresolvedSlice"
17
+
18
+ describe("DcbCommandTopicEntryPoint cold start", () => {
19
+ test("a slice whose partition cannot be inferred stops startup, naming it", async () => {
20
+ let message = await startWithUnresolvedSlice()
21
+ expect(message->String.includes("LinkGadget"))->toBe(true)
22
+ expect(message->String.includes("@partitionTag"))->toBe(true)
23
+ })
24
+
25
+ test("after a failed startup every invocation rethrows the same error", async () => {
26
+ let {messages, unhandled} = await invokeAfterFailedStartup()
27
+ expect(messages->Array.length)->toBe(2)
28
+ expect(messages->Array.includes("resolved"))->toBe(false)
29
+ expect(messages->Array.getUnsafe(0))->toBe(messages->Array.getUnsafe(1))
30
+ expect(unhandled)->toEqual([])
31
+ })
32
+ })
@@ -0,0 +1,33 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as DcbColdStartPartitionMjs from "./dcbColdStartPartition.mjs";
4
+
5
+ function invokeAfterFailedStartup(prim) {
6
+ return DcbColdStartPartitionMjs.invokeAfterFailedStartup();
7
+ }
8
+
9
+ function startWithUnresolvedSlice(prim) {
10
+ return DcbColdStartPartitionMjs.startWithUnresolvedSlice();
11
+ }
12
+
13
+ globalThis.describe("DcbCommandTopicEntryPoint cold start", () => {
14
+ globalThis.test("a slice whose partition cannot be inferred stops startup, naming it", async () => {
15
+ let message = await DcbColdStartPartitionMjs.startWithUnresolvedSlice();
16
+ globalThis.expect(message.includes("LinkGadget")).toBe(true);
17
+ globalThis.expect(message.includes("@partitionTag")).toBe(true);
18
+ });
19
+ globalThis.test("after a failed startup every invocation rethrows the same error", async () => {
20
+ let match = await DcbColdStartPartitionMjs.invokeAfterFailedStartup();
21
+ let messages = match.messages;
22
+ globalThis.expect(messages.length).toBe(2);
23
+ globalThis.expect(messages.includes("resolved")).toBe(false);
24
+ globalThis.expect(messages[0]).toBe(messages[1]);
25
+ globalThis.expect(match.unhandled).toEqual([]);
26
+ });
27
+ });
28
+
29
+ export {
30
+ invokeAfterFailedStartup,
31
+ startWithUnresolvedSlice,
32
+ }
33
+ /* Not a pure module */
@@ -259,6 +259,94 @@ describe("Runtime.buildScanFilter — excludes fence sentinels (Issue 12)", () =
259
259
  })
260
260
  })
261
261
 
262
+ describe("Runtime.derivePartitionKey — each event type under its slice's key", () => {
263
+ // Two entities in one log. AddressLinked declares addressId first, so a
264
+ // positional default would file it under the reference.
265
+ let partitionTag = Reventless.DcbTag.ByEventType(
266
+ Dict.fromArray([("OrderPlaced", "orderId"), ("AddressLinked", "customerId")]),
267
+ )
268
+ let thrownMessage = f =>
269
+ try {
270
+ let _ = f()
271
+ ""
272
+ } catch {
273
+ | JsExn(err) => JsExn.message(err)->Option.getOr("")
274
+ }
275
+
276
+ testSync("files an event under its own type's key, not its first tag", () =>
277
+ expect(
278
+ Runtime.derivePartitionKey(
279
+ ~partitionTag,
280
+ ~eventType="AddressLinked",
281
+ [tag("addressId", "a1"), tag("customerId", "c1")],
282
+ ),
283
+ )->toBe("customerId:c1")
284
+ )
285
+
286
+ testSync("another type in the same log keeps its own key", () =>
287
+ expect(
288
+ Runtime.derivePartitionKey(
289
+ ~partitionTag,
290
+ ~eventType="OrderPlaced",
291
+ [tag("customerId", "c1"), tag("orderId", "o1")],
292
+ ),
293
+ )->toBe("orderId:o1")
294
+ )
295
+
296
+ testSync("an event type no slice writes throws instead of guessing", () =>
297
+ expect(
298
+ thrownMessage(
299
+ () =>
300
+ Runtime.derivePartitionKey(
301
+ ~partitionTag,
302
+ ~eventType="Unknown",
303
+ [tag("customerId", "c1")],
304
+ ),
305
+ )->String.includes("Unknown has no partition key"),
306
+ )->toBe(true)
307
+ )
308
+
309
+ testSync("an event missing its partition tag throws instead of guessing", () =>
310
+ expect(
311
+ thrownMessage(
312
+ () =>
313
+ Runtime.derivePartitionKey(
314
+ ~partitionTag,
315
+ ~eventType="AddressLinked",
316
+ [tag("addressId", "a1")],
317
+ ),
318
+ )->String.includes("carries no customerId tag"),
319
+ )->toBe(true)
320
+ )
321
+
322
+ testSync("a conditional append puts and fences the event under that key", () => {
323
+ let linked: ReventlessCore.DcbEventLog_Adapter.rawStoredEvent = {
324
+ eventType: "AddressLinked",
325
+ data: JSON.Object(Dict.make()),
326
+ tags: [tag("addressId", "a1"), tag("customerId", "c1")],
327
+ meta: testMeta(),
328
+ }
329
+ let cond: Reventless.DcbTag.appendCondition = {
330
+ query: [{tags: [tag("customerId", "c1")], eventTypes: ["AddressLinked"]}],
331
+ after: "50",
332
+ }
333
+ let ids =
334
+ Runtime.buildConditionalTransactItems(table, [linked], cond, "100", ~partitionTag)
335
+ ->Array.filterMap(
336
+ it =>
337
+ switch (it.put, it.update) {
338
+ | (Some(p), _) => p.item->JSON.Decode.object->Option.flatMap(i => i->Dict.get("id"))
339
+ | (_, Some(u)) => u.key->Dict.get("id")
340
+ | _ => None
341
+ },
342
+ )
343
+ ->Array.filterMap(JSON.Decode.string)
344
+ expect(ids->Array.includes("customerId:c1"))->toBe(true)
345
+ expect(ids->Array.includes("fence#customerId:c1"))->toBe(true)
346
+ expect(ids->Array.some(id => id->String.includes("addressId")))->toBe(false)
347
+ })
348
+ })
349
+
262
350
  describe("Runtime.buildEventPuts", () => {
263
351
  let event = (eventType, tags): ReventlessCore.DcbEventLog_Adapter.rawStoredEvent => {
264
352
  eventType,