init
Some checks failed
Build and Release / release (push) Failing after 16s

This commit is contained in:
2026-04-20 09:29:40 +07:00
commit ea1e12a5bc
20 changed files with 4335 additions and 0 deletions

412
db/polygons.js Normal file
View File

@@ -0,0 +1,412 @@
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;
}