@reventlessdev/reventless-core 3.0.0-alpha.237 → 3.0.0-alpha.239

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 (49) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/package.json +8 -8
  3. package/src/Message.res +1 -1
  4. package/src/adapter/Monitoring/Monitoring.res +1 -1
  5. package/src/admin/Platform_Admin_Structure.res +1 -0
  6. package/src/admin/Platform_Admin_Structure.res.mjs +1 -0
  7. package/src/admin/Platform_ComponentDefinitionsApi.res +2 -1
  8. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
  9. package/src/admin/UiFragmentRegistry/StateChangeSlice/UiFragmentRegistry.res +1 -1
  10. package/src/components/Aggregate/Aggregate_Callback.res +2 -2
  11. package/src/components/Api/ApiAllowedStatesHelpers.res +2 -3
  12. package/src/components/Api/ApiTargetStateHelpers.res +4 -3
  13. package/src/components/Api/GraphQL_FragmentGenerator.res +122 -12
  14. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +41 -8
  15. package/src/components/Api/SuryToJsonSchema.res +8 -0
  16. package/src/components/Api/SuryToJsonSchema.res.mjs +8 -4
  17. package/src/components/Dcb/Dcb_Builder.res +79 -16
  18. package/src/components/Dcb/Dcb_Builder.res.mjs +41 -5
  19. package/src/components/EventLog/EventLog.res +1 -1
  20. package/src/plugin/component/Plugin_Builder.res +7 -0
  21. package/src/plugin/component/Plugin_Builder.res.mjs +3 -1
  22. package/src/plugin/component/Plugin_Structure.res +376 -27
  23. package/src/plugin/component/Plugin_Structure.res.mjs +175 -8
  24. package/src/plugin/connect/PluginExtensionPoint_UiFragment.res +1 -1
  25. package/tests/admin/Platform_BakedManifestTest.res +1 -0
  26. package/tests/admin/Platform_BakedManifestTest.res.mjs +1 -0
  27. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +3 -0
  28. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +6 -0
  29. package/tests/admin/Platform_PluginStructuresApiTest.res +1 -0
  30. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +2 -0
  31. package/tests/aggregate/AggregateCacheTest.res +1 -1
  32. package/tests/aggregate/AggregateSnapshotTest.res +1 -1
  33. package/tests/api/GraphQL_FragmentGeneratorTest.res +171 -0
  34. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +108 -0
  35. package/tests/api/SuryToJsonSchemaTest.res +30 -3
  36. package/tests/api/SuryToJsonSchemaTest.res.mjs +49 -4
  37. package/tests/commandgenerator/OwnerStampingTest.res +57 -0
  38. package/tests/commandgenerator/OwnerStampingTest.res.mjs +19 -0
  39. package/tests/message/MessageTest.res +1 -1
  40. package/tests/plugin/HeartbeatDisconnectGraceTest.res +1 -1
  41. package/tests/plugin/PluginStructureTest.res +268 -4
  42. package/tests/plugin/PluginStructureTest.res.mjs +284 -3
  43. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res +31 -0
  44. package/tests/plugin/StateChangeSlice/PsDispatchShipment.res.mjs +73 -0
  45. package/tests/plugin/StateChangeSlice/PsShipOrder.res +16 -1
  46. package/tests/plugin/StateChangeSlice/PsShipOrder.res.mjs +7 -2
  47. package/tests/plugin/StateViewSlice/PsShipmentsView.res +26 -0
  48. package/tests/plugin/StateViewSlice/PsShipmentsView.res.mjs +101 -0
  49. package/tests/util/CsvStreamTest.res +1 -1
@@ -322,6 +322,111 @@ globalThis.describe("GraphQL_FragmentGenerator.mutationArgTypes", () => {
322
322
  });
323
323
  });
324
324
 
325
+ let stateSchema = S.schema(s => ({
326
+ id: s.m(S.string),
327
+ name: s.m(S.string)
328
+ }));
329
+
330
+ let RefDoorRow = {
331
+ stateSchema: stateSchema
332
+ };
333
+
334
+ function queryFor(fragment, needle) {
335
+ return GraphQL_Stitcher$ReventlessCore.decode(fragment).queries.find(q => q.includes(needle));
336
+ }
337
+
338
+ function refDoorFragment(subIdField) {
339
+ return GraphQL_FragmentGenerator$ReventlessCore.generate([], [{
340
+ singleFieldName: "Catalog_Product",
341
+ listFieldName: "Catalog_Products",
342
+ returnTypeName: "Catalog_Product",
343
+ stateSchema: stateSchema,
344
+ authorization: undefined,
345
+ connectionSpec: true,
346
+ subIdField: subIdField
347
+ }]);
348
+ }
349
+
350
+ globalThis.describe("the reference door and the archive argument", () => {
351
+ globalThis.test("projects a reference to id, label and the state that retired it", () => {
352
+ let sdl = GraphQL_FragmentGenerator$ReventlessCore.deriveRefTypeSdl("Catalog_Product");
353
+ globalThis.expect([
354
+ sdl.includes("type Catalog_ProductRef"),
355
+ sdl.includes("label: String!"),
356
+ sdl.includes("retiredState: String"),
357
+ sdl.includes("price")
358
+ ]).toEqual([
359
+ true,
360
+ true,
361
+ true,
362
+ false
363
+ ]);
364
+ });
365
+ globalThis.test("names the door after the list field it resolves against", () => {
366
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.deriveRefsQueryField("Catalog_Products", "Catalog_Product")).toEqual(" Catalog_ProductsRefs(ids: [ID!]!): [Catalog_ProductRef!]!");
367
+ });
368
+ globalThis.test("emits the door from generate, not only from the helper", () => {
369
+ globalThis.expect([
370
+ Stdlib_Option.isSome(queryFor(refDoorFragment(undefined), "Catalog_ProductsRefs(ids: [ID!]!)")),
371
+ Stdlib_Option.isSome(typeDefFor(refDoorFragment(undefined), "type Catalog_ProductRef"))
372
+ ]).toEqual([
373
+ true,
374
+ true
375
+ ]);
376
+ });
377
+ globalThis.test("withholds the door from a composite-key view", () => {
378
+ globalThis.expect([
379
+ Stdlib_Option.isSome(queryFor(refDoorFragment("lineNo"), "Catalog_ProductsRefs")),
380
+ Stdlib_Option.isSome(typeDefFor(refDoorFragment("lineNo"), "type Catalog_ProductRef"))
381
+ ]).toEqual([
382
+ false,
383
+ false
384
+ ]);
385
+ });
386
+ globalThis.test("lets the single-entity and by-ids doors be asked for the archive", () => {
387
+ globalThis.expect([
388
+ GraphQL_FragmentGenerator$ReventlessCore.deriveObjectQueryField("Catalog_Product", "Catalog_Product", undefined, undefined).includes("includeRetired: Boolean"),
389
+ GraphQL_FragmentGenerator$ReventlessCore.deriveByIdsQueryField("Catalog_Products", "Catalog_Product").includes("includeRetired: Boolean")
390
+ ]).toEqual([
391
+ true,
392
+ true
393
+ ]);
394
+ });
395
+ globalThis.test("keeps the sub-id argument when the view has one", () => {
396
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.deriveObjectQueryField("Ordering_OrderLine", "Ordering_OrderLine", undefined, "lineNo")).toEqual(" Ordering_OrderLine(id: ID!, lineNo: String!, includeRetired: Boolean): Ordering_OrderLine");
397
+ });
398
+ });
399
+
400
+ globalThis.describe("the by-index door", () => {
401
+ let index = (index$1, idField, subIdField) => ({
402
+ index: index$1,
403
+ type_: "S",
404
+ idField: idField,
405
+ subIdField: subIdField,
406
+ projectionType: "ALL"
407
+ });
408
+ globalThis.test("takes the index value it filters on, and can be asked for the archive", () => {
409
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.deriveIndexQueryField("Catalog_Product", index("categoryId", undefined, undefined), "Catalog_ProductConnection")).toEqual(" Catalog_ProductByCategoryId(categoryId: String!, first: Int, after: String, last: Int, before: String, includeRetired: Boolean): Catalog_ProductConnection!");
410
+ });
411
+ globalThis.test("names the field after the index and keys it on the column", () => {
412
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.deriveIndexQueryField("Ordering_Order", index("byOwner", "ownerId", undefined), "Ordering_OrderConnection")).toEqual(" Ordering_OrderByOwner(ownerId: String!, first: Int, after: String, last: Int, before: String, includeRetired: Boolean): Ordering_OrderConnection!");
413
+ });
414
+ globalThis.test("offers the sort key the sort template has always read", () => {
415
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.deriveIndexQueryField("Ordering_Order", index("byCustomer", "customerId", "placedAt"), "Ordering_OrderConnection")).toEqual(" Ordering_OrderByCustomer(customerId: String!, placedAt: String, first: Int, after: String, last: Int, before: String, includeRetired: Boolean): Ordering_OrderConnection!");
416
+ });
417
+ globalThis.test("drops a leading `by` exactly once", () => {
418
+ globalThis.expect([
419
+ GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName("X", "byOwner"),
420
+ GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName("X", "ownerId"),
421
+ GraphQL_FragmentGenerator$ReventlessCore.indexQueryFieldName("X", "by")
422
+ ]).toEqual([
423
+ "XByOwner",
424
+ "XByOwnerId",
425
+ "XByBy"
426
+ ]);
427
+ });
428
+ });
429
+
325
430
  export {
326
431
  OrderSliceCmd,
327
432
  mutationFor,
@@ -330,5 +435,8 @@ export {
330
435
  AddProductCmd,
331
436
  ChangePriceCmd,
332
437
  PlaceOrder,
438
+ RefDoorRow,
439
+ queryFor,
440
+ refDoorFragment,
333
441
  }
334
442
  /* commandSchema Not a pure module */
@@ -431,7 +431,7 @@ describe("SuryToJsonSchema:", () => {
431
431
  let schema' =
432
432
  schema->withSpec({
433
433
  ...emptySpec,
434
- retired: Some({field: "archived", label: "Archived", showWhenFalse: true, values: None}),
434
+ retired: Some({field: "archived", label: "Archived", showWhenFalse: true, values: None, namedWhenRetired: false}),
435
435
  })
436
436
  let json = SuryToJsonSchema.deriveObjectSchema(schema')
437
437
  let retiredObj =
@@ -464,6 +464,7 @@ describe("SuryToJsonSchema:", () => {
464
464
  label: "",
465
465
  showWhenFalse: false,
466
466
  values: Some(values),
467
+ namedWhenRetired: false,
467
468
  }),
468
469
  })
469
470
  ->SuryToJsonSchema.deriveObjectSchema
@@ -522,7 +523,7 @@ describe("SuryToJsonSchema:", () => {
522
523
  let schema' =
523
524
  schema->withSpec({
524
525
  ...emptySpec,
525
- retired: Some({field: "archived", label: "", showWhenFalse: false, values: None}),
526
+ retired: Some({field: "archived", label: "", showWhenFalse: false, values: None, namedWhenRetired: false}),
526
527
  })
527
528
  let json = SuryToJsonSchema.deriveObjectSchema(schema')
528
529
  let retiredObj = json->retiredOn("archived")
@@ -532,12 +533,38 @@ describe("SuryToJsonSchema:", () => {
532
533
  ))->toEqual((None, None))
533
534
  })
534
535
 
536
+ // The opt-in that lets a reference name a withheld row. Travels only when
537
+ // true, on the omit-the-default rule the keys around it follow: false is what
538
+ // every record said before it existed, and writing it would put a key on
539
+ // every retirement to report that nothing changed.
540
+ testSync("carries namedWhenRetired only when the record opted in", () => {
541
+ let build = (~named: bool) => {
542
+ let schema = S.schema(s => {"archived": s.matches(S.bool)})->S.castToUnknown
543
+ schema
544
+ ->withSpec({
545
+ ...emptySpec,
546
+ retired: Some({
547
+ field: "archived",
548
+ label: "",
549
+ showWhenFalse: false,
550
+ values: None,
551
+ namedWhenRetired: named,
552
+ }),
553
+ })
554
+ ->SuryToJsonSchema.deriveObjectSchema
555
+ ->retiredOn("archived")
556
+ ->Option.flatMap(r => getProperty(r, "namedWhenRetired"))
557
+ ->Option.flatMap(JSON.Decode.bool)
558
+ }
559
+ expect((build(~named=true), build(~named=false)))->toEqual((Some(true), None))
560
+ })
561
+
535
562
  testSync("omits the retired label when empty but always states showWhenFalse", () => {
536
563
  let schema = S.schema(s => {"deactivated": s.matches(S.bool)})->S.castToUnknown
537
564
  let schema' =
538
565
  schema->withSpec({
539
566
  ...emptySpec,
540
- retired: Some({field: "deactivated", label: "", showWhenFalse: false, values: None}),
567
+ retired: Some({field: "deactivated", label: "", showWhenFalse: false, values: None, namedWhenRetired: false}),
541
568
  })
542
569
  let json = SuryToJsonSchema.deriveObjectSchema(schema')
543
570
  let retiredObj =
@@ -737,7 +737,8 @@ globalThis.describe("SuryToJsonSchema:", () => {
737
737
  field: "archived",
738
738
  label: "Archived",
739
739
  showWhenFalse: true,
740
- values: undefined
740
+ values: undefined,
741
+ namedWhenRetired: false
741
742
  }
742
743
  });
743
744
  let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(schema$p);
@@ -783,7 +784,8 @@ globalThis.describe("SuryToJsonSchema:", () => {
783
784
  field: "accountStatus",
784
785
  label: "",
785
786
  showWhenFalse: false,
786
- values: values
787
+ values: values,
788
+ namedWhenRetired: false
787
789
  }
788
790
  }));
789
791
  };
@@ -837,7 +839,8 @@ globalThis.describe("SuryToJsonSchema:", () => {
837
839
  field: "archived",
838
840
  label: "",
839
841
  showWhenFalse: false,
840
- values: undefined
842
+ values: undefined,
843
+ namedWhenRetired: false
841
844
  }
842
845
  });
843
846
  let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(schema$p);
@@ -850,6 +853,47 @@ globalThis.describe("SuryToJsonSchema:", () => {
850
853
  undefined
851
854
  ]);
852
855
  });
856
+ globalThis.test("carries namedWhenRetired only when the record opted in", () => {
857
+ let build = named => {
858
+ let schema = S.schema(s => ({
859
+ archived: s.m(S.bool)
860
+ }));
861
+ return Stdlib_Option.flatMap(Stdlib_Option.flatMap(retiredOn(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(withSpec(schema, {
862
+ ids: emptySpec_ids,
863
+ compositeIds: emptySpec_compositeIds,
864
+ subIds: emptySpec_subIds,
865
+ compositeSubIds: emptySpec_compositeSubIds,
866
+ indexes: emptySpec_indexes,
867
+ hidden: emptySpec_hidden,
868
+ summary: emptySpec_summary,
869
+ drillTargets: emptySpec_drillTargets,
870
+ drillTargetKeys: emptySpec_drillTargetKeys,
871
+ collapsed: emptySpec_collapsed,
872
+ scan: emptySpec_scan,
873
+ scanSort: emptySpec_scanSort,
874
+ semantic: emptySpec_semantic,
875
+ metric: emptySpec_metric,
876
+ lifecycle: undefined,
877
+ groupBy: undefined,
878
+ visibility: undefined,
879
+ live: undefined,
880
+ retired: {
881
+ field: "archived",
882
+ label: "",
883
+ showWhenFalse: false,
884
+ values: undefined,
885
+ namedWhenRetired: named
886
+ }
887
+ })), "archived"), r => getProperty(r, "namedWhenRetired")), Stdlib_JSON.Decode.bool);
888
+ };
889
+ globalThis.expect([
890
+ build(true),
891
+ build(false)
892
+ ]).toEqual([
893
+ true,
894
+ undefined
895
+ ]);
896
+ });
853
897
  globalThis.test("omits the retired label when empty but always states showWhenFalse", () => {
854
898
  let schema = S.schema(s => ({
855
899
  deactivated: s.m(S.bool)
@@ -877,7 +921,8 @@ globalThis.describe("SuryToJsonSchema:", () => {
877
921
  field: "deactivated",
878
922
  label: "",
879
923
  showWhenFalse: false,
880
- values: undefined
924
+ values: undefined,
925
+ namedWhenRetired: false
881
926
  }
882
927
  });
883
928
  let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(schema$p);
@@ -185,3 +185,60 @@ describe("@owner stamping on the command path:", () => {
185
185
  })
186
186
  })
187
187
  })
188
+
189
+ // The schema is not only a validator, and the case below is the one that proves
190
+ // it. Two AppSync-side call sites passed `S.json` in place of the command schema
191
+ // — a defensible substitution while its only job was validation, since AppSync
192
+ // has already checked the input against the SDL, and per-slice decoding happens
193
+ // downstream anyway. Owner stamping then gave the same argument a second job:
194
+ // it is where the `@owner` fields are read from. Handed a permissive schema the
195
+ // lookup answers "no owner fields" for every command there is, and the write
196
+ // keeps whatever owner the client sent — no error, no warning, a wrong row.
197
+ //
198
+ // Nothing about a single call site was wrong, which is why per-path tests could
199
+ // not catch it. What was missing is a check on the RELATIONSHIP: a generator
200
+ // built for a command whose spec marks an owner must be able to find it.
201
+ describe("the schema a generator is built with must answer for its commands:", () => {
202
+ let permissive = CommandGenerator_Callback.makeGenerateCommand(
203
+ ~publishJsons=async cmds => published := published.contents->Array.concat(cmds),
204
+ ~serviceName="Ordering",
205
+ ~commandSchema=S.json->S.castToUnknown,
206
+ ~componentKind=CommandGenerator_Callback.StateChangeSlice,
207
+ ~stripIdFromParams=false,
208
+ )
209
+
210
+ testPromise("a permissive schema silently stamps nothing — the defect, pinned", async () => {
211
+ let _ =
212
+ await permissive(
213
+ placeOrder(~customerId="cust-B", ~identity=cognito(~userId="cust-A", ~groups=["User"])),
214
+ )->Effect.runPromise
215
+ // Not the behaviour we want anywhere — recorded so that a change making the
216
+ // permissive path stamp correctly is a deliberate one, and so the contrast
217
+ // with the case below cannot be read as incidental.
218
+ expect(lastPublished()->fieldOf("customerId"))->toEqual(Some(str("cust-B")))
219
+ })
220
+
221
+ testPromise("the real schema stamps, from the same payload and caller", async () => {
222
+ let _ =
223
+ await generate(
224
+ placeOrder(~customerId="cust-B", ~identity=cognito(~userId="cust-A", ~groups=["User"])),
225
+ )->Effect.runPromise
226
+ expect(lastPublished()->fieldOf("customerId"))->toEqual(Some(str("cust-A")))
227
+ })
228
+
229
+ // The check a call site can run against itself, and the one the DCB routing
230
+ // maps now satisfy by construction: every command a spec exposes resolves to
231
+ // its own owner fields through the schema that command will be dispatched
232
+ // with. Reading `[]` here is exactly the state that stamps nothing.
233
+ testPromise("every command of a spec resolves its owner fields through that spec's schema", async () => {
234
+ let schema = commandSchema->S.castToUnknown
235
+ expect(Reventless.Owner.variantFieldNames(schema, ~variant="PlaceOrder"))->toEqual([
236
+ "customerId",
237
+ ])
238
+ expect(Reventless.Owner.variantFieldNames(schema, ~variant="ImportProducts"))->toEqual([])
239
+ // The substitution, asked the same question.
240
+ expect(
241
+ Reventless.Owner.variantFieldNames(S.json->S.castToUnknown, ~variant="PlaceOrder"),
242
+ )->toEqual([])
243
+ })
244
+ })
@@ -192,6 +192,25 @@ globalThis.describe("@owner stamping on the command path:", () => {
192
192
  });
193
193
  });
194
194
 
195
+ globalThis.describe("the schema a generator is built with must answer for its commands:", () => {
196
+ let permissive = CommandGenerator_Callback$ReventlessCore.makeGenerateCommand(async cmds => {
197
+ published.contents = published.contents.concat(cmds);
198
+ }, undefined, "Ordering", S.json, "StateChangeSlice", false);
199
+ globalThis.test("a permissive schema silently stamps nothing — the defect, pinned", async () => {
200
+ await Effect.runPromise(permissive(placeOrder("cust-B", cognito("cust-A", ["User"]))));
201
+ globalThis.expect(fieldOf(published.contents[published.contents.length - 1 | 0], "customerId")).toEqual("cust-B");
202
+ });
203
+ globalThis.test("the real schema stamps, from the same payload and caller", async () => {
204
+ await Effect.runPromise(generate(placeOrder("cust-B", cognito("cust-A", ["User"]))));
205
+ globalThis.expect(fieldOf(published.contents[published.contents.length - 1 | 0], "customerId")).toEqual("cust-A");
206
+ });
207
+ globalThis.test("every command of a spec resolves its owner fields through that spec's schema", async () => {
208
+ globalThis.expect(Owner$Reventless.variantFieldNames(commandSchema, "PlaceOrder")).toEqual(["customerId"]);
209
+ globalThis.expect(Owner$Reventless.variantFieldNames(commandSchema, "ImportProducts")).toEqual([]);
210
+ globalThis.expect(Owner$Reventless.variantFieldNames(S.json, "PlaceOrder")).toEqual([]);
211
+ });
212
+ });
213
+
195
214
  export {
196
215
  commandSchema,
197
216
  published,
@@ -96,7 +96,7 @@ describe("Message should", () => {
96
96
  // Schema-migration-on-read: a VersionConnected event persisted before `kind` (and
97
97
  // before later pluginStructure fields like `events`/`chapter`) existed must still
98
98
  // decode — otherwise the one stale event bricks the whole lifecycle aggregate. See
99
- // docs/plans/platform-infrastructure-in-plugin-list.md (durable fix option 2).
99
+ // docs/plans/done/platform-infrastructure-in-plugin-list.md (durable fix option 2).
100
100
  testSync("tolerantly decode a VersionConnected persisted before kind/structure fields existed", () => {
101
101
  open PluginSpec
102
102
  let def: Reventless.Plugin.pluginDefinition = {
@@ -7,7 +7,7 @@
7
7
  // (which drives CreateDisconnectSchedule). A deployed plugin once ran with schedule
8
8
  // rate(60min) but a Heartbeat(10) command (grace ~12min), so it reconnected for
9
9
  // ~12min after each hourly beat then sat Disconnected for the rest of the hour —
10
- // it flapped. See docs/plans/plugin-heartbeat-disconnect-margin-hardening.md.
10
+ // it flapped. See docs/plans/done/plugin-heartbeat-disconnect-margin-hardening.md.
11
11
  //
12
12
  // These tests pin the two guarantees that the fix provides at this layer:
13
13
  // 1. the disconnect grace scales with the interval (large cadences keep headroom);