import { strFromU8, strToU8, unzipSync, zipSync } from "fflate"; import type { MediaType } from "@sttracker/plugin-sdk/types"; import { toCsv } from "@sttracker/plugin-sdk/csv"; import type { Db } from "@sttracker/db/db"; import { newId } from "@sttracker/db/ids"; import { fromJson, toJson } from "@sttracker/db/json"; import type { AttachmentEntity, EntryStatus, Visibility } from "@sttracker/db/schema"; import { MAX_ATTACHMENT_BYTES, mimeAllowed, normalizeMime, sha256Hex, writeAttachmentFile } from "./attachments.ts"; import { isUniqueViolation } from "./matching.ts"; import { ImportError } from "./errors.ts"; import { createWorkIn, findWorkByExternalIdsIn, type K, type Position } from "./works.ts"; export const EXPORT_FORMAT = "sttracker-export/v1"; // Export rows are loosely typed records: the zip is untrusted input on import. // eslint-disable-next-line @typescript-eslint/no-explicit-any type Rec = Record; const camel = (s: string) => s.replace(/_([a-z0-9])/g, (_, c: string) => c.toUpperCase()); const snake = (s: string) => s.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`); const JSON_COLS = { works: ["creators", "metadata", "position"], entries: ["tags", "custom_fields", "overrides", "field_sources"], engagements: ["progress", "custom_fields", "field_sources"], listens: ["mbids"], copies: ["custom_fields"], defs: ["options", "applies_to"], history: ["before", "after"], } as const; /** DB row -> camelCase export record, JSON columns parsed, `drop` columns removed. */ function out(row: Rec, jsonCols: readonly string[], drop: readonly string[] = []): Rec { const o: Rec = {}; for (const [k, v] of Object.entries(row)) { if (drop.includes(k)) continue; o[camel(k)] = jsonCols.includes(k) ? fromJson(v as string, null) : v; } return o; } /** camelCase export record -> snake_case row, JSON columns serialised. */ function inn(rec: Rec, jsonCols: readonly string[]): Rec { const o: Rec = {}; for (const [k, v] of Object.entries(rec)) { const col = snake(k); o[col] = jsonCols.includes(col) ? toJson(v) : v; } return o; } const jsonl = (rows: Rec[]) => strToU8(rows.map((r) => JSON.stringify(r) + "\n").join("")); function readJsonl(files: Record, name: string): Rec[] { const f = files[name]; if (!f) return []; return strFromU8(f) .split("\n") .filter((l) => l.trim() !== "") .map((l) => JSON.parse(l) as Rec); } const CHUNK = 500; const MAX_UNCOMPRESSED = 2 * 1024 * 1024 * 1024; /** Run `fn` over `ids` in batches (keeps `IN (...)` lists under driver parameter limits). */ async function inChunks(ids: readonly string[], fn: (chunk: string[]) => Promise): Promise { const all: T[] = []; for (let i = 0; i < ids.length; i += CHUNK) all.push(...(await fn(ids.slice(i, i + CHUNK)))); return all; } /** * Per-user export bundle (zip). Includes soft-deleted rows so a restore is lossless; never credentials. * All reads happen in one transaction (repeatable read on Postgres) so the bundle is a consistent snapshot. */ export function exportUserBundle(db: Db, dataDir: string, userId: string): Promise { const tb = db.k.transaction(); return (db.dialect === "postgres" ? tb.setIsolationLevel("repeatable read") : tb).execute((tx) => buildBundle(tx, dataDir, userId)); } async function buildBundle(k: K, dataDir: string, userId: string): Promise { const user = await k.selectFrom("users").select(["username", "display_name"]).where("id", "=", userId).executeTakeFirstOrThrow(); const entries = await k.selectFrom("entries").selectAll().where("user_id", "=", userId).orderBy("id").execute(); const engagements = ( await k.selectFrom("engagements").innerJoin("entries", "entries.id", "engagements.entry_id").selectAll("engagements").where("entries.user_id", "=", userId).orderBy("engagements.id").execute() ); const listens = await k.selectFrom("listens").selectAll().where("user_id", "=", userId).orderBy("id").execute(); const lists = await k.selectFrom("lists").selectAll().where("user_id", "=", userId).orderBy("id").execute(); const items = await inChunks(lists.map((l) => l.id), (c) => k.selectFrom("list_items").selectAll().where("list_id", "in", c).orderBy("position").orderBy("id").execute(), ); const copies = await k.selectFrom("copies").selectAll().where("user_id", "=", userId).orderBy("id").execute(); const defs = await k.selectFrom("custom_field_definitions").selectAll().where("user_id", "=", userId).orderBy("id").execute(); const history = await k.selectFrom("user_history").selectAll().where("user_id", "=", userId).orderBy("created_at").orderBy("id").execute(); const atts = await k.selectFrom("attachments").selectAll().where("user_id", "=", userId).orderBy("id").execute(); const links = await inChunks(atts.map((a) => a.id), (c) => k.selectFrom("attachment_links").selectAll().where("attachment_id", "in", c).execute()); const aliases = await k.selectFrom("work_aliases").selectAll().where("user_id", "=", userId).orderBy("id").execute(); // Works: everything referenced, plus ancestors. const workIds = new Set(); for (const e of entries) workIds.add(e.work_id); for (const i of items) workIds.add(i.work_id); for (const c of copies) if (c.work_id) workIds.add(c.work_id); for (const l of listens) if (l.work_id) workIds.add(l.work_id); for (const a of aliases) { workIds.add(a.from_work_id); workIds.add(a.to_work_id); } const works = new Map(); let frontier = [...workIds]; while (frontier.length) { const rows = await inChunks(frontier, (c) => k.selectFrom("works").selectAll().where("id", "in", c).execute()); frontier = []; for (const w of rows) { works.set(w.id, w as Rec); // Ancestors and merge survivors are part of the bundle too. for (const ref of [w.parent_id, w.merged_into_id]) if (ref && !works.has(ref)) frontier.push(ref); } } const extIds = await inChunks([...works.keys()], (c) => k.selectFrom("external_ids").selectAll().where("work_id", "in", c).orderBy("namespace").execute()); const extByWork = new Map>(); for (const x of extIds) { const m = extByWork.get(x.work_id) ?? {}; m[x.namespace] = x.value; extByWork.set(x.work_id, m); } const workRecs = [...works.values()] .sort((a, b) => (a.id < b.id ? -1 : 1)) .map((w) => ({ ...out(w, JSON_COLS.works, ["owner_user_id", "cover_attachment_id"]), private: w.owner_user_id != null, externalIds: extByWork.get(w.id) ?? {}, })); const files: Record = {}; const attRecs: Rec[] = []; for (const a of atts) { const file = Bun.file(`${dataDir}/attachments/${a.sha256.slice(0, 2)}/${a.sha256.slice(2, 4)}/${a.sha256}`); if (!(await file.exists())) continue; files[`attachments/${a.sha256}`] = new Uint8Array(await file.arrayBuffer()); attRecs.push({ ...out(a, [], ["user_id"]), links: links.filter((l) => l.attachment_id === a.id).map((l) => ({ entity: l.entity, entityId: l.entity_id })), }); } const listRecs = lists.map((l) => ({ ...out(l, [], ["user_id"]), items: items.filter((i) => i.list_id === l.id).map((i) => out(i, [], ["list_id"])), })); const manifest = { format: EXPORT_FORMAT, exportedAt: new Date().toISOString(), user: { username: user.username, displayName: user.display_name }, }; return zipSync({ "manifest.json": strToU8(JSON.stringify(manifest, null, 2)), "works.jsonl": jsonl(workRecs), "entries.jsonl": jsonl(entries.map((e) => ({ ...out(e, JSON_COLS.entries, ["user_id"]), favorite: e.favorite !== 0 }))), "engagements.jsonl": jsonl(engagements.map((e) => out(e, JSON_COLS.engagements))), "listens.jsonl": jsonl(listens.map((l) => out(l, JSON_COLS.listens, ["user_id"]))), "lists.jsonl": jsonl(listRecs), "copies.jsonl": jsonl(copies.map((c) => out(c, JSON_COLS.copies, ["user_id"]))), "custom_fields.jsonl": jsonl(defs.map((d) => out(d, JSON_COLS.defs, ["user_id"]))), "work_aliases.jsonl": jsonl(aliases.map((a) => ({ fromWorkId: a.from_work_id, toWorkId: a.to_work_id }))), "history.jsonl": jsonl(history.map((h) => out(h, JSON_COLS.history, ["user_id"]))), "attachments.jsonl": jsonl(attRecs), ...files, }); } /** CSV per entity type (live rows only): entries, engagements, listens. */ export async function exportUserCsv(db: Db, userId: string): Promise> { const k = db.k; const s = (v: unknown) => (v == null ? "" : String(v)); const entries = await k .selectFrom("entries") .innerJoin("works", "works.id", "entries.work_id") .select(["entries.id", "entries.work_id", "works.title", "works.media_type", "works.year", "entries.status", "entries.rating", "entries.favorite", "entries.tags", "entries.review", "entries.created_at", "entries.updated_at"]) .where("entries.user_id", "=", userId) .where("entries.deleted_at", "is", null) .orderBy("entries.id") .execute(); const engagements = await k .selectFrom("engagements") .innerJoin("entries", "entries.id", "engagements.entry_id") .innerJoin("works", "works.id", "entries.work_id") .select(["engagements.id", "engagements.entry_id", "works.title", "engagements.started_at", "engagements.started_precision", "engagements.finished_at", "engagements.finished_precision", "engagements.platform", "engagements.rating", "engagements.notes"]) .where("entries.user_id", "=", userId) .where("entries.deleted_at", "is", null) .where("engagements.deleted_at", "is", null) .orderBy("engagements.id") .execute(); const listens = await k.selectFrom("listens").selectAll().where("user_id", "=", userId).orderBy("listened_at").orderBy("id").execute(); return { "entries.csv": toCsv([ ["id", "workId", "title", "mediaType", "year", "status", "rating", "favorite", "tags", "review", "createdAt", "updatedAt"], ...entries.map((e) => [e.id, e.work_id, e.title, e.media_type, s(e.year), e.status, s(e.rating), e.favorite ? "true" : "false", fromJson(e.tags, []).join("; "), s(e.review), e.created_at, e.updated_at]), ]), "engagements.csv": toCsv([ ["id", "entryId", "title", "startedAt", "startedPrecision", "finishedAt", "finishedPrecision", "platform", "rating", "notes"], ...engagements.map((e) => [e.id, e.entry_id, e.title, s(e.started_at), e.started_precision, s(e.finished_at), e.finished_precision, s(e.platform), s(e.rating), s(e.notes)]), ]), "listens.csv": toCsv([ ["id", "listenedAt", "artist", "track", "album", "durationMs", "source"], ...listens.map((l) => [l.id, l.listened_at, l.artist, l.track, s(l.album), s(l.duration_ms), l.source]), ]), }; } const UUID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g; const STATUSES: readonly EntryStatus[] = ["planned", "in_progress", "paused", "completed", "dropped", "none"]; const VISIBILITIES: readonly Visibility[] = ["private", "friends", "public"]; function corrupt(): never { throw new ImportError("corrupt_bundle"); } function checkRatingValue(v: unknown): void { if (v != null && !(typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 100)) corrupt(); } function checkRows(rows: Rec[], opts: { status?: boolean; visibility?: boolean; rating?: boolean }): void { for (const r of rows) { if (opts.rating) checkRatingValue(r.rating); if (opts.status && !STATUSES.includes(r.status)) corrupt(); if (opts.visibility && !VISIBILITIES.includes(r.visibility)) corrupt(); } } /** * Restore a bundle into an account with no entries. Works are re-matched by external id. Everything is * validated before anything is written; rows (attachments included) are inserted in one transaction. */ export async function importUserBundle( db: Db, dataDir: string, userId: string, zip: Uint8Array, ): Promise<{ works: number; entries: number }> { let files: Record; let total = 0; try { files = unzipSync(zip, { filter: (f) => { total += f.originalSize; if (total > MAX_UNCOMPRESSED) throw new ImportError("bundle_too_large"); return true; }, }); } catch (e) { if (e instanceof ImportError) throw e; throw new ImportError("unsupported_format"); } const mf = files["manifest.json"]; let manifest: Rec = {}; try { manifest = mf ? (JSON.parse(strFromU8(mf)) as Rec) : {}; } catch { /* handled below */ } if (manifest.format !== EXPORT_FORMAT) throw new ImportError("unsupported_format"); const existing = await db.k.selectFrom("entries").select("id").where("user_id", "=", userId).limit(1).executeTakeFirst(); if (existing) throw new ImportError("account_not_empty"); let works: Rec[], entries: Rec[], engagements: Rec[], listens: Rec[], lists: Rec[], copies: Rec[]; let defs: Rec[], history: Rec[], atts: Rec[], aliases: Rec[]; try { works = readJsonl(files, "works.jsonl"); entries = readJsonl(files, "entries.jsonl"); engagements = readJsonl(files, "engagements.jsonl"); listens = readJsonl(files, "listens.jsonl"); lists = readJsonl(files, "lists.jsonl"); copies = readJsonl(files, "copies.jsonl"); defs = readJsonl(files, "custom_fields.jsonl"); history = readJsonl(files, "history.jsonl"); atts = readJsonl(files, "attachments.jsonl"); aliases = readJsonl(files, "work_aliases.jsonl"); } catch { return corrupt(); } checkRows(entries, { rating: true, status: true, visibility: true }); checkRows(engagements, { rating: true }); checkRows(lists, { rating: true, visibility: true }); // Attachments: validate everything before writing anything. for (const a of atts) { const bytes = files[`attachments/${a.sha256}`]; if ( !bytes || !mimeAllowed(normalizeMime(String(a.mime ?? ""))) || bytes.byteLength > MAX_ATTACHMENT_BYTES || sha256Hex(bytes) !== a.sha256 ) { throw new ImportError("invalid_attachment"); } } for (const a of atts) await writeAttachmentFile(dataDir, files[`attachments/${a.sha256}`]!); const ids = new Map(); const remap = (old: unknown): string | null => (typeof old === "string" ? (ids.get(old) ?? null) : null); const remapCustom = (v: unknown): Rec => { const o: Rec = {}; for (const [key, val] of Object.entries((v ?? {}) as Rec)) o[ids.get(key) ?? key] = val; return o; }; // Parents before children. const byId = new Map(works.map((w) => [w.id as string, w])); const depth = (w: Rec): number => { let d = 0; const seen = new Set(); for (let p = w.parentId ? byId.get(w.parentId) : undefined; p && !seen.has(p.id); p = p.parentId ? byId.get(p.parentId) : undefined) { seen.add(p.id); d++; } return d; }; const ordered = works.map((w) => ({ w, d: depth(w) })).sort((a, b) => a.d - b.d).map((x) => x.w); try { await db.k.transaction().execute(async (tx) => { const created = new Set(); for (const w of ordered) { const externalIds = (w.externalIds ?? {}) as Record; const hasExt = Object.keys(externalIds).length > 0; const matched = hasExt ? await findWorkByExternalIdsIn(tx, externalIds, userId) : null; if (matched) { ids.set(w.id, matched); continue; } const newWorkId = await createWorkIn( tx, { hint: { title: w.title, mediaType: w.mediaType as MediaType, year: w.year ?? undefined, creators: w.creators ?? [], externalIds, coverUrl: w.coverUrl ?? "", description: w.description ?? "", position: (w.position ?? undefined) as Position | undefined, }, ownerUserId: hasExt ? null : userId, parentId: remap(w.parentId), }, "import", ); await tx .updateTable("works") .set({ original_title: w.originalTitle ?? "", release_date: w.releaseDate ?? null, metadata: toJson(w.metadata ?? {}), created_at: w.createdAt, updated_at: w.updatedAt, }) .where("id", "=", newWorkId) .execute(); ids.set(w.id, newWorkId); created.add(w.id); } // Tombstones: only for works created here (a matched existing work keeps its own state). for (const w of works) { const target = remap(w.mergedIntoId); if (!created.has(w.id) || !target) continue; await tx.updateTable("works").set({ merged_into_id: target }).where("id", "=", ids.get(w.id)!).execute(); } for (const a of aliases) { const from = remap(a.fromWorkId); const to = remap(a.toWorkId); if (!from || !to || from === to) continue; await tx.insertInto("work_aliases").values({ id: newId(), user_id: userId, from_work_id: from, to_work_id: to }).execute(); } for (const d of defs) { const id = newId(); ids.set(d.id, id); await tx.insertInto("custom_field_definitions").values({ ...inn(d, JSON_COLS.defs), id, user_id: userId } as never).execute(); } for (const e of entries) { const id = newId(); ids.set(e.id, id); const workId = remap(e.workId); if (!workId) corrupt(); await tx .insertInto("entries") .values({ ...inn(e, JSON_COLS.entries), id, user_id: userId, work_id: workId, favorite: e.favorite ? 1 : 0, custom_fields: toJson(remapCustom(e.customFields)) } as never) .execute(); } for (const g of engagements) { const id = newId(); ids.set(g.id, id); const entryId = remap(g.entryId); if (!entryId) corrupt(); await tx .insertInto("engagements") .values({ ...inn(g, JSON_COLS.engagements), id, entry_id: entryId, custom_fields: toJson(remapCustom(g.customFields)) } as never) .execute(); } for (const l of listens) { await tx .insertInto("listens") .values({ ...inn(l, JSON_COLS.listens), id: newId(), user_id: userId, work_id: remap(l.workId) } as never) .onConflict((oc) => oc.doNothing()) .execute(); } for (const l of lists) { const id = newId(); ids.set(l.id, id); const { items, ...rest } = l; await tx.insertInto("lists").values({ ...inn(rest, []), id, user_id: userId } as never).execute(); for (const it of (items ?? []) as Rec[]) { const workId = remap(it.workId); if (!workId) continue; await tx.insertInto("list_items").values({ ...inn(it, []), id: newId(), list_id: id, work_id: workId } as never).execute(); } } for (const c of copies) { const id = newId(); ids.set(c.id, id); await tx .insertInto("copies") .values({ ...inn(c, JSON_COLS.copies), id, user_id: userId, work_id: remap(c.workId), custom_fields: toJson(remapCustom(c.customFields)) } as never) .execute(); } for (const a of atts) { const links = ((a.links ?? []) as Rec[]) .map((l) => ({ entity: l.entity as AttachmentEntity, entityId: remap(l.entityId) })) .filter((l): l is { entity: AttachmentEntity; entityId: string } => l.entityId != null); if (links.length === 0) continue; const id = newId(); const { links: _links, ...attRow } = a; await tx .insertInto("attachments") .values({ ...inn(attRow, []), id, user_id: userId, mime: normalizeMime(a.mime), size: files[`attachments/${a.sha256}`]!.byteLength } as never) .execute(); for (const l of links) { await tx.insertInto("attachment_links").values({ id: newId(), attachment_id: id, entity: l.entity, entity_id: l.entityId }).execute(); } } // History last, so every referenced id has been remapped. for (const h of history) { const sub = (v: unknown) => toJson(JSON.parse(JSON.stringify(v).replace(UUID_RE, (m) => ids.get(m) ?? m))); await tx .insertInto("user_history") .values({ ...inn(h, []), id: newId(), user_id: userId, entity_id: remap(h.entityId) ?? h.entityId, before: sub(h.before), after: sub(h.after) } as never) .execute(); } }); } catch (e) { if (e instanceof ImportError) throw e; if (isUniqueViolation(e)) throw new ImportError("conflict"); throw e; } // An unlink of identical bytes between the pre-write and the commit may have removed a file. for (const a of atts) await writeAttachmentFile(dataDir, files[`attachments/${a.sha256}`]!); return { works: works.length, entries: entries.length }; }