@hyperscale0/udl 1.0.0 → 1.0.123

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 (234) hide show
  1. package/CHANGELOG.md +5 -216
  2. package/LICENSE.md +356 -0
  3. package/NOTICE.md +8 -0
  4. package/README.md +14 -106
  5. package/dist/canonical.d.ts.map +1 -1
  6. package/dist/canonical.js +1 -29
  7. package/dist/canonical.js.map +1 -1
  8. package/dist/diagnostics.d.ts +13 -165
  9. package/dist/diagnostics.d.ts.map +1 -1
  10. package/dist/diagnostics.js +33 -157
  11. package/dist/diagnostics.js.map +1 -1
  12. package/dist/field-value.d.ts +5 -0
  13. package/dist/field-value.d.ts.map +1 -0
  14. package/dist/field-value.js +85 -0
  15. package/dist/field-value.js.map +1 -0
  16. package/dist/finance.d.ts +10 -53
  17. package/dist/finance.d.ts.map +1 -1
  18. package/dist/finance.js +252 -536
  19. package/dist/finance.js.map +1 -1
  20. package/dist/index.d.ts +9 -13
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +9 -9
  23. package/dist/index.js.map +1 -1
  24. package/dist/json.d.ts +2 -0
  25. package/dist/json.d.ts.map +1 -0
  26. package/dist/json.js +30 -0
  27. package/dist/json.js.map +1 -0
  28. package/dist/limits.d.ts +3 -28
  29. package/dist/limits.d.ts.map +1 -1
  30. package/dist/limits.js +5 -31
  31. package/dist/limits.js.map +1 -1
  32. package/dist/object-contract.d.ts +140 -0
  33. package/dist/object-contract.d.ts.map +1 -0
  34. package/dist/object-contract.js +314 -0
  35. package/dist/object-contract.js.map +1 -0
  36. package/dist/object-semantics-schema.d.ts +5710 -0
  37. package/dist/object-semantics-schema.d.ts.map +1 -0
  38. package/dist/object-semantics-schema.js +119 -0
  39. package/dist/object-semantics-schema.js.map +1 -0
  40. package/dist/object-semantics.d.ts +6 -0
  41. package/dist/object-semantics.d.ts.map +1 -0
  42. package/dist/object-semantics.js +279 -0
  43. package/dist/object-semantics.js.map +1 -0
  44. package/dist/object-structures.d.ts +4 -0
  45. package/dist/object-structures.d.ts.map +1 -0
  46. package/dist/object-structures.js +134 -0
  47. package/dist/object-structures.js.map +1 -0
  48. package/dist/reporting-validation.d.ts +8 -0
  49. package/dist/reporting-validation.d.ts.map +1 -0
  50. package/dist/reporting-validation.js +252 -0
  51. package/dist/reporting-validation.js.map +1 -0
  52. package/dist/reporting.d.ts +477 -0
  53. package/dist/reporting.d.ts.map +1 -0
  54. package/dist/reporting.js +169 -0
  55. package/dist/reporting.js.map +1 -0
  56. package/dist/schema.d.ts +5572 -2710
  57. package/dist/schema.d.ts.map +1 -1
  58. package/dist/schema.js +444 -1121
  59. package/dist/schema.js.map +1 -1
  60. package/dist/validation.d.ts +10 -73
  61. package/dist/validation.d.ts.map +1 -1
  62. package/dist/validation.js +1109 -2569
  63. package/dist/validation.js.map +1 -1
  64. package/docs/assets/brand/manifest.json +30 -0
  65. package/docs/assets/brand/udl-horizontal-white.svg +1 -0
  66. package/docs/assets/brand/udl-horizontal.svg +1 -0
  67. package/docs/assets/brand/udl-stacked-white.svg +1 -0
  68. package/docs/assets/brand/udl-stacked.svg +1 -0
  69. package/docs/assets/udl.svg +11 -0
  70. package/package.json +13 -22
  71. package/spec/README.md +300 -172
  72. package/spec/cars.udl.json +249 -0
  73. package/spec/udl.schema.json +3066 -2242
  74. package/src/canonical.ts +1 -35
  75. package/src/diagnostics.ts +76 -209
  76. package/src/field-value.ts +87 -0
  77. package/src/finance.ts +327 -832
  78. package/src/index.ts +18 -95
  79. package/src/json.ts +34 -0
  80. package/src/limits.ts +5 -31
  81. package/src/object-contract.ts +559 -0
  82. package/src/object-semantics-schema.ts +148 -0
  83. package/src/object-semantics.ts +378 -0
  84. package/src/object-structures.ts +255 -0
  85. package/src/reporting-validation.ts +383 -0
  86. package/src/reporting.ts +178 -0
  87. package/src/schema.ts +510 -1277
  88. package/src/validation.ts +1630 -4545
  89. package/LICENSE +0 -661
  90. package/LICENSING.md +0 -51
  91. package/TRADEMARKS.md +0 -35
  92. package/conformance/README.md +0 -82
  93. package/conformance/evolution/action-contract.expected.json +0 -10
  94. package/conformance/evolution/action-contract.live.udl +0 -44
  95. package/conformance/evolution/action-contract.next.udl +0 -45
  96. package/conformance/evolution/product-identity.expected.json +0 -10
  97. package/conformance/evolution/product-identity.live.udl +0 -55
  98. package/conformance/evolution/product-identity.next.udl +0 -55
  99. package/conformance/evolution/version-required.expected.json +0 -10
  100. package/conformance/evolution/version-required.live.udl +0 -55
  101. package/conformance/evolution/version-required.next.udl +0 -58
  102. package/conformance/invalid/action-without-transition.expected.json +0 -10
  103. package/conformance/invalid/action-without-transition.udl +0 -60
  104. package/conformance/invalid/agent-description-too-long.expected.json +0 -10
  105. package/conformance/invalid/agent-description-too-long.udl +0 -57
  106. package/conformance/invalid/blank-title.expected.json +0 -10
  107. package/conformance/invalid/blank-title.udl +0 -54
  108. package/conformance/invalid/composition-dial-duplicate-key.expected.json +0 -10
  109. package/conformance/invalid/composition-dial-duplicate-key.udl +0 -73
  110. package/conformance/invalid/depth-budget.expected.json +0 -10
  111. package/conformance/invalid/depth-budget.udl +0 -49
  112. package/conformance/invalid/duplicate-subject.expected.json +0 -10
  113. package/conformance/invalid/duplicate-subject.udl +0 -230
  114. package/conformance/invalid/forged-effects.expected.json +0 -10
  115. package/conformance/invalid/forged-effects.udl +0 -76
  116. package/conformance/invalid/format-version.expected.json +0 -10
  117. package/conformance/invalid/format-version.udl +0 -54
  118. package/conformance/invalid/instrument-id-not-snake-case.expected.json +0 -10
  119. package/conformance/invalid/instrument-id-not-snake-case.udl +0 -54
  120. package/conformance/invalid/invalid-aggregate-gate-shape.expected.json +0 -10
  121. package/conformance/invalid/invalid-aggregate-gate-shape.udl +0 -1819
  122. package/conformance/invalid/invalid-check-duration.expected.json +0 -10
  123. package/conformance/invalid/invalid-check-duration.udl +0 -219
  124. package/conformance/invalid/invalid-dial-anchor.expected.json +0 -10
  125. package/conformance/invalid/invalid-dial-anchor.udl +0 -219
  126. package/conformance/invalid/invalid-exception-parent-ref.expected.json +0 -10
  127. package/conformance/invalid/invalid-exception-parent-ref.udl +0 -2487
  128. package/conformance/invalid/invalid-exposure-shape.expected.json +0 -10
  129. package/conformance/invalid/invalid-exposure-shape.udl +0 -1819
  130. package/conformance/invalid/invalid-remainder.expected.json +0 -10
  131. package/conformance/invalid/invalid-remainder.udl +0 -220
  132. package/conformance/invalid/invalid-schema-keyword.expected.json +0 -10
  133. package/conformance/invalid/invalid-schema-keyword.udl +0 -220
  134. package/conformance/invalid/invalid-utf8.expected.json +0 -10
  135. package/conformance/invalid/invalid-utf8.udl +0 -1
  136. package/conformance/invalid/malformed-json.expected.json +0 -10
  137. package/conformance/invalid/malformed-json.udl +0 -1
  138. package/conformance/invalid/missing-create-action.expected.json +0 -10
  139. package/conformance/invalid/missing-create-action.udl +0 -48
  140. package/conformance/invalid/missing-exception-amount-field.expected.json +0 -10
  141. package/conformance/invalid/missing-exception-amount-field.udl +0 -2487
  142. package/conformance/invalid/missing-exception-contract.expected.json +0 -14
  143. package/conformance/invalid/missing-exception-contract.udl +0 -2523
  144. package/conformance/invalid/missing-exception-reason-field.expected.json +0 -10
  145. package/conformance/invalid/missing-exception-reason-field.udl +0 -2487
  146. package/conformance/invalid/not-an-object.expected.json +0 -10
  147. package/conformance/invalid/not-an-object.udl +0 -1
  148. package/conformance/invalid/payout-reconcile-not-a-bank-debit.expected.json +0 -10
  149. package/conformance/invalid/payout-reconcile-not-a-bank-debit.udl +0 -259
  150. package/conformance/invalid/quote-freeze-set-incomplete.expected.json +0 -10
  151. package/conformance/invalid/quote-freeze-set-incomplete.udl +0 -261
  152. package/conformance/invalid/quote-named-reference-gate.expected.json +0 -10
  153. package/conformance/invalid/quote-named-reference-gate.udl +0 -50
  154. package/conformance/invalid/reconcile-named-reference-gate.expected.json +0 -10
  155. package/conformance/invalid/reconcile-named-reference-gate.udl +0 -50
  156. package/conformance/invalid/unknown-key.expected.json +0 -10
  157. package/conformance/invalid/unknown-key.udl +0 -55
  158. package/conformance/invalid/unknown-reference-gate-field.expected.json +0 -10
  159. package/conformance/invalid/unknown-reference-gate-field.udl +0 -1819
  160. package/conformance/invalid/unknown-required-field.expected.json +0 -10
  161. package/conformance/invalid/unknown-required-field.udl +0 -220
  162. package/conformance/invalid/unreachable-state.expected.json +0 -10
  163. package/conformance/invalid/unreachable-state.udl +0 -55
  164. package/conformance/invalid/wrong-exception-amount-field.expected.json +0 -10
  165. package/conformance/invalid/wrong-exception-amount-field.udl +0 -2487
  166. package/conformance/invalid/wrong-exception-reason-field.expected.json +0 -10
  167. package/conformance/invalid/wrong-exception-reason-field.udl +0 -2487
  168. package/conformance/valid/agent-description.expected.json +0 -6
  169. package/conformance/valid/agent-description.udl +0 -66
  170. package/conformance/valid/cards.expected.json +0 -6
  171. package/conformance/valid/cards.udl +0 -1579
  172. package/conformance/valid/commerce-escrow.expected.json +0 -6
  173. package/conformance/valid/commerce-escrow.udl +0 -1512
  174. package/conformance/valid/compiled-crowdfunding.expected.json +0 -6
  175. package/conformance/valid/compiled-crowdfunding.udl +0 -1843
  176. package/conformance/valid/compiled-watch-club.expected.json +0 -6
  177. package/conformance/valid/compiled-watch-club.udl +0 -2486
  178. package/conformance/valid/complete-contract.expected.json +0 -6
  179. package/conformance/valid/complete-contract.udl +0 -218
  180. package/conformance/valid/effect-signatures.expected.json +0 -6
  181. package/conformance/valid/effect-signatures.udl +0 -75
  182. package/conformance/valid/hand-edited.expected.json +0 -6
  183. package/conformance/valid/hand-edited.udl +0 -1
  184. package/conformance/valid/insured-car-marketplace.expected.json +0 -6
  185. package/conformance/valid/insured-car-marketplace.udl +0 -1050
  186. package/conformance/valid/insured-travel.expected.json +0 -6
  187. package/conformance/valid/insured-travel.udl +0 -3469
  188. package/conformance/valid/minimal.expected.json +0 -6
  189. package/conformance/valid/minimal.udl +0 -54
  190. package/conformance/valid/protection.expected.json +0 -6
  191. package/conformance/valid/protection.udl +0 -1551
  192. package/conformance/valid/string-escaping.expected.json +0 -6
  193. package/conformance/valid/string-escaping.udl +0 -54
  194. package/dist/check-profiles.d.ts +0 -57
  195. package/dist/check-profiles.d.ts.map +0 -1
  196. package/dist/check-profiles.js +0 -62
  197. package/dist/check-profiles.js.map +0 -1
  198. package/dist/cli.d.ts +0 -3
  199. package/dist/cli.d.ts.map +0 -1
  200. package/dist/cli.js +0 -184
  201. package/dist/cli.js.map +0 -1
  202. package/dist/effects.d.ts +0 -41
  203. package/dist/effects.d.ts.map +0 -1
  204. package/dist/effects.js +0 -110
  205. package/dist/effects.js.map +0 -1
  206. package/dist/evolution.d.ts +0 -120
  207. package/dist/evolution.d.ts.map +0 -1
  208. package/dist/evolution.js +0 -567
  209. package/dist/evolution.js.map +0 -1
  210. package/dist/parser.d.ts +0 -4
  211. package/dist/parser.d.ts.map +0 -1
  212. package/dist/parser.js +0 -44
  213. package/dist/parser.js.map +0 -1
  214. package/docs/README.md +0 -14
  215. package/docs/guide/01-a-document.md +0 -37
  216. package/docs/guide/02-money-steps.md +0 -23
  217. package/docs/guide/03-laws.md +0 -18
  218. package/docs/guide/04-fees-and-remainder.md +0 -15
  219. package/docs/guide/05-checks-updates-dials.md +0 -7
  220. package/docs/guide/06-effects.md +0 -11
  221. package/docs/guide/07-evolution.md +0 -11
  222. package/docs/guide/08-implementing.md +0 -13
  223. package/docs/llms-full.txt +0 -1241
  224. package/docs/llms.txt +0 -14
  225. package/docs/reference/canonical.md +0 -16
  226. package/docs/reference/clauses.md +0 -986
  227. package/docs/reference/cli.md +0 -22
  228. package/docs/reference/diagnostics.md +0 -32
  229. package/skills/udl/SKILL.md +0 -28
  230. package/src/check-profiles.ts +0 -80
  231. package/src/cli.ts +0 -196
  232. package/src/effects.ts +0 -163
  233. package/src/evolution.ts +0 -965
  234. package/src/parser.ts +0 -58
@@ -0,0 +1,383 @@
1
+ import type { UdlDocument } from "./schema.js";
2
+ import type {
3
+ ReportDefinition,
4
+ ReportExpression,
5
+ ReportType,
6
+ } from "./reporting.js";
7
+
8
+ type Types = Map<string, ReportType>;
9
+ const sealedFields = new Map([
10
+ ["id", "text"],
11
+ ["status", "text"],
12
+ ["createdAt", "date"],
13
+ ]);
14
+ const integer: ReportType = { kind: "integer" };
15
+ const boolean: ReportType = { kind: "boolean" };
16
+ const date: ReportType = { kind: "date" };
17
+ const same = (a: ReportType, b: ReportType) =>
18
+ a.kind === b.kind &&
19
+ (a.kind !== "money" || (b.kind === "money" && a.currency === b.currency));
20
+ function requireType(types: Types, name: string): ReportType {
21
+ const value = types.get(name);
22
+ if (!value) throw new Error(`Unknown reporting field or expression ${name}`);
23
+ return value;
24
+ }
25
+ function requireRule(condition: unknown, message: string): asserts condition {
26
+ if (!condition) throw new Error(message);
27
+ }
28
+ export function reportExpressionTypes(
29
+ expressions: readonly ReportExpression[],
30
+ initial: Types,
31
+ ): Types {
32
+ const types = new Map(initial);
33
+ for (const expression of expressions) {
34
+ requireRule(
35
+ !types.has(expression.id),
36
+ `Duplicate expression ${expression.id}`,
37
+ );
38
+ const read = (name: string) => requireType(types, name);
39
+ let result: ReportType;
40
+ switch (expression.op) {
41
+ case "field":
42
+ result = read(expression.path);
43
+ break;
44
+ case "parameter":
45
+ result = date;
46
+ break;
47
+ case "literal": {
48
+ result = expression.type;
49
+ const value = expression.value;
50
+ requireRule(
51
+ result.kind === "money"
52
+ ? typeof value === "string" && /^(0|[1-9][0-9]{0,17})$/.test(value)
53
+ : result.kind === "integer"
54
+ ? typeof value === "number" && Number.isSafeInteger(value)
55
+ : result.kind === "boolean"
56
+ ? typeof value === "boolean"
57
+ : result.kind === "date"
58
+ ? typeof value === "string" &&
59
+ Number.isFinite(Date.parse(value))
60
+ : typeof value === "string",
61
+ `Invalid literal ${expression.id}`,
62
+ );
63
+ break;
64
+ }
65
+ case "not":
66
+ requireRule(
67
+ read(expression.value).kind === "boolean",
68
+ "not requires boolean",
69
+ );
70
+ result = boolean;
71
+ break;
72
+ case "choose": {
73
+ requireRule(
74
+ read(expression.condition).kind === "boolean",
75
+ "choose requires boolean",
76
+ );
77
+ result = read(expression.yes);
78
+ requireRule(
79
+ same(result, read(expression.no)),
80
+ "choose branches must have matching types and currencies",
81
+ );
82
+ break;
83
+ }
84
+ case "bucket":
85
+ requireRule(
86
+ read(expression.value).kind === "date",
87
+ "bucket requires date",
88
+ );
89
+ result = { kind: "text" };
90
+ break;
91
+ case "ratio": {
92
+ const numerator = read(expression.numerator),
93
+ denominator = read(expression.denominator);
94
+ requireRule(
95
+ same(numerator, denominator) &&
96
+ ["money", "integer"].includes(numerator.kind),
97
+ "ratio needs matching numeric measures",
98
+ );
99
+ result = integer;
100
+ break;
101
+ }
102
+ default: {
103
+ const left = read(expression.left),
104
+ right = read(expression.right);
105
+ requireRule(
106
+ same(left, right),
107
+ `Incompatible types or currencies in ${expression.id}`,
108
+ );
109
+ if (["equal", "less", "atMost"].includes(expression.op))
110
+ result = boolean;
111
+ else if (expression.op === "and" || expression.op === "or") {
112
+ requireRule(
113
+ left.kind === "boolean",
114
+ "Boolean operation requires booleans",
115
+ );
116
+ result = boolean;
117
+ } else if (expression.op === "daysBetween") {
118
+ requireRule(left.kind === "date", "daysBetween requires dates");
119
+ result = integer;
120
+ } else {
121
+ requireRule(
122
+ ["money", "integer"].includes(left.kind),
123
+ "Arithmetic requires numeric measures",
124
+ );
125
+ result = left;
126
+ }
127
+ }
128
+ }
129
+ types.set(expression.id, result);
130
+ }
131
+ return types;
132
+ }
133
+ function aggregateTypes(
134
+ aggregates: ReportDefinition["calculation"]["aggregates"],
135
+ input: Types,
136
+ target: Types,
137
+ ) {
138
+ for (const aggregate of aggregates) {
139
+ requireRule(
140
+ !target.has(aggregate.name),
141
+ `Duplicate aggregate ${aggregate.name}`,
142
+ );
143
+ const type =
144
+ aggregate.op === "count"
145
+ ? integer
146
+ : requireType(input, aggregate.value ?? "");
147
+ requireRule(
148
+ !["sum", "average"].includes(aggregate.op) ||
149
+ ["money", "integer"].includes(type.kind),
150
+ "sum requires numeric values",
151
+ );
152
+ requireRule(
153
+ aggregate.op !== "count" || aggregate.value === undefined,
154
+ "count has no value field",
155
+ );
156
+ requireRule(
157
+ aggregate.op === "average"
158
+ ? aggregate.rounding !== undefined
159
+ : aggregate.rounding === undefined,
160
+ "Only average requires an explicit rounding policy",
161
+ );
162
+ target.set(aggregate.name, type);
163
+ }
164
+ }
165
+ /** Used by compilation and replay, never a report-name dispatch. */
166
+ export function validateReportDefinition(
167
+ report: ReportDefinition,
168
+ document: UdlDocument,
169
+ ): void {
170
+ requireRule(
171
+ report.scope.currency === document.currency,
172
+ "Report currency differs from Product currency",
173
+ );
174
+ const datasets = new Map<string, Types>();
175
+ for (const dataset of report.datasets) {
176
+ requireRule(!datasets.has(dataset.id), `Duplicate dataset ${dataset.id}`);
177
+ const fields: Types = new Map();
178
+ for (const column of dataset.columns) {
179
+ requireRule(!fields.has(column.name), `Duplicate column ${column.name}`);
180
+ if (column.type.kind === "money")
181
+ requireRule(
182
+ column.type.currency === report.scope.currency,
183
+ "Incompatible dataset currency",
184
+ );
185
+ if (dataset.source === "instrument") {
186
+ requireRule(
187
+ report.scope.kind === "product",
188
+ "Company instrument reports require cross-Build bindings",
189
+ );
190
+ requireRule(
191
+ dataset.instruments.length > 0,
192
+ "Instrument source requires declared instruments",
193
+ );
194
+ for (const id of dataset.instruments) {
195
+ const instrument = document.instruments.find(
196
+ (entry) => entry.id === id,
197
+ );
198
+ requireRule(instrument, `Unknown report instrument ${id}`);
199
+ const parts = column.field.split(".");
200
+ const field = instrument.fields.find(
201
+ (entry) => entry.name === parts[0],
202
+ );
203
+ const sealed = sealedFields.get(column.field);
204
+ const actual =
205
+ parts.length === 2 &&
206
+ parts[1] === "balance" &&
207
+ field?.type === "account"
208
+ ? "money"
209
+ : parts.length === 1
210
+ ? (sealed ??
211
+ (field && ["ref", "account", "enum"].includes(field.type)
212
+ ? "text"
213
+ : field?.type))
214
+ : undefined;
215
+ requireRule(
216
+ actual === column.type.kind,
217
+ `Unknown or mistyped report field ${id}.${column.field}`,
218
+ );
219
+ }
220
+ } else {
221
+ requireRule(
222
+ dataset.instruments.length === 0,
223
+ "Only instrument sources bind instruments",
224
+ );
225
+ const sourceFields: Record<string, Record<string, string>> = {
226
+ account: {
227
+ id: "text",
228
+ ownerParticipantId: "text",
229
+ currency: "text",
230
+ role: "text",
231
+ status: "text",
232
+ createdAt: "date",
233
+ balance: "money",
234
+ },
235
+ identity: {
236
+ id: "text",
237
+ entityId: "text",
238
+ participantId: "text",
239
+ status: "text",
240
+ createdAt: "date",
241
+ },
242
+ operation: {
243
+ id: "text",
244
+ name: "text",
245
+ status: "text",
246
+ createdAt: "date",
247
+ },
248
+ };
249
+ requireRule(
250
+ sourceFields[dataset.source]?.[column.field] === column.type.kind,
251
+ `Unknown or mistyped ${dataset.source} field ${column.field}`,
252
+ );
253
+ }
254
+ fields.set(column.name, column.type);
255
+ }
256
+ requireRule(
257
+ requireType(fields, dataset.rowKey).kind === "text",
258
+ "Row key must be text",
259
+ );
260
+ const types = reportExpressionTypes(dataset.expressions, fields);
261
+ if (dataset.selection)
262
+ requireRule(
263
+ requireType(types, dataset.selection).kind === "boolean",
264
+ "Selection must be boolean",
265
+ );
266
+ if (dataset.selection) validateSourceKey(dataset, dataset.selection);
267
+ datasets.set(dataset.id, types);
268
+ }
269
+ const aggregateNames = [
270
+ ...report.calculation.joins.flatMap((join) => join.aggregates),
271
+ ...report.calculation.aggregates,
272
+ ].map((aggregate) => aggregate.name);
273
+ requireRule(
274
+ new Set(aggregateNames).size === aggregateNames.length,
275
+ "Aggregate names must be unique across stages for null accounting",
276
+ );
277
+ const primary = datasets.get(report.selection.dataset);
278
+ requireRule(primary, "Unknown primary dataset");
279
+ let types = new Map(primary);
280
+ const joined = new Set<string>();
281
+ for (const join of report.calculation.joins) {
282
+ requireRule(
283
+ !joined.has(join.dataset),
284
+ "Duplicate join requires a distinct dataset alias",
285
+ );
286
+ requireRule(
287
+ join.dataset !== report.selection.dataset,
288
+ "Self joins require a distinct dataset alias",
289
+ );
290
+ joined.add(join.dataset);
291
+ const foreign = datasets.get(join.dataset);
292
+ requireRule(foreign, `Unknown joined dataset ${join.dataset}`);
293
+ validateSourceKey(
294
+ report.datasets.find((dataset) => dataset.id === join.dataset)!,
295
+ join.foreign,
296
+ );
297
+ requireRule(
298
+ same(requireType(types, join.local), requireType(foreign, join.foreign)),
299
+ "Join keys have incompatible types",
300
+ );
301
+ requireRule(
302
+ join.aggregates.length > 0,
303
+ "Joins must declare aggregates; implicit row multiplication is forbidden",
304
+ );
305
+ aggregateTypes(join.aggregates, foreign, types);
306
+ }
307
+ types = reportExpressionTypes(report.calculation.expressions, types);
308
+ if (report.selection.predicate)
309
+ requireRule(
310
+ requireType(types, report.selection.predicate).kind === "boolean",
311
+ "Selection must be boolean",
312
+ );
313
+ for (const required of report.validation.required)
314
+ requireType(types, required);
315
+ for (const pair of report.validation.rowReconcile)
316
+ requireRule(
317
+ same(requireType(types, pair.left), requireType(types, pair.right)),
318
+ "Row reconciliation requires matching measures",
319
+ );
320
+ if (report.calculation.aggregates.length) {
321
+ const grouped: Types = new Map(
322
+ report.calculation.groupBy.map((key) => [key, requireType(types, key)]),
323
+ );
324
+ aggregateTypes(report.calculation.aggregates, types, grouped);
325
+ types = grouped;
326
+ } else
327
+ requireRule(
328
+ report.calculation.groupBy.length === 0,
329
+ "Grouping requires aggregates",
330
+ );
331
+ types = reportExpressionTypes(report.calculation.resultExpressions, types);
332
+ for (const pair of report.validation.reconcile)
333
+ requireRule(
334
+ same(requireType(types, pair.left), requireType(types, pair.right)),
335
+ "Reconciliation requires matching measures",
336
+ );
337
+ const outputs = new Set<string>();
338
+ for (const column of report.output.columns) {
339
+ requireRule(!outputs.has(column.name), "Duplicate output column");
340
+ outputs.add(column.name);
341
+ requireRule(
342
+ same(requireType(types, column.name), column.type),
343
+ `Output type differs for ${column.name}`,
344
+ );
345
+ }
346
+ for (const sort of report.output.sort)
347
+ requireRule(outputs.has(sort), `Sort key ${sort} must be an output column`);
348
+ }
349
+
350
+ function validateSourceKey(
351
+ dataset: ReportDefinition["datasets"][number],
352
+ key: string,
353
+ seen = new Set<string>(),
354
+ ): void {
355
+ if (seen.has(key)) return;
356
+ seen.add(key);
357
+ const column = dataset.columns.find((entry) => entry.name === key);
358
+ if (column) {
359
+ requireRule(
360
+ column.field !== "balance" && !column.field.endsWith(".balance"),
361
+ "Source selection and join keys cannot depend on ledger balances",
362
+ );
363
+ return;
364
+ }
365
+ const expression = dataset.expressions.find((entry) => entry.id === key);
366
+ requireRule(expression, `Unknown source key ${key}`);
367
+ requireRule(
368
+ expression.op !== "ratio",
369
+ "Ratios belong after source selection",
370
+ );
371
+ const read = (name: string) => validateSourceKey(dataset, name, seen);
372
+ if (expression.op === "field") read(expression.path);
373
+ else if (expression.op === "choose") {
374
+ read(expression.condition);
375
+ read(expression.yes);
376
+ read(expression.no);
377
+ } else if (expression.op === "not" || expression.op === "bucket")
378
+ read(expression.value);
379
+ else if ("left" in expression) {
380
+ read(expression.left);
381
+ read(expression.right);
382
+ }
383
+ }
@@ -0,0 +1,178 @@
1
+ import * as z from "zod";
2
+
3
+ const name = z
4
+ .string()
5
+ .regex(/^[a-z][a-zA-Z0-9_]*$/)
6
+ .max(80);
7
+ const path = z
8
+ .string()
9
+ .regex(/^[a-z][a-zA-Z0-9_]*(\.[a-z][a-zA-Z0-9_]*)*$/)
10
+ .max(240);
11
+ const integer = z.number().int().safe();
12
+ export const reportTypeSchema = z.discriminatedUnion("kind", [
13
+ z.strictObject({
14
+ kind: z.literal("money"),
15
+ currency: z.string().regex(/^[A-Z]{3}$/),
16
+ }),
17
+ z.strictObject({ kind: z.enum(["text", "integer", "boolean", "date"]) }),
18
+ ]);
19
+ export type ReportType = z.infer<typeof reportTypeSchema>;
20
+ const literal = z.union([z.string().max(2048), integer, z.boolean()]);
21
+ /** Ordered expressions form a finite typed graph. References only point backwards. */
22
+ export const reportExpressionSchema = z.discriminatedUnion("op", [
23
+ z.strictObject({ id: name, op: z.literal("field"), path }),
24
+ z.strictObject({
25
+ id: name,
26
+ op: z.literal("literal"),
27
+ type: reportTypeSchema,
28
+ value: literal,
29
+ }),
30
+ z.strictObject({
31
+ id: name,
32
+ op: z.literal("parameter"),
33
+ name: z.enum(["periodStart", "periodEnd", "observationAt"]),
34
+ }),
35
+ z.strictObject({
36
+ id: name,
37
+ op: z.enum([
38
+ "sum",
39
+ "subtract",
40
+ "minimum",
41
+ "maximum",
42
+ "equal",
43
+ "less",
44
+ "atMost",
45
+ "and",
46
+ "or",
47
+ "daysBetween",
48
+ ]),
49
+ left: name,
50
+ right: name,
51
+ }),
52
+ z.strictObject({ id: name, op: z.literal("not"), value: name }),
53
+ z.strictObject({
54
+ id: name,
55
+ op: z.literal("choose"),
56
+ condition: name,
57
+ yes: name,
58
+ no: name,
59
+ }),
60
+ z.strictObject({
61
+ id: name,
62
+ op: z.literal("bucket"),
63
+ value: name,
64
+ unit: z.enum(["month", "quarter"]),
65
+ }),
66
+ z.strictObject({
67
+ id: name,
68
+ op: z.literal("ratio"),
69
+ numerator: name,
70
+ denominator: name,
71
+ scale: integer.min(1).max(1000000),
72
+ rounding: z.enum(["floor", "halfUp"]),
73
+ zero: z.enum(["refuse", "null"]),
74
+ }),
75
+ ]);
76
+ const column = z.strictObject({
77
+ name,
78
+ field: path,
79
+ type: reportTypeSchema,
80
+ required: z.boolean(),
81
+ });
82
+ const aggregate = z.strictObject({
83
+ name,
84
+ op: z.enum(["sum", "average", "count", "minimum", "maximum"]),
85
+ rounding: z.enum(["floor", "halfUp"]).optional(),
86
+ value: name.optional(),
87
+ });
88
+ export const reportDefinitionSchema = z.strictObject({
89
+ identity: z.strictObject({
90
+ id: name,
91
+ version: integer.positive(),
92
+ description: z.string().min(1).max(2048),
93
+ }),
94
+ scope: z.strictObject({
95
+ kind: z.enum(["product", "company"]),
96
+ classification: z.enum(["internal", "personal", "restricted"]),
97
+ currency: z.string().regex(/^[A-Z]{3}$/),
98
+ }),
99
+ datasets: z
100
+ .array(
101
+ z.strictObject({
102
+ id: name,
103
+ source: z.enum(["instrument", "account", "identity", "operation"]),
104
+ instruments: z
105
+ .array(name.meta({ "x-udl-reference": "instrument" }))
106
+ .max(32),
107
+ rowKey: name,
108
+ columns: z.array(column).min(1).max(64),
109
+ expressions: z.array(reportExpressionSchema).max(128),
110
+ selection: name.optional(),
111
+ }),
112
+ )
113
+ .min(1)
114
+ .max(16),
115
+ time: z.strictObject({
116
+ timezone: z.literal("Asia/Riyadh"),
117
+ boundary: z.literal("startInclusiveEndExclusive"),
118
+ observation: z.literal("periodEnd"),
119
+ history: z.literal("retainedOnly"),
120
+ }),
121
+ selection: z.strictObject({ dataset: name, predicate: name.optional() }),
122
+ calculation: z.strictObject({
123
+ joins: z
124
+ .array(
125
+ z.strictObject({
126
+ dataset: name,
127
+ local: name,
128
+ foreign: name,
129
+ cardinality: z.enum(["one", "many"]),
130
+ missing: z.enum(["refuse", "allow"]),
131
+ aggregates: z.array(aggregate).max(32),
132
+ }),
133
+ )
134
+ .max(16),
135
+ expressions: z.array(reportExpressionSchema).max(128),
136
+ groupBy: z.array(name).max(8),
137
+ aggregates: z.array(aggregate).max(64),
138
+ resultExpressions: z.array(reportExpressionSchema).max(64),
139
+ }),
140
+ validation: z.strictObject({
141
+ empty: z.enum(["refuse", "noEligibleFacilities"]),
142
+ required: z.array(name).max(64),
143
+ rowReconcile: z.array(z.strictObject({ left: name, right: name })).max(16),
144
+ reconcile: z.array(z.strictObject({ left: name, right: name })).max(16),
145
+ unavailableFacts: z.array(z.string().min(1).max(240)).max(32),
146
+ lockTimeoutMs: integer.min(1).max(2000),
147
+ captureTimeoutMs: integer.min(1).max(10000),
148
+ maxRows: integer.min(1).max(100000),
149
+ maxJoinRows: integer.min(1).max(1000000),
150
+ maxBytes: integer.min(1024).max(16777216),
151
+ }),
152
+ output: z.strictObject({
153
+ profile: z.literal("internal.v1"),
154
+ formats: z
155
+ .array(z.enum(["json", "csv"]))
156
+ .min(1)
157
+ .max(2),
158
+ columns: z
159
+ .array(
160
+ z.strictObject({
161
+ name,
162
+ label: z.string().min(1).max(120),
163
+ type: reportTypeSchema,
164
+ }),
165
+ )
166
+ .min(1)
167
+ .max(64),
168
+ sort: z.array(name).min(1).max(8),
169
+ }),
170
+ authority: z.strictObject({
171
+ request: z.array(name).min(1).max(64),
172
+ read: z.array(name).min(1).max(64),
173
+ release: z.array(name).max(64),
174
+ retention: z.strictObject({ policy: name, years: integer.min(1).max(100) }),
175
+ }),
176
+ });
177
+ export type ReportDefinition = z.infer<typeof reportDefinitionSchema>;
178
+ export type ReportExpression = z.infer<typeof reportExpressionSchema>;