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>
297 lines
8.8 KiB
TypeScript
297 lines
8.8 KiB
TypeScript
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);
|
|
});
|
|
});
|