CI / format (pull_request) Successful in 28s
CI / lint (pull_request) Successful in 28s
CI / install-and-build (pull_request) Successful in 53s
CI / typecheck-tests (pull_request) Successful in 28s
CI / typecheck-source (pull_request) Successful in 32s
CI / test (pull_request) Successful in 43s
Add backend-agnostic data export/import over an NDJSON wire format, plus an
inspect utility, exposed via three new CLIs and an HTTP export endpoint.
- ExportImport types + filter helpers (kind/record scoping, hard-intersection
of filters) in src/db/{types/ExportImport,export/filter}.ts
- SqliteInterface implements Exportable + Importable: signal-checked async
generator export (one query per table, per-record yield, trailing error
sentinel on mid-stream failure) and a manually-driven import transaction
that rolls back on any error/abort and never commits partial data
- ApiInterface implements Exportable via its own fetch({signal})
- computeForcedExportFilter enforces non-global-admin scope (resided-in abodes
+ co-resident users, intersected with apikey restrict_*); GET /export
intersects it with the caller's filter and wires an AbortController to the
response socket
- inspectExportStream reports kinds/counts from any stream without a db
- abode-export / abode-import / abode-inspect CLIs (import is sqlite-only)
- Secrets are not exported: imported users default to '#unset' passwords and
apikeys are re-minted a token (ClientApikey view round-trips exactly)
- test/tools/export-import.test.ts: round-trip, filter narrowing, forced-scope,
export/import cancellation, in-process Koa endpoint, inspect
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
767 lines
24 KiB
TypeScript
767 lines
24 KiB
TypeScript
import type { BackendDbInterface } from "../types/DbInterface.js";
|
|
import {
|
|
isValidUserPassword,
|
|
type ClientUser,
|
|
type CreateUser,
|
|
type LoginUser,
|
|
type UpdateUser,
|
|
type UserFlags,
|
|
} from "../types/User.js";
|
|
import { sqliteToClientUser, sqliteToDate, sqliteToUuid } from "./cast.js";
|
|
import {
|
|
ConflictAbodeError,
|
|
InvalidAbodeError,
|
|
NotAuthorizedAbodeError,
|
|
NotFoundAbodeError,
|
|
ReadonlyAbodeError,
|
|
} from "../types/errors.js";
|
|
import type { Abode, CreateAbode, UpdateAbode } from "../types/Abode.js";
|
|
import type {
|
|
CreateResident,
|
|
Resident,
|
|
ResidentFlags,
|
|
updateResident,
|
|
} from "../types/Resident.js";
|
|
import { validatePassword } from "../../util/hash.js";
|
|
import { createApikeyToken, createSessionToken } from "../../util/token.js";
|
|
import type { ClientApikey, CreateApikey } from "../types/Apikey.js";
|
|
import { calcUpdates, catSql, joinSql, sql } from "./sql.js";
|
|
import {
|
|
selectAbode,
|
|
selectAbodes,
|
|
selectClientApikey,
|
|
selectClientApikeys,
|
|
selectClientUser,
|
|
selectClientUsers,
|
|
selectNote,
|
|
selectNotes,
|
|
selectPartialNotes,
|
|
selectResident,
|
|
selectResidents,
|
|
} from "./query.js";
|
|
import type {
|
|
CreateNote,
|
|
Note,
|
|
PartialNote,
|
|
UpdateNote,
|
|
} from "../types/Note.js";
|
|
import type { WrappedDb } from "./impl/types.js";
|
|
import { Readable } from "node:stream";
|
|
import readline from "node:readline";
|
|
import type {
|
|
Exportable,
|
|
ExportKind,
|
|
ExportOptions,
|
|
Importable,
|
|
ImportOptions,
|
|
ImportResult,
|
|
} from "../types/ExportImport.js";
|
|
import { isExportKind, kindAllowed, recordAllowed } from "../export/filter.js";
|
|
|
|
export class SqliteInterface
|
|
implements BackendDbInterface, Exportable, Importable
|
|
{
|
|
#db: WrappedDb;
|
|
|
|
constructor(db: WrappedDb) {
|
|
this.#db = db;
|
|
}
|
|
|
|
#checkReadonly(): void {
|
|
if (this.#db.readonly) throw new ReadonlyAbodeError();
|
|
}
|
|
|
|
get _() {
|
|
return {
|
|
sql,
|
|
catSql,
|
|
joinSql,
|
|
db: this.#db,
|
|
};
|
|
}
|
|
|
|
get readonly(): boolean {
|
|
return this.#db.readonly;
|
|
}
|
|
get backend(): true {
|
|
return true;
|
|
}
|
|
get name(): "sqlite" {
|
|
return "sqlite";
|
|
}
|
|
|
|
async close(): Promise<void> {
|
|
this.#db.destroy();
|
|
}
|
|
|
|
async listUsers(): Promise<ClientUser[]> {
|
|
return selectClientUsers(this.#db);
|
|
}
|
|
#getUserById(uid: string): ClientUser {
|
|
const user = selectClientUser(this.#db, sql`"uid" = ${{ uuid: uid }}`);
|
|
if (!user) throw new NotFoundAbodeError();
|
|
return user;
|
|
}
|
|
async getUserById(id: string): Promise<ClientUser> {
|
|
return this.#getUserById(id);
|
|
}
|
|
async getUserByEmail(email: string): Promise<ClientUser> {
|
|
const user = selectClientUser(this.#db, sql`"email" = ${{ text: email }}`);
|
|
if (!user) throw new NotFoundAbodeError();
|
|
return user;
|
|
}
|
|
async getUserByLogin({ email, password }: LoginUser): Promise<ClientUser> {
|
|
const rawUser = this.#db.get<{
|
|
uid: Buffer;
|
|
email: string;
|
|
name: string;
|
|
flags: string;
|
|
created_at: string;
|
|
updated_at: string;
|
|
password: string;
|
|
}>(
|
|
sql`
|
|
SELECT "uid", "email", "name", json("flags") AS "flags", "created_at", "updated_at", "password"
|
|
FROM "users"
|
|
WHERE "email" = ${{ text: email }}
|
|
`,
|
|
);
|
|
if (!rawUser) throw new NotFoundAbodeError();
|
|
if (rawUser.password.startsWith("#")) throw new ConflictAbodeError();
|
|
if (!(await validatePassword(password, rawUser.password)))
|
|
throw new NotAuthorizedAbodeError();
|
|
return sqliteToClientUser(rawUser);
|
|
}
|
|
async deleteUserById(id: string): Promise<void> {
|
|
this.#checkReadonly();
|
|
const { changes } = this.#db.run(
|
|
sql`
|
|
DELETE FROM "users"
|
|
WHERE "uid" = ${{ uuid: id }}
|
|
`,
|
|
);
|
|
if (!changes) throw new NotFoundAbodeError();
|
|
}
|
|
async createUser(user: CreateUser): Promise<ClientUser> {
|
|
this.#checkReadonly();
|
|
if (!isValidUserPassword(user.password)) throw new InvalidAbodeError();
|
|
const uid = crypto.randomUUID();
|
|
return this.#db.rethrow(() =>
|
|
this.#db.multi(() => {
|
|
this.#db.run(
|
|
sql`
|
|
INSERT INTO "users"("uid", "email", "name", "password", "flags")
|
|
VALUES(
|
|
${{ uuid: uid }},
|
|
${{ text: user.email }},
|
|
${{ text: user.name }},
|
|
${{ text: user.password }},${{ jsonb: user.flags }})
|
|
`,
|
|
);
|
|
return this.#getUserById(uid);
|
|
}),
|
|
);
|
|
}
|
|
async updateUser(user: UpdateUser): Promise<ClientUser> {
|
|
this.#checkReadonly();
|
|
return this.#db.rethrow(() =>
|
|
this.#db.multi(() => {
|
|
const updates = calcUpdates({
|
|
email: (value: string) => sql`"email" = ${{ text: value }}`,
|
|
name: (value: string) => sql`"name" = ${{ text: value }}`,
|
|
password: (value: string) => {
|
|
if (!isValidUserPassword(value)) throw new InvalidAbodeError();
|
|
if (value.startsWith("#")) {
|
|
this.#db.run(sql`
|
|
DELETE FROM "apikeys"
|
|
WHERE "uid" = ${{ uuid: user.uid }}
|
|
`);
|
|
}
|
|
this.#db.run(sql`
|
|
DELETE FROM "sessions"
|
|
WHERE "uid" = ${{ uuid: user.uid }}
|
|
`);
|
|
return sql`"password" = ${{ text: value }}`;
|
|
},
|
|
flags: (value: UserFlags) => sql`"flags" = ${{ jsonb: value }}`,
|
|
})(user);
|
|
if (!updates.length) throw new InvalidAbodeError();
|
|
const { changes } = this.#db.run(
|
|
sql`
|
|
UPDATE "users"
|
|
SET
|
|
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
|
${joinSql(updates, sql`, `)}
|
|
WHERE "uid" = ${{ uuid: user.uid }}
|
|
`,
|
|
);
|
|
if (!changes) throw new NotFoundAbodeError();
|
|
return this.#getUserById(user.uid);
|
|
}),
|
|
);
|
|
}
|
|
|
|
async listAbodes(): Promise<Abode[]> {
|
|
return selectAbodes(this.#db);
|
|
}
|
|
#getAbodeById(aid: string): Abode {
|
|
const abode = selectAbode(this.#db, sql`"aid" = ${{ uuid: aid }}`);
|
|
if (!abode) throw new NotFoundAbodeError();
|
|
return abode;
|
|
}
|
|
async getAbodeById(id: string): Promise<Abode> {
|
|
return this.#getAbodeById(id);
|
|
}
|
|
async deleteAbodeById(id: string): Promise<void> {
|
|
this.#checkReadonly();
|
|
const { changes } = this.#db.run(
|
|
sql`
|
|
DELETE FROM "abodes"
|
|
WHERE "aid" = ${{ uuid: id }}
|
|
`,
|
|
);
|
|
if (!changes) throw new NotFoundAbodeError();
|
|
}
|
|
async createAbode(abode: CreateAbode, ctx: { uid: string }): Promise<Abode> {
|
|
this.#checkReadonly();
|
|
const aid = crypto.randomUUID();
|
|
return this.#db.rethrow(() =>
|
|
this.#db.multi(() => {
|
|
this.#db.run(
|
|
sql`
|
|
INSERT INTO "abodes"("aid", "name", "created_by", "updated_by")
|
|
VALUES(
|
|
${{ uuid: aid }},
|
|
${{ text: abode.name }},
|
|
${{ uuid: ctx.uid }},
|
|
${{ uuid: ctx.uid }}
|
|
)
|
|
`,
|
|
);
|
|
return this.#getAbodeById(aid);
|
|
}),
|
|
);
|
|
}
|
|
async updateAbode(abode: UpdateAbode, ctx: { uid: string }): Promise<Abode> {
|
|
this.#checkReadonly();
|
|
const updates = calcUpdates({
|
|
name: (value: string) => sql`"name" = ${{ text: value }}`,
|
|
})(abode);
|
|
if (!updates.length) throw new InvalidAbodeError();
|
|
return this.#db.rethrow(() =>
|
|
this.#db.multi(() => {
|
|
const { changes } = this.#db.run(
|
|
sql`
|
|
UPDATE "abodes"
|
|
SET
|
|
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
|
"updated_by" = ${{ uuid: ctx.uid }},
|
|
${joinSql(updates, sql`, `)}
|
|
WHERE "aid" = ${{ uuid: abode.aid }}
|
|
`,
|
|
);
|
|
if (!changes) throw new NotFoundAbodeError();
|
|
return this.#getAbodeById(abode.aid);
|
|
}),
|
|
);
|
|
}
|
|
|
|
async listResidents(): Promise<Resident[]> {
|
|
return selectResidents(this.#db);
|
|
}
|
|
async listResidentsByUserId(uid: string): Promise<Resident[]> {
|
|
return selectResidents(this.#db, sql`"uid" = ${{ uuid: uid }}`);
|
|
}
|
|
async listResidentsByAbodeId(aid: string): Promise<Resident[]> {
|
|
return selectResidents(this.#db, sql`"aid" = ${{ uuid: aid }}`);
|
|
}
|
|
#getResidentById(uid: string, aid: string): Resident {
|
|
const resident = selectResident(
|
|
this.#db,
|
|
sql`"uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}`,
|
|
);
|
|
if (!resident) throw new NotFoundAbodeError();
|
|
return resident;
|
|
}
|
|
async getResidentById(uid: string, aid: string): Promise<Resident> {
|
|
return this.#getResidentById(uid, aid);
|
|
}
|
|
async deleteResidentById(uid: string, aid: string): Promise<void> {
|
|
this.#checkReadonly();
|
|
const { changes } = this.#db.run(
|
|
sql`
|
|
DELETE FROM "residents"
|
|
WHERE "uid" = ${{ uuid: uid }} AND "aid" = ${{ uuid: aid }}
|
|
`,
|
|
);
|
|
if (!changes) throw new NotFoundAbodeError();
|
|
}
|
|
async createResident(
|
|
resident: CreateResident,
|
|
ctx: { uid: string },
|
|
): Promise<Resident> {
|
|
this.#checkReadonly();
|
|
return this.#db.rethrow(() =>
|
|
this.#db.multi(() => {
|
|
this.#db.run(
|
|
sql`
|
|
INSERT INTO "residents"("uid", "aid", "flags", "created_by", "updated_by")
|
|
VALUES(
|
|
${{ uuid: resident.uid }},
|
|
${{ uuid: resident.aid }},
|
|
${{ jsonb: resident.flags }},
|
|
${{ uuid: ctx.uid }},
|
|
${{ uuid: ctx.uid }}
|
|
)
|
|
`,
|
|
);
|
|
return this.#getResidentById(resident.uid, resident.aid);
|
|
}),
|
|
);
|
|
}
|
|
async updateResident(
|
|
resident: updateResident,
|
|
ctx: { uid: string },
|
|
): Promise<Resident> {
|
|
this.#checkReadonly();
|
|
const updates = calcUpdates({
|
|
flags: (value: ResidentFlags) => sql`"flags" = ${{ jsonb: value }}`,
|
|
})(resident);
|
|
if (!updates.length) throw new InvalidAbodeError();
|
|
return this.#db.rethrow(() =>
|
|
this.#db.multi(() => {
|
|
const { changes } = this.#db.run(
|
|
sql`
|
|
UPDATE "residents"
|
|
SET
|
|
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
|
"updated_by" = ${{ uuid: ctx.uid }},
|
|
${joinSql(updates, sql`, `)}
|
|
WHERE
|
|
"uid" = ${{ uuid: resident.uid }}
|
|
AND "aid" = ${{ uuid: resident.aid }}
|
|
`,
|
|
);
|
|
if (!changes) throw new NotFoundAbodeError();
|
|
return this.#getResidentById(resident.uid, resident.aid);
|
|
}),
|
|
);
|
|
}
|
|
|
|
async listUsersByAbodeId(id: string): Promise<ClientUser[]> {
|
|
return selectClientUsers(
|
|
this.#db,
|
|
sql`
|
|
JOIN "residents" r ON u."uid" = r."uid"
|
|
WHERE r."aid" = ${{ uuid: id }}
|
|
`,
|
|
);
|
|
}
|
|
async listAbodesByUserId(id: string): Promise<Abode[]> {
|
|
return selectAbodes(
|
|
this.#db,
|
|
sql`
|
|
JOIN "residents" r ON a."aid" = r."aid"
|
|
WHERE r."uid" = ${{ uuid: id }}
|
|
`,
|
|
);
|
|
}
|
|
|
|
async getUserBySession(token: `as_${string}`): Promise<ClientUser> {
|
|
const session = this.#db.get<{ uid: Buffer; expires_at: string }>(sql`
|
|
SELECT "uid", "expires_at"
|
|
FROM "sessions"
|
|
WHERE "token" = ${{ text: token }}
|
|
`);
|
|
if (!session) throw new NotFoundAbodeError();
|
|
if (new Date(sqliteToDate(session.expires_at)).getTime() < Date.now()) {
|
|
if (!this.readonly) {
|
|
this.#db.run(sql`
|
|
DELETE FROM "sessions"
|
|
WHERE "expires_at" < datetime('now', 'localtime', 'subsec')
|
|
`);
|
|
}
|
|
throw new NotFoundAbodeError();
|
|
}
|
|
if (!this.readonly) {
|
|
this.#db.run(sql`
|
|
UPDATE "sessions"
|
|
SET "expires_at" = datetime('now', 'localtime', 'subsec', '+7 days')
|
|
WHERE "token" = ${{ text: token }}
|
|
`);
|
|
}
|
|
return this.#getUserById(sqliteToUuid(session.uid));
|
|
}
|
|
async createSession(uid: string): Promise<`as_${string}`> {
|
|
this.#checkReadonly();
|
|
const token = createSessionToken();
|
|
this.#db.run(sql`
|
|
INSERT INTO "sessions"("uid", "token")
|
|
VALUES(${{ uuid: uid }}, ${{ text: token }})
|
|
`);
|
|
return token;
|
|
}
|
|
async deleteSessionsByUser(uid: string): Promise<void> {
|
|
this.#checkReadonly();
|
|
this.#db.run(sql`
|
|
DELETE FROM "sessions"
|
|
WHERE "uid" = ${{ uuid: uid }}
|
|
`);
|
|
}
|
|
async deleteSession(token: `as_${string}`): Promise<void> {
|
|
this.#checkReadonly();
|
|
this.#db.run(sql`
|
|
DELETE FROM "sessions"
|
|
WHERE "token" = ${{ text: token }}
|
|
`);
|
|
}
|
|
|
|
#getApikeyByToken(token: `at_${string}`): ClientApikey {
|
|
const apikey = selectClientApikey(
|
|
this.#db,
|
|
sql`"token" = ${{ text: token }}`,
|
|
);
|
|
if (!apikey) throw new NotFoundAbodeError();
|
|
return apikey;
|
|
}
|
|
async getUserByApikey(
|
|
token: `at_${string}`,
|
|
): Promise<[ClientUser, ClientApikey]> {
|
|
const apikey = this.#getApikeyByToken(token);
|
|
if (
|
|
apikey.expires_at &&
|
|
new Date(apikey.expires_at).getTime() < Date.now()
|
|
) {
|
|
throw new NotAuthorizedAbodeError();
|
|
}
|
|
return [this.#getUserById(apikey.uid), apikey];
|
|
}
|
|
async listApikeysByUser(uid: string): Promise<ClientApikey[]> {
|
|
return selectClientApikeys(this.#db, sql`"uid" = ${{ uuid: uid }}`);
|
|
}
|
|
async getApikeyById(kid: string): Promise<ClientApikey> {
|
|
const apikey = selectClientApikey(this.#db, sql`"kid" = ${{ uuid: kid }}`);
|
|
if (!apikey) throw new NotFoundAbodeError();
|
|
return apikey;
|
|
}
|
|
async createApikey(
|
|
apikey: CreateApikey,
|
|
): Promise<[ClientApikey, `at_${string}`]> {
|
|
this.#checkReadonly();
|
|
const token = createApikeyToken();
|
|
const kid = crypto.randomUUID();
|
|
let expires = apikey.expires_at;
|
|
if (expires === undefined)
|
|
expires = new Date(
|
|
new Date().getTime() + 1000 * 60 * 60 * 24 * 365,
|
|
).toISOString();
|
|
if (expires && new Date(expires).getTime() < Date.now())
|
|
throw new InvalidAbodeError();
|
|
|
|
this.#db.run(sql`
|
|
INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "expires_at")
|
|
VALUES(
|
|
${{ uuid: apikey.uid }},
|
|
${{ uuid: kid }},
|
|
${{ text: token }},
|
|
${{ text: apikey.name }},
|
|
${{ jsonb: apikey.permissions }},
|
|
${expires ? { date: expires } : { null: true }}
|
|
)
|
|
`);
|
|
return [this.#getApikeyByToken(token), token];
|
|
}
|
|
async deleteApikeyById(kid: string): Promise<void> {
|
|
this.#checkReadonly();
|
|
const { changes } = this.#db.run(sql`
|
|
DELETE FROM "apikeys"
|
|
WHERE "kid" = ${{ uuid: kid }}
|
|
`);
|
|
if (!changes) throw new NotFoundAbodeError();
|
|
}
|
|
|
|
async listNotes(): Promise<PartialNote[]> {
|
|
return selectPartialNotes(this.#db);
|
|
}
|
|
#getNoteById(nid: string): Note {
|
|
const note = selectNote(this.#db, sql`n."nid" = ${{ uuid: nid }}`);
|
|
if (!note) throw new NotFoundAbodeError();
|
|
return note;
|
|
}
|
|
async getNoteById(nid: string): Promise<Note> {
|
|
return this.#getNoteById(nid);
|
|
}
|
|
async deleteNoteById(nid: string): Promise<void> {
|
|
this.#checkReadonly();
|
|
const { changes } = this.#db.run(
|
|
sql`DELETE FROM "notes" WHERE "nid" = ${{ uuid: nid }}`,
|
|
);
|
|
if (!changes) throw new NotFoundAbodeError();
|
|
}
|
|
async createNote(note: CreateNote, ctx: { uid: string }): Promise<Note> {
|
|
this.#checkReadonly();
|
|
const nid = crypto.randomUUID();
|
|
return this.#db.rethrow(() =>
|
|
this.#db.multi(() => {
|
|
this.#db.run(sql`
|
|
INSERT INTO "notes"("nid", "aid", "name", "content", "properties", "created_by", "updated_by")
|
|
VALUES(
|
|
${{ uuid: nid }},
|
|
${{ uuid: note.aid }},
|
|
${{ text: note.name }},
|
|
${{ text: note.content ?? "" }},
|
|
${{ jsonb: note.properties }},
|
|
${{ uuid: ctx.uid }},
|
|
${{ uuid: ctx.uid }}
|
|
)
|
|
`);
|
|
return this.#getNoteById(nid);
|
|
}),
|
|
);
|
|
}
|
|
async updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note> {
|
|
this.#checkReadonly();
|
|
const updates = calcUpdates({
|
|
name: (value: string) => sql`"name" = ${{ text: value }}`,
|
|
content: (value: string) => sql`"content" = ${{ text: value }}`,
|
|
properties: (value: object) => sql`"properties" = ${{ jsonb: value }}`,
|
|
})(note);
|
|
if (!updates.length) throw new InvalidAbodeError();
|
|
return this.#db.rethrow(() =>
|
|
this.#db.multi(() => {
|
|
const { changes } = this.#db.run(sql`
|
|
UPDATE "notes"
|
|
SET
|
|
"updated_at" = datetime('now', 'localtime', 'subsec'),
|
|
"updated_by" = ${{ uuid: ctx.uid }},
|
|
${joinSql(updates, sql`, `)}
|
|
WHERE "nid" = ${{ uuid: note.nid }}
|
|
`);
|
|
if (!changes) throw new NotFoundAbodeError();
|
|
return this.#getNoteById(note.nid);
|
|
}),
|
|
);
|
|
}
|
|
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
|
|
return selectPartialNotes(this.#db, sql`n."aid" = ${{ uuid: aid }}`);
|
|
}
|
|
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
|
|
return selectPartialNotes(this.#db, sql`n."created_by" = ${{ uuid: uid }}`);
|
|
}
|
|
|
|
export(options: ExportOptions = {}): NodeJS.ReadableStream {
|
|
const { filter, signal } = options;
|
|
const db = this.#db;
|
|
const source = this.name;
|
|
|
|
// Per-table full materialization + JS-side per-record yielding (each
|
|
// `load()` is exactly one `WrappedDb.all()`). Kept lazy so the first query
|
|
// only fires once the destination starts pulling, and skipped entirely
|
|
// once the signal is aborted — no further reads after the destination
|
|
// goes away.
|
|
const tables: [ExportKind, () => { uid?: string; aid?: string }[]][] = [
|
|
["user", () => selectClientUsers(db)],
|
|
["abode", () => selectAbodes(db)],
|
|
["resident", () => selectResidents(db)],
|
|
["apikey", () => selectClientApikeys(db)],
|
|
["note", () => selectNotes(db)],
|
|
];
|
|
|
|
async function* generate(): AsyncGenerator<string> {
|
|
if (signal?.aborted) return;
|
|
yield JSON.stringify({
|
|
kind: "meta",
|
|
data: {
|
|
v: 1,
|
|
exportedAt: new Date().toISOString(),
|
|
source,
|
|
filter: filter ?? {},
|
|
},
|
|
}) + "\n";
|
|
try {
|
|
for (const [kind, load] of tables) {
|
|
if (signal?.aborted) return;
|
|
if (!kindAllowed(filter, kind)) continue;
|
|
for (const row of load()) {
|
|
if (signal?.aborted) return;
|
|
if (recordAllowed(filter, kind, row)) {
|
|
yield JSON.stringify({ kind, data: row }) + "\n";
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Aborts unwind via early `return`, never here; a genuine mid-stream
|
|
// failure is surfaced as a trailing sentinel line (HTTP 200 headers
|
|
// are already flushed, so `convertError` can no longer apply).
|
|
if (signal?.aborted) return;
|
|
yield JSON.stringify({
|
|
kind: "error",
|
|
data: {
|
|
message: e instanceof Error ? e.message : String(e),
|
|
code: e instanceof Error ? e.name : undefined,
|
|
},
|
|
}) + "\n";
|
|
}
|
|
}
|
|
|
|
return Readable.from(generate());
|
|
}
|
|
|
|
async import(
|
|
source: NodeJS.ReadableStream,
|
|
options: ImportOptions = {},
|
|
): Promise<ImportResult> {
|
|
this.#checkReadonly();
|
|
const { filter, signal } = options;
|
|
const db = this.#db;
|
|
const counts: Partial<Record<ExportKind, number>> = {};
|
|
|
|
const rl = readline.createInterface({
|
|
input: source,
|
|
crlfDelay: Infinity,
|
|
signal,
|
|
});
|
|
|
|
// Bulk restore trusts the export's referential integrity, and a filtered
|
|
// dump may legitimately reference `created_by`/`updated_by` users outside
|
|
// its scope. Suppress FK enforcement for the duration (can only be toggled
|
|
// outside a transaction) and restore it in `finally`.
|
|
db.run(sql`PRAGMA foreign_keys = OFF`);
|
|
db.run(sql`BEGIN`);
|
|
try {
|
|
for await (const raw of rl) {
|
|
signal?.throwIfAborted();
|
|
const line = raw.trim();
|
|
if (!line) continue;
|
|
let parsed: { kind?: unknown; data?: unknown };
|
|
try {
|
|
parsed = JSON.parse(line);
|
|
} catch {
|
|
throw new InvalidAbodeError();
|
|
}
|
|
if (parsed.kind === "meta") continue;
|
|
if (parsed.kind === "error") {
|
|
throw new Error(
|
|
`export stream reported an error: ${
|
|
(parsed.data as { message?: string })?.message ?? "unknown"
|
|
}`,
|
|
);
|
|
}
|
|
if (!isExportKind(parsed.kind)) continue;
|
|
if (!kindAllowed(filter, parsed.kind)) continue;
|
|
const data = parsed.data as { uid?: string; aid?: string };
|
|
if (!recordAllowed(filter, parsed.kind, data)) continue;
|
|
this.#importRecord(parsed.kind, parsed.data);
|
|
counts[parsed.kind] = (counts[parsed.kind] ?? 0) + 1;
|
|
}
|
|
// An abort while blocked on the source closes readline without throwing,
|
|
// so re-check before committing to guarantee no partial commit.
|
|
signal?.throwIfAborted();
|
|
db.run(sql`COMMIT`);
|
|
} catch (e) {
|
|
try {
|
|
db.run(sql`ROLLBACK`);
|
|
} catch {
|
|
/* already rolled back */
|
|
}
|
|
throw e;
|
|
} finally {
|
|
rl.close();
|
|
db.run(sql`PRAGMA foreign_keys = ON`);
|
|
}
|
|
|
|
return { counts };
|
|
}
|
|
|
|
#importRecord(kind: ExportKind, data: unknown): void {
|
|
const db = this.#db;
|
|
switch (kind) {
|
|
case "user": {
|
|
const u = data as ClientUser;
|
|
// `password` is never exported; imported users land on the schema
|
|
// default ('#unset') and must reset before they can log in.
|
|
db.run(sql`
|
|
INSERT INTO "users"("uid", "email", "name", "flags", "created_at", "updated_at")
|
|
VALUES(
|
|
${{ uuid: u.uid }},
|
|
${{ text: u.email }},
|
|
${{ text: u.name }},
|
|
${{ jsonb: u.flags }},
|
|
${{ date: u.created_at }},
|
|
${{ date: u.updated_at }}
|
|
)
|
|
`);
|
|
break;
|
|
}
|
|
case "abode": {
|
|
const a = data as Abode;
|
|
db.run(sql`
|
|
INSERT INTO "abodes"("aid", "name", "created_at", "created_by", "updated_at", "updated_by")
|
|
VALUES(
|
|
${{ uuid: a.aid }},
|
|
${{ text: a.name }},
|
|
${{ date: a.created_at }},
|
|
${a.created_by ? { uuid: a.created_by } : { null: true }},
|
|
${{ date: a.updated_at }},
|
|
${a.updated_by ? { uuid: a.updated_by } : { null: true }}
|
|
)
|
|
`);
|
|
break;
|
|
}
|
|
case "resident": {
|
|
const r = data as Resident;
|
|
db.run(sql`
|
|
INSERT INTO "residents"("uid", "aid", "flags", "created_at", "created_by", "updated_at", "updated_by")
|
|
VALUES(
|
|
${{ uuid: r.uid }},
|
|
${{ uuid: r.aid }},
|
|
${{ jsonb: r.flags }},
|
|
${{ date: r.created_at }},
|
|
${r.created_by ? { uuid: r.created_by } : { null: true }},
|
|
${{ date: r.updated_at }},
|
|
${r.updated_by ? { uuid: r.updated_by } : { null: true }}
|
|
)
|
|
`);
|
|
break;
|
|
}
|
|
case "apikey": {
|
|
const k = data as ClientApikey;
|
|
// `token` is never exported; mint a fresh unique one so the record's
|
|
// metadata (kid/permissions/expiry) survives even though the original
|
|
// secret cannot.
|
|
db.run(sql`
|
|
INSERT INTO "apikeys"("uid", "kid", "token", "name", "permissions", "created_at", "expires_at")
|
|
VALUES(
|
|
${{ uuid: k.uid }},
|
|
${{ uuid: k.kid }},
|
|
${{ text: createApikeyToken() }},
|
|
${{ text: k.name }},
|
|
${{ jsonb: k.permissions }},
|
|
${{ date: k.created_at }},
|
|
${k.expires_at ? { date: k.expires_at } : { null: true }}
|
|
)
|
|
`);
|
|
break;
|
|
}
|
|
case "note": {
|
|
const n = data as Note;
|
|
db.run(sql`
|
|
INSERT INTO "notes"("nid", "aid", "name", "content", "properties", "created_at", "created_by", "updated_at", "updated_by")
|
|
VALUES(
|
|
${{ uuid: n.nid }},
|
|
${{ uuid: n.aid }},
|
|
${{ text: n.name }},
|
|
${{ text: n.content ?? "" }},
|
|
${{ jsonb: n.properties }},
|
|
${{ date: n.created_at }},
|
|
${n.created_by ? { uuid: n.created_by } : { null: true }},
|
|
${{ date: n.updated_at }},
|
|
${n.updated_by ? { uuid: n.updated_by } : { null: true }}
|
|
)
|
|
`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|