From 4636273d5572327aef3a5fc52327f2ddd19842f5 Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 02:59:09 +0100 Subject: [PATCH] feat(db): progress, notes, attempts, library, jobs repositories Co-Authored-By: Claude Opus 5.5 --- packages/db/src/index.ts | 5 ++ packages/db/src/repos/attempts.ts | 36 +++++++++ packages/db/src/repos/jobs.ts | 63 +++++++++++++++ packages/db/src/repos/library.ts | 49 ++++++++++++ packages/db/src/repos/notes.ts | 66 ++++++++++++++++ packages/db/src/repos/progress.ts | 34 ++++++++ packages/db/test/repos.test.ts | 125 ++++++++++++++++++++++++++++++ 7 files changed, 378 insertions(+) create mode 100644 packages/db/src/repos/attempts.ts create mode 100644 packages/db/src/repos/jobs.ts create mode 100644 packages/db/src/repos/library.ts create mode 100644 packages/db/src/repos/notes.ts create mode 100644 packages/db/src/repos/progress.ts create mode 100644 packages/db/test/repos.test.ts diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 3aaa0f0..7dba514 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,2 +1,7 @@ export { connect, type Db } from "./connect"; export { migrate } from "./migrate"; +export { progressRepo, type LessonProgress, type ProgressState } from "./repos/progress"; +export { notesRepo, type Note, type NoteInput } from "./repos/notes"; +export { attemptsRepo, type Attempt } from "./repos/attempts"; +export { libraryRepo, type Collection } from "./repos/library"; +export { jobsRepo, type JobPatch } from "./repos/jobs"; diff --git a/packages/db/src/repos/attempts.ts b/packages/db/src/repos/attempts.ts new file mode 100644 index 0000000..843e1c2 --- /dev/null +++ b/packages/db/src/repos/attempts.ts @@ -0,0 +1,36 @@ +import type { Db } from "../connect"; + +export type Attempt = { + userId: string; courseId: string; lessonId: string; track: string; + code: string; result: unknown; passed: boolean; updatedAt: string; +}; + +export function attemptsRepo(db: Db) { + return { + async get(userId: string, courseId: string, lessonId: string, track: string): Promise { + const rows = await db.sql`SELECT * FROM exercise_attempts WHERE user_id = ${userId} AND course_id = ${courseId} AND lesson_id = ${lessonId} AND track = ${track}`; + if (rows.length === 0) return null; + const r = rows[0]; + return { + userId: r.user_id, courseId: r.course_id, lessonId: r.lesson_id, track: r.track, + code: r.code, result: r.result_json === null ? null : JSON.parse(r.result_json), + passed: Number(r.passed) === 1, updatedAt: r.updated_at, + }; + }, + + async save(a: Attempt): Promise { + const resultJson = a.result === null || a.result === undefined ? null : JSON.stringify(a.result); + const passed = a.passed ? 1 : 0; + await db.sql.begin(async (tx) => { + const existing = await tx`SELECT 1 AS x FROM exercise_attempts WHERE user_id = ${a.userId} AND course_id = ${a.courseId} AND lesson_id = ${a.lessonId} AND track = ${a.track}`; + if (existing.length > 0) { + await tx`UPDATE exercise_attempts SET code = ${a.code}, result_json = ${resultJson}, passed = ${passed}, updated_at = ${a.updatedAt} + WHERE user_id = ${a.userId} AND course_id = ${a.courseId} AND lesson_id = ${a.lessonId} AND track = ${a.track}`; + } else { + await tx`INSERT INTO exercise_attempts (user_id, course_id, lesson_id, track, code, result_json, passed, updated_at) + VALUES (${a.userId}, ${a.courseId}, ${a.lessonId}, ${a.track}, ${a.code}, ${resultJson}, ${passed}, ${a.updatedAt})`; + } + }); + }, + }; +} diff --git a/packages/db/src/repos/jobs.ts b/packages/db/src/repos/jobs.ts new file mode 100644 index 0000000..0713e72 --- /dev/null +++ b/packages/db/src/repos/jobs.ts @@ -0,0 +1,63 @@ +import type { JobRecord } from "@stc/contracts"; +import type { Db } from "../connect"; + +// biome-ignore lint/suspicious/noExplicitAny: raw driver row +const toJob = (r: any): JobRecord => ({ + id: r.id, kind: r.kind, status: r.status, progress: Number(r.progress), message: r.message, + payload: JSON.parse(r.payload_json), result: r.result_json === null ? null : JSON.parse(r.result_json), + error: r.error, createdAt: r.created_at, updatedAt: r.updated_at, +}); + +export type JobPatch = Partial>; + +export function jobsRepo(db: Db) { + const get = async (id: string): Promise => { + const rows = await db.sql`SELECT * FROM jobs WHERE id = ${id}`; + return rows.length ? toJob(rows[0]) : null; + }; + + return { + get, + + async insert(kind: string, payload: unknown): Promise { + const id = Bun.randomUUIDv7(); + const now = new Date().toISOString(); + await db.sql`INSERT INTO jobs (id, kind, status, progress, message, payload_json, result_json, error, created_at, updated_at) + VALUES (${id}, ${kind}, 'queued', 0, NULL, ${JSON.stringify(payload ?? null)}, NULL, NULL, ${now}, ${now})`; + return id; + }, + + /** + * Picks the oldest queued job of one of `kinds` and flips it to running with a guarded + * `UPDATE ... WHERE id = ? AND status = 'queued'`. Of any concurrent claimers of the same + * row exactly one sees an affected count of 1 (Postgres re-checks the predicate after + * waiting on the row lock); losers pick the next candidate. + */ + async claimNext(kinds: string[]): Promise { + if (kinds.length === 0) return null; + for (;;) { + const next = await db.sql`SELECT id FROM jobs WHERE status = 'queued' AND kind IN ${db.sql(kinds)} ORDER BY created_at, id LIMIT 1`; + if (next.length === 0) return null; + const id: string = next[0].id; + const res = await db.sql`UPDATE jobs SET status = 'running', updated_at = ${new Date().toISOString()} WHERE id = ${id} AND status = 'queued'`; + if (Number(res.count) > 0) return get(id); + } + }, + + async update(id: string, patch: JobPatch): Promise { + const cur = await get(id); + if (!cur) return; + const next = { ...cur, ...patch }; + await db.sql`UPDATE jobs SET status = ${next.status}, progress = ${next.progress}, message = ${next.message ?? null}, + result_json = ${next.result === null || next.result === undefined ? null : JSON.stringify(next.result)}, + error = ${next.error ?? null}, updated_at = ${new Date().toISOString()} WHERE id = ${id}`; + }, + + async list(kind?: string): Promise { + const rows = kind === undefined + ? await db.sql`SELECT * FROM jobs ORDER BY created_at, id` + : await db.sql`SELECT * FROM jobs WHERE kind = ${kind} ORDER BY created_at, id`; + return rows.map(toJob); + }, + }; +} diff --git a/packages/db/src/repos/library.ts b/packages/db/src/repos/library.ts new file mode 100644 index 0000000..30134b1 --- /dev/null +++ b/packages/db/src/repos/library.ts @@ -0,0 +1,49 @@ +import type { Db } from "../connect"; + +export type Collection = { id: string; title: string; sort: number; courseIds: string[] }; +const FEATURED = "featured_course_id"; + +export function libraryRepo(db: Db) { + return { + async getFeatured(): Promise { + const rows = await db.sql`SELECT value FROM library_settings WHERE key = ${FEATURED}`; + return rows.length ? rows[0].value : null; + }, + + async setFeatured(courseId: string | null): Promise { + await db.sql.begin(async (tx) => { + await tx`DELETE FROM library_settings WHERE key = ${FEATURED}`; + if (courseId !== null) await tx`INSERT INTO library_settings (key, value) VALUES (${FEATURED}, ${courseId})`; + }); + }, + + async listCollections(): Promise { + const cols = await db.sql`SELECT id, title, sort FROM collections ORDER BY sort, title, id`; + const links = await db.sql`SELECT collection_id, course_id FROM collection_courses ORDER BY sort, course_id`; + return cols.map((c: { id: string; title: string; sort: number }) => ({ + id: c.id, title: c.title, sort: Number(c.sort), + courseIds: links.filter((l: { collection_id: string }) => l.collection_id === c.id).map((l: { course_id: string }) => l.course_id), + })); + }, + + async saveCollection(c: Collection): Promise { + await db.sql.begin(async (tx) => { + const existing = await tx`SELECT 1 AS x FROM collections WHERE id = ${c.id}`; + if (existing.length > 0) await tx`UPDATE collections SET title = ${c.title}, sort = ${c.sort} WHERE id = ${c.id}`; + else await tx`INSERT INTO collections (id, title, sort) VALUES (${c.id}, ${c.title}, ${c.sort})`; + await tx`DELETE FROM collection_courses WHERE collection_id = ${c.id}`; + let i = 0; + for (const courseId of new Set(c.courseIds)) { + await tx`INSERT INTO collection_courses (collection_id, course_id, sort) VALUES (${c.id}, ${courseId}, ${i++})`; + } + }); + }, + + async deleteCollection(id: string): Promise { + await db.sql.begin(async (tx) => { + await tx`DELETE FROM collection_courses WHERE collection_id = ${id}`; + await tx`DELETE FROM collections WHERE id = ${id}`; + }); + }, + }; +} diff --git a/packages/db/src/repos/notes.ts b/packages/db/src/repos/notes.ts new file mode 100644 index 0000000..875a2b7 --- /dev/null +++ b/packages/db/src/repos/notes.ts @@ -0,0 +1,66 @@ +import type { Db } from "../connect"; + +export type Note = { + id: string; userId: string; courseId: string; lessonId: string; + anchorKind: "none" | "video" | "slide"; anchorValue: string | null; deckId: string | null; + body: string; createdAt: string; updatedAt: string; +}; +export type NoteInput = { + userId: string; courseId: string; lessonId: string; + anchorKind: Note["anchorKind"]; anchorValue?: string | null; deckId?: string | null; body: string; +}; + +// biome-ignore lint/suspicious/noExplicitAny: raw driver row +const toNote = (r: any): Note => ({ + id: r.id, userId: r.user_id, courseId: r.course_id, lessonId: r.lesson_id, + anchorKind: r.anchor_kind, anchorValue: r.anchor_value, deckId: r.deck_id, + body: r.body, createdAt: r.created_at, updatedAt: r.updated_at, +}); + +const numeric = (v: string | null): number => { + const n = v === null ? NaN : Number(v); + return Number.isNaN(n) ? Number.POSITIVE_INFINITY : n; +}; + +export function notesRepo(db: Db) { + const getOne = async (id: string): Promise => { + const rows = await db.sql`SELECT * FROM notes WHERE id = ${id}`; + return rows.length ? toNote(rows[0]) : null; + }; + + return { + /** Ordered by anchor_kind, then anchor_value read numerically (so page 2 precedes page 10), then created_at. */ + async list(userId: string, courseId: string, lessonId?: string): Promise { + const rows = lessonId === undefined + ? await db.sql`SELECT * FROM notes WHERE user_id = ${userId} AND course_id = ${courseId}` + : await db.sql`SELECT * FROM notes WHERE user_id = ${userId} AND course_id = ${courseId} AND lesson_id = ${lessonId}`; + // Numeric ordering is done here: CAST of non-numeric text errors on Postgres but not SQLite. + return (rows.map(toNote) as Note[]).sort((a, b) => + (a.anchorKind < b.anchorKind ? -1 : a.anchorKind > b.anchorKind ? 1 : 0) || + numeric(a.anchorValue) - numeric(b.anchorValue) || + (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0) || + (a.id < b.id ? -1 : 1)); + }, + + async create(input: NoteInput): Promise { + const id = Bun.randomUUIDv7(); + const now = new Date().toISOString(); + await db.sql`INSERT INTO notes (id, user_id, course_id, lesson_id, anchor_kind, anchor_value, deck_id, body, created_at, updated_at) + VALUES (${id}, ${input.userId}, ${input.courseId}, ${input.lessonId}, ${input.anchorKind}, ${input.anchorValue ?? null}, ${input.deckId ?? null}, ${input.body}, ${now}, ${now})`; + return (await getOne(id))!; + }, + + async update(id: string, userId: string, body: string): Promise { + await db.sql`UPDATE notes SET body = ${body}, updated_at = ${new Date().toISOString()} WHERE id = ${id} AND user_id = ${userId}`; + const n = await getOne(id); + return n && n.userId === userId ? n : null; + }, + + async remove(id: string, userId: string): Promise { + const owned = await db.sql`SELECT 1 AS x FROM notes WHERE id = ${id} AND user_id = ${userId}`; + if (owned.length === 0) return false; + await db.sql`DELETE FROM notes WHERE id = ${id} AND user_id = ${userId}`; + return true; + }, + }; +} diff --git a/packages/db/src/repos/progress.ts b/packages/db/src/repos/progress.ts new file mode 100644 index 0000000..9472e30 --- /dev/null +++ b/packages/db/src/repos/progress.ts @@ -0,0 +1,34 @@ +import type { Db } from "../connect"; + +export type ProgressState = "not-started" | "in-progress" | "done"; +export type LessonProgress = { state: ProgressState; positionSeconds: number | null; updatedAt: string }; + +export function progressRepo(db: Db) { + return { + async get(userId: string, courseId: string): Promise> { + const rows = await db.sql`SELECT lesson_id, state, position_seconds, updated_at FROM progress WHERE user_id = ${userId} AND course_id = ${courseId}`; + const out: Record = {}; + for (const r of rows) { + out[r.lesson_id] = { + state: r.state, + positionSeconds: r.position_seconds === null ? null : Number(r.position_seconds), + updatedAt: r.updated_at, + }; + } + return out; + }, + + async set(userId: string, courseId: string, lessonId: string, state: ProgressState, positionSeconds?: number): Promise { + const now = new Date().toISOString(); + const pos = positionSeconds ?? null; + await db.sql.begin(async (tx) => { + const existing = await tx`SELECT 1 AS x FROM progress WHERE user_id = ${userId} AND course_id = ${courseId} AND lesson_id = ${lessonId}`; + if (existing.length > 0) { + await tx`UPDATE progress SET state = ${state}, position_seconds = ${pos}, updated_at = ${now} WHERE user_id = ${userId} AND course_id = ${courseId} AND lesson_id = ${lessonId}`; + } else { + await tx`INSERT INTO progress (user_id, course_id, lesson_id, state, position_seconds, updated_at) VALUES (${userId}, ${courseId}, ${lessonId}, ${state}, ${pos}, ${now})`; + } + }); + }, + }; +} diff --git a/packages/db/test/repos.test.ts b/packages/db/test/repos.test.ts new file mode 100644 index 0000000..8169fb9 --- /dev/null +++ b/packages/db/test/repos.test.ts @@ -0,0 +1,125 @@ +import { expect } from "bun:test"; +import { forEachDb } from "./helpers"; +import { attemptsRepo, jobsRepo, libraryRepo, notesRepo, progressRepo } from "../src"; + +forEachDb("progress upsert keeps one row", async (db) => { + const p = progressRepo(db); + await p.set("u", "c", "l1", "in-progress", 12.5); + await p.set("u", "c", "l1", "done"); + await p.set("u", "c", "l2", "in-progress", 3); + await p.set("other", "c", "l1", "done"); + const got = await p.get("u", "c"); + expect(Object.keys(got).sort()).toEqual(["l1", "l2"]); + expect(got.l1.state).toBe("done"); + expect(got.l1.positionSeconds).toBeNull(); + expect(got.l2.positionSeconds).toBe(3); + expect(typeof got.l1.updatedAt).toBe("string"); + expect((await db.sql`SELECT COUNT(*) AS n FROM progress WHERE user_id = 'u'`)[0].n.toString()).toBe("2"); +}); + +forEachDb("notes order puts slide page 2 before page 10", async (db) => { + const n = notesRepo(db); + const base = { userId: "u", courseId: "c", lessonId: "l" }; + await n.create({ ...base, anchorKind: "slide", anchorValue: "10", deckId: "d", body: "ten" }); + await n.create({ ...base, anchorKind: "slide", anchorValue: "2", deckId: "d", body: "two" }); + await n.create({ ...base, anchorKind: "none", body: "plain" }); + await n.create({ ...base, anchorKind: "video", anchorValue: "90.5", body: "video" }); + await n.create({ ...base, lessonId: "other", anchorKind: "none", body: "elsewhere" }); + const all = await n.list("u", "c", "l"); + expect(all.map((x) => x.body)).toEqual(["plain", "two", "ten", "video"]); + expect(all[1].deckId).toBe("d"); + expect((await n.list("u", "c")).length).toBe(5); +}); + +forEachDb("another user cannot update or delete a note", async (db) => { + const n = notesRepo(db); + const note = await n.create({ userId: "u", courseId: "c", lessonId: "l", anchorKind: "none", body: "mine" }); + expect(await n.update(note.id, "intruder", "hacked")).toBeNull(); + expect(await n.remove(note.id, "intruder")).toBe(false); + expect((await n.list("u", "c", "l"))[0].body).toBe("mine"); + expect((await n.update(note.id, "u", "edited"))?.body).toBe("edited"); + expect(await n.remove(note.id, "u")).toBe(true); + expect(await n.list("u", "c", "l")).toEqual([]); +}); + +forEachDb("attempts round-trip", async (db) => { + const a = attemptsRepo(db); + expect(await a.get("u", "c", "l", "py")).toBeNull(); + const att = { userId: "u", courseId: "c", lessonId: "l", track: "py", code: "print(1)", result: { out: "1", tests: [true] }, passed: true, updatedAt: "2026-01-01T00:00:00.000Z" }; + await a.save(att); + expect(await a.get("u", "c", "l", "py")).toEqual(att); + await a.save({ ...att, code: "x", result: null, passed: false }); + const again = await a.get("u", "c", "l", "py"); + expect(again).toMatchObject({ code: "x", result: null, passed: false }); +}); + +forEachDb("featured course and collections keep their order", async (db) => { + const l = libraryRepo(db); + expect(await l.getFeatured()).toBeNull(); + await l.setFeatured("a"); + await l.setFeatured("b"); + expect(await l.getFeatured()).toBe("b"); + await l.setFeatured(null); + expect(await l.getFeatured()).toBeNull(); + + await l.saveCollection({ id: "c2", title: "Second", sort: 2, courseIds: ["z", "y"] }); + await l.saveCollection({ id: "c1", title: "First", sort: 1, courseIds: ["b", "a", "c"] }); + expect(await l.listCollections()).toEqual([ + { id: "c1", title: "First", sort: 1, courseIds: ["b", "a", "c"] }, + { id: "c2", title: "Second", sort: 2, courseIds: ["z", "y"] }, + ]); + await l.saveCollection({ id: "c1", title: "First!", sort: 3, courseIds: ["c", "a"] }); + expect((await l.listCollections()).map((c) => [c.id, c.courseIds])).toEqual([["c2", ["z", "y"]], ["c1", ["c", "a"]]]); + await l.deleteCollection("c2"); + expect((await l.listCollections()).map((c) => c.id)).toEqual(["c1"]); + expect((await db.sql`SELECT COUNT(*) AS n FROM collection_courses`)[0].n.toString()).toBe("2"); +}); + +forEachDb("jobs lifecycle", async (db) => { + const j = jobsRepo(db); + const id = await j.insert("import", { url: "x" }); + const got = await j.get(id); + expect(got).toMatchObject({ kind: "import", status: "queued", progress: 0, payload: { url: "x" }, result: null, error: null, message: null }); + await j.update(id, { progress: 0.5, message: "half" }); + await j.update(id, { status: "done", result: { ok: 1 } }); + expect(await j.get(id)).toMatchObject({ status: "done", progress: 0.5, message: "half", result: { ok: 1 } }); + await j.insert("other", {}); + expect((await j.list()).length).toBe(2); + expect((await j.list("import")).map((x) => x.id)).toEqual([id]); + expect(await j.get("missing")).toBeNull(); +}); + +forEachDb("claimNext returns null when nothing is queued", async (db) => { + const j = jobsRepo(db); + expect(await j.claimNext(["import"])).toBeNull(); + await j.insert("other", {}); + expect(await j.claimNext(["import"])).toBeNull(); + expect(await j.claimNext([])).toBeNull(); +}); + +forEachDb("claimNext claims oldest first and marks running", async (db) => { + const j = jobsRepo(db); + const a = await j.insert("import", 1); + await Bun.sleep(2); + const b = await j.insert("import", 2); + expect((await j.claimNext(["import"]))).toMatchObject({ id: a, status: "running" }); + expect((await j.claimNext(["import"]))?.id).toBe(b); + expect(await j.claimNext(["import"])).toBeNull(); +}); + +forEachDb("two concurrent claimNext calls never claim the same job", async (db) => { + const j = jobsRepo(db); + const ids = [await j.insert("k", 1), await j.insert("k", 2)]; + for (let round = 0; round < 1; round++) { + const got = await Promise.all([j.claimNext(["k"]), j.claimNext(["k"])]); + expect(got.every((x) => x !== null)).toBe(true); + expect(new Set(got.map((x) => x!.id)).size).toBe(2); + expect([...got.map((x) => x!.id)].sort()).toEqual([...ids].sort()); + } + // single job, two claimers: exactly one wins + const solo = jobsRepo(db); + await db.sql`DELETE FROM jobs`; + const only = await solo.insert("k", 3); + const res = await Promise.all([solo.claimNext(["k"]), solo.claimNext(["k"])]); + expect(res.filter((x) => x !== null).map((x) => x!.id)).toEqual([only]); +});