feat(db): progress, notes, attempts, library, jobs repositories
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
738d813fb8
commit
4636273d55
@@ -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";
|
||||
|
||||
@@ -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<Attempt | null> {
|
||||
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<void> {
|
||||
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})`;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<Pick<JobRecord, "status" | "progress" | "message" | "result" | "error">>;
|
||||
|
||||
export function jobsRepo(db: Db) {
|
||||
const get = async (id: string): Promise<JobRecord | null> => {
|
||||
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<string> {
|
||||
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<JobRecord | null> {
|
||||
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<void> {
|
||||
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<JobRecord[]> {
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<string | null> {
|
||||
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<void> {
|
||||
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<Collection[]> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await db.sql.begin(async (tx) => {
|
||||
await tx`DELETE FROM collection_courses WHERE collection_id = ${id}`;
|
||||
await tx`DELETE FROM collections WHERE id = ${id}`;
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<Note | null> => {
|
||||
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<Note[]> {
|
||||
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<Note> {
|
||||
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<Note | null> {
|
||||
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<boolean> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<Record<string, LessonProgress>> {
|
||||
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<string, LessonProgress> = {};
|
||||
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<void> {
|
||||
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})`;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
Reference in New Issue
Block a user