The reworked bundle's PORT.md makes row-level last-write-wins conditional
on three gates, each learned by losing real data. The port's sync broke
all three, and had four more ways to lose or stall work. Both ends change,
so this is one protocol version, refused by the other side if mismatched.
Gate 1, hydration. A device now pulls every page the server holds before
it may push anything; it used to push first. The 14 Sep laptop — a
week-old copy re-stamped at boot and pushed over a week of phone work —
is now a test, and the phone's week survives it. Boot writes nothing
syncable either: the lesson opens with the app's own words (the artifact's
seeded turn) and waits for Start, instead of stamping a reply and a
progress edit before a server can even be configured.
Gate 2, a counter. Every row remembers the change_seq it last agreed with
(base_seq). The server applies a write only if that still matches —
compare-and-swap under an advisory lock — and otherwise returns its copy
as a conflict. No clock is compared anywhere: a device an hour fast used
to win every conflict for an hour, and a slow one's newer edit was
silently dropped with HTTP 200. dirty and rev replace the timestamp
watermark, which lost edits whenever a clock moved backwards.
Gate 3, no silent shrinking. A conflict is settled by what each copy
holds (sync/resolve.ts): more reviews, more evidence, a finished unit, the
further roadmap position, the union of learned grammar. A deliberate
shrink is explicit: a reset or a cleared lesson raises a marker every
device obeys, including its own unsynced edits, so a reset is not undone
by a device that had not heard of it. Trimming the transcript is local
and tombstones nothing — it used to delete the other device's turns.
Also fixed on the way:
· keys travel as JSON arrays — a space in 몇 명 used to stop every
device's pull at that row, permanently;
· pulls take a shared lock against pushes, so a change_seq committed
out of order can no longer be skipped;
· study_log and peek are per device and summed, so two devices' reviews
of one day both count;
· each user's data has an epoch; a server that lost it is detected,
and the device re-hydrates and offers its data back;
· a protocol-1 client is refused with 426 rather than half-understood.
Migration 9 adds the columns, re-keys the counters and tombstones; the
server drops protocol-1 rows once (none were deployed). Verified: 14
two-device scenarios against a real Postgres, and two browser profiles
syncing a lesson through the UI.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
203 lines
6.5 KiB
TypeScript
203 lines
6.5 KiB
TypeScript
/* SRS cards, over the database.
|
|
|
|
lib/srs.js owns the scheduling; this owns the queries. Note the
|
|
vocabulary: statusOf() calls a mature card "secure". The artifact said
|
|
"known" in its CSS and its filter and "secure" in the library — the app
|
|
uses the library's word everywhere. */
|
|
|
|
import type { Db } from "../db/types.js";
|
|
import { editCard, editCardReset, editStudyLog, seedCard } from "../db/writes.js";
|
|
import { grade, markKnown, newCard, statusOf, type Card, type CardStatus, type Grade } from "@lib/srs.js";
|
|
import { GOOD } from "@lib/srs.js";
|
|
|
|
export interface CardRow extends Card {
|
|
lemma_id: number;
|
|
}
|
|
|
|
export interface DeckEntry {
|
|
lemmaId: number;
|
|
headword: string;
|
|
pos: string;
|
|
glossEn: string;
|
|
source: string;
|
|
topic: string | null;
|
|
/** The unit that introduces this word, for a roadmap word's card. */
|
|
unitId: string | null;
|
|
card: Card | null;
|
|
status: CardStatus;
|
|
}
|
|
|
|
const CARD_COLUMNS = "c.state, c.ease, c.interval, c.due, c.reps, c.lapses";
|
|
|
|
function toCard(row: Record<string, unknown>): Card | null {
|
|
if (row.state == null) return null;
|
|
return {
|
|
state: row.state as Card["state"],
|
|
ease: row.ease as number,
|
|
interval: row.interval as number,
|
|
due: row.due as number,
|
|
reps: row.reps as number,
|
|
lapses: row.lapses as number,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The reviewable deck: curated words and sentence chunks, not the whole
|
|
* dictionary. Reviewing 30,000 dictionary entries is not a study plan.
|
|
*/
|
|
// 'custom' is here so the learner's own words are reviewable like any
|
|
// other — adding a word you cannot then study would be pointless.
|
|
// 'curriculum' is a roadmap word the curated deck does not hold (먹어, 봤어):
|
|
// every word a unit introduces is a card, because the recall evidence and
|
|
// the phase review both hang off one.
|
|
const REVIEWABLE = "('curated', 'sfx', 'grammar', 'custom', 'curriculum')";
|
|
const SENTENCE_SOURCE = "('sentence')";
|
|
|
|
export interface DeckOptions {
|
|
/** Mix glossed sentence chunks in, per the session preference. */
|
|
sentences?: boolean;
|
|
/** Only this source — used by "practise these sentences". */
|
|
only?: "sentences";
|
|
}
|
|
|
|
function sourceClause(opts: DeckOptions): string {
|
|
if (opts.only === "sentences") return `l.source IN ${SENTENCE_SOURCE}`;
|
|
return opts.sentences
|
|
? `l.source IN ${REVIEWABLE} OR l.source IN ${SENTENCE_SOURCE}`
|
|
: `l.source IN ${REVIEWABLE}`;
|
|
}
|
|
|
|
export async function deck(db: Db, opts: DeckOptions = {}): Promise<DeckEntry[]> {
|
|
const rows = await db.all<Record<string, unknown>>(
|
|
`SELECT l.id AS lemmaId, l.headword, l.pos, l.gloss_en AS glossEn, l.source,
|
|
l.topic, l.unit_id AS unitId, ${CARD_COLUMNS}
|
|
FROM lemma l LEFT JOIN card c ON c.lemma_id = l.id
|
|
WHERE ${sourceClause(opts)}
|
|
ORDER BY l.headword`,
|
|
);
|
|
|
|
return rows.map((r) => {
|
|
const card = toCard(r);
|
|
return {
|
|
lemmaId: r.lemmaId as number,
|
|
headword: r.headword as string,
|
|
pos: r.pos as string,
|
|
glossEn: r.glossEn as string,
|
|
source: r.source as string,
|
|
topic: (r.topic as string | null) ?? null,
|
|
unitId: (r.unitId as string | null) ?? null,
|
|
card,
|
|
status: statusOf(card),
|
|
};
|
|
});
|
|
}
|
|
|
|
export interface Counts {
|
|
due: number;
|
|
fresh: number;
|
|
learning: number;
|
|
review: number;
|
|
secure: number;
|
|
total: number;
|
|
}
|
|
|
|
export async function counts(db: Db, today: number, opts: DeckOptions = {}): Promise<Counts> {
|
|
const entries = await deck(db, opts);
|
|
const out: Counts = { due: 0, fresh: 0, learning: 0, review: 0, secure: 0, total: entries.length };
|
|
|
|
for (const e of entries) {
|
|
if (e.status === "new") out.fresh++;
|
|
else {
|
|
out[e.status]++;
|
|
if (e.card && e.card.due <= today) out.due++;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* The queue for a session: everything due, oldest first, then up to
|
|
* `newPerDay` unseen cards.
|
|
*/
|
|
export async function buildQueue(
|
|
db: Db,
|
|
today: number,
|
|
newPerDay: number,
|
|
opts: DeckOptions = {},
|
|
): Promise<DeckEntry[]> {
|
|
const entries = await deck(db, opts);
|
|
|
|
const due = entries
|
|
.filter((e) => e.card && e.card.due <= today && e.status !== "new")
|
|
.sort((a, b) => (a.card!.due ?? 0) - (b.card!.due ?? 0));
|
|
|
|
const fresh = entries.filter((e) => e.status === "new");
|
|
// Rotate the fresh pool by the day so it is not the same alphabetical
|
|
// prefix every morning, but is stable within a day.
|
|
const offset = fresh.length ? today % fresh.length : 0;
|
|
const rotated = [...fresh.slice(offset), ...fresh.slice(0, offset)].slice(0, Math.max(0, newPerDay));
|
|
|
|
return [...due, ...rotated];
|
|
}
|
|
|
|
/** Answer a card. The one write that stamps the clock for a review. */
|
|
export async function answer(
|
|
db: Db,
|
|
entry: DeckEntry,
|
|
g: Grade,
|
|
today: number,
|
|
): Promise<Card> {
|
|
const next = grade(entry.card ?? newCard(), g, today);
|
|
await editCard(db, entry.lemmaId, next);
|
|
await editStudyLog(db, today, { reviews: 1, correct: g >= GOOD ? 1 : 0 });
|
|
return next;
|
|
}
|
|
|
|
/** "I already know this" — jump straight to a secure interval. */
|
|
export async function markAsKnown(db: Db, lemmaId: number, today: number): Promise<void> {
|
|
await editCard(db, lemmaId, markKnown(today));
|
|
}
|
|
|
|
export async function forget(db: Db, lemmaId: number): Promise<void> {
|
|
await editCardReset(db, lemmaId);
|
|
}
|
|
|
|
/** Pre-schedule a card without it counting as something the learner did. */
|
|
export async function seed(db: Db, lemmaId: number, card = newCard()): Promise<void> {
|
|
await seedCard(db, lemmaId, card);
|
|
}
|
|
|
|
/* ── the study log ───────────────────────────────────────────────── */
|
|
|
|
export interface DayRow {
|
|
day: number;
|
|
reviews: number;
|
|
correct: number;
|
|
drills: number;
|
|
}
|
|
|
|
export async function studyLog(db: Db, sinceDay: number): Promise<DayRow[]> {
|
|
// One row per device per day — summed, so a phone's reviews and a
|
|
// laptop's both count and neither can overwrite the other's.
|
|
return db.all<DayRow>(
|
|
`SELECT day, SUM(reviews) AS reviews, SUM(correct) AS correct, SUM(drills) AS drills
|
|
FROM study_log WHERE day >= ? GROUP BY day ORDER BY day`,
|
|
[sinceDay],
|
|
);
|
|
}
|
|
|
|
/** Consecutive days with any activity, counting back from today. */
|
|
export function streakFrom(rows: DayRow[], today: number): number {
|
|
const active = new Set(rows.filter((r) => r.reviews + r.drills > 0).map((r) => r.day));
|
|
let n = 0;
|
|
// Today not yet studied does not break a streak that ran to yesterday.
|
|
let day = active.has(today) ? today : today - 1;
|
|
while (active.has(day)) {
|
|
n++;
|
|
day--;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
export type { Card, CardStatus, Grade };
|