const Database = require("better-sqlite3"); const path = require("path"); const dbPath = path.join(__dirname, "..", "data", "polygons.db"); const db = new Database(dbPath); db.pragma("foreign_keys = ON"); db.prepare(` CREATE TABLE IF NOT EXISTS geometries ( id TEXT PRIMARY KEY, type TEXT, is_deleted INTEGER NOT NULL DEFAULT 0, draw_geometry TEXT NOT NULL, binding TEXT, time_start INTEGER, time_end INTEGER, bbox_min_lng REAL, bbox_min_lat REAL, bbox_max_lng REAL, bbox_max_lat REAL, created_at TEXT, updated_at TEXT ) `).run(); db.prepare(` CREATE TABLE IF NOT EXISTS entities ( id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, description TEXT, type_id TEXT NOT NULL DEFAULT 'country', status INTEGER DEFAULT 1, is_deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT, updated_at TEXT ) `).run(); db.prepare(` CREATE TABLE IF NOT EXISTS entity_geometries ( entity_id TEXT NOT NULL, geometry_id TEXT NOT NULL, created_at TEXT, PRIMARY KEY (entity_id, geometry_id), FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE, FOREIGN KEY (geometry_id) REFERENCES geometries(id) ON DELETE CASCADE ) `).run(); db.prepare(` CREATE TABLE IF NOT EXISTS sections ( id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT, user_id TEXT, created_by TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ) `).run(); db.prepare(` CREATE TABLE IF NOT EXISTS section_states ( section_id TEXT PRIMARY KEY, status TEXT NOT NULL DEFAULT 'editing', head_commit_id TEXT, version INTEGER NOT NULL DEFAULT 0, locked_by TEXT, locked_at TEXT, lock_expires_at TEXT, updated_at TEXT NOT NULL, FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE CASCADE ) `).run(); db.prepare(` CREATE TABLE IF NOT EXISTS section_commits ( id TEXT PRIMARY KEY, section_id TEXT NOT NULL, parent_commit_id TEXT, commit_no INTEGER NOT NULL, kind TEXT NOT NULL DEFAULT 'manual', restored_from_commit_id TEXT, created_by TEXT NOT NULL, created_at TEXT NOT NULL, title TEXT, note TEXT, snapshot_json TEXT NOT NULL, snapshot_hash TEXT, FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE CASCADE, FOREIGN KEY (parent_commit_id) REFERENCES section_commits(id), FOREIGN KEY (restored_from_commit_id) REFERENCES section_commits(id) ) `).run(); db.prepare(` CREATE TABLE IF NOT EXISTS section_submissions ( id TEXT PRIMARY KEY, section_id TEXT NOT NULL, commit_id TEXT NOT NULL, submitted_by TEXT NOT NULL, submitted_at TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', reviewed_by TEXT, reviewed_at TEXT, review_note TEXT, snapshot_json TEXT NOT NULL, snapshot_hash TEXT, FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE CASCADE, FOREIGN KEY (commit_id) REFERENCES section_commits(id) ) `).run(); ensureColumn("entities", "status", "INTEGER DEFAULT 1"); ensureColumn("entities", "is_deleted", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("entities", "type_id", "TEXT NOT NULL DEFAULT 'country'"); ensureColumn("geometries", "is_deleted", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("geometries", "binding", "TEXT"); ensureColumn("sections", "user_id", "TEXT"); dropEntityDeprecatedColumnsIfExists(); dropGeometryDeprecatedColumnsIfExists(); migrateLegacyGeometryTypeTokens(); db.prepare(` CREATE UNIQUE INDEX IF NOT EXISTS idx_entities_slug ON entities(slug) `).run(); db.prepare(` CREATE INDEX IF NOT EXISTS idx_entities_name ON entities(name) `).run(); db.prepare(`DROP INDEX IF EXISTS idx_entity_geometries_geometry_id`).run(); db.prepare(` CREATE INDEX IF NOT EXISTS idx_entity_geometries_geometry_id ON entity_geometries(geometry_id) `).run(); db.prepare(` CREATE INDEX IF NOT EXISTS idx_entity_geometries_entity_id ON entity_geometries(entity_id) `).run(); db.prepare(` CREATE INDEX IF NOT EXISTS idx_section_states_status ON section_states(status, updated_at) `).run(); db.prepare(` CREATE UNIQUE INDEX IF NOT EXISTS idx_section_commits_no ON section_commits(section_id, commit_no) `).run(); db.prepare(` CREATE INDEX IF NOT EXISTS idx_section_commits_section_time ON section_commits(section_id, created_at) `).run(); db.prepare(` CREATE INDEX IF NOT EXISTS idx_section_submissions_section_status ON section_submissions(section_id, status, submitted_at) `).run(); module.exports = db; function ensureColumn(tableName, columnName, columnDefinition) { const columns = db.prepare(`PRAGMA table_info(${tableName})`).all(); const hasColumn = columns.some((column) => column.name === columnName); if (hasColumn) return; db.prepare(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDefinition}`).run(); } function dropGeometryDeprecatedColumnsIfExists() { db.prepare(`DROP INDEX IF EXISTS idx_geometries_kind`).run(); const columns = db.prepare(`PRAGMA table_info(geometries)`).all(); const hasKind = columns.some((column) => column.name === "kind"); const hasLineMode = columns.some((column) => column.name === "line_mode"); if (!hasKind && !hasLineMode) return; rebuildGeometriesTableToCurrentSchema(columns); } function dropEntityDeprecatedColumnsIfExists() { const columns = db.prepare(`PRAGMA table_info(entities)`).all(); const hasKind = columns.some((column) => column.name === "kind"); if (!hasKind) return; rebuildEntitiesTableToCurrentSchema(columns); } function rebuildEntitiesTableToCurrentSchema(existingColumns = null) { const foreignKeysEnabled = Number(db.pragma("foreign_keys", { simple: true })) === 1; const columns = existingColumns || db.prepare(`PRAGMA table_info(entities)`).all(); const hasSlug = columns.some((column) => column.name === "slug"); const hasDescription = columns.some((column) => column.name === "description"); const hasTypeId = columns.some((column) => column.name === "type_id"); const hasStatus = columns.some((column) => column.name === "status"); const hasIsDeleted = columns.some((column) => column.name === "is_deleted"); const hasCreatedAt = columns.some((column) => column.name === "created_at"); const hasUpdatedAt = columns.some((column) => column.name === "updated_at"); const slugSelect = hasSlug ? "slug" : "NULL"; const descriptionSelect = hasDescription ? "description" : "NULL"; const typeIdSelect = hasTypeId ? "type_id" : "'country'"; const statusSelect = hasStatus ? "status" : "1"; const isDeletedSelect = hasIsDeleted ? "is_deleted" : "0"; const createdAtSelect = hasCreatedAt ? "created_at" : "NULL"; const updatedAtSelect = hasUpdatedAt ? "updated_at" : "NULL"; if (foreignKeysEnabled) { db.pragma("foreign_keys = OFF"); } try { const tx = db.transaction(() => { db.prepare(`DROP TABLE IF EXISTS entities_new`).run(); db.prepare(` CREATE TABLE entities_new ( id TEXT PRIMARY KEY, name TEXT NOT NULL, slug TEXT UNIQUE, description TEXT, type_id TEXT NOT NULL DEFAULT 'country', status INTEGER DEFAULT 1, is_deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT, updated_at TEXT ) `).run(); db.prepare(` INSERT INTO entities_new ( id, name, slug, description, type_id, status, is_deleted, created_at, updated_at ) SELECT id, name, ${slugSelect}, ${descriptionSelect}, ${typeIdSelect}, ${statusSelect}, ${isDeletedSelect}, ${createdAtSelect}, ${updatedAtSelect} FROM entities `).run(); db.prepare(`DROP TABLE entities`).run(); db.prepare(`ALTER TABLE entities_new RENAME TO entities`).run(); }); tx(); } finally { if (foreignKeysEnabled) { db.pragma("foreign_keys = ON"); } } } function rebuildGeometriesTableToCurrentSchema(existingColumns = null) { const foreignKeysEnabled = Number(db.pragma("foreign_keys", { simple: true })) === 1; const columns = existingColumns || db.prepare(`PRAGMA table_info(geometries)`).all(); const hasType = columns.some((column) => column.name === "type"); const hasIsDeleted = columns.some((column) => column.name === "is_deleted"); const hasBinding = columns.some((column) => column.name === "binding"); const hasTimeStart = columns.some((column) => column.name === "time_start"); const hasTimeEnd = columns.some((column) => column.name === "time_end"); const hasBBoxMinLng = columns.some((column) => column.name === "bbox_min_lng"); const hasBBoxMinLat = columns.some((column) => column.name === "bbox_min_lat"); const hasBBoxMaxLng = columns.some((column) => column.name === "bbox_max_lng"); const hasBBoxMaxLat = columns.some((column) => column.name === "bbox_max_lat"); const hasCreatedAt = columns.some((column) => column.name === "created_at"); const hasUpdatedAt = columns.some((column) => column.name === "updated_at"); const typeSelect = hasType ? "type" : "NULL"; const isDeletedSelect = hasIsDeleted ? "is_deleted" : "0"; const bindingSelect = hasBinding ? "binding" : "NULL"; const timeStartSelect = hasTimeStart ? "time_start" : "NULL"; const timeEndSelect = hasTimeEnd ? "time_end" : "NULL"; const bboxMinLngSelect = hasBBoxMinLng ? "bbox_min_lng" : "NULL"; const bboxMinLatSelect = hasBBoxMinLat ? "bbox_min_lat" : "NULL"; const bboxMaxLngSelect = hasBBoxMaxLng ? "bbox_max_lng" : "NULL"; const bboxMaxLatSelect = hasBBoxMaxLat ? "bbox_max_lat" : "NULL"; const createdAtSelect = hasCreatedAt ? "created_at" : "NULL"; const updatedAtSelect = hasUpdatedAt ? "updated_at" : "NULL"; if (foreignKeysEnabled) { db.pragma("foreign_keys = OFF"); } try { const tx = db.transaction(() => { db.prepare(`DROP TABLE IF EXISTS geometries_new`).run(); db.prepare(` CREATE TABLE geometries_new ( id TEXT PRIMARY KEY, type TEXT, is_deleted INTEGER NOT NULL DEFAULT 0, draw_geometry TEXT NOT NULL, binding TEXT, time_start INTEGER, time_end INTEGER, bbox_min_lng REAL, bbox_min_lat REAL, bbox_max_lng REAL, bbox_max_lat REAL, created_at TEXT, updated_at TEXT ) `).run(); db.prepare(` INSERT INTO geometries_new ( id, type, is_deleted, draw_geometry, binding, time_start, time_end, bbox_min_lng, bbox_min_lat, bbox_max_lng, bbox_max_lat, created_at, updated_at ) SELECT id, ${typeSelect}, ${isDeletedSelect}, draw_geometry, ${bindingSelect}, ${timeStartSelect}, ${timeEndSelect}, ${bboxMinLngSelect}, ${bboxMinLatSelect}, ${bboxMaxLngSelect}, ${bboxMaxLatSelect}, ${createdAtSelect}, ${updatedAtSelect} FROM geometries `).run(); db.prepare(`DROP TABLE geometries`).run(); db.prepare(`ALTER TABLE geometries_new RENAME TO geometries`).run(); }); tx(); } finally { if (foreignKeysEnabled) { db.pragma("foreign_keys = ON"); } } } function migrateLegacyGeometryTypeTokens() { const geometryColumns = db.prepare(`PRAGMA table_info(geometries)`).all(); const hasType = geometryColumns.some((column) => column.name === "type"); if (!hasType) return; const tx = db.transaction(() => { const legacyRows = db.prepare(` SELECT g.id, ( SELECT e.type_id FROM entity_geometries eg JOIN entities e ON e.id = eg.entity_id AND e.is_deleted = 0 WHERE eg.geometry_id = g.id ORDER BY eg.rowid ASC LIMIT 1 ) AS primary_entity_type FROM geometries g WHERE g.type IS NULL OR TRIM(g.type) = '' OR LOWER(TRIM(g.type)) IN ('line', 'path') `).all(); const updateTypeStmt = db.prepare(` UPDATE geometries SET type = ? WHERE id = ? `); for (const row of legacyRows) { const semanticType = normalizeText(row.primary_entity_type); updateTypeStmt.run(semanticType, row.id); } }); tx(); } function normalizeText(value) { if (value === undefined || value === null) return null; const normalized = String(value).trim().toLowerCase(); return normalized.length ? normalized : null; }