@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.
- package/package.json +40 -0
- package/src/actions/action.test.ts +108 -0
- package/src/actions/action.ts +60 -0
- package/src/actions/loader.ts +47 -0
- package/src/api/actions.ts +124 -0
- package/src/api/meta-mutations.ts +922 -0
- package/src/api/meta.ts +222 -0
- package/src/api/reports.ts +98 -0
- package/src/api/server.ts +24 -0
- package/src/api/tables.ts +108 -0
- package/src/api/views.ts +44 -0
- package/src/boot.ts +206 -0
- package/src/cli/ai-commands.ts +220 -0
- package/src/cli/check.ts +169 -0
- package/src/cli/cli-utils.test.ts +313 -0
- package/src/cli/describe.test.ts +151 -0
- package/src/cli/describe.ts +88 -0
- package/src/cli/emit-result.test.ts +160 -0
- package/src/cli/format.ts +150 -0
- package/src/cli/http-client.ts +55 -0
- package/src/cli/index.ts +162 -0
- package/src/cli/init.ts +35 -0
- package/src/cli/project-context.ts +38 -0
- package/src/cli/request.ts +146 -0
- package/src/cli/routes.ts +418 -0
- package/src/cli/rows-insert-master-detail.test.ts +124 -0
- package/src/cli/rows-insert-master-detail.ts +186 -0
- package/src/cli/rows-insert.test.ts +137 -0
- package/src/cli/rows-insert.ts +97 -0
- package/src/cli/serve-single.ts +49 -0
- package/src/create-project.ts +81 -0
- package/src/data/count.ts +62 -0
- package/src/data/crud.test.ts +188 -0
- package/src/data/crud.ts +242 -0
- package/src/data/lookup.test.ts +96 -0
- package/src/data/lookup.ts +104 -0
- package/src/data/query-parser.test.ts +67 -0
- package/src/data/query-parser.ts +106 -0
- package/src/data/sanitize.test.ts +57 -0
- package/src/data/sanitize.ts +25 -0
- package/src/data/save-pipeline.test.ts +115 -0
- package/src/data/save-pipeline.ts +93 -0
- package/src/data/validate.test.ts +110 -0
- package/src/data/validate.ts +98 -0
- package/src/db/errors.ts +20 -0
- package/src/db/logger.ts +63 -0
- package/src/db/sqlite-connection.test.ts +59 -0
- package/src/db/sqlite-connection.ts +79 -0
- package/src/index.ts +111 -0
- package/src/integration/api-actions.test.ts +60 -0
- package/src/integration/api-global.test.ts +21 -0
- package/src/integration/api-meta.test.ts +252 -0
- package/src/integration/api-reports.test.ts +77 -0
- package/src/integration/api-tables.test.ts +238 -0
- package/src/integration/api-views.test.ts +39 -0
- package/src/integration/cli-routes.test.ts +167 -0
- package/src/integration/fixtures/actions/create-account.ts +23 -0
- package/src/integration/fixtures/reports/account-list.ts +25 -0
- package/src/integration/fixtures/schema/accounts.ts +21 -0
- package/src/integration/fixtures/schema/audit-log.ts +19 -0
- package/src/integration/fixtures/schema/journal-entries.ts +20 -0
- package/src/integration/fixtures/views/dashboard.tsx +4 -0
- package/src/integration/fixtures/views/settings.tsx +3 -0
- package/src/integration/setup.ts +72 -0
- package/src/introspect/db-helpers.ts +109 -0
- package/src/introspect/describe-all.test.ts +73 -0
- package/src/introspect/describe-all.ts +80 -0
- package/src/introspect/describe.test.ts +65 -0
- package/src/introspect/describe.ts +184 -0
- package/src/introspect/exec.test.ts +103 -0
- package/src/introspect/exec.ts +57 -0
- package/src/introspect/indexes.test.ts +41 -0
- package/src/introspect/indexes.ts +95 -0
- package/src/introspect/inference.ts +98 -0
- package/src/introspect/list-tables.test.ts +40 -0
- package/src/introspect/list-tables.ts +62 -0
- package/src/introspect/query.test.ts +77 -0
- package/src/introspect/query.ts +47 -0
- package/src/introspect/sample.test.ts +67 -0
- package/src/introspect/sample.ts +50 -0
- package/src/introspect/sql-safety.ts +76 -0
- package/src/introspect/sqlite/db-helpers.test.ts +79 -0
- package/src/introspect/sqlite/db-helpers.ts +56 -0
- package/src/introspect/sqlite/describe-all.ts +21 -0
- package/src/introspect/sqlite/describe.test.ts +160 -0
- package/src/introspect/sqlite/describe.ts +185 -0
- package/src/introspect/sqlite/exec.ts +57 -0
- package/src/introspect/sqlite/indexes.test.ts +60 -0
- package/src/introspect/sqlite/indexes.ts +96 -0
- package/src/introspect/sqlite/list-tables.test.ts +100 -0
- package/src/introspect/sqlite/list-tables.ts +67 -0
- package/src/introspect/sqlite/query.ts +49 -0
- package/src/introspect/sqlite/sample.ts +50 -0
- package/src/introspect/table-rename.test.ts +235 -0
- package/src/introspect/table-rename.ts +115 -0
- package/src/introspect/types.ts +95 -0
- package/src/reports/check.test.ts +499 -0
- package/src/reports/check.ts +208 -0
- package/src/reports/engine.test.ts +1465 -0
- package/src/reports/engine.ts +678 -0
- package/src/reports/loader.ts +55 -0
- package/src/reports/report.ts +308 -0
- package/src/reports/sql-bind.ts +161 -0
- package/src/reports/sqlite-bind.test.ts +98 -0
- package/src/reports/sqlite-bind.ts +58 -0
- package/src/reports/sqlite-sql-client.ts +42 -0
- package/src/runtime.ts +3 -0
- package/src/schema/check.ts +90 -0
- package/src/schema/ddl.test.ts +210 -0
- package/src/schema/ddl.ts +180 -0
- package/src/schema/dynamic-builder.ts +297 -0
- package/src/schema/extract.test.ts +261 -0
- package/src/schema/extract.ts +285 -0
- package/src/schema/loader.test.ts +31 -0
- package/src/schema/loader.ts +60 -0
- package/src/schema/metadata-io.test.ts +261 -0
- package/src/schema/metadata-io.ts +161 -0
- package/src/schema/metadata-tables.test.ts +737 -0
- package/src/schema/metadata-tables.ts +341 -0
- package/src/schema/migrate.ts +195 -0
- package/src/schema/normalize-datatype.test.ts +58 -0
- package/src/schema/normalize-datatype.ts +99 -0
- package/src/schema/registry.test.ts +174 -0
- package/src/schema/registry.ts +139 -0
- package/src/schema/reserved.ts +227 -0
- package/src/schema/table.ts +135 -0
- package/src/test-fixtures/schema/accounts.ts +24 -0
- package/src/test-fixtures/schema/not-a-table.ts +6 -0
- package/src/testing/test-utils.ts +44 -0
- package/src/views/loader.test.ts +70 -0
- package/src/views/loader.ts +38 -0
- package/src/views/view.test.ts +121 -0
- package/src/views/view.ts +16 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite DB Helpers — existence checks and query builders
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Low-level database helper functions for SQLite. These replace the Postgres
|
|
6
|
+
// versions which queried information_schema with async postgres.js calls.
|
|
7
|
+
//
|
|
8
|
+
// All functions are synchronous — better-sqlite3 returns results immediately.
|
|
9
|
+
|
|
10
|
+
import type Database from "better-sqlite3";
|
|
11
|
+
import { OperationError, ErrorCode } from "./types.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check whether a table exists in the database.
|
|
15
|
+
* Uses sqlite_master (the authoritative catalog for SQLite).
|
|
16
|
+
*/
|
|
17
|
+
export function tableExists(
|
|
18
|
+
sqlite: Database.Database,
|
|
19
|
+
tableName: string,
|
|
20
|
+
): boolean {
|
|
21
|
+
const row = sqlite
|
|
22
|
+
.prepare(
|
|
23
|
+
`SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?`,
|
|
24
|
+
)
|
|
25
|
+
.get(tableName);
|
|
26
|
+
return row !== undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Check whether a specific column exists in a table.
|
|
31
|
+
* Uses PRAGMA table_info which returns one row per column.
|
|
32
|
+
*/
|
|
33
|
+
export function columnExists(
|
|
34
|
+
sqlite: Database.Database,
|
|
35
|
+
tableName: string,
|
|
36
|
+
columnName: string,
|
|
37
|
+
): boolean {
|
|
38
|
+
const cols = sqlite.pragma(
|
|
39
|
+
`table_info("${tableName}")`,
|
|
40
|
+
) as { name: string }[];
|
|
41
|
+
return cols.some((c) => c.name === columnName);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Return the set of column names for a table.
|
|
46
|
+
* Used to validate payload columns against actual table structure.
|
|
47
|
+
*/
|
|
48
|
+
export function getTableColumns(
|
|
49
|
+
sqlite: Database.Database,
|
|
50
|
+
tableName: string,
|
|
51
|
+
): Set<string> {
|
|
52
|
+
const cols = sqlite.pragma(
|
|
53
|
+
`table_info("${tableName}")`,
|
|
54
|
+
) as { name: string }[];
|
|
55
|
+
return new Set(cols.map((c) => c.name));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Assert that a table exists in the database.
|
|
60
|
+
* Throws TABLE_NOT_FOUND if the table doesn't exist.
|
|
61
|
+
*/
|
|
62
|
+
export function assertTableExists(
|
|
63
|
+
sqlite: Database.Database,
|
|
64
|
+
tableName: string,
|
|
65
|
+
): void {
|
|
66
|
+
if (!tableExists(sqlite, tableName)) {
|
|
67
|
+
throw new OperationError(`Table '${tableName}' not found`, ErrorCode.TABLE_NOT_FOUND);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Validate that all payload columns exist in the target table.
|
|
73
|
+
* Calls assertTableExists first, then checks each payload column against
|
|
74
|
+
* the actual column set. Throws INVALID_COLUMN_NAME on unknown columns.
|
|
75
|
+
*/
|
|
76
|
+
export function validatePayloadColumns(
|
|
77
|
+
sqlite: Database.Database,
|
|
78
|
+
tableName: string,
|
|
79
|
+
payloadColumns: string[],
|
|
80
|
+
): void {
|
|
81
|
+
assertTableExists(sqlite, tableName);
|
|
82
|
+
const dbColumns = getTableColumns(sqlite, tableName);
|
|
83
|
+
const unknown = payloadColumns.filter((c) => !dbColumns.has(c));
|
|
84
|
+
if (unknown.length > 0) {
|
|
85
|
+
throw new OperationError(
|
|
86
|
+
`Unknown column(s) in '${tableName}': ${unknown.join(", ")}`,
|
|
87
|
+
ErrorCode.INVALID_COLUMN_NAME,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build a parameterized INSERT query from a table name and a row object.
|
|
94
|
+
* Column names are double-quoted to handle reserved words.
|
|
95
|
+
*
|
|
96
|
+
* SQLite uses ? positional parameters (not $1, $2, ...) and returns
|
|
97
|
+
* inserted rows via a separate SELECT (SQLite's RETURNING clause
|
|
98
|
+
* requires SQLite 3.35+, which better-sqlite3 ships).
|
|
99
|
+
*/
|
|
100
|
+
export function buildInsertQuery(
|
|
101
|
+
tableName: string,
|
|
102
|
+
row: Record<string, unknown>,
|
|
103
|
+
): { query: string; values: unknown[] } {
|
|
104
|
+
const columns = Object.keys(row);
|
|
105
|
+
const values = Object.values(row);
|
|
106
|
+
const placeholders = columns.map(() => "?");
|
|
107
|
+
const query = `INSERT INTO "${tableName}" (${columns.map((c) => `"${c}"`).join(", ")}) VALUES (${placeholders.join(", ")}) RETURNING *`;
|
|
108
|
+
return { query, values };
|
|
109
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { dbDescribeAll } from "./describe-all.js";
|
|
4
|
+
|
|
5
|
+
describe("db describe-all", () => {
|
|
6
|
+
let sqlite: Database.Database;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
sqlite = new Database(":memory:");
|
|
10
|
+
sqlite.pragma("foreign_keys = ON");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
sqlite.close();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns all tables with their columns", () => {
|
|
18
|
+
sqlite.exec(`
|
|
19
|
+
CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
|
|
20
|
+
CREATE TABLE invoices (id INTEGER PRIMARY KEY AUTOINCREMENT, amount INTEGER);
|
|
21
|
+
`);
|
|
22
|
+
|
|
23
|
+
const result = dbDescribeAll(sqlite);
|
|
24
|
+
|
|
25
|
+
expect(result.ok).toBe(true);
|
|
26
|
+
if (!result.ok) return;
|
|
27
|
+
|
|
28
|
+
// Structured per-table data is in meta.tables
|
|
29
|
+
const tables = result.meta?.tables as any[];
|
|
30
|
+
expect(tables).toHaveLength(2);
|
|
31
|
+
|
|
32
|
+
const tableNames = tables.map((t: any) => t.table_name);
|
|
33
|
+
expect(tableNames).toContain("accounts");
|
|
34
|
+
expect(tableNames).toContain("invoices");
|
|
35
|
+
|
|
36
|
+
// Check columns are populated
|
|
37
|
+
const accounts = tables.find((t: any) => t.table_name === "accounts") as any;
|
|
38
|
+
const colNames = accounts.columns.map((c: any) => c.column_name);
|
|
39
|
+
expect(colNames).toContain("id");
|
|
40
|
+
expect(colNames).toContain("name");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("includes foreign keys in column data", () => {
|
|
44
|
+
sqlite.exec(`
|
|
45
|
+
CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
|
|
46
|
+
CREATE TABLE invoices (
|
|
47
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
48
|
+
account_id INTEGER REFERENCES accounts(id)
|
|
49
|
+
);
|
|
50
|
+
`);
|
|
51
|
+
|
|
52
|
+
const result = dbDescribeAll(sqlite);
|
|
53
|
+
|
|
54
|
+
expect(result.ok).toBe(true);
|
|
55
|
+
if (!result.ok) return;
|
|
56
|
+
|
|
57
|
+
// FK info is embedded in the column descriptions
|
|
58
|
+
const tables = result.meta?.tables as any[];
|
|
59
|
+
const invoices = tables.find((t: any) => t.table_name === "invoices") as any;
|
|
60
|
+
const fkCol = invoices.columns.find((c: any) => c.column_name === "account_id");
|
|
61
|
+
expect(fkCol.foreign_table).toBe("accounts");
|
|
62
|
+
expect(fkCol.foreign_column).toBe("id");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("returns empty when no tables", () => {
|
|
66
|
+
const result = dbDescribeAll(sqlite);
|
|
67
|
+
|
|
68
|
+
expect(result.ok).toBe(true);
|
|
69
|
+
if (!result.ok) return;
|
|
70
|
+
expect(result.data).toHaveLength(0);
|
|
71
|
+
expect(result.meta?.message).toBe("No tables found.");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite Describe All — batch table description
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Calls describeTable() for each table from listTables().
|
|
6
|
+
// No new PRAGMA patterns — just composition of existing primitives.
|
|
7
|
+
|
|
8
|
+
import type Database from "better-sqlite3";
|
|
9
|
+
import type { OperationResult } from "./types.js";
|
|
10
|
+
import { listTables } from "./list-tables.js";
|
|
11
|
+
import { describeTable, type TableDescription } from "./describe.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Describe all user tables in the database.
|
|
15
|
+
* Returns an array of TableDescription objects in alphabetical order.
|
|
16
|
+
*/
|
|
17
|
+
export function describeAllTables(
|
|
18
|
+
sqlite: Database.Database,
|
|
19
|
+
): TableDescription[] {
|
|
20
|
+
const tables = listTables(sqlite);
|
|
21
|
+
return tables.map((t) => describeTable(sqlite, t.name));
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* OperationResult wrapper for CLI/API consumption.
|
|
26
|
+
*
|
|
27
|
+
* Returns flat column array as primary data (for table-mode rendering)
|
|
28
|
+
* and structured per-table objects in meta (for JSON consumers).
|
|
29
|
+
*/
|
|
30
|
+
export function dbDescribeAll(sqlite: Database.Database): OperationResult {
|
|
31
|
+
const descriptions = describeAllTables(sqlite);
|
|
32
|
+
|
|
33
|
+
if (descriptions.length === 0) {
|
|
34
|
+
return { ok: true, data: [], meta: { message: "No tables found." } };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Flat column array with table_name prefix for table-mode rendering
|
|
38
|
+
const flatData: Record<string, unknown>[] = [];
|
|
39
|
+
for (const desc of descriptions) {
|
|
40
|
+
for (const col of desc.columns) {
|
|
41
|
+
flatData.push({
|
|
42
|
+
table_name: desc.name,
|
|
43
|
+
...col,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Structured per-table format for JSON consumers
|
|
49
|
+
const structured = descriptions.map((desc) => ({
|
|
50
|
+
table_name: desc.name,
|
|
51
|
+
columns: desc.columns,
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
// Text output grouping by table
|
|
55
|
+
const lines: string[] = [];
|
|
56
|
+
for (const desc of descriptions) {
|
|
57
|
+
lines.push(`\n── ${desc.name} ──`);
|
|
58
|
+
for (const col of desc.columns) {
|
|
59
|
+
const flags = [];
|
|
60
|
+
if (col.is_primary_key === "YES") flags.push("PK");
|
|
61
|
+
if (col.is_unique === "YES") flags.push("UNIQUE");
|
|
62
|
+
if (col.is_nullable === "NO") flags.push("NOT NULL");
|
|
63
|
+
if (col.column_default) flags.push(`DEFAULT ${col.column_default}`);
|
|
64
|
+
if (col.foreign_table) flags.push(`FK → ${col.foreign_table}.${col.foreign_column}`);
|
|
65
|
+
const flagStr = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
|
66
|
+
lines.push(` ${col.column_name}: ${col.data_type}${flagStr}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
ok: true,
|
|
72
|
+
data: flatData,
|
|
73
|
+
meta: {
|
|
74
|
+
tables: structured,
|
|
75
|
+
textOutput: lines.join("\n"),
|
|
76
|
+
tableOutputHandled: true,
|
|
77
|
+
message: lines.join("\n"),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { dbDescribe } from "./describe.js";
|
|
4
|
+
|
|
5
|
+
describe("db describe", () => {
|
|
6
|
+
let sqlite: Database.Database;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
sqlite = new Database(":memory:");
|
|
10
|
+
sqlite.pragma("foreign_keys = ON");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
sqlite.close();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("describes table columns", () => {
|
|
18
|
+
sqlite.exec(`
|
|
19
|
+
CREATE TABLE accounts (
|
|
20
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
21
|
+
name TEXT NOT NULL,
|
|
22
|
+
balance INTEGER
|
|
23
|
+
)
|
|
24
|
+
`);
|
|
25
|
+
|
|
26
|
+
const result = dbDescribe(sqlite, "accounts");
|
|
27
|
+
|
|
28
|
+
expect(result.ok).toBe(true);
|
|
29
|
+
if (!result.ok) return;
|
|
30
|
+
const columnNames = result.data.map((r) => r.column_name);
|
|
31
|
+
expect(columnNames).toContain("id");
|
|
32
|
+
expect(columnNames).toContain("name");
|
|
33
|
+
expect(columnNames).toContain("balance");
|
|
34
|
+
expect(result.meta?.tableName).toBe("accounts");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("shows foreign keys", () => {
|
|
38
|
+
sqlite.exec(`CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)`);
|
|
39
|
+
sqlite.exec(`
|
|
40
|
+
CREATE TABLE invoices (
|
|
41
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
42
|
+
account_id INTEGER NOT NULL REFERENCES accounts(id),
|
|
43
|
+
amount INTEGER NOT NULL
|
|
44
|
+
)
|
|
45
|
+
`);
|
|
46
|
+
|
|
47
|
+
const result = dbDescribe(sqlite, "invoices");
|
|
48
|
+
|
|
49
|
+
expect(result.ok).toBe(true);
|
|
50
|
+
if (!result.ok) return;
|
|
51
|
+
const fks = result.meta?.foreignKeys as Record<string, unknown>[];
|
|
52
|
+
expect(fks).toHaveLength(1);
|
|
53
|
+
expect(fks[0].column_name).toBe("account_id");
|
|
54
|
+
expect(fks[0].foreign_table).toBe("accounts");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("shows message for nonexistent table", () => {
|
|
58
|
+
const result = dbDescribe(sqlite, "nonexistent");
|
|
59
|
+
|
|
60
|
+
expect(result.ok).toBe(true);
|
|
61
|
+
if (!result.ok) return;
|
|
62
|
+
expect(result.data).toHaveLength(0);
|
|
63
|
+
expect(result.meta?.message).toBe("Table 'nonexistent' not found.");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite Table Description — column metadata via PRAGMA table_info
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Split into a pure transformer (buildColumnDescriptions) and a thin I/O
|
|
6
|
+
// wrapper (describeTable). The pure function can be tested with mock
|
|
7
|
+
// PRAGMA output, without touching any database.
|
|
8
|
+
//
|
|
9
|
+
// SQLite's PRAGMA table_info returns a fundamentally different shape than
|
|
10
|
+
// PostgreSQL's information_schema. Key differences:
|
|
11
|
+
// - Types are affinity-based (TEXT, INTEGER, REAL, BLOB, NUMERIC),
|
|
12
|
+
// not rich types (varchar(255), timestamptz, etc.)
|
|
13
|
+
// - Primary key is indicated by pk > 0, not a separate constraint query
|
|
14
|
+
// - Foreign keys require a separate PRAGMA foreign_key_list() call
|
|
15
|
+
// - No UNIQUE information from table_info (requires index_list + index_info)
|
|
16
|
+
|
|
17
|
+
import type Database from "better-sqlite3";
|
|
18
|
+
import type { OperationResult } from "./types.js";
|
|
19
|
+
|
|
20
|
+
// ─── Raw PRAGMA result types ────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/** Row from PRAGMA table_info("tableName") */
|
|
23
|
+
export interface PragmaTableInfo {
|
|
24
|
+
cid: number;
|
|
25
|
+
name: string;
|
|
26
|
+
type: string;
|
|
27
|
+
notnull: number; // 0 or 1
|
|
28
|
+
dflt_value: string | null;
|
|
29
|
+
pk: number; // 0 = not PK, >0 = PK ordinal position
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Row from PRAGMA foreign_key_list("tableName") */
|
|
33
|
+
export interface PragmaForeignKey {
|
|
34
|
+
id: number;
|
|
35
|
+
seq: number;
|
|
36
|
+
table: string;
|
|
37
|
+
from: string;
|
|
38
|
+
to: string;
|
|
39
|
+
on_update: string;
|
|
40
|
+
on_delete: string;
|
|
41
|
+
match: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── Output types ───────────────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export interface ColumnDescription {
|
|
47
|
+
column_name: string;
|
|
48
|
+
data_type: string;
|
|
49
|
+
is_nullable: string; // "YES" | "NO" — matches Postgres information_schema convention
|
|
50
|
+
column_default: string | null;
|
|
51
|
+
is_primary_key: string; // "YES" | "NO"
|
|
52
|
+
is_unique: string; // "YES" | "NO"
|
|
53
|
+
foreign_table: string | null;
|
|
54
|
+
foreign_column: string | null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface TableDescription {
|
|
58
|
+
name: string;
|
|
59
|
+
columns: ColumnDescription[];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ─── Pure transformer ───────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Transform raw PRAGMA output into a unified column description format.
|
|
66
|
+
*
|
|
67
|
+
* This is the pure, testable core. It maps SQLite's PRAGMA structures into
|
|
68
|
+
* the same shape used by the old Postgres describe.ts, so the CLI and API
|
|
69
|
+
* layers can consume both uniformly.
|
|
70
|
+
*
|
|
71
|
+
* @param pragmaInfo Rows from PRAGMA table_info()
|
|
72
|
+
* @param pragmaFks Rows from PRAGMA foreign_key_list()
|
|
73
|
+
* @param uniqueCols Set of column names that have a UNIQUE index (from describeTable)
|
|
74
|
+
*/
|
|
75
|
+
export function buildColumnDescriptions(
|
|
76
|
+
pragmaInfo: PragmaTableInfo[],
|
|
77
|
+
pragmaFks: PragmaForeignKey[],
|
|
78
|
+
uniqueCols: Set<string> = new Set(),
|
|
79
|
+
): ColumnDescription[] {
|
|
80
|
+
// Build FK lookup: source column name → { table, column }
|
|
81
|
+
// A column can only be the source of one FK in practice, but
|
|
82
|
+
// PRAGMA foreign_key_list can return multi-column FKs (seq > 0).
|
|
83
|
+
// We only handle single-column FKs here (seq === 0).
|
|
84
|
+
const fkMap = new Map<string, { table: string; column: string }>();
|
|
85
|
+
for (const fk of pragmaFks) {
|
|
86
|
+
if (fk.seq === 0) {
|
|
87
|
+
fkMap.set(fk.from, { table: fk.table, column: fk.to });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return pragmaInfo.map((col) => {
|
|
92
|
+
const fk = fkMap.get(col.name);
|
|
93
|
+
return {
|
|
94
|
+
column_name: col.name,
|
|
95
|
+
data_type: col.type || "TEXT", // SQLite allows empty type strings
|
|
96
|
+
is_nullable: col.notnull === 1 ? "NO" : "YES",
|
|
97
|
+
column_default: col.dflt_value,
|
|
98
|
+
is_primary_key: col.pk > 0 ? "YES" : "NO",
|
|
99
|
+
is_unique: uniqueCols.has(col.name) ? "YES" : "NO",
|
|
100
|
+
foreign_table: fk?.table ?? null,
|
|
101
|
+
foreign_column: fk?.column ?? null,
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ─── I/O wrapper ────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Describe a table's structure by querying SQLite PRAGMAs.
|
|
110
|
+
*
|
|
111
|
+
* Gathers column info, foreign keys, and unique constraints from three
|
|
112
|
+
* separate PRAGMAs and merges them via buildColumnDescriptions.
|
|
113
|
+
*/
|
|
114
|
+
export function describeTable(
|
|
115
|
+
sqlite: Database.Database,
|
|
116
|
+
tableName: string,
|
|
117
|
+
): TableDescription {
|
|
118
|
+
const info = sqlite.pragma(`table_info("${tableName}")`) as PragmaTableInfo[];
|
|
119
|
+
const fks = sqlite.pragma(
|
|
120
|
+
`foreign_key_list("${tableName}")`,
|
|
121
|
+
) as PragmaForeignKey[];
|
|
122
|
+
|
|
123
|
+
// Determine which columns have UNIQUE indexes.
|
|
124
|
+
// PRAGMA index_list returns all indexes; we filter for unique ones,
|
|
125
|
+
// then check index_info for single-column indexes to mark them.
|
|
126
|
+
const uniqueCols = new Set<string>();
|
|
127
|
+
const indexes = sqlite.pragma(`index_list("${tableName}")`) as {
|
|
128
|
+
name: string;
|
|
129
|
+
unique: number;
|
|
130
|
+
}[];
|
|
131
|
+
for (const idx of indexes) {
|
|
132
|
+
if (idx.unique === 1) {
|
|
133
|
+
const cols = sqlite.pragma(`index_info("${idx.name}")`) as {
|
|
134
|
+
name: string;
|
|
135
|
+
}[];
|
|
136
|
+
// Only mark single-column unique indexes (multi-column unique
|
|
137
|
+
// constraints are a table-level property, not per-column)
|
|
138
|
+
if (cols.length === 1) {
|
|
139
|
+
uniqueCols.add(cols[0].name);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
name: tableName,
|
|
146
|
+
columns: buildColumnDescriptions(info, fks, uniqueCols),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* OperationResult wrapper for CLI/API consumption.
|
|
152
|
+
*/
|
|
153
|
+
export function dbDescribe(
|
|
154
|
+
sqlite: Database.Database,
|
|
155
|
+
tableName: string,
|
|
156
|
+
): OperationResult {
|
|
157
|
+
const desc = describeTable(sqlite, tableName);
|
|
158
|
+
|
|
159
|
+
if (desc.columns.length === 0) {
|
|
160
|
+
return {
|
|
161
|
+
ok: true,
|
|
162
|
+
data: [],
|
|
163
|
+
meta: { message: `Table '${tableName}' not found.` },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Separate FK info for structured output (same pattern as old Postgres version)
|
|
168
|
+
const fkColumns = desc.columns.filter((c) => c.foreign_table !== null);
|
|
169
|
+
const foreignKeys = fkColumns.map((c) => ({
|
|
170
|
+
column_name: c.column_name,
|
|
171
|
+
foreign_table: c.foreign_table,
|
|
172
|
+
foreign_column: c.foreign_column,
|
|
173
|
+
}));
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
ok: true,
|
|
177
|
+
data: desc.columns as unknown as Record<string, unknown>[],
|
|
178
|
+
meta: {
|
|
179
|
+
message: `Table: ${tableName}\n`,
|
|
180
|
+
tableName,
|
|
181
|
+
foreignKeys,
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { dbExec } from "./exec.js";
|
|
4
|
+
|
|
5
|
+
describe("dbExec", () => {
|
|
6
|
+
let sqlite: Database.Database;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
sqlite = new Database(":memory:");
|
|
10
|
+
sqlite.pragma("foreign_keys = ON");
|
|
11
|
+
sqlite.exec(`
|
|
12
|
+
CREATE TABLE accounts (
|
|
13
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
14
|
+
name TEXT NOT NULL,
|
|
15
|
+
type TEXT NOT NULL
|
|
16
|
+
)
|
|
17
|
+
`);
|
|
18
|
+
// Seed a row for UPDATE/DELETE tests
|
|
19
|
+
sqlite.exec(`INSERT INTO accounts (name, type) VALUES ('Cash', 'asset')`);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
sqlite.close();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("executes INSERT and returns OK with row count", () => {
|
|
27
|
+
const result = dbExec(sqlite, `INSERT INTO accounts (name, type) VALUES ('Revenue', 'revenue')`);
|
|
28
|
+
|
|
29
|
+
expect(result.ok).toBe(true);
|
|
30
|
+
if (!result.ok) return;
|
|
31
|
+
// SQLite dbExec uses .run() which returns changes count, not rows
|
|
32
|
+
expect(result.data).toHaveLength(0);
|
|
33
|
+
expect(result.meta?.rowCount).toBe(1);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("executes UPDATE and returns row count", () => {
|
|
37
|
+
const result = dbExec(sqlite, `UPDATE accounts SET name = 'Petty Cash' WHERE name = 'Cash'`);
|
|
38
|
+
|
|
39
|
+
expect(result.ok).toBe(true);
|
|
40
|
+
if (!result.ok) return;
|
|
41
|
+
expect(result.data).toHaveLength(0);
|
|
42
|
+
expect(result.meta?.rowCount).toBe(1);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("returns OK (0 rows) message for mutations affecting no rows", () => {
|
|
46
|
+
const result = dbExec(sqlite, `UPDATE accounts SET name = 'Petty Cash' WHERE name = 'NonExistent'`);
|
|
47
|
+
|
|
48
|
+
expect(result.ok).toBe(true);
|
|
49
|
+
if (!result.ok) return;
|
|
50
|
+
expect(result.data).toHaveLength(0);
|
|
51
|
+
expect(result.meta?.message).toBe("OK (0 rows)");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("executes DELETE", () => {
|
|
55
|
+
const result = dbExec(sqlite, `DELETE FROM accounts WHERE name = 'Cash'`);
|
|
56
|
+
|
|
57
|
+
expect(result.ok).toBe(true);
|
|
58
|
+
if (!result.ok) return;
|
|
59
|
+
expect(result.meta?.rowCount).toBe(1);
|
|
60
|
+
|
|
61
|
+
// Verify row is gone
|
|
62
|
+
const rows = sqlite.prepare("SELECT * FROM accounts").all();
|
|
63
|
+
expect(rows).toHaveLength(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// -- Dangerous SQL rejection --
|
|
67
|
+
|
|
68
|
+
it("rejects DROP DATABASE", () => {
|
|
69
|
+
expect(() => dbExec(sqlite, "DROP DATABASE sapporta")).toThrow("DROP DATABASE");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rejects TRUNCATE", () => {
|
|
73
|
+
expect(() => dbExec(sqlite, "TRUNCATE accounts")).toThrow("TRUNCATE");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("rejects DROP SCHEMA", () => {
|
|
77
|
+
expect(() => dbExec(sqlite, "DROP SCHEMA public CASCADE")).toThrow("DROP SCHEMA");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// -- Dry-run mode --
|
|
81
|
+
// EXPLAIN QUERY PLAN validates the SQL plan without executing the statement.
|
|
82
|
+
|
|
83
|
+
it("dry-run returns EXPLAIN QUERY PLAN without executing", () => {
|
|
84
|
+
const result = dbExec(
|
|
85
|
+
sqlite,
|
|
86
|
+
`INSERT INTO accounts (name, type) VALUES ('Revenue', 'revenue')`,
|
|
87
|
+
true,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
expect(result.ok).toBe(true);
|
|
91
|
+
if (!result.ok) return;
|
|
92
|
+
expect(result.meta?.dryRun).toBe(true);
|
|
93
|
+
expect(result.meta?.message).toContain("Dry run");
|
|
94
|
+
|
|
95
|
+
// Verify nothing was actually inserted
|
|
96
|
+
const rows = sqlite.prepare("SELECT * FROM accounts").all();
|
|
97
|
+
expect(rows).toHaveLength(1); // Only the seeded row
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("dry-run still rejects dangerous SQL", () => {
|
|
101
|
+
expect(() => dbExec(sqlite, "DROP DATABASE sapporta", true)).toThrow("DROP DATABASE");
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite SQL Proxy — mutating statement execution
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Allows INSERT, UPDATE, DELETE, and DDL statements. Rejects dangerous
|
|
6
|
+
// operations (DROP DATABASE, TRUNCATE).
|
|
7
|
+
//
|
|
8
|
+
// Uses EXPLAIN QUERY PLAN (not EXPLAIN) for dry-run validation since
|
|
9
|
+
// SQLite's EXPLAIN output is bytecode-level and not useful for human
|
|
10
|
+
// consumption. EXPLAIN QUERY PLAN gives a high-level description of
|
|
11
|
+
// the query strategy.
|
|
12
|
+
|
|
13
|
+
import type Database from "better-sqlite3";
|
|
14
|
+
import type { OperationResult } from "./types.js";
|
|
15
|
+
import { rejectDangerousSQL } from "./sql-safety.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Execute a mutating SQL statement against the SQLite database.
|
|
19
|
+
*
|
|
20
|
+
* When dryRun is true, uses EXPLAIN QUERY PLAN to validate syntax
|
|
21
|
+
* without executing. Returns the query plan as data rows.
|
|
22
|
+
*
|
|
23
|
+
* When executing, returns the number of rows changed via info.changes.
|
|
24
|
+
*/
|
|
25
|
+
export function dbExec(
|
|
26
|
+
sqlite: Database.Database,
|
|
27
|
+
rawSql: string,
|
|
28
|
+
dryRun: boolean = false,
|
|
29
|
+
): OperationResult {
|
|
30
|
+
rejectDangerousSQL(rawSql);
|
|
31
|
+
|
|
32
|
+
if (dryRun) {
|
|
33
|
+
const plan = sqlite
|
|
34
|
+
.prepare(`EXPLAIN QUERY PLAN ${rawSql}`)
|
|
35
|
+
.all() as Record<string, unknown>[];
|
|
36
|
+
return {
|
|
37
|
+
ok: true,
|
|
38
|
+
data: plan,
|
|
39
|
+
meta: {
|
|
40
|
+
message: "Dry run: SQL is valid (EXPLAIN QUERY PLAN succeeded)",
|
|
41
|
+
dryRun: true,
|
|
42
|
+
tableOutputHandled: true,
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const info = sqlite.prepare(rawSql).run();
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
ok: true,
|
|
51
|
+
data: [],
|
|
52
|
+
meta: {
|
|
53
|
+
rowCount: info.changes,
|
|
54
|
+
...(info.changes === 0 && { message: "OK (0 rows)" }),
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
}
|