@reventlessdev/reventless-core 3.0.0-alpha.186 → 3.0.0-alpha.188

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.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,22 @@
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.188 (2026-07-28)
7
+
8
+ **Note:** Version bump only for package @reventlessdev/reventless-core
9
+
10
+
11
+
12
+
13
+
14
+ # 3.0.0-alpha.187 (2026-07-28)
15
+
16
+ ### Features
17
+
18
+ * **spec:** one semantic marker every typed semantic marks itself with ([aa18afc](https://github.com/ReventlessDev/reventless-core/commit/aa18afcf04c8edad9afe27e6fa4261d01e184da7))
19
+ * **spec:** StorageRef — the first semantic type, declared on the field's type ([44f15c3](https://github.com/ReventlessDev/reventless-core/commit/44f15c37de71261d701d18a9f1ada6f481c4a8dc))
20
+
21
+
6
22
  # 3.0.0-alpha.186 (2026-07-27)
7
23
 
8
24
  ### Features
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-core",
3
- "version": "3.0.0-alpha.186",
3
+ "version": "3.0.0-alpha.188",
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-alpha.4",
30
30
  "uuid": "^13.0.0",
31
- "@reventlessdev/rescript-effect": "0.1.0-alpha.31",
32
31
  "@reventlessdev/rescript-fast-csv": "1.2.0-alpha.14",
33
- "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
34
32
  "@reventlessdev/rescript-hash-object": "1.2.0-alpha.13",
33
+ "@reventlessdev/rescript-jest": "1.0.0-alpha.9",
35
34
  "@reventlessdev/rescript-node-streams": "1.1.0-alpha.13",
35
+ "@reventlessdev/rescript-effect": "0.1.0-alpha.31",
36
36
  "@reventlessdev/rescript-pulumi-pulumi": "2.3.0-alpha.17",
37
37
  "@reventlessdev/rescript-ssh2": "1.1.0-alpha.14",
38
38
  "@reventlessdev/rescript-uuid": "1.1.0-alpha.17",
39
- "@reventlessdev/reventless-interop": "3.0.0-alpha.29",
40
- "@reventlessdev/reventless-spec": "3.0.0-alpha.81",
41
- "@reventlessdev/reventless-infra": "3.0.0-alpha.106"
39
+ "@reventlessdev/reventless-infra": "3.0.0-alpha.107",
40
+ "@reventlessdev/reventless-spec": "3.0.0-alpha.82",
41
+ "@reventlessdev/reventless-interop": "3.0.0-alpha.29"
42
42
  },
43
43
  "devDependencies": {
44
44
  "rescript": "12.3.0",
45
45
  "sury-ppx": "11.0.0-alpha.2",
46
- "@reventlessdev/reventless-ppx": "1.0.0-alpha.56"
46
+ "@reventlessdev/reventless-ppx": "1.0.0-alpha.58"
47
47
  },
48
48
  "peerDependencies": {
49
49
  "rescript": "12.3.0"
@@ -62,6 +62,11 @@ let rec fromSchemaType = (
62
62
  collectedTypes->Array.push(`enum ${name} {\n ${valuesStr}\n}`)
63
63
  }
64
64
  `${name}${bang}`
65
+ // GraphQL carries the value's shape, not its meaning: a storage ref is a
66
+ // String on the wire exactly as it is in the event log. The semantic reaches
67
+ // the UI through the field's JSON Schema, which is the channel that can
68
+ // express it.
69
+ | Semantic(_, inner) => fromSchemaType(~required, ~asInput, inner, collectedTypes, seenTypes)
65
70
  | Unknown => `String${bang}`
66
71
  }
67
72
  }
@@ -171,13 +176,14 @@ let emptyCapability: serverCapability = {filterFields: [], sortFields: []}
171
176
  // Convert a SchemaType.schemaType to its GraphQL scalar name (input position).
172
177
  // Mirrors the scalar branches of fromSchemaType — kept inline because we don't
173
178
  // emit `!`/list wrappers for filter inputs.
174
- let scalarOfSchemaType = (st: SchemaType.schemaType): string =>
179
+ let rec scalarOfSchemaType = (st: SchemaType.schemaType): string =>
175
180
  switch st {
176
181
  | ScalarString => "String"
177
182
  | ScalarNumber => "Float"
178
183
  | ScalarBoolean => "Boolean"
179
184
  | ScalarBigInt => "String"
180
185
  | EntityId => "ID"
186
+ | Semantic(_, inner) => scalarOfSchemaType(inner)
181
187
  | _ => "String"
182
188
  }
183
189
 
@@ -71,6 +71,10 @@ function fromSchemaType(_required, _asInputOpt, _st, collectedTypes, seenTypes)
71
71
  collectedTypes.push(`enum ` + name$1 + ` {\n ` + valuesStr + `\n}`);
72
72
  }
73
73
  return name$1 + bang;
74
+ case "Semantic" :
75
+ _st = st._1;
76
+ _asInputOpt = asInput;
77
+ continue;
74
78
  }
75
79
  }
76
80
  };
@@ -144,20 +148,28 @@ let emptyCapability = {
144
148
  sortFields: emptyCapability_sortFields
145
149
  };
146
150
 
147
- function scalarOfSchemaType(st) {
148
- if (typeof st === "object") {
149
- return "String";
150
- }
151
- switch (st) {
152
- case "ScalarNumber" :
153
- return "Float";
154
- case "ScalarBoolean" :
155
- return "Boolean";
156
- case "EntityId" :
157
- return "ID";
158
- default:
159
- return "String";
160
- }
151
+ function scalarOfSchemaType(_st) {
152
+ while (true) {
153
+ let st = _st;
154
+ if (typeof st !== "object") {
155
+ switch (st) {
156
+ case "ScalarNumber" :
157
+ return "Float";
158
+ case "ScalarBoolean" :
159
+ return "Boolean";
160
+ case "EntityId" :
161
+ return "ID";
162
+ default:
163
+ return "String";
164
+ }
165
+ } else {
166
+ if (st.TAG !== "Semantic") {
167
+ return "String";
168
+ }
169
+ _st = st._1;
170
+ continue;
171
+ }
172
+ };
161
173
  }
162
174
 
163
175
  function deriveServerCapability(schema) {
@@ -9,6 +9,12 @@ type rec schemaType =
9
9
  | ArrayOf(schemaType)
10
10
  | ObjectRef(string, dict<schemaType>)
11
11
  | Enum(string, array<string>)
12
+ | /** A field whose type carries a semantic the IR has no dedicated shape for.
13
+ Wraps the shape the value actually has, so every consumer that only cares
14
+ about shape unwraps and is otherwise unaffected. `DateTime` and `EntityId`
15
+ are *not* expressed this way: they long predate the generic marker and
16
+ their JSON Schema `format` output is a published contract. */
17
+ Semantic(Reventless.Semantic.t, schemaType)
12
18
  | Unknown
13
19
 
14
20
  let isTagged = Reventless.DcbTag.isTagged
@@ -27,6 +33,26 @@ let isIdsFieldName = (name: string): bool => {
27
33
  }
28
34
 
29
35
  let rec fromSury = (~parentName: string, ~fieldName: string, schema: S.t<unknown>): schemaType => {
36
+ let shape = shapeOf(~parentName, ~fieldName, schema)
37
+ // Semantics the IR already has a dedicated shape for keep it: `dateTime` and
38
+ // `reference` are read below via `isDateTime` / `getTarget`, both of which now
39
+ // consult the generic marker, and their `format` output is a published
40
+ // contract. Every other semantic rides along as a wrapper, so surfacing a new
41
+ // one costs a value rather than a branch.
42
+ switch Reventless.Semantic.get(schema) {
43
+ | Some({id} as sem)
44
+ if id !== Reventless.Semantic.Id.dateTime && id !== Reventless.Semantic.Id.reference =>
45
+ Semantic(sem, shape)
46
+ | _ => shape
47
+ }
48
+ }
49
+
50
+ and shapeOf = (~parentName: string, ~fieldName: string, schema: S.t<unknown>): schemaType => {
51
+ // Two independent facts, checked independently. A DCB-tagged field is an
52
+ // entity id because it routes; a reference field is one because it points at
53
+ // an entity. Neither implies the other — a `@partitionTag` field carries no
54
+ // reference, and `Reference.toWithoutDcbTag` carries no tag — so collapsing
55
+ // these into one test would silently reclassify whichever case it dropped.
30
56
  if isTagged(schema) || Reventless.Reference.getTarget(schema)->Option.isSome {
31
57
  EntityId
32
58
  } else {
@@ -4,6 +4,7 @@ import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
4
4
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
5
5
  import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
6
6
  import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/types/DateTime.res.mjs";
7
+ import * as Semantic$Reventless from "@reventlessdev/reventless-spec/src/semantic/Semantic.res.mjs";
7
8
  import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
8
9
 
9
10
  function isIdFieldName(name) {
@@ -27,6 +28,24 @@ function isIdsFieldName(name) {
27
28
  }
28
29
 
29
30
  function fromSury(parentName, fieldName, schema) {
31
+ let shape = shapeOf(parentName, fieldName, schema);
32
+ let sem = Semantic$Reventless.get(schema);
33
+ if (sem === undefined) {
34
+ return shape;
35
+ }
36
+ let id = sem.id;
37
+ if (id !== Semantic$Reventless.Id.dateTime && id !== Semantic$Reventless.Id.reference) {
38
+ return {
39
+ TAG: "Semantic",
40
+ _0: sem,
41
+ _1: shape
42
+ };
43
+ } else {
44
+ return shape;
45
+ }
46
+ }
47
+
48
+ function shapeOf(parentName, fieldName, schema) {
30
49
  if (DcbTag$Reventless.isTagged(schema) || Stdlib_Option.isSome(Reference$Reventless.getTarget(schema))) {
31
50
  return "EntityId";
32
51
  }
@@ -156,6 +175,7 @@ export {
156
175
  isIdFieldName,
157
176
  isIdsFieldName,
158
177
  fromSury,
178
+ shapeOf,
159
179
  fromSuryObject,
160
180
  }
161
181
  /* DcbTag-Reventless Not a pure module */
@@ -1,6 +1,8 @@
1
1
  // Shared utility: convert sury S.t<'a> schemas to JSON Schema objects.
2
2
  // Uses SchemaType as the shared intermediate representation.
3
3
 
4
+ let log = Logger.fromEnv()
5
+
4
6
  // ── Helpers ──────────────────────────────────────────────────────────────
5
7
 
6
8
  let str = JSON.Encode.string
@@ -8,6 +10,18 @@ let str = JSON.Encode.string
8
10
  let jsonObject = (entries: array<(string, JSON.t)>): JSON.t =>
9
11
  JSON.Encode.object(Dict.fromArray(entries))
10
12
 
13
+ // An absent plugin means "the declaring plugin's own", which the key's absence
14
+ // says exactly. Writing an empty string instead would make "mine" and "unnamed"
15
+ // indistinguishable to every reader.
16
+ let withOptionalPlugin = (
17
+ entries: array<(string, JSON.t)>,
18
+ plugin: option<string>,
19
+ ): array<(string, JSON.t)> =>
20
+ switch plugin {
21
+ | Some(p) => entries->Array.concat([("plugin", JSON.Encode.string(p))])
22
+ | None => entries
23
+ }
24
+
11
25
  // Merge x-reventless-* extension properties from a Reventless.StateAnnotations.stateAnnotationSpec
12
26
  // into a property schema (a JSON Schema object). Returns the schema unchanged when the field
13
27
  // has no annotations or when the schema is not a JSON object.
@@ -70,7 +84,21 @@ let mergeAnnotations = (
70
84
  }
71
85
  switch spec.semantic->Array.find(((field, _)) => field === fieldName) {
72
86
  | Some((_, semanticId)) =>
73
- obj->Dict.set("x-reventless-semantic", JSON.Encode.string(semanticId))
87
+ // The type wins. A typed declaration is better-sourced than an annotation
88
+ // — the compiler checked it and it cannot drift from the field's shape —
89
+ // so an annotation never overwrites one. A field carrying both is the
90
+ // domain saying two things about one value, which is worth hearing about
91
+ // rather than resolving in silence.
92
+ switch obj->Dict.get("x-reventless-semantic") {
93
+ | Some(fromType) =>
94
+ if fromType !== JSON.Encode.string(semanticId) {
95
+ log.warn(
96
+ ~comp="SuryToJsonSchema",
97
+ `field "${fieldName}" declares semantic "${semanticId}" by annotation but its type already carries ${fromType->JSON.stringify}; the type wins`,
98
+ )
99
+ }
100
+ | None => obj->Dict.set("x-reventless-semantic", JSON.Encode.string(semanticId))
101
+ }
74
102
  | None => ()
75
103
  }
76
104
  switch spec.metric->Array.find(((field, _)) => field === fieldName) {
@@ -107,9 +135,50 @@ let rec fromSchemaType = (st: SchemaType.schemaType): JSON.t =>
107
135
  ("type", str("string")),
108
136
  ("enum", JSON.Encode.array(values->Array.map(JSON.Encode.string))),
109
137
  ])
138
+ | Semantic(sem, inner) => fromSchemaType(inner)->withSemantic(sem)
110
139
  | Unknown => jsonObject([("type", str("string"))])
111
140
  }
112
141
 
142
+ // Attach a type-carried semantic to a field's JSON Schema.
143
+ //
144
+ // This is the type path's emission point, and it is deliberately *not*
145
+ // `mergeAnnotations`: that one is fed by the PPX-collected annotation spec,
146
+ // which the PPX only ever collects on read-model `state` records. This walk is
147
+ // schema-shape-driven and runs over every schema, commands and events included —
148
+ // which is where a declaration like a storage ref has to live, since that is
149
+ // where the value is first accepted.
150
+ //
151
+ // Both paths write the same `x-reventless-semantic` key, on purpose: one wire
152
+ // format, whatever the source. The payload rides in sibling keys so a reader
153
+ // that knows only the bare string still reads it correctly.
154
+ and withSemantic = (fieldSchema: JSON.t, sem: Reventless.Semantic.t): JSON.t =>
155
+ switch fieldSchema->JSON.Decode.object {
156
+ | None => fieldSchema
157
+ | Some(obj) =>
158
+ obj->Dict.set("x-reventless-semantic", str(sem.id))
159
+ // The `type` discriminator is what lets a reader tell a type-carried
160
+ // semantic from an annotated one, and rank it above the annotation
161
+ // accordingly: the compiler checked this one, and it cannot drift from the
162
+ // field's shape.
163
+ obj->Dict.set("x-reventless-semantic-source", str("type"))
164
+ switch sem.payload {
165
+ | Plain => ()
166
+ | ReferenceTo({entity, plugin}) =>
167
+ obj->Dict.set(
168
+ "x-reventless-semantic-target",
169
+ jsonObject(withOptionalPlugin([("entity", str(entity))], plugin)),
170
+ )
171
+ | StoredIn({plugin, store}) =>
172
+ // An absent plugin means "this plugin's own store" — omit the key rather
173
+ // than writing an empty string, so a reader never has to tell those apart.
174
+ obj->Dict.set(
175
+ "x-reventless-semantic-target",
176
+ jsonObject(withOptionalPlugin([("store", str(store))], plugin)),
177
+ )
178
+ }
179
+ JSON.Encode.object(obj)
180
+ }
181
+
113
182
  and objectRefToJsonSchema = (
114
183
  ~annotations: option<Reventless.StateAnnotations.stateAnnotationSpec>=?,
115
184
  fields: dict<SchemaType.schemaType>,
@@ -1,9 +1,12 @@
1
1
  // Generated by ReScript, PLEASE EDIT WITH CARE
2
2
 
3
3
  import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
4
+ import * as Logger$ReventlessCore from "../../util/Logger.res.mjs";
4
5
  import * as SchemaType$ReventlessCore from "./SchemaType.res.mjs";
5
6
  import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
6
7
 
8
+ let log = Logger$ReventlessCore.fromEnv();
9
+
7
10
  function str(prim) {
8
11
  return prim;
9
12
  }
@@ -12,6 +15,17 @@ function jsonObject(entries) {
12
15
  return Object.fromEntries(entries);
13
16
  }
14
17
 
18
+ function withOptionalPlugin(entries, plugin) {
19
+ if (plugin !== undefined) {
20
+ return entries.concat([[
21
+ "plugin",
22
+ plugin
23
+ ]]);
24
+ } else {
25
+ return entries;
26
+ }
27
+ }
28
+
15
29
  function mergeAnnotations(fieldSchema, fieldName, spec) {
16
30
  let obj = Stdlib_JSON.Decode.object(fieldSchema);
17
31
  if (obj === undefined) {
@@ -64,7 +78,15 @@ function mergeAnnotations(fieldSchema, fieldName, spec) {
64
78
  }
65
79
  let match$3 = spec.semantic.find(param => param[0] === fieldName);
66
80
  if (match$3 !== undefined) {
67
- obj["x-reventless-semantic"] = match$3[1];
81
+ let semanticId = match$3[1];
82
+ let fromType = obj["x-reventless-semantic"];
83
+ if (fromType !== undefined) {
84
+ if (fromType !== semanticId) {
85
+ log.warn("SuryToJsonSchema", undefined, `field "` + fieldName + `" declares semantic "` + semanticId + `" by annotation but its type already carries ` + JSON.stringify(fromType) + `; the type wins`);
86
+ }
87
+ } else {
88
+ obj["x-reventless-semantic"] = semanticId;
89
+ }
68
90
  }
69
91
  let match$4 = spec.metric.find(param => param[0] === fieldName);
70
92
  if (match$4 !== undefined) {
@@ -167,8 +189,36 @@ function fromSchemaType(st) {
167
189
  st._1.map(prim => prim)
168
190
  ]
169
191
  ]);
192
+ case "Semantic" :
193
+ return withSemantic(fromSchemaType(st._1), st._0);
194
+ }
195
+ }
196
+ }
197
+
198
+ function withSemantic(fieldSchema, sem) {
199
+ let obj = Stdlib_JSON.Decode.object(fieldSchema);
200
+ if (obj === undefined) {
201
+ return fieldSchema;
202
+ }
203
+ obj["x-reventless-semantic"] = sem.id;
204
+ obj["x-reventless-semantic-source"] = "type";
205
+ let match = sem.payload;
206
+ if (typeof match === "object") {
207
+ if (match.TAG === "ReferenceTo") {
208
+ let match$1 = match._0;
209
+ obj["x-reventless-semantic-target"] = Object.fromEntries(withOptionalPlugin([[
210
+ "entity",
211
+ match$1.entity
212
+ ]], match$1.plugin));
213
+ } else {
214
+ let match$2 = match._0;
215
+ obj["x-reventless-semantic-target"] = Object.fromEntries(withOptionalPlugin([[
216
+ "store",
217
+ match$2.store
218
+ ]], match$2.plugin));
170
219
  }
171
220
  }
221
+ return obj;
172
222
  }
173
223
 
174
224
  function objectRefToJsonSchema(annotations, fields) {
@@ -231,12 +281,15 @@ function toJsonSchema(schema) {
231
281
  }
232
282
 
233
283
  export {
284
+ log,
234
285
  str,
235
286
  jsonObject,
287
+ withOptionalPlugin,
236
288
  mergeAnnotations,
237
289
  fromSchemaType,
290
+ withSemantic,
238
291
  objectRefToJsonSchema,
239
292
  deriveObjectSchema,
240
293
  toJsonSchema,
241
294
  }
242
- /* SchemaType-ReventlessCore Not a pure module */
295
+ /* log Not a pure module */
@@ -490,4 +490,133 @@ describe("SuryToJsonSchema:", () => {
490
490
  )->toBe(None)
491
491
  })
492
492
  })
493
+
494
+ // Reference-ness and DCB-tagged-ness are separate facts that happen to
495
+ // co-occur on `Reference.to_`. The schema walk tests them independently, and
496
+ // must keep doing so: a field can carry either alone, and a semantic type that
497
+ // is marked but deliberately *not* DCB-tagged (a storage ref, say) must not be
498
+ // dragged into entity-id classification by the other fact. Routing failures do
499
+ // not show up in a schema diff, so the distinction is pinned here executably.
500
+ describe("reference / DCB-tag decoupling:", () => {
501
+ let formatOf = (schema: JSON.t, fieldName: string): option<JSON.t> =>
502
+ getPropertyOf(schema, fieldName)->Option.flatMap(s => getProperty(s, "format"))
503
+
504
+ testSync("a DCB-tagged field with no reference is still an entity id", () => {
505
+ let schema = S.schema(s =>
506
+ {
507
+ "orderId": s.matches(Reventless.DcbTag.string),
508
+ }
509
+ )->S.castToUnknown
510
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
511
+ expect(formatOf(json, "orderId"))->toEqual(Some(JSON.Encode.string("uuid")))
512
+ expect(Reventless.Reference.getTarget(schema))->toBe(None)
513
+ })
514
+
515
+ testSync("a reference without a DCB tag is still an entity id", () => {
516
+ let field = Reventless.Reference.toWithoutDcbTag("Customer")
517
+ let schema = S.schema(s => {"customerId": s.matches(field)})->S.castToUnknown
518
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
519
+ expect(formatOf(json, "customerId"))->toEqual(Some(JSON.Encode.string("uuid")))
520
+ expect(Reventless.DcbTag.isTagged(field->S.castToUnknown))->toBe(false)
521
+ })
522
+
523
+ testSync("Reference.to_ carries both facts", () => {
524
+ let field = Reventless.Reference.to_("Customer")
525
+ expect(Reventless.DcbTag.isTagged(field->S.castToUnknown))->toBe(true)
526
+ expect(
527
+ Reventless.Reference.getTarget(field->S.castToUnknown)->Option.map(t => t.entity),
528
+ )->toEqual(Some("Customer"))
529
+ })
530
+
531
+ testSync("neither reference nor tag emits x-reventless-semantic on the wire", () => {
532
+ // `dateTime` and `reference` predate the generic marker and keep emitting
533
+ // `format`; adding the semantic key for them would change a published
534
+ // contract. Only semantics without a dedicated shape surface as the key.
535
+ let schema = S.schema(s =>
536
+ {
537
+ "customerId": s.matches(Reventless.Reference.to_("Customer")),
538
+ "placedAt": s.matches(Reventless.DateTime.string),
539
+ }
540
+ )->S.castToUnknown
541
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
542
+ let semanticOf = fieldName =>
543
+ getPropertyOf(json, fieldName)->Option.flatMap(s =>
544
+ getProperty(s, "x-reventless-semantic")
545
+ )
546
+ expect(semanticOf("customerId"))->toBe(None)
547
+ expect(semanticOf("placedAt"))->toBe(None)
548
+ })
549
+ })
550
+
551
+ // The reason the generic marker exists. `x-reventless-semantic` used to be
552
+ // emitted only by the annotation merge, which is fed by a PPX pass that only
553
+ // ever runs on read-model `state` records — so a command field could not carry
554
+ // a semantic at all. This walk is shape-driven and runs over every schema, so
555
+ // the declaration reaches the place the value is first accepted.
556
+ describe("type-carried semantics on a command field:", () => {
557
+ let semanticOf = (json, fieldName) =>
558
+ getPropertyOf(json, fieldName)->Option.flatMap(s => getProperty(s, "x-reventless-semantic"))
559
+
560
+ testSync("a storage-ref command field emits the semantic id", () => {
561
+ let schema = S.schema(s =>
562
+ {
563
+ "productId": s.matches(S.string),
564
+ "imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages")),
565
+ }
566
+ )->S.castToUnknown
567
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
568
+ expect(semanticOf(json, "imageUrl"))->toEqual(Some(JSON.Encode.string("storageRef")))
569
+ expect(semanticOf(json, "productId"))->toBe(None)
570
+ })
571
+
572
+ testSync("it carries the store identity and marks the type as its source", () => {
573
+ let schema = S.schema(s =>
574
+ {
575
+ "imageUrl": s.matches(
576
+ Reventless.StorageRef.forStore(~plugin="catalog", ~store="productImages"),
577
+ ),
578
+ }
579
+ )->S.castToUnknown
580
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
581
+ let field = getPropertyOf(json, "imageUrl")
582
+ expect(field->Option.flatMap(s => getProperty(s, "x-reventless-semantic-source")))->toEqual(
583
+ Some(JSON.Encode.string("type")),
584
+ )
585
+ expect(field->Option.flatMap(s => getProperty(s, "x-reventless-semantic-target")))->toEqual(
586
+ Some(
587
+ JSON.Encode.object(
588
+ Dict.fromArray([
589
+ ("store", JSON.Encode.string("productImages")),
590
+ ("plugin", JSON.Encode.string("catalog")),
591
+ ]),
592
+ ),
593
+ ),
594
+ )
595
+ })
596
+
597
+ testSync("the underlying string shape is unchanged", () => {
598
+ // The marker refines an existing `string` field; nothing about what is
599
+ // stored changes, which is why it can be retrofitted onto a live schema.
600
+ let schema = S.schema(s =>
601
+ {"imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages"))}
602
+ )->S.castToUnknown
603
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
604
+ expect(
605
+ getPropertyOf(json, "imageUrl")->Option.flatMap(s => getProperty(s, "type")),
606
+ )->toEqual(Some(JSON.Encode.string("string")))
607
+ })
608
+
609
+ testSync("a type-carried semantic beats an annotation naming the same field", () => {
610
+ let withSpec = (schema, spec) =>
611
+ schema->S.Metadata.set(~id=Reventless.StateAnnotations.stateAnnotationsId, spec)
612
+ let schema =
613
+ S.schema(s =>
614
+ {"imageUrl": s.matches(Reventless.StorageRef.forStore(~store="productImages"))}
615
+ )
616
+ ->S.castToUnknown
617
+ ->withSpec({...emptySpec, semantic: [("imageUrl", "image")]})
618
+ let json = SuryToJsonSchema.deriveObjectSchema(schema)
619
+ expect(semanticOf(json, "imageUrl"))->toEqual(Some(JSON.Encode.string("storageRef")))
620
+ })
621
+ })
493
622
  })
@@ -5,7 +5,10 @@ import * as Stdlib_JSON from "@rescript/runtime/lib/es6/Stdlib_JSON.js";
5
5
  import * as Stdlib_Array from "@rescript/runtime/lib/es6/Stdlib_Array.js";
6
6
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
7
7
  import * as Primitive_string from "@rescript/runtime/lib/es6/Primitive_string.js";
8
+ import * as DcbTag$Reventless from "@reventlessdev/reventless-spec/src/components/DcbTag.res.mjs";
8
9
  import * as DateTime$Reventless from "@reventlessdev/reventless-spec/src/types/DateTime.res.mjs";
10
+ import * as Reference$Reventless from "@reventlessdev/reventless-spec/src/components/Reference.res.mjs";
11
+ import * as StorageRef$Reventless from "@reventlessdev/reventless-spec/src/semantic/StorageRef.res.mjs";
9
12
  import * as StateAnnotations$Reventless from "@reventlessdev/reventless-spec/src/components/StateAnnotations.res.mjs";
10
13
  import * as SuryToJsonSchema$ReventlessCore from "../../src/components/Api/SuryToJsonSchema.res.mjs";
11
14
 
@@ -833,6 +836,107 @@ globalThis.describe("SuryToJsonSchema:", () => {
833
836
  globalThis.expect(Stdlib_Option.flatMap(entityIdSchema, s => getProperty(s, "x-reventless-id"))).toBe(undefined);
834
837
  });
835
838
  });
839
+ globalThis.describe("reference / DCB-tag decoupling:", () => {
840
+ let formatOf = (schema, fieldName) => Stdlib_Option.flatMap(getPropertyOf(schema, fieldName), s => getProperty(s, "format"));
841
+ globalThis.test("a DCB-tagged field with no reference is still an entity id", () => {
842
+ let schema = S.schema(s => ({
843
+ orderId: s.m(DcbTag$Reventless.string)
844
+ }));
845
+ let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(schema);
846
+ globalThis.expect(formatOf(json, "orderId")).toEqual("uuid");
847
+ globalThis.expect(Reference$Reventless.getTarget(schema)).toBe(undefined);
848
+ });
849
+ globalThis.test("a reference without a DCB tag is still an entity id", () => {
850
+ let field = Reference$Reventless.toWithoutDcbTag(undefined, "Customer");
851
+ let schema = S.schema(s => ({
852
+ customerId: s.m(field)
853
+ }));
854
+ let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(schema);
855
+ globalThis.expect(formatOf(json, "customerId")).toEqual("uuid");
856
+ globalThis.expect(DcbTag$Reventless.isTagged(field)).toBe(false);
857
+ });
858
+ globalThis.test("Reference.to_ carries both facts", () => {
859
+ let field = Reference$Reventless.to_(undefined, undefined, "Customer");
860
+ globalThis.expect(DcbTag$Reventless.isTagged(field)).toBe(true);
861
+ globalThis.expect(Stdlib_Option.map(Reference$Reventless.getTarget(field), t => t.entity)).toEqual("Customer");
862
+ });
863
+ globalThis.test("neither reference nor tag emits x-reventless-semantic on the wire", () => {
864
+ let schema = S.schema(s => ({
865
+ customerId: s.m(Reference$Reventless.to_(undefined, undefined, "Customer")),
866
+ placedAt: s.m(DateTime$Reventless.string)
867
+ }));
868
+ let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(schema);
869
+ let semanticOf = fieldName => Stdlib_Option.flatMap(getPropertyOf(json, fieldName), s => getProperty(s, "x-reventless-semantic"));
870
+ globalThis.expect(semanticOf("customerId")).toBe(undefined);
871
+ globalThis.expect(semanticOf("placedAt")).toBe(undefined);
872
+ });
873
+ });
874
+ globalThis.describe("type-carried semantics on a command field:", () => {
875
+ let semanticOf = (json, fieldName) => Stdlib_Option.flatMap(getPropertyOf(json, fieldName), s => getProperty(s, "x-reventless-semantic"));
876
+ globalThis.test("a storage-ref command field emits the semantic id", () => {
877
+ let schema = S.schema(s => ({
878
+ productId: s.m(S.string),
879
+ imageUrl: s.m(StorageRef$Reventless.forStore(undefined, "productImages"))
880
+ }));
881
+ let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(schema);
882
+ globalThis.expect(semanticOf(json, "imageUrl")).toEqual("storageRef");
883
+ globalThis.expect(semanticOf(json, "productId")).toBe(undefined);
884
+ });
885
+ globalThis.test("it carries the store identity and marks the type as its source", () => {
886
+ let schema = S.schema(s => ({
887
+ imageUrl: s.m(StorageRef$Reventless.forStore("catalog", "productImages"))
888
+ }));
889
+ let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(schema);
890
+ let field = getPropertyOf(json, "imageUrl");
891
+ globalThis.expect(Stdlib_Option.flatMap(field, s => getProperty(s, "x-reventless-semantic-source"))).toEqual("type");
892
+ globalThis.expect(Stdlib_Option.flatMap(field, s => getProperty(s, "x-reventless-semantic-target"))).toEqual(Object.fromEntries([
893
+ [
894
+ "store",
895
+ "productImages"
896
+ ],
897
+ [
898
+ "plugin",
899
+ "catalog"
900
+ ]
901
+ ]));
902
+ });
903
+ globalThis.test("the underlying string shape is unchanged", () => {
904
+ let schema = S.schema(s => ({
905
+ imageUrl: s.m(StorageRef$Reventless.forStore(undefined, "productImages"))
906
+ }));
907
+ let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(schema);
908
+ globalThis.expect(Stdlib_Option.flatMap(getPropertyOf(json, "imageUrl"), s => getProperty(s, "type"))).toEqual("string");
909
+ });
910
+ globalThis.test("a type-carried semantic beats an annotation naming the same field", () => {
911
+ let withSpec = (schema, spec) => S.Metadata.set(schema, StateAnnotations$Reventless.stateAnnotationsId, spec);
912
+ let schema = withSpec(S.schema(s => ({
913
+ imageUrl: s.m(StorageRef$Reventless.forStore(undefined, "productImages"))
914
+ })), {
915
+ ids: emptySpec_ids,
916
+ compositeIds: emptySpec_compositeIds,
917
+ subIds: emptySpec_subIds,
918
+ compositeSubIds: emptySpec_compositeSubIds,
919
+ indexes: emptySpec_indexes,
920
+ hidden: emptySpec_hidden,
921
+ summary: emptySpec_summary,
922
+ drillTargets: emptySpec_drillTargets,
923
+ drillTargetKeys: emptySpec_drillTargetKeys,
924
+ collapsed: emptySpec_collapsed,
925
+ scan: emptySpec_scan,
926
+ scanSort: emptySpec_scanSort,
927
+ semantic: [[
928
+ "imageUrl",
929
+ "image"
930
+ ]],
931
+ metric: emptySpec_metric,
932
+ status: undefined,
933
+ groupBy: undefined,
934
+ visibility: undefined
935
+ });
936
+ let json = SuryToJsonSchema$ReventlessCore.deriveObjectSchema(schema);
937
+ globalThis.expect(semanticOf(json, "imageUrl")).toEqual("storageRef");
938
+ });
939
+ });
836
940
  });
837
941
 
838
942
  export {