@reventlessdev/reventless-spec 3.0.0-alpha.128 → 3.0.0-alpha.129

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,46 @@
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.129 (2026-09-04)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **logging:** let a runtime declare whether a person reads its logs ([df7a6cb](https://github.com/ReventlessDev/reventless-core/commit/df7a6cbdff62b856ea6b9d1f169b913563ba13a1))
11
+ * feat(spec)!: one optional encoding on the wire, with no annotation ([320f91d](https://github.com/ReventlessDev/reventless-core/commit/320f91daa8bd90812a6e82069e7a1cb473041930))
12
+
13
+ ### BREAKING CHANGES
14
+
15
+ * the two encodings cannot read each other. Stored
16
+ pluginDefinition / pluginStructure payloads and already-deployed plugins must
17
+ go — wipe the platform scope (SEED_RESET_SCOPE=platform), quiesce, and
18
+ redeploy the fleet from one commit. Domain plugin data is untouched.
19
+
20
+ Two guards had to learn the new shape, both of which defined "optional" as
21
+ has.null and so mistook an omitted key for something to invent:
22
+
23
+ - Message.fillMissingDefaults reached `return undefined` only below two arms
24
+ that fire first — an enum's first const, and an object member filled with
25
+ zeros. Absent option<record> therefore healed to a zero-filled record, not
26
+ None: on the real schema, dcbEventLog became Some({name: "", eventTopicArn:
27
+ ""}), which manageSubscriptions would have read as a peer to subscribe to.
28
+ One line, mirroring the has.null guard, above both arms.
29
+ - PluginDefinitionScalars' walker reported 16 optional fields as newly-added
30
+ bare required scalars. With both encodings understood, the golden list is
31
+ unchanged.
32
+
33
+ The frozen lifecycle corpus holds five real payloads in the old encoding.
34
+ Null-valued keys were stripped mechanically — the rewrite round-trips each
35
+ file unchanged before editing, and a key-by-key diff shows null removals and
36
+ nothing else. The README records it beside the account-id redaction and says
37
+ why it is not a regeneration: no fixture was rebuilt from ReScript types, so
38
+ every generation in its table is still pinned.
39
+
40
+ check:graphql is unchanged, as expected: SchemaType.fromSury collapses Null
41
+ and Undefined to the same Nullable, so the emitted schema never distinguished
42
+ them.
43
+
44
+
45
+
6
46
  # 3.0.0-alpha.128 (2026-09-04)
7
47
 
8
48
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.128",
3
+ "version": "3.0.0-alpha.129",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -24,7 +24,7 @@
24
24
  "sury": "11.0.0-rc.2",
25
25
  "sury-ppx": "11.0.0-rc.2",
26
26
  "yaml": "^2.8.3",
27
- "@reventlessdev/rescript-node": "2.0.0-alpha.8"
27
+ "@reventlessdev/rescript-node": "2.0.0-alpha.9"
28
28
  },
29
29
  "devDependencies": {
30
30
  "rescript": "12.3.0",
package/src/AnsiStyle.res CHANGED
@@ -11,14 +11,23 @@
11
11
  @val external _isTty: option<bool> = "process.stdout.isTTY"
12
12
  @val external _logFormat: option<string> = "process.env.REVENTLESS_LOG_FORMAT"
13
13
 
14
- // "json" | "text". Explicit REVENTLESS_LOG_FORMAT override always wins; otherwise
15
- // a TTY stdout ⇒ human-readable text, everything else (Lambda, Fargate, ECS,
16
- // Azure, GCP, Docker, CI runners, piped stdout) ⇒ structured JSON.
14
+ // What this runtime is, for when stdout cannot say. A local dev platform's logs
15
+ // are read by a person even when its stdout is a pipe — `concurrently`, `tsx
16
+ // watch` and an IDE terminal all pipe — while a Lambda's are read by a collector
17
+ // through a pipe that looks identical. TTY-ness is only a proxy for "a person is
18
+ // reading this", and a pipe is exactly where the proxy fails, so a runtime that
19
+ // knows which it is says so instead of being guessed at.
20
+ let _default: ref<option<string>> = ref(None)
21
+
22
+ // "json" | "text", in precedence order: an explicit REVENTLESS_LOG_FORMAT, then
23
+ // what the runtime declared itself to be, then the TTY probe — a TTY stdout ⇒
24
+ // text, everything else (Lambda, Fargate, ECS, Docker, CI) ⇒ structured JSON.
17
25
  let _resolveFormat = (): string =>
18
- switch (_logFormat, _isTty) {
19
- | (Some("json"), _) => "json"
20
- | (Some("text"), _) => "text"
21
- | (_, Some(true)) => "text"
26
+ switch (_logFormat, _default.contents, _isTty) {
27
+ | (Some("json"), _, _) => "json"
28
+ | (Some("text"), _, _) => "text"
29
+ | (_, Some(declared), _) => declared
30
+ | (_, None, Some(true)) => "text"
22
31
  | _ => "json"
23
32
  }
24
33
 
@@ -27,6 +36,21 @@ let _resolveFormat = (): string =>
27
36
  // keeps every log call off `process.env`.
28
37
  let _format = ref(_resolveFormat())
29
38
 
39
+ /** Declares what this runtime is, for the pipe case the TTY probe gets wrong.
40
+ Call it at module init, before anything logs. An explicit
41
+ REVENTLESS_LOG_FORMAT still wins, so a developer can always ask for the other
42
+ one. A runtime that says nothing keeps the TTY behaviour. */
43
+ let setDefaultFormat = (format: [#text | #json]) => {
44
+ _default :=
45
+ Some(
46
+ switch format {
47
+ | #text => "text"
48
+ | #json => "json"
49
+ },
50
+ )
51
+ _format := _resolveFormat()
52
+ }
53
+
30
54
  /** Test-only: re-evaluate REVENTLESS_LOG_FORMAT / process.stdout.isTTY. */
31
55
  let reload = () => _format := _resolveFormat()
32
56
 
@@ -1,9 +1,14 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
 
4
+ let _default = {
5
+ contents: undefined
6
+ };
7
+
4
8
  function _resolveFormat() {
5
9
  let match = process.env.REVENTLESS_LOG_FORMAT;
6
- let match$1 = process.stdout.isTTY;
10
+ let match$1 = _default.contents;
11
+ let match$2 = process.stdout.isTTY;
7
12
  if (match !== undefined) {
8
13
  switch (match) {
9
14
  case "json" :
@@ -12,7 +17,9 @@ function _resolveFormat() {
12
17
  return "text";
13
18
  }
14
19
  }
15
- if (match$1 !== undefined && match$1) {
20
+ if (match$1 !== undefined) {
21
+ return match$1;
22
+ } else if (match$2 !== undefined && match$2) {
16
23
  return "text";
17
24
  } else {
18
25
  return "json";
@@ -23,6 +30,11 @@ let _format = {
23
30
  contents: _resolveFormat()
24
31
  };
25
32
 
33
+ function setDefaultFormat(format) {
34
+ _default.contents = format === "text" ? "text" : "json";
35
+ _format.contents = _resolveFormat();
36
+ }
37
+
26
38
  function reload() {
27
39
  _format.contents = _resolveFormat();
28
40
  }
@@ -44,8 +56,10 @@ function bold(s) {
44
56
  }
45
57
 
46
58
  export {
59
+ _default,
47
60
  _resolveFormat,
48
61
  _format,
62
+ setDefaultFormat,
49
63
  reload,
50
64
  isJsonSink,
51
65
  useAnsi,
@@ -73,13 +73,13 @@ type apiSchemaFragment = {encoded: string, protocol: string}
73
73
  @schema
74
74
  type apiTarget = Domain | Platform
75
75
 
76
- // js_nullable (T | null) is the only optional that passes jsonableValidation inside
77
- // union variant payloads; nullableAsOption adds `undefined` and fails it.
76
+ // Every optional below carries sury's default `option` encoding: the key is omitted,
77
+ // never written as `null`. That is the shape the rest of the repo already uses
78
+ // (`Message.meta`, `storedEvent.tags`), so there is one optional form on the wire and
79
+ // nothing to annotate. The `T | null` these fields used to carry worked around a sury
80
+ // bug — undefined failing jsonableValidation inside a union variant payload — fixed in
81
+ // 11.0.0-alpha.11. `Offload` below is not an optional wrapper but an either-or codec.
78
82
  let apiSchemaFragmentOffloadSchema = Offload.optionSchema(~store="pluginApiFragments", apiSchemaFragmentSchema)
79
- let dcbEventLogOptionSchema = dcbEventLogDefinitionSchema->S.nullAsOption
80
- let stringOptionSchema = S.string->S.nullAsOption
81
- let stringArrayOptionSchema = S.array(S.string)->S.nullAsOption
82
- let boolOptionSchema = S.bool->S.nullAsOption
83
83
 
84
84
  // ── UI fragment manifest types ────────────────────────────────────────────────
85
85
 
@@ -89,14 +89,14 @@ type panelManifestEntry = {
89
89
  title: string,
90
90
  description: string,
91
91
  positions: array<string>,
92
- requiredAccess: @s.matches(stringOptionSchema) option<string>,
92
+ requiredAccess: option<string>,
93
93
  }
94
94
 
95
95
  @schema
96
96
  type menuEntry = {
97
97
  label: string,
98
- icon: @s.matches(stringOptionSchema) option<string>,
99
- group: @s.matches(stringOptionSchema) option<string>,
98
+ icon: option<string>,
99
+ group: option<string>,
100
100
  sortOrder: int,
101
101
  }
102
102
 
@@ -105,7 +105,7 @@ type pageManifestEntry = {
105
105
  fragmentId: string,
106
106
  title: string,
107
107
  menuEntry: menuEntry,
108
- requiredAccess: @s.matches(stringOptionSchema) option<string>,
108
+ requiredAccess: option<string>,
109
109
  }
110
110
 
111
111
  @schema
@@ -115,7 +115,7 @@ type uiFragmentManifest = {
115
115
  pages: array<pageManifestEntry>,
116
116
  }
117
117
 
118
- let uiFragmentManifestOptionSchema = uiFragmentManifestSchema->S.nullAsOption
118
+ let uiFragmentManifestOptionSchema = S.option(uiFragmentManifestSchema)
119
119
 
120
120
  // ── Plugin structure types (component metadata for Auto UI and event graph) ──
121
121
 
@@ -126,7 +126,7 @@ type commandLevel = Collection | Instance
126
126
  type fieldReference = {
127
127
  fieldName: string,
128
128
  entity: string,
129
- plugin: @s.matches(stringOptionSchema) option<string>,
129
+ plugin: option<string>,
130
130
  }
131
131
 
132
132
  @schema
@@ -134,23 +134,23 @@ type commandDef = {
134
134
  name: string,
135
135
  schema: string,
136
136
  level: commandLevel,
137
- aggregateIdField: @s.matches(stringOptionSchema) option<string>,
137
+ aggregateIdField: option<string>,
138
138
  mutationField: string,
139
139
  references: array<fieldReference>,
140
140
  /** The declared *from* set — lifecycle states this command is meaningful in.
141
141
  `None` means always available; `Some([])` means never show. */
142
- allowedStates: @s.matches(stringArrayOptionSchema) option<array<string>>,
142
+ allowedStates: option<array<string>>,
143
143
  /** The declared *to* state this command's handler writes. `None` with a
144
144
  from-set present means the command does not move the row. */
145
- targetState: @s.matches(stringOptionSchema) option<string>,
145
+ targetState: option<string>,
146
146
  /** Whether the variant is exposed in the generated API (non-`@noApi`). */
147
- apiExposed: @s.matches(boolOptionSchema) option<bool>,
147
+ apiExposed: option<bool>,
148
148
  /** Access keys — any one of them — a caller needs to be *offered* this command.
149
149
  A hint derived from the server's rule, never the refusal itself. */
150
- requiredAccess: @s.matches(stringArrayOptionSchema) option<array<string>>,
150
+ requiredAccess: option<array<string>>,
151
151
  /** The `@owner` command field the server stamps with the caller's identity; a
152
152
  client omits it from a form, since whatever it collects is discarded. */
153
- ownerField: @s.matches(stringOptionSchema) option<string>,
153
+ ownerField: option<string>,
154
154
  }
155
155
 
156
156
  @schema
@@ -168,41 +168,41 @@ type queryableDef = {
168
168
  searchableFields: array<string>,
169
169
  /** Which rung produced `labelField`, so a consumer can rank it against its own
170
170
  rule: `"annotation"` | `"convention"` | `"position"` | `"fallback"`. */
171
- labelFieldSource: @s.matches(stringOptionSchema) option<string>,
171
+ labelFieldSource: option<string>,
172
172
  /** The state field holding the row's lifecycle, paired with
173
173
  `commandDef.allowedStates`. From `@lifecycle`, else an enum named `lifecycle`. */
174
- lifecycleField: @s.matches(stringOptionSchema) option<string>,
174
+ lifecycleField: option<string>,
175
175
  /** The `@owner` state field. Reads of this view are narrowed server-side to a
176
176
  non-elevated caller's own rows. */
177
- ownerField: @s.matches(stringOptionSchema) option<string>,
177
+ ownerField: option<string>,
178
178
  /** The `@retired` state field withdrawing a row from ordinary reads. From the
179
179
  annotation only — no fallback by name, since guessing hides data. */
180
- retiredField: @s.matches(stringOptionSchema) option<string>,
180
+ retiredField: option<string>,
181
181
  /** The states a row is retired *in* (state form of `@retired`); `None` is the
182
182
  boolean form, where the excluded value is always `true`. */
183
- retiredValues: @s.matches(stringArrayOptionSchema) option<array<string>>,
183
+ retiredValues: option<array<string>>,
184
184
  /** Whether the view publishes the by-ids reference door that names a retired row
185
185
  to any caller holding a pointer (`@namedWhenRetired`). Never true without
186
186
  `retiredField`. */
187
- namedWhenRetired: @s.matches(boolOptionSchema) option<bool>,
187
+ namedWhenRetired: option<bool>,
188
188
  /** `@@reventless.visibility`. `Some("Internal")` hides the component from AutoUI;
189
189
  it is still carried here for developer tooling. `None` means Public. */
190
- visibility: @s.matches(stringOptionSchema) option<string>,
190
+ visibility: option<string>,
191
191
  /** Intra-plugin grouping band, the first non-kind path segment under `src/`.
192
192
  `None` renders flat. */
193
- chapter: @s.matches(stringOptionSchema) option<string>,
193
+ chapter: option<string>,
194
194
  /** The singular counterpart of `queryField` (`Plugin_Order`), also the prefix of
195
195
  the generated input types. Not derivable without `Api_Naming.singularize`. */
196
- singleQueryField: @s.matches(stringOptionSchema) option<string>,
196
+ singleQueryField: option<string>,
197
197
  /** The state field identifying a row, as opposed to a reference to another entity.
198
198
  `None` means unresolved — no key-derived filter or sort until `@id` is declared. */
199
- idField: @s.matches(stringOptionSchema) option<string>,
199
+ idField: option<string>,
200
200
  /** Which rung produced `idField`, as `labelFieldSource` does: `"annotation"` |
201
201
  `"convention"` | `"sole"`. */
202
- idFieldSource: @s.matches(stringOptionSchema) option<string>,
202
+ idFieldSource: option<string>,
203
203
  /** Access keys — any one of them — a caller needs to be *offered* this view. A
204
204
  denied read comes back empty rather than erroring, hence the hint. */
205
- requiredAccess: @s.matches(stringArrayOptionSchema) option<array<string>>,
205
+ requiredAccess: option<array<string>>,
206
206
  }
207
207
 
208
208
  /** One emitted event of a write side. `name` is the variant name, `schema` its
@@ -230,7 +230,7 @@ type writableDef = {
230
230
  producedEventTypes: array<string>,
231
231
  consumedEventTypes: array<string>,
232
232
  linkedViews: array<string>,
233
- consistencyRead: @s.matches(stringOptionSchema) option<string>,
233
+ consistencyRead: option<string>,
234
234
  /** Emitted-event field schemas; `[]` when there are none. */
235
235
  events: array<eventDef>,
236
236
  /** Declared-error field schemas. Required, but a persisted structure predating a
@@ -238,7 +238,7 @@ type writableDef = {
238
238
  `[]` on read, and a new one must be added there too. */
239
239
  errors: array<errorDef>,
240
240
  /** Chapter grouping band — see `queryableDef.chapter`. */
241
- chapter: @s.matches(stringOptionSchema) option<string>,
241
+ chapter: option<string>,
242
242
  }
243
243
 
244
244
  @schema
@@ -248,7 +248,7 @@ type automationSliceDef = {
248
248
  producedCommandTypes: array<string>,
249
249
  targetName: string,
250
250
  /** Chapter grouping band — see `queryableDef.chapter`. */
251
- chapter: @s.matches(stringOptionSchema) option<string>,
251
+ chapter: option<string>,
252
252
  }
253
253
 
254
254
  @schema
@@ -256,11 +256,11 @@ type outboundTranslationSliceDef = {
256
256
  name: string,
257
257
  consumedEventTypes: array<string>,
258
258
  inboundCommandTypes: array<string>,
259
- targetName: @s.matches(stringOptionSchema) option<string>,
259
+ targetName: option<string>,
260
260
  // Foreign system this slice publishes to — drives the external box (Event Graph).
261
- externalSystem: @s.matches(stringOptionSchema) option<string>,
261
+ externalSystem: option<string>,
262
262
  /** Chapter grouping band — see `queryableDef.chapter`. */
263
- chapter: @s.matches(stringOptionSchema) option<string>,
263
+ chapter: option<string>,
264
264
  /** The topics this slice subscribes to, as `Spec.sourceNames` declares them —
265
265
  an Aggregate's `Spec.name` or a DCB source name. `Some([])` is the declared
266
266
  default and means this plugin's own DCB log; `None` is an older structure
@@ -268,7 +268,7 @@ type outboundTranslationSliceDef = {
268
268
  which names event types and not where they came from — two topics carrying
269
269
  an event of the same name are indistinguishable there. Optional so an older
270
270
  reader ignores it. */
271
- consumedSources: @s.matches(stringArrayOptionSchema) option<array<string>>,
271
+ consumedSources: option<array<string>>,
272
272
  }
273
273
 
274
274
  @schema
@@ -277,9 +277,9 @@ type inboundTranslationSliceDef = {
277
277
  commandTypes: array<string>,
278
278
  targetName: string,
279
279
  // Foreign system this slice receives from — drives the external box (Event Graph).
280
- externalSystem: @s.matches(stringOptionSchema) option<string>,
280
+ externalSystem: option<string>,
281
281
  /** Chapter grouping band — see `queryableDef.chapter`. */
282
- chapter: @s.matches(stringOptionSchema) option<string>,
282
+ chapter: option<string>,
283
283
  }
284
284
 
285
285
  /** A published event of an extension point and the internal events producing it.
@@ -291,8 +291,6 @@ type publishedEventDef = {
291
291
  fromEventTypes: array<string>,
292
292
  }
293
293
 
294
- let publishedEventDefArrayOptionSchema = S.array(publishedEventDefSchema)->S.nullAsOption
295
-
296
294
  /** The command direction's producer half: a command an extension point takes and
297
295
  the delegate commands it routes to. `name` is EP-qualified, `toCommandTypes`
298
296
  plugin-qualified. */
@@ -302,8 +300,6 @@ type acceptedCommandDef = {
302
300
  toCommandTypes: array<string>,
303
301
  }
304
302
 
305
- let acceptedCommandDefArrayOptionSchema = S.array(acceptedCommandDefSchema)->S.nullAsOption
306
-
307
303
  /** The subscriber's half: a published event and the commands it routes to. A
308
304
  delegate command is plugin-qualified, one sent back to the EP is EP-qualified. */
309
305
  @schema
@@ -312,8 +308,6 @@ type handledEventDef = {
312
308
  toCommandTypes: array<string>,
313
309
  }
314
310
 
315
- let handledEventDefArrayOptionSchema = S.array(handledEventDefSchema)->S.nullAsOption
316
-
317
311
  /** The command direction's subscriber half: a command sent back to the port and
318
312
  the internal events producing it. `name` is EP-qualified, `fromEventTypes`
319
313
  plugin-qualified. */
@@ -323,21 +317,19 @@ type issuedCommandDef = {
323
317
  fromEventTypes: array<string>,
324
318
  }
325
319
 
326
- let issuedCommandDefArrayOptionSchema = S.array(issuedCommandDefSchema)->S.nullAsOption
327
-
328
320
  @schema
329
321
  type extensionDef = {
330
322
  name: string,
331
323
  delegateNames: array<string>,
332
324
  eventTypes: array<string>,
333
325
  commandTypes: array<string>,
334
- /** Which published event routes to which commands. js_nullable like
326
+ /** Which published event routes to which commands. Optional like
335
327
  `extensionPointDef.commandTypes`; re-emit definitions persisted before it. */
336
- handledEvents: @s.matches(handledEventDefArrayOptionSchema) option<array<handledEventDef>>,
328
+ handledEvents: option<array<handledEventDef>>,
337
329
  /** Which internal event sends which command back to the port. `None` means a
338
330
  definition persisted before the field, NOT an extension that issues nothing —
339
331
  a reader joining the two halves must keep them apart. */
340
- issuedCommands: @s.matches(issuedCommandDefArrayOptionSchema) option<array<issuedCommandDef>>,
332
+ issuedCommands: option<array<issuedCommandDef>>,
341
333
  }
342
334
 
343
335
  /**
@@ -346,28 +338,21 @@ An extension point owned by a plugin, from the producer side.
346
338
  `sourceEventTypes` are the `Delegate`'s events feeding the published protocol,
347
339
  plugin-qualified to match `writableDef.producedEventTypes`. `commandTypes` is the
348
340
  EP's inbound protocol — None (read as []) for a `command = unit` EP, which routes
349
- nothing. js_nullable is the only JSON-safe optional here (this def is nested in the
350
- lifecycle Message union); definitions persisted before a field must be re-emitted.
341
+ nothing. Definitions persisted before a field was added must be re-emitted.
351
342
  */
352
343
  @schema
353
344
  type extensionPointDef = {
354
345
  name: string,
355
346
  delegateNames: array<string>,
356
347
  sourceEventTypes: array<string>,
357
- commandTypes: @s.matches(stringArrayOptionSchema) option<array<string>>,
348
+ commandTypes: option<array<string>>,
358
349
  /** Which internal event becomes which published event. */
359
- publishedEvents: @s.matches(publishedEventDefArrayOptionSchema)
360
- option<array<publishedEventDef>>,
350
+ publishedEvents: option<array<publishedEventDef>>,
361
351
  /** Which arriving command becomes which delegate command. `None` means a
362
352
  definition persisted before the field, NOT a port that accepts nothing. */
363
- acceptedCommands: @s.matches(acceptedCommandDefArrayOptionSchema)
364
- option<array<acceptedCommandDef>>,
353
+ acceptedCommands: option<array<acceptedCommandDef>>,
365
354
  }
366
355
 
367
- // js_nullable creates `array | null` (not `| undefined`), which passes sury's
368
- // jsonableValidation inside the pluginStructure union variant payload.
369
- let extensionPointDefArrayOptionSchema = S.array(extensionPointDefSchema)->S.nullAsOption
370
-
371
356
  /**
372
357
  One field's store requirement, with its provenance.
373
358
 
@@ -382,12 +367,9 @@ type requiredStoreDeclaration = {
382
367
  store: string,
383
368
  component: string,
384
369
  field: string,
385
- annotation: @s.matches(stringOptionSchema) option<string>,
370
+ annotation: option<string>,
386
371
  }
387
372
 
388
- let requiredStoreDeclarationArrayOptionSchema =
389
- S.array(requiredStoreDeclarationSchema)->S.nullAsOption
390
-
391
373
  /**
392
374
  One component's capability requirement, with its provenance.
393
375
 
@@ -400,9 +382,6 @@ expressible as an annotation on one, which is why the need is declared.
400
382
  @schema
401
383
  type requiredCapabilityDeclaration = {capability: string, component: string}
402
384
 
403
- let requiredCapabilityDeclarationArrayOptionSchema =
404
- S.array(requiredCapabilityDeclarationSchema)->S.nullAsOption
405
-
406
385
  /**
407
386
  One graft's provenance: which trait, at which version, on which component.
408
387
 
@@ -427,14 +406,13 @@ type traitDeclaration = {
427
406
  component: string,
428
407
  }
429
408
 
430
- let traitDeclarationArrayOptionSchema = S.array(traitDeclarationSchema)->S.nullAsOption
431
-
432
409
  /**
433
410
  Adding a field here? It must be a shape a stale event can be healed into — the
434
411
  lifecycle aggregate replays its own log before every decision, so one event that
435
- fails to decode freezes that plugin's registration. `Message.parseJsonTolerant`
436
- heals `T | null`, arrays, enums and nested objects; a bare scalar is fabricated
437
- and warned about. Prefer `js_nullable`. Regression suite:
412
+ fails to decode freezes that plugin's registration. Make it `option`, which
413
+ `Message.parseJsonTolerant` heals to `None` whatever the inner type is. A required
414
+ array heals to `[]` and a required enum to its first variant; a required *scalar*
415
+ is fabricated and warned about, so it is the one shape to avoid. Regression suite:
438
416
  `PluginLifecycleCorpusTest` — if it goes red, re-shape the field, not the fixtures.
439
417
  */
440
418
  @schema
@@ -449,27 +427,23 @@ type pluginStructure = {
449
427
  extensions: array<extensionDef>,
450
428
  // Extension points owned by this plugin (producer side). Optional so older
451
429
  // definitions still decode (absent → None, read as []).
452
- extensionPoints: @s.matches(extensionPointDefArrayOptionSchema)
453
- option<array<extensionPointDef>>,
430
+ extensionPoints: option<array<extensionPointDef>>,
454
431
  /** The object stores this plugin's fields declare they need, deduplicated and
455
432
  qualified as `{plugin}.{store}` even for the same-plugin case. */
456
- requiredStores: @s.matches(stringArrayOptionSchema) option<array<string>>,
433
+ requiredStores: option<array<string>>,
457
434
  /** Provenance for `requiredStores`: one entry per declaring `(component, field)`.
458
435
  `requiredStores` is derived from it, so the two cannot disagree. */
459
- requiredStoreDeclarations: @s.matches(requiredStoreDeclarationArrayOptionSchema)
460
- option<array<requiredStoreDeclaration>>,
436
+ requiredStoreDeclarations: option<array<requiredStoreDeclaration>>,
461
437
  /** The platform capabilities this plugin's components declare they need, one
462
438
  entry per declaring component. Object stores are not here — a store need is
463
439
  a field's, and travels as `requiredStores`. Absent → None, read as []. */
464
- requiredCapabilities: @s.matches(requiredCapabilityDeclarationArrayOptionSchema)
465
- option<array<requiredCapabilityDeclaration>>,
440
+ requiredCapabilities: option<array<requiredCapabilityDeclaration>>,
466
441
  /** The domain traits grafted into this plugin, one entry per declaring
467
442
  component. Absent → None, read as []. The only signal a graft leaves that
468
443
  survives into a deployed plugin — every other one (the dependency, the
469
444
  variant spread, the rules alias, the conformance binding) is source-side.
470
445
  A claim about origin, never about behaviour: see `Trait`. */
471
- traitDeclarations: @s.matches(traitDeclarationArrayOptionSchema)
472
- option<array<traitDeclaration>>,
446
+ traitDeclarations: option<array<traitDeclaration>>,
473
447
  }
474
448
 
475
449
  let pluginStructureOffloadSchema = Offload.optionSchema(~store="pluginStructures", pluginStructureSchema)
@@ -496,15 +470,15 @@ type pluginDefinition = {
496
470
  apiSchemaFragment: @s.matches(apiSchemaFragmentOffloadSchema) option<Offload.payload<apiSchemaFragment>>,
497
471
  // Schema routing in split-API mode: None/"Domain" → DomainApi, Some("Platform") →
498
472
  // PlatformApi (and excluded from the DomainApi runtime schema).
499
- apiTarget: @s.matches(stringOptionSchema) option<string>,
473
+ apiTarget: option<string>,
500
474
  // Component graph metadata, offloadable like apiSchemaFragment. Absent for older
501
475
  // protocol versions.
502
476
  structure: @s.matches(pluginStructureOffloadSchema) option<Offload.payload<pluginStructure>>,
503
477
  // EventTopic ARN of a bundled DcbEventLog, so the admin can subscribe peer
504
478
  // EventCollectors to it. None for plugins without one.
505
- dcbEventLog: @s.matches(dcbEventLogOptionSchema) option<dcbEventLogDefinition>,
479
+ dcbEventLog: option<dcbEventLogDefinition>,
506
480
  // Mandatory; `Domain` is resolved as the default in Plugin_Builder. Payload-less
507
- // variant → a bare JSON string, so JSON-safe without js_nullable.
481
+ // variant → a bare JSON string.
508
482
  kind: pluginKind,
509
483
  }
510
484
 
@@ -49,26 +49,18 @@ let apiTargetSchema = Sury.union([
49
49
 
50
50
  let apiSchemaFragmentOffloadSchema = Offload$Reventless.optionSchema(undefined, "pluginApiFragments", undefined, apiSchemaFragmentSchema);
51
51
 
52
- let dcbEventLogOptionSchema = Sury.$nullAsOption(dcbEventLogDefinitionSchema);
53
-
54
- let stringOptionSchema = Sury.$nullAsOption(Sury.string);
55
-
56
- let stringArrayOptionSchema = Sury.$nullAsOption(Sury.array(Sury.string));
57
-
58
- let boolOptionSchema = Sury.$nullAsOption(Sury.bool);
59
-
60
52
  let panelManifestEntrySchema = Sury.$schema(s => ({
61
53
  fragmentId: s.m(Sury.string),
62
54
  title: s.m(Sury.string),
63
55
  description: s.m(Sury.string),
64
56
  positions: s.m(Sury.array(Sury.string)),
65
- requiredAccess: s.m(stringOptionSchema)
57
+ requiredAccess: s.m(Sury.$option(Sury.string))
66
58
  }));
67
59
 
68
60
  let menuEntrySchema = Sury.$schema(s => ({
69
61
  label: s.m(Sury.string),
70
- icon: s.m(stringOptionSchema),
71
- group: s.m(stringOptionSchema),
62
+ icon: s.m(Sury.$option(Sury.string)),
63
+ group: s.m(Sury.$option(Sury.string)),
72
64
  sortOrder: s.m(Sury.int)
73
65
  }));
74
66
 
@@ -76,7 +68,7 @@ let pageManifestEntrySchema = Sury.$schema(s => ({
76
68
  fragmentId: s.m(Sury.string),
77
69
  title: s.m(Sury.string),
78
70
  menuEntry: s.m(menuEntrySchema),
79
- requiredAccess: s.m(stringOptionSchema)
71
+ requiredAccess: s.m(Sury.$option(Sury.string))
80
72
  }));
81
73
 
82
74
  let uiFragmentManifestSchema = Sury.$schema(s => ({
@@ -85,7 +77,7 @@ let uiFragmentManifestSchema = Sury.$schema(s => ({
85
77
  pages: s.m(Sury.array(pageManifestEntrySchema))
86
78
  }));
87
79
 
88
- let uiFragmentManifestOptionSchema = Sury.$nullAsOption(uiFragmentManifestSchema);
80
+ let uiFragmentManifestOptionSchema = Sury.$option(uiFragmentManifestSchema);
89
81
 
90
82
  let commandLevelSchema = Sury.union([
91
83
  Sury.literal("Collection"),
@@ -95,21 +87,21 @@ let commandLevelSchema = Sury.union([
95
87
  let fieldReferenceSchema = Sury.$schema(s => ({
96
88
  fieldName: s.m(Sury.string),
97
89
  entity: s.m(Sury.string),
98
- plugin: s.m(stringOptionSchema)
90
+ plugin: s.m(Sury.$option(Sury.string))
99
91
  }));
100
92
 
101
93
  let commandDefSchema = Sury.$schema(s => ({
102
94
  name: s.m(Sury.string),
103
95
  schema: s.m(Sury.string),
104
96
  level: s.m(commandLevelSchema),
105
- aggregateIdField: s.m(stringOptionSchema),
97
+ aggregateIdField: s.m(Sury.$option(Sury.string)),
106
98
  mutationField: s.m(Sury.string),
107
99
  references: s.m(Sury.array(fieldReferenceSchema)),
108
- allowedStates: s.m(stringArrayOptionSchema),
109
- targetState: s.m(stringOptionSchema),
110
- apiExposed: s.m(boolOptionSchema),
111
- requiredAccess: s.m(stringArrayOptionSchema),
112
- ownerField: s.m(stringOptionSchema)
100
+ allowedStates: s.m(Sury.$option(Sury.array(Sury.string))),
101
+ targetState: s.m(Sury.$option(Sury.string)),
102
+ apiExposed: s.m(Sury.$option(Sury.bool)),
103
+ requiredAccess: s.m(Sury.$option(Sury.array(Sury.string))),
104
+ ownerField: s.m(Sury.$option(Sury.string))
113
105
  }));
114
106
 
115
107
  let queryableDefSchema = Sury.$schema(s => ({
@@ -120,18 +112,18 @@ let queryableDefSchema = Sury.$schema(s => ({
120
112
  linkedWriteSide: s.m(Sury.array(Sury.string)),
121
113
  labelField: s.m(Sury.string),
122
114
  searchableFields: s.m(Sury.array(Sury.string)),
123
- labelFieldSource: s.m(stringOptionSchema),
124
- lifecycleField: s.m(stringOptionSchema),
125
- ownerField: s.m(stringOptionSchema),
126
- retiredField: s.m(stringOptionSchema),
127
- retiredValues: s.m(stringArrayOptionSchema),
128
- namedWhenRetired: s.m(boolOptionSchema),
129
- visibility: s.m(stringOptionSchema),
130
- chapter: s.m(stringOptionSchema),
131
- singleQueryField: s.m(stringOptionSchema),
132
- idField: s.m(stringOptionSchema),
133
- idFieldSource: s.m(stringOptionSchema),
134
- requiredAccess: s.m(stringArrayOptionSchema)
115
+ labelFieldSource: s.m(Sury.$option(Sury.string)),
116
+ lifecycleField: s.m(Sury.$option(Sury.string)),
117
+ ownerField: s.m(Sury.$option(Sury.string)),
118
+ retiredField: s.m(Sury.$option(Sury.string)),
119
+ retiredValues: s.m(Sury.$option(Sury.array(Sury.string))),
120
+ namedWhenRetired: s.m(Sury.$option(Sury.bool)),
121
+ visibility: s.m(Sury.$option(Sury.string)),
122
+ chapter: s.m(Sury.$option(Sury.string)),
123
+ singleQueryField: s.m(Sury.$option(Sury.string)),
124
+ idField: s.m(Sury.$option(Sury.string)),
125
+ idFieldSource: s.m(Sury.$option(Sury.string)),
126
+ requiredAccess: s.m(Sury.$option(Sury.array(Sury.string)))
135
127
  }));
136
128
 
137
129
  let eventDefSchema = Sury.$schema(s => ({
@@ -152,10 +144,10 @@ let writableDefSchema = Sury.$schema(s => ({
152
144
  producedEventTypes: s.m(Sury.array(Sury.string)),
153
145
  consumedEventTypes: s.m(Sury.array(Sury.string)),
154
146
  linkedViews: s.m(Sury.array(Sury.string)),
155
- consistencyRead: s.m(stringOptionSchema),
147
+ consistencyRead: s.m(Sury.$option(Sury.string)),
156
148
  events: s.m(Sury.array(eventDefSchema)),
157
149
  errors: s.m(Sury.array(errorDefSchema)),
158
- chapter: s.m(stringOptionSchema)
150
+ chapter: s.m(Sury.$option(Sury.string))
159
151
  }));
160
152
 
161
153
  let automationSliceDefSchema = Sury.$schema(s => ({
@@ -163,25 +155,25 @@ let automationSliceDefSchema = Sury.$schema(s => ({
163
155
  consumedEventTypes: s.m(Sury.array(Sury.string)),
164
156
  producedCommandTypes: s.m(Sury.array(Sury.string)),
165
157
  targetName: s.m(Sury.string),
166
- chapter: s.m(stringOptionSchema)
158
+ chapter: s.m(Sury.$option(Sury.string))
167
159
  }));
168
160
 
169
161
  let outboundTranslationSliceDefSchema = Sury.$schema(s => ({
170
162
  name: s.m(Sury.string),
171
163
  consumedEventTypes: s.m(Sury.array(Sury.string)),
172
164
  inboundCommandTypes: s.m(Sury.array(Sury.string)),
173
- targetName: s.m(stringOptionSchema),
174
- externalSystem: s.m(stringOptionSchema),
175
- chapter: s.m(stringOptionSchema),
176
- consumedSources: s.m(stringArrayOptionSchema)
165
+ targetName: s.m(Sury.$option(Sury.string)),
166
+ externalSystem: s.m(Sury.$option(Sury.string)),
167
+ chapter: s.m(Sury.$option(Sury.string)),
168
+ consumedSources: s.m(Sury.$option(Sury.array(Sury.string)))
177
169
  }));
178
170
 
179
171
  let inboundTranslationSliceDefSchema = Sury.$schema(s => ({
180
172
  name: s.m(Sury.string),
181
173
  commandTypes: s.m(Sury.array(Sury.string)),
182
174
  targetName: s.m(Sury.string),
183
- externalSystem: s.m(stringOptionSchema),
184
- chapter: s.m(stringOptionSchema)
175
+ externalSystem: s.m(Sury.$option(Sury.string)),
176
+ chapter: s.m(Sury.$option(Sury.string))
185
177
  }));
186
178
 
187
179
  let publishedEventDefSchema = Sury.$schema(s => ({
@@ -189,65 +181,51 @@ let publishedEventDefSchema = Sury.$schema(s => ({
189
181
  fromEventTypes: s.m(Sury.array(Sury.string))
190
182
  }));
191
183
 
192
- let publishedEventDefArrayOptionSchema = Sury.$nullAsOption(Sury.array(publishedEventDefSchema));
193
-
194
184
  let acceptedCommandDefSchema = Sury.$schema(s => ({
195
185
  name: s.m(Sury.string),
196
186
  toCommandTypes: s.m(Sury.array(Sury.string))
197
187
  }));
198
188
 
199
- let acceptedCommandDefArrayOptionSchema = Sury.$nullAsOption(Sury.array(acceptedCommandDefSchema));
200
-
201
189
  let handledEventDefSchema = Sury.$schema(s => ({
202
190
  name: s.m(Sury.string),
203
191
  toCommandTypes: s.m(Sury.array(Sury.string))
204
192
  }));
205
193
 
206
- let handledEventDefArrayOptionSchema = Sury.$nullAsOption(Sury.array(handledEventDefSchema));
207
-
208
194
  let issuedCommandDefSchema = Sury.$schema(s => ({
209
195
  name: s.m(Sury.string),
210
196
  fromEventTypes: s.m(Sury.array(Sury.string))
211
197
  }));
212
198
 
213
- let issuedCommandDefArrayOptionSchema = Sury.$nullAsOption(Sury.array(issuedCommandDefSchema));
214
-
215
199
  let extensionDefSchema = Sury.$schema(s => ({
216
200
  name: s.m(Sury.string),
217
201
  delegateNames: s.m(Sury.array(Sury.string)),
218
202
  eventTypes: s.m(Sury.array(Sury.string)),
219
203
  commandTypes: s.m(Sury.array(Sury.string)),
220
- handledEvents: s.m(handledEventDefArrayOptionSchema),
221
- issuedCommands: s.m(issuedCommandDefArrayOptionSchema)
204
+ handledEvents: s.m(Sury.$option(Sury.array(handledEventDefSchema))),
205
+ issuedCommands: s.m(Sury.$option(Sury.array(issuedCommandDefSchema)))
222
206
  }));
223
207
 
224
208
  let extensionPointDefSchema = Sury.$schema(s => ({
225
209
  name: s.m(Sury.string),
226
210
  delegateNames: s.m(Sury.array(Sury.string)),
227
211
  sourceEventTypes: s.m(Sury.array(Sury.string)),
228
- commandTypes: s.m(stringArrayOptionSchema),
229
- publishedEvents: s.m(publishedEventDefArrayOptionSchema),
230
- acceptedCommands: s.m(acceptedCommandDefArrayOptionSchema)
212
+ commandTypes: s.m(Sury.$option(Sury.array(Sury.string))),
213
+ publishedEvents: s.m(Sury.$option(Sury.array(publishedEventDefSchema))),
214
+ acceptedCommands: s.m(Sury.$option(Sury.array(acceptedCommandDefSchema)))
231
215
  }));
232
216
 
233
- let extensionPointDefArrayOptionSchema = Sury.$nullAsOption(Sury.array(extensionPointDefSchema));
234
-
235
217
  let requiredStoreDeclarationSchema = Sury.$schema(s => ({
236
218
  store: s.m(Sury.string),
237
219
  component: s.m(Sury.string),
238
220
  field: s.m(Sury.string),
239
- annotation: s.m(stringOptionSchema)
221
+ annotation: s.m(Sury.$option(Sury.string))
240
222
  }));
241
223
 
242
- let requiredStoreDeclarationArrayOptionSchema = Sury.$nullAsOption(Sury.array(requiredStoreDeclarationSchema));
243
-
244
224
  let requiredCapabilityDeclarationSchema = Sury.$schema(s => ({
245
225
  capability: s.m(Sury.string),
246
226
  component: s.m(Sury.string)
247
227
  }));
248
228
 
249
- let requiredCapabilityDeclarationArrayOptionSchema = Sury.$nullAsOption(Sury.array(requiredCapabilityDeclarationSchema));
250
-
251
229
  let traitDeclarationSchema = Sury.$schema(s => ({
252
230
  trait: s.m(Sury.string),
253
231
  version: s.m(Sury.string),
@@ -255,8 +233,6 @@ let traitDeclarationSchema = Sury.$schema(s => ({
255
233
  component: s.m(Sury.string)
256
234
  }));
257
235
 
258
- let traitDeclarationArrayOptionSchema = Sury.$nullAsOption(Sury.array(traitDeclarationSchema));
259
-
260
236
  let pluginStructureSchema = Sury.$schema(s => ({
261
237
  readModels: s.m(Sury.array(queryableDefSchema)),
262
238
  stateViewSlices: s.m(Sury.array(queryableDefSchema)),
@@ -266,11 +242,11 @@ let pluginStructureSchema = Sury.$schema(s => ({
266
242
  outboundTranslationSlices: s.m(Sury.array(outboundTranslationSliceDefSchema)),
267
243
  inboundTranslationSlices: s.m(Sury.array(inboundTranslationSliceDefSchema)),
268
244
  extensions: s.m(Sury.array(extensionDefSchema)),
269
- extensionPoints: s.m(extensionPointDefArrayOptionSchema),
270
- requiredStores: s.m(stringArrayOptionSchema),
271
- requiredStoreDeclarations: s.m(requiredStoreDeclarationArrayOptionSchema),
272
- requiredCapabilities: s.m(requiredCapabilityDeclarationArrayOptionSchema),
273
- traitDeclarations: s.m(traitDeclarationArrayOptionSchema)
245
+ extensionPoints: s.m(Sury.$option(Sury.array(extensionPointDefSchema))),
246
+ requiredStores: s.m(Sury.$option(Sury.array(Sury.string))),
247
+ requiredStoreDeclarations: s.m(Sury.$option(Sury.array(requiredStoreDeclarationSchema))),
248
+ requiredCapabilities: s.m(Sury.$option(Sury.array(requiredCapabilityDeclarationSchema))),
249
+ traitDeclarations: s.m(Sury.$option(Sury.array(traitDeclarationSchema)))
274
250
  }));
275
251
 
276
252
  let pluginStructureOffloadSchema = Offload$Reventless.optionSchema(undefined, "pluginStructures", undefined, pluginStructureSchema);
@@ -284,9 +260,9 @@ let pluginDefinitionSchema = Sury.$schema(s => ({
284
260
  eventCollector: s.m(Sury.string),
285
261
  extensionProtocols: s.m(Sury.array(extensionProtocolSchema)),
286
262
  apiSchemaFragment: s.m(apiSchemaFragmentOffloadSchema),
287
- apiTarget: s.m(stringOptionSchema),
263
+ apiTarget: s.m(Sury.$option(Sury.string)),
288
264
  structure: s.m(pluginStructureOffloadSchema),
289
- dcbEventLog: s.m(dcbEventLogOptionSchema),
265
+ dcbEventLog: s.m(Sury.$option(dcbEventLogDefinitionSchema)),
290
266
  kind: s.m(pluginKindSchema)
291
267
  }));
292
268
 
@@ -301,10 +277,6 @@ export {
301
277
  apiSchemaFragmentSchema,
302
278
  apiTargetSchema,
303
279
  apiSchemaFragmentOffloadSchema,
304
- dcbEventLogOptionSchema,
305
- stringOptionSchema,
306
- stringArrayOptionSchema,
307
- boolOptionSchema,
308
280
  panelManifestEntrySchema,
309
281
  menuEntrySchema,
310
282
  pageManifestEntrySchema,
@@ -321,22 +293,14 @@ export {
321
293
  outboundTranslationSliceDefSchema,
322
294
  inboundTranslationSliceDefSchema,
323
295
  publishedEventDefSchema,
324
- publishedEventDefArrayOptionSchema,
325
296
  acceptedCommandDefSchema,
326
- acceptedCommandDefArrayOptionSchema,
327
297
  handledEventDefSchema,
328
- handledEventDefArrayOptionSchema,
329
298
  issuedCommandDefSchema,
330
- issuedCommandDefArrayOptionSchema,
331
299
  extensionDefSchema,
332
300
  extensionPointDefSchema,
333
- extensionPointDefArrayOptionSchema,
334
301
  requiredStoreDeclarationSchema,
335
- requiredStoreDeclarationArrayOptionSchema,
336
302
  requiredCapabilityDeclarationSchema,
337
- requiredCapabilityDeclarationArrayOptionSchema,
338
303
  traitDeclarationSchema,
339
- traitDeclarationArrayOptionSchema,
340
304
  pluginStructureSchema,
341
305
  pluginStructureOffloadSchema,
342
306
  pluginDefinitionSchema,
@@ -55,7 +55,7 @@ let parseFlags = (args: array<string>): dict<JSON.t> => {
55
55
  switch args->Array.get(i) {
56
56
  | None => ()
57
57
  | Some(arg) if arg->String.startsWith("--") =>
58
- let key = arg->String.sliceToEnd(~start=2)
58
+ let key = arg->String.slice(~start=2, ~end=arg->String.length)
59
59
  switch args->Array.get(i + 1) {
60
60
  | Some(value) if !(value->String.startsWith("--")) =>
61
61
  out->Dict.set(key, JSON.Encode.string(value))
@@ -107,14 +107,14 @@ let arrayFieldNames = (schema: S.t<unknown>): array<string> =>
107
107
  // ── Entry point ──────────────────────────────────────────────────────────────
108
108
 
109
109
  let main = async () => {
110
- let argv = NodeProcess.argv->Array.sliceToEnd(~start=2)
110
+ let argv = NodeProcess.argv->Array.slice(~start=2, ~end=NodeProcess.argv->Array.length)
111
111
  switch argv->Array.get(0) {
112
112
  | None | Some("") | Some("--help") | Some("-h") => {
113
113
  Console.log(usage)
114
114
  NodeProcess.exit(argv->Array.length == 0 ? 1 : 0)
115
115
  }
116
116
  | Some(traitPackage) => {
117
- let flags = parseFlags(argv->Array.sliceToEnd(~start=1))
117
+ let flags = parseFlags(argv->Array.slice(~start=1, ~end=argv->Array.length))
118
118
  let stringFlag = key => flags->Dict.get(key)->Option.flatMap(JSON.Decode.string)
119
119
  let into = stringFlag("into")
120
120
  let tests = stringFlag("tests")
@@ -136,7 +136,8 @@ let main = async () => {
136
136
  ->String.replace("trait-", "")
137
137
  ->String.split("-")
138
138
  ->Array.map(part =>
139
- part->String.charAt(0)->String.toUpperCase ++ part->String.sliceToEnd(~start=1)
139
+ part->String.charAt(0)->String.toUpperCase ++
140
+ part->String.slice(~start=1, ~end=part->String.length)
140
141
  )
141
142
  ->Array.join("") ++ "_Scaffold"
142
143
  let specifier = `${traitPackage}/src/${scaffoldModule}.res.mjs`
@@ -36,7 +36,7 @@ function parseFlags(args) {
36
36
  return;
37
37
  }
38
38
  if (arg.startsWith("--")) {
39
- let key = arg.slice(2);
39
+ let key = arg.slice(2, arg.length);
40
40
  let value = args[i + 1 | 0];
41
41
  if (value !== undefined && !value.startsWith("--")) {
42
42
  out[key] = value;
@@ -83,7 +83,7 @@ function arrayFieldNames(schema) {
83
83
  }
84
84
 
85
85
  async function main() {
86
- let argv = process.argv.slice(2);
86
+ let argv = process.argv.slice(2, process.argv.length);
87
87
  let traitPackage = argv[0];
88
88
  if (traitPackage !== undefined) {
89
89
  switch (traitPackage) {
@@ -92,7 +92,7 @@ async function main() {
92
92
  case "-h" :
93
93
  break;
94
94
  default:
95
- let flags = parseFlags(argv.slice(1));
95
+ let flags = parseFlags(argv.slice(1, argv.length));
96
96
  let stringFlag = key => Stdlib_Option.flatMap(flags[key], Stdlib_JSON.Decode.string);
97
97
  let into = stringFlag("into");
98
98
  let tests = stringFlag("tests");
@@ -103,7 +103,7 @@ async function main() {
103
103
  if (tests === undefined) {
104
104
  return fail("--into and --tests are both required.\n\n" + usage);
105
105
  }
106
- let scaffoldModule = Stdlib_Option.getOr(Stdlib_Array.last(traitPackage.split("/")), "").replace("trait-", "").split("-").map(part => part.charAt(0).toUpperCase() + part.slice(1)).join("") + "_Scaffold";
106
+ let scaffoldModule = Stdlib_Option.getOr(Stdlib_Array.last(traitPackage.split("/")), "").replace("trait-", "").split("-").map(part => part.charAt(0).toUpperCase() + part.slice(1, part.length)).join("") + "_Scaffold";
107
107
  let specifier = traitPackage + `/src/` + scaffoldModule + `.res.mjs`;
108
108
  let modulePath;
109
109
  try {
@@ -141,22 +141,23 @@ type commandJson = {
141
141
 
142
142
  // ── Schema-migration-on-read ──────────────────────────────────────────────────
143
143
  // Nested `@schema` types (notably `pluginDefinition`/`pluginStructure`) gain fields
144
- // over time — `kind`, `chapter`, `events`, `extensionPoints`, `apiExposed`, … Because
145
- // those types are JSON-encoded inside union-variant payloads, every optional field must
146
- // use the `js_nullable` (`T | null`) encoding: it is the only JSON-safe optional form,
147
- // since `S.option`/`nullableAsOption` carry `undefined`, which fails sury's
148
- // `jsonableValidation` inside a union variant. That encoding is *present-required on
149
- // decode*, so ONE message persisted before a field was added SuryError-bricks decode. For
150
- // an aggregate that rehydrates from its own event log (the Plugin lifecycle aggregate),
151
- // that single event then freezes EVERY later heartbeat/redetect/connect on that instance
152
- // — a silent lifecycle freeze with no error surfaced near the operator.
144
+ // over time — `kind`, `chapter`, `events`, `extensionPoints`, `apiExposed`, … A field
145
+ // that is not optional is *present-required on decode*, so ONE message persisted before
146
+ // it was added SuryError-bricks decode. For an aggregate that rehydrates from its own
147
+ // event log (the Plugin lifecycle aggregate), that single event then freezes EVERY later
148
+ // heartbeat/redetect/connect on that instance — a silent lifecycle freeze with no error
149
+ // surfaced near the operator.
153
150
  //
154
151
  // We heal on read. Strict decode stays the fast path (unchanged for every current
155
152
  // message); only when it throws do we schema-guide the raw JSON and retry once. The fill
156
153
  // walks the target sury schema and inserts, for any absent field, the value that field's
157
- // schema expects: `null` for a `T | null` union (→ `None`), `[]` for a missing array, the
154
+ // schema expects: `null` for a `T | null` union (→ `None`), nothing at all for an
155
+ // `option` (a union admitting `undefined` — also `None`), `[]` for a missing array, the
158
156
  // first variant of a mandatory enum (`kind` → `Domain`), a filled `{}` for a missing
159
- // nested object, and a zero value for a missing scalar. It descends only into values
157
+ // nested object, and a zero value for a missing scalar. The `undefined` arm mirrors the
158
+ // `null` one and must precede the enum/object guesses below it: an absent `option<enum>`
159
+ // would otherwise heal to that enum's first variant and an absent `option<record>` to a
160
+ // filled `{}`, turning `None` into a `Some` of an invented value. It descends only into values
160
161
  // actually present, matches tagged-union members by their `TAG` const, is purely additive
161
162
  // (clones via a JSON round-trip; never re-encodes through the schema), is idempotent on
162
163
  // valid data, and falls back to the ORIGINAL error when the fill doesn't resolve the
@@ -229,6 +230,7 @@ let fillMissingDefaults: (S.t<'a>, JSON.t, array<string>) => JSON.t = %raw(`func
229
230
  var has=schema.has||{};
230
231
  if(value===undefined){
231
232
  if(has.null) return null;
233
+ if(has.undefined) return undefined;
232
234
  var c=firstConst(schema.anyOf); if(c!==undefined) return c;
233
235
  var obj=(schema.anyOf||[]).find(function(s){return s.type==="object";}); if(obj) return fill(obj,{},path);
234
236
  return undefined;
@@ -273,7 +275,7 @@ let parseJsonTolerant = (json, schema) =>
273
275
  ->Int.toString} missing scalar field(s): ${scalarFills->Array.join(
274
276
  ", ",
275
277
  )}. A required scalar was added to a persisted type after this message was ` ++
276
- `written; the value above is fabricated, not recovered. Prefer a js_nullable (T | null) field.`,
278
+ `written; the value above is fabricated, not recovered. Prefer an optional field.`,
277
279
  )
278
280
  }
279
281
  value
@@ -87,6 +87,7 @@ let fillMissingDefaults = (function(schema, json, scalarFills){
87
87
  var has=schema.has||{};
88
88
  if(value===undefined){
89
89
  if(has.null) return null;
90
+ if(has.undefined) return undefined;
90
91
  var c=firstConst(schema.anyOf); if(c!==undefined) return c;
91
92
  var obj=(schema.anyOf||[]).find(function(s){return s.type==="object";}); if(obj) return fill(obj,{},path);
92
93
  return undefined;
@@ -126,7 +127,7 @@ function parseJsonTolerant(json, schema) {
126
127
  throw firstErr;
127
128
  }
128
129
  if (scalarFills.length !== 0) {
129
- console.warn(`[reventless] decoded a stored message by inventing ` + scalarFills.length.toString() + ` missing scalar field(s): ` + scalarFills.join(", ") + `. A required scalar was added to a persisted type after this message was written; the value above is fabricated, not recovered. Prefer a js_nullable (T | null) field.`);
130
+ console.warn(`[reventless] decoded a stored message by inventing ` + scalarFills.length.toString() + ` missing scalar field(s): ` + scalarFills.join(", ") + `. A required scalar was added to a persisted type after this message was written; the value above is fabricated, not recovered. Prefer an optional field.`);
130
131
  }
131
132
  return value;
132
133
  }