@reventlessdev/reventless-core 3.0.0-alpha.165 → 3.0.0-alpha.167

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +7 -7
  3. package/src/admin/AdminApi.res +9 -62
  4. package/src/admin/AdminApi.res.mjs +6 -28
  5. package/src/components/Api/GraphQL_Stitcher.res +41 -123
  6. package/src/components/Api/GraphQL_Stitcher.res.mjs +15 -94
  7. package/tests/api/GraphQL_StitcherTest.res +32 -198
  8. package/tests/api/GraphQL_StitcherTest.res.mjs +32 -0
  9. package/src/admin/ApiFragmentRegistry/ApiFragmentRegistryBehavior.res +0 -108
  10. package/src/admin/ApiFragmentRegistry/ApiFragmentRegistryBehavior.res.mjs +0 -190
  11. package/src/admin/ApiFragmentRegistry/ApiFragmentRegistrySpec.res +0 -71
  12. package/src/admin/ApiFragmentRegistry/ApiFragmentRegistrySpec.res.mjs +0 -92
  13. package/src/admin/ApiFragmentRegistry/ApiFragmentsProjection.res +0 -63
  14. package/src/admin/ApiFragmentRegistry/ApiFragmentsProjection.res.mjs +0 -108
  15. package/src/admin/ApiFragmentRegistry/ApiFragmentsReadModelSpec.res +0 -23
  16. package/src/admin/ApiFragmentRegistry/ApiFragmentsReadModelSpec.res.mjs +0 -43
  17. package/src/admin/Platform_ApiFragmentsApi.res +0 -32
  18. package/src/admin/Platform_ApiFragmentsApi.res.mjs +0 -52
  19. package/src/components/Api/Api_Builder.res +0 -40
  20. package/src/components/Api/Api_Builder.res.mjs +0 -33
  21. package/src/components/Api/Api_Operations.res +0 -13
  22. package/src/components/Api/Api_Operations.res.mjs +0 -12
  23. package/src/components/Api/GraphQL_PushPlanner.res +0 -65
  24. package/src/components/Api/GraphQL_PushPlanner.res.mjs +0 -46
  25. package/tests/admin/Platform_ApiFragmentsApiTest.res +0 -66
  26. package/tests/admin/Platform_ApiFragmentsApiTest.res.mjs +0 -68
  27. package/tests/api/GraphQL_PushPlannerTest.res +0 -62
  28. package/tests/api/GraphQL_PushPlannerTest.res.mjs +0 -80
@@ -1,204 +1,6 @@
1
1
  open JestGlobals
2
2
  open GraphQL_Stitcher
3
3
 
4
- // A representative stitched schema: admin base (Platform_*) plus two domain
5
- // plugins (Catalog_*, Ordering_*). 4 Mutation + 4 Query root fields.
6
- let fullSchemaSdl = `type Node {
7
- id: ID!
8
- }
9
-
10
- type Query {
11
- node(id: ID!): Node
12
- Platform_plugins: [Plugin]
13
- Catalog_products: [Product]
14
- Ordering_orders: [Order]
15
- }
16
-
17
- type Mutation {
18
- Platform_connectPlugin(input: ConnectInput): ConnectResult
19
- Catalog_addProduct(input: AddInput): Product
20
- Catalog_updateProduct(input: UpdateInput): Product
21
- Ordering_placeOrder(input: OrderInput): Order
22
- }`
23
-
24
- // The exact failure observed in alpha: a lifecycle window collapses the live
25
- // schema back to admin-base only — every Catalog_*/Ordering_* field gone.
26
- // 1 Mutation + 2 Query root fields.
27
- let collapsedSchemaSdl = `type Node {
28
- id: ID!
29
- }
30
-
31
- type Query {
32
- node(id: ID!): Node
33
- Platform_plugins: [Plugin]
34
- }
35
-
36
- type Mutation {
37
- Platform_connectPlugin(input: ConnectInput): ConnectResult
38
- }`
39
-
40
- // Real stitched shape: injectAwsAuthAll appends @aws_auth on its own indented
41
- // line after each Mutation field, so a field spans two physical lines.
42
- let authAnnotatedSdl = `type Mutation {
43
- Platform_connectPlugin(input: ConnectInput): ConnectResult
44
- @aws_auth(cognito_groups: ["Admin"])
45
- Catalog_addProduct(input: AddInput): Product
46
- @aws_auth(cognito_groups: ["User"])
47
- }
48
-
49
- type Query {
50
- node(id: ID!): Node @aws_auth(cognito_groups: ["Admin"])
51
- }`
52
-
53
- describe("GraphQL_Stitcher.countRootTypeFields", () => {
54
- testSync("counts Mutation fields", () => {
55
- expect(countRootTypeFields(~sdl=fullSchemaSdl, ~typeName="Mutation"))->toBe(4)
56
- })
57
- testSync("counts Query fields", () => {
58
- expect(countRootTypeFields(~sdl=fullSchemaSdl, ~typeName="Query"))->toBe(4)
59
- })
60
- testSync("ignores @aws_auth directive lines, counts each Mutation field once", () => {
61
- expect(countRootTypeFields(~sdl=authAnnotatedSdl, ~typeName="Mutation"))->toBe(2)
62
- })
63
- testSync("ignores @aws_auth directive lines, counts each Query field once", () => {
64
- expect(countRootTypeFields(~sdl=authAnnotatedSdl, ~typeName="Query"))->toBe(1)
65
- })
66
- testSync("missing type returns 0", () => {
67
- expect(countRootTypeFields(~sdl=fullSchemaSdl, ~typeName="Subscription"))->toBe(0)
68
- })
69
- testSync("empty SDL returns 0", () => {
70
- expect(countRootTypeFields(~sdl="", ~typeName="Mutation"))->toBe(0)
71
- })
72
- })
73
-
74
- describe("GraphQL_Stitcher.isCatastrophicSchemaShrink", () => {
75
- testSync("rejects the all-disconnected collapse (8 → 3 root fields, < 50%)", () => {
76
- expect(
77
- isCatastrophicSchemaShrink(~currentSdl=fullSchemaSdl, ~newSdl=collapsedSchemaSdl, ~threshold=0.5),
78
- )->toBe(true)
79
- })
80
- testSync("allows an intentional small removal above threshold (8 → 7)", () => {
81
- let minusOneSdl = `type Query {
82
- node(id: ID!): Node
83
- Platform_plugins: [Plugin]
84
- Catalog_products: [Product]
85
- Ordering_orders: [Order]
86
- }
87
-
88
- type Mutation {
89
- Platform_connectPlugin(input: ConnectInput): ConnectResult
90
- Catalog_addProduct(input: AddInput): Product
91
- Ordering_placeOrder(input: OrderInput): Order
92
- }`
93
- expect(
94
- isCatastrophicSchemaShrink(~currentSdl=fullSchemaSdl, ~newSdl=minusOneSdl, ~threshold=0.5),
95
- )->toBe(false)
96
- })
97
- testSync("allows the first push when there is no live schema to protect", () => {
98
- expect(
99
- isCatastrophicSchemaShrink(~currentSdl="", ~newSdl=fullSchemaSdl, ~threshold=0.5),
100
- )->toBe(false)
101
- })
102
- testSync("allows an unchanged re-push (same SDL)", () => {
103
- expect(
104
- isCatastrophicSchemaShrink(~currentSdl=fullSchemaSdl, ~newSdl=fullSchemaSdl, ~threshold=0.5),
105
- )->toBe(false)
106
- })
107
- testSync("allows a purely additive push even below threshold cardinality", () => {
108
- // A tiny live schema growing into a big one drops nothing → never a clobber,
109
- // regardless of the ratio.
110
- expect(
111
- isCatastrophicSchemaShrink(~currentSdl=collapsedSchemaSdl, ~newSdl=fullSchemaSdl, ~threshold=0.5),
112
- )->toBe(false)
113
- })
114
- testSync("includes Subscription fields when judging a shrink", () => {
115
- let withSubs = `type Query {
116
- node(id: ID!): Node
117
- }
118
-
119
- type Mutation {
120
- Ordering_placeOrder(input: OrderInput): Order
121
- }
122
-
123
- type Subscription {
124
- onOrdering_Customer_Register: Customer
125
- onOrdering_Customer_UpdateAddress: Customer
126
- onCatalog_Product_Added: Product
127
- }`
128
- // Dropping every Subscription field (and keeping Mutation/Query) is a shrink
129
- // the Mutation+Query-only guard would have missed.
130
- let subsGone = `type Query {
131
- node(id: ID!): Node
132
- }
133
-
134
- type Mutation {
135
- Ordering_placeOrder(input: OrderInput): Order
136
- }`
137
- expect(
138
- isCatastrophicSchemaShrink(~currentSdl=withSubs, ~newSdl=subsGone, ~threshold=0.5),
139
- )->toBe(true)
140
- })
141
- })
142
-
143
- describe("GraphQL_Stitcher.rootTypeFieldNames", () => {
144
- testSync("lists Mutation field names", () => {
145
- expect(rootTypeFieldNames(~sdl=fullSchemaSdl, ~typeName="Mutation"))->toEqual([
146
- "Platform_connectPlugin",
147
- "Catalog_addProduct",
148
- "Catalog_updateProduct",
149
- "Ordering_placeOrder",
150
- ])
151
- })
152
- testSync("strips args and @aws_auth directive lines", () => {
153
- expect(rootTypeFieldNames(~sdl=authAnnotatedSdl, ~typeName="Mutation"))->toEqual([
154
- "Platform_connectPlugin",
155
- "Catalog_addProduct",
156
- ])
157
- })
158
- testSync("missing type returns []", () => {
159
- expect(rootTypeFieldNames(~sdl=fullSchemaSdl, ~typeName="Subscription"))->toEqual([])
160
- })
161
- })
162
-
163
- describe("GraphQL_Stitcher.missingRootFields", () => {
164
- testSync("reports plugin fields absent from an admin-base clobber", () => {
165
- // The alpha 2026-07-08 clobber: live schema is admin-base only. Every
166
- // Catalog_*/Ordering_* field the deploy expects is missing → repair fires.
167
- let missing = missingRootFields(~expectedSdl=fullSchemaSdl, ~liveSdl=collapsedSchemaSdl)
168
- expect(missing->Array.includes("Catalog_addProduct"))->toBe(true)
169
- expect(missing->Array.includes("Ordering_placeOrder"))->toBe(true)
170
- expect(missing->Array.includes("Ordering_orders"))->toBe(true)
171
- })
172
- testSync("empty when live schema is a superset (intact)", () => {
173
- expect(missingRootFields(~expectedSdl=fullSchemaSdl, ~liveSdl=fullSchemaSdl))->toEqual([])
174
- })
175
- testSync("detects an equal-cardinality field swap the count test misses", () => {
176
- // Same number of root fields, but one Mutation field replaced by a foreign
177
- // one — a swap. countRootTypeFields would report no drift; the name-set diff
178
- // catches it.
179
- let swapped = `type Query {
180
- node(id: ID!): Node
181
- Platform_plugins: [Plugin]
182
- Catalog_products: [Product]
183
- Ordering_orders: [Order]
184
- }
185
-
186
- type Mutation {
187
- Platform_connectPlugin(input: ConnectInput): ConnectResult
188
- Catalog_addProduct(input: AddInput): Product
189
- Catalog_updateProduct(input: UpdateInput): Product
190
- Foreign_strayField(input: OrderInput): Order
191
- }`
192
- // Same count on both sides.
193
- expect(countRootTypeFields(~sdl=swapped, ~typeName="Mutation"))->toBe(
194
- countRootTypeFields(~sdl=fullSchemaSdl, ~typeName="Mutation"),
195
- )
196
- // But the expected Ordering_placeOrder is missing from the live (swapped) schema.
197
- let missing = missingRootFields(~expectedSdl=fullSchemaSdl, ~liveSdl=swapped)
198
- expect(missing->Array.includes("Ordering_placeOrder"))->toBe(true)
199
- })
200
- })
201
-
202
4
  describe("GraphQL_Stitcher.subscriptionSources", () => {
203
5
  testSync("encode/decode round-trips the subscription→mutation source mapping", () => {
204
6
  let fragment = encode({
@@ -261,3 +63,35 @@ describe("GraphQL_Stitcher.subscriptionSources", () => {
261
63
  expect(fields->Array.includes("onPluginStatusChange"))->toBe(true)
262
64
  })
263
65
  })
66
+
67
+ describe("GraphQL_Stitcher.stitchStandalone", () => {
68
+ let pluginFragment = encode({
69
+ types: [
70
+ `type Product implements Node {\n id: ID!\n name: String\n}`,
71
+ `union CommandResult = CommandAccepted | CommandRejected | CommandPending`,
72
+ ],
73
+ mutations: [` Catalog_AddProduct(input: AddInput): CommandResult`],
74
+ queries: [` Catalog_products: [Product]`],
75
+ subscriptions: [` onCatalog_AddProduct(id: ID): CommandResult`],
76
+ subscriptionSources: [{field: "onCatalog_AddProduct", mutations: ["Catalog_AddProduct"]}],
77
+ })
78
+
79
+ testSync("renders a self-contained subgraph document with relay base types", () => {
80
+ let sdl = stitchStandalone(~fragment=pluginFragment)
81
+ expect(sdl)->toContain("interface Node {")
82
+ expect(sdl)->toContain("type PageInfo {")
83
+ expect(sdl)->toContain("type Product implements Node {")
84
+ expect(sdl)->toContain("Catalog_AddProduct(input: AddInput): CommandResult")
85
+ expect(sdl)->toContain("onCatalog_AddProduct(id: ID): CommandResult")
86
+ })
87
+
88
+ testSync("omits the global node query (the canonical base document owns it)", () => {
89
+ let sdl = stitchStandalone(~fragment=pluginFragment)
90
+ expect(sdl->String.includes("node(id: ID!): Node"))->toBe(false)
91
+ })
92
+
93
+ testSync("stitch keeps injecting the global node query (whole-schema path unchanged)", () => {
94
+ let sdl = stitch(~baseFragment=pluginFragment, ~pluginFragments=[])
95
+ expect(sdl)->toContain("node(id: ID!): Node")
96
+ })
97
+ })
@@ -240,6 +240,38 @@ globalThis.describe("GraphQL_Stitcher.subscriptionSources", () => {
240
240
  });
241
241
  });
242
242
 
243
+ globalThis.describe("GraphQL_Stitcher.stitchStandalone", () => {
244
+ let pluginFragment = GraphQL_Stitcher$ReventlessCore.encode({
245
+ types: [
246
+ `type Product implements Node {\n id: ID!\n name: String\n}`,
247
+ `union CommandResult = CommandAccepted | CommandRejected | CommandPending`
248
+ ],
249
+ mutations: [` Catalog_AddProduct(input: AddInput): CommandResult`],
250
+ queries: [` Catalog_products: [Product]`],
251
+ subscriptions: [` onCatalog_AddProduct(id: ID): CommandResult`],
252
+ subscriptionSources: [{
253
+ field: "onCatalog_AddProduct",
254
+ mutations: ["Catalog_AddProduct"]
255
+ }]
256
+ });
257
+ globalThis.test("renders a self-contained subgraph document with relay base types", () => {
258
+ let sdl = GraphQL_Stitcher$ReventlessCore.stitchStandalone(pluginFragment);
259
+ globalThis.expect(sdl).toContain("interface Node {");
260
+ globalThis.expect(sdl).toContain("type PageInfo {");
261
+ globalThis.expect(sdl).toContain("type Product implements Node {");
262
+ globalThis.expect(sdl).toContain("Catalog_AddProduct(input: AddInput): CommandResult");
263
+ globalThis.expect(sdl).toContain("onCatalog_AddProduct(id: ID): CommandResult");
264
+ });
265
+ globalThis.test("omits the global node query (the canonical base document owns it)", () => {
266
+ let sdl = GraphQL_Stitcher$ReventlessCore.stitchStandalone(pluginFragment);
267
+ globalThis.expect(sdl.includes("node(id: ID!): Node")).toBe(false);
268
+ });
269
+ globalThis.test("stitch keeps injecting the global node query (whole-schema path unchanged)", () => {
270
+ let sdl = GraphQL_Stitcher$ReventlessCore.stitch(pluginFragment, []);
271
+ globalThis.expect(sdl).toContain("node(id: ID!): Node");
272
+ });
273
+ });
274
+
243
275
  export {
244
276
  fullSchemaSdl,
245
277
  collapsedSchemaSdl,
@@ -1,108 +0,0 @@
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
- }
@@ -1,190 +0,0 @@
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 */
@@ -1,71 +0,0 @@
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>})