@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.
- package/CHANGELOG.md +15 -0
- package/package.json +6 -6
- package/src/Platform.res +290 -44
- package/src/Platform.res.mjs +416 -90
- package/src/adapter/Api/ApiFragmentDeregistration.res +137 -0
- package/src/adapter/Api/ApiFragmentDeregistration.res.mjs +108 -0
- package/src/adapter/Api/CommandSubscriptionResolvers_AppSync.res +4 -3
- package/src/adapter/Api/Platform_ApiFragments_Lambda.res +212 -0
- package/src/adapter/Api/Platform_ApiFragments_Lambda.res.mjs +202 -0
- package/src/adapter/Api/Platform_UIFragments_Lambda.res +8 -8
- package/src/adapter/Api/Platform_UIFragments_Lambda.res.mjs +11 -11
- package/src/adapter/CommandGenerator/CommandGeneratorResolvers_AppSync.res +2 -1
- package/src/adapter/QueryDb/QueryDbBackend.res +1 -1
- package/src/adapter/Runtime/AdminEventCollectorEntryPoint.mjs +235 -9
- package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res +4 -0
- package/src/adapter/Runtime/AutomationSliceRuntime_Builder_Single.res.mjs +5 -0
- package/src/components/Api/AppSync_Adapter.res +36 -59
- package/src/components/Api/AppSync_Adapter.res.mjs +18 -74
- package/src/components/Api/AppSync_SdlDecorate.res +189 -0
- package/src/components/Api/AppSync_SdlDecorate.res.mjs +125 -0
- package/src/plugin/runtime/PluginRuntime_Builder.res +107 -2
- package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +70 -7
- package/src/util/Util_AppSync_Caller.res +12 -4
- package/src/util/Util_AppSync_Caller.res.mjs +9 -3
- package/tests/AppSync_AdapterTest.res +6 -0
- package/tests/AppSync_AdapterTest.res.mjs +12 -6
- package/tests/AppSync_SdlDecorateTest.res +157 -0
- 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
|
|
4
|
-
// (one item per plugin
|
|
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).
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
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: "
|
|
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).
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
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: "
|
|
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).
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
//
|
|
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 (
|
|
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
|
|
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 {
|
|
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
|
|
522
|
-
const
|
|
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
|
|
735
|
-
//
|
|
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
|
-
|
|
1076
|
+
const sqsHandler = handleDynamoDbOrSqsEvent(makeQueueRef(config.queueUrl), callback.handleJsonEvents);
|
|
1077
|
+
return { sqsHandler, reactiveApiSchemaPush };
|
|
867
1078
|
}
|
|
868
1079
|
|
|
869
|
-
const
|
|
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
|
|
877
|
-
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
let stampSharedIamTypes =
|
|
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
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
let
|
|
527
|
-
|
|
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
|
-
|
|
531
|
-
|
|
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
|
|
623
|
-
// post-dedupe) — see
|
|
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.
|