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

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.
@@ -93,4 +93,150 @@ describe("Message should", () => {
93
93
 
94
94
  expect(eventId)->toBe("testId")
95
95
  })
96
+
97
+ // Schema-migration-on-read: a VersionConnected event persisted before `kind` (and
98
+ // before later pluginStructure fields like `events`/`chapter`) existed must still
99
+ // decode — otherwise the one stale event bricks the whole lifecycle aggregate. See
100
+ // docs/plans/platform-infrastructure-in-plugin-list.md (durable fix option 2).
101
+ testSync("tolerantly decode a VersionConnected persisted before kind/structure fields existed", () => {
102
+ open PluginSpec
103
+ let def: Reventless.Plugin.pluginDefinition = {
104
+ id: "id",
105
+ name: "Ordering",
106
+ version: "1.0.0",
107
+ extensionPoints: [],
108
+ extensions: [],
109
+ eventCollector: "arn",
110
+ extensionProtocols: [],
111
+ apiSchemaFragment: None,
112
+ apiTarget: None,
113
+ uiFragments: None,
114
+ structure: Some({
115
+ readModels: [],
116
+ stateViewSlices: [],
117
+ stateChangeSlices: [],
118
+ aggregates: [
119
+ {
120
+ name: "Order",
121
+ commands: [],
122
+ producedEventTypes: [],
123
+ consumedEventTypes: [],
124
+ linkedViews: [],
125
+ consistencyRead: None,
126
+ events: [],
127
+ chapter: None,
128
+ },
129
+ ],
130
+ automationSlices: [],
131
+ outboundTranslationSlices: [],
132
+ inboundTranslationSlices: [],
133
+ extensions: [],
134
+ extensionPoints: None,
135
+ }),
136
+ dcbEventLog: None,
137
+ kind: Domain,
138
+ }
139
+ let event' = {
140
+ id: "id",
141
+ meta: {
142
+ service: "svc",
143
+ time: "t",
144
+ ip: "ip",
145
+ user: "u",
146
+ msgId: "m",
147
+ correlationId: "c",
148
+ },
149
+ event: VersionConnected(def),
150
+ }
151
+ let json = event'->Message.encodeEvent'(S.string, PluginSpec.eventSchema)
152
+
153
+ // Simulate the persisted JSON as written before these fields existed: drop the
154
+ // mandatory enum `kind`, a top-level `T | null` field, and two nested writableDef
155
+ // fields (a mandatory array + a `T | null`).
156
+ let oldJson =
157
+ %raw(`function(j){
158
+ var c = JSON.parse(JSON.stringify(j));
159
+ var d = c.event._0;
160
+ delete d.kind;
161
+ delete d.apiSchemaFragment;
162
+ var agg = d.structure.aggregates[0];
163
+ delete agg.events;
164
+ delete agg.chapter;
165
+ return c;
166
+ }`)(json)
167
+
168
+ // Strict decode must reject the stale JSON — the tolerance is what saves it.
169
+ let strictThrew = switch oldJson->S.parseJsonOrThrow(
170
+ Message.toEventSchema'(S.string, PluginSpec.eventSchema),
171
+ ) {
172
+ | _ => false
173
+ | exception _ => true
174
+ }
175
+ expect(strictThrew)->toBe(true)
176
+
177
+ // Tolerant decode heals: kind -> Domain (first variant), missing array -> [],
178
+ // missing `T | null` fields -> None.
179
+ let decoded = oldJson->Message.decodeEvent'(S.string, PluginSpec.eventSchema)
180
+ switch decoded.event {
181
+ | VersionConnected(d) =>
182
+ expect(
183
+ switch d.kind {
184
+ | Domain => "Domain"
185
+ | _ => "other"
186
+ },
187
+ )->toBe("Domain")
188
+ expect(d.apiSchemaFragment->Option.isNone)->toBe(true)
189
+ let structure: Reventless.Plugin.pluginStructure = d.structure->Option.getOrThrow
190
+ let agg = structure.aggregates->Array.getUnsafe(0)
191
+ expect(agg.events->Array.length)->toBe(0)
192
+ expect(agg.chapter->Option.isNone)->toBe(true)
193
+ | _ => expect("wrong-variant")->toBe("VersionConnected")
194
+ }
195
+ })
196
+
197
+ // The exact aggregate-replay path: EventLog_Operations.decodeEvent reassembles the
198
+ // event variant and calls `Message.decode(Spec.eventSchema)`. This is what actually
199
+ // bricked in production, so assert `Message.decode` itself heals a stale def.
200
+ testSync("Message.decode heals a VersionConnected variant missing kind (the replay path)", () => {
201
+ open PluginSpec
202
+ let def: Reventless.Plugin.pluginDefinition = {
203
+ id: "id",
204
+ name: "Ordering",
205
+ version: "1.0.0",
206
+ extensionPoints: [],
207
+ extensions: [],
208
+ eventCollector: "arn",
209
+ extensionProtocols: [],
210
+ apiSchemaFragment: None,
211
+ apiTarget: None,
212
+ uiFragments: None,
213
+ structure: None,
214
+ dcbEventLog: None,
215
+ kind: Domain,
216
+ }
217
+ let variantJson = VersionConnected(def)->Message.encode(PluginSpec.eventSchema)
218
+ let staleJson =
219
+ %raw(`function(j){ var c = JSON.parse(JSON.stringify(j)); delete c._0.kind; return c; }`)(
220
+ variantJson,
221
+ )
222
+
223
+ // Strict decode of the stale variant throws (reproduces the production brick).
224
+ let strictThrew = switch staleJson->S.parseJsonOrThrow(PluginSpec.eventSchema) {
225
+ | _ => false
226
+ | exception _ => true
227
+ }
228
+ expect(strictThrew)->toBe(true)
229
+
230
+ // The replay decoder heals kind -> Domain.
231
+ switch staleJson->Message.decode(PluginSpec.eventSchema) {
232
+ | VersionConnected(d) =>
233
+ expect(
234
+ switch d.kind {
235
+ | Domain => "Domain"
236
+ | _ => "other"
237
+ },
238
+ )->toBe("Domain")
239
+ | _ => expect("wrong-variant")->toBe("VersionConnected")
240
+ }
241
+ })
96
242
  })
@@ -89,6 +89,133 @@ globalThis.describe("Message should", () => {
89
89
  let eventId = Stdlib_Option.getOrThrow(Message$ReventlessCore.idOfEvent$pJson(eventJson$p), undefined);
90
90
  globalThis.expect(eventId).toBe("testId");
91
91
  });
92
+ globalThis.test("tolerantly decode a VersionConnected persisted before kind/structure fields existed", () => {
93
+ let def = {
94
+ id: "id",
95
+ name: "Ordering",
96
+ version: "1.0.0",
97
+ extensionPoints: [],
98
+ extensions: [],
99
+ eventCollector: "arn",
100
+ extensionProtocols: [],
101
+ apiSchemaFragment: undefined,
102
+ apiTarget: undefined,
103
+ uiFragments: undefined,
104
+ structure: {
105
+ readModels: [],
106
+ stateViewSlices: [],
107
+ stateChangeSlices: [],
108
+ aggregates: [{
109
+ name: "Order",
110
+ commands: [],
111
+ producedEventTypes: [],
112
+ consumedEventTypes: [],
113
+ linkedViews: [],
114
+ consistencyRead: undefined,
115
+ events: [],
116
+ chapter: undefined
117
+ }],
118
+ automationSlices: [],
119
+ outboundTranslationSlices: [],
120
+ inboundTranslationSlices: [],
121
+ extensions: [],
122
+ extensionPoints: undefined
123
+ },
124
+ dcbEventLog: undefined,
125
+ kind: "Domain"
126
+ };
127
+ let event$p_meta = {
128
+ service: "svc",
129
+ time: "t",
130
+ ip: "ip",
131
+ user: "u",
132
+ msgId: "m",
133
+ correlationId: "c"
134
+ };
135
+ let event$p_event = {
136
+ TAG: "VersionConnected",
137
+ _0: def
138
+ };
139
+ let event$p = {
140
+ id: "id",
141
+ meta: event$p_meta,
142
+ event: event$p_event
143
+ };
144
+ let json = Message$ReventlessCore.encodeEvent$p(event$p, S.string, PluginSpec$ReventlessCore.eventSchema);
145
+ let oldJson = (function(j){
146
+ var c = JSON.parse(JSON.stringify(j));
147
+ var d = c.event._0;
148
+ delete d.kind;
149
+ delete d.apiSchemaFragment;
150
+ var agg = d.structure.aggregates[0];
151
+ delete agg.events;
152
+ delete agg.chapter;
153
+ return c;
154
+ })(json);
155
+ let strictThrew;
156
+ try {
157
+ S.parseJsonOrThrow(oldJson, Message$ReventlessCore.toEventSchema$p(S.string, PluginSpec$ReventlessCore.eventSchema));
158
+ strictThrew = false;
159
+ } catch (exn) {
160
+ strictThrew = true;
161
+ }
162
+ globalThis.expect(strictThrew).toBe(true);
163
+ let decoded = Message$ReventlessCore.decodeEvent$p(oldJson, S.string, PluginSpec$ReventlessCore.eventSchema);
164
+ let d = decoded.event;
165
+ if (d.TAG === "VersionConnected") {
166
+ let d$1 = d._0;
167
+ let match = d$1.kind;
168
+ let tmp;
169
+ tmp = match === "Domain" ? "Domain" : "other";
170
+ globalThis.expect(tmp).toBe("Domain");
171
+ globalThis.expect(Stdlib_Option.isNone(d$1.apiSchemaFragment)).toBe(true);
172
+ let structure = Stdlib_Option.getOrThrow(d$1.structure, undefined);
173
+ let agg = structure.aggregates[0];
174
+ globalThis.expect(agg.events.length).toBe(0);
175
+ globalThis.expect(Stdlib_Option.isNone(agg.chapter)).toBe(true);
176
+ return;
177
+ }
178
+ globalThis.expect("wrong-variant").toBe("VersionConnected");
179
+ });
180
+ globalThis.test("Message.decode heals a VersionConnected variant missing kind (the replay path)", () => {
181
+ let def = {
182
+ id: "id",
183
+ name: "Ordering",
184
+ version: "1.0.0",
185
+ extensionPoints: [],
186
+ extensions: [],
187
+ eventCollector: "arn",
188
+ extensionProtocols: [],
189
+ apiSchemaFragment: undefined,
190
+ apiTarget: undefined,
191
+ uiFragments: undefined,
192
+ structure: undefined,
193
+ dcbEventLog: undefined,
194
+ kind: "Domain"
195
+ };
196
+ let variantJson = Message$ReventlessCore.encode({
197
+ TAG: "VersionConnected",
198
+ _0: def
199
+ }, PluginSpec$ReventlessCore.eventSchema);
200
+ let staleJson = (function(j){ var c = JSON.parse(JSON.stringify(j)); delete c._0.kind; return c; })(variantJson);
201
+ let strictThrew;
202
+ try {
203
+ S.parseJsonOrThrow(staleJson, PluginSpec$ReventlessCore.eventSchema);
204
+ strictThrew = false;
205
+ } catch (exn) {
206
+ strictThrew = true;
207
+ }
208
+ globalThis.expect(strictThrew).toBe(true);
209
+ let d = Message$ReventlessCore.decode(staleJson, PluginSpec$ReventlessCore.eventSchema);
210
+ if (d.TAG === "VersionConnected") {
211
+ let match = d._0.kind;
212
+ let tmp;
213
+ tmp = match === "Domain" ? "Domain" : "other";
214
+ globalThis.expect(tmp).toBe("Domain");
215
+ return;
216
+ }
217
+ globalThis.expect("wrong-variant").toBe("VersionConnected");
218
+ });
92
219
  });
93
220
 
94
221
  /* Not a pure module */
@@ -177,7 +177,7 @@ globalThis.describe("ReadModel visibility filter", () => {
177
177
  });
178
178
 
179
179
  globalThis.describe("Plugin_Structure.make — visibility tagging", () => {
180
- let structure = Plugin_Structure$ReventlessCore.make("VisibilityPlugin", undefined, readModels, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
180
+ let structure = Plugin_Structure$ReventlessCore.make("VisibilityPlugin", undefined, readModels, undefined, undefined, undefined, undefined, undefined, undefined, undefined, undefined);
181
181
  globalThis.test("readModels carries both Public and Internal entries", () => {
182
182
  globalThis.expect(structure.readModels.length).toBe(2);
183
183
  });
@@ -295,4 +295,38 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
295
295
  )->toBe(None)
296
296
  })
297
297
  })
298
+
299
+ // The generator captures each component's chapter (source-folder grouping band)
300
+ // and passes it as ~componentChapters; Plugin_Structure looks it up by Spec.name
301
+ // and threads it onto every def, so a deployed-graph consumer can render chapter
302
+ // bands without workspace access. See docs/plans/deployed-chapter-grouping.md.
303
+ describe("componentChapters threading", () => {
304
+ let chaptered = Plugin_Structure.make(
305
+ ~name="TestPlugin",
306
+ ~stateChangeSlices=[module(PsPlaceOrderSlice), module(PsShipOrderSlice)],
307
+ ~stateViewSlices=[module(PsOrdersViewSlice)],
308
+ // PlaceOrder + Orders live under a chapter; ShipOrder does not.
309
+ ~componentChapters=Dict.fromArray([("PlaceOrder", "Order"), ("Orders", "Order")]),
310
+ )
311
+
312
+ testSync("a chaptered state-change slice carries Some(chapter)", () => {
313
+ let placeOrder = chaptered.stateChangeSlices->Array.getUnsafe(0)
314
+ expect(placeOrder.chapter)->toEqual(Some("Order"))
315
+ })
316
+
317
+ testSync("a chaptered state-view slice carries Some(chapter)", () => {
318
+ let orders = chaptered.stateViewSlices->Array.getUnsafe(0)
319
+ expect(orders.chapter)->toEqual(Some("Order"))
320
+ })
321
+
322
+ testSync("a component absent from the map carries None", () => {
323
+ let shipOrder = chaptered.stateChangeSlices->Array.getUnsafe(1)
324
+ expect(shipOrder.chapter)->toEqual(None)
325
+ })
326
+
327
+ testSync("no ~componentChapters → every def is chapterless (None)", () => {
328
+ let placeOrder = structure.stateChangeSlices->Array.getUnsafe(0)
329
+ expect(placeOrder.chapter)->toEqual(None)
330
+ })
331
+ })
298
332
  })
@@ -183,7 +183,7 @@ let structure = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, un
183
183
  ], [
184
184
  PsPlaceOrderSlice,
185
185
  PsShipOrderSlice
186
- ], undefined, undefined, undefined, undefined, undefined);
186
+ ], undefined, undefined, undefined, undefined, undefined, undefined);
187
187
 
188
188
  globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
189
189
  globalThis.describe("stateChangeSlices", () => {
@@ -374,6 +374,37 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
374
374
  globalThis.expect(Stdlib_Option.flatMap(orderIdField, s => getProperty(s, "x-reventless-id"))).toBe(undefined);
375
375
  });
376
376
  });
377
+ globalThis.describe("componentChapters threading", () => {
378
+ let chaptered = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, [PsOrdersViewSlice], [
379
+ PsPlaceOrderSlice,
380
+ PsShipOrderSlice
381
+ ], undefined, undefined, undefined, undefined, undefined, Object.fromEntries([
382
+ [
383
+ "PlaceOrder",
384
+ "Order"
385
+ ],
386
+ [
387
+ "Orders",
388
+ "Order"
389
+ ]
390
+ ]));
391
+ globalThis.test("a chaptered state-change slice carries Some(chapter)", () => {
392
+ let placeOrder = chaptered.stateChangeSlices[0];
393
+ globalThis.expect(placeOrder.chapter).toEqual("Order");
394
+ });
395
+ globalThis.test("a chaptered state-view slice carries Some(chapter)", () => {
396
+ let orders = chaptered.stateViewSlices[0];
397
+ globalThis.expect(orders.chapter).toEqual("Order");
398
+ });
399
+ globalThis.test("a component absent from the map carries None", () => {
400
+ let shipOrder = chaptered.stateChangeSlices[1];
401
+ globalThis.expect(shipOrder.chapter).toEqual(undefined);
402
+ });
403
+ globalThis.test("no ~componentChapters → every def is chapterless (None)", () => {
404
+ let placeOrder = structure.stateChangeSlices[0];
405
+ globalThis.expect(placeOrder.chapter).toEqual(undefined);
406
+ });
407
+ });
377
408
  });
378
409
 
379
410
  export {