@reventlessdev/reventless-core 3.0.0-alpha.144 → 3.0.0-alpha.146

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,20 @@
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.146 (2026-07-08)
7
+
8
+ ### Features
9
+
10
+ * **reventless-core:** classify plugins by kind in the lifecycle read model ([64e3f22](https://github.com/ReventlessDev/reventless-core/commit/64e3f22b8114a771886b7c8ec023e95971413c0b))
11
+
12
+
13
+ # 3.0.0-alpha.145 (2026-07-08)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **reventless-aws:** stop admin-base schema clobber of split-mode DomainApi ([afce85f](https://github.com/ReventlessDev/reventless-core/commit/afce85fa57474735cea3dcb1c2a151c2d8804f0e))
18
+
19
+
6
20
  # 3.0.0-alpha.144 (2026-07-07)
7
21
 
8
22
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.144",
3
+ "version": "3.0.0-alpha.146",
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.25",
32
- "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.11",
33
31
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.10",
32
+ "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.11",
33
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.25",
34
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.6",
35
35
  "@reventlessdev/rescript-node-streams": "1.1.0-alpha.10",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
37
37
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.14",
38
- "@reventlessdev/reventless-infra": "3.0.0-alpha.90",
38
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.91",
39
39
  "@reventlessdev/reventless-interop": "3.0.0-alpha.24",
40
- "@reventlessdev/rescript-ssh2": "1.1.0-alpha.11",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.68"
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.69",
41
+ "@reventlessdev/rescript-ssh2": "1.1.0-alpha.11"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "^12.3.0",
@@ -80,6 +80,7 @@ module Make = (
80
80
  uiFragments: None,
81
81
  structure: None,
82
82
  dcbEventLog: None,
83
+ kind: Domain,
83
84
  }
84
85
 
85
86
  module Callback = Admin_Callback.Make({
@@ -46,7 +46,8 @@ function Make(RuntimeEnvironment) {
46
46
  apiTarget: undefined,
47
47
  uiFragments: undefined,
48
48
  structure: undefined,
49
- dcbEventLog: undefined
49
+ dcbEventLog: undefined,
50
+ kind: "Domain"
50
51
  };
51
52
  let Callback = Admin_Callback$ReventlessCore.Make({
52
53
  pluginDefinition: fakePluginDefinition,
@@ -187,7 +188,8 @@ function Make(RuntimeEnvironment) {
187
188
  apiTarget: undefined,
188
189
  uiFragments: undefined,
189
190
  structure: undefined,
190
- dcbEventLog: undefined
191
+ dcbEventLog: undefined,
192
+ kind: "Domain"
191
193
  };
192
194
  let Callback = Admin_Callback$ReventlessCore.Make({
193
195
  pluginDefinition: fakePluginDefinition,
@@ -218,21 +218,24 @@ let isIdentStart = (line: string): bool =>
218
218
  }
219
219
 
220
220
  /**
221
- Count the field definitions inside a root object type (`Mutation` / `Query`) of a
222
- full SDL string.
223
-
224
- Used by the runtime schema-update circuit breaker (AdminEventCollectorEntryPoint)
225
- to detect a catastrophic shrink before pushing a new schema to AppSync. Heuristic
226
- but adequate for a shrink guard: root operation fields are flat single-line
227
- declarations (`name(args): T` or `name: T`), so we slice the `type <typeName> {
228
- … }` block at its first `{`/`}` and count indented lines that begin with an
229
- identifier character, ignoring directive-only lines (`@…`), comments (`#…`) and
230
- braces.
221
+ List the field NAMES defined inside a root object type (`Mutation` / `Query` /
222
+ `Subscription`) of a full SDL string.
223
+
224
+ This is the identity-aware primitive underlying every schema-clobber guard:
225
+ `countRootTypeFields` is just its length, and the deploy-time repair / shrink
226
+ guards compare the returned NAME SETS rather than cardinalities so they catch
227
+ field *swaps* and equal-cardinality drift that a count comparison misses.
228
+
229
+ Heuristic but adequate: root operation fields are flat single-line declarations
230
+ (`name(args): T` or `name: T`), so we slice the `type <typeName> { … }` block at
231
+ its first `{`/`}` and read the leading identifier of each indented line that
232
+ begins with an identifier character, ignoring directive-only lines (`@…`),
233
+ comments (`#…`) and braces.
231
234
  */
232
- let countRootTypeFields = (~sdl: string, ~typeName: string): int => {
235
+ let rootTypeFieldNames = (~sdl: string, ~typeName: string): array<string> => {
233
236
  let marker = `type ${typeName}`
234
237
  switch sdl->String.indexOfOpt(marker) {
235
- | None => 0
238
+ | None => []
236
239
  | Some(typeStart) =>
237
240
  let rest = sdl->String.slice(~start=typeStart)
238
241
  switch (rest->String.indexOfOpt("{"), rest->String.indexOfOpt("}")) {
@@ -240,32 +243,82 @@ let countRootTypeFields = (~sdl: string, ~typeName: string): int => {
240
243
  rest
241
244
  ->String.slice(~start=openIdx + 1, ~end=closeIdx)
242
245
  ->String.split("\n")
243
- ->Array.filter(line => {
246
+ ->Array.filterMap(line => {
244
247
  let trimmed = line->String.trim
245
- trimmed->String.length > 0 && isIdentStart(trimmed)
248
+ if trimmed->String.length > 0 && isIdentStart(trimmed) {
249
+ // `name(args): T` → extractLeadingName strips the arg list; `name: T`
250
+ // (no args) leaves a trailing colon on the token — drop it so a field
251
+ // reads the same whether or not it takes arguments.
252
+ let name = extractLeadingName(trimmed)
253
+ let name = name->String.endsWith(":")
254
+ ? name->String.slice(~start=0, ~end=name->String.length - 1)
255
+ : name
256
+ name->String.length > 0 ? Some(name) : None
257
+ } else {
258
+ None
259
+ }
246
260
  })
247
- ->Array.length
248
- | _ => 0
261
+ | _ => []
249
262
  }
250
263
  }
251
264
  }
252
265
 
266
+ /**
267
+ Count the field definitions inside a root object type (`Mutation` / `Query`) of a
268
+ full SDL string. Length of `rootTypeFieldNames`.
269
+ */
270
+ let countRootTypeFields = (~sdl: string, ~typeName: string): int =>
271
+ rootTypeFieldNames(~sdl, ~typeName)->Array.length
272
+
273
+ // The three GraphQL root operation types every clobber guard reasons over.
274
+ let rootTypeNames = ["Mutation", "Query", "Subscription"]
275
+
276
+ /**
277
+ Union of every root-operation field name across `Mutation` + `Query` +
278
+ `Subscription`. The identity set the deploy-time repair and shrink guards diff.
279
+ */
280
+ let allRootFieldNames = (~sdl: string): array<string> =>
281
+ rootTypeNames->Array.flatMap(typeName => rootTypeFieldNames(~sdl, ~typeName))
282
+
283
+ /**
284
+ Root-operation field names present in `expectedSdl` but MISSING from `liveSdl`
285
+ (across Mutation + Query + Subscription). Non-empty ⇒ the live schema is not a
286
+ superset of what we expect and a repair push is warranted — this heals field
287
+ *swaps* and equal-cardinality drift that a bare count comparison would skip.
288
+ */
289
+ let missingRootFields = (~expectedSdl: string, ~liveSdl: string): array<string> => {
290
+ let liveSet = allRootFieldNames(~sdl=liveSdl)->Set.fromArray
291
+ allRootFieldNames(~sdl=expectedSdl)->Array.filter(name => !(liveSet->Set.has(name)))
292
+ }
293
+
253
294
  /**
254
295
  Decide whether replacing the live schema (`currentSdl`) with `newSdl` would drop
255
- so many root-type (`Mutation` + `Query`) fields that it is almost certainly a
296
+ root-operation fields the pushing stack does not carry — i.e. an almost-certainly
256
297
  transient/incomplete stitch rather than an intentional removal.
257
298
 
258
- Returns `true` when the new root-field total is below `threshold` × the current
259
- total. When the current schema has no countable root fields (e.g. first deploy,
260
- or introspection was unavailable so `currentSdl` is empty) it returns `false` so
261
- the initial push always proceeds.
299
+ Identity-based (across `Mutation` + `Query` + `Subscription`):
300
+ - When `newSdl`'s field-name set is a SUPERSET of the live set — nothing is
301
+ dropped — it is purely additive (or unchanged) and never a clobber, so this
302
+ returns `false` even when the pushing stack is legitimately adding its own
303
+ fields.
304
+ - Otherwise it falls back to the cardinality threshold on the union of the three
305
+ root types: `true` only when the new total drops below `threshold` × the
306
+ current total. A small intentional removal (above threshold) is allowed.
307
+
308
+ When the current schema has no countable root fields (e.g. first deploy, or
309
+ introspection was unavailable so `currentSdl` is empty) it returns `false` so the
310
+ initial push always proceeds.
262
311
  */
263
312
  let isCatastrophicSchemaShrink = (~currentSdl: string, ~newSdl: string, ~threshold: float): bool => {
264
- let current =
265
- countRootTypeFields(~sdl=currentSdl, ~typeName="Mutation") +
266
- countRootTypeFields(~sdl=currentSdl, ~typeName="Query")
267
- let next =
268
- countRootTypeFields(~sdl=newSdl, ~typeName="Mutation") +
269
- countRootTypeFields(~sdl=newSdl, ~typeName="Query")
270
- current > 0 && next->Int.toFloat < current->Int.toFloat *. threshold
313
+ let currentNames = allRootFieldNames(~sdl=currentSdl)
314
+ let newSet = allRootFieldNames(~sdl=newSdl)->Set.fromArray
315
+ let dropped = currentNames->Array.filter(name => !(newSet->Set.has(name)))
316
+ if dropped->Array.length == 0 {
317
+ // Additive or unchanged push — the new schema keeps every live field.
318
+ false
319
+ } else {
320
+ let current = currentNames->Array.length
321
+ let next = allRootFieldNames(~sdl=newSdl)->Array.length
322
+ current > 0 && next->Int.toFloat < current->Int.toFloat *. threshold
323
+ }
271
324
  }
@@ -181,32 +181,68 @@ function isIdentStart(line) {
181
181
  }
182
182
  }
183
183
 
184
- function countRootTypeFields(sdl, typeName) {
184
+ function rootTypeFieldNames(sdl, typeName) {
185
185
  let marker = `type ` + typeName;
186
186
  let typeStart = Stdlib_String.indexOfOpt(sdl, marker);
187
187
  if (typeStart === undefined) {
188
- return 0;
188
+ return [];
189
189
  }
190
190
  let rest = sdl.slice(typeStart);
191
191
  let match = Stdlib_String.indexOfOpt(rest, "{");
192
192
  let match$1 = Stdlib_String.indexOfOpt(rest, "}");
193
- if (match !== undefined && match$1 !== undefined && match$1 > match) {
194
- return rest.slice(match + 1 | 0, match$1).split("\n").filter(line => {
195
- let trimmed = line.trim();
196
- if (trimmed.length > 0) {
197
- return isIdentStart(trimmed);
193
+ if (match !== undefined) {
194
+ if (match$1 !== undefined) {
195
+ if (match$1 > match) {
196
+ return Stdlib_Array.filterMap(rest.slice(match + 1 | 0, match$1).split("\n"), line => {
197
+ let trimmed = line.trim();
198
+ if (!(trimmed.length > 0 && isIdentStart(trimmed))) {
199
+ return;
200
+ }
201
+ let name = extractLeadingName(trimmed);
202
+ let name$1 = name.endsWith(":") ? name.slice(0, name.length - 1 | 0) : name;
203
+ if (name$1.length > 0) {
204
+ return name$1;
205
+ }
206
+ });
198
207
  } else {
199
- return false;
208
+ return [];
200
209
  }
201
- }).length;
210
+ } else {
211
+ return [];
212
+ }
202
213
  } else {
203
- return 0;
214
+ return [];
204
215
  }
205
216
  }
206
217
 
218
+ function countRootTypeFields(sdl, typeName) {
219
+ return rootTypeFieldNames(sdl, typeName).length;
220
+ }
221
+
222
+ let rootTypeNames = [
223
+ "Mutation",
224
+ "Query",
225
+ "Subscription"
226
+ ];
227
+
228
+ function allRootFieldNames(sdl) {
229
+ return rootTypeNames.flatMap(typeName => rootTypeFieldNames(sdl, typeName));
230
+ }
231
+
232
+ function missingRootFields(expectedSdl, liveSdl) {
233
+ let liveSet = new Set(allRootFieldNames(liveSdl));
234
+ return allRootFieldNames(expectedSdl).filter(name => !liveSet.has(name));
235
+ }
236
+
207
237
  function isCatastrophicSchemaShrink(currentSdl, newSdl, threshold) {
208
- let current = countRootTypeFields(currentSdl, "Mutation") + countRootTypeFields(currentSdl, "Query") | 0;
209
- let next = countRootTypeFields(newSdl, "Mutation") + countRootTypeFields(newSdl, "Query") | 0;
238
+ let currentNames = allRootFieldNames(currentSdl);
239
+ let newSet = new Set(allRootFieldNames(newSdl));
240
+ let dropped = currentNames.filter(name => !newSet.has(name));
241
+ if (dropped.length === 0) {
242
+ return false;
243
+ }
244
+ let current = currentNames.length;
245
+ let next = allRootFieldNames(newSdl).length;
210
246
  if (current > 0) {
211
247
  return next < current * threshold;
212
248
  } else {
@@ -223,7 +259,11 @@ export {
223
259
  relayBaseQueries,
224
260
  stitch,
225
261
  isIdentStart,
262
+ rootTypeFieldNames,
226
263
  countRootTypeFields,
264
+ rootTypeNames,
265
+ allRootFieldNames,
266
+ missingRootFields,
227
267
  isCatastrophicSchemaShrink,
228
268
  }
229
269
  /* log Not a pure module */
@@ -687,6 +687,12 @@ module Make = (
687
687
  uiFragments,
688
688
  structure: pluginStructure,
689
689
  dcbEventLog: dcbEventLogDef,
690
+ // Business role from the deploy-time metadata registry (set via
691
+ // registerPluginMetadata). Unset → Domain. Rides the handshake to the
692
+ // plugin-lifecycle projection so the admin can segregate infrastructure.
693
+ kind: Plugin_Helpers.pluginMetadataRegistry.contents
694
+ ->Option.flatMap(m => m.kind)
695
+ ->Option.getOr(Plugin_Helpers.Domain),
690
696
  })
691
697
 
692
698
  // Bundled Plugin EventCollector reconstructs Extension_Operations at
@@ -462,7 +462,8 @@ function Make(Spec) {
462
462
  apiTarget: capturedDeployTarget,
463
463
  uiFragments: uiFragments,
464
464
  structure: pluginStructure,
465
- dcbEventLog: param[2]
465
+ dcbEventLog: param[2],
466
+ kind: Stdlib_Option.getOr(Stdlib_Option.flatMap(Plugin_Helpers$ReventlessCore.pluginMetadataRegistry.contents, m => m.kind), "Domain")
466
467
  }));
467
468
  let aggregateQueueUrls = {};
468
469
  Object.entries(aggregateResources).forEach(param => {
@@ -12,8 +12,11 @@
12
12
  // describe a plugin's role and origin.
13
13
  // ---------------------------------------------------------------------------
14
14
 
15
- /** Classifies the business role of a plugin. Absent defaults to Domain. */
16
- type pluginKind =
15
+ /** Classifies the business role of a plugin. Absent defaults to Domain.
16
+ Aliases the canonical `@schema` enum in `reventless-spec` (`Reventless.Plugin.pluginKind`)
17
+ so the deploy-time metadata registry and the serialized `pluginDefinition` share one
18
+ type — no mapping needed where metadata flows onto the definition. */
19
+ type pluginKind = Reventless.Plugin.pluginKind =
17
20
  | Domain
18
21
  | PlatformInfrastructure
19
22
  | Commercial
@@ -58,6 +58,7 @@ let displayState = (
58
58
  uiFragments: def.uiFragments,
59
59
  structure: def.structure,
60
60
  dcbEventLog: def.dcbEventLog,
61
+ kind: def.kind,
61
62
  // The current version is never listed among the "other" connected versions.
62
63
  otherConnectedVersions: otherConnectedVersions->Util.without(def.version),
63
64
  }
@@ -74,6 +74,7 @@ function displayState(def, status, statusChange, otherConnectedVersions) {
74
74
  let base_uiFragments = def.uiFragments;
75
75
  let base_structure = def.structure;
76
76
  let base_dcbEventLog = def.dcbEventLog;
77
+ let base_kind = def.kind;
77
78
  let base_otherConnectedVersions = without(otherConnectedVersions, def.version);
78
79
  let base = {
79
80
  name: base_name,
@@ -89,6 +90,7 @@ function displayState(def, status, statusChange, otherConnectedVersions) {
89
90
  uiFragments: base_uiFragments,
90
91
  structure: base_structure,
91
92
  dcbEventLog: base_dcbEventLog,
93
+ kind: base_kind,
92
94
  otherConnectedVersions: base_otherConnectedVersions
93
95
  };
94
96
  let target = def.apiTarget;
@@ -37,6 +37,12 @@ type state = {
37
37
  // from this plugin's DCB topic → peer EventCollectors (and vice-versa). None
38
38
  // for pure-aggregate plugins or for plugins persisted before Phase 4.
39
39
  dcbEventLog: @s.matches(Reventless.Plugin.dcbEventLogOptionSchema) option<Reventless.Plugin.dcbEventLogDefinition>,
40
+ // Business role of the plugin (from pluginDefinition.kind). Lets the admin Plugins
41
+ // view segregate PlatformInfrastructure / Commercial / Marketplace from Domain.
42
+ // @scan opts the field into server-side equality filtering so the connection gains
43
+ // a `kindEq` filter — the panel split (main list vs System/Infrastructure) pages
44
+ // correctly server-side rather than filtering a page client-side.
45
+ @scan kind: Reventless.Plugin.pluginKind,
40
46
  // Other versions of this name that are currently **Connected** — excludes this
41
47
  // row's own `version` (whose status is the `status` field above). Lets the
42
48
  // projection recompute which version is current (highest Connected) during a
@@ -61,6 +67,7 @@ type queryResult = {
61
67
  uiFragments: option<Reventless.Plugin.uiFragmentManifest>,
62
68
  structure: option<Reventless.Plugin.pluginStructure>,
63
69
  dcbEventLog: option<Reventless.Plugin.dcbEventLogDefinition>,
70
+ kind: Reventless.Plugin.pluginKind,
64
71
  }
65
72
 
66
73
 
@@ -4,6 +4,7 @@ import * as S from "sury/src/S.res.mjs";
4
4
  import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
5
5
  import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
6
6
  import * as Message$ReventlessCore from "../../Message.res.mjs";
7
+ import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
7
8
 
8
9
  let statusSchema = S.union([
9
10
  S.literal("Connected"),
@@ -27,11 +28,29 @@ let stateSchema = S.schema(s => ({
27
28
  uiFragments: s.m(Plugin$Reventless.uiFragmentManifestOptionSchema),
28
29
  structure: s.m(Plugin$Reventless.pluginStructureOptionSchema),
29
30
  dcbEventLog: s.m(Plugin$Reventless.dcbEventLogOptionSchema),
31
+ kind: s.m(Plugin$Reventless.pluginKindSchema),
30
32
  otherConnectedVersions: s.m(S.array(Plugin$Reventless.versionSchema))
31
33
  }));
32
34
 
33
35
  let config = ReadModel$Reventless.config(undefined, undefined, undefined);
34
36
 
37
+ let stateSchema$1 = S.Metadata.set(stateSchema, StateAnnotations$Reventless.stateAnnotationsId, {
38
+ ids: [],
39
+ compositeIds: [],
40
+ subIds: [],
41
+ compositeSubIds: [],
42
+ indexes: [],
43
+ hidden: [],
44
+ summary: [],
45
+ drillTargets: [],
46
+ drillTargetKeys: [],
47
+ collapsed: [],
48
+ scan: ["kind"],
49
+ scanSort: [],
50
+ status: undefined,
51
+ visibility: undefined
52
+ });
53
+
35
54
  let name = "Plugins";
36
55
 
37
56
  let Id;
@@ -48,9 +67,9 @@ export {
48
67
  name,
49
68
  Id,
50
69
  statusSchema,
51
- stateSchema,
52
70
  config,
53
71
  subIdConfig,
72
+ stateSchema$1 as stateSchema,
54
73
  moduleUrl,
55
74
  authorization,
56
75
  visibility,
@@ -104,4 +104,97 @@ type Mutation {
104
104
  isCatastrophicSchemaShrink(~currentSdl=fullSchemaSdl, ~newSdl=fullSchemaSdl, ~threshold=0.5),
105
105
  )->toBe(false)
106
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
+ })
107
200
  })
@@ -89,6 +89,80 @@ type Mutation {
89
89
  globalThis.test("allows an unchanged re-push (same SDL)", () => {
90
90
  globalThis.expect(GraphQL_Stitcher$ReventlessCore.isCatastrophicSchemaShrink(fullSchemaSdl, fullSchemaSdl, 0.5)).toBe(false);
91
91
  });
92
+ globalThis.test("allows a purely additive push even below threshold cardinality", () => {
93
+ globalThis.expect(GraphQL_Stitcher$ReventlessCore.isCatastrophicSchemaShrink(collapsedSchemaSdl, fullSchemaSdl, 0.5)).toBe(false);
94
+ });
95
+ globalThis.test("includes Subscription fields when judging a shrink", () => {
96
+ globalThis.expect(GraphQL_Stitcher$ReventlessCore.isCatastrophicSchemaShrink(`type Query {
97
+ node(id: ID!): Node
98
+ }
99
+
100
+ type Mutation {
101
+ Ordering_placeOrder(input: OrderInput): Order
102
+ }
103
+
104
+ type Subscription {
105
+ onOrdering_Customer_Register: Customer
106
+ onOrdering_Customer_UpdateAddress: Customer
107
+ onCatalog_Product_Added: Product
108
+ }`, `type Query {
109
+ node(id: ID!): Node
110
+ }
111
+
112
+ type Mutation {
113
+ Ordering_placeOrder(input: OrderInput): Order
114
+ }`, 0.5)).toBe(true);
115
+ });
116
+ });
117
+
118
+ globalThis.describe("GraphQL_Stitcher.rootTypeFieldNames", () => {
119
+ globalThis.test("lists Mutation field names", () => {
120
+ globalThis.expect(GraphQL_Stitcher$ReventlessCore.rootTypeFieldNames(fullSchemaSdl, "Mutation")).toEqual([
121
+ "Platform_connectPlugin",
122
+ "Catalog_addProduct",
123
+ "Catalog_updateProduct",
124
+ "Ordering_placeOrder"
125
+ ]);
126
+ });
127
+ globalThis.test("strips args and @aws_auth directive lines", () => {
128
+ globalThis.expect(GraphQL_Stitcher$ReventlessCore.rootTypeFieldNames(authAnnotatedSdl, "Mutation")).toEqual([
129
+ "Platform_connectPlugin",
130
+ "Catalog_addProduct"
131
+ ]);
132
+ });
133
+ globalThis.test("missing type returns []", () => {
134
+ globalThis.expect(GraphQL_Stitcher$ReventlessCore.rootTypeFieldNames(fullSchemaSdl, "Subscription")).toEqual([]);
135
+ });
136
+ });
137
+
138
+ globalThis.describe("GraphQL_Stitcher.missingRootFields", () => {
139
+ globalThis.test("reports plugin fields absent from an admin-base clobber", () => {
140
+ let missing = GraphQL_Stitcher$ReventlessCore.missingRootFields(fullSchemaSdl, collapsedSchemaSdl);
141
+ globalThis.expect(missing.includes("Catalog_addProduct")).toBe(true);
142
+ globalThis.expect(missing.includes("Ordering_placeOrder")).toBe(true);
143
+ globalThis.expect(missing.includes("Ordering_orders")).toBe(true);
144
+ });
145
+ globalThis.test("empty when live schema is a superset (intact)", () => {
146
+ globalThis.expect(GraphQL_Stitcher$ReventlessCore.missingRootFields(fullSchemaSdl, fullSchemaSdl)).toEqual([]);
147
+ });
148
+ globalThis.test("detects an equal-cardinality field swap the count test misses", () => {
149
+ let swapped = `type Query {
150
+ node(id: ID!): Node
151
+ Platform_plugins: [Plugin]
152
+ Catalog_products: [Product]
153
+ Ordering_orders: [Order]
154
+ }
155
+
156
+ type Mutation {
157
+ Platform_connectPlugin(input: ConnectInput): ConnectResult
158
+ Catalog_addProduct(input: AddInput): Product
159
+ Catalog_updateProduct(input: UpdateInput): Product
160
+ Foreign_strayField(input: OrderInput): Order
161
+ }`;
162
+ globalThis.expect(GraphQL_Stitcher$ReventlessCore.countRootTypeFields(swapped, "Mutation")).toBe(GraphQL_Stitcher$ReventlessCore.countRootTypeFields(fullSchemaSdl, "Mutation"));
163
+ let missing = GraphQL_Stitcher$ReventlessCore.missingRootFields(fullSchemaSdl, swapped);
164
+ globalThis.expect(missing.includes("Ordering_placeOrder")).toBe(true);
165
+ });
92
166
  });
93
167
 
94
168
  export {
@@ -76,6 +76,7 @@ describe("LogFormat", () => {
76
76
  uiFragments: None,
77
77
  structure: None,
78
78
  dcbEventLog: None,
79
+ kind: Domain,
79
80
  }),
80
81
  ]
81
82
  let meta: Message.meta = {
@@ -97,7 +98,7 @@ describe("LogFormat", () => {
97
98
  },
98
99
  )
99
100
  let expected1 = `1/2: \x1b[1mHeartbeat\x1b[0m(0): {"command":{"TAG":"Heartbeat","_0":"0.0.0"},"meta":${metaStr},"id":"0"}`
100
- let expected2 = `2/2: \x1b[1mConnect\x1b[0m(1): {"command":{"TAG":"Connect","_0":{"id":"id","name":"testName","version":"testVersion","extensionPoints":[{"name":"testExtensionPoint","commandTopic":"testCommandTopic","eventTopic":"testEventTopic"}],"extensions":[{"name":"testExtension","extensionPointName":"testExtensionPoint","dcbSources":[]}],"eventCollector":"testEventCollector","extensionProtocols":[],"apiSchemaFragment":null,"apiTarget":null,"uiFragments":null,"structure":null,"dcbEventLog":null}},"meta":${metaStr},"id":"1"}`
101
+ let expected2 = `2/2: \x1b[1mConnect\x1b[0m(1): {"command":{"TAG":"Connect","_0":{"id":"id","name":"testName","version":"testVersion","extensionPoints":[{"name":"testExtensionPoint","commandTopic":"testCommandTopic","eventTopic":"testEventTopic"}],"extensions":[{"name":"testExtension","extensionPointName":"testExtensionPoint","dcbSources":[]}],"eventCollector":"testEventCollector","extensionProtocols":[],"apiSchemaFragment":null,"apiTarget":null,"uiFragments":null,"structure":null,"dcbEventLog":null,"kind":"Domain"}},"meta":${metaStr},"id":"1"}`
101
102
  expect(commandJsonsToLogMessages(arr))->toEqual([expected1, expected2])
102
103
  },
103
104
  )
@@ -76,7 +76,8 @@ globalThis.describe("LogFormat", () => {
76
76
  apiTarget: undefined,
77
77
  uiFragments: undefined,
78
78
  structure: undefined,
79
- dcbEventLog: undefined
79
+ dcbEventLog: undefined,
80
+ kind: "Domain"
80
81
  }
81
82
  }
82
83
  ];
@@ -95,7 +96,7 @@ globalThis.describe("LogFormat", () => {
95
96
  commandJson: Message$ReventlessCore.encode(command, PluginSpec$ReventlessCore.commandSchema)
96
97
  }));
97
98
  let expected1 = `1/2: \x1b[1mHeartbeat\x1b[0m(0): {"command":{"TAG":"Heartbeat","_0":"0.0.0"},"meta":` + metaStr + `,"id":"0"}`;
98
- let expected2 = `2/2: \x1b[1mConnect\x1b[0m(1): {"command":{"TAG":"Connect","_0":{"id":"id","name":"testName","version":"testVersion","extensionPoints":[{"name":"testExtensionPoint","commandTopic":"testCommandTopic","eventTopic":"testEventTopic"}],"extensions":[{"name":"testExtension","extensionPointName":"testExtensionPoint","dcbSources":[]}],"eventCollector":"testEventCollector","extensionProtocols":[],"apiSchemaFragment":null,"apiTarget":null,"uiFragments":null,"structure":null,"dcbEventLog":null}},"meta":` + metaStr + `,"id":"1"}`;
99
+ let expected2 = `2/2: \x1b[1mConnect\x1b[0m(1): {"command":{"TAG":"Connect","_0":{"id":"id","name":"testName","version":"testVersion","extensionPoints":[{"name":"testExtensionPoint","commandTopic":"testCommandTopic","eventTopic":"testEventTopic"}],"extensions":[{"name":"testExtension","extensionPointName":"testExtensionPoint","dcbSources":[]}],"eventCollector":"testEventCollector","extensionProtocols":[],"apiSchemaFragment":null,"apiTarget":null,"uiFragments":null,"structure":null,"dcbEventLog":null,"kind":"Domain"}},"meta":` + metaStr + `,"id":"1"}`;
99
100
  globalThis.expect(LogFormat$ReventlessCore.commandJsonsToLogMessages(arr)).toEqual([
100
101
  expected1,
101
102
  expected2
@@ -44,6 +44,7 @@ describe("Message should", () => {
44
44
  uiFragments: None,
45
45
  structure: None,
46
46
  dcbEventLog: None,
47
+ kind: Domain,
47
48
  })
48
49
  let variantJson = variant->Message.encode(PluginSpec.commandSchema)
49
50
  let variantName = variantNameOfJson(variantJson)
@@ -43,7 +43,8 @@ globalThis.describe("Message should", () => {
43
43
  apiTarget: undefined,
44
44
  uiFragments: undefined,
45
45
  structure: undefined,
46
- dcbEventLog: undefined
46
+ dcbEventLog: undefined,
47
+ kind: "Domain"
47
48
  }
48
49
  };
49
50
  let variantJson = Message$ReventlessCore.encode(variant, PluginSpec$ReventlessCore.commandSchema);