@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,59 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { connectProject } from "./sqlite-connection.js";
3
+
4
+ describe("connectProject", () => {
5
+ it("connects to an in-memory database", () => {
6
+ const { sqlite, db } = connectProject(":memory:");
7
+ expect(sqlite).toBeDefined();
8
+ expect(db).toBeDefined();
9
+ sqlite.close();
10
+ });
11
+
12
+ it("sets WAL journal mode", () => {
13
+ const { sqlite } = connectProject(":memory:");
14
+ // WAL mode returns "memory" for :memory: databases (WAL requires a file),
15
+ // but the pragma call itself must not throw.
16
+ const mode = sqlite.pragma("journal_mode") as { journal_mode: string }[];
17
+ expect(mode[0].journal_mode).toMatch(/wal|memory/);
18
+ sqlite.close();
19
+ });
20
+
21
+ it("enables foreign keys", () => {
22
+ const { sqlite } = connectProject(":memory:");
23
+ const fk = sqlite.pragma("foreign_keys") as { foreign_keys: number }[];
24
+ expect(fk[0].foreign_keys).toBe(1);
25
+ sqlite.close();
26
+ });
27
+
28
+ it("enforces foreign key constraints at runtime", () => {
29
+ const { sqlite } = connectProject(":memory:");
30
+ sqlite.exec(`CREATE TABLE parent (id INTEGER PRIMARY KEY)`);
31
+ sqlite.exec(`CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id))`);
32
+
33
+ // Inserting a child with a non-existent parent should fail
34
+ expect(() => {
35
+ sqlite.prepare("INSERT INTO child (id, parent_id) VALUES (1, 999)").run();
36
+ }).toThrow(/FOREIGN KEY/);
37
+
38
+ sqlite.close();
39
+ });
40
+
41
+ it("closes without error", () => {
42
+ const { sqlite } = connectProject(":memory:");
43
+ expect(() => sqlite.close()).not.toThrow();
44
+ });
45
+
46
+ it("returns a working Drizzle instance", () => {
47
+ const { sqlite, db } = connectProject(":memory:");
48
+ sqlite.exec("CREATE TABLE test (id INTEGER PRIMARY KEY, name TEXT)");
49
+ sqlite.prepare("INSERT INTO test (id, name) VALUES (1, 'hello')").run();
50
+
51
+ // Verify Drizzle can query via sql.raw
52
+ const { sql } = require("drizzle-orm");
53
+ const rows = db.all(sql.raw("SELECT * FROM test"));
54
+ expect(rows).toHaveLength(1);
55
+ expect(rows[0]).toMatchObject({ id: 1, name: "hello" });
56
+
57
+ sqlite.close();
58
+ });
59
+ });
@@ -0,0 +1,79 @@
1
+ // ============================================================================
2
+ // SQLite Connection — project database connection for SQLite-backed projects
3
+ // ============================================================================
4
+ //
5
+ // Each Sapporta project gets its own SQLite database file. This module creates
6
+ // the connection with optimized PRAGMAs for a multi-reader, single-writer
7
+ // web application workload.
8
+ //
9
+ // Design: No abstraction layer — better-sqlite3's Database type is used
10
+ // directly throughout the codebase. The synchronous API naturally serializes
11
+ // writes, eliminating SQLITE_BUSY contention that plagues async drivers.
12
+ // If a different driver is ever needed, refactor then (YAGNI).
13
+ //
14
+ // The Drizzle ORM wrapper is returned alongside the raw handle because:
15
+ // - Raw handle: PRAGMA introspection, raw SQL, explicit transactions
16
+ // - Drizzle db: typed ORM queries, schema push via drizzle-kit/api
17
+
18
+ import { existsSync } from "node:fs";
19
+ import { dirname, resolve } from "node:path";
20
+ import Database from "better-sqlite3";
21
+ import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
22
+
23
+ export interface ProjectDbConnection {
24
+ /** Raw better-sqlite3 handle — used for PRAGMAs, raw SQL, transactions */
25
+ sqlite: Database.Database;
26
+ /** Drizzle ORM wrapper — used for typed queries and schema push */
27
+ db: BetterSQLite3Database;
28
+ }
29
+
30
+ /**
31
+ * Open a SQLite database and configure it for server workloads.
32
+ *
33
+ * PRAGMA ordering matters:
34
+ * 1. journal_mode=WAL must be set first — it changes the file format and
35
+ * affects how subsequent PRAGMAs interact with the database.
36
+ * 2. foreign_keys=ON must be set per-connection (not persisted in the file),
37
+ * so it runs on every open.
38
+ * 3. The remaining PRAGMAs are performance tuning and order-independent.
39
+ *
40
+ * @param filepath Path to the .sqlite file, or ":memory:" for tests.
41
+ */
42
+ export function connectProject(filepath: string): ProjectDbConnection {
43
+ if (filepath !== ":memory:") {
44
+ const dir = dirname(resolve(filepath));
45
+ if (!existsSync(dir)) {
46
+ throw new Error(
47
+ `Cannot open database "${resolve(filepath)}": parent directory "${dir}" does not exist`,
48
+ );
49
+ }
50
+ }
51
+
52
+ const sqlite = new Database(filepath);
53
+
54
+ // WAL mode: allows concurrent reads while writing. Must be set before
55
+ // any other operations on the database. Persists across connections
56
+ // (stored in the file), but setting it is idempotent.
57
+ sqlite.pragma("journal_mode = WAL");
58
+
59
+ // Busy timeout: wait up to 5 seconds for locks instead of failing
60
+ // immediately. Prevents transient SQLITE_BUSY errors under load.
61
+ sqlite.pragma("busy_timeout = 5000");
62
+
63
+ // NORMAL sync: WAL mode already provides crash safety via the WAL file.
64
+ // NORMAL skips the extra fsync on each commit, trading a tiny risk of
65
+ // losing the last transaction on OS crash for ~2x write throughput.
66
+ sqlite.pragma("synchronous = NORMAL");
67
+
68
+ // Foreign keys: OFF by default in SQLite (historical compatibility).
69
+ // Must be enabled per-connection — not persisted in the database file.
70
+ sqlite.pragma("foreign_keys = ON");
71
+
72
+ // 8MB page cache: SQLite's default is ~2MB. More cache means fewer
73
+ // disk reads for repeated queries against the same pages.
74
+ // Negative value = size in KiB (so -8000 ≈ 8MB).
75
+ sqlite.pragma("cache_size = -8000");
76
+
77
+ const db = drizzle(sqlite);
78
+ return { sqlite, db };
79
+ }
package/src/index.ts ADDED
@@ -0,0 +1,111 @@
1
+ // @sapporta/server - public API
2
+
3
+ // Server
4
+ export { createApp } from "./api/server.js";
5
+
6
+ // Table definition
7
+ export { table } from "./schema/table.js";
8
+ export type {
9
+ TableDef,
10
+ TableOptions,
11
+ SapportaMeta,
12
+ SelectMeta,
13
+ ChildMeta,
14
+ } from "./schema/table.js";
15
+
16
+ // Schema loader
17
+ export { loadSchemas } from "./schema/loader.js";
18
+ export type { SchemaLoadResult } from "./schema/loader.js";
19
+
20
+ // Schema diff / migration
21
+ export { migrateSchemas } from "./schema/migrate.js";
22
+
23
+ // Validation
24
+ export { buildZodSchema, validate } from "./data/validate.js";
25
+ export type { ValidationErrorDetail } from "./data/validate.js";
26
+
27
+ // Save pipeline
28
+ export { savePipeline, insertRow, updateRow } from "./data/save-pipeline.js";
29
+
30
+ // Query parser
31
+ export { parseQuery } from "./data/query-parser.js";
32
+ export type { ParsedQuery } from "./data/query-parser.js";
33
+
34
+ // CRUD
35
+ export { crud, handleList, handleGet, handleCreate, handleUpdate, handleDelete } from "./data/crud.js";
36
+
37
+ // Schema API
38
+ export { schemaApi, extractSchemas, extractSchema } from "./schema/extract.js";
39
+ export type { TableSchema, ColumnSchema, ChildSchema } from "./schema/extract.js";
40
+
41
+ // Lookup
42
+ export { lookupEndpoint, handleLookup, findDisplayColumn } from "./data/lookup.js";
43
+
44
+ // Count
45
+ export { countEndpoint, handleCount } from "./data/count.js";
46
+
47
+ // Schema registry
48
+ export { SchemaRegistry } from "./schema/registry.js";
49
+ export type { RegistryEntry } from "./schema/registry.js";
50
+
51
+ // Tables API (dynamic CRUD routing)
52
+ export { tablesApi } from "./api/tables.js";
53
+
54
+ // Dynamic table builder
55
+ export { buildDrizzleTable, planTable, realizeTable, generateCreateTableDDL, generateAddColumnDDL, sqliteTypeForColumnType, VALID_COLUMN_TYPES } from "./schema/dynamic-builder.js";
56
+ export type { TableMeta, ColumnMetaRow, TableSpec, ColumnSpec } from "./schema/dynamic-builder.js";
57
+
58
+ // Metadata tables (UI-managed table definitions)
59
+ export { ensureMetadataTables, loadUISchemas, validateUISchemas } from "./schema/metadata-tables.js";
60
+
61
+ // Metadata mutation API
62
+ export { metadataApi } from "./api/meta-mutations.js";
63
+
64
+ // Meta API (unified /meta namespace)
65
+ export { metaApi } from "./api/meta.js";
66
+
67
+ // Name validation
68
+ export { validateTableName, validateColumnName } from "./schema/reserved.js";
69
+
70
+ // Actions
71
+ export { action } from "./actions/action.js";
72
+ export type { ActionConfig, ActionInstance } from "./actions/action.js";
73
+ export { loadActions } from "./actions/loader.js";
74
+ export { actionApi } from "./api/actions.js";
75
+
76
+ // Reports
77
+ export { report } from "./reports/report.js";
78
+ export type {
79
+ ReportDefinition,
80
+ ReportParam,
81
+ ReportSource,
82
+ ReportTreeNode,
83
+ ReportOutputNode,
84
+ ReportFooterRow,
85
+ ReportResult,
86
+ ReportSort,
87
+ ReportFooter,
88
+ TransformContext,
89
+ } from "./reports/report.js";
90
+ export { executeReport } from "./reports/engine.js";
91
+ export { loadReports } from "./reports/loader.js";
92
+ export { reportApi } from "./api/reports.js";
93
+
94
+ // Views
95
+ export type { ViewMeta } from "./views/view.js";
96
+ export { loadViewMeta } from "./views/loader.js";
97
+ export { viewApi } from "./api/views.js";
98
+
99
+ // Boot (single-project)
100
+ export { bootProject } from "./boot.js";
101
+ export type { InProcessProject, ProjectConfig } from "./boot.js";
102
+
103
+ // SQLite connection
104
+ export { connectProject } from "./db/sqlite-connection.js";
105
+ export type { ProjectDbConnection } from "./db/sqlite-connection.js";
106
+
107
+ // Errors
108
+ export { ValidationError, ActionError } from "./db/errors.js";
109
+
110
+ // Test utilities
111
+ export { createTestDb } from "./testing/test-utils.js";
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Integration tests for the /actions namespace (single-project mode).
3
+ */
4
+ import { describe, it, expect, beforeAll } from "vitest";
5
+ import { createIntegrationApp, request, postJson } from "./setup.js";
6
+
7
+ beforeAll(async () => {
8
+ await createIntegrationApp();
9
+ });
10
+
11
+ describe("/actions", () => {
12
+ it("GET /actions lists actions with name and inputSchema", async () => {
13
+ const res = await request("/actions");
14
+ expect(res.status).toBe(200);
15
+
16
+ const body = await res.json();
17
+ expect(body.actions).toHaveLength(1);
18
+
19
+ const action = body.actions[0];
20
+ expect(action.name).toBe("create_account");
21
+ expect(action.label).toBe("Create Account");
22
+ expect(action.inputSchema).toBeDefined();
23
+ expect(action.inputSchema.type).toBe("object");
24
+ expect(action.inputSchema.properties).toHaveProperty("name");
25
+ expect(action.inputSchema.properties).toHaveProperty("type");
26
+ });
27
+
28
+ it("POST /actions/create_account with valid input succeeds", async () => {
29
+ const res = await postJson("/actions/create_account", {
30
+ name: "Action Cash",
31
+ type: "asset",
32
+ balance: 5000,
33
+ });
34
+ expect(res.status).toBe(200);
35
+
36
+ const body = await res.json();
37
+ expect(body.data).toBeDefined();
38
+ expect(body.data.name).toBe("Action Cash");
39
+ expect(body.data.type).toBe("asset");
40
+ expect(body.data.balance).toBe(5000);
41
+ expect(body.data.id).toBeGreaterThan(0);
42
+ });
43
+
44
+ it("POST /actions/create_account with invalid input returns 422", async () => {
45
+ const res = await postJson("/actions/create_account", {
46
+ type: "invalid_type",
47
+ });
48
+ expect(res.status).toBe(422);
49
+
50
+ const body = await res.json();
51
+ expect(body.error).toBe("Validation failed");
52
+ expect(body.details).toBeDefined();
53
+ expect(Array.isArray(body.details)).toBe(true);
54
+ });
55
+
56
+ it("POST /actions/nonexistent returns 404", async () => {
57
+ const res = await postJson("/actions/nonexistent", {});
58
+ expect(res.status).toBe(404);
59
+ });
60
+ });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Integration tests for global (non-project-scoped) endpoints.
3
+ *
4
+ * These validate that the entire boot sequence completes successfully
5
+ * and the health endpoint responds correctly.
6
+ */
7
+ import { describe, it, expect, beforeAll } from "vitest";
8
+ import { createIntegrationApp, request } from "./setup.js";
9
+
10
+ beforeAll(async () => {
11
+ await createIntegrationApp();
12
+ });
13
+
14
+ describe("GET /health", () => {
15
+ it("returns 200 with status ok", async () => {
16
+ const res = await request("/health");
17
+ expect(res.status).toBe(200);
18
+ const body = await res.json();
19
+ expect(body).toEqual({ status: "ok" });
20
+ });
21
+ });
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Integration tests for the /meta namespace (single-project mode).
3
+ *
4
+ * The meta namespace composes schema introspection, DB introspection,
5
+ * schema mutations (UI tables), and a SQL proxy.
6
+ */
7
+ import { describe, it, expect, beforeAll } from "vitest";
8
+ import { createIntegrationApp, request, postJson, patchJson, del } from "./setup.js";
9
+
10
+ beforeAll(async () => {
11
+ await createIntegrationApp();
12
+ });
13
+
14
+ describe("/meta", () => {
15
+ // ── Schema introspection ──────────────────────────────────────────
16
+
17
+ describe("schema introspection", () => {
18
+ it("GET /meta/tables lists all tables with structure", async () => {
19
+ const res = await request("/meta/tables");
20
+ expect(res.status).toBe(200);
21
+
22
+ const body = await res.json();
23
+ expect(body.tables).toBeDefined();
24
+ // 3 fixture schemas: accounts, journal_entries, audit_log
25
+ expect(body.tables).toHaveLength(3);
26
+
27
+ const names = body.tables.map((t: any) => t.name).sort();
28
+ expect(names).toEqual(["accounts", "audit_log", "journal_entries"]);
29
+
30
+ const accounts = body.tables.find((t: any) => t.name === "accounts");
31
+ expect(accounts.label).toBe("Accounts");
32
+ expect(accounts.source).toBe("file");
33
+ expect(accounts.columns.length).toBeGreaterThan(0);
34
+ expect(accounts.rowCount).toBeGreaterThanOrEqual(0);
35
+ });
36
+
37
+ it("GET /meta/tables/accounts returns single table with columns and selects", async () => {
38
+ const res = await request("/meta/tables/accounts");
39
+ expect(res.status).toBe(200);
40
+
41
+ const body = await res.json();
42
+ expect(body.name).toBe("accounts");
43
+ expect(body.label).toBe("Accounts");
44
+ expect(body.immutable).toBe(false);
45
+ expect(body.source).toBe("file");
46
+
47
+ const colNames = body.columns.map((c: any) => c.name);
48
+ expect(colNames).toContain("id");
49
+ expect(colNames).toContain("name");
50
+ expect(colNames).toContain("type");
51
+ expect(colNames).toContain("balance");
52
+
53
+ const typeCol = body.columns.find((c: any) => c.name === "type");
54
+ expect(typeCol.select).toBeDefined();
55
+ expect(typeCol.select.options).toEqual(
56
+ ["asset", "liability", "equity", "revenue", "expense"],
57
+ );
58
+ });
59
+
60
+ it("GET /meta/tables/nonexistent returns 404", async () => {
61
+ const res = await request("/meta/tables/nonexistent");
62
+ expect(res.status).toBe(404);
63
+ });
64
+ });
65
+
66
+ // ── DB introspection ──────────────────────────────────────────────
67
+
68
+ describe("DB introspection", () => {
69
+ it("GET /meta/tables/accounts/indexes returns index list", async () => {
70
+ const res = await request("/meta/tables/accounts/indexes");
71
+ expect(res.status).toBe(200);
72
+
73
+ const body = await res.json();
74
+ expect(Array.isArray(body)).toBe(true);
75
+ });
76
+
77
+ it("GET /meta/tables/accounts/sample returns sample rows", async () => {
78
+ await postJson("/tables/accounts", { name: "SampleAccount", type: "asset" });
79
+
80
+ const res = await request("/meta/tables/accounts/sample");
81
+ expect(res.status).toBe(200);
82
+
83
+ const body = await res.json();
84
+ expect(Array.isArray(body)).toBe(true);
85
+ expect(body.length).toBeGreaterThan(0);
86
+ });
87
+
88
+ it("GET /meta/enums returns 404 (SQLite has no native enum type)", async () => {
89
+ const res = await request("/meta/enums");
90
+ expect(res.status).toBe(404);
91
+ });
92
+ });
93
+
94
+ // ── SQL proxy ─────────────────────────────────────────────────────
95
+
96
+ describe("SQL proxy", () => {
97
+ it("POST /meta/sql/query runs a SELECT", async () => {
98
+ await postJson("/tables/accounts", { name: "SQLTestAccount", type: "equity" });
99
+
100
+ const res = await postJson("/meta/sql/query", {
101
+ sql: "SELECT name, type FROM accounts WHERE type = 'equity'",
102
+ });
103
+ expect(res.status).toBe(200);
104
+
105
+ const body = await res.json();
106
+ expect(Array.isArray(body)).toBe(true);
107
+ expect(body.length).toBeGreaterThanOrEqual(1);
108
+ expect(body[0].name).toBe("SQLTestAccount");
109
+ });
110
+
111
+ it("POST /meta/sql/query with INSERT returns 400 SELECT_ONLY", async () => {
112
+ const res = await postJson("/meta/sql/query", {
113
+ sql: "INSERT INTO accounts (name, type) VALUES ('hack', 'asset')",
114
+ });
115
+ expect(res.status).toBe(400);
116
+ });
117
+
118
+ it("POST /meta/sql/exec runs an INSERT", async () => {
119
+ const res = await postJson("/meta/sql/exec", {
120
+ sql: "INSERT INTO accounts (name, type) VALUES ('ExecTest', 'liability')",
121
+ });
122
+ expect(res.status).toBe(200);
123
+
124
+ const check = await postJson("/meta/sql/query", {
125
+ sql: "SELECT name FROM accounts WHERE name = 'ExecTest'",
126
+ });
127
+ const rows = await check.json();
128
+ expect(rows.length).toBe(1);
129
+ });
130
+ });
131
+
132
+ // ── Schema mutations (UI tables) ──────────────────────────────────
133
+
134
+ describe("schema mutations (UI tables)", () => {
135
+ it("POST /meta/tables creates a UI table", async () => {
136
+ const res = await postJson("/meta/tables", {
137
+ name: "ui_contacts",
138
+ label: "Contacts",
139
+ });
140
+ expect(res.status).toBe(201);
141
+
142
+ const body = await res.json();
143
+ expect(body.name).toBe("ui_contacts");
144
+ expect(body.label).toBe("Contacts");
145
+ });
146
+
147
+ it("POST /meta/tables with duplicate name returns 409", async () => {
148
+ const res = await postJson("/meta/tables", {
149
+ name: "ui_contacts",
150
+ label: "Contacts Again",
151
+ });
152
+ expect(res.status).toBe(409);
153
+ });
154
+
155
+ it("PATCH /meta/tables/:name updates a UI table label", async () => {
156
+ const res = await patchJson("/meta/tables/ui_contacts", {
157
+ label: "My Contacts",
158
+ });
159
+ expect(res.status).toBe(200);
160
+
161
+ const body = await res.json();
162
+ expect(body.ok).toBe(true);
163
+ });
164
+
165
+ it("PATCH /meta/tables/accounts (file-managed) returns 409", async () => {
166
+ const res = await patchJson("/meta/tables/accounts", {
167
+ label: "Hacked",
168
+ });
169
+ expect(res.status).toBe(409);
170
+ });
171
+
172
+ it("POST /meta/tables/:name/columns adds a column to a UI table", async () => {
173
+ const res = await postJson("/meta/tables/ui_contacts/columns", {
174
+ column_name: "email",
175
+ column_type: "email",
176
+ });
177
+ expect(res.status).toBe(201);
178
+
179
+ const body = await res.json();
180
+ expect(body.ok).toBe(true);
181
+ expect(body.column).toBe("email");
182
+ });
183
+
184
+ it("DELETE /meta/tables/:name/columns/:col drops a column", async () => {
185
+ const res = await del("/meta/tables/ui_contacts/columns/email");
186
+ expect(res.status).toBe(200);
187
+
188
+ const body = await res.json();
189
+ expect(body.ok).toBe(true);
190
+ expect(body.dropped).toBe("email");
191
+ });
192
+
193
+ it("DELETE /meta/tables/:name without confirm returns 409 with rowCount", async () => {
194
+ await postJson("/meta/sql/exec", {
195
+ sql: "INSERT INTO ui_contacts (id) VALUES (1)",
196
+ });
197
+
198
+ const res = await del("/meta/tables/ui_contacts");
199
+ expect(res.status).toBe(409);
200
+
201
+ const body = await res.json();
202
+ expect(body.rowCount).toBeGreaterThanOrEqual(1);
203
+ });
204
+
205
+ it("DELETE /meta/tables/:name?confirm=true drops the table", async () => {
206
+ const res = await del("/meta/tables/ui_contacts", "confirm=true");
207
+ expect(res.status).toBe(200);
208
+
209
+ const body = await res.json();
210
+ expect(body.ok).toBe(true);
211
+ expect(body.dropped).toBe("ui_contacts");
212
+
213
+ const check = await request("/meta/tables/ui_contacts");
214
+ expect(check.status).toBe(404);
215
+ });
216
+ });
217
+
218
+ // ── Cross-namespace test ──────────────────────────────────────────
219
+
220
+ describe("cross-namespace", () => {
221
+ it("create UI table via /meta, CRUD via /tables, query via /meta/sql", async () => {
222
+ const createRes = await postJson("/meta/tables", {
223
+ name: "ui_tasks",
224
+ label: "Tasks",
225
+ columns: [
226
+ { column_name: "title", column_type: "text" },
227
+ ],
228
+ });
229
+ expect(createRes.status).toBe(201);
230
+
231
+ const insertRes = await postJson("/tables/ui_tasks", {
232
+ title: "Write integration tests",
233
+ });
234
+ expect(insertRes.status).toBe(201);
235
+ const taskId = (await insertRes.json()).data.id;
236
+
237
+ const readRes = await request(`/tables/ui_tasks/${taskId}`);
238
+ expect(readRes.status).toBe(200);
239
+ const readBody = await readRes.json();
240
+ expect(readBody.data.title).toBe("Write integration tests");
241
+
242
+ const sqlRes = await postJson("/meta/sql/query", {
243
+ sql: "SELECT title FROM ui_tasks WHERE id = 1",
244
+ });
245
+ expect(sqlRes.status).toBe(200);
246
+ const sqlRows = await sqlRes.json();
247
+ expect(sqlRows[0].title).toBe("Write integration tests");
248
+
249
+ await del("/meta/tables/ui_tasks", "confirm=true");
250
+ });
251
+ });
252
+ });
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Integration tests for the /reports namespace (single-project mode).
3
+ */
4
+ import { describe, it, expect, beforeAll } from "vitest";
5
+ import { createIntegrationApp, request, postJson } from "./setup.js";
6
+
7
+ beforeAll(async () => {
8
+ await createIntegrationApp();
9
+
10
+ // Seed accounts data for report tests.
11
+ await postJson("/tables/accounts", { name: "Cash", type: "asset", balance: 10000 });
12
+ await postJson("/tables/accounts", { name: "Revenue", type: "revenue", balance: 5000 });
13
+ await postJson("/tables/accounts", { name: "Equipment", type: "asset", balance: 25000 });
14
+ });
15
+
16
+ describe("/reports", () => {
17
+ it("GET /reports lists reports with name, label, params", async () => {
18
+ const res = await request("/reports");
19
+ expect(res.status).toBe(200);
20
+
21
+ const body = await res.json();
22
+ expect(body.reports).toHaveLength(1);
23
+
24
+ const report = body.reports[0];
25
+ expect(report.name).toBe("account-list");
26
+ expect(report.label).toBe("Account List");
27
+ expect(report.params).toHaveLength(1);
28
+ expect(report.params[0].name).toBe("type");
29
+ expect(report.params[0].required).toBe(false);
30
+ });
31
+
32
+ it("GET /reports/account-list returns metadata with columns", async () => {
33
+ const res = await request("/reports/account-list");
34
+ expect(res.status).toBe(200);
35
+
36
+ const body = await res.json();
37
+ expect(body.name).toBe("account-list");
38
+ expect(body.label).toBe("Account List");
39
+ expect(body.columns).toHaveLength(3);
40
+
41
+ const colNames = body.columns.map((c: any) => c.name);
42
+ expect(colNames).toEqual(["name", "type", "balance"]);
43
+ });
44
+
45
+ it("GET /reports/nonexistent returns 404", async () => {
46
+ const res = await request("/reports/nonexistent");
47
+ expect(res.status).toBe(404);
48
+ });
49
+
50
+ it("GET /reports/account-list/results executes the report", async () => {
51
+ const res = await request("/reports/account-list/results");
52
+ expect(res.status).toBe(200);
53
+
54
+ const body = await res.json();
55
+ expect(body.data).toBeDefined();
56
+ expect(body.data).toHaveLength(3);
57
+
58
+ const first = body.data[0];
59
+ expect(first.levelName).toBe("account");
60
+ expect(first.columns).toBeDefined();
61
+ expect(first.columns).toHaveProperty("name");
62
+ expect(first.columns).toHaveProperty("type");
63
+ expect(first.columns).toHaveProperty("balance");
64
+ });
65
+
66
+ it("GET /reports/account-list/results?type=asset returns filtered results", async () => {
67
+ const res = await request("/reports/account-list/results?type=asset");
68
+ expect(res.status).toBe(200);
69
+
70
+ const body = await res.json();
71
+ expect(body.data).toBeDefined();
72
+ expect(body.data).toHaveLength(2);
73
+ for (const node of body.data) {
74
+ expect(node.columns.type).toBe("asset");
75
+ }
76
+ });
77
+ });