@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 +15 -0
- package/package.json +7 -7
- package/src/Platform.res +34 -475
- package/src/Platform.res.mjs +119 -610
- package/src/adapter/Api/ApiSchemaPush.res +24 -8
- package/src/adapter/Api/ApiSchemaPush.res.mjs +27 -1
- package/src/adapter/Runtime/AdminEventCollectorEntryPoint.mjs +7 -382
- package/src/adapter/Runtime/ApiSchemaPush_Runtime.mjs +8 -1
- package/src/components/Api/AppSync_Adapter.res +0 -91
- package/src/components/Api/AppSync_Adapter.res.mjs +0 -84
- package/src/plugin/runtime/PluginRuntime_Builder.res +0 -139
- package/src/plugin/runtime/PluginRuntime_Builder.res.mjs +4 -80
- package/tests/ApiSchemaPushTest.res +8 -0
- package/tests/ApiSchemaPushTest.res.mjs +4 -0
- package/tests/MCP_LambdaTest.res +6 -4
- package/tests/MCP_LambdaTest.res.mjs +2 -2
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.
|
|
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-
|
|
20
|
-
"@reventlessdev/reventless-
|
|
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
|
-
//
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
//
|
|
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
|
-
//
|
|
960
|
-
//
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
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
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
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
|
-
|
|
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.)
|