@hyperscale0/udl 3.0.2 → 3.2.0

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/src/finance.ts CHANGED
@@ -315,7 +315,7 @@ export function analyzeInstrumentFinance(
315
315
  const action = instrument.actions[name];
316
316
  if (!action) continue;
317
317
  const next = copy(state);
318
- next.status = edge.to;
318
+ if (edge.to !== "preserve") next.status = edge.to;
319
319
  apply(next, action, name);
320
320
  pending.push(next);
321
321
  }
package/src/index.ts CHANGED
@@ -22,3 +22,9 @@ export {
22
22
  } from "./instrument-references.js";
23
23
  export { diffValidatedUdlEvolution } from "./evolution.js";
24
24
  export { fixedIsoDurationMs } from "./duration.js";
25
+
26
+ export * from "./reporting.js";
27
+ export {
28
+ validateReportDefinition,
29
+ reportExpressionTypes,
30
+ } from "./reporting-validation.js";
@@ -0,0 +1,381 @@
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 integer: ReportType = { kind: "integer" };
10
+ const boolean: ReportType = { kind: "boolean" };
11
+ const date: ReportType = { kind: "date" };
12
+ const same = (a: ReportType, b: ReportType) =>
13
+ a.kind === b.kind &&
14
+ (a.kind !== "money" || (b.kind === "money" && a.currency === b.currency));
15
+ function requireType(types: Types, name: string): ReportType {
16
+ const value = types.get(name);
17
+ if (!value) throw new Error(`Unknown reporting field or expression ${name}`);
18
+ return value;
19
+ }
20
+ function requireRule(condition: unknown, message: string): asserts condition {
21
+ if (!condition) throw new Error(message);
22
+ }
23
+ export function reportExpressionTypes(
24
+ expressions: readonly ReportExpression[],
25
+ initial: Types,
26
+ ): Types {
27
+ const types = new Map(initial);
28
+ for (const expression of expressions) {
29
+ requireRule(
30
+ !types.has(expression.id),
31
+ `Duplicate expression ${expression.id}`,
32
+ );
33
+ const read = (name: string) => requireType(types, name);
34
+ let result: ReportType;
35
+ switch (expression.op) {
36
+ case "field":
37
+ result = read(expression.path);
38
+ break;
39
+ case "parameter":
40
+ result = date;
41
+ break;
42
+ case "literal": {
43
+ result = expression.type;
44
+ const value = expression.value;
45
+ requireRule(
46
+ result.kind === "money"
47
+ ? typeof value === "string" && /^(0|[1-9][0-9]{0,17})$/.test(value)
48
+ : result.kind === "integer"
49
+ ? typeof value === "number" && Number.isSafeInteger(value)
50
+ : result.kind === "boolean"
51
+ ? typeof value === "boolean"
52
+ : result.kind === "date"
53
+ ? typeof value === "string" &&
54
+ Number.isFinite(Date.parse(value))
55
+ : typeof value === "string",
56
+ `Invalid literal ${expression.id}`,
57
+ );
58
+ break;
59
+ }
60
+ case "not":
61
+ requireRule(
62
+ read(expression.value).kind === "boolean",
63
+ "not requires boolean",
64
+ );
65
+ result = boolean;
66
+ break;
67
+ case "choose": {
68
+ requireRule(
69
+ read(expression.condition).kind === "boolean",
70
+ "choose requires boolean",
71
+ );
72
+ result = read(expression.yes);
73
+ requireRule(
74
+ same(result, read(expression.no)),
75
+ "choose branches must have matching types and currencies",
76
+ );
77
+ break;
78
+ }
79
+ case "bucket":
80
+ requireRule(
81
+ read(expression.value).kind === "date",
82
+ "bucket requires date",
83
+ );
84
+ result = { kind: "text" };
85
+ break;
86
+ case "ratio": {
87
+ const numerator = read(expression.numerator),
88
+ denominator = read(expression.denominator);
89
+ requireRule(
90
+ same(numerator, denominator) &&
91
+ ["money", "integer"].includes(numerator.kind),
92
+ "ratio needs matching numeric measures",
93
+ );
94
+ result = integer;
95
+ break;
96
+ }
97
+ default: {
98
+ const left = read(expression.left),
99
+ right = read(expression.right);
100
+ requireRule(
101
+ same(left, right),
102
+ `Incompatible types or currencies in ${expression.id}`,
103
+ );
104
+ if (["equal", "less", "atMost"].includes(expression.op))
105
+ result = boolean;
106
+ else if (expression.op === "and" || expression.op === "or") {
107
+ requireRule(
108
+ left.kind === "boolean",
109
+ "Boolean operation requires booleans",
110
+ );
111
+ result = boolean;
112
+ } else if (expression.op === "daysBetween") {
113
+ requireRule(left.kind === "date", "daysBetween requires dates");
114
+ result = integer;
115
+ } else {
116
+ requireRule(
117
+ ["money", "integer"].includes(left.kind),
118
+ "Arithmetic requires numeric measures",
119
+ );
120
+ result = left;
121
+ }
122
+ }
123
+ }
124
+ types.set(expression.id, result);
125
+ }
126
+ return types;
127
+ }
128
+ function aggregateTypes(
129
+ aggregates: ReportDefinition["calculation"]["aggregates"],
130
+ input: Types,
131
+ target: Types,
132
+ ) {
133
+ for (const aggregate of aggregates) {
134
+ requireRule(
135
+ !target.has(aggregate.name),
136
+ `Duplicate aggregate ${aggregate.name}`,
137
+ );
138
+ const type =
139
+ aggregate.op === "count"
140
+ ? integer
141
+ : requireType(input, aggregate.value ?? "");
142
+ requireRule(
143
+ !["sum", "average"].includes(aggregate.op) ||
144
+ ["money", "integer"].includes(type.kind),
145
+ "sum requires numeric values",
146
+ );
147
+ requireRule(
148
+ aggregate.op !== "count" || aggregate.value === undefined,
149
+ "count has no value field",
150
+ );
151
+ requireRule(
152
+ aggregate.op === "average"
153
+ ? aggregate.rounding !== undefined
154
+ : aggregate.rounding === undefined,
155
+ "Only average requires an explicit rounding policy",
156
+ );
157
+ target.set(aggregate.name, type);
158
+ }
159
+ }
160
+ /** Used by compilation and replay, never a report-name dispatch. */
161
+ export function validateReportDefinition(
162
+ report: ReportDefinition,
163
+ document: UdlDocument,
164
+ ): void {
165
+ requireRule(
166
+ report.scope.currency === document.currency,
167
+ "Report currency differs from Product currency",
168
+ );
169
+ const datasets = new Map<string, Types>();
170
+ for (const dataset of report.datasets) {
171
+ requireRule(!datasets.has(dataset.id), `Duplicate dataset ${dataset.id}`);
172
+ const fields: Types = new Map();
173
+ for (const column of dataset.columns) {
174
+ requireRule(!fields.has(column.name), `Duplicate column ${column.name}`);
175
+ if (column.type.kind === "money")
176
+ requireRule(
177
+ column.type.currency === report.scope.currency,
178
+ "Incompatible dataset currency",
179
+ );
180
+ if (dataset.source === "instrument") {
181
+ requireRule(
182
+ report.scope.kind === "product",
183
+ "Company instrument reports require cross-Build bindings",
184
+ );
185
+ requireRule(
186
+ dataset.instruments.length > 0,
187
+ "Instrument source requires declared instruments",
188
+ );
189
+ for (const id of dataset.instruments) {
190
+ const instrument = document.instruments.find(
191
+ (entry) => entry.id === id,
192
+ );
193
+ requireRule(instrument, `Unknown report instrument ${id}`);
194
+ const parts = column.field.split(".");
195
+ const field = instrument.fields.find(
196
+ (entry) => entry.name === parts[0],
197
+ );
198
+ const sealed = (
199
+ { id: "text", status: "text", createdAt: "date" } as Record<
200
+ string,
201
+ string
202
+ >
203
+ )[column.field];
204
+ const actual =
205
+ parts.length === 2 &&
206
+ parts[1] === "balance" &&
207
+ field?.type === "account"
208
+ ? "money"
209
+ : (sealed ??
210
+ (field && ["ref", "account", "enum"].includes(field.type)
211
+ ? "text"
212
+ : field?.type));
213
+ requireRule(
214
+ actual === column.type.kind,
215
+ `Unknown or mistyped report field ${id}.${column.field}`,
216
+ );
217
+ }
218
+ } else {
219
+ requireRule(
220
+ dataset.instruments.length === 0,
221
+ "Only instrument sources bind instruments",
222
+ );
223
+ const sourceFields: Record<string, Record<string, string>> = {
224
+ account: {
225
+ id: "text",
226
+ ownerParticipantId: "text",
227
+ currency: "text",
228
+ role: "text",
229
+ status: "text",
230
+ createdAt: "date",
231
+ balance: "money",
232
+ },
233
+ identity: {
234
+ id: "text",
235
+ entityId: "text",
236
+ participantId: "text",
237
+ status: "text",
238
+ createdAt: "date",
239
+ },
240
+ operation: {
241
+ id: "text",
242
+ name: "text",
243
+ status: "text",
244
+ createdAt: "date",
245
+ },
246
+ };
247
+ requireRule(
248
+ sourceFields[dataset.source]?.[column.field] === column.type.kind,
249
+ `Unknown or mistyped ${dataset.source} field ${column.field}`,
250
+ );
251
+ }
252
+ fields.set(column.name, column.type);
253
+ }
254
+ requireRule(
255
+ requireType(fields, dataset.rowKey).kind === "text",
256
+ "Row key must be text",
257
+ );
258
+ const types = reportExpressionTypes(dataset.expressions, fields);
259
+ if (dataset.selection)
260
+ requireRule(
261
+ requireType(types, dataset.selection).kind === "boolean",
262
+ "Selection must be boolean",
263
+ );
264
+ if (dataset.selection) validateSourceKey(dataset, dataset.selection);
265
+ datasets.set(dataset.id, types);
266
+ }
267
+ const aggregateNames = [
268
+ ...report.calculation.joins.flatMap((join) => join.aggregates),
269
+ ...report.calculation.aggregates,
270
+ ].map((aggregate) => aggregate.name);
271
+ requireRule(
272
+ new Set(aggregateNames).size === aggregateNames.length,
273
+ "Aggregate names must be unique across stages for null accounting",
274
+ );
275
+ const primary = datasets.get(report.selection.dataset);
276
+ requireRule(primary, "Unknown primary dataset");
277
+ let types = new Map(primary);
278
+ const joined = new Set<string>();
279
+ for (const join of report.calculation.joins) {
280
+ requireRule(
281
+ !joined.has(join.dataset),
282
+ "Duplicate join requires a distinct dataset alias",
283
+ );
284
+ requireRule(
285
+ join.dataset !== report.selection.dataset,
286
+ "Self joins require a distinct dataset alias",
287
+ );
288
+ joined.add(join.dataset);
289
+ const foreign = datasets.get(join.dataset);
290
+ requireRule(foreign, `Unknown joined dataset ${join.dataset}`);
291
+ validateSourceKey(
292
+ report.datasets.find((dataset) => dataset.id === join.dataset)!,
293
+ join.foreign,
294
+ );
295
+ requireRule(
296
+ same(requireType(types, join.local), requireType(foreign, join.foreign)),
297
+ "Join keys have incompatible types",
298
+ );
299
+ requireRule(
300
+ join.aggregates.length > 0,
301
+ "Joins must declare aggregates; implicit row multiplication is forbidden",
302
+ );
303
+ aggregateTypes(join.aggregates, foreign, types);
304
+ }
305
+ types = reportExpressionTypes(report.calculation.expressions, types);
306
+ if (report.selection.predicate)
307
+ requireRule(
308
+ requireType(types, report.selection.predicate).kind === "boolean",
309
+ "Selection must be boolean",
310
+ );
311
+ for (const required of report.validation.required)
312
+ requireType(types, required);
313
+ for (const pair of report.validation.rowReconcile)
314
+ requireRule(
315
+ same(requireType(types, pair.left), requireType(types, pair.right)),
316
+ "Row reconciliation requires matching measures",
317
+ );
318
+ if (report.calculation.aggregates.length) {
319
+ const grouped: Types = new Map(
320
+ report.calculation.groupBy.map((key) => [key, requireType(types, key)]),
321
+ );
322
+ aggregateTypes(report.calculation.aggregates, types, grouped);
323
+ types = grouped;
324
+ } else
325
+ requireRule(
326
+ report.calculation.groupBy.length === 0,
327
+ "Grouping requires aggregates",
328
+ );
329
+ types = reportExpressionTypes(report.calculation.resultExpressions, types);
330
+ for (const pair of report.validation.reconcile)
331
+ requireRule(
332
+ same(requireType(types, pair.left), requireType(types, pair.right)),
333
+ "Reconciliation requires matching measures",
334
+ );
335
+ const outputs = new Set<string>();
336
+ for (const column of report.output.columns) {
337
+ requireRule(!outputs.has(column.name), "Duplicate output column");
338
+ outputs.add(column.name);
339
+ requireRule(
340
+ same(requireType(types, column.name), column.type),
341
+ `Output type differs for ${column.name}`,
342
+ );
343
+ }
344
+ for (const sort of report.output.sort)
345
+ requireRule(outputs.has(sort), `Sort key ${sort} must be an output column`);
346
+ }
347
+
348
+ function validateSourceKey(
349
+ dataset: ReportDefinition["datasets"][number],
350
+ key: string,
351
+ seen = new Set<string>(),
352
+ ): void {
353
+ if (seen.has(key)) return;
354
+ seen.add(key);
355
+ const column = dataset.columns.find((entry) => entry.name === key);
356
+ if (column) {
357
+ requireRule(
358
+ column.field !== "balance" && !column.field.endsWith(".balance"),
359
+ "Source selection and join keys cannot depend on ledger balances",
360
+ );
361
+ return;
362
+ }
363
+ const expression = dataset.expressions.find((entry) => entry.id === key);
364
+ requireRule(expression, `Unknown source key ${key}`);
365
+ requireRule(
366
+ expression.op !== "ratio",
367
+ "Ratios belong after source selection",
368
+ );
369
+ const read = (name: string) => validateSourceKey(dataset, name, seen);
370
+ if (expression.op === "field") read(expression.path);
371
+ else if (expression.op === "choose") {
372
+ read(expression.condition);
373
+ read(expression.yes);
374
+ read(expression.no);
375
+ } else if (expression.op === "not" || expression.op === "bucket")
376
+ read(expression.value);
377
+ else if ("left" in expression) {
378
+ read(expression.left);
379
+ read(expression.right);
380
+ }
381
+ }
@@ -0,0 +1,179 @@
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
+ independentApproval: z.literal(true),
175
+ retention: z.strictObject({ policy: name, years: integer.min(1).max(100) }),
176
+ }),
177
+ });
178
+ export type ReportDefinition = z.infer<typeof reportDefinitionSchema>;
179
+ export type ReportExpression = z.infer<typeof reportExpressionSchema>;