@reventlessdev/reventless-aws 3.0.0-alpha.274 → 3.0.0-alpha.275
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 +8 -0
- package/package.json +8 -8
- package/src/Platform.res +45 -32
- package/src/Platform.res.mjs +74 -66
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res +108 -18
- package/src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res.mjs +153 -7
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res +114 -0
- package/tests/Platform_ComponentDefinitions_Lambda_OpsTest.res.mjs +73 -0
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
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.275 (2026-08-09)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **aws:** fire the deploy-time re-detect after the deferred plugin callback ([b373ff1](https://github.com/ReventlessDev/reventless-core/commit/b373ff19cf910328c098ca63d5e52015d070a236))
|
|
11
|
+
* **aws:** heal a persisted structure that predates a required list field ([dc4c42c](https://github.com/ReventlessDev/reventless-core/commit/dc4c42cab5a7db538d90edbce6d13b949830aad6))
|
|
12
|
+
|
|
13
|
+
|
|
6
14
|
# 3.0.0-alpha.274 (2026-08-08)
|
|
7
15
|
|
|
8
16
|
### 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.275",
|
|
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-aws-sdk": "3.0.0-alpha.
|
|
15
|
+
"@reventlessdev/rescript-aws-sdk": "3.0.0-alpha.5",
|
|
16
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.32",
|
|
16
17
|
"@reventlessdev/rescript-node": "2.0.0-alpha.2",
|
|
17
18
|
"@reventlessdev/rescript-jest": "1.0.0-alpha.10",
|
|
18
|
-
"@reventlessdev/rescript-
|
|
19
|
-
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.67",
|
|
19
|
+
"@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.68",
|
|
20
20
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.18",
|
|
21
21
|
"@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
|
|
22
|
-
"@reventlessdev/reventless-
|
|
22
|
+
"@reventlessdev/reventless-infra": "3.0.0-alpha.129",
|
|
23
|
+
"@reventlessdev/reventless-core": "3.0.0-alpha.218",
|
|
24
|
+
"@reventlessdev/reventless-postgres": "3.0.0-alpha.82",
|
|
23
25
|
"@reventlessdev/reventless-interop": "3.0.0-alpha.30",
|
|
24
|
-
"@reventlessdev/reventless-
|
|
25
|
-
"@reventlessdev/reventless-postgres": "3.0.0-alpha.81",
|
|
26
|
-
"@reventlessdev/reventless-spec": "3.0.0-alpha.102"
|
|
26
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.103"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"rescript": "12.3.0",
|
package/src/Platform.res
CHANGED
|
@@ -2472,38 +2472,51 @@ module MakeWithConfig = (
|
|
|
2472
2472
|
// fails) the handover still completes on the next natural heartbeat — graceful
|
|
2473
2473
|
// degradation.
|
|
2474
2474
|
// heartbeatConfigRef holds the just-built plugin's config (deployPlugin builds
|
|
2475
|
-
// exactly one plugin per call,
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
)
|
|
2489
|
-
|
|
2490
|
-
hbConfig.
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2475
|
+
// exactly one plugin per call), but it is written from the
|
|
2476
|
+
// onHeartbeatEpChannelAvailable hook, which core fires from inside the deferred
|
|
2477
|
+
// construct callback — after `P.make()` has returned. Reading it here
|
|
2478
|
+
// synchronously always saw the initial empty config, so the publish was skipped
|
|
2479
|
+
// on every deploy. `pluginOutputs.heartbeat` derives from that same callback's
|
|
2480
|
+
// output, so applying to it is the earliest point the config is populated.
|
|
2481
|
+
let _ = pluginOutputs.heartbeat->Pulumi.Output.apply(_ => {
|
|
2482
|
+
let hbConfig = PluginRuntime_Builder.heartbeatConfigRef.contents
|
|
2483
|
+
switch (Pulumi.Pulumi.isDryRun(), hbConfig.epQueueUrl) {
|
|
2484
|
+
| (true, _) => () // `pulumi preview` — no deploy-time side effects
|
|
2485
|
+
| (false, Some(epQueueUrl)) =>
|
|
2486
|
+
let _ = epQueueUrl->Pulumi.Output.apply(async url => {
|
|
2487
|
+
let queue: Util_SQS_Runtime.resolvedQueue = {id: url, name: "", arn: ""}
|
|
2488
|
+
let publishHeartbeat = queue->CommandTopicChannel_SQS_Runtime.publishJsons(AWS.SQS_FIFO)
|
|
2489
|
+
let message: ReventlessCore.Message.commandJson = {
|
|
2490
|
+
id: hbConfig.pluginId,
|
|
2491
|
+
meta: ReventlessCore.Message.generateMeta(
|
|
2492
|
+
~service=ReventlessInfra.PluginExtensionPointSpec.name,
|
|
2493
|
+
~user="DeployHeartbeat",
|
|
2494
|
+
),
|
|
2495
|
+
commandJson: ReventlessInfra.PluginExtensionPointSpec.RedetectPlugin(
|
|
2496
|
+
hbConfig.heartbeatTimeout,
|
|
2497
|
+
)->S.reverseConvertToJsonOrThrow(ReventlessInfra.PluginExtensionPointSpec.commandSchema),
|
|
2498
|
+
}
|
|
2499
|
+
try {
|
|
2500
|
+
await publishHeartbeat([message])
|
|
2501
|
+
log.info(
|
|
2502
|
+
~comp="Platform:deployPlugin",
|
|
2503
|
+
`synthetic re-detect published for ${hbConfig.pluginId}`,
|
|
2504
|
+
)
|
|
2505
|
+
} catch {
|
|
2506
|
+
| _ =>
|
|
2507
|
+
log.warn(
|
|
2508
|
+
~comp="Platform:deployPlugin",
|
|
2509
|
+
`synthetic heartbeat failed for ${hbConfig.pluginId} (handover falls back to the next natural heartbeat)`,
|
|
2510
|
+
)
|
|
2511
|
+
}
|
|
2512
|
+
})
|
|
2513
|
+
| (false, None) =>
|
|
2514
|
+
log.warn(
|
|
2515
|
+
~comp="Platform:deployPlugin",
|
|
2516
|
+
"synthetic heartbeat skipped: no EP queue URL in heartbeat config",
|
|
2517
|
+
)
|
|
2518
|
+
}
|
|
2519
|
+
})
|
|
2507
2520
|
|
|
2508
2521
|
Pulumi.Pulumi.getOutputs()
|
|
2509
2522
|
}
|
package/src/Platform.res.mjs
CHANGED
|
@@ -1315,39 +1315,43 @@ function MakeWithConfig(Config) {
|
|
|
1315
1315
|
if (sel !== undefined) {
|
|
1316
1316
|
PgQueryResolver_Builder$ReventlessAws.provision(domainApi, sel, {});
|
|
1317
1317
|
}
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
match$1
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1318
|
+
pluginOutputs.heartbeat.apply(param => {
|
|
1319
|
+
let hbConfig = PluginRuntime_Builder$ReventlessAws.heartbeatConfigRef.contents;
|
|
1320
|
+
let match = IndexJs.isDryRun();
|
|
1321
|
+
let match$1 = hbConfig.epQueueUrl;
|
|
1322
|
+
if (match) {
|
|
1323
|
+
return;
|
|
1324
|
+
} else if (match$1 !== undefined) {
|
|
1325
|
+
match$1.apply(async url => {
|
|
1326
|
+
let queue = {
|
|
1327
|
+
id: url,
|
|
1328
|
+
name: "",
|
|
1329
|
+
arn: ""
|
|
1330
|
+
};
|
|
1331
|
+
let publishHeartbeat = CommandTopicChannel_SQS_Runtime$ReventlessAws.publishJsons(queue, "SQS_FIFO");
|
|
1332
|
+
let message_id = hbConfig.pluginId;
|
|
1333
|
+
let message_meta = Message$ReventlessCore.generateMeta(PluginExtensionPointSpec$ReventlessInfra.name, undefined, "DeployHeartbeat", undefined, undefined, undefined, undefined, undefined);
|
|
1334
|
+
let message_commandJson = S.reverseConvertToJsonOrThrow({
|
|
1335
|
+
TAG: "RedetectPlugin",
|
|
1336
|
+
_0: hbConfig.heartbeatTimeout
|
|
1337
|
+
}, PluginExtensionPointSpec$ReventlessInfra.commandSchema);
|
|
1338
|
+
let message = {
|
|
1339
|
+
id: message_id,
|
|
1340
|
+
meta: message_meta,
|
|
1341
|
+
commandJson: message_commandJson
|
|
1342
|
+
};
|
|
1343
|
+
try {
|
|
1344
|
+
await publishHeartbeat([message]);
|
|
1345
|
+
return log.info("Platform:deployPlugin", undefined, `synthetic re-detect published for ` + hbConfig.pluginId);
|
|
1346
|
+
} catch (exn) {
|
|
1347
|
+
return log.warn("Platform:deployPlugin", undefined, `synthetic heartbeat failed for ` + hbConfig.pluginId + ` (handover falls back to the next natural heartbeat)`);
|
|
1348
|
+
}
|
|
1349
|
+
});
|
|
1350
|
+
return;
|
|
1351
|
+
} else {
|
|
1352
|
+
return log.warn("Platform:deployPlugin", undefined, "synthetic heartbeat skipped: no EP queue URL in heartbeat config");
|
|
1353
|
+
}
|
|
1354
|
+
});
|
|
1351
1355
|
return Pulumi$Pulumi.getOutputs();
|
|
1352
1356
|
};
|
|
1353
1357
|
return {
|
|
@@ -2544,39 +2548,43 @@ function Make($star) {
|
|
|
2544
2548
|
if (sel !== undefined) {
|
|
2545
2549
|
PgQueryResolver_Builder$ReventlessAws.provision(domainApi, sel, {});
|
|
2546
2550
|
}
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
match$1
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
2551
|
+
pluginOutputs.heartbeat.apply(param => {
|
|
2552
|
+
let hbConfig = PluginRuntime_Builder$ReventlessAws.heartbeatConfigRef.contents;
|
|
2553
|
+
let match = IndexJs.isDryRun();
|
|
2554
|
+
let match$1 = hbConfig.epQueueUrl;
|
|
2555
|
+
if (match) {
|
|
2556
|
+
return;
|
|
2557
|
+
} else if (match$1 !== undefined) {
|
|
2558
|
+
match$1.apply(async url => {
|
|
2559
|
+
let queue = {
|
|
2560
|
+
id: url,
|
|
2561
|
+
name: "",
|
|
2562
|
+
arn: ""
|
|
2563
|
+
};
|
|
2564
|
+
let publishHeartbeat = CommandTopicChannel_SQS_Runtime$ReventlessAws.publishJsons(queue, "SQS_FIFO");
|
|
2565
|
+
let message_id = hbConfig.pluginId;
|
|
2566
|
+
let message_meta = Message$ReventlessCore.generateMeta(PluginExtensionPointSpec$ReventlessInfra.name, undefined, "DeployHeartbeat", undefined, undefined, undefined, undefined, undefined);
|
|
2567
|
+
let message_commandJson = S.reverseConvertToJsonOrThrow({
|
|
2568
|
+
TAG: "RedetectPlugin",
|
|
2569
|
+
_0: hbConfig.heartbeatTimeout
|
|
2570
|
+
}, PluginExtensionPointSpec$ReventlessInfra.commandSchema);
|
|
2571
|
+
let message = {
|
|
2572
|
+
id: message_id,
|
|
2573
|
+
meta: message_meta,
|
|
2574
|
+
commandJson: message_commandJson
|
|
2575
|
+
};
|
|
2576
|
+
try {
|
|
2577
|
+
await publishHeartbeat([message]);
|
|
2578
|
+
return log.info("Platform:deployPlugin", undefined, `synthetic re-detect published for ` + hbConfig.pluginId);
|
|
2579
|
+
} catch (exn) {
|
|
2580
|
+
return log.warn("Platform:deployPlugin", undefined, `synthetic heartbeat failed for ` + hbConfig.pluginId + ` (handover falls back to the next natural heartbeat)`);
|
|
2581
|
+
}
|
|
2582
|
+
});
|
|
2583
|
+
return;
|
|
2584
|
+
} else {
|
|
2585
|
+
return log.warn("Platform:deployPlugin", undefined, "synthetic heartbeat skipped: no EP queue URL in heartbeat config");
|
|
2586
|
+
}
|
|
2587
|
+
});
|
|
2580
2588
|
return Pulumi$Pulumi.getOutputs();
|
|
2581
2589
|
};
|
|
2582
2590
|
let StateViewSlice = {
|
|
@@ -16,6 +16,96 @@
|
|
|
16
16
|
let str = (item: dict<JSON.t>, key: string): option<string> =>
|
|
17
17
|
item->Dict.get(key)->Option.flatMap(JSON.Decode.string)
|
|
18
18
|
|
|
19
|
+
// Which list fields each entry collection's members must carry to satisfy the
|
|
20
|
+
// `[T!]!` shape the admin SDL declares, plus the nested member lists one level
|
|
21
|
+
// down (`references` on a command / event / error).
|
|
22
|
+
//
|
|
23
|
+
// This handler serves the persisted structure as raw JSON — nothing on the read
|
|
24
|
+
// path decodes it through `pluginStructureSchema` — so a component written by a
|
|
25
|
+
// deploy that predates one of these fields simply has no key for it. Absent
|
|
26
|
+
// resolves to null against a non-null list, GraphQL propagates that null up the
|
|
27
|
+
// unbroken non-null chain to the root, and the whole query answers `data: null`:
|
|
28
|
+
// ONE plugin still carrying an older structure blackholes every other plugin's
|
|
29
|
+
// components. A plugin whose version never changes (a private, unpublished stack)
|
|
30
|
+
// stays on its old structure indefinitely, so this is not a transient window.
|
|
31
|
+
//
|
|
32
|
+
// `[]` is what an absent array already means on every one of these fields — the
|
|
33
|
+
// structure is re-derived on every build, so it never encodes "cannot say" — which
|
|
34
|
+
// makes filling it in the honest read rather than a guess.
|
|
35
|
+
type healSpec = {lists: array<string>, nested: array<(string, array<string>)>}
|
|
36
|
+
|
|
37
|
+
let refLists = ["references"]
|
|
38
|
+
|
|
39
|
+
let writeSideHeal = {
|
|
40
|
+
lists: ["commands", "linkedViews", "producedEventTypes", "consumedEventTypes", "events", "errors"],
|
|
41
|
+
nested: [("commands", refLists), ("events", refLists), ("errors", refLists)],
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let readSideHeal = {
|
|
45
|
+
lists: ["consumedEventTypes", "linkedWriteSide", "searchableFields"],
|
|
46
|
+
nested: [],
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let healByCollection: array<(string, healSpec)> = [
|
|
50
|
+
("readModels", readSideHeal),
|
|
51
|
+
("stateViewSlices", readSideHeal),
|
|
52
|
+
("stateChangeSlices", writeSideHeal),
|
|
53
|
+
("aggregates", writeSideHeal),
|
|
54
|
+
("automationSlices", {lists: ["consumedEventTypes", "producedCommandTypes"], nested: []}),
|
|
55
|
+
(
|
|
56
|
+
"outboundTranslationSlices",
|
|
57
|
+
{lists: ["consumedEventTypes", "inboundCommandTypes"], nested: []},
|
|
58
|
+
),
|
|
59
|
+
("inboundTranslationSlices", {lists: ["commandTypes"], nested: []}),
|
|
60
|
+
("extensions", {lists: ["delegateNames", "eventTypes", "commandTypes"], nested: []}),
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
// Anything that is not already an array — missing, null, or a scalar written by a
|
|
64
|
+
// structure this handler cannot make sense of — becomes `[]`.
|
|
65
|
+
let fillLists = (obj: dict<JSON.t>, keys: array<string>): unit =>
|
|
66
|
+
keys->Array.forEach(k =>
|
|
67
|
+
switch obj->Dict.get(k) {
|
|
68
|
+
| Some(JSON.Array(_)) => ()
|
|
69
|
+
| _ => obj->Dict.set(k, JSON.Encode.array([]))
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
let mapMembers = (obj: dict<JSON.t>, key: string, f: dict<JSON.t> => unit): unit =>
|
|
74
|
+
switch obj->Dict.get(key)->Option.flatMap(JSON.Decode.array) {
|
|
75
|
+
| None => ()
|
|
76
|
+
| Some(members) =>
|
|
77
|
+
obj->Dict.set(
|
|
78
|
+
key,
|
|
79
|
+
members
|
|
80
|
+
->Array.map(m =>
|
|
81
|
+
switch m->JSON.Decode.object {
|
|
82
|
+
| None => m
|
|
83
|
+
| Some(mo) =>
|
|
84
|
+
let out = Dict.fromArray(mo->Dict.toArray)
|
|
85
|
+
f(out)
|
|
86
|
+
JSON.Encode.object(out)
|
|
87
|
+
}
|
|
88
|
+
)
|
|
89
|
+
->JSON.Encode.array,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let healStructure = (structure: dict<JSON.t>): dict<JSON.t> => {
|
|
94
|
+
let out = Dict.fromArray(structure->Dict.toArray)
|
|
95
|
+
// The collections themselves are `[T!]!` too, so an absent one is the same
|
|
96
|
+
// failure a scale smaller.
|
|
97
|
+
out->fillLists(healByCollection->Array.map(((collection, _)) => collection))
|
|
98
|
+
healByCollection->Array.forEach(((collection, spec)) =>
|
|
99
|
+
out->mapMembers(collection, component => {
|
|
100
|
+
component->fillLists(spec.lists)
|
|
101
|
+
spec.nested->Array.forEach(((key, keys)) =>
|
|
102
|
+
component->mapMembers(key, member => member->fillLists(keys))
|
|
103
|
+
)
|
|
104
|
+
})
|
|
105
|
+
)
|
|
106
|
+
out
|
|
107
|
+
}
|
|
108
|
+
|
|
19
109
|
// Mirror ReventlessCore.Platform_ComponentDefinitionsApi.isPublicQueryable on
|
|
20
110
|
// the read path: a component is public unless its visibility is "Internal".
|
|
21
111
|
let isPublicQueryable = (q: JSON.t): bool =>
|
|
@@ -26,22 +116,19 @@ let isPublicQueryable = (q: JSON.t): bool =>
|
|
|
26
116
|
|
|
27
117
|
// Drop Internal ReadModels / StateViewSlices from a persisted structure. A
|
|
28
118
|
// missing/non-array field defaults to `[]` (mirrors the JS `?? []`).
|
|
29
|
-
let filterStructure = (structure: JSON.t):
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
out->Dict.set("stateViewSlices", filterArr("stateViewSlices"))
|
|
43
|
-
Some(out)
|
|
44
|
-
}
|
|
119
|
+
let filterStructure = (structure: dict<JSON.t>): dict<JSON.t> => {
|
|
120
|
+
let out = Dict.fromArray(structure->Dict.toArray)
|
|
121
|
+
let filterArr = key =>
|
|
122
|
+
out
|
|
123
|
+
->Dict.get(key)
|
|
124
|
+
->Option.flatMap(JSON.Decode.array)
|
|
125
|
+
->Option.getOr([])
|
|
126
|
+
->Array.filter(isPublicQueryable)
|
|
127
|
+
->JSON.Encode.array
|
|
128
|
+
out->Dict.set("readModels", filterArr("readModels"))
|
|
129
|
+
out->Dict.set("stateViewSlices", filterArr("stateViewSlices"))
|
|
130
|
+
out
|
|
131
|
+
}
|
|
45
132
|
|
|
46
133
|
// `{pluginId: name, ...structure}` — pluginId first so the structure's fields win
|
|
47
134
|
// on the (never-expected) key collision, matching the JS spread. Drops a row with
|
|
@@ -51,12 +138,15 @@ let filterStructure = (structure: JSON.t): option<dict<JSON.t>> =>
|
|
|
51
138
|
// (`Platform_ComponentDefinitions`) gets the public-queryable subset, developer
|
|
52
139
|
// tooling (`Platform_PluginStructures`) gets the structure exactly as persisted,
|
|
53
140
|
// including Internal components and the producer-side `extensionPoints` that the
|
|
54
|
-
// AutoUI entry type does not declare.
|
|
141
|
+
// AutoUI entry type does not declare. Both are healed first — the SDL's non-null
|
|
142
|
+
// lists are the same on either field, so an unhealed structure breaks both.
|
|
55
143
|
let toEntryWith = (~filter: bool, item: dict<JSON.t>, ~name: string): option<JSON.t> => {
|
|
56
144
|
let structure =
|
|
57
145
|
item
|
|
58
146
|
->Dict.get("structure")
|
|
59
|
-
->Option.flatMap(
|
|
147
|
+
->Option.flatMap(JSON.Decode.object)
|
|
148
|
+
->Option.map(healStructure)
|
|
149
|
+
->Option.map(s => filter ? filterStructure(s) : s)
|
|
60
150
|
switch structure {
|
|
61
151
|
| None => None
|
|
62
152
|
| Some(structureObj) =>
|
|
@@ -12,6 +12,149 @@ function str(item, key) {
|
|
|
12
12
|
return Stdlib_Option.flatMap(item[key], Stdlib_JSON.Decode.string);
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
let refLists = ["references"];
|
|
16
|
+
|
|
17
|
+
let writeSideHeal_lists = [
|
|
18
|
+
"commands",
|
|
19
|
+
"linkedViews",
|
|
20
|
+
"producedEventTypes",
|
|
21
|
+
"consumedEventTypes",
|
|
22
|
+
"events",
|
|
23
|
+
"errors"
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
let writeSideHeal_nested = [
|
|
27
|
+
[
|
|
28
|
+
"commands",
|
|
29
|
+
refLists
|
|
30
|
+
],
|
|
31
|
+
[
|
|
32
|
+
"events",
|
|
33
|
+
refLists
|
|
34
|
+
],
|
|
35
|
+
[
|
|
36
|
+
"errors",
|
|
37
|
+
refLists
|
|
38
|
+
]
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
let writeSideHeal = {
|
|
42
|
+
lists: writeSideHeal_lists,
|
|
43
|
+
nested: writeSideHeal_nested
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
let readSideHeal_lists = [
|
|
47
|
+
"consumedEventTypes",
|
|
48
|
+
"linkedWriteSide",
|
|
49
|
+
"searchableFields"
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
let readSideHeal_nested = [];
|
|
53
|
+
|
|
54
|
+
let readSideHeal = {
|
|
55
|
+
lists: readSideHeal_lists,
|
|
56
|
+
nested: readSideHeal_nested
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
let healByCollection = [
|
|
60
|
+
[
|
|
61
|
+
"readModels",
|
|
62
|
+
readSideHeal
|
|
63
|
+
],
|
|
64
|
+
[
|
|
65
|
+
"stateViewSlices",
|
|
66
|
+
readSideHeal
|
|
67
|
+
],
|
|
68
|
+
[
|
|
69
|
+
"stateChangeSlices",
|
|
70
|
+
writeSideHeal
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
"aggregates",
|
|
74
|
+
writeSideHeal
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
"automationSlices",
|
|
78
|
+
{
|
|
79
|
+
lists: [
|
|
80
|
+
"consumedEventTypes",
|
|
81
|
+
"producedCommandTypes"
|
|
82
|
+
],
|
|
83
|
+
nested: []
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
[
|
|
87
|
+
"outboundTranslationSlices",
|
|
88
|
+
{
|
|
89
|
+
lists: [
|
|
90
|
+
"consumedEventTypes",
|
|
91
|
+
"inboundCommandTypes"
|
|
92
|
+
],
|
|
93
|
+
nested: []
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
[
|
|
97
|
+
"inboundTranslationSlices",
|
|
98
|
+
{
|
|
99
|
+
lists: ["commandTypes"],
|
|
100
|
+
nested: []
|
|
101
|
+
}
|
|
102
|
+
],
|
|
103
|
+
[
|
|
104
|
+
"extensions",
|
|
105
|
+
{
|
|
106
|
+
lists: [
|
|
107
|
+
"delegateNames",
|
|
108
|
+
"eventTypes",
|
|
109
|
+
"commandTypes"
|
|
110
|
+
],
|
|
111
|
+
nested: []
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
];
|
|
115
|
+
|
|
116
|
+
function fillLists(obj, keys) {
|
|
117
|
+
keys.forEach(k => {
|
|
118
|
+
let match = obj[k];
|
|
119
|
+
if (Array.isArray(match)) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
obj[k] = [];
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function mapMembers(obj, key, f) {
|
|
127
|
+
let members = Stdlib_Option.flatMap(obj[key], Stdlib_JSON.Decode.array);
|
|
128
|
+
if (members !== undefined) {
|
|
129
|
+
obj[key] = members.map(m => {
|
|
130
|
+
let mo = Stdlib_JSON.Decode.object(m);
|
|
131
|
+
if (mo === undefined) {
|
|
132
|
+
return m;
|
|
133
|
+
}
|
|
134
|
+
let out = Object.fromEntries(Object.entries(mo));
|
|
135
|
+
f(out);
|
|
136
|
+
return out;
|
|
137
|
+
});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function healStructure(structure) {
|
|
143
|
+
let out = Object.fromEntries(Object.entries(structure));
|
|
144
|
+
fillLists(out, healByCollection.map(param => param[0]));
|
|
145
|
+
healByCollection.forEach(param => {
|
|
146
|
+
let spec = param[1];
|
|
147
|
+
mapMembers(out, param[0], component => {
|
|
148
|
+
fillLists(component, spec.lists);
|
|
149
|
+
spec.nested.forEach(param => {
|
|
150
|
+
let keys = param[1];
|
|
151
|
+
mapMembers(component, param[0], member => fillLists(member, keys));
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
return out;
|
|
156
|
+
}
|
|
157
|
+
|
|
15
158
|
function isPublicQueryable(q) {
|
|
16
159
|
let match = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_JSON.Decode.object(q), o => o["visibility"]), Stdlib_JSON.Decode.string);
|
|
17
160
|
if (match !== undefined) {
|
|
@@ -22,11 +165,7 @@ function isPublicQueryable(q) {
|
|
|
22
165
|
}
|
|
23
166
|
|
|
24
167
|
function filterStructure(structure) {
|
|
25
|
-
let
|
|
26
|
-
if (obj === undefined) {
|
|
27
|
-
return;
|
|
28
|
-
}
|
|
29
|
-
let out = Object.fromEntries(Object.entries(obj));
|
|
168
|
+
let out = Object.fromEntries(Object.entries(structure));
|
|
30
169
|
let filterArr = key => Stdlib_Option.getOr(Stdlib_Option.flatMap(out[key], Stdlib_JSON.Decode.array), []).filter(isPublicQueryable);
|
|
31
170
|
out["readModels"] = filterArr("readModels");
|
|
32
171
|
out["stateViewSlices"] = filterArr("stateViewSlices");
|
|
@@ -34,11 +173,11 @@ function filterStructure(structure) {
|
|
|
34
173
|
}
|
|
35
174
|
|
|
36
175
|
function toEntryWith(filter, item, name) {
|
|
37
|
-
let structure = Stdlib_Option.flatMap(item["structure"], s => {
|
|
176
|
+
let structure = Stdlib_Option.map(Stdlib_Option.map(Stdlib_Option.flatMap(item["structure"], Stdlib_JSON.Decode.object), healStructure), s => {
|
|
38
177
|
if (filter) {
|
|
39
178
|
return filterStructure(s);
|
|
40
179
|
} else {
|
|
41
|
-
return
|
|
180
|
+
return s;
|
|
42
181
|
}
|
|
43
182
|
});
|
|
44
183
|
if (structure === undefined) {
|
|
@@ -111,6 +250,13 @@ async function handler(event) {
|
|
|
111
250
|
|
|
112
251
|
export {
|
|
113
252
|
str,
|
|
253
|
+
refLists,
|
|
254
|
+
writeSideHeal,
|
|
255
|
+
readSideHeal,
|
|
256
|
+
healByCollection,
|
|
257
|
+
fillLists,
|
|
258
|
+
mapMembers,
|
|
259
|
+
healStructure,
|
|
114
260
|
isPublicQueryable,
|
|
115
261
|
filterStructure,
|
|
116
262
|
toEntryWith,
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Guards the read-path healing in Platform_ComponentDefinitions_Lambda_Ops.
|
|
2
|
+
//
|
|
3
|
+
// The handler serves a persisted plugin structure as raw JSON — it is never
|
|
4
|
+
// decoded through `pluginStructureSchema` — so a structure written before a
|
|
5
|
+
// required list field existed reaches the resolver without that key. Against the
|
|
6
|
+
// admin SDL's `[T!]!` the missing key resolves to null, GraphQL propagates the
|
|
7
|
+
// null up an unbroken non-null chain, and the entire query answers `data: null`.
|
|
8
|
+
// One plugin on an older structure therefore blackholes every other plugin.
|
|
9
|
+
//
|
|
10
|
+
// A plugin whose version never changes never re-runs the connect handshake, so it
|
|
11
|
+
// keeps its old structure indefinitely — the reason this is worth healing rather
|
|
12
|
+
// than waiting out.
|
|
13
|
+
|
|
14
|
+
open JestGlobals
|
|
15
|
+
|
|
16
|
+
let entry = (~filter, structure: JSON.t): option<dict<JSON.t>> =>
|
|
17
|
+
Platform_ComponentDefinitions_Lambda_Ops.toEntryWith(
|
|
18
|
+
~filter,
|
|
19
|
+
Dict.fromArray([("structure", structure)]),
|
|
20
|
+
~name="Ordering",
|
|
21
|
+
)->Option.flatMap(JSON.Decode.object)
|
|
22
|
+
|
|
23
|
+
let members = (entry: dict<JSON.t>, collection: string): array<dict<JSON.t>> =>
|
|
24
|
+
entry
|
|
25
|
+
->Dict.get(collection)
|
|
26
|
+
->Option.flatMap(JSON.Decode.array)
|
|
27
|
+
->Option.getOr([])
|
|
28
|
+
->Array.filterMap(JSON.Decode.object)
|
|
29
|
+
|
|
30
|
+
// A state-change slice as persisted before `errors` (and before `references` on a
|
|
31
|
+
// command) existed: every other required list is present.
|
|
32
|
+
let preErrorsSlice = JSON.parseOrThrow(`{
|
|
33
|
+
"name": "PlaceOrder",
|
|
34
|
+
"commands": [{"name": "PlaceOrder", "schema": "{}", "level": "Collection", "mutationField": "placeOrder"}],
|
|
35
|
+
"linkedViews": [],
|
|
36
|
+
"producedEventTypes": ["OrderPlaced"],
|
|
37
|
+
"consumedEventTypes": [],
|
|
38
|
+
"events": []
|
|
39
|
+
}`)
|
|
40
|
+
|
|
41
|
+
let preErrorsStructure = Dict.fromArray([
|
|
42
|
+
("stateChangeSlices", JSON.Encode.array([preErrorsSlice])),
|
|
43
|
+
])->JSON.Encode.object
|
|
44
|
+
|
|
45
|
+
describe("toEntryWith heals a structure persisted before a required list existed", () => {
|
|
46
|
+
testSync("fills the absent list on the component", () => {
|
|
47
|
+
let slice =
|
|
48
|
+
entry(~filter=false, preErrorsStructure)
|
|
49
|
+
->Option.map(members(_, "stateChangeSlices"))
|
|
50
|
+
->Option.flatMap(Array.get(_, 0))
|
|
51
|
+
expect(slice->Option.flatMap(s => s->Dict.get("errors")))->toEqual(
|
|
52
|
+
Some(JSON.Encode.array([])),
|
|
53
|
+
)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
testSync("fills the absent list one level down, on a command's references", () => {
|
|
57
|
+
let command =
|
|
58
|
+
entry(~filter=false, preErrorsStructure)
|
|
59
|
+
->Option.map(members(_, "stateChangeSlices"))
|
|
60
|
+
->Option.flatMap(Array.get(_, 0))
|
|
61
|
+
->Option.flatMap(s => s->Dict.get("commands"))
|
|
62
|
+
->Option.flatMap(JSON.Decode.array)
|
|
63
|
+
->Option.flatMap(Array.get(_, 0))
|
|
64
|
+
->Option.flatMap(JSON.Decode.object)
|
|
65
|
+
expect(command->Option.flatMap(c => c->Dict.get("references")))->toEqual(
|
|
66
|
+
Some(JSON.Encode.array([])),
|
|
67
|
+
)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
testSync("fills an absent collection, which is a non-null list of its own", () => {
|
|
71
|
+
let e = entry(~filter=false, preErrorsStructure)
|
|
72
|
+
expect(e->Option.flatMap(o => o->Dict.get("aggregates")))->toEqual(
|
|
73
|
+
Some(JSON.Encode.array([])),
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
testSync("leaves a present list untouched", () => {
|
|
78
|
+
let slice =
|
|
79
|
+
entry(~filter=false, preErrorsStructure)
|
|
80
|
+
->Option.map(members(_, "stateChangeSlices"))
|
|
81
|
+
->Option.flatMap(Array.get(_, 0))
|
|
82
|
+
expect(slice->Option.flatMap(s => s->Dict.get("producedEventTypes")))->toEqual(
|
|
83
|
+
Some(JSON.Encode.array([JSON.Encode.string("OrderPlaced")])),
|
|
84
|
+
)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
testSync("heals the filtered field too — both fields share the component types", () => {
|
|
88
|
+
let slice =
|
|
89
|
+
entry(~filter=true, preErrorsStructure)
|
|
90
|
+
->Option.map(members(_, "stateChangeSlices"))
|
|
91
|
+
->Option.flatMap(Array.get(_, 0))
|
|
92
|
+
expect(slice->Option.flatMap(s => s->Dict.get("errors")))->toEqual(
|
|
93
|
+
Some(JSON.Encode.array([])),
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
testSync("still drops Internal read models when filtering", () => {
|
|
98
|
+
let structure = Dict.fromArray([
|
|
99
|
+
(
|
|
100
|
+
"readModels",
|
|
101
|
+
JSON.Encode.array([
|
|
102
|
+
JSON.parseOrThrow(`{"name": "Orders", "visibility": "Internal"}`),
|
|
103
|
+
JSON.parseOrThrow(`{"name": "Products"}`),
|
|
104
|
+
]),
|
|
105
|
+
),
|
|
106
|
+
])->JSON.Encode.object
|
|
107
|
+
let names =
|
|
108
|
+
entry(~filter=true, structure)
|
|
109
|
+
->Option.map(members(_, "readModels"))
|
|
110
|
+
->Option.getOr([])
|
|
111
|
+
->Array.filterMap(m => m->Dict.get("name")->Option.flatMap(JSON.Decode.string))
|
|
112
|
+
expect(names)->toEqual(["Products"])
|
|
113
|
+
})
|
|
114
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
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_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
5
|
+
import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
6
|
+
import * as Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws from "../src/adapter/Api/Platform_ComponentDefinitions_Lambda_Ops.res.mjs";
|
|
7
|
+
|
|
8
|
+
function entry(filter, structure) {
|
|
9
|
+
return Stdlib_Option.flatMap(Platform_ComponentDefinitions_Lambda_Ops$ReventlessAws.toEntryWith(filter, Object.fromEntries([[
|
|
10
|
+
"structure",
|
|
11
|
+
structure
|
|
12
|
+
]]), "Ordering"), Stdlib_JSON.Decode.object);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function members(entry, collection) {
|
|
16
|
+
return Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.flatMap(entry[collection], Stdlib_JSON.Decode.array), []), Stdlib_JSON.Decode.object);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let preErrorsSlice = JSON.parse(`{
|
|
20
|
+
"name": "PlaceOrder",
|
|
21
|
+
"commands": [{"name": "PlaceOrder", "schema": "{}", "level": "Collection", "mutationField": "placeOrder"}],
|
|
22
|
+
"linkedViews": [],
|
|
23
|
+
"producedEventTypes": ["OrderPlaced"],
|
|
24
|
+
"consumedEventTypes": [],
|
|
25
|
+
"events": []
|
|
26
|
+
}`);
|
|
27
|
+
|
|
28
|
+
let preErrorsStructure = Object.fromEntries([[
|
|
29
|
+
"stateChangeSlices",
|
|
30
|
+
[preErrorsSlice]
|
|
31
|
+
]]);
|
|
32
|
+
|
|
33
|
+
globalThis.describe("toEntryWith heals a structure persisted before a required list existed", () => {
|
|
34
|
+
globalThis.test("fills the absent list on the component", () => {
|
|
35
|
+
let slice = Stdlib_Option.flatMap(Stdlib_Option.map(entry(false, preErrorsStructure), __x => members(__x, "stateChangeSlices")), __x => __x[0]);
|
|
36
|
+
globalThis.expect(Stdlib_Option.flatMap(slice, s => s["errors"])).toEqual([]);
|
|
37
|
+
});
|
|
38
|
+
globalThis.test("fills the absent list one level down, on a command's references", () => {
|
|
39
|
+
let command = Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.flatMap(Stdlib_Option.map(entry(false, preErrorsStructure), __x => members(__x, "stateChangeSlices")), __x => __x[0]), s => s["commands"]), Stdlib_JSON.Decode.array), __x => __x[0]), Stdlib_JSON.Decode.object);
|
|
40
|
+
globalThis.expect(Stdlib_Option.flatMap(command, c => c["references"])).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
globalThis.test("fills an absent collection, which is a non-null list of its own", () => {
|
|
43
|
+
let e = entry(false, preErrorsStructure);
|
|
44
|
+
globalThis.expect(Stdlib_Option.flatMap(e, o => o["aggregates"])).toEqual([]);
|
|
45
|
+
});
|
|
46
|
+
globalThis.test("leaves a present list untouched", () => {
|
|
47
|
+
let slice = Stdlib_Option.flatMap(Stdlib_Option.map(entry(false, preErrorsStructure), __x => members(__x, "stateChangeSlices")), __x => __x[0]);
|
|
48
|
+
globalThis.expect(Stdlib_Option.flatMap(slice, s => s["producedEventTypes"])).toEqual(["OrderPlaced"]);
|
|
49
|
+
});
|
|
50
|
+
globalThis.test("heals the filtered field too — both fields share the component types", () => {
|
|
51
|
+
let slice = Stdlib_Option.flatMap(Stdlib_Option.map(entry(true, preErrorsStructure), __x => members(__x, "stateChangeSlices")), __x => __x[0]);
|
|
52
|
+
globalThis.expect(Stdlib_Option.flatMap(slice, s => s["errors"])).toEqual([]);
|
|
53
|
+
});
|
|
54
|
+
globalThis.test("still drops Internal read models when filtering", () => {
|
|
55
|
+
let structure = Object.fromEntries([[
|
|
56
|
+
"readModels",
|
|
57
|
+
[
|
|
58
|
+
JSON.parse(`{"name": "Orders", "visibility": "Internal"}`),
|
|
59
|
+
JSON.parse(`{"name": "Products"}`)
|
|
60
|
+
]
|
|
61
|
+
]]);
|
|
62
|
+
let names = Stdlib_Array.filterMap(Stdlib_Option.getOr(Stdlib_Option.map(entry(true, structure), __x => members(__x, "readModels")), []), m => Stdlib_Option.flatMap(m["name"], Stdlib_JSON.Decode.string));
|
|
63
|
+
globalThis.expect(names).toEqual(["Products"]);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
export {
|
|
68
|
+
entry,
|
|
69
|
+
members,
|
|
70
|
+
preErrorsSlice,
|
|
71
|
+
preErrorsStructure,
|
|
72
|
+
}
|
|
73
|
+
/* preErrorsSlice Not a pure module */
|