mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* 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
112 lines
4.2 KiB
TypeScript
112 lines
4.2 KiB
TypeScript
import { readdirSync, readFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import Database from "better-sqlite3";
|
|
import { hashPassword } from "../../apps/api/src/plugins/auth.js";
|
|
|
|
const LEGACY_DIR = join(
|
|
dirname(fileURLToPath(import.meta.url)),
|
|
"../../apps/api/drizzle-sqlite-legacy",
|
|
);
|
|
|
|
/**
|
|
* Rebuild the final 1.17.2 SQLite schema by replaying every archived legacy
|
|
* migration in filename order. Drizzle separates statements with the marker
|
|
* "--> statement-breakpoint"; each chunk is a single statement, so prepare().run()
|
|
* applies it. Standalone comment lines (some migrations, e.g. 0012, prefix a
|
|
* statement with them) are stripped first. Using the real migrations (not a
|
|
* hand-written schema) guarantees the fixture matches what a real 1.x instance
|
|
* has, including columns 2.x later dropped.
|
|
*/
|
|
export function buildLegacySqlite(path: string): void {
|
|
const files = readdirSync(LEGACY_DIR)
|
|
.filter((f) => f.endsWith(".sql"))
|
|
.sort();
|
|
const s = new Database(path);
|
|
try {
|
|
s.pragma("foreign_keys = OFF");
|
|
for (const file of files) {
|
|
const sql = readFileSync(join(LEGACY_DIR, file), "utf8");
|
|
for (const chunk of sql.split("--> statement-breakpoint")) {
|
|
// Drop standalone comment lines, trailing semicolon, and surrounding whitespace.
|
|
const stmt = chunk
|
|
.replace(/^\s*--.*$/gm, "")
|
|
.trim()
|
|
.replace(/;\s*$/, "");
|
|
if (!stmt) continue;
|
|
s.prepare(stmt).run();
|
|
}
|
|
}
|
|
} finally {
|
|
s.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Insert a small but representative 1.17.2 dataset: a user with a REAL scrypt
|
|
* password hash (so login-after-migrate can be verified), populated analytics
|
|
* columns (the drop-me case), a saved file, a pipeline, jobs whose statuses
|
|
* include the out-of-2.x-enum value "error", and an unexpired session (to prove
|
|
* sessions are NOT copied). Returns the plaintext password and admin id to assert on.
|
|
*/
|
|
export async function seedRealistic1xData(
|
|
path: string,
|
|
): Promise<{ password: string; adminId: string }> {
|
|
const password = "correct horse battery staple";
|
|
const passwordHash = await hashPassword(password);
|
|
const now = 1748000000; // epoch seconds, as 1.x stored
|
|
const s = new Database(path);
|
|
try {
|
|
s.prepare(
|
|
`INSERT INTO users (id, username, password_hash, role, team, must_change_password,
|
|
auth_provider, external_id, email, created_at, updated_at,
|
|
analytics_enabled, analytics_consent_shown_at, analytics_consent_remind_at)
|
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
).run(
|
|
"u-admin",
|
|
"admin",
|
|
passwordHash,
|
|
"admin",
|
|
"Default",
|
|
0,
|
|
"local",
|
|
null,
|
|
"admin@example.com",
|
|
now,
|
|
now,
|
|
1,
|
|
now,
|
|
null,
|
|
);
|
|
// The "Default" team is already seeded by legacy migration 0005; add a
|
|
// distinct custom team so the copy exercises a non-default row too.
|
|
s.prepare("INSERT INTO teams (id, name, created_at) VALUES (?,?,?)").run(
|
|
"tm-eng",
|
|
"Engineering",
|
|
now,
|
|
);
|
|
s.prepare(
|
|
"INSERT INTO pipelines (id, user_id, name, steps, created_at) VALUES (?,?,?,?,?)",
|
|
).run("p-1", "u-admin", "Shrink", '[{"toolId":"compress","settings":{"quality":70}}]', now);
|
|
// job with an out-of-enum status "error" (must map to "failed")
|
|
s.prepare(
|
|
"INSERT INTO jobs (id, type, status, progress, input_files, created_at) VALUES (?,?,?,?,?,?)",
|
|
).run("j-err", "single", "error", 0.5, '["a.png"]', now);
|
|
s.prepare(
|
|
"INSERT INTO jobs (id, type, status, progress, input_files, created_at, completed_at) VALUES (?,?,?,?,?,?,?)",
|
|
).run("j-ok", "single", "completed", 1.0, '["b.png"]', now, now);
|
|
s.prepare(
|
|
"INSERT INTO user_files (id, user_id, original_name, stored_name, mime_type, size, version, created_at) VALUES (?,?,?,?,?,?,?,?)",
|
|
).run("uf-1", "u-admin", "photo.png", "abc123.png", "image/png", 1024, 1, now);
|
|
s.prepare("INSERT INTO sessions (id, user_id, expires_at, created_at) VALUES (?,?,?,?)").run(
|
|
"ses-1",
|
|
"u-admin",
|
|
4102444800,
|
|
now,
|
|
); // expires year 2100
|
|
} finally {
|
|
s.close();
|
|
}
|
|
return { password, adminId: "u-admin" };
|
|
}
|