mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -27,6 +27,7 @@ const SENSITIVE_KEYS = new Set([
|
||||
"scim_token_hash",
|
||||
"siem_config",
|
||||
"siem_webhook_auth",
|
||||
"sqlite_import",
|
||||
"webhook_destinations",
|
||||
]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user