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

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 (27) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/package.json +7 -7
  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/plugin/component/Plugin_Structure.res +13 -0
  12. package/src/plugin/component/Plugin_Structure.res.mjs +5 -1
  13. package/tests/admin/AdminApiSchemaDriftTest.res +1 -0
  14. package/tests/admin/AdminApiSchemaDriftTest.res.mjs +4 -1
  15. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +2 -0
  16. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +4 -2
  17. package/tests/admin/Platform_PluginStructuresApiTest.res +32 -0
  18. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +9 -0
  19. package/tests/api/GraphQL_FragmentGeneratorTest.res +47 -0
  20. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +32 -0
  21. package/tests/api/SuryToJsonSchemaTest.res +157 -0
  22. package/tests/api/SuryToJsonSchemaTest.res.mjs +81 -0
  23. package/tests/plugin/PluginStructureTest.res +22 -0
  24. package/tests/plugin/PluginStructureTest.res.mjs +7 -0
  25. package/tests/plugin/StateViewSlice/PsAnnotatedView.res +13 -3
  26. package/tests/plugin/StateViewSlice/PsAnnotatedView.res.mjs +7 -3
  27. package/tests/plugin/pluginDefinitionRequiredScalars.txt +6 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,17 @@
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.255 (2026-09-04)
7
+
8
+ ### Features
9
+
10
+ * **api:** a view says when a second *Id field took its key away ([10b5a4e](https://github.com/ReventlessDev/reventless-core/commit/10b5a4e41b01cfd27146f7f596573af27e4937d9))
11
+ * **plugin:** a slice publishes which topics it subscribes to ([c689695](https://github.com/ReventlessDev/reventless-core/commit/c6896957ecb636204678222ac5a26b30870439cb))
12
+ * **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))
13
+ * **spec:** a field can say it selects one of the values its row already holds ([2ae50c3](https://github.com/ReventlessDev/reventless-core/commit/2ae50c34deee48508a9f1f39e3eef6a5d2f5df00))
14
+ * **spec:** a field can say its value must not be rendered into a message ([3183f53](https://github.com/ReventlessDev/reventless-core/commit/3183f53a0ae667f794bbd1a3d77acf362dfa8e57))
15
+
16
+
6
17
  # 3.0.0-alpha.254 (2026-09-02)
7
18
 
8
19
  * 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.255",
4
4
  "description": "Core package for Reventless framework",
5
5
  "license": "Apache-2.0",
6
6
  "jest": {
@@ -29,21 +29,21 @@
29
29
  "sury": "11.0.0-rc.2",
30
30
  "uuid": "^13.0.0",
31
31
  "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
32
- "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
33
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
34
- "@reventlessdev/rescript-node": "2.0.0-alpha.8",
33
+ "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
35
34
  "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
35
+ "@reventlessdev/rescript-node": "2.0.0-alpha.8",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
37
- "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
37
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.156",
38
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",
39
+ "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.128",
41
41
  "@reventlessdev/reventless-interop": "3.0.0-alpha.34"
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
  }
@@ -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,
@@ -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 = {
@@ -199,13 +199,16 @@ let outbound_externalSystem = "Shipper";
199
199
 
200
200
  let outbound_chapter = "Fulfilment";
201
201
 
202
+ let outbound_consumedSources = ["OrderingDcbEventLog"];
203
+
202
204
  let outbound = {
203
205
  name: "ToShipper",
204
206
  consumedEventTypes: outbound_consumedEventTypes,
205
207
  inboundCommandTypes: outbound_inboundCommandTypes,
206
208
  targetName: outbound_targetName,
207
209
  externalSystem: outbound_externalSystem,
208
- chapter: outbound_chapter
210
+ chapter: outbound_chapter,
211
+ consumedSources: outbound_consumedSources
209
212
  };
210
213
 
211
214
  let inbound_commandTypes = ["RecordPayment"];
@@ -77,6 +77,7 @@ let structure: pluginStructure = {
77
77
  targetName: None,
78
78
  externalSystem: None,
79
79
  chapter: None,
80
+ consumedSources: None,
80
81
  },
81
82
  ],
82
83
  inboundTranslationSlices: [
@@ -227,6 +228,7 @@ describe("translation-slice externalSystem round-trip", () => {
227
228
  targetName: None,
228
229
  externalSystem: Some("ShipperGateway"),
229
230
  chapter: None,
231
+ consumedSources: None,
230
232
  },
231
233
  ],
232
234
  inboundTranslationSlices: [
@@ -118,7 +118,8 @@ let structure_outboundTranslationSlices = [{
118
118
  inboundCommandTypes: ["Ship"],
119
119
  targetName: undefined,
120
120
  externalSystem: undefined,
121
- chapter: undefined
121
+ chapter: undefined,
122
+ consumedSources: undefined
122
123
  }];
123
124
 
124
125
  let structure_inboundTranslationSlices = [{
@@ -245,7 +246,8 @@ globalThis.describe("translation-slice externalSystem round-trip", () => {
245
246
  inboundCommandTypes: ["Ship"],
246
247
  targetName: undefined,
247
248
  externalSystem: "ShipperGateway",
248
- chapter: undefined
249
+ chapter: undefined,
250
+ consumedSources: undefined
249
251
  }];
250
252
  let externalStructure_inboundTranslationSlices = [{
251
253
  name: "FromBilling",
@@ -183,4 +183,36 @@ describe("absent optional collections", () => {
183
183
  testSync("encodes absent requiredStores as null", () =>
184
184
  expect(bareJson->String.includes("\"requiredStores\":null"))->toEqual(true)
185
185
  )
186
+
187
+ // The case the frozen corpus cannot reach: every fixture in it carries
188
+ // `"outboundTranslationSlices":[]`, so no stored payload has an outbound slice
189
+ // to be missing a field. A structure written before `consumedSources` existed
190
+ // has no such key, and the schema alone refuses it — `S.nullAsOption` is
191
+ // `T | null`, and the variant that also accepts `undefined` fails sury's
192
+ // jsonable validation inside a union payload, which is where a structure
193
+ // travels. `parseJsonTolerant` is what makes the absence survivable, and it is
194
+ // the path the Plugin aggregate replays through.
195
+ testSync("an outbound slice stored before consumedSources existed still replays", () =>
196
+ expect(
197
+ (
198
+ `{"name":"ToShipper","consumedEventTypes":[],"inboundCommandTypes":[],` ++
199
+ `"targetName":null,"externalSystem":null,"chapter":null}`
200
+ )
201
+ ->JSON.parseOrThrow
202
+ ->Reventless.Message.parseJsonTolerant(outboundTranslationSliceDefSchema)
203
+ ->(slice => slice.consumedSources),
204
+ )->toEqual(None)
205
+ )
206
+
207
+ testSync("an explicit null decodes as None", () =>
208
+ expect(
209
+ (
210
+ `{"name":"ToShipper","consumedEventTypes":[],"inboundCommandTypes":[],` ++
211
+ `"targetName":null,"externalSystem":null,"chapter":null,"consumedSources":null}`
212
+ )
213
+ ->JSON.parseOrThrow
214
+ ->Reventless.Util_Sury.fromJson(outboundTranslationSliceDefSchema)
215
+ ->(slice => slice.consumedSources),
216
+ )->toEqual(None)
217
+ )
186
218
  })
@@ -4,6 +4,9 @@ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
4
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
7
+ import * as Plugin$Reventless from "@reventlessdev/reventless-spec/src/components/Plugin.res.mjs";
8
+ import * as Message$Reventless from "@reventlessdev/reventless-spec/src/types/Message.res.mjs";
9
+ import * as Util_Sury$Reventless from "@reventlessdev/reventless-spec/src/util/Util_Sury.res.mjs";
7
10
  import * as Platform_PluginStructuresApi$ReventlessCore from "../../src/admin/Platform_PluginStructuresApi.res.mjs";
8
11
 
9
12
  let publicRm_consumedEventTypes = ["ProductAdded"];
@@ -237,6 +240,12 @@ globalThis.describe("absent optional collections", () => {
237
240
  globalThis.test("encodes absent requiredStores as null", () => {
238
241
  globalThis.expect(bareJson.includes("\"requiredStores\":null")).toEqual(true);
239
242
  });
243
+ globalThis.test("an outbound slice stored before consumedSources existed still replays", () => {
244
+ globalThis.expect(Message$Reventless.parseJsonTolerant(JSON.parse(`{"name":"ToShipper","consumedEventTypes":[],"inboundCommandTypes":[],"targetName":null,"externalSystem":null,"chapter":null}`), Plugin$Reventless.outboundTranslationSliceDefSchema).consumedSources).toEqual(undefined);
245
+ });
246
+ globalThis.test("an explicit null decodes as None", () => {
247
+ globalThis.expect(Util_Sury$Reventless.fromJson(JSON.parse(`{"name":"ToShipper","consumedEventTypes":[],"inboundCommandTypes":[],"targetName":null,"externalSystem":null,"chapter":null,"consumedSources":null}`), Plugin$Reventless.outboundTranslationSliceDefSchema).consumedSources).toEqual(undefined);
248
+ });
240
249
  });
241
250
 
242
251
  export {
@@ -139,6 +139,53 @@ describe("resolveKeyField — the ladder", () => {
139
139
  })
140
140
  })
141
141
 
142
+ // Losing the key is invisible — the view keeps every field and every row, and
143
+ // drops its `<field>Eq` filter and its whole `orderBy` from the SDL. These pin
144
+ // which of the two ways to lose it is worth saying out loud.
145
+ describe("keyFieldGapMessage — which gap is worth a warning", () => {
146
+ let gapFor = (~entityName, schema) =>
147
+ GraphQL_FragmentGenerator.classifyKeyField(~entityName, schema->S.castToUnknown)
148
+ ->GraphQL_FragmentGenerator.keyFieldGapMessage
149
+
150
+ // The accident §14b.2 named: the view had `productId` alone and somebody added
151
+ // `categoryId`, so `sole` stopped firing and the name matches neither.
152
+ testSync("a second `*Id` field taking the key away is named, with both fields", () => {
153
+ let schema = S.schema(s =>
154
+ {"productId": s.matches(S.string), "categoryId": s.matches(S.string)}
155
+ )
156
+ let message = gapFor(~entityName="ProductDemand", schema)->Option.getOr("")
157
+ expect(message->String.includes("productId, categoryId"))->toBe(true)
158
+ expect(message->String.includes("productDemandId"))->toBe(true)
159
+ })
160
+
161
+ // A read model over an aggregate keeps the row's id on the row key, not in its
162
+ // state. Warning here would fire on most of them and get the rule silenced —
163
+ // six views across the example plugins are exactly this shape.
164
+ testSync("a state with no `*Id` field at all is silent, not warned about", () =>
165
+ expect(
166
+ gapFor(
167
+ ~entityName="Customers",
168
+ S.schema(s => {"email": s.matches(S.string), "address": s.matches(S.string)}),
169
+ ),
170
+ )->toEqual(None)
171
+ )
172
+
173
+ testSync("a resolved key says nothing", () =>
174
+ expect(
175
+ gapFor(~entityName="Orders", S.schema(s => {"orderId": s.matches(S.string)})),
176
+ )->toEqual(None)
177
+ )
178
+
179
+ testSync("an @id-less view whose name picks one of its `*Id` fields is fine", () =>
180
+ expect(
181
+ gapFor(
182
+ ~entityName="Products",
183
+ S.schema(s => {"productId": s.matches(S.string), "categoryId": s.matches(S.string)}),
184
+ ),
185
+ )->toEqual(None)
186
+ )
187
+ })
188
+
142
189
  describe("deriveServerCapability — inferred keys reach the SDL surface", () => {
143
190
  let capabilityFor = (~entityName, schema) =>
144
191
  GraphQL_FragmentGenerator.deriveServerCapability(~entityName, schema->S.castToUnknown)
@@ -148,6 +148,38 @@ globalThis.describe("resolveKeyField — the ladder", () => {
148
148
  });
149
149
  });
150
150
 
151
+ globalThis.describe("keyFieldGapMessage — which gap is worth a warning", () => {
152
+ globalThis.test("a second `*Id` field taking the key away is named, with both fields", () => {
153
+ let schema = Sury.$schema(s => ({
154
+ productId: s.m(Sury.string),
155
+ categoryId: s.m(Sury.string)
156
+ }));
157
+ let message = Stdlib_Option.getOr(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField("ProductDemand", schema)), "");
158
+ globalThis.expect(message.includes("productId, categoryId")).toBe(true);
159
+ globalThis.expect(message.includes("productDemandId")).toBe(true);
160
+ });
161
+ globalThis.test("a state with no `*Id` field at all is silent, not warned about", () => {
162
+ let schema = Sury.$schema(s => ({
163
+ email: s.m(Sury.string),
164
+ address: s.m(Sury.string)
165
+ }));
166
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField("Customers", schema))).toEqual(undefined);
167
+ });
168
+ globalThis.test("a resolved key says nothing", () => {
169
+ let schema = Sury.$schema(s => ({
170
+ orderId: s.m(Sury.string)
171
+ }));
172
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField("Orders", schema))).toEqual(undefined);
173
+ });
174
+ globalThis.test("an @id-less view whose name picks one of its `*Id` fields is fine", () => {
175
+ let schema = Sury.$schema(s => ({
176
+ productId: s.m(Sury.string),
177
+ categoryId: s.m(Sury.string)
178
+ }));
179
+ globalThis.expect(GraphQL_FragmentGenerator$ReventlessCore.keyFieldGapMessage(GraphQL_FragmentGenerator$ReventlessCore.classifyKeyField("Products", schema))).toEqual(undefined);
180
+ });
181
+ });
182
+
151
183
  globalThis.describe("deriveServerCapability — inferred keys reach the SDL surface", () => {
152
184
  globalThis.test("an inferred key yields an eq filter and a sort field", () => {
153
185
  let schema = Sury.$schema(s => ({
@@ -119,6 +119,78 @@ describe("SuryToJsonSchema:", () => {
119
119
  })
120
120
  })
121
121
 
122
+ describe("deriveObjectSchema with a sensitive field:", () => {
123
+ let sensitiveOf = (json, name) =>
124
+ getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, "x-reventless-sensitive"))
125
+ let ownerOf = (json, name) =>
126
+ getPropertyOf(json, name)->Option.flatMap(s => getProperty(s, "x-reventless-owner"))
127
+
128
+ let json = SuryToJsonSchema.deriveObjectSchema(
129
+ S.schema(s =>
130
+ {
131
+ "resetToken": s.matches(Reventless.Sensitive.string),
132
+ "note": s.matches(S.string),
133
+ "contact": s.matches(Reventless.Email.schema),
134
+ }
135
+ )->S.castToUnknown,
136
+ )
137
+
138
+ testSync("the marked field carries x-reventless-sensitive", () =>
139
+ expect(sensitiveOf(json, "resetToken"))->toEqual(Some(JSON.Encode.bool(true)))
140
+ )
141
+
142
+ // The control. Absent means "not stated", and a reader that misses the
143
+ // marker renders the value — so a bug that marked everything would look like
144
+ // caution while a bug that marked nothing leaks. Both need catching, and
145
+ // only this assertion catches the first.
146
+ testSync("an unmarked string field carries nothing", () =>
147
+ expect(sensitiveOf(json, "note"))->toBe(None)
148
+ )
149
+
150
+ // A contact detail is sensitive whether or not anybody wrote it down: the
151
+ // whole meaning of the semantic is "how to reach a particular person".
152
+ testSync("an email field is sensitive with no annotation", () =>
153
+ expect(sensitiveOf(json, "contact"))->toEqual(Some(JSON.Encode.bool(true)))
154
+ )
155
+
156
+ testSync("the field is still a plain string on the wire", () =>
157
+ expect(
158
+ getPropertyOf(json, "resetToken")->Option.flatMap(s => getProperty(s, "type")),
159
+ )->toEqual(Some(JSON.Encode.string("string")))
160
+ )
161
+
162
+ // Same trap as the owner case, and worse in consequence: a reader that only
163
+ // inspects the outer schema answers "not sensitive" for an optional field,
164
+ // and the value goes into a message.
165
+ testSync("an optional sensitive field is still recognised", () => {
166
+ let optJson = SuryToJsonSchema.deriveObjectSchema(
167
+ S.schema(s =>
168
+ {
169
+ "resetToken": s.matches(S.option(Reventless.Sensitive.string)),
170
+ }
171
+ )->S.castToUnknown,
172
+ )
173
+ expect(sensitiveOf(optJson, "resetToken"))->toEqual(Some(JSON.Encode.bool(true)))
174
+ })
175
+
176
+ // The composition rule. `mark` wraps rather than replaces, so a field that
177
+ // is both an owner and sensitive keeps both — a marker that silently
178
+ // replaced the other would unscope a view or unmask a value.
179
+ testSync("sensitivity composes with ownership on one field", () => {
180
+ let bothJson = SuryToJsonSchema.deriveObjectSchema(
181
+ S.schema(s =>
182
+ {
183
+ "customerId": s.matches(Reventless.Sensitive.mark(Reventless.Owner.string)),
184
+ }
185
+ )->S.castToUnknown,
186
+ )
187
+ expect((sensitiveOf(bothJson, "customerId"), ownerOf(bothJson, "customerId")))->toEqual((
188
+ Some(JSON.Encode.bool(true)),
189
+ Some(JSON.Encode.bool(true)),
190
+ ))
191
+ })
192
+ })
193
+
122
194
  describe("deriveObjectSchema with stateAnnotations metadata:", () => {
123
195
  let withSpec = (schema, spec) =>
124
196
  schema->S.Metadata.set(~id=Reventless.StateAnnotations.stateAnnotationsId, spec)
@@ -927,6 +999,91 @@ describe("SuryToJsonSchema:", () => {
927
999
  })
928
1000
  })
929
1001
 
1002
+ // A selection, not an input. The whole point of the id is that it travels the
1003
+ // same channel as a store declaration and says something a store declaration
1004
+ // cannot: the candidates are on the row, so nothing is provisioned for it.
1005
+ describe("a member-reference field:", () => {
1006
+ let targetOf = (json, name) =>
1007
+ getPropertyOf(json, name)->Option.flatMap(s =>
1008
+ getProperty(s, "x-reventless-semantic-target")
1009
+ )
1010
+
1011
+ testSync("carries the collection, the view holding it, and what a member is", () => {
1012
+ let json = SuryToJsonSchema.deriveObjectSchema(
1013
+ S.schema(s =>
1014
+ {
1015
+ "productImage": s.matches(
1016
+ Reventless.MemberRef.of_(
1017
+ ~view="Products",
1018
+ ~content=Reventless.Semantic.Id.imageRef,
1019
+ ~field="productImages",
1020
+ ),
1021
+ ),
1022
+ }
1023
+ )->S.castToUnknown,
1024
+ )
1025
+ expect(
1026
+ getPropertyOf(json, "productImage")->Option.flatMap(s =>
1027
+ getProperty(s, "x-reventless-semantic")
1028
+ ),
1029
+ )->toEqual(Some(JSON.Encode.string("memberRef")))
1030
+ expect(targetOf(json, "productImage"))->toEqual(
1031
+ Some(
1032
+ JSON.Encode.object(
1033
+ Dict.fromArray([
1034
+ ("field", JSON.Encode.string("productImages")),
1035
+ ("view", JSON.Encode.string("Products")),
1036
+ ("content", JSON.Encode.string("imageRef")),
1037
+ ]),
1038
+ ),
1039
+ ),
1040
+ )
1041
+ })
1042
+
1043
+ // The second position: a declaration on a view's own state, where the
1044
+ // collection is on the same record and naming a view would invite the reader
1045
+ // to think another one could be meant.
1046
+ testSync("omits the view where the collection is on this very record", () =>
1047
+ expect(
1048
+ targetOf(
1049
+ SuryToJsonSchema.deriveObjectSchema(
1050
+ S.schema(s =>
1051
+ {"productImage": s.matches(Reventless.MemberRef.of_(~field="productImages"))}
1052
+ )->S.castToUnknown,
1053
+ ),
1054
+ "productImage",
1055
+ ),
1056
+ )->toEqual(
1057
+ Some(JSON.Encode.object(Dict.fromArray([("field", JSON.Encode.string("productImages"))]))),
1058
+ )
1059
+ )
1060
+
1061
+ // The assertion the whole design rests on, and the one a reader will want to
1062
+ // see rather than infer: a `memberRef` field declares NO store, so the
1063
+ // provisioning walk passes it by and no upload endpoint is bound. This is
1064
+ // exactly what `imageRef` gets today, reached through a different payload.
1065
+ testSync("declares no store, so nothing is provisioned for it", () =>
1066
+ expect(
1067
+ Reventless.StorageRef.getFieldStore(
1068
+ Reventless.MemberRef.of_(~view="Products", ~field="productImages"),
1069
+ ),
1070
+ )->toBe(None)
1071
+ )
1072
+
1073
+ testSync("stays a plain string on the wire", () =>
1074
+ expect(
1075
+ getPropertyOf(
1076
+ SuryToJsonSchema.deriveObjectSchema(
1077
+ S.schema(s =>
1078
+ {"productImage": s.matches(Reventless.MemberRef.of_(~field="productImages"))}
1079
+ )->S.castToUnknown,
1080
+ ),
1081
+ "productImage",
1082
+ )->Option.flatMap(s => getProperty(s, "type")),
1083
+ )->toEqual(Some(JSON.Encode.string("string")))
1084
+ )
1085
+ })
1086
+
930
1087
  // The claim the branded scalars rest on: marking a field adds an annotation,
931
1088
  // not a shape. A string stays a string and a number stays a number on the
932
1089
  // wire, which is what makes them retrofittable onto a log that already has
@@ -13,8 +13,11 @@ import * as Percent$Reventless from "@reventlessdev/reventless-spec/src/semantic
13
13
  import * as Currency$Reventless from "@reventlessdev/reventless-spec/src/semantic/Currency.res.mjs";
14
14
  import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/types/DateTime.res.mjs";
15
15
  import * as GeoPoint$Reventless from "@reventlessdev/reventless-spec/src/semantic/GeoPoint.res.mjs";
16
+ import * as Semantic$Reventless from "@reventlessdev/reventless-spec/src/semantic/Semantic.res.mjs";
16
17
  import * as DateRange$Reventless from "@reventlessdev/reventless-spec/src/semantic/DateRange.res.mjs";
18
+ import * as MemberRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/MemberRef.res.mjs";
17
19
  import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
20
+ import * as Sensitive$Reventless from "@reventlessdev/reventless-spec/src/components/Sensitive.res.mjs";
18
21
  import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
19
22
  import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
20
23
  import * as SuryToJsonSchema$ReventlessCore from "../../src/components/Api/SuryToJsonSchema.res.mjs";
@@ -130,6 +133,45 @@ globalThis.describe("SuryToJsonSchema:", () => {
130
133
  globalThis.expect(ownerOf(optJson, "customerId")).toEqual(true);
131
134
  });
132
135
  });
136
+ globalThis.describe("deriveObjectSchema with a sensitive field:", () => {
137
+ let sensitiveOf = (json, name) => Stdlib_Option.flatMap(getPropertyOf(json, name), s => getProperty(s, "x-reventless-sensitive"));
138
+ let ownerOf = (json, name) => Stdlib_Option.flatMap(getPropertyOf(json, name), s => getProperty(s, "x-reventless-owner"));
139
+ let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(Sury.$schema(s => ({
140
+ resetToken: s.m(Sensitive$Reventless.string),
141
+ note: s.m(Sury.string),
142
+ contact: s.m(Email$Reventless.schema)
143
+ })));
144
+ globalThis.test("the marked field carries x-reventless-sensitive", () => {
145
+ globalThis.expect(sensitiveOf(json, "resetToken")).toEqual(true);
146
+ });
147
+ globalThis.test("an unmarked string field carries nothing", () => {
148
+ globalThis.expect(sensitiveOf(json, "note")).toBe(undefined);
149
+ });
150
+ globalThis.test("an email field is sensitive with no annotation", () => {
151
+ globalThis.expect(sensitiveOf(json, "contact")).toEqual(true);
152
+ });
153
+ globalThis.test("the field is still a plain string on the wire", () => {
154
+ globalThis.expect(Stdlib_Option.flatMap(getPropertyOf(json, "resetToken"), s => getProperty(s, "type"))).toEqual("string");
155
+ });
156
+ globalThis.test("an optional sensitive field is still recognised", () => {
157
+ let optJson = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(Sury.$schema(s => ({
158
+ resetToken: s.m(Sury.$option(Sensitive$Reventless.string))
159
+ })));
160
+ globalThis.expect(sensitiveOf(optJson, "resetToken")).toEqual(true);
161
+ });
162
+ globalThis.test("sensitivity composes with ownership on one field", () => {
163
+ let bothJson = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(Sury.$schema(s => ({
164
+ customerId: s.m(Sensitive$Reventless.mark(Owner$Reventless.string))
165
+ })));
166
+ globalThis.expect([
167
+ sensitiveOf(bothJson, "customerId"),
168
+ ownerOf(bothJson, "customerId")
169
+ ]).toEqual([
170
+ true,
171
+ true
172
+ ]);
173
+ });
174
+ });
133
175
  globalThis.describe("deriveObjectSchema with stateAnnotations metadata:", () => {
134
176
  globalThis.test("emits x-reventless-id on field listed in ids", () => {
135
177
  let schema = Sury.$schema(s => ({
@@ -872,6 +914,45 @@ globalThis.describe("SuryToJsonSchema:", () => {
872
914
  globalThis.expect(semanticOf(json, "imageUrl")).toEqual("storageRef");
873
915
  });
874
916
  });
917
+ globalThis.describe("a member-reference field:", () => {
918
+ let targetOf = (json, name) => Stdlib_Option.flatMap(getPropertyOf(json, name), s => getProperty(s, "x-reventless-semantic-target"));
919
+ globalThis.test("carries the collection, the view holding it, and what a member is", () => {
920
+ let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(Sury.$schema(s => ({
921
+ productImage: s.m(MemberRef$Reventless.of_(undefined, "Products", Semantic$Reventless.Id.imageRef, "productImages"))
922
+ })));
923
+ globalThis.expect(Stdlib_Option.flatMap(getPropertyOf(json, "productImage"), s => getProperty(s, "x-reventless-semantic"))).toEqual("memberRef");
924
+ globalThis.expect(targetOf(json, "productImage")).toEqual(Object.fromEntries([
925
+ [
926
+ "field",
927
+ "productImages"
928
+ ],
929
+ [
930
+ "view",
931
+ "Products"
932
+ ],
933
+ [
934
+ "content",
935
+ "imageRef"
936
+ ]
937
+ ]));
938
+ });
939
+ globalThis.test("omits the view where the collection is on this very record", () => {
940
+ globalThis.expect(targetOf(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(Sury.$schema(s => ({
941
+ productImage: s.m(MemberRef$Reventless.of_(undefined, undefined, undefined, "productImages"))
942
+ }))), "productImage")).toEqual(Object.fromEntries([[
943
+ "field",
944
+ "productImages"
945
+ ]]));
946
+ });
947
+ globalThis.test("declares no store, so nothing is provisioned for it", () => {
948
+ globalThis.expect(StorageRef$Reventless.getFieldStore(MemberRef$Reventless.of_(undefined, "Products", undefined, "productImages"))).toBe(undefined);
949
+ });
950
+ globalThis.test("stays a plain string on the wire", () => {
951
+ globalThis.expect(Stdlib_Option.flatMap(getPropertyOf(SuryToJsonSchema$ReventlessCore.deriveObjectSchema(Sury.$schema(s => ({
952
+ productImage: s.m(MemberRef$Reventless.of_(undefined, undefined, undefined, "productImages"))
953
+ }))), "productImage"), s => getProperty(s, "type"))).toEqual("string");
954
+ });
955
+ });
875
956
  globalThis.describe("branded scalars keep their underlying shape:", () => {
876
957
  let keyOf = (json, name, key) => Stdlib_Option.flatMap(getPropertyOf(json, name), s => getProperty(s, key));
877
958
  let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(Sury.$schema(s => ({
@@ -1300,6 +1300,28 @@ describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1300
1300
  )->toBe(Some("currency"))
1301
1301
  })
1302
1302
 
1303
+ testSync("@sensitive flows through the PPX to x-reventless-sensitive", () => {
1304
+ expect(
1305
+ annotatedSchema
1306
+ ->getPropertyOf("contact")
1307
+ ->Option.flatMap(s => getProperty(s, "x-reventless-sensitive"))
1308
+ ->Option.flatMap(JSON.Decode.bool),
1309
+ )->toBe(Some(true))
1310
+ })
1311
+
1312
+ // The case the marker exists for. A renderer composes from an event payload,
1313
+ // and the annotation path `deriveObjectSchema` reads for state is
1314
+ // state-only — so a marker that lived there would be absent exactly where
1315
+ // the value is about to be interpolated into a message.
1316
+ testSync("@sensitive reaches an event variant's payload, not only state", () =>
1317
+ expect(
1318
+ Reventless.Sensitive.variantFieldNames(
1319
+ PsAnnotatedView.consumedEventSchema->S.castToUnknown,
1320
+ ~variant="ItemRecorded",
1321
+ ),
1322
+ )->toEqual(["contact"])
1323
+ )
1324
+
1303
1325
  testSync("@metric flows through the PPX to x-reventless-metric {aggregate,label}", () => {
1304
1326
  let metricObj =
1305
1327
  annotatedSchema
@@ -11,6 +11,7 @@ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/component
11
11
  import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/types/DateTime.res.mjs";
12
12
  import * as Primitive_exceptions from "@rescript/runtime/lib/es6/Primitive_exceptions.js";
13
13
  import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
14
+ import * as Sensitive$Reventless from "@reventlessdev/reventless-spec/src/components/Sensitive.res.mjs";
14
15
  import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
15
16
  import * as Api_Naming$ReventlessCore from "../../src/components/Api/Api_Naming.res.mjs";
16
17
  import * as PsShipOrder$ReventlessCore from "./StateChangeSlice/PsShipOrder.res.mjs";
@@ -1444,6 +1445,12 @@ globalThis.describe("Plugin_Structure.make — Phase 2 graph fields", () => {
1444
1445
  globalThis.test("@semantic(\"currency\") flows through the PPX to x-reventless-semantic", () => {
1445
1446
  globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(getPropertyOf(annotatedSchema, "total"), s => getProperty(s, "x-reventless-semantic")), Stdlib_JSON.Decode.string)).toBe("currency");
1446
1447
  });
1448
+ globalThis.test("@sensitive flows through the PPX to x-reventless-sensitive", () => {
1449
+ globalThis.expect(Stdlib_Option.flatMap(Stdlib_Option.flatMap(getPropertyOf(annotatedSchema, "contact"), s => getProperty(s, "x-reventless-sensitive")), Stdlib_JSON.Decode.bool)).toBe(true);
1450
+ });
1451
+ globalThis.test("@sensitive reaches an event variant's payload, not only state", () => {
1452
+ globalThis.expect(Sensitive$Reventless.variantFieldNames(PsAnnotatedView$ReventlessCore.consumedEventSchema, "ItemRecorded")).toEqual(["contact"]);
1453
+ });
1447
1454
  globalThis.test("@metric flows through the PPX to x-reventless-metric {aggregate,label}", () => {
1448
1455
  let metricObj = Stdlib_Option.flatMap(getPropertyOf(annotatedSchema, "total"), s => getProperty(s, "x-reventless-metric"));
1449
1456
  globalThis.expect([
@@ -7,7 +7,16 @@
7
7
 
8
8
  @schema
9
9
  type consumedEvent =
10
- | ItemRecorded({itemId: string, ownerId: string, version: string, name: string, total: float})
10
+ | ItemRecorded({
11
+ itemId: string,
12
+ ownerId: string,
13
+ version: string,
14
+ name: string,
15
+ total: float,
16
+ // On the event, not just the state: a renderer composes from an event
17
+ // payload, so this is the case the marker has to reach.
18
+ @sensitive contact: string,
19
+ })
11
20
 
12
21
  @live(false)
13
22
  @schema
@@ -17,10 +26,11 @@ type state = {
17
26
  @index("byOwner") ownerId: string,
18
27
  name: string,
19
28
  @semantic("currency") @metric({aggregate: "sum", label: "Revenue"}) total: float,
29
+ @sensitive contact: string,
20
30
  }
21
31
 
22
32
  let project = ({event}: Reventless.StateViewSlice.consumed<consumedEvent>) =>
23
33
  switch event {
24
- | ItemRecorded({itemId, ownerId, version, name, total}) =>
25
- [Set(itemId, {itemId, ownerId, version, name, total})]
34
+ | ItemRecorded({itemId, ownerId, version, name, total, contact}) =>
35
+ [Set(itemId, {itemId, ownerId, version, name, total, contact})]
26
36
  }
@@ -3,6 +3,7 @@
3
3
  import * as Sury from "sury";
4
4
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
5
5
  import * as ReadModel$Reventless from "@reventlessdev/reventless-spec/src/components/ReadModel.res.mjs";
6
+ import * as Sensitive$Reventless from "@reventlessdev/reventless-spec/src/components/Sensitive.res.mjs";
6
7
  import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
7
8
 
8
9
  let consumedEventSchema = Sury.$schema(s => ({
@@ -11,7 +12,8 @@ let consumedEventSchema = Sury.$schema(s => ({
11
12
  ownerId: s.m(DcbTag$Reventless.string),
12
13
  version: s.m(Sury.string),
13
14
  name: s.m(Sury.string),
14
- total: s.m(Sury.float)
15
+ total: s.m(Sury.float),
16
+ contact: s.m(Sensitive$Reventless.string)
15
17
  }));
16
18
 
17
19
  let stateSchema = Sury.$schema(s => ({
@@ -19,7 +21,8 @@ let stateSchema = Sury.$schema(s => ({
19
21
  version: s.m(Sury.string),
20
22
  ownerId: s.m(Sury.string),
21
23
  name: s.m(Sury.string),
22
- total: s.m(Sury.float)
24
+ total: s.m(Sury.float),
25
+ contact: s.m(Sensitive$Reventless.string)
23
26
  }));
24
27
 
25
28
  function project(param) {
@@ -33,7 +36,8 @@ function project(param) {
33
36
  version: event.version,
34
37
  ownerId: event.ownerId,
35
38
  name: event.name,
36
- total: event.total
39
+ total: event.total,
40
+ contact: event.contact
37
41
  }
38
42
  }];
39
43
  }
@@ -94,6 +94,12 @@
94
94
  .structure.inboundTranslationSlices[].name: string
95
95
  .structure.inboundTranslationSlices[].targetName: string
96
96
  .structure.outboundTranslationSlices[].consumedEventTypes[]: string
97
+ # `consumedSources` is itself `js_nullable` and is NEW, so no persisted payload
98
+ # carries the array: a stored structure heals to null and this line is never
99
+ # reached — the same call `traitDeclarations` made below. Required inside it
100
+ # because a source name is a topic key the slice subscribes to, and a fabricated
101
+ # `""` would name a topic that does not exist rather than saying nothing.
102
+ .structure.outboundTranslationSlices[].consumedSources[]: string
97
103
  .structure.outboundTranslationSlices[].inboundCommandTypes[]: string
98
104
  .structure.outboundTranslationSlices[].name: string
99
105
  .structure.readModels[].consumedEventTypes[]: string