@reventlessdev/reventless-core 3.0.0-alpha.254 → 3.0.0-alpha.256

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 (41) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/package.json +8 -8
  3. package/src/admin/Platform_ComponentDefinitionsApi.res +5 -1
  4. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
  5. package/src/components/Api/GraphQL_FragmentGenerator.res +55 -14
  6. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +43 -12
  7. package/src/components/Api/SchemaType.res +1 -0
  8. package/src/components/Api/SchemaType.res.mjs +4 -0
  9. package/src/components/Api/SuryToJsonSchema.res +41 -0
  10. package/src/components/Api/SuryToJsonSchema.res.mjs +50 -17
  11. package/src/components/StateChangeSlice/StateChangeSlice_Callback.res +2 -2
  12. package/src/plugin/component/Plugin_Structure.res +13 -0
  13. package/src/plugin/component/Plugin_Structure.res.mjs +5 -1
  14. package/src/plugin/lifecycle/PluginsReadModelSpec.res +1 -1
  15. package/src/plugin/lifecycle/PluginsReadModelSpec.res.mjs +1 -1
  16. package/tests/admin/AdminApiSchemaDriftTest.res +1 -0
  17. package/tests/admin/AdminApiSchemaDriftTest.res.mjs +4 -1
  18. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +2 -0
  19. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +4 -2
  20. package/tests/admin/Platform_PluginStructuresApiTest.res +26 -0
  21. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +9 -0
  22. package/tests/api/GraphQL_FragmentGeneratorTest.res +47 -0
  23. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +32 -0
  24. package/tests/api/SuryToJsonSchemaTest.res +157 -0
  25. package/tests/api/SuryToJsonSchemaTest.res.mjs +81 -0
  26. package/tests/fixtures/plugin-lifecycle/2026-07-22-versionconnected-no-requiredstores.json +1 -1
  27. package/tests/fixtures/plugin-lifecycle/2026-07-22-versionsuperseded-nested-definitions.json +1 -1
  28. package/tests/fixtures/plugin-lifecycle/2026-07-28-versionconnected-requiredstores-only.json +1 -1
  29. package/tests/fixtures/plugin-lifecycle/2026-07-30-versionconnected-declarations-without-annotation.json +1 -1
  30. package/tests/fixtures/plugin-lifecycle/2026-07-30-versiondisconnected-declarations-without-annotation.json +1 -1
  31. package/tests/fixtures/plugin-lifecycle/README.md +12 -0
  32. package/tests/logger/LogFormatTest.res +1 -1
  33. package/tests/logger/LogFormatTest.res.mjs +1 -1
  34. package/tests/plugin/PluginDefinitionRequiredScalarsTest.res +1 -1
  35. package/tests/plugin/PluginDefinitionScalars.res +13 -8
  36. package/tests/plugin/PluginDefinitionScalars.res.mjs +13 -5
  37. package/tests/plugin/PluginStructureTest.res +22 -0
  38. package/tests/plugin/PluginStructureTest.res.mjs +7 -0
  39. package/tests/plugin/StateViewSlice/PsAnnotatedView.res +13 -3
  40. package/tests/plugin/StateViewSlice/PsAnnotatedView.res.mjs +7 -3
  41. package/tests/plugin/pluginDefinitionRequiredScalars.txt +17 -12
package/CHANGELOG.md CHANGED
@@ -3,6 +3,54 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # 3.0.0-alpha.256 (2026-09-04)
7
+
8
+ * feat(spec)!: one optional encoding on the wire, with no annotation ([320f91d](https://github.com/ReventlessDev/reventless-core/commit/320f91daa8bd90812a6e82069e7a1cb473041930))
9
+
10
+ ### BREAKING CHANGES
11
+
12
+ * the two encodings cannot read each other. Stored
13
+ pluginDefinition / pluginStructure payloads and already-deployed plugins must
14
+ go — wipe the platform scope (SEED_RESET_SCOPE=platform), quiesce, and
15
+ redeploy the fleet from one commit. Domain plugin data is untouched.
16
+
17
+ Two guards had to learn the new shape, both of which defined "optional" as
18
+ has.null and so mistook an omitted key for something to invent:
19
+
20
+ - Message.fillMissingDefaults reached `return undefined` only below two arms
21
+ that fire first — an enum's first const, and an object member filled with
22
+ zeros. Absent option<record> therefore healed to a zero-filled record, not
23
+ None: on the real schema, dcbEventLog became Some({name: "", eventTopicArn:
24
+ ""}), which manageSubscriptions would have read as a peer to subscribe to.
25
+ One line, mirroring the has.null guard, above both arms.
26
+ - PluginDefinitionScalars' walker reported 16 optional fields as newly-added
27
+ bare required scalars. With both encodings understood, the golden list is
28
+ unchanged.
29
+
30
+ The frozen lifecycle corpus holds five real payloads in the old encoding.
31
+ Null-valued keys were stripped mechanically — the rewrite round-trips each
32
+ file unchanged before editing, and a key-by-key diff shows null removals and
33
+ nothing else. The README records it beside the account-id redaction and says
34
+ why it is not a regeneration: no fixture was rebuilt from ReScript types, so
35
+ every generation in its table is still pinned.
36
+
37
+ check:graphql is unchanged, as expected: SchemaType.fromSury collapses Null
38
+ and Undefined to the same Nullable, so the emitted schema never distinguished
39
+ them.
40
+
41
+
42
+
43
+ # 3.0.0-alpha.255 (2026-09-04)
44
+
45
+ ### Features
46
+
47
+ * **api:** a view says when a second *Id field took its key away ([10b5a4e](https://github.com/ReventlessDev/reventless-core/commit/10b5a4e41b01cfd27146f7f596573af27e4937d9))
48
+ * **plugin:** a slice publishes which topics it subscribes to ([c689695](https://github.com/ReventlessDev/reventless-core/commit/c6896957ecb636204678222ac5a26b30870439cb))
49
+ * **spec,traits:** an image carries the text that goes with it, and a set's first member is its primary ([e4e5845](https://github.com/ReventlessDev/reventless-core/commit/e4e58458aee7b3db5564727d358a3a9767362ca4))
50
+ * **spec:** a field can say it selects one of the values its row already holds ([2ae50c3](https://github.com/ReventlessDev/reventless-core/commit/2ae50c34deee48508a9f1f39e3eef6a5d2f5df00))
51
+ * **spec:** a field can say its value must not be rendered into a message ([3183f53](https://github.com/ReventlessDev/reventless-core/commit/3183f53a0ae667f794bbd1a3d77acf362dfa8e57))
52
+
53
+
6
54
  # 3.0.0-alpha.254 (2026-09-02)
7
55
 
8
56
  * feat(aws)!: the messaging sender is configuration, and a stack can choose to only log ([23b8b4b](https://github.com/ReventlessDev/reventless-core/commit/23b8b4bfe9c70555de4d74266ca686cb427485ca))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.254",
3
+ "version": "3.0.0-alpha.256",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -28,22 +28,22 @@
28
28
  "dependencies": {
29
29
  "sury": "11.0.0-rc.2",
30
30
  "uuid": "^13.0.0",
31
+ "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.9",
31
32
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
- "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
33
33
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
34
- "@reventlessdev/rescript-node": "2.0.0-alpha.8",
35
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
35
+ "@reventlessdev/rescript-node": "2.0.0-alpha.9",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
37
+ "@reventlessdev/rescript-ssh2": "2.0.0-alpha.9",
37
38
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
38
- "@reventlessdev/rescript-ssh2": "2.0.0-alpha.8",
39
- "@reventlessdev/reventless-infra": "3.0.0-alpha.155",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.127",
41
- "@reventlessdev/reventless-interop": "3.0.0-alpha.34"
39
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.35",
40
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.157",
41
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.129"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
45
45
  "sury-ppx": "11.0.0-rc.2",
46
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.76"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.77"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "rescript": "12.3.0"
@@ -31,7 +31,7 @@ let sdlTypes: array<string> = [
31
31
  // `idField` to report.
32
32
  `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n lifecycleField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n retiredField: String\n retiredValues: [String!]\n namedWhenRetired: Boolean!\n}`,
33
33
  `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
34
- `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
34
+ `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n consumedSources: [String!]\n}`,
35
35
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
36
36
  // The subscriber's half of the port's translation table — see `handledEventDef`.
37
37
  `type Platform_HandledEventDef {\n name: String!\n toCommandTypes: [String!]!\n}`,
@@ -193,6 +193,10 @@ let encodeOutboundTranslationSliceDef = (o: outboundTranslationSliceDef): JSON.t
193
193
  // the external-system boundary box without workspace access. None → null.
194
194
  ("externalSystem", o.externalSystem->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
195
195
  ("chapter", o.chapter->Option.mapOr(JSON.Encode.null, JSON.Encode.string)),
196
+ // Which topics this slice subscribes to, so a graph consumer can tell two
197
+ // topics carrying an event of the same name apart. `[]` means this plugin's
198
+ // own DCB log; null is a structure from before the field existed.
199
+ ("consumedSources", o.consumedSources->Option.mapOr(JSON.Encode.null, encodeStrings)),
196
200
  ])->JSON.Encode.object
197
201
 
198
202
  let encodeInboundTranslationSliceDef = (i: inboundTranslationSliceDef): JSON.t =>
@@ -12,7 +12,7 @@ let sdlTypes = [
12
12
  `type Platform_WriteSideDef {\n name: String!\n commands: [Platform_CommandDef!]!\n linkedViews: [String!]!\n consistencyRead: String\n producedEventTypes: [String!]!\n consumedEventTypes: [String!]!\n events: [Platform_EventDef!]!\n errors: [Platform_ErrorDef!]!\n chapter: String\n}`,
13
13
  `type Platform_ReadSideDef {\n name: String!\n queryField: String!\n schema: String!\n consumedEventTypes: [String!]!\n linkedWriteSide: [String!]!\n labelField: String!\n searchableFields: [String!]!\n labelFieldSource: String\n lifecycleField: String\n visibility: String\n chapter: String\n singleQueryField: String\n idField: String\n idFieldSource: String\n requiredAccess: [String!]\n ownerField: String\n retiredField: String\n retiredValues: [String!]\n namedWhenRetired: Boolean!\n}`,
14
14
  `type Platform_AutomationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n producedCommandTypes: [String!]!\n targetName: String\n chapter: String\n}`,
15
- `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
15
+ `type Platform_OutboundTranslationSliceDef {\n name: String!\n consumedEventTypes: [String!]!\n inboundCommandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n consumedSources: [String!]\n}`,
16
16
  `type Platform_InboundTranslationSliceDef {\n name: String!\n commandTypes: [String!]!\n targetName: String\n externalSystem: String\n chapter: String\n}`,
17
17
  `type Platform_HandledEventDef {\n name: String!\n toCommandTypes: [String!]!\n}`,
18
18
  `type Platform_IssuedCommandDef {\n name: String!\n fromEventTypes: [String!]!\n}`,
@@ -303,6 +303,10 @@ function encodeOutboundTranslationSliceDef(o) {
303
303
  [
304
304
  "chapter",
305
305
  Stdlib_Option.mapOr(o.chapter, null, prim => prim)
306
+ ],
307
+ [
308
+ "consumedSources",
309
+ Stdlib_Option.mapOr(o.consumedSources, null, encodeStrings)
306
310
  ]
307
311
  ]);
308
312
  }
@@ -300,28 +300,34 @@ let rec scalarOfSchemaType = (st: SchemaType.schemaType): string =>
300
300
  let isKeyFieldName = (name: string): bool =>
301
301
  name->String.length > 2 && name->String.endsWith("Id")
302
302
 
303
+ /** What the ladder below concluded. The two ways to have no key are opposite
304
+ mistakes, so they are kept apart rather than collapsed into `None`. */
305
+ type keyFieldResolution =
306
+ | Resolved({field: string, rung: string})
307
+ | /** Several `*Id` fields and none matching the name — usually a field
308
+ somebody added to a view that used to have exactly one. */
309
+ Ambiguous({candidates: array<string>, conventional: string})
310
+ | NoCandidate
311
+
303
312
  /**
304
313
  The field that identifies a row, and which rung answered:
305
314
 
306
315
  - `"annotation"` — the state declares `@id`. Nothing outranks it.
307
316
  - `"convention"` — a field named `<singular entity name>Id` exists
308
- (`Products` → `productId`). A guess, but one that can only fire on a field
309
- that is actually there.
310
- - `"sole"` — the state has exactly one `*Id` field, so there is nothing else it
311
- could be (`AvailableProducts` → `productId`).
312
-
313
- `None` is the honest answer for a state with several `*Id` fields and no name
314
- match (`ProductDemand`: `productId` + `categoryId`), or with none at all — those
315
- need `@id`. Convention outranks sole so a view carrying one foreign key and no
316
- key of its own is not keyed by the foreign key.
317
+ (`Products` → `productId`).
318
+ - `"sole"` — the state has exactly one `*Id` field (`AvailableProducts`).
319
+
320
+ Convention outranks sole so a view carrying one foreign key and no key of its own
321
+ is not keyed by the foreign key. `resolveKeyField` is this, with both gaps
322
+ flattened to `None`.
317
323
  */
318
- let resolveKeyField = (~entityName: string, schema: S.t<unknown>): option<(string, string)> => {
324
+ let classifyKeyField = (~entityName: string, schema: S.t<unknown>): keyFieldResolution => {
319
325
  let declared = switch Reventless.StateAnnotations.getSpec(schema) {
320
326
  | Some({ids}) => ids->Array.get(0)
321
327
  | None => None
322
328
  }
323
329
  switch declared {
324
- | Some(field) => Some((field, "annotation"))
330
+ | Some(field) => Resolved({field, rung: "annotation"})
325
331
  | None =>
326
332
  let candidates =
327
333
  SchemaType.fromSuryObject(~typeName="", schema)
@@ -333,15 +339,50 @@ let resolveKeyField = (~entityName: string, schema: S.t<unknown>): option<(strin
333
339
  singular->String.slice(~start=0, ~end=1)->String.toLowerCase ++
334
340
  singular->String.slice(~start=1, ~end=singular->String.length) ++ "Id"
335
341
  if candidates->Array.includes(conventional) {
336
- Some((conventional, "convention"))
342
+ Resolved({field: conventional, rung: "convention"})
337
343
  } else if candidates->Array.length == 1 {
338
- Some((candidates->Array.getUnsafe(0), "sole"))
344
+ Resolved({field: candidates->Array.getUnsafe(0), rung: "sole"})
345
+ } else if Array.length(candidates) == 0 {
346
+ NoCandidate
339
347
  } else {
340
- None
348
+ Ambiguous({candidates, conventional})
341
349
  }
342
350
  }
343
351
  }
344
352
 
353
+ let resolveKeyField = (~entityName: string, schema: S.t<unknown>): option<(string, string)> =>
354
+ switch classifyKeyField(~entityName, schema) {
355
+ | Resolved({field, rung}) => Some((field, rung))
356
+ | Ambiguous(_) | NoCandidate => None
357
+ }
358
+
359
+ /**
360
+ What losing the key costs this view, said where the ladder is so a caller only
361
+ has to report it.
362
+
363
+ Invisible otherwise: the view keeps every field and every row, and loses its
364
+ `<field>Eq` filter and its whole `orderBy` from the schema, so a client's
365
+ narrowing silently becomes a page fetched and filtered on the client.
366
+
367
+ `None` for `NoCandidate` as well as for a resolved key, and that is the whole
368
+ judgement here. A read model over an aggregate keeps the row's id on the row key
369
+ rather than in its state, so having no `*Id` field is its ordinary shape — warning
370
+ about it would fire on most of them and get the rule silenced. `Ambiguous` is the
371
+ accident: the view HAD a key and a second `*Id` field took it away.
372
+ */
373
+ let keyFieldGapMessage = (resolution: keyFieldResolution): option<string> =>
374
+ switch resolution {
375
+ | Resolved(_) | NoCandidate => None
376
+ | Ambiguous({candidates, conventional}) =>
377
+ Some(
378
+ `has no row key: it declares no @id and its \`*Id\` fields ` ++
379
+ `(${candidates->Array.join(", ")}) include no "${conventional}" for the name to ` ++
380
+ `pick. Adding a second \`*Id\` field to a view that had one is what lands here, ` ++
381
+ `and it costs the view its filter and its whole orderBy in the schema. Declare ` ++
382
+ `@id on the field that identifies a row.`,
383
+ )
384
+ }
385
+
345
386
  // The component name the key-field convention is read against. `specName` is the
346
387
  // read model's own `Spec.name`; without it, `returnTypeName` minus its plugin
347
388
  // prefix is the same string (`Catalog_Product` → `Product`).
@@ -244,30 +244,59 @@ function isKeyFieldName(name) {
244
244
  }
245
245
  }
246
246
 
247
- function resolveKeyField(entityName, schema) {
247
+ function classifyKeyField(entityName, schema) {
248
248
  let match = StateAnnotations$Reventless.getSpec(schema);
249
249
  let declared = match !== undefined ? match.ids[0] : undefined;
250
250
  if (declared !== undefined) {
251
- return [
252
- declared,
253
- "annotation"
254
- ];
251
+ return {
252
+ TAG: "Resolved",
253
+ field: declared,
254
+ rung: "annotation"
255
+ };
255
256
  }
256
257
  let candidates = Object.keys(Stdlib_Option.getOr(SchemaType$ReventlessCore.fromSuryObject("", schema), {})).filter(isKeyFieldName);
257
258
  let singular = Api_Naming$ReventlessCore.singularize(Api_Naming$ReventlessCore.stripViewSuffix(entityName));
258
259
  let conventional = singular.slice(0, 1).toLowerCase() + singular.slice(1, singular.length) + "Id";
259
260
  if (candidates.includes(conventional)) {
260
- return [
261
- conventional,
262
- "convention"
263
- ];
261
+ return {
262
+ TAG: "Resolved",
263
+ field: conventional,
264
+ rung: "convention"
265
+ };
264
266
  } else if (candidates.length === 1) {
267
+ return {
268
+ TAG: "Resolved",
269
+ field: candidates[0],
270
+ rung: "sole"
271
+ };
272
+ } else if (candidates.length === 0) {
273
+ return "NoCandidate";
274
+ } else {
275
+ return {
276
+ TAG: "Ambiguous",
277
+ candidates: candidates,
278
+ conventional: conventional
279
+ };
280
+ }
281
+ }
282
+
283
+ function resolveKeyField(entityName, schema) {
284
+ let match = classifyKeyField(entityName, schema);
285
+ if (typeof match !== "object" || match.TAG !== "Resolved") {
286
+ return;
287
+ } else {
265
288
  return [
266
- candidates[0],
267
- "sole"
289
+ match.field,
290
+ match.rung
268
291
  ];
269
- } else {
292
+ }
293
+ }
294
+
295
+ function keyFieldGapMessage(resolution) {
296
+ if (typeof resolution !== "object" || resolution.TAG === "Resolved") {
270
297
  return;
298
+ } else {
299
+ return `has no row key: it declares no @id and its \`*Id\` fields ` + (`(` + resolution.candidates.join(", ") + `) include no "` + resolution.conventional + `" for the name to `) + `pick. Adding a second \`*Id\` field to a view that had one is what lands here, and it costs the view its filter and its whole orderBy in the schema. Declare @id on the field that identifies a row.`;
271
300
  }
272
301
  }
273
302
 
@@ -655,7 +684,9 @@ export {
655
684
  emptyCapability,
656
685
  scalarOfSchemaType,
657
686
  isKeyFieldName,
687
+ classifyKeyField,
658
688
  resolveKeyField,
689
+ keyFieldGapMessage,
659
690
  entityNameOf,
660
691
  deriveServerCapability,
661
692
  validateScanSortAlignment,
@@ -60,6 +60,7 @@ let semanticCompositeNames = [
60
60
  (Reventless.Semantic.Id.money, "Money"),
61
61
  (Reventless.Semantic.Id.dateRange, "DateRange"),
62
62
  (Reventless.Semantic.Id.geoPoint, "GeoPoint"),
63
+ (Reventless.Semantic.Id.captionedImage, "CaptionedImage"),
63
64
  ]
64
65
 
65
66
  let canonicalName = (id: string): option<string> =>
@@ -40,6 +40,10 @@ let semanticCompositeNames = [
40
40
  [
41
41
  Semantic$Reventless.Id.geoPoint,
42
42
  "GeoPoint"
43
+ ],
44
+ [
45
+ Semantic$Reventless.Id.captionedImage,
46
+ "CaptionedImage"
43
47
  ]
44
48
  ];
45
49
 
@@ -271,6 +271,24 @@ and withSemantic = (fieldSchema: JSON.t, sem: Reventless.Semantic.t): JSON.t =>
271
271
  "x-reventless-semantic-target",
272
272
  jsonObject(withOptionalPlugin([("store", str(store))], plugin)),
273
273
  )
274
+ | MemberOf({plugin, view, field, content}) =>
275
+ // Same channel as the two above, and `view` and `content` are omitted on
276
+ // the same terms as `plugin`: absent means the collection is on the row
277
+ // this field is already part of, and that the member's kind is the
278
+ // reader's own to work out.
279
+ let optional = (pairs, key, value) =>
280
+ switch value {
281
+ | Some(v) => Array.concat(pairs, [(key, str(v))])
282
+ | None => pairs
283
+ }
284
+ obj->Dict.set(
285
+ "x-reventless-semantic-target",
286
+ jsonObject(
287
+ withOptionalPlugin([("field", str(field))], plugin)
288
+ ->optional("view", view)
289
+ ->optional("content", content),
290
+ ),
291
+ )
274
292
  }
275
293
  JSON.Encode.object(obj)
276
294
  }
@@ -291,10 +309,15 @@ and withSemantic = (fieldSchema: JSON.t, sem: Reventless.Semantic.t): JSON.t =>
291
309
  // belongs to, leaving the field a plain string either way. Threading it here
292
310
  // also keeps it available on command variants, which carry no annotation spec
293
311
  // for `mergeAnnotations` to read.
312
+ // `sensitive` rides here on the same reasoning as `owners`, and is read the same
313
+ // way — off the sury schema by the caller. What it marks is a field whose value
314
+ // must not be rendered into content somebody receives, which is a property of
315
+ // the domain model rather than of the field's shape.
294
316
  and objectRefToJsonSchema = (
295
317
  ~annotations: option<Reventless.StateAnnotations.stateAnnotationSpec>=?,
296
318
  ~optional: array<string>=[],
297
319
  ~owners: array<string>=[],
320
+ ~sensitive: array<string>=[],
298
321
  fields: dict<SchemaType.schemaType>,
299
322
  ): JSON.t => {
300
323
  let props = Dict.make()
@@ -328,6 +351,23 @@ and objectRefToJsonSchema = (
328
351
  } else {
329
352
  withAnnotations
330
353
  }
354
+ // Last, so it can read the semantic both earlier paths may have written. A
355
+ // field whose semantic is a way of reaching a particular person is sensitive
356
+ // whether or not anybody marked it — the annotation is for the values a type
357
+ // cannot betray.
358
+ let withAnnotations = switch withAnnotations->JSON.Decode.object {
359
+ | Some(obj) =>
360
+ let bySemantic =
361
+ obj
362
+ ->Dict.get("x-reventless-semantic")
363
+ ->Option.flatMap(JSON.Decode.string)
364
+ ->Option.mapOr(false, Reventless.Sensitive.impliedBySemantic)
365
+ if sensitive->Array.includes(fieldName) || bySemantic {
366
+ obj->Dict.set("x-reventless-sensitive", JSON.Encode.bool(true))
367
+ }
368
+ JSON.Encode.object(obj)
369
+ | None => withAnnotations
370
+ }
331
371
  props->Dict.set(fieldName, withAnnotations)
332
372
  // Optional two ways, because neither source answers alone. `optional` is
333
373
  // read off the sury schema and is the only thing that can speak for a
@@ -362,6 +402,7 @@ let deriveObjectSchema = (schema: S.t<unknown>): JSON.t =>
362
402
  ~annotations?,
363
403
  ~optional=SchemaType.optionalFieldNames(schema),
364
404
  ~owners=Reventless.Owner.fieldNames(schema),
405
+ ~sensitive=Reventless.Sensitive.fieldNames(schema),
365
406
  fields,
366
407
  )
367
408
  // Surface component-level hints on the top-level object schema.
@@ -3,6 +3,7 @@
3
3
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as Owner$Reventless from "@reventlessdev/reventless-spec/src/components/Owner.res.mjs";
6
+ import * as Sensitive$Reventless from "@reventlessdev/reventless-spec/src/components/Sensitive.res.mjs";
6
7
  import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
7
8
  import * as SchemaType$ReventlessCore from "./SchemaType.res.mjs";
8
9
  import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
@@ -232,7 +233,7 @@ function fromSchemaType(st) {
232
233
  ]
233
234
  ]);
234
235
  case "ObjectRef" :
235
- return objectRefToJsonSchema(undefined, undefined, undefined, st._1);
236
+ return objectRefToJsonSchema(undefined, undefined, undefined, undefined, st._1);
236
237
  case "Enum" :
237
238
  return Object.fromEntries([
238
239
  [
@@ -269,7 +270,7 @@ function armToJsonSchema(tag, armType) {
269
270
  if (armType.TAG !== "ObjectRef") {
270
271
  return fromSchemaType(armType);
271
272
  }
272
- let base = objectRefToJsonSchema(undefined, undefined, undefined, armType._1);
273
+ let base = objectRefToJsonSchema(undefined, undefined, undefined, undefined, armType._1);
273
274
  let obj = Stdlib_JSON.Decode.object(base);
274
275
  if (obj === undefined) {
275
276
  return base;
@@ -303,26 +304,47 @@ function withSemantic(fieldSchema, sem) {
303
304
  obj["x-reventless-semantic-source"] = "type";
304
305
  let match = sem.payload;
305
306
  if (typeof match === "object") {
306
- if (match.TAG === "ReferenceTo") {
307
- let match$1 = match._0;
308
- obj["x-reventless-semantic-target"] = Object.fromEntries(withOptionalPlugin([[
309
- "entity",
310
- match$1.entity
311
- ]], match$1.plugin));
312
- } else {
313
- let match$2 = match._0;
314
- obj["x-reventless-semantic-target"] = Object.fromEntries(withOptionalPlugin([[
315
- "store",
316
- match$2.store
317
- ]], match$2.plugin));
307
+ switch (match.TAG) {
308
+ case "ReferenceTo" :
309
+ let match$1 = match._0;
310
+ obj["x-reventless-semantic-target"] = Object.fromEntries(withOptionalPlugin([[
311
+ "entity",
312
+ match$1.entity
313
+ ]], match$1.plugin));
314
+ break;
315
+ case "StoredIn" :
316
+ let match$2 = match._0;
317
+ obj["x-reventless-semantic-target"] = Object.fromEntries(withOptionalPlugin([[
318
+ "store",
319
+ match$2.store
320
+ ]], match$2.plugin));
321
+ break;
322
+ case "MemberOf" :
323
+ let match$3 = match._0;
324
+ let optional = (pairs, key, value) => {
325
+ if (value !== undefined) {
326
+ return pairs.concat([[
327
+ key,
328
+ value
329
+ ]]);
330
+ } else {
331
+ return pairs;
332
+ }
333
+ };
334
+ obj["x-reventless-semantic-target"] = Object.fromEntries(optional(optional(withOptionalPlugin([[
335
+ "field",
336
+ match$3.field
337
+ ]], match$3.plugin), "view", match$3.view), "content", match$3.content));
338
+ break;
318
339
  }
319
340
  }
320
341
  return obj;
321
342
  }
322
343
 
323
- function objectRefToJsonSchema(annotations, optionalOpt, ownersOpt, fields) {
344
+ function objectRefToJsonSchema(annotations, optionalOpt, ownersOpt, sensitiveOpt, fields) {
324
345
  let optional = optionalOpt !== undefined ? optionalOpt : [];
325
346
  let owners = ownersOpt !== undefined ? ownersOpt : [];
347
+ let sensitive = sensitiveOpt !== undefined ? sensitiveOpt : [];
326
348
  let props = {};
327
349
  let required = [];
328
350
  let internal = Stdlib_Option.getOr(Stdlib_Option.flatMap(annotations, spec => spec.internal), []);
@@ -343,7 +365,18 @@ function objectRefToJsonSchema(annotations, optionalOpt, ownersOpt, fields) {
343
365
  } else {
344
366
  withAnnotations$1 = withAnnotations;
345
367
  }
346
- props[fieldName] = withAnnotations$1;
368
+ let obj$1 = Stdlib_JSON.Decode.object(withAnnotations$1);
369
+ let withAnnotations$2;
370
+ if (obj$1 !== undefined) {
371
+ let bySemantic = Stdlib_Option.mapOr(Stdlib_Option.flatMap(obj$1["x-reventless-semantic"], Stdlib_JSON.Decode.string), false, Sensitive$Reventless.impliedBySemantic);
372
+ if (sensitive.includes(fieldName) || bySemantic) {
373
+ obj$1["x-reventless-sensitive"] = true;
374
+ }
375
+ withAnnotations$2 = obj$1;
376
+ } else {
377
+ withAnnotations$2 = withAnnotations$1;
378
+ }
379
+ props[fieldName] = withAnnotations$2;
347
380
  if (!optional.includes(fieldName) && !isNullableType(fieldType)) {
348
381
  required.push(fieldName);
349
382
  return;
@@ -377,7 +410,7 @@ function deriveObjectSchema(schema) {
377
410
  ]]);
378
411
  }
379
412
  let annotations = StateAnnotations$Reventless.getSpec(schema);
380
- let objSchema = objectRefToJsonSchema(annotations, SchemaType$ReventlessCore.optionalFieldNames(schema), Owner$Reventless.fieldNames(schema), fields);
413
+ let objSchema = objectRefToJsonSchema(annotations, SchemaType$ReventlessCore.optionalFieldNames(schema), Owner$Reventless.fieldNames(schema), Sensitive$Reventless.fieldNames(schema), fields);
381
414
  if (annotations === undefined) {
382
415
  return objSchema;
383
416
  }
@@ -37,8 +37,8 @@ module Make = (
37
37
  // Encode through the event schema (like the Aggregate path's Message.encode),
38
38
  // NOT JSON.stringifyAny: the runtime representation drops `None` option
39
39
  // fields entirely, while the consumer side (DcbDecode) parses with the sury
40
- // schema — a js_nullable option field would reject the missing key and the
41
- // event would be dropped as schema drift.
40
+ // schema — an encoding that writes `null` for an absent field would reject the
41
+ // missing key and the event would be dropped as schema drift.
42
42
  let json = event->Reventless.Util_Sury.toJson(Spec.eventSchema)
43
43
  let (eventType, data) = json->Message.splitMessage
44
44
  // Use `extractTagsExpanded` (not `extractTags`) so per-element tags on
@@ -1282,6 +1282,13 @@ let make = (
1282
1282
  | Checked(failures) => failures->Array.forEach(f => retiredFailures->Array.push(f)->ignore)
1283
1283
  }
1284
1284
 
1285
+ // Warned rather than refused: an unkeyed view still answers, just without a
1286
+ // filter or an ordering, and a lint that stops a deploy gets silenced.
1287
+ let recordKeyField = (~entityName, stateSchema) =>
1288
+ GraphQL_FragmentGenerator.classifyKeyField(~entityName, stateSchema)
1289
+ ->GraphQL_FragmentGenerator.keyFieldGapMessage
1290
+ ->Option.forEach(why => log.warn(~comp="Plugin_Structure", `${name}/${entityName} ${why}`))
1291
+
1285
1292
  let readModelDefs =
1286
1293
  readModels
1287
1294
  ->Array.map((
@@ -1300,6 +1307,7 @@ let make = (
1300
1307
  let consumed = qualify(~prefix=name, R.consumedEventNames)
1301
1308
  recordRetired(~entityName=R.Spec.name, stateSchema)
1302
1309
  recordLifecycle(~entityName=R.Spec.name, stateSchema)
1310
+ recordKeyField(~entityName=R.Spec.name, stateSchema)
1303
1311
  ({
1304
1312
  Reventless.Plugin.name: R.Spec.name,
1305
1313
  queryField: qf.listFieldName,
@@ -1340,6 +1348,7 @@ let make = (
1340
1348
  )
1341
1349
  recordRetired(~entityName=SVS.Spec.name, stateSchema)
1342
1350
  recordLifecycle(~entityName=SVS.Spec.name, stateSchema)
1351
+ recordKeyField(~entityName=SVS.Spec.name, stateSchema)
1343
1352
  ({
1344
1353
  Reventless.Plugin.name: SVS.Spec.name,
1345
1354
  queryField: qf.listFieldName,
@@ -1452,6 +1461,10 @@ let make = (
1452
1461
  targetName: OTS.Spec.targetName,
1453
1462
  externalSystem: OTS.Spec.externalSystem,
1454
1463
  chapter: chapterOf(OTS.Spec.name),
1464
+ // Published verbatim, `[]` included: `[]` is the declared default meaning
1465
+ // this plugin's own DCB log, and resolving it here to a derived name would
1466
+ // be a second place that name is spelled.
1467
+ consumedSources: Some(OTS.Spec.sourceNames),
1455
1468
  }: Reventless.Plugin.outboundTranslationSliceDef))
1456
1469
 
1457
1470
  // ── Inbound translation slices ────────────────────────────────────────────
@@ -962,6 +962,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
962
962
  retiredFailures.push(f);
963
963
  });
964
964
  };
965
+ let recordKeyField = (entityName, stateSchema) => Stdlib_Option.forEach(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField(entityName, stateSchema)), why => log.warn("Plugin_Structure", undefined, name + `/` + entityName + ` ` + why));
965
966
  let readModelDefs = readModels.map(R => {
966
967
  let qf = Api_Naming$ReventlessCore.queryFieldNamesForReadModel(name, R.Spec.name, undefined);
967
968
  let stateSchema = R.Spec.stateSchema;
@@ -970,6 +971,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
970
971
  let consumed = qualify(name, R.consumedEventNames);
971
972
  recordRetired(R.Spec.name, stateSchema);
972
973
  recordLifecycle(R.Spec.name, stateSchema);
974
+ recordKeyField(R.Spec.name, stateSchema);
973
975
  return {
974
976
  name: R.Spec.name,
975
977
  queryField: qf.listFieldName,
@@ -1001,6 +1003,7 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1001
1003
  let keyField = GraphQL_FragmentGenerator$ReventlessCore.resolveKeyField(SVS.Spec.name, stateSchema);
1002
1004
  recordRetired(SVS.Spec.name, stateSchema);
1003
1005
  recordLifecycle(SVS.Spec.name, stateSchema);
1006
+ recordKeyField(SVS.Spec.name, stateSchema);
1004
1007
  return {
1005
1008
  name: SVS.Spec.name,
1006
1009
  queryField: qf.listFieldName,
@@ -1071,7 +1074,8 @@ function make(name, aggregatesOpt, readModelsOpt, stateViewSlicesOpt, stateChang
1071
1074
  inboundCommandTypes: qualify(name, DcbTag$Reventless.extractAllVariantNames(OTS.Spec.inboundCommandSchema)),
1072
1075
  targetName: OTS.Spec.targetName,
1073
1076
  externalSystem: OTS.Spec.externalSystem,
1074
- chapter: componentChapters[OTS.Spec.name]
1077
+ chapter: componentChapters[OTS.Spec.name],
1078
+ consumedSources: OTS.Spec.sourceNames
1075
1079
  }));
1076
1080
  let inboundTranslationSliceDefs = inboundTranslationSlices.map(ITS => ({
1077
1081
  name: ITS.Spec.name,
@@ -71,7 +71,7 @@ type state = {
71
71
  // Admin's manageSubscriptions uses this to wire cross-plugin SNS subscriptions
72
72
  // from this plugin's DCB topic → peer EventCollectors (and vice-versa). None
73
73
  // for pure-aggregate plugins or for plugins persisted before Phase 4.
74
- dcbEventLog: @s.matches(Reventless.Plugin.dcbEventLogOptionSchema) option<Reventless.Plugin.dcbEventLogDefinition>,
74
+ dcbEventLog: option<Reventless.Plugin.dcbEventLogDefinition>,
75
75
  // Business role of the plugin (from pluginDefinition.kind). Lets the admin Plugins
76
76
  // view segregate PlatformInfrastructure / Commercial / Marketplace from Domain.
77
77
  // @scan opts the field into server-side equality filtering so the connection gains
@@ -26,7 +26,7 @@ let stateSchema = Sury.$schema(s => ({
26
26
  apiSchemaFragment: s.m(Sury.$option(Sury.json)),
27
27
  apiTarget: s.m(Sury.$option(Sury.string)),
28
28
  structure: s.m(Sury.$option(Sury.json)),
29
- dcbEventLog: s.m(Plugin$Reventless.dcbEventLogOptionSchema),
29
+ dcbEventLog: s.m(Sury.$option(Plugin$Reventless.dcbEventLogDefinitionSchema)),
30
30
  kind: s.m(Sury.$option(Plugin$Reventless.pluginKindSchema)),
31
31
  otherConnectedVersions: s.m(Sury.array(Plugin$Reventless.versionSchema))
32
32
  }));
@@ -128,6 +128,7 @@ let outbound: outboundTranslationSliceDef = {
128
128
  targetName: Some("Shipment"),
129
129
  externalSystem: Some("Shipper"),
130
130
  chapter: Some("Fulfilment"),
131
+ consumedSources: Some(["OrderingDcbEventLog"]),
131
132
  }
132
133
 
133
134
  let inbound: inboundTranslationSliceDef = {