Port bun-mariadb schema guard, targetId fix, lazy init and tests

- schema: add --dry-run (sync in a rolled-back transaction) and require
  confirmation (or --yes) before dropping tables or columns; unapproved
  destructive changes are refused
- only rebuild tables whose CREATE TABLE statement changed
- always AND targetId with the query, regardless of searchOperator
- empty IN / NOT IN lists become 1 = 0 / 1 = 1
- initialize lazily on first call so importing never throws
- read CLI version from package.json, chmod CLI on compile, pin @types/bun
- add SQL generator, value and schema guard tests

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-14 03:00:29 +01:00
co-authored by Claude Opus 5.5
parent 87da436f81
commit 4e47bd8ce0
23 changed files with 649 additions and 172 deletions
+18
View File
@@ -32,6 +32,7 @@ A schema-driven SQLite manager for [Bun](https://bun.sh), featuring automatic sc
- [TypeScript Type Generation](#typescript-type-generation) - [TypeScript Type Generation](#typescript-type-generation)
- [Default Fields](#default-fields) - [Default Fields](#default-fields)
- [Project Structure](#project-structure) - [Project Structure](#project-structure)
- [Testing](#testing)
--- ---
@@ -272,6 +273,8 @@ bunx bun-sqlite schema [options]
| ----------------- | ---------------------------------------------------------- | | ----------------- | ---------------------------------------------------------- |
| `-v`, `--vector` | Drop and recreate all vector (`sqlite-vec`) virtual tables | | `-v`, `--vector` | Drop and recreate all vector (`sqlite-vec`) virtual tables |
| `-t`, `--typedef` | Also generate TypeScript type definitions after syncing | | `-t`, `--typedef` | Also generate TypeScript type definitions after syncing |
| `--dry-run` | Print the planned schema changes without applying them |
| `-y`, `--yes` | Apply destructive changes (dropped tables / columns) without confirmation |
**Examples:** **Examples:**
@@ -284,8 +287,13 @@ bunx bun-sqlite schema --typedef
# Sync schema, recreate vector tables, and regenerate types # Sync schema, recreate vector tables, and regenerate types
bunx bun-sqlite schema --vector --typedef bunx bun-sqlite schema --vector --typedef
# Review planned changes first
bunx bun-sqlite schema --dry-run
``` ```
Only tables whose definition changed are rebuilt. Changes that discard data (dropping a table, or a column removed from the schema) are listed and need confirmation before they run. Non-interactive runs (CI, scripts) refuse them unless `--yes` is passed. The plan is exact: `--dry-run` runs the whole sync in a transaction and rolls it back.
### `typedef` — Generate TypeScript types only ### `typedef` — Generate TypeScript types only
```bash ```bash
@@ -844,6 +852,16 @@ bun-sqlite/
--- ---
## Testing
```bash
bun run test
```
Runs the unit tests (`tests/`) and the schema sync tests (`src/lib/schema/*.test.ts`) against in-memory SQLite databases. No setup needed.
---
## License ## License
MIT MIT
+3 -3
View File
@@ -13,7 +13,7 @@
"sqlite-vec": "^0.1.7-alpha.2", "sqlite-vec": "^0.1.7-alpha.2",
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "1.3.14",
"@types/inquirer": "^9.0.9", "@types/inquirer": "^9.0.9",
"@types/lodash": "^4.17.24", "@types/lodash": "^4.17.24",
"@types/node": "^25.3.3", "@types/node": "^25.3.3",
@@ -56,7 +56,7 @@
"@inquirer/type": ["@inquirer/[email protected]", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-PamArxO3cFJZoOzspzo6cxVlLeIftyBsZw/S9bKY5DzxqJVZgjoj1oP8d0rskKtp7sZxBycsoer1g6UeJV1BBA=="], "@inquirer/type": ["@inquirer/[email protected]", "", { "peerDependencies": { "@types/node": ">=18" }, "optionalPeers": ["@types/node"] }, "sha512-PamArxO3cFJZoOzspzo6cxVlLeIftyBsZw/S9bKY5DzxqJVZgjoj1oP8d0rskKtp7sZxBycsoer1g6UeJV1BBA=="],
"@types/bun": ["@types/[email protected].9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/bun": ["@types/[email protected].14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/inquirer": ["@types/[email protected]", "", { "dependencies": { "@types/through": "*", "rxjs": "^7.2.0" } }, "sha512-/mWx5136gts2Z2e5izdoRCo46lPp5TMs9R15GTSsgg/XnZyxDWVqoVU3R9lWnccKpqwsJLvRoxbCjoJtZB7DSw=="], "@types/inquirer": ["@types/[email protected]", "", { "dependencies": { "@types/through": "*", "rxjs": "^7.2.0" } }, "sha512-/mWx5136gts2Z2e5izdoRCo46lPp5TMs9R15GTSsgg/XnZyxDWVqoVU3R9lWnccKpqwsJLvRoxbCjoJtZB7DSw=="],
@@ -66,7 +66,7 @@
"@types/through": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ=="], "@types/through": ["@types/[email protected]", "", { "dependencies": { "@types/node": "*" } }, "sha512-HsJ+z3QuETzP3cswwtzt2vEIiHBk/dCcHGhbmG5X3ecnwFD/lPrMpliGXxSCg03L9AhrdwA4Oz/qfspkDW+xGQ=="],
"bun-types": ["[email protected].9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "bun-types": ["[email protected].14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
+3 -3
View File
@@ -26,13 +26,13 @@
}, },
"scripts": { "scripts": {
"dev": "tsc --watch", "dev": "tsc --watch",
"compile": "rm -rf dist tsconfig.tsbuildinfo && tsc -p tsconfig.json", "compile": "rm -rf dist tsconfig.tsbuildinfo && tsc -p tsconfig.json && chmod +x dist/commands/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"test": "bun test src", "test": "bun test src tests",
"prepublishOnly": "bun run compile" "prepublishOnly": "bun run compile"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "1.3.14",
"@types/inquirer": "^9.0.9", "@types/inquirer": "^9.0.9",
"@types/lodash": "^4.17.24", "@types/lodash": "^4.17.24",
"@types/node": "^25.3.3" "@types/node": "^25.3.3"
+7 -1
View File
@@ -9,6 +9,7 @@ import exportCmd from "./export";
import importCmd from "./import"; import importCmd from "./import";
import admin from "./admin"; import admin from "./admin";
import init from "../functions/init"; import init from "../functions/init";
import { readFileSync } from "fs";
try { try {
init(); init();
@@ -17,13 +18,18 @@ try {
process.exit(1); process.exit(1);
} }
/** Resolves to the package root from both src/commands and dist/commands */
const { version } = JSON.parse(
readFileSync(new URL("../../package.json", import.meta.url), "utf-8"),
);
/** /**
* # Describe Program * # Describe Program
*/ */
program program
.name(`bun-sqlite`) .name(`bun-sqlite`)
.description(`SQLite manager for Bun`) .description(`SQLite manager for Bun`)
.version(`1.0.0`); .version(version);
/** /**
* # Declare Commands * # Declare Commands
+87 -4
View File
@@ -9,6 +9,8 @@ import { writeLiveSchema } from "../functions/live-schema";
import grabDBDir from "../utils/grab-db-dir"; import grabDBDir from "../utils/grab-db-dir";
import snapshotDb from "../utils/snapshot-db"; import snapshotDb from "../utils/snapshot-db";
import replaceDbFile from "../utils/replace-db-file"; import replaceDbFile from "../utils/replace-db-file";
import { confirm } from "@inquirer/prompts";
import type { SchemaChange } from "../types";
export default function () { export default function () {
return new Command("schema") return new Command("schema")
@@ -18,6 +20,14 @@ export default function () {
"Recreate Vector Tables. This will drop and rebuild all vector tables", "Recreate Vector Tables. This will drop and rebuild all vector tables",
) )
.option("-t, --typedef", "Generate typescript type definitions") .option("-t, --typedef", "Generate typescript type definitions")
.option(
"--dry-run",
"Print the schema changes that would be applied, without applying them",
)
.option(
"-y, --yes",
"Apply destructive changes (dropped tables / columns) without confirmation",
)
.action(async (opts) => { .action(async (opts) => {
console.log(`Starting process ...`); console.log(`Starting process ...`);
@@ -27,9 +37,6 @@ export default function () {
const { ROOT_DIR, BUN_SQLITE_TEMP_DB_FILE_PATH } = grabDirNames(); const { ROOT_DIR, BUN_SQLITE_TEMP_DB_FILE_PATH } = grabDirNames();
const { db_file_path } = grabDBDir({ config }); const { db_file_path } = grabDBDir({ config });
snapshotDb({ db: DbClient, dst: BUN_SQLITE_TEMP_DB_FILE_PATH });
try {
const isVector = Boolean(opts.vector || opts.v); const isVector = Boolean(opts.vector || opts.v);
const isTypeDef = Boolean(opts.typedef || opts.t); const isTypeDef = Boolean(opts.typedef || opts.t);
@@ -37,10 +44,86 @@ export default function () {
dbSchema, dbSchema,
}); });
createDBSchema({ const params = {
db: DbClient, db: DbClient,
db_schema: finaldbSchema, db_schema: finaldbSchema,
recreate_vector_table: isVector, recreate_vector_table: isVector,
};
/**
* Plan: run the sync in a transaction that is rolled back
*/
const plan: SchemaChange[] = [];
try {
createDBSchema({ ...params, dry_run: true, changes: plan });
} catch (error: any) {
console.error(chalk.red(`Schema sync ERROR => ${error.message}`));
process.exit(1);
}
const destructive = plan.filter((change) => change.destructive);
if (opts.dryRun) {
if (!plan.length) {
console.log(chalk.green(`Schema is up to date.`));
} else {
console.log(chalk.bold(`Planned schema changes:\n`));
for (const change of plan) {
console.log(
change.destructive
? chalk.red(` ${change.description}`)
: ` ${change.description}`,
);
}
if (destructive.length) {
console.log(
chalk.red(
`\n${destructive.length} destructive change(s) (in red) would discard data.`,
),
);
}
}
process.exit(0);
}
if (destructive.length && !opts.yes) {
console.log(
chalk.yellow(`\nThese changes will permanently discard data:\n`),
);
for (const change of destructive) {
console.log(chalk.red(` ${change.description}`));
}
console.log();
if (!process.stdin.isTTY) {
console.error(
chalk.red(
`Refusing to apply destructive changes non-interactively. Re-run with --yes to apply them.`,
),
);
process.exit(1);
}
const proceed = await confirm({
message: `Apply ${destructive.length} destructive change(s)?`,
default: false,
});
if (!proceed) {
console.log(chalk.dim(`Schema sync cancelled.`));
process.exit(1);
}
}
snapshotDb({ db: DbClient, dst: BUN_SQLITE_TEMP_DB_FILE_PATH });
try {
createDBSchema({
...params,
allow_destructive: opts.yes
? true
: new Set(destructive.map((c) => c.description)),
}); });
DbClient.close(); DbClient.close();
+16 -7
View File
@@ -7,16 +7,25 @@ import DbUpdate from "./lib/sqlite/db-update";
import grabDbSchema from "./utils/grab-db-schema"; import grabDbSchema from "./utils/grab-db-schema";
import grabJoinFieldsFromQueryObject from "./utils/grab-join-fields-from-query-object"; import grabJoinFieldsFromQueryObject from "./utils/grab-join-fields-from-query-object";
init(); /**
* Initialize lazily on first call, so importing the library never throws.
* Setup errors (missing config / schema) reject the call instead.
*/
function withInit<F extends (...args: any[]) => Promise<any>>(fn: F): F {
return (async (...args: Parameters<F>) => {
init();
return fn(...args);
}) as F;
}
const BunSQLite = { const BunSQLite = {
select: DbSelect, select: withInit(DbSelect),
insert: DbInsert, insert: withInit(DbInsert),
update: DbUpdate, update: withInit(DbUpdate),
delete: DbDelete, delete: withInit(DbDelete),
sql: DbSQL, sql: withInit(DbSQL),
utils: { utils: {
grab_db_schema: grabDbSchema, grab_db_schema: withInit(grabDbSchema),
grab_join_fields_from_query_object: grabJoinFieldsFromQueryObject, grab_join_fields_from_query_object: grabJoinFieldsFromQueryObject,
}, },
} as const; } as const;
+54
View File
@@ -0,0 +1,54 @@
import type {
BUN_SQLITE_DatabaseSchemaType,
BUN_SQLITE_TableSchemaType,
} from "../../types";
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
import buildColumnDefinition from "./build-column-definition";
import buildForeignKeyConstraint from "./build-foreign-key-constraint";
import resolveTable from "./resolve-table";
type Params = {
db_schema: BUN_SQLITE_DatabaseSchemaType;
table: BUN_SQLITE_TableSchemaType;
};
/**
* Build the `CREATE TABLE` (or `CREATE VIRTUAL TABLE`) statement for a table
*/
export default function buildCreateTableSql({ db_schema, table }: Params) {
const new_table = resolveTable({ table, db_schema });
const columns: string[] = [];
const foreignKeys: string[] = [];
for (const field of new_table.fields) {
columns.push(buildColumnDefinition(field, new_table.isVector));
if (field.foreignKey && !new_table.isVector) {
foreignKeys.push(buildForeignKeyConstraint(field));
}
}
// Add unique constraints
for (const constraint of new_table.uniqueConstraints || []) {
if (!constraint.constraintTableFields?.length) continue;
const fieldNames = constraint.constraintTableFields.map((f) =>
String(f.value),
);
const constraintName =
constraint.constraintName || `unique_${fieldNames.join(", ")}`;
columns.push(
`CONSTRAINT ${q(constraintName)} UNIQUE (${fieldNames
.map(q)
.join(", ")})`,
);
}
const allConstraints = [...columns, ...foreignKeys];
return new_table.isVector
? `CREATE VIRTUAL TABLE ${q(new_table.tableName)} USING ${new_table.vectorType || "vec0"}(${allConstraints.join(", ")})`
: `CREATE TABLE ${q(new_table.tableName)} (${allConstraints.join(", ")})`;
}
+52 -2
View File
@@ -3,6 +3,7 @@ import { Database } from "bun:sqlite";
import type { import type {
BUN_SQLITE_FieldSchemaType, BUN_SQLITE_FieldSchemaType,
BUN_SQLITE_TableSchemaType, BUN_SQLITE_TableSchemaType,
SchemaChange,
} from "../../types"; } from "../../types";
import createDBSchema from "./create-db-schema"; import createDBSchema from "./create-db-schema";
@@ -38,8 +39,13 @@ const posts: BUN_SQLITE_TableSchemaType = {
let db: Database; let db: Database;
function sync(tables: BUN_SQLITE_TableSchemaType[]) { function sync(
createDBSchema({ db, db_schema: { tables } }); tables: BUN_SQLITE_TableSchemaType[],
options: { dry_run?: boolean; allow_destructive?: true | Set<string> } = {},
) {
const changes: SchemaChange[] = [];
createDBSchema({ db, db_schema: { tables }, changes, ...options });
return changes.map((c) => (c.destructive ? `! ${c.description}` : c.description));
} }
function tableNames() { function tableNames() {
@@ -143,4 +149,48 @@ describe("createDBSchema", () => {
), ),
).toEqual(["id", "name"]); ).toEqual(["id", "name"]);
}); });
test("leaves unchanged tables alone", () => {
sync([users, posts]);
expect(sync([users, posts])).toEqual([]);
});
test("records changes, marking data loss as destructive", () => {
sync([users, posts]);
expect(
sync([{ ...users, fields: [id, { fieldName: "email", dataType: "TEXT" }] }]),
).toEqual([
`Rebuild table "users"`,
`! Drop column "users"."name"`,
`! Drop table "posts"`,
]);
});
test("dry run changes nothing", () => {
sync([users, posts]);
db.run(`INSERT INTO users (name) VALUES ('ada')`);
expect(sync([], { dry_run: true })).toEqual([
`! Drop table "users"`,
`! Drop table "posts"`,
]);
expect(tableNames()).toEqual(["posts", "users"]);
expect(db.query(`SELECT name FROM users`).all()).toEqual([
{ name: "ada" },
]);
});
test("refuses unapproved destructive changes and rolls back", () => {
sync([users, posts]);
expect(() =>
sync([users], { allow_destructive: new Set() }),
).toThrow(/Refusing unapproved destructive change/);
expect(tableNames()).toEqual(["posts", "users"]);
sync([users], { allow_destructive: new Set([`Drop table "posts"`]) });
expect(tableNames()).toEqual(["users"]);
});
}); });
+12 -15
View File
@@ -8,21 +8,15 @@ import orderDBSchema from "./order-db-schema";
/** /**
* # Sync the database to the schema * # Sync the database to the schema
* @description Tables are rebuilt by copying into a new table and dropping * @description Changed tables are rebuilt by copying into a new table and
* the old one. With foreign keys ON, `DROP TABLE` runs an implicit * dropping the old one. With foreign keys ON, `DROP TABLE` runs an implicit
* `DELETE FROM` that fires `ON DELETE CASCADE` on child tables, so keys are * `DELETE FROM` that fires `ON DELETE CASCADE` on child tables, so keys are
* disabled for the sync and checked before committing. (`PRAGMA * disabled for the sync and checked before committing. (`PRAGMA
* foreign_keys` is a no-op inside a transaction.) * foreign_keys` is a no-op inside a transaction.) With `dry_run` the
* transaction is rolled back, so `changes` is the exact plan.
*/ */
export default function createDBSchema(params: CreateDBSchemaParams) { export default function createDBSchema(params: CreateDBSchemaParams) {
const { db } = params; const { db, dry_run } = params;
console.log("Starting schema synchronization...");
/**
* Create Schema Manager Table
*/
createDBManagerTable({ db });
/** /**
* Reorder Tables (parents before children with FKs) * Reorder Tables (parents before children with FKs)
@@ -33,6 +27,11 @@ export default function createDBSchema(params: CreateDBSchemaParams) {
db.run("BEGIN"); db.run("BEGIN");
try { try {
/**
* Create Schema Manager Table
*/
createDBManagerTable({ db });
const existing_tables = getExistingTablesFromTablesManagerTable({ const existing_tables = getExistingTablesFromTablesManagerTable({
db, db,
}); });
@@ -53,7 +52,7 @@ export default function createDBSchema(params: CreateDBSchemaParams) {
* Drop tables that no longer exist in schema * Drop tables that no longer exist in schema
*/ */
dropRemovedTables({ dropRemovedTables({
db, ...params,
existing_tables, existing_tables,
schema_tables: db_schema.tables.map((t) => t.tableName), schema_tables: db_schema.tables.map((t) => t.tableName),
}); });
@@ -72,13 +71,11 @@ export default function createDBSchema(params: CreateDBSchemaParams) {
); );
} }
db.run("COMMIT"); db.run(dry_run ? "ROLLBACK" : "COMMIT");
} catch (error) { } catch (error) {
if (db.inTransaction) db.run("ROLLBACK"); if (db.inTransaction) db.run("ROLLBACK");
throw error; throw error;
} finally { } finally {
db.run("PRAGMA foreign_keys = ON;"); db.run("PRAGMA foreign_keys = ON;");
} }
console.log("Schema synchronization complete!");
} }
+8 -44
View File
@@ -2,10 +2,8 @@ import type {
BUN_SQLITE_TableSchemaType, BUN_SQLITE_TableSchemaType,
CreateDBSchemaParams, CreateDBSchemaParams,
} from "../../types"; } from "../../types";
import { sqlQuoteIdent as q } from "../../utils/sql-safe"; import buildCreateTableSql from "./build-create-table-sql";
import buildColumnDefinition from "./build-column-definition"; import recordSchemaChange from "./record-schema-change";
import buildForeignKeyConstraint from "./build-foreign-key-constraint";
import resolveTable from "./resolve-table";
type Params = CreateDBSchemaParams & { type Params = CreateDBSchemaParams & {
table: BUN_SQLITE_TableSchemaType; table: BUN_SQLITE_TableSchemaType;
@@ -14,46 +12,12 @@ type Params = CreateDBSchemaParams & {
/** /**
* Create a new table * Create a new table
*/ */
export default function createTable({ db, db_schema, table }: Params) { export default function createTable(params: Params) {
if (!table.tableName.match(/_temp_\d+$/)) { const { db, db_schema, table } = params;
console.log(`Creating table: ${table.tableName}`);
}
const new_table = resolveTable({ table, db_schema }); recordSchemaChange(params, {
description: `Create table "${table.tableName}"`,
});
const columns: string[] = []; db.run(buildCreateTableSql({ db_schema, table }));
const foreignKeys: string[] = [];
for (const field of new_table.fields) {
columns.push(buildColumnDefinition(field, new_table.isVector));
if (field.foreignKey && !new_table.isVector) {
foreignKeys.push(buildForeignKeyConstraint(field));
}
}
// Add unique constraints
for (const constraint of new_table.uniqueConstraints || []) {
if (!constraint.constraintTableFields?.length) continue;
const fieldNames = constraint.constraintTableFields.map((f) =>
String(f.value),
);
const constraintName =
constraint.constraintName || `unique_${fieldNames.join(", ")}`;
columns.push(
`CONSTRAINT ${q(constraintName)} UNIQUE (${fieldNames
.map(q)
.join(", ")})`,
);
}
const allConstraints = [...columns, ...foreignKeys];
const sql = new_table.isVector
? `CREATE VIRTUAL TABLE ${q(new_table.tableName)} USING ${new_table.vectorType || "vec0"}(${allConstraints.join(", ")})`
: `CREATE TABLE ${q(new_table.tableName)} (${allConstraints.join(", ")})`;
db.run(sql);
} }
+17 -11
View File
@@ -1,10 +1,12 @@
import type { Database } from "bun:sqlite"; import _ from "lodash";
import type { CreateDBSchemaParams } from "../../types";
import { readLiveSchema } from "../../functions/live-schema"; import { readLiveSchema } from "../../functions/live-schema";
import { sqlQuoteIdent as q } from "../../utils/sql-safe"; import { sqlQuoteIdent as q } from "../../utils/sql-safe";
import removeDBManagerTable from "./remove-db-manager-table"; import removeDBManagerTable from "./remove-db-manager-table";
import recordSchemaChange from "./record-schema-change";
import checkIfTableExists from "./check-if-table-exists";
type Params = { type Params = CreateDBSchemaParams & {
db: Database;
existing_tables: string[]; existing_tables: string[];
schema_tables: string[]; schema_tables: string[];
}; };
@@ -12,12 +14,8 @@ type Params = {
/** /**
* Drop tables that are no longer in the schema * Drop tables that are no longer in the schema
*/ */
export default function dropRemovedTables({ export default function dropRemovedTables(params: Params) {
db, const { db, existing_tables, schema_tables } = params;
existing_tables,
schema_tables,
}: Params) {
console.log(`Cleaning up tables ...`);
const tablesToDrop = existing_tables.filter( const tablesToDrop = existing_tables.filter(
(t) => (t) =>
@@ -35,8 +33,16 @@ export default function dropRemovedTables({
} }
} }
for (const tableName of tablesToDrop) { for (const tableName of _.uniq(tablesToDrop)) {
console.log(`Dropping table: ${tableName}`); if (!checkIfTableExists({ db, table_name: tableName })) {
removeDBManagerTable({ db, table_name: tableName });
continue;
}
recordSchemaChange(params, {
description: `Drop table "${tableName}"`,
destructive: true,
});
db.run(`DROP TABLE IF EXISTS ${q(tableName)}`); db.run(`DROP TABLE IF EXISTS ${q(tableName)}`);
removeDBManagerTable({ db, table_name: tableName }); removeDBManagerTable({ db, table_name: tableName });
} }
+5 -4
View File
@@ -5,6 +5,7 @@ import type {
import { sqlQuoteIdent as q } from "../../utils/sql-safe"; import { sqlQuoteIdent as q } from "../../utils/sql-safe";
import createTable from "./create-table"; import createTable from "./create-table";
import insertDBManagerTable from "./insert-db-manager-table"; import insertDBManagerTable from "./insert-db-manager-table";
import recordSchemaChange from "./record-schema-change";
import removeDBManagerTable from "./remove-db-manager-table"; import removeDBManagerTable from "./remove-db-manager-table";
import syncIndexes from "./sync-indexes"; import syncIndexes from "./sync-indexes";
import updateTable from "./update-table"; import updateTable from "./update-table";
@@ -28,9 +29,9 @@ export default function handleDBSchemaTable(params: Params) {
table.tableNameOld !== table.tableName && table.tableNameOld !== table.tableName &&
existing_tables.includes(table.tableNameOld) existing_tables.includes(table.tableNameOld)
) { ) {
console.log( recordSchemaChange(params, {
`Renaming table: ${table.tableNameOld} -> ${table.tableName}`, description: `Rename table "${table.tableNameOld}" -> "${table.tableName}"`,
); });
db.run( db.run(
`ALTER TABLE ${q(table.tableNameOld)} RENAME TO ${q(table.tableName)}`, `ALTER TABLE ${q(table.tableNameOld)} RENAME TO ${q(table.tableName)}`,
); );
@@ -46,5 +47,5 @@ export default function handleDBSchemaTable(params: Params) {
updateTable(params); updateTable(params);
} }
syncIndexes({ db, table }); syncIndexes(params);
} }
+35
View File
@@ -0,0 +1,35 @@
import type { CreateDBSchemaParams, SchemaChange } from "../../types";
/**
* # Record a schema change before it runs
* @description Logs the change (unless this is a dry run) and refuses
* destructive changes that were not approved.
*/
export default function recordSchemaChange(
params: Pick<
CreateDBSchemaParams,
"dry_run" | "changes" | "allow_destructive"
>,
change: SchemaChange,
) {
params.changes?.push(change);
if (params.dry_run) {
return;
}
const allowed = params.allow_destructive;
if (
change.destructive &&
allowed !== undefined &&
allowed !== true &&
!allowed.has(change.description)
) {
throw new Error(
`Refusing unapproved destructive change: ${change.description}. Re-run with --dry-run to review, or --yes to allow it.`,
);
}
console.log(change.description);
}
+15 -7
View File
@@ -1,16 +1,20 @@
import type { Database } from "bun:sqlite"; import type {
import type { BUN_SQLITE_TableSchemaType } from "../../types"; BUN_SQLITE_TableSchemaType,
CreateDBSchemaParams,
} from "../../types";
import { sqlQuoteIdent as q } from "../../utils/sql-safe"; import { sqlQuoteIdent as q } from "../../utils/sql-safe";
import recordSchemaChange from "./record-schema-change";
type Params = { type Params = CreateDBSchemaParams & {
db: Database;
table: BUN_SQLITE_TableSchemaType; table: BUN_SQLITE_TableSchemaType;
}; };
/** /**
* Sync indexes for a table * Sync indexes for a table
*/ */
export default function syncIndexes({ db, table }: Params) { export default function syncIndexes(params: Params) {
const { db, table } = params;
if (!table.indexes || table.indexes.length === 0) { if (!table.indexes || table.indexes.length === 0) {
return; return;
} }
@@ -29,7 +33,9 @@ export default function syncIndexes({ db, table }: Params) {
(idx) => idx.indexName === indexName, (idx) => idx.indexName === indexName,
); );
if (!stillExists) { if (!stillExists) {
console.log(`Dropping index: ${indexName}`); recordSchemaChange(params, {
description: `Drop index "${indexName}"`,
});
db.run(`DROP INDEX IF EXISTS ${q(indexName)}`); db.run(`DROP INDEX IF EXISTS ${q(indexName)}`);
} }
} }
@@ -41,7 +47,9 @@ export default function syncIndexes({ db, table }: Params) {
} }
if (!existingIndexes.includes(index.indexName)) { if (!existingIndexes.includes(index.indexName)) {
console.log(`Creating index: ${index.indexName}`); recordSchemaChange(params, {
description: `Create index "${index.indexName}" on "${table.tableName}"`,
});
const fields = index.indexTableFields const fields = index.indexTableFields
.map((f) => q(String(f))) .map((f) => q(String(f)))
.join(", "); .join(", ");
+49 -15
View File
@@ -3,9 +3,10 @@ import type {
CreateDBSchemaParams, CreateDBSchemaParams,
} from "../../types"; } from "../../types";
import { sqlQuoteIdent as q } from "../../utils/sql-safe"; import { sqlQuoteIdent as q } from "../../utils/sql-safe";
import buildCreateTableSql from "./build-create-table-sql";
import checkIfTableExists from "./check-if-table-exists"; import checkIfTableExists from "./check-if-table-exists";
import createTable from "./create-table";
import getTableColumns from "./get-table-columns"; import getTableColumns from "./get-table-columns";
import recordSchemaChange from "./record-schema-change";
import resolveTable from "./resolve-table"; import resolveTable from "./resolve-table";
type Params = CreateDBSchemaParams & { type Params = CreateDBSchemaParams & {
@@ -14,36 +15,65 @@ type Params = CreateDBSchemaParams & {
/** /**
* # Update an existing table * # Update an existing table
* @description SQLite has limited ALTER TABLE support, so the table is * @description SQLite has limited ALTER TABLE support, so a changed table is
* rebuilt: create a temp table with the new schema, copy the data over, * rebuilt: create a temp table with the new schema, copy the data over,
* drop the old table and rename the temp table. * drop the old table and rename the temp table. Unchanged tables are left
* alone.
*/ */
export default function updateTable(params: Params) { export default function updateTable(params: Params) {
const { db, db_schema, table } = params; const { db, db_schema, table } = params;
console.log(`Updating table: ${table.tableName}`);
if (table.isVector) { if (table.isVector) {
recreateVectorTable(params); recreateVectorTable(params);
return; return;
} }
/**
* SQLite keeps the `CREATE TABLE` text (with the renamed table name), so
* an identical statement means the table already matches the schema.
*/
const current = db
.query(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`)
.get(table.tableName) as { sql: string } | null;
if (current?.sql === buildCreateTableSql({ db_schema, table })) {
return;
}
const tempTableName = `${table.tableName}_temp_${Date.now()}`; const tempTableName = `${table.tableName}_temp_${Date.now()}`;
// Columns are matched by name only: SQLite converts values to the new // Columns are matched by name only: SQLite converts values to the new
// column affinity on copy, so a type change keeps the data instead of // column affinity on copy, so a type change keeps the data instead of
// silently dropping the column. // silently dropping the column.
const existingColumns = getTableColumns({ db, table_name: table.tableName }); const existingColumns = getTableColumns({ db, table_name: table.tableName });
const columnsToKeep = resolveTable({ table, db_schema }) const schemaColumns = resolveTable({ table, db_schema }).fields.map(
.fields.filter( (f) => f.fieldName,
(f) => );
f.fieldName && const columnsToKeep = existingColumns
existingColumns.find((c) => c.name == f.fieldName), .map((c) => c.name)
) .filter((name) => schemaColumns.includes(name));
.map((f) => f.fieldName as string); const columnsToDrop = existingColumns
.map((c) => c.name)
.filter((name) => !schemaColumns.includes(name));
recordSchemaChange(params, {
description: `Rebuild table "${table.tableName}"`,
});
for (const column of columnsToDrop) {
recordSchemaChange(params, {
description: `Drop column "${table.tableName}"."${column}"`,
destructive: true,
});
}
// Create temp table with new schema // Create temp table with new schema
createTable({ ...params, table: { ...table, tableName: tempTableName } }); db.run(
buildCreateTableSql({
db_schema,
table: { ...table, tableName: tempTableName },
}),
);
// Copy data if there are common columns // Copy data if there are common columns
if (columnsToKeep.length > 0) { if (columnsToKeep.length > 0) {
@@ -61,12 +91,16 @@ export default function updateTable(params: Params) {
* Vector tables are only rebuilt when `recreate_vector_table` is set. * Vector tables are only rebuilt when `recreate_vector_table` is set.
*/ */
function recreateVectorTable(params: Params) { function recreateVectorTable(params: Params) {
const { db, table, recreate_vector_table } = params; const { db, db_schema, table, recreate_vector_table } = params;
if (!recreate_vector_table) { if (!recreate_vector_table) {
return; return;
} }
recordSchemaChange(params, {
description: `Recreate vector table "${table.tableName}"`,
});
let existingRows: { [k: string]: any }[] = []; let existingRows: { [k: string]: any }[] = [];
if (checkIfTableExists({ db, table_name: table.tableName })) { if (checkIfTableExists({ db, table_name: table.tableName })) {
@@ -77,7 +111,7 @@ function recreateVectorTable(params: Params) {
db.run(`DROP TABLE ${q(table.tableName)}`); db.run(`DROP TABLE ${q(table.tableName)}`);
} }
createTable(params); db.run(buildCreateTableSql({ db_schema, table }));
for (const row of existingRows) { for (const row of existingRows) {
const columns = Object.keys(row); const columns = Object.keys(row);
+2 -15
View File
@@ -30,25 +30,12 @@ export default async function DbDelete<
let sqlObj: ReturnType<typeof sqlGenerator> | null = null; let sqlObj: ReturnType<typeof sqlGenerator> | null = null;
try { try {
let finalQuery = query || {}; const finalQuery = query || {};
if (targetId !== undefined && targetId !== null && targetId !== "") {
finalQuery = _.merge<
ServerQueryParam<any>,
ServerQueryParam<any>,
ServerQueryParam<any>
>({}, finalQuery, {
query: {
id: {
value: String(targetId),
},
},
});
}
sqlObj = sqlGenerator({ sqlObj = sqlGenerator({
tableName: table, tableName: table,
genObject: _.pick(finalQuery, ["query", "searchOperator"]), genObject: _.pick(finalQuery, ["query", "searchOperator"]),
targetId,
}); });
const whereClause = sqlObj.where.string; const whereClause = sqlObj.where.string;
+3 -16
View File
@@ -1,5 +1,4 @@
import dbHandler from "../db-handler"; import dbHandler from "../db-handler";
import _ from "lodash";
import type { import type {
APIResponseObject, APIResponseObject,
BunSQLiteConfig, BunSQLiteConfig,
@@ -31,25 +30,12 @@ export default async function DbSelect<
let sqlObj: ReturnType<typeof sqlGenerator> | null = null; let sqlObj: ReturnType<typeof sqlGenerator> | null = null;
try { try {
let finalQuery = query || {}; const finalQuery = query || {};
if (targetId !== undefined && targetId !== null && targetId !== "") {
finalQuery = _.merge<
ServerQueryParam<any>,
ServerQueryParam<any>,
ServerQueryParam<any>
>({}, finalQuery, {
query: {
id: {
value: String(targetId),
},
},
});
}
sqlObj = sqlGenerator({ sqlObj = sqlGenerator({
tableName: table, tableName: table,
genObject: finalQuery, genObject: finalQuery,
targetId,
}); });
const sql = sqlObj.string; const sql = sqlObj.string;
@@ -80,6 +66,7 @@ export default async function DbSelect<
const count_sql_object = sqlGenerator({ const count_sql_object = sqlGenerator({
tableName: table, tableName: table,
genObject: finalQuery, genObject: finalQuery,
targetId,
count, count,
}); });
+2 -15
View File
@@ -37,25 +37,12 @@ export default async function DbUpdate<
}; };
try { try {
let finalQuery = query || {}; const finalQuery = query || {};
if (targetId !== undefined && targetId !== null && targetId !== "") {
finalQuery = _.merge<
ServerQueryParam<any>,
ServerQueryParam<any>,
ServerQueryParam<any>
>({}, finalQuery, {
query: {
id: {
value: String(targetId),
},
},
});
}
const sqlQueryObj = sqlGenerator({ const sqlQueryObj = sqlGenerator({
tableName: table, tableName: table,
genObject: _.pick(finalQuery, ["query", "searchOperator"]), genObject: _.pick(finalQuery, ["query", "searchOperator"]),
targetId,
}); });
let values: SQLInsertGenValueType[] = []; let values: SQLInsertGenValueType[] = [];
+22
View File
@@ -1580,6 +1580,28 @@ export type CreateDBSchemaParams = {
db: import("bun:sqlite").Database; db: import("bun:sqlite").Database;
db_schema: BUN_SQLITE_DatabaseSchemaType; db_schema: BUN_SQLITE_DatabaseSchemaType;
recreate_vector_table?: boolean; recreate_vector_table?: boolean;
/**
* Run the sync inside a transaction and roll it back. The planned
* changes are recorded in `changes`.
*/
dry_run?: boolean;
/**
* Collects every change made (or planned) by the sync
*/
changes?: SchemaChange[];
/**
* Destructive changes that may run. `true` (or unset) allows all of
* them; otherwise only changes whose `description` is in the set.
*/
allow_destructive?: true | Set<string>;
};
/**
* A change made by the schema sync. Destructive changes discard data.
*/
export type SchemaChange = {
description: string;
destructive?: boolean;
}; };
/** /**
+4 -1
View File
@@ -83,7 +83,10 @@ export default function sqlGenGenSearchStr({
.filter(isPresent); .filter(isPresent);
const equality = queryObj.equality; const equality = queryObj.equality;
if (equality == "IN" || equality == "NOT IN") { if ((equality == "IN" || equality == "NOT IN") && !rawValues.length) {
// Empty IN () is invalid SQL: IN matches nothing, NOT IN matches everything
str = equality == "IN" ? "1 = 0" : "1 = 1";
} else if (equality == "IN" || equality == "NOT IN") {
str = `${finalFieldName} ${equality} (${rawValues str = `${finalFieldName} ${equality} (${rawValues
.map(() => "?") .map(() => "?")
.join(", ")})`; .join(", ")})`;
+33 -3
View File
@@ -18,6 +18,11 @@ type Param<T extends { [key: string]: any } = { [key: string]: any }> = {
tableName: string; tableName: string;
dbFullName?: string; dbFullName?: string;
count?: boolean; count?: boolean;
/**
* Restrict to a single row id. Always combined with AND, regardless of
* `searchOperator`.
*/
targetId?: number | string;
}; };
type Return = { type Return = {
@@ -40,7 +45,12 @@ type Return = {
*/ */
export default function sqlGenerator< export default function sqlGenerator<
T extends { [key: string]: any } = { [key: string]: any }, T extends { [key: string]: any } = { [key: string]: any },
>({ tableName: passedTableName, genObject, count }: Param<T>): Return { >({
tableName: passedTableName,
genObject,
count,
targetId,
}: Param<T>): Return {
const tableName = sqlIdent(passedTableName, "table name"); const tableName = sqlIdent(passedTableName, "table name");
const finalQuery = genObject?.query ? genObject.query : undefined; const finalQuery = genObject?.query ? genObject.query : undefined;
@@ -115,11 +125,31 @@ export default function sqlGenerator<
(str) => typeof str == "string", (str) => typeof str == "string",
); );
let whereString = ""; const whereConditions: string[] = [];
if (cleanedUpSearchStr?.[0]) { if (cleanedUpSearchStr?.[0]) {
const stringOperator = sqlOperator(genObject?.searchOperator); const stringOperator = sqlOperator(genObject?.searchOperator);
whereString = `WHERE ${cleanedUpSearchStr.join(` ${stringOperator} `)}`; whereConditions.push(
`(${cleanedUpSearchStr.join(` ${stringOperator} `)})`,
);
}
if (targetId !== undefined && targetId !== null && targetId !== "") {
const id = Number(targetId);
if (!Number.isSafeInteger(id)) {
throw new Error(`Invalid targetId: ${JSON.stringify(targetId)}`);
}
whereConditions.push(`${genObject?.join ? `${tableName}.` : ""}id = ?`);
whereValues.push(id);
}
const whereString = whereConditions.length
? `WHERE ${whereConditions.join(" AND ")}`
: "";
if (whereString) {
queryString += ` ${whereString}`; queryString += ` ${whereString}`;
sqlSearhValues.push(...whereValues); sqlSearhValues.push(...whereValues);
} }
+135
View File
@@ -0,0 +1,135 @@
import { describe, expect, test } from "bun:test";
import sqlGenerator from "../src/utils/sql-generator";
const gen = (genObject: any, extra: Record<string, any> = {}) =>
sqlGenerator({ tableName: "users", genObject, ...extra });
describe("identifiers", () => {
test("binds values and keeps identifiers", () => {
const { string, values } = gen({ query: { email: { value: "[email protected]" } } });
expect(string).toBe("SELECT * FROM users WHERE (email = ?)");
expect(values).toEqual(["[email protected]"]);
});
test.each([
["field name", { query: { "1=1 OR id": { value: 1 } } }],
["quoted field name", { query: { 'a" OR 1=1 --': { value: 1 } } }],
["select field", { selectFields: ["name; DROP TABLE users"] }],
])("rejects an injected %s", (_name, genObject) => {
expect(() => gen(genObject)).toThrow();
});
test("rejects an injected table name", () => {
expect(() =>
sqlGenerator({ tableName: "users; DROP TABLE users", genObject: {} }),
).toThrow();
});
});
describe("input validation", () => {
test.each([
["order strategy", { order: { field: "id", strategy: "DESC; DROP TABLE x" } }],
["limit", { limit: "1; --" }],
["negative offset", { offset: -1 }],
["page", { limit: 10, page: "2 OR 1" }],
["search operator", { query: { a: { value: 1 } }, searchOperator: "OR 1=1" }],
["raw equality", { query: { a: { value: 1, raw_equality: "= 1 OR" } } }],
["join type", { join: [{ tableName: "p", joinType: "CROSS JOIN x;", match: { source: "id", target: "uid" } }] }],
])("rejects invalid %s", (_name, genObject) => {
expect(() => gen(genObject)).toThrow();
});
test("rejects non-numeric targetId", () => {
expect(() => gen({}, { targetId: "abc" })).toThrow();
});
});
describe("targetId", () => {
test("is ANDed with the query, even with searchOperator OR", () => {
const { string, values } = gen(
{ query: { a: { value: 1 }, b: { value: 2 } }, searchOperator: "OR" },
{ targetId: 5 },
);
expect(string).toBe(
"SELECT * FROM users WHERE (a = ? OR b = ?) AND id = ?",
);
expect(values).toEqual([1, 2, 5]);
});
test("accepts numeric strings", () => {
const { string, values } = gen({}, { targetId: "7" });
expect(string).toBe("SELECT * FROM users WHERE id = ?");
expect(values).toEqual([7]);
});
test("0 is a real id", () => {
expect(gen({}, { targetId: 0 }).values).toEqual([0]);
});
test("is table-qualified with joins", () => {
const { string } = gen(
{
join: [
{
tableName: "posts",
joinType: "LEFT JOIN",
match: { source: "id", target: "user_id" },
},
],
},
{ targetId: 3 },
);
expect(string).toEndWith("WHERE users.id = ?");
});
test("is part of the where clause used by update / delete", () => {
const { where } = gen({ query: { a: { value: 1 } } }, { targetId: 2 });
expect(where).toEqual({
string: "WHERE (a = ?) AND id = ?",
values: [1, 2],
});
});
});
describe("values", () => {
test("0 is a real value", () => {
expect(gen({ query: { age: { value: 0 } } }).values).toEqual([0]);
});
test("IN binds each value", () => {
const { string, values } = gen({
query: { id: { value: [1, 2], equality: "IN" } },
});
expect(string).toBe("SELECT * FROM users WHERE (id IN (?, ?))");
expect(values).toEqual([1, 2]);
});
test("empty IN matches nothing, empty NOT IN matches everything", () => {
expect(gen({ query: { id: { value: [], equality: "IN" } } }).string).toBe(
"SELECT * FROM users WHERE (1 = 0)",
);
expect(
gen({ query: { id: { value: [], equality: "NOT IN" } } }).string,
).toBe("SELECT * FROM users WHERE (1 = 1)");
});
test("BETWEEN needs exactly two values", () => {
expect(() =>
gen({ query: { age: { value: [1], equality: "BETWEEN" } } }),
).toThrow();
});
});
describe("pagination", () => {
test("page is converted to an offset", () => {
expect(gen({ limit: 10, page: 3 }).string).toBe(
"SELECT * FROM users LIMIT 10 OFFSET 20",
);
});
test("count queries skip pagination", () => {
expect(gen({ limit: 10, page: 3 }, { count: true }).string).not.toContain(
"LIMIT",
);
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, test } from "bun:test";
import sqlInsertGenerator, { sqlValue } from "../src/utils/sql-insert-generator";
describe("sqlValue", () => {
test("booleans become 0 / 1", () => {
expect(sqlValue(false)).toBe(0);
expect(sqlValue(true)).toBe(1);
});
test("0 and empty strings are kept", () => {
expect(sqlValue(0)).toBe(0);
expect(sqlValue("")).toBe("");
});
test("undefined becomes NULL", () => {
expect(sqlValue(undefined)).toBeNull();
});
});
describe("sqlInsertGenerator", () => {
test("builds a multi-row insert", () => {
const res = sqlInsertGenerator({
tableName: "items",
data: [
{ name: "a", active: false, qty: 0 },
{ name: "b", note: "" },
],
});
expect(res?.query).toBe(
"INSERT INTO items (name,active,qty,note) VALUES (?,?,?,?),(?,?,?,?)",
);
expect(res?.values).toEqual(["a", 0, 0, null, "b", null, null, ""]);
});
test("function values are called once", () => {
let calls = 0;
const res = sqlInsertGenerator({
tableName: "items",
data: [
{
emb: () => {
calls++;
return { value: "[1,2]", placeholder: "vec_f32(?)" };
},
},
],
});
expect(calls).toBe(1);
expect(res?.query).toBe("INSERT INTO items (emb) VALUES (vec_f32(?))");
expect(res?.values).toEqual(["[1,2]"]);
});
test("rejects injected column names", () => {
expect(() =>
sqlInsertGenerator({
tableName: "items",
data: [{ "name) VALUES (1); --": "x" }],
}),
).toThrow();
});
});