@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,41 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { dbIndexes } from "./indexes.js";
|
|
4
|
+
|
|
5
|
+
describe("db indexes", () => {
|
|
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("shows custom indexes", () => {
|
|
18
|
+
sqlite.exec(`
|
|
19
|
+
CREATE TABLE accounts (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
|
|
20
|
+
CREATE UNIQUE INDEX idx_accounts_name ON accounts(name);
|
|
21
|
+
`);
|
|
22
|
+
|
|
23
|
+
const result = dbIndexes(sqlite, "accounts");
|
|
24
|
+
|
|
25
|
+
expect(result.ok).toBe(true);
|
|
26
|
+
if (!result.ok) return;
|
|
27
|
+
const indexNames = result.data.map((r) => r.index_name);
|
|
28
|
+
expect(indexNames).toContain("idx_accounts_name");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("shows message when no indexes", () => {
|
|
32
|
+
const result = dbIndexes(sqlite, "nonexistent");
|
|
33
|
+
|
|
34
|
+
expect(result.ok).toBe(true);
|
|
35
|
+
if (!result.ok) return;
|
|
36
|
+
expect(result.data).toHaveLength(0);
|
|
37
|
+
expect(result.meta?.message).toBe(
|
|
38
|
+
"No indexes found for table 'nonexistent'.",
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite Index Listing — index metadata via PRAGMA index_list / index_info
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Uses two PRAGMAs:
|
|
6
|
+
// - index_list("table") → list of indexes with uniqueness flag and origin
|
|
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
|
+
*/
|
|
68
|
+
export function dbIndexes(
|
|
69
|
+
sqlite: Database.Database,
|
|
70
|
+
tableName: string,
|
|
71
|
+
): OperationResult {
|
|
72
|
+
const indexes = describeIndexes(sqlite, tableName);
|
|
73
|
+
|
|
74
|
+
if (indexes.length === 0) {
|
|
75
|
+
return {
|
|
76
|
+
ok: true,
|
|
77
|
+
data: [],
|
|
78
|
+
meta: { message: `No indexes found for table '${tableName}'.` },
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Flatten to match the output shape for CLI table rendering
|
|
83
|
+
const data = indexes.map((idx) => ({
|
|
84
|
+
index_name: idx.name,
|
|
85
|
+
columns: idx.columns.join(", "),
|
|
86
|
+
is_unique: idx.unique,
|
|
87
|
+
is_primary: idx.origin === "pk",
|
|
88
|
+
}));
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
data,
|
|
93
|
+
meta: { message: `Indexes on ${tableName}:` },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM Inference — infer table and column metadata from sample data.
|
|
3
|
+
*
|
|
4
|
+
* Uses Claude (via nuabase gateway) to analyze the first row of a newly
|
|
5
|
+
* created table and suggest meaningful column names, types, and a table
|
|
6
|
+
* label. This runs once per table after the first row is committed.
|
|
7
|
+
*/
|
|
8
|
+
import { Nua, z } from "nuabase";
|
|
9
|
+
import { logger } from "../db/logger.js";
|
|
10
|
+
|
|
11
|
+
const log = logger.child({ module: "inference" });
|
|
12
|
+
|
|
13
|
+
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
export interface InferenceInput {
|
|
16
|
+
columns: string[];
|
|
17
|
+
rows: Record<string, unknown>[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface InferredColumn {
|
|
21
|
+
name: string;
|
|
22
|
+
header: string;
|
|
23
|
+
type: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface InferenceResult {
|
|
27
|
+
table_label: string;
|
|
28
|
+
columns: Record<string, InferredColumn>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── Output Schema ──────────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
const InferredColumnSchema = z.object({
|
|
34
|
+
name: z.string(),
|
|
35
|
+
header: z.string(),
|
|
36
|
+
type: z.enum([
|
|
37
|
+
"text", "integer", "numeric", "boolean", "date", "timestamp",
|
|
38
|
+
"select", "currency", "percentage", "email", "url",
|
|
39
|
+
]),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const InferenceResultSchema = z.object({
|
|
43
|
+
table_label: z.string(),
|
|
44
|
+
columns: z.record(z.string(), InferredColumnSchema),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ─── Prompt ──────────────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
function buildPrompt(input: InferenceInput): string {
|
|
50
|
+
return `You are analyzing data entered into a spreadsheet. Based on the column names and sample data, infer:
|
|
51
|
+
1. A descriptive table label (like "Contacts", "Products", "Invoices")
|
|
52
|
+
2. For each column that has data, suggest a better column name (snake_case), a display header (Title Case), and a data type.
|
|
53
|
+
|
|
54
|
+
Available data types: text, integer, numeric, boolean, date, timestamp, email, url, currency, percentage
|
|
55
|
+
|
|
56
|
+
Input columns: ${JSON.stringify(input.columns)}
|
|
57
|
+
Sample data: ${JSON.stringify(input.rows)}
|
|
58
|
+
|
|
59
|
+
Rules:
|
|
60
|
+
- Only include columns that have non-null, non-empty data in the output.
|
|
61
|
+
- Column names must be snake_case, lowercase, no spaces.
|
|
62
|
+
- If data looks like an email address (contains @), use type "email".
|
|
63
|
+
- If data looks like a URL (starts with http:// or https://), use type "url".
|
|
64
|
+
- If data is a number with decimals or currency symbol, use "numeric" or "currency".
|
|
65
|
+
- If data is a whole number, use "integer".
|
|
66
|
+
- If data looks like a date (YYYY-MM-DD or similar), use "date".
|
|
67
|
+
- If data is "true"/"false"/"yes"/"no", use "boolean".
|
|
68
|
+
- Default to "text" if unsure.`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── Inference Function ──────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Call the LLM to infer table metadata from sample data.
|
|
75
|
+
*
|
|
76
|
+
* Uses NUABASE_API_KEY from environment. Returns null if the inference
|
|
77
|
+
* fails or returns no columns.
|
|
78
|
+
*/
|
|
79
|
+
export async function inferSchema(input: InferenceInput): Promise<InferenceResult | null> {
|
|
80
|
+
const nua = Nua.gateway({});
|
|
81
|
+
|
|
82
|
+
const result = await nua.get(buildPrompt(input), {
|
|
83
|
+
model: "claude-haiku-4-5-20251001",
|
|
84
|
+
output: { name: "inference", schema: InferenceResultSchema },
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
if (!result.success) {
|
|
88
|
+
log.warn("Inference failed", { error: result.error });
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (Object.keys(result.data.columns).length === 0) {
|
|
93
|
+
log.warn("Inference returned no columns");
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return result.data;
|
|
98
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { dbListTables } from "./list-tables.js";
|
|
4
|
+
|
|
5
|
+
describe("db list-tables", () => {
|
|
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("lists tables in the database", () => {
|
|
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 = dbListTables(sqlite);
|
|
24
|
+
|
|
25
|
+
expect(result.ok).toBe(true);
|
|
26
|
+
if (!result.ok) return;
|
|
27
|
+
const tableNames = result.data.map((r) => r.table_name);
|
|
28
|
+
expect(tableNames).toContain("accounts");
|
|
29
|
+
expect(tableNames).toContain("invoices");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("shows message when no tables exist", () => {
|
|
33
|
+
const result = dbListTables(sqlite);
|
|
34
|
+
|
|
35
|
+
expect(result.ok).toBe(true);
|
|
36
|
+
if (!result.ok) return;
|
|
37
|
+
expect(result.data).toHaveLength(0);
|
|
38
|
+
expect(result.meta?.message).toBe("No tables found.");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite Table Listing — enumerate user tables with row counts
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Uses sqlite_master for table enumeration and SELECT COUNT(*) for exact
|
|
6
|
+
// row counts. Exact counts are fine for SQLite's dataset sizes — if this
|
|
7
|
+
// ever becomes a bottleneck, we can switch to a stat table or sampling.
|
|
8
|
+
//
|
|
9
|
+
// Excludes internal tables:
|
|
10
|
+
// - sqlite_* (sqlite_sequence, sqlite_stat1, etc.)
|
|
11
|
+
// - _litestream_* (Litestream replication metadata)
|
|
12
|
+
|
|
13
|
+
import type Database from "better-sqlite3";
|
|
14
|
+
import type { OperationResult } from "./types.js";
|
|
15
|
+
|
|
16
|
+
export interface TableInfo {
|
|
17
|
+
name: string;
|
|
18
|
+
rowCount: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* List all user tables in the SQLite database with exact row counts.
|
|
23
|
+
*
|
|
24
|
+
* The WHERE filters use NOT LIKE rather than a NOT IN list so we
|
|
25
|
+
* automatically cover any future internal table prefixes.
|
|
26
|
+
*/
|
|
27
|
+
export function listTables(sqlite: Database.Database): TableInfo[] {
|
|
28
|
+
const tables = sqlite
|
|
29
|
+
.prepare(
|
|
30
|
+
`SELECT name FROM sqlite_master
|
|
31
|
+
WHERE type = 'table'
|
|
32
|
+
AND name NOT LIKE 'sqlite_%'
|
|
33
|
+
AND name NOT LIKE '_litestream_%'
|
|
34
|
+
ORDER BY name`,
|
|
35
|
+
)
|
|
36
|
+
.all() as { name: string }[];
|
|
37
|
+
|
|
38
|
+
return tables.map((t) => ({
|
|
39
|
+
name: t.name,
|
|
40
|
+
rowCount: (
|
|
41
|
+
sqlite.prepare(`SELECT COUNT(*) AS cnt FROM "${t.name}"`).get() as {
|
|
42
|
+
cnt: number;
|
|
43
|
+
}
|
|
44
|
+
).cnt,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* OperationResult wrapper for CLI/API consumption.
|
|
50
|
+
*/
|
|
51
|
+
export function dbListTables(sqlite: Database.Database): OperationResult {
|
|
52
|
+
const rows = listTables(sqlite);
|
|
53
|
+
|
|
54
|
+
if (rows.length === 0) {
|
|
55
|
+
return { ok: true, data: [], meta: { message: "No tables found." } };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
data: rows.map((r) => ({ table_name: r.name, row_count: r.rowCount })),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { dbQuery } from "./query.js";
|
|
4
|
+
|
|
5
|
+
describe("db query", () => {
|
|
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 (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, type TEXT NOT NULL);
|
|
13
|
+
INSERT INTO accounts (name, type) VALUES ('Cash', 'asset'), ('Revenue', 'revenue');
|
|
14
|
+
`);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
sqlite.close();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("runs a SELECT query and returns results", () => {
|
|
22
|
+
const result = dbQuery(sqlite, "SELECT id, name FROM accounts ORDER BY id");
|
|
23
|
+
|
|
24
|
+
expect(result.ok).toBe(true);
|
|
25
|
+
if (!result.ok) return;
|
|
26
|
+
expect(result.data).toHaveLength(2);
|
|
27
|
+
expect(result.data[0].name).toBe("Cash");
|
|
28
|
+
expect(result.data[1].name).toBe("Revenue");
|
|
29
|
+
expect(result.meta?.rowCount).toBe(2);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("returns empty data for no results", () => {
|
|
33
|
+
const result = dbQuery(sqlite, "SELECT * FROM accounts WHERE id = 999");
|
|
34
|
+
|
|
35
|
+
expect(result.ok).toBe(true);
|
|
36
|
+
if (!result.ok) return;
|
|
37
|
+
expect(result.data).toHaveLength(0);
|
|
38
|
+
expect(result.meta?.rowCount).toBe(0);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("rejects non-SELECT queries", () => {
|
|
42
|
+
expect(() =>
|
|
43
|
+
dbQuery(sqlite, "INSERT INTO accounts (name, type) VALUES ('x', 'y')"),
|
|
44
|
+
).toThrow("Only SELECT");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("allows WITH (CTE) queries", () => {
|
|
48
|
+
const result = dbQuery(
|
|
49
|
+
sqlite,
|
|
50
|
+
"WITH cte AS (SELECT * FROM accounts) SELECT * FROM cte",
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
expect(result.ok).toBe(true);
|
|
54
|
+
if (!result.ok) return;
|
|
55
|
+
expect(result.data.length).toBeGreaterThan(0);
|
|
56
|
+
expect(result.data[0].name).toBe("Cash");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("respects limit parameter", () => {
|
|
60
|
+
const result = dbQuery(sqlite, "SELECT * FROM accounts ORDER BY id", 1);
|
|
61
|
+
|
|
62
|
+
expect(result.ok).toBe(true);
|
|
63
|
+
if (!result.ok) return;
|
|
64
|
+
expect(result.data).toHaveLength(1);
|
|
65
|
+
expect(result.meta?.truncated).toBe(true);
|
|
66
|
+
expect(result.meta?.limit).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("does not set truncated when under limit", () => {
|
|
70
|
+
const result = dbQuery(sqlite, "SELECT * FROM accounts ORDER BY id", 10);
|
|
71
|
+
|
|
72
|
+
expect(result.ok).toBe(true);
|
|
73
|
+
if (!result.ok) return;
|
|
74
|
+
expect(result.data).toHaveLength(2);
|
|
75
|
+
expect(result.meta?.truncated).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite SQL Proxy — read-only query execution
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Same safety checks (requireSelect, rejectDangerousSQL) applied before
|
|
6
|
+
// execution. SQLite's LIMIT wrapping uses a bare subquery without the AS
|
|
7
|
+
// alias (SQLite doesn't require subquery aliases).
|
|
8
|
+
|
|
9
|
+
import type Database from "better-sqlite3";
|
|
10
|
+
import type { OperationResult } from "./types.js";
|
|
11
|
+
import { requireSelect, rejectDangerousSQL } from "./sql-safety.js";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Run a read-only SQL query against the SQLite database.
|
|
15
|
+
* Only SELECT and WITH (CTE) queries are allowed.
|
|
16
|
+
*
|
|
17
|
+
* When limit is specified, wraps the query in a subquery with LIMIT
|
|
18
|
+
* so the database handles row capping efficiently.
|
|
19
|
+
*/
|
|
20
|
+
export function dbQuery(
|
|
21
|
+
sqlite: Database.Database,
|
|
22
|
+
rawSql: string,
|
|
23
|
+
limit?: number,
|
|
24
|
+
): OperationResult {
|
|
25
|
+
requireSelect(rawSql);
|
|
26
|
+
rejectDangerousSQL(rawSql);
|
|
27
|
+
|
|
28
|
+
let effectiveSql = rawSql;
|
|
29
|
+
if (limit !== undefined) {
|
|
30
|
+
effectiveSql = `SELECT * FROM (${rawSql}) LIMIT ${limit}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const rows = sqlite.prepare(effectiveSql).all() as Record<
|
|
34
|
+
string,
|
|
35
|
+
unknown
|
|
36
|
+
>[];
|
|
37
|
+
const truncated = limit !== undefined && rows.length >= limit;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
ok: true,
|
|
41
|
+
data: rows,
|
|
42
|
+
meta: {
|
|
43
|
+
rowCount: rows.length,
|
|
44
|
+
...(truncated && { truncated: true, limit }),
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import Database from "better-sqlite3";
|
|
3
|
+
import { dbSample } from "./sample.js";
|
|
4
|
+
|
|
5
|
+
describe("db sample", () => {
|
|
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 (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL);
|
|
13
|
+
INSERT INTO accounts (name) VALUES ('A'), ('B'), ('C'), ('D'), ('E'), ('F');
|
|
14
|
+
`);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
sqlite.close();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("shows sample rows with default limit", () => {
|
|
22
|
+
const result = dbSample(sqlite, "accounts");
|
|
23
|
+
|
|
24
|
+
expect(result.ok).toBe(true);
|
|
25
|
+
if (!result.ok) return;
|
|
26
|
+
expect(result.data).toHaveLength(5);
|
|
27
|
+
expect(result.meta?.rowCount).toBe(5);
|
|
28
|
+
expect(result.meta?.limit).toBe(5);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("respects custom limit", () => {
|
|
32
|
+
const result = dbSample(sqlite, "accounts", 2);
|
|
33
|
+
|
|
34
|
+
expect(result.ok).toBe(true);
|
|
35
|
+
if (!result.ok) return;
|
|
36
|
+
expect(result.data).toHaveLength(2);
|
|
37
|
+
expect(result.meta?.rowCount).toBe(2);
|
|
38
|
+
expect(result.meta?.limit).toBe(2);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("shows empty message for empty table", () => {
|
|
42
|
+
sqlite.exec("DELETE FROM accounts");
|
|
43
|
+
|
|
44
|
+
const result = dbSample(sqlite, "accounts");
|
|
45
|
+
|
|
46
|
+
expect(result.ok).toBe(true);
|
|
47
|
+
if (!result.ok) return;
|
|
48
|
+
expect(result.data).toHaveLength(0);
|
|
49
|
+
expect(result.meta?.message).toBe("Table 'accounts' is empty.");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("rejects invalid table names", () => {
|
|
53
|
+
expect(() => dbSample(sqlite, "'; DROP TABLE accounts; --")).toThrow(
|
|
54
|
+
"Invalid table name",
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("selects specific fields with --fields", () => {
|
|
59
|
+
const result = dbSample(sqlite, "accounts", 5, ["name"]);
|
|
60
|
+
|
|
61
|
+
expect(result.ok).toBe(true);
|
|
62
|
+
if (!result.ok) return;
|
|
63
|
+
expect(result.data).toHaveLength(5);
|
|
64
|
+
// Should only have the 'name' column (plus id from ORDER BY isn't added)
|
|
65
|
+
expect(Object.keys(result.data[0])).toEqual(["name"]);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// SQLite Sample Rows — fetch sample data from a table
|
|
3
|
+
// ============================================================================
|
|
4
|
+
//
|
|
5
|
+
// Same safety checks as the old 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,76 @@
|
|
|
1
|
+
import { OperationError, ErrorCode } from "./types.js";
|
|
2
|
+
import { isSafeIdentifier } from "../data/sanitize.js";
|
|
3
|
+
import { rejectControlChars as coreRejectControlChars } from "../data/sanitize.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Reject SQL that isn't a SELECT or WITH (CTE) statement.
|
|
7
|
+
* Throws if the query would mutate data.
|
|
8
|
+
*/
|
|
9
|
+
export function requireSelect(sql: string): void {
|
|
10
|
+
const trimmed = sql.trim().toUpperCase();
|
|
11
|
+
if (!trimmed.startsWith("SELECT") && !trimmed.startsWith("WITH")) {
|
|
12
|
+
throw new OperationError("Only SELECT and WITH (CTE) queries are allowed", ErrorCode.SELECT_ONLY);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Reject dangerous SQL statements that could destroy data or structure.
|
|
18
|
+
*/
|
|
19
|
+
export function rejectDangerousSQL(sql: string): void {
|
|
20
|
+
const trimmed = sql.trim().toUpperCase();
|
|
21
|
+
const dangerous = [
|
|
22
|
+
"DROP DATABASE",
|
|
23
|
+
"TRUNCATE",
|
|
24
|
+
"DROP SCHEMA",
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
for (const pattern of dangerous) {
|
|
28
|
+
if (trimmed.includes(pattern)) {
|
|
29
|
+
throw new OperationError(`Dangerous SQL rejected: contains ${pattern}`, ErrorCode.DANGEROUS_SQL);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Validate that a table name is a safe SQL identifier.
|
|
36
|
+
* Rejects names containing special characters that could enable SQL injection.
|
|
37
|
+
*/
|
|
38
|
+
export function validateTableName(name: string): void {
|
|
39
|
+
if (!isSafeIdentifier(name)) {
|
|
40
|
+
throw new OperationError(
|
|
41
|
+
`Invalid table name: ${name}`,
|
|
42
|
+
ErrorCode.INVALID_TABLE_NAME,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate that all column names in a list are safe identifiers.
|
|
49
|
+
* Prevents SQL injection via hallucinated column names containing
|
|
50
|
+
* special characters like `;`, `?`, `'`, etc.
|
|
51
|
+
*/
|
|
52
|
+
export function validateColumnNames(columns: string[]): void {
|
|
53
|
+
for (const col of columns) {
|
|
54
|
+
if (!isSafeIdentifier(col)) {
|
|
55
|
+
throw new OperationError(
|
|
56
|
+
`Invalid column name: ${col}`,
|
|
57
|
+
ErrorCode.INVALID_COLUMN_NAME,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Reject JSON strings containing control characters.
|
|
65
|
+
* Delegates to core sanitize, wraps in OperationError for error envelope.
|
|
66
|
+
*/
|
|
67
|
+
export function rejectControlChars(text: string): void {
|
|
68
|
+
try {
|
|
69
|
+
coreRejectControlChars(text);
|
|
70
|
+
} catch {
|
|
71
|
+
throw new OperationError(
|
|
72
|
+
"Input contains control characters",
|
|
73
|
+
ErrorCode.INVALID_JSON,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|