@reventlessdev/reventless-aws 3.0.0-alpha.301 → 3.0.0-alpha.302
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 +10 -0
- package/package.json +8 -8
- package/src/Platform.res +18 -4
- package/src/Platform.res.mjs +24 -4
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res +39 -4
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda.res.mjs +34 -13
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res +87 -20
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res.mjs +79 -17
- package/src/util/Util_DynamoDbStream.res +15 -4
- package/src/util/Util_DynamoDbStream.res.mjs +15 -5
- package/src/util/Util_ShellConfig.res +22 -2
- package/src/util/Util_ShellConfig.res.mjs +11 -0
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res +46 -0
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res.mjs +31 -0
- package/tests/Util_DynamoDbStreamTest.res +43 -0
- package/tests/Util_DynamoDbStreamTest.res.mjs +41 -0
- package/tests/Util_ShellConfigTest.res +107 -0
- package/tests/Util_ShellConfigTest.res.mjs +100 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,16 @@
|
|
|
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.302 (2026-08-15)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **aws:** let a stream resource answer for its own stream ([4617773](https://github.com/ReventlessDev/reventless-core/commit/461777349f10ebc83085d59907bac5b5537429ed))
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
* **aws:** serve one baked manifest per journey ([16d6307](https://github.com/ReventlessDev/reventless-core/commit/16d63071f059cf1fc7025f9be9206dda51f71053))
|
|
14
|
+
|
|
15
|
+
|
|
6
16
|
# 3.0.0-alpha.301 (2026-08-15)
|
|
7
17
|
|
|
8
18
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-aws",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.302",
|
|
4
4
|
"description": "AWS adapters for Reventless",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"dependencies": {
|
|
@@ -12,18 +12,18 @@
|
|
|
12
12
|
"@aws-sdk/s3-request-presigner": "3.970.0",
|
|
13
13
|
"sury": "11.0.0-alpha.4",
|
|
14
14
|
"uuid": "^13.0.0",
|
|
15
|
-
"@reventlessdev/rescript-
|
|
15
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
16
16
|
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
17
17
|
"@reventlessdev/rescript-node": "2.0.0-alpha.7",
|
|
18
|
-
"@reventlessdev/rescript-
|
|
18
|
+
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.11",
|
|
19
19
|
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.76",
|
|
20
|
+
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
|
|
20
21
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
21
|
-
"@reventlessdev/reventless-
|
|
22
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.142",
|
|
23
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.236",
|
|
22
24
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.31",
|
|
23
|
-
"@reventlessdev/
|
|
24
|
-
"@reventlessdev/reventless-
|
|
25
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.114",
|
|
26
|
-
"@reventlessdev/reventless-infra": "3.0.0-alpha.142"
|
|
25
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.100",
|
|
26
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.114"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"rescript": "12.3.0",
|
package/src/Platform.res
CHANGED
|
@@ -2221,9 +2221,14 @@ module MakeWithConfig = (
|
|
|
2221
2221
|
// target without reading either out of the deploy program.
|
|
2222
2222
|
switch (componentDefinitions, cfg.bakedManifest) {
|
|
2223
2223
|
| (Some(fn), Some(bake)) =>
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2224
|
+
// Every file the declaration produces: the default journey's, which is
|
|
2225
|
+
// what the shell's `manifestUrl` points at, and one per declared journey.
|
|
2226
|
+
// Resolved through the same function the bake and `config.json` use, so
|
|
2227
|
+
// a grant cannot be scoped to a key nothing writes.
|
|
2228
|
+
let manifestKeys =
|
|
2229
|
+
ReventlessCore.Platform_BakedManifest.files(~config=bake)->Array.map(((key, _)) => key)
|
|
2230
|
+
let manifestKey =
|
|
2231
|
+
manifestKeys->Array.get(0)->Option.getOr(ReventlessCore.Platform_BakedManifest.defaultKey)
|
|
2227
2232
|
let _ =
|
|
2228
2233
|
(fn.roleId, bucketName)
|
|
2229
2234
|
->Pulumi.Output.all2
|
|
@@ -2239,7 +2244,16 @@ module MakeWithConfig = (
|
|
|
2239
2244
|
sid: "AllowWriteBakedManifest",
|
|
2240
2245
|
effect: Allow,
|
|
2241
2246
|
actions: Actions(["s3:PutObject"]),
|
|
2242
|
-
|
|
2247
|
+
// A single key stays a bare `Resource` string: the two
|
|
2248
|
+
// forms mean the same thing to IAM, and a deployment that
|
|
2249
|
+
// declares no journey should not see its policy rewritten
|
|
2250
|
+
// for a field it does not use.
|
|
2251
|
+
resources: switch manifestKeys->Array.map(key =>
|
|
2252
|
+
`arn:aws:s3:::${bucket}/${key}`
|
|
2253
|
+
) {
|
|
2254
|
+
| [only] => Resource(only)
|
|
2255
|
+
| arns => Resources(arns)
|
|
2256
|
+
},
|
|
2243
2257
|
},
|
|
2244
2258
|
],
|
|
2245
2259
|
)
|
package/src/Platform.res.mjs
CHANGED
|
@@ -1210,17 +1210,27 @@ function MakeWithConfig(Config) {
|
|
|
1210
1210
|
}
|
|
1211
1211
|
let match$5 = hostUiBundle.bakedManifest;
|
|
1212
1212
|
if (componentDefinitions !== undefined && match$5 !== undefined) {
|
|
1213
|
-
let
|
|
1213
|
+
let manifestKeys = Platform_BakedManifest$ReventlessCore.files(match$5).map(param => param[0]);
|
|
1214
|
+
let manifestKey = Stdlib_Option.getOr(manifestKeys[0], Platform_BakedManifest$ReventlessCore.defaultKey);
|
|
1214
1215
|
Pulumi.all([
|
|
1215
1216
|
componentDefinitions.roleId,
|
|
1216
1217
|
bucketName
|
|
1217
1218
|
]).apply(param => {
|
|
1219
|
+
let bucket = param[1];
|
|
1220
|
+
let arns = manifestKeys.map(key => `arn:aws:s3:::` + bucket + `/` + key);
|
|
1221
|
+
let tmp;
|
|
1222
|
+
if (arns.length !== 1) {
|
|
1223
|
+
tmp = arns;
|
|
1224
|
+
} else {
|
|
1225
|
+
let only = arns[0];
|
|
1226
|
+
tmp = only;
|
|
1227
|
+
}
|
|
1218
1228
|
new (Aws.iam.RolePolicy)("PlatformUIDefinitionsBake", {
|
|
1219
1229
|
policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, "PlatformUIDefinitionsBakePolicy", [{
|
|
1220
1230
|
Sid: "AllowWriteBakedManifest",
|
|
1221
1231
|
Effect: "Allow",
|
|
1222
1232
|
Action: ["s3:PutObject"],
|
|
1223
|
-
Resource:
|
|
1233
|
+
Resource: tmp
|
|
1224
1234
|
}])),
|
|
1225
1235
|
role: param[0]
|
|
1226
1236
|
});
|
|
@@ -2490,17 +2500,27 @@ function Make($star) {
|
|
|
2490
2500
|
}
|
|
2491
2501
|
let match$5 = hostUiBundle.bakedManifest;
|
|
2492
2502
|
if (componentDefinitions !== undefined && match$5 !== undefined) {
|
|
2493
|
-
let
|
|
2503
|
+
let manifestKeys = Platform_BakedManifest$ReventlessCore.files(match$5).map(param => param[0]);
|
|
2504
|
+
let manifestKey = Stdlib_Option.getOr(manifestKeys[0], Platform_BakedManifest$ReventlessCore.defaultKey);
|
|
2494
2505
|
Pulumi.all([
|
|
2495
2506
|
componentDefinitions.roleId,
|
|
2496
2507
|
bucketName
|
|
2497
2508
|
]).apply(param => {
|
|
2509
|
+
let bucket = param[1];
|
|
2510
|
+
let arns = manifestKeys.map(key => `arn:aws:s3:::` + bucket + `/` + key);
|
|
2511
|
+
let tmp;
|
|
2512
|
+
if (arns.length !== 1) {
|
|
2513
|
+
tmp = arns;
|
|
2514
|
+
} else {
|
|
2515
|
+
let only = arns[0];
|
|
2516
|
+
tmp = only;
|
|
2517
|
+
}
|
|
2498
2518
|
new (Aws.iam.RolePolicy)("PlatformUIDefinitionsBake", {
|
|
2499
2519
|
policy: PolicyDocument$PulumiAws.toJsonString(PolicyDocument$PulumiAws.make(undefined, "PlatformUIDefinitionsBakePolicy", [{
|
|
2500
2520
|
Sid: "AllowWriteBakedManifest",
|
|
2501
2521
|
Effect: "Allow",
|
|
2502
2522
|
Action: ["s3:PutObject"],
|
|
2503
|
-
Resource:
|
|
2523
|
+
Resource: tmp
|
|
2504
2524
|
}])),
|
|
2505
2525
|
role: param[0]
|
|
2506
2526
|
});
|
|
@@ -141,10 +141,8 @@ let make = (
|
|
|
141
141
|
// as a record so the deploy input and the runtime input are the same shape —
|
|
142
142
|
// `views`/`commands` absent means "every public component", which a record
|
|
143
143
|
// with explicit nulls would not say.
|
|
144
|
-
let
|
|
145
|
-
|
|
146
|
-
| Some(bake) =>
|
|
147
|
-
bake.components
|
|
144
|
+
let encodeSelections = (components: array<ReventlessCore.Platform_BakedManifest.selection>) =>
|
|
145
|
+
components
|
|
148
146
|
->Array.map(sel => {
|
|
149
147
|
let entry = Dict.fromArray([("plugin", JSON.Encode.string(sel.plugin))])
|
|
150
148
|
let strings = (key, value) =>
|
|
@@ -157,6 +155,39 @@ let make = (
|
|
|
157
155
|
entry->JSON.Encode.object
|
|
158
156
|
})
|
|
159
157
|
->JSON.Encode.array
|
|
158
|
+
|
|
159
|
+
let bakeSelectionsJson = switch bakedManifest {
|
|
160
|
+
| None => ""
|
|
161
|
+
| Some(bake) =>
|
|
162
|
+
bake.components
|
|
163
|
+
->Array.map(ReventlessCore.Platform_BakedManifest.toSelection)
|
|
164
|
+
->encodeSelections
|
|
165
|
+
->JSON.stringify
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// The journeys, in a variable of their own rather than folded into the one
|
|
169
|
+
// above: a deployment that declares none then puts the same string in the same
|
|
170
|
+
// place it always did, and its function is not replaced for a field it does
|
|
171
|
+
// not use.
|
|
172
|
+
//
|
|
173
|
+
// Each carries the key the DECLARATION resolves to, taken from the function
|
|
174
|
+
// that also decides the IAM grant and the URL `config.json` publishes. A key
|
|
175
|
+
// the handler derived for itself would be a fourth derivation of one name, and
|
|
176
|
+
// the way it fails is a file written where nothing fetches it.
|
|
177
|
+
let bakeJourneysJson = switch bakedManifest->Option.mapOr([], config =>
|
|
178
|
+
ReventlessCore.Platform_BakedManifest.journeyFiles(~config)
|
|
179
|
+
) {
|
|
180
|
+
| [] => ""
|
|
181
|
+
| journeys =>
|
|
182
|
+
journeys
|
|
183
|
+
->Array.map(j =>
|
|
184
|
+
Dict.fromArray([
|
|
185
|
+
("group", JSON.Encode.string(j.group)),
|
|
186
|
+
("key", JSON.Encode.string(j.key)),
|
|
187
|
+
("components", j.selections->encodeSelections),
|
|
188
|
+
])->JSON.Encode.object
|
|
189
|
+
)
|
|
190
|
+
->JSON.Encode.array
|
|
160
191
|
->JSON.stringify
|
|
161
192
|
}
|
|
162
193
|
|
|
@@ -226,6 +257,10 @@ let make = (
|
|
|
226
257
|
// An include-list is names only, so it stays far from the 4096-byte
|
|
227
258
|
// total the admin entry already pushed out of this dict.
|
|
228
259
|
("BAKE_SELECTIONS", bakeSelectionsJson->Pulumi.Input.make),
|
|
260
|
+
// One curated surface per audience beside the default one. Empty for
|
|
261
|
+
// a deployment that declares none, which is every deployment that
|
|
262
|
+
// predates journeys.
|
|
263
|
+
("BAKE_JOURNEYS", bakeJourneysJson->Pulumi.Input.make),
|
|
229
264
|
("NODE_OPTIONS", Util_Bundle.esmLoaderNodeOptions->Pulumi.Input.make),
|
|
230
265
|
("ESM_FALLBACK_DIRS", Util_Bundle.esmFallbackDirs->Pulumi.Input.make),
|
|
231
266
|
Util_LambdaLogging.logLevelEntry(),
|
|
@@ -13,6 +13,7 @@ import * as Util_Bundle$ReventlessAws from "../../util/Util_Bundle.res.mjs";
|
|
|
13
13
|
import * as Util_Pulumi$ReventlessCore from "@reventlessdev/reventless-core/src/util/Util_Pulumi.res.mjs";
|
|
14
14
|
import * as Util_LambdaLogging$ReventlessAws from "../../util/Util_LambdaLogging.res.mjs";
|
|
15
15
|
import * as AppSync_Resolver_Native$ReventlessAws from "./AppSync_Resolver_Native.res.mjs";
|
|
16
|
+
import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
|
|
16
17
|
import * as Platform_Admin_Structure$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_Admin_Structure.res.mjs";
|
|
17
18
|
import * as Platform_ComponentDefinitionsApi$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_ComponentDefinitionsApi.res.mjs";
|
|
18
19
|
|
|
@@ -77,19 +78,35 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, bak
|
|
|
77
78
|
role: lambdaRole.id
|
|
78
79
|
}, opts$1);
|
|
79
80
|
});
|
|
80
|
-
let
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
81
|
+
let encodeSelections = components => components.map(sel => {
|
|
82
|
+
let entry = Object.fromEntries([[
|
|
83
|
+
"plugin",
|
|
84
|
+
sel.plugin
|
|
85
|
+
]]);
|
|
86
|
+
let strings = (key, value) => Stdlib_Option.forEach(value, names => {
|
|
87
|
+
entry[key] = names.map(prim => prim);
|
|
88
|
+
});
|
|
89
|
+
strings("views", sel.views);
|
|
90
|
+
strings("commands", sel.commands);
|
|
91
|
+
strings("derived", sel.derived);
|
|
92
|
+
return entry;
|
|
93
|
+
});
|
|
94
|
+
let bakeSelectionsJson = bakedManifest !== undefined ? JSON.stringify(encodeSelections(bakedManifest.components.map(Platform_BakedManifest$ReventlessCore.toSelection))) : "";
|
|
95
|
+
let journeys = Stdlib_Option.mapOr(bakedManifest, [], Platform_BakedManifest$ReventlessCore.journeyFiles);
|
|
96
|
+
let bakeJourneysJson = journeys.length !== 0 ? JSON.stringify(journeys.map(j => Object.fromEntries([
|
|
97
|
+
[
|
|
98
|
+
"group",
|
|
99
|
+
j.group
|
|
100
|
+
],
|
|
101
|
+
[
|
|
102
|
+
"key",
|
|
103
|
+
j.key
|
|
104
|
+
],
|
|
105
|
+
[
|
|
106
|
+
"components",
|
|
107
|
+
encodeSelections(j.selections)
|
|
108
|
+
]
|
|
109
|
+
]))) : "";
|
|
93
110
|
let adminEntryJson = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry(Platform_Admin_Structure$ReventlessCore.pluginId, undefined, Platform_Admin_Structure$ReventlessCore.structure));
|
|
94
111
|
let packageDirs = Object.fromEntries([[
|
|
95
112
|
"@reventlessdev/reventless-aws",
|
|
@@ -128,6 +145,10 @@ function make(api, pluginReadModelTableName, offloadBucketName, schemaReady, bak
|
|
|
128
145
|
"BAKE_SELECTIONS",
|
|
129
146
|
bakeSelectionsJson
|
|
130
147
|
],
|
|
148
|
+
[
|
|
149
|
+
"BAKE_JOURNEYS",
|
|
150
|
+
bakeJourneysJson
|
|
151
|
+
],
|
|
131
152
|
[
|
|
132
153
|
"NODE_OPTIONS",
|
|
133
154
|
Util_Bundle$ReventlessAws.esmLoaderNodeOptions
|
|
@@ -288,6 +288,18 @@ let structureOf = (item: dict<JSON.t>, ~name as _: string): option<(
|
|
|
288
288
|
| _ => None
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
// One curated surface per audience, beside the default one. Only what the write
|
|
292
|
+
// needs travels: the file's key, resolved by the deploy so that the grant, the
|
|
293
|
+
// URL in `config.json` and the object written here are one string, and the
|
|
294
|
+
// include-list that decides its contents. The group the journey serves is
|
|
295
|
+
// carried too, purely so the invocation's report names the audience a file was
|
|
296
|
+
// written for.
|
|
297
|
+
type journey = {
|
|
298
|
+
group: string,
|
|
299
|
+
key: string,
|
|
300
|
+
selections: array<ReventlessCore.Platform_BakedManifest.selection>,
|
|
301
|
+
}
|
|
302
|
+
|
|
291
303
|
let bakeSelection = (json: JSON.t): option<ReventlessCore.Platform_BakedManifest.selection> => {
|
|
292
304
|
let strings = (o, key) =>
|
|
293
305
|
o
|
|
@@ -309,6 +321,9 @@ let bakeSelection = (json: JSON.t): option<ReventlessCore.Platform_BakedManifest
|
|
|
309
321
|
)
|
|
310
322
|
}
|
|
311
323
|
|
|
324
|
+
let decodeSelections = (json: JSON.t): array<ReventlessCore.Platform_BakedManifest.selection> =>
|
|
325
|
+
json->JSON.Decode.array->Option.getOr([])->Array.filterMap(bakeSelection)
|
|
326
|
+
|
|
312
327
|
// The declaration travels as an env var because it is deploy input — stated once
|
|
313
328
|
// in the platform program, beside the bucket it is written to. The target travels
|
|
314
329
|
// in the invocation payload instead: the bucket only exists inside the host-UI
|
|
@@ -319,14 +334,44 @@ let bakeSelections = (): array<ReventlessCore.Platform_BakedManifest.selection>
|
|
|
319
334
|
switch NodeProcess.env->Dict.get("BAKE_SELECTIONS") {
|
|
320
335
|
| None | Some("") => []
|
|
321
336
|
| Some(raw) =>
|
|
322
|
-
switch raw->JSON.parseOrThrow
|
|
323
|
-
|
|
|
324
|
-
| None => []
|
|
337
|
+
switch raw->JSON.parseOrThrow {
|
|
338
|
+
| json => json->decodeSelections
|
|
325
339
|
| exception _ =>
|
|
326
340
|
JsError.throwWithMessage("baked manifest: BAKE_SELECTIONS is not a JSON array")
|
|
327
341
|
}
|
|
328
342
|
}
|
|
329
343
|
|
|
344
|
+
let bakeJourney = (json: JSON.t): option<journey> =>
|
|
345
|
+
json
|
|
346
|
+
->JSON.Decode.object
|
|
347
|
+
->Option.flatMap(o =>
|
|
348
|
+
switch (
|
|
349
|
+
o->Dict.get("group")->Option.flatMap(JSON.Decode.string),
|
|
350
|
+
o->Dict.get("key")->Option.flatMap(JSON.Decode.string),
|
|
351
|
+
) {
|
|
352
|
+
| (Some(group), Some(key)) =>
|
|
353
|
+
Some({
|
|
354
|
+
group,
|
|
355
|
+
key,
|
|
356
|
+
selections: o->Dict.get("components")->Option.mapOr([], decodeSelections),
|
|
357
|
+
})
|
|
358
|
+
| _ => None
|
|
359
|
+
}
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
// Absent for every deployment that declares one audience, which is what the
|
|
363
|
+
// declaration meant before journeys existed and what it still means.
|
|
364
|
+
let bakeJourneys = (): array<journey> =>
|
|
365
|
+
switch NodeProcess.env->Dict.get("BAKE_JOURNEYS") {
|
|
366
|
+
| None | Some("") => []
|
|
367
|
+
| Some(raw) =>
|
|
368
|
+
switch raw->JSON.parseOrThrow->JSON.Decode.array {
|
|
369
|
+
| Some(entries) => entries->Array.filterMap(bakeJourney)
|
|
370
|
+
| None => []
|
|
371
|
+
| exception _ => JsError.throwWithMessage("baked manifest: BAKE_JOURNEYS is not a JSON array")
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
330
375
|
type bakeTarget = {bucket: string, key: string}
|
|
331
376
|
|
|
332
377
|
let bakeTargetOf = (event: JSON.t): option<bakeTarget> =>
|
|
@@ -417,27 +462,49 @@ let runBake = async (
|
|
|
417
462
|
"so there is nothing to write and a shell pointed at the file would find none.",
|
|
418
463
|
)
|
|
419
464
|
}
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
465
|
+
// The default journey first and always — the caller supplies its key, because
|
|
466
|
+
// the caller is what knows where the shell it configured fetches from — then
|
|
467
|
+
// one file per declared journey, under the key the deploy resolved.
|
|
468
|
+
let files = Array.concat(
|
|
469
|
+
[(None, target.key, selections)],
|
|
470
|
+
bakeJourneys()->Array.map(j => (Some(j.group), j.key, j.selections)),
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
// Every file is curated before any is written, for the reason the in-memory
|
|
474
|
+
// platform curates before it writes: a declaration naming a component that
|
|
475
|
+
// does not exist fails the bake, and failing it halfway would leave the bucket
|
|
476
|
+
// serving one audience's file beside a stale copy of another's.
|
|
477
|
+
let curated = files->Array.map(((group, key, selections)) => (
|
|
478
|
+
group,
|
|
479
|
+
key,
|
|
480
|
+
selections,
|
|
481
|
+
switch ReventlessCore.Platform_BakedManifest.curate(~structures, ~selections) {
|
|
482
|
+
| Error(e) => JsError.throwWithMessage(ReventlessCore.Platform_BakedManifest.describe(e))
|
|
483
|
+
| Ok(manifest) => JSON.stringify(manifest, ~space=2)
|
|
484
|
+
},
|
|
485
|
+
))
|
|
486
|
+
|
|
487
|
+
await Promise.all(
|
|
488
|
+
curated->Array.map(async ((group, key, selections, body)) => {
|
|
489
|
+
let _ = await AwsSdk.S3.PutObjectCommand.make({
|
|
490
|
+
bucket: target.bucket,
|
|
491
|
+
key,
|
|
492
|
+
body: AwsSdk.S3.PutObjectCommand.bodyFromString(body),
|
|
493
|
+
contentType: "application/json",
|
|
494
|
+
})->AwsSdk.S3.PutObjectCommand.send
|
|
495
|
+
let report = Dict.fromArray([
|
|
433
496
|
("baked", JSON.Encode.bool(true)),
|
|
434
497
|
("bucket", JSON.Encode.string(target.bucket)),
|
|
435
|
-
("key", JSON.Encode.string(
|
|
498
|
+
("key", JSON.Encode.string(key)),
|
|
436
499
|
("plugins", JSON.Encode.int(selections->Array.length)),
|
|
437
500
|
("bytes", JSON.Encode.int(body->String.length)),
|
|
438
|
-
])
|
|
439
|
-
|
|
440
|
-
|
|
501
|
+
])
|
|
502
|
+
// Only a journey's entry carries the audience it was written for, so the
|
|
503
|
+
// default one reports exactly what it reported before journeys existed.
|
|
504
|
+
group->Option.forEach(g => report->Dict.set("group", JSON.Encode.string(g)))
|
|
505
|
+
report->JSON.Encode.object
|
|
506
|
+
}),
|
|
507
|
+
)
|
|
441
508
|
}
|
|
442
509
|
|
|
443
510
|
let handler = async (event: JSON.t): array<JSON.t> => {
|
|
@@ -268,6 +268,10 @@ function bakeSelection(json) {
|
|
|
268
268
|
})));
|
|
269
269
|
}
|
|
270
270
|
|
|
271
|
+
function decodeSelections(json) {
|
|
272
|
+
return Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_JSON.Decode.array(json), []), bakeSelection);
|
|
273
|
+
}
|
|
274
|
+
|
|
271
275
|
function bakeSelections() {
|
|
272
276
|
let raw = process.env["BAKE_SELECTIONS"];
|
|
273
277
|
if (raw === undefined) {
|
|
@@ -276,14 +280,45 @@ function bakeSelections() {
|
|
|
276
280
|
if (raw === "") {
|
|
277
281
|
return [];
|
|
278
282
|
}
|
|
283
|
+
let json;
|
|
284
|
+
try {
|
|
285
|
+
json = JSON.parse(raw);
|
|
286
|
+
} catch (exn) {
|
|
287
|
+
return Stdlib_JsError.throwWithMessage("baked manifest: BAKE_SELECTIONS is not a JSON array");
|
|
288
|
+
}
|
|
289
|
+
return decodeSelections(json);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function bakeJourney(json) {
|
|
293
|
+
return Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(json), o => {
|
|
294
|
+
let match = Stdlib_Option.flatMap(o["group"], Stdlib_JSON.Decode.string);
|
|
295
|
+
let match$1 = Stdlib_Option.flatMap(o["key"], Stdlib_JSON.Decode.string);
|
|
296
|
+
if (match !== undefined && match$1 !== undefined) {
|
|
297
|
+
return {
|
|
298
|
+
group: match,
|
|
299
|
+
key: match$1,
|
|
300
|
+
selections: Stdlib_Option.mapOr(o["components"], [], decodeSelections)
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function bakeJourneys() {
|
|
307
|
+
let raw = process.env["BAKE_JOURNEYS"];
|
|
308
|
+
if (raw === undefined) {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
if (raw === "") {
|
|
312
|
+
return [];
|
|
313
|
+
}
|
|
279
314
|
let entries;
|
|
280
315
|
try {
|
|
281
316
|
entries = Stdlib_JSON.Decode.array(JSON.parse(raw));
|
|
282
317
|
} catch (exn) {
|
|
283
|
-
return Stdlib_JsError.throwWithMessage("baked manifest:
|
|
318
|
+
return Stdlib_JsError.throwWithMessage("baked manifest: BAKE_JOURNEYS is not a JSON array");
|
|
284
319
|
}
|
|
285
320
|
if (entries !== undefined) {
|
|
286
|
-
return Stdlib_Array.filterMap(entries,
|
|
321
|
+
return Stdlib_Array.filterMap(entries, bakeJourney);
|
|
287
322
|
} else {
|
|
288
323
|
return [];
|
|
289
324
|
}
|
|
@@ -336,18 +371,37 @@ async function runBake(target, structures) {
|
|
|
336
371
|
if (selections.length === 0) {
|
|
337
372
|
Stdlib_JsError.throwWithMessage("baked manifest: BAKE_SELECTIONS is empty — this platform declares no bake, so there is nothing to write and a shell pointed at the file would find none.");
|
|
338
373
|
}
|
|
339
|
-
let
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
374
|
+
let files = [[
|
|
375
|
+
undefined,
|
|
376
|
+
target.key,
|
|
377
|
+
selections
|
|
378
|
+
]].concat(bakeJourneys().map(j => [
|
|
379
|
+
j.group,
|
|
380
|
+
j.key,
|
|
381
|
+
j.selections
|
|
382
|
+
]));
|
|
383
|
+
let curated = files.map(param => {
|
|
384
|
+
let selections = param[2];
|
|
385
|
+
let e = Platform_BakedManifest$ReventlessCore.curate(structures, selections);
|
|
386
|
+
let tmp;
|
|
387
|
+
tmp = e.TAG === "Ok" ? JSON.stringify(e._0, undefined, 2) : Stdlib_JsError.throwWithMessage(Platform_BakedManifest$ReventlessCore.describe(e._0));
|
|
388
|
+
return [
|
|
389
|
+
param[0],
|
|
390
|
+
param[1],
|
|
391
|
+
selections,
|
|
392
|
+
tmp
|
|
393
|
+
];
|
|
394
|
+
});
|
|
395
|
+
return await Promise.all(curated.map(async param => {
|
|
396
|
+
let body = param[3];
|
|
397
|
+
let key = param[1];
|
|
398
|
+
await S3$AwsSdk.PutObjectCommand.send(new ClientS3.PutObjectCommand({
|
|
399
|
+
Bucket: target.bucket,
|
|
400
|
+
Key: key,
|
|
401
|
+
Body: body,
|
|
402
|
+
ContentType: "application/json"
|
|
403
|
+
}));
|
|
404
|
+
let report = Object.fromEntries([
|
|
351
405
|
[
|
|
352
406
|
"baked",
|
|
353
407
|
true
|
|
@@ -358,17 +412,22 @@ async function runBake(target, structures) {
|
|
|
358
412
|
],
|
|
359
413
|
[
|
|
360
414
|
"key",
|
|
361
|
-
|
|
415
|
+
key
|
|
362
416
|
],
|
|
363
417
|
[
|
|
364
418
|
"plugins",
|
|
365
|
-
|
|
419
|
+
param[2].length
|
|
366
420
|
],
|
|
367
421
|
[
|
|
368
422
|
"bytes",
|
|
369
423
|
body.length
|
|
370
424
|
]
|
|
371
|
-
])
|
|
425
|
+
]);
|
|
426
|
+
Stdlib_Option.forEach(param[0], g => {
|
|
427
|
+
report["group"] = g;
|
|
428
|
+
});
|
|
429
|
+
return report;
|
|
430
|
+
}));
|
|
372
431
|
}
|
|
373
432
|
|
|
374
433
|
async function handler(event) {
|
|
@@ -433,7 +492,10 @@ export {
|
|
|
433
492
|
isComplete,
|
|
434
493
|
structureOf,
|
|
435
494
|
bakeSelection,
|
|
495
|
+
decodeSelections,
|
|
436
496
|
bakeSelections,
|
|
497
|
+
bakeJourney,
|
|
498
|
+
bakeJourneys,
|
|
437
499
|
bakeTargetOf,
|
|
438
500
|
bakeExpectations,
|
|
439
501
|
structureRefKey,
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
let toResourceInfo = (table: PulumiAws.DynamoDb.Table.t) =>
|
|
2
2
|
table.streamArn->Pulumi.Output.apply(streamArn => ReventlessInfra.Adapter.StreamSource({sourceUrn: streamArn}))
|
|
3
3
|
|
|
4
|
+
// The table name rides IN the apply rather than being read with `Output.get` on
|
|
5
|
+
// the failure arm: `get` throws "Cannot call '.get' during update or preview" on
|
|
6
|
+
// every deploy, so the arm meant to name the offending table replaced its own
|
|
7
|
+
// message with that one and hid which resource was missing a stream.
|
|
4
8
|
let streamArnFromDynamoDbTableResource = (resource: ReventlessInfra.Adapter.resource) =>
|
|
5
|
-
resource.resourceInfo
|
|
9
|
+
(resource.resourceInfo, resource.name)
|
|
10
|
+
->Pulumi.Output.all2
|
|
11
|
+
->Pulumi.Output.apply(((resourceInfo, tableName)) =>
|
|
6
12
|
switch resourceInfo {
|
|
7
13
|
| StreamSource({sourceUrn}) => sourceUrn
|
|
8
|
-
| _ =>
|
|
9
|
-
let tableName = resource.name->Pulumi.Output.get
|
|
10
|
-
JsError.throwWithMessage("No streamArn field given for table " ++ tableName)
|
|
14
|
+
| _ => JsError.throwWithMessage("No streamArn field given for table " ++ tableName)
|
|
11
15
|
}
|
|
12
16
|
)
|
|
13
17
|
|
|
@@ -30,6 +34,13 @@ let toStreamResource = (table: ReventlessInfra.Adapter.resource): ReventlessInfr
|
|
|
30
34
|
~id=streamArn,
|
|
31
35
|
~urn=streamArn,
|
|
32
36
|
~service=table.name->Pulumi.Output.apply(_ => AWS.DynamoDbStream.service),
|
|
37
|
+
// Carries the same StreamSource the table resource carries, so asking a
|
|
38
|
+
// stream resource for its ARN answers instead of throwing. The event-topic
|
|
39
|
+
// resources a publisher exports are these, and `Upload_Claim_S3` asks
|
|
40
|
+
// exactly that of resources[0].
|
|
41
|
+
~resourceInfo=streamArn->Pulumi.Output.apply(sourceUrn => ReventlessInfra.Adapter.StreamSource({
|
|
42
|
+
sourceUrn: sourceUrn,
|
|
43
|
+
})),
|
|
33
44
|
~resourceType="aws:dynamodb:Stream"->Pulumi.Output.make,
|
|
34
45
|
)
|
|
35
46
|
}
|
|
@@ -22,12 +22,19 @@ function toResourceInfo(table) {
|
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
function streamArnFromDynamoDbTableResource(resource) {
|
|
25
|
-
return
|
|
26
|
-
|
|
25
|
+
return Pulumi.all([
|
|
26
|
+
resource.resourceInfo,
|
|
27
|
+
resource.name
|
|
28
|
+
]).apply(param => {
|
|
29
|
+
let tableName = param[1];
|
|
30
|
+
let resourceInfo = param[0];
|
|
31
|
+
if (typeof resourceInfo !== "object") {
|
|
32
|
+
return Stdlib_JsError.throwWithMessage("No streamArn field given for table " + tableName);
|
|
33
|
+
} else if (resourceInfo.TAG === "StreamSource") {
|
|
27
34
|
return resourceInfo.sourceUrn;
|
|
35
|
+
} else {
|
|
36
|
+
return Stdlib_JsError.throwWithMessage("No streamArn field given for table " + tableName);
|
|
28
37
|
}
|
|
29
|
-
let tableName = resource.name.get();
|
|
30
|
-
return Stdlib_JsError.throwWithMessage("No streamArn field given for table " + tableName);
|
|
31
38
|
});
|
|
32
39
|
}
|
|
33
40
|
|
|
@@ -37,7 +44,10 @@ function toResource(tags, table) {
|
|
|
37
44
|
|
|
38
45
|
function toStreamResource(table) {
|
|
39
46
|
let streamArn = streamArnFromDynamoDbTableResource(table);
|
|
40
|
-
return Adapter$ReventlessInfra.make(table.name, streamArn, streamArn, table.name.apply(param => AWS$ReventlessAws.DynamoDbStream.service),
|
|
47
|
+
return Adapter$ReventlessInfra.make(table.name, streamArn, streamArn, table.name.apply(param => AWS$ReventlessAws.DynamoDbStream.service), streamArn.apply(sourceUrn => ({
|
|
48
|
+
TAG: "StreamSource",
|
|
49
|
+
sourceUrn: sourceUrn
|
|
50
|
+
})), undefined, undefined, Pulumi.output("aws:dynamodb:Stream"), undefined, undefined);
|
|
41
51
|
}
|
|
42
52
|
|
|
43
53
|
let log = Logger$ReventlessCore.fromEnv();
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
|
|
10
10
|
module Platform = ReventlessInfra.Platform
|
|
11
11
|
|
|
12
|
+
// Where a caller acting as a given role discovers from — a group→url map. Named
|
|
13
|
+
// here as the shell reads it, the same way `manifestUrl` is.
|
|
14
|
+
let journeyManifestsKey = "journeyManifestUrls"
|
|
15
|
+
|
|
12
16
|
// A mode's options, flattened to the wire shape. They are payloads of their arm
|
|
13
17
|
// on the deploy side (so `mapStyle` with the map off cannot be expressed) and
|
|
14
18
|
// flat siblings of `viewModes` on the wire (because that is where the released
|
|
@@ -52,12 +56,28 @@ let fields = (
|
|
|
52
56
|
// decides where the file goes — a passthrough could point the shell at a key
|
|
53
57
|
// nothing writes, and a statically-discovered shell has no admin API behind it
|
|
54
58
|
// to notice.
|
|
55
|
-
bakedManifest->Option.forEach(bake =>
|
|
59
|
+
bakedManifest->Option.forEach(bake => {
|
|
56
60
|
out->Dict.set(
|
|
57
61
|
"manifestUrl",
|
|
58
62
|
JSON.Encode.string(ReventlessCore.Platform_BakedManifest.urlForKey(bake.key)),
|
|
59
63
|
)
|
|
60
|
-
|
|
64
|
+
// Where a caller acting as a given role discovers from, beside the default
|
|
65
|
+
// rather than instead of it: `manifestUrl` stays the default journey, which
|
|
66
|
+
// is what a caller matching no declared group gets. Omitted entirely when
|
|
67
|
+
// nothing is declared, so a single-audience deployment writes the key set it
|
|
68
|
+
// always did and a shell that has never heard of journeys sees no new key.
|
|
69
|
+
switch ReventlessCore.Platform_BakedManifest.journeyUrls(~config=bake) {
|
|
70
|
+
| [] => ()
|
|
71
|
+
| urls =>
|
|
72
|
+
out->Dict.set(
|
|
73
|
+
journeyManifestsKey,
|
|
74
|
+
urls
|
|
75
|
+
->Array.map(((group, url)) => (group, JSON.Encode.string(url)))
|
|
76
|
+
->Dict.fromArray
|
|
77
|
+
->JSON.Encode.object,
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
61
81
|
|
|
62
82
|
switch viewModes {
|
|
63
83
|
| Some(modes) =>
|
|
@@ -6,6 +6,8 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
|
6
6
|
import * as Platform$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/Platform.res.mjs";
|
|
7
7
|
import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
|
|
8
8
|
|
|
9
|
+
let journeyManifestsKey = "journeyManifestUrls";
|
|
10
|
+
|
|
9
11
|
function modeOptions(mode) {
|
|
10
12
|
if (mode.TAG === "Map") {
|
|
11
13
|
let style = mode._0.style;
|
|
@@ -33,6 +35,14 @@ function fields(computed, viewModes, bakedManifest, shellConfig) {
|
|
|
33
35
|
let out = Object.fromEntries(computed);
|
|
34
36
|
Stdlib_Option.forEach(bakedManifest, bake => {
|
|
35
37
|
out["manifestUrl"] = Platform_BakedManifest$ReventlessCore.urlForKey(bake.key);
|
|
38
|
+
let urls = Platform_BakedManifest$ReventlessCore.journeyUrls(bake);
|
|
39
|
+
if (urls.length !== 0) {
|
|
40
|
+
out[journeyManifestsKey] = Object.fromEntries(urls.map(param => [
|
|
41
|
+
param[0],
|
|
42
|
+
param[1]
|
|
43
|
+
]));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
36
46
|
});
|
|
37
47
|
if (viewModes !== undefined) {
|
|
38
48
|
out["viewModes"] = viewModes.map(Platform$ReventlessInfra.viewModeToString);
|
|
@@ -58,6 +68,7 @@ let Platform;
|
|
|
58
68
|
|
|
59
69
|
export {
|
|
60
70
|
Platform,
|
|
71
|
+
journeyManifestsKey,
|
|
61
72
|
modeOptions,
|
|
62
73
|
fields,
|
|
63
74
|
}
|
|
@@ -241,6 +241,52 @@ describe("bakeSelection", () => {
|
|
|
241
241
|
)
|
|
242
242
|
})
|
|
243
243
|
|
|
244
|
+
// A journey travels from the deploy in the function's environment, exactly as the
|
|
245
|
+
// default include-list does, carrying the key the deploy resolved. Deriving the
|
|
246
|
+
// key a second time here would be a file written where `config.json` does not
|
|
247
|
+
// send anybody, so the handler only reads it.
|
|
248
|
+
describe("bakeJourney", () => {
|
|
249
|
+
let journey = raw => Platform_ComponentDefinitions_Lambda_Ops.bakeJourney(JSON.parseOrThrow(raw))
|
|
250
|
+
|
|
251
|
+
testSync("reads the group, the key and the include-list the deploy encoded", () => {
|
|
252
|
+
let decoded = journey(`{
|
|
253
|
+
"group": "Fulfilment",
|
|
254
|
+
"key": "component-manifest-fulfilment.json",
|
|
255
|
+
"components": [{"plugin": "Ordering", "views": ["Orders"], "derived": ["lifecycles"]}]
|
|
256
|
+
}`)
|
|
257
|
+
expect(decoded->Option.map(j => (j.group, j.key, j.selections->Array.map(s => s.plugin))))->toEqual(
|
|
258
|
+
Some(("Fulfilment", "component-manifest-fulfilment.json", ["Ordering"])),
|
|
259
|
+
)
|
|
260
|
+
expect(
|
|
261
|
+
decoded->Option.flatMap(j => j.selections->Array.get(0))->Option.flatMap(s => s.derived),
|
|
262
|
+
)->toEqual(Some(["lifecycles"]))
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
// Absent is not empty here either: a journey whose selection names no `views`
|
|
266
|
+
// takes every public view of that plugin.
|
|
267
|
+
testSync("keeps a selection's absent lists absent", () => {
|
|
268
|
+
let decoded = journey(`{"group": "Shopper", "key": "s.json", "components": [{"plugin": "Catalog"}]}`)
|
|
269
|
+
expect(
|
|
270
|
+
decoded->Option.flatMap(j => j.selections->Array.get(0))->Option.flatMap(s => s.views),
|
|
271
|
+
)->toEqual(None)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
// Both are what the write needs. A journey missing either would be baked to a
|
|
275
|
+
// key nobody granted or reported as belonging to no audience.
|
|
276
|
+
testSync("refuses an entry naming no group or no key", () => {
|
|
277
|
+
expect(journey(`{"key": "s.json", "components": []}`)->Option.isNone)->toBe(true)
|
|
278
|
+
expect(journey(`{"group": "Shopper", "components": []}`)->Option.isNone)->toBe(true)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
// A journey that curates nothing is still a journey — it writes an empty file
|
|
282
|
+
// rather than falling back to the default one.
|
|
283
|
+
testSync("a journey with no components decodes to an empty include-list", () =>
|
|
284
|
+
expect(
|
|
285
|
+
journey(`{"group": "Shopper", "key": "s.json"}`)->Option.map(j => j.selections->Array.length),
|
|
286
|
+
)->toEqual(Some(0))
|
|
287
|
+
)
|
|
288
|
+
})
|
|
289
|
+
|
|
244
290
|
// The bake reads a read model the deploy updates asynchronously, so "is this the
|
|
245
291
|
// deployment I was asked to bake" is a question it has to be able to answer. The
|
|
246
292
|
// answer is an equality check against the key each plugin stack just wrote.
|
|
@@ -154,6 +154,37 @@ globalThis.describe("bakeSelection", () => {
|
|
|
154
154
|
});
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
+
globalThis.describe("bakeJourney", () => {
|
|
158
|
+
globalThis.test("reads the group, the key and the include-list the deploy encoded", () => {
|
|
159
|
+
let decoded = Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{
|
|
160
|
+
"group": "Fulfilment",
|
|
161
|
+
"key": "component-manifest-fulfilment.json",
|
|
162
|
+
"components": [{"plugin": "Ordering", "views": ["Orders"], "derived": ["lifecycles"]}]
|
|
163
|
+
}`));
|
|
164
|
+
globalThis.expect(Stdlib_Option.map(decoded, j => [
|
|
165
|
+
j.group,
|
|
166
|
+
j.key,
|
|
167
|
+
j.selections.map(s => s.plugin)
|
|
168
|
+
])).toEqual([
|
|
169
|
+
"Fulfilment",
|
|
170
|
+
"component-manifest-fulfilment.json",
|
|
171
|
+
["Ordering"]
|
|
172
|
+
]);
|
|
173
|
+
globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(decoded, j => j.selections[0]), s => s.derived)).toEqual(["lifecycles"]);
|
|
174
|
+
});
|
|
175
|
+
globalThis.test("keeps a selection's absent lists absent", () => {
|
|
176
|
+
let decoded = Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"group": "Shopper", "key": "s.json", "components": [{"plugin": "Catalog"}]}`));
|
|
177
|
+
globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(decoded, j => j.selections[0]), s => s.views)).toEqual(undefined);
|
|
178
|
+
});
|
|
179
|
+
globalThis.test("refuses an entry naming no group or no key", () => {
|
|
180
|
+
globalThis.expect(Stdlib_Option.isNone(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"key": "s.json", "components": []}`)))).toBe(true);
|
|
181
|
+
globalThis.expect(Stdlib_Option.isNone(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"group": "Shopper", "components": []}`)))).toBe(true);
|
|
182
|
+
});
|
|
183
|
+
globalThis.test("a journey with no components decodes to an empty include-list", () => {
|
|
184
|
+
globalThis.expect(Stdlib_Option.map(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"group": "Shopper", "key": "s.json"}`)), j => j.selections.length)).toEqual(0);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
157
188
|
globalThis.describe("pendingRegistrations", () => {
|
|
158
189
|
let row = (name, key, param) => {
|
|
159
190
|
let item = Object.fromEntries([[
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
open JestGlobals
|
|
2
|
+
|
|
3
|
+
// Guards that a stream resource describes its own stream.
|
|
4
|
+
//
|
|
5
|
+
// `toStreamResource` used to leave `resourceInfo` at its `NoInfo` default even
|
|
6
|
+
// though it was built FROM a stream ARN. The event-topic resources a DynamoDB
|
|
7
|
+
// stream publisher exports are these, so every reader that asks an event topic
|
|
8
|
+
// for its stream ARN — `Upload_Claim_S3` does, to grant its Lambda
|
|
9
|
+
// `dynamodb:GetRecords` — took the arm that has no ARN to give and failed the
|
|
10
|
+
// deploy. The failure only surfaced once a plugin's first StateChangeSlice
|
|
11
|
+
// declared a `@storageRef` field, which is what registers the claimer at all.
|
|
12
|
+
|
|
13
|
+
let resolve = (output: Pulumi.Output.t<'a>): promise<'a> =>
|
|
14
|
+
Promise.make((resolve, _) => {
|
|
15
|
+
let _ = output->Pulumi.Output.apply(value => resolve(value))
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
let streamArn = "arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123/stream/2026-01-01T00:00:00.000"
|
|
19
|
+
|
|
20
|
+
let tableResource = ReventlessInfra.Adapter.make(
|
|
21
|
+
~name="CatalogDcbEventLog-abc123"->Pulumi.Output.make,
|
|
22
|
+
~id="CatalogDcbEventLog-abc123"->Pulumi.Output.make,
|
|
23
|
+
~urn="arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123"->Pulumi.Output.make,
|
|
24
|
+
~service=AWS.DynamoDbStream.service->Pulumi.Output.make,
|
|
25
|
+
~resourceInfo=ReventlessInfra.Adapter.StreamSource({sourceUrn: streamArn})->Pulumi.Output.make,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
describe("Util_DynamoDbStream.toStreamResource", () => {
|
|
29
|
+
test("carries the source stream in resourceInfo", async () => {
|
|
30
|
+
let stream = tableResource->Util_DynamoDbStream.toStreamResource
|
|
31
|
+
let resourceInfo = await stream.resourceInfo->resolve
|
|
32
|
+
expect(resourceInfo)->toEqual(ReventlessInfra.Adapter.StreamSource({sourceUrn: streamArn}))
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
test("answers its own stream ARN", async () => {
|
|
36
|
+
let arn =
|
|
37
|
+
await tableResource
|
|
38
|
+
->Util_DynamoDbStream.toStreamResource
|
|
39
|
+
->Util_DynamoDbStream.streamArnFromDynamoDbTableResource
|
|
40
|
+
->resolve
|
|
41
|
+
expect(arn)->toBe(streamArn)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Pulumi from "@pulumi/pulumi";
|
|
4
|
+
import * as AWS$ReventlessAws from "../src/adapter/AWS.res.mjs";
|
|
5
|
+
import * as Adapter$ReventlessInfra from "@reventlessdev/reventless-infra/src/adapter/Adapter.res.mjs";
|
|
6
|
+
import * as Util_DynamoDbStream$ReventlessAws from "../src/util/Util_DynamoDbStream.res.mjs";
|
|
7
|
+
|
|
8
|
+
function resolve(output) {
|
|
9
|
+
return new Promise((resolve, param) => {
|
|
10
|
+
output.apply(value => resolve(value));
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
let streamArn = "arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123/stream/2026-01-01T00:00:00.000";
|
|
15
|
+
|
|
16
|
+
let tableResource = Adapter$ReventlessInfra.make(Pulumi.output("CatalogDcbEventLog-abc123"), Pulumi.output("CatalogDcbEventLog-abc123"), Pulumi.output("arn:aws:dynamodb:eu-west-1:123456789012:table/CatalogDcbEventLog-abc123"), Pulumi.output(AWS$ReventlessAws.DynamoDbStream.service), Pulumi.output({
|
|
17
|
+
TAG: "StreamSource",
|
|
18
|
+
sourceUrn: streamArn
|
|
19
|
+
}), undefined, undefined, undefined, undefined, undefined);
|
|
20
|
+
|
|
21
|
+
globalThis.describe("Util_DynamoDbStream.toStreamResource", () => {
|
|
22
|
+
globalThis.test("carries the source stream in resourceInfo", async () => {
|
|
23
|
+
let stream = Util_DynamoDbStream$ReventlessAws.toStreamResource(tableResource);
|
|
24
|
+
let resourceInfo = await resolve(stream.resourceInfo);
|
|
25
|
+
globalThis.expect(resourceInfo).toEqual({
|
|
26
|
+
TAG: "StreamSource",
|
|
27
|
+
sourceUrn: streamArn
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
globalThis.test("answers its own stream ARN", async () => {
|
|
31
|
+
let arn = await resolve(Util_DynamoDbStream$ReventlessAws.streamArnFromDynamoDbTableResource(Util_DynamoDbStream$ReventlessAws.toStreamResource(tableResource)));
|
|
32
|
+
globalThis.expect(arn).toBe(streamArn);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
resolve,
|
|
38
|
+
streamArn,
|
|
39
|
+
tableResource,
|
|
40
|
+
}
|
|
41
|
+
/* tableResource Not a pure module */
|
|
@@ -81,6 +81,113 @@ describe("Util_ShellConfig.fields — bakedManifest", () => {
|
|
|
81
81
|
})
|
|
82
82
|
})
|
|
83
83
|
|
|
84
|
+
// ── Journeys ──────────────────────────────────────────────────────────────
|
|
85
|
+
//
|
|
86
|
+
// One curated surface per audience, beside the default one. The property under
|
|
87
|
+
// test throughout is that a deployment declaring none is untouched: every
|
|
88
|
+
// deployment that predates journeys has exactly one audience, and its
|
|
89
|
+
// config.json must not grow a key for a feature it does not use.
|
|
90
|
+
describe("Util_ShellConfig.fields — journeys", () => {
|
|
91
|
+
let withJourneys = (
|
|
92
|
+
~journeys: array<ReventlessInfra.Platform.bakedJourney>,
|
|
93
|
+
): ReventlessInfra.Platform.bakedManifest => {
|
|
94
|
+
components: [{plugin: "Catalog", views: ["Products"], commands: []}],
|
|
95
|
+
journeys,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let shopper: ReventlessInfra.Platform.bakedJourney = {
|
|
99
|
+
group: "Shopper",
|
|
100
|
+
components: [{plugin: "Catalog", views: ["Products"], commands: []}],
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let fulfilment: ReventlessInfra.Platform.bakedJourney = {
|
|
104
|
+
group: "Fulfilment",
|
|
105
|
+
components: [{plugin: "Ordering", views: ["Orders"], commands: ["ShipOrder"]}],
|
|
106
|
+
key: "fulfilment.json",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
testSync("a bake declaring no journeys writes no map", () => {
|
|
110
|
+
let out = Util_ShellConfig.fields(
|
|
111
|
+
~computed,
|
|
112
|
+
~bakedManifest={components: [{plugin: "Catalog", views: ["Products"], commands: []}]},
|
|
113
|
+
)
|
|
114
|
+
expect(out->Dict.get("journeyManifestUrls")->Option.isNone)->toBe(true)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
testSync("an empty journeys array is the same as none", () => {
|
|
118
|
+
let out = Util_ShellConfig.fields(~computed, ~bakedManifest=withJourneys(~journeys=[]))
|
|
119
|
+
expect(out->Dict.get("journeyManifestUrls")->Option.isNone)->toBe(true)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
// The default journey keeps `manifestUrl`, so a caller matching no declared
|
|
123
|
+
// group lands where every caller landed before.
|
|
124
|
+
testSync("keeps manifestUrl as the default journey", () => {
|
|
125
|
+
let out = Util_ShellConfig.fields(~computed, ~bakedManifest=withJourneys(~journeys=[shopper]))
|
|
126
|
+
expect(out->get("manifestUrl"))->toEqual(JSON.Encode.string("/component-manifest.json"))
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
testSync("maps each declared group to its own file", () => {
|
|
130
|
+
let out = Util_ShellConfig.fields(
|
|
131
|
+
~computed,
|
|
132
|
+
~bakedManifest=withJourneys(~journeys=[shopper, fulfilment]),
|
|
133
|
+
)
|
|
134
|
+
expect(out->get("journeyManifestUrls"))->toEqual(
|
|
135
|
+
JSON.Encode.object(
|
|
136
|
+
Dict.fromArray([
|
|
137
|
+
// Derived from the group, lower-cased, because a key is part of a URL.
|
|
138
|
+
("Shopper", JSON.Encode.string("/component-manifest-shopper.json")),
|
|
139
|
+
// Named explicitly, and the declaration wins.
|
|
140
|
+
("Fulfilment", JSON.Encode.string("/fulfilment.json")),
|
|
141
|
+
]),
|
|
142
|
+
),
|
|
143
|
+
)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// The URL a shell fetches and the key the bake writes are one string, derived
|
|
147
|
+
// once — a second derivation is a file written where nothing looks for it. So
|
|
148
|
+
// every key the bake writes is published, and the default one is published as
|
|
149
|
+
// `manifestUrl` rather than in the map.
|
|
150
|
+
testSync("publishes the URL of every key the bake writes, exactly once", () => {
|
|
151
|
+
let bake = withJourneys(~journeys=[shopper, fulfilment])
|
|
152
|
+
let out = Util_ShellConfig.fields(~computed, ~bakedManifest=bake)
|
|
153
|
+
let published = Array.concat(
|
|
154
|
+
out->get("manifestUrl")->JSON.Decode.string->Option.mapOr([], url => [url]),
|
|
155
|
+
out
|
|
156
|
+
->get("journeyManifestUrls")
|
|
157
|
+
->JSON.Decode.object
|
|
158
|
+
->Option.getOr(Dict.make())
|
|
159
|
+
->Dict.valuesToArray
|
|
160
|
+
->Array.filterMap(JSON.Decode.string),
|
|
161
|
+
)
|
|
162
|
+
expect(published)->toEqual(
|
|
163
|
+
ReventlessCore.Platform_BakedManifest.files(~config=bake)->Array.map(((key, _)) =>
|
|
164
|
+
"/" ++ key
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// A passthrough cannot redirect a key the deploy computes — the same rule
|
|
170
|
+
// `manifestUrl` already carries, extended to the map beside it.
|
|
171
|
+
testSync("a shellConfig journey map fails the deploy rather than redirecting it", () => {
|
|
172
|
+
let failure = try {
|
|
173
|
+
let _ = Util_ShellConfig.fields(
|
|
174
|
+
~computed,
|
|
175
|
+
~bakedManifest=withJourneys(~journeys=[shopper]),
|
|
176
|
+
~shellConfig=Dict.fromArray([
|
|
177
|
+
("journeyManifestUrls", JSON.Encode.object(Dict.make())),
|
|
178
|
+
]),
|
|
179
|
+
)
|
|
180
|
+
None
|
|
181
|
+
} catch {
|
|
182
|
+
| Failure(message) => Some(message)
|
|
183
|
+
}
|
|
184
|
+
switch failure {
|
|
185
|
+
| Some(message) => expect(message->String.includes("journeyManifestUrls"))->toBe(true)
|
|
186
|
+
| None => fail("a shellConfig key redirecting the computed journey map must fail the deploy")
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
})
|
|
190
|
+
|
|
84
191
|
describe("Util_ShellConfig.fields — shellConfig passthrough", () => {
|
|
85
192
|
testSync("shell-owned keys land verbatim, under the computed ones", () => {
|
|
86
193
|
let out = Util_ShellConfig.fields(
|
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as JestGlobals from "@reventlessdev/rescript-jest/src/JestGlobals.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";
|
|
4
6
|
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
5
7
|
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
6
8
|
import * as Util_ShellConfig$ReventlessAws from "../src/util/Util_ShellConfig.res.mjs";
|
|
9
|
+
import * as Platform_BakedManifest$ReventlessCore from "@reventlessdev/reventless-core/src/admin/Platform_BakedManifest.res.mjs";
|
|
7
10
|
|
|
8
11
|
let computed = [
|
|
9
12
|
[
|
|
@@ -107,6 +110,103 @@ globalThis.describe("Util_ShellConfig.fields — bakedManifest", () => {
|
|
|
107
110
|
});
|
|
108
111
|
});
|
|
109
112
|
|
|
113
|
+
globalThis.describe("Util_ShellConfig.fields — journeys", () => {
|
|
114
|
+
let withJourneys = journeys => ({
|
|
115
|
+
components: [{
|
|
116
|
+
plugin: "Catalog",
|
|
117
|
+
views: ["Products"],
|
|
118
|
+
commands: []
|
|
119
|
+
}],
|
|
120
|
+
journeys: journeys
|
|
121
|
+
});
|
|
122
|
+
let shopper_components = [{
|
|
123
|
+
plugin: "Catalog",
|
|
124
|
+
views: ["Products"],
|
|
125
|
+
commands: []
|
|
126
|
+
}];
|
|
127
|
+
let shopper = {
|
|
128
|
+
group: "Shopper",
|
|
129
|
+
components: shopper_components
|
|
130
|
+
};
|
|
131
|
+
let fulfilment_components = [{
|
|
132
|
+
plugin: "Ordering",
|
|
133
|
+
views: ["Orders"],
|
|
134
|
+
commands: ["ShipOrder"]
|
|
135
|
+
}];
|
|
136
|
+
let fulfilment_key = "fulfilment.json";
|
|
137
|
+
let fulfilment = {
|
|
138
|
+
group: "Fulfilment",
|
|
139
|
+
components: fulfilment_components,
|
|
140
|
+
key: fulfilment_key
|
|
141
|
+
};
|
|
142
|
+
globalThis.test("a bake declaring no journeys writes no map", () => {
|
|
143
|
+
let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, {
|
|
144
|
+
components: [{
|
|
145
|
+
plugin: "Catalog",
|
|
146
|
+
views: ["Products"],
|
|
147
|
+
commands: []
|
|
148
|
+
}]
|
|
149
|
+
}, undefined);
|
|
150
|
+
globalThis.expect(Stdlib_Option.isNone(out["journeyManifestUrls"])).toBe(true);
|
|
151
|
+
});
|
|
152
|
+
globalThis.test("an empty journeys array is the same as none", () => {
|
|
153
|
+
let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, withJourneys([]), undefined);
|
|
154
|
+
globalThis.expect(Stdlib_Option.isNone(out["journeyManifestUrls"])).toBe(true);
|
|
155
|
+
});
|
|
156
|
+
globalThis.test("keeps manifestUrl as the default journey", () => {
|
|
157
|
+
let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, withJourneys([shopper]), undefined);
|
|
158
|
+
globalThis.expect(get(out, "manifestUrl")).toEqual("/component-manifest.json");
|
|
159
|
+
});
|
|
160
|
+
globalThis.test("maps each declared group to its own file", () => {
|
|
161
|
+
let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, withJourneys([
|
|
162
|
+
shopper,
|
|
163
|
+
fulfilment
|
|
164
|
+
]), undefined);
|
|
165
|
+
globalThis.expect(get(out, "journeyManifestUrls")).toEqual(Object.fromEntries([
|
|
166
|
+
[
|
|
167
|
+
"Shopper",
|
|
168
|
+
"/component-manifest-shopper.json"
|
|
169
|
+
],
|
|
170
|
+
[
|
|
171
|
+
"Fulfilment",
|
|
172
|
+
"/fulfilment.json"
|
|
173
|
+
]
|
|
174
|
+
]));
|
|
175
|
+
});
|
|
176
|
+
globalThis.test("publishes the URL of every key the bake writes, exactly once", () => {
|
|
177
|
+
let bake = withJourneys([
|
|
178
|
+
shopper,
|
|
179
|
+
fulfilment
|
|
180
|
+
]);
|
|
181
|
+
let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, bake, undefined);
|
|
182
|
+
let published = Stdlib_Option.mapOr(Stdlib_JSON.Decode.string(get(out, "manifestUrl")), [], url => [url]).concat(Stdlib_Array.filterMap(Object.values(Stdlib_Option.getOr(Stdlib_JSON.Decode.object(get(out, "journeyManifestUrls")), {})), Stdlib_JSON.Decode.string));
|
|
183
|
+
globalThis.expect(published).toEqual(Platform_BakedManifest$ReventlessCore.files(bake).map(param => "/" + param[0]));
|
|
184
|
+
});
|
|
185
|
+
globalThis.test("a shellConfig journey map fails the deploy rather than redirecting it", () => {
|
|
186
|
+
let failure;
|
|
187
|
+
try {
|
|
188
|
+
Util_ShellConfig$ReventlessAws.fields(computed, undefined, withJourneys([shopper]), Object.fromEntries([[
|
|
189
|
+
"journeyManifestUrls",
|
|
190
|
+
{}
|
|
191
|
+
]]));
|
|
192
|
+
failure = undefined;
|
|
193
|
+
} catch (raw_message) {
|
|
194
|
+
let message = Primitive_exceptions.internalToException(raw_message);
|
|
195
|
+
if (message.RE_EXN_ID === "Failure") {
|
|
196
|
+
failure = message._1;
|
|
197
|
+
} else {
|
|
198
|
+
throw message;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (failure !== undefined) {
|
|
202
|
+
globalThis.expect(failure.includes("journeyManifestUrls")).toBe(true);
|
|
203
|
+
return;
|
|
204
|
+
} else {
|
|
205
|
+
return JestGlobals.fail("a shellConfig key redirecting the computed journey map must fail the deploy");
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
110
210
|
globalThis.describe("Util_ShellConfig.fields — shellConfig passthrough", () => {
|
|
111
211
|
globalThis.test("shell-owned keys land verbatim, under the computed ones", () => {
|
|
112
212
|
let out = Util_ShellConfig$ReventlessAws.fields(computed, undefined, undefined, Object.fromEntries([
|