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>
770 lines
24 KiB
TypeScript
770 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 {
|
|
EXPORT_KIND_ORDER,
|
|
type Exportable,
|
|
type ExportKind,
|
|
type ExportOptions,
|
|
type Importable,
|
|
type ImportOptions,
|
|
type 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. Emission follows the FK-safe EXPORT_KIND_ORDER (part of the
|
|
// wire contract; see ExportImport.ts).
|
|
const loaders: Record<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 of EXPORT_KIND_ORDER) {
|
|
if (signal?.aborted) return;
|
|
if (!kindAllowed(filter, kind)) continue;
|
|
for (const row of loaders[kind]()) {
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|