@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.
@@ -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 {
@@ -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,