@reventlessdev/reventless-spec 3.0.0-alpha.136 → 3.0.0-alpha.138

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,30 @@
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.138 (2026-09-20)
7
+
8
+ * fix(spec)!: let an explicit partition tag declare an identity the name cannot ([01d82c0](https://github.com/ReventlessDev/reventless-core/commit/01d82c0714a31f31d55c466eae9100cf4dcc85f0))
9
+
10
+ ### BREAKING CHANGES
11
+
12
+ * a @partitionTag on a field whose name is not *Id / *Ids was
13
+ inert and now takes effect, so such a slice's partition key — and with it its
14
+ storage key, fence and read scope — changes to the annotated field. Nothing else
15
+ moves: the annotation was doing nothing before, so no slice that resolved a key
16
+ resolves a different one.
17
+
18
+
19
+
20
+ # 3.0.0-alpha.137 (2026-09-20)
21
+
22
+ ### Bug Fixes
23
+
24
+ * **spec:** resolve the drift guard's paths from the module, not the cwd ([7f4d8de](https://github.com/ReventlessDev/reventless-core/commit/7f4d8de896f37ce4a855ecc6a980788f01d0e1c7))
25
+ ### Features
26
+
27
+ * **spec:** enumerate the transparent-string semantics, and check the list against the sources ([995c31c](https://github.com/ReventlessDev/reventless-core/commit/995c31c5eb1a9bdb2ad88d39b2b9a8a5348be0da))
28
+
29
+
6
30
  # 3.0.0-alpha.136 (2026-09-16)
7
31
 
8
32
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reventlessdev/reventless-spec",
3
- "version": "3.0.0-alpha.136",
3
+ "version": "3.0.0-alpha.138",
4
4
  "description": "Specifications for Reventless",
5
5
  "license": "Apache-2.0",
6
6
  "bin": {
@@ -34,8 +34,22 @@ The three rules (over the representation):
34
34
  on `ProductAdded`) are payload ⇒ not indexed ⇒ the sibling-leak GSI write never
35
35
  happens.
36
36
  */
37
- /** A `*Id` / `*Ids`-shaped field, identified by name only (no schema, no tag flag). */
38
- type idField = {name: string, isList: bool}
37
+ /** A field the slice graph treats as an entity identity.
38
+
39
+ Normally that is a `*Id` / `*Ids`-shaped name — the convention is the signal,
40
+ and this module stays schema-agnostic by taking the name alone.
41
+
42
+ `byTag` marks the exception: an identity the *name* does not declare, which an
43
+ adapter recognised from an explicit `@partitionTag`. A domain's own identifier
44
+ is often not suffixed — `sku`, `isbn`, `vin` — and without this the annotation
45
+ naming one would be extracted as a hint and then dropped, because `seedOf`
46
+ only honours a hint already among the produced keys. So the escape hatch would
47
+ have worked for every field except the ones that need it.
48
+
49
+ It is carried rather than folded in because removing the annotation removes
50
+ the identity, which is exactly what the redundancy check has to know: a hint
51
+ inference cannot reach without it is never redundant. */
52
+ type idField = {name: string, isList: bool, byTag?: bool}
39
53
 
40
54
  /** One variant arm: its constructor name and the `*Id` fields it carries. */
41
55
  type eventShape = {eventType: string, idFields: array<idField>}
@@ -1084,29 +1084,42 @@ which is this module's half of the split.
1084
1084
  */
1085
1085
  let idFieldsOfProperties = (properties: dict<S.t<unknown>>): array<DcbScopeInference.idField> => {
1086
1086
  let isIdName = (name: string) => name->String.endsWith("Ids") || name->String.endsWith("Id")
1087
- properties
1088
- ->Dict.toArray
1089
- ->Array.flatMap(((name, fieldSchema)) =>
1087
+ // An explicit `@partitionTag` declares an identity the naming convention cannot
1088
+ // express — a domain's own identifier is often unsuffixed (`sku`, `isbn`).
1089
+ // Without this the hint is extracted and then ignored, since `seedOf` honours
1090
+ // only a hint already among the produced keys, so the escape hatch would work
1091
+ // for every field except the ones that need it. Flagged, not folded in: taking
1092
+ // the annotation away takes the identity with it, which is what the redundancy
1093
+ // check has to be able to tell.
1094
+ let identity = (name, fieldSchema, isList) =>
1090
1095
  if isIdName(name) {
1091
- let isList = switch fieldSchema {
1092
- | Array(_) => true
1093
- | _ => false
1094
- }
1095
- [{DcbScopeInference.name, isList}]
1096
+ Some({DcbScopeInference.name, isList})
1097
+ } else if isPartitionTag(fieldSchema) {
1098
+ Some({DcbScopeInference.name, isList, byTag: true})
1096
1099
  } else {
1100
+ None
1101
+ }
1102
+ properties
1103
+ ->Dict.toArray
1104
+ ->Array.flatMap(((name, fieldSchema)) => {
1105
+ let isList = switch fieldSchema {
1106
+ | Array(_) => true
1107
+ | _ => false
1108
+ }
1109
+ switch identity(name, fieldSchema, isList) {
1110
+ | Some(f) => [f]
1111
+ | None =>
1097
1112
  switch nestedRecordProperties(fieldSchema) {
1098
1113
  | Some((nested, nestedIsList)) =>
1099
1114
  nested
1100
1115
  ->Dict.toArray
1101
- ->Array.filterMap(((nestedName, _)) =>
1102
- isIdName(nestedName)
1103
- ? Some({DcbScopeInference.name: nestedName, isList: nestedIsList})
1104
- : None
1116
+ ->Array.filterMap(((nestedName, nestedSchema)) =>
1117
+ identity(nestedName, nestedSchema, nestedIsList)
1105
1118
  )
1106
1119
  | None => []
1107
1120
  }
1108
1121
  }
1109
- )
1122
+ })
1110
1123
  }
1111
1124
 
1112
1125
  /**
@@ -703,31 +703,36 @@ function idFieldsOfProperties(properties) {
703
703
  return name.endsWith("Id");
704
704
  }
705
705
  };
706
+ let identity = (name, fieldSchema, isList) => {
707
+ if (isIdName(name)) {
708
+ return {
709
+ name: name,
710
+ isList: isList
711
+ };
712
+ } else if (Stdlib_Option.isSome(Sury.$Metadata_get(fieldSchema, dcbPartitionTagId))) {
713
+ return {
714
+ name: name,
715
+ isList: isList,
716
+ byTag: true
717
+ };
718
+ } else {
719
+ return;
720
+ }
721
+ };
706
722
  return Object.entries(properties).flatMap(param => {
707
723
  let fieldSchema = param[1];
708
- let name = param[0];
709
- if (isIdName(name)) {
710
- let isList;
711
- isList = fieldSchema.type === "array";
712
- return [{
713
- name: name,
714
- isList: isList
715
- }];
724
+ let isList;
725
+ isList = fieldSchema.type === "array";
726
+ let f = identity(param[0], fieldSchema, isList);
727
+ if (f !== undefined) {
728
+ return [f];
716
729
  }
717
730
  let match = nestedRecordProperties(fieldSchema);
718
731
  if (match === undefined) {
719
732
  return [];
720
733
  }
721
734
  let nestedIsList = match[1];
722
- return Stdlib_Array.filterMap(Object.entries(match[0]), param => {
723
- let nestedName = param[0];
724
- if (isIdName(nestedName)) {
725
- return {
726
- name: nestedName,
727
- isList: nestedIsList
728
- };
729
- }
730
- });
735
+ return Stdlib_Array.filterMap(Object.entries(match[0]), param => identity(param[0], param[1], nestedIsList));
731
736
  });
732
737
  }
733
738
 
@@ -501,29 +501,62 @@ let validatePartitionHintsVsInference = (
501
501
  shapes->Array.forEach(s =>
502
502
  switch s.partitionHint {
503
503
  | Some(hint) if DcbScopeInference.producedKeys(s)->Array.includes(hint) =>
504
+ // "Without the annotation" has to mean without everything the annotation
505
+ // brought. A field that is an identity only because it carries the tag
506
+ // (`byTag`) stops being one when the tag goes, so dropping only the hint
507
+ // would ask whether inference reaches a key it can no longer see — and
508
+ // answer yes, reporting a load-bearing annotation as removable.
509
+ let withoutTag = (e: DcbScopeInference.eventShape) => {
510
+ ...e,
511
+ idFields: e.idFields->Array.filter(f => f.byTag != Some(true)),
512
+ }
513
+ // A hint naming an identity that exists only because of the annotation is
514
+ // **necessary**, and fits neither verdict this check was built to give.
515
+ // Called redundant it would be removed and the key would vanish; called
516
+ // contradictory it would be "corrected" to whatever name-shaped field
517
+ // happens to sit beside it — which is the author's choice overruled, not a
518
+ // mistake found. Both readings come from assuming the field is an identity
519
+ // either way, which is true only when the name says so.
520
+ let declaredByTag =
521
+ s.produced->Array.some(e =>
522
+ e.idFields->Array.some(f => f.name == hint && f.byTag == Some(true))
523
+ )
504
524
  let unaided = DcbScopeInference.resolvePartitions(
505
- shapes->Array.map(o => o.sliceName == s.sliceName ? {...o, partitionHint: None} : o),
525
+ shapes->Array.map(o =>
526
+ o.sliceName == s.sliceName
527
+ ? {
528
+ ...o,
529
+ partitionHint: None,
530
+ produced: o.produced->Array.map(withoutTag),
531
+ consumed: o.consumed->Array.map(withoutTag),
532
+ }
533
+ : o
534
+ ),
506
535
  )
507
- switch unaided.partitionBySlice->Dict.get(s.sliceName) {
508
- | Some(inferred) if inferred == hint =>
509
- redundancies->Array.push({
510
- sliceName: s.sliceName,
511
- message: `@partitionTag ${hint} is what inference derives without it — the annotation is redundant and can be removed.`,
512
- })
513
- | Some(inferred) =>
514
- contradictions->Array.push({
515
- sliceName: s.sliceName,
516
- message: `@partitionTag names ${hint}, but inference derives ${inferred} from the slice graph — ${hint} is read from another entity. Remove the annotation, or move it to ${inferred}.`,
517
- })
518
- | None =>
519
- let candidates = unaided.candidatesBySlice->Dict.get(s.sliceName)->Option.getOr([])
520
- if !(candidates->Array.includes(hint)) {
536
+ if declaredByTag {
537
+ ()
538
+ } else {
539
+ switch unaided.partitionBySlice->Dict.get(s.sliceName) {
540
+ | Some(inferred) if inferred == hint =>
541
+ redundancies->Array.push({
542
+ sliceName: s.sliceName,
543
+ message: `@partitionTag ${hint} is what inference derives without it — the annotation is redundant and can be removed.`,
544
+ })
545
+ | Some(inferred) =>
521
546
  contradictions->Array.push({
522
547
  sliceName: s.sliceName,
523
- message: `@partitionTag names ${hint}, which this slice only reads as a reference to another entity (candidates: ${candidates->Array.join(
524
- ", ",
525
- )}). Move the annotation to the slice's own key.`,
548
+ message: `@partitionTag names ${hint}, but inference derives ${inferred} from the slice graph — ${hint} is read from another entity. Remove the annotation, or move it to ${inferred}.`,
526
549
  })
550
+ | None =>
551
+ let candidates = unaided.candidatesBySlice->Dict.get(s.sliceName)->Option.getOr([])
552
+ if !(candidates->Array.includes(hint)) {
553
+ contradictions->Array.push({
554
+ sliceName: s.sliceName,
555
+ message: `@partitionTag names ${hint}, which this slice only reads as a reference to another entity (candidates: ${candidates->Array.join(
556
+ ", ",
557
+ )}). Move the annotation to the slice's own key.`,
558
+ })
559
+ }
527
560
  }
528
561
  }
529
562
  | _ => ()
@@ -437,14 +437,30 @@ function validatePartitionHintsVsInference(shapes) {
437
437
  if (!DcbScopeInference$Reventless.producedKeys(s).includes(hint)) {
438
438
  return;
439
439
  }
440
+ let withoutTag = e => ({
441
+ eventType: e.eventType,
442
+ idFields: e.idFields.filter(f => Primitive_object.notequal(f.byTag, true))
443
+ });
444
+ let declaredByTag = s.produced.some(e => e.idFields.some(f => {
445
+ if (f.name === hint) {
446
+ return Primitive_object.equal(f.byTag, true);
447
+ } else {
448
+ return false;
449
+ }
450
+ }));
440
451
  let unaided = DcbScopeInference$Reventless.resolvePartitions(shapes.map(o => {
441
452
  if (o.sliceName !== s.sliceName) {
442
453
  return o;
443
454
  }
444
455
  let newrecord = {...o};
445
456
  newrecord.partitionHint = undefined;
457
+ newrecord.produced = o.produced.map(withoutTag);
458
+ newrecord.consumed = o.consumed.map(withoutTag);
446
459
  return newrecord;
447
460
  }));
461
+ if (declaredByTag) {
462
+ return;
463
+ }
448
464
  let inferred = unaided.partitionBySlice[s.sliceName];
449
465
  if (inferred !== undefined) {
450
466
  if (inferred === hint) {
@@ -113,6 +113,50 @@ module Id = {
113
113
  let geolocation = "geolocation"
114
114
  }
115
115
 
116
+ /** One transparent-string semantic: a `semantic/` module whose `type t` is a
117
+ bare `string`. */
118
+ type brandedString = {
119
+ moduleName: string,
120
+ /** The `Id` values of this module carry. Not derivable from the module name —
121
+ `CalendarDate` carries `date`. */
122
+ id: string,
123
+ /** Whether the module exposes the `let schema` that sury-ppx resolves `X.t`
124
+ to by convention. The `false`s build theirs from a function taking
125
+ arguments (`forStore` / `forField` / `forCollection`), so such a field
126
+ always carries an explicit `@s.matches` and there is no name a pass could
127
+ derive. Offering one as a plain type pick emits code that does not
128
+ compile. */
129
+ hasDerivableSchema: bool,
130
+ }
131
+
132
+ /** Every transparent-string semantic, so a consumer can ask what they are
133
+ instead of transcribing the set.
134
+
135
+ A check written against the literal `string` keyword sees only the brand and
136
+ refuses the field, which would make declaring a semantic cost the field
137
+ whatever that check gates — so a pass that must leave these alone needs the
138
+ set, not a guess. It cannot be guessed from the names: `Money.t` is a record,
139
+ `Duration.t` an int, `Percent.t` and `Bytes.t` floats, and all four are
140
+ deliberately absent.
141
+
142
+ Both facts here are computable from the sources, and `SemanticBrandedStringsTest`
143
+ recomputes them rather than trusting this list — so adding a module without
144
+ adding it here fails, and so does the ppx's copy drifting from either. */
145
+ let brandedStrings: array<brandedString> = [
146
+ {moduleName: "DateTime", id: Id.dateTime, hasDerivableSchema: true},
147
+ {moduleName: "CalendarDate", id: Id.date, hasDerivableSchema: true},
148
+ {moduleName: "Email", id: Id.email, hasDerivableSchema: true},
149
+ {moduleName: "Phone", id: Id.phone, hasDerivableSchema: true},
150
+ {moduleName: "Url", id: Id.url, hasDerivableSchema: true},
151
+ {moduleName: "Color", id: Id.color, hasDerivableSchema: true},
152
+ {moduleName: "FileRef", id: Id.fileRef, hasDerivableSchema: true},
153
+ {moduleName: "ImageRef", id: Id.imageRef, hasDerivableSchema: true},
154
+ {moduleName: "MemberRef", id: Id.memberRef, hasDerivableSchema: false},
155
+ {moduleName: "StorageRef", id: Id.storageRef, hasDerivableSchema: false},
156
+ {moduleName: "UploadableFile", id: Id.uploadableFile, hasDerivableSchema: false},
157
+ {moduleName: "UploadableImage", id: Id.uploadableImage, hasDerivableSchema: false},
158
+ ]
159
+
116
160
  let semanticId: S.Metadata.Id.t<t> = S.Metadata.Id.make(~namespace="reventless", ~name="semantic")
117
161
 
118
162
  /** Mark a schema as carrying a semantic. */
@@ -4,25 +4,49 @@ import * as S from "sury/src/S.res.mjs";
4
4
  import * as Sury from "sury";
5
5
  import * as Stdlib_Option from "@rescript/runtime/lib/es6/Stdlib_Option.js";
6
6
 
7
+ let dateTime = "dateTime";
8
+
9
+ let date = "date";
10
+
11
+ let storageRef = "storageRef";
12
+
13
+ let uploadableImage = "uploadableImage";
14
+
15
+ let uploadableFile = "uploadableFile";
16
+
17
+ let imageRef = "imageRef";
18
+
19
+ let fileRef = "fileRef";
20
+
21
+ let memberRef = "memberRef";
22
+
23
+ let email = "email";
24
+
25
+ let phone = "phone";
26
+
27
+ let url = "url";
28
+
29
+ let color = "color";
30
+
7
31
  let Id = {
8
- dateTime: "dateTime",
9
- date: "date",
32
+ dateTime: dateTime,
33
+ date: date,
10
34
  reference: "reference",
11
- storageRef: "storageRef",
35
+ storageRef: storageRef,
12
36
  offload: "offload",
13
- uploadableImage: "uploadableImage",
14
- uploadableFile: "uploadableFile",
15
- imageRef: "imageRef",
16
- fileRef: "fileRef",
37
+ uploadableImage: uploadableImage,
38
+ uploadableFile: uploadableFile,
39
+ imageRef: imageRef,
40
+ fileRef: fileRef,
17
41
  captionedImage: "captionedImage",
18
- memberRef: "memberRef",
19
- email: "email",
20
- phone: "phone",
21
- url: "url",
42
+ memberRef: memberRef,
43
+ email: email,
44
+ phone: phone,
45
+ url: url,
22
46
  percent: "percent",
23
47
  bytes: "bytes",
24
48
  duration: "duration",
25
- color: "color",
49
+ color: color,
26
50
  money: "money",
27
51
  dateRange: "dateRange",
28
52
  geoPoint: "geoPoint",
@@ -30,6 +54,69 @@ let Id = {
30
54
  geolocation: "geolocation"
31
55
  };
32
56
 
57
+ let brandedStrings = [
58
+ {
59
+ moduleName: "DateTime",
60
+ id: dateTime,
61
+ hasDerivableSchema: true
62
+ },
63
+ {
64
+ moduleName: "CalendarDate",
65
+ id: date,
66
+ hasDerivableSchema: true
67
+ },
68
+ {
69
+ moduleName: "Email",
70
+ id: email,
71
+ hasDerivableSchema: true
72
+ },
73
+ {
74
+ moduleName: "Phone",
75
+ id: phone,
76
+ hasDerivableSchema: true
77
+ },
78
+ {
79
+ moduleName: "Url",
80
+ id: url,
81
+ hasDerivableSchema: true
82
+ },
83
+ {
84
+ moduleName: "Color",
85
+ id: color,
86
+ hasDerivableSchema: true
87
+ },
88
+ {
89
+ moduleName: "FileRef",
90
+ id: fileRef,
91
+ hasDerivableSchema: true
92
+ },
93
+ {
94
+ moduleName: "ImageRef",
95
+ id: imageRef,
96
+ hasDerivableSchema: true
97
+ },
98
+ {
99
+ moduleName: "MemberRef",
100
+ id: memberRef,
101
+ hasDerivableSchema: false
102
+ },
103
+ {
104
+ moduleName: "StorageRef",
105
+ id: storageRef,
106
+ hasDerivableSchema: false
107
+ },
108
+ {
109
+ moduleName: "UploadableFile",
110
+ id: uploadableFile,
111
+ hasDerivableSchema: false
112
+ },
113
+ {
114
+ moduleName: "UploadableImage",
115
+ id: uploadableImage,
116
+ hasDerivableSchema: false
117
+ }
118
+ ];
119
+
33
120
  let semanticId = Sury.$Metadata_Id_make("reventless", "semantic");
34
121
 
35
122
  function mark(schema, id, payloadOpt) {
@@ -129,6 +216,7 @@ function has(fieldSchema, id) {
129
216
 
130
217
  export {
131
218
  Id,
219
+ brandedStrings,
132
220
  semanticId,
133
221
  mark,
134
222
  refined,