feat: add export/import to the postgres backend; import auto-detects backend
CI / format (pull_request) Successful in 23s
CI / lint (pull_request) Successful in 23s
CI / install-and-build (pull_request) Successful in 45s
CI / typecheck-source (pull_request) Successful in 25s
CI / typecheck-tests (pull_request) Successful in 30s
CI / test (pull_request) Successful in 40s
CI / format (pull_request) Successful in 23s
CI / lint (pull_request) Successful in 23s
CI / install-and-build (pull_request) Successful in 45s
CI / typecheck-source (pull_request) Successful in 25s
CI / typecheck-tests (pull_request) Successful in 30s
CI / test (pull_request) Successful in 40s
The export/import plan predated the postgres backend. Bring it up to parity: - PostgresInterface implements Exportable + Importable, mirroring the sqlite backend. Export is a signal-checked async generator (one query per table); import drives a transaction via `WrappedPool.multi`, which rolls back on any error/abort and commits only after the whole stream is consumed cleanly. - The `note` kind is skipped on postgres (its note CRUD is still unimplemented, so a pg database holds none) — a full dump from sqlite imports its user/abode/resident/apikey records and drops notes. - Unlike sqlite (PRAGMA foreign_keys=off), postgres keeps FK enforcement; the FK-safe insertion order keeps a full dump valid, and truly-dangling partial dumps will (correctly) fail. - abode-import now resolves the backend via getDbInterface instead of constructing SqliteInterface directly; isImportable keeps it from ever running over the remote (api) interface, which has no import. - Add isImportable(); make postgres selectClientApikeys' where optional. - Tests: exercise the real PostgresInterface export/import paths against an in-memory fake WrappedPgClient (no pg service in CI) — NDJSON shape, meta.source, filtering, note-skip, insert dispatch, and error/abort rollback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,296 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Readable } from "node:stream";
|
||||
import { PostgresInterface } from "../../../src/db/postgres/PostgresInterface.js";
|
||||
import type { WrappedPgClient } from "../../../src/db/postgres/pool.js";
|
||||
import type { SqlCode } from "../../../src/db/postgres/sql.js";
|
||||
|
||||
// The postgres backend has no CI database, so these tests drive the real
|
||||
// PostgresInterface.export/import code paths against an in-memory fake client.
|
||||
// They verify control flow (NDJSON shape, meta.source, filtering, note
|
||||
// skipping, counts, insert dispatch, abort -> rollback); the SQL-arg forms are
|
||||
// the same {uuid}/{text}/{jsonb}/{date} patterns the pg backend's own CRUD
|
||||
// already exercises against real Postgres.
|
||||
|
||||
interface Rows {
|
||||
users?: Record<string, unknown>[];
|
||||
abodes?: Record<string, unknown>[];
|
||||
residents?: Record<string, unknown>[];
|
||||
apikeys?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
class FakePg implements WrappedPgClient {
|
||||
readonly = false;
|
||||
inserts: { table: string; vars: unknown[] }[] = [];
|
||||
committed = false;
|
||||
rolledBack = false;
|
||||
#rows: Rows;
|
||||
|
||||
constructor(rows: Rows = {}) {
|
||||
this.#rows = rows;
|
||||
}
|
||||
|
||||
async destroy(): Promise<void> {}
|
||||
|
||||
async all<R>(stmt: SqlCode): Promise<R[]> {
|
||||
const s = stmt._sql;
|
||||
if (s.includes('FROM "users"')) return (this.#rows.users ?? []) as R[];
|
||||
if (s.includes('FROM "abodes"')) return (this.#rows.abodes ?? []) as R[];
|
||||
if (s.includes('FROM "residents"'))
|
||||
return (this.#rows.residents ?? []) as R[];
|
||||
if (s.includes('FROM "apikeys"')) return (this.#rows.apikeys ?? []) as R[];
|
||||
return [] as R[];
|
||||
}
|
||||
|
||||
async get<R>(stmt: SqlCode): Promise<R | null> {
|
||||
const rows = await this.all<R>(stmt);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
async run(stmt: SqlCode): Promise<{ changes: number }> {
|
||||
const table = stmt._sql.match(/INSERT INTO "(\w+)"/)?.[1] ?? "?";
|
||||
this.inserts.push({ table, vars: stmt._vars });
|
||||
return { changes: 1 };
|
||||
}
|
||||
|
||||
async multi<R>(fn: (tx: WrappedPgClient) => Promise<R>): Promise<R> {
|
||||
try {
|
||||
const r = await fn(this);
|
||||
this.committed = true;
|
||||
return r;
|
||||
} catch (e) {
|
||||
this.rolledBack = true;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
async rethrow<R>(fn: () => Promise<R>): Promise<R> {
|
||||
return fn();
|
||||
}
|
||||
}
|
||||
|
||||
const iso = "2026-01-02T03:04:05.000Z";
|
||||
|
||||
function seededRows(): Rows {
|
||||
return {
|
||||
users: [
|
||||
{
|
||||
uid: "11111111-1111-1111-1111-111111111111",
|
||||
email: "u1@test.example",
|
||||
name: "User One",
|
||||
flags: {},
|
||||
created_at: new Date(iso),
|
||||
updated_at: new Date(iso),
|
||||
},
|
||||
],
|
||||
abodes: [
|
||||
{
|
||||
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
|
||||
name: "Abode One",
|
||||
created_at: new Date(iso),
|
||||
created_by: "11111111-1111-1111-1111-111111111111",
|
||||
updated_at: new Date(iso),
|
||||
updated_by: null,
|
||||
},
|
||||
{
|
||||
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2",
|
||||
name: "Abode Two",
|
||||
created_at: new Date(iso),
|
||||
created_by: null,
|
||||
updated_at: new Date(iso),
|
||||
updated_by: null,
|
||||
},
|
||||
],
|
||||
residents: [
|
||||
{
|
||||
uid: "11111111-1111-1111-1111-111111111111",
|
||||
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
|
||||
flags: {},
|
||||
created_at: new Date(iso),
|
||||
created_by: null,
|
||||
updated_at: new Date(iso),
|
||||
updated_by: null,
|
||||
},
|
||||
],
|
||||
apikeys: [
|
||||
{
|
||||
uid: "11111111-1111-1111-1111-111111111111",
|
||||
kid: "kkkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkk1",
|
||||
name: "key one",
|
||||
permissions: {},
|
||||
created_at: new Date(iso),
|
||||
expires_at: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function streamToString(s: NodeJS.ReadableStream): Promise<string> {
|
||||
let out = "";
|
||||
for await (const chunk of s) out += chunk;
|
||||
return out;
|
||||
}
|
||||
function parseLines(ndjson: string): { kind: string; data: any }[] {
|
||||
return ndjson
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.map((l) => JSON.parse(l));
|
||||
}
|
||||
function line(kind: string, data: unknown): string {
|
||||
return JSON.stringify({ kind, data }) + "\n";
|
||||
}
|
||||
|
||||
describe("postgres export", () => {
|
||||
it("streams meta + the four supported kinds, never note", async () => {
|
||||
const db = new PostgresInterface(new FakePg(seededRows()));
|
||||
const lines = parseLines(await streamToString(db.export()));
|
||||
|
||||
const meta = lines.find((l) => l.kind === "meta");
|
||||
assert.ok(meta);
|
||||
assert.equal(meta!.data.source, "postgres");
|
||||
assert.equal(meta!.data.v, 1);
|
||||
|
||||
const kinds = new Set(lines.map((l) => l.kind));
|
||||
assert.ok(kinds.has("user"));
|
||||
assert.ok(kinds.has("abode"));
|
||||
assert.ok(kinds.has("resident"));
|
||||
assert.ok(kinds.has("apikey"));
|
||||
assert.ok(!kinds.has("note"), "note is unsupported on postgres");
|
||||
});
|
||||
|
||||
it("applies the kinds filter", async () => {
|
||||
const db = new PostgresInterface(new FakePg(seededRows()));
|
||||
const lines = parseLines(
|
||||
await streamToString(db.export({ filter: { kinds: ["abode"] } })),
|
||||
);
|
||||
const kinds = new Set(
|
||||
lines.filter((l) => l.kind !== "meta").map((l) => l.kind),
|
||||
);
|
||||
assert.deepEqual(kinds, new Set(["abode"]));
|
||||
});
|
||||
|
||||
it("applies the abodes allowlist to abode/resident records", async () => {
|
||||
const db = new PostgresInterface(new FakePg(seededRows()));
|
||||
const lines = parseLines(
|
||||
await streamToString(
|
||||
db.export({
|
||||
filter: { abodes: ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"] },
|
||||
}),
|
||||
),
|
||||
);
|
||||
const abodeAids = lines
|
||||
.filter((l) => l.kind === "abode")
|
||||
.map((l) => l.data.aid);
|
||||
assert.deepEqual(abodeAids, ["aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("postgres import", () => {
|
||||
it("dispatches inserts per kind, skips note, and commits", async () => {
|
||||
const fake = new FakePg();
|
||||
const db = new PostgresInterface(fake);
|
||||
const source = Readable.from([
|
||||
line("meta", { v: 1 }),
|
||||
line("user", {
|
||||
uid: "11111111-1111-1111-1111-111111111111",
|
||||
email: "u1@test.example",
|
||||
name: "User One",
|
||||
flags: {},
|
||||
created_at: iso,
|
||||
updated_at: iso,
|
||||
}),
|
||||
line("abode", {
|
||||
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
|
||||
name: "Abode One",
|
||||
created_at: iso,
|
||||
created_by: "11111111-1111-1111-1111-111111111111",
|
||||
updated_at: iso,
|
||||
updated_by: null,
|
||||
}),
|
||||
line("apikey", {
|
||||
uid: "11111111-1111-1111-1111-111111111111",
|
||||
kid: "kkkkkkkk-kkkk-kkkk-kkkk-kkkkkkkkkkk1",
|
||||
name: "key one",
|
||||
permissions: {},
|
||||
created_at: iso,
|
||||
expires_at: null,
|
||||
}),
|
||||
// note lines are silently skipped on postgres
|
||||
line("note", {
|
||||
nid: "nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnn1",
|
||||
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
|
||||
name: "Note",
|
||||
content: "x",
|
||||
properties: {},
|
||||
created_at: iso,
|
||||
created_by: null,
|
||||
updated_at: iso,
|
||||
updated_by: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await db.import(source);
|
||||
assert.deepEqual(result.counts, { user: 1, abode: 1, apikey: 1 });
|
||||
assert.ok(fake.committed);
|
||||
assert.deepEqual(fake.inserts.map((i) => i.table).sort(), [
|
||||
"abodes",
|
||||
"apikeys",
|
||||
"users",
|
||||
]);
|
||||
assert.ok(!fake.inserts.some((i) => i.table === "notes"), "no note insert");
|
||||
// apikey gets a freshly-minted token (never exported)
|
||||
const apikeyInsert = fake.inserts.find((i) => i.table === "apikeys")!;
|
||||
assert.ok(
|
||||
apikeyInsert.vars.some(
|
||||
(v) => typeof v === "string" && v.startsWith("at_"),
|
||||
),
|
||||
"apikey insert carries a fresh token",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects and rolls back when the stream carries an error sentinel", async () => {
|
||||
const fake = new FakePg();
|
||||
const db = new PostgresInterface(fake);
|
||||
const source = Readable.from([
|
||||
line("meta", { v: 1 }),
|
||||
line("user", {
|
||||
uid: "11111111-1111-1111-1111-111111111111",
|
||||
email: "u1@test.example",
|
||||
name: "User One",
|
||||
flags: {},
|
||||
created_at: iso,
|
||||
updated_at: iso,
|
||||
}),
|
||||
line("error", { message: "boom" }),
|
||||
]);
|
||||
|
||||
await assert.rejects(() => db.import(source), /boom/);
|
||||
assert.ok(fake.rolledBack);
|
||||
assert.ok(!fake.committed);
|
||||
});
|
||||
|
||||
it("rejects and rolls back on signal abort", async () => {
|
||||
const fake = new FakePg();
|
||||
const db = new PostgresInterface(fake);
|
||||
const ac = new AbortController();
|
||||
const source = Readable.from(
|
||||
(async function* () {
|
||||
yield line("meta", { v: 1 });
|
||||
yield line("user", {
|
||||
uid: "11111111-1111-1111-1111-111111111111",
|
||||
email: "u1@test.example",
|
||||
name: "User One",
|
||||
flags: {},
|
||||
created_at: iso,
|
||||
updated_at: iso,
|
||||
});
|
||||
ac.abort();
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
})(),
|
||||
);
|
||||
|
||||
await assert.rejects(() => db.import(source, { signal: ac.signal }));
|
||||
assert.ok(fake.rolledBack);
|
||||
assert.ok(!fake.committed);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user