@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,96 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite Index Listing — index metadata via PRAGMA index_list / index_info
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// SQLite equivalent of the Postgres indexes.ts. Uses two PRAGMAs:
|
|
6
|
+
// - index_list("table") → list of indexes with uniqueness flag
|
|
7
|
+
// - index_info("index") → columns in each index
|
|
8
|
+
|
|
9
|
+
import type Database from "better-sqlite3";
|
|
10
|
+
import type { OperationResult } from "../types.js";
|
|
11
|
+
|
|
12
|
+
/** Row from PRAGMA index_list("tableName") */
|
|
13
|
+
interface PragmaIndexList {
|
|
14
|
+
seq: number;
|
|
15
|
+
name: string;
|
|
16
|
+
unique: number; // 0 or 1
|
|
17
|
+
origin: string; // "c" = CREATE INDEX, "u" = UNIQUE constraint, "pk" = PRIMARY KEY
|
|
18
|
+
partial: number; // 0 or 1
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Row from PRAGMA index_info("indexName") */
|
|
22
|
+
interface PragmaIndexInfo {
|
|
23
|
+
seqno: number;
|
|
24
|
+
cid: number;
|
|
25
|
+
name: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface IndexDescription {
|
|
29
|
+
name: string;
|
|
30
|
+
unique: boolean;
|
|
31
|
+
columns: string[];
|
|
32
|
+
origin: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* List all indexes on a table.
|
|
37
|
+
*
|
|
38
|
+
* For each index, queries PRAGMA index_info to resolve column names.
|
|
39
|
+
* The origin field indicates how the index was created:
|
|
40
|
+
* "c" = explicit CREATE INDEX
|
|
41
|
+
* "u" = UNIQUE constraint in CREATE TABLE
|
|
42
|
+
* "pk" = PRIMARY KEY constraint
|
|
43
|
+
*/
|
|
44
|
+
export function describeIndexes(
|
|
45
|
+
sqlite: Database.Database,
|
|
46
|
+
tableName: string,
|
|
47
|
+
): IndexDescription[] {
|
|
48
|
+
const indexes = sqlite.pragma(
|
|
49
|
+
`index_list("${tableName}")`,
|
|
50
|
+
) as PragmaIndexList[];
|
|
51
|
+
|
|
52
|
+
return indexes.map((idx) => {
|
|
53
|
+
const cols = sqlite.pragma(
|
|
54
|
+
`index_info("${idx.name}")`,
|
|
55
|
+
) as PragmaIndexInfo[];
|
|
56
|
+
return {
|
|
57
|
+
name: idx.name,
|
|
58
|
+
unique: idx.unique === 1,
|
|
59
|
+
columns: cols.map((c) => c.name),
|
|
60
|
+
origin: idx.origin,
|
|
61
|
+
};
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* OperationResult wrapper for CLI/API consumption.
|
|
67
|
+
* Matches the interface of the Postgres dbIndexes().
|
|
68
|
+
*/
|
|
69
|
+
export function dbIndexes(
|
|
70
|
+
sqlite: Database.Database,
|
|
71
|
+
tableName: string,
|
|
72
|
+
): OperationResult {
|
|
73
|
+
const indexes = describeIndexes(sqlite, tableName);
|
|
74
|
+
|
|
75
|
+
if (indexes.length === 0) {
|
|
76
|
+
return {
|
|
77
|
+
ok: true,
|
|
78
|
+
data: [],
|
|
79
|
+
meta: { message: `No indexes found for table '${tableName}'.` },
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Flatten to match the Postgres output shape for CLI table rendering
|
|
84
|
+
const data = indexes.map((idx) => ({
|
|
85
|
+
index_name: idx.name,
|
|
86
|
+
columns: idx.columns.join(", "),
|
|
87
|
+
is_unique: idx.unique,
|
|
88
|
+
is_primary: idx.origin === "pk",
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
ok: true,
|
|
93
|
+
data,
|
|
94
|
+
meta: { message: `Indexes on ${tableName}:` },
|
|
95
|
+
};
|
|
96
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { listTables, dbListTables } from "./list-tables.js";
|
|
4
|
+
|
|
5
|
+
describe("listTables", () => {
|
|
6
|
+
let sqlite: Database.Database;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
sqlite = new Database(":memory:");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
sqlite.close();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("returns empty array for empty database", () => {
|
|
17
|
+
const tables = listTables(sqlite);
|
|
18
|
+
expect(tables).toEqual([]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("lists user tables with row counts", () => {
|
|
22
|
+
sqlite.exec(`CREATE TABLE accounts (id INTEGER PRIMARY KEY, name TEXT)`);
|
|
23
|
+
sqlite.exec(`CREATE TABLE entries (id INTEGER PRIMARY KEY, amount REAL)`);
|
|
24
|
+
sqlite.prepare("INSERT INTO accounts (name) VALUES (?)").run("Cash");
|
|
25
|
+
sqlite.prepare("INSERT INTO accounts (name) VALUES (?)").run("Revenue");
|
|
26
|
+
sqlite
|
|
27
|
+
.prepare("INSERT INTO entries (amount) VALUES (?)")
|
|
28
|
+
.run(100);
|
|
29
|
+
|
|
30
|
+
const tables = listTables(sqlite);
|
|
31
|
+
expect(tables).toEqual([
|
|
32
|
+
{ name: "accounts", rowCount: 2 },
|
|
33
|
+
{ name: "entries", rowCount: 1 },
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("excludes sqlite_ internal tables", () => {
|
|
38
|
+
// sqlite_sequence is auto-created when AUTOINCREMENT is used
|
|
39
|
+
sqlite.exec(
|
|
40
|
+
`CREATE TABLE test (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`,
|
|
41
|
+
);
|
|
42
|
+
sqlite.prepare("INSERT INTO test (name) VALUES (?)").run("hello");
|
|
43
|
+
|
|
44
|
+
const tables = listTables(sqlite);
|
|
45
|
+
expect(tables).toHaveLength(1);
|
|
46
|
+
expect(tables[0].name).toBe("test");
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("excludes _litestream_ tables", () => {
|
|
50
|
+
sqlite.exec(`CREATE TABLE _litestream_seq (id INTEGER PRIMARY KEY)`);
|
|
51
|
+
sqlite.exec(`CREATE TABLE real_table (id INTEGER PRIMARY KEY)`);
|
|
52
|
+
|
|
53
|
+
const tables = listTables(sqlite);
|
|
54
|
+
expect(tables).toHaveLength(1);
|
|
55
|
+
expect(tables[0].name).toBe("real_table");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("returns tables sorted alphabetically", () => {
|
|
59
|
+
sqlite.exec(`CREATE TABLE zeta (id INTEGER PRIMARY KEY)`);
|
|
60
|
+
sqlite.exec(`CREATE TABLE alpha (id INTEGER PRIMARY KEY)`);
|
|
61
|
+
sqlite.exec(`CREATE TABLE mu (id INTEGER PRIMARY KEY)`);
|
|
62
|
+
|
|
63
|
+
const names = listTables(sqlite).map((t) => t.name);
|
|
64
|
+
expect(names).toEqual(["alpha", "mu", "zeta"]);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("dbListTables", () => {
|
|
69
|
+
let sqlite: Database.Database;
|
|
70
|
+
|
|
71
|
+
beforeEach(() => {
|
|
72
|
+
sqlite = new Database(":memory:");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
sqlite.close();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns OperationResult with message for empty DB", () => {
|
|
80
|
+
const result = dbListTables(sqlite);
|
|
81
|
+
expect(result.ok).toBe(true);
|
|
82
|
+
if (result.ok) {
|
|
83
|
+
expect(result.data).toEqual([]);
|
|
84
|
+
expect(result.meta?.message).toBe("No tables found.");
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("returns OperationResult with table_name/row_count shape", () => {
|
|
89
|
+
sqlite.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`);
|
|
90
|
+
sqlite.prepare("INSERT INTO users (name) VALUES (?)").run("Alice");
|
|
91
|
+
|
|
92
|
+
const result = dbListTables(sqlite);
|
|
93
|
+
expect(result.ok).toBe(true);
|
|
94
|
+
if (result.ok) {
|
|
95
|
+
expect(result.data).toEqual([
|
|
96
|
+
{ table_name: "users", row_count: 1 },
|
|
97
|
+
]);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite Table Listing — enumerate user tables with row counts
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// SQLite equivalent of the Postgres list-tables.ts. Uses sqlite_master
|
|
6
|
+
// instead of information_schema, and SELECT COUNT(*) for exact row counts
|
|
7
|
+
// instead of pg_stat_get_live_tuples() (which returns estimates).
|
|
8
|
+
//
|
|
9
|
+
// Exact counts are fine for SQLite's dataset sizes. If this ever becomes
|
|
10
|
+
// a bottleneck, we can switch to a stat table or sampling.
|
|
11
|
+
|
|
12
|
+
import type Database from "better-sqlite3";
|
|
13
|
+
import type { OperationResult } from "../types.js";
|
|
14
|
+
|
|
15
|
+
export interface TableInfo {
|
|
16
|
+
name: string;
|
|
17
|
+
rowCount: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* List all user tables in the SQLite database with exact row counts.
|
|
22
|
+
*
|
|
23
|
+
* Excludes:
|
|
24
|
+
* - sqlite_* internal tables (sqlite_sequence, sqlite_stat1, etc.)
|
|
25
|
+
* - _litestream_* tables (Litestream replication metadata)
|
|
26
|
+
*
|
|
27
|
+
* The WHERE filters use NOT LIKE rather than a NOT IN list so we
|
|
28
|
+
* automatically cover any future internal table prefixes.
|
|
29
|
+
*/
|
|
30
|
+
export function listTables(sqlite: Database.Database): TableInfo[] {
|
|
31
|
+
const tables = sqlite
|
|
32
|
+
.prepare(
|
|
33
|
+
`SELECT name FROM sqlite_master
|
|
34
|
+
WHERE type = 'table'
|
|
35
|
+
AND name NOT LIKE 'sqlite_%'
|
|
36
|
+
AND name NOT LIKE '_litestream_%'
|
|
37
|
+
ORDER BY name`,
|
|
38
|
+
)
|
|
39
|
+
.all() as { name: string }[];
|
|
40
|
+
|
|
41
|
+
return tables.map((t) => ({
|
|
42
|
+
name: t.name,
|
|
43
|
+
rowCount: (
|
|
44
|
+
sqlite.prepare(`SELECT COUNT(*) AS cnt FROM "${t.name}"`).get() as {
|
|
45
|
+
cnt: number;
|
|
46
|
+
}
|
|
47
|
+
).cnt,
|
|
48
|
+
}));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* OperationResult wrapper for CLI/API consumption.
|
|
53
|
+
* Matches the interface of the Postgres dbListTables().
|
|
54
|
+
*/
|
|
55
|
+
export function dbListTables(sqlite: Database.Database): OperationResult {
|
|
56
|
+
const rows = listTables(sqlite);
|
|
57
|
+
|
|
58
|
+
if (rows.length === 0) {
|
|
59
|
+
return { ok: true, data: [], meta: { message: "No tables found." } };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Map to the same shape as the Postgres version (table_name, row_count)
|
|
63
|
+
return {
|
|
64
|
+
ok: true,
|
|
65
|
+
data: rows.map((r) => ({ table_name: r.name, row_count: r.rowCount })),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite SQL Proxy — read-only query execution
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// SQLite equivalent of the Postgres query.ts. Same safety checks
|
|
6
|
+
// (requireSelect, rejectDangerousSQL) applied before execution.
|
|
7
|
+
//
|
|
8
|
+
// Key difference: SQLite's LIMIT wrapping uses a bare subquery without
|
|
9
|
+
// the AS alias (SQLite doesn't require subquery aliases).
|
|
10
|
+
|
|
11
|
+
import type Database from "better-sqlite3";
|
|
12
|
+
import type { OperationResult } from "../types.js";
|
|
13
|
+
import { requireSelect, rejectDangerousSQL } from "../sql-safety.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Run a read-only SQL query against the SQLite database.
|
|
17
|
+
* Only SELECT and WITH (CTE) queries are allowed.
|
|
18
|
+
*
|
|
19
|
+
* When limit is specified, wraps the query in a subquery with LIMIT
|
|
20
|
+
* so the database handles row capping efficiently.
|
|
21
|
+
*/
|
|
22
|
+
export function dbQuery(
|
|
23
|
+
sqlite: Database.Database,
|
|
24
|
+
rawSql: string,
|
|
25
|
+
limit?: number,
|
|
26
|
+
): OperationResult {
|
|
27
|
+
requireSelect(rawSql);
|
|
28
|
+
rejectDangerousSQL(rawSql);
|
|
29
|
+
|
|
30
|
+
let effectiveSql = rawSql;
|
|
31
|
+
if (limit !== undefined) {
|
|
32
|
+
effectiveSql = `SELECT * FROM (${rawSql}) LIMIT ${limit}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rows = sqlite.prepare(effectiveSql).all() as Record<
|
|
36
|
+
string,
|
|
37
|
+
unknown
|
|
38
|
+
>[];
|
|
39
|
+
const truncated = limit !== undefined && rows.length >= limit;
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
ok: true,
|
|
43
|
+
data: rows,
|
|
44
|
+
meta: {
|
|
45
|
+
rowCount: rows.length,
|
|
46
|
+
...(truncated && { truncated: true, limit }),
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite Sample Rows — fetch sample data from a table
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Same safety checks as the Postgres version: validates table and column
|
|
6
|
+
// names via isSafeIdentifier() before interpolating into SQL.
|
|
7
|
+
|
|
8
|
+
import type Database from "better-sqlite3";
|
|
9
|
+
import type { OperationResult } from "../types.js";
|
|
10
|
+
import { validateTableName, validateColumnNames } from "../sql-safety.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Return sample rows from a table, optionally selecting specific columns.
|
|
14
|
+
*
|
|
15
|
+
* Table and column names are validated against SQL injection via
|
|
16
|
+
* isSafeIdentifier() before being interpolated into the query string.
|
|
17
|
+
* The limit is passed as a query parameter (not interpolated).
|
|
18
|
+
*/
|
|
19
|
+
export function dbSample(
|
|
20
|
+
sqlite: Database.Database,
|
|
21
|
+
tableName: string,
|
|
22
|
+
limit: number = 5,
|
|
23
|
+
fields?: string[],
|
|
24
|
+
): OperationResult {
|
|
25
|
+
validateTableName(tableName);
|
|
26
|
+
|
|
27
|
+
let selectClause = "*";
|
|
28
|
+
if (fields && fields.length > 0) {
|
|
29
|
+
validateColumnNames(fields);
|
|
30
|
+
selectClause = fields.map((f) => `"${f}"`).join(", ");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const rows = sqlite
|
|
34
|
+
.prepare(`SELECT ${selectClause} FROM "${tableName}" ORDER BY id LIMIT ?`)
|
|
35
|
+
.all(limit) as Record<string, unknown>[];
|
|
36
|
+
|
|
37
|
+
if (rows.length === 0) {
|
|
38
|
+
return {
|
|
39
|
+
ok: true,
|
|
40
|
+
data: [],
|
|
41
|
+
meta: { message: `Table '${tableName}' is empty.` },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
ok: true,
|
|
47
|
+
data: rows,
|
|
48
|
+
meta: { rowCount: rows.length, limit },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { tableRename } from "./table-rename.js";
|
|
4
|
+
import type { SqlClient } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Convert Postgres-style $1, $2 placeholders to SQLite ? placeholders.
|
|
8
|
+
*/
|
|
9
|
+
function pgToSqliteParams(query: string): string {
|
|
10
|
+
return query.replace(/\$\d+/g, "?");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function createTestSql(sqlite: Database.Database): SqlClient {
|
|
14
|
+
function makeSql(db: Database.Database): SqlClient {
|
|
15
|
+
return {
|
|
16
|
+
unsafe: async (query: string, params?: any[]) => {
|
|
17
|
+
const converted = pgToSqliteParams(query);
|
|
18
|
+
const stmt = db.prepare(converted);
|
|
19
|
+
const trimmed = converted.trim().toUpperCase();
|
|
20
|
+
if (
|
|
21
|
+
trimmed.startsWith("SELECT") ||
|
|
22
|
+
trimmed.startsWith("WITH") ||
|
|
23
|
+
trimmed.startsWith("INSERT") && converted.toUpperCase().includes("RETURNING")
|
|
24
|
+
) {
|
|
25
|
+
return stmt.all(...(params ?? [])) as any[];
|
|
26
|
+
}
|
|
27
|
+
stmt.run(...(params ?? []));
|
|
28
|
+
return [] as any[];
|
|
29
|
+
},
|
|
30
|
+
begin: async (fn) => {
|
|
31
|
+
// We can't use db.transaction() with async functions directly.
|
|
32
|
+
// Instead, manually manage BEGIN/COMMIT/ROLLBACK.
|
|
33
|
+
db.exec("BEGIN");
|
|
34
|
+
try {
|
|
35
|
+
const result = await fn(makeSql(db));
|
|
36
|
+
db.exec("COMMIT");
|
|
37
|
+
return result;
|
|
38
|
+
} catch (err) {
|
|
39
|
+
db.exec("ROLLBACK");
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
},
|
|
43
|
+
end: async () => {},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return makeSql(sqlite);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
describe("table rename", () => {
|
|
50
|
+
let sqlite: Database.Database;
|
|
51
|
+
let sql: SqlClient;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
sqlite = new Database(":memory:");
|
|
55
|
+
sqlite.pragma("foreign_keys = ON");
|
|
56
|
+
sql = createTestSql(sqlite);
|
|
57
|
+
|
|
58
|
+
// Create metadata tables
|
|
59
|
+
sqlite.exec(`
|
|
60
|
+
CREATE TABLE _sapporta_tables (
|
|
61
|
+
name TEXT PRIMARY KEY,
|
|
62
|
+
label TEXT,
|
|
63
|
+
display_column TEXT,
|
|
64
|
+
immutable INTEGER NOT NULL DEFAULT 0,
|
|
65
|
+
inferred INTEGER NOT NULL DEFAULT 0,
|
|
66
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
67
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
68
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
69
|
+
)
|
|
70
|
+
`);
|
|
71
|
+
sqlite.exec(`
|
|
72
|
+
CREATE TABLE _sapporta_columns (
|
|
73
|
+
table_name TEXT NOT NULL REFERENCES _sapporta_tables(name) ON DELETE CASCADE,
|
|
74
|
+
column_name TEXT NOT NULL,
|
|
75
|
+
column_type TEXT NOT NULL DEFAULT 'text',
|
|
76
|
+
position INTEGER NOT NULL DEFAULT 0,
|
|
77
|
+
not_null INTEGER NOT NULL DEFAULT 0,
|
|
78
|
+
is_unique INTEGER NOT NULL DEFAULT 0,
|
|
79
|
+
default_value TEXT,
|
|
80
|
+
references_table TEXT,
|
|
81
|
+
select_options TEXT,
|
|
82
|
+
ui_hints TEXT NOT NULL DEFAULT '{}',
|
|
83
|
+
PRIMARY KEY (table_name, column_name)
|
|
84
|
+
)
|
|
85
|
+
`);
|
|
86
|
+
|
|
87
|
+
// Create a UI-managed table
|
|
88
|
+
sqlite.exec(`
|
|
89
|
+
CREATE TABLE untitled_1 (
|
|
90
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
91
|
+
name TEXT,
|
|
92
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
93
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
94
|
+
)
|
|
95
|
+
`);
|
|
96
|
+
sqlite.exec(`
|
|
97
|
+
INSERT INTO _sapporta_tables (name, label) VALUES ('untitled_1', 'Untitled 1')
|
|
98
|
+
`);
|
|
99
|
+
sqlite.exec(`
|
|
100
|
+
INSERT INTO _sapporta_columns (table_name, column_name, column_type, position)
|
|
101
|
+
VALUES ('untitled_1', 'name', 'text', 1)
|
|
102
|
+
`);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
sqlite.close();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("renames table + metadata atomically", async () => {
|
|
110
|
+
const result = await tableRename(sql, "untitled_1", "targets");
|
|
111
|
+
|
|
112
|
+
expect(result.ok).toBe(true);
|
|
113
|
+
if (!result.ok) return;
|
|
114
|
+
expect(result.data[0]).toEqual({
|
|
115
|
+
old_name: "untitled_1",
|
|
116
|
+
new_name: "targets",
|
|
117
|
+
label: "Targets",
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Table was renamed
|
|
121
|
+
const tables = sqlite
|
|
122
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'targets'`)
|
|
123
|
+
.all();
|
|
124
|
+
expect(tables).toHaveLength(1);
|
|
125
|
+
|
|
126
|
+
// Old table is gone
|
|
127
|
+
const oldTables = sqlite
|
|
128
|
+
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'untitled_1'`)
|
|
129
|
+
.all();
|
|
130
|
+
expect(oldTables).toHaveLength(0);
|
|
131
|
+
|
|
132
|
+
// Metadata updated
|
|
133
|
+
const meta = sqlite
|
|
134
|
+
.prepare(`SELECT name, label FROM _sapporta_tables WHERE name = 'targets'`)
|
|
135
|
+
.all() as any[];
|
|
136
|
+
expect(meta).toHaveLength(1);
|
|
137
|
+
expect(meta[0].label).toBe("Targets");
|
|
138
|
+
|
|
139
|
+
// Old metadata gone
|
|
140
|
+
const oldMeta = sqlite
|
|
141
|
+
.prepare(`SELECT name FROM _sapporta_tables WHERE name = 'untitled_1'`)
|
|
142
|
+
.all();
|
|
143
|
+
expect(oldMeta).toHaveLength(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("updates _sapporta_columns.table_name", async () => {
|
|
147
|
+
await tableRename(sql, "untitled_1", "targets");
|
|
148
|
+
|
|
149
|
+
const cols = sqlite
|
|
150
|
+
.prepare(`SELECT table_name, column_name FROM _sapporta_columns WHERE table_name = 'targets'`)
|
|
151
|
+
.all() as any[];
|
|
152
|
+
expect(cols).toHaveLength(1);
|
|
153
|
+
expect(cols[0].column_name).toBe("name");
|
|
154
|
+
|
|
155
|
+
// No columns left for old name
|
|
156
|
+
const oldCols = sqlite
|
|
157
|
+
.prepare(`SELECT * FROM _sapporta_columns WHERE table_name = 'untitled_1'`)
|
|
158
|
+
.all();
|
|
159
|
+
expect(oldCols).toHaveLength(0);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("updates _sapporta_columns.references_table for FK columns pointing to renamed table", async () => {
|
|
163
|
+
// Create another table that references untitled_1
|
|
164
|
+
sqlite.exec(`
|
|
165
|
+
CREATE TABLE tasks (
|
|
166
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
167
|
+
target_id INTEGER,
|
|
168
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
169
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
170
|
+
)
|
|
171
|
+
`);
|
|
172
|
+
sqlite.exec(`
|
|
173
|
+
INSERT INTO _sapporta_tables (name, label) VALUES ('tasks', 'Tasks')
|
|
174
|
+
`);
|
|
175
|
+
sqlite.exec(`
|
|
176
|
+
INSERT INTO _sapporta_columns (table_name, column_name, column_type, references_table, position)
|
|
177
|
+
VALUES ('tasks', 'target_id', 'link', 'untitled_1', 1)
|
|
178
|
+
`);
|
|
179
|
+
|
|
180
|
+
await tableRename(sql, "untitled_1", "targets");
|
|
181
|
+
|
|
182
|
+
// FK reference should now point to 'targets'
|
|
183
|
+
const fk = sqlite
|
|
184
|
+
.prepare(`SELECT references_table FROM _sapporta_columns WHERE table_name = 'tasks' AND column_name = 'target_id'`)
|
|
185
|
+
.all() as any[];
|
|
186
|
+
expect(fk[0].references_table).toBe("targets");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("rejects non-existent table", async () => {
|
|
190
|
+
await expect(tableRename(sql, "no_such_table", "targets")).rejects.toThrow(
|
|
191
|
+
"not found in _sapporta_tables",
|
|
192
|
+
);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("rejects rename to name that already exists", async () => {
|
|
196
|
+
// Create a second table
|
|
197
|
+
sqlite.exec(`
|
|
198
|
+
CREATE TABLE other_table (id INTEGER PRIMARY KEY AUTOINCREMENT)
|
|
199
|
+
`);
|
|
200
|
+
sqlite.exec(`
|
|
201
|
+
INSERT INTO _sapporta_tables (name, label) VALUES ('other_table', 'Other')
|
|
202
|
+
`);
|
|
203
|
+
|
|
204
|
+
await expect(tableRename(sql, "untitled_1", "other_table")).rejects.toThrow(
|
|
205
|
+
"already exists",
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("default label is title-cased from newName", async () => {
|
|
210
|
+
const result = await tableRename(sql, "untitled_1", "sales_targets");
|
|
211
|
+
|
|
212
|
+
expect(result.ok).toBe(true);
|
|
213
|
+
if (!result.ok) return;
|
|
214
|
+
expect(result.data[0].label).toBe("Sales Targets");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it("uses custom label when provided", async () => {
|
|
218
|
+
const result = await tableRename(sql, "untitled_1", "targets", "My Targets");
|
|
219
|
+
|
|
220
|
+
expect(result.ok).toBe(true);
|
|
221
|
+
if (!result.ok) return;
|
|
222
|
+
expect(result.data[0].label).toBe("My Targets");
|
|
223
|
+
|
|
224
|
+
const meta = sqlite
|
|
225
|
+
.prepare(`SELECT label FROM _sapporta_tables WHERE name = 'targets'`)
|
|
226
|
+
.all() as any[];
|
|
227
|
+
expect(meta[0].label).toBe("My Targets");
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("rejects same name", async () => {
|
|
231
|
+
await expect(tableRename(sql, "untitled_1", "untitled_1")).rejects.toThrow(
|
|
232
|
+
"same",
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
});
|