feat(db): progress, notes, attempts, library, jobs repositories

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 02:59:09 +01:00
1 parent 738d813fb8
commit 4636273d55
7 files changed
+378

No files matched your search

+5
View File
@@ -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";
+36
View File
@@ -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})`;
}
});
},
};
}
+63
View File
@@ -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);
},
};
}
+49
View File
@@ -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}`;
});
},
};
}
+66
View File
@@ -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;
},
};
}
+34
View File
@@ -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})`;
}
});
},
};
}