feat(postgres): implement notes CRUD and export/import #16
@@ -35,6 +35,9 @@ import {
|
|||||||
selectClientUsers,
|
selectClientUsers,
|
||||||
selectResident,
|
selectResident,
|
||||||
selectResidents,
|
selectResidents,
|
||||||
|
selectNote,
|
||||||
|
selectNotes,
|
||||||
|
selectPartialNotes,
|
||||||
} from "./query.js";
|
} from "./query.js";
|
||||||
import type {
|
import type {
|
||||||
CreateNote,
|
CreateNote,
|
||||||
@@ -502,25 +505,66 @@ export class PostgresInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listNotes(): Promise<PartialNote[]> {
|
async listNotes(): Promise<PartialNote[]> {
|
||||||
throw new Error("Unimplemented");
|
return selectPartialNotes(this.#db);
|
||||||
}
|
}
|
||||||
async getNoteById(_nid: string): Promise<Note> {
|
async #getNoteById(nid: string, db: WrappedPgClient): Promise<Note> {
|
||||||
throw new Error("Unimplemented");
|
const note = await selectNote(db, sql`n."nid" = ${{ uuid: nid }}`);
|
||||||
|
if (!note) throw new NotFoundAbodeError();
|
||||||
|
return note;
|
||||||
}
|
}
|
||||||
async deleteNoteById(_nid: string): Promise<void> {
|
async getNoteById(nid: string): Promise<Note> {
|
||||||
throw new Error("Unimplemented");
|
return this.#getNoteById(nid, this.#db);
|
||||||
}
|
}
|
||||||
async createNote(_note: CreateNote, _ctx: { uid: string }): Promise<Note> {
|
async deleteNoteById(nid: string): Promise<void> {
|
||||||
throw new Error("Unimplemented");
|
this.#checkReadonly();
|
||||||
|
const { changes } = await this.#db.run(sql`
|
||||||
|
DELETE FROM "notes" WHERE "nid" = ${{ uuid: nid }}
|
||||||
|
`);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
}
|
}
|
||||||
async updateNote(_note: UpdateNote, _ctx: { uid: string }): Promise<Note> {
|
async createNote(note: CreateNote, ctx: { uid: string }): Promise<Note> {
|
||||||
throw new Error("Unimplemented");
|
this.#checkReadonly();
|
||||||
|
const nid = crypto.randomUUID();
|
||||||
|
return this.#db.rethrow(() =>
|
||||||
|
this.#db.multi(async (tx) => {
|
||||||
|
await tx.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, tx);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
async listNotesByAbodeId(_aid: string): Promise<PartialNote[]> {
|
async updateNote(note: UpdateNote, ctx: { uid: string }): Promise<Note> {
|
||||||
throw new Error("Unimplemented");
|
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(async (tx) => {
|
||||||
|
const { changes } = await tx.run(sql`
|
||||||
|
UPDATE "notes"
|
||||||
|
SET "updated_at" = NOW(), "updated_by" = ${{ uuid: ctx.uid }},
|
||||||
|
${joinSql(updates, sql`, `)}
|
||||||
|
WHERE "nid" = ${{ uuid: note.nid }}
|
||||||
|
`);
|
||||||
|
if (!changes) throw new NotFoundAbodeError();
|
||||||
|
return this.#getNoteById(note.nid, tx);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
async listNotesByUserId(_uid: string): Promise<PartialNote[]> {
|
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
|
||||||
throw new Error("Unimplemented");
|
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 {
|
export(options: ExportOptions = {}): NodeJS.ReadableStream {
|
||||||
@@ -529,10 +573,8 @@ export class PostgresInterface
|
|||||||
const source = this.name;
|
const source = this.name;
|
||||||
|
|
||||||
// Emission follows the FK-safe EXPORT_KIND_ORDER (part of the wire
|
// Emission follows the FK-safe EXPORT_KIND_ORDER (part of the wire
|
||||||
// contract; see ExportImport.ts). `note` has no loader: the postgres
|
// contract; each `load()` is a single query, run lazily and skipped once
|
||||||
// backend has no note CRUD yet, so a pg database can hold none. Each
|
// the destination aborts.
|
||||||
// `load()` is a single query, run lazily and skipped once the destination
|
|
||||||
// aborts.
|
|
||||||
const loaders: Partial<
|
const loaders: Partial<
|
||||||
Record<ExportKind, () => Promise<{ uid?: string; aid?: string }[]>>
|
Record<ExportKind, () => Promise<{ uid?: string; aid?: string }[]>>
|
||||||
> = {
|
> = {
|
||||||
@@ -540,6 +582,7 @@ export class PostgresInterface
|
|||||||
abode: () => selectAbodes(db),
|
abode: () => selectAbodes(db),
|
||||||
resident: () => selectResidents(db),
|
resident: () => selectResidents(db),
|
||||||
apikey: () => selectClientApikeys(db),
|
apikey: () => selectClientApikeys(db),
|
||||||
|
note: () => selectNotes(db),
|
||||||
};
|
};
|
||||||
|
|
||||||
async function* generate(): AsyncGenerator<string> {
|
async function* generate(): AsyncGenerator<string> {
|
||||||
@@ -596,8 +639,7 @@ export class PostgresInterface
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Postgres holds FK enforcement (unlike sqlite, which we toggle off): the
|
// Postgres holds FK enforcement (unlike sqlite, which we toggle off): the
|
||||||
// FK-safe insertion order — user, abode, resident, apikey — keeps a full
|
// FK-safe insertion order keeps a full dump valid.
|
||||||
// dump valid. `notes` are skipped entirely (unsupported on this backend).
|
|
||||||
// The transaction commits only if the whole stream is consumed cleanly; an
|
// The transaction commits only if the whole stream is consumed cleanly; an
|
||||||
// error/abort rolls it back via `multi`.
|
// error/abort rolls it back via `multi`.
|
||||||
try {
|
try {
|
||||||
@@ -622,7 +664,6 @@ export class PostgresInterface
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!isExportKind(parsed.kind)) continue;
|
if (!isExportKind(parsed.kind)) continue;
|
||||||
if (parsed.kind === "note") continue; // unsupported on postgres
|
|
||||||
if (!kindAllowed(filter, parsed.kind)) continue;
|
if (!kindAllowed(filter, parsed.kind)) continue;
|
||||||
const data = parsed.data as { uid?: string; aid?: string };
|
const data = parsed.data as { uid?: string; aid?: string };
|
||||||
if (!recordAllowed(filter, parsed.kind, data)) continue;
|
if (!recordAllowed(filter, parsed.kind, data)) continue;
|
||||||
@@ -714,8 +755,21 @@ export class PostgresInterface
|
|||||||
`);
|
`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "note":
|
case "note": {
|
||||||
break; // unsupported on postgres; skipped before reaching here
|
const n = data as Note;
|
||||||
|
await tx.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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import type { Abode } from "../types/Abode.js";
|
import type { Abode } from "../types/Abode.js";
|
||||||
import type { ApikeyPermissions, ClientApikey } from "../types/Apikey.js";
|
import type { ApikeyPermissions, ClientApikey } from "../types/Apikey.js";
|
||||||
import type { Resident, ResidentFlags } from "../types/Resident.js";
|
import type { Resident, ResidentFlags } from "../types/Resident.js";
|
||||||
|
import type {
|
||||||
|
Note,
|
||||||
|
NoteProperties,
|
||||||
|
PartialNote,
|
||||||
|
PartialNoteProperties,
|
||||||
|
} from "../types/Note.js";
|
||||||
import type { ClientUser, PartialUser, UserFlags } from "../types/User.js";
|
import type { ClientUser, PartialUser, UserFlags } from "../types/User.js";
|
||||||
|
|
||||||
export function pgToDate(d: Date | string): string {
|
export function pgToDate(d: Date | string): string {
|
||||||
@@ -135,3 +141,68 @@ export function pgToClientApikey(apikey: {
|
|||||||
expires_at: apikey.expires_at ? pgToDate(apikey.expires_at) : null,
|
expires_at: apikey.expires_at ? pgToDate(apikey.expires_at) : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validNoteTypes = new Set(["note"]);
|
||||||
|
|
||||||
|
function pgToNoteProperties(properties: unknown): NoteProperties {
|
||||||
|
if (
|
||||||
|
typeof properties !== "object" ||
|
||||||
|
!properties ||
|
||||||
|
Array.isArray(properties)
|
||||||
|
)
|
||||||
|
return {};
|
||||||
|
const value = properties as Record<string, unknown>;
|
||||||
|
return validNoteTypes.has(value.type as string)
|
||||||
|
? { type: value.type as "note" }
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function pgToPartialNoteProperties(properties: unknown): PartialNoteProperties {
|
||||||
|
return { type: pgToNoteProperties(properties).type ?? "note" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pgToNote(note: {
|
||||||
|
nid: string;
|
||||||
|
aid: string;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
properties: unknown;
|
||||||
|
created_at: Date | string;
|
||||||
|
created_by: string | null;
|
||||||
|
updated_at: Date | string;
|
||||||
|
updated_by: string | null;
|
||||||
|
}): Note {
|
||||||
|
return {
|
||||||
|
nid: note.nid,
|
||||||
|
aid: note.aid,
|
||||||
|
name: note.name,
|
||||||
|
content: note.content,
|
||||||
|
properties: pgToNoteProperties(note.properties),
|
||||||
|
created_at: pgToDate(note.created_at),
|
||||||
|
created_by: note.created_by,
|
||||||
|
updated_at: pgToDate(note.updated_at),
|
||||||
|
updated_by: note.updated_by,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pgToPartialNote(note: {
|
||||||
|
nid: string;
|
||||||
|
aid: string;
|
||||||
|
name: string;
|
||||||
|
properties: unknown;
|
||||||
|
created_at: Date | string;
|
||||||
|
created_by: string | null;
|
||||||
|
updated_at: Date | string;
|
||||||
|
updated_by: string | null;
|
||||||
|
}): PartialNote {
|
||||||
|
return {
|
||||||
|
nid: note.nid,
|
||||||
|
aid: note.aid,
|
||||||
|
name: note.name,
|
||||||
|
properties: pgToPartialNoteProperties(note.properties),
|
||||||
|
created_at: pgToDate(note.created_at),
|
||||||
|
created_by: note.created_by,
|
||||||
|
updated_at: pgToDate(note.updated_at),
|
||||||
|
updated_by: note.updated_by,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ import type { Abode } from "../types/Abode.js";
|
|||||||
import type { ClientApikey } from "../types/Apikey.js";
|
import type { ClientApikey } from "../types/Apikey.js";
|
||||||
import type { Resident } from "../types/Resident.js";
|
import type { Resident } from "../types/Resident.js";
|
||||||
import type { ClientUser } from "../types/User.js";
|
import type { ClientUser } from "../types/User.js";
|
||||||
|
import type { Note, PartialNote } from "../types/Note.js";
|
||||||
import {
|
import {
|
||||||
pgToAbode,
|
pgToAbode,
|
||||||
pgToClientApikey,
|
pgToClientApikey,
|
||||||
pgToClientUser,
|
pgToClientUser,
|
||||||
pgToResident,
|
pgToResident,
|
||||||
|
pgToNote,
|
||||||
|
pgToPartialNote,
|
||||||
} from "./cast.js";
|
} from "./cast.js";
|
||||||
import type { WrappedPgClient } from "./pool.js";
|
import type { WrappedPgClient } from "./pool.js";
|
||||||
import { sql, type SqlCode } from "./sql.js";
|
import { sql, type SqlCode } from "./sql.js";
|
||||||
@@ -137,3 +140,53 @@ export async function selectClientApikeys(
|
|||||||
);
|
);
|
||||||
return rows.map(pgToClientApikey);
|
return rows.map(pgToClientApikey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RawNote = {
|
||||||
|
nid: string;
|
||||||
|
aid: string;
|
||||||
|
name: string;
|
||||||
|
content: string;
|
||||||
|
properties: unknown;
|
||||||
|
created_at: Date;
|
||||||
|
created_by: string | null;
|
||||||
|
updated_at: Date;
|
||||||
|
updated_by: string | null;
|
||||||
|
};
|
||||||
|
const sqlNote = sql`
|
||||||
|
SELECT n."nid", n."aid", n."name", n."content", n."properties",
|
||||||
|
n."created_at", n."created_by", n."updated_at", n."updated_by"
|
||||||
|
FROM "notes" n
|
||||||
|
`;
|
||||||
|
|
||||||
|
type RawPartialNote = Omit<RawNote, "content">;
|
||||||
|
const sqlPartialNote = sql`
|
||||||
|
SELECT n."nid", n."aid", n."name", n."properties",
|
||||||
|
n."created_at", n."created_by", n."updated_at", n."updated_by"
|
||||||
|
FROM "notes" n
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function selectNote(
|
||||||
|
db: WrappedPgClient,
|
||||||
|
where: SqlCode,
|
||||||
|
): Promise<Note | null> {
|
||||||
|
const raw = await db.get<RawNote>(sql`${sqlNote} WHERE ${where}`);
|
||||||
|
return raw ? pgToNote(raw) : null;
|
||||||
|
}
|
||||||
|
export async function selectNotes(
|
||||||
|
db: WrappedPgClient,
|
||||||
|
where?: SqlCode,
|
||||||
|
): Promise<Note[]> {
|
||||||
|
const rows = await db.all<RawNote>(
|
||||||
|
where ? sql`${sqlNote} WHERE ${where}` : sqlNote,
|
||||||
|
);
|
||||||
|
return rows.map(pgToNote);
|
||||||
|
}
|
||||||
|
export async function selectPartialNotes(
|
||||||
|
db: WrappedPgClient,
|
||||||
|
where?: SqlCode,
|
||||||
|
): Promise<PartialNote[]> {
|
||||||
|
const rows = await db.all<RawPartialNote>(
|
||||||
|
where ? sql`${sqlPartialNote} WHERE ${where}` : sqlPartialNote,
|
||||||
|
);
|
||||||
|
return rows.map(pgToPartialNote);
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface Rows {
|
|||||||
abodes?: Record<string, unknown>[];
|
abodes?: Record<string, unknown>[];
|
||||||
residents?: Record<string, unknown>[];
|
residents?: Record<string, unknown>[];
|
||||||
apikeys?: Record<string, unknown>[];
|
apikeys?: Record<string, unknown>[];
|
||||||
|
notes?: Record<string, unknown>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
class FakePg implements WrappedPgClient {
|
class FakePg implements WrappedPgClient {
|
||||||
@@ -43,6 +44,7 @@ class FakePg implements WrappedPgClient {
|
|||||||
if (s.includes('FROM "residents"'))
|
if (s.includes('FROM "residents"'))
|
||||||
return (this.#rows.residents ?? []) as R[];
|
return (this.#rows.residents ?? []) as R[];
|
||||||
if (s.includes('FROM "apikeys"')) return (this.#rows.apikeys ?? []) as R[];
|
if (s.includes('FROM "apikeys"')) return (this.#rows.apikeys ?? []) as R[];
|
||||||
|
if (s.includes('FROM "notes"')) return (this.#rows.notes ?? []) as R[];
|
||||||
return [] as R[];
|
return [] as R[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +128,19 @@ function seededRows(): Rows {
|
|||||||
expires_at: null,
|
expires_at: null,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
notes: [
|
||||||
|
{
|
||||||
|
nid: "nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnn1",
|
||||||
|
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
|
||||||
|
name: "Note one",
|
||||||
|
content: "# Content",
|
||||||
|
properties: { type: "note" },
|
||||||
|
created_at: new Date(iso),
|
||||||
|
created_by: "11111111-1111-1111-1111-111111111111",
|
||||||
|
updated_at: new Date(iso),
|
||||||
|
updated_by: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +160,7 @@ function line(kind: string, data: unknown): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("postgres export", () => {
|
describe("postgres export", () => {
|
||||||
it("streams meta + the four supported kinds, never note", async () => {
|
it("streams meta and all supported kinds, including notes", async () => {
|
||||||
const db = new PostgresInterface(new FakePg(seededRows()));
|
const db = new PostgresInterface(new FakePg(seededRows()));
|
||||||
const lines = parseLines(await streamToString(db.export()));
|
const lines = parseLines(await streamToString(db.export()));
|
||||||
|
|
||||||
@@ -159,7 +174,7 @@ describe("postgres export", () => {
|
|||||||
assert.ok(kinds.has("abode"));
|
assert.ok(kinds.has("abode"));
|
||||||
assert.ok(kinds.has("resident"));
|
assert.ok(kinds.has("resident"));
|
||||||
assert.ok(kinds.has("apikey"));
|
assert.ok(kinds.has("apikey"));
|
||||||
assert.ok(!kinds.has("note"), "note is unsupported on postgres");
|
assert.ok(kinds.has("note"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("emits record kinds grouped in FK-safe EXPORT_KIND_ORDER", async () => {
|
it("emits record kinds grouped in FK-safe EXPORT_KIND_ORDER", async () => {
|
||||||
@@ -204,7 +219,7 @@ describe("postgres export", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("postgres import", () => {
|
describe("postgres import", () => {
|
||||||
it("dispatches inserts per kind, skips note, and commits", async () => {
|
it("dispatches inserts per kind, including note, and commits", async () => {
|
||||||
const fake = new FakePg();
|
const fake = new FakePg();
|
||||||
const db = new PostgresInterface(fake);
|
const db = new PostgresInterface(fake);
|
||||||
const source = Readable.from([
|
const source = Readable.from([
|
||||||
@@ -233,7 +248,6 @@ describe("postgres import", () => {
|
|||||||
created_at: iso,
|
created_at: iso,
|
||||||
expires_at: null,
|
expires_at: null,
|
||||||
}),
|
}),
|
||||||
// note lines are silently skipped on postgres
|
|
||||||
line("note", {
|
line("note", {
|
||||||
nid: "nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnn1",
|
nid: "nnnnnnnn-nnnn-nnnn-nnnn-nnnnnnnnnnn1",
|
||||||
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
|
aid: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1",
|
||||||
@@ -248,14 +262,14 @@ describe("postgres import", () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const result = await db.import(source);
|
const result = await db.import(source);
|
||||||
assert.deepEqual(result.counts, { user: 1, abode: 1, apikey: 1 });
|
assert.deepEqual(result.counts, { user: 1, abode: 1, apikey: 1, note: 1 });
|
||||||
assert.ok(fake.committed);
|
assert.ok(fake.committed);
|
||||||
assert.deepEqual(fake.inserts.map((i) => i.table).sort(), [
|
assert.deepEqual(fake.inserts.map((i) => i.table).sort(), [
|
||||||
"abodes",
|
"abodes",
|
||||||
"apikeys",
|
"apikeys",
|
||||||
|
"notes",
|
||||||
"users",
|
"users",
|
||||||
]);
|
]);
|
||||||
assert.ok(!fake.inserts.some((i) => i.table === "notes"), "no note insert");
|
|
||||||
// apikey gets a freshly-minted token (never exported)
|
// apikey gets a freshly-minted token (never exported)
|
||||||
const apikeyInsert = fake.inserts.find((i) => i.table === "apikeys")!;
|
const apikeyInsert = fake.inserts.find((i) => i.table === "apikeys")!;
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|||||||
Reference in New Issue
Block a user