@reventlessdev/reventless-core 3.0.0-alpha.159 → 3.0.0-alpha.161
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 +6 -6
- package/src/admin/AdminApi.res +38 -38
- package/src/admin/AdminApi.res.mjs +19 -12
- package/src/admin/ApiFragmentRegistry/ApiFragmentRegistryBehavior.res +108 -0
- package/src/admin/ApiFragmentRegistry/ApiFragmentRegistryBehavior.res.mjs +190 -0
- package/src/admin/ApiFragmentRegistry/ApiFragmentRegistrySpec.res +71 -0
- package/src/admin/ApiFragmentRegistry/ApiFragmentRegistrySpec.res.mjs +92 -0
- package/src/admin/ApiFragmentRegistry/ApiFragmentsProjection.res +63 -0
- package/src/admin/ApiFragmentRegistry/ApiFragmentsProjection.res.mjs +108 -0
- package/src/admin/ApiFragmentRegistry/ApiFragmentsReadModelSpec.res +23 -0
- package/src/admin/ApiFragmentRegistry/ApiFragmentsReadModelSpec.res.mjs +43 -0
- package/src/admin/Platform_Admin.res +0 -4
- package/src/admin/Platform_Admin.res.mjs +1 -1
- package/src/admin/Platform_ApiFragmentsApi.res +2 -2
- package/src/components/Api/GraphQL_Stitcher.res +11 -1
- package/src/components/Api/GraphQL_Stitcher.res.mjs +1 -1
- package/src/components/SideEffectHandler/SideEffectHandler.res +5 -0
- package/src/components/SideEffectHandler/SideEffectHandler_Builder.res +3 -0
- package/src/components/SideEffectHandler/SideEffectHandler_Builder.res.mjs +1 -1
- package/src/components/Task/Task_Builder.res.mjs +1 -1
- package/tests/admin/Platform_ApiFragmentsApiTest.res +1 -1
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.161 (2026-07-13)
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **admin:** ApiFragmentRegistry singleton aggregate + ApiFragments read model (foundation) ([3203512](https://github.com/ReventlessDev/reventless-core/commit/3203512f8b312e7ce6361ffb7a89e6659364dcb8)), closes [#4](https://github.com/ReventlessDev/reventless-core/issues/4)
|
|
11
|
+
* **admin:** ApiFragmentRegistry slice→singleton-aggregate + reactive schema-push SideEffect ([cf70903](https://github.com/ReventlessDev/reventless-core/commit/cf709033f0d822248ebbcfe3fe6b4019b3c1e8a0)), closes [#4](https://github.com/ReventlessDev/reventless-core/issues/4)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# 3.0.0-alpha.160 (2026-07-13)
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* **api:** dual-auth arg-less IAM fields — extractLeadingName dropped the trailing colon ([ea34da2](https://github.com/ReventlessDev/reventless-core/commit/ea34da229164db63988ad035138c17ba9134ac55))
|
|
19
|
+
|
|
20
|
+
|
|
6
21
|
# 3.0.0-alpha.159 (2026-07-12)
|
|
7
22
|
|
|
8
23
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reventlessdev/reventless-core",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.161",
|
|
4
4
|
"description": "Core package for Reventless framework",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"jest": {
|
|
@@ -28,17 +28,17 @@
|
|
|
28
28
|
"dependencies": {
|
|
29
29
|
"sury": "11.0.0-alpha.4",
|
|
30
30
|
"uuid": "^13.0.0",
|
|
31
|
+
"@reventlessdev/rescript-effect": "0.1.0-alpha.27",
|
|
32
|
+
"@reventlessdev/rescript-fast-csv": "1.2.0-alpha.12",
|
|
31
33
|
"@reventlessdev/rescript-hash-object": "1.2.0-alpha.11",
|
|
32
|
-
"@reventlessdev/rescript-jest": "1.0.0-alpha.7",
|
|
33
34
|
"@reventlessdev/rescript-node-streams": "1.1.0-alpha.11",
|
|
34
|
-
"@reventlessdev/rescript-
|
|
35
|
-
"@reventlessdev/rescript-effect": "0.1.0-alpha.27",
|
|
35
|
+
"@reventlessdev/rescript-jest": "1.0.0-alpha.7",
|
|
36
36
|
"@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.15",
|
|
37
37
|
"@reventlessdev/rescript-ssh2": "1.1.0-alpha.12",
|
|
38
38
|
"@reventlessdev/rescript-uuid": "1.1.0-alpha.15",
|
|
39
39
|
"@reventlessdev/reventless-infra": "3.0.0-alpha.98",
|
|
40
|
-
"@reventlessdev/reventless-
|
|
41
|
-
"@reventlessdev/reventless-
|
|
40
|
+
"@reventlessdev/reventless-interop": "3.0.0-alpha.26",
|
|
41
|
+
"@reventlessdev/reventless-spec": "3.0.0-alpha.76"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
44
|
"rescript": "^12.3.0",
|
package/src/admin/AdminApi.res
CHANGED
|
@@ -9,55 +9,55 @@ let cloneMutationEntry: mutationSchemaEntry = {
|
|
|
9
9
|
description: "Clone the system to a specific point in time",
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
// Aggregate-derived admin mutations come from
|
|
13
|
-
// `PluginBaseFragment.pluginAggregateMutationEntries` so they end up in the
|
|
14
|
-
// stitched SDL (the AWS path pushes `AdminApi.baseFragment` directly). The
|
|
15
|
-
// corresponding resolver wiring is fired in parallel by
|
|
16
|
-
// `Plugin_Helpers.registerAdminAggregateMutations` from `Platform_Admin.construct`.
|
|
17
|
-
let mutationEntries = (~cloner: bool) => {
|
|
18
|
-
let base = PluginBaseFragment.pluginAggregateMutationEntries
|
|
19
|
-
if cloner {
|
|
20
|
-
Array.concat(base, [cloneMutationEntry])
|
|
21
|
-
} else {
|
|
22
|
-
base
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
let queryEntries = PluginBaseFragment.queryEntries
|
|
27
|
-
|
|
28
12
|
// ApiFragmentRegistry admin mutations — the real GraphQL surface of the ApiFragmentRegistry
|
|
29
|
-
//
|
|
30
|
-
// `
|
|
31
|
-
//
|
|
32
|
-
// `
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
// pushed to AppSync (`Platform.res` preAdminResolversSchemaHook), whereas the constructed admin
|
|
38
|
-
// fragment (`Platform_Admin.construct`) already carries them via `dcbResult.mutationEntries`. Adding
|
|
39
|
-
// them to `mutationEntries` — which feeds both — would duplicate the fields in the constructed
|
|
40
|
-
// fragment. They are folded into `baseFragment`'s own `generate` call (not a separate one) so its
|
|
41
|
-
// shared `seenTypes` dedups the shared `CommandResult` type family instead of re-emitting it.
|
|
13
|
+
// singleton aggregate: the plugin / standalone-service deploy calls
|
|
14
|
+
// `Platform_ApiFragmentRegistry_RegisterApiFragment` / `..._DeregisterApiFragment` as a SigV4
|
|
15
|
+
// system caller. Derived from the aggregate's `commandSchema` exactly like the Plugin aggregate's
|
|
16
|
+
// entries (`PluginBaseFragment.pluginAggregateMutationEntries`) — one `Platform_<Spec>_<Command>`
|
|
17
|
+
// field per non-`@noApi` variant (`RecordApiFragmentPush` is `@noApi`, filtered out). The matching
|
|
18
|
+
// resolver wiring is fired by `Plugin_Helpers.registerAdminAggregateMutations` over the same
|
|
19
|
+
// `~aggregates` list in `Platform_Admin.construct`, so the SDL field and the auto-bound resolver
|
|
20
|
+
// carry byte-identical names.
|
|
42
21
|
let apiFragmentRegistryMutationEntries: array<mutationSchemaEntry> = {
|
|
43
|
-
let commandSchema =
|
|
44
|
-
let
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
22
|
+
let commandSchema = ApiFragmentRegistrySpec.commandSchema->S.castToUnknown
|
|
23
|
+
let constructorNames = Reventless.DcbTag.extractAllVariantNames(ApiFragmentRegistrySpec.commandSchema)
|
|
24
|
+
let filteredConstructorNames = ApiNoApiHelpers.filterNoApiVariants(constructorNames, commandSchema)
|
|
25
|
+
let fieldNames =
|
|
26
|
+
filteredConstructorNames->Array.map(cname =>
|
|
27
|
+
Api_Naming.adminField(~name=ApiFragmentRegistrySpec.name ++ "_" ++ cname)
|
|
28
|
+
)
|
|
29
|
+
if fieldNames->Array.length === 0 {
|
|
50
30
|
[]
|
|
51
31
|
} else {
|
|
52
32
|
[
|
|
53
33
|
{
|
|
54
|
-
ReventlessInfra.Api.fieldNames
|
|
34
|
+
ReventlessInfra.Api.fieldNames,
|
|
55
35
|
commandSchema,
|
|
56
36
|
},
|
|
57
37
|
]
|
|
58
38
|
}
|
|
59
39
|
}
|
|
60
40
|
|
|
41
|
+
// Aggregate-derived admin mutations come from `PluginBaseFragment.pluginAggregateMutationEntries`
|
|
42
|
+
// (Plugin aggregate) + `apiFragmentRegistryMutationEntries` (ApiFragmentRegistry aggregate) so they
|
|
43
|
+
// end up in the stitched SDL AND in the static `baseFragment` the AWS path pushes directly. The
|
|
44
|
+
// corresponding resolver wiring is fired in parallel by
|
|
45
|
+
// `Plugin_Helpers.registerAdminAggregateMutations` from `Platform_Admin.construct` over the same
|
|
46
|
+
// `~aggregates` list.
|
|
47
|
+
let mutationEntries = (~cloner: bool) => {
|
|
48
|
+
let base = Array.concat(
|
|
49
|
+
PluginBaseFragment.pluginAggregateMutationEntries,
|
|
50
|
+
apiFragmentRegistryMutationEntries,
|
|
51
|
+
)
|
|
52
|
+
if cloner {
|
|
53
|
+
Array.concat(base, [cloneMutationEntry])
|
|
54
|
+
} else {
|
|
55
|
+
base
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
let queryEntries = PluginBaseFragment.queryEntries
|
|
60
|
+
|
|
61
61
|
// Admin fields a deploy-time system caller (machine credentials — SigV4/IAM on AWS) invokes: the
|
|
62
62
|
// two ApiFragmentRegistry mutations (the deploy's register/deregister write path) and
|
|
63
63
|
// `Platform_ApiFragments` (the deploy waiter's push-status poll). Provider-neutral by design — the
|
|
@@ -122,7 +122,7 @@ let pluginStatusSubscriptionSource: GraphQL_Stitcher.subscriptionSource = {
|
|
|
122
122
|
|
|
123
123
|
let baseFragment = (~cloner: bool) => {
|
|
124
124
|
let base = GraphQL_FragmentGenerator.generate(
|
|
125
|
-
~mutationEntries=
|
|
125
|
+
~mutationEntries=mutationEntries(~cloner),
|
|
126
126
|
~queryEntries,
|
|
127
127
|
)
|
|
128
128
|
let parts = GraphQL_Stitcher.decode(base)
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
4
5
|
import * as Api_Naming$ReventlessCore from "../components/Api/Api_Naming.res.mjs";
|
|
6
|
+
import * as ApiNoApiHelpers$ReventlessCore from "../components/Api/ApiNoApiHelpers.res.mjs";
|
|
5
7
|
import * as GraphQL_Stitcher$ReventlessCore from "../components/Api/GraphQL_Stitcher.res.mjs";
|
|
6
8
|
import * as PluginBaseFragment$ReventlessCore from "../plugin/api/PluginBaseFragment.res.mjs";
|
|
7
|
-
import * as
|
|
9
|
+
import * as ApiFragmentRegistrySpec$ReventlessCore from "./ApiFragmentRegistry/ApiFragmentRegistrySpec.res.mjs";
|
|
8
10
|
import * as Platform_UIFragmentsApi$ReventlessCore from "./Platform_UIFragmentsApi.res.mjs";
|
|
9
11
|
import * as Platform_ApiFragmentsApi$ReventlessCore from "./Platform_ApiFragmentsApi.res.mjs";
|
|
10
12
|
import * as GraphQL_FragmentGenerator$ReventlessCore from "../components/Api/GraphQL_FragmentGenerator.res.mjs";
|
|
@@ -25,21 +27,26 @@ let cloneMutationEntry = {
|
|
|
25
27
|
description: cloneMutationEntry_description
|
|
26
28
|
};
|
|
27
29
|
|
|
30
|
+
let constructorNames = DcbTag$Reventless.extractAllVariantNames(ApiFragmentRegistrySpec$ReventlessCore.commandSchema);
|
|
31
|
+
|
|
32
|
+
let filteredConstructorNames = ApiNoApiHelpers$ReventlessCore.filterNoApiVariants(constructorNames, ApiFragmentRegistrySpec$ReventlessCore.commandSchema);
|
|
33
|
+
|
|
34
|
+
let fieldNames = filteredConstructorNames.map(cname => Api_Naming$ReventlessCore.adminField(ApiFragmentRegistrySpec$ReventlessCore.name + "_" + cname));
|
|
35
|
+
|
|
36
|
+
let apiFragmentRegistryMutationEntries = fieldNames.length === 0 ? [] : [{
|
|
37
|
+
fieldNames: fieldNames,
|
|
38
|
+
commandSchema: ApiFragmentRegistrySpec$ReventlessCore.commandSchema
|
|
39
|
+
}];
|
|
40
|
+
|
|
28
41
|
function mutationEntries(cloner) {
|
|
42
|
+
let base = PluginBaseFragment$ReventlessCore.pluginAggregateMutationEntries.concat(apiFragmentRegistryMutationEntries);
|
|
29
43
|
if (cloner) {
|
|
30
|
-
return
|
|
44
|
+
return base.concat([cloneMutationEntry]);
|
|
31
45
|
} else {
|
|
32
|
-
return
|
|
46
|
+
return base;
|
|
33
47
|
}
|
|
34
48
|
}
|
|
35
49
|
|
|
36
|
-
let fieldSpecs = Api_Naming$ReventlessCore.sliceMutationFields("Platform", ApiFragmentRegistry$ReventlessCore.name, ApiFragmentRegistry$ReventlessCore.commandSchema);
|
|
37
|
-
|
|
38
|
-
let apiFragmentRegistryMutationEntries = fieldSpecs.length === 0 ? [] : [{
|
|
39
|
-
fieldNames: fieldSpecs.map(param => param[0]),
|
|
40
|
-
commandSchema: ApiFragmentRegistry$ReventlessCore.commandSchema
|
|
41
|
-
}];
|
|
42
|
-
|
|
43
50
|
let systemCallerFieldNames = apiFragmentRegistryMutationEntries.flatMap(e => e.fieldNames).concat([Platform_ApiFragmentsApi$ReventlessCore.queryFieldName]);
|
|
44
51
|
|
|
45
52
|
let uiFragmentSubscriptionTypes = [
|
|
@@ -83,7 +90,7 @@ let pluginStatusSubscriptionSource = {
|
|
|
83
90
|
};
|
|
84
91
|
|
|
85
92
|
function baseFragment(cloner) {
|
|
86
|
-
let base = GraphQL_FragmentGenerator$ReventlessCore.generate(mutationEntries(cloner)
|
|
93
|
+
let base = GraphQL_FragmentGenerator$ReventlessCore.generate(mutationEntries(cloner), PluginBaseFragment$ReventlessCore.queryEntries);
|
|
87
94
|
let parts = GraphQL_Stitcher$ReventlessCore.decode(base);
|
|
88
95
|
let match = Plugin_SubscriptionSchema$ReventlessCore.sourceCFields(PluginBaseFragment$ReventlessCore.pluginAggregateMutationEntries);
|
|
89
96
|
return GraphQL_Stitcher$ReventlessCore.encode({
|
|
@@ -110,9 +117,9 @@ let queryEntries = PluginBaseFragment$ReventlessCore.queryEntries;
|
|
|
110
117
|
export {
|
|
111
118
|
cloneArgsSchema,
|
|
112
119
|
cloneMutationEntry,
|
|
120
|
+
apiFragmentRegistryMutationEntries,
|
|
113
121
|
mutationEntries,
|
|
114
122
|
queryEntries,
|
|
115
|
-
apiFragmentRegistryMutationEntries,
|
|
116
123
|
systemCallerFieldNames,
|
|
117
124
|
uiFragmentSubscriptionTypes,
|
|
118
125
|
uiFragmentMutationFields,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
@@reventless.behavior(ApiFragmentRegistrySpec)
|
|
2
|
+
|
|
3
|
+
// NOTE: no `open Reventless.Plugin` — it would shadow the `encoded`/`protocol` labels reused
|
|
4
|
+
// below when building the fragment snapshot (warning 45). The two Plugin types are qualified.
|
|
5
|
+
|
|
6
|
+
// The current fragment for a plugin name plus its target API and the last recorded push
|
|
7
|
+
// outcome (kept only to dedupe at-least-once redelivery of RecordApiFragmentPush).
|
|
8
|
+
type registryEntry = {
|
|
9
|
+
fragment: Reventless.Plugin.apiSchemaFragment,
|
|
10
|
+
apiTarget: Reventless.Plugin.apiTarget,
|
|
11
|
+
lastPush: option<(bool, string, string)>, // (ok, message, at)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Singleton aggregate: the WHOLE registry, keyed by pluginId, in one consistency boundary.
|
|
15
|
+
type state = {entries: dict<registryEntry>}
|
|
16
|
+
|
|
17
|
+
let initialState = {entries: Dict.make()}
|
|
18
|
+
|
|
19
|
+
// Framework-internal aggregate outside an Aggregate/ folder — the Behavior.T snapshot/counter
|
|
20
|
+
// fields are satisfied manually (mirrors PluginBehavior). The registry's history is short
|
|
21
|
+
// (deploy-frequency writes), so persisted snapshots would buy nothing.
|
|
22
|
+
let snapshot = None
|
|
23
|
+
let atomicCounter = None
|
|
24
|
+
|
|
25
|
+
// Immutable dict updates (no reliance on Dict.copy/Dict.delete): rebuild via toArray/fromArray
|
|
26
|
+
// so evolve never mutates the replayed state.
|
|
27
|
+
let entriesWith = (entries: dict<registryEntry>, pluginId, entry) =>
|
|
28
|
+
entries
|
|
29
|
+
->Dict.toArray
|
|
30
|
+
->Array.filter(((k, _)) => k != pluginId)
|
|
31
|
+
->Array.concat([(pluginId, entry)])
|
|
32
|
+
->Dict.fromArray
|
|
33
|
+
|
|
34
|
+
let entriesWithout = (entries: dict<registryEntry>, pluginId) =>
|
|
35
|
+
entries->Dict.toArray->Array.filter(((k, _)) => k != pluginId)->Dict.fromArray
|
|
36
|
+
|
|
37
|
+
let evolve = (state, event) =>
|
|
38
|
+
switch event {
|
|
39
|
+
| ApiFragmentRegistered({pluginId, fragment, apiTarget}) => {
|
|
40
|
+
entries: state.entries->entriesWith(pluginId, {fragment, apiTarget, lastPush: None}),
|
|
41
|
+
}
|
|
42
|
+
| ApiFragmentUpdated({pluginId, newFragment, apiTarget}) => {
|
|
43
|
+
entries: state.entries->entriesWith(pluginId, {fragment: newFragment, apiTarget, lastPush: None}),
|
|
44
|
+
}
|
|
45
|
+
| ApiFragmentDeregistered({pluginId}) => {entries: state.entries->entriesWithout(pluginId)}
|
|
46
|
+
| ApiFragmentPushRecorded({pluginId, ok, message, at}) =>
|
|
47
|
+
switch state.entries->Dict.get(pluginId) {
|
|
48
|
+
| Some(entry) => {
|
|
49
|
+
entries: state.entries->entriesWith(pluginId, {...entry, lastPush: Some((ok, message, at))}),
|
|
50
|
+
}
|
|
51
|
+
| None => state
|
|
52
|
+
}
|
|
53
|
+
// Derived echo — no fold change.
|
|
54
|
+
| ApiSchemaComputed(_) => state
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// The consistent registry snapshot AFTER the triggering change — one entry per registered plugin.
|
|
58
|
+
let snapshotOf = (entries: dict<registryEntry>): array<ApiFragmentRegistrySpec.fragmentSnapshotEntry> =>
|
|
59
|
+
entries
|
|
60
|
+
->Dict.toArray
|
|
61
|
+
->Array.map(((pluginId, e)) => {
|
|
62
|
+
ApiFragmentRegistrySpec.pluginId,
|
|
63
|
+
encoded: e.fragment.encoded,
|
|
64
|
+
protocol: e.fragment.protocol,
|
|
65
|
+
apiTarget: e.apiTarget,
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// Emit the fact event plus ApiSchemaComputed{snapshot} folded from the POST-change state, so the
|
|
69
|
+
// reactive push SideEffect stitches from consistent state. Idempotent no-ops emit nothing (and
|
|
70
|
+
// therefore no push). RecordApiFragmentPush records the outcome only — it changes no fragments,
|
|
71
|
+
// so it emits no ApiSchemaComputed (which also avoids a recompute loop).
|
|
72
|
+
let decide = (state, command) => {
|
|
73
|
+
let withSchema = (fact: ApiFragmentRegistrySpec.event) => {
|
|
74
|
+
let next = evolve(state, fact)
|
|
75
|
+
Ok([fact, ApiSchemaComputed({snapshot: snapshotOf(next.entries)})])
|
|
76
|
+
}
|
|
77
|
+
switch command {
|
|
78
|
+
| RegisterApiFragment({pluginId, fragment, apiTarget, at}) =>
|
|
79
|
+
switch state.entries->Dict.get(pluginId) {
|
|
80
|
+
| None => withSchema(ApiFragmentRegistered({pluginId, fragment, apiTarget, at}))
|
|
81
|
+
// Idempotent: an unchanged re-registration (same fragment AND same target — a redeploy
|
|
82
|
+
// carrying the same schema, or a version supersession that didn't change the SDL) emits
|
|
83
|
+
// nothing. A target change with an identical fragment IS meaningful (the fields move between
|
|
84
|
+
// the Domain and Platform APIs), so it is not a no-op.
|
|
85
|
+
| Some({fragment: current, apiTarget: currentTarget})
|
|
86
|
+
if current == fragment && currentTarget == apiTarget =>
|
|
87
|
+
Ok([])
|
|
88
|
+
| Some({fragment: current}) =>
|
|
89
|
+
withSchema(
|
|
90
|
+
ApiFragmentUpdated({pluginId, previousFragment: current, newFragment: fragment, apiTarget, at}),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
| DeregisterApiFragment({pluginId}) =>
|
|
94
|
+
switch state.entries->Dict.get(pluginId) {
|
|
95
|
+
| None => Ok([]) // idempotent — nothing registered
|
|
96
|
+
| Some(_) => withSchema(ApiFragmentDeregistered({pluginId: pluginId}))
|
|
97
|
+
}
|
|
98
|
+
| RecordApiFragmentPush({pluginId, ok, message, at}) =>
|
|
99
|
+
switch state.entries->Dict.get(pluginId) {
|
|
100
|
+
| None => Ok([]) // fragment deregistered in the meantime — nowhere to record
|
|
101
|
+
// Idempotent: an at-least-once redelivery carries the identical payload.
|
|
102
|
+
| Some({lastPush: Some((lastOk, lastMessage, lastAt))})
|
|
103
|
+
if lastOk == ok && lastMessage == message && lastAt == at =>
|
|
104
|
+
Ok([])
|
|
105
|
+
| Some(_) => Ok([ApiFragmentPushRecorded({pluginId, ok, message, at})])
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
4
|
+
|
|
5
|
+
let initialState = {
|
|
6
|
+
entries: {}
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function entriesWith(entries, pluginId, entry) {
|
|
10
|
+
return Object.fromEntries(Object.entries(entries).filter(param => param[0] !== pluginId).concat([[
|
|
11
|
+
pluginId,
|
|
12
|
+
entry
|
|
13
|
+
]]));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function entriesWithout(entries, pluginId) {
|
|
17
|
+
return Object.fromEntries(Object.entries(entries).filter(param => param[0] !== pluginId));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function evolve(state, event) {
|
|
21
|
+
switch (event.TAG) {
|
|
22
|
+
case "ApiFragmentRegistered" :
|
|
23
|
+
return {
|
|
24
|
+
entries: entriesWith(state.entries, event.pluginId, {
|
|
25
|
+
fragment: event.fragment,
|
|
26
|
+
apiTarget: event.apiTarget,
|
|
27
|
+
lastPush: undefined
|
|
28
|
+
})
|
|
29
|
+
};
|
|
30
|
+
case "ApiFragmentUpdated" :
|
|
31
|
+
return {
|
|
32
|
+
entries: entriesWith(state.entries, event.pluginId, {
|
|
33
|
+
fragment: event.newFragment,
|
|
34
|
+
apiTarget: event.apiTarget,
|
|
35
|
+
lastPush: undefined
|
|
36
|
+
})
|
|
37
|
+
};
|
|
38
|
+
case "ApiFragmentDeregistered" :
|
|
39
|
+
return {
|
|
40
|
+
entries: entriesWithout(state.entries, event.pluginId)
|
|
41
|
+
};
|
|
42
|
+
case "ApiFragmentPushRecorded" :
|
|
43
|
+
let pluginId = event.pluginId;
|
|
44
|
+
let entry = state.entries[pluginId];
|
|
45
|
+
if (entry !== undefined) {
|
|
46
|
+
return {
|
|
47
|
+
entries: entriesWith(state.entries, pluginId, {
|
|
48
|
+
fragment: entry.fragment,
|
|
49
|
+
apiTarget: entry.apiTarget,
|
|
50
|
+
lastPush: [
|
|
51
|
+
event.ok,
|
|
52
|
+
event.message,
|
|
53
|
+
event.at
|
|
54
|
+
]
|
|
55
|
+
})
|
|
56
|
+
};
|
|
57
|
+
} else {
|
|
58
|
+
return state;
|
|
59
|
+
}
|
|
60
|
+
case "ApiSchemaComputed" :
|
|
61
|
+
return state;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function snapshotOf(entries) {
|
|
66
|
+
return Object.entries(entries).map(param => {
|
|
67
|
+
let e = param[1];
|
|
68
|
+
return {
|
|
69
|
+
pluginId: param[0],
|
|
70
|
+
encoded: e.fragment.encoded,
|
|
71
|
+
protocol: e.fragment.protocol,
|
|
72
|
+
apiTarget: e.apiTarget
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function decide(state, command) {
|
|
78
|
+
let withSchema = fact => {
|
|
79
|
+
let next = evolve(state, fact);
|
|
80
|
+
return {
|
|
81
|
+
TAG: "Ok",
|
|
82
|
+
_0: [
|
|
83
|
+
fact,
|
|
84
|
+
{
|
|
85
|
+
TAG: "ApiSchemaComputed",
|
|
86
|
+
snapshot: snapshotOf(next.entries)
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
switch (command.TAG) {
|
|
92
|
+
case "RegisterApiFragment" :
|
|
93
|
+
let at = command.at;
|
|
94
|
+
let apiTarget = command.apiTarget;
|
|
95
|
+
let fragment = command.fragment;
|
|
96
|
+
let pluginId = command.pluginId;
|
|
97
|
+
let match = state.entries[pluginId];
|
|
98
|
+
if (match === undefined) {
|
|
99
|
+
return withSchema({
|
|
100
|
+
TAG: "ApiFragmentRegistered",
|
|
101
|
+
pluginId: pluginId,
|
|
102
|
+
fragment: fragment,
|
|
103
|
+
apiTarget: apiTarget,
|
|
104
|
+
at: at
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
let current = match.fragment;
|
|
108
|
+
if (Primitive_object.equal(current, fragment) && match.apiTarget === apiTarget) {
|
|
109
|
+
return {
|
|
110
|
+
TAG: "Ok",
|
|
111
|
+
_0: []
|
|
112
|
+
};
|
|
113
|
+
} else {
|
|
114
|
+
return withSchema({
|
|
115
|
+
TAG: "ApiFragmentUpdated",
|
|
116
|
+
pluginId: pluginId,
|
|
117
|
+
previousFragment: current,
|
|
118
|
+
newFragment: fragment,
|
|
119
|
+
apiTarget: apiTarget,
|
|
120
|
+
at: at
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
case "DeregisterApiFragment" :
|
|
124
|
+
let pluginId$1 = command.pluginId;
|
|
125
|
+
let match$1 = state.entries[pluginId$1];
|
|
126
|
+
if (match$1 !== undefined) {
|
|
127
|
+
return withSchema({
|
|
128
|
+
TAG: "ApiFragmentDeregistered",
|
|
129
|
+
pluginId: pluginId$1
|
|
130
|
+
});
|
|
131
|
+
} else {
|
|
132
|
+
return {
|
|
133
|
+
TAG: "Ok",
|
|
134
|
+
_0: []
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
case "RecordApiFragmentPush" :
|
|
138
|
+
let at$1 = command.at;
|
|
139
|
+
let message = command.message;
|
|
140
|
+
let ok = command.ok;
|
|
141
|
+
let pluginId$2 = command.pluginId;
|
|
142
|
+
let match$2 = state.entries[pluginId$2];
|
|
143
|
+
if (match$2 === undefined) {
|
|
144
|
+
return {
|
|
145
|
+
TAG: "Ok",
|
|
146
|
+
_0: []
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
let match$3 = match$2.lastPush;
|
|
150
|
+
if (match$3 !== undefined && match$3[0] === ok && match$3[1] === message && match$3[2] === at$1) {
|
|
151
|
+
return {
|
|
152
|
+
TAG: "Ok",
|
|
153
|
+
_0: []
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
TAG: "Ok",
|
|
158
|
+
_0: [{
|
|
159
|
+
TAG: "ApiFragmentPushRecorded",
|
|
160
|
+
pluginId: pluginId$2,
|
|
161
|
+
ok: ok,
|
|
162
|
+
message: message,
|
|
163
|
+
at: at$1
|
|
164
|
+
}]
|
|
165
|
+
};
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let Spec;
|
|
171
|
+
|
|
172
|
+
let snapshot;
|
|
173
|
+
|
|
174
|
+
let atomicCounter;
|
|
175
|
+
|
|
176
|
+
let moduleUrl = "@reventlessdev/reventless-core/src/admin/ApiFragmentRegistry/ApiFragmentRegistryBehavior.res.mjs";
|
|
177
|
+
|
|
178
|
+
export {
|
|
179
|
+
Spec,
|
|
180
|
+
initialState,
|
|
181
|
+
snapshot,
|
|
182
|
+
atomicCounter,
|
|
183
|
+
entriesWith,
|
|
184
|
+
entriesWithout,
|
|
185
|
+
evolve,
|
|
186
|
+
snapshotOf,
|
|
187
|
+
decide,
|
|
188
|
+
moduleUrl,
|
|
189
|
+
}
|
|
190
|
+
/* No side effect */
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// ApiFragmentRegistry aggregate — the platform API-schema fragment registry, replacing the
|
|
2
|
+
// raw `deploy-schema:*` keyspace (see docs/plans/event-sourced-fragment-registries.md).
|
|
3
|
+
//
|
|
4
|
+
// Modelled as a SINGLETON aggregate (fixed id, e.g. "registry"): the whole registry lives in
|
|
5
|
+
// one consistency boundary / one event stream, so a fragment change reads the whole registry
|
|
6
|
+
// with a single strongly-consistent partition read + snapshot — fixing the lagging-read
|
|
7
|
+
// consistency AND the unbounded-scan cost of the earlier per-plugin DCB-slice design (deploy
|
|
8
|
+
// validation #4). `pluginId` is a payload field here, not the aggregate id.
|
|
9
|
+
//
|
|
10
|
+
// Transitions are driven by the DEPLOY / DESTROY of a plugin stack: the deploy registers its
|
|
11
|
+
// fragment as a SigV4 system caller against the Platform API, and only final retirement
|
|
12
|
+
// (`pulumi destroy`, resolvers gone) deregisters. Version supersession must NOT deregister —
|
|
13
|
+
// the registry is keyed by plugin NAME, so a successor's deploy overwrites its predecessor's
|
|
14
|
+
// fragment in place. The registry is idempotent: re-registering an identical fragment is a
|
|
15
|
+
// no-op, deregistering an absent fragment is a no-op — it never rejects a command.
|
|
16
|
+
//
|
|
17
|
+
// `RecordApiFragmentPush` is the write-back of the reactive schema-push SideEffect: after
|
|
18
|
+
// stitching and pushing the composed schema it records the outcome (ok / error + message) on
|
|
19
|
+
// the row of the plugin whose fragment change triggered the push, so the deploy waiter can
|
|
20
|
+
// poll the push status instead of timing out on introspection. It is `@noApi` — dispatched
|
|
21
|
+
// platform-side, never by a GraphQL caller.
|
|
22
|
+
@@reventless.spec("ApiFragmentRegistry")
|
|
23
|
+
|
|
24
|
+
open Reventless.Plugin
|
|
25
|
+
|
|
26
|
+
// One entry of the consistent registry snapshot carried on `ApiSchemaComputed` — the whole
|
|
27
|
+
// per-plugin fragment set AFTER the triggering change, folded from the aggregate's own
|
|
28
|
+
// consistent state. The push SideEffect stitches per target from this snapshot (splitApi +
|
|
29
|
+
// admin base are its deploy concerns), so consistency is guaranteed without any
|
|
30
|
+
// eventually-consistent read.
|
|
31
|
+
@schema
|
|
32
|
+
type fragmentSnapshotEntry = {
|
|
33
|
+
pluginId: string,
|
|
34
|
+
encoded: string,
|
|
35
|
+
protocol: string,
|
|
36
|
+
apiTarget: apiTarget,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// `RegisterApiFragment` / `DeregisterApiFragment` ARE the GraphQL surface — the deploy calls
|
|
40
|
+
// them as a SigV4 system caller against the Platform API (exposed via the standard admin-aggregate
|
|
41
|
+
// path as `Platform_RegisterApiFragment` / `Platform_DeregisterApiFragment`; IAM via
|
|
42
|
+
// `systemCallerFieldNames`). `at` is the registration timestamp, threaded so the read-model row
|
|
43
|
+
// can surface registered/updated times.
|
|
44
|
+
@schema
|
|
45
|
+
type command =
|
|
46
|
+
| RegisterApiFragment({pluginId: string, fragment: apiSchemaFragment, apiTarget: apiTarget, at: string})
|
|
47
|
+
| DeregisterApiFragment({pluginId: string})
|
|
48
|
+
| @noApi RecordApiFragmentPush({pluginId: string, ok: bool, message: string, at: string})
|
|
49
|
+
|
|
50
|
+
// The registry never rejects (idempotent register/deregister/record); this variant is never
|
|
51
|
+
// returned.
|
|
52
|
+
@schema
|
|
53
|
+
type error = | RegistryError
|
|
54
|
+
|
|
55
|
+
@schema
|
|
56
|
+
type event =
|
|
57
|
+
| ApiFragmentRegistered({pluginId: string, fragment: apiSchemaFragment, apiTarget: apiTarget, at: string})
|
|
58
|
+
| ApiFragmentUpdated({
|
|
59
|
+
pluginId: string,
|
|
60
|
+
previousFragment: apiSchemaFragment,
|
|
61
|
+
newFragment: apiSchemaFragment,
|
|
62
|
+
apiTarget: apiTarget,
|
|
63
|
+
at: string,
|
|
64
|
+
})
|
|
65
|
+
| ApiFragmentDeregistered({pluginId: string})
|
|
66
|
+
| ApiFragmentPushRecorded({pluginId: string, ok: bool, message: string, at: string})
|
|
67
|
+
// Derived echo of the consistent registry contents after a fragment change — the reactive
|
|
68
|
+
// push SideEffect's trigger. Carries the full per-plugin snapshot so the SideEffect stitches
|
|
69
|
+
// from consistent state without a read. Does NOT change fold state (evolve ignores it), and
|
|
70
|
+
// is NOT projected by the ApiFragments read model.
|
|
71
|
+
| ApiSchemaComputed({snapshot: array<fragmentSnapshotEntry>})
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
|
|
5
|
+
import * as Api$ReventlessInfra from "@reventlessdev/reventless-infra/src/components/Api.res.mjs";
|
|
6
|
+
|
|
7
|
+
let fragmentSnapshotEntrySchema = S.schema(s => ({
|
|
8
|
+
pluginId: s.m(S.string),
|
|
9
|
+
encoded: s.m(S.string),
|
|
10
|
+
protocol: s.m(S.string),
|
|
11
|
+
apiTarget: s.m(Plugin$Reventless.apiTargetSchema)
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
let commandSchema = S.union([
|
|
15
|
+
S.schema(s => ({
|
|
16
|
+
TAG: "RegisterApiFragment",
|
|
17
|
+
pluginId: s.m(S.string),
|
|
18
|
+
fragment: s.m(Plugin$Reventless.apiSchemaFragmentSchema),
|
|
19
|
+
apiTarget: s.m(Plugin$Reventless.apiTargetSchema),
|
|
20
|
+
at: s.m(S.string)
|
|
21
|
+
})),
|
|
22
|
+
S.schema(s => ({
|
|
23
|
+
TAG: "DeregisterApiFragment",
|
|
24
|
+
pluginId: s.m(S.string)
|
|
25
|
+
})),
|
|
26
|
+
S.schema(s => ({
|
|
27
|
+
TAG: "RecordApiFragmentPush",
|
|
28
|
+
pluginId: s.m(S.string),
|
|
29
|
+
ok: s.m(S.bool),
|
|
30
|
+
message: s.m(S.string),
|
|
31
|
+
at: s.m(S.string)
|
|
32
|
+
}))
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
let errorSchema = S.literal("RegistryError");
|
|
36
|
+
|
|
37
|
+
let eventSchema = S.union([
|
|
38
|
+
S.schema(s => ({
|
|
39
|
+
TAG: "ApiFragmentRegistered",
|
|
40
|
+
pluginId: s.m(S.string),
|
|
41
|
+
fragment: s.m(Plugin$Reventless.apiSchemaFragmentSchema),
|
|
42
|
+
apiTarget: s.m(Plugin$Reventless.apiTargetSchema),
|
|
43
|
+
at: s.m(S.string)
|
|
44
|
+
})),
|
|
45
|
+
S.schema(s => ({
|
|
46
|
+
TAG: "ApiFragmentUpdated",
|
|
47
|
+
pluginId: s.m(S.string),
|
|
48
|
+
previousFragment: s.m(Plugin$Reventless.apiSchemaFragmentSchema),
|
|
49
|
+
newFragment: s.m(Plugin$Reventless.apiSchemaFragmentSchema),
|
|
50
|
+
apiTarget: s.m(Plugin$Reventless.apiTargetSchema),
|
|
51
|
+
at: s.m(S.string)
|
|
52
|
+
})),
|
|
53
|
+
S.schema(s => ({
|
|
54
|
+
TAG: "ApiFragmentDeregistered",
|
|
55
|
+
pluginId: s.m(S.string)
|
|
56
|
+
})),
|
|
57
|
+
S.schema(s => ({
|
|
58
|
+
TAG: "ApiFragmentPushRecorded",
|
|
59
|
+
pluginId: s.m(S.string),
|
|
60
|
+
ok: s.m(S.bool),
|
|
61
|
+
message: s.m(S.string),
|
|
62
|
+
at: s.m(S.string)
|
|
63
|
+
})),
|
|
64
|
+
S.schema(s => ({
|
|
65
|
+
TAG: "ApiSchemaComputed",
|
|
66
|
+
snapshot: s.m(S.array(fragmentSnapshotEntrySchema))
|
|
67
|
+
}))
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
let commandSchema$1 = Api$ReventlessInfra.markNoApiVariants(commandSchema, ["RecordApiFragmentPush"]);
|
|
71
|
+
|
|
72
|
+
function commandAuthorization(param) {
|
|
73
|
+
return "AllowAuthenticated";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let name = "ApiFragmentRegistry";
|
|
77
|
+
|
|
78
|
+
let Id;
|
|
79
|
+
|
|
80
|
+
let moduleUrl = "@reventlessdev/reventless-core/src/admin/ApiFragmentRegistry/ApiFragmentRegistrySpec.res.mjs";
|
|
81
|
+
|
|
82
|
+
export {
|
|
83
|
+
name,
|
|
84
|
+
Id,
|
|
85
|
+
fragmentSnapshotEntrySchema,
|
|
86
|
+
errorSchema,
|
|
87
|
+
eventSchema,
|
|
88
|
+
commandSchema$1 as commandSchema,
|
|
89
|
+
moduleUrl,
|
|
90
|
+
commandAuthorization,
|
|
91
|
+
}
|
|
92
|
+
/* fragmentSnapshotEntrySchema Not a pure module */
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// ApiFragments projection — maps ApiFragmentRegistry aggregate events to per-plugin rows of
|
|
2
|
+
// the ApiFragments read model. Manual Mapping.Make form (mirrors PluginsProjection; no ppx —
|
|
3
|
+
// this file lives outside a ReadModel/ folder). Rows are keyed by the payload `pluginId`, not
|
|
4
|
+
// the (singleton) aggregate id. `ApiSchemaComputed` is the reactive push's trigger only — it
|
|
5
|
+
// carries no per-plugin row change, so it is Ignored here.
|
|
6
|
+
//
|
|
7
|
+
// A fragment change resets the row to "pending"; the push outcome arrives as a subsequent
|
|
8
|
+
// ApiFragmentPushRecorded. An Update on a deleted row (a push recorded after deregistration) is
|
|
9
|
+
// a no-op by Update semantics.
|
|
10
|
+
|
|
11
|
+
open Reventless.Projection
|
|
12
|
+
open Reventless.Message // brings the projection input record ({event, id, meta}) labels into scope
|
|
13
|
+
|
|
14
|
+
let moduleUrl: string = %raw(`import.meta.url`)
|
|
15
|
+
|
|
16
|
+
module ApiFragmentsMapping = Reventless.Projection.Mapping.Make(
|
|
17
|
+
ApiFragmentRegistrySpec,
|
|
18
|
+
ApiFragmentsReadModelSpec,
|
|
19
|
+
{
|
|
20
|
+
let project = ({event, _}) =>
|
|
21
|
+
switch event {
|
|
22
|
+
| ApiFragmentRegistrySpec.ApiFragmentRegistered({pluginId, fragment, apiTarget, at}) =>
|
|
23
|
+
Set(
|
|
24
|
+
pluginId,
|
|
25
|
+
{
|
|
26
|
+
ApiFragmentsReadModelSpec.pluginId,
|
|
27
|
+
encoded: fragment.encoded,
|
|
28
|
+
protocol: fragment.protocol,
|
|
29
|
+
apiTarget,
|
|
30
|
+
registeredAt: at,
|
|
31
|
+
updatedAt: at,
|
|
32
|
+
pushStatus: "pending",
|
|
33
|
+
pushMessage: "",
|
|
34
|
+
pushedAt: "",
|
|
35
|
+
},
|
|
36
|
+
)
|
|
37
|
+
| ApiFragmentUpdated({pluginId, newFragment, apiTarget, at}) =>
|
|
38
|
+
Update(pluginId, state => {
|
|
39
|
+
...state,
|
|
40
|
+
encoded: newFragment.encoded,
|
|
41
|
+
protocol: newFragment.protocol,
|
|
42
|
+
apiTarget,
|
|
43
|
+
updatedAt: at,
|
|
44
|
+
pushStatus: "pending",
|
|
45
|
+
pushMessage: "",
|
|
46
|
+
})
|
|
47
|
+
| ApiFragmentDeregistered({pluginId}) => Delete(pluginId)
|
|
48
|
+
| ApiFragmentPushRecorded({pluginId, ok, message, at}) =>
|
|
49
|
+
Update(pluginId, state => {
|
|
50
|
+
...state,
|
|
51
|
+
pushStatus: ok ? "ok" : "error",
|
|
52
|
+
pushMessage: message,
|
|
53
|
+
pushedAt: at,
|
|
54
|
+
})
|
|
55
|
+
// Reactive push trigger only — no row change.
|
|
56
|
+
| ApiSchemaComputed(_) => Ignore
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
module Mappings = Reventless.Projection.Mappings.Make(ApiFragmentsReadModelSpec)
|
|
62
|
+
|
|
63
|
+
let mappings: array<module(Mappings.Mapping)> = [module(ApiFragmentsMapping)]
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
|
|
4
|
+
import * as Projection$Reventless from "@reventlessdev/reventless-spec/src/types/Projection.res.mjs";
|
|
5
|
+
import * as ApiFragmentRegistrySpec$ReventlessCore from "./ApiFragmentRegistrySpec.res.mjs";
|
|
6
|
+
import * as ApiFragmentsReadModelSpec$ReventlessCore from "./ApiFragmentsReadModelSpec.res.mjs";
|
|
7
|
+
|
|
8
|
+
let moduleUrl = import.meta.url;
|
|
9
|
+
|
|
10
|
+
function project(param) {
|
|
11
|
+
let event = param.event;
|
|
12
|
+
switch (event.TAG) {
|
|
13
|
+
case "ApiFragmentRegistered" :
|
|
14
|
+
let at = event.at;
|
|
15
|
+
let fragment = event.fragment;
|
|
16
|
+
let pluginId = event.pluginId;
|
|
17
|
+
return {
|
|
18
|
+
TAG: "Set",
|
|
19
|
+
_0: pluginId,
|
|
20
|
+
_1: {
|
|
21
|
+
pluginId: pluginId,
|
|
22
|
+
encoded: fragment.encoded,
|
|
23
|
+
protocol: fragment.protocol,
|
|
24
|
+
apiTarget: event.apiTarget,
|
|
25
|
+
registeredAt: at,
|
|
26
|
+
updatedAt: at,
|
|
27
|
+
pushStatus: "pending",
|
|
28
|
+
pushMessage: "",
|
|
29
|
+
pushedAt: ""
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
case "ApiFragmentUpdated" :
|
|
33
|
+
let at$1 = event.at;
|
|
34
|
+
let apiTarget = event.apiTarget;
|
|
35
|
+
let newFragment = event.newFragment;
|
|
36
|
+
return {
|
|
37
|
+
TAG: "Update",
|
|
38
|
+
_0: event.pluginId,
|
|
39
|
+
_1: state => ({
|
|
40
|
+
pluginId: state.pluginId,
|
|
41
|
+
encoded: newFragment.encoded,
|
|
42
|
+
protocol: newFragment.protocol,
|
|
43
|
+
apiTarget: apiTarget,
|
|
44
|
+
registeredAt: state.registeredAt,
|
|
45
|
+
updatedAt: at$1,
|
|
46
|
+
pushStatus: "pending",
|
|
47
|
+
pushMessage: "",
|
|
48
|
+
pushedAt: state.pushedAt
|
|
49
|
+
})
|
|
50
|
+
};
|
|
51
|
+
case "ApiFragmentDeregistered" :
|
|
52
|
+
return {
|
|
53
|
+
TAG: "Delete",
|
|
54
|
+
_0: event.pluginId
|
|
55
|
+
};
|
|
56
|
+
case "ApiFragmentPushRecorded" :
|
|
57
|
+
let at$2 = event.at;
|
|
58
|
+
let message = event.message;
|
|
59
|
+
let ok = event.ok;
|
|
60
|
+
return {
|
|
61
|
+
TAG: "Update",
|
|
62
|
+
_0: event.pluginId,
|
|
63
|
+
_1: state => ({
|
|
64
|
+
pluginId: state.pluginId,
|
|
65
|
+
encoded: state.encoded,
|
|
66
|
+
protocol: state.protocol,
|
|
67
|
+
apiTarget: state.apiTarget,
|
|
68
|
+
registeredAt: state.registeredAt,
|
|
69
|
+
updatedAt: state.updatedAt,
|
|
70
|
+
pushStatus: ok ? "ok" : "error",
|
|
71
|
+
pushMessage: message,
|
|
72
|
+
pushedAt: at$2
|
|
73
|
+
})
|
|
74
|
+
};
|
|
75
|
+
case "ApiSchemaComputed" :
|
|
76
|
+
return "Ignore";
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let ApiFragmentsMapping = Projection$Reventless.Mapping.Make({
|
|
81
|
+
Id: Id$Reventless.$$String,
|
|
82
|
+
name: ApiFragmentRegistrySpec$ReventlessCore.name,
|
|
83
|
+
eventSchema: ApiFragmentRegistrySpec$ReventlessCore.eventSchema
|
|
84
|
+
})({
|
|
85
|
+
Id: Id$Reventless.$$String,
|
|
86
|
+
name: ApiFragmentsReadModelSpec$ReventlessCore.name,
|
|
87
|
+
stateSchema: ApiFragmentsReadModelSpec$ReventlessCore.stateSchema,
|
|
88
|
+
subIdConfig: undefined
|
|
89
|
+
})({
|
|
90
|
+
project: project
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
let Mappings = Projection$Reventless.Mappings.Make({
|
|
94
|
+
Id: Id$Reventless.$$String,
|
|
95
|
+
name: ApiFragmentsReadModelSpec$ReventlessCore.name,
|
|
96
|
+
stateSchema: ApiFragmentsReadModelSpec$ReventlessCore.stateSchema,
|
|
97
|
+
subIdConfig: undefined
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
let mappings = [ApiFragmentsMapping];
|
|
101
|
+
|
|
102
|
+
export {
|
|
103
|
+
moduleUrl,
|
|
104
|
+
ApiFragmentsMapping,
|
|
105
|
+
Mappings,
|
|
106
|
+
mappings,
|
|
107
|
+
}
|
|
108
|
+
/* moduleUrl Not a pure module */
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// ApiFragments read model — the read side of the platform API-schema fragment registry:
|
|
2
|
+
// one row per plugin name holding its current fragment plus the outcome of the last schema
|
|
3
|
+
// push that included it. Projected off the ApiFragmentRegistry singleton aggregate (keyed by
|
|
4
|
+
// pluginId from the event payload, NOT by the aggregate id — the aggregate is a singleton).
|
|
5
|
+
// Backs the `Platform_ApiFragments` status query the deploy waiter polls.
|
|
6
|
+
//
|
|
7
|
+
// `pushStatus` is "pending" from registration/update until the reactive push SideEffect records
|
|
8
|
+
// the outcome ("ok" / "error"); `pushMessage`/`pushedAt` are "" until then (plain strings, no
|
|
9
|
+
// option fields — matches the aggregate's wire contract).
|
|
10
|
+
@@reventless.spec("ApiFragments")
|
|
11
|
+
|
|
12
|
+
@schema
|
|
13
|
+
type state = {
|
|
14
|
+
pluginId: string,
|
|
15
|
+
encoded: string,
|
|
16
|
+
protocol: string,
|
|
17
|
+
apiTarget: Reventless.Plugin.apiTarget,
|
|
18
|
+
registeredAt: string,
|
|
19
|
+
updatedAt: string,
|
|
20
|
+
pushStatus: string,
|
|
21
|
+
pushMessage: string,
|
|
22
|
+
pushedAt: string,
|
|
23
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
|
|
5
|
+
import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
|
|
6
|
+
|
|
7
|
+
let stateSchema = S.schema(s => ({
|
|
8
|
+
pluginId: s.m(S.string),
|
|
9
|
+
encoded: s.m(S.string),
|
|
10
|
+
protocol: s.m(S.string),
|
|
11
|
+
apiTarget: s.m(Plugin$Reventless.apiTargetSchema),
|
|
12
|
+
registeredAt: s.m(S.string),
|
|
13
|
+
updatedAt: s.m(S.string),
|
|
14
|
+
pushStatus: s.m(S.string),
|
|
15
|
+
pushMessage: s.m(S.string),
|
|
16
|
+
pushedAt: s.m(S.string)
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
let config = ReadModel$Reventless.config(undefined, undefined, undefined);
|
|
20
|
+
|
|
21
|
+
let name = "ApiFragments";
|
|
22
|
+
|
|
23
|
+
let Id;
|
|
24
|
+
|
|
25
|
+
let subIdConfig;
|
|
26
|
+
|
|
27
|
+
let moduleUrl = "@reventlessdev/reventless-core/src/admin/ApiFragmentRegistry/ApiFragmentsReadModelSpec.res.mjs";
|
|
28
|
+
|
|
29
|
+
let authorization = "AllowAuthenticated";
|
|
30
|
+
|
|
31
|
+
let visibility = "Public";
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
name,
|
|
35
|
+
Id,
|
|
36
|
+
stateSchema,
|
|
37
|
+
config,
|
|
38
|
+
subIdConfig,
|
|
39
|
+
moduleUrl,
|
|
40
|
+
authorization,
|
|
41
|
+
visibility,
|
|
42
|
+
}
|
|
43
|
+
/* stateSchema Not a pure module */
|
|
@@ -152,10 +152,6 @@ module Make = (
|
|
|
152
152
|
// hand-declared admin SDL (`AdminApi.baseFragment`, via `Api_Naming.adminField`) so the
|
|
153
153
|
// auto-bound DCB resolver targets the exact field name that is pushed to the schema.
|
|
154
154
|
~apiNamePrefix="Platform",
|
|
155
|
-
// The API-schema registry's Register/Deregister mutations are invoked by the plugin/standalone
|
|
156
|
-
// deploy as a SigV4 system caller — mark them systemCallable so the AWS provider stamps the
|
|
157
|
-
// dual-auth (`@aws_cognito_user_pools @aws_iam`) directive on the constructed fragment entries.
|
|
158
|
-
~systemCallableComponents=["ApiFragmentRegistry"],
|
|
159
155
|
// Admin DCB mutation resolvers bind to the Platform API (split mode), not the deploy-target
|
|
160
156
|
// Domain API — routes the provider hook to `hooks.adminApi`.
|
|
161
157
|
~onAdminApi=true,
|
|
@@ -71,7 +71,7 @@ function Make(RuntimeEnvironment) {
|
|
|
71
71
|
let aggregatesWithoutEventMappers = Builder_Helpers$ReventlessCore.createAggregatesWithoutEventMappers(aggregates, api, opts);
|
|
72
72
|
let aggregateEventTopics = Aggregate$ReventlessCore.allEventTopics(aggregatesWithoutEventMappers);
|
|
73
73
|
Plugin_Helpers$ReventlessCore.registerAdminAggregateMutations(aggregates, Config.hooks);
|
|
74
|
-
let dcbResult = DcbBuilder.construct(name, name, "Platform", true, undefined, undefined, aggregateEventTopics, stateChangeSlices, stateViewSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices,
|
|
74
|
+
let dcbResult = DcbBuilder.construct(name, name, "Platform", true, undefined, undefined, aggregateEventTopics, stateChangeSlices, stateViewSlices, automationSlices, outboundTranslationSlices, inboundTranslationSlices, undefined, undefined, opts);
|
|
75
75
|
let slicePublishJsons = dcbResult.dcbPublishJsons;
|
|
76
76
|
if (slicePublishJsons !== undefined) {
|
|
77
77
|
stateChangeSlices.forEach(Sc => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SDL type and JSON encoder for the Platform_ApiFragments GraphQL query — the
|
|
2
2
|
// deploy-facing status surface of the API-schema fragment registry. Shared
|
|
3
3
|
// between the in-memory adapter (which resolves from the in-process ApiFragments
|
|
4
|
-
//
|
|
4
|
+
// read-model QueryDb) and the AWS adapter (which scans the ApiFragments
|
|
5
5
|
// DynamoDB table), so the wire shape is byte-identical and one client query
|
|
6
6
|
// string works against either.
|
|
7
7
|
//
|
|
@@ -18,7 +18,7 @@ let queryFieldName: string = "Platform_ApiFragments"
|
|
|
18
18
|
|
|
19
19
|
let sdlQueryField: string = ` ${queryFieldName}: [Platform_ApiFragmentEntry!]!`
|
|
20
20
|
|
|
21
|
-
let encodeApiFragmentEntry = (entry:
|
|
21
|
+
let encodeApiFragmentEntry = (entry: ApiFragmentsReadModelSpec.state): JSON.t =>
|
|
22
22
|
Dict.fromArray([
|
|
23
23
|
("pluginId", JSON.Encode.string(Plugin.name(entry.pluginId))),
|
|
24
24
|
// apiTarget is a payload-less variant — its schema serializes to the bare
|
|
@@ -134,7 +134,13 @@ let extractLeadingName = (str: string): string => {
|
|
|
134
134
|
} else {
|
|
135
135
|
trimmed
|
|
136
136
|
}
|
|
137
|
-
// Split on "(" first to remove arg list, then on " " and "
|
|
137
|
+
// Split on "(" first to remove arg list, then on " ", "{", and ":" for the name.
|
|
138
|
+
// The ":" split is load-bearing for ARG-LESS fields: `Foo: [Bar!]!` has no "(" to
|
|
139
|
+
// cleave the type off, and no space before the colon, so without it the name comes
|
|
140
|
+
// back as `Foo:` (trailing colon) and fails exact-match lookups like iamFieldNames
|
|
141
|
+
// — which is what dropped @aws_iam from the arg-less Platform_ApiFragments query and
|
|
142
|
+
// 401'd the deploy waiter. Fields WITH args already lost their type via split("("),
|
|
143
|
+
// so the ":" split is a harmless no-op for them.
|
|
138
144
|
afterType
|
|
139
145
|
->String.split("(")
|
|
140
146
|
->Array.get(0)
|
|
@@ -148,6 +154,10 @@ let extractLeadingName = (str: string): string => {
|
|
|
148
154
|
->Array.get(0)
|
|
149
155
|
->Option.getOr("")
|
|
150
156
|
->String.trim
|
|
157
|
+
->String.split(":")
|
|
158
|
+
->Array.get(0)
|
|
159
|
+
->Option.getOr("")
|
|
160
|
+
->String.trim
|
|
151
161
|
}
|
|
152
162
|
|
|
153
163
|
// Relay base types injected into every stitched schema.
|
|
@@ -121,7 +121,7 @@ function extractLeadingName(str) {
|
|
|
121
121
|
let afterType = trimmed.startsWith("type ") || trimmed.startsWith("enum ") ? trimmed.slice(5, trimmed.length).trim() : (
|
|
122
122
|
trimmed.startsWith("input ") ? trimmed.slice(6, trimmed.length).trim() : trimmed
|
|
123
123
|
);
|
|
124
|
-
return Stdlib_Option.getOr(Stdlib_Option.getOr(Stdlib_Option.getOr(afterType.split("(")[0], "").trim().split(" ")[0], "").trim().split("{")[0], "").trim();
|
|
124
|
+
return Stdlib_Option.getOr(Stdlib_Option.getOr(Stdlib_Option.getOr(Stdlib_Option.getOr(afterType.split("(")[0], "").trim().split(" ")[0], "").trim().split("{")[0], "").trim().split(":")[0], "").trim();
|
|
125
125
|
}
|
|
126
126
|
|
|
127
127
|
let relayBaseTypes = [
|
|
@@ -25,6 +25,11 @@ module type T = {
|
|
|
25
25
|
~resourceNaming: ReventlessInfra.ResourceNaming.operations,
|
|
26
26
|
~memorySize: int=?,
|
|
27
27
|
~timeout: int=?,
|
|
28
|
+
// Extra environment variables merged onto the side-effect-handler Lambda — used
|
|
29
|
+
// by bespoke platform side effects (e.g. the admin ApiSchemaPush) to carry
|
|
30
|
+
// deploy-derived config (API ids, command-topic URLs) that `execute` reads at
|
|
31
|
+
// runtime. Ignored by adapters that don't provision a real Lambda (in-memory).
|
|
32
|
+
~extraEnvVars: dict<Pulumi.Input.t<string>>=?,
|
|
28
33
|
~opts: Pulumi.ComponentResource.options=?,
|
|
29
34
|
) => component
|
|
30
35
|
}
|
|
@@ -94,6 +94,9 @@ module Make = (
|
|
|
94
94
|
~resourceNaming,
|
|
95
95
|
~memorySize=2048,
|
|
96
96
|
~timeout=180,
|
|
97
|
+
// Provider-specific adapters (e.g. the AWS runtime builder) inject extra Lambda
|
|
98
|
+
// env vars via their own side-registry; the generic builder ignores them.
|
|
99
|
+
~extraEnvVars as _=?,
|
|
97
100
|
~opts=?,
|
|
98
101
|
) => {
|
|
99
102
|
Component.make(
|
|
@@ -14,7 +14,7 @@ import * as SideEffectHandler_Callback$ReventlessCore from "./SideEffectHandler_
|
|
|
14
14
|
|
|
15
15
|
function Make(RuntimeEnvironment) {
|
|
16
16
|
return EventCollectorChannel => (SpecificEventCollector => (EventCollectorRuntimeBuilder => {
|
|
17
|
-
let make = (name, sideEffects, allEventTopics, allCommandTopics, targets, queryEngine, scheduler, resourceNaming, memorySizeOpt, timeoutOpt, opts) => {
|
|
17
|
+
let make = (name, sideEffects, allEventTopics, allCommandTopics, targets, queryEngine, scheduler, resourceNaming, memorySizeOpt, timeoutOpt, param, opts) => {
|
|
18
18
|
let memorySize = memorySizeOpt !== undefined ? memorySizeOpt : 2048;
|
|
19
19
|
let timeout = timeoutOpt !== undefined ? timeoutOpt : 180;
|
|
20
20
|
return Component$ReventlessCore.make(ComponentType$ReventlessCore.toString(SideEffectHandler$ReventlessCore.componentType), name, (extra, extra$1) => {
|
|
@@ -36,7 +36,7 @@ function Make(Spec) {
|
|
|
36
36
|
}));
|
|
37
37
|
let publishCommands = (aggregateName, cmdJsons) => Stdlib_Option.getOrThrow(publishToAggregates[aggregateName], undefined)(cmdJsons);
|
|
38
38
|
let config = Spec.setup(queryEngine, queryBucketName, opts);
|
|
39
|
-
let sideEffectHandler = Stdlib_Option.map(config.sideEffects, sideEffects => SpecificSideEffectHandler.make(extra$1, sideEffects, Aggregate$ReventlessCore.allEventTopics(allAggregates), allCommandTopics, undefined, queryEngine, scheduler, resourceNaming, undefined, undefined, opts));
|
|
39
|
+
let sideEffectHandler = Stdlib_Option.map(config.sideEffects, sideEffects => SpecificSideEffectHandler.make(extra$1, sideEffects, Aggregate$ReventlessCore.allEventTopics(allAggregates), allCommandTopics, undefined, queryEngine, scheduler, resourceNaming, undefined, undefined, undefined, opts));
|
|
40
40
|
let taskActionsHandler = (taskActions, operations) => Util_Promise$ReventlessCore.toUnit(Promise.all(taskActions.map(async taskAction => {
|
|
41
41
|
switch (taskAction.TAG) {
|
|
42
42
|
case "PublishCommands" :
|