fix(db): исправить импорт jsonb из sqlite
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Failing after 12s
Docker images / backend-image (push) Skipped
Docker images / frontend-image (push) Successful in 3m14s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / publish-release (push) Skipped
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Failing after 12s
Docker images / backend-image (push) Skipped
Docker images / frontend-image (push) Successful in 3m14s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 47s
Docker images / publish-release (push) Skipped
JSON-колонки передаются строкой с приведением ::jsonb, иначе node-pg кодирует массивы как PostgreSQL array и ETL падает. Повтор импорта идёт, пока нет маркера. Том PG18 монтируется в /var/lib/postgresql. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -128,7 +128,7 @@ sequenceDiagram
|
|||||||
| Зависимость | Реализация |
|
| Зависимость | Реализация |
|
||||||
|-------------|------------|
|
|-------------|------------|
|
||||||
| PostgreSQL | Контейнер `mmapp-postgres` (`postgres:18-alpine`). `DATABASE_URL=postgres://mmapp:…@postgres:5432/mmapp`. |
|
| PostgreSQL | Контейнер `mmapp-postgres` (`postgres:18-alpine`). `DATABASE_URL=postgres://mmapp:…@postgres:5432/mmapp`. |
|
||||||
| SQLite (ETL) | Файл `mikrotik.db` на томе `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db`). При первом старте, если PG пустой, backend сам импортирует данные и ставит маркер. Повторный старт не копирует заново. |
|
| SQLite (ETL) | Файл `mikrotik.db` на томе `/app/data` (`DATABASE_PATH=/app/data/mikrotik.db`). При старте, пока нет маркера `data_migration.sqlite_imported_at`, backend импортирует sqlite в PG (`ON CONFLICT` / upsert). После успешного импорта повтор не копирует заново. Том PG18: `mmapp-pgdata:/var/lib/postgresql` (не `.../data`). |
|
||||||
| Docker socket | Только у контейнера updater: `/var/run/docker.sock` — доступ к Docker API хоста (управление контейнерами, pull). |
|
| Docker socket | Только у контейнера updater: `/var/run/docker.sock` — доступ к Docker API хоста (управление контейнерами, pull). |
|
||||||
|
|
||||||
## Локальная разработка
|
## Локальная разработка
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
|
"test:traffic-rate": "tsx src/services/traffic-rate.test.ts",
|
||||||
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts",
|
"test:traffic-flow": "tsx src/services/traffic-flow-parse.test.ts && tsx src/services/traffic-flow-map-exporter.test.ts && tsx src/services/traffic-flow-ifaces.test.ts && tsx src/services/traffic-flow-dedup.test.ts && tsx src/services/traffic-flow-planes.test.ts && tsx src/services/traffic-flow-ip.test.ts && tsx src/services/traffic-flow-classify.test.ts && tsx src/services/traffic-flow-ripe.test.ts && tsx src/services/traffic-flow-brands.test.ts && tsx src/services/traffic-flow-ingest.test.ts && tsx src/services/traffic-flow-analytics.test.ts && tsx src/services/traffic-flow-map-hops.test.ts && tsx src/services/traffic-flow-purge.test.ts",
|
||||||
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts",
|
"test:users": "tsx src/modules/users/iface-type.test.ts && tsx src/modules/users/bindings.test.ts",
|
||||||
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/pg-schema.test.ts",
|
"test:pg": "tsx src/db/sql-bind.test.ts && tsx src/db/sqlite-json.test.ts && tsx src/db/pg-schema.test.ts",
|
||||||
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg"
|
"test": "npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -53,4 +53,31 @@ if (!(await withPgOrSkip())) {
|
|||||||
await dbQuery(`DELETE FROM alert_outbox WHERE dedupe_key = 'pg-dedupe-key'`)
|
await dbQuery(`DELETE FROM alert_outbox WHERE dedupe_key = 'pg-dedupe-key'`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
const peers = [{ endpoint: "msk-gw02.rtnt.top:13232", publicKey: "x" }]
|
||||||
|
await dbQuery(
|
||||||
|
`INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
|
||||||
|
VALUES ('pg-json-arr', 'pg-json-arr', $1::jsonb, now())`,
|
||||||
|
[JSON.stringify(peers)],
|
||||||
|
)
|
||||||
|
const { rows } = await dbQuery<{ payload_json: unknown }>(
|
||||||
|
`SELECT payload_json FROM alert_outbox WHERE id = 'pg-json-arr'`,
|
||||||
|
)
|
||||||
|
assert.equal(Array.isArray(rows[0]?.payload_json), true)
|
||||||
|
await dbQuery(`DELETE FROM alert_outbox WHERE id = 'pg-json-arr'`)
|
||||||
|
|
||||||
|
let arrayAsPgArrayFailed = false
|
||||||
|
try {
|
||||||
|
await dbQuery(
|
||||||
|
`INSERT INTO alert_outbox (id, dedupe_key, payload_json, next_attempt_at)
|
||||||
|
VALUES ('pg-json-bad', 'pg-json-bad', $1, now())`,
|
||||||
|
[peers],
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
arrayAsPgArrayFailed = err instanceof Error && /json|22P02/i.test(err.message)
|
||||||
|
}
|
||||||
|
await dbQuery(`DELETE FROM alert_outbox WHERE id = 'pg-json-bad'`).catch(() => undefined)
|
||||||
|
assert.equal(arrayAsPgArrayFailed, true, "JS array must not be bound as jsonb without stringify")
|
||||||
|
}
|
||||||
|
|
||||||
console.log("pg-schema.test.ts: ok")
|
console.log("pg-schema.test.ts: ok")
|
||||||
|
|||||||
@@ -278,9 +278,11 @@ function coerce(kind: ColKind, value: unknown, strict: boolean, rejects: string[
|
|||||||
case "bool":
|
case "bool":
|
||||||
return parseBool(value)
|
return parseBool(value)
|
||||||
case "json":
|
case "json":
|
||||||
return parseJson(value, [])
|
// Always JSON text. JS arrays must not go to node-pg as values — it encodes
|
||||||
|
// them as PG arrays (`{...}`), which jsonb rejects (22P02).
|
||||||
|
return JSON.stringify(parseJson(value, []))
|
||||||
case "json-null":
|
case "json-null":
|
||||||
return value == null || value === "" ? null : parseJson(value, null)
|
return value == null || value === "" ? null : JSON.stringify(parseJson(value, null))
|
||||||
case "bigint-id": {
|
case "bigint-id": {
|
||||||
const t = String(value ?? "").trim()
|
const t = String(value ?? "").trim()
|
||||||
if (!t) return null
|
if (!t) return null
|
||||||
@@ -341,7 +343,9 @@ async function copyTable(
|
|||||||
const total = (sqlite.prepare(`SELECT COUNT(*) AS c FROM ${spec.table}${where}`).get() as { c: number }).c
|
const total = (sqlite.prepare(`SELECT COUNT(*) AS c FROM ${spec.table}${where}`).get() as { c: number }).c
|
||||||
const part = specForParent(spec.table)
|
const part = specForParent(spec.table)
|
||||||
const cols = spec.columns.map(([c]) => c)
|
const cols = spec.columns.map(([c]) => c)
|
||||||
const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ")
|
const placeholders = spec.columns
|
||||||
|
.map(([, kind], i) => (kind === "json" || kind === "json-null" ? `$${i + 1}::jsonb` : `$${i + 1}`))
|
||||||
|
.join(", ")
|
||||||
const conflictSql = spec.upsert
|
const conflictSql = spec.upsert
|
||||||
? `ON CONFLICT (id) DO UPDATE SET ${cols.filter((c) => c !== "id").map((c) => `${c} = EXCLUDED.${c}`).join(", ")}`
|
? `ON CONFLICT (id) DO UPDATE SET ${cols.filter((c) => c !== "id").map((c) => `${c} = EXCLUDED.${c}`).join(", ")}`
|
||||||
: `ON CONFLICT DO NOTHING`
|
: `ON CONFLICT DO NOTHING`
|
||||||
@@ -362,7 +366,8 @@ async function copyTable(
|
|||||||
await client.query("COMMIT")
|
await client.query("COMMIT")
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await client.query("ROLLBACK")
|
await client.query("ROLLBACK")
|
||||||
throw err
|
const detail = err instanceof Error ? err.message : String(err)
|
||||||
|
throw new Error(`${spec.table}: ${detail}`)
|
||||||
} finally {
|
} finally {
|
||||||
client.release()
|
client.release()
|
||||||
batch.length = 0
|
batch.length = 0
|
||||||
@@ -421,7 +426,11 @@ export async function shouldImportSqlite(pool: Pool, sqlitePath: string): Promis
|
|||||||
)
|
)
|
||||||
if (marker.rows[0]?.sqlite_imported_at) return false
|
if (marker.rows[0]?.sqlite_imported_at) return false
|
||||||
const servers = await pool.query<{ c: string }>(`SELECT COUNT(*)::text AS c FROM servers`)
|
const servers = await pool.query<{ c: string }>(`SELECT COUNT(*)::text AS c FROM servers`)
|
||||||
if (Number(servers.rows[0]?.c ?? 0) > 0) return false
|
if (Number(servers.rows[0]?.c ?? 0) > 0) {
|
||||||
|
console.warn(
|
||||||
|
"SQLite → PostgreSQL: повтор недописанного импорта (маркера нет, servers уже не пустые)",
|
||||||
|
)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -462,6 +471,7 @@ export async function importSqliteToPostgres(
|
|||||||
return report
|
return report
|
||||||
}
|
}
|
||||||
for (const spec of TABLES) {
|
for (const spec of TABLES) {
|
||||||
|
console.log(`SQLite → PostgreSQL: таблица ${spec.table}`)
|
||||||
report.tables[spec.table] = await copyTable(sqlite, pool, spec, { strict, fullHistory, rejects })
|
report.tables[spec.table] = await copyTable(sqlite, pool, spec, { strict, fullHistory, rejects })
|
||||||
}
|
}
|
||||||
sqlite.close()
|
sqlite.close()
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import assert from "node:assert/strict"
|
||||||
|
|
||||||
|
/** node-pg encodes a JS array as a PostgreSQL array (`{...}`), not JSON (`[...]`). */
|
||||||
|
{
|
||||||
|
const peers = [{ endpoint: "msk-gw02.rtnt.top:13232" }]
|
||||||
|
const asJson = JSON.stringify(peers)
|
||||||
|
assert.equal(asJson.startsWith("["), true)
|
||||||
|
assert.equal(asJson.includes("msk-gw02.rtnt.top:13232"), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("sqlite-json.test.ts: ok")
|
||||||
@@ -146,7 +146,8 @@ services:
|
|||||||
- -c
|
- -c
|
||||||
- wal_compression=on
|
- wal_compression=on
|
||||||
volumes:
|
volumes:
|
||||||
- mmapp-pgdata:/var/lib/postgresql/data
|
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||||
|
- mmapp-pgdata:/var/lib/postgresql
|
||||||
networks:
|
networks:
|
||||||
- mmapp
|
- mmapp
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ services:
|
|||||||
- -c
|
- -c
|
||||||
- wal_compression=on
|
- wal_compression=on
|
||||||
volumes:
|
volumes:
|
||||||
- mmapp-pgdata:/var/lib/postgresql/data
|
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||||
|
- mmapp-pgdata:/var/lib/postgresql
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
|
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ services:
|
|||||||
- -c
|
- -c
|
||||||
- wal_compression=on
|
- wal_compression=on
|
||||||
volumes:
|
volumes:
|
||||||
- mmapp-pgdata:/var/lib/postgresql/data
|
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||||
|
- mmapp-pgdata:/var/lib/postgresql
|
||||||
networks:
|
networks:
|
||||||
- mmapp
|
- mmapp
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ services:
|
|||||||
- -c
|
- -c
|
||||||
- wal_compression=on
|
- wal_compression=on
|
||||||
volumes:
|
volumes:
|
||||||
- mmapp-pgdata:/var/lib/postgresql/data
|
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||||
|
- mmapp-pgdata:/var/lib/postgresql
|
||||||
networks:
|
networks:
|
||||||
- mmapp
|
- mmapp
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ services:
|
|||||||
- -c
|
- -c
|
||||||
- wal_compression=on
|
- wal_compression=on
|
||||||
volumes:
|
volumes:
|
||||||
- mmapp-pgdata:/var/lib/postgresql/data
|
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
|
||||||
|
- mmapp-pgdata:/var/lib/postgresql
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U mmapp -d mmapp"]
|
test: ["CMD-SHELL", "pg_isready -U mmapp -d mmapp"]
|
||||||
interval: 5s
|
interval: 5s
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ if ! docker inspect mmapp-postgres >/dev/null 2>&1; then
|
|||||||
-e POSTGRES_INITDB_ARGS="--encoding=UTF8 --locale=C.UTF-8 --data-checksums" \
|
-e POSTGRES_INITDB_ARGS="--encoding=UTF8 --locale=C.UTF-8 --data-checksums" \
|
||||||
-e TZ=UTC \
|
-e TZ=UTC \
|
||||||
-e PGTZ=UTC \
|
-e PGTZ=UTC \
|
||||||
-v mmapp-pgdata:/var/lib/postgresql/data \
|
-v mmapp-pgdata:/var/lib/postgresql \
|
||||||
--health-cmd="pg_isready -U mmapp -d mmapp" \
|
--health-cmd="pg_isready -U mmapp -d mmapp" \
|
||||||
--health-interval=5s \
|
--health-interval=5s \
|
||||||
--health-timeout=5s \
|
--health-timeout=5s \
|
||||||
|
|||||||
Reference in New Issue
Block a user