From a0a436c4b8ab66a38198076cc36734a285d2aa63 Mon Sep 17 00:00:00 2001 From: Codinget Date: Thu, 23 Jul 2026 18:49:23 +0000 Subject: [PATCH] feat(postgres): implement notes CRUD and export import Co-Authored-By: gpt-5.6-luna --- src/db/postgres/PostgresInterface.ts | 101 +++++++++++++++---- src/db/postgres/cast.ts | 64 ++++++++++++ src/db/postgres/query.ts | 53 ++++++++++ test/backends/postgres/export-import.test.ts | 26 +++-- 4 files changed, 216 insertions(+), 28 deletions(-) diff --git a/src/db/postgres/PostgresInterface.ts b/src/db/postgres/PostgresInterface.ts index 3ed448f..75c87d7 100644 --- a/src/db/postgres/PostgresInterface.ts +++ b/src/db/postgres/PostgresInterface.ts @@ -35,6 +35,9 @@ import { selectClientUsers, selectResident, selectResidents, + selectNote, + selectNotes, + selectPartialNotes, } from "./query.js"; import type { CreateNote, @@ -502,25 +505,69 @@ export class PostgresInterface } async listNotes(): Promise { - throw new Error("Unimplemented"); + return selectPartialNotes(this.#db); } - async getNoteById(_nid: string): Promise { - throw new Error("Unimplemented"); + async #getNoteById(nid: string, db: WrappedPgClient): Promise { + const note = await selectNote(db, sql`n."nid" = ${{ uuid: nid }}`); + if (!note) throw new NotFoundAbodeError(); + return note; } - async deleteNoteById(_nid: string): Promise { - throw new Error("Unimplemented"); + async getNoteById(nid: string): Promise { + return this.#getNoteById(nid, this.#db); } - async createNote(_note: CreateNote, _ctx: { uid: string }): Promise { - throw new Error("Unimplemented"); + async deleteNoteById(nid: string): Promise { + 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 { - throw new Error("Unimplemented"); + async createNote(note: CreateNote, ctx: { uid: string }): Promise { + 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 { - throw new Error("Unimplemented"); + async updateNote(note: UpdateNote, ctx: { uid: string }): Promise { + 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 { - throw new Error("Unimplemented"); + async listNotesByAbodeId(aid: string): Promise { + return selectPartialNotes(this.#db, sql`n."aid" = ${{ uuid: aid }}`); + } + async listNotesByUserId(uid: string): Promise { + return selectPartialNotes( + this.#db, + sql`n."created_by" = ${{ uuid: uid }}`, + ); } export(options: ExportOptions = {}): NodeJS.ReadableStream { @@ -529,10 +576,8 @@ export class PostgresInterface const source = this.name; // Emission follows the FK-safe EXPORT_KIND_ORDER (part of the wire - // contract; see ExportImport.ts). `note` has no loader: the postgres - // backend has no note CRUD yet, so a pg database can hold none. Each - // `load()` is a single query, run lazily and skipped once the destination - // aborts. + // contract; each `load()` is a single query, run lazily and skipped once + // the destination aborts. const loaders: Partial< Record Promise<{ uid?: string; aid?: string }[]>> > = { @@ -540,6 +585,7 @@ export class PostgresInterface abode: () => selectAbodes(db), resident: () => selectResidents(db), apikey: () => selectClientApikeys(db), + note: () => selectNotes(db), }; async function* generate(): AsyncGenerator { @@ -596,8 +642,7 @@ export class PostgresInterface }); // Postgres holds FK enforcement (unlike sqlite, which we toggle off): the - // FK-safe insertion order — user, abode, resident, apikey — keeps a full - // dump valid. `notes` are skipped entirely (unsupported on this backend). + // FK-safe insertion order keeps a full dump valid. // The transaction commits only if the whole stream is consumed cleanly; an // error/abort rolls it back via `multi`. try { @@ -622,7 +667,6 @@ export class PostgresInterface ); } if (!isExportKind(parsed.kind)) continue; - if (parsed.kind === "note") continue; // unsupported on postgres if (!kindAllowed(filter, parsed.kind)) continue; const data = parsed.data as { uid?: string; aid?: string }; if (!recordAllowed(filter, parsed.kind, data)) continue; @@ -714,8 +758,21 @@ export class PostgresInterface `); break; } - case "note": - break; // unsupported on postgres; skipped before reaching here + case "note": { + 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; + } } } } diff --git a/src/db/postgres/cast.ts b/src/db/postgres/cast.ts index ffe1195..20ae6ea 100644 --- a/src/db/postgres/cast.ts +++ b/src/db/postgres/cast.ts @@ -1,6 +1,7 @@ import type { Abode } from "../types/Abode.js"; import type { ApikeyPermissions, ClientApikey } from "../types/Apikey.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"; export function pgToDate(d: Date | string): string { @@ -135,3 +136,66 @@ export function pgToClientApikey(apikey: { 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; + 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, + }; +} diff --git a/src/db/postgres/query.ts b/src/db/postgres/query.ts index d74f4d8..6f0381a 100644 --- a/src/db/postgres/query.ts +++ b/src/db/postgres/query.ts @@ -2,11 +2,14 @@ import type { Abode } from "../types/Abode.js"; import type { ClientApikey } from "../types/Apikey.js"; import type { Resident } from "../types/Resident.js"; import type { ClientUser } from "../types/User.js"; +import type { Note, PartialNote } from "../types/Note.js"; import { pgToAbode, pgToClientApikey, pgToClientUser, pgToResident, + pgToNote, + pgToPartialNote, } from "./cast.js"; import type { WrappedPgClient } from "./pool.js"; import { sql, type SqlCode } from "./sql.js"; @@ -137,3 +140,53 @@ export async function selectClientApikeys( ); 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; +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 { + const raw = await db.get(sql`${sqlNote} WHERE ${where}`); + return raw ? pgToNote(raw) : null; +} +export async function selectNotes( + db: WrappedPgClient, + where?: SqlCode, +): Promise { + const rows = await db.all( + where ? sql`${sqlNote} WHERE ${where}` : sqlNote, + ); + return rows.map(pgToNote); +} +export async function selectPartialNotes( + db: WrappedPgClient, + where?: SqlCode, +): Promise { + const rows = await db.all( + where ? sql`${sqlPartialNote} WHERE ${where}` : sqlPartialNote, + ); + return rows.map(pgToPartialNote); +} diff --git a/test/backends/postgres/export-import.test.ts b/test/backends/postgres/export-import.test.ts index 214386f..2f115c6 100644 --- a/test/backends/postgres/export-import.test.ts +++ b/test/backends/postgres/export-import.test.ts @@ -21,6 +21,7 @@ interface Rows { abodes?: Record[]; residents?: Record[]; apikeys?: Record[]; + notes?: Record[]; } class FakePg implements WrappedPgClient { @@ -43,6 +44,7 @@ class FakePg implements WrappedPgClient { if (s.includes('FROM "residents"')) return (this.#rows.residents ?? []) 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[]; } @@ -126,6 +128,19 @@ function seededRows(): Rows { 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", () => { - 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 lines = parseLines(await streamToString(db.export())); @@ -159,7 +174,7 @@ describe("postgres export", () => { 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"); + assert.ok(kinds.has("note")); }); it("emits record kinds grouped in FK-safe EXPORT_KIND_ORDER", async () => { @@ -204,7 +219,7 @@ describe("postgres export", () => { }); 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 db = new PostgresInterface(fake); const source = Readable.from([ @@ -233,7 +248,6 @@ describe("postgres import", () => { 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", @@ -248,14 +262,14 @@ describe("postgres import", () => { ]); 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.deepEqual(fake.inserts.map((i) => i.table).sort(), [ "abodes", "apikeys", + "notes", "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(