@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.
- package/CHANGELOG.md +26 -0
- package/package.json +17 -13
- package/rescript.json +2 -1
- package/run-bake-manifest.mjs +3 -0
- package/run-deploy-app.mjs +3 -0
- package/scripts/BakeManifest.res +395 -0
- package/scripts/BakeManifest.res.mjs +487 -0
- package/scripts/DeployApp.res +708 -0
- package/scripts/DeployApp.res.mjs +1015 -0
- package/scripts/DeployManifest.res +84 -0
- package/scripts/DeployManifest.res.mjs +112 -0
- package/scripts/ProvisionAccounts.res +60 -46
- package/scripts/ProvisionAccounts.res.mjs +51 -37
- package/scripts/PulumiCli.res +43 -0
- package/scripts/PulumiCli.res.mjs +106 -0
- package/src/Platform.res +19 -2
- package/src/Platform.res.mjs +12 -4
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +1 -5
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +1 -1
- package/src/adapter/Api/Platform_UIFragments_Lambda.res +1 -5
- package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +1 -2
- package/src/adapter/Auth/Auth_ActiveRoleStore.res +1 -5
- package/src/adapter/Auth/Auth_ActiveRoleStore.res.mjs +1 -2
- package/src/adapter/Auth/Auth_ActiveRoleTrigger.res +1 -5
- package/src/adapter/Auth/Auth_ActiveRoleTrigger.res.mjs +1 -2
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res +52 -31
- package/src/adapter/DcbEventLog/DcbEventLogStorage_DynamoDb_Runtime.res.mjs +75 -44
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res +1 -5
- package/src/adapter/EventLogSubscription/EventLogSubscription_AppSync.res.mjs +1 -2
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res +1 -5
- package/src/adapter/Geocoder/Geocoder_AwsLocation_Resolver.res.mjs +1 -2
- package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res +5 -4
- package/src/adapter/Postgres/PgChangeFeedRelay_Runtime.res.mjs +1 -1
- package/src/adapter/Postgres/PgMigration_Builder.res +1 -1
- package/src/adapter/Postgres/PgMigration_Builder.res.mjs +1 -2
- package/src/adapter/Runtime/CommandGeneratorEntryPoint_Ops.res +2 -0
- package/src/adapter/Runtime/CommandGeneratorEntryPoint_Ops.res.mjs +2 -2
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint.mjs +15 -6
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res +51 -30
- package/src/adapter/Runtime/DcbCommandTopicEntryPoint_Ops.res.mjs +31 -14
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res +1 -5
- package/src/adapter/Runtime/RuntimeEnvironment_Lambda.res.mjs +1 -1
- package/src/adapter/StateTopic/StateTopic_AppSync.res +1 -5
- package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +1 -1
- package/src/adapter/Task/TaskBucket_S3.res +1 -3
- package/src/adapter/Task/TaskBucket_S3.res.mjs +1 -1
- package/src/adapter/Upload/Upload_Claim_S3.res +1 -5
- package/src/adapter/Upload/Upload_Claim_S3.res.mjs +1 -1
- package/src/adapter/Upload/Upload_Presign_S3.res +1 -5
- package/src/adapter/Upload/Upload_Presign_S3.res.mjs +1 -2
- package/src/capability/Capability_Geocoding_AwsLocation.res +5 -1
- package/src/capability/Capability_Geocoding_AwsLocation.res.mjs +7 -1
- package/src/components/Api/AppSync_SdlDecorate.res +4 -2
- package/src/components/Api/AppSync_SdlDecorate.res.mjs +1 -1
- package/src/plugin/cloner/ClonerRunner_Fargate.res +1 -5
- package/src/plugin/cloner/ClonerRunner_Fargate.res.mjs +1 -1
- package/src/plugin/stack/Plugin_Stack.res +4 -0
- package/src/plugin/stack/Plugin_Stack.res.mjs +2 -0
- package/src/util/Util_DeadLetterQueue.res +1 -5
- package/src/util/Util_DeadLetterQueue.res.mjs +1 -2
- package/src/util/Util_StoreLayout.res +15 -2
- package/src/util/Util_StoreLayout.res.mjs +11 -3
- package/tests/BakeManifestTest.res +204 -0
- package/tests/BakeManifestTest.res.mjs +259 -0
- package/tests/DcbColdStartPartitionTest.res +32 -0
- package/tests/DcbColdStartPartitionTest.res.mjs +33 -0
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res +88 -0
- package/tests/DcbEventLogStorage_DynamoDb_RuntimeTest.res.mjs +112 -0
- package/tests/DcbUnresolvedPartitionSlice.res +28 -0
- package/tests/DcbUnresolvedPartitionSlice.res.mjs +38 -0
- package/tests/DeployAppTest.res +90 -0
- package/tests/DeployAppTest.res.mjs +133 -0
- package/tests/LambdaLayerLookupTest.res +104 -0
- package/tests/LambdaLayerLookupTest.res.mjs +117 -0
- package/tests/ProvisionAccountsTest.res +26 -0
- package/tests/ProvisionAccountsTest.res.mjs +31 -0
- package/tests/Util_StoreLayoutTest.res +6 -0
- package/tests/Util_StoreLayoutTest.res.mjs +7 -4
- package/tests/dcbColdStartPartition.mjs +66 -0
- 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
|
-
/*
|
|
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,
|