@reventlessdev/reventless-aws 3.0.0-alpha.197 → 3.0.0-alpha.198

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.
Files changed (28) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +6 -6
  3. package/src/Platform.res +290 -44
  4. package/src/Platform.res.mjs +416 -90
  5. package/src/adapter/Api/ApiFragmentDeregistration.res +137 -0
  6. package/src/adapter/Api/ApiFragmentDeregistration.res.mjs +108 -0
  7. package/src/adapter/Api/CommandSubscriptionResolvers_AppSync.res +4 -3
  8. package/src/adapter/Api/Platform_ApiFragments_Lambda.res +212 -0
  9. package/src/adapter/Api/Platform_ApiFragments_Lambda.res.mjs +202 -0
  10. package/src/adapter/Api/Platform_UIFragments_Lambda.res +8 -8
  11. package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +11 -11
  12. package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync.res +2 -1
  13. package/src/adapter/QueryDb/QueryDbBackend.res +1 -1
  14. package/src/adapter/Runtime/AdminEventCollectorEntryPoint.mjs +235 -9
  15. package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res +4 -0
  16. package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res.mjs +5 -0
  17. package/src/components/Api/AppSync_Adapter.res +36 -59
  18. package/src/components/Api/AppSync_Adapter.res.mjs +18 -74
  19. package/src/components/Api/AppSync_SdlDecorate.res +189 -0
  20. package/src/components/Api/AppSync_SdlDecorate.res.mjs +125 -0
  21. package/src/plugin/runtime/PluginRuntime_Builder.res +107 -2
  22. package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +70 -7
  23. package/src/util/Util_AppSync_Caller.res +12 -4
  24. package/src/util/Util_AppSync_Caller.res.mjs +9 -3
  25. package/tests/AppSync_AdapterTest.res +6 -0
  26. package/tests/AppSync_AdapterTest.res.mjs +12 -6
  27. package/tests/AppSync_SdlDecorateTest.res +157 -0
  28. package/tests/AppSync_SdlDecorateTest.res.mjs +147 -0
@@ -1,7 +1,7 @@
1
1
  // AWS resolver for the `Platform_UIFragments` admin GraphQL query.
2
2
  //
3
- // Backed by a Lambda DataSource that scans the UIFragmentRegistry read model
4
- // (one item per plugin whose pluginDefinition carries a uiFragments manifest).
3
+ // Backed by a Lambda DataSource that scans the UiFragments StateViewSlice table
4
+ // (one item per plugin with a registered UI-fragment manifest).
5
5
  // The persisted state is sury-encoded with the same shape the in-memory adapter's
6
6
  // `Platform_UIFragmentsApi.encodeUIFragmentEntry` produces (null-encoded options,
7
7
  // nested panel/page objects), so the handler simply returns the rows as-is.
@@ -34,11 +34,11 @@ export async function handler() {
34
34
  } while (exclusiveStartKey);
35
35
 
36
36
  // Platform invariant: one version per plugin at a time, so the UI sees just
37
- // the bare plugin name (mirrors ReventlessCore.Plugin.name). UIFragmentRegistry
38
- // accumulates one row per deployed plugin version and carries no lifecycle
39
- // status of its own, so without deduping a redeployed federation plugin would
40
- // surface duplicate fragments. Collapse to the highest version per plugin name
41
- // (mirrors ReventlessCore.Plugin.compareVersions).
37
+ // the bare plugin name (mirrors ReventlessCore.Plugin.name). The registry is
38
+ // keyed by bare plugin name (a no-op for the split below), but rows persisted
39
+ // by the pre-slice registry were keyed name@version keep the collapse to the
40
+ // highest version per plugin name (mirrors ReventlessCore.Plugin.compareVersions)
41
+ // so a mixed table never surfaces duplicate fragments.
42
42
  const cmpVer = (a, b) => {
43
43
  const pa = String(a).replace(/[-+]/g, ".").split(".");
44
44
  const pb = String(b).replace(/[-+]/g, ".").split(".");
@@ -117,7 +117,7 @@ let make = (
117
117
  resources: Resource("arn:aws:logs:*:*:*"),
118
118
  },
119
119
  {
120
- sid: "AllowScanUIFragmentRegistryRm",
120
+ sid: "AllowScanUiFragmentsTable",
121
121
  effect: Allow,
122
122
  actions: Actions(["dynamodb:Scan"]),
123
123
  resources: Resource("arn:aws:dynamodb:*:*:table/" ++ tableName),
@@ -40,11 +40,11 @@ export async function handler() {
40
40
  } while (exclusiveStartKey);
41
41
 
42
42
  // Platform invariant: one version per plugin at a time, so the UI sees just
43
- // the bare plugin name (mirrors ReventlessCore.Plugin.name). UIFragmentRegistry
44
- // accumulates one row per deployed plugin version and carries no lifecycle
45
- // status of its own, so without deduping a redeployed federation plugin would
46
- // surface duplicate fragments. Collapse to the highest version per plugin name
47
- // (mirrors ReventlessCore.Plugin.compareVersions).
43
+ // the bare plugin name (mirrors ReventlessCore.Plugin.name). The registry is
44
+ // keyed by bare plugin name (a no-op for the split below), but rows persisted
45
+ // by the pre-slice registry were keyed name@version keep the collapse to the
46
+ // highest version per plugin name (mirrors ReventlessCore.Plugin.compareVersions)
47
+ // so a mixed table never surfaces duplicate fragments.
48
48
  const cmpVer = (a, b) => {
49
49
  const pa = String(a).replace(/[-+]/g, ".").split(".");
50
50
  const pb = String(b).replace(/[-+]/g, ".").split(".");
@@ -108,7 +108,7 @@ function make(api, uiFragmentRegistryTableName, opts) {
108
108
  Resource: "arn:aws:logs:*:*:*"
109
109
  },
110
110
  {
111
- Sid: "AllowScanUIFragmentRegistryRm",
111
+ Sid: "AllowScanUiFragmentsTable",
112
112
  Effect: "Allow",
113
113
  Action: ["dynamodb:Scan"],
114
114
  Resource: "arn:aws:dynamodb:*:*:table/" + tableName
@@ -143,11 +143,11 @@ export async function handler() {
143
143
  } while (exclusiveStartKey);
144
144
 
145
145
  // Platform invariant: one version per plugin at a time, so the UI sees just
146
- // the bare plugin name (mirrors ReventlessCore.Plugin.name). UIFragmentRegistry
147
- // accumulates one row per deployed plugin version and carries no lifecycle
148
- // status of its own, so without deduping a redeployed federation plugin would
149
- // surface duplicate fragments. Collapse to the highest version per plugin name
150
- // (mirrors ReventlessCore.Plugin.compareVersions).
146
+ // the bare plugin name (mirrors ReventlessCore.Plugin.name). The registry is
147
+ // keyed by bare plugin name (a no-op for the split below), but rows persisted
148
+ // by the pre-slice registry were keyed name@version keep the collapse to the
149
+ // highest version per plugin name (mirrors ReventlessCore.Plugin.compareVersions)
150
+ // so a mixed table never surfaces duplicate fragments.
151
151
  const cmpVer = (a, b) => {
152
152
  const pa = String(a).replace(/[-+]/g, ".").split(".");
153
153
  const pb = String(b).replace(/[-+]/g, ".").split(".");
@@ -146,7 +146,8 @@ let make: ReventlessCore.CommandGenerator_Adapter.resolversMaker<api, Util.Lambd
146
146
  })
147
147
 
148
148
  // Source C: create Subscription.onX resolver for each mutation field.
149
- // @aws_subscribe in the SDL (emitted by Plugin_SubscriptionSchema) handles
149
+ // @aws_subscribe in the pushed SDL (appended by AppSync_SdlDecorate from the
150
+ // fragment's subscription-source metadata) handles
150
151
  // delivery. AWS requires a dataSourceName even on UNIT subscription resolvers,
151
152
  // so we reuse the mutation's data source (its code never executes for subs).
152
153
  CommandSubscriptionResolvers_AppSync.make(
@@ -13,7 +13,7 @@
13
13
  // Postgres-backed handlers' HANDLER_CONFIG entries and put the projection
14
14
  // Lambdas in-VPC with secret access.
15
15
  //
16
- // ADMIN EXEMPTION: platform/admin read models (Plugins, UIFragmentRegistry, …)
16
+ // ADMIN EXEMPTION: platform/admin stores (Plugins, UiFragments, …)
17
17
  // stay on DynamoDB even when Postgres is selected. Deploy-time consumers (the
18
18
  // AppSync schema-clobber guard's Plugin-RM scan, `PLUGIN_RM_TABLE_NAME` gates,
19
19
  // retire hooks) query these tables during `pulumi up` — from outside the VPC —
@@ -83,8 +83,10 @@ import {
83
83
  decode as decodeFragment,
84
84
  countRootTypeFields,
85
85
  isCatastrophicSchemaShrink,
86
+ collectSubscriptionSources,
86
87
  } from "@reventlessdev/reventless-core/src/components/Api/GraphQL_Stitcher.res.mjs";
87
- import { baseFragment as adminBaseFragment } from "@reventlessdev/reventless-core/src/admin/AdminApi.res.mjs";
88
+ import { injectAwsSubscribe, planAwsPushes } from "@reventlessdev/reventless-aws/src/components/Api/AppSync_SdlDecorate.res.mjs";
89
+ import { baseFragment as adminBaseFragment, systemCallerFieldNames } from "@reventlessdev/reventless-core/src/admin/AdminApi.res.mjs";
88
90
  import { tag as requestContextTag } from "@reventlessdev/reventless-core/src/RequestContext.res.mjs";
89
91
 
90
92
  // The Plugin RM state schema uses `@s.matches(_jsNullable …)` for many
@@ -168,6 +170,13 @@ function parseHandlerConfig(rawJson) {
168
170
  // Optional fields with empty defaults — admin context omits them entirely.
169
171
  if (config.readModelQueueUrls === undefined) config.readModelQueueUrls = {};
170
172
  if (config.readModelNamesForSourceName === undefined) config.readModelNamesForSourceName = {};
173
+ // Reactive ApiFragmentRegistry single-writer (2e) — only the admin EventCollector
174
+ // carries a real registry table + admin DCB command topic; plugin ECs default to
175
+ // placeholders which disable the reactive push.
176
+ if (config.apiFragmentRegistryTableName === undefined) config.apiFragmentRegistryTableName = "NOT_AVAILABLE";
177
+ if (config.platformApiId === undefined) config.platformApiId = "NOT_AVAILABLE";
178
+ if (config.adminDcbCmdTopicUrl === undefined) config.adminDcbCmdTopicUrl = "";
179
+ if (config.splitApi === undefined) config.splitApi = false;
171
180
  // Per-extension defaults so older serialisers (or partial admin configs) keep working.
172
181
  for (const ext of config.extensions) {
173
182
  if (!Array.isArray(ext.aggregateNames)) ext.aggregateNames = [];
@@ -518,8 +527,14 @@ function mkUpdateApiSchema(schemaTableName, apiId, clonerEnabled) {
518
527
  fragments = resolved.map((json) => json && json.apiSchemaFragment).filter(Boolean);
519
528
  log.warn(`PluginSchemaPersistence table unavailable — falling back to ${fragments.length} Connected Plugin RM fragment(s)`, { comp: "updateApiSchema" });
520
529
  }
521
- const adminBase = injectAwsAuthAll(adminBaseFragment(clonerEnabled || false), "Admin");
522
- const sdl = graphqlStitch(adminBase, fragments);
530
+ const rawAdminBase = adminBaseFragment(clonerEnabled || false);
531
+ const adminBase = injectAwsAuthAll(rawAdminBase, "Admin");
532
+ // Core fragments are dialect-neutral — append @aws_subscribe from the
533
+ // structured subscription→mutation metadata (collected from the raw admin
534
+ // base + plugin fragments) onto the stitched SDL, mirroring the deploy
535
+ // path's stitchWithAwsDirectives.
536
+ const subscriptionSources = collectSubscriptionSources(rawAdminBase, fragments);
537
+ const sdl = injectAwsSubscribe(graphqlStitch(adminBase, fragments), subscriptionSources);
523
538
 
524
539
  // Shrink guard — never let a transient/incomplete stitch clobber the live schema.
525
540
  const threshold = parseShrinkThreshold(process.env["RUNTIME_SCHEMA_SHRINK_THRESHOLD"]);
@@ -537,6 +552,183 @@ function mkUpdateApiSchema(schemaTableName, apiId, clonerEnabled) {
537
552
  };
538
553
  }
539
554
 
555
+ // ── Reactive ApiFragmentRegistry single writer (Plan 2e) ─────────────────────
556
+ // The admin EventCollector is (as of 2e) also subscribed to the admin DcbEventLog
557
+ // DynamoDB stream. On any ApiFragmentRegistered / ApiFragmentUpdated /
558
+ // ApiFragmentDeregistered event, re-fold the ApiFragmentRegistry, stitch one
559
+ // AppSync-decorated schema per target API (via the runtime-pure
560
+ // AppSync_SdlDecorate.planAwsPushes — identical decoration to the deploy path),
561
+ // push each with the shrink guard, and write the outcome back with
562
+ // RecordApiFragmentPush onto the admin DCB command topic. This is ADDITIVE — the
563
+ // legacy connect-driven mkUpdateApiSchema (deploy-schema:* → single Domain API)
564
+ // stays until Plan Phase 4. ApiFragmentPushRecorded is intentionally NOT a
565
+ // trigger (the write-back would otherwise loop); UiFragment* events on the same
566
+ // stream are ignored (different prefix).
567
+
568
+ const reactiveTriggerTags = new Set([
569
+ "ApiFragmentRegistered",
570
+ "ApiFragmentUpdated",
571
+ "ApiFragmentDeregistered",
572
+ ]);
573
+
574
+ function isDynamoStreamRecord(r) {
575
+ return !!r && (r.eventSource === "aws:dynamodb" || r.EventSource === "aws:dynamodb");
576
+ }
577
+
578
+ // Read a DynamoDB attribute-value string, tolerating both the raw stream shape
579
+ // ({S: "..."}) and an already-unmarshalled plain string.
580
+ function avString(av) {
581
+ if (av == null) return undefined;
582
+ if (typeof av === "string") return av;
583
+ if (typeof av.S === "string") return av.S;
584
+ return undefined;
585
+ }
586
+
587
+ // Pull the {encoded, protocol} fragment out of an event's `data` attribute
588
+ // (raw {M:{...}} or unmarshalled object), reading the named fragment field
589
+ // (`fragment` for Registered, `newFragment` for Updated).
590
+ function readFragmentField(dataAv, fieldName) {
591
+ const data = dataAv && dataAv.M ? dataAv.M : dataAv;
592
+ if (!data || typeof data !== "object") return null;
593
+ const fragAv = data[fieldName];
594
+ const frag = fragAv && fragAv.M ? fragAv.M : fragAv;
595
+ if (!frag || typeof frag !== "object") return null;
596
+ const encoded = avString(frag.encoded);
597
+ if (typeof encoded !== "string" || encoded.length === 0) return null;
598
+ return { encoded, protocol: avString(frag.protocol) || "graphql" };
599
+ }
600
+
601
+ // Scan the raw stream records for ApiFragment* trigger events; return one entry
602
+ // per event carrying the pluginId and the fragment override the event asserts
603
+ // (or {remove:true} for a deregistration). The override lets the push reflect
604
+ // the just-happened change even if the ApiFragments projection (a separate
605
+ // stream consumer) hasn't caught up yet — the plan's "stitch from consistent
606
+ // state, not an eventually-consistent RM scan".
607
+ function detectApiFragmentTriggers(records) {
608
+ const out = [];
609
+ for (const r of records || []) {
610
+ if (!isDynamoStreamRecord(r)) continue;
611
+ const img = r.dynamodb && r.dynamodb.NewImage;
612
+ if (!img) continue;
613
+ const tag = avString(img.event);
614
+ if (!tag || !reactiveTriggerTags.has(tag)) continue;
615
+ const dataAv = img.data;
616
+ const dataObj = dataAv && dataAv.M ? dataAv.M : dataAv;
617
+ let pluginId =
618
+ (dataObj && dataObj.pluginId && avString(dataObj.pluginId)) || avString(img.tag_pluginId);
619
+ if (!pluginId) continue;
620
+ if (tag === "ApiFragmentDeregistered") {
621
+ out.push({ pluginId, remove: true });
622
+ } else {
623
+ const fieldName = tag === "ApiFragmentUpdated" ? "newFragment" : "fragment";
624
+ const frag = readFragmentField(dataAv, fieldName);
625
+ const target = (dataObj && avString(dataObj.apiTarget)) === "Platform" ? "Platform" : "Domain";
626
+ out.push(frag ? { pluginId, encoded: frag.encoded, protocol: frag.protocol, target } : { pluginId });
627
+ }
628
+ }
629
+ return out;
630
+ }
631
+
632
+ // Enqueue one RecordApiFragmentPush per distinct triggering plugin onto the admin
633
+ // DCB command topic (FIFO, MessageGroupId = pluginId). RecordApiFragmentPush is
634
+ // @noApi and idempotent — a no-op in the slice behaviour if the plugin was
635
+ // deregistered in the meantime.
636
+ async function recordPushOutcomes(recordPublisher, pluginIds, ok, message) {
637
+ if (!recordPublisher || pluginIds.length === 0) return;
638
+ const at = new Date().toISOString();
639
+ const commandJsons = pluginIds.map((pluginId) => ({
640
+ id: pluginId,
641
+ // toMessageBody re-stamps msgId (uuid) + time (now); service + correlationId are required.
642
+ meta: { service: "AdminEventCollector", time: at, msgId: "pending", correlationId: pluginId },
643
+ commandJson: { TAG: "RecordApiFragmentPush", pluginId, ok, message, at },
644
+ }));
645
+ try {
646
+ await recordPublisher(commandJsons);
647
+ } catch (e) {
648
+ log.error(`RecordApiFragmentPush dispatch failed: ${(e && e.message) || e}`, { comp: "reactiveApiPush" });
649
+ }
650
+ }
651
+
652
+ function mkReactiveApiSchemaPush(config) {
653
+ const tableName = config.apiFragmentRegistryTableName;
654
+ const adminDcbUrl = config.adminDcbCmdTopicUrl;
655
+ // Only the admin EventCollector carries a real registry table + admin DCB topic.
656
+ if (!tableName || tableName === "NOT_AVAILABLE" || !adminDcbUrl || adminDcbUrl === "NOT_AVAILABLE") {
657
+ return undefined;
658
+ }
659
+ const domainApiId = config.appSyncApiId;
660
+ // In unified mode platformApiId is unset/"NOT_AVAILABLE" and every plan targets the Domain API.
661
+ const platformApiId =
662
+ config.platformApiId && config.platformApiId !== "NOT_AVAILABLE"
663
+ ? config.platformApiId
664
+ : domainApiId;
665
+ const splitApi = !!config.splitApi;
666
+ const clonerEnabled = config.clonerEnabled || false;
667
+ const recordPublisher = sqsPublishJsons(makeQueueRef(adminDcbUrl), "SQS_FIFO");
668
+
669
+ return async (triggers) => {
670
+ const pluginIds = [...new Set(triggers.map((t) => t.pluginId))];
671
+ // 1. Fold the registry: RM scan for all plugins, overlaid with this batch's
672
+ // own asserted fragments (closes the projection-lag race for the trigger).
673
+ const byId = new Map();
674
+ try {
675
+ const rows = await scanByTableName(tableName, [], 1000);
676
+ for (const row of rows) {
677
+ if (!row || typeof row.pluginId !== "string" || typeof row.encoded !== "string" || !row.encoded) continue;
678
+ byId.set(row.pluginId, {
679
+ encoded: row.encoded,
680
+ protocol: typeof row.protocol === "string" ? row.protocol : "graphql",
681
+ target: row.apiTarget === "Platform" ? "Platform" : "Domain",
682
+ });
683
+ }
684
+ } catch (e) {
685
+ const msg = `ApiFragments scan failed: ${(e && e.message) || e}`;
686
+ log.error(msg, { comp: "reactiveApiPush" });
687
+ await recordPushOutcomes(recordPublisher, pluginIds, false, msg);
688
+ return;
689
+ }
690
+ for (const t of triggers) {
691
+ if (t.remove) byId.delete(t.pluginId);
692
+ else if (t.encoded) byId.set(t.pluginId, { encoded: t.encoded, protocol: t.protocol, target: t.target });
693
+ }
694
+ const fragments = [...byId.values()];
695
+
696
+ // 2. Plan one AWS-decorated push per target API.
697
+ const rawAdminBase = adminBaseFragment(clonerEnabled);
698
+ const plans = planAwsPushes(rawAdminBase, systemCallerFieldNames, fragments, splitApi);
699
+
700
+ // 3. Push each plan behind the shrink guard.
701
+ let ok = true;
702
+ let message = "";
703
+ for (const plan of plans) {
704
+ const apiId = plan.api === "PlatformApi" ? platformApiId : domainApiId;
705
+ if (!apiId || apiId === "NOT_AVAILABLE") continue;
706
+ const threshold = parseShrinkThreshold(process.env["RUNTIME_SCHEMA_SHRINK_THRESHOLD"]);
707
+ const currentSdl = await getCurrentSchemaSdl(apiId);
708
+ if (isCatastrophicSchemaShrink(currentSdl, plan.sdl, threshold)) {
709
+ const cur = countRootTypeFields(currentSdl, "Mutation") + countRootTypeFields(currentSdl, "Query");
710
+ const nw = countRootTypeFields(plan.sdl, "Mutation") + countRootTypeFields(plan.sdl, "Query");
711
+ log.error(`ABORTED reactive push for ${plan.api} (${apiId}): ${nw} root field(s) vs ${cur} live (threshold ${threshold}).`, { comp: "reactiveApiPush" });
712
+ emitShrinkRejectionMetric(apiId, cur, nw);
713
+ ok = false;
714
+ message = `shrink guard aborted push for ${plan.api}`;
715
+ continue;
716
+ }
717
+ try {
718
+ await updateAppSyncSchema(apiId, plan.sdl);
719
+ log.info(`reactive schema push OK: ${plan.api} (${apiId})`, { comp: "reactiveApiPush" });
720
+ } catch (e) {
721
+ ok = false;
722
+ message = (e && e.message) || String(e);
723
+ log.error(`reactive schema push FAILED: ${plan.api} (${apiId}): ${message}`, { comp: "reactiveApiPush" });
724
+ }
725
+ }
726
+
727
+ // 4. Write the outcome back per triggering plugin (the deploy waiter polls this).
728
+ await recordPushOutcomes(recordPublisher, pluginIds, ok, message);
729
+ };
730
+ }
731
+
540
732
  function buildPublishToAggregates(map) {
541
733
  const out = {};
542
734
  for (const [aggName, envVarName] of Object.entries(map || {})) {
@@ -582,6 +774,19 @@ function loadPluginDefinition() {
582
774
  }
583
775
  }
584
776
 
777
+ // The plugin's UI-fragment manifest — shipped as its own asset since the
778
+ // manifest no longer rides pluginDefinition (the UiFragmentRegistry slice owns
779
+ // fragment state). Contains JSON `null` for plugins without a UI; tolerate a
780
+ // missing file (archives built before the asset existed) the same way.
781
+ function loadUiFragments() {
782
+ try {
783
+ const raw = readFileSync(new URL("./uiFragments.json", `file://${process.cwd()}/`), "utf-8");
784
+ return JSON.parse(raw);
785
+ } catch (e) {
786
+ return null;
787
+ }
788
+ }
789
+
585
790
  // Cross-plugin spec packages (e.g. "@reventlessdev/online-shop-hybrid-catalog-spec")
586
791
  // are bundled into the function asset under /var/task/node_modules/ by
587
792
  // PluginRuntime_Builder.forPluginEventCollector. This entry-point file, however,
@@ -649,6 +854,8 @@ async function buildHandler() {
649
854
  config.clonerEnabled,
650
855
  );
651
856
 
857
+ const reactiveApiSchemaPush = mkReactiveApiSchemaPush(config);
858
+
652
859
  const manageSubscriptionsFn = mkManageSubscriptions(config.pluginReadModelTableName);
653
860
 
654
861
  // EP operations — admin has 1 entry (Plugin EP), plugins have N user EPs.
@@ -731,10 +938,13 @@ async function buildHandler() {
731
938
  const specMod = patchSpecId(await importFromAsset(ext.specModule));
732
939
  const mappingsMod = await importFromAsset(ext.mappingsModule);
733
940
  // After Phase 3 of plugin-eventcollector-runtime-rewire, the Connect
734
- // extension Spec carries only pluginDefinition cross-plugin subscribe /
735
- // unsubscribe directives moved to admin's manageSubscriptions hook.
941
+ // extension Spec carries pluginDefinition plus the plugin's UI-fragment
942
+ // manifest (its own asset) cross-plugin subscribe / unsubscribe
943
+ // directives moved to admin's manageSubscriptions hook. ReScript None
944
+ // compiles to `undefined`, so map the asset's JSON null accordingly.
736
945
  const extBuilder = mappingsMod.Make({
737
946
  pluginDefinition,
947
+ uiFragments: loadUiFragments() ?? undefined,
738
948
  });
739
949
  // PluginConnectExtension_Builder.Make returns a module exposing:
740
950
  // - ConnectPluginMapping (single mapping)
@@ -863,17 +1073,33 @@ async function buildHandler() {
863
1073
  // level, so the modest extra init latency is paid only on true cold starts.
864
1074
  await reconcileSubscriptionsOnce(config.pluginReadModelTableName, manageSubscriptionsFn);
865
1075
 
866
- return handleDynamoDbOrSqsEvent(makeQueueRef(config.queueUrl), callback.handleJsonEvents);
1076
+ const sqsHandler = handleDynamoDbOrSqsEvent(makeQueueRef(config.queueUrl), callback.handleJsonEvents);
1077
+ return { sqsHandler, reactiveApiSchemaPush };
867
1078
  }
868
1079
 
869
- const sqsHandlerPromise = buildHandler();
1080
+ const handlerBundlePromise = buildHandler();
870
1081
 
871
1082
  export async function handler(event, context) {
872
1083
  _currentRequestId = context?.awsRequestId || "unknown";
873
1084
  const records = event.Records || [];
874
1085
  const correlationId = extractCorrelationId(records);
875
1086
  log.debug("processing " + records.length.toString() + " record(s)", { comp: "PluginEventCollectorRuntime" });
876
- const sqsHandler = await sqsHandlerPromise;
877
- await runEffect(correlationId, sqsHandler(event, context));
1087
+ const { sqsHandler, reactiveApiSchemaPush } = await handlerBundlePromise;
1088
+ if (reactiveApiSchemaPush) {
1089
+ // Admin EC: DcbEventLog stream records drive the reactive ApiFragmentRegistry
1090
+ // push; everything else (Plugin-aggregate lifecycle delivered SNS→SQS) goes to
1091
+ // the plugin callback as before. Splitting keeps unrelated DCB-slice stream
1092
+ // events out of the callback, which has no handler for them. (A Lambda batch
1093
+ // is single-source, so in practice exactly one branch has records.)
1094
+ const streamRecords = records.filter(isDynamoStreamRecord);
1095
+ const otherRecords = records.filter((r) => !isDynamoStreamRecord(r));
1096
+ if (otherRecords.length > 0) {
1097
+ await runEffect(correlationId, sqsHandler({ ...event, Records: otherRecords }, context));
1098
+ }
1099
+ const triggers = detectApiFragmentTriggers(streamRecords);
1100
+ if (triggers.length > 0) await reactiveApiSchemaPush(triggers);
1101
+ } else {
1102
+ await runEffect(correlationId, sqsHandler(event, context));
1103
+ }
878
1104
  return "";
879
1105
  }
@@ -16,6 +16,10 @@ let bundledInfos: dict<sliceInfo> = Dict.make()
16
16
 
17
17
  let dcbQueueUrlRef: ref<option<Pulumi.Output.t<string>>> = ref(None)
18
18
  let setDcbQueueUrl = url => dcbQueueUrlRef := Some(url)
19
+ // The admin/plugin DCB command-topic FIFO URL captured by the
20
+ // onDcbCommandTopicCreated hook. Read by the admin EventCollector's reactive
21
+ // ApiFragmentRegistry push (2e) to dispatch RecordApiFragmentPush.
22
+ let getDcbQueueUrl = () => dcbQueueUrlRef.contents
19
23
 
20
24
  let registerAutomationSlice = (
21
25
  ~name,
@@ -22,6 +22,10 @@ function setDcbQueueUrl(url) {
22
22
  dcbQueueUrlRef.contents = url;
23
23
  }
24
24
 
25
+ function getDcbQueueUrl() {
26
+ return dcbQueueUrlRef.contents;
27
+ }
28
+
25
29
  function registerAutomationSlice(name, specModulePath, callbackTypeOpt, queryDbTableName) {
26
30
  let callbackType = callbackTypeOpt !== undefined ? callbackTypeOpt : "automation";
27
31
  bundledInfos[name] = {
@@ -137,6 +141,7 @@ export {
137
141
  bundledInfos,
138
142
  dcbQueueUrlRef,
139
143
  setDcbQueueUrl,
144
+ getDcbQueueUrl,
140
145
  registerAutomationSlice,
141
146
  storedSpecs,
142
147
  grandParent,
@@ -353,12 +353,9 @@ let _stampTypeDualAuth = (decl: string): string =>
353
353
  // the stitcher's first-wins dedupe against unstamped copies from sibling
354
354
  // fragments. Unconditional — the API always configures AWS_IAM as an
355
355
  // additional provider, so the directive is always valid.
356
- let sharedIamTypeNames = ["PageInfo", "CommandAccepted", "CommandRejected", "CommandPending"]
357
-
358
- let stampSharedIamTypes = (sdl: string): string =>
359
- sharedIamTypeNames->Array.reduce(sdl, (acc, name) =>
360
- acc->String.replace(`type ${name} {`, `type ${name} @aws_cognito_user_pools @aws_iam {`)
361
- )
356
+ // Canonical definition lives in the runtime-pure AppSync_SdlDecorate so the
357
+ // bundled AdminEventCollector Lambda's reactive push decorates identically.
358
+ let stampSharedIamTypes = AppSync_SdlDecorate.stampSharedIamTypes
362
359
 
363
360
  let injectAwsAuth = (
364
361
  fragment: Reventless.Plugin.apiSchemaFragment,
@@ -479,20 +476,12 @@ let injectAwsAuth = (
479
476
  }
480
477
  )
481
478
 
482
- let encoded =
483
- JSON.Encode.object(
484
- Dict.fromArray([
485
- ("types", JSON.Encode.array(augmentedTypes->Array.map(JSON.Encode.string))),
486
- ("mutations", JSON.Encode.array(augmentedMutations->Array.map(JSON.Encode.string))),
487
- ("queries", JSON.Encode.array(augmentedQueries->Array.map(JSON.Encode.string))),
488
- (
489
- "subscriptions",
490
- JSON.Encode.array(parts.subscriptions->Array.map(JSON.Encode.string)),
491
- ),
492
- ]),
493
- )->JSON.stringify
494
-
495
- {Reventless.Plugin.encoded, protocol: "graphql"}
479
+ ReventlessCore.GraphQL_Stitcher.encode({
480
+ ...parts,
481
+ types: augmentedTypes,
482
+ mutations: augmentedMutations,
483
+ queries: augmentedQueries,
484
+ })
496
485
  }
497
486
 
498
487
  // Injects @aws_auth with the given group on ALL mutation, query, and subscription
@@ -504,43 +493,35 @@ let injectAwsAuth = (
504
493
  // ["<group>"]) @aws_iam` instead of the single-mode `@aws_auth(...)`, keeping
505
494
  // the same Cognito group gating while also admitting the SigV4 system caller.
506
495
  // Subscriptions are never IAM-marked (the deploy caller does not subscribe).
496
+ // Canonical definition lives in the runtime-pure AppSync_SdlDecorate so the
497
+ // bundled AdminEventCollector Lambda's reactive push decorates the admin base
498
+ // identically to this deploy path. Eta-expanded to preserve the optional arg.
507
499
  let injectAwsAuthAll = (
508
500
  fragment: Reventless.Plugin.apiSchemaFragment,
509
501
  ~group: string,
510
502
  ~iamFieldNames: array<string>=[],
511
- ): Reventless.Plugin.apiSchemaFragment => {
512
- let parts = ReventlessCore.GraphQL_Stitcher.decode(fragment)
513
- let isIam = (field: string): bool =>
514
- iamFieldNames->Array.includes(ReventlessCore.GraphQL_Stitcher.extractLeadingName(field))
515
-
516
- let augmentedMutations = parts.mutations->Array.map(field =>
517
- isIam(field)
518
- ? `${field}\n ${_formatDualAuthDirective(Some([group]))}`
519
- : `${field}\n @aws_auth(cognito_groups: ["${group}"])`
520
- )
521
- let augmentedQueries = parts.queries->Array.map(field =>
522
- isIam(field)
523
- ? `${field} ${_formatDualAuthDirective(Some([group]))}`
524
- : `${field} @aws_auth(cognito_groups: ["${group}"])`
525
- )
526
- let augmentedSubscriptions = parts.subscriptions->Array.map(field =>
527
- `${field}\n @aws_auth(cognito_groups: ["${group}"])`
503
+ ): Reventless.Plugin.apiSchemaFragment =>
504
+ AppSync_SdlDecorate.injectAwsAuthAll(fragment, ~group, ~iamFieldNames)
505
+
506
+ /**
507
+ Stitch base + plugin fragments and decorate the assembled SDL with the AppSync
508
+ dialect: `@aws_subscribe` on mutation-sourced subscription fields (from the
509
+ fragments' neutral `subscriptionSources` metadata — core no longer emits the
510
+ directive) and `@aws_cognito_user_pools @aws_iam` on the shared traversal
511
+ types. Every AWS schema push assembles its SDL through here so the dialect is
512
+ applied uniformly.
513
+ */
514
+ let stitchWithAwsDirectives = (
515
+ ~baseFragment: Reventless.Plugin.apiSchemaFragment,
516
+ ~pluginFragments: array<Reventless.Plugin.apiSchemaFragment>,
517
+ ): string => {
518
+ let sources = ReventlessCore.GraphQL_Stitcher.collectSubscriptionSources(
519
+ ~baseFragment,
520
+ ~pluginFragments,
528
521
  )
529
-
530
- let encoded =
531
- JSON.Encode.object(
532
- Dict.fromArray([
533
- ("types", JSON.Encode.array(parts.types->Array.map(JSON.Encode.string))),
534
- ("mutations", JSON.Encode.array(augmentedMutations->Array.map(JSON.Encode.string))),
535
- ("queries", JSON.Encode.array(augmentedQueries->Array.map(JSON.Encode.string))),
536
- (
537
- "subscriptions",
538
- JSON.Encode.array(augmentedSubscriptions->Array.map(JSON.Encode.string)),
539
- ),
540
- ]),
541
- )->JSON.stringify
542
-
543
- {Reventless.Plugin.encoded, protocol: "graphql"}
522
+ ReventlessCore.GraphQL_Stitcher.stitch(~baseFragment, ~pluginFragments)
523
+ ->AppSync_SdlDecorate.injectAwsSubscribe(~sources)
524
+ ->stampSharedIamTypes
544
525
  }
545
526
 
546
527
  // ── Provider implementation ────────────────────────────────────────────────
@@ -619,13 +600,9 @@ let updateSchema = (
619
600
  // The base fragment contains core Plugin aggregate queries/mutations — all Admin-only.
620
601
  // Plugin fragments already have @aws_auth injected via generateFragment.
621
602
  let augmentedBaseFragment = injectAwsAuthAll(baseFragment, ~group="Admin")
622
- // Shared traversal types are stamped once on the assembled SDL (post-stitch,
623
- // post-dedupe) — see stampSharedIamTypes.
624
- let sdl =
625
- ReventlessCore.GraphQL_Stitcher.stitch(
626
- ~baseFragment=augmentedBaseFragment,
627
- ~pluginFragments,
628
- )->stampSharedIamTypes
603
+ // Shared traversal types + @aws_subscribe are stamped once on the assembled
604
+ // SDL (post-stitch, post-dedupe) — see stitchWithAwsDirectives.
605
+ let sdl = stitchWithAwsDirectives(~baseFragment=augmentedBaseFragment, ~pluginFragments)
629
606
  // Resolve the API ID from the Output chain. In mock mode (tests) and in Lambda runtime
630
607
  // (where the Output is backed by already-known values), this completes synchronously.
631
608
  // The resulting promise wraps the AppSync SDK call.