feat(db): progress, notes, attempts, library, jobs repositories
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
738d813fb8
commit
4636273d55
7 files changed
+378
No files matched your search
@@ -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})`;
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user