pre updating version control

This commit is contained in:
taDuc
2026-04-17 20:55:33 +07:00
parent 6f7e819aca
commit 5397bf9808
5 changed files with 806 additions and 166 deletions

View File

@@ -30,7 +30,6 @@ db.prepare(`
slug TEXT UNIQUE, slug TEXT UNIQUE,
description TEXT, description TEXT,
type_id TEXT NOT NULL DEFAULT 'country', type_id TEXT NOT NULL DEFAULT 'country',
kind TEXT,
status INTEGER DEFAULT 1, status INTEGER DEFAULT 1,
is_deleted INTEGER NOT NULL DEFAULT 0, is_deleted INTEGER NOT NULL DEFAULT 0,
created_at TEXT, created_at TEXT,
@@ -54,6 +53,7 @@ ensureColumn("entities", "is_deleted", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("entities", "type_id", "TEXT NOT NULL DEFAULT 'country'"); ensureColumn("entities", "type_id", "TEXT NOT NULL DEFAULT 'country'");
ensureColumn("geometries", "is_deleted", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("geometries", "is_deleted", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("geometries", "binding", "TEXT"); ensureColumn("geometries", "binding", "TEXT");
dropEntityDeprecatedColumnsIfExists();
dropGeometryDeprecatedColumnsIfExists(); dropGeometryDeprecatedColumnsIfExists();
migrateLegacyGeometryTypeTokens(); migrateLegacyGeometryTypeTokens();
@@ -98,6 +98,91 @@ function dropGeometryDeprecatedColumnsIfExists() {
rebuildGeometriesTableToCurrentSchema(columns); 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) { function rebuildGeometriesTableToCurrentSchema(existingColumns = null) {
const foreignKeysEnabled = Number(db.pragma("foreign_keys", { simple: true })) === 1; const foreignKeysEnabled = Number(db.pragma("foreign_keys", { simple: true })) === 1;
const columns = existingColumns || db.prepare(`PRAGMA table_info(geometries)`).all(); const columns = existingColumns || db.prepare(`PRAGMA table_info(geometries)`).all();

302
lib/entityBatch.js Normal file
View File

@@ -0,0 +1,302 @@
const crypto = require("crypto");
const db = require("../db/polygons");
function applyEntityBatchChanges(changes, options = {}) {
const now = options.now || new Date().toISOString();
const createdEntityIds = [];
for (const change of changes) {
const hasAction = Object.prototype.hasOwnProperty.call(change || {}, "action");
const action = normalizeBatchAction(hasAction ? change.action : change?.type);
if (!change || !action) {
throw createValidationError("Invalid entity change entry");
}
if (action === "create") {
const payload = normalizeEntityCreatePayload(change);
const id = payload.id || crypto.randomUUID();
try {
db.prepare(`
INSERT INTO entities (
id, name, slug, description, type_id, status, is_deleted, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
`).run(
id,
payload.name,
payload.slug,
payload.description,
payload.typeId,
payload.status,
now,
now
);
} catch (err) {
if (isSqliteConstraint(err)) {
throw createValidationError("Entity name/slug must be unique", 409);
}
throw err;
}
createdEntityIds.push(id);
continue;
}
if (action === "update") {
const payload = normalizeEntityUpdatePayload(change);
const existing = db.prepare(`
SELECT *
FROM entities
WHERE id = ?
AND is_deleted = 0
`).get(payload.id);
if (!existing) {
continue;
}
const hasName = Object.prototype.hasOwnProperty.call(payload.fields, "name");
const hasSlug = Object.prototype.hasOwnProperty.call(payload.fields, "slug");
const hasDescription = Object.prototype.hasOwnProperty.call(payload.fields, "description");
const hasTypeId = Object.prototype.hasOwnProperty.call(payload.fields, "type_id");
const hasStatus = Object.prototype.hasOwnProperty.call(payload.fields, "status");
if (!hasName && !hasSlug && !hasDescription && !hasTypeId && !hasStatus) {
throw createValidationError("Invalid entity update payload");
}
const name = hasName ? normalizeRequiredString(payload.fields.name) : existing.name;
if (!name) {
throw createValidationError("name cannot be empty");
}
const slug = hasSlug ? normalizeOptionalString(payload.fields.slug) : existing.slug;
const description = hasDescription
? normalizeOptionalString(payload.fields.description)
: existing.description;
const typeId = hasTypeId ? normalizeTypeId(payload.fields.type_id) : existing.type_id;
const status = hasStatus
? (normalizeOptionalNumber(payload.fields.status) ?? existing.status)
: existing.status;
try {
db.prepare(`
UPDATE entities
SET name = ?,
slug = ?,
description = ?,
type_id = ?,
status = ?,
updated_at = ?
WHERE id = ?
AND is_deleted = 0
`).run(
name,
slug,
description,
typeId,
status,
now,
payload.id
);
} catch (err) {
if (isSqliteConstraint(err)) {
throw createValidationError("Entity name/slug must be unique", 409);
}
throw err;
}
continue;
}
if (action === "delete") {
const id = normalizeDeleteEntityId(change);
if (!id) {
throw createValidationError("Invalid entity delete payload");
}
const existing = db.prepare(`
SELECT id
FROM entities
WHERE id = ?
AND is_deleted = 0
`).get(id);
if (!existing) {
continue;
}
assertEntityCanBeDeleted(id);
db.prepare(`
UPDATE entities
SET is_deleted = 1,
updated_at = ?
WHERE id = ?
AND is_deleted = 0
`).run(now, id);
db.prepare(`
DELETE FROM entity_geometries
WHERE entity_id = ?
`).run(id);
continue;
}
throw createValidationError(`Unknown entity change type: ${String(action)}`);
}
return {
applied: changes.length,
createdEntityIds,
};
}
module.exports = {
applyEntityBatchChanges,
};
function normalizeEntityCreatePayload(change) {
const source = isPlainObject(change.entity) ? change.entity : change;
const name = normalizeRequiredString(source?.name);
if (!name) {
throw createValidationError("Invalid entity create payload: name is required");
}
const id = normalizeEntityId(source?.id);
const slug = Object.prototype.hasOwnProperty.call(source || {}, "slug")
? normalizeOptionalString(source.slug)
: toSlug(name);
const description = normalizeOptionalString(source?.description);
const typeId = normalizeTypeId(source?.type_id);
const status = normalizeOptionalNumber(source?.status) ?? 1;
return {
id,
name,
slug,
description,
typeId,
status,
};
}
function normalizeEntityUpdatePayload(change) {
const source = isPlainObject(change.entity) ? change.entity : change;
const id = normalizeEntityId(source?.id ?? change?.id);
if (!id) {
throw createValidationError("Invalid entity update payload: id is required");
}
return {
id,
fields: source,
};
}
function normalizeDeleteEntityId(change) {
const source = isPlainObject(change.entity) ? change.entity : null;
return normalizeEntityId(change?.id ?? source?.id);
}
function assertEntityCanBeDeleted(entityId) {
const orphanedGeometryRows = db.prepare(`
SELECT eg_target.geometry_id
FROM entity_geometries eg_target
JOIN geometries g_target
ON g_target.id = eg_target.geometry_id
AND g_target.is_deleted = 0
LEFT JOIN entity_geometries eg_other
ON eg_other.geometry_id = eg_target.geometry_id
AND eg_other.entity_id <> eg_target.entity_id
LEFT JOIN entities e_other
ON e_other.id = eg_other.entity_id
AND e_other.is_deleted = 0
WHERE eg_target.entity_id = ?
GROUP BY eg_target.geometry_id
HAVING COUNT(e_other.id) = 0
`).all(entityId);
if (!orphanedGeometryRows.length) return;
const previewIds = orphanedGeometryRows.slice(0, 10).map((row) => row.geometry_id);
const suffix = orphanedGeometryRows.length > 10 ? ", ..." : "";
throw createValidationError(
`Cannot delete entity. Reassign ${orphanedGeometryRows.length} linked geometries first: ${previewIds.join(", ")}${suffix}`,
409
);
}
function normalizeBatchAction(value) {
if (value === undefined || value === null) return null;
const normalized = String(value).trim().toLowerCase();
if (normalized === "create" || normalized === "update" || normalized === "delete") {
return normalized;
}
return null;
}
function normalizeEntityId(value) {
if (value === undefined || value === null || value === "") return null;
const normalized = String(value).trim();
return normalized || null;
}
function normalizeRequiredString(value) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
function normalizeOptionalString(value) {
if (value === undefined || value === null) return null;
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length ? trimmed : null;
}
function normalizeOptionalNumber(value) {
if (value === undefined || value === null || value === "") return null;
const num = Number(value);
if (!Number.isFinite(num)) return null;
return num;
}
function normalizeTypeId(value) {
if (value === undefined || value === null || value === "") {
return "country";
}
const trimmed = String(value).trim().toLowerCase();
return trimmed || "country";
}
function toSlug(value) {
return value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 120) || null;
}
function isPlainObject(value) {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function isSqliteConstraint(error) {
if (!error || typeof error !== "object") return false;
const code = error.code || "";
if (code === "SQLITE_CONSTRAINT" || String(code).startsWith("SQLITE_CONSTRAINT_")) {
return true;
}
const message = typeof error.message === "string" ? error.message : "";
return message.includes("UNIQUE constraint failed");
}
function createValidationError(message, status = 400) {
const err = new Error(message);
err.status = status;
return err;
}

View File

@@ -1,6 +1,7 @@
const express = require("express"); const express = require("express");
const crypto = require("crypto"); const crypto = require("crypto");
const db = require("../db/polygons"); const db = require("../db/polygons");
const { applyEntityBatchChanges } = require("../lib/entityBatch");
const router = express.Router(); const router = express.Router();
@@ -95,16 +96,15 @@ router.post("/", (req, res) => {
try { try {
db.prepare(` db.prepare(`
INSERT INTO entities ( INSERT INTO entities (
id, name, slug, description, type_id, kind, status, is_deleted, created_at, updated_at id, name, slug, description, type_id, status, is_deleted, created_at, updated_at
) )
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?) VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
`).run( `).run(
id, id,
name, name,
slug, slug,
description, description,
typeId, typeId,
null,
status, status,
now, now,
now now
@@ -173,7 +173,6 @@ router.put("/:id", (req, res) => {
slug = ?, slug = ?,
description = ?, description = ?,
type_id = ?, type_id = ?,
kind = ?,
status = ?, status = ?,
updated_at = ? updated_at = ?
WHERE id = ? WHERE id = ?
@@ -182,7 +181,6 @@ router.put("/:id", (req, res) => {
slug, slug,
description, description,
typeId, typeId,
null,
status, status,
now, now,
req.params.id req.params.id
@@ -277,6 +275,33 @@ router.delete("/:id", (req, res) => {
res.json({ success: true }); res.json({ success: true });
}); });
router.post("/batch", (req, res) => {
const { changes } = req.body || {};
if (!Array.isArray(changes)) {
return res.status(400).json({ error: "changes must be an array" });
}
const now = new Date().toISOString();
try {
const tx = db.transaction((items) => applyEntityBatchChanges(items, { now }));
const result = tx(changes);
res.json({
success: true,
applied: result.applied,
created_entity_ids: result.createdEntityIds,
});
} catch (err) {
if (err.status) {
return res.status(err.status).json({ error: err.message });
}
if (isSqliteConstraint(err)) {
return res.status(409).json({ error: "Entity name/slug must be unique" });
}
console.error("Batch entity apply failed", err);
res.status(500).json({ error: "Batch entity apply failed" });
}
});
module.exports = router; module.exports = router;
function normalizeEntityRow(row) { function normalizeEntityRow(row) {

View File

@@ -1,6 +1,7 @@
const express = require("express"); const express = require("express");
const crypto = require("crypto"); const crypto = require("crypto");
const db = require("../db/polygons"); const db = require("../db/polygons");
const { applyEntityBatchChanges } = require("../lib/entityBatch");
const { getBBox } = require("../utils/bbox"); const { getBBox } = require("../utils/bbox");
const router = express.Router(); const router = express.Router();
@@ -304,9 +305,64 @@ router.post("/batch", (req, res) => {
} }
const now = new Date().toISOString(); const now = new Date().toISOString();
try { try {
const tx = db.transaction((items) => { const tx = db.transaction((items) => {
applyGeometryBatchChanges(items, now);
});
tx(changes);
res.json({ success: true, applied: changes.length });
} catch (err) {
if (err.status) {
return res.status(err.status).json({ error: err.message });
}
console.error("Batch apply error:", err);
res.status(500).json({ error: "Batch apply failed" });
}
});
// =======================
// Apply entities + geometries in one transaction
// =======================
router.post("/batch/combined", (req, res) => {
const entityChangesRaw = req.body?.entity_changes;
const geometryChangesRaw = req.body?.geometry_changes;
if (entityChangesRaw !== undefined && !Array.isArray(entityChangesRaw)) {
return res.status(400).json({ error: "entity_changes must be an array" });
}
if (geometryChangesRaw !== undefined && !Array.isArray(geometryChangesRaw)) {
return res.status(400).json({ error: "geometry_changes must be an array" });
}
const entityChanges = entityChangesRaw || [];
const geometryChanges = geometryChangesRaw || [];
const now = new Date().toISOString();
try {
const tx = db.transaction((entities, geometries) => {
const entityResult = applyEntityBatchChanges(entities, { now });
applyGeometryBatchChanges(geometries, now);
return entityResult;
});
const entityResult = tx(entityChanges, geometryChanges);
res.json({
success: true,
applied: entityChanges.length + geometryChanges.length,
entity_applied: entityChanges.length,
geometry_applied: geometryChanges.length,
created_entity_ids: entityResult.createdEntityIds,
});
} catch (err) {
if (err.status) {
return res.status(err.status).json({ error: err.message });
}
console.error("Combined batch apply error:", err);
res.status(500).json({ error: "Combined batch apply failed" });
}
});
function applyGeometryBatchChanges(items, now) {
for (const change of items) { for (const change of items) {
const hasAction = Object.prototype.hasOwnProperty.call(change || {}, "action"); const hasAction = Object.prototype.hasOwnProperty.call(change || {}, "action");
const action = normalizeBatchAction(hasAction ? change.action : change?.type); const action = normalizeBatchAction(hasAction ? change.action : change?.type);
@@ -465,18 +521,7 @@ router.post("/batch", (req, res) => {
throw createValidationError(`Unknown change type: ${String(action)}`); throw createValidationError(`Unknown change type: ${String(action)}`);
} }
}); }
tx(changes);
res.json({ success: true, applied: changes.length });
} catch (err) {
if (err.status) {
return res.status(err.status).json({ error: err.message });
}
console.error("Batch apply error:", err);
res.status(500).json({ error: "Batch apply failed" });
}
});
module.exports = router; module.exports = router;

View File

@@ -69,6 +69,64 @@ const openApiSpec = {
status: { type: "number" }, status: { type: "number" },
}, },
}, },
EntityBatchCreateChange: {
type: "object",
properties: {
action: { type: "string", enum: ["create"] },
entity: { $ref: "#/components/schemas/EntityCreateInput" },
},
required: ["action"],
},
EntityBatchUpdateChange: {
type: "object",
properties: {
action: { type: "string", enum: ["update"] },
id: { type: "string" },
entity: { $ref: "#/components/schemas/EntityUpdateInput" },
name: { type: "string" },
slug: { type: "string", nullable: true },
description: { type: "string", nullable: true },
type_id: { type: "string" },
status: { type: "number" },
},
required: ["action", "id"],
},
EntityBatchDeleteChange: {
type: "object",
properties: {
action: { type: "string", enum: ["delete"] },
id: { type: "string" },
},
required: ["action", "id"],
},
EntityBatchPayload: {
type: "object",
properties: {
changes: {
type: "array",
items: {
oneOf: [
{ $ref: "#/components/schemas/EntityBatchCreateChange" },
{ $ref: "#/components/schemas/EntityBatchUpdateChange" },
{ $ref: "#/components/schemas/EntityBatchDeleteChange" },
],
},
},
},
required: ["changes"],
},
EntityBatchResponse: {
type: "object",
properties: {
success: { type: "boolean" },
applied: { type: "number" },
created_entity_ids: {
type: "array",
items: { type: "string" },
},
},
required: ["success", "applied", "created_entity_ids"],
},
GeoJSONGeometry: { GeoJSONGeometry: {
type: "object", type: "object",
properties: { properties: {
@@ -221,6 +279,51 @@ const openApiSpec = {
}, },
required: ["success", "applied"], required: ["success", "applied"],
}, },
CombinedBatchPayload: {
type: "object",
properties: {
entity_changes: {
type: "array",
items: {
oneOf: [
{ $ref: "#/components/schemas/EntityBatchCreateChange" },
{ $ref: "#/components/schemas/EntityBatchUpdateChange" },
{ $ref: "#/components/schemas/EntityBatchDeleteChange" },
],
},
},
geometry_changes: {
type: "array",
items: {
oneOf: [
{ $ref: "#/components/schemas/BatchCreateChange" },
{ $ref: "#/components/schemas/BatchUpdateChange" },
{ $ref: "#/components/schemas/BatchDeleteChange" },
],
},
},
},
},
CombinedBatchResponse: {
type: "object",
properties: {
success: { type: "boolean" },
applied: { type: "number" },
entity_applied: { type: "number" },
geometry_applied: { type: "number" },
created_entity_ids: {
type: "array",
items: { type: "string" },
},
},
required: [
"success",
"applied",
"entity_applied",
"geometry_applied",
"created_entity_ids",
],
},
MetadataResponse: { MetadataResponse: {
type: "object", type: "object",
additionalProperties: { additionalProperties: {
@@ -442,6 +545,46 @@ const openApiSpec = {
}, },
}, },
}, },
"/entities/batch": {
post: {
tags: ["Entities"],
summary: "Apply batch create/update/delete entities",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/EntityBatchPayload" },
},
},
},
responses: {
200: {
description: "Batch applied",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/EntityBatchResponse" },
},
},
},
400: {
description: "Invalid payload",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
},
},
},
409: {
description: "Unique conflict or cannot delete due to orphaned geometries",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
},
},
},
},
},
},
"/entities/{id}": { "/entities/{id}": {
get: { get: {
tags: ["Entities"], tags: ["Entities"],
@@ -721,6 +864,46 @@ const openApiSpec = {
}, },
}, },
}, },
"/geometries/batch/combined": {
post: {
tags: ["Geometries", "Entities"],
summary: "Apply entity batch and geometry batch in one transaction",
requestBody: {
required: true,
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CombinedBatchPayload" },
},
},
},
responses: {
200: {
description: "Combined batch applied",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/CombinedBatchResponse" },
},
},
},
400: {
description: "Invalid payload",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
},
},
},
409: {
description: "Conflict in entity changes (unique or orphan guard)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/ErrorResponse" },
},
},
},
},
},
},
}, },
}; };