Compare commits

...
2 Commits
Author SHA1 Message Date
DenozordecandCursor bf5cfff12c ci(backend): убрать конфликт контейнера PostgreSQL в тестах
Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m4s
Docker images / frontend-image (push) Successful in 3m16s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 3m0s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
На общем Docker-раннере имя mm-pg и порт 5432 сталкивались с прошлым запуском. Контейнер и порт теперь уникальны, старый mm-pg снимается.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 09:34:29 +07:00
DenozordecandCursor 30a8ec4420 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
JSON-колонки передаются строкой с приведением ::jsonb, иначе node-pg кодирует массивы как PostgreSQL array и ETL падает. Повтор импорта идёт, пока нет маркера. Том PG18 монтируется в /var/lib/postgresql.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 09:28:03 +07:00
12 changed files with 80 additions and 18 deletions
+14 -5
View File
@@ -53,30 +53,39 @@ jobs:
shell: bash
run: |
set -euo pipefail
docker run -d --name mm-pg \
NAME="mm-pg-${{ gitea.run_id }}-${{ gitea.run_attempt }}"
docker rm -f "$NAME" mm-pg 2>/dev/null || true
HOST_PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')
docker run -d --name "$NAME" --rm \
-e POSTGRES_USER=mmapp \
-e POSTGRES_PASSWORD=mmapp \
-e POSTGRES_DB=mmapp \
-p 5432:5432 \
-p "127.0.0.1:${HOST_PORT}:5432" \
postgres:18-alpine
echo "PG_CONTAINER=$NAME" >> "${GITHUB_ENV}"
echo "DATABASE_URL=postgres://mmapp:mmapp@127.0.0.1:${HOST_PORT}/mmapp" >> "${GITHUB_ENV}"
for i in $(seq 1 40); do
if docker exec mm-pg pg_isready -U mmapp -d mmapp; then
if docker exec "$NAME" pg_isready -U mmapp -d mmapp; then
exit 0
fi
sleep 1
done
echo "PostgreSQL не поднялся"
docker logs "$NAME" || true
exit 1
- name: Install and test backend
shell: bash
env:
DATABASE_URL: postgres://mmapp:mmapp@127.0.0.1:5432/mmapp
run: |
set -euo pipefail
npm ci
npm run test --prefix backend
- name: Stop PostgreSQL
if: always()
shell: bash
run: docker rm -f "${PG_CONTAINER:-}" mm-pg 2>/dev/null || true
backend-image:
needs:
- prepare-release
+1 -1
View File
@@ -128,7 +128,7 @@ sequenceDiagram
| Зависимость | Реализация |
|-------------|------------|
| 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). |
## Локальная разработка
+1 -1
View File
@@ -17,7 +17,7 @@
"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: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"
},
"dependencies": {
+27
View File
@@ -53,4 +53,31 @@ if (!(await withPgOrSkip())) {
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")
+15 -5
View File
@@ -278,9 +278,11 @@ function coerce(kind: ColKind, value: unknown, strict: boolean, rejects: string[
case "bool":
return parseBool(value)
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":
return value == null || value === "" ? null : parseJson(value, null)
return value == null || value === "" ? null : JSON.stringify(parseJson(value, null))
case "bigint-id": {
const t = String(value ?? "").trim()
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 part = specForParent(spec.table)
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
? `ON CONFLICT (id) DO UPDATE SET ${cols.filter((c) => c !== "id").map((c) => `${c} = EXCLUDED.${c}`).join(", ")}`
: `ON CONFLICT DO NOTHING`
@@ -362,7 +366,8 @@ async function copyTable(
await client.query("COMMIT")
} catch (err) {
await client.query("ROLLBACK")
throw err
const detail = err instanceof Error ? err.message : String(err)
throw new Error(`${spec.table}: ${detail}`)
} finally {
client.release()
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
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
}
@@ -462,6 +471,7 @@ export async function importSqliteToPostgres(
return report
}
for (const spec of TABLES) {
console.log(`SQLite → PostgreSQL: таблица ${spec.table}`)
report.tables[spec.table] = await copyTable(sqlite, pool, spec, { strict, fullHistory, rejects })
}
sqlite.close()
+11
View File
@@ -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")
+2 -1
View File
@@ -146,7 +146,8 @@ services:
- -c
- wal_compression=on
volumes:
- mmapp-pgdata:/var/lib/postgresql/data
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
- mmapp-pgdata:/var/lib/postgresql
networks:
- mmapp
healthcheck:
+2 -1
View File
@@ -32,7 +32,8 @@ services:
- -c
- wal_compression=on
volumes:
- mmapp-pgdata:/var/lib/postgresql/data
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
- mmapp-pgdata:/var/lib/postgresql
ports:
- "127.0.0.1:${POSTGRES_PORT:-5432}:5432"
healthcheck:
+2 -1
View File
@@ -48,7 +48,8 @@ services:
- -c
- wal_compression=on
volumes:
- mmapp-pgdata:/var/lib/postgresql/data
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
- mmapp-pgdata:/var/lib/postgresql
networks:
- mmapp
healthcheck:
+2 -1
View File
@@ -83,7 +83,8 @@ services:
- -c
- wal_compression=on
volumes:
- mmapp-pgdata:/var/lib/postgresql/data
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
- mmapp-pgdata:/var/lib/postgresql
networks:
- mmapp
healthcheck:
+2 -1
View File
@@ -28,7 +28,8 @@ services:
- -c
- wal_compression=on
volumes:
- mmapp-pgdata:/var/lib/postgresql/data
# PostgreSQL 18+: VOLUME is /var/lib/postgresql (PGDATA = .../18/docker)
- mmapp-pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mmapp -d mmapp"]
interval: 5s
+1 -1
View File
@@ -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 TZ=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-interval=5s \
--health-timeout=5s \