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

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.
Files changed (32) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +6 -6
  3. package/src/Platform.res +116 -849
  4. package/src/Platform.res.mjs +75 -710
  5. package/src/adapter/QueryDb/PgQueryResolver_Builder.res +4 -47
  6. package/src/adapter/QueryDb/PgQueryResolver_Builder.res.mjs +1 -30
  7. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res +0 -11
  8. package/src/adapter/QueryDb/QueryDbResolvers_AppSync.res.mjs +0 -5
  9. package/src/adapter/Runtime/SideEffectHandlerRuntime_Builder_Single.res +6 -7
  10. package/src/components/Api/AppSync_Adapter.res +13 -105
  11. package/src/components/Api/AppSync_Adapter.res.mjs +0 -52
  12. package/src/components/Api/AppSync_MergedApi.res +1 -1
  13. package/src/components/Api/AppSync_SdlDecorate.res +6 -65
  14. package/src/components/Api/AppSync_SdlDecorate.res.mjs +1 -31
  15. package/src/components/Plugin.res.mjs +1 -2
  16. package/tests/AppSync_AdapterTest.res +23 -15
  17. package/tests/AppSync_AdapterTest.res.mjs +18 -16
  18. package/tests/AppSync_SdlDecorateTest.res +9 -89
  19. package/tests/AppSync_SdlDecorateTest.res.mjs +7 -72
  20. package/tests/MCP_LambdaTest.res +4 -6
  21. package/tests/MCP_LambdaTest.res.mjs +2 -2
  22. package/src/adapter/Api/ApiFragmentDeregistration.res +0 -138
  23. package/src/adapter/Api/ApiFragmentDeregistration.res.mjs +0 -108
  24. package/src/adapter/Api/ApiSchemaPush.res +0 -79
  25. package/src/adapter/Api/ApiSchemaPush.res.mjs +0 -64
  26. package/src/adapter/Api/Platform_ApiFragments_Lambda.res +0 -222
  27. package/src/adapter/Api/Platform_ApiFragments_Lambda.res.mjs +0 -202
  28. package/src/adapter/QueryDb/NodeResolver_AppSync.res +0 -71
  29. package/src/adapter/QueryDb/NodeResolver_AppSync.res.mjs +0 -44
  30. package/src/adapter/Runtime/ApiSchemaPush_Runtime.mjs +0 -204
  31. package/tests/ApiSchemaPushTest.res +0 -32
  32. package/tests/ApiSchemaPushTest.res.mjs +0 -20
@@ -1,204 +0,0 @@
1
- // Runtime-pure push engine for the admin ApiSchemaPush SideEffect
2
- // (docs/plans/event-sourced-fragment-registries.md § Reactive writer design).
3
- //
4
- // The ApiFragmentRegistry singleton aggregate's behaviour emits ApiSchemaComputed
5
- // { snapshot } — the WHOLE per-plugin fragment set folded from the aggregate's own
6
- // consistent single-partition state after each change. The ApiSchemaPush SideEffect
7
- // (compiled from ApiSchemaPush.res) subscribes to that event via the aggregate's
8
- // DynamoDB stream (single-shard for a singleton → naturally serialized, no concurrent
9
- // StartSchemaCreation), and hands the snapshot here.
10
- //
11
- // This module stitches one AWS-decorated schema per target API from the snapshot
12
- // (identical decoration to the deploy path via the runtime-pure
13
- // AppSync_SdlDecorate.planAwsPushes), pushes each behind the catastrophic-shrink
14
- // guard, and writes the outcome back with RecordApiFragmentPush onto the
15
- // ApiFragmentRegistry aggregate's command topic so the deploy waiter can poll
16
- // Platform_ApiFragments.
17
- //
18
- // Runtime-purity discipline: NO @pulumi imports (this loads in the SideEffectHandler
19
- // Lambda — see reference_pulumi_leaks_into_lambda_runtime_graph). All config is read
20
- // from env at invocation time; the AppSync push uses the AppSync SDK directly.
21
-
22
- import { makeQueueRef, log } from "./HandlerFactoryHelpers.mjs";
23
- import { publishJsons as sqsPublishJsons } from "@reventlessdev/reventless-aws/src/adapter/CommandTopic/CommandTopicChannel_SQS_Runtime.res.mjs";
24
- import { planAwsPushes } from "@reventlessdev/reventless-aws/src/components/Api/AppSync_SdlDecorate.res.mjs";
25
- import {
26
- countRootTypeFields,
27
- isCatastrophicSchemaShrink,
28
- } from "@reventlessdev/reventless-core/src/components/Api/GraphQL_Stitcher.res.mjs";
29
- import {
30
- baseFragment as adminBaseFragment,
31
- systemCallerFieldNames,
32
- } from "@reventlessdev/reventless-core/src/admin/AdminApi.res.mjs";
33
-
34
- const COMP = "apiSchemaPush";
35
-
36
- // ── AppSync push primitives (mirror AdminEventCollectorEntryPoint.mjs) ──────────
37
-
38
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
39
-
40
- // Push the SDL. Bounded retry on the AppSync API-level lock ("Schema is currently being
41
- // altered" / ConcurrentModification) — the legacy connect-driven mkUpdateApiSchema still
42
- // pushes until Plan Phase 4, and a standalone-service push can also contend. The lock
43
- // clears in seconds, so this is NOT the plan's rejected lagging-read retry (that had no
44
- // ceiling). The singleton aggregate's single-shard stream already serializes THIS writer
45
- // against itself; this only covers cross-writer contention.
46
- async function updateAppSyncSchema(apiId, sdl) {
47
- const { AppSyncClient, StartSchemaCreationCommand } = await import("@aws-sdk/client-appsync");
48
- const client = new AppSyncClient({});
49
- const maxAttempts = 6;
50
- for (let attempt = 1; ; attempt++) {
51
- try {
52
- await client.send(new StartSchemaCreationCommand({ apiId, definition: sdl }));
53
- return;
54
- } catch (e) {
55
- const msg = (e && e.message) || String(e);
56
- const locked = /being altered|ConcurrentModification|currently in the process/i.test(msg);
57
- if (!locked || attempt >= maxAttempts) throw e;
58
- const backoff = Math.min(1000 * 2 ** (attempt - 1), 8000);
59
- log.warn(`schema push contended (${msg}) — retry ${attempt}/${maxAttempts} in ${backoff}ms`, { comp: COMP });
60
- await sleep(backoff);
61
- }
62
- }
63
- }
64
-
65
- // Current live schema as SDL for the shrink guard. "" (not error) when the API has
66
- // no schema yet or introspection fails — the caller treats "" as "no baseline".
67
- async function getCurrentSchemaSdl(apiId) {
68
- try {
69
- const { AppSyncClient, GetIntrospectionSchemaCommand } = await import("@aws-sdk/client-appsync");
70
- const client = new AppSyncClient({});
71
- const resp = await client.send(new GetIntrospectionSchemaCommand({ apiId, format: "SDL" }));
72
- if (!resp || !resp.schema) return "";
73
- return Buffer.from(resp.schema).toString("utf-8");
74
- } catch (e) {
75
- log.warn(`could not introspect current schema (${(e && e.message) || e}) — skipping shrink guard`, { comp: COMP });
76
- return "";
77
- }
78
- }
79
-
80
- function parseShrinkThreshold(raw) {
81
- const n = raw ? Number(raw) : NaN;
82
- return Number.isFinite(n) && n > 0 && n < 1 ? n : 0.5;
83
- }
84
-
85
- // Emit the SchemaShrinkRejected CloudWatch metric via EMF (raw console.log — must
86
- // stay unwrapped so CloudWatch's EMF auto-detect fires).
87
- function emitShrinkRejectionMetric(apiId, currentRootFields, newRootFields) {
88
- try {
89
- // eslint-disable-next-line no-console
90
- console.log(
91
- JSON.stringify({
92
- _aws: {
93
- Timestamp: Date.now(),
94
- CloudWatchMetrics: [
95
- {
96
- Namespace: "Reventless/Runtime",
97
- Dimensions: [["ApiId"]],
98
- Metrics: [{ Name: "SchemaShrinkRejected", Unit: "Count" }],
99
- },
100
- ],
101
- },
102
- ApiId: apiId,
103
- SchemaShrinkRejected: 1,
104
- currentRootFields,
105
- newRootFields,
106
- })
107
- );
108
- } catch {
109
- // metric emission is best-effort
110
- }
111
- }
112
-
113
- // ── Config (deploy-derived, injected as Lambda env by the admin SideEffectHandler) ──
114
-
115
- function readConfig() {
116
- const na = (v) => (v && v !== "NOT_AVAILABLE" ? v : "");
117
- const domainApiId = na(process.env["API_SCHEMA_PUSH_DOMAIN_API_ID"]);
118
- const platformApiId = na(process.env["API_SCHEMA_PUSH_PLATFORM_API_ID"]) || domainApiId;
119
- return {
120
- domainApiId,
121
- platformApiId,
122
- splitApi: process.env["API_SCHEMA_PUSH_SPLIT_API"] === "true",
123
- clonerEnabled: process.env["API_SCHEMA_PUSH_CLONER"] === "true",
124
- cmdTopicUrl: na(process.env["API_SCHEMA_PUSH_CMD_TOPIC_URL"]),
125
- };
126
- }
127
-
128
- // Write RecordApiFragmentPush back per plugin onto the ApiFragmentRegistry aggregate's
129
- // command topic (FIFO, singleton id="registry"). @noApi + idempotent — a no-op in the
130
- // aggregate behaviour if the plugin was deregistered in the meantime.
131
- async function recordPushOutcomes(cmdTopicUrl, pluginIds, ok, message) {
132
- if (!cmdTopicUrl || pluginIds.length === 0) return;
133
- const publisher = sqsPublishJsons(makeQueueRef(cmdTopicUrl), "SQS_FIFO");
134
- const at = new Date().toISOString();
135
- const commandJsons = pluginIds.map((pluginId) => ({
136
- id: "registry",
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 },
145
- commandJson: { TAG: "RecordApiFragmentPush", pluginId, ok, message, at },
146
- }));
147
- try {
148
- await publisher(commandJsons);
149
- } catch (e) {
150
- log.error(`RecordApiFragmentPush dispatch failed: ${(e && e.message) || e}`, { comp: COMP });
151
- }
152
- }
153
-
154
- // ── Entry point ────────────────────────────────────────────────────────────────
155
-
156
- // snapshot: array of { pluginId, encoded, protocol, apiTarget } — the consistent
157
- // registry contents after the triggering change, carried on the ApiSchemaComputed event.
158
- export async function pushApiSchema(snapshot) {
159
- const cfg = readConfig();
160
- const entries = Array.isArray(snapshot) ? snapshot.filter((e) => e && typeof e.encoded === "string" && e.encoded) : [];
161
- const pluginIds = [...new Set(entries.map((e) => e.pluginId).filter((p) => typeof p === "string"))];
162
- if (!cfg.cmdTopicUrl) {
163
- log.warn("ApiSchemaPush: no command-topic URL configured — skipping", { comp: COMP });
164
- return;
165
- }
166
-
167
- // Fragments straight from the consistent snapshot — NO eventually-consistent read.
168
- const fragments = entries.map((e) => ({
169
- encoded: e.encoded,
170
- protocol: typeof e.protocol === "string" ? e.protocol : "graphql",
171
- target: e.apiTarget === "Platform" ? "Platform" : "Domain",
172
- }));
173
-
174
- const rawAdminBase = adminBaseFragment(cfg.clonerEnabled);
175
- const plans = planAwsPushes(rawAdminBase, systemCallerFieldNames, fragments, cfg.splitApi);
176
-
177
- let ok = true;
178
- let message = "";
179
- for (const plan of plans) {
180
- const apiId = plan.api === "PlatformApi" ? cfg.platformApiId : cfg.domainApiId;
181
- if (!apiId) continue;
182
- const threshold = parseShrinkThreshold(process.env["RUNTIME_SCHEMA_SHRINK_THRESHOLD"]);
183
- const currentSdl = await getCurrentSchemaSdl(apiId);
184
- if (isCatastrophicSchemaShrink(currentSdl, plan.sdl, threshold)) {
185
- const cur = countRootTypeFields(currentSdl, "Mutation") + countRootTypeFields(currentSdl, "Query");
186
- const nw = countRootTypeFields(plan.sdl, "Mutation") + countRootTypeFields(plan.sdl, "Query");
187
- log.error(`ABORTED schema push for ${plan.api} (${apiId}): ${nw} root field(s) vs ${cur} live (threshold ${threshold}).`, { comp: COMP });
188
- emitShrinkRejectionMetric(apiId, cur, nw);
189
- ok = false;
190
- message = `shrink guard aborted push for ${plan.api}`;
191
- continue;
192
- }
193
- try {
194
- await updateAppSyncSchema(apiId, plan.sdl);
195
- log.info(`schema push OK: ${plan.api} (${apiId})`, { comp: COMP });
196
- } catch (e) {
197
- ok = false;
198
- message = (e && e.message) || String(e);
199
- log.error(`schema push FAILED: ${plan.api} (${apiId}): ${message}`, { comp: COMP });
200
- }
201
- }
202
-
203
- await recordPushOutcomes(cfg.cmdTopicUrl, pluginIds, ok, message);
204
- }
@@ -1,32 +0,0 @@
1
- // Regression guard for the ApiSchemaPush SideEffect "Source erasure" deploy-only bug.
2
- //
3
- // SideEffectHandler_Callback.Make reads Source.{name,eventSchema,Id} reflectively off
4
- // the imported ApiSchemaPush module at Lambda cold start. ApiSchemaPush only uses Source
5
- // at the TYPE level, so a bare `module Source = …` alias gets dead-shaken by ReScript to
6
- // `let Source;` (undefined) — crashing the handler with "Cannot read properties of
7
- // undefined (reading 'eventSchema')" and hanging the deploy waiter. The `include` in
8
- // ApiSchemaPush.res materialises Source's runtime values. These assertions dereference
9
- // exactly the fields the runtime reads, so a regression (alias reintroduced) fails here
10
- // instead of only on a live deploy.
11
-
12
- open JestGlobals
13
-
14
- describe("ApiSchemaPush SideEffect Source (runtime materialisation)", () => {
15
- testSync("Source.name is the registry aggregate name", () => {
16
- expect(ApiSchemaPush.Source.name)->toBe("ApiFragmentRegistry")
17
- })
18
-
19
- testSync("Source.eventSchema is a live schema — extractAllVariantNames succeeds", () => {
20
- // Mirrors SideEffectHandler_Callback.Make: throws if Source is erased to undefined.
21
- let tags = Reventless.DcbTag.extractAllVariantNames(ApiSchemaPush.Source.eventSchema)
22
- expect(tags)->toContain("ApiSchemaComputed")
23
- })
24
-
25
- testSync("Source.Id is a live module — the reflective id round-trip does not crash", () => {
26
- // SideEffectHandler_Callback reads `Source.Id.schema` on the id-bearing registry events.
27
- // A bare `module Id` alias compiles to `Id: undefined` in the Source record, so any access
28
- // (makeFromString/toString/schema) throws "reading 'schema'"/undefined on a regression.
29
- let id = "registry"->ApiSchemaPush.Source.Id.makeFromString
30
- expect(id->ApiSchemaPush.Source.Id.toString)->toBe("registry")
31
- })
32
- })
@@ -1,20 +0,0 @@
1
- // Generated by ReScript, PLEASE EDIT WITH CARE
2
-
3
- import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
4
- import * as ApiSchemaPush$ReventlessAws from "../src/adapter/Api/ApiSchemaPush.res.mjs";
5
-
6
- globalThis.describe("ApiSchemaPush SideEffect Source (runtime materialisation)", () => {
7
- globalThis.test("Source.name is the registry aggregate name", () => {
8
- globalThis.expect(ApiSchemaPush$ReventlessAws.Source.name).toBe("ApiFragmentRegistry");
9
- });
10
- globalThis.test("Source.eventSchema is a live schema — extractAllVariantNames succeeds", () => {
11
- let tags = DcbTag$Reventless.extractAllVariantNames(ApiSchemaPush$ReventlessAws.Source.eventSchema);
12
- globalThis.expect(tags).toContain("ApiSchemaComputed");
13
- });
14
- globalThis.test("Source.Id is a live module — the reflective id round-trip does not crash", () => {
15
- let id = ApiSchemaPush$ReventlessAws.Source.Id.makeFromString("registry");
16
- globalThis.expect(ApiSchemaPush$ReventlessAws.Source.Id.toString(id)).toBe("registry");
17
- });
18
- });
19
-
20
- /* Not a pure module */