feat: implement notes CRUD for SQLite and API backends

Fills in all 7 previously-unimplemented note methods in SqliteInterface
and ApiInterface, adds cast/query helpers for notes, and fixes the
apirouter (missing updatenote validator, two /user/ → /users/ typos that
were also bypassing auth middleware).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-01 00:23:07 +00:00
co-authored by Claude
parent 3b9a6bc85c
commit 360a475509
5 changed files with 199 additions and 19 deletions
+18 -9
View File
@@ -266,24 +266,33 @@ export class ApiInterface implements DbInterface {
}
async listNotes(): Promise<PartialNote[]> {
throw new Error("Unimplemented");
return this.#call("GET", "/notes");
}
async getNoteById(nid: string): Promise<Note> {
throw new Error("Unimplemented");
return this.#call("GET", "/notes/:nid", { params: { nid } });
}
async deleteNoteById(nid: string): Promise<void> {
throw new Error("Unimplemented");
this.#checkReadonly();
await this.#call("DELETE", "/notes/:nid", { params: { nid } });
}
async createNote(note: CreateNote): Promise<Note> {
throw new Error("Unimplemented");
async createNote(note: CreateNote, _ctx: { uid: string }): Promise<Note> {
this.#checkReadonly();
return this.#call("POST", "/abodes/:aid/notes", {
params: { aid: note.aid },
body: note,
});
}
async updateNote(note: UpdateNote): Promise<Note> {
throw new Error("Unimplemented");
async updateNote(note: UpdateNote, _ctx: { uid: string }): Promise<Note> {
this.#checkReadonly();
return this.#call("PATCH", "/notes/:nid", {
params: { nid: note.nid },
body: note,
});
}
async listNotesByAbodeId(aid: string): Promise<PartialNote[]> {
throw new Error("Unimplemented");
return this.#call("GET", "/abodes/:aid/notes", { params: { aid } });
}
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
throw new Error("Unimplemented");
return this.#call("GET", "/users/:uid/notes", { params: { uid } });
}
}
+56 -7
View File
@@ -33,6 +33,8 @@ import {
selectClientApikeys,
selectClientUser,
selectClientUsers,
selectNote,
selectPartialNotes,
selectResident,
selectResidents,
} from "./query.js";
@@ -458,24 +460,71 @@ export class SqliteInterface implements BackendDbInterface {
}
async listNotes(): Promise<PartialNote[]> {
throw new Error("Unimplemented");
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> {
throw new Error("Unimplemented");
return this.#getNoteById(nid);
}
async deleteNoteById(nid: string): Promise<void> {
throw new Error("Unimplemented");
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> {
throw new Error("Unimplemented");
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> {
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(() => {
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[]> {
throw new Error("Unimplemented");
return selectPartialNotes(this.#db, sql`n."aid" = ${{ uuid: aid }}`);
}
async listNotesByUserId(uid: string): Promise<PartialNote[]> {
throw new Error("Unimplemented");
return selectPartialNotes(this.#db, sql`n."created_by" = ${{ uuid: uid }}`);
}
}
+75
View File
@@ -1,5 +1,12 @@
import type { Abode } from "../types/Abode.js";
import type { ApikeyPermissions, ClientApikey } from "../types/Apikey.js";
import type {
Note,
NoteProperties,
NoteType,
PartialNote,
PartialNoteProperties,
} from "../types/Note.js";
import type { Resident, ResidentFlags } from "../types/Resident.js";
import type { ClientUser, PartialUser, UserFlags } from "../types/User.js";
@@ -160,3 +167,71 @@ export function sqliteToClientApikey(apikey: {
expires_at: apikey.expires_at ? sqliteToDate(apikey.expires_at) : null,
};
}
const validNoteTypes = new Set<NoteType>(["note"]);
export function sqliteToNoteProperties(props: string): NoteProperties {
const parsed = JSON.parse(props);
const out: NoteProperties = {};
if (
typeof parsed === "object" &&
parsed &&
!Array.isArray(parsed) &&
validNoteTypes.has(parsed.type)
) {
out.type = parsed.type;
}
return out;
}
export function sqliteToPartialNoteProperties(
props: string
): PartialNoteProperties {
const base = sqliteToNoteProperties(props);
return { type: base.type ?? "note" };
}
export function sqliteToNote(note: {
nid: Buffer | Uint8Array;
aid: Buffer | Uint8Array;
name: string;
content: string;
properties: string;
created_at: string;
created_by: Buffer | Uint8Array | null;
updated_at: string;
updated_by: Buffer | Uint8Array | null;
}): Note {
return {
nid: sqliteToUuid(note.nid),
aid: sqliteToUuid(note.aid),
name: note.name,
content: note.content,
properties: sqliteToNoteProperties(note.properties),
created_at: sqliteToDate(note.created_at),
created_by: note.created_by ? sqliteToUuid(note.created_by) : null,
updated_at: sqliteToDate(note.updated_at),
updated_by: note.updated_by ? sqliteToUuid(note.updated_by) : null,
};
}
export function sqliteToPartialNote(note: {
nid: Buffer | Uint8Array;
aid: Buffer | Uint8Array;
name: string;
properties: string;
created_at: string;
created_by: Buffer | Uint8Array | null;
updated_at: string;
updated_by: Buffer | Uint8Array | null;
}): PartialNote {
return {
nid: sqliteToUuid(note.nid),
aid: sqliteToUuid(note.aid),
name: note.name,
properties: sqliteToPartialNoteProperties(note.properties),
created_at: sqliteToDate(note.created_at),
created_by: note.created_by ? sqliteToUuid(note.created_by) : null,
updated_at: sqliteToDate(note.updated_at),
updated_by: note.updated_by ? sqliteToUuid(note.updated_by) : null,
};
}
+46
View File
@@ -1,11 +1,14 @@
import type { Abode } from "../types/Abode.js";
import type { ClientApikey } from "../types/Apikey.js";
import type { Note, PartialNote } from "../types/Note.js";
import type { Resident } from "../types/Resident.js";
import type { ClientUser } from "../types/User.js";
import {
sqliteToAbode,
sqliteToClientApikey,
sqliteToClientUser,
sqliteToNote,
sqliteToPartialNote,
sqliteToResident,
} from "./cast.js";
import type { WrappedDb } from "./impl/types.js";
@@ -122,3 +125,46 @@ export function selectClientApikeys(
);
return rawApikeys.map(sqliteToClientApikey);
}
type RawNote = {
nid: Buffer | Uint8Array;
aid: Buffer | Uint8Array;
name: string;
content: string;
properties: string;
created_at: string;
created_by: Buffer | Uint8Array | null;
updated_at: string;
updated_by: Buffer | Uint8Array | null;
};
const sqlNote = sql`
SELECT n."nid", n."aid", n."name", n."content", json(n."properties") AS "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", json(n."properties") AS "properties",
n."created_at", n."created_by", n."updated_at", n."updated_by"
FROM "notes" n
`;
export function selectNote(db: WrappedDb, where: SqlCode): Note | null {
const raw = db.get<RawNote>(sql`${sqlNote} WHERE ${where}`);
if (raw) return sqliteToNote(raw);
return null;
}
export function selectNotes(db: WrappedDb, where?: SqlCode): Note[] {
const raws = db.all<RawNote>(where ? sql`${sqlNote} WHERE ${where}` : sqlNote);
return raws.map(sqliteToNote);
}
export function selectPartialNotes(
db: WrappedDb,
where?: SqlCode
): PartialNote[] {
const raws = db.all<RawPartialNote>(
where ? sql`${sqlPartialNote} WHERE ${where}` : sqlPartialNote
);
return raws.map(sqliteToPartialNote);
}
+4 -3
View File
@@ -11,6 +11,7 @@ import {
loginuser,
updateabode,
updateresident,
updatenote,
updateuser,
} from "../schema/validators.js";
import { authenticate } from "./middleware/authenticate.js";
@@ -92,11 +93,11 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
await db.deleteApikeyById(ctx.params.kid);
ctx.status = 204;
});
router.post("/user/:uid/auth/clear-sessions", async (ctx) => {
router.post("/users/:uid/auth/clear-sessions", async (ctx) => {
await db.deleteSessionsByUser(ctx.params.uid);
ctx.status = 204;
});
router.get("/user/:uid/notes", async (ctx) => {
router.get("/users/:uid/notes", async (ctx) => {
ctx.body = await db.listNotesByUserId(ctx.params.uid);
});
@@ -186,7 +187,7 @@ export function apirouter(db: BackendDbInterface): KoaRouter {
});
router.patch(
"/notes/:nid",
jsonBody({ includeParams: ["nid"] }),
jsonBody({ validate: updatenote, includeParams: ["nid"] }),
async (ctx) => {
ctx.body = await db.updateNote(ctx.request.body, { uid: ctx.user!.uid });
}