@reventlessdev/reventless-aws 3.0.0-alpha.301 → 3.0.0-alpha.303
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 +20 -0
- package/package.json +7 -7
- 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 +108 -20
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res.mjs +99 -17
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res +41 -3
- package/src/adapter/QueryDb/PgQueryResolver_Lambda.res.mjs +27 -6
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +34 -4
- package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +16 -5
- package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res +10 -0
- package/src/adapter/Runtime/PgQueryResolverEntryPoint_Ops.res.mjs +4 -1
- package/src/adapter/Runtime/StateTopicPublish.mjs +38 -5
- package/src/adapter/StateTopic/StateTopic_AppSync.res +49 -1
- package/src/adapter/StateTopic/StateTopic_AppSync.res.mjs +28 -3
- package/src/adapter/StateTopic/StateTopic_AppSync_Ops.res +73 -2
- package/src/adapter/StateTopic/StateTopic_AppSync_Ops.res.mjs +39 -4
- 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/PgQueryResolver_LambdaTest.res +16 -1
- package/tests/PgQueryResolver_LambdaTest.res.mjs +46 -32
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res +111 -0
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res.mjs +67 -0
- package/tests/QueryDbResolvers_AppSyncTest.res.mjs +3 -3
- package/tests/StateChangeDescriptorParityTest.res +82 -1
- package/tests/StateChangeDescriptorParityTest.res.mjs +97 -1
- 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/tests/stateChangeDescriptorParity.mjs +72 -3
|
@@ -113,6 +113,71 @@ describe("toEntryWith heals a structure persisted before a required list existed
|
|
|
113
113
|
})
|
|
114
114
|
})
|
|
115
115
|
|
|
116
|
+
// The `statusField` → `lifecycleField` rename has the same shape of problem one
|
|
117
|
+
// severity down: the SDL field is a nullable `String`, so an absent key answers
|
|
118
|
+
// null rather than blackholing the query. That is worse to find, not better —
|
|
119
|
+
// lists keep rendering while command-menu filtering, board columns, group
|
|
120
|
+
// sections, progress trackers and state diagrams all go quiet. Asserted because
|
|
121
|
+
// the shim is the one part of the rename with nothing to fail at compile time.
|
|
122
|
+
describe("toEntryWith reads a pre-rename structure's lifecycle field", () => {
|
|
123
|
+
let legacyStructure = Dict.fromArray([
|
|
124
|
+
(
|
|
125
|
+
"readModels",
|
|
126
|
+
JSON.Encode.array([
|
|
127
|
+
JSON.parseOrThrow(`{"name": "Customers", "statusField": "locationStatus"}`),
|
|
128
|
+
]),
|
|
129
|
+
),
|
|
130
|
+
(
|
|
131
|
+
"stateViewSlices",
|
|
132
|
+
JSON.Encode.array([JSON.parseOrThrow(`{"name": "Orders", "statusField": "status"}`)]),
|
|
133
|
+
),
|
|
134
|
+
])->JSON.Encode.object
|
|
135
|
+
|
|
136
|
+
let fieldOf = (~collection) =>
|
|
137
|
+
entry(~filter=false, legacyStructure)
|
|
138
|
+
->Option.map(members(_, collection))
|
|
139
|
+
->Option.flatMap(Array.get(_, 0))
|
|
140
|
+
->Option.flatMap(c => c->Dict.get("lifecycleField"))
|
|
141
|
+
|
|
142
|
+
testSync("a read model's legacy key answers on the new name", () =>
|
|
143
|
+
expect(fieldOf(~collection="readModels"))->toEqual(
|
|
144
|
+
Some(JSON.Encode.string("locationStatus")),
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
testSync("so does a state view slice's", () =>
|
|
149
|
+
expect(fieldOf(~collection="stateViewSlices"))->toEqual(Some(JSON.Encode.string("status")))
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
testSync("a post-rename structure is left alone", () => {
|
|
153
|
+
let current = Dict.fromArray([
|
|
154
|
+
(
|
|
155
|
+
"readModels",
|
|
156
|
+
JSON.Encode.array([
|
|
157
|
+
JSON.parseOrThrow(`{"name": "Orders", "lifecycleField": "lifecycle", "statusField": "stale"}`),
|
|
158
|
+
]),
|
|
159
|
+
),
|
|
160
|
+
])->JSON.Encode.object
|
|
161
|
+
let field =
|
|
162
|
+
entry(~filter=false, current)
|
|
163
|
+
->Option.map(members(_, "readModels"))
|
|
164
|
+
->Option.flatMap(Array.get(_, 0))
|
|
165
|
+
->Option.flatMap(c => c->Dict.get("lifecycleField"))
|
|
166
|
+
expect(field)->toEqual(Some(JSON.Encode.string("lifecycle")))
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
testSync("a structure declaring neither still answers nothing", () => {
|
|
170
|
+
let neither = Dict.fromArray([
|
|
171
|
+
("readModels", JSON.Encode.array([JSON.parseOrThrow(`{"name": "Products"}`)])),
|
|
172
|
+
])->JSON.Encode.object
|
|
173
|
+
let component =
|
|
174
|
+
entry(~filter=false, neither)
|
|
175
|
+
->Option.map(members(_, "readModels"))
|
|
176
|
+
->Option.flatMap(Array.get(_, 0))
|
|
177
|
+
expect(component->Option.flatMap(c => c->Dict.get("lifecycleField")))->toEqual(None)
|
|
178
|
+
})
|
|
179
|
+
})
|
|
180
|
+
|
|
116
181
|
// The persisted structure is pre-filter, so this handler is a twin of the
|
|
117
182
|
// in-memory `encodePluginStructureEntry` rather than a consequence of it. A shell
|
|
118
183
|
// reading `internalQueryables` against a platform that emits it on only one
|
|
@@ -241,6 +306,52 @@ describe("bakeSelection", () => {
|
|
|
241
306
|
)
|
|
242
307
|
})
|
|
243
308
|
|
|
309
|
+
// A journey travels from the deploy in the function's environment, exactly as the
|
|
310
|
+
// default include-list does, carrying the key the deploy resolved. Deriving the
|
|
311
|
+
// key a second time here would be a file written where `config.json` does not
|
|
312
|
+
// send anybody, so the handler only reads it.
|
|
313
|
+
describe("bakeJourney", () => {
|
|
314
|
+
let journey = raw => Platform_ComponentDefinitions_Lambda_Ops.bakeJourney(JSON.parseOrThrow(raw))
|
|
315
|
+
|
|
316
|
+
testSync("reads the group, the key and the include-list the deploy encoded", () => {
|
|
317
|
+
let decoded = journey(`{
|
|
318
|
+
"group": "Fulfilment",
|
|
319
|
+
"key": "component-manifest-fulfilment.json",
|
|
320
|
+
"components": [{"plugin": "Ordering", "views": ["Orders"], "derived": ["lifecycles"]}]
|
|
321
|
+
}`)
|
|
322
|
+
expect(decoded->Option.map(j => (j.group, j.key, j.selections->Array.map(s => s.plugin))))->toEqual(
|
|
323
|
+
Some(("Fulfilment", "component-manifest-fulfilment.json", ["Ordering"])),
|
|
324
|
+
)
|
|
325
|
+
expect(
|
|
326
|
+
decoded->Option.flatMap(j => j.selections->Array.get(0))->Option.flatMap(s => s.derived),
|
|
327
|
+
)->toEqual(Some(["lifecycles"]))
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
// Absent is not empty here either: a journey whose selection names no `views`
|
|
331
|
+
// takes every public view of that plugin.
|
|
332
|
+
testSync("keeps a selection's absent lists absent", () => {
|
|
333
|
+
let decoded = journey(`{"group": "Shopper", "key": "s.json", "components": [{"plugin": "Catalog"}]}`)
|
|
334
|
+
expect(
|
|
335
|
+
decoded->Option.flatMap(j => j.selections->Array.get(0))->Option.flatMap(s => s.views),
|
|
336
|
+
)->toEqual(None)
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
// Both are what the write needs. A journey missing either would be baked to a
|
|
340
|
+
// key nobody granted or reported as belonging to no audience.
|
|
341
|
+
testSync("refuses an entry naming no group or no key", () => {
|
|
342
|
+
expect(journey(`{"key": "s.json", "components": []}`)->Option.isNone)->toBe(true)
|
|
343
|
+
expect(journey(`{"group": "Shopper", "components": []}`)->Option.isNone)->toBe(true)
|
|
344
|
+
})
|
|
345
|
+
|
|
346
|
+
// A journey that curates nothing is still a journey — it writes an empty file
|
|
347
|
+
// rather than falling back to the default one.
|
|
348
|
+
testSync("a journey with no components decodes to an empty include-list", () =>
|
|
349
|
+
expect(
|
|
350
|
+
journey(`{"group": "Shopper", "key": "s.json"}`)->Option.map(j => j.selections->Array.length),
|
|
351
|
+
)->toEqual(Some(0))
|
|
352
|
+
)
|
|
353
|
+
})
|
|
354
|
+
|
|
244
355
|
// The bake reads a read model the deploy updates asynchronously, so "is this the
|
|
245
356
|
// deployment I was asked to bake" is a question it has to be able to answer. The
|
|
246
357
|
// answer is an equality check against the key each plugin stack just wrote.
|
|
@@ -67,6 +67,42 @@ globalThis.describe("toEntryWith heals a structure persisted before a required l
|
|
|
67
67
|
});
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
globalThis.describe("toEntryWith reads a pre-rename structure's lifecycle field", () => {
|
|
71
|
+
let legacyStructure = Object.fromEntries([
|
|
72
|
+
[
|
|
73
|
+
"readModels",
|
|
74
|
+
[JSON.parse(`{"name": "Customers", "statusField": "locationStatus"}`)]
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
"stateViewSlices",
|
|
78
|
+
[JSON.parse(`{"name": "Orders", "statusField": "status"}`)]
|
|
79
|
+
]
|
|
80
|
+
]);
|
|
81
|
+
let fieldOf = collection => Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.map(entry(false, legacyStructure), __x => members(__x, collection)), __x => __x[0]), c => c["lifecycleField"]);
|
|
82
|
+
globalThis.test("a read model's legacy key answers on the new name", () => {
|
|
83
|
+
globalThis.expect(fieldOf("readModels")).toEqual("locationStatus");
|
|
84
|
+
});
|
|
85
|
+
globalThis.test("so does a state view slice's", () => {
|
|
86
|
+
globalThis.expect(fieldOf("stateViewSlices")).toEqual("status");
|
|
87
|
+
});
|
|
88
|
+
globalThis.test("a post-rename structure is left alone", () => {
|
|
89
|
+
let current = Object.fromEntries([[
|
|
90
|
+
"readModels",
|
|
91
|
+
[JSON.parse(`{"name": "Orders", "lifecycleField": "lifecycle", "statusField": "stale"}`)]
|
|
92
|
+
]]);
|
|
93
|
+
let field = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.map(entry(false, current), __x => members(__x, "readModels")), __x => __x[0]), c => c["lifecycleField"]);
|
|
94
|
+
globalThis.expect(field).toEqual("lifecycle");
|
|
95
|
+
});
|
|
96
|
+
globalThis.test("a structure declaring neither still answers nothing", () => {
|
|
97
|
+
let neither = Object.fromEntries([[
|
|
98
|
+
"readModels",
|
|
99
|
+
[JSON.parse(`{"name": "Products"}`)]
|
|
100
|
+
]]);
|
|
101
|
+
let component = Stdlib_Option.flatMap(Stdlib_Option.map(entry(false, neither), __x => members(__x, "readModels")), __x => __x[0]);
|
|
102
|
+
globalThis.expect(Stdlib_Option.flatMap(component, c => c["lifecycleField"])).toEqual(undefined);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
70
106
|
globalThis.describe("toEntryWith carries Internal queryables on the filtered field", () => {
|
|
71
107
|
let mixed = Object.fromEntries([
|
|
72
108
|
[
|
|
@@ -154,6 +190,37 @@ globalThis.describe("bakeSelection", () => {
|
|
|
154
190
|
});
|
|
155
191
|
});
|
|
156
192
|
|
|
193
|
+
globalThis.describe("bakeJourney", () => {
|
|
194
|
+
globalThis.test("reads the group, the key and the include-list the deploy encoded", () => {
|
|
195
|
+
let decoded = Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{
|
|
196
|
+
"group": "Fulfilment",
|
|
197
|
+
"key": "component-manifest-fulfilment.json",
|
|
198
|
+
"components": [{"plugin": "Ordering", "views": ["Orders"], "derived": ["lifecycles"]}]
|
|
199
|
+
}`));
|
|
200
|
+
globalThis.expect(Stdlib_Option.map(decoded, j => [
|
|
201
|
+
j.group,
|
|
202
|
+
j.key,
|
|
203
|
+
j.selections.map(s => s.plugin)
|
|
204
|
+
])).toEqual([
|
|
205
|
+
"Fulfilment",
|
|
206
|
+
"component-manifest-fulfilment.json",
|
|
207
|
+
["Ordering"]
|
|
208
|
+
]);
|
|
209
|
+
globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(decoded, j => j.selections[0]), s => s.derived)).toEqual(["lifecycles"]);
|
|
210
|
+
});
|
|
211
|
+
globalThis.test("keeps a selection's absent lists absent", () => {
|
|
212
|
+
let decoded = Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"group": "Shopper", "key": "s.json", "components": [{"plugin": "Catalog"}]}`));
|
|
213
|
+
globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(decoded, j => j.selections[0]), s => s.views)).toEqual(undefined);
|
|
214
|
+
});
|
|
215
|
+
globalThis.test("refuses an entry naming no group or no key", () => {
|
|
216
|
+
globalThis.expect(Stdlib_Option.isNone(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"key": "s.json", "components": []}`)))).toBe(true);
|
|
217
|
+
globalThis.expect(Stdlib_Option.isNone(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"group": "Shopper", "components": []}`)))).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
globalThis.test("a journey with no components decodes to an empty include-list", () => {
|
|
220
|
+
globalThis.expect(Stdlib_Option.map(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.bakeJourney(JSON.parse(`{"group": "Shopper", "key": "s.json"}`)), j => j.selections.length)).toEqual(0);
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
157
224
|
globalThis.describe("pendingRegistrations", () => {
|
|
158
225
|
let row = (name, key, param) => {
|
|
159
226
|
let item = Object.fromEntries([[
|
|
@@ -19,18 +19,18 @@ globalThis.describe("QueryDbResolvers_AppSync.internalRowRequiredAttr", () => {
|
|
|
19
19
|
|
|
20
20
|
globalThis.describe("AppSync_Resolver_Retrying.Functions.listAllItemsConnection", () => {
|
|
21
21
|
globalThis.test("with ~requireAttribute emits an attribute_exists filter that excludes internal rows", () => {
|
|
22
|
-
let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, "name", undefined, undefined);
|
|
22
|
+
let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, "name", undefined, undefined, undefined, undefined);
|
|
23
23
|
globalThis.expect(code.includes("attribute_exists(#name)")).toBe(true);
|
|
24
24
|
globalThis.expect(code.includes("names['#name'] = 'name'")).toBe(true);
|
|
25
25
|
});
|
|
26
26
|
globalThis.test("without ~requireAttribute emits no attribute_exists filter (default read models unchanged)", () => {
|
|
27
|
-
let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined);
|
|
27
|
+
let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
|
|
28
28
|
globalThis.expect(code.includes("attribute_exists")).toBe(false);
|
|
29
29
|
});
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
globalThis.describe("listAllItemsConnection — Scan cursor round-trip (paging fixes 1–3)", () => {
|
|
33
|
-
let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined);
|
|
33
|
+
let code = AppSync_Resolver_Functions$PulumiAws.listAllItemsConnection("name", undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
|
|
34
34
|
globalThis.test("Fix 1: response encodes the real nextToken as the cursor", () => {
|
|
35
35
|
globalThis.expect(code.includes("const next = ctx.result?.nextToken ?? null;")).toBe(true);
|
|
36
36
|
globalThis.expect(code.includes("util.base64Encode(JSON.stringify({ token: next, index: i }))")).toBe(true);
|
|
@@ -29,7 +29,7 @@ describe("state-change descriptor parity", () => {
|
|
|
29
29
|
testAsync("every implementation builds the same descriptor", async () => {
|
|
30
30
|
let cases = await buildAll()
|
|
31
31
|
// A silent zero-case run would pass while asserting nothing.
|
|
32
|
-
expect(cases->Array.length)->toBe(
|
|
32
|
+
expect(cases->Array.length)->toBe(11)
|
|
33
33
|
cases->Array.forEach(c => {
|
|
34
34
|
expect((c.name, c.relay))->toEqual((c.name, c.local))
|
|
35
35
|
expect((c.name, c.postgres))->toEqual((c.name, c.local))
|
|
@@ -55,4 +55,85 @@ describe("state-change descriptor parity", () => {
|
|
|
55
55
|
| None => expect("oversized case missing")->toBe("present")
|
|
56
56
|
}
|
|
57
57
|
})
|
|
58
|
+
|
|
59
|
+
// The live channel is the third place a retired row could reach a caller the
|
|
60
|
+
// resolvers refuse it to, and the only one that cannot be scoped per
|
|
61
|
+
// subscriber — the channel is keyed by view and entity, and everyone watching
|
|
62
|
+
// receives the frame. So the payload has to go, in all three implementations.
|
|
63
|
+
testAsync("a retired row publishes as metadata only, everywhere", async () => {
|
|
64
|
+
let cases = await buildAll()
|
|
65
|
+
let keyOf = (d: JSON.t, k) => d->JSON.Decode.object->Option.flatMap(o => o->Dict.get(k))
|
|
66
|
+
let byName = name => cases->Array.find(c => c.name == name)
|
|
67
|
+
|
|
68
|
+
switch byName("retired row degrades to metadata only") {
|
|
69
|
+
| Some(c) =>
|
|
70
|
+
// Not just the payload: the sort value is a timestamp off a row the
|
|
71
|
+
// subscriber may not read, and this row carries one.
|
|
72
|
+
expect((keyOf(c.local, "state"), keyOf(c.local, "sortKeyValue")))->toEqual((None, None))
|
|
73
|
+
expect((keyOf(c.relay, "state"), keyOf(c.relay, "sortKeyValue")))->toEqual((None, None))
|
|
74
|
+
expect((keyOf(c.postgres, "state"), keyOf(c.postgres, "sortKeyValue")))->toEqual((None, None))
|
|
75
|
+
// `Updated`, never `Removed`: the row is not gone. Saying so would be
|
|
76
|
+
// false for an elevated subscriber reading with `includeRetired`, and
|
|
77
|
+
// false again the moment the row is restored.
|
|
78
|
+
expect(keyOf(c.local, "changeKind"))->toEqual(Some(JSON.Encode.string("Updated")))
|
|
79
|
+
| None => expect("retired case missing")->toBe("present")
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// The control that separates "drops a retired row" from "drops any row on a
|
|
83
|
+
// view that declares the flag".
|
|
84
|
+
switch byName("live row with a retirement flag carries the row") {
|
|
85
|
+
| Some(c) =>
|
|
86
|
+
expect(keyOf(c.local, "state")->Option.isSome)->toBe(true)
|
|
87
|
+
expect(keyOf(c.relay, "state")->Option.isSome)->toBe(true)
|
|
88
|
+
expect(keyOf(c.postgres, "state")->Option.isSome)->toBe(true)
|
|
89
|
+
| None => expect("live-with-flag case missing")->toBe("present")
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
// The state form, where the predicate is membership rather than equality. Both
|
|
94
|
+
// states are asserted, so an implementation comparing against only the first
|
|
95
|
+
// member of the set fails on the second — which is the shape of mistake a set
|
|
96
|
+
// invites and a single value could not.
|
|
97
|
+
testAsync("a row in any retired state publishes as metadata only", async () => {
|
|
98
|
+
let cases = await buildAll()
|
|
99
|
+
let keyOf = (d: JSON.t, k) => d->JSON.Decode.object->Option.flatMap(o => o->Dict.get(k))
|
|
100
|
+
let byName = name => cases->Array.find(c => c.name == name)
|
|
101
|
+
|
|
102
|
+
[
|
|
103
|
+
"row in the first retired state degrades to metadata only",
|
|
104
|
+
"row in the second retired state degrades to metadata only",
|
|
105
|
+
]->Array.forEach(name =>
|
|
106
|
+
switch byName(name) {
|
|
107
|
+
| Some(c) =>
|
|
108
|
+
expect((name, keyOf(c.local, "state"), keyOf(c.local, "sortKeyValue")))->toEqual((
|
|
109
|
+
name,
|
|
110
|
+
None,
|
|
111
|
+
None,
|
|
112
|
+
))
|
|
113
|
+
expect((name, keyOf(c.relay, "state"), keyOf(c.relay, "sortKeyValue")))->toEqual((
|
|
114
|
+
name,
|
|
115
|
+
None,
|
|
116
|
+
None,
|
|
117
|
+
))
|
|
118
|
+
expect((name, keyOf(c.postgres, "state"), keyOf(c.postgres, "sortKeyValue")))->toEqual((
|
|
119
|
+
name,
|
|
120
|
+
None,
|
|
121
|
+
None,
|
|
122
|
+
))
|
|
123
|
+
| None => expect(name ++ " missing")->toBe("present")
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
// The control for the state form: a live state on the same lifecycle, so an
|
|
128
|
+
// implementation dropping every row of a view that declares states fails.
|
|
129
|
+
switch byName("row in a live state carries the row") {
|
|
130
|
+
| Some(c) =>
|
|
131
|
+
expect((
|
|
132
|
+
keyOf(c.local, "state")->Option.isSome,
|
|
133
|
+
keyOf(c.relay, "state")->Option.isSome,
|
|
134
|
+
keyOf(c.postgres, "state")->Option.isSome,
|
|
135
|
+
))->toEqual((true, true, true))
|
|
136
|
+
| None => expect("live-state case missing")->toBe("present")
|
|
137
|
+
}
|
|
138
|
+
})
|
|
58
139
|
})
|
|
@@ -11,7 +11,7 @@ function buildAll(prim) {
|
|
|
11
11
|
globalThis.describe("state-change descriptor parity", () => {
|
|
12
12
|
globalThis.test("every implementation builds the same descriptor", async () => {
|
|
13
13
|
let cases = await StateChangeDescriptorParityMjs.buildAll();
|
|
14
|
-
globalThis.expect(cases.length).toBe(
|
|
14
|
+
globalThis.expect(cases.length).toBe(11);
|
|
15
15
|
cases.forEach(c => {
|
|
16
16
|
globalThis.expect([
|
|
17
17
|
c.name,
|
|
@@ -52,6 +52,102 @@ globalThis.describe("state-change descriptor parity", () => {
|
|
|
52
52
|
globalThis.expect("oversized case missing").toBe("present");
|
|
53
53
|
}
|
|
54
54
|
});
|
|
55
|
+
globalThis.test("a retired row publishes as metadata only, everywhere", async () => {
|
|
56
|
+
let cases = await StateChangeDescriptorParityMjs.buildAll();
|
|
57
|
+
let keyOf = (d, k) => Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(d), o => o[k]);
|
|
58
|
+
let byName = name => cases.find(c => c.name === name);
|
|
59
|
+
let c = byName("retired row degrades to metadata only");
|
|
60
|
+
if (c !== undefined) {
|
|
61
|
+
globalThis.expect([
|
|
62
|
+
keyOf(c.local, "state"),
|
|
63
|
+
keyOf(c.local, "sortKeyValue")
|
|
64
|
+
]).toEqual([
|
|
65
|
+
undefined,
|
|
66
|
+
undefined
|
|
67
|
+
]);
|
|
68
|
+
globalThis.expect([
|
|
69
|
+
keyOf(c.relay, "state"),
|
|
70
|
+
keyOf(c.relay, "sortKeyValue")
|
|
71
|
+
]).toEqual([
|
|
72
|
+
undefined,
|
|
73
|
+
undefined
|
|
74
|
+
]);
|
|
75
|
+
globalThis.expect([
|
|
76
|
+
keyOf(c.postgres, "state"),
|
|
77
|
+
keyOf(c.postgres, "sortKeyValue")
|
|
78
|
+
]).toEqual([
|
|
79
|
+
undefined,
|
|
80
|
+
undefined
|
|
81
|
+
]);
|
|
82
|
+
globalThis.expect(keyOf(c.local, "changeKind")).toEqual("Updated");
|
|
83
|
+
} else {
|
|
84
|
+
globalThis.expect("retired case missing").toBe("present");
|
|
85
|
+
}
|
|
86
|
+
let c$1 = byName("live row with a retirement flag carries the row");
|
|
87
|
+
if (c$1 !== undefined) {
|
|
88
|
+
globalThis.expect(Stdlib_Option.isSome(keyOf(c$1.local, "state"))).toBe(true);
|
|
89
|
+
globalThis.expect(Stdlib_Option.isSome(keyOf(c$1.relay, "state"))).toBe(true);
|
|
90
|
+
globalThis.expect(Stdlib_Option.isSome(keyOf(c$1.postgres, "state"))).toBe(true);
|
|
91
|
+
} else {
|
|
92
|
+
globalThis.expect("live-with-flag case missing").toBe("present");
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
globalThis.test("a row in any retired state publishes as metadata only", async () => {
|
|
96
|
+
let cases = await StateChangeDescriptorParityMjs.buildAll();
|
|
97
|
+
let keyOf = (d, k) => Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(d), o => o[k]);
|
|
98
|
+
let byName = name => cases.find(c => c.name === name);
|
|
99
|
+
[
|
|
100
|
+
"row in the first retired state degrades to metadata only",
|
|
101
|
+
"row in the second retired state degrades to metadata only"
|
|
102
|
+
].forEach(name => {
|
|
103
|
+
let c = byName(name);
|
|
104
|
+
if (c !== undefined) {
|
|
105
|
+
globalThis.expect([
|
|
106
|
+
name,
|
|
107
|
+
keyOf(c.local, "state"),
|
|
108
|
+
keyOf(c.local, "sortKeyValue")
|
|
109
|
+
]).toEqual([
|
|
110
|
+
name,
|
|
111
|
+
undefined,
|
|
112
|
+
undefined
|
|
113
|
+
]);
|
|
114
|
+
globalThis.expect([
|
|
115
|
+
name,
|
|
116
|
+
keyOf(c.relay, "state"),
|
|
117
|
+
keyOf(c.relay, "sortKeyValue")
|
|
118
|
+
]).toEqual([
|
|
119
|
+
name,
|
|
120
|
+
undefined,
|
|
121
|
+
undefined
|
|
122
|
+
]);
|
|
123
|
+
globalThis.expect([
|
|
124
|
+
name,
|
|
125
|
+
keyOf(c.postgres, "state"),
|
|
126
|
+
keyOf(c.postgres, "sortKeyValue")
|
|
127
|
+
]).toEqual([
|
|
128
|
+
name,
|
|
129
|
+
undefined,
|
|
130
|
+
undefined
|
|
131
|
+
]);
|
|
132
|
+
} else {
|
|
133
|
+
globalThis.expect(name + " missing").toBe("present");
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
let c = byName("row in a live state carries the row");
|
|
137
|
+
if (c !== undefined) {
|
|
138
|
+
globalThis.expect([
|
|
139
|
+
Stdlib_Option.isSome(keyOf(c.local, "state")),
|
|
140
|
+
Stdlib_Option.isSome(keyOf(c.relay, "state")),
|
|
141
|
+
Stdlib_Option.isSome(keyOf(c.postgres, "state"))
|
|
142
|
+
]).toEqual([
|
|
143
|
+
true,
|
|
144
|
+
true,
|
|
145
|
+
true
|
|
146
|
+
]);
|
|
147
|
+
} else {
|
|
148
|
+
globalThis.expect("live-state case missing").toBe("present");
|
|
149
|
+
}
|
|
150
|
+
});
|
|
55
151
|
});
|
|
56
152
|
|
|
57
153
|
export {
|
|
@@ -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(
|