feat(postgres): implement notes CRUD and export/import #16
@@ -35,6 +35,9 @@ import {
|
||||
selectClientUsers,
|
||||
selectResident,
|
||||
selectResidents,
|
||||
selectNote,
|
||||
selectNotes,
|
||||
selectPartialNotes,
|
||||
} from "./query.js";
|
||||
import type {
|
||||
CreateNote,
|
||||
@@ -502,25 +505,66 @@ 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 +573,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 +582,7 @@ export class PostgresInterface
|
||||
abode: () => selectAbodes(db),
|
||||
resident: () => selectResidents(db),
|
||||
apikey: () => selectClientApikeys(db),
|
||||
note: () => selectNotes(db),
|
||||
};
|
||||
|
||||
async function* generate(): AsyncGenerator<string> {
|
||||
@@ -596,8 +639,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 +664,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 +755,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
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 +141,68 @@ 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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface Rows {
|
||||
abodes?: Record<string, unknown>[];
|
||||
residents?: Record<string, unknown>[];
|
||||
apikeys?: Record<string, unknown>[];
|
||||
notes?: Record<string, unknown>[];
|
||||
}
|
||||
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user