@reventlessdev/reventless-core 3.0.0-alpha.253 → 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 (31) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +8 -8
  3. package/src/adapter/Messaging/Messaging_Log_Backend.res +98 -0
  4. package/src/adapter/Messaging/Messaging_Log_Backend.res.mjs +86 -0
  5. package/src/admin/Platform_ComponentDefinitionsApi.res +5 -1
  6. package/src/admin/Platform_ComponentDefinitionsApi.res.mjs +5 -1
  7. package/src/components/Api/GraphQL_FragmentGenerator.res +55 -14
  8. package/src/components/Api/GraphQL_FragmentGenerator.res.mjs +43 -12
  9. package/src/components/Api/SchemaType.res +1 -0
  10. package/src/components/Api/SchemaType.res.mjs +4 -0
  11. package/src/components/Api/SuryToJsonSchema.res +41 -0
  12. package/src/components/Api/SuryToJsonSchema.res.mjs +50 -17
  13. package/src/components/Dcb/Dcb_Builder.res +21 -4
  14. package/src/components/Dcb/Dcb_Builder.res.mjs +6 -3
  15. package/src/plugin/component/Plugin_Structure.res +13 -0
  16. package/src/plugin/component/Plugin_Structure.res.mjs +5 -1
  17. package/tests/admin/AdminApiSchemaDriftTest.res +1 -0
  18. package/tests/admin/AdminApiSchemaDriftTest.res.mjs +4 -1
  19. package/tests/admin/Platform_ComponentDefinitionsApiTest.res +2 -0
  20. package/tests/admin/Platform_ComponentDefinitionsApiTest.res.mjs +4 -2
  21. package/tests/admin/Platform_PluginStructuresApiTest.res +32 -0
  22. package/tests/admin/Platform_PluginStructuresApiTest.res.mjs +9 -0
  23. package/tests/api/GraphQL_FragmentGeneratorTest.res +47 -0
  24. package/tests/api/GraphQL_FragmentGeneratorTest.res.mjs +32 -0
  25. package/tests/api/SuryToJsonSchemaTest.res +157 -0
  26. package/tests/api/SuryToJsonSchemaTest.res.mjs +81 -0
  27. package/tests/plugin/PluginStructureTest.res +22 -0
  28. package/tests/plugin/PluginStructureTest.res.mjs +7 -0
  29. package/tests/plugin/StateViewSlice/PsAnnotatedView.res +13 -3
  30. package/tests/plugin/StateViewSlice/PsAnnotatedView.res.mjs +7 -3
  31. package/tests/plugin/pluginDefinitionRequiredScalars.txt +6 -0
package/CHANGELOG.md CHANGED
@@ -3,6 +3,34 @@
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
+
17
+ # 3.0.0-alpha.254 (2026-09-02)
18
+
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))
20
+ ### Features
21
+
22
+ * **dcb:** a boundary that cannot derive its scope says so ([db79969](https://github.com/ReventlessDev/reventless-core/commit/db79969df36bd90607425f6a22b4624227cfc4a0))
23
+
24
+ ### BREAKING CHANGES
25
+
26
+ * `Capability_Messaging_Ses.make` is replaced by
27
+ `Capability_Messaging.make(~name)`, which reads the transport and the address
28
+ from config; the SES module keeps only `emailSender`. A deployment that named its
29
+ sender in code must move it to `platform:messagingEmailSender` or the deploy is
30
+ refused.
31
+
32
+
33
+
6
34
  # 3.0.0-alpha.253 (2026-09-01)
7
35
 
8
36
  **Note:** Version bump only for package @reventlessdev/reventless-core
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.253",
3
+ "version": "3.0.0-alpha.255",
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-effect": "0.1.0-alpha.32",
32
+ "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
31
33
  "@reventlessdev/rescript-fast-csv": "2.0.0-alpha.8",
34
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.10",
32
35
  "@reventlessdev/rescript-node": "2.0.0-alpha.8",
33
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.19",
34
- "@reventlessdev/rescript-hash-object": "1.2.0-alpha.14",
35
- "@reventlessdev/rescript-effect": "0.1.0-alpha.32",
37
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.156",
36
38
  "@reventlessdev/rescript-ssh2": "2.0.0-alpha.8",
37
- "@reventlessdev/reventless-infra": "3.0.0-alpha.154",
38
39
  "@reventlessdev/rescript-uuid": "2.0.0-alpha.0",
39
- "@reventlessdev/reventless-interop": "3.0.0-alpha.34",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.126",
41
- "@reventlessdev/rescript-jest": "1.0.0-alpha.10"
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.128",
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"
@@ -0,0 +1,98 @@
1
+ // A logging messaging transport: accepts every message, delivers none, prints
2
+ // each one to the log.
3
+ //
4
+ // Provider-neutral and therefore here rather than beside either platform's own
5
+ // transport: both use it, and two copies would drift into two different ideas of
6
+ // what a logged send looks like. It is the counterpart to
7
+ // `Messaging_Ses_Backend` and deliberately the same shape — `channels` derived
8
+ // from the sender, `send` answering the shared `Messaging` vocabulary, `provider`
9
+ // closing over the sender. A slice reaches it through the injected capability
10
+ // record and cannot tell which one it got, which is the property that makes
11
+ // running a shop against it worth anything.
12
+ //
13
+ // **Accepting is the honest answer here, not a shortcut.** The alternative is
14
+ // `Capabilities.none`, which reports `Unavailable` — a *retryable* outage, so
15
+ // every notification is retried three times and then recorded as failed. A
16
+ // delivery view that is all failures teaches the wrong thing about a competency
17
+ // that works. Nothing leaves the process either way; the difference is only
18
+ // whether the outcome tells the truth about the domain.
19
+ //
20
+ // It refuses nothing a real provider would refuse, and that is the tradeoff to
21
+ // know: SES rejects a message with no subject, this does not, so a bug of that
22
+ // shape is caught on the real transport and not here.
23
+ //
24
+ // Runtime-pure: no Pulumi import, so it can be bundled into a Lambda entry point
25
+ // without dragging deploy-time modules into the cold-start graph.
26
+
27
+ let log = Logger.fromEnv()
28
+
29
+ /** Logged messages are numbered rather than given a random id: someone reading two
30
+ log lines wants to know they are two messages, and a counter says so at a
31
+ glance where a uuid does not. Per process, like the transport itself. */
32
+ let sent = ref(0)
33
+
34
+ /**
35
+ The channels this transport can attempt.
36
+
37
+ Derived from the sender exactly as SES derives it, so a platform that chose this
38
+ transport but named no address publishes the same empty list an unprovisioned
39
+ deployment does. SMS and push are absent for the same reason they are absent
40
+ there — no transport — and answer `UnsupportedChannel`, which is settled rather
41
+ than retried.
42
+ */
43
+ let channels = (~sender: string): array<Reventless.Messaging.channel> =>
44
+ sender == "" ? [] : [Email]
45
+
46
+ /** The message as it would have gone out, headers and all.
47
+
48
+ The whole point of the transport: someone checking that a notification says the
49
+ right thing needs the words, not a line saying words were sent. Rendered as
50
+ one record rather than a line per header so two concurrent sends cannot
51
+ interleave into one unreadable message. */
52
+ let render = (
53
+ ~ref_: string,
54
+ ~sender: string,
55
+ ~to_: string,
56
+ ~message: Reventless.Messaging.message,
57
+ ) =>
58
+ [
59
+ `${ref_} — logged, not sent`,
60
+ ` From: ${sender}`,
61
+ ` To: ${to_}`,
62
+ ` Subject: ${message.subject->Option.getOr("(none)")}`,
63
+ "",
64
+ message.body,
65
+ ]->Array.join("\n")
66
+
67
+ /** Accept the message, print it, deliver nothing. */
68
+ let send = async (
69
+ ~sender: string,
70
+ ~recipient: Reventless.Messaging.recipient,
71
+ ~message: Reventless.Messaging.message,
72
+ ): result<Reventless.Messaging.receipt, Reventless.Messaging.failure> =>
73
+ switch recipient {
74
+ | ToSms(_) => Error(UnsupportedChannel(Sms))
75
+ | ToPush(_) => Error(UnsupportedChannel(Push))
76
+ | ToEmail(address) =>
77
+ if sender == "" {
78
+ // The wording an unprovisioned platform gave before this transport existed.
79
+ // It is what lands in the delivery view's `detail`, and a platform that
80
+ // provisions nothing should still read the way it always did.
81
+ Error(Unavailable("no messaging provider is configured for this platform"))
82
+ } else {
83
+ sent := sent.contents + 1
84
+ let ref_ = `log:${sent.contents->Int.toString}`
85
+ log.info(
86
+ ~comp="Messaging:Log",
87
+ render(~ref_, ~sender, ~to_=address->Reventless.Email.toString, ~message),
88
+ )
89
+ Ok({ref: ref_})
90
+ }
91
+ }
92
+
93
+ /** The port, closed over the sender this platform was given. What a platform's
94
+ capability record carries when logging is the chosen transport. */
95
+ let provider = (~sender: string): Reventless.Messaging.provider => {
96
+ channels: channels(~sender),
97
+ send: (~recipient, ~message) => send(~sender, ~recipient, ~message),
98
+ }
@@ -0,0 +1,86 @@
1
+ // Generated by ReScript, PLEASE EDIT WITH CARE
2
+
3
+ import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
4
+ import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
5
+
6
+ let log = Logger$ReventlessCore.fromEnv();
7
+
8
+ let sent = {
9
+ contents: 0
10
+ };
11
+
12
+ function channels(sender) {
13
+ if (sender === "") {
14
+ return [];
15
+ } else {
16
+ return ["Email"];
17
+ }
18
+ }
19
+
20
+ function render(ref_, sender, to_, message) {
21
+ return [
22
+ ref_ + ` — logged, not sent`,
23
+ ` From: ` + sender,
24
+ ` To: ` + to_,
25
+ ` Subject: ` + Stdlib_Option.getOr(message.subject, "(none)"),
26
+ "",
27
+ message.body
28
+ ].join("\n");
29
+ }
30
+
31
+ async function send(sender, recipient, message) {
32
+ switch (recipient.TAG) {
33
+ case "ToEmail" :
34
+ if (sender === "") {
35
+ return {
36
+ TAG: "Error",
37
+ _0: {
38
+ TAG: "Unavailable",
39
+ _0: "no messaging provider is configured for this platform"
40
+ }
41
+ };
42
+ }
43
+ sent.contents = sent.contents + 1 | 0;
44
+ let ref_ = `log:` + sent.contents.toString();
45
+ log.info("Messaging:Log", undefined, render(ref_, sender, recipient._0, message));
46
+ return {
47
+ TAG: "Ok",
48
+ _0: {
49
+ ref: ref_
50
+ }
51
+ };
52
+ case "ToSms" :
53
+ return {
54
+ TAG: "Error",
55
+ _0: {
56
+ TAG: "UnsupportedChannel",
57
+ _0: "Sms"
58
+ }
59
+ };
60
+ case "ToPush" :
61
+ return {
62
+ TAG: "Error",
63
+ _0: {
64
+ TAG: "UnsupportedChannel",
65
+ _0: "Push"
66
+ }
67
+ };
68
+ }
69
+ }
70
+
71
+ function provider(sender) {
72
+ return {
73
+ channels: channels(sender),
74
+ send: (recipient, message) => send(sender, recipient, message)
75
+ };
76
+ }
77
+
78
+ export {
79
+ log,
80
+ sent,
81
+ channels,
82
+ render,
83
+ send,
84
+ provider,
85
+ }
86
+ /* log Not a pure module */
@@ -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.