@reventlessdev/reventless-aws 3.0.0-alpha.207 → 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.
@@ -14,15 +14,31 @@
14
14
  // is loaded via dynamic import so no @aws-sdk / provider code is statically captured
15
15
  // beyond what the SideEffectHandler Lambda already bundles (runtime-purity — see
16
16
  // reference_pulumi_leaks_into_lambda_runtime_graph).
17
- // `include` (not a bare `module Source = …` alias) is deliberate. SideEffectHandler_Callback
18
- // reads Source.{name,eventSchema,Id} reflectively off this module's compiled export at cold
19
- // start, but ApiSchemaPush only uses Source at the TYPE level (Source.event, Source.Id.t,
20
- // Source.fragmentSnapshotEntry) a bare module alias is dead-shaken by ReScript to
21
- // `let Source;` (undefined), crashing the handler with "Cannot read properties of undefined
22
- // (reading 'eventSchema')". `include` materialises the module's runtime values into the export
23
- // while keeping full type transparency for the pattern match below.
17
+ module Spec = ReventlessCore.ApiFragmentRegistrySpec
18
+
19
+ // Source is built EXPLICITLY rather than aliasing/including the spec. SideEffectHandler_Callback
20
+ // reads Source.{name,eventSchema,Id.schema} reflectively off this module's compiled export at
21
+ // Lambda cold start, but the spec uses these only at the TYPE level, so ReScript dead-shakes
22
+ // them: a bare `module Source = Spec` alias erases the whole binding to `let Source;`, and even
23
+ // the spec's own @@reventless.spec-injected `module Id` compiles to `let Id;` (undefined) — the
24
+ // cross-module runtime reflection is invisible to ReScript's optimiser (deploy-time is fine
25
+ // because Platform.res hand-wires these values). Both surfaced on deploy as "Cannot read
26
+ // properties of undefined (reading 'eventSchema' / 'schema')". Forwarding name/eventSchema (real
27
+ // spec exports) and re-binding Id (String — the singleton registry id, matching Platform.res's
28
+ // hand-wiring) materialises all three, while the type aliases keep full transparency for the
29
+ // pattern match below.
24
30
  module Source = {
25
- include ReventlessCore.ApiFragmentRegistrySpec
31
+ type event = Spec.event
32
+ type fragmentSnapshotEntry = Spec.fragmentSnapshotEntry
33
+ // `include` (not `module Id = Reventless.Id.String`) — a bare module alias compiles to
34
+ // `Id: undefined` in the Source record (Id is only type-projected via Source.Id.t), and
35
+ // SideEffectHandler_Callback's `Source.Id.schema` reflective read crashes at cold start.
36
+ // `include` materialises the Id module's runtime values into the record.
37
+ module Id = {
38
+ include Reventless.Id.String
39
+ }
40
+ let name = Spec.name
41
+ let eventSchema = Spec.eventSchema
26
42
  }
27
43
 
28
44
  let moduleUrl: string = %raw(`import.meta.url`)
@@ -1,8 +1,31 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
+ import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
3
4
  import * as ApiFragmentRegistrySpec$ReventlessCore from "@reventlessdev/reventless-core/src/admin/ApiFragmentRegistry/ApiFragmentRegistrySpec.res.mjs";
4
5
 
5
- let Source = ApiFragmentRegistrySpec$ReventlessCore;
6
+ let Id_schema = Id$Reventless.$$String.schema;
7
+
8
+ let Id_make = Id$Reventless.$$String.make;
9
+
10
+ let Id_makeFromString = Id$Reventless.$$String.makeFromString;
11
+
12
+ let Id_toString = Id$Reventless.$$String.toString;
13
+
14
+ let Id_cmp = Id$Reventless.$$String.cmp;
15
+
16
+ let Id = {
17
+ schema: Id_schema,
18
+ make: Id_make,
19
+ makeFromString: Id_makeFromString,
20
+ toString: Id_toString,
21
+ cmp: Id_cmp
22
+ };
23
+
24
+ let Source = {
25
+ Id: Id,
26
+ name: ApiFragmentRegistrySpec$ReventlessCore.name,
27
+ eventSchema: ApiFragmentRegistrySpec$ReventlessCore.eventSchema
28
+ };
6
29
 
7
30
  let moduleUrl = import.meta.url;
8
31
 
@@ -29,7 +52,10 @@ async function execute(_id, _meta, event, _queryEngine) {
29
52
  return await pushApiSchema(jsSnapshot);
30
53
  }
31
54
 
55
+ let Spec;
56
+
32
57
  export {
58
+ Spec,
33
59
  Source,
34
60
  moduleUrl,
35
61
  pushApiSchema,
@@ -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 = () =>