feat(postgres): implement notes CRUD and export import
CI / lint (pull_request) Successful in 32s
CI / format (pull_request) Failing after 32s
CI / install-and-build (pull_request) Successful in 1m0s
CI / typecheck-tests (pull_request) Successful in 36s
CI / typecheck-source (pull_request) Successful in 37s
CI / test (pull_request) Successful in 41s

Co-Authored-By: gpt-5.6-luna <noreply@openai.com>
This commit is contained in:
2026-07-23 18:49:23 +00:00
co-authored by Codex
parent d7e31dfce9
commit a0a436c4b8
4 changed files with 216 additions and 28 deletions
+79 -22
View File
@@ -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<PartialNote[]> {
throw new Error("Unimplemented");
return selectPartialNotes(this.#db);
}
async getNoteById(_nid: string): Promise<Note> {
throw new Error("Unimplemented");
async #getNoteById(nid: string, db: WrappedPgClient): Promise<Note> {
const note = await selectNote(db, sql`n."nid" = ${{ uuid: nid }}`);
if (!note) throw new NotFoundAbodeError();
return note;
}
async deleteNoteById(_nid: string): Promise<void> {
throw new Error("Unimplemented");
async getNoteById(nid: string): Promise<Note> {
return this.#getNoteById(nid, this.#db);
}
async createNote(_note: CreateNote, _ctx: { uid: string }): Promise<Note> {
throw new Error("Unimplemented");
async deleteNoteById(nid: string): Promise<void> {
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> {
throw new Error("Unimplemented");
async createNote(note: CreateNote, ctx: { uid: string }): Promise<Note> {
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[]> {
throw new Error("Unimplemented");
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(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[]> {
throw new Error("Unimplemented");
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 {
@@ -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<ExportKind, () => 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<string> {
@@ -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;
}
}
}
}
+64
View File
@@ -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<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,
};
}
+53
View File
@@ -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<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);
}