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>
112 lines
3.6 KiB
TypeScript
112 lines
3.6 KiB
TypeScript
import { createReadStream } from "node:fs";
|
|
import { getDbInterface } from "../db/index.js";
|
|
import { isExportKind } from "../db/export/filter.js";
|
|
import { isImportable, type ExportFilter } from "../db/types/ExportImport.js";
|
|
|
|
const args = process.argv.slice(2);
|
|
|
|
function printUsage(err: boolean | string = false): never {
|
|
const log = (err ? console.error : console.log).bind(console);
|
|
if (typeof err === "string") {
|
|
log(`Error: ${err}`);
|
|
log("");
|
|
}
|
|
log("Usage:");
|
|
log("\tabode-import --help");
|
|
log("\tabode-import <database-url> <input-file|-> [--kinds=...] \\");
|
|
log(
|
|
"\t [--exclude-kinds=...] [--abodes=aid,...] [--users=uid,...]",
|
|
);
|
|
log("");
|
|
log(
|
|
"The target must be a local backend (sqlite or postgres), already migrated",
|
|
);
|
|
log("(run abode-migrate first). Remote (api) targets are not importable.");
|
|
process.exit(err ? 1 : 0);
|
|
}
|
|
|
|
if (["-h", "--help", "help"].some((x) => args.includes(x))) printUsage();
|
|
|
|
const positional: string[] = [];
|
|
const flags = new Map<string, string>();
|
|
for (const arg of args) {
|
|
if (arg.startsWith("--")) {
|
|
const eq = arg.indexOf("=");
|
|
if (eq === -1) printUsage(`invalid flag: ${arg}`);
|
|
flags.set(arg.slice(2, eq), arg.slice(eq + 1));
|
|
} else {
|
|
positional.push(arg);
|
|
}
|
|
}
|
|
|
|
const url = positional[0];
|
|
const input = positional[1];
|
|
if (!url) printUsage("missing <database-url>");
|
|
if (!input) printUsage("missing <input-file|->");
|
|
if (positional.length > 2) printUsage("too many arguments");
|
|
|
|
const knownFlags = ["kinds", "exclude-kinds", "abodes", "users"];
|
|
for (const key of flags.keys()) {
|
|
if (!knownFlags.includes(key)) printUsage(`unknown flag: --${key}`);
|
|
}
|
|
|
|
function parseList(value: string | undefined): string[] | undefined {
|
|
if (value === undefined) return undefined;
|
|
return value.split(",").filter(Boolean);
|
|
}
|
|
function parseKinds(value: string | undefined) {
|
|
const list = parseList(value);
|
|
if (!list) return undefined;
|
|
const bad = list.filter((k) => !isExportKind(k));
|
|
if (bad.length) printUsage(`invalid kind(s): ${bad.join(", ")}`);
|
|
return list.filter(isExportKind);
|
|
}
|
|
|
|
const filter: ExportFilter = {};
|
|
const kinds = parseKinds(flags.get("kinds"));
|
|
if (kinds) filter.kinds = kinds;
|
|
const excludeKinds = parseKinds(flags.get("exclude-kinds"));
|
|
if (excludeKinds) filter.excludeKinds = excludeKinds;
|
|
const abodes = parseList(flags.get("abodes"));
|
|
if (abodes) filter.abodes = abodes;
|
|
const users = parseList(flags.get("users"));
|
|
if (users) filter.users = users;
|
|
|
|
// Resolve the backend generically. Import lives on the local backends (sqlite,
|
|
// postgres); the remote (api) interface has no `import`, so `isImportable`
|
|
// keeps it from ever running over HTTP.
|
|
const db = await getDbInterface(url);
|
|
if (!isImportable(db)) {
|
|
console.error(`Error: backend '${db.name}' does not support import`);
|
|
await db.close().catch(() => {});
|
|
process.exit(1);
|
|
}
|
|
|
|
const source = input === "-" ? process.stdin : createReadStream(input);
|
|
|
|
const ac = new AbortController();
|
|
const onSignal = () => ac.abort();
|
|
process.on("SIGINT", onSignal);
|
|
process.on("SIGTERM", onSignal);
|
|
|
|
try {
|
|
const result = await db.import(source, { filter, signal: ac.signal });
|
|
const total = Object.values(result.counts).reduce((a, b) => a + b, 0);
|
|
console.log(`Imported ${total} record(s):`);
|
|
for (const [kind, count] of Object.entries(result.counts)) {
|
|
console.log(`- ${kind}: ${count}`);
|
|
}
|
|
} catch (e) {
|
|
if (ac.signal.aborted) {
|
|
console.error("Import aborted; no changes committed");
|
|
process.exit(130);
|
|
}
|
|
throw e;
|
|
} finally {
|
|
process.off("SIGINT", onSignal);
|
|
process.off("SIGTERM", onSignal);
|
|
await db.close().catch(() => {});
|
|
}
|
|
|
|
process.exit(0);
|