Port bun-mariadb conventions: init globals, db handler, split schema sync

- init() loads config, schema and the SQLite client once into globals
- all CRUD queries go through lib/db-handler.ts (optional per-call config)
- split db-schema-manager.ts into lib/schema/* with FK-aware table ordering
- add bun:test tests for schema ordering, FK constraints and schema sync
- config/schema files can be .ts/.js/.json/.yaml/.yml; db_schema_file_name
  is now optional (defaults to schema.* in db_dir)
- add export/import commands (database snapshot + schema archives)
- package exports map, type re-exports, hardened publish.sh
- validate table name in delete

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-14 02:37:35 +01:00
co-authored by Claude Opus 5.5
parent 66f73c4228
commit 87da436f81
54 changed files with 2385 additions and 857 deletions
+56 -10
View File
@@ -19,6 +19,8 @@ A schema-driven SQLite manager for [Bun](https://bun.sh), featuring automatic sc
- [`typedef`](#typedef--generate-typescript-types-only) - [`typedef`](#typedef--generate-typescript-types-only)
- [`backup`](#backup--back-up-the-database) - [`backup`](#backup--back-up-the-database)
- [`restore`](#restore--restore-the-database-from-a-backup) - [`restore`](#restore--restore-the-database-from-a-backup)
- [`export`](#export--portable-database--schema-archive)
- [`import`](#import--database-file-sql-script-or-export-archive)
- [CRUD API](#crud-api) - [CRUD API](#crud-api)
- [Select](#select) - [Select](#select)
- [Insert](#insert) - [Insert](#insert)
@@ -47,7 +49,7 @@ A schema-driven SQLite manager for [Bun](https://bun.sh), featuring automatic sc
## Prerequisites ## Prerequisites
This works for just `bun`, so `bun` should be installed. This works for just `bun`, so `bun` (>= 1.3) should be installed.
--- ---
@@ -92,7 +94,6 @@ import type { BunSQLiteConfig } from "@moduletrace/bun-sqlite";
const config: BunSQLiteConfig = { const config: BunSQLiteConfig = {
db_name: "my-app.db", db_name: "my-app.db",
db_schema_file_name: "schema.ts",
db_dir: "./db", // optional: where to store the db file db_dir: "./db", // optional: where to store the db file
db_backup_dir: ".backups", // optional: name of backups directory. Relative to the db dir. db_backup_dir: ".backups", // optional: name of backups directory. Relative to the db dir.
typedef_file_path: "./db/types/db.ts", // optional: where to write generated types typedef_file_path: "./db/types/db.ts", // optional: where to write generated types
@@ -103,7 +104,7 @@ export default config;
### 2. Define your schema ### 2. Define your schema
Create `./db/schema.ts` (matching `db_schema_file_name` above): Create `./db/schema.ts` in `db_dir` (or `schema.js`, `schema.json`, `schema.yaml`, `schema.yml`):
```ts ```ts
import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite"; import type { BUN_SQLITE_DatabaseSchemaType } from "@moduletrace/bun-sqlite";
@@ -163,16 +164,18 @@ await BunSQLite.delete({ table: "users", targetId: 1 });
## Configuration ## Configuration
The config file must be named `bun-sqlite.config.ts` and placed at the root of your project. The config file is `bun-sqlite.config.ts` at the root of your project. `bun-sqlite.config.js`, `.json`, `.yaml` and `.yml` also work; keep only one. `.ts`/`.js` files must `export default` the config.
| Field | Type | Required | Description | | Field | Type | Required | Description |
| --------------------- | -------- | -------- | ------------------------------------------------------------------------------------------ | | --------------------- | -------- | -------- | ------------------------------------------------------------------------------------------ |
| `db_name` | `string` | Yes | Filename for the SQLite database (e.g. `"app.db"`) | | `db_name` | `string` | Yes | Filename for the SQLite database (e.g. `"app.db"`) |
| `db_schema_file_name` | `string` | Yes | Filename of the schema file relative to `db_dir` (or root if `db_dir` is not set) | | `db_schema_file_name` | `string` | No | Schema filename relative to `db_dir`. Defaults to `schema.ts\|js\|json\|yaml\|yml` in `db_dir` |
| `db_backup_dir` | `string` | No | Directory for database backups, relative to `db_dir` | | `db_backup_dir` | `string` | No | Directory for database backups, relative to `db_dir` |
| `db_dir` | `string` | No | Root directory for the database file and schema. Defaults to project root | | `db_dir` | `string` | No | Root directory for the database file and schema. Defaults to project root |
| `typedef_file_path` | `string` | No | Output path for generated TypeScript types, relative to project root | | `typedef_file_path` | `string` | No | Output path for generated TypeScript types, relative to project root |
| `max_backups` | `number` | No | Maximum number of backup files to keep. Oldest are deleted automatically. Defaults to `10` | | `max_backups` | `number` | No | Maximum number of backup files to keep. Oldest are deleted automatically. Defaults to `10` |
| `max_exports` | `number` | No | Maximum number of archives kept in `{db_dir}/.exports` by `export`. Defaults to `10` |
| `wal_mode` | `boolean`| No | Enable SQLite `WAL` journal mode |
--- ---
@@ -334,6 +337,43 @@ bunx bun-sqlite restore
--- ---
### `export` — Portable database + schema archive
```bash
# Write {db_name}-{timestamp}.tar.gz into {db_dir}/.exports
bunx bun-sqlite export
# Explicit path / format
bunx bun-sqlite export -o ./my-app.tar.gz
bunx bun-sqlite export -f zip
```
The archive holds `database.sqlite` (a consistent `VACUUM INTO` snapshot) and your schema file as `schema.<ext>`. Archives in `.exports` are trimmed to `max_exports`. `.zip` needs the `zip` binary.
---
### `import` — Database file, SQL script, or export archive
```bash
# Interactive picker (from {db_dir}/.exports)
bunx bun-sqlite import
# Full export (database + schema)
bunx bun-sqlite import ./my-app.tar.gz
# Archive: database only / schema only
bunx bun-sqlite import ./my-app.tar.gz --sql-only
bunx bun-sqlite import ./my-app.tar.gz --schema-only
# A SQLite database file, or an SQL script (run into a fresh database)
bunx bun-sqlite import ./other.db
bunx bun-sqlite import ./dump.sql
```
The current database is backed up to `db_backup_dir` before it is replaced. A `.ts`/`.js` schema from an archive runs as code on the next load, so `import` asks before writing it (pass `--yes` to skip the prompt).
---
## CRUD API ## CRUD API
Import the default export: Import the default export:
@@ -757,12 +797,16 @@ bun-sqlite/
│ │ ├── schema.ts # `schema` command │ │ ├── schema.ts # `schema` command
│ │ ├── typedef.ts # `typedef` command │ │ ├── typedef.ts # `typedef` command
│ │ ├── backup.ts # `backup` command │ │ ├── backup.ts # `backup` command
│ │ └── restore.ts # `restore` command │ │ ├── restore.ts # `restore` command
│ │ ├── export.ts # `export` command
│ │ └── import.ts # `import` command
│ ├── functions/ │ ├── functions/
│ │ └── init.ts # Config + schema loader │ │ ├── init.ts # Loads config, schema and client into globals
│ │ └── grab-sqlite-client.ts # Database client (bun:sqlite + sqlite-vec)
│ ├── lib/
│ │ ├── db-handler.ts # Runs every CRUD query
│ │ └── schema/ # Schema synchronization (one step per file, + tests)
│ ├── lib/sqlite/ │ ├── lib/sqlite/
│ │ ├── index.ts # Database client (bun:sqlite + sqlite-vec)
│ │ ├── db-schema-manager.ts # Schema synchronization engine
│ │ ├── db-select.ts # Select implementation │ │ ├── db-select.ts # Select implementation
│ │ ├── db-insert.ts # Insert implementation │ │ ├── db-insert.ts # Insert implementation
│ │ ├── db-update.ts # Update implementation │ │ ├── db-update.ts # Update implementation
@@ -778,7 +822,9 @@ bun-sqlite/
│ ├── sql-gen-operator-gen.ts # Equality operator mapper │ ├── sql-gen-operator-gen.ts # Equality operator mapper
│ ├── sql-equality-parser.ts # Equality string parser │ ├── sql-equality-parser.ts # Equality string parser
│ ├── append-default-fields-to-db-schema.ts │ ├── append-default-fields-to-db-schema.ts
│ ├── grab-db-dir.ts # Resolve db/backup directory paths │ ├── resolve-and-load-data-file.ts # Load .ts/.js/.json/.yaml config and schema files
│ ├── export-archive.ts # Read/write export archives
│ ├── grab-db-dir.ts # Resolve db/backup/export directory paths
│ ├── grab-db-backup-file-name.ts # Generate timestamped backup filename │ ├── grab-db-backup-file-name.ts # Generate timestamped backup filename
│ ├── grab-sorted-backups.ts # List backups sorted newest-first │ ├── grab-sorted-backups.ts # List backups sorted newest-first
│ ├── grab-backup-data.ts # Parse metadata from a backup filename │ ├── grab-backup-data.ts # Parse metadata from a backup filename
+32 -4
View File
@@ -1,15 +1,35 @@
{ {
"name": "@moduletrace/bun-sqlite", "name": "@moduletrace/bun-sqlite",
"version": "1.1.13", "version": "1.1.13",
"description": "SQLite manager for Bun", "description": "Schema-driven SQLite manager for Bun",
"author": "Benjamin Toby", "author": "Benjamin Toby",
"main": "dist/index.js", "type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./types": {
"types": "./dist/types/index.d.ts",
"import": "./dist/types/index.js",
"default": "./dist/types/index.js"
}
},
"bin": { "bin": {
"bun-sqlite": "dist/commands/index.js" "bun-sqlite": "./dist/commands/index.js"
},
"engines": {
"bun": ">=1.3.0"
}, },
"scripts": { "scripts": {
"dev": "tsc --watch", "dev": "tsc --watch",
"compile": "rm -rf dist && tsc" "compile": "rm -rf dist tsconfig.tsbuildinfo && tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"test": "bun test src",
"prepublishOnly": "bun run compile"
}, },
"devDependencies": { "devDependencies": {
"@types/bun": "latest", "@types/bun": "latest",
@@ -29,6 +49,14 @@
"type": "git", "type": "git",
"url": "git+https://git.tben.me/Moduletrace/bun-sqlite.git" "url": "git+https://git.tben.me/Moduletrace/bun-sqlite.git"
}, },
"keywords": [
"bun",
"sqlite",
"schema",
"orm",
"sql",
"vector"
],
"dependencies": { "dependencies": {
"@inquirer/prompts": "^8.3.0", "@inquirer/prompts": "^8.3.0",
"chalk": "^5.6.2", "chalk": "^5.6.2",
+43 -8
View File
@@ -1,17 +1,52 @@
#!/bin/bash #!/bin/bash
set -e set -euo pipefail
if [ -z "$1" ]; then cd "$(dirname "$0")"
if [ -z "${1:-}" ]; then
msg="Updates" msg="Updates"
else else
msg="$1" msg="$1"
fi fi
tsc --noEmit # Always use the project's TypeScript (global `tsc` may not emit)
rm -rf dist export PATH="$(pwd)/node_modules/.bin:$PATH"
tsc
git add . if [ ! -x "./node_modules/.bin/tsc" ]; then
git commit -m "$msg" echo "Installing dependencies..."
git push bun install
fi
echo "Typechecking..."
tsc -p tsconfig.json --noEmit
echo "Compiling..."
# Clear emit output AND incremental cache. Otherwise a prior --noEmit run leaves
# tsbuildinfo "up to date" and tsc emits nothing after `rm -rf dist`.
rm -rf dist tsconfig.tsbuildinfo
tsc -p tsconfig.json
if [ ! -f dist/commands/index.js ]; then
echo "ERROR: compile failed — dist/commands/index.js was not produced"
exit 1
fi
# Ensure CLI shebang survived compile
if ! head -1 dist/commands/index.js | grep -q '^#!/usr/bin/env bun'; then
echo "Restoring bun shebang on dist/commands/index.js"
printf '%s\n' '#!/usr/bin/env bun' | cat - dist/commands/index.js > dist/commands/index.js.tmp
mv dist/commands/index.js.tmp dist/commands/index.js
fi
chmod +x dist/commands/index.js
echo "Build OK: $(head -1 dist/commands/index.js)"
echo "Publishing package..."
git add -A
git commit -m "$msg" || true
git push || true
bun publish bun publish
echo "Done."
+1 -9
View File
@@ -1,22 +1,14 @@
import { Command } from "commander"; import { Command } from "commander";
import init from "../../functions/init";
import grabDBDir from "../../utils/grab-db-dir";
import chalk from "chalk"; import chalk from "chalk";
import { select } from "@inquirer/prompts"; import { select } from "@inquirer/prompts";
import { Database } from "bun:sqlite";
import listTables from "./list-tables"; import listTables from "./list-tables";
import runSQL from "./run-sql"; import runSQL from "./run-sql";
import * as sqliteVec from "sqlite-vec";
export default function () { export default function () {
return new Command("admin") return new Command("admin")
.description("View Tables and Data, Run SQL Queries, Etc.") .description("View Tables and Data, Run SQL Queries, Etc.")
.action(async () => { .action(async () => {
const { config } = await init(); const db = global.SQLITE_CLIENT;
const { db_file_path } = grabDBDir({ config });
const db = new Database(db_file_path);
sqliteVec.load(db);
console.log(chalk.bold(chalk.blue("\nBun SQLite Admin\n"))); console.log(chalk.bold(chalk.blue("\nBun SQLite Admin\n")));
+2 -4
View File
@@ -1,11 +1,9 @@
import { Command } from "commander"; import { Command } from "commander";
import init from "../functions/init";
import path from "path"; import path from "path";
import grabDBDir from "../utils/grab-db-dir"; import grabDBDir from "../utils/grab-db-dir";
import grabDBBackupFileName from "../utils/grab-db-backup-file-name"; import grabDBBackupFileName from "../utils/grab-db-backup-file-name";
import chalk from "chalk"; import chalk from "chalk";
import trimBackups from "../utils/trim-backups"; import trimBackups from "../utils/trim-backups";
import DbClient from "../lib/sqlite";
import snapshotDb from "../utils/snapshot-db"; import snapshotDb from "../utils/snapshot-db";
export default function () { export default function () {
@@ -14,14 +12,14 @@ export default function () {
.action(async (opts) => { .action(async (opts) => {
console.log(`Backing up database ...`); console.log(`Backing up database ...`);
const { config } = await init(); const config = global.CONFIG;
const { backup_dir } = grabDBDir({ config }); const { backup_dir } = grabDBDir({ config });
const new_db_file_name = grabDBBackupFileName({ config }); const new_db_file_name = grabDBBackupFileName({ config });
snapshotDb({ snapshotDb({
db: DbClient, db: global.SQLITE_CLIENT,
dst: path.join(backup_dir, new_db_file_name), dst: path.join(backup_dir, new_db_file_name),
}); });
+105
View File
@@ -0,0 +1,105 @@
import { Command } from "commander";
import path from "path";
import fs from "fs";
import chalk from "chalk";
import grabDBDir from "../utils/grab-db-dir";
import grabDirNames from "../data/grab-dir-names";
import snapshotDb from "../utils/snapshot-db";
import { writeExportArchive } from "../utils/export-archive";
import trimExports from "../utils/trim-exports";
import { AppData } from "../data/app-data";
function defaultExportFileName(
dbName: string,
format: "tar.gz" | "zip",
): string {
return `${dbName}-${Date.now()}.${format}`;
}
export default function () {
return new Command("export")
.description("Export database file + schema into a portable archive")
.option(
"-o, --output <path>",
"Output archive path (.tar.gz or .zip). Defaults to db exports dir",
)
.option(
"-f, --format <format>",
"Archive format when --output is omitted: tar.gz | zip",
"tar.gz",
)
.action(async (opts) => {
console.log(`Exporting database ...`);
const config = global.CONFIG;
const { export_dir } = grabDBDir({ config });
const { BUN_SQLITE_TEMP_DIR } = grabDirNames();
if (!fs.existsSync(export_dir)) {
fs.mkdirSync(export_dir, { recursive: true });
}
const formatRaw = String(opts.format || "tar.gz").toLowerCase();
const format: "tar.gz" | "zip" =
formatRaw === "zip" ? "zip" : "tar.gz";
let outPath: string;
if (opts.output) {
outPath = path.resolve(opts.output);
fs.mkdirSync(path.dirname(outPath), { recursive: true });
} else {
outPath = path.join(
export_dir,
defaultExportFileName(config.db_name, format),
);
}
const snapshot_path = path.join(
BUN_SQLITE_TEMP_DIR,
`export-${Date.now()}.db`,
);
/** Remove the temp database file before exiting */
const exit = (code: number): never => {
fs.rmSync(snapshot_path, { force: true });
process.exit(code);
};
try {
snapshotDb({ db: global.SQLITE_CLIENT, dst: snapshot_path });
/**
* The schema is always stored as `schema.<ext>` so `import`
* can find it, whatever `db_schema_file_name` is.
*/
const schemaFileName = `${AppData.DbSchemaFileName}${path.extname(global.SCHEMA_FILE_PATH)}`;
await writeExportArchive({
contents: {
db: new Uint8Array(fs.readFileSync(snapshot_path)),
schema: fs.readFileSync(
global.SCHEMA_FILE_PATH,
"utf-8",
),
schemaFileName,
},
outPath,
});
if (path.dirname(outPath) === export_dir) {
trimExports({ config });
}
console.log(
`${chalk.bold(chalk.green(`DB Export Success!`))} → ${outPath}`,
);
console.log(
chalk.dim(`Contains: database.sqlite + ${schemaFileName}`),
);
exit(0);
} catch (error: any) {
console.error(chalk.red(`Export ERROR => ${error.message}`));
exit(1);
}
});
}
+283
View File
@@ -0,0 +1,283 @@
import { Command } from "commander";
import path from "path";
import fs from "fs";
import chalk from "chalk";
import { Database } from "bun:sqlite";
import * as sqliteVec from "sqlite-vec";
import { confirm, select } from "@inquirer/prompts";
import grabDBDir from "../utils/grab-db-dir";
import grabDirNames from "../data/grab-dir-names";
import grabSortedExports from "../utils/grab-sorted-exports";
import grabBackupData from "../utils/grab-backup-data";
import grabDBBackupFileName from "../utils/grab-db-backup-file-name";
import snapshotDb from "../utils/snapshot-db";
import trimBackups from "../utils/trim-backups";
import replaceDbFile from "../utils/replace-db-file";
import {
isArchivePath,
isSQLiteFile,
isSqlPath,
readExportArchive,
} from "../utils/export-archive";
import { resolveDataFile } from "../utils/resolve-and-load-data-file";
import { AppData } from "../data/app-data";
function formatChoice(name: string, index: number): string {
const { backup_date } = grabBackupData({
backup_name: name.replace(/\.(tar\.gz|tgz|tar|zip)$/i, ""),
});
const time = Number.isNaN(backup_date.getTime())
? "unknown date"
: `${backup_date.toDateString()} ${backup_date.getHours()}:${backup_date.getMinutes()}:${backup_date.getSeconds().toString().padStart(2, "0")}`;
return `#${index + 1}: ${name} (${time})`;
}
/**
* # Replace the live database with the database file at `src`
* @description The current database is backed up first.
*/
function swapDatabase(src: string) {
const config = global.CONFIG;
const { backup_dir, db_file_path } = grabDBDir({ config });
const backup_path = path.join(backup_dir, grabDBBackupFileName({ config }));
snapshotDb({ db: global.SQLITE_CLIENT, dst: backup_path });
trimBackups({ config });
console.log(chalk.dim(`Backed up current database → ${backup_path}`));
global.SQLITE_CLIENT.close();
replaceDbFile({ src, db_file_path });
}
/**
* # Build a database file from an SQL script
*/
function buildDbFromSql({ sql, dst }: { sql: string; dst: string }) {
fs.mkdirSync(path.dirname(dst), { recursive: true });
fs.rmSync(dst, { force: true });
const db = new Database(dst, { create: true });
try {
sqliteVec.load(db);
db.run(sql);
} finally {
db.close();
}
}
/**
* # Write the schema file from an archive into the project
*/
function writeSchema({
schema,
schemaFileName,
}: {
schema: string;
schemaFileName: string;
}) {
const config = global.CONFIG;
const { db_dir } = grabDBDir({ config });
if (config.db_schema_file_name) {
const schemaPath = path.join(db_dir, config.db_schema_file_name);
if (path.extname(schemaPath) !== path.extname(schemaFileName)) {
throw new Error(
`Archive schema is \`${schemaFileName}\` but \`db_schema_file_name\` is \`${config.db_schema_file_name}\`. Use --sql-only, or change \`db_schema_file_name\`.`,
);
}
fs.writeFileSync(schemaPath, schema, "utf-8");
console.log(chalk.green(`Schema written → ${schemaPath}`));
return;
}
const existing = resolveDataFile(db_dir, AppData.DbSchemaFileName);
const schemaPath = path.join(db_dir, schemaFileName);
// Remove a differently-named schema so only one format remains
if (existing && path.resolve(existing.path) !== path.resolve(schemaPath)) {
fs.unlinkSync(existing.path);
console.log(
chalk.dim(`Removed previous schema file: ${existing.basename}`),
);
}
fs.writeFileSync(schemaPath, schema, "utf-8");
console.log(chalk.green(`Schema written → ${schemaPath}`));
}
export default function () {
return new Command("import")
.description(
"Import a SQLite database file, an SQL script, or a full export archive (database + schema)",
)
.argument(
"[file]",
"Path to a SQLite file, .sql file, or export archive (.tar.gz / .tar / .zip)",
)
.option(
"--sql-only",
"When importing an archive, restore the database only (skip writing schema)",
)
.option(
"--schema-only",
"When importing an archive, write schema only (skip database restore)",
)
.option(
"-y, --yes",
"Do not ask for confirmation before writing an executable (.ts/.js) schema from the archive",
)
.action(async (fileArg: string | undefined, opts) => {
console.log(`Importing database ...`);
const config = global.CONFIG;
const { db_dir, export_dir } = grabDBDir({ config });
const { BUN_SQLITE_TEMP_DIR } = grabDirNames();
const temp_db_path = path.join(
BUN_SQLITE_TEMP_DIR,
`import-${Date.now()}.db`,
);
/** Remove the temp database file before exiting */
const exit = (code: number): never => {
fs.rmSync(temp_db_path, { force: true });
process.exit(code);
};
try {
let filePath = fileArg ? path.resolve(fileArg) : "";
if (!filePath) {
const candidates = grabSortedExports({ config }).filter(
(b) => isArchivePath(b),
);
if (!candidates[0]) {
console.error(
`No export archives found in \`${export_dir}\`. Use \`export\`, or pass a file path.`,
);
exit(1);
}
const selected = await select({
message: "Select an export to import:",
choices: candidates.map((b, i) => ({
name: formatChoice(b, i),
value: b,
})),
});
filePath = path.join(export_dir, selected);
}
if (!fs.existsSync(filePath)) {
console.error(chalk.red(`File not found: ${filePath}`));
exit(1);
}
if (opts.sqlOnly && opts.schemaOnly) {
console.error(
chalk.red(
`Cannot combine --sql-only and --schema-only.`,
),
);
exit(1);
}
if (!isArchivePath(filePath)) {
if (opts.schemaOnly) {
console.error(
chalk.red(
`--schema-only requires an export archive.`,
),
);
exit(1);
}
if (isSqlPath(filePath)) {
buildDbFromSql({
sql: fs.readFileSync(filePath, "utf-8"),
dst: temp_db_path,
});
swapDatabase(temp_db_path);
} else if (
isSQLiteFile(new Uint8Array(fs.readFileSync(filePath)))
) {
swapDatabase(filePath);
} else {
console.error(
chalk.red(
`Unsupported file: ${filePath}. Use a SQLite database file, .sql, .tar.gz, .tar, or .zip.`,
),
);
exit(1);
}
console.log(
`${chalk.bold(chalk.green(`DB Import Success!`))} ← ${filePath}`,
);
exit(0);
}
const { db, schema, schemaFileName } =
await readExportArchive(filePath);
/**
* A .ts/.js schema is executed (require) on the next run.
* Confirm before writing code from an archive into the project.
*/
const isExecutableSchema = /\.(ts|js)$/i.test(schemaFileName);
if (!opts.sqlOnly && isExecutableSchema && !opts.yes) {
if (!process.stdin.isTTY) {
console.error(
chalk.red(
`Archive contains an executable schema (${schemaFileName}). Re-run with --yes to accept it, or --sql-only to skip it.`,
),
);
exit(1);
}
console.log(
chalk.yellow(
`\nThis archive contains an executable schema file (${schemaFileName}).\nIt will run as code the next time bun-sqlite or your app loads it.\nOnly continue if you trust the source of this archive.\n`,
),
);
const proceed = await confirm({
message: `Write ${schemaFileName} into ${db_dir}?`,
default: false,
});
if (!proceed) {
console.log(chalk.dim(`Import cancelled.`));
exit(1);
}
}
if (!opts.sqlOnly) {
writeSchema({ schema, schemaFileName });
}
if (!opts.schemaOnly) {
fs.mkdirSync(path.dirname(temp_db_path), {
recursive: true,
});
fs.writeFileSync(temp_db_path, db);
swapDatabase(temp_db_path);
console.log(chalk.green(`Database restored from archive`));
}
console.log(
`${chalk.bold(chalk.green(`DB Import Success!`))} ← ${filePath}`,
);
exit(0);
} catch (error: any) {
console.error(chalk.red(`Import ERROR => ${error.message}`));
exit(1);
}
});
}
+11 -4
View File
@@ -5,12 +5,17 @@ import schema from "./schema";
import typedef from "./typedef"; import typedef from "./typedef";
import backup from "./backup"; import backup from "./backup";
import restore from "./restore"; import restore from "./restore";
import exportCmd from "./export";
import importCmd from "./import";
import admin from "./admin"; import admin from "./admin";
import init from "../functions/init";
/** try {
* # Declare Global Variables init();
*/ } catch (error: any) {
declare global {} console.error(`Initialization ERROR => ${error.message}`);
process.exit(1);
}
/** /**
* # Describe Program * # Describe Program
@@ -27,6 +32,8 @@ program.addCommand(schema());
program.addCommand(typedef()); program.addCommand(typedef());
program.addCommand(backup()); program.addCommand(backup());
program.addCommand(restore()); program.addCommand(restore());
program.addCommand(exportCmd());
program.addCommand(importCmd());
program.addCommand(admin()); program.addCommand(admin());
/** /**
+2 -4
View File
@@ -1,7 +1,5 @@
import { Command } from "commander"; import { Command } from "commander";
import init from "../functions/init";
import grabDBDir from "../utils/grab-db-dir"; import grabDBDir from "../utils/grab-db-dir";
import DbClient from "../lib/sqlite";
import replaceDbFile from "../utils/replace-db-file"; import replaceDbFile from "../utils/replace-db-file";
import chalk from "chalk"; import chalk from "chalk";
import grabSortedBackups from "../utils/grab-sorted-backups"; import grabSortedBackups from "../utils/grab-sorted-backups";
@@ -15,7 +13,7 @@ export default function () {
.action(async (opts) => { .action(async (opts) => {
console.log(`Restoring up database ...`); console.log(`Restoring up database ...`);
const { config } = await init(); const config = global.CONFIG;
const { backup_dir, db_file_path } = grabDBDir({ config }); const { backup_dir, db_file_path } = grabDBDir({ config });
@@ -42,7 +40,7 @@ export default function () {
}), }),
}); });
DbClient.close(); global.SQLITE_CLIENT.close();
replaceDbFile({ replaceDbFile({
src: path.join(backup_dir, selected_backup), src: path.join(backup_dir, selected_backup),
+8 -9
View File
@@ -1,15 +1,12 @@
import { Command } from "commander"; import { Command } from "commander";
import { SQLiteSchemaManager } from "../lib/sqlite/db-schema-manager"; import createDBSchema from "../lib/schema/create-db-schema";
import init from "../functions/init";
import grabDirNames from "../data/grab-dir-names"; import grabDirNames from "../data/grab-dir-names";
import path from "path"; import path from "path";
import dbSchemaToTypeDef from "../lib/sqlite/schema-to-typedef"; import dbSchemaToTypeDef from "../lib/sqlite/schema-to-typedef";
import _ from "lodash";
import appendDefaultFieldsToDbSchema from "../utils/append-default-fields-to-db-schema"; import appendDefaultFieldsToDbSchema from "../utils/append-default-fields-to-db-schema";
import chalk from "chalk"; import chalk from "chalk";
import { writeLiveSchema } from "../functions/live-schema"; import { writeLiveSchema } from "../functions/live-schema";
import grabDBDir from "../utils/grab-db-dir"; import grabDBDir from "../utils/grab-db-dir";
import DbClient from "../lib/sqlite";
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";
@@ -24,7 +21,9 @@ export default function () {
.action(async (opts) => { .action(async (opts) => {
console.log(`Starting process ...`); console.log(`Starting process ...`);
const { config, dbSchema } = await init(); const config = global.CONFIG;
const dbSchema = global.DB_SCHEMA;
const DbClient = global.SQLITE_CLIENT;
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 });
@@ -38,13 +37,13 @@ export default function () {
dbSchema, dbSchema,
}); });
const manager = new SQLiteSchemaManager({ createDBSchema({
schema: finaldbSchema, db: DbClient,
db_schema: finaldbSchema,
recreate_vector_table: isVector, recreate_vector_table: isVector,
}); });
await manager.syncSchema(); DbClient.close();
manager.close();
if (isTypeDef && config.typedef_file_path) { if (isTypeDef && config.typedef_file_path) {
const out_file = path.resolve( const out_file = path.resolve(
+5 -3
View File
@@ -1,5 +1,4 @@
import { Command } from "commander"; import { Command } from "commander";
import init from "../functions/init";
import dbSchemaToTypeDef from "../lib/sqlite/schema-to-typedef"; import dbSchemaToTypeDef from "../lib/sqlite/schema-to-typedef";
import path from "path"; import path from "path";
import grabDirNames from "../data/grab-dir-names"; import grabDirNames from "../data/grab-dir-names";
@@ -12,7 +11,8 @@ export default function () {
.action(async (opts) => { .action(async (opts) => {
console.log(`Creating Type Definition From DB Schema ...`); console.log(`Creating Type Definition From DB Schema ...`);
const { config, dbSchema } = await init(); const config = global.CONFIG;
const dbSchema = global.DB_SCHEMA;
const { ROOT_DIR } = grabDirNames(); const { ROOT_DIR } = grabDirNames();
const finaldbSchema = appendDefaultFieldsToDbSchema({ dbSchema }); const finaldbSchema = appendDefaultFieldsToDbSchema({ dbSchema });
@@ -28,7 +28,9 @@ export default function () {
config, config,
}); });
} else { } else {
console.error(``); console.error(
`\`typedef_file_path\` is required in your config`,
);
process.exit(1); process.exit(1);
} }
+6 -1
View File
@@ -1,6 +1,11 @@
export const AppData = { export const AppData = {
ConfigFileName: "bun-sqlite.config.ts", ConfigFileName: "bun-sqlite.config",
MaxBackups: 10, MaxBackups: 10,
MaxExports: 10,
DefaultBackupDirName: ".backups", DefaultBackupDirName: ".backups",
DefaultExportDirName: ".exports",
DbSchemaManagerTableName: "__db_schema_manager__", DbSchemaManagerTableName: "__db_schema_manager__",
DbSchemaFileName: "schema",
/** Priority order when resolving config/schema files */
SupportedDataFileExtensions: [".ts", ".js", ".json", ".yaml", ".yml"] as const,
} as const; } as const;
+33
View File
@@ -0,0 +1,33 @@
import { Database } from "bun:sqlite";
import * as sqliteVec from "sqlite-vec";
import type { BunSQLiteConfig } from "../types";
import grabDBDir from "../utils/grab-db-dir";
type Params = {
config: BunSQLiteConfig;
};
/** Reuse one connection per database file. */
const clientCache = new Map<string, Database>();
export default function grabSQLiteClient({ config }: Params): Database {
const { db_file_path } = grabDBDir({ config });
const cached = clientCache.get(db_file_path);
if (cached) {
return cached;
}
const SQLiteClient = new Database(db_file_path, {
create: true,
});
sqliteVec.load(SQLiteClient);
if (config.wal_mode) {
SQLiteClient.run("PRAGMA journal_mode = WAL;");
}
clientCache.set(db_file_path, SQLiteClient);
return SQLiteClient;
}
+127 -23
View File
@@ -1,5 +1,6 @@
import path from "path"; import path from "path";
import fs from "fs"; import fs from "fs";
import type { Database } from "bun:sqlite";
import { AppData } from "../data/app-data"; import { AppData } from "../data/app-data";
import grabDirNames from "../data/grab-dir-names"; import grabDirNames from "../data/grab-dir-names";
import type { import type {
@@ -7,50 +8,153 @@ import type {
BunSQLiteConfigReturn, BunSQLiteConfigReturn,
BUN_SQLITE_DatabaseSchemaType, BUN_SQLITE_DatabaseSchemaType,
} from "../types"; } from "../types";
import setSQLiteClient from "./set-sqlite-client";
import grabDBDir from "../utils/grab-db-dir";
import {
loadDataFile,
resolveAndLoadDataFile,
supportedDataFileNames,
type LoadedDataFile,
type ResolvedDataFile,
} from "../utils/resolve-and-load-data-file";
/**
* # Declare Global Variables
*/
declare global {
var CONFIG: BunSQLiteConfig;
var DB_SCHEMA: BUN_SQLITE_DatabaseSchemaType;
var SQLITE_CLIENT: Database;
var CONFIG_FILE_PATH: string;
var SCHEMA_FILE_PATH: string;
}
/**
* # Load config, schema and the SQLite client into globals
* @description Runs once per process. Later calls return the loaded
* globals.
*/
export default function init(): BunSQLiteConfigReturn {
if (global.CONFIG && global.DB_SCHEMA && global.SQLITE_CLIENT) {
return { config: global.CONFIG, dbSchema: global.DB_SCHEMA };
}
export default async function init(): Promise<BunSQLiteConfigReturn> {
const { ROOT_DIR } = grabDirNames(); const { ROOT_DIR } = grabDirNames();
const { ConfigFileName } = AppData; const { ConfigFileName } = AppData;
const ConfigFilePath = path.join(ROOT_DIR, ConfigFileName); const loadedConfig = resolveAndLoadDataFile<BunSQLiteConfig>(
ROOT_DIR,
ConfigFileName,
);
if (!fs.existsSync(ConfigFilePath)) { if (!loadedConfig) {
throw new Error( throw new Error(
`Please create a \`${ConfigFileName}\` file at the root of your project. (Looked for ${ConfigFilePath})`, `Please create a ${supportedDataFileNames(ConfigFileName)} file at the root of your project. (Looked in ${ROOT_DIR})`,
); );
} }
const ConfigImport = await import(ConfigFilePath); const Config = loadedConfig.data;
const Config = ConfigImport["default"] as BunSQLiteConfig;
if (!Config || typeof Config !== "object") {
throw new Error(
`Invalid config in \`${loadedConfig.path}\`. Expected a config object${isCodeFile(loadedConfig) ? " (export default)" : ""}.`,
);
}
if (!Config.db_name) { if (!Config.db_name) {
throw new Error(`\`db_name\` is required in your config`); throw new Error(`\`db_name\` is required in your config`);
} }
if (!Config.db_schema_file_name) { const { db_dir, backup_dir } = grabDBDir({ config: Config });
throw new Error(`\`db_schema_file_name\` is required in your config`);
if (!fs.existsSync(db_dir)) {
fs.mkdirSync(db_dir, { recursive: true });
} }
let db_dir = ROOT_DIR; const loadedSchema = loadSchema({ config: Config, db_dir });
if (Config.db_dir) { const DbSchema = loadedSchema.data;
db_dir = path.resolve(ROOT_DIR, Config.db_dir);
if (!fs.existsSync(db_dir)) { if (!DbSchema || typeof DbSchema !== "object") {
fs.mkdirSync(db_dir, { recursive: true }); throw new Error(
} `Invalid schema in \`${loadedSchema.path}\`. Expected a schema object${isCodeFile(loadedSchema) ? " (export default)" : ""}.`,
);
} }
const DBSchemaFilePath = path.join(db_dir, Config.db_schema_file_name); if (!fs.existsSync(backup_dir)) {
const DbSchemaImport = await import(DBSchemaFilePath); fs.mkdirSync(backup_dir, { recursive: true });
const DbSchema = DbSchemaImport["default"] as BUN_SQLITE_DatabaseSchemaType;
const backup_dir = Config.db_backup_dir || AppData["DefaultBackupDirName"];
const BackupDir = path.resolve(db_dir, backup_dir);
if (!fs.existsSync(BackupDir)) {
fs.mkdirSync(BackupDir, { recursive: true });
} }
global.CONFIG = Config;
global.DB_SCHEMA = DbSchema;
global.CONFIG_FILE_PATH = loadedConfig.path;
global.SCHEMA_FILE_PATH = loadedSchema.path;
setSQLiteClient({ config: Config });
return { config: Config, dbSchema: DbSchema }; return { config: Config, dbSchema: DbSchema };
} }
/**
* # Load the schema file
* @description Uses `db_schema_file_name` when set, otherwise
* `schema.ts|js|json|yaml|yml` in `db_dir`.
*/
function loadSchema({
config,
db_dir,
}: {
config: BunSQLiteConfig;
db_dir: string;
}): LoadedDataFile<BUN_SQLITE_DatabaseSchemaType> {
if (config.db_schema_file_name) {
const schema_file_path = path.join(db_dir, config.db_schema_file_name);
const extension = path.extname(schema_file_path);
const supported = AppData.SupportedDataFileExtensions.find(
(ext) => ext === extension,
);
if (!supported) {
throw new Error(
`Unsupported schema file \`${config.db_schema_file_name}\`. Use one of: ${AppData.SupportedDataFileExtensions.join(", ")}`,
);
}
if (!fs.existsSync(schema_file_path)) {
throw new Error(`Schema file not found: ${schema_file_path}`);
}
const resolved: ResolvedDataFile = {
path: schema_file_path,
basename: path.basename(schema_file_path),
extension: supported,
format:
supported === ".yml"
? "yaml"
: (supported.slice(1) as ResolvedDataFile["format"]),
};
return {
...resolved,
data: loadDataFile<BUN_SQLITE_DatabaseSchemaType>(resolved),
};
}
const loaded = resolveAndLoadDataFile<BUN_SQLITE_DatabaseSchemaType>(
db_dir,
AppData.DbSchemaFileName,
);
if (!loaded) {
throw new Error(
`Please create a schema file (${supportedDataFileNames(AppData.DbSchemaFileName)}) in \`${db_dir}\`, or set \`db_schema_file_name\` in your config.`,
);
}
return loaded;
}
function isCodeFile(file: ResolvedDataFile) {
return file.format === "ts" || file.format === "js";
}
+10
View File
@@ -0,0 +1,10 @@
import type { BunSQLiteConfig } from "../types";
import grabSQLiteClient from "./grab-sqlite-client";
type Params = {
config: BunSQLiteConfig;
};
export default function setSQLiteClient({ config }: Params): void {
global.SQLITE_CLIENT = grabSQLiteClient({ config });
}
+21
View File
@@ -1,3 +1,4 @@
import init from "./functions/init";
import DbDelete from "./lib/sqlite/db-delete"; import DbDelete from "./lib/sqlite/db-delete";
import DbInsert from "./lib/sqlite/db-insert"; import DbInsert from "./lib/sqlite/db-insert";
import DbSelect from "./lib/sqlite/db-select"; import DbSelect from "./lib/sqlite/db-select";
@@ -6,6 +7,8 @@ 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();
const BunSQLite = { const BunSQLite = {
select: DbSelect, select: DbSelect,
insert: DbInsert, insert: DbInsert,
@@ -19,3 +22,21 @@ const BunSQLite = {
} as const; } as const;
export default BunSQLite; export default BunSQLite;
export type * from "./types";
export {
UsersOmitedFields,
MariaDBCollations,
TextFieldTypesArray,
BUN_SQLITE_DATATYPES,
ServerQueryOperators,
ServerQueryEqualities,
SQlComparisons,
DataCrudRequestMethods,
DataCrudRequestMethodsLowerCase,
DsqlCrudActions,
QueryFields,
DockerComposeServices,
IndexTypes,
DefaultFields,
} from "./types";
+73
View File
@@ -0,0 +1,73 @@
import type { Database } from "bun:sqlite";
import init from "../functions/init";
import grabSQLiteClient from "../functions/grab-sqlite-client";
import type {
APIResponseObject,
BunSQLiteConfig,
SQLInsertGenValueType,
} from "../types";
type Param = {
query: string;
values?: SQLInsertGenValueType[];
config?: BunSQLiteConfig;
};
/**
* Resolve the SQLite client. The global client is used unless a `config`
* for another database is passed.
*/
function resolveClient(config?: BunSQLiteConfig): Database {
if (config) {
return grabSQLiteClient({ config });
}
init();
return global.SQLITE_CLIENT;
}
/**
* # Main DB Handler Function
* @description Any statement that produces columns (SELECT, WITH, PRAGMA,
* RETURNING, ...) returns rows. Everything else goes through `run`, which
* also handles multiple statements.
*/
export default function dbHandler<
T extends { [k: string]: any } = { [k: string]: any },
>({ query, values, config }: Param): APIResponseObject<T> {
try {
const CLIENT = resolveClient(config);
const stmt = CLIENT.prepare(query);
const returnsRows = stmt.columnNames.length > 0;
try {
if (returnsRows) {
const rows = stmt.all(...((values || []) as any[])) as T[];
return {
success: true,
payload: rows,
singleRes: rows[0],
};
}
} finally {
stmt.finalize();
}
const res = CLIENT.run(query, (values || []) as any[]);
return {
success: true,
postInsertReturn: {
affectedRows: res.changes,
insertId: Number(res.lastInsertRowid),
},
};
} catch (error: any) {
return {
success: false,
error: error.message,
};
}
}
+1 -1
View File
@@ -9,7 +9,7 @@ type Params = {
export default async function ({ sql: passed_sql, table, data }: Params) { export default async function ({ sql: passed_sql, table, data }: Params) {
let sql = passed_sql; let sql = passed_sql;
const { dbSchema } = await init(); const { dbSchema } = init();
const table_schema = dbSchema.tables.find((t) => t.tableName == table); const table_schema = dbSchema.tables.find((t) => t.tableName == table);
const now = Date.now(); const now = Date.now();
+65
View File
@@ -0,0 +1,65 @@
import type { BUN_SQLITE_FieldSchemaType } from "../../types";
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
import mapDataType from "./map-data-types";
/**
* Build column definition SQL
*/
export default function buildColumnDefinition(
field: BUN_SQLITE_FieldSchemaType,
is_vector?: boolean,
): string {
if (!field.fieldName) {
throw new Error("Field name is required");
}
// vec0 can't parse quoted column names
const fieldName = is_vector
? field.sideCar
? `+${field.fieldName}`
: `${field.fieldName}`
: q(field.fieldName);
const parts: string[] = [fieldName];
// Data type mapping
parts.push(mapDataType(field));
// Primary key
if (field.primaryKey) {
parts.push("PRIMARY KEY");
if (field.autoIncrement) {
parts.push("AUTOINCREMENT");
}
}
// Not null
if (field.notNullValue && !field.primaryKey) {
parts.push("NOT NULL");
}
// Unique
if (field.unique && !field.primaryKey) {
parts.push("UNIQUE");
}
if (field.vectorDistanceMetric) {
parts.push(`distance_metric=${field.vectorDistanceMetric}`);
}
// Default value
if (field.defaultValue !== undefined) {
if (typeof field.defaultValue === "string") {
parts.push(
// Escape single quotes by doubling them to prevent SQL injection and wrap in single quotes
`DEFAULT '${field.defaultValue.replace(/'/g, "''")}'`,
);
} else {
parts.push(`DEFAULT ${field.defaultValue}`);
}
} else if (field.defaultValueLiteral) {
parts.push(`DEFAULT ${field.defaultValueLiteral}`);
}
return parts.join(" ");
}
@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test";
import type { BUN_SQLITE_FieldSchemaType } from "../../types";
import buildForeignKeyConstraint from "./build-foreign-key-constraint";
function field(
fk: Partial<NonNullable<BUN_SQLITE_FieldSchemaType["foreignKey"]>> = {},
): BUN_SQLITE_FieldSchemaType {
return {
fieldName: "user_id",
dataType: "INTEGER",
foreignKey: {
destinationTableName: "users",
destinationTableColumnName: "id",
...fk,
},
};
}
describe("buildForeignKeyConstraint", () => {
test("references the destination column", () => {
expect(buildForeignKeyConstraint(field())).toBe(
`FOREIGN KEY ("user_id") REFERENCES "users"("id")`,
);
});
test("adds cascade rules", () => {
expect(
buildForeignKeyConstraint(
field({ cascadeDelete: true, cascadeUpdate: true }),
),
).toBe(
`FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE`,
);
});
test("quotes identifiers", () => {
expect(
buildForeignKeyConstraint(
field({ destinationTableName: `we"ird` }),
),
).toBe(`FOREIGN KEY ("user_id") REFERENCES "we""ird"("id")`);
});
});
@@ -0,0 +1,22 @@
import type { BUN_SQLITE_FieldSchemaType } from "../../types";
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
/**
* Build foreign key constraint
*/
export default function buildForeignKeyConstraint(
field: BUN_SQLITE_FieldSchemaType,
): string {
const fk = field.foreignKey!;
let constraint = `FOREIGN KEY (${q(field.fieldName!)}) REFERENCES ${q(String(fk.destinationTableName))}(${q(String(fk.destinationTableColumnName))})`;
if (fk.cascadeDelete) {
constraint += " ON DELETE CASCADE";
}
if (fk.cascadeUpdate) {
constraint += " ON UPDATE CASCADE";
}
return constraint;
}
+14
View File
@@ -0,0 +1,14 @@
import type { Database } from "bun:sqlite";
type Params = {
db: Database;
table_name: string;
};
export default function checkIfTableExists({ db, table_name }: Params) {
const tableExists = db
.query(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`)
.get(table_name) as any;
return Boolean(tableExists?.name);
}
+13
View File
@@ -0,0 +1,13 @@
import type { Database } from "bun:sqlite";
import { AppData } from "../../data/app-data";
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
export default function createDBManagerTable({ db }: { db: Database }) {
db.run(`
CREATE TABLE IF NOT EXISTS ${q(AppData["DbSchemaManagerTableName"])} (
table_name TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
`);
}
+146
View File
@@ -0,0 +1,146 @@
import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
import { Database } from "bun:sqlite";
import type {
BUN_SQLITE_FieldSchemaType,
BUN_SQLITE_TableSchemaType,
} from "../../types";
import createDBSchema from "./create-db-schema";
const id: BUN_SQLITE_FieldSchemaType = {
fieldName: "id",
dataType: "INTEGER",
primaryKey: true,
autoIncrement: true,
};
const users: BUN_SQLITE_TableSchemaType = {
tableName: "users",
fields: [id, { fieldName: "name", dataType: "TEXT" }],
};
const posts: BUN_SQLITE_TableSchemaType = {
tableName: "posts",
fields: [
id,
{
fieldName: "user_id",
dataType: "INTEGER",
foreignKey: {
destinationTableName: "users",
destinationTableColumnName: "id",
cascadeDelete: true,
},
},
{ fieldName: "title", dataType: "TEXT" },
],
indexes: [{ indexName: "posts_title_idx", indexTableFields: ["title"] }],
};
let db: Database;
function sync(tables: BUN_SQLITE_TableSchemaType[]) {
createDBSchema({ db, db_schema: { tables } });
}
function tableNames() {
return (
db
.query(
`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE '\\_\\_%' ESCAPE '\\' AND name NOT LIKE 'sqlite_%' ORDER BY name`,
)
.all() as { name: string }[]
).map((r) => r.name);
}
beforeEach(() => {
spyOn(console, "log").mockImplementation(() => {});
db = new Database(":memory:");
});
afterEach(() => {
db.close();
});
describe("createDBSchema", () => {
test("creates tables, foreign keys and indexes", () => {
sync([posts, users]);
expect(tableNames()).toEqual(["posts", "users"]);
expect(
db.query(`PRAGMA foreign_key_list("posts")`).all(),
).toMatchObject([{ table: "users", from: "user_id", to: "id" }]);
expect(
db
.query(
`SELECT name FROM sqlite_master WHERE type='index' AND name = 'posts_title_idx'`,
)
.get(),
).toBeTruthy();
});
test("keeps data (and child rows) when a table is rebuilt", () => {
sync([users, posts]);
db.run(`INSERT INTO users (name) VALUES ('ada')`);
db.run(`INSERT INTO posts (user_id, title) VALUES (1, 'hello')`);
sync([
{
...users,
fields: [...users.fields, { fieldName: "email", dataType: "TEXT" }],
},
posts,
]);
expect(db.query(`SELECT name, email FROM users`).all()).toEqual([
{ name: "ada", email: null },
]);
expect(db.query(`SELECT title FROM posts`).all()).toEqual([
{ title: "hello" },
]);
});
test("drops tables removed from the schema", () => {
sync([users, posts]);
sync([users]);
expect(tableNames()).toEqual(["users"]);
});
test("renames tables with tableNameOld", () => {
sync([users]);
db.run(`INSERT INTO users (name) VALUES ('ada')`);
sync([{ ...users, tableName: "people", tableNameOld: "users" }]);
expect(tableNames()).toEqual(["people"]);
expect(db.query(`SELECT name FROM people`).all()).toEqual([
{ name: "ada" },
]);
});
test("rolls back on foreign key violations", () => {
sync([users, posts]);
db.run(`PRAGMA foreign_keys = OFF`);
db.run(`INSERT INTO posts (user_id, title) VALUES (99, 'orphan')`);
db.run(`PRAGMA foreign_keys = ON`);
expect(() =>
sync([
{
...users,
fields: [
...users.fields,
{ fieldName: "email", dataType: "TEXT" },
],
},
posts,
]),
).toThrow(/Foreign key violations/);
expect(
(db.query(`PRAGMA table_info("users")`).all() as any[]).map(
(c) => c.name,
),
).toEqual(["id", "name"]);
});
});
+84
View File
@@ -0,0 +1,84 @@
import _ from "lodash";
import type { CreateDBSchemaParams } from "../../types";
import createDBManagerTable from "./create-db-manager-table";
import dropRemovedTables from "./drop-removed-tables";
import getExistingTablesFromTablesManagerTable from "./get-existing-tables-from-tables-manager-table";
import handleDBSchemaTable from "./handle-db-schema-table";
import orderDBSchema from "./order-db-schema";
/**
* # Sync the database to the schema
* @description Tables are rebuilt by copying into a new table and 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
* disabled for the sync and checked before committing. (`PRAGMA
* foreign_keys` is a no-op inside a transaction.)
*/
export default function createDBSchema(params: CreateDBSchemaParams) {
const { db } = params;
console.log("Starting schema synchronization...");
/**
* Create Schema Manager Table
*/
createDBManagerTable({ db });
/**
* Reorder Tables (parents before children with FKs)
*/
const db_schema = orderDBSchema(params);
db.run("PRAGMA foreign_keys = OFF;");
db.run("BEGIN");
try {
const existing_tables = getExistingTablesFromTablesManagerTable({
db,
});
/**
* Create or update tables
*/
for (const table of db_schema.tables) {
handleDBSchemaTable({
...params,
db_schema,
table,
existing_tables,
});
}
/**
* Drop tables that no longer exist in schema
*/
dropRemovedTables({
db,
existing_tables,
schema_tables: db_schema.tables.map((t) => t.tableName),
});
const fkViolations = db.query(`PRAGMA foreign_key_check`).all() as {
table: string;
parent: string;
}[];
if (fkViolations.length) {
const tables = _.uniq(
fkViolations.map((v) => `${v.table} -> ${v.parent}`),
);
throw new Error(
`Foreign key violations after schema sync: ${tables.join(", ")}`,
);
}
db.run("COMMIT");
} catch (error) {
if (db.inTransaction) db.run("ROLLBACK");
throw error;
} finally {
db.run("PRAGMA foreign_keys = ON;");
}
console.log("Schema synchronization complete!");
}
+59
View File
@@ -0,0 +1,59 @@
import type {
BUN_SQLITE_TableSchemaType,
CreateDBSchemaParams,
} 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 = CreateDBSchemaParams & {
table: BUN_SQLITE_TableSchemaType;
};
/**
* Create a new table
*/
export default function createTable({ db, db_schema, table }: Params) {
if (!table.tableName.match(/_temp_\d+$/)) {
console.log(`Creating table: ${table.tableName}`);
}
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];
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);
}
+43
View File
@@ -0,0 +1,43 @@
import type { Database } from "bun:sqlite";
import { readLiveSchema } from "../../functions/live-schema";
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
import removeDBManagerTable from "./remove-db-manager-table";
type Params = {
db: Database;
existing_tables: string[];
schema_tables: string[];
};
/**
* Drop tables that are no longer in the schema
*/
export default function dropRemovedTables({
db,
existing_tables,
schema_tables,
}: Params) {
console.log(`Cleaning up tables ...`);
const tablesToDrop = existing_tables.filter(
(t) =>
!schema_tables.includes(t) &&
!schema_tables.find((scT) => t.startsWith(scT + "_")),
);
const currentSchema = readLiveSchema();
for (const table of currentSchema?.tables || []) {
if (!table?.tableName) continue;
if (!schema_tables.includes(table.tableName)) {
tablesToDrop.push(table.tableName);
}
}
for (const tableName of tablesToDrop) {
console.log(`Dropping table: ${tableName}`);
db.run(`DROP TABLE IF EXISTS ${q(tableName)}`);
removeDBManagerTable({ db, table_name: tableName });
}
}
@@ -0,0 +1,15 @@
import type { Database } from "bun:sqlite";
import { AppData } from "../../data/app-data";
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
export default function getExistingTablesFromTablesManagerTable({
db,
}: {
db: Database;
}): string[] {
const results = db
.query(`SELECT table_name FROM ${q(AppData["DbSchemaManagerTableName"])}`)
.all() as { table_name: string }[];
return results.map((r) => r.table_name);
}
+17
View File
@@ -0,0 +1,17 @@
import type { Database } from "bun:sqlite";
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
type Params = {
db: Database;
table_name: string;
};
export default function getTableColumns({
db,
table_name,
}: Params): { name: string; type: string }[] {
return db.query(`PRAGMA table_info(${q(table_name)})`).all() as {
name: string;
type: string;
}[];
}
+50
View File
@@ -0,0 +1,50 @@
import type {
BUN_SQLITE_TableSchemaType,
CreateDBSchemaParams,
} from "../../types";
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
import createTable from "./create-table";
import insertDBManagerTable from "./insert-db-manager-table";
import removeDBManagerTable from "./remove-db-manager-table";
import syncIndexes from "./sync-indexes";
import updateTable from "./update-table";
type Params = CreateDBSchemaParams & {
table: BUN_SQLITE_TableSchemaType;
existing_tables: string[];
};
/**
* Sync a single table (rename, create or update, then indexes)
*/
export default function handleDBSchemaTable(params: Params) {
const { db, table, existing_tables } = params;
let tableExists = existing_tables.includes(table.tableName);
// Handle table rename
if (
table.tableNameOld &&
table.tableNameOld !== table.tableName &&
existing_tables.includes(table.tableNameOld)
) {
console.log(
`Renaming table: ${table.tableNameOld} -> ${table.tableName}`,
);
db.run(
`ALTER TABLE ${q(table.tableNameOld)} RENAME TO ${q(table.tableName)}`,
);
insertDBManagerTable({ db, table_name: table.tableName });
removeDBManagerTable({ db, table_name: table.tableNameOld });
tableExists = true;
}
if (!tableExists) {
createTable(params);
insertDBManagerTable({ db, table_name: table.tableName });
} else {
updateTable(params);
}
syncIndexes({ db, table });
}
+15
View File
@@ -0,0 +1,15 @@
import type { Database } from "bun:sqlite";
import { AppData } from "../../data/app-data";
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
type Params = {
db: Database;
table_name: string;
};
export default function insertDBManagerTable({ db, table_name }: Params) {
db.run(
`INSERT INTO ${q(AppData["DbSchemaManagerTableName"])} (table_name,created_at,updated_at) VALUES (?, ?, ?)`,
[table_name, Date.now(), Date.now()],
);
}
+58
View File
@@ -0,0 +1,58 @@
import type {
BUN_SQLITE_DATATYPES,
BUN_SQLITE_FieldSchemaType,
} from "../../types";
/**
* Map DSQL data types to SQLite types
*/
export default function mapDataType(
field: BUN_SQLITE_FieldSchemaType,
): (typeof BUN_SQLITE_DATATYPES)[number]["value"] {
const dataType = field.dataType?.toLowerCase() || "text";
const vectorSize = field.vectorSize || 1536;
// Vector Embeddings
if (field.isVector) {
return `FLOAT[${vectorSize}]` as any;
}
// Integer types
if (
dataType.includes("int") ||
dataType === "bigint" ||
dataType === "smallint" ||
dataType === "tinyint"
) {
return "INTEGER";
}
// Real/Float types
if (
dataType.includes("real") ||
dataType.includes("float") ||
dataType.includes("double") ||
dataType === "decimal" ||
dataType === "numeric"
) {
return "REAL";
}
// Blob types
if (dataType.includes("blob") || dataType.includes("binary")) {
return "BLOB";
}
// Boolean
if (dataType === "boolean" || dataType === "bool") {
return "INTEGER"; // SQLite uses INTEGER for boolean (0/1)
}
// Date/Time types
if (dataType.includes("date") || dataType.includes("time")) {
return "TEXT"; // SQLite stores dates as TEXT or INTEGER
}
// Default to TEXT for all text-based types
return "TEXT";
}
+73
View File
@@ -0,0 +1,73 @@
import { describe, expect, test } from "bun:test";
import type { BUN_SQLITE_TableSchemaType } from "../../types";
import orderDBSchema from "./order-db-schema";
function table({
name,
refs = [],
}: {
name: string;
refs?: string[];
}): BUN_SQLITE_TableSchemaType {
return {
tableName: name,
fields: refs.map((ref) => ({
fieldName: `${ref}_id`,
dataType: "INTEGER",
foreignKey: {
destinationTableName: ref,
destinationTableColumnName: "id",
},
})),
};
}
function order(tables: BUN_SQLITE_TableSchemaType[]) {
const res = orderDBSchema({ db_schema: { tables } });
return res.tables.map((t) => t.tableName);
}
describe("orderDBSchema", () => {
test("places parents before children", () => {
expect(
order([
table({ name: "comments", refs: ["posts", "users"] }),
table({ name: "posts", refs: ["users"] }),
table({ name: "users" }),
]),
).toEqual(["users", "posts", "comments"]);
});
test("keeps declaration order when there are no dependencies", () => {
expect(
order([table({ name: "b" }), table({ name: "a" })]),
).toEqual(["b", "a"]);
});
test("handles self references without recursing forever", () => {
expect(
order([
table({ name: "categories", refs: ["categories"] }),
table({ name: "comments", refs: ["comments", "users"] }),
table({ name: "users" }),
]),
).toEqual(["categories", "users", "comments"]);
});
test("handles cycles and emits each table once", () => {
const res = order([
table({ name: "a", refs: ["b"] }),
table({ name: "b", refs: ["a"] }),
table({ name: "c", refs: ["a"] }),
]);
expect(res).toHaveLength(3);
expect(new Set(res).size).toBe(3);
expect(res.indexOf("c")).toBeGreaterThan(res.indexOf("a"));
});
test("ignores references to tables outside the schema", () => {
expect(
order([table({ name: "posts", refs: ["external_users"] })]),
).toEqual(["posts"]);
});
});
+67
View File
@@ -0,0 +1,67 @@
import _ from "lodash";
import type {
BUN_SQLITE_DatabaseSchemaType,
BUN_SQLITE_TableSchemaType,
CreateDBSchemaParams,
} from "../../types";
/**
* Topologically order tables so referenced (parent) tables come before the
* tables that reference them. Declaration order is kept wherever the FK
* graph allows it.
*
* - Self references (e.g. `comments.parent_comment_id`) are ignored.
* - References to tables outside the schema are ignored.
* - Cycles do not throw: a table already on the current path is skipped,
* so every table is still emitted exactly once. Foreign keys are not
* enforced during the sync, so cycles are safe.
*/
export default function orderDBSchema({
db_schema,
}: Pick<CreateDBSchemaParams, "db_schema">): BUN_SQLITE_DatabaseSchemaType {
const new_db_schema = _.cloneDeep(db_schema);
const tables = new_db_schema.tables;
const tables_by_name = new Map<string, BUN_SQLITE_TableSchemaType>();
for (const table of tables) {
if (table) tables_by_name.set(table.tableName, table);
}
const ordered: BUN_SQLITE_TableSchemaType[] = [];
const visited = new Set<string>();
const visiting = new Set<string>();
function visit(table: BUN_SQLITE_TableSchemaType) {
if (visited.has(table.tableName) || visiting.has(table.tableName)) {
return;
}
visiting.add(table.tableName);
for (const field of table.fields || []) {
const dst_table_name = field?.foreignKey?.destinationTableName;
if (!dst_table_name || dst_table_name === table.tableName) {
continue;
}
const fk_table = tables_by_name.get(dst_table_name);
if (fk_table) {
visit(fk_table);
}
}
visiting.delete(table.tableName);
visited.add(table.tableName);
ordered.push(table);
}
for (const table of tables) {
if (table) visit(table);
}
new_db_schema.tables = ordered;
return new_db_schema;
}
+15
View File
@@ -0,0 +1,15 @@
import type { Database } from "bun:sqlite";
import { AppData } from "../../data/app-data";
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
type Params = {
db: Database;
table_name: string;
};
export default function removeDBManagerTable({ db, table_name }: Params) {
db.run(
`DELETE FROM ${q(AppData["DbSchemaManagerTableName"])} WHERE table_name = ?`,
[table_name],
);
}
+39
View File
@@ -0,0 +1,39 @@
import _ from "lodash";
import type {
BUN_SQLITE_DatabaseSchemaType,
BUN_SQLITE_TableSchemaType,
} from "../../types";
type Params = {
table: BUN_SQLITE_TableSchemaType;
db_schema: BUN_SQLITE_DatabaseSchemaType;
};
/**
* Resolve a table's effective definition. Tables with `parentTableName`
* take their structure from the parent. Always returns a copy: the schema
* objects are never mutated.
*/
export default function resolveTable({
table,
db_schema,
}: Params): BUN_SQLITE_TableSchemaType {
if (!table.parentTableName) {
return _.cloneDeep(table);
}
const parent_table = db_schema.tables.find(
(t) => t.tableName === table.parentTableName,
);
if (!parent_table) {
throw new Error(
`Parent table \`${table.parentTableName}\` not found for \`${table.tableName}\``,
);
}
return _.merge(_.cloneDeep(parent_table), {
tableName: table.tableName,
tableDescription: table.tableDescription,
});
}
+53
View File
@@ -0,0 +1,53 @@
import type { Database } from "bun:sqlite";
import type { BUN_SQLITE_TableSchemaType } from "../../types";
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
type Params = {
db: Database;
table: BUN_SQLITE_TableSchemaType;
};
/**
* Sync indexes for a table
*/
export default function syncIndexes({ db, table }: Params) {
if (!table.indexes || table.indexes.length === 0) {
return;
}
const existingIndexes = (
db
.query(
`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name=? AND name NOT LIKE 'sqlite_%'`,
)
.all(table.tableName) as { name: string }[]
).map((r) => r.name);
// Drop indexes not in schema
for (const indexName of existingIndexes) {
const stillExists = table.indexes.some(
(idx) => idx.indexName === indexName,
);
if (!stillExists) {
console.log(`Dropping index: ${indexName}`);
db.run(`DROP INDEX IF EXISTS ${q(indexName)}`);
}
}
// Create new indexes
for (const index of table.indexes) {
if (!index.indexName || !index.indexTableFields?.length) {
continue;
}
if (!existingIndexes.includes(index.indexName)) {
console.log(`Creating index: ${index.indexName}`);
const fields = index.indexTableFields
.map((f) => q(String(f)))
.join(", ");
db.run(
`CREATE INDEX ${q(index.indexName)} ON ${q(table.tableName)} (${fields})`,
);
}
}
}
+91
View File
@@ -0,0 +1,91 @@
import type {
BUN_SQLITE_TableSchemaType,
CreateDBSchemaParams,
} from "../../types";
import { sqlQuoteIdent as q } from "../../utils/sql-safe";
import checkIfTableExists from "./check-if-table-exists";
import createTable from "./create-table";
import getTableColumns from "./get-table-columns";
import resolveTable from "./resolve-table";
type Params = CreateDBSchemaParams & {
table: BUN_SQLITE_TableSchemaType;
};
/**
* # Update an existing table
* @description SQLite has limited ALTER TABLE support, so the table is
* rebuilt: create a temp table with the new schema, copy the data over,
* drop the old table and rename the temp table.
*/
export default function updateTable(params: Params) {
const { db, db_schema, table } = params;
console.log(`Updating table: ${table.tableName}`);
if (table.isVector) {
recreateVectorTable(params);
return;
}
const tempTableName = `${table.tableName}_temp_${Date.now()}`;
// 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
// silently dropping the column.
const existingColumns = getTableColumns({ db, table_name: table.tableName });
const columnsToKeep = resolveTable({ table, db_schema })
.fields.filter(
(f) =>
f.fieldName &&
existingColumns.find((c) => c.name == f.fieldName),
)
.map((f) => f.fieldName as string);
// Create temp table with new schema
createTable({ ...params, table: { ...table, tableName: tempTableName } });
// Copy data if there are common columns
if (columnsToKeep.length > 0) {
const columnList = columnsToKeep.map(q).join(", ");
db.run(
`INSERT INTO ${q(tempTableName)} (${columnList}) SELECT ${columnList} FROM ${q(table.tableName)}`,
);
}
db.run(`DROP TABLE ${q(table.tableName)}`);
db.run(`ALTER TABLE ${q(tempTableName)} RENAME TO ${q(table.tableName)}`);
}
/**
* Vector tables are only rebuilt when `recreate_vector_table` is set.
*/
function recreateVectorTable(params: Params) {
const { db, table, recreate_vector_table } = params;
if (!recreate_vector_table) {
return;
}
let existingRows: { [k: string]: any }[] = [];
if (checkIfTableExists({ db, table_name: table.tableName })) {
existingRows = db
.query(`SELECT * FROM ${q(table.tableName)}`)
.all() as { [k: string]: any }[];
db.run(`DROP TABLE ${q(table.tableName)}`);
}
createTable(params);
for (const row of existingRows) {
const columns = Object.keys(row);
const placeholders = columns.map(() => "?").join(", ");
db.run(
`INSERT INTO ${q(table.tableName)} (${columns.map(q).join(", ")}) VALUES (${placeholders})`,
Object.values(row),
);
}
}
+21 -9
View File
@@ -1,7 +1,12 @@
import DbClient from "."; import dbHandler from "../db-handler";
import _ from "lodash"; import _ from "lodash";
import type { APIResponseObject, ServerQueryParam } from "../../types"; import type {
APIResponseObject,
BunSQLiteConfig,
ServerQueryParam,
} from "../../types";
import sqlGenerator from "../../utils/sql-generator"; import sqlGenerator from "../../utils/sql-generator";
import { sqlIdent } from "../../utils/sql-safe";
type Params< type Params<
Schema extends { [k: string]: any } = { [k: string]: any }, Schema extends { [k: string]: any } = { [k: string]: any },
@@ -10,6 +15,7 @@ type Params<
table: Table; table: Table;
query?: ServerQueryParam<Schema>; query?: ServerQueryParam<Schema>;
targetId?: number | string; targetId?: number | string;
config?: BunSQLiteConfig;
}; };
export default async function DbDelete< export default async function DbDelete<
@@ -19,6 +25,7 @@ export default async function DbDelete<
table, table,
query, query,
targetId, targetId,
config,
}: Params<Schema, Table>): Promise<APIResponseObject> { }: Params<Schema, Table>): Promise<APIResponseObject> {
let sqlObj: ReturnType<typeof sqlGenerator> | null = null; let sqlObj: ReturnType<typeof sqlGenerator> | null = null;
@@ -47,19 +54,24 @@ export default async function DbDelete<
const whereClause = sqlObj.where.string; const whereClause = sqlObj.where.string;
if (whereClause) { if (whereClause) {
let sql = `DELETE FROM ${table} ${whereClause}`; let sql = `DELETE FROM ${sqlIdent(table, "table name")} ${whereClause}`;
sqlObj.string = sql; sqlObj.string = sql;
sqlObj.values = sqlObj.where.values; sqlObj.values = sqlObj.where.values;
const res = DbClient.run(sql, sqlObj.values); const res = dbHandler({
query: sql,
values: sqlObj.values,
config,
});
if (!res.success) {
return { ...res, debug: { sqlObj } };
}
return { return {
success: Boolean(res.changes), success: Boolean(res.postInsertReturn?.affectedRows),
postInsertReturn: { postInsertReturn: res.postInsertReturn,
affectedRows: res.changes,
insertId: Number(res.lastInsertRowid),
},
debug: { debug: {
sqlObj, sqlObj,
}, },
+19 -8
View File
@@ -1,5 +1,9 @@
import DbClient from "."; import dbHandler from "../db-handler";
import type { APIResponseObject, SQLInsertGenReturn } from "../../types"; import type {
APIResponseObject,
BunSQLiteConfig,
SQLInsertGenReturn,
} from "../../types";
import sqlInsertGenerator from "../../utils/sql-insert-generator"; import sqlInsertGenerator from "../../utils/sql-insert-generator";
import grabDuplicateSafeInsertSql from "../grab-duplicate-safe-insert-sql"; import grabDuplicateSafeInsertSql from "../grab-duplicate-safe-insert-sql";
@@ -10,6 +14,7 @@ type Params<
table: Table; table: Table;
data: Schema[]; data: Schema[];
update_on_duplicate?: boolean; update_on_duplicate?: boolean;
config?: BunSQLiteConfig;
}; };
export default async function DbInsert< export default async function DbInsert<
@@ -19,6 +24,7 @@ export default async function DbInsert<
table, table,
data, data,
update_on_duplicate, update_on_duplicate,
config,
}: Params<Schema, Table>): Promise<APIResponseObject> { }: Params<Schema, Table>): Promise<APIResponseObject> {
let sqlObj: SQLInsertGenReturn | null = null; let sqlObj: SQLInsertGenReturn | null = null;
@@ -43,14 +49,19 @@ export default async function DbInsert<
(sqlObj || ({} as any)).query = sql; (sqlObj || ({} as any)).query = sql;
const res = DbClient.run(sql, sqlObj?.values || []); const res = dbHandler({
query: sql,
values: sqlObj?.values || [],
config,
});
if (!res.success) {
return { ...res, debug: { sqlObj } };
}
return { return {
success: Boolean(Number(res.lastInsertRowid)), success: Boolean(res.postInsertReturn?.insertId),
postInsertReturn: { postInsertReturn: res.postInsertReturn,
affectedRows: res.changes,
insertId: Number(res.lastInsertRowid),
},
debug: { debug: {
sqlObj, sqlObj,
}, },
File diff suppressed because it is too large Load Diff
+35 -9
View File
@@ -1,6 +1,10 @@
import DbClient from "."; import dbHandler from "../db-handler";
import _ from "lodash"; import _ from "lodash";
import type { APIResponseObject, ServerQueryParam } from "../../types"; import type {
APIResponseObject,
BunSQLiteConfig,
ServerQueryParam,
} from "../../types";
import sqlGenerator from "../../utils/sql-generator"; import sqlGenerator from "../../utils/sql-generator";
type Params< type Params<
@@ -11,6 +15,7 @@ type Params<
table: Table; table: Table;
count?: boolean; count?: boolean;
targetId?: number | string; targetId?: number | string;
config?: BunSQLiteConfig;
}; };
export default async function DbSelect< export default async function DbSelect<
@@ -21,6 +26,7 @@ export default async function DbSelect<
query, query,
count, count,
targetId, targetId,
config,
}: Params<Schema, Table>): Promise<APIResponseObject<Schema>> { }: Params<Schema, Table>): Promise<APIResponseObject<Schema>> {
let sqlObj: ReturnType<typeof sqlGenerator> | null = null; let sqlObj: ReturnType<typeof sqlGenerator> | null = null;
@@ -48,9 +54,17 @@ export default async function DbSelect<
const sql = sqlObj.string; const sql = sqlObj.string;
const batchRes = DbClient.query<Schema, any[]>(sql).all( const res = dbHandler<Schema>({
...sqlObj.values, query: sql,
); values: sqlObj.values,
config,
});
if (!res.success) {
return { ...res, debug: { sqlObj, sql } };
}
const batchRes = res.payload || [];
let resp: APIResponseObject<Schema> = { let resp: APIResponseObject<Schema> = {
success: Boolean(batchRes[0]), success: Boolean(batchRes[0]),
@@ -71,11 +85,23 @@ export default async function DbSelect<
const count_sql = `SELECT COUNT(*) AS count FROM (${count_sql_object.string}) AS c`; const count_sql = `SELECT COUNT(*) AS count FROM (${count_sql_object.string}) AS c`;
const count_res = DbClient.query<{ count: number }, any[]>( const count_res = dbHandler<{ count: number }>({
count_sql, query: count_sql,
).get(...count_sql_object.values); values: count_sql_object.values,
config,
});
resp["count"] = Number(count_res?.count); if (!count_res.success) {
return {
...resp,
success: false,
error: count_res.error,
msg: "Database count failed",
debug: { ...resp.debug, count_sql },
};
}
resp["count"] = Number(count_res.singleRes?.count);
resp["debug"]["count_sql"] = count_sql; resp["debug"]["count_sql"] = count_sql;
} }
+23 -42
View File
@@ -1,54 +1,35 @@
import DbClient from "."; import dbHandler from "../db-handler";
import _ from "lodash"; import type {
import type { APIResponseObject, SQLInsertGenValueType } from "../../types"; APIResponseObject,
BunSQLiteConfig,
SQLInsertGenValueType,
} from "../../types";
type Params = { type Params = {
sql: string; sql: string;
values?: SQLInsertGenValueType[]; values?: SQLInsertGenValueType[];
config?: BunSQLiteConfig;
}; };
export default async function DbSQL< export default async function DbSQL<
T extends { [k: string]: any } = { [k: string]: any }, T extends { [k: string]: any } = { [k: string]: any },
>({ sql, values }: Params): Promise<APIResponseObject<T>> { >({ sql, values, config }: Params): Promise<APIResponseObject<T>> {
try { const trimmed_sql = sql.trim();
const trimmed_sql = sql.trim();
/** const res = dbHandler<T>({ query: trimmed_sql, values, config });
* Any statement that produces columns (SELECT, WITH, PRAGMA,
* RETURNING, ...) returns rows. Everything else goes through `run`,
* which also handles multiple statements.
*/
const stmt = DbClient.prepare(trimmed_sql);
const returnsRows = stmt.columnNames.length > 0;
const res = returnsRows if (!res.success) {
? stmt.all(...(values || [])) return res;
: DbClient.run(trimmed_sql, values || []);
stmt.finalize();
return {
success: true,
payload: Array.isArray(res) ? (res as T[]) : undefined,
singleRes: Array.isArray(res) ? (res as T[])?.[0] : undefined,
postInsertReturn: Array.isArray(res)
? undefined
: {
affectedRows: res.changes,
insertId: Number(res.lastInsertRowid),
},
debug: {
sqlObj: {
sql: trimmed_sql,
values,
},
sql,
},
};
} catch (error: any) {
return {
success: false,
error: error.message,
};
} }
return {
...res,
debug: {
sqlObj: {
sql: trimmed_sql,
values,
},
sql,
},
};
} }
+11 -7
View File
@@ -1,7 +1,8 @@
import DbClient from "."; import dbHandler from "../db-handler";
import _ from "lodash"; import _ from "lodash";
import type { import type {
APIResponseObject, APIResponseObject,
BunSQLiteConfig,
SQLInsertGenValueType, SQLInsertGenValueType,
ServerQueryParam, ServerQueryParam,
} from "../../types"; } from "../../types";
@@ -17,6 +18,7 @@ type Params<
data: Schema; data: Schema;
query?: ServerQueryParam<Schema>; query?: ServerQueryParam<Schema>;
targetId?: number | string; targetId?: number | string;
config?: BunSQLiteConfig;
}; };
export default async function DbUpdate< export default async function DbUpdate<
@@ -27,6 +29,7 @@ export default async function DbUpdate<
data, data,
query, query,
targetId, targetId,
config,
}: Params<Schema, Table>): Promise<APIResponseObject> { }: Params<Schema, Table>): Promise<APIResponseObject> {
let sqlObj: Pick<ReturnType<typeof sqlGenerator>, "string" | "values"> = { let sqlObj: Pick<ReturnType<typeof sqlGenerator>, "string" | "values"> = {
string: "", string: "",
@@ -89,14 +92,15 @@ export default async function DbUpdate<
sqlObj.string = sql; sqlObj.string = sql;
sqlObj.values = values as any[]; sqlObj.values = values as any[];
const res = DbClient.run(sql, values); const res = dbHandler({ query: sql, values, config });
if (!res.success) {
return { ...res, debug: { sqlObj } };
}
return { return {
success: Boolean(res.changes), success: Boolean(res.postInsertReturn?.affectedRows),
postInsertReturn: { postInsertReturn: res.postInsertReturn,
affectedRows: res.changes,
insertId: Number(res.lastInsertRowid),
},
debug: { debug: {
sqlObj, sqlObj,
}, },
-28
View File
@@ -1,28 +0,0 @@
import { Database } from "bun:sqlite";
import * as sqliteVec from "sqlite-vec";
import grabDirNames from "../../data/grab-dir-names";
import init from "../../functions/init";
import grabDBDir from "../../utils/grab-db-dir";
const { ROOT_DIR } = grabDirNames();
const { config } = await init();
let db_dir = ROOT_DIR;
if (config.db_dir) {
db_dir = config.db_dir;
}
const { db_file_path } = grabDBDir({ config });
const DbClient = new Database(db_file_path, {
create: true,
});
sqliteVec.load(DbClient);
if (config.wal_mode) {
DbClient.run("PRAGMA journal_mode = WAL;");
}
export default DbClient;
-7
View File
@@ -1,7 +0,0 @@
import _ from "lodash";
import type { BUN_SQLITE_DatabaseSchemaType } from "../../types";
export const DbSchema: BUN_SQLITE_DatabaseSchemaType = {
dbName: "travis-ai",
tables: [],
};
+16 -2
View File
@@ -1537,14 +1537,19 @@ export type BunSQLiteConfig = {
db_name: string; db_name: string;
/** /**
* The Name of the Database Schema File. Eg `db_schema.ts`. This is * The Name of the Database Schema File. Eg `db_schema.ts`. This is
* relative to `db_dir`, or root dir if `db_dir` is not provided * relative to `db_dir`, or root dir if `db_dir` is not provided. When
* omitted, `schema.ts|js|json|yaml|yml` in `db_dir` is used.
*/ */
db_schema_file_name: string; db_schema_file_name?: string;
/** /**
* The Directory for backups. Relative to db_dir. * The Directory for backups. Relative to db_dir.
*/ */
db_backup_dir?: string; db_backup_dir?: string;
max_backups?: number; max_backups?: number;
/**
* Max number of archives kept in `{db_dir}/.exports` by `export`
*/
max_exports?: number;
/** /**
* The Root Directory for the DB file and schema * The Root Directory for the DB file and schema
*/ */
@@ -1568,6 +1573,15 @@ export type BunSQLiteConfigReturn = {
dbSchema: BUN_SQLITE_DatabaseSchemaType; dbSchema: BUN_SQLITE_DatabaseSchemaType;
}; };
/**
* Params shared by the schema sync functions in `lib/schema`.
*/
export type CreateDBSchemaParams = {
db: import("bun:sqlite").Database;
db_schema: BUN_SQLITE_DatabaseSchemaType;
recreate_vector_table?: boolean;
};
/** /**
* Default fields automatically suggested for new tables. * Default fields automatically suggested for new tables.
*/ */
+253
View File
@@ -0,0 +1,253 @@
import fs from "fs";
import path from "path";
import { AppData } from "../data/app-data";
import grabDirNames from "../data/grab-dir-names";
import { isSchemaFileName } from "./resolve-and-load-data-file";
export const ExportArchiveMembers = {
DbFileName: "database.sqlite",
SchemaFileName: AppData.DbSchemaFileName,
} as const;
export type ExportArchiveContents = {
db: Uint8Array;
schema: string;
/** Archive member basename, e.g. schema.ts / schema.json / schema.yaml */
schemaFileName: string;
};
const ARCHIVE_EXTENSIONS = [".tar.gz", ".tgz", ".tar", ".zip"] as const;
/** Every SQLite database file starts with this header */
const SQLITE_HEADER = "SQLite format 3\0";
export function isArchivePath(filePath: string): boolean {
const lower = filePath.toLowerCase();
return ARCHIVE_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
export function isSqlPath(filePath: string): boolean {
return filePath.toLowerCase().endsWith(".sql");
}
export function isSQLiteFile(bytes: Uint8Array): boolean {
return (
new TextDecoder().decode(bytes.subarray(0, SQLITE_HEADER.length)) ===
SQLITE_HEADER
);
}
/**
* Create a portable export archive (tar / tar.gz / zip) with the database
* file + schema file.
*/
export async function writeExportArchive({
contents,
outPath,
}: {
contents: ExportArchiveContents;
outPath: string;
}): Promise<void> {
const lower = outPath.toLowerCase();
if (lower.endsWith(".zip")) {
await writeZipArchive({ contents, outPath });
return;
}
const members = {
[ExportArchiveMembers.DbFileName]: contents.db,
[contents.schemaFileName]: contents.schema,
};
const gzip = lower.endsWith(".gz") || lower.endsWith(".tgz");
if (gzip) {
await Bun.Archive.write(outPath, members, { compress: "gzip" });
} else {
await Bun.Archive.write(outPath, members);
}
}
/**
* Read a portable export archive (tar / tar.gz / zip).
*/
export async function readExportArchive(
archivePath: string,
): Promise<ExportArchiveContents> {
if (archivePath.toLowerCase().endsWith(".zip")) {
return readZipArchive(archivePath);
}
const bytes = await Bun.file(archivePath).bytes();
const archive = new Bun.Archive(bytes);
const files = await archive.files();
let db: Uint8Array | null = null;
let schemaEntry: { content: string; fileName: string } | null = null;
for (const [entry, file] of files) {
const base = path.basename(entry);
if (!db && base === ExportArchiveMembers.DbFileName) {
db = await file.bytes();
} else if (!schemaEntry && isSchemaFileName(base)) {
schemaEntry = {
content: await file.text(),
fileName: schemaMemberName(base),
};
}
}
return validateContents({ db, schemaEntry });
}
function schemaMemberName(base: string) {
return base === AppData.DbSchemaFileName ? "schema.ts" : base;
}
function validateContents({
db,
schemaEntry,
}: {
db: Uint8Array | null;
schemaEntry: { content: string; fileName: string } | null;
}): ExportArchiveContents {
if (!db) {
throw new Error(
`Archive is missing the database file (expected \`${ExportArchiveMembers.DbFileName}\`)`,
);
}
if (!isSQLiteFile(db)) {
throw new Error(
`\`${ExportArchiveMembers.DbFileName}\` in the archive is not a SQLite database`,
);
}
if (!schemaEntry) {
throw new Error(
`Archive is missing schema file (expected \`${AppData.DbSchemaFileName}.ts|js|json|yaml|yml\`)`,
);
}
return {
db,
schema: schemaEntry.content,
schemaFileName: schemaEntry.fileName,
};
}
async function runCommand(cmd: string[], cwd?: string) {
const proc = Bun.spawn(cmd, {
cwd,
stdout: "pipe",
stderr: "pipe",
});
const [stderr, exitCode] = await Promise.all([
new Response(proc.stderr).text(),
proc.exited,
]);
if (exitCode !== 0) {
throw new Error(
`${cmd[0]} failed (exit ${exitCode}): ${stderr || "unknown error"}. Ensure \`${cmd[0]}\` is installed, or use .tar.gz.`,
);
}
}
function makeTempDir(prefix: string) {
const { BUN_SQLITE_TEMP_DIR } = grabDirNames();
const tempDir = path.join(
BUN_SQLITE_TEMP_DIR,
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}`,
);
fs.mkdirSync(tempDir, { recursive: true });
return tempDir;
}
async function writeZipArchive({
contents,
outPath,
}: {
contents: ExportArchiveContents;
outPath: string;
}): Promise<void> {
const tempDir = makeTempDir("export");
try {
fs.writeFileSync(
path.join(tempDir, ExportArchiveMembers.DbFileName),
contents.db,
);
fs.writeFileSync(
path.join(tempDir, contents.schemaFileName),
contents.schema,
"utf-8",
);
await runCommand(
[
"zip",
"-q",
"-j",
path.resolve(outPath),
ExportArchiveMembers.DbFileName,
contents.schemaFileName,
],
tempDir,
);
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
async function readZipArchive(
archivePath: string,
): Promise<ExportArchiveContents> {
const tempDir = makeTempDir("import");
try {
await runCommand([
"unzip",
"-q",
"-o",
path.resolve(archivePath),
"-d",
tempDir,
]);
let db: Uint8Array | null = null;
let schemaEntry: { content: string; fileName: string } | null = null;
const stack = [tempDir];
while (stack.length) {
const current = stack.pop()!;
for (const entry of fs.readdirSync(current, {
withFileTypes: true,
})) {
const full = path.join(current, entry.name);
// Never follow symlinks from an extracted (untrusted) archive
if (entry.isDirectory()) {
stack.push(full);
} else if (!entry.isFile()) {
continue;
} else if (
!db &&
entry.name === ExportArchiveMembers.DbFileName
) {
db = new Uint8Array(fs.readFileSync(full));
} else if (!schemaEntry && isSchemaFileName(entry.name)) {
schemaEntry = {
content: fs.readFileSync(full, "utf-8"),
fileName: schemaMemberName(entry.name),
};
}
}
}
return validateContents({ db, schemaEntry });
} finally {
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
+3 -2
View File
@@ -13,14 +13,15 @@ export default function grabDBDir({ config }: Params) {
let db_dir = ROOT_DIR; let db_dir = ROOT_DIR;
if (config.db_dir) { if (config.db_dir) {
db_dir = config.db_dir; db_dir = path.resolve(ROOT_DIR, config.db_dir);
} }
const backup_dir_name = const backup_dir_name =
config.db_backup_dir || AppData["DefaultBackupDirName"]; config.db_backup_dir || AppData["DefaultBackupDirName"];
const backup_dir = path.resolve(db_dir, backup_dir_name); const backup_dir = path.resolve(db_dir, backup_dir_name);
const export_dir = path.resolve(db_dir, AppData["DefaultExportDirName"]);
const db_file_path = path.resolve(db_dir, config.db_name); const db_file_path = path.resolve(db_dir, config.db_name);
return { db_dir, backup_dir, db_file_path }; return { db_dir, backup_dir, export_dir, db_file_path };
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import init from "../functions/init"; import init from "../functions/init";
export default async function grabDbSchema() { export default async function grabDbSchema() {
const { dbSchema } = await init(); const { dbSchema } = init();
return dbSchema; return dbSchema;
} }
+34
View File
@@ -0,0 +1,34 @@
import fs from "fs";
import type { BunSQLiteConfig } from "../types";
import grabDBDir from "./grab-db-dir";
type Params = {
config: BunSQLiteConfig;
};
function stripExportExtension(name: string): string {
const lower = name.toLowerCase();
if (lower.endsWith(".tar.gz")) return name.slice(0, -7);
if (lower.endsWith(".tgz")) return name.slice(0, -4);
if (lower.endsWith(".tar")) return name.slice(0, -4);
if (lower.endsWith(".zip")) return name.slice(0, -4);
return name;
}
function exportTimestamp(name: string): number {
const base = stripExportExtension(name);
const ts = Number(base.split("-").pop());
return Number.isFinite(ts) ? ts : 0;
}
export default function grabSortedExports({ config }: Params) {
const { export_dir } = grabDBDir({ config });
if (!fs.existsSync(export_dir)) {
return [] as string[];
}
const exports = fs.readdirSync(export_dir);
return exports.sort((a, b) => exportTimestamp(b) - exportTimestamp(a));
}
+132
View File
@@ -0,0 +1,132 @@
import fs from "fs";
import path from "path";
import { AppData } from "../data/app-data";
export type DataFileFormat = "ts" | "js" | "json" | "yaml";
export type ResolvedDataFile = {
path: string;
basename: string;
extension: (typeof AppData.SupportedDataFileExtensions)[number];
format: DataFileFormat;
};
export type LoadedDataFile<T> = ResolvedDataFile & {
data: T;
};
function extensionToFormat(
extension: (typeof AppData.SupportedDataFileExtensions)[number],
): DataFileFormat {
switch (extension) {
case ".ts":
return "ts";
case ".js":
return "js";
case ".json":
return "json";
case ".yaml":
case ".yml":
return "yaml";
}
}
/**
* Resolve a basename (no extension) to an existing file among supported formats.
* Priority: .ts > .js > .json > .yaml > .yml
* Errors if multiple matches exist.
*/
export function resolveDataFile(
dir: string,
baseName: string,
): ResolvedDataFile | null {
const matches: ResolvedDataFile[] = [];
for (const extension of AppData.SupportedDataFileExtensions) {
const filePath = path.join(dir, `${baseName}${extension}`);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
matches.push({
path: filePath,
basename: `${baseName}${extension}`,
extension,
format: extensionToFormat(extension),
});
}
}
if (matches.length === 0) {
return null;
}
if (matches.length > 1) {
const found = matches.map((m) => m.basename).join(", ");
throw new Error(
`Multiple \`${baseName}\` files found (${found}). Keep only one of: ${AppData.SupportedDataFileExtensions.join(", ")}`,
);
}
return matches[0]!;
}
export function supportedDataFileNames(baseName: string): string {
return AppData.SupportedDataFileExtensions.map(
(ext) => `\`${baseName}${ext}\``,
).join(", ");
}
export function isSchemaFileName(name: string): boolean {
const base = path.basename(name);
if (base === AppData.DbSchemaFileName) {
return true;
}
return AppData.SupportedDataFileExtensions.some(
(ext) => base === `${AppData.DbSchemaFileName}${ext}`,
);
}
/**
* Load a data file as a plain object.
* - .ts / .js: require() and use default export (or module itself)
* - .json: JSON.parse
* - .yaml / .yml: Bun.YAML.parse
*/
export function loadDataFile<T>(resolved: ResolvedDataFile): T {
if (resolved.format === "ts" || resolved.format === "js") {
const imported = require(resolved.path);
const data =
imported && typeof imported === "object" && "default" in imported
? imported.default
: imported;
if (data == null) {
throw new Error(
`No default export from \`${resolved.path}\`. Please export a default module.`,
);
}
return data as T;
}
const text = fs.readFileSync(resolved.path, "utf-8");
if (resolved.format === "json") {
return JSON.parse(text) as T;
}
return Bun.YAML.parse(text) as T;
}
export function resolveAndLoadDataFile<T>(
dir: string,
baseName: string,
): LoadedDataFile<T> | null {
const resolved = resolveDataFile(dir, baseName);
if (!resolved) {
return null;
}
return {
...resolved,
data: loadDataFile<T>(resolved),
};
}
+34
View File
@@ -0,0 +1,34 @@
import fs from "fs";
import path from "path";
import _ from "lodash";
import type { BunSQLiteConfig } from "../types";
import { AppData } from "../data/app-data";
import grabDBDir from "./grab-db-dir";
import grabSortedExports from "./grab-sorted-exports";
type Params = {
config: BunSQLiteConfig;
};
export default function trimExports({ config }: Params) {
const { export_dir } = grabDBDir({ config });
// Only consider archives written by `export` (`{db_name}-{timestamp}.tar.gz|zip`)
const exportNamePattern = new RegExp(
`^${_.escapeRegExp(config.db_name)}-\\d+\\.(tar\\.gz|zip)$`,
);
const exports = grabSortedExports({ config }).filter(
(name) =>
exportNamePattern.test(name) &&
fs.statSync(path.join(export_dir, name)).isFile(),
);
const max_exports = config.max_exports || AppData["MaxExports"];
for (let i = 0; i < exports.length; i++) {
const export_name = exports[i];
if (!export_name) continue;
if (i > max_exports - 1) {
fs.unlinkSync(path.join(export_dir, export_name));
}
}
}
+2 -1
View File
@@ -21,8 +21,9 @@
"maxNodeModuleJsDepth": 10, "maxNodeModuleJsDepth": 10,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"incremental": true, "incremental": true,
"rootDir": "src",
"outDir": "dist" "outDir": "dist"
}, },
"include": ["src"], "include": ["src"],
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist", "src/**/*.test.ts"]
} }