@reventlessdev/reventless-core 3.0.0-alpha.237 → 3.0.0-alpha.238
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 +12 -0
- package/package.json +7 -7
- package/src/Message.res +1 -1
- package/src/adapter/Monitoring/Monitoring.res +1 -1
- package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res +1 -1
- package/src/components/Aggregate/Aggregate_Callback.res +2 -2
- package/src/components/Api/ApiAllowedStatesHelpers.res +2 -3
- package/src/components/Api/ApiTargetStateHelpers.res +4 -3
- package/src/components/Dcb/Dcb_Builder.res +79 -16
- package/src/components/Dcb/Dcb_Builder.res.mjs +41 -5
- package/src/components/EventLog/EventLog.res +1 -1
- package/src/plugin/component/Plugin_Structure.res +269 -7
- package/src/plugin/component/Plugin_Structure.res.mjs +128 -1
- package/src/plugin/connect/PluginExtensionPoint_UiFragment.res +1 -1
- package/tests/aggregate/AggregateCacheTest.res +1 -1
- package/tests/aggregate/AggregateSnapshotTest.res +1 -1
- package/tests/commandgenerator/OwnerStampingTest.res +57 -0
- package/tests/commandgenerator/OwnerStampingTest.res.mjs +19 -0
- package/tests/message/MessageTest.res +1 -1
- package/tests/plugin/HeartbeatDisconnectGraceTest.res +1 -1
- package/tests/plugin/PluginStructureTest.res +164 -3
- package/tests/plugin/PluginStructureTest.res.mjs +170 -1
- package/tests/plugin/StateChangeSlice/PsDispatchShipment.res +31 -0
- package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +73 -0
- package/tests/plugin/StateChangeSlice/PsShipOrder.res +16 -1
- package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -2
- package/tests/plugin/StateViewSlice/PsShipmentsView.res +26 -0
- package/tests/plugin/StateViewSlice/PsShipmentsView.res.mjs +101 -0
- package/tests/util/CsvStreamTest.res +1 -1
|
@@ -144,6 +144,29 @@ module PsReserveStockSlice: ReventlessInfra.StateChangeSlice.T = {
|
|
|
144
144
|
let make = (~dcbEventLog as _, ~publishJsons as _, ~tagKeysByEventType as _=?, ~crossPartitionTagKeys as _=?, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
+
module PsDispatchShipmentSlice: ReventlessInfra.StateChangeSlice.T = {
|
|
148
|
+
module Spec = PsDispatchShipment
|
|
149
|
+
module Behavior = {
|
|
150
|
+
type state = PsDispatchShipment.state
|
|
151
|
+
let initialState = PsDispatchShipment.initialState
|
|
152
|
+
let evolve = PsDispatchShipment.evolve
|
|
153
|
+
let decide = PsDispatchShipment.decide
|
|
154
|
+
let moduleUrl = PsDispatchShipment.moduleUrl
|
|
155
|
+
}
|
|
156
|
+
let isAsync = false
|
|
157
|
+
type component = scsComponent
|
|
158
|
+
let make = (~dcbEventLog as _, ~publishJsons as _, ~tagKeysByEventType as _=?, ~crossPartitionTagKeys as _=?, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
|
|
159
|
+
}
|
|
160
|
+
module PsShipmentsViewSlice: ReventlessInfra.StateViewSlice.T = {
|
|
161
|
+
module Spec = PsShipmentsView
|
|
162
|
+
module Projection = {
|
|
163
|
+
let project = PsShipmentsView.project
|
|
164
|
+
let moduleUrl = PsShipmentsView.moduleUrl
|
|
165
|
+
}
|
|
166
|
+
type component = svsComponent
|
|
167
|
+
let make = (~dcbEventLog as _, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
|
|
168
|
+
}
|
|
169
|
+
|
|
147
170
|
let structure = Plugin_Structure.make(
|
|
148
171
|
~name="TestPlugin",
|
|
149
172
|
~stateChangeSlices=[module(PsPlaceOrderSlice), module(PsShipOrderSlice)],
|
|
@@ -155,6 +178,132 @@ let structure = Plugin_Structure.make(
|
|
|
155
178
|
],
|
|
156
179
|
)
|
|
157
180
|
|
|
181
|
+
describe("lifecycle topology", () => {
|
|
182
|
+
let ordersStates = Dict.fromArray([("Orders", ["Placed", "Shipped", "Cancelled"])])
|
|
183
|
+
let writableWith = (commands): Reventless.Plugin.writableDef => {
|
|
184
|
+
name: "Ordering",
|
|
185
|
+
commands,
|
|
186
|
+
producedEventTypes: [],
|
|
187
|
+
consumedEventTypes: [],
|
|
188
|
+
linkedViews: ["Orders"],
|
|
189
|
+
consistencyRead: None,
|
|
190
|
+
events: [],
|
|
191
|
+
errors: [],
|
|
192
|
+
chapter: None,
|
|
193
|
+
}
|
|
194
|
+
let command = (~name, ~allowedStates=?, ~targetState=?, ()): Reventless.Plugin.commandDef => {
|
|
195
|
+
name,
|
|
196
|
+
schema: "",
|
|
197
|
+
level: Instance,
|
|
198
|
+
aggregateIdField: None,
|
|
199
|
+
mutationField: "",
|
|
200
|
+
references: [],
|
|
201
|
+
allowedStates,
|
|
202
|
+
targetState,
|
|
203
|
+
apiExposed: None,
|
|
204
|
+
requiredAccess: None,
|
|
205
|
+
ownerField: None,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
testSync("names a state no command can reach", () => {
|
|
209
|
+
// `Cancelled` is in the enum and nothing declares an edge into it.
|
|
210
|
+
let findings = Plugin_Structure.lifecycleTopologyFindings(
|
|
211
|
+
~writables=[
|
|
212
|
+
writableWith([command(~name="Ship", ~allowedStates=["Placed"], ~targetState="Shipped", ())]),
|
|
213
|
+
],
|
|
214
|
+
~lifecycleStatesByView=ordersStates,
|
|
215
|
+
)
|
|
216
|
+
expect(findings->Array.map(((_, m)) => m->String.includes("Cancelled")))->toEqual([true])
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
testSync("says nothing when every state is reachable", () => {
|
|
220
|
+
let findings = Plugin_Structure.lifecycleTopologyFindings(
|
|
221
|
+
~writables=[
|
|
222
|
+
writableWith([
|
|
223
|
+
command(~name="Ship", ~allowedStates=["Placed"], ~targetState="Shipped", ()),
|
|
224
|
+
command(~name="Cancel", ~allowedStates=["Placed"], ~targetState="Cancelled", ()),
|
|
225
|
+
]),
|
|
226
|
+
],
|
|
227
|
+
~lifecycleStatesByView=ordersStates,
|
|
228
|
+
)
|
|
229
|
+
expect(findings->Array.length)->toBe(0)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
// The first declared state is where rows begin, so nothing pointing at it is
|
|
233
|
+
// expected rather than suspicious.
|
|
234
|
+
testSync("does not call the initial state unreachable", () => {
|
|
235
|
+
let findings = Plugin_Structure.lifecycleTopologyFindings(
|
|
236
|
+
~writables=[
|
|
237
|
+
writableWith([
|
|
238
|
+
command(~name="Ship", ~allowedStates=["Placed"], ~targetState="Shipped", ()),
|
|
239
|
+
command(~name="Cancel", ~allowedStates=["Placed"], ~targetState="Cancelled", ()),
|
|
240
|
+
]),
|
|
241
|
+
],
|
|
242
|
+
~lifecycleStatesByView=ordersStates,
|
|
243
|
+
)
|
|
244
|
+
expect(findings->Array.map(((_, m)) => m->String.includes("Placed")))->toEqual([])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
// A terminal state is not a finding. `Shipped` and `Refunded` have no way out
|
|
248
|
+
// in the shipped aggregates example, and both are correct — which is why the
|
|
249
|
+
// "dead end" rule this pass was going to carry was dropped rather than
|
|
250
|
+
// silenced. See the comment in Plugin_Structure.
|
|
251
|
+
testSync("does not report a state with no way out", () => {
|
|
252
|
+
let findings = Plugin_Structure.lifecycleTopologyFindings(
|
|
253
|
+
~writables=[
|
|
254
|
+
writableWith([
|
|
255
|
+
command(~name="Ship", ~allowedStates=["Placed"], ~targetState="Shipped", ()),
|
|
256
|
+
command(~name="Cancel", ~allowedStates=["Placed"], ~targetState="Cancelled", ()),
|
|
257
|
+
]),
|
|
258
|
+
],
|
|
259
|
+
~lifecycleStatesByView=ordersStates,
|
|
260
|
+
)
|
|
261
|
+
expect(findings->Array.length)->toBe(0)
|
|
262
|
+
})
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
describe("@transition cross-check", () => {
|
|
266
|
+
// The exit criterion: a state no linked view declares stops the build, and the
|
|
267
|
+
// message says which command, which state, and what it was checked against.
|
|
268
|
+
// `DispatchShipment` produces `ShipmentDispatched`, which the `Shipments` view
|
|
269
|
+
// consumes — so the two are linked, the view's lifecycle is in hand, and
|
|
270
|
+
// "Dispatchd" has somewhere to be wrong.
|
|
271
|
+
let buildBad = () =>
|
|
272
|
+
try {
|
|
273
|
+
Plugin_Structure.make(
|
|
274
|
+
~name="TransitionPlugin",
|
|
275
|
+
~stateChangeSlices=[module(PsDispatchShipmentSlice)],
|
|
276
|
+
~stateViewSlices=[module(PsShipmentsViewSlice)],
|
|
277
|
+
)->ignore
|
|
278
|
+
None
|
|
279
|
+
} catch {
|
|
280
|
+
| Exn.Error(e) => Some(Exn.message(e)->Option.getOr(""))
|
|
281
|
+
| _ => Some("")
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
testSync("a state no linked view declares fails the build", () => {
|
|
285
|
+
expect(buildBad()->Option.isSome)->toBe(true)
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
testSync("the failure names the command, the state and the view", () => {
|
|
289
|
+
let message = buildBad()->Option.getOr("")
|
|
290
|
+
expect((
|
|
291
|
+
message->String.includes("DispatchShipment"),
|
|
292
|
+
message->String.includes("Dispatchd"),
|
|
293
|
+
message->String.includes("Shipments"),
|
|
294
|
+
message->String.includes("Booked"),
|
|
295
|
+
))->toEqual((true, true, true, true))
|
|
296
|
+
})
|
|
297
|
+
|
|
298
|
+
// The other half of the rule: a correctly-spelled edge passes, and a command
|
|
299
|
+
// whose linked views declare no lifecycle is warned about rather than failed —
|
|
300
|
+
// `PsShipOrder` is exactly that shape, and the suite's main `structure` above
|
|
301
|
+
// builds without throwing, which is that path already exercised.
|
|
302
|
+
testSync("a plugin whose views declare no lifecycle still builds", () => {
|
|
303
|
+
expect(structure.stateChangeSlices->Array.length)->toBe(2)
|
|
304
|
+
})
|
|
305
|
+
})
|
|
306
|
+
|
|
158
307
|
describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
159
308
|
describe("stateChangeSlices", () => {
|
|
160
309
|
testSync("produces two SCS entries in declaration order", () => {
|
|
@@ -233,8 +382,8 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
233
382
|
))->toEqual((Some(true), Some(false)))
|
|
234
383
|
})
|
|
235
384
|
|
|
236
|
-
testSync("ShipOrder: @
|
|
237
|
-
// End-to-end: the reventless-ppx @
|
|
385
|
+
testSync("ShipOrder: @transition's target flows through the PPX to commandDef.targetState", () => {
|
|
386
|
+
// End-to-end: the reventless-ppx @transition annotation → markTargetState
|
|
238
387
|
// metadata → ApiTargetStateHelpers.getTargetState → commandDef. The
|
|
239
388
|
// un-annotated CancelShipment carries None (resolver falls back to
|
|
240
389
|
// name-stem).
|
|
@@ -246,6 +395,18 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
246
395
|
))->toEqual((Some("Shipped"), None))
|
|
247
396
|
})
|
|
248
397
|
|
|
398
|
+
testSync("ShipOrder: @transition's from-set flows through to commandDef.allowedStates", () => {
|
|
399
|
+
// The other half of the same annotation, which the removed pair spelled
|
|
400
|
+
// separately: one attribute now fills both fields, so a command that
|
|
401
|
+
// declares a target cannot end up without the states it may run from.
|
|
402
|
+
let shipOrder = structure.stateChangeSlices->Array.getUnsafe(1)
|
|
403
|
+
let byName = name => shipOrder.commands->Array.find(c => c.name == name)
|
|
404
|
+
expect((
|
|
405
|
+
byName("ShipOrder")->Option.flatMap(c => c.allowedStates),
|
|
406
|
+
byName("CancelShipment")->Option.flatMap(c => c.allowedStates),
|
|
407
|
+
))->toEqual((Some(["Placed"]), None))
|
|
408
|
+
})
|
|
409
|
+
|
|
249
410
|
testSync("ShipOrder: the @noApi variant carries no callable mutation field (no sibling leak)", () => {
|
|
250
411
|
// Regression: for a single-exposed-command slice, `mutationFieldFor`
|
|
251
412
|
// resolves every variant — including the @noApi one — to the slice's one
|
|
@@ -962,7 +1123,7 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
962
1123
|
// The generator captures each component's chapter (source-folder grouping band)
|
|
963
1124
|
// and passes it as ~componentChapters; Plugin_Structure looks it up by Spec.name
|
|
964
1125
|
// and threads it onto every def, so a deployed-graph consumer can render chapter
|
|
965
|
-
// bands without workspace access. See docs/plans/deployed-chapter-grouping.md.
|
|
1126
|
+
// bands without workspace access. See docs/plans/done/deployed-chapter-grouping.md.
|
|
966
1127
|
describe("componentChapters threading", () => {
|
|
967
1128
|
let chaptered = Plugin_Structure.make(
|
|
968
1129
|
~name="TestPlugin",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
2
|
|
|
3
3
|
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as Stdlib_Exn from "@rescript/runtime/lib/es6/Stdlib_Exn.js";
|
|
4
5
|
import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
|
|
5
6
|
import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
|
|
6
7
|
import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
|
|
@@ -8,6 +9,7 @@ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
|
|
|
8
9
|
import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
|
|
9
10
|
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
10
11
|
import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/types/DateTime.res.mjs";
|
|
12
|
+
import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
|
|
11
13
|
import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
|
|
12
14
|
import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
|
|
13
15
|
import * as Api_Naming$ReventlessCore from "../../src/components/Api/Api_Naming.res.mjs";
|
|
@@ -22,8 +24,10 @@ import * as PsUploadAvatar$ReventlessCore from "./StateChangeSlice/PsUploadAvata
|
|
|
22
24
|
import * as PsAnnotatedView$ReventlessCore from "./StateViewSlice/PsAnnotatedView.res.mjs";
|
|
23
25
|
import * as PsAttachInvoice$ReventlessCore from "./StateChangeSlice/PsAttachInvoice.res.mjs";
|
|
24
26
|
import * as PsCustomersView$ReventlessCore from "./StateViewSlice/PsCustomersView.res.mjs";
|
|
27
|
+
import * as PsShipmentsView$ReventlessCore from "./StateViewSlice/PsShipmentsView.res.mjs";
|
|
25
28
|
import * as Plugin_Structure$ReventlessCore from "../../src/plugin/component/Plugin_Structure.res.mjs";
|
|
26
29
|
import * as PsCategoriesView$ReventlessCore from "./StateViewSlice/PsCategoriesView.res.mjs";
|
|
30
|
+
import * as PsDispatchShipment$ReventlessCore from "./StateChangeSlice/PsDispatchShipment.res.mjs";
|
|
27
31
|
import * as Capability_Inference$ReventlessCore from "../../src/plugin/component/Capability_Inference.res.mjs";
|
|
28
32
|
import * as PsAvailableProductsView$ReventlessCore from "./StateViewSlice/PsAvailableProductsView.res.mjs";
|
|
29
33
|
|
|
@@ -337,6 +341,62 @@ let PsReserveStockSlice = {
|
|
|
337
341
|
make: make$10
|
|
338
342
|
};
|
|
339
343
|
|
|
344
|
+
let Behavior$6 = {
|
|
345
|
+
initialState: false,
|
|
346
|
+
evolve: PsDispatchShipment$ReventlessCore.evolve,
|
|
347
|
+
decide: PsDispatchShipment$ReventlessCore.decide,
|
|
348
|
+
moduleUrl: PsDispatchShipment$ReventlessCore.moduleUrl
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
function make$11(param, param$1, param$2, param$3, param$4, param$5) {
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
let PsDispatchShipmentSlice_Spec = {
|
|
356
|
+
name: PsDispatchShipment$ReventlessCore.name,
|
|
357
|
+
moduleUrl: PsDispatchShipment$ReventlessCore.moduleUrl,
|
|
358
|
+
Id: Id$Reventless.$$String,
|
|
359
|
+
consumedEventSchema: PsDispatchShipment$ReventlessCore.consumedEventSchema,
|
|
360
|
+
errorSchema: PsDispatchShipment$ReventlessCore.errorSchema,
|
|
361
|
+
eventSchema: PsDispatchShipment$ReventlessCore.eventSchema,
|
|
362
|
+
commandSchema: PsDispatchShipment$ReventlessCore.commandSchema,
|
|
363
|
+
commandAuthorization: PsDispatchShipment$ReventlessCore.commandAuthorization,
|
|
364
|
+
readConsistency: PsDispatchShipment$ReventlessCore.readConsistency
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
let PsDispatchShipmentSlice = {
|
|
368
|
+
Spec: PsDispatchShipmentSlice_Spec,
|
|
369
|
+
Behavior: Behavior$6,
|
|
370
|
+
isAsync: false,
|
|
371
|
+
make: make$11
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
let Projection$5 = {
|
|
375
|
+
project: PsShipmentsView$ReventlessCore.project,
|
|
376
|
+
moduleUrl: PsShipmentsView$ReventlessCore.moduleUrl
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
function make$12(param, param$1, param$2) {
|
|
380
|
+
return 0;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let PsShipmentsViewSlice_Spec = {
|
|
384
|
+
name: PsShipmentsView$ReventlessCore.name,
|
|
385
|
+
moduleUrl: PsShipmentsView$ReventlessCore.moduleUrl,
|
|
386
|
+
stateSchema: PsShipmentsView$ReventlessCore.stateSchema,
|
|
387
|
+
consumedEventSchema: PsShipmentsView$ReventlessCore.consumedEventSchema,
|
|
388
|
+
config: PsShipmentsView$ReventlessCore.config,
|
|
389
|
+
subIdConfig: undefined,
|
|
390
|
+
authorization: PsShipmentsView$ReventlessCore.authorization,
|
|
391
|
+
visibility: PsShipmentsView$ReventlessCore.visibility
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
let PsShipmentsViewSlice = {
|
|
395
|
+
Spec: PsShipmentsViewSlice_Spec,
|
|
396
|
+
Projection: Projection$5,
|
|
397
|
+
make: make$12
|
|
398
|
+
};
|
|
399
|
+
|
|
340
400
|
let structure = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, undefined, [
|
|
341
401
|
PsOrdersViewSlice,
|
|
342
402
|
PsAvailableProductsViewSlice,
|
|
@@ -347,6 +407,102 @@ let structure = Plugin_Structure$ReventlessCore.make("TestPlugin", undefined, un
|
|
|
347
407
|
PsShipOrderSlice
|
|
348
408
|
], undefined, undefined, undefined, undefined, undefined, undefined);
|
|
349
409
|
|
|
410
|
+
globalThis.describe("lifecycle topology", () => {
|
|
411
|
+
let ordersStates = Object.fromEntries([[
|
|
412
|
+
"Orders",
|
|
413
|
+
[
|
|
414
|
+
"Placed",
|
|
415
|
+
"Shipped",
|
|
416
|
+
"Cancelled"
|
|
417
|
+
]
|
|
418
|
+
]]);
|
|
419
|
+
let writableWith = commands => ({
|
|
420
|
+
name: "Ordering",
|
|
421
|
+
commands: commands,
|
|
422
|
+
producedEventTypes: [],
|
|
423
|
+
consumedEventTypes: [],
|
|
424
|
+
linkedViews: ["Orders"],
|
|
425
|
+
consistencyRead: undefined,
|
|
426
|
+
events: [],
|
|
427
|
+
errors: [],
|
|
428
|
+
chapter: undefined
|
|
429
|
+
});
|
|
430
|
+
let command = (name, allowedStates, targetState, param) => ({
|
|
431
|
+
name: name,
|
|
432
|
+
schema: "",
|
|
433
|
+
level: "Instance",
|
|
434
|
+
aggregateIdField: undefined,
|
|
435
|
+
mutationField: "",
|
|
436
|
+
references: [],
|
|
437
|
+
allowedStates: allowedStates,
|
|
438
|
+
targetState: targetState,
|
|
439
|
+
apiExposed: undefined,
|
|
440
|
+
requiredAccess: undefined,
|
|
441
|
+
ownerField: undefined
|
|
442
|
+
});
|
|
443
|
+
globalThis.test("names a state no command can reach", () => {
|
|
444
|
+
let findings = Plugin_Structure$ReventlessCore.lifecycleTopologyFindings([writableWith([command("Ship", ["Placed"], "Shipped", undefined)])], ordersStates);
|
|
445
|
+
globalThis.expect(findings.map(param => param[1].includes("Cancelled"))).toEqual([true]);
|
|
446
|
+
});
|
|
447
|
+
globalThis.test("says nothing when every state is reachable", () => {
|
|
448
|
+
let findings = Plugin_Structure$ReventlessCore.lifecycleTopologyFindings([writableWith([
|
|
449
|
+
command("Ship", ["Placed"], "Shipped", undefined),
|
|
450
|
+
command("Cancel", ["Placed"], "Cancelled", undefined)
|
|
451
|
+
])], ordersStates);
|
|
452
|
+
globalThis.expect(findings.length).toBe(0);
|
|
453
|
+
});
|
|
454
|
+
globalThis.test("does not call the initial state unreachable", () => {
|
|
455
|
+
let findings = Plugin_Structure$ReventlessCore.lifecycleTopologyFindings([writableWith([
|
|
456
|
+
command("Ship", ["Placed"], "Shipped", undefined),
|
|
457
|
+
command("Cancel", ["Placed"], "Cancelled", undefined)
|
|
458
|
+
])], ordersStates);
|
|
459
|
+
globalThis.expect(findings.map(param => param[1].includes("Placed"))).toEqual([]);
|
|
460
|
+
});
|
|
461
|
+
globalThis.test("does not report a state with no way out", () => {
|
|
462
|
+
let findings = Plugin_Structure$ReventlessCore.lifecycleTopologyFindings([writableWith([
|
|
463
|
+
command("Ship", ["Placed"], "Shipped", undefined),
|
|
464
|
+
command("Cancel", ["Placed"], "Cancelled", undefined)
|
|
465
|
+
])], ordersStates);
|
|
466
|
+
globalThis.expect(findings.length).toBe(0);
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
globalThis.describe("@transition cross-check", () => {
|
|
471
|
+
let buildBad = () => {
|
|
472
|
+
try {
|
|
473
|
+
Plugin_Structure$ReventlessCore.make("TransitionPlugin", undefined, undefined, [PsShipmentsViewSlice], [PsDispatchShipmentSlice], undefined, undefined, undefined, undefined, undefined, undefined);
|
|
474
|
+
return;
|
|
475
|
+
} catch (raw_e) {
|
|
476
|
+
let e = Primitive_exceptions.internalToException(raw_e);
|
|
477
|
+
if (e.RE_EXN_ID === Stdlib_Exn.$$Error) {
|
|
478
|
+
return Stdlib_Option.getOr(e._1.message, "");
|
|
479
|
+
} else {
|
|
480
|
+
return "";
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
globalThis.test("a state no linked view declares fails the build", () => {
|
|
485
|
+
globalThis.expect(Stdlib_Option.isSome(buildBad())).toBe(true);
|
|
486
|
+
});
|
|
487
|
+
globalThis.test("the failure names the command, the state and the view", () => {
|
|
488
|
+
let message = Stdlib_Option.getOr(buildBad(), "");
|
|
489
|
+
globalThis.expect([
|
|
490
|
+
message.includes("DispatchShipment"),
|
|
491
|
+
message.includes("Dispatchd"),
|
|
492
|
+
message.includes("Shipments"),
|
|
493
|
+
message.includes("Booked")
|
|
494
|
+
]).toEqual([
|
|
495
|
+
true,
|
|
496
|
+
true,
|
|
497
|
+
true,
|
|
498
|
+
true
|
|
499
|
+
]);
|
|
500
|
+
});
|
|
501
|
+
globalThis.test("a plugin whose views declare no lifecycle still builds", () => {
|
|
502
|
+
globalThis.expect(structure.stateChangeSlices.length).toBe(2);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
|
|
350
506
|
globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
351
507
|
globalThis.describe("stateChangeSlices", () => {
|
|
352
508
|
globalThis.test("produces two SCS entries in declaration order", () => {
|
|
@@ -424,7 +580,7 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
424
580
|
false
|
|
425
581
|
]);
|
|
426
582
|
});
|
|
427
|
-
globalThis.test("ShipOrder: @
|
|
583
|
+
globalThis.test("ShipOrder: @transition's target flows through the PPX to commandDef.targetState", () => {
|
|
428
584
|
let shipOrder = structure.stateChangeSlices[1];
|
|
429
585
|
let byName = name => shipOrder.commands.find(c => c.name === name);
|
|
430
586
|
globalThis.expect([
|
|
@@ -435,6 +591,17 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
|
|
|
435
591
|
undefined
|
|
436
592
|
]);
|
|
437
593
|
});
|
|
594
|
+
globalThis.test("ShipOrder: @transition's from-set flows through to commandDef.allowedStates", () => {
|
|
595
|
+
let shipOrder = structure.stateChangeSlices[1];
|
|
596
|
+
let byName = name => shipOrder.commands.find(c => c.name === name);
|
|
597
|
+
globalThis.expect([
|
|
598
|
+
Stdlib_Option.flatMap(byName("ShipOrder"), c => c.allowedStates),
|
|
599
|
+
Stdlib_Option.flatMap(byName("CancelShipment"), c => c.allowedStates)
|
|
600
|
+
]).toEqual([
|
|
601
|
+
["Placed"],
|
|
602
|
+
undefined
|
|
603
|
+
]);
|
|
604
|
+
});
|
|
438
605
|
globalThis.test("ShipOrder: the @noApi variant carries no callable mutation field (no sibling leak)", () => {
|
|
439
606
|
let shipOrder = structure.stateChangeSlices[1];
|
|
440
607
|
let byName = name => shipOrder.commands.find(c => c.name === name);
|
|
@@ -1275,6 +1442,8 @@ export {
|
|
|
1275
1442
|
PsUploadAvatarSlice,
|
|
1276
1443
|
PsChangePhotoSlice,
|
|
1277
1444
|
PsReserveStockSlice,
|
|
1445
|
+
PsDispatchShipmentSlice,
|
|
1446
|
+
PsShipmentsViewSlice,
|
|
1278
1447
|
structure,
|
|
1279
1448
|
}
|
|
1280
1449
|
/* structure Not a pure module */
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Test fixture spec for the @transition cross-check — the failing case.
|
|
2
|
+
//
|
|
3
|
+
// `Dispatchd` is a deliberate typo. It compiles: the PPX strips the attribute
|
|
4
|
+
// before the typechecker sees it, so a misspelled state is a well-formed string
|
|
5
|
+
// all the way down. That is exactly the hole the structure-side check closes,
|
|
6
|
+
// and a fixture that could not compile would not demonstrate it.
|
|
7
|
+
|
|
8
|
+
@@reventless.spec("DispatchShipment")
|
|
9
|
+
|
|
10
|
+
type shipmentStatus =
|
|
11
|
+
| Booked
|
|
12
|
+
| Dispatched
|
|
13
|
+
|
|
14
|
+
@schema
|
|
15
|
+
type consumedEvent = ShipmentBooked({shipmentId: string})
|
|
16
|
+
|
|
17
|
+
let evolve = (_state, _event) => true
|
|
18
|
+
|
|
19
|
+
type state = bool
|
|
20
|
+
let initialState = false
|
|
21
|
+
|
|
22
|
+
@schema
|
|
23
|
+
type command = @transition(([Booked]) => Dispatchd) DispatchShipment({shipmentId: string})
|
|
24
|
+
|
|
25
|
+
@schema
|
|
26
|
+
type error = ShipmentNotFound
|
|
27
|
+
|
|
28
|
+
@schema
|
|
29
|
+
type event = ShipmentDispatched({shipmentId: string})
|
|
30
|
+
|
|
31
|
+
let decide = (_state, _command): result<array<event>, error> => Ok([])
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Generated by ReScript, PLEASE EDIT WITH CARE
|
|
2
|
+
|
|
3
|
+
import * as S from "sury/src/S.res.mjs";
|
|
4
|
+
import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
|
|
5
|
+
import * as Api$ReventlessInfra from "@reventlessdev/reventless-infra/src/components/Api.res.mjs";
|
|
6
|
+
|
|
7
|
+
let consumedEventSchema = S.schema(s => ({
|
|
8
|
+
TAG: "ShipmentBooked",
|
|
9
|
+
shipmentId: s.m(DcbTag$Reventless.string)
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
function evolve(_state, _event) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let commandSchema = S.schema(s => ({
|
|
17
|
+
TAG: "DispatchShipment",
|
|
18
|
+
shipmentId: s.m(DcbTag$Reventless.string)
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
let errorSchema = S.literal("ShipmentNotFound");
|
|
22
|
+
|
|
23
|
+
let eventSchema = S.schema(s => ({
|
|
24
|
+
TAG: "ShipmentDispatched",
|
|
25
|
+
shipmentId: s.m(DcbTag$Reventless.string)
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
function decide(_state, _command) {
|
|
29
|
+
return {
|
|
30
|
+
TAG: "Ok",
|
|
31
|
+
_0: []
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let commandSchema$1 = Api$ReventlessInfra.markAllowedStates(commandSchema, [[
|
|
36
|
+
"DispatchShipment",
|
|
37
|
+
["Booked"]
|
|
38
|
+
]]);
|
|
39
|
+
|
|
40
|
+
let commandSchema$2 = Api$ReventlessInfra.markTargetState(commandSchema$1, [[
|
|
41
|
+
"DispatchShipment",
|
|
42
|
+
"Dispatchd"
|
|
43
|
+
]]);
|
|
44
|
+
|
|
45
|
+
function commandAuthorization(param) {
|
|
46
|
+
return "AllowAuthenticated";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let name = "DispatchShipment";
|
|
50
|
+
|
|
51
|
+
let Id;
|
|
52
|
+
|
|
53
|
+
let initialState = false;
|
|
54
|
+
|
|
55
|
+
let moduleUrl = "@reventlessdev/reventless-core/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs";
|
|
56
|
+
|
|
57
|
+
let readConsistency = "EscalateOnRetry";
|
|
58
|
+
|
|
59
|
+
export {
|
|
60
|
+
name,
|
|
61
|
+
Id,
|
|
62
|
+
consumedEventSchema,
|
|
63
|
+
evolve,
|
|
64
|
+
initialState,
|
|
65
|
+
errorSchema,
|
|
66
|
+
eventSchema,
|
|
67
|
+
decide,
|
|
68
|
+
commandSchema$2 as commandSchema,
|
|
69
|
+
moduleUrl,
|
|
70
|
+
commandAuthorization,
|
|
71
|
+
readConsistency,
|
|
72
|
+
}
|
|
73
|
+
/* consumedEventSchema Not a pure module */
|
|
@@ -18,8 +18,23 @@ let evolve = (_state, _event) => true
|
|
|
18
18
|
// It is also `@noApi` (internal/admin only): a two-variant slice with exactly
|
|
19
19
|
// one API-exposed command, which is the case that used to leak the exposed
|
|
20
20
|
// command's slice mutation field onto the non-exposed variant.
|
|
21
|
+
// The lifecycle this slice's commands move a shipment through. The PPX strips
|
|
22
|
+
// @transition before the typechecker sees it, so this enum is not what makes the
|
|
23
|
+
// reference below valid — the structure-side check against the linked view is.
|
|
24
|
+
// It is here because a state is a constructor, and naming one in a string is a
|
|
25
|
+
// reference nothing can check at any layer.
|
|
26
|
+
type shipmentStatus = Placed | Shipped | Cancelled
|
|
27
|
+
|
|
28
|
+
// CancelShipment is payload-less — it compiles to a bare string literal, so it
|
|
29
|
+
// exercises toCommandDef's payload-less branch (still surfaced in `commands`).
|
|
30
|
+
// It is also `@noApi` (internal/admin only): a two-variant slice with exactly
|
|
31
|
+
// one API-exposed command, which is the case that used to leak the exposed
|
|
32
|
+
// command's slice mutation field onto the non-exposed variant. It carries no
|
|
33
|
+
// @transition, which is the un-annotated case the resolver still guesses for.
|
|
21
34
|
@schema
|
|
22
|
-
type command =
|
|
35
|
+
type command =
|
|
36
|
+
| @transition(([Placed]) => Shipped) ShipOrder({orderId: string})
|
|
37
|
+
| @noApi CancelShipment
|
|
23
38
|
|
|
24
39
|
// Two variants, one payload-less and one carrying a field — the same two branches
|
|
25
40
|
// `events` exercises, so the structure's `errors` list is pinned on both.
|
|
@@ -47,7 +47,12 @@ function decide(_state, _command) {
|
|
|
47
47
|
|
|
48
48
|
let commandSchema$1 = Api$ReventlessInfra.markNoApiVariants(commandSchema, ["CancelShipment"]);
|
|
49
49
|
|
|
50
|
-
let commandSchema$2 = Api$ReventlessInfra.
|
|
50
|
+
let commandSchema$2 = Api$ReventlessInfra.markAllowedStates(commandSchema$1, [[
|
|
51
|
+
"ShipOrder",
|
|
52
|
+
["Placed"]
|
|
53
|
+
]]);
|
|
54
|
+
|
|
55
|
+
let commandSchema$3 = Api$ReventlessInfra.markTargetState(commandSchema$2, [[
|
|
51
56
|
"ShipOrder",
|
|
52
57
|
"Shipped"
|
|
53
58
|
]]);
|
|
@@ -75,7 +80,7 @@ export {
|
|
|
75
80
|
errorSchema,
|
|
76
81
|
eventSchema,
|
|
77
82
|
decide,
|
|
78
|
-
commandSchema$
|
|
83
|
+
commandSchema$3 as commandSchema,
|
|
79
84
|
moduleUrl,
|
|
80
85
|
commandAuthorization,
|
|
81
86
|
readConsistency,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Test fixture spec for the @transition cross-check.
|
|
2
|
+
// A view that declares a lifecycle, so a command's declared states have
|
|
3
|
+
// something to be checked against. `PsOrdersView` deliberately declares none —
|
|
4
|
+
// the two together cover both the checked and the unvalidated path.
|
|
5
|
+
|
|
6
|
+
@@reventless.spec("Shipments")
|
|
7
|
+
|
|
8
|
+
@schema
|
|
9
|
+
type consumedEvent =
|
|
10
|
+
| ShipmentBooked({shipmentId: string})
|
|
11
|
+
| ShipmentDispatched({shipmentId: string})
|
|
12
|
+
|
|
13
|
+
@schema
|
|
14
|
+
type shipmentStatus =
|
|
15
|
+
| Booked
|
|
16
|
+
| Dispatched
|
|
17
|
+
|
|
18
|
+
@schema
|
|
19
|
+
type state = {shipmentId: string, @lifecycle shipmentStatus: shipmentStatus}
|
|
20
|
+
|
|
21
|
+
let project = ({event}: Reventless.StateViewSlice.consumed<consumedEvent>) =>
|
|
22
|
+
switch event {
|
|
23
|
+
| ShipmentBooked({shipmentId}) => [Set(shipmentId, {shipmentId, shipmentStatus: Booked})]
|
|
24
|
+
| ShipmentDispatched({shipmentId}) =>
|
|
25
|
+
[Update(shipmentId, s => {...s, shipmentStatus: Dispatched})]
|
|
26
|
+
}
|