@sapporta/server 0.0.1

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 (133) hide show
  1. package/package.json +40 -0
  2. package/src/actions/action.test.ts +108 -0
  3. package/src/actions/action.ts +60 -0
  4. package/src/actions/loader.ts +47 -0
  5. package/src/api/actions.ts +124 -0
  6. package/src/api/meta-mutations.ts +922 -0
  7. package/src/api/meta.ts +222 -0
  8. package/src/api/reports.ts +98 -0
  9. package/src/api/server.ts +24 -0
  10. package/src/api/tables.ts +108 -0
  11. package/src/api/views.ts +44 -0
  12. package/src/boot.ts +206 -0
  13. package/src/cli/ai-commands.ts +220 -0
  14. package/src/cli/check.ts +169 -0
  15. package/src/cli/cli-utils.test.ts +313 -0
  16. package/src/cli/describe.test.ts +151 -0
  17. package/src/cli/describe.ts +88 -0
  18. package/src/cli/emit-result.test.ts +160 -0
  19. package/src/cli/format.ts +150 -0
  20. package/src/cli/http-client.ts +55 -0
  21. package/src/cli/index.ts +162 -0
  22. package/src/cli/init.ts +35 -0
  23. package/src/cli/project-context.ts +38 -0
  24. package/src/cli/request.ts +146 -0
  25. package/src/cli/routes.ts +418 -0
  26. package/src/cli/rows-insert-master-detail.test.ts +124 -0
  27. package/src/cli/rows-insert-master-detail.ts +186 -0
  28. package/src/cli/rows-insert.test.ts +137 -0
  29. package/src/cli/rows-insert.ts +97 -0
  30. package/src/cli/serve-single.ts +49 -0
  31. package/src/create-project.ts +81 -0
  32. package/src/data/count.ts +62 -0
  33. package/src/data/crud.test.ts +188 -0
  34. package/src/data/crud.ts +242 -0
  35. package/src/data/lookup.test.ts +96 -0
  36. package/src/data/lookup.ts +104 -0
  37. package/src/data/query-parser.test.ts +67 -0
  38. package/src/data/query-parser.ts +106 -0
  39. package/src/data/sanitize.test.ts +57 -0
  40. package/src/data/sanitize.ts +25 -0
  41. package/src/data/save-pipeline.test.ts +115 -0
  42. package/src/data/save-pipeline.ts +93 -0
  43. package/src/data/validate.test.ts +110 -0
  44. package/src/data/validate.ts +98 -0
  45. package/src/db/errors.ts +20 -0
  46. package/src/db/logger.ts +63 -0
  47. package/src/db/sqlite-connection.test.ts +59 -0
  48. package/src/db/sqlite-connection.ts +79 -0
  49. package/src/index.ts +111 -0
  50. package/src/integration/api-actions.test.ts +60 -0
  51. package/src/integration/api-global.test.ts +21 -0
  52. package/src/integration/api-meta.test.ts +252 -0
  53. package/src/integration/api-reports.test.ts +77 -0
  54. package/src/integration/api-tables.test.ts +238 -0
  55. package/src/integration/api-views.test.ts +39 -0
  56. package/src/integration/cli-routes.test.ts +167 -0
  57. package/src/integration/fixtures/actions/create-account.ts +23 -0
  58. package/src/integration/fixtures/reports/account-list.ts +25 -0
  59. package/src/integration/fixtures/schema/accounts.ts +21 -0
  60. package/src/integration/fixtures/schema/audit-log.ts +19 -0
  61. package/src/integration/fixtures/schema/journal-entries.ts +20 -0
  62. package/src/integration/fixtures/views/dashboard.tsx +4 -0
  63. package/src/integration/fixtures/views/settings.tsx +3 -0
  64. package/src/integration/setup.ts +72 -0
  65. package/src/introspect/db-helpers.ts +109 -0
  66. package/src/introspect/describe-all.test.ts +73 -0
  67. package/src/introspect/describe-all.ts +80 -0
  68. package/src/introspect/describe.test.ts +65 -0
  69. package/src/introspect/describe.ts +184 -0
  70. package/src/introspect/exec.test.ts +103 -0
  71. package/src/introspect/exec.ts +57 -0
  72. package/src/introspect/indexes.test.ts +41 -0
  73. package/src/introspect/indexes.ts +95 -0
  74. package/src/introspect/inference.ts +98 -0
  75. package/src/introspect/list-tables.test.ts +40 -0
  76. package/src/introspect/list-tables.ts +62 -0
  77. package/src/introspect/query.test.ts +77 -0
  78. package/src/introspect/query.ts +47 -0
  79. package/src/introspect/sample.test.ts +67 -0
  80. package/src/introspect/sample.ts +50 -0
  81. package/src/introspect/sql-safety.ts +76 -0
  82. package/src/introspect/sqlite/db-helpers.test.ts +79 -0
  83. package/src/introspect/sqlite/db-helpers.ts +56 -0
  84. package/src/introspect/sqlite/describe-all.ts +21 -0
  85. package/src/introspect/sqlite/describe.test.ts +160 -0
  86. package/src/introspect/sqlite/describe.ts +185 -0
  87. package/src/introspect/sqlite/exec.ts +57 -0
  88. package/src/introspect/sqlite/indexes.test.ts +60 -0
  89. package/src/introspect/sqlite/indexes.ts +96 -0
  90. package/src/introspect/sqlite/list-tables.test.ts +100 -0
  91. package/src/introspect/sqlite/list-tables.ts +67 -0
  92. package/src/introspect/sqlite/query.ts +49 -0
  93. package/src/introspect/sqlite/sample.ts +50 -0
  94. package/src/introspect/table-rename.test.ts +235 -0
  95. package/src/introspect/table-rename.ts +115 -0
  96. package/src/introspect/types.ts +95 -0
  97. package/src/reports/check.test.ts +499 -0
  98. package/src/reports/check.ts +208 -0
  99. package/src/reports/engine.test.ts +1465 -0
  100. package/src/reports/engine.ts +678 -0
  101. package/src/reports/loader.ts +55 -0
  102. package/src/reports/report.ts +308 -0
  103. package/src/reports/sql-bind.ts +161 -0
  104. package/src/reports/sqlite-bind.test.ts +98 -0
  105. package/src/reports/sqlite-bind.ts +58 -0
  106. package/src/reports/sqlite-sql-client.ts +42 -0
  107. package/src/runtime.ts +3 -0
  108. package/src/schema/check.ts +90 -0
  109. package/src/schema/ddl.test.ts +210 -0
  110. package/src/schema/ddl.ts +180 -0
  111. package/src/schema/dynamic-builder.ts +297 -0
  112. package/src/schema/extract.test.ts +261 -0
  113. package/src/schema/extract.ts +285 -0
  114. package/src/schema/loader.test.ts +31 -0
  115. package/src/schema/loader.ts +60 -0
  116. package/src/schema/metadata-io.test.ts +261 -0
  117. package/src/schema/metadata-io.ts +161 -0
  118. package/src/schema/metadata-tables.test.ts +737 -0
  119. package/src/schema/metadata-tables.ts +341 -0
  120. package/src/schema/migrate.ts +195 -0
  121. package/src/schema/normalize-datatype.test.ts +58 -0
  122. package/src/schema/normalize-datatype.ts +99 -0
  123. package/src/schema/registry.test.ts +174 -0
  124. package/src/schema/registry.ts +139 -0
  125. package/src/schema/reserved.ts +227 -0
  126. package/src/schema/table.ts +135 -0
  127. package/src/test-fixtures/schema/accounts.ts +24 -0
  128. package/src/test-fixtures/schema/not-a-table.ts +6 -0
  129. package/src/testing/test-utils.ts +44 -0
  130. package/src/views/loader.test.ts +70 -0
  131. package/src/views/loader.ts +38 -0
  132. package/src/views/view.test.ts +121 -0
  133. package/src/views/view.ts +16 -0
@@ -0,0 +1,678 @@
1
+ import type { ColumnSchema } from "../schema/extract.js";
2
+ import { extractBindVariables, buildPositionalQuery } from "./sql-bind.js";
3
+
4
+ // Re-export for existing consumers (check.ts, tests)
5
+ export { extractBindVariables, buildPositionalQuery } from "./sql-bind.js";
6
+
7
+ /**
8
+ * Minimal SQL client interface for the report engine.
9
+ * Only `unsafe()` is needed — the engine doesn't manage connections or
10
+ * transactions. This is intentionally narrower than the full SqlClient,
11
+ * so the engine stays decoupled from CLI concerns and can be tested with
12
+ * a simple adapter (e.g., createReportSqlClient() from sqlite-sql-client.ts).
13
+ *
14
+ * Parameters use ? positional binding (SQLite convention). The caller must
15
+ * convert $name variables to ? placeholders via buildPositionalQuery()
16
+ * before passing the query to this client.
17
+ */
18
+ export interface ReportSqlClient {
19
+ unsafe: (query: string, params?: any[]) => Promise<any[]>;
20
+ }
21
+
22
+ import type {
23
+ ReportDefinition,
24
+ ReportTreeNode,
25
+ ReportSource,
26
+ ReportParam,
27
+ ReportOutputNode,
28
+ ReportFooterRow,
29
+ ReportResult,
30
+ ReportSort,
31
+ } from "./report.js";
32
+
33
+ /**
34
+ * Internal engine type — extends ReportOutputNode with the full SQL row.
35
+ *
36
+ * Created during tree execution (assembleOutputNode), threaded through
37
+ * transforms and rollups, then stripped by applyDisplayFunctions before
38
+ * the result leaves the engine. Code outside engine.ts never sees this type.
39
+ */
40
+ type EngineNode = ReportOutputNode & { __rawRow: Record<string, unknown> };
41
+
42
+ type TreeNodeResult = { nodes: EngineNode[]; footerRows: ReportFooterRow[] };
43
+
44
+ /**
45
+ * Session-level context threaded through recursive tree execution.
46
+ *
47
+ * These values are constant for the entire execution of a report — they
48
+ * don't change as the engine recurses into child tree nodes. Bundling them
49
+ * into a named type reduces executeTreeNode's parameter count and makes
50
+ * the distinction between session state and per-node state explicit.
51
+ */
52
+ type TreeContext = {
53
+ sql: ReportSqlClient;
54
+ sources: Record<string, ReportSource>;
55
+ params: Record<string, unknown>;
56
+ errors: { path: string; message: string }[];
57
+ };
58
+
59
+ // ---------------------------------------------------------------------------
60
+ // Param resolution
61
+ // ---------------------------------------------------------------------------
62
+
63
+ export function resolveParams(
64
+ paramDefs: ReportParam[],
65
+ userParams: Record<string, unknown>,
66
+ ): Record<string, unknown> {
67
+ const resolved: Record<string, unknown> = {};
68
+
69
+ for (const def of paramDefs) {
70
+ let value = userParams[def.name];
71
+
72
+ if (value === undefined || value === null || value === "") {
73
+ if (def.required) {
74
+ throw new Error(`Required parameter "${def.name}" is missing`);
75
+ }
76
+ value = def.default ?? null;
77
+ }
78
+
79
+ // Type coercion
80
+ if (value != null) {
81
+ switch (def.type) {
82
+ case "integer":
83
+ value = typeof value === "string" ? parseInt(value, 10) : value;
84
+ break;
85
+ case "float":
86
+ value = typeof value === "string" ? parseFloat(value as string) : value;
87
+ break;
88
+ case "date":
89
+ // Pass through as string — SQLite stores dates as ISO 8601 text
90
+ break;
91
+ case "string":
92
+ value = String(value);
93
+ break;
94
+ }
95
+ }
96
+
97
+ resolved[def.name] = value;
98
+ }
99
+
100
+ return resolved;
101
+ }
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Bind resolution
105
+ // ---------------------------------------------------------------------------
106
+
107
+ function resolveBinds(
108
+ bind:
109
+ | Record<string, string>
110
+ | ((
111
+ parent: Record<string, unknown>,
112
+ params: Record<string, unknown>,
113
+ ) => Record<string, unknown>)
114
+ | undefined,
115
+ parentRow: Record<string, unknown> | null,
116
+ params: Record<string, unknown>,
117
+ ): Record<string, unknown> {
118
+ if (!bind) return {};
119
+
120
+ if (typeof bind === "function") {
121
+ return bind(parentRow ?? {}, params);
122
+ }
123
+
124
+ // Map form: { account_id: "$parent.id" }
125
+ const result: Record<string, unknown> = {};
126
+ for (const [key, ref] of Object.entries(bind)) {
127
+ if (typeof ref === "string" && ref.startsWith("$parent.")) {
128
+ const col = ref.slice("$parent.".length);
129
+ result[key] = parentRow?.[col] ?? null;
130
+ } else {
131
+ result[key] = ref;
132
+ }
133
+ }
134
+ return result;
135
+ }
136
+
137
+ // ---------------------------------------------------------------------------
138
+ // Numeric coercion
139
+ // ---------------------------------------------------------------------------
140
+
141
+ /**
142
+ * Coerce string values that look like numbers to actual numbers.
143
+ *
144
+ * SQLite REAL returns native JS numbers, but currency/percentage columns
145
+ * stored as TEXT return strings like "95.50". Coercing these to numbers
146
+ * is correct for report calculations — the formatted display happens in
147
+ * the UI layer, not the engine layer.
148
+ */
149
+ function coerceNumericStrings(
150
+ rows: Record<string, unknown>[],
151
+ ): Record<string, unknown>[] {
152
+ for (const row of rows) {
153
+ for (const key of Object.keys(row)) {
154
+ const val = row[key];
155
+ if (typeof val === "string" && val !== "" && !isNaN(Number(val))) {
156
+ // Check it's actually a numeric string, not a date or other format
157
+ if (/^-?\d+(\.\d+)?$/.test(val)) {
158
+ row[key] = Number(val);
159
+ }
160
+ }
161
+ }
162
+ }
163
+ return rows;
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Source execution
168
+ // ---------------------------------------------------------------------------
169
+
170
+ async function executeSource(
171
+ sql: ReportSqlClient,
172
+ source: ReportSource,
173
+ allValues: Record<string, unknown>,
174
+ ): Promise<Record<string, unknown>[]> {
175
+ const bindVars = extractBindVariables(source.query);
176
+ const { sql: processedSql, values } = buildPositionalQuery(
177
+ source.query,
178
+ bindVars,
179
+ allValues,
180
+ );
181
+
182
+ const result = await sql.unsafe(processedSql, values as any[]);
183
+ return coerceNumericStrings(result as unknown as Record<string, unknown>[]);
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // Output node assembly
188
+ // ---------------------------------------------------------------------------
189
+
190
+ function assembleOutputNode(
191
+ treeNode: ReportTreeNode,
192
+ row: Record<string, unknown>,
193
+ ): EngineNode {
194
+ const columns: Record<string, unknown> = {};
195
+ for (const col of treeNode.columns) {
196
+ if (col.name in row) {
197
+ columns[col.name] = row[col.name];
198
+ }
199
+ }
200
+ return { levelName: treeNode.levelName, columns, __rawRow: row };
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Sorting
205
+ // ---------------------------------------------------------------------------
206
+
207
+ function sortNodes<T extends ReportOutputNode>(
208
+ nodes: T[],
209
+ sortSpec: ReportSort[],
210
+ ): T[] {
211
+ if (sortSpec.length === 0) return nodes;
212
+
213
+ return nodes.sort((a, b) => {
214
+ for (const spec of sortSpec) {
215
+ const aVal = a.columns[spec.key] ?? a.rollup?.[spec.key];
216
+ const bVal = b.columns[spec.key] ?? b.rollup?.[spec.key];
217
+
218
+ let cmp = 0;
219
+ if (typeof aVal === "number" && typeof bVal === "number") {
220
+ cmp = aVal - bVal;
221
+ } else {
222
+ cmp = String(aVal ?? "").localeCompare(String(bVal ?? ""));
223
+ }
224
+
225
+ if (cmp !== 0) {
226
+ return spec.direction === "desc" ? -cmp : cmp;
227
+ }
228
+ }
229
+ return 0;
230
+ });
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Child processing — execute children for a single parent row
235
+ // ---------------------------------------------------------------------------
236
+
237
+ /**
238
+ * Process all child tree nodes for a given parent row.
239
+ *
240
+ * For each child definition: check `when` condition, execute the child tree,
241
+ * assign the result (respecting singular vs array mode), and store footer rows.
242
+ * Children are processed in declaration order so that later children can see
243
+ * earlier siblings via `childSiblings`.
244
+ */
245
+ async function processChildren(
246
+ ctx: TreeContext,
247
+ treeNode: ReportTreeNode,
248
+ node: EngineNode,
249
+ row: Record<string, unknown>,
250
+ nodePath: string,
251
+ ): Promise<void> {
252
+ if (!treeNode.children || treeNode.children.length === 0) return;
253
+
254
+ node.children = {};
255
+ const childSiblings: Record<
256
+ string,
257
+ ReportOutputNode | ReportOutputNode[] | null
258
+ > = {};
259
+
260
+ for (const childDef of treeNode.children) {
261
+ const emptyValue = childDef.singular ? null : [];
262
+
263
+ // Check `when` condition — skip child if false
264
+ if (childDef.when && !childDef.when(row)) {
265
+ node.children[childDef.levelName] = emptyValue;
266
+ childSiblings[childDef.levelName] = emptyValue;
267
+ continue;
268
+ }
269
+
270
+ let childResult: TreeNodeResult;
271
+ try {
272
+ childResult = await executeTreeNode(
273
+ ctx,
274
+ childDef,
275
+ row,
276
+ node,
277
+ childSiblings,
278
+ `${nodePath}.${childDef.levelName}`,
279
+ );
280
+ } catch (err: any) {
281
+ ctx.errors.push({
282
+ path: `${nodePath}.${childDef.levelName}`,
283
+ message: err.message,
284
+ });
285
+ node.children[childDef.levelName] = emptyValue;
286
+ childSiblings[childDef.levelName] = emptyValue;
287
+ continue;
288
+ }
289
+
290
+ if (childDef.singular) {
291
+ const single = childResult.nodes[0] ?? null;
292
+ node.children[childDef.levelName] = single;
293
+ childSiblings[childDef.levelName] = single;
294
+ } else {
295
+ node.children[childDef.levelName] = childResult.nodes;
296
+ childSiblings[childDef.levelName] = childResult.nodes;
297
+ }
298
+
299
+ if (!childDef.singular && childResult.footerRows.length > 0) {
300
+ if (!node.childFooterRows) node.childFooterRows = {};
301
+ node.childFooterRows[childDef.levelName] = childResult.footerRows;
302
+ }
303
+ }
304
+
305
+ // Compute rollup from children
306
+ if (treeNode.rollup) {
307
+ node.rollup = computeRollup(treeNode.rollup, node.children);
308
+ }
309
+ }
310
+
311
+ // ---------------------------------------------------------------------------
312
+ // Rollup computation
313
+ // ---------------------------------------------------------------------------
314
+
315
+ /**
316
+ * Compute rollup values from a node's materialized children.
317
+ *
318
+ * Normalizes children to arrays (singular children become single-element
319
+ * arrays) and calls the rollup function. Warning about undeclared keys
320
+ * is handled by the caller (once per tree level, not per row).
321
+ */
322
+ function computeRollup(
323
+ rollupFn: (children: Record<string, ReportOutputNode[]>) => Record<string, unknown>,
324
+ children: Record<string, ReportOutputNode[] | ReportOutputNode | null>,
325
+ ): Record<string, unknown> {
326
+ const childArrays: Record<string, ReportOutputNode[]> = {};
327
+ for (const [key, val] of Object.entries(children)) {
328
+ if (Array.isArray(val)) {
329
+ childArrays[key] = val;
330
+ } else if (val != null) {
331
+ childArrays[key] = [val];
332
+ } else {
333
+ childArrays[key] = [];
334
+ }
335
+ }
336
+ return rollupFn(childArrays);
337
+ }
338
+
339
+ // ---------------------------------------------------------------------------
340
+ // Tree execution
341
+ // ---------------------------------------------------------------------------
342
+
343
+ async function executeTreeNode(
344
+ ctx: TreeContext,
345
+ treeNode: ReportTreeNode,
346
+ parentRow: Record<string, unknown> | null,
347
+ parentOutputNode: ReportOutputNode | null,
348
+ siblingsSoFar: Record<
349
+ string,
350
+ ReportOutputNode | ReportOutputNode[] | null
351
+ >,
352
+ path: string,
353
+ ): Promise<TreeNodeResult> {
354
+ // 1. Resolve bind values and merge with params
355
+ const bindValues = resolveBinds(treeNode.bind, parentRow, ctx.params);
356
+ const allValues = { ...ctx.params, ...bindValues };
357
+
358
+ // 2. Execute source query
359
+ const source = ctx.sources[treeNode.source];
360
+ if (!source) {
361
+ ctx.errors.push({ path, message: `Source "${treeNode.source}" not found` });
362
+ return { nodes: [], footerRows: [] };
363
+ }
364
+
365
+ let rows: Record<string, unknown>[];
366
+ try {
367
+ rows = await executeSource(ctx.sql, source, allValues);
368
+ } catch (err: any) {
369
+ ctx.errors.push({
370
+ path,
371
+ message: `Query error on source "${treeNode.source}": ${err.message}`,
372
+ });
373
+ return { nodes: [], footerRows: [] };
374
+ }
375
+
376
+ // 3. For each row, assemble output node and process children
377
+ const nodes: EngineNode[] = [];
378
+ for (let rowIdx = 0; rowIdx < rows.length; rowIdx++) {
379
+ const row = rows[rowIdx];
380
+ const node = assembleOutputNode(treeNode, row);
381
+ await processChildren(ctx, treeNode, node, row, `${path}[${rowIdx}]`);
382
+ nodes.push(node);
383
+ }
384
+
385
+ // Warn once per tree level if rollup produces keys not declared in columns[].
386
+ // This is a definition-level warning (the rollup function's output shape
387
+ // doesn't change per row), so we check only the first node that has a rollup.
388
+ if (treeNode.rollup && nodes.length > 0 && nodes[0].rollup) {
389
+ const declaredColNames = new Set(treeNode.columns.map((c) => c.name));
390
+ const undeclaredRollup = Object.keys(nodes[0].rollup).filter(
391
+ (k) => !declaredColNames.has(k),
392
+ );
393
+ if (undeclaredRollup.length > 0) {
394
+ ctx.errors.push({
395
+ path: `${path}.rollup`,
396
+ message:
397
+ `Rollup produces keys not declared in columns[]: ${undeclaredRollup.join(", ")}. ` +
398
+ `The UI will not render these values. Add them to columns[] on the "${treeNode.levelName}" level.`,
399
+ });
400
+ }
401
+ }
402
+
403
+ // 4. Run transform if declared
404
+ //
405
+ // Transforms run at every tree level, including the root. Root-level
406
+ // reports (e.g. Net Worth Over Time) use transforms to compute cumulative
407
+ // virtual columns from SQL-sourced deltas.
408
+ //
409
+ // When there is no parent (root level), we provide a synthetic empty
410
+ // context so the TransformContext contract is satisfied without requiring
411
+ // transform authors to handle nulls.
412
+ //
413
+ // rawRows gives transforms access to ALL SQL columns — including those not
414
+ // declared in columns[]. The array is parallel to the nodes array.
415
+ let resultNodes: EngineNode[] = nodes;
416
+ if (treeNode.transform) {
417
+ const rawRows = nodes.map((n) => n.__rawRow);
418
+ const context = parentOutputNode
419
+ ? { parent: parentOutputNode, siblings: siblingsSoFar, params: ctx.params, rawRows }
420
+ : { parent: { levelName: "__root__" as const, columns: {} }, siblings: {} as Record<string, ReportOutputNode | ReportOutputNode[] | null>, params: ctx.params, rawRows };
421
+ const transformed = treeNode.transform(nodes, context);
422
+
423
+ // Carry forward __rawRow from the original nodes to the transform's output.
424
+ // Transforms return ReportOutputNode[] (the public type), but the engine
425
+ // needs EngineNode[] for display functions. If a transform returns a new
426
+ // object without __rawRow, copy it from the original node at the same index.
427
+ resultNodes = transformed.map((tNode, i) => {
428
+ if ('__rawRow' in tNode) return tNode as EngineNode;
429
+ const rawRow = i < nodes.length ? nodes[i].__rawRow : {};
430
+ return Object.assign(tNode, { __rawRow: rawRow }) as EngineNode;
431
+ });
432
+ }
433
+
434
+ // 5. Sort if declared
435
+ if (treeNode.sort && treeNode.sort.length > 0) {
436
+ resultNodes = sortNodes(resultNodes, treeNode.sort);
437
+ }
438
+
439
+ // 6. Compute footer rows (separate from data nodes)
440
+ const footerRows: ReportFooterRow[] = [];
441
+ if (treeNode.footer && treeNode.footer.length > 0) {
442
+ const declaredColNames = new Set(treeNode.columns.map((c) => c.name));
443
+ for (const footer of treeNode.footer) {
444
+ const footerValues = footer.compute(resultNodes);
445
+ footerRows.push({ label: footer.label, columns: footerValues });
446
+
447
+ const undeclaredFooter = Object.keys(footerValues).filter(
448
+ (k) => !declaredColNames.has(k),
449
+ );
450
+ if (undeclaredFooter.length > 0) {
451
+ ctx.errors.push({
452
+ path: `${path}.footer["${footer.label}"]`,
453
+ message:
454
+ `Footer "${footer.label}" produces keys not declared in columns[]: ${undeclaredFooter.join(", ")}. ` +
455
+ `The UI will not render these values. Add them to columns[] on the "${treeNode.levelName}" level.`,
456
+ });
457
+ }
458
+ }
459
+ }
460
+
461
+ return { nodes: resultNodes, footerRows };
462
+ }
463
+
464
+ // ---------------------------------------------------------------------------
465
+ // Level column collection
466
+ // ---------------------------------------------------------------------------
467
+
468
+ /**
469
+ * Walk the tree definition and collect column schemas for each level.
470
+ * Returns a map of levelName → ColumnSchema[].
471
+ */
472
+ function collectLevelColumns(
473
+ tree: ReportTreeNode,
474
+ ): Record<string, ColumnSchema[]> {
475
+ const result: Record<string, ColumnSchema[]> = {};
476
+
477
+ function walk(node: ReportTreeNode) {
478
+ result[node.levelName] = node.columns;
479
+ if (node.children) {
480
+ for (const child of node.children) {
481
+ walk(child);
482
+ }
483
+ }
484
+ }
485
+
486
+ walk(tree);
487
+ return result;
488
+ }
489
+
490
+ // ---------------------------------------------------------------------------
491
+ // Level options collection
492
+ // ---------------------------------------------------------------------------
493
+
494
+ /**
495
+ * Walk the tree definition and collect per-level UI options into a flat map.
496
+ *
497
+ * Mirrors `collectLevelColumns` — both do a pre-order walk of the tree and
498
+ * extract definition-time metadata that the UI needs. The UI receives these
499
+ * as part of ReportResult so it can initialize expand/collapse state without
500
+ * access to the ReportDefinition itself (which lives on the server and
501
+ * contains functions that can't be serialized over JSON).
502
+ *
503
+ * Only levels that have explicitly set options are included in the result;
504
+ * the UI treats absence as "use defaults" (e.g. expanded).
505
+ */
506
+ function collectLevelOptions(
507
+ tree: ReportTreeNode,
508
+ ): Record<string, { defaultCollapsed?: boolean }> {
509
+ const result: Record<string, { defaultCollapsed?: boolean }> = {};
510
+
511
+ function walk(node: ReportTreeNode) {
512
+ if (node.defaultCollapsed != null) {
513
+ result[node.levelName] = { defaultCollapsed: node.defaultCollapsed };
514
+ }
515
+ if (node.children) {
516
+ for (const child of node.children) {
517
+ walk(child);
518
+ }
519
+ }
520
+ }
521
+
522
+ walk(tree);
523
+ return result;
524
+ }
525
+
526
+ // ---------------------------------------------------------------------------
527
+ // Display function application
528
+ // ---------------------------------------------------------------------------
529
+
530
+ /**
531
+ * Recursively walk the result tree and apply column `display` functions.
532
+ *
533
+ * TIMING: This runs as the LAST step in executeReport, AFTER the entire tree
534
+ * is materialized — transforms, rollups, sorts, and footers are all complete.
535
+ * This ordering is critical: rollup and footer functions see raw numeric
536
+ * column values, not display-formatted strings. Display is purely cosmetic
537
+ * and only affects the final output sent to the UI.
538
+ *
539
+ * For each node, the display function receives a merged object:
540
+ * { ...rawSqlRow, ...node.columns, ...node.rollup }
541
+ * This means display can reference any SQL column (including undeclared ones),
542
+ * declared column values (possibly modified by transform), and rollup values.
543
+ *
544
+ * Also cleans up the internal __rawRow property from all nodes.
545
+ */
546
+ function applyDisplayFunctions(
547
+ nodes: EngineNode[],
548
+ tree: ReportTreeNode,
549
+ ): void {
550
+ // Build a lookup of column display functions for this level
551
+ const displayFns = new Map<string, (data: Record<string, unknown>) => string | number | null>();
552
+ for (const col of tree.columns) {
553
+ if (col.display) {
554
+ displayFns.set(col.name, col.display);
555
+ }
556
+ }
557
+
558
+ for (const node of nodes) {
559
+ // Spread order: raw SQL row first, then declared columns (which may have
560
+ // been modified by transform), then rollup values. Later spreads win on
561
+ // key collisions, so transform modifications take precedence over raw SQL.
562
+ const merged = { ...node.__rawRow, ...node.columns, ...node.rollup };
563
+
564
+ // Apply display functions — store the result back into node.columns
565
+ for (const [colName, displayFn] of displayFns) {
566
+ node.columns[colName] = displayFn(merged);
567
+ }
568
+
569
+ // Strip __rawRow — transitioning from EngineNode to ReportOutputNode.
570
+ // After this point the node is a plain ReportOutputNode.
571
+ delete (node as ReportOutputNode & { __rawRow?: unknown }).__rawRow;
572
+
573
+ // Recurse into children, matching each child group to its tree definition.
574
+ // Children are EngineNodes — they were produced by executeTreeNode which
575
+ // returns EngineNode[], and the children map preserves the concrete type
576
+ // even though its declared type is ReportOutputNode[].
577
+ if (node.children && tree.children) {
578
+ for (const childTree of tree.children) {
579
+ const childData = node.children[childTree.levelName];
580
+ if (Array.isArray(childData)) {
581
+ applyDisplayFunctions(childData as EngineNode[], childTree);
582
+ } else if (childData != null) {
583
+ applyDisplayFunctions([childData] as EngineNode[], childTree);
584
+ }
585
+
586
+ // Apply display to child footer rows (e.g. "Closing Balance" on entries)
587
+ const childFooters = node.childFooterRows?.[childTree.levelName];
588
+ if (childFooters) {
589
+ applyFooterDisplayFunctions(childFooters, childTree);
590
+ }
591
+ }
592
+ }
593
+ }
594
+ }
595
+
596
+ /**
597
+ * Apply display functions to footer rows. Footer rows don't have __rawRow
598
+ * (they're synthetic), so the merged data is just the footer's columns.
599
+ */
600
+ function applyFooterDisplayFunctions(
601
+ footerRows: ReportFooterRow[],
602
+ tree: ReportTreeNode,
603
+ ): void {
604
+ const displayFns = new Map<string, (data: Record<string, unknown>) => string | number | null>();
605
+ for (const col of tree.columns) {
606
+ if (col.display) {
607
+ displayFns.set(col.name, col.display);
608
+ }
609
+ }
610
+ if (displayFns.size === 0) return;
611
+
612
+ for (const row of footerRows) {
613
+ const merged = { ...row.columns };
614
+ for (const [colName, displayFn] of displayFns) {
615
+ row.columns[colName] = displayFn(merged);
616
+ }
617
+ }
618
+ }
619
+
620
+ // ---------------------------------------------------------------------------
621
+ // Main entry point
622
+ // ---------------------------------------------------------------------------
623
+
624
+ export async function executeReport(
625
+ sql: ReportSqlClient,
626
+ definition: ReportDefinition,
627
+ userParams: Record<string, unknown>,
628
+ ): Promise<ReportResult> {
629
+ const params = resolveParams(definition.params, userParams);
630
+
631
+ const ctx: TreeContext = {
632
+ sql,
633
+ sources: definition.sources,
634
+ params,
635
+ errors: [],
636
+ };
637
+
638
+ // Execute tree from root — produces EngineNode[] (ReportOutputNode + __rawRow).
639
+ // applyDisplayFunctions consumes __rawRow and deletes it, leaving plain
640
+ // ReportOutputNode objects that are safe to return in the result.
641
+ const { nodes: data, footerRows } = await executeTreeNode(
642
+ ctx,
643
+ definition.tree,
644
+ null, // no parent row for root
645
+ null, // no parent output node for root
646
+ {}, // no siblings for root
647
+ definition.tree.levelName,
648
+ );
649
+
650
+ // Apply display functions AFTER the entire tree is materialized.
651
+ // This ordering ensures rollup/footer/transform all see raw numeric
652
+ // values. Display is the last step before returning results.
653
+ // Also cleans up __rawRow from all nodes.
654
+ applyDisplayFunctions(data, definition.tree);
655
+ if (footerRows.length > 0) {
656
+ applyFooterDisplayFunctions(footerRows, definition.tree);
657
+ }
658
+
659
+ // Collect static metadata from the tree definition. These are pre-order
660
+ // walks that flatten hierarchical definition-time info into keyed maps
661
+ // for the UI. Both use levelName as the key, which is unique per level.
662
+ const levelColumns = collectLevelColumns(definition.tree);
663
+ const levelOptions = collectLevelOptions(definition.tree);
664
+
665
+ // Omit empty optional fields to keep the JSON response clean.
666
+ // The UI treats missing fields as "use defaults".
667
+ return {
668
+ name: definition.name,
669
+ label: definition.label,
670
+ params: definition.params,
671
+ columns: definition.tree.columns,
672
+ levelColumns,
673
+ data,
674
+ ...(Object.keys(levelOptions).length > 0 ? { levelOptions } : {}),
675
+ ...(footerRows.length > 0 ? { footerRows } : {}),
676
+ ...(ctx.errors.length > 0 ? { errors: ctx.errors } : {}),
677
+ };
678
+ }