@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 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.274",
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.4",
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-effect": "0.1.0-alpha.32",
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-core": "3.0.0-alpha.217",
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-infra": "3.0.0-alpha.128",
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, and registerHeartbeatConfig ran during P.make()).
2476
- let hbConfig = PluginRuntime_Builder.heartbeatConfigRef.contents
2477
- switch (Pulumi.Pulumi.isDryRun(), hbConfig.epQueueUrl) {
2478
- | (true, _) => () // `pulumi preview` no deploy-time side effects
2479
- | (false, Some(epQueueUrl)) =>
2480
- let _ = epQueueUrl->Pulumi.Output.apply(async url => {
2481
- let queue: Util_SQS_Runtime.resolvedQueue = {id: url, name: "", arn: ""}
2482
- let publishHeartbeat = queue->CommandTopicChannel_SQS_Runtime.publishJsons(AWS.SQS_FIFO)
2483
- let message: ReventlessCore.Message.commandJson = {
2484
- id: hbConfig.pluginId,
2485
- meta: ReventlessCore.Message.generateMeta(
2486
- ~service=ReventlessInfra.PluginExtensionPointSpec.name,
2487
- ~user="DeployHeartbeat",
2488
- ),
2489
- commandJson: ReventlessInfra.PluginExtensionPointSpec.RedetectPlugin(
2490
- hbConfig.heartbeatTimeout,
2491
- )->S.reverseConvertToJsonOrThrow(ReventlessInfra.PluginExtensionPointSpec.commandSchema),
2492
- }
2493
- try await publishHeartbeat([message]) catch {
2494
- | _ =>
2495
- log.warn(
2496
- ~comp="Platform:deployPlugin",
2497
- `synthetic heartbeat failed for ${hbConfig.pluginId} (handover falls back to the next natural heartbeat)`,
2498
- )
2499
- }
2500
- })
2501
- | (false, None) =>
2502
- log.warn(
2503
- ~comp="Platform:deployPlugin",
2504
- "synthetic heartbeat skipped: no EP queue URL in heartbeat config",
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
  }
@@ -1315,39 +1315,43 @@ function MakeWithConfig(Config) {
1315
1315
  if (sel !== undefined) {
1316
1316
  PgQueryResolver_Builder$ReventlessAws.provision(domainApi, sel, {});
1317
1317
  }
1318
- let hbConfig = PluginRuntime_Builder$ReventlessAws.heartbeatConfigRef.contents;
1319
- let match = IndexJs.isDryRun();
1320
- let match$1 = hbConfig.epQueueUrl;
1321
- if (match) {
1322
-
1323
- } else if (match$1 !== undefined) {
1324
- match$1.apply(async url => {
1325
- let queue = {
1326
- id: url,
1327
- name: "",
1328
- arn: ""
1329
- };
1330
- let publishHeartbeat = CommandTopicChannel_SQS_Runtime$ReventlessAws.publishJsons(queue, "SQS_FIFO");
1331
- let message_id = hbConfig.pluginId;
1332
- let message_meta = Message$ReventlessCore.generateMeta(PluginExtensionPointSpec$ReventlessInfra.name, undefined, "DeployHeartbeat", undefined, undefined, undefined, undefined, undefined);
1333
- let message_commandJson = S.reverseConvertToJsonOrThrow({
1334
- TAG: "RedetectPlugin",
1335
- _0: hbConfig.heartbeatTimeout
1336
- }, PluginExtensionPointSpec$ReventlessInfra.commandSchema);
1337
- let message = {
1338
- id: message_id,
1339
- meta: message_meta,
1340
- commandJson: message_commandJson
1341
- };
1342
- try {
1343
- return await publishHeartbeat([message]);
1344
- } catch (exn) {
1345
- return log.warn("Platform:deployPlugin", undefined, `synthetic heartbeat failed for ` + hbConfig.pluginId + ` (handover falls back to the next natural heartbeat)`);
1346
- }
1347
- });
1348
- } else {
1349
- log.warn("Platform:deployPlugin", undefined, "synthetic heartbeat skipped: no EP queue URL in heartbeat config");
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
- let hbConfig = PluginRuntime_Builder$ReventlessAws.heartbeatConfigRef.contents;
2548
- let match = IndexJs.isDryRun();
2549
- let match$1 = hbConfig.epQueueUrl;
2550
- if (match) {
2551
-
2552
- } else if (match$1 !== undefined) {
2553
- match$1.apply(async url => {
2554
- let queue = {
2555
- id: url,
2556
- name: "",
2557
- arn: ""
2558
- };
2559
- let publishHeartbeat = CommandTopicChannel_SQS_Runtime$ReventlessAws.publishJsons(queue, "SQS_FIFO");
2560
- let message_id = hbConfig.pluginId;
2561
- let message_meta = Message$ReventlessCore.generateMeta(PluginExtensionPointSpec$ReventlessInfra.name, undefined, "DeployHeartbeat", undefined, undefined, undefined, undefined, undefined);
2562
- let message_commandJson = S.reverseConvertToJsonOrThrow({
2563
- TAG: "RedetectPlugin",
2564
- _0: hbConfig.heartbeatTimeout
2565
- }, PluginExtensionPointSpec$ReventlessInfra.commandSchema);
2566
- let message = {
2567
- id: message_id,
2568
- meta: message_meta,
2569
- commandJson: message_commandJson
2570
- };
2571
- try {
2572
- return await publishHeartbeat([message]);
2573
- } catch (exn) {
2574
- return log.warn("Platform:deployPlugin", undefined, `synthetic heartbeat failed for ` + hbConfig.pluginId + ` (handover falls back to the next natural heartbeat)`);
2575
- }
2576
- });
2577
- } else {
2578
- log.warn("Platform:deployPlugin", undefined, "synthetic heartbeat skipped: no EP queue URL in heartbeat config");
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): option<dict<JSON.t>> =>
30
- switch structure->JSON.Decode.object {
31
- | None => None
32
- | Some(obj) =>
33
- let out = Dict.fromArray(obj->Dict.toArray)
34
- let filterArr = key =>
35
- out
36
- ->Dict.get(key)
37
- ->Option.flatMap(JSON.Decode.array)
38
- ->Option.getOr([])
39
- ->Array.filter(isPublicQueryable)
40
- ->JSON.Encode.array
41
- out->Dict.set("readModels", filterArr("readModels"))
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(s => filter ? filterStructure(s) : s->JSON.Decode.object)
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 obj = Stdlib_JSON.Decode.object(structure);
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 Stdlib_JSON.Decode.object(s);
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 */