@reventlessdev/reventless-core 3.0.0-alpha.190 → 3.0.0-alpha.192

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 (37) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/package.json +5 -5
  3. package/src/admin/Platform_Admin.res +2 -2
  4. package/src/admin/Platform_Admin.res.mjs +6 -2
  5. package/src/admin/Platform_Admin_Structure.res +4 -0
  6. package/src/admin/Platform_Admin_Structure.res.mjs +5 -1
  7. package/src/admin/Platform_ComponentDefinitionsApi.res +5 -1
  8. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
  9. package/src/components/Aggregate/Aggregate.res +7 -7
  10. package/src/components/Aggregate/Aggregate.res.mjs +7 -2
  11. package/src/components/Aggregate/Aggregate_Builder.res +4 -1
  12. package/src/components/Aggregate/Aggregate_Builder.res.mjs +11 -3
  13. package/src/components/Builder_Helpers.res +37 -4
  14. package/src/components/Builder_Helpers.res.mjs +24 -2
  15. package/src/components/Dcb/Dcb_Builder.res +2 -2
  16. package/src/components/Dcb/Dcb_Builder.res.mjs +4 -4
  17. package/src/components/SideEffectHandler/SideEffectHandler.res +11 -0
  18. package/src/components/SideEffectHandler/SideEffectHandler_Builder.res +2 -0
  19. package/src/components/SideEffectHandler/SideEffectHandler_Builder.res.mjs +3 -1
  20. package/src/components/Task/Task_Builder.res +11 -0
  21. package/src/components/Task/Task_Builder.res.mjs +2 -0
  22. package/src/plugin/component/Capability_Inference.res +68 -0
  23. package/src/plugin/component/Capability_Inference.res.mjs +79 -0
  24. package/src/plugin/component/Plugin_Builder.res +2 -2
  25. package/src/plugin/component/Plugin_Builder.res.mjs +4 -4
  26. package/src/plugin/component/Plugin_Helpers.res +18 -7
  27. package/src/plugin/component/Plugin_Helpers.res.mjs +20 -1
  28. package/src/plugin/component/Plugin_Structure.res +215 -79
  29. package/src/plugin/component/Plugin_Structure.res.mjs +195 -50
  30. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +5 -0
  31. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +15 -4
  32. package/tests/message/MessageTest.res +1 -0
  33. package/tests/message/MessageTest.res.mjs +2 -1
  34. package/tests/plugin/PluginStructureTest.res +391 -0
  35. package/tests/plugin/PluginStructureTest.res.mjs +355 -0
  36. package/tests/plugin/StateChangeSlice/PsChangePhoto.res +33 -0
  37. package/tests/plugin/StateChangeSlice/PsChangePhoto.res.mjs +64 -0
@@ -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)],
@@ -275,6 +289,252 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
275
289
  })
276
290
  })
277
291
 
292
+ // What a field *is* comes from `SchemaType`, the IR every other schema
293
+ // consumer reads, rather than from a `String(_)` match here. These pin the
294
+ // shapes that separates the two: a date, a reference, a semantic-carrying
295
+ // string and an optional one all match `String(_)` loosely or not at all, and
296
+ // each was answered wrong before.
297
+ describe("labelField — the shape rule", () => {
298
+ let labelOf = (~entityName="Test", schema) => {
299
+ let r = Plugin_Structure.labelFieldsFromStateSchema(~entityName, schema->S.castToUnknown)
300
+ (r.field, r.searchableFields)
301
+ }
302
+
303
+ testSync("a DateTime is not a name — a state whose only string is one falls to id", () => {
304
+ // The shipped `Orders` case: `placedAt` was added so date views have a
305
+ // field to key off, and a positional rule let it rename the entity.
306
+ let schema = S.schema(s =>
307
+ {
308
+ "orderId": s.matches(Reventless.Reference.to_("Order")),
309
+ "placedAt": s.matches(Reventless.DateTime.string),
310
+ "shippedAt": s.matches(Reventless.DateTime.string),
311
+ }
312
+ )
313
+ expect(labelOf(schema))->toEqual(("id", []))
314
+ })
315
+
316
+ testSync("a DateTime is skipped, not fatal — a later plain string still wins", () => {
317
+ let schema = S.schema(s =>
318
+ {
319
+ "placedAt": s.matches(Reventless.DateTime.string),
320
+ "reference": s.matches(S.string),
321
+ }
322
+ )
323
+ expect(labelOf(schema))->toEqual(("reference", ["reference"]))
324
+ })
325
+
326
+ testSync("an optional string is still the entity's name", () => {
327
+ let schema = S.schema(s =>
328
+ {
329
+ "nickname": s.matches(S.option(S.string)),
330
+ }
331
+ )
332
+ expect(labelOf(schema))->toEqual(("nickname", ["nickname"]))
333
+ })
334
+
335
+ testSync("a reference is not a name even when its field is not called `*Id`", () => {
336
+ let schema = S.schema(s =>
337
+ {
338
+ "customer": s.matches(Reventless.Reference.to_("Customer")),
339
+ "note": s.matches(S.string),
340
+ }
341
+ )
342
+ expect(labelOf(schema))->toEqual(("note", ["note"]))
343
+ })
344
+
345
+ testSync("a DCB-tagged string is not a name", () => {
346
+ let schema = S.schema(s =>
347
+ {
348
+ "partition": s.matches(Reventless.DcbTag.string),
349
+ "note": s.matches(S.string),
350
+ }
351
+ )
352
+ expect(labelOf(schema))->toEqual(("note", ["note"]))
353
+ })
354
+
355
+ testSync("the `*Id` test is case-insensitive — `productID` is a reference", () => {
356
+ let schema = S.schema(s =>
357
+ {
358
+ "productID": s.matches(S.string),
359
+ "note": s.matches(S.string),
360
+ }
361
+ )
362
+ expect(labelOf(schema))->toEqual(("note", ["note"]))
363
+ })
364
+
365
+ testSync("a semantic-carrying string is a value, not a name", () => {
366
+ // `imageUrl` names a stored object. A bucket key read as a product's name
367
+ // is what the previous rule did whenever no plain string preceded it.
368
+ let schema = S.schema(s =>
369
+ {
370
+ "imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages")),
371
+ "note": s.matches(S.string),
372
+ }
373
+ )
374
+ expect(labelOf(schema))->toEqual(("note", ["note"]))
375
+ })
376
+
377
+ testSync("a state whose only string is a semantic falls to id", () => {
378
+ let schema = S.schema(s =>
379
+ {
380
+ "imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages")),
381
+ }
382
+ )
383
+ expect(labelOf(schema))->toEqual(("id", []))
384
+ })
385
+
386
+ testSync("`id` itself is still excluded by name", () => {
387
+ // `SchemaType` reads a name that short as an ordinary string, so the
388
+ // exclusion cannot come from the IR.
389
+ let schema = S.schema(s =>
390
+ {
391
+ "id": s.matches(S.string),
392
+ "note": s.matches(S.string),
393
+ }
394
+ )
395
+ expect(labelOf(schema))->toEqual(("note", ["note"]))
396
+ })
397
+ })
398
+
399
+ describe("labelField — the conventional-name rung", () => {
400
+ let labelOf = schema => {
401
+ let r = Plugin_Structure.labelFieldsFromStateSchema(~entityName="Test", schema->S.castToUnknown)
402
+ (r.field, r.searchableFields)
403
+ }
404
+
405
+ testSync("a field named `name` beats an earlier string", () => {
406
+ let schema = S.schema(s =>
407
+ {
408
+ "sku": s.matches(S.string),
409
+ "name": s.matches(S.string),
410
+ }
411
+ )
412
+ expect(labelOf(schema))->toEqual(("name", ["name"]))
413
+ })
414
+
415
+ testSync("`title` counts too, case-insensitively", () => {
416
+ let schema = S.schema(s =>
417
+ {
418
+ "body": s.matches(S.string),
419
+ "Title": s.matches(S.string),
420
+ }
421
+ )
422
+ expect(labelOf(schema))->toEqual(("Title", ["Title"]))
423
+ })
424
+
425
+ testSync("the match is exact — `customerName` is a customer's name, not this record's", () => {
426
+ // The shipped `OrdersView` fixture depends on this: it has exactly one
427
+ // eligible field and must be picked by position, not by convention.
428
+ let schema = S.schema(s =>
429
+ {
430
+ "customerName": s.matches(S.string),
431
+ "note": s.matches(S.string),
432
+ }
433
+ )
434
+ expect(labelOf(schema))->toEqual(("customerName", ["customerName"]))
435
+ })
436
+
437
+ testSync("an optional `name` still outranks an earlier string", () => {
438
+ let schema = S.schema(s =>
439
+ {
440
+ "sku": s.matches(S.string),
441
+ "name": s.matches(S.option(S.string)),
442
+ }
443
+ )
444
+ expect(labelOf(schema))->toEqual(("name", ["name"]))
445
+ })
446
+ })
447
+
448
+ // The rung is published because the four are not equally believable: a
449
+ // consumer with a name rule of its own has to know whether it is ranking
450
+ // against a declaration or against a guess.
451
+ describe("labelFieldSource — which rung answered", () => {
452
+ let sourceOf = schema =>
453
+ Plugin_Structure.labelFieldsFromStateSchema(
454
+ ~entityName="Test",
455
+ schema->S.castToUnknown,
456
+ ).source->Plugin_Structure.labelFieldSourceToString
457
+
458
+ testSync("a @displayName spec is a declaration", () => {
459
+ let customers = structure.stateViewSlices->Array.getUnsafe(2)
460
+ expect((customers.labelField, customers.labelFieldSource))->toEqual((
461
+ "displayName",
462
+ Some("annotation"),
463
+ ))
464
+ })
465
+
466
+ testSync("a field named `name` is a guess the consumer can also make", () => {
467
+ let schema = S.schema(s =>
468
+ {
469
+ "sku": s.matches(S.string),
470
+ "name": s.matches(S.string),
471
+ }
472
+ )
473
+ expect(sourceOf(schema))->toEqual("convention")
474
+ })
475
+
476
+ testSync("declaration order is a guess only this side can make", () => {
477
+ // `customerName` is eligible and unconventional, so it wins by position —
478
+ // and a consumer told only "customerName" could not tell that from a
479
+ // declaration.
480
+ let schema = S.schema(s =>
481
+ {
482
+ "customerName": s.matches(S.string),
483
+ "note": s.matches(S.string),
484
+ }
485
+ )
486
+ expect(sourceOf(schema))->toEqual("position")
487
+ })
488
+
489
+ testSync("no candidate at all is the state saying it has no human field", () => {
490
+ let schema = S.schema(s =>
491
+ {
492
+ "orderId": s.matches(Reventless.Reference.to_("Order")),
493
+ "placedAt": s.matches(Reventless.DateTime.string),
494
+ }
495
+ )
496
+ expect(sourceOf(schema))->toEqual("fallback")
497
+ })
498
+
499
+ testSync("every built def states one", () => {
500
+ let stated =
501
+ Array.concat(structure.readModels, structure.stateViewSlices)->Array.every(q =>
502
+ q.labelFieldSource->Option.isSome
503
+ )
504
+ expect(stated)->toEqual(true)
505
+ })
506
+ })
507
+
508
+ describe("statusField — the shape rule", () => {
509
+ let statusOf = schema =>
510
+ Plugin_Structure.statusFieldFromStateSchema(~entityName="Test", schema->S.castToUnknown)
511
+
512
+ testSync("a `status` field holding a closed set of values is the lifecycle field", () => {
513
+ let schema = S.schema(s =>
514
+ {
515
+ "status": s.matches(S.union([S.literal("Placed"), S.literal("Shipped")])),
516
+ }
517
+ )
518
+ expect(statusOf(schema))->toEqual(Some("status"))
519
+ })
520
+
521
+ testSync("an optional one counts too", () => {
522
+ let schema = S.schema(s =>
523
+ {
524
+ "status": s.matches(S.option(S.union([S.literal("Placed"), S.literal("Shipped")]))),
525
+ }
526
+ )
527
+ expect(statusOf(schema))->toEqual(Some("status"))
528
+ })
529
+
530
+ testSync("free text named `status` is not a lifecycle", () => {
531
+ // `allowedStates` filtering needs states to compare against; a string
532
+ // field named `status` gives a command menu nothing to match.
533
+ let schema = S.schema(s => {"status": s.matches(S.string)})
534
+ expect(statusOf(schema))->toEqual(None)
535
+ })
536
+ })
537
+
278
538
  // Phase 4: queryableDef.schema must carry x-reventless-* extension keys for
279
539
  // annotated state types. Plugin_Structure now uses SuryToJsonSchema.deriveObjectSchema
280
540
  // (annotation-aware) instead of S.toJSONSchema (metadata-blind).
@@ -429,5 +689,136 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
429
689
  testSync("a plugin declaring no stores collects none — not None", () =>
430
690
  expect(structure.requiredStores)->toEqual(Some([]))
431
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
+ })
432
823
  })
433
824
  })