@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,115 @@
1
+ import { z } from "zod";
2
+ import type { SqlClient, OperationResult } from "./types.js";
3
+ import { OperationError, ErrorCode } from "./types.js";
4
+ import { validateTableName } from "./sql-safety.js";
5
+
6
+ export const tableRenameInput = z.object({
7
+ oldName: z.string().describe("Current SQL table name"),
8
+ newName: z.string().describe("New SQL table name (snake_case)"),
9
+ newLabel: z.string().optional().describe("New display label (defaults to title-cased newName)"),
10
+ });
11
+
12
+ /**
13
+ * Title-case a snake_case name: "sales_targets" → "Sales Targets"
14
+ */
15
+ function titleCase(name: string): string {
16
+ return name
17
+ .split("_")
18
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
19
+ .join(" ");
20
+ }
21
+
22
+ /**
23
+ * Rename a UI-managed table atomically.
24
+ *
25
+ * Steps inside sql.begin():
26
+ * 1. Verify oldName exists in _sapporta_tables (reject file-managed tables)
27
+ * 2. ALTER TABLE "oldName" RENAME TO "newName"
28
+ * 3. INSERT new row into _sapporta_tables (copy metadata with new name/label)
29
+ * 4. UPDATE _sapporta_columns SET table_name = newName WHERE table_name = oldName
30
+ * 5. UPDATE _sapporta_columns SET references_table = newName WHERE references_table = oldName
31
+ * 6. DELETE old row from _sapporta_tables
32
+ *
33
+ * Why insert-then-delete: _sapporta_columns.table_name FK references
34
+ * _sapporta_tables.name with NO ON UPDATE CASCADE. Can't update PK in place.
35
+ * Instead: insert new row so FK target exists, move column references, then
36
+ * delete old row.
37
+ */
38
+ export async function tableRename(
39
+ sql: SqlClient,
40
+ oldName: string,
41
+ newName: string,
42
+ newLabel?: string,
43
+ ): Promise<OperationResult> {
44
+ validateTableName(oldName);
45
+ validateTableName(newName);
46
+
47
+ if (oldName === newName) {
48
+ throw new OperationError("Old and new names are the same", ErrorCode.VALIDATION_FAILED);
49
+ }
50
+
51
+ const label = newLabel ?? titleCase(newName);
52
+
53
+ await sql.begin(async (tx) => {
54
+ // 1. Verify oldName exists in _sapporta_tables
55
+ const existing = await tx.unsafe(
56
+ `SELECT name FROM _sapporta_tables WHERE name = $1`,
57
+ [oldName],
58
+ );
59
+ if (existing.length === 0) {
60
+ throw new OperationError(
61
+ `Table "${oldName}" not found in _sapporta_tables (only UI-managed tables can be renamed)`,
62
+ ErrorCode.TABLE_NOT_FOUND,
63
+ );
64
+ }
65
+
66
+ // Check newName doesn't already exist
67
+ const conflict = await tx.unsafe(
68
+ `SELECT name FROM _sapporta_tables WHERE name = $1`,
69
+ [newName],
70
+ );
71
+ if (conflict.length > 0) {
72
+ throw new OperationError(
73
+ `Table "${newName}" already exists in _sapporta_tables`,
74
+ ErrorCode.VALIDATION_FAILED,
75
+ );
76
+ }
77
+
78
+ // 2. Rename the actual PG table
79
+ await tx.unsafe(`ALTER TABLE "${oldName}" RENAME TO "${newName}"`);
80
+
81
+ // 3. Insert new metadata row (copy from old with new name/label)
82
+ await tx.unsafe(
83
+ `INSERT INTO _sapporta_tables (name, label, display_column, immutable, inferred, position)
84
+ SELECT $1, $2, display_column, immutable, inferred, position
85
+ FROM _sapporta_tables WHERE name = $3`,
86
+ [newName, label, oldName],
87
+ );
88
+
89
+ // 4. Move columns to new table name
90
+ await tx.unsafe(
91
+ `UPDATE _sapporta_columns SET table_name = $1 WHERE table_name = $2`,
92
+ [newName, oldName],
93
+ );
94
+
95
+ // 5. Update FK references pointing to the old table
96
+ await tx.unsafe(
97
+ `UPDATE _sapporta_columns SET references_table = $1 WHERE references_table = $2`,
98
+ [newName, oldName],
99
+ );
100
+
101
+ // 6. Delete old metadata row (columns already moved, so no FK violation)
102
+ await tx.unsafe(
103
+ `DELETE FROM _sapporta_tables WHERE name = $1`,
104
+ [oldName],
105
+ );
106
+ });
107
+
108
+ return {
109
+ ok: true,
110
+ data: [{ old_name: oldName, new_name: newName, label }],
111
+ meta: {
112
+ message: `Renamed table "${oldName}" → "${newName}" (label: "${label}")`,
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,95 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Operation result envelope — the contract between domain operations and
3
+ // the layers that consume them (API routes, CLI output).
4
+ //
5
+ // These types were originally in cli-utils.ts as CliResult/CliError but have
6
+ // nothing to do with the CLI — they're a structured result pattern used by
7
+ // database introspection functions, SQL proxy, and other operations.
8
+ // ---------------------------------------------------------------------------
9
+
10
+ /**
11
+ * Every operation returns an OperationResult instead of printing directly.
12
+ * This separates data production from presentation, enabling:
13
+ * - HTTP API responses (meta-api.ts converts to JSON + status codes)
14
+ * - CLI output formatting (emitResult converts to table or JSON)
15
+ * - Tests that assert on data, not console output
16
+ */
17
+ export type OperationResult = OperationSuccess | OperationFailure;
18
+
19
+ /**
20
+ * Well-known metadata fields used by the output layer for rendering.
21
+ * These fields are the contract between operation functions and consumers:
22
+ * - message: displayed before the data table (or as the sole output if tableOutputHandled)
23
+ * - tableOutputHandled: signals that message/additionalOutput contain the full rendering,
24
+ * so the output layer should NOT format data[] as a table
25
+ * - additionalOutput: extra text sections printed after the main table
26
+ * - errorText: printed to stderr (e.g. warnings from report execution)
27
+ * - rowCount, dryRun: informational fields for agents consuming JSON output
28
+ *
29
+ * The index signature allows operation-specific extras (e.g. foreignKeys, reportData).
30
+ */
31
+ export type OperationMeta = {
32
+ message?: string;
33
+ tableOutputHandled?: boolean;
34
+ additionalOutput?: string;
35
+ errorText?: string;
36
+ rowCount?: number;
37
+ dryRun?: boolean;
38
+ [key: string]: unknown;
39
+ };
40
+
41
+ export type OperationSuccess = {
42
+ ok: true;
43
+ /** Primary result rows. Most operations return a single table of rows. */
44
+ data: Record<string, unknown>[];
45
+ /** Optional metadata (row counts, messages, secondary data like foreign keys). */
46
+ meta?: OperationMeta;
47
+ };
48
+
49
+ export type OperationFailure = {
50
+ ok: false;
51
+ error: string;
52
+ code: string;
53
+ };
54
+
55
+ /**
56
+ * Well-known error codes for structured error output.
57
+ * Agents can match on these programmatically instead of parsing error messages.
58
+ */
59
+ export const ErrorCode = {
60
+ TABLE_NOT_FOUND: "TABLE_NOT_FOUND",
61
+ INVALID_TABLE_NAME: "INVALID_TABLE_NAME",
62
+ INVALID_COLUMN_NAME: "INVALID_COLUMN_NAME",
63
+ INVALID_JSON: "INVALID_JSON",
64
+ DANGEROUS_SQL: "DANGEROUS_SQL",
65
+ SELECT_ONLY: "SELECT_ONLY",
66
+ PROJECT_NOT_FOUND: "PROJECT_NOT_FOUND",
67
+ REPORT_NOT_FOUND: "REPORT_NOT_FOUND",
68
+ VALIDATION_FAILED: "VALIDATION_FAILED",
69
+ MISSING_ARGUMENT: "MISSING_ARGUMENT",
70
+ INTERNAL: "INTERNAL",
71
+ } as const;
72
+
73
+ /**
74
+ * Typed error that carries a machine-readable error code.
75
+ * Operations throw these; consumers catch and convert to appropriate output.
76
+ */
77
+ export class OperationError extends Error {
78
+ constructor(
79
+ message: string,
80
+ public code: string,
81
+ ) {
82
+ super(message);
83
+ }
84
+ }
85
+
86
+ /**
87
+ * SQL client interface matching the subset of postgres.js used by operations.
88
+ * In production, a real postgres.js client is passed.
89
+ * In tests, a PGlite adapter is injected.
90
+ */
91
+ export interface SqlClient {
92
+ unsafe: (query: string, params?: any[]) => Promise<any[]>;
93
+ begin: (fn: (sql: SqlClient) => Promise<any>) => Promise<any>;
94
+ end: () => Promise<void>;
95
+ }
@@ -0,0 +1,499 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import Database from "better-sqlite3";
3
+ import { checkReportDefinition, checkReportSqlColumns } from "./check.js";
4
+ import type { CheckSql } from "./check.js";
5
+ import type { ReportDefinition } from "./report.js";
6
+
7
+ describe("checkReportDefinition", () => {
8
+ it("returns no issues for a valid report", () => {
9
+ const def: ReportDefinition = {
10
+ name: "valid",
11
+ label: "Valid Report",
12
+ params: [],
13
+ sources: {
14
+ items: { query: "SELECT name, amount FROM items" },
15
+ },
16
+ tree: {
17
+ source: "items",
18
+ levelName: "item",
19
+ columns: [
20
+ { name: "name", header: "Name" },
21
+ { name: "amount", header: "Amount" },
22
+ ],
23
+ },
24
+ };
25
+
26
+ expect(checkReportDefinition(def)).toEqual([]);
27
+ });
28
+
29
+ it("detects missing source reference", () => {
30
+ const def: ReportDefinition = {
31
+ name: "bad-source",
32
+ label: "Bad Source",
33
+ params: [],
34
+ sources: {
35
+ items: { query: "SELECT 1" },
36
+ },
37
+ tree: {
38
+ source: "nonexistent",
39
+ levelName: "item",
40
+ columns: [{ name: "name" }],
41
+ },
42
+ };
43
+
44
+ const issues = checkReportDefinition(def);
45
+ expect(issues).toHaveLength(1);
46
+ expect(issues[0].path).toBe("item");
47
+ expect(issues[0].message).toContain("nonexistent");
48
+ expect(issues[0].message).toContain("not found in sources");
49
+ });
50
+
51
+ it("detects missing source in child node", () => {
52
+ const def: ReportDefinition = {
53
+ name: "bad-child-source",
54
+ label: "Bad Child Source",
55
+ params: [],
56
+ sources: {
57
+ parents: { query: "SELECT name FROM groups" },
58
+ },
59
+ tree: {
60
+ source: "parents",
61
+ levelName: "group",
62
+ columns: [{ name: "name" }],
63
+ children: [
64
+ {
65
+ source: "missing_source",
66
+ levelName: "detail",
67
+ columns: [{ name: "info" }],
68
+ },
69
+ ],
70
+ },
71
+ };
72
+
73
+ const issues = checkReportDefinition(def);
74
+ expect(issues).toHaveLength(1);
75
+ expect(issues[0].path).toBe("group.detail");
76
+ expect(issues[0].message).toContain("missing_source");
77
+ });
78
+
79
+ it("detects rollup keys not declared in columns[]", () => {
80
+ const def: ReportDefinition = {
81
+ name: "rollup-warn",
82
+ label: "Rollup Warn",
83
+ params: [],
84
+ sources: {
85
+ parents: { query: "SELECT name FROM groups" },
86
+ kids: { query: "SELECT name FROM items" },
87
+ },
88
+ tree: {
89
+ source: "parents",
90
+ levelName: "group",
91
+ columns: [{ name: "name" }],
92
+ rollup: (children) => ({
93
+ total: children.item.reduce((s) => s + 1, 0),
94
+ count: children.item.length,
95
+ }),
96
+ children: [
97
+ {
98
+ source: "kids",
99
+ levelName: "item",
100
+ columns: [{ name: "name" }],
101
+ },
102
+ ],
103
+ },
104
+ };
105
+
106
+ const issues = checkReportDefinition(def);
107
+ expect(issues).toHaveLength(2);
108
+ expect(issues[0].path).toBe("group.rollup");
109
+ expect(issues[0].message).toContain('"total"');
110
+ expect(issues[0].message).toContain('level "group"');
111
+ expect(issues[1].path).toBe("group.rollup");
112
+ expect(issues[1].message).toContain('"count"');
113
+ });
114
+
115
+ it("does not warn for rollup keys that are declared", () => {
116
+ const def: ReportDefinition = {
117
+ name: "rollup-ok",
118
+ label: "Rollup OK",
119
+ params: [],
120
+ sources: {
121
+ parents: { query: "SELECT name FROM groups" },
122
+ kids: { query: "SELECT name FROM items" },
123
+ },
124
+ tree: {
125
+ source: "parents",
126
+ levelName: "group",
127
+ columns: [
128
+ { name: "name" },
129
+ { name: "total", header: "Total", format: "currency" },
130
+ ],
131
+ rollup: (children) => ({
132
+ total: children.item.reduce((s) => s + 1, 0),
133
+ }),
134
+ children: [
135
+ {
136
+ source: "kids",
137
+ levelName: "item",
138
+ columns: [{ name: "name" }],
139
+ },
140
+ ],
141
+ },
142
+ };
143
+
144
+ expect(checkReportDefinition(def)).toEqual([]);
145
+ });
146
+
147
+ it("detects footer keys not declared in columns[]", () => {
148
+ const def: ReportDefinition = {
149
+ name: "footer-warn",
150
+ label: "Footer Warn",
151
+ params: [],
152
+ sources: {
153
+ items: { query: "SELECT name FROM items" },
154
+ },
155
+ tree: {
156
+ source: "items",
157
+ levelName: "item",
158
+ columns: [{ name: "name" }],
159
+ footer: [
160
+ {
161
+ label: "Grand Total",
162
+ compute: () => ({
163
+ grand_total: 0,
164
+ extra: 0,
165
+ }),
166
+ },
167
+ ],
168
+ },
169
+ };
170
+
171
+ const issues = checkReportDefinition(def);
172
+ expect(issues).toHaveLength(2);
173
+ expect(issues[0].path).toBe('item.footer["Grand Total"]');
174
+ expect(issues[0].message).toContain('"grand_total"');
175
+ expect(issues[1].message).toContain('"extra"');
176
+ });
177
+
178
+ it("does not warn for footer keys that are declared", () => {
179
+ const def: ReportDefinition = {
180
+ name: "footer-ok",
181
+ label: "Footer OK",
182
+ params: [],
183
+ sources: {
184
+ items: { query: "SELECT name, amount FROM items" },
185
+ },
186
+ tree: {
187
+ source: "items",
188
+ levelName: "item",
189
+ columns: [
190
+ { name: "name" },
191
+ { name: "amount", header: "Amount" },
192
+ ],
193
+ footer: [
194
+ {
195
+ label: "Total",
196
+ compute: () => ({ amount: 0 }),
197
+ },
198
+ ],
199
+ },
200
+ };
201
+
202
+ expect(checkReportDefinition(def)).toEqual([]);
203
+ });
204
+
205
+ it("detects multiple issues across the tree", () => {
206
+ const def: ReportDefinition = {
207
+ name: "multi-issue",
208
+ label: "Multi Issue",
209
+ params: [],
210
+ sources: {
211
+ sections: { query: "SELECT section FROM sections" },
212
+ // "accounts" source is missing
213
+ },
214
+ tree: {
215
+ source: "sections",
216
+ levelName: "section",
217
+ columns: [{ name: "section" }],
218
+ rollup: (children) => ({
219
+ section_total: children.accounts?.reduce((s) => s, 0) ?? 0,
220
+ }),
221
+ footer: [
222
+ {
223
+ label: "Net",
224
+ compute: () => ({ section_total: 0 }),
225
+ },
226
+ ],
227
+ children: [
228
+ {
229
+ source: "accounts",
230
+ levelName: "accounts",
231
+ columns: [{ name: "name" }],
232
+ },
233
+ ],
234
+ },
235
+ };
236
+
237
+ const issues = checkReportDefinition(def);
238
+ // Should have: rollup undeclared key, footer undeclared key, child missing source
239
+ expect(issues.length).toBe(3);
240
+
241
+ const rollupIssue = issues.find((i) => i.path.includes("rollup"));
242
+ expect(rollupIssue).toBeDefined();
243
+ expect(rollupIssue!.message).toContain('"section_total"');
244
+
245
+ const footerIssue = issues.find((i) => i.path.includes("footer"));
246
+ expect(footerIssue).toBeDefined();
247
+ expect(footerIssue!.message).toContain('"section_total"');
248
+
249
+ const sourceIssue = issues.find((i) => i.path === "section.accounts");
250
+ expect(sourceIssue).toBeDefined();
251
+ expect(sourceIssue!.message).toContain("accounts");
252
+ });
253
+
254
+ it("validates the balance-sheet pattern (valid)", () => {
255
+ const def: ReportDefinition = {
256
+ name: "balance-sheet",
257
+ label: "Balance Sheet",
258
+ params: [
259
+ { name: "as_of_date", type: "date", required: true, label: "As of Date" },
260
+ ],
261
+ sources: {
262
+ sections: { query: "SELECT section FROM sections" },
263
+ section_accounts: { query: "SELECT name, balance FROM accounts WHERE section = $section" },
264
+ },
265
+ tree: {
266
+ source: "sections",
267
+ levelName: "section",
268
+ columns: [
269
+ { name: "section", header: "Section" },
270
+ { name: "section_total", header: "Total", format: "currency" },
271
+ ],
272
+ rollup: (children) => ({
273
+ section_total: children.accounts.reduce(
274
+ (s, n) => s + Number(n.columns.balance ?? 0),
275
+ 0,
276
+ ),
277
+ }),
278
+ footer: [
279
+ {
280
+ label: "Total",
281
+ compute: (nodes) => ({
282
+ section_total: nodes.reduce(
283
+ (s, n) => s + Number(n.rollup?.section_total ?? 0),
284
+ 0,
285
+ ),
286
+ }),
287
+ },
288
+ ],
289
+ children: [
290
+ {
291
+ source: "section_accounts",
292
+ levelName: "accounts",
293
+ columns: [
294
+ { name: "name", header: "Account" },
295
+ { name: "balance", header: "Balance", format: "currency" },
296
+ ],
297
+ },
298
+ ],
299
+ },
300
+ };
301
+
302
+ expect(checkReportDefinition(def)).toEqual([]);
303
+ });
304
+
305
+ it("handles rollup that throws with empty children gracefully", () => {
306
+ const def: ReportDefinition = {
307
+ name: "rollup-throws",
308
+ label: "Rollup Throws",
309
+ params: [],
310
+ sources: {
311
+ parents: { query: "SELECT name FROM groups" },
312
+ kids: { query: "SELECT name FROM items" },
313
+ },
314
+ tree: {
315
+ source: "parents",
316
+ levelName: "group",
317
+ columns: [{ name: "name" }],
318
+ rollup: (children) => {
319
+ // This will throw because children.item[0] is undefined
320
+ return { total: children.item[0].columns.amount as number };
321
+ },
322
+ children: [
323
+ {
324
+ source: "kids",
325
+ levelName: "item",
326
+ columns: [{ name: "name" }],
327
+ },
328
+ ],
329
+ },
330
+ };
331
+
332
+ // Should not throw — just silently skip the rollup check
333
+ const issues = checkReportDefinition(def);
334
+ // Only the items that could be checked are reported; no crash
335
+ expect(issues).toEqual([]);
336
+ });
337
+
338
+ it("handles footer compute that throws with empty input gracefully", () => {
339
+ const def: ReportDefinition = {
340
+ name: "footer-throws",
341
+ label: "Footer Throws",
342
+ params: [],
343
+ sources: {
344
+ items: { query: "SELECT name FROM items" },
345
+ },
346
+ tree: {
347
+ source: "items",
348
+ levelName: "item",
349
+ columns: [{ name: "name" }],
350
+ footer: [
351
+ {
352
+ label: "Problematic",
353
+ compute: (nodes) => {
354
+ // This will throw when nodes is empty
355
+ return { total: nodes[0].columns.amount as number };
356
+ },
357
+ },
358
+ ],
359
+ },
360
+ };
361
+
362
+ // Should not throw — gracefully skip
363
+ const issues = checkReportDefinition(def);
364
+ expect(issues).toEqual([]);
365
+ });
366
+ });
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // DB-aware column checks (checkReportSqlColumns)
370
+ // ---------------------------------------------------------------------------
371
+
372
+ /**
373
+ * Create a CheckSql adapter from better-sqlite3. The CheckSql interface
374
+ * requires result arrays with a `.columns` property containing column
375
+ * metadata. We use .prepare().columns() to discover column names, then
376
+ * attach them to the result array.
377
+ */
378
+ function createCheckSql(sqlite: Database.Database): CheckSql {
379
+ return {
380
+ unsafe: (query: string, params?: unknown[]) => {
381
+ const stmt = sqlite.prepare(query);
382
+ const rows = stmt.all(...(params ?? [])) as any;
383
+ // Attach column metadata matching the CheckSql contract
384
+ rows.columns = stmt.columns().map((c: any) => ({ name: c.name }));
385
+ return Promise.resolve(rows);
386
+ },
387
+ };
388
+ }
389
+
390
+ describe("checkReportSqlColumns", () => {
391
+ let sqlite: Database.Database;
392
+ let sql: CheckSql;
393
+
394
+ beforeEach(() => {
395
+ sqlite = new Database(":memory:");
396
+ sqlite.pragma("foreign_keys = ON");
397
+ sql = createCheckSql(sqlite);
398
+ sqlite.exec(`
399
+ CREATE TABLE items (
400
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
401
+ name TEXT NOT NULL,
402
+ amount REAL NOT NULL,
403
+ category TEXT
404
+ )
405
+ `);
406
+ sqlite.exec(`
407
+ CREATE TABLE sections (
408
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
409
+ section TEXT NOT NULL
410
+ )
411
+ `);
412
+ });
413
+
414
+ afterEach(() => {
415
+ sqlite.close();
416
+ });
417
+
418
+ it("no issues for valid SQL with undeclared columns (no longer flagged)", async () => {
419
+ const def: ReportDefinition = {
420
+ name: "undeclared-ok",
421
+ label: "Undeclared OK",
422
+ params: [],
423
+ sources: {
424
+ items: { query: "SELECT id, name, amount, category FROM items" },
425
+ },
426
+ tree: {
427
+ source: "items",
428
+ levelName: "item",
429
+ columns: [{ name: "name" }],
430
+ transform: (nodes) => nodes,
431
+ },
432
+ };
433
+
434
+ const issues = await checkReportSqlColumns(sql, def);
435
+ expect(issues).toEqual([]);
436
+ });
437
+
438
+ it("validates valid SQL passes planning", async () => {
439
+ const def: ReportDefinition = {
440
+ name: "valid-sql",
441
+ label: "Valid SQL",
442
+ params: [],
443
+ sources: {
444
+ items: { query: "SELECT name, amount FROM items" },
445
+ },
446
+ tree: {
447
+ source: "items",
448
+ levelName: "item",
449
+ columns: [{ name: "name" }, { name: "amount" }],
450
+ },
451
+ };
452
+
453
+ const issues = await checkReportSqlColumns(sql, def);
454
+ expect(issues).toEqual([]);
455
+ });
456
+
457
+ it("catches SQL planning errors", async () => {
458
+ const def: ReportDefinition = {
459
+ name: "bad-planning",
460
+ label: "Bad Planning",
461
+ params: [],
462
+ sources: {
463
+ missing: { query: "SELECT * FROM nonexistent_table" },
464
+ },
465
+ tree: {
466
+ source: "missing",
467
+ levelName: "item",
468
+ columns: [{ name: "name" }],
469
+ },
470
+ };
471
+
472
+ const issues = await checkReportSqlColumns(sql, def);
473
+ expect(issues).toHaveLength(1);
474
+ expect(issues[0].message).toContain("SQL planning failed");
475
+ expect(issues[0].message).toContain("nonexistent_table");
476
+ });
477
+
478
+ it("handles bind variables (replaced with NULLs for planning)", async () => {
479
+ const def: ReportDefinition = {
480
+ name: "bind-vars",
481
+ label: "Bind Vars",
482
+ params: [],
483
+ sources: {
484
+ items: {
485
+ query: "SELECT name, amount FROM items WHERE category = $cat AND amount > $min",
486
+ },
487
+ },
488
+ tree: {
489
+ source: "items",
490
+ levelName: "item",
491
+ columns: [{ name: "name" }],
492
+ },
493
+ };
494
+
495
+ // Valid SQL with bind variables should pass planning
496
+ const issues = await checkReportSqlColumns(sql, def);
497
+ expect(issues).toEqual([]);
498
+ });
499
+ });