@reventlessdev/reventless-core 3.0.0-alpha.258 → 3.0.0-alpha.259

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +6 -6
  3. package/src/Projection.res +158 -40
  4. package/src/Projection.res.mjs +107 -28
  5. package/src/admin/UiFragmentRegistry/{StateChangeSlice → StateChange}/UiFragmentRegistry.res.mjs +1 -1
  6. package/src/admin/UiFragmentRegistry/{StateChangeSlice → StateChange}/UiFragmentRegistry_Behavior.res.mjs +1 -1
  7. package/src/admin/UiFragmentRegistry/{StateViewSlice → StateView}/UiFragments.res.mjs +1 -1
  8. package/src/admin/UiFragmentRegistry/{StateViewSlice → StateView}/UiFragments_Projection.res.mjs +1 -1
  9. package/src/components/Api/GraphQL_FragmentGenerator.res +12 -1
  10. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +2 -1
  11. package/src/components/Api/SchemaType.res +66 -1
  12. package/src/components/Api/SchemaType.res.mjs +66 -3
  13. package/src/components/Api/SuryToJsonSchema.res +1 -0
  14. package/src/components/Api/SuryToJsonSchema.res.mjs +11 -0
  15. package/src/components/ReadModel/ReadModel_Callback.res +5 -2
  16. package/src/components/ReadModel/ReadModel_Callback.res.mjs +2 -5
  17. package/src/components/StateViewSlice/StateViewSlice_Builder.res +7 -6
  18. package/src/components/StateViewSlice/StateViewSlice_Builder.res.mjs +2 -5
  19. package/src/components/StateViewSlice/StateViewSlice_Callback.res +3 -1
  20. package/src/components/StateViewSlice/StateViewSlice_Callback.res.mjs +1 -1
  21. package/src/plugin/component/Plugin_Structure.res +9 -46
  22. package/src/plugin/component/Plugin_Structure.res.mjs +8 -40
  23. package/src/plugin/connect/PluginExtensionPoint_UiFragment.res.mjs +1 -1
  24. package/tests/admin/AdminApiSchemaDriftTest.res.mjs +1 -1
  25. package/tests/api/BrandedMarkerCompositionTest.res +74 -0
  26. package/tests/api/BrandedMarkerCompositionTest.res.mjs +74 -0
  27. package/tests/api/PsBrandedMarkersReadModel.res +24 -0
  28. package/tests/api/PsBrandedMarkersReadModel.res.mjs +87 -0
  29. package/tests/api/SuryToJsonSchemaTest.res +35 -3
  30. package/tests/api/SuryToJsonSchemaTest.res.mjs +35 -4
  31. package/tests/plugin/PluginStructureAccessTest.res.mjs +3 -3
  32. package/tests/plugin/PluginStructureTest.res +5 -5
  33. package/tests/plugin/PluginStructureTest.res.mjs +26 -26
  34. package/tests/plugin/{StateChangeSlice → StateChange}/PsAttachInvoice.res.mjs +1 -1
  35. package/tests/plugin/{StateChangeSlice → StateChange}/PsChangePhoto.res.mjs +1 -1
  36. package/tests/plugin/{StateChangeSlice → StateChange}/PsDispatchShipment.res.mjs +1 -1
  37. package/tests/plugin/{StateChangeSlice → StateChange}/PsGatedCommands.res.mjs +1 -1
  38. package/tests/plugin/{StateChangeSlice → StateChange}/PsPlaceOrder.res.mjs +1 -1
  39. package/tests/plugin/{StateChangeSlice → StateChange}/PsReserveStock.res.mjs +1 -1
  40. package/tests/plugin/{StateChangeSlice → StateChange}/PsShipOrder.res.mjs +1 -1
  41. package/tests/plugin/{StateChangeSlice → StateChange}/PsTransitionSwitch.res.mjs +1 -1
  42. package/tests/plugin/{StateChangeSlice → StateChange}/PsTypoStore.res.mjs +1 -1
  43. package/tests/plugin/{StateChangeSlice → StateChange}/PsUploadAvatar.res.mjs +1 -1
  44. package/tests/plugin/{StateChangeSlice → StateChange}/PsUploadImages.res.mjs +1 -1
  45. package/tests/plugin/{StateViewSlice → StateView}/PsAnnotatedView.res.mjs +1 -1
  46. package/tests/plugin/{StateViewSlice → StateView}/PsAvailableProductsView.res.mjs +1 -1
  47. package/tests/plugin/{StateViewSlice → StateView}/PsCategoriesView.res.mjs +1 -1
  48. package/tests/plugin/{StateViewSlice → StateView}/PsCustomersView.res.mjs +1 -1
  49. package/tests/plugin/{StateViewSlice → StateView}/PsGatedView.res.mjs +1 -1
  50. package/tests/plugin/{StateViewSlice → StateView}/PsOrdersView.res.mjs +1 -1
  51. package/tests/plugin/{StateViewSlice → StateView}/PsShipmentsView.res.mjs +1 -1
  52. package/tests/projection/LifecycleTrailTest.res +139 -0
  53. package/tests/projection/LifecycleTrailTest.res.mjs +280 -0
  54. /package/src/admin/UiFragmentRegistry/{StateChangeSlice → StateChange}/UiFragmentRegistry.res +0 -0
  55. /package/src/admin/UiFragmentRegistry/{StateChangeSlice → StateChange}/UiFragmentRegistry_Behavior.res +0 -0
  56. /package/src/admin/UiFragmentRegistry/{StateViewSlice → StateView}/UiFragments.res +0 -0
  57. /package/src/admin/UiFragmentRegistry/{StateViewSlice → StateView}/UiFragments_Projection.res +0 -0
  58. /package/tests/plugin/{StateChangeSlice → StateChange}/PsAttachInvoice.res +0 -0
  59. /package/tests/plugin/{StateChangeSlice → StateChange}/PsChangePhoto.res +0 -0
  60. /package/tests/plugin/{StateChangeSlice → StateChange}/PsDispatchShipment.res +0 -0
  61. /package/tests/plugin/{StateChangeSlice → StateChange}/PsGatedCommands.res +0 -0
  62. /package/tests/plugin/{StateChangeSlice → StateChange}/PsPlaceOrder.res +0 -0
  63. /package/tests/plugin/{StateChangeSlice → StateChange}/PsReserveStock.res +0 -0
  64. /package/tests/plugin/{StateChangeSlice → StateChange}/PsShipOrder.res +0 -0
  65. /package/tests/plugin/{StateChangeSlice → StateChange}/PsTransitionSwitch.res +0 -0
  66. /package/tests/plugin/{StateChangeSlice → StateChange}/PsTypoStore.res +0 -0
  67. /package/tests/plugin/{StateChangeSlice → StateChange}/PsUploadAvatar.res +0 -0
  68. /package/tests/plugin/{StateChangeSlice → StateChange}/PsUploadImages.res +0 -0
  69. /package/tests/plugin/{StateViewSlice → StateView}/PsAnnotatedView.res +0 -0
  70. /package/tests/plugin/{StateViewSlice → StateView}/PsAvailableProductsView.res +0 -0
  71. /package/tests/plugin/{StateViewSlice → StateView}/PsCategoriesView.res +0 -0
  72. /package/tests/plugin/{StateViewSlice → StateView}/PsCustomersView.res +0 -0
  73. /package/tests/plugin/{StateViewSlice → StateView}/PsGatedView.res +0 -0
  74. /package/tests/plugin/{StateViewSlice → StateView}/PsOrdersView.res +0 -0
  75. /package/tests/plugin/{StateViewSlice → StateView}/PsShipmentsView.res +0 -0
@@ -10,6 +10,13 @@ type rec schemaType =
10
10
  | ScalarBigInt
11
11
  | EntityId
12
12
  | DateTime
13
+ | /** A calendar day, with no instant in it. A shape of its own rather than a
14
+ plain string so `format: "date"` reaches the JSON Schema — but, unlike
15
+ `DateTime`, it is not excluded from the generic semantic wrapper below,
16
+ so it carries `x-reventless-semantic` as well. `DateTime`'s exclusion
17
+ exists because its bare-`format` output predates the marker and is a
18
+ published contract; a new semantic has no such history. */
19
+ CalendarDate
13
20
  | Nullable(schemaType)
14
21
  | ArrayOf(schemaType)
15
22
  | ObjectRef(string, dict<schemaType>)
@@ -32,6 +39,7 @@ type rec schemaType =
32
39
 
33
40
  let isTagged = Reventless.DcbTag.isTagged
34
41
  let isDateTime = Reventless.DateTime.isDateTime
42
+ let isCalendarDate = Reventless.CalendarDate.isCalendarDate
35
43
 
36
44
  let isIdFieldName = (name: string): bool => {
37
45
  let lower = String.toLowerCase(name)
@@ -107,6 +115,7 @@ and shapeOf = (~parentName: string, ~fieldName: string, schema: S.t<unknown>): s
107
115
  switch schema {
108
116
  | String({const: ?Some(_)}) => ScalarString
109
117
  | String(_) if isDateTime(schema) => DateTime
118
+ | String(_) if isCalendarDate(schema) => CalendarDate
110
119
  | String(_) => isIdFieldName(fieldName) ? EntityId : ScalarString
111
120
  // `Int32` and `Port` are both whole-number formats; anything else (or no
112
121
  // format at all) is a `float` as far as the schema is concerned.
@@ -348,15 +357,71 @@ let optionalFieldNames = (schema: S.t<unknown>): array<string> =>
348
357
  | _ => []
349
358
  }
350
359
 
360
+ // A lifecycle trail's entries are `{state, at}`, and `state` holds the record's
361
+ // own lifecycle. Walked under the lifecycle field's name so it resolves to the
362
+ // enum that field already emits, rather than a second copy under the trail's
363
+ // path — the trail and the current state must read as one vocabulary.
364
+ let trailShape = (
365
+ ~parentName: string,
366
+ ~trailField: string,
367
+ ~lifecycleField: string,
368
+ schema: S.t<unknown>,
369
+ ): schemaType => {
370
+ let entryName =
371
+ parentName ++
372
+ trailField->String.charAt(0)->String.toUpperCase ++
373
+ trailField->String.slice(~start=1, ~end=trailField->String.length)
374
+ let entrySchema = switch schema {
375
+ | Array({additionalItems: Schema(item)}) => Some(item)
376
+ | _ => None
377
+ }
378
+ switch entrySchema {
379
+ | Some(Object({properties})) =>
380
+ let fields = Dict.make()
381
+ properties
382
+ ->Dict.toArray
383
+ ->Array.forEach(((propName, propSchema)) =>
384
+ fields->Dict.set(
385
+ propName,
386
+ propName === "state"
387
+ ? fromSury(~parentName, ~fieldName=lifecycleField, propSchema)
388
+ : fromSury(~parentName=entryName, ~fieldName=propName, propSchema),
389
+ )
390
+ )
391
+ let shape = ArrayOf(ObjectRef(entryName, fields))
392
+ // Walking the entries by shape skips the semantic dispatch `fromSury` does,
393
+ // so the marker is re-applied here. Without it the trail reaches a consumer
394
+ // as an unremarkable array of objects, and recognising it would come down to
395
+ // the field's name.
396
+ switch Reventless.Semantic.get(schema) {
397
+ | Some(sem) => Semantic(sem, shape)
398
+ | None => shape
399
+ }
400
+ | _ => fromSury(~parentName, ~fieldName=trailField, schema)
401
+ }
402
+ }
403
+
351
404
  let fromSuryObject = (~typeName: string, schema: S.t<unknown>): option<dict<schemaType>> =>
352
405
  switch schema {
353
406
  | Object({properties}) =>
407
+ let trail = switch (
408
+ Reventless.Lifecycle.Trail.fieldName(schema),
409
+ Reventless.Lifecycle.fieldName(schema),
410
+ ) {
411
+ | (Some(trailField), Some(lifecycleField)) => Some((trailField, lifecycleField))
412
+ | _ => None
413
+ }
354
414
  let fields = Dict.make()
355
415
  properties
356
416
  ->Dict.toArray
357
417
  ->Array.forEach(((propName, propSchema)) => {
358
418
  if propName !== "TAG" {
359
- fields->Dict.set(propName, fromSury(~parentName=typeName, ~fieldName=propName, propSchema))
419
+ let shape = switch trail {
420
+ | Some((trailField, lifecycleField)) if trailField === propName =>
421
+ trailShape(~parentName=typeName, ~trailField, ~lifecycleField, propSchema)
422
+ | _ => fromSury(~parentName=typeName, ~fieldName=propName, propSchema)
423
+ }
424
+ fields->Dict.set(propName, shape)
360
425
  }
361
426
  })
362
427
  Some(fields)
@@ -3,10 +3,12 @@
3
3
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
6
- import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/types/DateTime.res.mjs";
6
+ import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/semantic/DateTime.res.mjs";
7
7
  import * as Semantic$Reventless from "@reventlessdev/reventless-spec/src/semantic/Semantic.res.mjs";
8
+ import * as Lifecycle$Reventless from "@reventlessdev/reventless-spec/src/types/Lifecycle.res.mjs";
8
9
  import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
9
10
  import * as TaggedUnion$Reventless from "@reventlessdev/reventless-spec/src/components/TaggedUnion.res.mjs";
11
+ import * as CalendarDate$Reventless from "@reventlessdev/reventless-spec/src/semantic/CalendarDate.res.mjs";
10
12
 
11
13
  function isIdFieldName(name) {
12
14
  let lower = name.toLowerCase();
@@ -79,6 +81,8 @@ function shapeOf(parentName, fieldName, schema) {
79
81
  return "ScalarString";
80
82
  } else if (DateTime$Reventless.isDateTime(schema)) {
81
83
  return "DateTime";
84
+ } else if (CalendarDate$Reventless.isCalendarDate(schema)) {
85
+ return "CalendarDate";
82
86
  } else if (isIdFieldName(fieldName)) {
83
87
  return "EntityId";
84
88
  } else {
@@ -324,17 +328,72 @@ function optionalFieldNames(schema) {
324
328
  }
325
329
  }
326
330
 
331
+ function trailShape(parentName, trailField, lifecycleField, schema) {
332
+ let entryName = parentName + trailField.charAt(0).toUpperCase() + trailField.slice(1, trailField.length);
333
+ let entrySchema;
334
+ if (schema.type === "array") {
335
+ let item = schema.additionalItems;
336
+ entrySchema = item === "strip" || item === "strict" ? undefined : item;
337
+ } else {
338
+ entrySchema = undefined;
339
+ }
340
+ if (entrySchema === undefined) {
341
+ return fromSury(parentName, trailField, schema);
342
+ }
343
+ if (entrySchema.type !== "object") {
344
+ return fromSury(parentName, trailField, schema);
345
+ }
346
+ let fields = {};
347
+ Object.entries(entrySchema.properties).forEach(param => {
348
+ let propSchema = param[1];
349
+ let propName = param[0];
350
+ fields[propName] = propName === "state" ? fromSury(parentName, lifecycleField, propSchema) : fromSury(entryName, propName, propSchema);
351
+ });
352
+ let shape = {
353
+ TAG: "ArrayOf",
354
+ _0: {
355
+ TAG: "ObjectRef",
356
+ _0: entryName,
357
+ _1: fields
358
+ }
359
+ };
360
+ let sem = Semantic$Reventless.get(schema);
361
+ if (sem !== undefined) {
362
+ return {
363
+ TAG: "Semantic",
364
+ _0: sem,
365
+ _1: shape
366
+ };
367
+ } else {
368
+ return shape;
369
+ }
370
+ }
371
+
327
372
  function fromSuryObject(typeName, schema) {
328
373
  if (schema.type !== "object") {
329
374
  return;
330
375
  }
376
+ let match = Lifecycle$Reventless.Trail.fieldName(schema);
377
+ let match$1 = Lifecycle$Reventless.fieldName(schema);
378
+ let trail = match !== undefined && match$1 !== undefined ? [
379
+ match,
380
+ match$1
381
+ ] : undefined;
331
382
  let fields = {};
332
383
  Object.entries(schema.properties).forEach(param => {
333
384
  let propName = param[0];
334
- if (propName !== "TAG") {
335
- fields[propName] = fromSury(typeName, propName, param[1]);
385
+ if (propName === "TAG") {
336
386
  return;
337
387
  }
388
+ let propSchema = param[1];
389
+ let shape;
390
+ if (trail !== undefined) {
391
+ let trailField = trail[0];
392
+ shape = trailField === propName ? trailShape(typeName, trailField, trail[1], propSchema) : fromSury(typeName, propName, propSchema);
393
+ } else {
394
+ shape = fromSury(typeName, propName, propSchema);
395
+ }
396
+ fields[propName] = shape;
338
397
  });
339
398
  return fields;
340
399
  }
@@ -343,11 +402,14 @@ let isTagged = DcbTag$Reventless.isTagged;
343
402
 
344
403
  let isDateTime = DateTime$Reventless.isDateTime;
345
404
 
405
+ let isCalendarDate = CalendarDate$Reventless.isCalendarDate;
406
+
346
407
  let maxReportDepth = 12;
347
408
 
348
409
  export {
349
410
  isTagged,
350
411
  isDateTime,
412
+ isCalendarDate,
351
413
  isIdFieldName,
352
414
  isIdsFieldName,
353
415
  semanticCompositeNames,
@@ -358,6 +420,7 @@ export {
358
420
  collectUnclassifiedUnions,
359
421
  unclassifiedUnions,
360
422
  optionalFieldNames,
423
+ trailShape,
361
424
  fromSuryObject,
362
425
  }
363
426
  /* DcbTag-Reventless Not a pure module */
@@ -207,6 +207,7 @@ let rec fromSchemaType = (~inputNames: bool=false, st: SchemaType.schemaType): J
207
207
  | ScalarBigInt => jsonObject([("type", str("integer"))])
208
208
  | EntityId => jsonObject([("type", str("string")), ("format", str("uuid"))])
209
209
  | DateTime => jsonObject([("type", str("string")), ("format", str("date-time"))])
210
+ | CalendarDate => jsonObject([("type", str("string")), ("format", str("date"))])
210
211
  | Nullable(inner) =>
211
212
  let innerSchema = fromSchemaType(~inputNames, inner)
212
213
  jsonObject([("oneOf", JSON.Encode.array([innerSchema, jsonObject([("type", str("null"))])]))])
@@ -212,6 +212,17 @@ function fromSchemaType(inputNamesOpt, st) {
212
212
  "date-time"
213
213
  ]
214
214
  ]);
215
+ case "CalendarDate" :
216
+ return Object.fromEntries([
217
+ [
218
+ "type",
219
+ "string"
220
+ ],
221
+ [
222
+ "format",
223
+ "date"
224
+ ]
225
+ ]);
215
226
  case "ScalarString" :
216
227
  case "Unknown" :
217
228
  return Object.fromEntries([[
@@ -31,13 +31,16 @@ module Make = (
31
31
  json,
32
32
  )} actions:${actionsStr}`,
33
33
  )->Effect.runSync
34
- actions
34
+ // Rewritten here rather than at the apply below: a trail entry is
35
+ // stamped with this envelope's own time.
36
+ actions->Array.map(
37
+ Projection.rewriteAction(_, ~at=context.meta.time, ReadModelSpec.stateSchema),
38
+ )
35
39
  })
36
40
  ->Array.flat
37
41
  ->Array.reduce(Effect.succeed(), (acc, action) =>
38
42
  acc->Effect.flatMap(
39
43
  _ => {
40
- let action = Projection.rewriteAction(action, ReadModelSpec.stateSchema)
41
44
  Effect.promise(
42
45
  () => Projection.handleAction(~comp, action, Spec.operations, ReadModelSpec.subIdConfig),
43
46
  )->Effect.map(_ => ())
@@ -28,11 +28,8 @@ function Make(ReadModelSpec) {
28
28
  let actionsStr = LogFormat$ReventlessCore.actionNames(actions);
29
29
  let idxStr = (idx + 1 | 0).toString();
30
30
  Effect.runSync(EffectLogger$ReventlessCore.logInfo(comp, json, `handling event ` + idxStr + `/` + total + ` from ` + sourceName + `: ` + LogFormat$ReventlessCore.eventDetail(json) + ` actions:` + actionsStr));
31
- return actions;
32
- }).flat(), Effect.succeed(), (acc, action) => Effect.flatMap(acc, () => {
33
- let action$1 = Projection$ReventlessCore.rewriteAction(action, ReadModelSpec.stateSchema);
34
- return Effect.map(Effect.promise(() => Projection$ReventlessCore.handleAction(comp, action$1, Spec.operations, ReadModelSpec.subIdConfig)), param => {});
35
- }));
31
+ return actions.map(__x => Projection$ReventlessCore.rewriteAction(__x, context.meta.time, ReadModelSpec.stateSchema));
32
+ }).flat(), Effect.succeed(), (acc, action) => Effect.flatMap(acc, () => Effect.map(Effect.promise(() => Projection$ReventlessCore.handleAction(comp, action, Spec.operations, ReadModelSpec.subIdConfig)), param => {})));
36
33
  });
37
34
  return {
38
35
  EventProjector: EventProjector,
@@ -148,18 +148,19 @@ module Make = (
148
148
  ~detail=eventData,
149
149
  `handling event ${idxStr}/${total}: ${LogFormat.bold(eventType)}${fieldsStr} ${actionsStr}`,
150
150
  )->Effect.runSync
151
- actions
151
+ // A slice's state carries the same synthetic `displayName` the
152
+ // ppx injects for a read model's, and the same lifecycle trail.
153
+ // Rewritten here rather than at the apply below because the
154
+ // trail entry is stamped with this envelope's own time.
155
+ actions->Array.map(
156
+ FrameworkProjection.rewriteAction(_, ~at=meta.time, Spec.stateSchema),
157
+ )
152
158
  | None => []
153
159
  }
154
160
  })
155
161
  ->Array.flat
156
162
  ->Array.reduce(Effect.succeed(), (acc, action) =>
157
163
  acc->Effect.flatMap(_ => {
158
- // A slice's state carries the same synthetic `displayName` the
159
- // ppx injects for a read model's, so it is composed on the same
160
- // terms — without this the column stays null and every surface
161
- // names the row by its id.
162
- let action = FrameworkProjection.rewriteAction(action, Spec.stateSchema)
163
164
  Effect.promise(() =>
164
165
  FrameworkProjection.handleAction(~comp, action, projectionOps, Spec.subIdConfig)
165
166
  )->Effect.map(_ => ())
@@ -120,11 +120,8 @@ function Make(RuntimeEnvironment) {
120
120
  let f = Object.entries(dataDict).map(param => param[0] + `:` + JSON.stringify(param[1])).join(",");
121
121
  let fieldsStr = f === "" ? "" : `({` + f + `})`;
122
122
  Effect.runSync(EffectLogger$ReventlessCore.logInfo(comp, dataDict, `handling event ` + idxStr + `/` + total + `: ` + LogFormat$ReventlessCore.bold(eventType) + fieldsStr + ` ` + actionsStr));
123
- return actions;
124
- }).flat(), Effect.succeed(), (acc, action) => Effect.flatMap(acc, () => {
125
- let action$1 = Projection$ReventlessCore.rewriteAction(action, Spec.stateSchema);
126
- return Effect.map(Effect.promise(() => Projection$ReventlessCore.handleAction(comp, action$1, projectionOps, Spec.subIdConfig)), param => {});
127
- }));
123
+ return actions.map(__x => Projection$ReventlessCore.rewriteAction(__x, meta.time, Spec.stateSchema));
124
+ }).flat(), Effect.succeed(), (acc, action) => Effect.flatMap(acc, () => Effect.map(Effect.promise(() => Projection$ReventlessCore.handleAction(comp, action, projectionOps, Spec.subIdConfig)), param => {})));
128
125
  });
129
126
  let handler = SpecificEventCollector.makeHandler(ec, jsonEventsHandler);
130
127
  let resources = Component$ReventlessCore.outputs(queryDb).resources;
@@ -48,7 +48,9 @@ module Make = (
48
48
  `handling event ${idx.contents->Int.toString}/${count}: ${LogFormat.bold(raw.eventType)}(${id}) ${actionsStr}`,
49
49
  )->Effect.runSync
50
50
  allActions->Array.pushMany(
51
- actions->Array.map(FrameworkProjection.rewriteAction(_, Spec.stateSchema)),
51
+ actions->Array.map(
52
+ FrameworkProjection.rewriteAction(_, ~at=raw.meta.time, Spec.stateSchema),
53
+ ),
52
54
  )
53
55
  Some(event)
54
56
  | None => None
@@ -35,7 +35,7 @@ function Make(Spec) {
35
35
  });
36
36
  let actionsStr = LogFormat$ReventlessCore.actionNames(actions);
37
37
  Effect.runSync(EffectLogger$ReventlessCore.logInfo(comp, raw.data, `handling event ` + idx.contents.toString() + `/` + count + `: ` + LogFormat$ReventlessCore.bold(raw.eventType) + `(` + id + `) ` + actionsStr));
38
- allActions.push(...actions.map(__x => Projection$ReventlessCore.rewriteAction(__x, Spec.stateSchema)));
38
+ allActions.push(...actions.map(__x => Projection$ReventlessCore.rewriteAction(__x, raw.meta.time, Spec.stateSchema)));
39
39
  return Primitive_option.some(event);
40
40
  });
41
41
  let skipped = rawEvents.length - events.length | 0;
@@ -21,20 +21,6 @@ let rec isLabelShape = (t: SchemaType.schemaType): bool =>
21
21
  | _ => false
22
22
  }
23
23
 
24
- // Whether a field can hold a lifecycle: a closed set of values, or an optional
25
- // one. A free-text `lifecycle: string` is not a lifecycle — filtering a command
26
- // menu against `allowedStates` needs states to compare with.
27
- let rec isLifecycleShape = (t: SchemaType.schemaType): bool =>
28
- switch t {
29
- | Enum(_, _) => true
30
- | Nullable(inner) => isLifecycleShape(inner)
31
- // A union is a closed set of *shapes*, not of values, and `allowedStates`
32
- // compares values. The same answer the ppx gives `@lifecycle` on a union
33
- // field, which is a compile error — this is the convention rung agreeing.
34
- | TaggedUnion(_, _) => false
35
- | _ => false
36
- }
37
-
38
24
  // Field names that say "this one is the record's name" in the only way
39
25
  // available short of the `@displayName` annotation. Matched case-insensitively
40
26
  // and *exactly*: `customerName` holds a customer's name, not this record's.
@@ -43,33 +29,10 @@ let conventionalLabelNames = ["name", "title", "label", "displayname"]
43
29
  let shapeOfField = (~entityName: string, ~name: string, schema: S.t<unknown>): SchemaType.schemaType =>
44
30
  SchemaType.fromSury(~parentName=entityName, ~fieldName=name, schema)
45
31
 
46
- // The field holding the entity's lifecycle, for filtering a per-row command menu:
47
- // `@lifecycle`, else an enum field literally named `lifecycle`, else None. Not
48
- // keyed on `status` — a promiscuous name that would guess, and guess often.
49
- let lifecycleFieldFromStateSchema = (
50
- ~entityName: string,
51
- stateSchema: S.t<unknown>,
52
- ): option<string> => {
53
- let annotated = switch Reventless.StateAnnotations.getSpec(stateSchema) {
54
- | Some(spec) => spec.lifecycle
55
- | None => None
56
- }
57
- switch annotated {
58
- | Some(_) as some => some
59
- | None =>
60
- switch stateSchema {
61
- | Object({properties}) =>
62
- properties
63
- ->Dict.get("lifecycle")
64
- ->Option.flatMap(schema =>
65
- isLifecycleShape(shapeOfField(~entityName, ~name="lifecycle", schema))
66
- ? Some("lifecycle")
67
- : None
68
- )
69
- | _ => None
70
- }
71
- }
72
- }
32
+ // The field holding the entity's lifecycle, for filtering a per-row command
33
+ // menu. One rule, stated in `Reventless.Lifecycle` — the projection machinery
34
+ // resolves the field a trail entry records against from the same place.
35
+ let lifecycleFieldFromStateSchema = Reventless.Lifecycle.fieldName
73
36
 
74
37
  // The field whose truth withdraws a row from ordinary reads. Annotation-only —
75
38
  // no convention rung, since guessing wrong here makes rows vanish.
@@ -113,7 +76,7 @@ let checkRetiredValue = (~entityName: string, stateSchema: S.t<unknown>): retire
113
76
  | None | Some({values: None}) => NotDeclared
114
77
  | Some({field, values: Some(values)}) =>
115
78
  let named = values->Array.join(", ")
116
- let lifecycle = lifecycleFieldFromStateSchema(~entityName, stateSchema)
79
+ let lifecycle = lifecycleFieldFromStateSchema(stateSchema)
117
80
  if lifecycle != Some(field) {
118
81
  log.warn(
119
82
  ~comp="Plugin_Structure",
@@ -190,7 +153,7 @@ let lifecycleStatesFromStateSchema = (
190
153
  ~entityName: string,
191
154
  stateSchema: S.t<unknown>,
192
155
  ): option<array<string>> =>
193
- lifecycleFieldFromStateSchema(~entityName, stateSchema)->Option.flatMap(field =>
156
+ lifecycleFieldFromStateSchema(stateSchema)->Option.flatMap(field =>
194
157
  switch stateSchema {
195
158
  | Object({properties}) =>
196
159
  properties
@@ -965,7 +928,7 @@ let queryableDefFromSpec = (
965
928
  labelField: label.field,
966
929
  searchableFields: label.searchableFields,
967
930
  labelFieldSource: Some(labelFieldSourceToString(label.source)),
968
- lifecycleField: lifecycleFieldFromStateSchema(~entityName=name, stateSchema),
931
+ lifecycleField: lifecycleFieldFromStateSchema(stateSchema),
969
932
  ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
970
933
  retiredField: retiredFieldFromStateSchema(stateSchema),
971
934
  retiredValues: retiredValuesFromStateSchema(stateSchema),
@@ -1410,7 +1373,7 @@ let make = (
1410
1373
  labelField: label.field,
1411
1374
  searchableFields: label.searchableFields,
1412
1375
  labelFieldSource: Some(labelFieldSourceToString(label.source)),
1413
- lifecycleField: lifecycleFieldFromStateSchema(~entityName=R.Spec.name, stateSchema),
1376
+ lifecycleField: lifecycleFieldFromStateSchema(stateSchema),
1414
1377
  // Same schema `lifecycleField` reads, so the two cannot disagree about which
1415
1378
  // fields this view has.
1416
1379
  ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
@@ -1451,7 +1414,7 @@ let make = (
1451
1414
  labelField: label.field,
1452
1415
  searchableFields: label.searchableFields,
1453
1416
  labelFieldSource: Some(labelFieldSourceToString(label.source)),
1454
- lifecycleField: lifecycleFieldFromStateSchema(~entityName=SVS.Spec.name, stateSchema),
1417
+ lifecycleField: lifecycleFieldFromStateSchema(stateSchema),
1455
1418
  ownerField: Reventless.Owner.fieldNames(stateSchema)->Array.get(0),
1456
1419
  retiredField: retiredFieldFromStateSchema(stateSchema),
1457
1420
  retiredValues: retiredValuesFromStateSchema(stateSchema),
@@ -12,6 +12,7 @@ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js
12
12
  import * as Trait$Reventless from "@reventlessdev/reventless-spec/src/types/Trait.res.mjs";
13
13
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
14
14
  import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
15
+ import * as Lifecycle$Reventless from "@reventlessdev/reventless-spec/src/types/Lifecycle.res.mjs";
15
16
  import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
16
17
  import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
17
18
  import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
@@ -47,24 +48,6 @@ function isLabelShape(_t) {
47
48
  };
48
49
  }
49
50
 
50
- function isLifecycleShape(_t) {
51
- while (true) {
52
- let t = _t;
53
- if (typeof t !== "object") {
54
- return false;
55
- }
56
- switch (t.TAG) {
57
- case "Nullable" :
58
- _t = t._0;
59
- continue;
60
- case "Enum" :
61
- return true;
62
- default:
63
- return false;
64
- }
65
- };
66
- }
67
-
68
51
  let conventionalLabelNames = [
69
52
  "name",
70
53
  "title",
@@ -74,22 +57,6 @@ let conventionalLabelNames = [
74
57
 
75
58
  let shapeOfField = SchemaType$ReventlessCore.fromSury;
76
59
 
77
- function lifecycleFieldFromStateSchema(entityName, stateSchema) {
78
- let spec = StateAnnotations$Reventless.getSpec(stateSchema);
79
- let annotated = spec !== undefined ? spec.lifecycle : undefined;
80
- if (annotated !== undefined) {
81
- return annotated;
82
- } else if (stateSchema.type === "object") {
83
- return Stdlib_Option.flatMap(stateSchema.properties["lifecycle"], schema => {
84
- if (isLifecycleShape(SchemaType$ReventlessCore.fromSury(entityName, "lifecycle", schema))) {
85
- return "lifecycle";
86
- }
87
- });
88
- } else {
89
- return;
90
- }
91
- }
92
-
93
60
  function retiredFromStateSchema(stateSchema) {
94
61
  let spec = StateAnnotations$Reventless.getSpec(stateSchema);
95
62
  if (spec !== undefined) {
@@ -120,7 +87,7 @@ function checkRetiredValue(entityName, stateSchema) {
120
87
  }
121
88
  let field = match.field;
122
89
  let named = values.join(", ");
123
- let lifecycle = lifecycleFieldFromStateSchema(entityName, stateSchema);
90
+ let lifecycle = Lifecycle$Reventless.fieldName(stateSchema);
124
91
  if (Primitive_object.notequal(lifecycle, field)) {
125
92
  log.warn("Plugin_Structure", undefined, entityName + `: @retired(` + named + `) is on "` + field + `", which is not this record's lifecycle field` + Stdlib_Option.getOr(Stdlib_Option.map(lifecycle, f => ` (that is "` + f + `")`), " (it declares none)") + `. A retirement state no command's declared edge can name loses the command filtering the state form exists for.`);
126
93
  }
@@ -169,7 +136,7 @@ function reportRetiredStates(pluginName, failures, unchecked) {
169
136
  }
170
137
 
171
138
  function lifecycleStatesFromStateSchema(entityName, stateSchema) {
172
- return Stdlib_Option.flatMap(lifecycleFieldFromStateSchema(entityName, stateSchema), field => {
139
+ return Stdlib_Option.flatMap(Lifecycle$Reventless.fieldName(stateSchema), field => {
173
140
  if (stateSchema.type === "object") {
174
141
  return Stdlib_Option.map(stateSchema.properties[field], schema => {
175
142
  let match = SchemaType$ReventlessCore.fromSury(entityName, field, schema);
@@ -715,7 +682,7 @@ function queryableDefFromSpec(plugin, name, stateSchema, authorization, visibili
715
682
  labelField: label.field,
716
683
  searchableFields: label.searchableFields,
717
684
  labelFieldSource: labelFieldSourceToString(label.source),
718
- lifecycleField: lifecycleFieldFromStateSchema(name, stateSchema),
685
+ lifecycleField: Lifecycle$Reventless.fieldName(stateSchema),
719
686
  ownerField: Owner$Reventless.fieldNames(stateSchema)[0],
720
687
  retiredField: retiredFieldFromStateSchema(stateSchema),
721
688
  retiredValues: retiredValuesFromStateSchema(stateSchema),
@@ -1024,7 +991,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1024
991
  labelField: label.field,
1025
992
  searchableFields: label.searchableFields,
1026
993
  labelFieldSource: labelFieldSourceToString(label.source),
1027
- lifecycleField: lifecycleFieldFromStateSchema(R.Spec.name, stateSchema),
994
+ lifecycleField: Lifecycle$Reventless.fieldName(stateSchema),
1028
995
  ownerField: Owner$Reventless.fieldNames(stateSchema)[0],
1029
996
  retiredField: retiredFieldFromStateSchema(stateSchema),
1030
997
  retiredValues: retiredValuesFromStateSchema(stateSchema),
@@ -1056,7 +1023,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1056
1023
  labelField: label.field,
1057
1024
  searchableFields: label.searchableFields,
1058
1025
  labelFieldSource: labelFieldSourceToString(label.source),
1059
- lifecycleField: lifecycleFieldFromStateSchema(SVS.Spec.name, stateSchema),
1026
+ lifecycleField: Lifecycle$Reventless.fieldName(stateSchema),
1060
1027
  ownerField: Owner$Reventless.fieldNames(stateSchema)[0],
1061
1028
  retiredField: retiredFieldFromStateSchema(stateSchema),
1062
1029
  retiredValues: retiredValuesFromStateSchema(stateSchema),
@@ -1383,11 +1350,12 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1383
1350
  };
1384
1351
  }
1385
1352
 
1353
+ let lifecycleFieldFromStateSchema = Lifecycle$Reventless.fieldName;
1354
+
1386
1355
  export {
1387
1356
  log,
1388
1357
  declaredTransitionsOnly,
1389
1358
  isLabelShape,
1390
- isLifecycleShape,
1391
1359
  conventionalLabelNames,
1392
1360
  shapeOfField,
1393
1361
  lifecycleFieldFromStateSchema,
@@ -2,7 +2,7 @@
2
2
 
3
3
  import * as Id$Reventless from "@reventlessdev/reventless-spec/src/types/Id.res.mjs";
4
4
  import * as Plugin$ReventlessCore from "../component/Plugin.res.mjs";
5
- import * as UiFragmentRegistry$ReventlessCore from "../../admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res.mjs";
5
+ import * as UiFragmentRegistry$ReventlessCore from "../../admin/UiFragmentRegistry/StateChange/UiFragmentRegistry.res.mjs";
6
6
  import * as ExtensionPointMapping$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/ExtensionPointMapping.res.mjs";
7
7
  import * as PluginExtensionPointSpec$ReventlessInfra from "@reventlessdev/reventless-infra/src/types/PluginExtensionPointSpec.res.mjs";
8
8
 
@@ -4,7 +4,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
6
6
  import * as SchemaType$ReventlessCore from "../../src/components/Api/SchemaType.res.mjs";
7
- import * as UiFragments$ReventlessCore from "../../src/admin/UiFragmentRegistry/StateViewSlice/UiFragments.res.mjs";
7
+ import * as UiFragments$ReventlessCore from "../../src/admin/UiFragmentRegistry/StateView/UiFragments.res.mjs";
8
8
  import * as Platform_UIFragmentsApi$ReventlessCore from "../../src/admin/Platform_UIFragmentsApi.res.mjs";
9
9
  import * as Platform_PluginStructuresApi$ReventlessCore from "../../src/admin/Platform_PluginStructuresApi.res.mjs";
10
10
  import * as Platform_ComponentDefinitionsApi$ReventlessCore from "../../src/admin/Platform_ComponentDefinitionsApi.res.mjs";
@@ -0,0 +1,74 @@
1
+ open JestGlobals
2
+
3
+ // A field marker and a branded semantic type on the same field. Both markers
4
+ // used to test for the literal `string` keyword, so both saw the brand instead
5
+ // of the string and refused a field they should have taken — which would have
6
+ // made declaring what a field *is* cost it whatever the marker gave.
7
+ //
8
+ // The composition assertion is the one that matters. `@owner` cannot see the
9
+ // schema sury-ppx derives from `Email.t`, so the tempting fix is to inject
10
+ // `Owner.string` over it: that type-checks, passes an "is it marked" test, and
11
+ // silently leaves the field a plain string with the address grammar gone.
12
+ // Asserting the marker is there proves nothing on its own — the rejection below
13
+ // is what says the brand survived.
14
+ describe("a field marker on a branded semantic:", () => {
15
+ let stateSchema = PsBrandedMarkersReadModel.stateSchema->S.castToUnknown
16
+
17
+ let fieldOf = name =>
18
+ switch stateSchema {
19
+ | Object({properties}) => properties->Dict.get(name)
20
+ | _ => None
21
+ }
22
+
23
+ describe("@owner on an Email field:", () => {
24
+ testSync("marks the field as the owner", () =>
25
+ expect(fieldOf("email")->Option.mapOr(false, Reventless.Owner.isFieldOwner))->toBe(true)
26
+ )
27
+
28
+ testSync("keeps the email semantic", () =>
29
+ expect(
30
+ fieldOf("email")
31
+ ->Option.flatMap(Reventless.Semantic.getFrom)
32
+ ->Option.map(s => s.id),
33
+ )->toEqual(Some("email"))
34
+ )
35
+
36
+ // The regression a substituting fix would land, and it lands in silence:
37
+ // the row still scopes to its owner, and the field accepts anything.
38
+ testSync("still rejects what is not an address", () => {
39
+ let parses = raw =>
40
+ switch fieldOf("email") {
41
+ | Some(schema) =>
42
+ switch raw->JSON.Encode.string->S.parseOrThrow(~to=schema) {
43
+ | _ => true
44
+ | exception _ => false
45
+ }
46
+ | None => false
47
+ }
48
+ expect((parses("buyer@example.com"), parses("buyer")))->toEqual((true, false))
49
+ })
50
+ })
51
+
52
+ describe("@displayName on a DateTime field:", () => {
53
+ testSync("names the row by it", () =>
54
+ expect(
55
+ S.Metadata.get(stateSchema, ~id=Reventless.DisplayName.displayNameId)->Option.map(d =>
56
+ d.fields
57
+ ),
58
+ )->toEqual(Some(["openedAt"]))
59
+ )
60
+
61
+ testSync("and the instant grammar survives", () => {
62
+ let parses = raw =>
63
+ switch fieldOf("openedAt") {
64
+ | Some(schema) =>
65
+ switch raw->JSON.Encode.string->S.parseOrThrow(~to=schema) {
66
+ | _ => true
67
+ | exception _ => false
68
+ }
69
+ | None => false
70
+ }
71
+ expect((parses("2026-03-02T09:00:00Z"), parses("tomorrow")))->toEqual((true, false))
72
+ })
73
+ })
74
+ })