@reventlessdev/reventless-core 3.0.0-alpha.150 → 3.0.0-alpha.152

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,21 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.152 (2026-07-10)
7
+
8
+ ### Features
9
+
10
+ * **plugin-lifecycle:** expose command/event/consumed-event metadata on plugin hooks for StateChange/StateView slices ([194abf3](https://github.com/ReventlessDev/reventless-core/commit/194abf30460ae0140a7900d8ca67d671b15deb3a))
11
+
12
+
13
+ # 3.0.0-alpha.151 (2026-07-10)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **plugin-lifecycle:** backfill plugin kind onto existing rows via deploy-time RedetectPlugin ([502dbd6](https://github.com/ReventlessDev/reventless-core/commit/502dbd65d1fdd5b8beae8a7b96df06dcfa32fe3e))
18
+ * **plugin-lifecycle:** make read-model kind nullable so kind-less rows don't null the admin Plugins query ([671c4f6](https://github.com/ReventlessDev/reventless-core/commit/671c4f6cbbdde32a8dced7290fc8aa5b0063536f))
19
+
20
+
6
21
  # 3.0.0-alpha.150 (2026-07-10)
7
22
 
8
23
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.150",
3
+ "version": "3.0.0-alpha.152",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -32,13 +32,13 @@
32
32
  "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.11",
33
33
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.10",
34
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.6",
35
- "@reventlessdev/rescript-node-streams": "1.1.0-alpha.10",
36
35
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
36
+ "@reventlessdev/rescript-node-streams": "1.1.0-alpha.10",
37
37
  "@reventlessdev/rescript-ssh2": "1.1.0-alpha.11",
38
38
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.14",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.92",
40
- "@reventlessdev/reventless-interop": "3.0.0-alpha.24",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.70"
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.93",
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.71",
41
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.24"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "^12.3.0",
@@ -398,14 +398,6 @@ module Make = (
398
398
  ({name: R.Spec.name, kind: "ReadModel", schema}: Plugin_Helpers.pluginBuiltComponent)
399
399
  })
400
400
 
401
- let mapNames = (d: dict<_>, kind: string) =>
402
- d
403
- ->Dict.keysToArray
404
- ->Array.map(name => {
405
- let schema: Plugin_Helpers.pluginDeployedSchema = {}
406
- ({Plugin_Helpers.name, kind, schema}: Plugin_Helpers.pluginBuiltComponent)
407
- })
408
-
409
401
  // Routing slices (automation / inbound + outbound translation) expose their
410
402
  // command/event wiring and routing target on the hook surface, at parity
411
403
  // with aggregates and read models — mirroring the metadata Plugin_Structure
@@ -457,15 +449,44 @@ module Make = (
457
449
  )
458
450
  )
459
451
 
452
+ // StateChange / StateView slices carry no routing target, but they do carry
453
+ // command / event / consumed-event types. Surface those on the hook at parity
454
+ // with aggregates, read models, and routing slices — mirroring the metadata
455
+ // Plugin_Structure records on the static plugin def. Type strings stay
456
+ // unqualified to match the aggregate/read-model entries already on this same
457
+ // hook surface (Plugin_Structure additionally prefixes them with the plugin
458
+ // name).
459
+ let stateSchemas: Dict.t<Plugin_Helpers.pluginDeployedSchema> = Dict.make()
460
+ stateChangeSlices->Array.forEach((module(Scs: ReventlessInfra.StateChangeSlice.T)) =>
461
+ stateSchemas->Dict.set(
462
+ Scs.Spec.name,
463
+ {
464
+ commandTypes: extractTypes(Scs.Spec.commandSchema),
465
+ eventTypes: extractEventTypes(Scs.Spec.eventSchema->S.castToUnknown),
466
+ consumedEventTypes: extractEventTypes(Scs.Spec.consumedEventSchema->S.castToUnknown),
467
+ },
468
+ )
469
+ )
470
+ stateViewSlices->Array.forEach((module(Svs: ReventlessInfra.StateViewSlice.T)) =>
471
+ stateSchemas->Dict.set(
472
+ Svs.Spec.name,
473
+ {consumedEventTypes: extractEventTypes(Svs.Spec.consumedEventSchema->S.castToUnknown)},
474
+ )
475
+ )
476
+
460
477
  // Component set still derives from the DCB outputs dict (unchanged
461
478
  // membership); enrich + register the schema where a matching spec module
462
479
  // was found above, so the deployed hook surfaces it too (the built-hook
463
480
  // components carry it inline).
464
- let routingComponents = (d: dict<_>, kind: string) =>
481
+ let enrichedComponents = (
482
+ d: dict<_>,
483
+ kind: string,
484
+ schemas: Dict.t<Plugin_Helpers.pluginDeployedSchema>,
485
+ ) =>
465
486
  d
466
487
  ->Dict.keysToArray
467
488
  ->Array.map(name => {
468
- let schema = routingSchemas->Dict.get(name)->Option.getOr({})
489
+ let schema = schemas->Dict.get(name)->Option.getOr({})
469
490
  Plugin_Helpers.componentSchemaRegistry->Dict.set(name, schema)
470
491
  ({Plugin_Helpers.name, kind, schema}: Plugin_Helpers.pluginBuiltComponent)
471
492
  })
@@ -473,11 +494,19 @@ module Make = (
473
494
  let components = Array.flat([
474
495
  aggregateComponents,
475
496
  readModelComponents,
476
- mapNames(dcbResult.stateChangeSlicesOutputs, "StateChangeSlice"),
477
- mapNames(dcbResult.stateViewSlicesOutputs, "StateViewSlice"),
478
- routingComponents(dcbResult.automationSlicesOutputs, "AutomationSlice"),
479
- routingComponents(dcbResult.outboundTranslationSlicesOutputs, "OutboundTranslationSlice"),
480
- routingComponents(dcbResult.inboundTranslationSlicesOutputs, "InboundTranslationSlice"),
497
+ enrichedComponents(dcbResult.stateChangeSlicesOutputs, "StateChangeSlice", stateSchemas),
498
+ enrichedComponents(dcbResult.stateViewSlicesOutputs, "StateViewSlice", stateSchemas),
499
+ enrichedComponents(dcbResult.automationSlicesOutputs, "AutomationSlice", routingSchemas),
500
+ enrichedComponents(
501
+ dcbResult.outboundTranslationSlicesOutputs,
502
+ "OutboundTranslationSlice",
503
+ routingSchemas,
504
+ ),
505
+ enrichedComponents(
506
+ dcbResult.inboundTranslationSlicesOutputs,
507
+ "InboundTranslationSlice",
508
+ routingSchemas,
509
+ ),
481
510
  ])
482
511
 
483
512
  switch Plugin_Helpers.onPluginBuiltHook.contents {
@@ -300,11 +300,6 @@ function Make(Spec) {
300
300
  schema: schema
301
301
  };
302
302
  });
303
- let mapNames = (d, kind) => Object.keys(d).map(name => ({
304
- name: name,
305
- kind: kind,
306
- schema: {}
307
- }));
308
303
  let routingSchemas = {};
309
304
  automationSlices.forEach(As => {
310
305
  let consumedEventTypes = Belt_SetString.toArray(Belt_SetString.fromArray(As.Automation.mappings.flatMap(M => DcbTag$Reventless.extractVariantNames(M.sourceEventSchema))));
@@ -327,8 +322,21 @@ function Make(Spec) {
327
322
  targetName: Its.Spec.targetName
328
323
  };
329
324
  });
330
- let routingComponents = (d, kind) => Object.keys(d).map(name => {
331
- let schema = Stdlib_Option.getOr(routingSchemas[name], {});
325
+ let stateSchemas = {};
326
+ stateChangeSlices.forEach(Scs => {
327
+ stateSchemas[Scs.Spec.name] = {
328
+ commandTypes: DcbTag$Reventless.extractAllVariantNames(Scs.Spec.commandSchema),
329
+ eventTypes: DcbTag$Reventless.extractVariantNames(Scs.Spec.eventSchema),
330
+ consumedEventTypes: DcbTag$Reventless.extractVariantNames(Scs.Spec.consumedEventSchema)
331
+ };
332
+ });
333
+ stateViewSlices.forEach(Svs => {
334
+ stateSchemas[Svs.Spec.name] = {
335
+ consumedEventTypes: DcbTag$Reventless.extractVariantNames(Svs.Spec.consumedEventSchema)
336
+ };
337
+ });
338
+ let enrichedComponents = (d, kind, schemas) => Object.keys(d).map(name => {
339
+ let schema = Stdlib_Option.getOr(schemas[name], {});
332
340
  Plugin_Helpers$ReventlessCore.componentSchemaRegistry[name] = schema;
333
341
  return {
334
342
  name: name,
@@ -339,11 +347,11 @@ function Make(Spec) {
339
347
  let components = [
340
348
  aggregateComponents,
341
349
  readModelComponents,
342
- mapNames(dcbResult.stateChangeSlicesOutputs, "StateChangeSlice"),
343
- mapNames(dcbResult.stateViewSlicesOutputs, "StateViewSlice"),
344
- routingComponents(dcbResult.automationSlicesOutputs, "AutomationSlice"),
345
- routingComponents(dcbResult.outboundTranslationSlicesOutputs, "OutboundTranslationSlice"),
346
- routingComponents(dcbResult.inboundTranslationSlicesOutputs, "InboundTranslationSlice")
350
+ enrichedComponents(dcbResult.stateChangeSlicesOutputs, "StateChangeSlice", stateSchemas),
351
+ enrichedComponents(dcbResult.stateViewSlicesOutputs, "StateViewSlice", stateSchemas),
352
+ enrichedComponents(dcbResult.automationSlicesOutputs, "AutomationSlice", routingSchemas),
353
+ enrichedComponents(dcbResult.outboundTranslationSlicesOutputs, "OutboundTranslationSlice", routingSchemas),
354
+ enrichedComponents(dcbResult.inboundTranslationSlicesOutputs, "InboundTranslationSlice", routingSchemas)
347
355
  ].flat();
348
356
  let hook = Plugin_Helpers$ReventlessCore.onPluginBuiltHook.contents;
349
357
  if (hook !== undefined) {
@@ -150,6 +150,14 @@ module Make = (Spec: Spec) => {
150
150
  // 1 additional minute to allow additional latency
151
151
  HandleDirective(directiveHandler, CreateDisconnectSchedule(id, interval + 2)),
152
152
  ]
153
+ | RedetectPlugin(interval) => [
154
+ // Deploy-time re-detect: drive Redetect (not Heartbeat) so an already-connected
155
+ // version re-runs the handshake and refreshes its stored definition. Still
156
+ // re-arm the disconnect schedule exactly like a heartbeat so liveness tracking
157
+ // continues from the deploy moment.
158
+ PublishCommand(Plugin.name(id), Delegate.Redetect(Plugin.version(id))),
159
+ HandleDirective(directiveHandler, CreateDisconnectSchedule(id, interval + 2)),
160
+ ]
153
161
  | ConnectPlugin(pluginDefinition) =>
154
162
  // Validate protocol versions declared by the connecting plugin.
155
163
  // On mismatch, emit a ReportIncompatibility command so that an
@@ -183,6 +183,26 @@ function Make(Spec) {
183
183
  _0: cmd._0
184
184
  }
185
185
  }];
186
+ case "RedetectPlugin" :
187
+ return [
188
+ {
189
+ TAG: "PublishCommand",
190
+ _0: Plugin$ReventlessCore.name(id),
191
+ _1: {
192
+ TAG: "Redetect",
193
+ _0: Plugin$ReventlessCore.version(id)
194
+ }
195
+ },
196
+ {
197
+ TAG: "HandleDirective",
198
+ _0: directiveHandler,
199
+ _1: {
200
+ TAG: "CreateDisconnectSchedule",
201
+ _0: id,
202
+ _1: cmd._0 + 2 | 0
203
+ }
204
+ }
205
+ ];
186
206
  }
187
207
  };
188
208
  let mapOutgoingEvent = (id, event, _meta, _queryEngine) => {
@@ -145,10 +145,24 @@ let decide = (state, command) =>
145
145
  | Some({status: Disconnected, definition}) => Ok(connectEvents(state, v, definition)) // reconnect
146
146
  | Some({status: Inactive | Retired}) => Ok([]) // not heartbeat-revivable (admin only)
147
147
  }
148
+ | Redetect(v) =>
149
+ // Deploy-time re-detect: unlike Heartbeat, a *connected* version re-runs the
150
+ // handshake (VersionDetected → ConnectPlugin → Connect) so the plugin re-answers
151
+ // with its current definition and Connect below refreshes the stored def. Unknown
152
+ // and disconnected versions detect exactly as a heartbeat would; archived versions
153
+ // stay admin-only.
154
+ switch state.known->Dict.get(v) {
155
+ | Some({status: Inactive | Retired}) => Ok([]) // archived — not re-detect-revivable
156
+ | None | Some({status: Connected | Disconnected}) => Ok([VersionDetected(v)])
157
+ }
148
158
  | Connect(def) =>
149
159
  let v = def.version
150
160
  switch state.known->Dict.get(v) {
151
- | Some({status: Connected}) => Ok([]) // idempotent
161
+ // Idempotent when the definition is unchanged; when a redeploy carries a changed
162
+ // definition (e.g. a newly added `kind`, updated uiFragments/protocols) re-emit
163
+ // VersionConnected to overwrite the stored def and re-project the row. The version
164
+ // is already current, so connectEvents' supersede stays empty — a bare refresh.
165
+ | Some({status: Connected, definition}) => definition == def ? Ok([]) : Ok([VersionConnected(def)])
152
166
  | _ => Ok(connectEvents(state, v, def))
153
167
  }
154
168
  | Disconnect(v) =>
@@ -172,55 +172,94 @@ function decide(state, command) {
172
172
  _0: []
173
173
  };
174
174
  }
175
- case "Connect" :
176
- let def = command._0;
177
- let v$1 = def.version;
175
+ case "Redetect" :
176
+ let v$1 = command._0;
178
177
  let match$1 = state.known[v$1];
179
- if (match$1 !== undefined && match$1.status === "Connected") {
178
+ if (match$1 === undefined) {
180
179
  return {
181
180
  TAG: "Ok",
182
- _0: []
181
+ _0: [{
182
+ TAG: "VersionDetected",
183
+ _0: v$1
184
+ }]
183
185
  };
186
+ }
187
+ switch (match$1.status) {
188
+ case "Connected" :
189
+ case "Disconnected" :
190
+ return {
191
+ TAG: "Ok",
192
+ _0: [{
193
+ TAG: "VersionDetected",
194
+ _0: v$1
195
+ }]
196
+ };
197
+ case "Inactive" :
198
+ case "Retired" :
199
+ return {
200
+ TAG: "Ok",
201
+ _0: []
202
+ };
203
+ }
204
+ case "Connect" :
205
+ let def = command._0;
206
+ let v$2 = def.version;
207
+ let match$2 = state.known[v$2];
208
+ if (match$2 !== undefined && match$2.status === "Connected") {
209
+ if (Primitive_object.equal(match$2.definition, def)) {
210
+ return {
211
+ TAG: "Ok",
212
+ _0: []
213
+ };
214
+ } else {
215
+ return {
216
+ TAG: "Ok",
217
+ _0: [{
218
+ TAG: "VersionConnected",
219
+ _0: def
220
+ }]
221
+ };
222
+ }
184
223
  } else {
185
224
  return {
186
225
  TAG: "Ok",
187
- _0: connectEvents(state, v$1, def)
226
+ _0: connectEvents(state, v$2, def)
188
227
  };
189
228
  }
190
229
  case "Disconnect" :
191
- let v$2 = command._0;
192
- let match$2 = state.known[v$2];
193
- if (match$2 === undefined) {
230
+ let v$3 = command._0;
231
+ let match$3 = state.known[v$3];
232
+ if (match$3 === undefined) {
194
233
  return {
195
234
  TAG: "Ok",
196
235
  _0: []
197
236
  };
198
237
  }
199
- if (match$2.status !== "Connected") {
238
+ if (match$3.status !== "Connected") {
200
239
  return {
201
240
  TAG: "Ok",
202
241
  _0: []
203
242
  };
204
243
  }
205
- let definition = match$2.definition;
244
+ let definition = match$3.definition;
206
245
  return {
207
246
  TAG: "Ok",
208
247
  _0: [{
209
248
  TAG: "VersionDisconnected",
210
249
  _0: definition
211
- }].concat(uiDeregisterEvents(definition.id, definition.uiFragments)).concat(promoteEvents(state, v$2))
250
+ }].concat(uiDeregisterEvents(definition.id, definition.uiFragments)).concat(promoteEvents(state, v$3))
212
251
  };
213
252
  case "Activate" :
214
- let v$3 = command._0;
215
- let match$3 = state.known[v$3];
216
- if (match$3 === undefined) {
253
+ let v$4 = command._0;
254
+ let match$4 = state.known[v$4];
255
+ if (match$4 === undefined) {
217
256
  return {
218
257
  TAG: "Error",
219
258
  _0: "UnknownVersion"
220
259
  };
221
260
  }
222
- let definition$1 = match$3.definition;
223
- switch (match$3.status) {
261
+ let definition$1 = match$4.definition;
262
+ switch (match$4.status) {
224
263
  case "Connected" :
225
264
  return {
226
265
  TAG: "Ok",
@@ -240,17 +279,17 @@ function decide(state, command) {
240
279
  _0: definition$1
241
280
  }].concat(uiRegisterEvents(definition$1.id, definition$1.uiFragments));
242
281
  let supersede;
243
- if (currentAfterConnect(state.known, v$3) === v$3) {
282
+ if (currentAfterConnect(state.known, v$4) === v$4) {
244
283
  let c = state.current;
245
284
  if (c !== undefined) {
246
- if (c !== v$3) {
247
- let match$4 = state.known[c];
248
- supersede = match$4 !== undefined ? [{
285
+ if (c !== v$4) {
286
+ let match$5 = state.known[c];
287
+ supersede = match$5 !== undefined ? [{
249
288
  TAG: "VersionSuperseded",
250
289
  _0: {
251
290
  supersededVersion: c,
252
- supersededDefinition: match$4.definition,
253
- newVersion: v$3,
291
+ supersededDefinition: match$5.definition,
292
+ newVersion: v$4,
254
293
  newDefinition: definition$1
255
294
  }
256
295
  }] : [];
@@ -269,16 +308,16 @@ function decide(state, command) {
269
308
  };
270
309
  break;
271
310
  case "Deactivate" :
272
- let v$4 = command._0;
273
- let match$5 = state.known[v$4];
274
- if (match$5 === undefined) {
311
+ let v$5 = command._0;
312
+ let match$6 = state.known[v$5];
313
+ if (match$6 === undefined) {
275
314
  return {
276
315
  TAG: "Error",
277
316
  _0: "UnknownVersion"
278
317
  };
279
318
  }
280
- let definition$2 = match$5.definition;
281
- switch (match$5.status) {
319
+ let definition$2 = match$6.definition;
320
+ switch (match$6.status) {
282
321
  case "Connected" :
283
322
  case "Disconnected" :
284
323
  break;
@@ -294,7 +333,7 @@ function decide(state, command) {
294
333
  _0: [{
295
334
  TAG: "VersionDeactivated",
296
335
  _0: definition$2
297
- }].concat(uiDeregisterEvents(definition$2.id, definition$2.uiFragments)).concat(promoteEvents(state, v$4))
336
+ }].concat(uiDeregisterEvents(definition$2.id, definition$2.uiFragments)).concat(promoteEvents(state, v$5))
298
337
  };
299
338
  break;
300
339
  case "ReportIncompatibility" :
@@ -306,16 +345,16 @@ function decide(state, command) {
306
345
  }]
307
346
  };
308
347
  case "Retire" :
309
- let v$5 = command._0;
310
- let match$6 = state.known[v$5];
311
- if (match$6 === undefined) {
348
+ let v$6 = command._0;
349
+ let match$7 = state.known[v$6];
350
+ if (match$7 === undefined) {
312
351
  return {
313
352
  TAG: "Error",
314
353
  _0: "UnknownVersion"
315
354
  };
316
355
  }
317
- let definition$3 = match$6.definition;
318
- if (match$6.status === "Retired") {
356
+ let definition$3 = match$7.definition;
357
+ if (match$7.status === "Retired") {
319
358
  return {
320
359
  TAG: "Ok",
321
360
  _0: []
@@ -326,7 +365,7 @@ function decide(state, command) {
326
365
  _0: [{
327
366
  TAG: "VersionRetired",
328
367
  _0: definition$3
329
- }].concat(uiDeregisterEvents(definition$3.id, definition$3.uiFragments)).concat(promoteEvents(state, v$5))
368
+ }].concat(uiDeregisterEvents(definition$3.id, definition$3.uiFragments)).concat(promoteEvents(state, v$6))
330
369
  };
331
370
  }
332
371
  }
@@ -10,6 +10,10 @@ open Reventless.Plugin
10
10
  @schema
11
11
  type command =
12
12
  | @noApi Heartbeat(version)
13
+ // Deploy-time re-detect (from RedetectPlugin). Forces the connect handshake to
14
+ // re-run for an already-connected version so its definition is refreshed on the
15
+ // row — unlike Heartbeat, which no-ops a connected keep-alive.
16
+ | @noApi Redetect(version)
13
17
  | @noApi Connect(pluginDefinition)
14
18
  | @noApi Disconnect(version)
15
19
  // Admin lifecycle commands — API-exposed (auto-derived admin mutations,
@@ -9,6 +9,10 @@ let commandSchema = S.union([
9
9
  TAG: "Heartbeat",
10
10
  _0: s.m(Plugin$Reventless.versionSchema)
11
11
  })),
12
+ S.schema(s => ({
13
+ TAG: "Redetect",
14
+ _0: s.m(Plugin$Reventless.versionSchema)
15
+ })),
12
16
  S.schema(s => ({
13
17
  TAG: "Connect",
14
18
  _0: s.m(Plugin$Reventless.pluginDefinitionSchema)
@@ -117,6 +121,7 @@ let errorSchema = S.union([
117
121
 
118
122
  let commandSchema$1 = Api$ReventlessInfra.markNoApiVariants(commandSchema, [
119
123
  "Heartbeat",
124
+ "Redetect",
120
125
  "Connect",
121
126
  "Disconnect",
122
127
  "ReportIncompatibility"
@@ -58,7 +58,10 @@ let displayState = (
58
58
  uiFragments: def.uiFragments,
59
59
  structure: def.structure,
60
60
  dcbEventLog: def.dcbEventLog,
61
- kind: def.kind,
61
+ // A freshly projected row always knows its kind (the definition carries it as a
62
+ // mandatory field). `option` here is purely for rows persisted before `kind`
63
+ // existed — see PluginsReadModelSpec.state.kind.
64
+ kind: Some(def.kind),
62
65
  // The current version is never listed among the "other" connected versions.
63
66
  otherConnectedVersions: otherConnectedVersions->Util.without(def.version),
64
67
  }
@@ -44,7 +44,16 @@ type state = {
44
44
  // correctly server-side rather than filtering a page client-side.
45
45
  // @groupBy tags this field as the list view's section key (emitted as
46
46
  // `x-reventless-group-by`); AutoUI sections the admin Plugins view by kind.
47
- @scan @groupBy kind: Reventless.Plugin.pluginKind,
47
+ //
48
+ // **Nullable on purpose.** Rows projected before `kind` existed carry no `kind`
49
+ // attribute, and the deploy-time RedetectPlugin backfill only stamps a row on the
50
+ // owning plugin's next deploy. A non-null `PluginKind!` would make GraphQL null the
51
+ // entire `Platform_Plugins` query the moment a single kind-less row is returned
52
+ // (existing rows, a newly-connecting plugin before it's stamped, or an internal
53
+ // bookkeeping row). Optional here → nullable `Platform_PluginKind` in the SDL, so a
54
+ // blank kind degrades to a missing group (AutoListView's trailing group) instead of
55
+ // taking down the whole list. Backfill then upgrades blanks to real kinds.
56
+ @scan @groupBy kind: option<Reventless.Plugin.pluginKind>,
48
57
  // Other versions of this name that are currently **Connected** — excludes this
49
58
  // row's own `version` (whose status is the `status` field above). Lets the
50
59
  // projection recompute which version is current (highest Connected) during a
@@ -69,7 +78,7 @@ type queryResult = {
69
78
  uiFragments: option<Reventless.Plugin.uiFragmentManifest>,
70
79
  structure: option<Reventless.Plugin.pluginStructure>,
71
80
  dcbEventLog: option<Reventless.Plugin.dcbEventLogDefinition>,
72
- kind: Reventless.Plugin.pluginKind,
81
+ kind: option<Reventless.Plugin.pluginKind>,
73
82
  }
74
83
 
75
84
 
@@ -28,7 +28,7 @@ let stateSchema = S.schema(s => ({
28
28
  uiFragments: s.m(Plugin$Reventless.uiFragmentManifestOptionSchema),
29
29
  structure: s.m(Plugin$Reventless.pluginStructureOptionSchema),
30
30
  dcbEventLog: s.m(Plugin$Reventless.dcbEventLogOptionSchema),
31
- kind: s.m(Plugin$Reventless.pluginKindSchema),
31
+ kind: s.m(S.option(Plugin$Reventless.pluginKindSchema)),
32
32
  otherConnectedVersions: s.m(S.array(Plugin$Reventless.versionSchema))
33
33
  }));
34
34