@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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.209 (2026-07-13)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **admin:** resolve ApiSchemaPush cmd-topic URL via switch, not option<Output> ([de6c7b3](https://github.com/ReventlessDev/reventless-core/commit/de6c7b39be732d7def8667004e80fa78c31b7772)), closes [#11](https://github.com/ReventlessDev/reventless-core/issues/11)
11
+ * **admin:** unique + SQS-safe msgId for RecordApiFragmentPush write-back ([af122fa](https://github.com/ReventlessDev/reventless-core/commit/af122fa37c4a337e1cd5b8eccc0d0c4d069403a1)), closes [#12](https://github.com/ReventlessDev/reventless-core/issues/12)
12
+
13
+
14
+ # 3.0.0-alpha.208 (2026-07-13)
15
+
16
+ ### Bug Fixes
17
+
18
+ * **admin:** materialise ApiSchemaPush Source.Id at runtime (part 2) ([e64eeae](https://github.com/ReventlessDev/reventless-core/commit/e64eeae9fd08c03dcadf600aac733840790772e4)), closes [#9](https://github.com/ReventlessDev/reventless-core/issues/9) [#10](https://github.com/ReventlessDev/reventless-core/issues/10)
19
+
20
+
6
21
  # 3.0.0-alpha.207 (2026-07-13)
7
22
 
8
23
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-aws",
3
- "version": "3.0.0-alpha.207",
3
+ "version": "3.0.0-alpha.209",
4
4
  "description": "AWS adapters for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "dependencies": {
@@ -8,17 +8,17 @@
8
8
  "@aws-sdk/client-cloudfront": "3.970.0",
9
9
  "sury": "11.0.0-alpha.4",
10
10
  "uuid": "^13.0.0",
11
- "@reventlessdev/rescript-effect": "0.1.0-alpha.27",
12
11
  "@reventlessdev/rescript-aws-sdk": "2.2.0-alpha.21",
13
- "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.50",
14
12
  "@reventlessdev/rescript-jest": "1.0.0-alpha.7",
13
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.27",
14
+ "@reventlessdev/rescript-pulumi-aws": "2.4.0-alpha.50",
15
15
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.15",
16
- "@reventlessdev/reventless-core": "3.0.0-alpha.164",
17
16
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.15",
18
17
  "@reventlessdev/reventless-infra": "3.0.0-alpha.98",
19
- "@reventlessdev/reventless-interop": "3.0.0-alpha.26",
20
- "@reventlessdev/reventless-postgres": "3.0.0-alpha.28",
21
- "@reventlessdev/reventless-spec": "3.0.0-alpha.76"
18
+ "@reventlessdev/reventless-postgres": "3.0.0-alpha.29",
19
+ "@reventlessdev/reventless-core": "3.0.0-alpha.165",
20
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.76",
21
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.26"
22
22
  },
23
23
  "devDependencies": {
24
24
  "rescript": "^12.3.0",
package/src/Platform.res CHANGED
@@ -17,14 +17,6 @@ let log = ReventlessCore.Logger.fromEnv()
17
17
  // runtime RUNTIME_SCHEMA_SHRINK_THRESHOLD in AdminEventCollectorEntryPoint.mjs).
18
18
  // A push whose stitched SDL has fewer than (threshold × live) root fields is
19
19
  // refused as a likely stale concurrent-deploy stitch. Default 0.5; override via
20
- // DEPLOY_SCHEMA_SHRINK_THRESHOLD; values outside (0, 1) fall back to the default.
21
- @val @scope("process") external processEnv: Dict.t<string> = "env"
22
- let deploySchemaShrinkThreshold: float =
23
- switch processEnv->Dict.get("DEPLOY_SCHEMA_SHRINK_THRESHOLD")->Option.flatMap(Float.fromString) {
24
- | Some(n) if n > 0. && n < 1. => n
25
- | _ => 0.5
26
- }
27
-
28
20
  // API config ref — populated during MakeWithConfig so slice builders
29
21
  // can access api/apiRole outside the functor constraint.
30
22
  //
@@ -622,34 +614,6 @@ module MakeWithConfig = (
622
614
 
623
615
  // AWS platform hooks — all AWS-specific callbacks defined as a record.
624
616
  // In-memory hooks (mutationResolverHook etc.) are absent (optional = None).
625
- let deploySchemaPrefix = "deploy-schema:"
626
- let deploySchemaPlatformPrefix = "deploy-schema-platform:"
627
- let deploySchemaHashPrefix = "deploy-schema-hash:"
628
-
629
- let readSchemaHash = async (~tableName: string, ~apiId: string): option<string> => {
630
- open AwsSdk.DynamoDb.DocumentClient
631
- let key = Dict.fromArray([("id", `${deploySchemaHashPrefix}${apiId}`->JSON.Encode.string)])
632
- try {
633
- let result = await GetCommand.send(GetCommand.make({GetCommand.tableName, key}))
634
- result.item
635
- ->Option.flatMap(item => item->JSON.Decode.object)
636
- ->Option.flatMap(d => d->Dict.get("hash"))
637
- ->Option.flatMap(v => v->JSON.Decode.string)
638
- } catch {
639
- | _ => None
640
- }
641
- }
642
-
643
- let writeSchemaHash = async (~tableName: string, ~apiId: string, ~hash: string): unit => {
644
- open AwsSdk.DynamoDb.DocumentClient
645
- let item =
646
- Dict.fromArray([
647
- ("id", `${deploySchemaHashPrefix}${apiId}`->JSON.Encode.string),
648
- ("hash", hash->JSON.Encode.string),
649
- ])->JSON.Encode.object
650
- let _ = await PutCommand.send(PutCommand.make({PutCommand.tableName, item}))
651
- }
652
-
653
617
  // (Deploy-time retire hook removed: supersession is now decided by the
654
618
  // name-keyed Plugin aggregate (VersionSuperseded) — no RM scan drives a command.)
655
619
 
@@ -931,11 +895,11 @@ module MakeWithConfig = (
931
895
  }
932
896
  },
933
897
 
934
- // Accumulate fragments across independent plugin deployments: each plugin
935
- // writes its fragment to the Plugin RM table (keyed "deploy-schema:<name>")
936
- // at deploy time. The hook then scans for ALL deploy-schema entries and
937
- // stitches them together ensuring the schema is cumulative rather than
938
- // overwritten by each plugin deployment.
898
+ // Staged deploy (deployPlugin against a running platform): register the
899
+ // plugin's fragment via the Platform API (SigV4); the reactive ApiSchemaPush
900
+ // SideEffect stitches + pushes cumulatively. The legacy all-at-once
901
+ // deploy-schema:* write+scan+push path was retired in Phase 4b (makePlatform
902
+ // with plugins is no longer supported on AWS).
939
903
  preResolversSchemaHook: (~name, ~version, pluginFragment) => {
940
904
  log.info(
941
905
  ~comp="preResolversSchemaHook",
@@ -956,342 +920,14 @@ module MakeWithConfig = (
956
920
  }
957
921
  registerFragmentViaApi(~name, ~fragment=pluginFragment, ~apiTargetName)
958
922
  | None =>
959
- // All-at-once (makePlatform): platform + plugins deploy in one stack, so the
960
- // reactive writer is dormant keep the direct deploy-time stitch + push below.
961
-
962
- // Select DynamoDB key prefix and target AppSync API based on the current deploy target.
963
- // Domain plugins use "deploy-schema:" and the Domain API (default behaviour).
964
- // Platform plugins use a separate "deploy-schema-platform:" namespace and the Core API,
965
- // so their cumulative schema is kept independent from the Domain API's schema.
966
- let (schemaPrefix, targetApi) = switch capturedDeployTarget {
967
- | Domain => (deploySchemaPrefix, domainApi)
968
- | Platform =>
969
- let api = switch apiConfigRef.contents {
970
- | Some({platformApi}) => platformApi
971
- | None => domainApi // fallback: Core API not yet constructed
972
- }
973
- (deploySchemaPlatformPrefix, api)
974
- }
975
-
976
- // Read a string output from the platform StackReference, falling back to
977
- // the bundled "default" output object if the named export is not present
978
- // (matches the layout Pulumi emits when a stack uses a single default
979
- // export rather than per-key exports).
980
- let readStackRefString = (stackRef, key) => {
981
- let direct: Pulumi.Output.t<option<JSON.t>> =
982
- stackRef->Pulumi.StackReference.getOutput(key)
983
- let defaultOutput: Pulumi.Output.t<option<JSON.t>> =
984
- stackRef->Pulumi.StackReference.getOutput("default")
985
- (direct, defaultOutput)
986
- ->Pulumi.Output.all2
987
- ->Pulumi.Output.apply(((direct, default)) =>
988
- switch direct->Option.flatMap(v => v->JSON.Decode.string) {
989
- | Some(name) => Some(name)
990
- | None =>
991
- default
992
- ->Option.flatMap(d => d->JSON.Decode.object)
993
- ->Option.flatMap(d => d->Dict.get(key))
994
- ->Option.flatMap(v => v->JSON.Decode.string)
995
- }
996
- )
997
- }
998
-
999
- // Prefer the dedicated PluginSchemaPersistence table (post-platform-fix);
1000
- // fall back to the Plugin RM table for backward compatibility with
1001
- // platforms deployed before the schema-persistence table existed. The
1002
- // Plugin RM table must not be reused for new schema-fragment writes —
1003
- // doing so leaks deploy-schema rows through the Platform_Plugins AppSync
1004
- // Connection resolver.
1005
- let schemaPersistenceTableNameOutput: Pulumi.Output.t<option<string>> = switch platformStackRef {
1006
- | Some(stackRef) =>
1007
- (
1008
- readStackRefString(stackRef, "pluginSchemaPersistenceTableName"),
1009
- readStackRefString(stackRef, "pluginRmTableName"),
1010
- )
1011
- ->Pulumi.Output.all2
1012
- ->Pulumi.Output.apply(((dedicated, legacy)) =>
1013
- switch dedicated {
1014
- | Some(_) as s => s
1015
- | None => legacy
1016
- }
1017
- )
1018
- | None => Pulumi.Output.make(None)
1019
- }
1020
-
1021
- schemaPersistenceTableNameOutput
1022
- ->Pulumi.Output.flatMap(tableNameOpt => {
1023
- // Write this plugin's fragment to DynamoDB, then scan all deploy-schema
1024
- // entries to collect every deployed plugin's fragment.
1025
- let writeAndScanFragments = () =>
1026
- switch tableNameOpt {
1027
- | None =>
1028
- log.info(
1029
- ~comp="preResolversSchemaHook",
1030
- "No pluginSchemaPersistenceTableName / pluginRmTableName — skipping fragment persistence",
1031
- )
1032
- Promise.resolve([pluginFragment])
1033
- | Some(tableName) =>
1034
- open AwsSdk.DynamoDb.DocumentClient
1035
- // Write this plugin's fragment so subsequent plugin deployments find it.
1036
- let deployItem =
1037
- Dict.fromArray([
1038
- ("id", `${schemaPrefix}${name}`->JSON.Encode.string),
1039
- ("fragment", pluginFragment.encoded->JSON.Encode.string),
1040
- ])->JSON.Encode.object
1041
- log.info(
1042
- ~comp="preResolversSchemaHook",
1043
- `Writing deploy-schema entry for ${name} to ${tableName}`,
1044
- )
1045
- // Paginated scan — accumulate every deploy-schema entry across pages.
1046
- // A single ScanCommand returns at most 1 MB before yielding a
1047
- // LastEvaluatedKey; loop until the table is exhausted so a platform
1048
- // with many plugin fragments never stitches a partial schema.
1049
- let scanAllDeploySchemaItems = async () => {
1050
- let allItems = []
1051
- let startKey = ref(None)
1052
- let more = ref(true)
1053
- while more.contents {
1054
- let result = await ScanCommand.send(
1055
- ScanCommand.make({
1056
- ScanCommand.tableName: tableName,
1057
- filterExpression: "begins_with(#id, :prefix)",
1058
- expressionAttributeNames: Dict.fromArray([("#id", "id")]),
1059
- expressionAttributeValues: Dict.fromArray([
1060
- (":prefix", schemaPrefix->JSON.Encode.string),
1061
- ]),
1062
- exclusiveStartKey: ?startKey.contents,
1063
- }),
1064
- )
1065
- result.items->Option.getOr([])->Array.forEach(item => allItems->Array.push(item))
1066
- switch result.lastEvaluatedKey {
1067
- | Some(_) as k => startKey := k
1068
- | None => more := false
1069
- }
1070
- }
1071
- allItems
1072
- }
1073
-
1074
- PutCommand.send(PutCommand.make({PutCommand.tableName: tableName, item: deployItem}))
1075
- ->Promise.then(_ => {
1076
- // Scan for all deploy-schema entries from previously deployed plugins.
1077
- log.info(
1078
- ~comp="preResolversSchemaHook",
1079
- `Scanning ${tableName} for deploy-schema entries`,
1080
- )
1081
- scanAllDeploySchemaItems()
1082
- })
1083
- ->Promise.then(items => {
1084
- let fragments = items->Array.filterMap(item => {
1085
- try {
1086
- let obj = item->JSON.stringify->JSON.parseOrThrow
1087
- switch obj->JSON.Decode.object->Option.flatMap(d => d->Dict.get("fragment")) {
1088
- | Some(fragmentJson) =>
1089
- switch fragmentJson->JSON.Decode.string {
1090
- | Some(encoded) =>
1091
- Some({Reventless.Plugin.encoded, protocol: "graphql"})
1092
- | None => None
1093
- }
1094
- | None => None
1095
- }
1096
- } catch {
1097
- | _ => None
1098
- }
1099
- })
1100
- log.info(
1101
- ~comp="preResolversSchemaHook",
1102
- `Found ${fragments->Array.length->Int.toString} deploy-schema entries`,
1103
- )
1104
- Promise.resolve(fragments)
1105
- })
1106
- ->Promise.catch(err => {
1107
- let msg =
1108
- err
1109
- ->JsExn.fromException
1110
- ->Option.flatMap(JsExn.message)
1111
- ->Option.getOr("unknown")
1112
- log.info(
1113
- ~comp="preResolversSchemaHook",
1114
- `DynamoDB write/scan failed (${msg}) — using current plugin only`,
1115
- )
1116
- Promise.resolve([pluginFragment])
1117
- })
1118
- }
1119
-
1120
- targetApi->Pulumi.Output.flatMap(api =>
1121
- api.id->Pulumi.Output.flatMap(apiId => {
1122
- // Serialise write-row → scan → stitch → push under the shared
1123
- // schema-push lease so a concurrent peer's stale scan can't clobber
1124
- // this stack's fields (see AppSync_Adapter.withSchemaPushLock).
1125
- let runSchemaPush = () => {
1126
- writeAndScanFragments()
1127
- ->Promise.then(async allPluginFragments => {
1128
- // Base fragment selection:
1129
- // - Platform target: always include admin base (the Core API owns admin ops).
1130
- // - Domain target, split mode: empty base (admin lives on Core API).
1131
- // - Domain target, unified mode: include admin base (single API has everything).
1132
- // Use capturedDeployTarget (set synchronously above) — currentDeployTarget has
1133
- // been reset to Domain by deployPlugin before this async callback runs.
1134
- let baseFragment = switch capturedDeployTarget {
1135
- | Platform =>
1136
- AppSync_Adapter.injectAwsAuthAll(
1137
- ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
1138
- ~group="Admin",
1139
- ~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
1140
- )
1141
- | Domain =>
1142
- if Config.splitApi {
1143
- emptyBaseFragment
1144
- } else {
1145
- AppSync_Adapter.injectAwsAuthAll(
1146
- ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
1147
- ~group="Admin",
1148
- )
1149
- }
1150
- }
1151
- let sdl = AppSync_Adapter.stitchWithAwsDirectives(
1152
- ~baseFragment,
1153
- ~pluginFragments=allPluginFragments,
1154
- )
1155
- let currentHash = AppSync_Adapter.sha256Hex(sdl)
1156
- let storedHash = switch tableNameOpt {
1157
- | Some(tn) => await readSchemaHash(~tableName=tn, ~apiId)
1158
- | None => None
1159
- }
1160
- let client = AppSync_Adapter.getClient()
1161
-
1162
- // Introspect the live schema once — reused for the hash-match
1163
- // drift/repair check and the catastrophic-shrink guard on the push.
1164
- let liveSdl = await AppSync_Adapter.getIntrospectionSdl(client, apiId)
1165
-
1166
- // The stored hash records what the DEPLOY last pushed. A runtime
1167
- // re-stitch (mkUpdateApiSchema) can clobber the live schema
1168
- // out-of-band WITHOUT updating this hash, so a matching hash does
1169
- // not guarantee the live schema is intact. Before trusting the
1170
- // hash to skip the push, introspect the live schema and confirm it
1171
- // still carries at least as many root-type (Mutation + Query +
1172
- // Subscription) fields as the SDL we would push. If it has drifted
1173
- // (shrunk) — or cannot be introspected despite a stored hash, which
1174
- // means a real failure rather than a first deploy — force the
1175
- // repair push so a clobbered schema heals on the next deploy.
1176
- let countRoots = s =>
1177
- ReventlessCore.GraphQL_Stitcher.countRootTypeFields(~sdl=s, ~typeName="Mutation") +
1178
- ReventlessCore.GraphQL_Stitcher.countRootTypeFields(~sdl=s, ~typeName="Query") +
1179
- ReventlessCore.GraphQL_Stitcher.countRootTypeFields(
1180
- ~sdl=s,
1181
- ~typeName="Subscription",
1182
- )
1183
- // Identity-aware drift check (not a bare count): the live schema is
1184
- // "intact" only when it is a SUPERSET of every root field we would
1185
- // push. Comparing name SETS heals equal-cardinality drift and field
1186
- // *swaps* — an admin-base clobber that leaves the DomainApi with the
1187
- // SAME number of root fields but the WRONG ones (admin-base instead
1188
- // of plugin fields) has a matching count yet is missing every
1189
- // expected plugin field, so a count test would wrongly skip.
1190
- let missingFields = ReventlessCore.GraphQL_Stitcher.missingRootFields(
1191
- ~expectedSdl=sdl,
1192
- ~liveSdl,
1193
- )
1194
- let skipPush = switch storedHash {
1195
- | Some(prev) if prev == currentHash =>
1196
- if liveSdl == "" {
1197
- log.info(
1198
- ~comp="preResolversSchemaHook",
1199
- `hash matches but live schema could not be introspected — forcing repair push (check appsync:GetIntrospectionSchema permission)`,
1200
- )
1201
- false
1202
- } else if missingFields->Array.length > 0 {
1203
- log.info(
1204
- ~comp="preResolversSchemaHook",
1205
- `hash matches but live schema is missing ${missingFields
1206
- ->Array.length
1207
- ->Int.toString} expected root field(s) (e.g. ${missingFields
1208
- ->Array.slice(~start=0, ~end=5)
1209
- ->Array.join(", ")}) — forcing repair push`,
1210
- )
1211
- false
1212
- } else {
1213
- log.info(
1214
- ~comp="preResolversSchemaHook",
1215
- `SDL unchanged (hash ${currentHash->String.slice(
1216
- ~start=0,
1217
- ~end=12,
1218
- )}…) and live schema is a superset of the expected root fields (${countRoots(
1219
- liveSdl,
1220
- )->Int.toString} live); skipping push`,
1221
- )
1222
- true
1223
- }
1224
- | _ => false
1225
- }
1226
- if !skipPush {
1227
- // Shrink guard — deploy-time counterpart of the runtime
1228
- // mkUpdateApiSchema guard (AdminEventCollectorEntryPoint.mjs).
1229
- // Plugin/service stacks share one AppSync API and StartSchemaCreation
1230
- // REPLACES the whole schema. A concurrent peer that scanned the
1231
- // deploy-schema table before this stack wrote its fragment row
1232
- // stitches an SDL missing this stack's fields; pushing it would drop
1233
- // the live fields and orphan their resolvers (NotFoundException: No
1234
- // field named X). Refuse a push that would catastrophically shrink
1235
- // the live schema — the field-owner's own deploy (whose scan includes
1236
- // its freshly-written row) pushes the complete set.
1237
- // isCatastrophicSchemaShrink returns false when the live schema is
1238
- // empty (first deploy / introspection unavailable), so the initial
1239
- // push still proceeds.
1240
- if (
1241
- ReventlessCore.GraphQL_Stitcher.isCatastrophicSchemaShrink(
1242
- ~currentSdl=liveSdl,
1243
- ~newSdl=sdl,
1244
- ~threshold=deploySchemaShrinkThreshold,
1245
- )
1246
- ) {
1247
- log.error(
1248
- ~comp="preResolversSchemaHook",
1249
- `ABORTED schema push for ${apiId}: stitched SDL (${countRoots(
1250
- sdl,
1251
- )->Int.toString} root field(s)) would catastrophically shrink the live schema (${countRoots(
1252
- liveSdl,
1253
- )->Int.toString} root field(s), threshold ${deploySchemaShrinkThreshold->Float.toString}) — refusing to clobber resolvers (likely a stale concurrent-deploy scan)`,
1254
- )
1255
- } else {
1256
- log.info(
1257
- ~comp="preResolversSchemaHook",
1258
- `Pushing schema to API ${apiId} (${allPluginFragments->Array.length->Int.toString} plugin fragments, new hash: ${currentHash->String.slice(~start=0, ~end=12)}…)`,
1259
- )
1260
- await client->AppSync_Adapter.startSchemaCreationRetrying({
1261
- apiId,
1262
- definition: sdl,
1263
- })
1264
- log.info(
1265
- ~comp="preResolversSchemaHook",
1266
- "startSchemaCreation called, waiting for ACTIVE",
1267
- )
1268
- await AppSync_Adapter.waitForSchemaActive(client, apiId)
1269
- log.info(~comp="preResolversSchemaHook", "Schema is ACTIVE")
1270
- switch tableNameOpt {
1271
- | Some(tn) =>
1272
- await writeSchemaHash(~tableName=tn, ~apiId, ~hash=currentHash)
1273
- | None => ()
1274
- }
1275
- }
1276
- }
1277
-
1278
- // No deploy-time retire scan: the name-keyed Plugin aggregate
1279
- // decides supersession itself (VersionSuperseded) when the new
1280
- // version connects, so the manifest carries only the current
1281
- // version without any RM-read-driven command.
1282
- })
1283
- }
1284
-
1285
- (
1286
- switch tableNameOpt {
1287
- | Some(tableName) =>
1288
- AppSync_Adapter.withSchemaPushLock(~tableName, ~apiId, runSchemaPush)
1289
- | None => runSchemaPush()
1290
- }
1291
- )->Pulumi.Output.fromPromise
1292
- })
923
+ // makePlatform(~plugins=[…]) all-at-once deploy-schema push retired in Phase 4b.
924
+ // Staged deployPlatform + deployPlugin (register + reactive SideEffect push) is the
925
+ // sole supported AWS deploy path. deployPlatform deploys no plugins, so this hook only
926
+ // fires for makePlatform-with-plugins no longer supported on AWS.
927
+ failwith(
928
+ "makePlatform(~plugins=[…]) is no longer supported on AWS deploy the platform with " ++
929
+ "deployPlatform and each plugin with deployPlugin (staged register + reactive push).",
1293
930
  )
1294
- })
1295
931
  }
1296
932
  },
1297
933
  // DCB EventLog created hook — extracts DynamoDB table name for DCB CommandTopic Lambda handler.
@@ -1890,47 +1526,9 @@ module MakeWithConfig = (
1890
1526
  // (Heartbeat, ForwardCommand). None here keeps the deploy-time path
1891
1527
  // unchanged; the .mjs entry point supplies a real implementation.
1892
1528
  let manageSubscriptions = None
1893
- let updateApiSchema = Some(async (queryEngine: Reventless.QueryEngine.operations) => {
1894
- open Reventless.QueryEngine.Filter
1895
- let apiId = domainApiId->Pulumi.Output.get
1896
- let plugins = await queryEngine.scan(
1897
- ~readModelName="Plugins",
1898
- ~filterConfigs=[("status", Contains, String("Connected"))],
1899
- ~limit=1000,
1900
- )
1901
- let fragments = plugins->Array.filterMap(json =>
1902
- try {
1903
- let state = json->S.parseOrThrow(ReventlessCore.PluginsReadModelSpec.stateSchema)
1904
- // Exclude Platform-target plugins — their schema belongs on the PlatformApi,
1905
- // not the DomainApi. Absent apiTarget defaults to "Domain".
1906
- switch state.apiTarget {
1907
- | Some("Platform") => None
1908
- | _ => state.apiSchemaFragment
1909
- }
1910
- } catch {
1911
- | _ => None
1912
- }
1913
- )
1914
- // In split mode, the plugin API only has plugin schema (admin is on the core API).
1915
- // In unified mode, stitch admin + plugins into the single shared API.
1916
- let baseFragment = if Config.splitApi {
1917
- emptyBaseFragment
1918
- } else {
1919
- AppSync_Adapter.injectAwsAuthAll(
1920
- ReventlessCore.AdminApi.baseFragment(~cloner=Config.cloner),
1921
- ~group="Admin",
1922
- ~iamFieldNames=ReventlessCore.AdminApi.systemCallerFieldNames,
1923
- )
1924
- }
1925
- let sdl = AppSync_Adapter.stitchWithAwsDirectives(
1926
- ~baseFragment,
1927
- ~pluginFragments=fragments,
1928
- )
1929
- await AppSync_Adapter.getClient()->AppSync_Adapter.startSchemaCreationRetrying({
1930
- apiId,
1931
- definition: sdl,
1932
- })
1933
- })
1529
+ // Runtime connect-driven schema self-heal retired in Phase 4b — the reactive
1530
+ // ApiSchemaPush SideEffect (on ApiFragmentRegistry events) is the single writer.
1531
+ let updateApiSchema = None
1934
1532
  })
1935
1533
 
1936
1534
  // Phase 2: Admin resolvers go on the Platform API (platformApi) in split mode,
@@ -1960,18 +1558,26 @@ module MakeWithConfig = (
1960
1558
  // deploy caller fires RegisterApiFragment); makePlatform pushes the schema directly.
1961
1559
  let apiSchemaPushEventTopics = ReventlessCore.Aggregate.allEventTopics(admin.aggregatesOutputs)
1962
1560
  let apiSchemaPushCmdTopics = ReventlessCore.Aggregate.allCommandTopics(admin.aggregatesOutputs)
1963
- let apiSchemaPushCmdTopicUrl =
1964
- admin.aggregatesOutputs
1965
- ->Dict.get(ReventlessCore.ApiFragmentRegistrySpec.name)
1966
- ->Option.map(agg =>
1967
- agg.commandTopic->Pulumi.Output.flatMap(ct =>
1968
- switch ct.resources->Array.get(0) {
1969
- | Some(r) => r.id
1970
- | None => Pulumi.Output.make("")
1971
- }
1972
- )
1561
+ // MUST be a `switch`, NOT `->Option.map(...)->Option.getOr(...)`. The map/getOr form
1562
+ // materialises an `option<Pulumi.Output.t<string>>`, and wrapping a Pulumi Output in a
1563
+ // ReScript option collapses the nested Output to `undefined` at runtime (the documented
1564
+ // "option(Pulumi.Output.t) doesn't work" pitfall). That made API_SCHEMA_PUSH_CMD_TOPIC_URL
1565
+ // resolve to undefined → Pulumi dropped the env var → the ApiSchemaPush runtime logged
1566
+ // "no command-topic URL configured — skipping" and never pushed/recorded, so the deploy
1567
+ // waiter timed out. Verified via local `pulumi preview`: map/getOr → isValidOutput=false;
1568
+ // switch isValidOutput=true.
1569
+ let apiSchemaPushCmdTopicUrl = switch admin.aggregatesOutputs->Dict.get(
1570
+ ReventlessCore.ApiFragmentRegistrySpec.name,
1571
+ ) {
1572
+ | Some(agg) =>
1573
+ agg.commandTopic->Pulumi.Output.flatMap(ct =>
1574
+ switch ct.resources->Array.get(0) {
1575
+ | Some(r) => r.id
1576
+ | None => Pulumi.Output.make("")
1577
+ }
1973
1578
  )
1974
- ->Option.getOr(Pulumi.Output.make(""))
1579
+ | None => Pulumi.Output.make("")
1580
+ }
1975
1581
  let apiSchemaPushEnv = Dict.fromArray([
1976
1582
  ("API_SCHEMA_PUSH_DOMAIN_API_ID", domainApiId->Pulumi.Output.asInput),
1977
1583
  (
@@ -2029,19 +1635,6 @@ module MakeWithConfig = (
2029
1635
  | None => None
2030
1636
  }
2031
1637
 
2032
- // Dedicated DynamoDB table for deploy-time schema-fragment persistence
2033
- // (deploy-schema:<name>, deploy-schema-platform:<name>, deploy-schema-hash:<apiId>).
2034
- // Previously these infrastructure rows shared the Plugin RM table, which
2035
- // caused them to leak through Platform_Plugins' auto-generated AppSync
2036
- // Connection resolver (an unfiltered Scan). Hosting them on their own
2037
- // table keeps Plugin RM = Plugin aggregate entities only and restores
2038
- // parity with the in-memory adapter (which has no preResolversSchemaHook).
2039
- let pluginSchemaPersistenceTable = Util.DynamoDb.makeTable(
2040
- "PluginSchemaPersistence",
2041
- ~attributes=[{name: "id", type_: "S"}],
2042
- ~opts={},
2043
- )
2044
-
2045
1638
  PluginExtensionPointRuntime_Builder.registerPluginExtensionPoint(
2046
1639
  ~pluginReadModelTableName?,
2047
1640
  ~schedulerRoleArn=hooks.schedulerRoleUrn.contents,
@@ -2075,34 +1668,8 @@ module MakeWithConfig = (
2075
1668
  ~eventTopicArn=pluginEpEventTopicArn,
2076
1669
  ~appSyncApiId=domainApiId,
2077
1670
  ~pluginReadModelTableName?,
2078
- // Runtime schema stitch reads deploy-time fragments from this durable table
2079
- // rather than the lifecycle-volatile Plugin RM Connected rows.
2080
- ~pluginSchemaPersistenceTableName=pluginSchemaPersistenceTable.name,
2081
1671
  ~schedulerRoleArn=hooks.schedulerRoleUrn.contents,
2082
1672
  ~clonerEnabled=Config.cloner,
2083
- // 2e: the reactive ApiFragmentRegistry single writer (admin EventCollector).
2084
- // The Platform AppSync id it pushes Platform-target fragments to (unified →
2085
- // domainApi, since platformApi == domainApi); the ApiFragments StateViewSlice
2086
- // table it re-folds from; the admin DCB command-topic FIFO URL it dispatches
2087
- // RecordApiFragmentPush to (captured during Admin.construct); and the mode flag.
2088
- ~platformApiId=platformApi->Pulumi.Output.flatMap(api => api.id),
2089
- // NB: must NOT be `->Option.map(r => r.name)`. `apiFragmentRegistryTableName`
2090
- // is `option<Pulumi.Output.t<string>>` (the forbidden pattern, CLAUDE.md code
2091
- // smells). The generic `Option.map` body runs `Primitive_option.some(r.name)`,
2092
- // and because a Pulumi Output lifts arbitrary property access, `some` inspects
2093
- // `.BS_PRIVATE_NESTED_SOME_NONE`, mis-reads the Output as a nested option, and
2094
- // stores the sentinel `{BS_PRIVATE_NESTED_SOME_NONE: 0}` instead of the Output —
2095
- // so the consumer's `tableOutput->Pulumi.Output.apply` crashes with
2096
- // "apply is not a function". A `Some(r.name)` LITERAL compiles unboxed (bare
2097
- // r.name), preserving the Output — the same dodge `pluginReadModelTableName` uses.
2098
- ~apiFragmentRegistryTableName=?switch admin.readModelsOutputs
2099
- ->Dict.get("ApiFragments")
2100
- ->Option.flatMap(rm => rm.queryDb.resources->Array.get(0)) {
2101
- | Some(r) => Some(r.name)
2102
- | None => None
2103
- },
2104
- ~adminDcbCmdTopicUrl=?AutomationSliceRuntime_Builder_Single.getDcbQueueUrl(),
2105
- ~splitApi=Config.splitApi,
2106
1673
  (),
2107
1674
  )
2108
1675
 
@@ -2215,14 +1782,6 @@ module MakeWithConfig = (
2215
1782
  | None => ()
2216
1783
  }
2217
1784
 
2218
- // Export the dedicated schema-persistence table name. preResolversSchemaHook
2219
- // prefers this over pluginRmTableName so deploy-schema rows no longer share
2220
- // the Plugin RM table.
2221
- Pulumi.Pulumi.export(
2222
- "pluginSchemaPersistenceTableName",
2223
- pluginSchemaPersistenceTable.name,
2224
- )
2225
-
2226
1785
  // (No pluginAggrCmdTopicUrl export: the deploy-time retire hook that
2227
1786
  // published Retire commands to the Plugin aggregate queue is gone —
2228
1787
  // supersession is decided by the aggregate on connect.)