fix(migrator): correct and harden the 1.x to 2.0 SQLite import (#434)

* feat(api): parse DATA_DIR from env for 1.x import auto-detection

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* test(migrator): build 1.17.2 fixtures by replaying legacy migrations

Discovered the legacy migrations seed a Default team (0005) and builtin roles
(0007), so the replayed fixture carries them. Seed uses a distinct custom team.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* fix(migrator): self-adjusting column copy, jobs.status map, drop sessions, advisory lock

The importer now inserts only the intersection of source and live target columns,
so the three analytics_* columns 2.x dropped no longer break the first users INSERT
(and future dropped columns are handled generically). jobs.status is mapped onto the
2.x enum (error->failed). Sessions are no longer migrated. A pg_advisory_xact_lock
serializes concurrent replicas. Includes login-after-migrate and library assertions.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* test(migrator): CI drift guard fails when a required column is unfillable from 1.17.2

Introspects every NOT-NULL-no-default column of each migrated table in the current
schema and asserts the engine can fill it from a real 1.17.2 source. Turns a future
breaking schema change into a PR-time failure instead of a production import break.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* feat(migrator): orchestrator with detection, boot states, marker, blob count

sqlite-import.ts owns source resolution (explicit path, 'off' sentinel, DATA_DIR
probe), the four boot states (import/leftover/locked/none), the persisted
sqlite_import marker, and a read-only library-blob count. runBootImport wires them
together and catches TargetNonEmptyError as a benign multi-replica skip.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* feat(api): route boot through the 1.x import orchestrator; hide marker from non-admins

index.ts now calls runBootImport (which owns detection + the four boot states)
instead of the inline SQLITE_MIGRATE_PATH block. The sqlite_import marker is added
to SENSITIVE_KEYS (but not REDACTED_KEYS) so admins see the counts for the banner
while non-admins don't see the key at all.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* feat(migrator): add analyzeSqlite + dry-run/verify CLI

analyzeSqlite is a read-only pre-flight (no live Postgres): per-table row counts,
library-blob presence, and out-of-enum job statuses. The migrate:sqlite CLI now
lives in the orchestrator and supports --dry-run/--verify (prints the analysis and
exits without writing) alongside the existing import and --force.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* docs: add 1.x to 2.0 upgrade guide; fix volume-name casing

New apps/docs upgrade guide covering auto-detect, the SQLITE_MIGRATE_PATH override +
off opt-out, the dry-run, what carries over, locked-state recovery, and non-destructive
rollback. Leads with 'back up the WHOLE /data volume, not just snapotter.db' because
1.x WAL mode leaves data in snapotter.db-wal (surfaced by the real-image upgrade test).
Standardizes README/DOCKERHUB compose volume names on the canonical SnapOtter-data
casing so they match the repo compose and don't orphan an upgrader's volume.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* feat(web): admin 1.x migration banner + 21-locale strings

A one-time admin banner reads the sqlite_import marker from /v1/settings and shows
the import result (user + saved-file counts) on success, or a warning when a 1.x
database was found but not imported. Dismissal persists to a sqlite_import.dismissedAt
settings key. shouldShowMigrationBanner/parseMigrationMarker sit in feedback.ts with
the other shouldShow helpers; strings added to en.ts and all 20 other locales.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w

* style(landing): biome-format Hero.astro trustBadges array

Pre-existing formatting drift on main (its Lint check was skipped on the merge that
introduced it); this PR's full Lint run surfaced it. Formatting-only, applied via
the repo's own biome formatter to unblock the required Lint check.

Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w
This commit is contained in:
SnapOtter
2026-07-04 15:15:39 +00:00
committed by GitHub
parent dc589fd0a1
commit dadf766899
43 changed files with 1206 additions and 87 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
"lint": "biome check src/",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist",
"migrate:sqlite": "tsx src/db/migrate-from-sqlite.ts"
"migrate:sqlite": "tsx src/db/sqlite-import.ts"
},
"dependencies": {
"@fastify/busboy": "^3.2.0",
+100 -41
View File
@@ -2,11 +2,17 @@ import { sql } from "drizzle-orm";
import { db } from "./index.js";
import { runMigrations } from "./migrate.js";
// Advisory lock: 7_421_xxx reserved for SnapOtter app locks (7_421_001 = schema migrate).
const SQLITE_IMPORT_LOCK_KEY = 7_421_002;
type SqliteRow = Record<string, unknown>;
export interface MigrationResult {
tables: Record<string, number>;
}
/** Thrown when the target became non-empty (e.g. another replica imported first). */
export class TargetNonEmptyError extends Error {}
// columns storing epoch-seconds integers in 1.x
const TS = new Set(["created_at", "updated_at", "expires_at", "completed_at", "last_used_at"]);
// columns storing 0/1 booleans in 1.x
@@ -20,13 +26,29 @@ const JSONB: Record<string, Set<string>> = {
audit_log: new Set(["details"]),
user_files: new Set(["tool_chain"]),
};
// FK-safe copy order
const TABLE_ORDER = [
// Map 1.x job status values onto the 2.x job_status enum. Anything unrecognized
// is coerced to "failed" so no single row can abort the transaction on an enum error.
const STATUS_MAP: Record<string, string> = {
queued: "queued",
processing: "processing",
running: "processing",
completed: "completed",
complete: "completed",
failed: "failed",
error: "failed",
canceled: "canceled",
cancelled: "canceled",
};
const VALID_STATUS = new Set(["queued", "processing", "completed", "failed", "canceled"]);
// FK-safe copy order. Sessions are intentionally NOT migrated (users re-auth once;
// credentials are unchanged so the same login works).
export const MIGRATED_TABLES = [
"users",
"teams",
"settings",
"roles",
"sessions",
"api_keys",
"pipelines",
"jobs",
@@ -34,32 +56,61 @@ const TABLE_ORDER = [
"user_files",
] as const;
// Column renames between 1.x and 2.x (source name -> target name). None today.
const RENAMES: Record<string, Record<string, string>> = {};
/**
* Target columns the engine can populate for `table` given the 1.x row's source
* columns. Used by the CI drift guard so it agrees with the actual copy logic.
*/
export function columnsEngineCanFill(table: string, sourceColumns: string[]): Set<string> {
const rename = RENAMES[table] ?? {};
const out = new Set(sourceColumns.map((c) => rename[c] ?? c));
if (table === "jobs") {
// jobs remap discards source file paths and produces these target columns:
out.delete("input_files");
out.delete("output_path");
out.add("input_refs");
out.add("output_refs");
// progress/error/status keep their source names.
}
return out;
}
function convertRow(table: string, row: SqliteRow): SqliteRow {
const out: SqliteRow = {};
for (const [col, raw] of Object.entries(row)) {
// Jobs table: remap removed 1.x columns to new spine columns
const rename = RENAMES[table] ?? {};
for (const [rawCol, raw] of Object.entries(row)) {
const col = rename[rawCol] ?? rawCol;
// Jobs table: remap removed/renamed 1.x columns to new spine columns.
if (table === "jobs") {
if (col === "input_files") {
if (rawCol === "input_files") {
// 1.x refs are dead workspace paths; discard content, store empty array
out.input_refs = [];
continue;
}
if (col === "output_path") {
if (rawCol === "output_path") {
// Replaced by output_refs; 1.x paths are dead
out.output_refs = [];
continue;
}
if (col === "progress") {
if (rawCol === "progress") {
// real 0-1 becomes jsonb {percent}
const p = typeof raw === "number" ? raw : 0;
out.progress = { percent: Math.round(p * 100) };
continue;
}
if (col === "error") {
if (rawCol === "error") {
// text becomes jsonb {message}
out.error = raw ? { message: String(raw) } : null;
continue;
}
if (rawCol === "status") {
// Map 1.x status onto the 2.x enum; unknown -> failed.
const mapped = STATUS_MAP[String(raw).toLowerCase()] ?? "failed";
out.status = VALID_STATUS.has(mapped) ? mapped : "failed";
continue;
}
}
if (raw === null || raw === undefined) {
@@ -83,6 +134,18 @@ function convertRow(table: string, row: SqliteRow): SqliteRow {
return out;
}
/** Live target columns for a public table (drizzle transaction handle). */
async function targetColumns(
tx: { execute: typeof db.execute },
table: string,
): Promise<Set<string>> {
const res = await tx.execute(
sql`SELECT column_name FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = ${table}`,
);
return new Set(res.rows.map((r) => r.column_name as string));
}
export async function migrateFromSqlite(
sqlitePath: string,
opts: { force: boolean },
@@ -92,22 +155,38 @@ export async function migrateFromSqlite(
// so the CLI works standalone; do not remove.
await runMigrations();
const existing = await db.execute(sql`SELECT count(*)::int AS n FROM users`);
if ((existing.rows[0].n as number) > 0 && !opts.force) {
throw new Error(
"Target Postgres database is non-empty; refusing to migrate. Re-run with --force to attempt inserting 1.x rows into the existing database. This will FAIL and roll back if any primary key or unique value (username, team name, role name) collides with existing data.",
);
}
const sqlite = new Database(sqlitePath, { readonly: true, fileMustExist: true });
const result: MigrationResult = { tables: {} };
try {
await db.transaction(async (tx) => {
for (const table of TABLE_ORDER) {
const rows = sqlite.prepare(`SELECT * FROM ${table}`).all() as SqliteRow[];
// Serialize concurrent replicas: only one import proceeds; losers re-check below.
await tx.execute(sql`SELECT pg_advisory_xact_lock(${SQLITE_IMPORT_LOCK_KEY})`);
const existing = await tx.execute(sql`SELECT count(*)::int AS n FROM users`);
if ((existing.rows[0].n as number) > 0 && !opts.force) {
throw new TargetNonEmptyError(
"Target Postgres database is non-empty; refusing to migrate. Re-run with --force to attempt inserting 1.x rows into the existing database. This will FAIL and roll back if any primary key or unique value (username, team name, role name) collides with existing data.",
);
}
for (const table of MIGRATED_TABLES) {
let rows: SqliteRow[];
try {
rows = sqlite.prepare(`SELECT * FROM ${table}`).all() as SqliteRow[];
} catch (e) {
// Older-than-1.17.2 files may lack a table. Skip rather than fatal.
if (/no such table/i.test((e as Error).message)) {
result.tables[table] = 0;
continue;
}
throw e;
}
const target = await targetColumns(tx, table);
for (const row of rows) {
const converted = convertRow(table, row);
const cols = Object.keys(converted);
// Self-adjusting: insert only columns that exist in the live target, so a
// column 1.x has but 2.x dropped (analytics_*) is skipped generically.
const cols = Object.keys(converted).filter((c) => target.has(c));
const colList = sql.raw(cols.map((c) => `"${c}"`).join(", "));
const values = sql.join(
cols.map((c) => {
@@ -139,25 +218,5 @@ export async function migrateFromSqlite(
}
return result;
}
// CLI entry: pnpm --filter @snapotter/api migrate:sqlite -- <path> [--force]
const invokedDirectly = /migrate-from-sqlite\.[tj]s$/.test(process.argv[1] ?? "");
if (invokedDirectly) {
// pnpm forwards "--" as a literal arg; skip it and any flags to find the positional path
const args = process.argv.slice(2);
const path = args.find((a) => a !== "--" && !a.startsWith("--"));
const force = args.includes("--force");
if (!path) {
console.error("Usage: migrate-from-sqlite <path-to-1.x-sqlite-db> [--force]");
process.exit(1);
}
migrateFromSqlite(path, { force })
.then((r) => {
console.log("Migration complete:", JSON.stringify(r.tables));
process.exit(0);
})
.catch((err) => {
console.error("Migration FAILED (no partial state; transaction rolled back):", err.message);
process.exit(1);
});
}
// The CLI (including --dry-run) lives in sqlite-import.ts, the orchestrator that
// wraps this engine. `pnpm --filter @snapotter/api migrate:sqlite` runs that.
+240
View File
@@ -0,0 +1,240 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { sql } from "drizzle-orm";
import { getSettingString, upsertSetting } from "../lib/settings-helpers.js";
import { db } from "./index.js";
import { MIGRATED_TABLES, migrateFromSqlite, TargetNonEmptyError } from "./migrate-from-sqlite.js";
export const IMPORT_MARKER_KEY = "sqlite_import";
export type ImportStatus = "completed" | "detected_locked";
export interface ImportMarker {
status: ImportStatus;
tables?: Record<string, number>;
blobs?: { present: number; missing: number };
sourcePath?: string;
at?: string;
}
export type BootState = "import" | "leftover" | "locked" | "none";
/** Resolve which SQLite file to consider. Explicit path wins; "off" disables; else probe DATA_DIR. */
export function resolveSource(env: {
SQLITE_MIGRATE_PATH: string;
DATA_DIR: string;
}): string | null {
const explicit = env.SQLITE_MIGRATE_PATH.trim();
if (explicit.toLowerCase() === "off") return null;
if (explicit) return explicit;
const probed = join(env.DATA_DIR, "snapotter.db");
return existsSync(probed) ? probed : null;
}
/**
* Decide the boot action from the three inputs. `import` runs the copy; `leftover`
* means we already imported (source file is a harmless leftover); `locked` means a
* source is present but the instance already has data, so we cannot auto-import.
*/
export function evaluateBootState(input: {
usersCount: number;
source: string | null;
marker: { status: ImportStatus } | null;
}): BootState {
if (!input.source) return "none";
if (input.marker?.status === "completed") return "leftover";
if (input.usersCount === 0) return "import";
return "locked";
}
/**
* Count how many user_files.stored_name blobs exist under filesStoragePath.
* Read-only; only ever called during an actual import or the CLI, never on
* leftover/locked boots (a large library must not stat-scan on every boot).
*/
export async function countLibraryBlobs(
sqlitePath: string,
filesStoragePath: string,
): Promise<{ present: number; missing: number }> {
const { default: Database } = await import("better-sqlite3");
const s = new Database(sqlitePath, { readonly: true });
let present = 0;
let missing = 0;
try {
const rows = s.prepare("SELECT stored_name FROM user_files").all() as Array<{
stored_name: string;
}>;
for (const r of rows) {
if (existsSync(join(filesStoragePath, r.stored_name))) present++;
else missing++;
}
} catch {
/* no user_files table on very old files */
} finally {
s.close();
}
return { present, missing };
}
const VALID_JOB_STATUS = new Set(["queued", "processing", "completed", "failed", "canceled"]);
export interface AnalyzeReport {
tables: Record<string, number>;
blobs: { present: number; missing: number };
badStatuses: string[];
}
/**
* Read-only pre-flight analysis of a 1.x SQLite file. Needs no live Postgres, so an
* operator can run it before starting the stack. Reports per-table row counts,
* library-blob presence, and any job status values outside the 2.x enum (which the
* importer maps to "failed"/"processing").
*/
export async function analyzeSqlite(
sqlitePath: string,
filesStoragePath: string,
): Promise<AnalyzeReport> {
const { default: Database } = await import("better-sqlite3");
const s = new Database(sqlitePath, { readonly: true, fileMustExist: true });
const tables: Record<string, number> = {};
const badStatuses = new Set<string>();
try {
for (const t of MIGRATED_TABLES) {
try {
tables[t] = (s.prepare(`SELECT count(*) AS n FROM ${t}`).get() as { n: number }).n;
} catch {
tables[t] = 0; // table absent in an older 1.x file
}
}
try {
const rows = s.prepare("SELECT DISTINCT status FROM jobs").all() as Array<{ status: string }>;
for (const r of rows) {
if (!VALID_JOB_STATUS.has(String(r.status).toLowerCase())) {
badStatuses.add(String(r.status));
}
}
} catch {
/* no jobs table */
}
} finally {
s.close();
}
const blobs = await countLibraryBlobs(sqlitePath, filesStoragePath);
return { tables, blobs, badStatuses: [...badStatuses] };
}
export async function getImportMarker(): Promise<ImportMarker | null> {
const raw = await getSettingString(IMPORT_MARKER_KEY, "");
if (!raw) return null;
try {
return JSON.parse(raw) as ImportMarker;
} catch {
return null;
}
}
async function writeImportMarker(marker: ImportMarker): Promise<void> {
await upsertSetting(IMPORT_MARKER_KEY, JSON.stringify(marker));
}
/**
* Boot entry point. Resolves the source, evaluates the state, and acts, keeping
* the "import before any seeding" ordering the caller relies on. Never deletes
* the source. Returns the resolved state for logging.
*/
export async function runBootImport(env: {
SQLITE_MIGRATE_PATH: string;
DATA_DIR: string;
FILES_STORAGE_PATH: string;
}): Promise<BootState> {
const source = resolveSource(env);
const marker = await getImportMarker();
const { rows } = await db.execute(sql`SELECT count(*)::int AS n FROM users`);
const state = evaluateBootState({ usersCount: rows[0].n as number, source, marker });
if (state === "import" && source) {
try {
const result = await migrateFromSqlite(source, { force: false });
const blobs = await countLibraryBlobs(source, env.FILES_STORAGE_PATH);
await writeImportMarker({
status: "completed",
tables: result.tables,
blobs,
sourcePath: source,
at: new Date().toISOString(),
});
console.log(
"Imported 1.x SQLite database:",
JSON.stringify({ tables: result.tables, blobs }),
);
} catch (err) {
if (err instanceof TargetNonEmptyError) {
// Another replica imported first; benign.
console.log("1.x import skipped: another instance populated the database first.");
return "leftover";
}
console.error(
`FATAL: 1.x SQLite import failed from ${source}: ${(err as Error).message}. No partial data was written.`,
);
process.exit(1);
}
} else if (state === "locked" && source) {
console.warn(
`WARNING: a 1.x database was found at ${source} but this instance already has data, so it was NOT imported. ` +
"Importing 1.x data requires an empty instance. See the upgrade guide.",
);
await writeImportMarker({ status: "detected_locked", sourcePath: source });
} else if (state === "leftover" && source) {
console.log(`1.x import already applied; you may delete ${source}.`);
}
return state;
}
// CLI: pnpm --filter @snapotter/api migrate:sqlite -- <path> [--dry-run|--verify] [--force]
const invokedDirectly = /sqlite-import\.[tj]s$/.test(process.argv[1] ?? "");
if (invokedDirectly) {
const args = process.argv.slice(2);
const cliPath = args.find((a) => a !== "--" && !a.startsWith("--"));
const dryRun = args.includes("--dry-run") || args.includes("--verify");
const force = args.includes("--force");
if (!cliPath) {
console.error("Usage: migrate:sqlite -- <path-to-1.x-sqlite-db> [--dry-run] [--force]");
process.exit(1);
}
const filesStoragePath = process.env.FILES_STORAGE_PATH || "./data/files";
if (dryRun) {
analyzeSqlite(cliPath, filesStoragePath)
.then((r) => {
const total = Object.values(r.tables).reduce((a, b) => a + b, 0);
console.log("1.x import dry run (no writes):");
console.log(" rows per table:", JSON.stringify(r.tables));
console.log(` library blobs: ${r.blobs.present} present, ${r.blobs.missing} missing`);
if (r.blobs.missing > 0) {
console.log(" WARNING: some saved-library files are missing from the files directory.");
}
if (r.badStatuses.length) {
console.log(
` job statuses to be mapped: ${r.badStatuses.join(", ")} -> failed/processing`,
);
}
console.log(` would import ${total} rows total (sessions are intentionally skipped).`);
process.exit(0);
})
.catch((err) => {
console.error("Dry run failed:", (err as Error).message);
process.exit(1);
});
} else {
migrateFromSqlite(cliPath, { force })
.then((r) => {
console.log("Migration complete:", JSON.stringify(r.tables));
process.exit(0);
})
.catch((err) => {
console.error(
"Migration FAILED (no partial state; transaction rolled back):",
(err as Error).message,
);
process.exit(1);
});
}
}
+10 -17
View File
@@ -103,23 +103,16 @@ try {
process.exit(1);
}
// Auto-import 1.x SQLite database on first boot (before default user creation)
if (env.SQLITE_MIGRATE_PATH) {
const { rows } = await db.execute(sql`SELECT count(*)::int AS n FROM users`);
if ((rows[0].n as number) === 0) {
try {
const { migrateFromSqlite } = await import("./db/migrate-from-sqlite.js");
const result = await migrateFromSqlite(env.SQLITE_MIGRATE_PATH, { force: false });
console.log("Imported 1.x SQLite database:", JSON.stringify(result.tables));
} catch (err) {
console.error(
`FATAL: 1.x SQLite import failed from ${env.SQLITE_MIGRATE_PATH}: ${(err as Error).message}. No partial data was written.`,
);
process.exit(1);
}
} else {
console.log("SQLITE_MIGRATE_PATH set but target is not empty; skipping import");
}
// Auto-import / detect a 1.x SQLite database on boot (before default user creation).
// The orchestrator owns detection (explicit path, "off" sentinel, or DATA_DIR probe),
// the four boot states, and the persisted marker. See db/sqlite-import.ts.
{
const { runBootImport } = await import("./db/sqlite-import.js");
await runBootImport({
SQLITE_MIGRATE_PATH: env.SQLITE_MIGRATE_PATH,
DATA_DIR: env.DATA_DIR,
FILES_STORAGE_PATH: env.FILES_STORAGE_PATH,
});
}
// Seed built-in roles (admin, editor, user) that legacy SQLite migrations
+1
View File
@@ -36,6 +36,7 @@ const envSchema = z
API_KEYS_RATE_LIMIT_PER_MIN: z.coerce.number().default(30),
DATABASE_URL: z.string().default("postgres://snapotter:snapotter@localhost:5432/snapotter"),
SQLITE_MIGRATE_PATH: z.string().default(""),
DATA_DIR: z.string().default("./data"),
FILES_STORAGE_PATH: z.string().default("./data/files"),
WORKSPACE_PATH: z.string().default("./tmp/workspace"),
DEFAULT_THEME: z.enum(["light", "dark", "system"]).default("light"),
+1
View File
@@ -27,6 +27,7 @@ const SENSITIVE_KEYS = new Set([
"scim_token_hash",
"siem_config",
"siem_webhook_auth",
"sqlite_import",
"webhook_destinations",
]);