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

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,14 @@
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.151 (2026-07-10)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **plugin-lifecycle:** backfill plugin kind onto existing rows via deploy-time RedetectPlugin ([502dbd6](https://github.com/ReventlessDev/reventless-core/commit/502dbd65d1fdd5b8beae8a7b96df06dcfa32fe3e))
11
+ * **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))
12
+
13
+
6
14
  # 3.0.0-alpha.150 (2026-07-10)
7
15
 
8
16
  ### 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.151",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,16 +29,16 @@
29
29
  "sury": "11.0.0-alpha.4",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.25",
32
- "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.11",
33
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.10",
34
- "@reventlessdev/rescript-jest": "1.0.0-alpha.6",
35
33
  "@reventlessdev/rescript-node-streams": "1.1.0-alpha.10",
34
+ "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.11",
35
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.6",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.14",
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",
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.93",
40
40
  "@reventlessdev/reventless-interop": "3.0.0-alpha.24",
41
- "@reventlessdev/reventless-spec": "3.0.0-alpha.70"
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.71"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "^12.3.0",
@@ -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