@reventlessdev/reventless-core 3.0.0-alpha.191 → 3.0.0-alpha.193

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 (32) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +6 -6
  3. package/src/admin/Platform_Admin.res.mjs +4 -0
  4. package/src/admin/Platform_Admin_Structure.res +1 -0
  5. package/src/admin/Platform_Admin_Structure.res.mjs +2 -1
  6. package/src/components/Aggregate/Aggregate.res +7 -7
  7. package/src/components/Aggregate/Aggregate.res.mjs +7 -2
  8. package/src/components/Aggregate/Aggregate_Builder.res +4 -1
  9. package/src/components/Aggregate/Aggregate_Builder.res.mjs +11 -3
  10. package/src/components/Builder_Helpers.res +37 -4
  11. package/src/components/Builder_Helpers.res.mjs +24 -2
  12. package/src/components/SideEffectHandler/SideEffectHandler.res +11 -0
  13. package/src/components/SideEffectHandler/SideEffectHandler_Builder.res +2 -0
  14. package/src/components/SideEffectHandler/SideEffectHandler_Builder.res.mjs +3 -1
  15. package/src/components/Task/Task_Builder.res +11 -0
  16. package/src/components/Task/Task_Builder.res.mjs +2 -0
  17. package/src/plugin/component/Capability_Inference.res +68 -0
  18. package/src/plugin/component/Capability_Inference.res.mjs +79 -0
  19. package/src/plugin/component/Plugin_Helpers.res +18 -7
  20. package/src/plugin/component/Plugin_Helpers.res.mjs +20 -1
  21. package/src/plugin/component/Plugin_Structure.res +91 -34
  22. package/src/plugin/component/Plugin_Structure.res.mjs +82 -12
  23. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +2 -0
  24. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +8 -4
  25. package/tests/api/SuryToJsonSchemaTest.res +42 -0
  26. package/tests/api/SuryToJsonSchemaTest.res.mjs +36 -0
  27. package/tests/message/MessageTest.res +1 -0
  28. package/tests/message/MessageTest.res.mjs +2 -1
  29. package/tests/plugin/PluginStructureTest.res +145 -0
  30. package/tests/plugin/PluginStructureTest.res.mjs +138 -0
  31. package/tests/plugin/StateChangeSlice/PsChangePhoto.res +33 -0
  32. package/tests/plugin/StateChangeSlice/PsChangePhoto.res.mjs +64 -0
@@ -481,23 +481,34 @@ let make = (
481
481
  // An unqualified store belongs to the declaring plugin, so every collected
482
482
  // entry is qualified to `{plugin}.{store}` — one shape, and the string is
483
483
  // directly the store's identity. Many fields legitimately name one store, so
484
- // the result is deduplicated.
485
-
486
- let storesFromProperties = (properties: dict<S.t<unknown>>): array<string> =>
484
+ // the key set is deduplicated.
485
+ //
486
+ // Each collected entry keeps its declaration site `(component, field)` as
487
+ // provenance, and `requiredStores` is derived from the collected entries —
488
+ // one walk, so the capability manifest's provenance and the deploy's key set
489
+ // cannot come from different readings of the schemas.
490
+
491
+ let storesFromProperties = (~component, properties: dict<S.t<unknown>>): array<
492
+ Reventless.Plugin.requiredStoreDeclaration,
493
+ > =>
487
494
  properties
488
495
  ->Dict.toArray
489
- ->Array.filterMap(((_, fieldSchema)) =>
490
- Reventless.StorageRef.getStore(fieldSchema)->Option.map(target =>
491
- target.plugin->Option.getOr(name) ++ "." ++ target.store
492
- )
496
+ ->Array.filterMap(((field, fieldSchema)) =>
497
+ Reventless.StorageRef.getStore(fieldSchema)->Option.map(target => {
498
+ Reventless.Plugin.store: target.plugin->Option.getOr(name) ++ "." ++ target.store,
499
+ component,
500
+ field,
501
+ })
493
502
  )
494
503
 
495
504
  // Commands and events are unions of per-variant objects; state is a single
496
505
  // object. Both reduce to "the properties of every object in this schema".
497
- let storesFromSchema = (schema: S.t<unknown>): array<string> => {
506
+ let storesFromSchema = (~component, schema: S.t<unknown>): array<
507
+ Reventless.Plugin.requiredStoreDeclaration,
508
+ > => {
498
509
  let fromVariant = v =>
499
510
  switch v {
500
- | S.Object({properties}) => storesFromProperties(properties)
511
+ | S.Object({properties}) => storesFromProperties(~component, properties)
501
512
  | _ => []
502
513
  }
503
514
  switch schema {
@@ -506,37 +517,82 @@ let make = (
506
517
  }
507
518
  }
508
519
 
509
- let requiredStores =
520
+ let compareDeclarations = (
521
+ a: Reventless.Plugin.requiredStoreDeclaration,
522
+ b: Reventless.Plugin.requiredStoreDeclaration,
523
+ ) => {
524
+ let byStore = String.compare(a.store, b.store)
525
+ if byStore != Ordering.equal {
526
+ byStore
527
+ } else {
528
+ let byComponent = String.compare(a.component, b.component)
529
+ if byComponent != Ordering.equal {
530
+ byComponent
531
+ } else {
532
+ String.compare(a.field, b.field)
533
+ }
534
+ }
535
+ }
536
+
537
+ // The (component, schema) sites the store walk reads. One list feeds both the
538
+ // declared-store collection and the heuristic lint below, so a field is read
539
+ // exactly once: either its declaration is collected, or its name is eligible
540
+ // to warn — never a third reading that could disagree with both.
541
+ let storeDeclarationSites: array<(string, S.t<unknown>)> =
510
542
  [
511
- aggregates->Array.flatMap((module(A: ReventlessInfra.Aggregate.T with type api = api)) =>
512
- Array.concat(
513
- storesFromSchema(A.Spec.commandSchema->S.castToUnknown),
514
- storesFromSchema(A.Spec.eventSchema->S.castToUnknown),
515
- )
516
- ),
517
- stateChangeSlices->Array.flatMap((module(SCS: ReventlessInfra.StateChangeSlice.T)) =>
518
- Array.concat(
519
- storesFromSchema(SCS.Spec.commandSchema->S.castToUnknown),
520
- storesFromSchema(SCS.Spec.eventSchema->S.castToUnknown),
521
- )
522
- ),
523
- stateViewSlices->Array.flatMap((module(SVS: ReventlessInfra.StateViewSlice.T)) =>
524
- storesFromSchema(SVS.Spec.stateSchema->S.castToUnknown)
525
- ),
526
- readModels->Array.flatMap((
543
+ aggregates->Array.flatMap((module(A: ReventlessInfra.Aggregate.T with type api = api)) => [
544
+ (A.Spec.name, A.Spec.commandSchema->S.castToUnknown),
545
+ (A.Spec.name, A.Spec.eventSchema->S.castToUnknown),
546
+ ]),
547
+ stateChangeSlices->Array.flatMap((module(SCS: ReventlessInfra.StateChangeSlice.T)) => [
548
+ (SCS.Spec.name, SCS.Spec.commandSchema->S.castToUnknown),
549
+ (SCS.Spec.name, SCS.Spec.eventSchema->S.castToUnknown),
550
+ ]),
551
+ stateViewSlices->Array.map((module(SVS: ReventlessInfra.StateViewSlice.T)) => (
552
+ SVS.Spec.name,
553
+ SVS.Spec.stateSchema->S.castToUnknown,
554
+ )),
555
+ readModels->Array.map((
527
556
  module(R: ReventlessInfra.ReadModel.T with type api = api and type role = role),
528
- ) => storesFromSchema(R.Spec.stateSchema->S.castToUnknown)),
529
- automationSlices->Array.flatMap((module(AS: ReventlessInfra.AutomationSlice.T)) =>
530
- storesFromSchema(AS.Spec.commandSchema->S.castToUnknown)
531
- ),
532
- inboundTranslationSlices->Array.flatMap((
557
+ ) => (R.Spec.name, R.Spec.stateSchema->S.castToUnknown)),
558
+ automationSlices->Array.map((module(AS: ReventlessInfra.AutomationSlice.T)) => (
559
+ AS.Spec.name,
560
+ AS.Spec.commandSchema->S.castToUnknown,
561
+ )),
562
+ inboundTranslationSlices->Array.map((
533
563
  module(ITS: ReventlessInfra.InboundTranslationSlice.T),
534
- ) => storesFromSchema(ITS.Spec.commandSchema->S.castToUnknown)),
535
- ]
536
- ->Array.flat
564
+ ) => (ITS.Spec.name, ITS.Spec.commandSchema->S.castToUnknown)),
565
+ ]->Array.flat
566
+
567
+ // Sorted so the emitted manifest is deterministic; identical triples collapse
568
+ // (one store named by a component's command field and again by its event
569
+ // field under the same field name is one declaration site).
570
+ let requiredStoreDeclarations =
571
+ storeDeclarationSites
572
+ ->Array.flatMap(((component, schema)) => storesFromSchema(~component, schema))
573
+ ->Array.toSorted(compareDeclarations)
574
+ ->Array.reduce([], (acc, d) =>
575
+ switch acc->Array.last {
576
+ | Some(prev) if prev == d => acc
577
+ | _ => acc->Array.concat([d])
578
+ }
579
+ )
580
+
581
+ let requiredStores =
582
+ requiredStoreDeclarations
583
+ ->Array.map(d => d.store)
537
584
  ->Belt.Set.String.fromArray
538
585
  ->Belt.Set.String.toArray
539
586
 
587
+ // Heuristic-only matches — a field *named* like a stored-object ref with no
588
+ // `@storageRef` — warn and provision nothing. Declaration outranks inference;
589
+ // the warning names the annotation that would settle it.
590
+ storeDeclarationSites->Array.forEach(((component, schema)) =>
591
+ Capability_Inference.scanSchema(~component, schema)->Array.forEach(w =>
592
+ log.warn(~comp="Plugin_Structure", Capability_Inference.message(w))
593
+ )
594
+ )
595
+
540
596
  // ── Build queryable defs ───────────────────────────────────────────────────
541
597
  //
542
598
  // Internal ReadModels and StateViewSlices (marked `@@reventless.visibility(Internal)`)
@@ -755,5 +811,6 @@ let make = (
755
811
  extensions: extensionDefs,
756
812
  extensionPoints: Some(extensionPointDefs),
757
813
  requiredStores: Some(requiredStores),
814
+ requiredStoreDeclarations: Some(requiredStoreDeclarations),
758
815
  }
759
816
  }
@@ -5,6 +5,8 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Primitive_int from "@rescript/runtime/lib/es6/Primitive_int.js";
6
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
7
  import * as Belt_SetString from "@rescript/runtime/lib/es6/Belt_SetString.js";
8
+ import * as Primitive_object from "@rescript/runtime/lib/es6/Primitive_object.js";
9
+ import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
8
10
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
9
11
  import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
10
12
  import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
@@ -15,6 +17,7 @@ import * as SchemaType$ReventlessCore from "../../components/Api/SchemaType.res.
15
17
  import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
16
18
  import * as ApiNoApiHelpers$ReventlessCore from "../../components/Api/ApiNoApiHelpers.res.mjs";
17
19
  import * as SuryToJsonSchema$ReventlessCore from "../../components/Api/SuryToJsonSchema.res.mjs";
20
+ import * as Capability_Inference$ReventlessCore from "./Capability_Inference.res.mjs";
18
21
  import * as ApiTargetStateHelpers$ReventlessCore from "../../components/Api/ApiTargetStateHelpers.res.mjs";
19
22
  import * as ApiAllowedStatesHelpers$ReventlessCore from "../../components/Api/ApiAllowedStatesHelpers.res.mjs";
20
23
 
@@ -386,11 +389,70 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
386
389
  let match$3 = scored[0];
387
390
  return match$3[0];
388
391
  };
389
- let storesFromSchema = schema => {
392
+ let compareDeclarations = (a, b) => {
393
+ let byStore = Primitive_string.compare(a.store, b.store);
394
+ if (byStore !== 0) {
395
+ return byStore;
396
+ }
397
+ let byComponent = Primitive_string.compare(a.component, b.component);
398
+ if (byComponent !== 0) {
399
+ return byComponent;
400
+ } else {
401
+ return Primitive_string.compare(a.field, b.field);
402
+ }
403
+ };
404
+ let storeDeclarationSites = [
405
+ aggregates.flatMap(A => [
406
+ [
407
+ A.Spec.name,
408
+ A.Spec.commandSchema
409
+ ],
410
+ [
411
+ A.Spec.name,
412
+ A.Spec.eventSchema
413
+ ]
414
+ ]),
415
+ stateChangeSlices.flatMap(SCS => [
416
+ [
417
+ SCS.Spec.name,
418
+ SCS.Spec.commandSchema
419
+ ],
420
+ [
421
+ SCS.Spec.name,
422
+ SCS.Spec.eventSchema
423
+ ]
424
+ ]),
425
+ stateViewSlices.map(SVS => [
426
+ SVS.Spec.name,
427
+ SVS.Spec.stateSchema
428
+ ]),
429
+ readModels.map(R => [
430
+ R.Spec.name,
431
+ R.Spec.stateSchema
432
+ ]),
433
+ automationSlices.map(AS => [
434
+ AS.Spec.name,
435
+ AS.Spec.commandSchema
436
+ ]),
437
+ inboundTranslationSlices.map(ITS => [
438
+ ITS.Spec.name,
439
+ ITS.Spec.commandSchema
440
+ ])
441
+ ].flat();
442
+ let requiredStoreDeclarations = Stdlib_Array.reduce(storeDeclarationSites.flatMap(param => {
443
+ let component = param[0];
444
+ let schema = param[1];
390
445
  let fromVariant = v => {
391
446
  if (v.type === "object") {
392
447
  let properties = v.properties;
393
- return Stdlib_Array.filterMap(Object.entries(properties), param => Stdlib_Option.map(StorageRef$Reventless.getStore(param[1]), target => Stdlib_Option.getOr(target.plugin, name) + "." + target.store));
448
+ return Stdlib_Array.filterMap(Object.entries(properties), param => {
449
+ let field = param[0];
450
+ return Stdlib_Option.map(StorageRef$Reventless.getStore(param[1]), target => ({
451
+ store: Stdlib_Option.getOr(target.plugin, name) + "." + target.store,
452
+ component: component,
453
+ field: field
454
+ }));
455
+ });
394
456
  } else {
395
457
  return [];
396
458
  }
@@ -400,15 +462,22 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
400
462
  } else {
401
463
  return fromVariant(schema);
402
464
  }
403
- };
404
- let requiredStores = Belt_SetString.toArray(Belt_SetString.fromArray([
405
- aggregates.flatMap(A => storesFromSchema(A.Spec.commandSchema).concat(storesFromSchema(A.Spec.eventSchema))),
406
- stateChangeSlices.flatMap(SCS => storesFromSchema(SCS.Spec.commandSchema).concat(storesFromSchema(SCS.Spec.eventSchema))),
407
- stateViewSlices.flatMap(SVS => storesFromSchema(SVS.Spec.stateSchema)),
408
- readModels.flatMap(R => storesFromSchema(R.Spec.stateSchema)),
409
- automationSlices.flatMap(AS => storesFromSchema(AS.Spec.commandSchema)),
410
- inboundTranslationSlices.flatMap(ITS => storesFromSchema(ITS.Spec.commandSchema))
411
- ].flat()));
465
+ }).toSorted(compareDeclarations), [], (acc, d) => {
466
+ let prev = Stdlib_Array.last(acc);
467
+ if (prev !== undefined) {
468
+ if (Primitive_object.equal(prev, d)) {
469
+ return acc;
470
+ } else {
471
+ return acc.concat([d]);
472
+ }
473
+ } else {
474
+ return acc.concat([d]);
475
+ }
476
+ });
477
+ let requiredStores = Belt_SetString.toArray(Belt_SetString.fromArray(requiredStoreDeclarations.map(d => d.store)));
478
+ storeDeclarationSites.forEach(param => {
479
+ Capability_Inference$ReventlessCore.scanSchema(param[0], param[1]).forEach(w => log.warn("Plugin_Structure", undefined, Capability_Inference$ReventlessCore.message(w)));
480
+ });
412
481
  let visibilityTag = v => {
413
482
  if (v === "Public") {
414
483
  return;
@@ -554,7 +623,8 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
554
623
  inboundTranslationSlices: inboundTranslationSliceDefs,
555
624
  extensions: extensionDefs,
556
625
  extensionPoints: extensionPointDefs,
557
- requiredStores: requiredStores
626
+ requiredStores: requiredStores,
627
+ requiredStoreDeclarations: requiredStoreDeclarations
558
628
  };
559
629
  }
560
630
 
@@ -87,6 +87,7 @@ let structure: pluginStructure = {
87
87
  ],
88
88
  extensionPoints: None,
89
89
  requiredStores: None,
90
+ requiredStoreDeclarations: None,
90
91
  }
91
92
 
92
93
  let encoded = Platform_ComponentDefinitionsApi.encodePluginStructureEntry(
@@ -300,6 +301,7 @@ describe("allowedStates + statusField populated", () => {
300
301
  extensions: [],
301
302
  extensionPoints: None,
302
303
  requiredStores: None,
304
+ requiredStoreDeclarations: None,
303
305
  }
304
306
 
305
307
  let json =
@@ -122,7 +122,8 @@ let structure = {
122
122
  inboundTranslationSlices: structure_inboundTranslationSlices,
123
123
  extensions: structure_extensions,
124
124
  extensionPoints: undefined,
125
- requiredStores: undefined
125
+ requiredStores: undefined,
126
+ requiredStoreDeclarations: undefined
126
127
  };
127
128
 
128
129
  let encoded = Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", structure);
@@ -221,7 +222,8 @@ globalThis.describe("translation-slice externalSystem round-trip", () => {
221
222
  inboundTranslationSlices: externalStructure_inboundTranslationSlices,
222
223
  extensions: externalStructure_extensions,
223
224
  extensionPoints: undefined,
224
- requiredStores: undefined
225
+ requiredStores: undefined,
226
+ requiredStoreDeclarations: undefined
225
227
  };
226
228
  let externalJson = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Catalog", externalStructure));
227
229
  globalThis.test("surfaces Some outbound externalSystem as its inner string", () => {
@@ -272,7 +274,8 @@ globalThis.describe("visibility filtering (deployed AutoUI hides Internal)", ()
272
274
  inboundTranslationSlices: mixed_inboundTranslationSlices,
273
275
  extensions: mixed_extensions,
274
276
  extensionPoints: undefined,
275
- requiredStores: undefined
277
+ requiredStores: undefined,
278
+ requiredStoreDeclarations: undefined
276
279
  };
277
280
  let mixedJson = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Ordering", mixed));
278
281
  globalThis.test("excludes an Internal queryableDef from the encoded read-side", () => {
@@ -351,7 +354,8 @@ globalThis.describe("allowedStates + statusField populated", () => {
351
354
  inboundTranslationSlices: structureWithStates_inboundTranslationSlices,
352
355
  extensions: structureWithStates_extensions,
353
356
  extensionPoints: undefined,
354
- requiredStores: undefined
357
+ requiredStores: undefined,
358
+ requiredStoreDeclarations: undefined
355
359
  };
356
360
  let json = JSON.stringify(Platform_ComponentDefinitionsApi$ReventlessCore.encodePluginStructureEntry("Platform", structureWithStates));
357
361
  globalThis.test("encodes populated allowedStates as a JSON array", () => {
@@ -619,4 +619,46 @@ describe("SuryToJsonSchema:", () => {
619
619
  expect(semanticOf(json, "imageUrl"))->toEqual(Some(JSON.Encode.string("storageRef")))
620
620
  })
621
621
  })
622
+
623
+ // The claim the branded scalars rest on: marking a field adds an annotation,
624
+ // not a shape. A string stays a string and a number stays a number on the
625
+ // wire, which is what makes them retrofittable onto a log that already has
626
+ // events in it. One of each is enough — the emission path is the same for all
627
+ // seven, and it is the *shape* half that is the load-bearing assertion here.
628
+ describe("branded scalars keep their underlying shape:", () => {
629
+ let fieldOf = (json, name) => getPropertyOf(json, name)
630
+ let keyOf = (json, name, key) => fieldOf(json, name)->Option.flatMap(s => getProperty(s, key))
631
+
632
+ let json = SuryToJsonSchema.deriveObjectSchema(
633
+ S.schema(s =>
634
+ {
635
+ "contactEmail": s.matches(Reventless.Email.schema),
636
+ "taxRate": s.matches(Reventless.Percent.schema),
637
+ }
638
+ )->S.castToUnknown,
639
+ )
640
+
641
+ testSync("a string scalar stays a string and carries its id", () =>
642
+ expect((
643
+ keyOf(json, "contactEmail", "type"),
644
+ keyOf(json, "contactEmail", "x-reventless-semantic"),
645
+ ))->toEqual((Some(JSON.Encode.string("string")), Some(JSON.Encode.string("email"))))
646
+ )
647
+
648
+ testSync("a numeric scalar stays a number and carries its id", () =>
649
+ expect((
650
+ keyOf(json, "taxRate", "type"),
651
+ keyOf(json, "taxRate", "x-reventless-semantic"),
652
+ ))->toEqual((Some(JSON.Encode.string("number")), Some(JSON.Encode.string("percent"))))
653
+ )
654
+
655
+ // The provenance half: a typed field outranks an annotated one, and this is
656
+ // the key a consumer reads to tell them apart.
657
+ testSync("both name the type as their source", () =>
658
+ expect((
659
+ keyOf(json, "contactEmail", "x-reventless-semantic-source"),
660
+ keyOf(json, "taxRate", "x-reventless-semantic-source"),
661
+ ))->toEqual((Some(JSON.Encode.string("type")), Some(JSON.Encode.string("type"))))
662
+ )
663
+ })
622
664
  })
@@ -4,8 +4,10 @@ import * as S from "sury/src/S.res.mjs";
4
4
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
5
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
+ import * as Email$Reventless from "@reventlessdev/reventless-spec/src/semantic/Email.res.mjs";
7
8
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
8
9
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
10
+ import * as Percent$Reventless from "@reventlessdev/reventless-spec/src/semantic/Percent.res.mjs";
9
11
  import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/types/DateTime.res.mjs";
10
12
  import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
11
13
  import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
@@ -937,6 +939,40 @@ globalThis.describe("SuryToJsonSchema:", () => {
937
939
  globalThis.expect(semanticOf(json, "imageUrl")).toEqual("storageRef");
938
940
  });
939
941
  });
942
+ globalThis.describe("branded scalars keep their underlying shape:", () => {
943
+ let keyOf = (json, name, key) => Stdlib_Option.flatMap(getPropertyOf(json, name), s => getProperty(s, key));
944
+ let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(S.schema(s => ({
945
+ contactEmail: s.m(Email$Reventless.schema),
946
+ taxRate: s.m(Percent$Reventless.schema)
947
+ })));
948
+ globalThis.test("a string scalar stays a string and carries its id", () => {
949
+ globalThis.expect([
950
+ keyOf(json, "contactEmail", "type"),
951
+ keyOf(json, "contactEmail", "x-reventless-semantic")
952
+ ]).toEqual([
953
+ "string",
954
+ "email"
955
+ ]);
956
+ });
957
+ globalThis.test("a numeric scalar stays a number and carries its id", () => {
958
+ globalThis.expect([
959
+ keyOf(json, "taxRate", "type"),
960
+ keyOf(json, "taxRate", "x-reventless-semantic")
961
+ ]).toEqual([
962
+ "number",
963
+ "percent"
964
+ ]);
965
+ });
966
+ globalThis.test("both name the type as their source", () => {
967
+ globalThis.expect([
968
+ keyOf(json, "contactEmail", "x-reventless-semantic-source"),
969
+ keyOf(json, "taxRate", "x-reventless-semantic-source")
970
+ ]).toEqual([
971
+ "type",
972
+ "type"
973
+ ]);
974
+ });
975
+ });
940
976
  });
941
977
 
942
978
  export {
@@ -131,6 +131,7 @@ describe("Message should", () => {
131
131
  extensions: [],
132
132
  extensionPoints: None,
133
133
  requiredStores: None,
134
+ requiredStoreDeclarations: None,
134
135
  }),
135
136
  dcbEventLog: None,
136
137
  kind: Domain,
@@ -118,7 +118,8 @@ globalThis.describe("Message should", () => {
118
118
  inboundTranslationSlices: [],
119
119
  extensions: [],
120
120
  extensionPoints: undefined,
121
- requiredStores: undefined
121
+ requiredStores: undefined,
122
+ requiredStoreDeclarations: undefined
122
123
  },
123
124
  dcbEventLog: undefined,
124
125
  kind: "Domain"
@@ -93,6 +93,20 @@ module PsAttachInvoiceSlice: ReventlessInfra.StateChangeSlice.T = {
93
93
  let make = (~dcbEventLog as _, ~publishJsons as _, ~tagKeysByEventType as _=?, ~crossPartitionTagKeys as _=?, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
94
94
  }
95
95
 
96
+ module PsChangePhotoSlice: ReventlessInfra.StateChangeSlice.T = {
97
+ module Spec = PsChangePhoto
98
+ module Behavior = {
99
+ type state = PsChangePhoto.state
100
+ let initialState = PsChangePhoto.initialState
101
+ let evolve = PsChangePhoto.evolve
102
+ let decide = PsChangePhoto.decide
103
+ let moduleUrl = PsChangePhoto.moduleUrl
104
+ }
105
+ let isAsync = false
106
+ type component = scsComponent
107
+ let make = (~dcbEventLog as _, ~publishJsons as _, ~tagKeysByEventType as _=?, ~crossPartitionTagKeys as _=?, ~runtime as _=?, ~opts as _=?): component => Obj.magic(0)
108
+ }
109
+
96
110
  let structure = Plugin_Structure.make(
97
111
  ~name="TestPlugin",
98
112
  ~stateChangeSlices=[module(PsPlaceOrderSlice), module(PsShipOrderSlice)],
@@ -675,5 +689,136 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
675
689
  testSync("a plugin declaring no stores collects none — not None", () =>
676
690
  expect(structure.requiredStores)->toEqual(Some([]))
677
691
  )
692
+
693
+ testSync("each requirement keeps its declaring (component, field) sites", () => {
694
+ // `documents` is declared on a command field AND an event field of the
695
+ // same component under the same field name — one declaration site, so
696
+ // the identical triples collapse to one entry.
697
+ let expected: array<Reventless.Plugin.requiredStoreDeclaration> = [
698
+ {store: "TestPlugin.documents", component: "AttachInvoice", field: "documentUrl"},
699
+ {store: "branding.logos", component: "AttachInvoice", field: "logoUrl"},
700
+ ]
701
+ expect(withStores.requiredStoreDeclarations)->toEqual(Some(expected))
702
+ })
703
+
704
+ // The manifest is a rendering of the structure. Its key set must be
705
+ // byte-identical to what the deployed plugin reports as `requiredStores`,
706
+ // because the deploy-time coverage assertion compares against exactly that
707
+ // — this equality is the whole contract of `capabilities.json`.
708
+ describe("capability manifest", () => {
709
+ let manifest = Reventless.CapabilityManifest.fromStructure(withStores)
710
+
711
+ testSync("keys are byte-identical to requiredStores", () =>
712
+ expect(manifest.capabilities->Array.map(c => c.key))->toEqual(
713
+ withStores.requiredStores->Option.getOr([]),
714
+ )
715
+ )
716
+
717
+ testSync("each entry carries its provenance", () => {
718
+ let entry =
719
+ manifest.capabilities->Array.find(c => c.key == "TestPlugin.documents")->Option.getOrThrow
720
+ let expected: array<Reventless.CapabilityManifest.provenance> = [
721
+ {component: "AttachInvoice", field: "documentUrl"},
722
+ ]
723
+ expect(entry.declaredBy)->toEqual(expected)
724
+ })
725
+
726
+ testSync("a structure declaring nothing yields an empty list, not a missing manifest", () =>
727
+ expect(Reventless.CapabilityManifest.fromStructure(structure).capabilities)->toEqual([])
728
+ )
729
+
730
+ testSync("rendering is deterministic and newline-terminated", () => {
731
+ let rendered = Reventless.CapabilityManifest.renderForStructure(withStores)
732
+ expect(rendered)->toBe(
733
+ `{
734
+ "capabilities": [
735
+ {
736
+ "kind": "ObjectStore",
737
+ "key": "TestPlugin.documents",
738
+ "declaredBy": [
739
+ {
740
+ "component": "AttachInvoice",
741
+ "field": "documentUrl"
742
+ }
743
+ ]
744
+ },
745
+ {
746
+ "kind": "ObjectStore",
747
+ "key": "branding.logos",
748
+ "declaredBy": [
749
+ {
750
+ "component": "AttachInvoice",
751
+ "field": "logoUrl"
752
+ }
753
+ ]
754
+ }
755
+ ]
756
+ }
757
+ `,
758
+ )
759
+ })
760
+ })
761
+ })
762
+
763
+ // Declarations are authoritative; the name heuristics are a lint. A
764
+ // heuristic-only match warns and never reaches the manifest, so nothing is
765
+ // provisioned from a guess — `imageUrl` is genuinely ambiguous between an
766
+ // uploaded object and an external URL, and only the author can settle it.
767
+ describe("capability inference", () => {
768
+ let commandWarnings = Capability_Inference.scanSchema(
769
+ ~component="ChangePhoto",
770
+ PsChangePhoto.commandSchema->S.castToUnknown,
771
+ )
772
+ let eventWarnings = Capability_Inference.scanSchema(
773
+ ~component="ChangePhoto",
774
+ PsChangePhoto.eventSchema->S.castToUnknown,
775
+ )
776
+
777
+ testSync("a heuristic-only match produces a warning", () => {
778
+ let expected: array<Capability_Inference.warning> = [
779
+ {component: "ChangePhoto", field: "photoUrl"},
780
+ ]
781
+ expect(commandWarnings)->toEqual(expected)
782
+ })
783
+
784
+ testSync("a declared field with a heuristic name does not warn", () => {
785
+ // The event's `thumbnail` matches the name heuristic but carries
786
+ // @storageRef — only the undeclared `photoUrl` warns.
787
+ let expected: array<Capability_Inference.warning> = [
788
+ {component: "ChangePhoto", field: "photoUrl"},
789
+ ]
790
+ expect(eventWarnings)->toEqual(expected)
791
+ })
792
+
793
+ testSync("a heuristic-only match reaches no manifest entry", () => {
794
+ let withHeuristicField = Plugin_Structure.make(
795
+ ~name="TestPlugin",
796
+ ~stateChangeSlices=[module(PsChangePhotoSlice)],
797
+ )
798
+ // Only the *declared* `thumbnail` store is collected; `photoUrl` — the
799
+ // heuristic-only match — provisions nothing.
800
+ expect(withHeuristicField.requiredStores)->toEqual(Some(["TestPlugin.productPhotos"]))
801
+ let manifest = Reventless.CapabilityManifest.fromStructure(withHeuristicField)
802
+ expect(
803
+ manifest.capabilities->Array.every(entry =>
804
+ entry.declaredBy->Array.every(site => site.field != "photoUrl")
805
+ ),
806
+ )->toBe(true)
807
+ })
808
+
809
+ testSync("the warning names the field and the settling annotation", () => {
810
+ let text =
811
+ commandWarnings->Array.getUnsafe(0)->Capability_Inference.message
812
+ expect(text->String.includes("ChangePhoto.photoUrl"))->toBe(true)
813
+ expect(text->String.includes("@storageRef"))->toBe(true)
814
+ })
815
+
816
+ testSync("suffix and exact name rules match; ordinary names do not", () => {
817
+ expect(
818
+ ["invoiceFileRef", "upload", "thumbnail", "customerName", "documentUrl"]->Array.map(
819
+ Capability_Inference.nameMatches,
820
+ ),
821
+ )->toEqual([true, true, true, false, false])
822
+ })
678
823
  })
679
824
  })