CI / lint (pull_request) Successful in 31s
CI / format (pull_request) Successful in 31s
CI / install-and-build (pull_request) Successful in 55s
CI / typecheck-tests (pull_request) Successful in 31s
CI / typecheck-source (pull_request) Successful in 31s
CI / test (pull_request) Successful in 42s
The postgres importer inserts records sequentially with FK enforcement live, so it depends on records arriving in dependency order. That requirement was implicit in each backend's export table list; make it explicit and enforced. - Add EXPORT_KIND_ORDER (user, abode, resident, apikey, note) as a documented single source of truth, with the FK dependency chain spelled out on its doc comment, and note the ordering guarantee on the ExportEnvelope wire-format doc. Derive the isExportKind set from it. - Both backends' export() now iterate EXPORT_KIND_ORDER via a loader map (postgres omits note by leaving it out of the map), so emission order is tied to the constant and can't drift. - Tests: a change-detector on EXPORT_KIND_ORDER, plus assertions that both the sqlite and postgres (fake) exports emit record kinds grouped in FK-safe order (kind rank non-decreasing down the stream, after the leading meta). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
315 lines
9.4 KiB
TypeScript
315 lines
9.4 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";
|
|
import {
|
|
EXPORT_KIND_ORDER,
|
|
type ExportKind,
|
|
} from "../../../src/db/types/ExportImport.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("emits record kinds grouped in FK-safe EXPORT_KIND_ORDER", async () => {
|
|
const db = new PostgresInterface(new FakePg(seededRows()));
|
|
const lines = parseLines(await streamToString(db.export()));
|
|
assert.equal(lines[0]?.kind, "meta", "first line is meta");
|
|
const rank = (k: string) => EXPORT_KIND_ORDER.indexOf(k as ExportKind);
|
|
let last = -1;
|
|
for (const { kind } of lines.slice(1)) {
|
|
const r = rank(kind);
|
|
assert.notEqual(r, -1, `unexpected kind ${kind}`);
|
|
assert.ok(r >= last, `kind ${kind} out of FK-safe order`);
|
|
last = r;
|
|
}
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|