@reventlessdev/reventless-aws 3.0.0-alpha.208 → 3.0.0-alpha.209
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/package.json +8 -8
- package/src/Platform.res +34 -475
- package/src/Platform.res.mjs +117 -608
- package/src/adapter/Runtime/AdminEventCollectorEntryPoint.mjs +7 -382
- package/src/adapter/Runtime/ApiSchemaPush_Runtime.mjs +8 -1
- package/src/components/Api/AppSync_Adapter.res +0 -91
- package/src/components/Api/AppSync_Adapter.res.mjs +0 -84
- package/src/plugin/runtime/PluginRuntime_Builder.res +0 -139
- package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +4 -80
- package/tests/MCP_LambdaTest.res +6 -4
- package/tests/MCP_LambdaTest.res.mjs +2 -2
|
@@ -79,14 +79,8 @@ import { Make as pluginCallbackMake } from "@reventlessdev/reventless-core/src/p
|
|
|
79
79
|
import { handleDynamoDbOrSqsEvent } from "@reventlessdev/reventless-aws/src/adapter/EventCollector/EventCollectorChannel_SQS_Runtime.res.mjs";
|
|
80
80
|
import { createSchedule as cwCreateSchedule, deleteSchedule as cwDeleteSchedule } from "@reventlessdev/reventless-aws/src/adapter/ScheduledPublisher/ScheduledPublisher_CloudWatchEvents_Runtime.res.mjs";
|
|
81
81
|
import {
|
|
82
|
-
stitch as graphqlStitch,
|
|
83
82
|
decode as decodeFragment,
|
|
84
|
-
countRootTypeFields,
|
|
85
|
-
isCatastrophicSchemaShrink,
|
|
86
|
-
collectSubscriptionSources,
|
|
87
83
|
} from "@reventlessdev/reventless-core/src/components/Api/GraphQL_Stitcher.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";
|
|
90
84
|
import { tag as requestContextTag } from "@reventlessdev/reventless-core/src/RequestContext.res.mjs";
|
|
91
85
|
|
|
92
86
|
// The Plugin RM state schema uses `@s.matches(_jsNullable …)` for many
|
|
@@ -97,8 +91,7 @@ import { tag as requestContextTag } from "@reventlessdev/reventless-core/src/Req
|
|
|
97
91
|
// whack-a-mole; instead, manageSubscriptions and reconcileSubscriptions only
|
|
98
92
|
// need a tiny subset of the state (id / status / extensions / extensionPoints
|
|
99
93
|
// / eventCollector), so they sidestep sury entirely and read the DDB row
|
|
100
|
-
// directly via this small projection.
|
|
101
|
-
// apiSchemaFragment further below.
|
|
94
|
+
// directly via this small projection.
|
|
102
95
|
function projectPluginRow(row) {
|
|
103
96
|
if (!row || typeof row !== "object") return null;
|
|
104
97
|
if (typeof row.id !== "string" || typeof row.status !== "string") return null;
|
|
@@ -170,13 +163,6 @@ function parseHandlerConfig(rawJson) {
|
|
|
170
163
|
// Optional fields with empty defaults — admin context omits them entirely.
|
|
171
164
|
if (config.readModelQueueUrls === undefined) config.readModelQueueUrls = {};
|
|
172
165
|
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;
|
|
180
166
|
// Per-extension defaults so older serialisers (or partial admin configs) keep working.
|
|
181
167
|
for (const ext of config.extensions) {
|
|
182
168
|
if (!Array.isArray(ext.aggregateNames)) ext.aggregateNames = [];
|
|
@@ -187,110 +173,6 @@ function parseHandlerConfig(rawJson) {
|
|
|
187
173
|
return config;
|
|
188
174
|
}
|
|
189
175
|
|
|
190
|
-
function injectAwsAuthAll(fragment, group) {
|
|
191
|
-
const parts = decodeFragment(fragment);
|
|
192
|
-
const augmentedMutations = parts.mutations.map(
|
|
193
|
-
(field) => field + "\n @aws_auth(cognito_groups: [\"" + group + "\"])"
|
|
194
|
-
);
|
|
195
|
-
const augmentedQueries = parts.queries.map(
|
|
196
|
-
(field) => field + " @aws_auth(cognito_groups: [\"" + group + "\"])"
|
|
197
|
-
);
|
|
198
|
-
const encoded = JSON.stringify({
|
|
199
|
-
types: parts.types,
|
|
200
|
-
mutations: augmentedMutations,
|
|
201
|
-
queries: augmentedQueries,
|
|
202
|
-
});
|
|
203
|
-
return { encoded, protocol: "graphql" };
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
async function updateAppSyncSchema(apiId, sdl) {
|
|
207
|
-
const { AppSyncClient, StartSchemaCreationCommand } = await import("@aws-sdk/client-appsync");
|
|
208
|
-
const client = new AppSyncClient({});
|
|
209
|
-
await client.send(new StartSchemaCreationCommand({ apiId, definition: sdl }));
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Fetch the current live AppSync schema as an SDL string for the shrink guard.
|
|
213
|
-
// Returns "" (not an error) when the API has no schema yet (first deploy) or
|
|
214
|
-
// introspection fails — the caller treats an empty current schema as "no
|
|
215
|
-
// baseline to protect", so the push proceeds.
|
|
216
|
-
async function getCurrentSchemaSdl(apiId) {
|
|
217
|
-
try {
|
|
218
|
-
const { AppSyncClient, GetIntrospectionSchemaCommand } = await import("@aws-sdk/client-appsync");
|
|
219
|
-
const client = new AppSyncClient({});
|
|
220
|
-
const resp = await client.send(new GetIntrospectionSchemaCommand({ apiId, format: "SDL" }));
|
|
221
|
-
if (!resp || !resp.schema) return "";
|
|
222
|
-
// resp.schema is a Uint8Array of the SDL text.
|
|
223
|
-
return Buffer.from(resp.schema).toString("utf-8");
|
|
224
|
-
} catch (e) {
|
|
225
|
-
log.warn(`could not introspect current schema (${(e && e.message) || e}) — skipping shrink guard`, { comp: "updateApiSchema" });
|
|
226
|
-
return "";
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Shrink-guard threshold: abort the push if the new SDL has fewer than
|
|
231
|
-
// (threshold × current) root fields. Configurable via env; default 0.5 (50%).
|
|
232
|
-
// Anything outside (0, 1) falls back to the default.
|
|
233
|
-
function parseShrinkThreshold(raw) {
|
|
234
|
-
const n = raw ? Number(raw) : NaN;
|
|
235
|
-
return Number.isFinite(n) && n > 0 && n < 1 ? n : 0.5;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
// Emit a CloudWatch metric via Embedded Metric Format (EMF). Any Lambda log line
|
|
239
|
-
// shaped like this is auto-parsed by CloudWatch into the metric
|
|
240
|
-
// Reventless/Runtime SchemaShrinkRejected (dimension ApiId) — no PutMetricData
|
|
241
|
-
// call, SDK dependency, or extra IAM permission required.
|
|
242
|
-
// NOTE: must stay as a raw `console.log` of the exact EMF envelope — routing
|
|
243
|
-
// through `log.info` would add `time`/`level`/`message` siblings and break
|
|
244
|
-
// CloudWatch's EMF auto-detect (the `_aws` block must be at the root of the
|
|
245
|
-
// log record).
|
|
246
|
-
function emitShrinkRejectionMetric(apiId, currentRootFields, newRootFields) {
|
|
247
|
-
try {
|
|
248
|
-
// eslint-disable-next-line no-console
|
|
249
|
-
console.log(
|
|
250
|
-
JSON.stringify({
|
|
251
|
-
_aws: {
|
|
252
|
-
Timestamp: Date.now(),
|
|
253
|
-
CloudWatchMetrics: [
|
|
254
|
-
{
|
|
255
|
-
Namespace: "Reventless/Runtime",
|
|
256
|
-
Dimensions: [["ApiId"]],
|
|
257
|
-
Metrics: [{ Name: "SchemaShrinkRejected", Unit: "Count" }],
|
|
258
|
-
},
|
|
259
|
-
],
|
|
260
|
-
},
|
|
261
|
-
ApiId: apiId,
|
|
262
|
-
SchemaShrinkRejected: 1,
|
|
263
|
-
currentRootFields,
|
|
264
|
-
newRootFields,
|
|
265
|
-
})
|
|
266
|
-
);
|
|
267
|
-
} catch (_) {}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
const DEPLOY_SCHEMA_PREFIX = "deploy-schema:";
|
|
271
|
-
|
|
272
|
-
// Read every plugin's deploy-time SDL fragment from the dedicated
|
|
273
|
-
// PluginSchemaPersistence table (rows keyed "deploy-schema:<name>", written by
|
|
274
|
-
// Platform.preResolversSchemaHook at deploy time). begins_with("deploy-schema:")
|
|
275
|
-
// matches only Domain fragments — the platform ("deploy-schema-platform:") and
|
|
276
|
-
// hash ("deploy-schema-hash:") rows have a hyphen at that position, so they are
|
|
277
|
-
// excluded. Each row's `fragment` attribute is the encoded SDL string; wrap it
|
|
278
|
-
// in the {encoded, protocol} shape the stitcher consumes.
|
|
279
|
-
async function collectDeploySchemaFragments(tableName) {
|
|
280
|
-
const rows = await scanByTableName(
|
|
281
|
-
tableName,
|
|
282
|
-
[["id", { TAG: "BeginsWith" }, { TAG: "String", _0: DEPLOY_SCHEMA_PREFIX }]],
|
|
283
|
-
1000
|
|
284
|
-
);
|
|
285
|
-
return rows
|
|
286
|
-
.map((row) =>
|
|
287
|
-
row && typeof row.fragment === "string"
|
|
288
|
-
? { encoded: row.fragment, protocol: "graphql" }
|
|
289
|
-
: null
|
|
290
|
-
)
|
|
291
|
-
.filter(Boolean);
|
|
292
|
-
}
|
|
293
|
-
|
|
294
176
|
// Set at handler entry; read by runEffect to tag logs with the Lambda request id.
|
|
295
177
|
let _currentRequestId = "unknown";
|
|
296
178
|
|
|
@@ -493,242 +375,6 @@ async function reconcileSubscriptionsOnce(tableName, manageSubscriptions) {
|
|
|
493
375
|
}
|
|
494
376
|
}
|
|
495
377
|
|
|
496
|
-
// Re-stitch and push the live AppSync schema on each plugin Connect/Disconnect.
|
|
497
|
-
//
|
|
498
|
-
// Source of plugin fragments (option A): the durable PluginSchemaPersistence
|
|
499
|
-
// table (deploy-time "deploy-schema:<name>" rows), NOT the lifecycle-volatile
|
|
500
|
-
// Plugin RM "Connected" rows. Reading the deploy-time source means lifecycle
|
|
501
|
-
// churn (a redeploy window where every plugin is briefly Disconnected, an
|
|
502
|
-
// eventually-consistent scan, etc.) can no longer shrink the stitched schema —
|
|
503
|
-
// every push re-asserts the full deployed set rather than clobbering field
|
|
504
|
-
// resolvers with whatever subset happened to be Connected at scan time. The
|
|
505
|
-
// Plugin RM scan remains only as a fallback for older platform stacks deployed
|
|
506
|
-
// before the dedicated table existed (schemaTableName === "NOT_AVAILABLE").
|
|
507
|
-
//
|
|
508
|
-
// Circuit breaker (option D, defense-in-depth): before pushing, introspect the
|
|
509
|
-
// current live schema and compare root-type (Mutation + Query) field counts. If
|
|
510
|
-
// the new SDL drops below the configured fraction of the live field count, abort
|
|
511
|
-
// the push, log loudly, and emit a CloudWatch metric — catching any
|
|
512
|
-
// catastrophic shrink the source switch alone misses.
|
|
513
|
-
function mkUpdateApiSchema(schemaTableName, apiId, clonerEnabled) {
|
|
514
|
-
if (!apiId || apiId === "NOT_AVAILABLE") return undefined;
|
|
515
|
-
const hasSchemaTable = !!schemaTableName && schemaTableName !== "NOT_AVAILABLE";
|
|
516
|
-
return async (queryEngine) => {
|
|
517
|
-
let fragments;
|
|
518
|
-
if (hasSchemaTable) {
|
|
519
|
-
fragments = await collectDeploySchemaFragments(schemaTableName);
|
|
520
|
-
log.info(`stitching ${fragments.length} deploy-schema fragment(s) from ${schemaTableName}`, { comp: "updateApiSchema" });
|
|
521
|
-
} else {
|
|
522
|
-
const resolved = await queryEngine.scan(
|
|
523
|
-
"Plugin",
|
|
524
|
-
[["status", { TAG: "Contains" }, { TAG: "String", _0: "Connected" }]],
|
|
525
|
-
1000
|
|
526
|
-
);
|
|
527
|
-
fragments = resolved.map((json) => json && json.apiSchemaFragment).filter(Boolean);
|
|
528
|
-
log.warn(`PluginSchemaPersistence table unavailable — falling back to ${fragments.length} Connected Plugin RM fragment(s)`, { comp: "updateApiSchema" });
|
|
529
|
-
}
|
|
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);
|
|
538
|
-
|
|
539
|
-
// Shrink guard — never let a transient/incomplete stitch clobber the live schema.
|
|
540
|
-
const threshold = parseShrinkThreshold(process.env["RUNTIME_SCHEMA_SHRINK_THRESHOLD"]);
|
|
541
|
-
const currentSdl = await getCurrentSchemaSdl(apiId);
|
|
542
|
-
if (isCatastrophicSchemaShrink(currentSdl, sdl, threshold)) {
|
|
543
|
-
const currentRootFields =
|
|
544
|
-
countRootTypeFields(currentSdl, "Mutation") + countRootTypeFields(currentSdl, "Query");
|
|
545
|
-
const newRootFields =
|
|
546
|
-
countRootTypeFields(sdl, "Mutation") + countRootTypeFields(sdl, "Query");
|
|
547
|
-
log.error(`ABORTED schema push for ${apiId}: new SDL has ${newRootFields} root field(s) vs ${currentRootFields} live (threshold ${threshold}). Refusing to clobber resolvers.`, { comp: "updateApiSchema" });
|
|
548
|
-
emitShrinkRejectionMetric(apiId, currentRootFields, newRootFields);
|
|
549
|
-
return;
|
|
550
|
-
}
|
|
551
|
-
await updateAppSyncSchema(apiId, sdl);
|
|
552
|
-
};
|
|
553
|
-
}
|
|
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
|
-
|
|
732
378
|
function buildPublishToAggregates(map) {
|
|
733
379
|
const out = {};
|
|
734
380
|
for (const [aggName, envVarName] of Object.entries(map || {})) {
|
|
@@ -848,14 +494,6 @@ async function buildHandler() {
|
|
|
848
494
|
}]
|
|
849
495
|
: [];
|
|
850
496
|
|
|
851
|
-
const updateApiSchemaFn = mkUpdateApiSchema(
|
|
852
|
-
config.pluginSchemaPersistenceTableName,
|
|
853
|
-
config.appSyncApiId,
|
|
854
|
-
config.clonerEnabled,
|
|
855
|
-
);
|
|
856
|
-
|
|
857
|
-
const reactiveApiSchemaPush = mkReactiveApiSchemaPush(config);
|
|
858
|
-
|
|
859
497
|
const manageSubscriptionsFn = mkManageSubscriptions(config.pluginReadModelTableName);
|
|
860
498
|
|
|
861
499
|
// EP operations — admin has 1 entry (Plugin EP), plugins have N user EPs.
|
|
@@ -884,7 +522,9 @@ async function buildHandler() {
|
|
|
884
522
|
const epModule = mappingsMod.Make({
|
|
885
523
|
runtimeOps,
|
|
886
524
|
environment: lambdaFunctionName,
|
|
887
|
-
|
|
525
|
+
// Connect-driven schema self-heal retired (Phase 4b) — the ApiSchemaPush
|
|
526
|
+
// SideEffect on ApiFragmentRegistry events is the single schema writer.
|
|
527
|
+
updateApiSchema: undefined,
|
|
888
528
|
manageSubscriptions: manageSubscriptionsFn,
|
|
889
529
|
});
|
|
890
530
|
mappingsModule = { mappings: [epModule.Mapping] };
|
|
@@ -1074,7 +714,7 @@ async function buildHandler() {
|
|
|
1074
714
|
await reconcileSubscriptionsOnce(config.pluginReadModelTableName, manageSubscriptionsFn);
|
|
1075
715
|
|
|
1076
716
|
const sqsHandler = handleDynamoDbOrSqsEvent(makeQueueRef(config.queueUrl), callback.handleJsonEvents);
|
|
1077
|
-
return { sqsHandler
|
|
717
|
+
return { sqsHandler };
|
|
1078
718
|
}
|
|
1079
719
|
|
|
1080
720
|
const handlerBundlePromise = buildHandler();
|
|
@@ -1084,22 +724,7 @@ export async function handler(event, context) {
|
|
|
1084
724
|
const records = event.Records || [];
|
|
1085
725
|
const correlationId = extractCorrelationId(records);
|
|
1086
726
|
log.debug("processing " + records.length.toString() + " record(s)", { comp: "PluginEventCollectorRuntime" });
|
|
1087
|
-
const { sqsHandler
|
|
1088
|
-
|
|
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
|
-
}
|
|
727
|
+
const { sqsHandler } = await handlerBundlePromise;
|
|
728
|
+
await runEffect(correlationId, sqsHandler(event, context));
|
|
1104
729
|
return "";
|
|
1105
730
|
}
|
|
@@ -134,7 +134,14 @@ async function recordPushOutcomes(cmdTopicUrl, pluginIds, ok, message) {
|
|
|
134
134
|
const at = new Date().toISOString();
|
|
135
135
|
const commandJsons = pluginIds.map((pluginId) => ({
|
|
136
136
|
id: "registry",
|
|
137
|
-
|
|
137
|
+
// msgId MUST be unique per message: it becomes the SQS SendMessageBatch entry Id (and the
|
|
138
|
+
// FIFO MessageDeduplicationId). A shared placeholder made every entry collide in one batch
|
|
139
|
+
// ("Id pending repeated" → the whole write-back failed → the deploy waiter timed out even
|
|
140
|
+
// though the schema push itself succeeded). pluginId is unique per message; `at` keeps it
|
|
141
|
+
// unique across pushes (and gives distinct FIFO dedup ids so a later re-push isn't dropped).
|
|
142
|
+
// Sanitize: an SQS batch-entry Id only allows [A-Za-z0-9_-] (≤80 chars), so the ISO
|
|
143
|
+
// timestamp's `:`/`.` must be replaced or SQS rejects the whole batch.
|
|
144
|
+
meta: { service: "ApiFragmentRegistry", time: at, msgId: `push-${pluginId}-${at}`.replace(/[^A-Za-z0-9_-]/g, "-"), correlationId: pluginId },
|
|
138
145
|
commandJson: { TAG: "RecordApiFragmentPush", pluginId, ok, message, at },
|
|
139
146
|
}));
|
|
140
147
|
try {
|
|
@@ -146,97 +146,6 @@ let deploySchemaWithRetry = (
|
|
|
146
146
|
() => startSchemaCreation(client, {apiId, definition})->Promise.then(_ => Promise.resolve()),
|
|
147
147
|
)->Effect.retry(AppSync_Error.retrySchedule)
|
|
148
148
|
|
|
149
|
-
// ── Schema-push serialization lock ──────────────────────────────────────────
|
|
150
|
-
//
|
|
151
|
-
// Plugin/service stacks share one AppSync API, and StartSchemaCreation REPLACES
|
|
152
|
-
// the whole schema. When two stacks scan the deploy-schema table, stitch, and
|
|
153
|
-
// push concurrently, a push built from a stale scan (missing a peer's not-yet-
|
|
154
|
-
// written fragment) clobbers the peer's fields — orphaning their resolvers
|
|
155
|
-
// (NotFoundException: No field named X). The shrink guard only catches
|
|
156
|
-
// catastrophic (>threshold) drops, not a single dropped field.
|
|
157
|
-
//
|
|
158
|
-
// This lease serialises scan→stitch→push across stacks via a conditional-write
|
|
159
|
-
// lock row in the shared PluginSchemaPersistence table. A push that holds the
|
|
160
|
-
// lease always scans a table already containing every prior push's fragment, so
|
|
161
|
-
// the last push is complete and nothing is clobbered. The lease carries a TTL so
|
|
162
|
-
// a crashed holder cannot deadlock the table; if the lock cannot be acquired
|
|
163
|
-
// within maxWaitMs we proceed best-effort rather than fail the deploy.
|
|
164
|
-
|
|
165
|
-
@val external schemaLockSetTimeout: (unit => unit, int) => unit = "setTimeout"
|
|
166
|
-
let schemaLockSleep = (ms: int): promise<unit> =>
|
|
167
|
-
Promise.make((resolve, _) => schemaLockSetTimeout(() => resolve(), ms))
|
|
168
|
-
let _schemaLockCounter = ref(0)
|
|
169
|
-
|
|
170
|
-
let withSchemaPushLock = async (
|
|
171
|
-
~tableName: string,
|
|
172
|
-
~apiId: string,
|
|
173
|
-
~leaseMs: int=120000,
|
|
174
|
-
~maxWaitMs: int=180000,
|
|
175
|
-
fn: unit => promise<'a>,
|
|
176
|
-
): 'a => {
|
|
177
|
-
open AwsSdk.DynamoDb.DocumentClient
|
|
178
|
-
let lockId = `schema-push-lock:${apiId}`
|
|
179
|
-
_schemaLockCounter := _schemaLockCounter.contents + 1
|
|
180
|
-
let holder = `${apiId}#${Date.now()->Float.toString}#${_schemaLockCounter.contents->Int.toString}`
|
|
181
|
-
|
|
182
|
-
let acquire = async () => {
|
|
183
|
-
let deadline = Date.now() +. maxWaitMs->Int.toFloat
|
|
184
|
-
let acquired = ref(false)
|
|
185
|
-
while !acquired.contents {
|
|
186
|
-
let now = Date.now()
|
|
187
|
-
let ok =
|
|
188
|
-
await PutCommand.make({
|
|
189
|
-
PutCommand.tableName,
|
|
190
|
-
item: Dict.fromArray([
|
|
191
|
-
("id", lockId->JSON.Encode.string),
|
|
192
|
-
("holder", holder->JSON.Encode.string),
|
|
193
|
-
("expiresAt", (now +. leaseMs->Int.toFloat)->JSON.Encode.float),
|
|
194
|
-
])->JSON.Encode.object,
|
|
195
|
-
conditionExpression: "attribute_not_exists(id) OR expiresAt < :now",
|
|
196
|
-
expressionAttributeValues: Dict.fromArray([(":now", now->JSON.Encode.float)]),
|
|
197
|
-
})
|
|
198
|
-
->PutCommand.send
|
|
199
|
-
->Promise.thenResolve(_ => true)
|
|
200
|
-
->Promise.catch(_ => Promise.resolve(false))
|
|
201
|
-
if ok {
|
|
202
|
-
acquired := true
|
|
203
|
-
} else if Date.now() > deadline {
|
|
204
|
-
log.warn(
|
|
205
|
-
~comp="AppSync_Adapter",
|
|
206
|
-
`schema-push lock for ${apiId} not acquired within ${maxWaitMs->Int.toString}ms — proceeding best-effort`,
|
|
207
|
-
)
|
|
208
|
-
acquired := true
|
|
209
|
-
} else {
|
|
210
|
-
await schemaLockSleep(1000)
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
let release = async () => {
|
|
216
|
-
let _ =
|
|
217
|
-
await DeleteCommand.make({
|
|
218
|
-
DeleteCommand.tableName,
|
|
219
|
-
key: Dict.fromArray([("id", lockId->JSON.Encode.string)]),
|
|
220
|
-
conditionExpression: "holder = :holder",
|
|
221
|
-
expressionAttributeValues: Dict.fromArray([(":holder", holder->JSON.Encode.string)]),
|
|
222
|
-
})
|
|
223
|
-
->DeleteCommand.send
|
|
224
|
-
->Promise.thenResolve(_ => ())
|
|
225
|
-
->Promise.catch(_ => Promise.resolve()) // expired lease stolen by a peer — leave theirs intact
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
await acquire()
|
|
229
|
-
try {
|
|
230
|
-
let r = await fn()
|
|
231
|
-
let _ = await release()
|
|
232
|
-
r
|
|
233
|
-
} catch {
|
|
234
|
-
| exn =>
|
|
235
|
-
let _ = await release()
|
|
236
|
-
throw(exn)
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
149
|
// Lazy singleton AppSync client (runtime only)
|
|
241
150
|
let _client: ref<option<appSyncClient>> = ref(None)
|
|
242
151
|
let getClient = () =>
|
|
@@ -10,15 +10,12 @@ import * as Stdlib_String from "@rescript/runtime/lib/es6/Stdlib_String.js";
|
|
|
10
10
|
import * as Effect$1 from "effect/Effect";
|
|
11
11
|
import * as Pulumi from "@pulumi/pulumi";
|
|
12
12
|
import * as Stdlib_JsError from "@rescript/runtime/lib/es6/Stdlib_JsError.js";
|
|
13
|
-
import * as Stdlib_Promise from "@rescript/runtime/lib/es6/Stdlib_Promise.js";
|
|
14
13
|
import * as Primitive_option from "@rescript/runtime/lib/es6/Primitive_option.js";
|
|
15
14
|
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
16
|
-
import * as LibDynamodb from "@aws-sdk/lib-dynamodb";
|
|
17
15
|
import * as Logger$ReventlessCore from "@reventlessdev/reventless-core/src/util/Logger.res.mjs";
|
|
18
16
|
import * as ClientAppsync from "@aws-sdk/client-appsync";
|
|
19
17
|
import * as Auth_Cognito$ReventlessAws from "../../adapter/Auth/Auth_Cognito.res.mjs";
|
|
20
18
|
import * as AppSync_Error$ReventlessAws from "../../errors/AppSync_Error.res.mjs";
|
|
21
|
-
import * as DynamoDb_DocumentClient$AwsSdk from "@reventlessdev/rescript-aws-sdk/src/DynamoDb_DocumentClient.res.mjs";
|
|
22
19
|
import * as GraphQL_Stitcher$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_Stitcher.res.mjs";
|
|
23
20
|
import * as AppSync_SdlDecorate$ReventlessAws from "./AppSync_SdlDecorate.res.mjs";
|
|
24
21
|
import * as GraphQL_FragmentGenerator$ReventlessCore from "@reventlessdev/reventless-core/src/components/Api/GraphQL_FragmentGenerator.res.mjs";
|
|
@@ -90,84 +87,6 @@ function deploySchemaWithRetry(client, apiId, definition) {
|
|
|
90
87
|
})).then(param => Promise.resolve())), AppSync_Error$ReventlessAws.retrySchedule);
|
|
91
88
|
}
|
|
92
89
|
|
|
93
|
-
function schemaLockSleep(ms) {
|
|
94
|
-
return new Promise((resolve, param) => {
|
|
95
|
-
setTimeout(() => resolve(), ms);
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
let _schemaLockCounter = {
|
|
100
|
-
contents: 0
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
async function withSchemaPushLock(tableName, apiId, leaseMsOpt, maxWaitMsOpt, fn) {
|
|
104
|
-
let leaseMs = leaseMsOpt !== undefined ? leaseMsOpt : 120000;
|
|
105
|
-
let maxWaitMs = maxWaitMsOpt !== undefined ? maxWaitMsOpt : 180000;
|
|
106
|
-
let lockId = `schema-push-lock:` + apiId;
|
|
107
|
-
_schemaLockCounter.contents = _schemaLockCounter.contents + 1 | 0;
|
|
108
|
-
let holder = apiId + `#` + Date.now().toString() + `#` + _schemaLockCounter.contents.toString();
|
|
109
|
-
let acquire = async () => {
|
|
110
|
-
let deadline = Date.now() + maxWaitMs;
|
|
111
|
-
let acquired = false;
|
|
112
|
-
while (!acquired) {
|
|
113
|
-
let now = Date.now();
|
|
114
|
-
let ok = await Stdlib_Promise.$$catch(DynamoDb_DocumentClient$AwsSdk.PutCommand.send(new LibDynamodb.PutCommand({
|
|
115
|
-
Item: Object.fromEntries([
|
|
116
|
-
[
|
|
117
|
-
"id",
|
|
118
|
-
lockId
|
|
119
|
-
],
|
|
120
|
-
[
|
|
121
|
-
"holder",
|
|
122
|
-
holder
|
|
123
|
-
],
|
|
124
|
-
[
|
|
125
|
-
"expiresAt",
|
|
126
|
-
now + leaseMs
|
|
127
|
-
]
|
|
128
|
-
]),
|
|
129
|
-
TableName: tableName,
|
|
130
|
-
ConditionExpression: "attribute_not_exists(id) OR expiresAt < :now",
|
|
131
|
-
ExpressionAttributeValues: Object.fromEntries([[
|
|
132
|
-
":now",
|
|
133
|
-
now
|
|
134
|
-
]])
|
|
135
|
-
})).then(param => true), param => Promise.resolve(false));
|
|
136
|
-
if (ok) {
|
|
137
|
-
acquired = true;
|
|
138
|
-
} else if (Date.now() > deadline) {
|
|
139
|
-
log.warn("AppSync_Adapter", undefined, `schema-push lock for ` + apiId + ` not acquired within ` + maxWaitMs.toString() + `ms — proceeding best-effort`);
|
|
140
|
-
acquired = true;
|
|
141
|
-
} else {
|
|
142
|
-
await schemaLockSleep(1000);
|
|
143
|
-
}
|
|
144
|
-
};
|
|
145
|
-
};
|
|
146
|
-
let release = async () => {
|
|
147
|
-
await Stdlib_Promise.$$catch(DynamoDb_DocumentClient$AwsSdk.DeleteCommand.send(new LibDynamodb.DeleteCommand({
|
|
148
|
-
TableName: tableName,
|
|
149
|
-
Key: Object.fromEntries([[
|
|
150
|
-
"id",
|
|
151
|
-
lockId
|
|
152
|
-
]]),
|
|
153
|
-
ConditionExpression: "holder = :holder",
|
|
154
|
-
ExpressionAttributeValues: Object.fromEntries([[
|
|
155
|
-
":holder",
|
|
156
|
-
holder
|
|
157
|
-
]])
|
|
158
|
-
})).then(param => {}), param => Promise.resolve());
|
|
159
|
-
};
|
|
160
|
-
await acquire();
|
|
161
|
-
try {
|
|
162
|
-
let r = await fn();
|
|
163
|
-
await release();
|
|
164
|
-
return r;
|
|
165
|
-
} catch (exn) {
|
|
166
|
-
await release();
|
|
167
|
-
throw exn;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
90
|
let _client = {
|
|
172
91
|
contents: undefined
|
|
173
92
|
};
|
|
@@ -408,9 +327,6 @@ export {
|
|
|
408
327
|
waitForSchemaActive,
|
|
409
328
|
getIntrospectionSdl,
|
|
410
329
|
deploySchemaWithRetry,
|
|
411
|
-
schemaLockSleep,
|
|
412
|
-
_schemaLockCounter,
|
|
413
|
-
withSchemaPushLock,
|
|
414
330
|
_client,
|
|
415
331
|
getClient,
|
|
416
332
|
_permissionToCognitoGroups,
|