import { DatabaseSync } from "node:sqlite"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; const schemaPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "schema.sql"); export type Db = DatabaseSync; export function openDb(dbPath: string): Db { fs.mkdirSync(path.dirname(dbPath), { recursive: true }); const db = new DatabaseSync(dbPath); db.exec("PRAGMA journal_mode = WAL;"); db.exec("PRAGMA foreign_keys = ON;"); migrate(db); return db; } function migrate(db: Db): void { const version = (db.prepare("PRAGMA user_version").get() as { user_version: number }).user_version; if (version < 1) { const ddl = fs.readFileSync(schemaPath, "utf8"); db.exec("BEGIN"); try { db.exec(ddl); db.exec("PRAGMA user_version = 1"); db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); throw err; } } if (version < 2) { // v2: przybliżony hex odcienia (chipy UI; nie jest pomiarem barwy) const cols = db.prepare("PRAGMA table_info(fabric_shades)").all() as Array<{ name: string }>; if (!cols.some((c) => c.name === "color_hex")) { db.exec("ALTER TABLE fabric_shades ADD COLUMN color_hex TEXT"); } db.exec("PRAGMA user_version = 2"); } if (version < 3) { // v3: wersjonowanie assetów per wariant — stare UNIQUE(product_id, role, // version) kolidowało z assetami wariantów (master + wariant v1 tej samej // roli). Odbudowa tabeli z unikalnym indeksem zamiast ograniczenia. db.exec("PRAGMA foreign_keys = OFF"); db.exec("BEGIN"); try { const hasOldUnique = ( db .prepare( "SELECT COUNT(*) AS c FROM sqlite_master WHERE type='index' AND name LIKE 'sqlite_autoindex_assets%' AND sql IS NULL" ) .get() as { c: number } ).c; if (hasOldUnique) { db.exec(` CREATE TABLE assets_v3 ( id TEXT PRIMARY KEY, product_id TEXT NOT NULL REFERENCES products(id), variant_id TEXT REFERENCES variants(id), kind TEXT NOT NULL, role TEXT NOT NULL, version INTEGER NOT NULL, parent_asset_id TEXT, job_id TEXT REFERENCES jobs(id), sha256 TEXT NOT NULL, mime TEXT NOT NULL, bytes INTEGER NOT NULL, width INTEGER, height INTEGER, path TEXT NOT NULL, thumb_path TEXT, status TEXT NOT NULL DEFAULT 'candidate', origin TEXT NOT NULL, provenance_json TEXT NOT NULL DEFAULT '{}', note TEXT, created_at TEXT NOT NULL ); INSERT INTO assets_v3 SELECT * FROM assets; DROP TABLE assets; ALTER TABLE assets_v3 RENAME TO assets; CREATE INDEX IF NOT EXISTS idx_assets_product ON assets(product_id); CREATE INDEX IF NOT EXISTS idx_assets_variant ON assets(variant_id); `); } db.exec( "CREATE UNIQUE INDEX IF NOT EXISTS uq_assets_version ON assets(product_id, role, COALESCE(variant_id, ''), version)" ); db.exec("PRAGMA user_version = 3"); db.exec("COMMIT"); } catch (err) { db.exec("ROLLBACK"); throw err; } db.exec("PRAGMA foreign_keys = ON"); } if (version < 4) { // v4: werdykty Maciusia — doradcza ocena jakości assetów (vision-LLM). // Nigdy nie zmienia statusu assetu; finalna decyzja zawsze u człowieka. db.exec(` CREATE TABLE IF NOT EXISTS macius_verdicts ( id TEXT PRIMARY KEY, asset_id TEXT NOT NULL REFERENCES assets(id), asset_sha256 TEXT NOT NULL, model TEXT NOT NULL, prompt_version TEXT NOT NULL, verdict TEXT NOT NULL, -- ok | do_poprawy | odrzucone | blad issues_json TEXT NOT NULL DEFAULT '[]', justification TEXT NOT NULL DEFAULT '', raw_json TEXT NOT NULL DEFAULT '{}', created_by TEXT, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_verdicts_asset ON macius_verdicts(asset_id); `); db.exec("PRAGMA user_version = 4"); } if (version < 5) { tx(db, () => { db.exec(` CREATE TABLE offer_content_revisions ( id TEXT PRIMARY KEY, product_id TEXT NOT NULL REFERENCES products(id), version INTEGER NOT NULL, source_sha256 TEXT NOT NULL, content_sha256 TEXT NOT NULL, content_json TEXT NOT NULL, created_by TEXT NOT NULL, note TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(product_id, version), UNIQUE(product_id, source_sha256, content_sha256) ); CREATE TABLE offer_content_decisions ( id TEXT PRIMARY KEY, revision_id TEXT NOT NULL REFERENCES offer_content_revisions(id), content_sha256 TEXT NOT NULL, decision TEXT NOT NULL CHECK(decision IN ('approved', 'rejected')), decided_by TEXT NOT NULL, note TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE INDEX idx_content_decisions_revision ON offer_content_decisions(revision_id); PRAGMA user_version = 5; `); }); } if (version < 6) { tx(db, () => { db.exec(` CREATE TABLE channel_renditions ( id TEXT PRIMARY KEY, product_id TEXT NOT NULL REFERENCES products(id), source_asset_id TEXT NOT NULL REFERENCES assets(id), source_sha256 TEXT NOT NULL, variant_id TEXT REFERENCES variants(id), role TEXT NOT NULL, profile TEXT NOT NULL, sha256 TEXT NOT NULL, mime TEXT NOT NULL, bytes INTEGER NOT NULL, width INTEGER, height INTEGER, path TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(product_id, source_asset_id, profile) ); CREATE INDEX idx_renditions_product ON channel_renditions(product_id, profile); CREATE INDEX idx_renditions_variant ON channel_renditions(variant_id); CREATE TABLE offer_snapshots ( id TEXT PRIMARY KEY, product_id TEXT NOT NULL REFERENCES products(id), version INTEGER NOT NULL, source_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL, payload_json TEXT NOT NULL, created_by TEXT NOT NULL, note TEXT NOT NULL, created_at TEXT NOT NULL, UNIQUE(product_id, version), UNIQUE(product_id, source_sha256, snapshot_sha256) ); CREATE TABLE offer_snapshot_decisions ( id TEXT PRIMARY KEY, snapshot_id TEXT NOT NULL REFERENCES offer_snapshots(id), snapshot_sha256 TEXT NOT NULL, decision TEXT NOT NULL CHECK(decision IN ('approved', 'rejected')), decided_by TEXT NOT NULL, note TEXT NOT NULL, created_at TEXT NOT NULL ); CREATE INDEX idx_snapshot_decisions_snapshot ON offer_snapshot_decisions(snapshot_id); PRAGMA user_version = 6; `); }); } if (version < 7) { // v7: pipeline "zdjęcie pod ujęcie" — trwałe przypisanie źródła do roli // planu ujęć (primary = wzorzec perspektywy/stanu, supporting = kontekst) // + pochodzenie źródła (URL dla importów vendor, np. bobochicparis.com). tx(db, () => { const cols = db.prepare("PRAGMA table_info(source_assets)").all() as Array<{ name: string }>; if (!cols.some((c) => c.name === "source_url")) { db.exec("ALTER TABLE source_assets ADD COLUMN source_url TEXT"); db.exec("ALTER TABLE source_assets ADD COLUMN fetched_at TEXT"); } db.exec(` CREATE TABLE IF NOT EXISTS shot_refs ( id TEXT PRIMARY KEY, product_id TEXT NOT NULL REFERENCES products(id), shot_role TEXT NOT NULL, source_id TEXT NOT NULL REFERENCES source_assets(id), ref_kind TEXT NOT NULL, -- primary | supporting note TEXT, created_by TEXT, created_at TEXT NOT NULL, UNIQUE(product_id, shot_role, source_id) ); CREATE UNIQUE INDEX IF NOT EXISTS uq_shot_primary ON shot_refs(product_id, shot_role) WHERE ref_kind = 'primary'; CREATE INDEX IF NOT EXISTS idx_shot_refs_product ON shot_refs(product_id); PRAGMA user_version = 7; `); }); } if (version < 8) { // v8: ochrona wnętrza/okucia ujęć (np. storage) jako DANE, nie stałe // w kodzie. Wielokąt wiązany z SHA treści mastera — geometria ma sens // tylko dla konkretnych pikseli. Nowa wersja mastera = nowy wiersz // autorski (podgląd + weryfikacja wzrokowa), nie edycja kodu. tx(db, () => { db.exec(` CREATE TABLE asset_protections ( id TEXT PRIMARY KEY, asset_sha256 TEXT NOT NULL, role TEXT NOT NULL, version TEXT NOT NULL, width INTEGER NOT NULL, height INTEGER NOT NULL, points_json TEXT NOT NULL, note TEXT, created_by TEXT, created_at TEXT NOT NULL, UNIQUE(asset_sha256) ); `); // przeniesienie historycznych wielokątów OSKAR (były stałymi // STORAGE_PROTECTION* w recolor.ts) — zachowanie bez zmian const ins = db.prepare( `INSERT INTO asset_protections (id, asset_sha256, role, version, width, height, points_json, note, created_by, created_at) VALUES (?, ?, 'storage', ?, ?, ?, ?, ?, ?, ?)` ); const now = new Date().toISOString(); ins.run( "prt_oskar_v2", "74617eb4b0cd3e54b09c2fb1f4459ab4f6b3f3060b83fc867366b57be39361ea", "oskar-storage-interior-v2", 1184, 880, JSON.stringify([[452, 298], [655, 302], [660, 352], [628, 392], [575, 498], [470, 478], [388, 470], [398, 330], [430, 300]]), "migracja z recolor.ts — wielokąt wnętrza pojemnika OSKAR", "migration:v8", now ); ins.run( "prt_oskar_v1", "20583415b13f3d3ec0950640766bcb16a6c7375292eab5d0e8ac5c0b3f4988fb", "oskar-storage-interior-v1", 1184, 880, JSON.stringify([[394, 453], [532, 359], [541, 363], [689, 390], [577, 533], [552, 522], [510, 505], [469, 486], [427, 468]]), "migracja z recolor.ts — master przywrócony z eksportu Base (inny kadr)", "migration:v8", now ); db.exec("PRAGMA user_version = 8"); }); } if (version < 9) { tx(db, () => { db.exec(` CREATE TABLE source_imports ( id TEXT PRIMARY KEY, job_id TEXT NOT NULL UNIQUE REFERENCES jobs(id), product_id TEXT REFERENCES products(id), kind TEXT NOT NULL CHECK(kind IN ('product_page', 'fabric_page', 'fabric_card', 'fabric_sample')), requested_url TEXT NOT NULL, fetch_url TEXT NOT NULL, fragment TEXT, request_key TEXT NOT NULL UNIQUE, request_fingerprint TEXT NOT NULL, phase TEXT NOT NULL DEFAULT 'queued' CHECK(phase IN ('queued', 'fetching', 'stored', 'failed', 'cancelled')), sha256 TEXT, mime TEXT, bytes INTEGER, final_url TEXT, redirects_json TEXT, fetched_at TEXT, error_code TEXT, created_by TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX idx_source_imports_product ON source_imports(product_id, created_at); PRAGMA user_version = 9; `); }); } if (version < 10) { // v10: import tkanin — powiązanie wiersza importu z rekordem fabrics. tx(db, () => { const cols = db.prepare("PRAGMA table_info(source_imports)").all() as Array<{ name: string }>; if (!cols.some((c) => c.name === "fabric_id")) { db.exec("ALTER TABLE source_imports ADD COLUMN fabric_id TEXT REFERENCES fabrics(id)"); db.exec("CREATE INDEX idx_source_imports_fabric ON source_imports(fabric_id, created_at)"); } db.exec("PRAGMA user_version = 10"); }); } if (version < 11) { tx(db, () => db.exec(` CREATE TABLE orchestrator_operations ( id TEXT PRIMARY KEY, product_id TEXT NOT NULL REFERENCES products(id), request_key TEXT NOT NULL, request_sha256 TEXT NOT NULL, action_json TEXT NOT NULL, preview_json TEXT NOT NULL, preview_sha256 TEXT NOT NULL, dependencies_json TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('proposed','queued','running','succeeded','rejected','stale','failed')), directive TEXT, created_by TEXT NOT NULL REFERENCES users(id), created_at TEXT NOT NULL, expires_at TEXT NOT NULL, decided_by TEXT REFERENCES users(id), decided_at TEXT, decision_note TEXT, job_id TEXT UNIQUE REFERENCES jobs(id), result_json TEXT, error TEXT, updated_at TEXT NOT NULL, UNIQUE(created_by, request_key) ); CREATE INDEX idx_orchestrator_product ON orchestrator_operations(product_id, created_at); CREATE INDEX IF NOT EXISTS idx_approvals_asset ON approvals(asset_id); CREATE INDEX IF NOT EXISTS idx_assets_selection ON assets(product_id, role, variant_id, status, version); CREATE TABLE orchestrator_inspections ( product_id TEXT PRIMARY KEY REFERENCES products(id), state_sha256 TEXT NOT NULL, inspection_json TEXT NOT NULL, checked_at TEXT NOT NULL ); PRAGMA user_version = 11; `)); } if (version < 12) { // v12: outbox eksportu Base — osobna, odwołalna zgoda na dokładny SHA // zatwierdzonego snapshotu; wsad per zgoda; pozycje z zewnętrznym ID, // read-backiem i stanem 'unconfirmed' po niepewnym wysłaniu. tx(db, () => db.exec(` CREATE TABLE export_consents ( id TEXT PRIMARY KEY, product_id TEXT NOT NULL REFERENCES products(id), snapshot_id TEXT NOT NULL REFERENCES offer_snapshots(id), snapshot_sha256 TEXT NOT NULL, source_sha256 TEXT NOT NULL, scope_json TEXT NOT NULL, note TEXT NOT NULL, granted_by TEXT NOT NULL REFERENCES users(id), granted_at TEXT NOT NULL, revoked_at TEXT, revoked_by TEXT REFERENCES users(id), revoke_note TEXT ); CREATE INDEX idx_export_consents_product ON export_consents(product_id, granted_at); CREATE TABLE export_batches ( id TEXT PRIMARY KEY, product_id TEXT NOT NULL REFERENCES products(id), consent_id TEXT NOT NULL UNIQUE REFERENCES export_consents(id), snapshot_id TEXT NOT NULL REFERENCES offer_snapshots(id), snapshot_sha256 TEXT NOT NULL, job_id TEXT UNIQUE REFERENCES jobs(id), status TEXT NOT NULL CHECK(status IN ('queued','running','done','partial','failed','cancelled')), created_by TEXT NOT NULL REFERENCES users(id), created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); CREATE INDEX idx_export_batches_product ON export_batches(product_id, created_at); CREATE TABLE export_items ( id TEXT PRIMARY KEY, batch_id TEXT NOT NULL REFERENCES export_batches(id), product_id TEXT NOT NULL REFERENCES products(id), kind TEXT NOT NULL CHECK(kind IN ('parent','variant')), variant_id TEXT REFERENCES variants(id), position INTEGER NOT NULL, sku TEXT, method TEXT NOT NULL, payload_json TEXT NOT NULL, payload_sha256 TEXT NOT NULL, status TEXT NOT NULL CHECK(status IN ('pending','sending','unconfirmed','verified','failed','cancelled')), external_id INTEGER, attempts INTEGER NOT NULL DEFAULT 0, warnings_json TEXT, last_error TEXT, readback_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, UNIQUE(batch_id, kind, variant_id) ); CREATE INDEX idx_export_items_batch ON export_items(batch_id, position); PRAGMA user_version = 12; `)); } if (version < 13) { // v13: ustawienia aplikacji — klucz/wartość (m.in. token Base i domyślne // parametry base.*), edytowalne z panelu; zmiany idą do audit_log. tx(db, () => db.exec(` CREATE TABLE app_settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT NOT NULL, updated_by TEXT ); PRAGMA user_version = 13; `)); } if (version < 14) { // v14: podmiana struktury tkaniny i nóżek w generowanych ujęciach // (ai_prompt na tkaninie + katalog furniture_legs) oraz pula EAN // wydawana wariantom FIFO. assigned_variant_id jest TEXT, bo // variants.id jest tekstowy — spec "INT" dotyczyła schematu MySQL. tx(db, () => { const cols = db.prepare("PRAGMA table_info(fabrics)").all() as Array<{ name: string }>; if (!cols.some((c) => c.name === "ai_prompt")) { db.exec("ALTER TABLE fabrics ADD COLUMN ai_prompt TEXT"); } db.exec(` CREATE TABLE IF NOT EXISTS furniture_legs ( id TEXT PRIMARY KEY, name TEXT NOT NULL, ai_prompt TEXT, finish_type TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS ean_pool ( id INTEGER PRIMARY KEY AUTOINCREMENT, ean TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'available' CHECK(status IN ('available','assigned')), assigned_variant_id TEXT REFERENCES variants(id), assigned_at TEXT ); CREATE INDEX IF NOT EXISTS idx_ean_pool_status ON ean_pool(status, id); PRAGMA user_version = 14; `); }); } if (version < 15) { // v15: nóżki jako referencja wizualna — zdjęcie produktu w source_assets // (rola leg_reference), wysokość, materiał i link źródłowy. Multi-image // conditioning w worker.ts podaje to zdjęcie jako Image 3. tx(db, () => { const cols = db.prepare("PRAGMA table_info(furniture_legs)").all() as Array<{ name: string }>; if (!cols.some((c) => c.name === "image_asset_id")) { db.exec(` ALTER TABLE furniture_legs ADD COLUMN image_asset_id TEXT REFERENCES source_assets(id); ALTER TABLE furniture_legs ADD COLUMN height_cm REAL; ALTER TABLE furniture_legs ADD COLUMN material TEXT; ALTER TABLE furniture_legs ADD COLUMN source_url TEXT; `); } db.exec("PRAGMA user_version = 15"); }); } if (version < 16) { // v16: jeden plik źródłowy (sha256 UNIQUE) może należeć do wielu produktów // — import tego samego mebla pod innym slugiem linkuje istniejący asset // zamiast zgłaszać "identyczne bajty już zapisane przy innym produkcie". tx(db, () => db.exec(` CREATE TABLE IF NOT EXISTS source_asset_products ( source_id TEXT NOT NULL REFERENCES source_assets(id), product_id TEXT NOT NULL REFERENCES products(id), created_at TEXT NOT NULL, PRIMARY KEY (source_id, product_id) ); CREATE INDEX IF NOT EXISTS idx_sap_product ON source_asset_products(product_id); PRAGMA user_version = 16; `)); } if (version < 17) { // v17: studio ze zdjęcia hali. // studio_exclusions — jawnie wskazane obszary obcych obiektów na źródle // (np. folia za oparciem), stosowane przy wycinaniu mebla; decyzja // operatora z autorem, trwała dla powtórek i retry. // studio_material_links — produkt w innej kolekcji tkaniny korzysta // z masterów konstrukcji produktu bazowego (zamiana materiału // w stałym obrysie), zamiast generować bryłę od nowa. tx(db, () => db.exec(` CREATE TABLE IF NOT EXISTS studio_exclusions ( source_id TEXT PRIMARY KEY REFERENCES source_assets(id), spec TEXT NOT NULL, note TEXT, created_by TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS studio_material_links ( product_id TEXT PRIMARY KEY REFERENCES products(id), base_product_id TEXT NOT NULL REFERENCES products(id), created_by TEXT, created_at TEXT NOT NULL ); PRAGMA user_version = 17; `)); } if (version < 18) { // v18: zatwierdzenie maski mastera przez człowieka. Powiązane z assetem // mastera i skrótem pliku maski — zmiana mastera (nowy asset) albo // przeliczenie maski unieważnia zatwierdzenie samo z siebie. tx(db, () => db.exec(` CREATE TABLE IF NOT EXISTS master_mask_reviews ( asset_id TEXT PRIMARY KEY REFERENCES assets(id), mask_key TEXT NOT NULL, reviewed_by TEXT, created_at TEXT NOT NULL ); PRAGMA user_version = 18; `)); } if (version < 19) { // v19: nota struktury tkaniny po polsku — operator wybiera typ struktury // (ai_structure, klucz z FABRIC_STRUCTURES) i ewentualnie dopisuje wolną // uwagę po polsku (ai_note_pl); klauzulę EN do promptu (ai_prompt) // składa system, wolny tekst tłumaczy Groq przy zapisie. tx(db, () => { const cols = db.prepare("PRAGMA table_info(fabrics)").all() as Array<{ name: string }>; if (!cols.some((c) => c.name === "ai_structure")) { db.exec(` ALTER TABLE fabrics ADD COLUMN ai_structure TEXT; ALTER TABLE fabrics ADD COLUMN ai_note_pl TEXT; `); } db.exec("PRAGMA user_version = 19"); }); } } export function nowIso(): string { return new Date().toISOString(); } let transactionSequence = 0; export function tx(db: Db, fn: () => T): T { const savepoint = `vilmal_tx_${++transactionSequence}`; db.exec(`SAVEPOINT ${savepoint}`); try { const out = fn(); db.exec(`RELEASE SAVEPOINT ${savepoint}`); return out; } catch (err) { db.exec(`ROLLBACK TO SAVEPOINT ${savepoint}`); db.exec(`RELEASE SAVEPOINT ${savepoint}`); throw err; } }