@reventlessdev/reventless-aws 3.0.0-alpha.208 → 3.0.0-alpha.210

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.
@@ -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. mkUpdateApiSchema does the same for
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
- updateApiSchema: updateApiSchemaFn,
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, reactiveApiSchemaPush };
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, 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
- }
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
- meta: { service: "ApiFragmentRegistry", time: at, msgId: "pending", correlationId: pluginId },
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 {