Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -1338,16 +1338,19 @@ nothing could rebuild it.
An answer to a [course](courses.md) question says how it was given
(`sm:answerMode sm:multipleChoice`) and, when it was wrong, which
wrong option was chosen (`sm:chosenDistractor`, the distractor in the
deck's cards document). An answer given in study says neither, which
means recalled. Since vocabulary 1.19 an answer may say how long it
took, in milliseconds the app was shown. That's from the prompt to the
answer revealed, or to the option chosen (`sm:recallMillis`), and
from there to the grade (`sm:gradeMillis`), each left out above ten
minutes. It
may say the day the prompt was due before it (`sm:priorDue`, absent
on a first answer), and the device's offset from UTC then, in minutes
east of Greenwich (`sm:utcOffsetMinutes`). Each is optional, and an
older app ignores them ([migrations.md](migrations.md)).
deck's cards document). It's left out when that IRI couldn't be
written: longer than a term may be, or not one Turtle writes as it is
(`chosenDistractorIn`). A release's own address may be much shorter
than the deck's, and the answer is logged without it. An answer given
in study says neither, which means recalled. Since vocabulary 1.19 an
answer may say how long it took, in milliseconds the app was shown.
That's from the prompt to the answer revealed, or to the option chosen
(`sm:recallMillis`), and from there to the grade (`sm:gradeMillis`),
each left out above ten minutes. It may say the day the prompt was due
before it (`sm:priorDue`, absent on a first answer), and the device's
offset from UTC then, in minutes east of Greenwich
(`sm:utcOffsetMinutes`). Each is optional, and an older app ignores
them ([migrations.md](migrations.md)).
- **The intervals and due day are those of the scheduler that set
them.** The grade is always the SM-2 quality, but the interval before
and after an answer and the day it was due come from what set the
Expand All @@ -1364,7 +1367,10 @@ nothing could rebuild it.
64 KiB (`appendAll`). Each id is the guest's followed by the new
deck's, so adding it again to the same deck changes nothing. Adding
it to another deck (the guest's deck added anew) makes an entry of
its own, never one naming two decks.
its own, never one naming two decks. A wrong option chosen that would
be too long in the new deck's longer address is left out, not the
answer. An answer to a card left out, whose IRI there would be too
long, isn't added.
- **After the review, not in its way**: `recordReview` saves the review
state, then queues the answer, with its response times
([statistics.md](statistics.md#response-times)), to be added in the
Expand All @@ -1375,10 +1381,16 @@ nothing could rebuild it.
session in it, its statistics, resetting a day. An add the server
hasn't answered within 10 seconds is given up and tried again later,
which changes nothing if it was saved (`APPEND_DEADLINE_MS`). An
answer the write check refuses (`dataNotConforming`) would be refused
every time, so only that answer is set aside, never logged, and those
after it go on. An answer still queued when the tab closes is lost;
its review isn't.
answer refused before it's sent would be refused every time: by the
write check (`dataNotConforming`), or for a term Turtle can't write
as it is (`unwritableTerm`) or longer than a term may be
(`termTooLong`). Only that answer is set aside, never logged, and
those after it go on. So is one the pod refuses as a bad request
(400, 413, 414 or 422) a third time. Any other failure, such as no
answer from the pod, a failed login or a server error, is tried
again: it says nothing of the answer, and those after it would fail
alike. An answer still queued when the tab closes is lost; its
review isn't.
- **Resetting the day removes that deck's answers of the day**
(`removeDay`: read, remove, save with If-Match, again on 412).
**Undoing an answer in a session removes that one answer** by its id
Expand Down
23 changes: 18 additions & 5 deletions docs/guest-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,15 @@ It runs these steps:
part of the deck. Its review states keep their SM-2 fields and
snapshots, each naming its card, direction and scheduler; its
FSRS-6 states come as they are;
- a card's id, or a distractor's, comes from a release and may fit
under the guest's short address but not under the new deck's. A
card whose IRI there, or the longest its review states take
(`#<id>@back-to-front@fsrs6`), would be longer than a term may be
is left out, with its review states and its answers: it could be
neither written nor studied there (`cardFitsDeck`). Such a
distractor is left out, not the card (`movedCard`). A course still
asks the card with its release's options, which it reads from the
release;
- the cards document, then the reviews document, is created with
one PUT only where nothing is, then the deck's catalog entry, with
`If-Match`, read and added again on 412 a few times at most.
Expand All @@ -184,10 +193,12 @@ It runs these steps:
added to the instance's log for its month, insert-only, unread
(`appendAll`, one PATCH per month, more where one would pass 64
KiB). Its deck, card and chosen wrong option are named in the new
deck's documents, by the same fragment ids; its grade, intervals
and response times stay as given. Its id is the guest's followed by
the new deck's (`answer-<time>-<random>-deck-<uuid>`). Answers of a
deck the guest removed, or left out, aren't added.
deck's documents, by the same fragment ids; a chosen wrong option
whose IRI would be too long there is left out, not the answer. Its
grade, intervals and response times stay as given. Its id is the
guest's followed by the new deck's
(`answer-<time>-<random>-deck-<uuid>`). Answers of a deck the guest
removed, or left out, aren't added, nor those of a card left out.

No address in a deck's entry, cards or review states may name the
guest's pod, in any spelling of its origin (`guestDeckUrlsLeft`, which
Expand Down Expand Up @@ -276,7 +287,9 @@ instance goes). It runs these steps:
copy that failed half-way walks it within the same bounds.
2. **copy:** copy each resource with every IRI under the guest's
instance moved under the target, and the guest's WebID renamed to the
user's (`ContainerMove.renames`).
user's (`ContainerMove.renames`). A copy of a card whose IRI would be
longer than a term may be under the target fails, as below. Added to
an instance instead, such a card is left out.
3. **adopt:** name the catalog's publisher as the user's profile names
them. No Turtle document of the copy may still name an IRI at the
origin of `GUEST_ORIGIN`, in any spelling. The failure names the
Expand Down
66 changes: 66 additions & 0 deletions packages/application/src/answerStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,72 @@ describe("the answer log", () => {
]);
});

it.each(["unwritableTerm", "termTooLong"] as const)(
"sets aside an answer refused before it is sent (%s), and those after it go on",
async (code) => {
const { log, byMonth, useCases } = setup();
log.append.mockRejectedValueOnce(new AppError(code, { term: "<a>", limit: 16_383 }));
await useCases.recordReview(
instance.url,
deck,
{ card, direction: "front-to-back" },
4,
noon,
);
await useCases.recordReview(
instance.url,
deck,
{ card, direction: "back-to-front" },
5,
noon,
);
await useCases.refreshStudyDigest(instance.url, deck);
expect(byMonth.get("2026-09")!.map((answer) => [answer.direction, answer.grade])).toEqual([
["back-to-front", 5],
]);
expect(log.append).toHaveBeenCalledTimes(2);
},
);

it("sets aside an answer the pod refuses as at fault a third time, and those after it go on", async () => {
const { log, byMonth, useCases } = setup();
const refused = new AppError("addFailed", { url: "https://pod.example/log", status: 400 });
log.append
.mockRejectedValueOnce(refused)
.mockRejectedValueOnce(new AppError("addFailed", { url: "", status: 503 }))
.mockRejectedValueOnce(refused)
.mockRejectedValueOnce(refused);
await useCases.recordReview(instance.url, deck, { card, direction: "front-to-back" }, 4, noon);
await useCases.refreshStudyDigest(instance.url, deck);
await useCases.refreshStudyDigest(instance.url, deck);
expect(byMonth.size).toBe(0);
await useCases.recordReview(instance.url, deck, { card, direction: "back-to-front" }, 5, noon);
await useCases.refreshStudyDigest(instance.url, deck);
expect(byMonth.get("2026-09")!.map((answer) => [answer.direction, answer.grade])).toEqual([
["back-to-front", 5],
]);
expect(log.append).toHaveBeenCalledTimes(5);
});

it.each([401, 403, 408, 429, 500])(
"never sets aside an answer the pod refuses with a %s, which says nothing of the answer",
async (status) => {
const { log, byMonth, useCases } = setup();
for (let time = 0; time < 4; time++)
log.append.mockRejectedValueOnce(new AppError("addFailed", { url: "", status }));
await useCases.recordReview(
instance.url,
deck,
{ card, direction: "front-to-back" },
4,
noon,
);
for (let time = 0; time < 4; time++) await useCases.refreshStudyDigest(instance.url, deck);
expect(byMonth.get("2026-09")!.map((answer) => answer.grade)).toEqual([4]);
expect(log.append).toHaveBeenCalledTimes(5);
},
);

it("never drops an answer for any other failure, an AppError among them", async () => {
const { log, byMonth, useCases } = setup();
log.append.mockRejectedValueOnce(new AppError("podNoAnswer"));
Expand Down
55 changes: 49 additions & 6 deletions packages/application/src/answerStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// SPDX-License-Identifier: MIT

import { AppError } from "@solid-memo/domain/appError";
import { AppError, type ErrorCode } from "@solid-memo/domain/appError";
import type { Deck, StudyDirection } from "@solid-memo/domain/deck";
import type { StudyPreferences } from "@solid-memo/domain/preferences";
import type {
Expand Down Expand Up @@ -75,6 +75,39 @@ export function lastLogMonthOf(today: string): string {
return monthOfStudyDay(shiftStudyDay(today, 2));
}

/**
* The refusals of an answer before anything is sent, each the same on
* every try: the write check's (dataNotConforming), and a term Turtle
* cannot write as it is (unwritableTerm) or longer than any read takes
* in (termTooLong). Any other failure, such as no answer from the pod or
* a server error, may pass on a later try.
*/
const REFUSED_BEFORE_SENDING: ReadonlySet<ErrorCode> = new Set<ErrorCode>([
"dataNotConforming",
"unwritableTerm",
"termTooLong",
]);

/**
* What the pod answers to a request that is itself at fault (a 400, 413,
* 414 or 422), which the same answer is likely to get again: unlike a
* failed login (401, 403) or a busy or failing server (408, 429, 5xx),
* which say nothing of the answer and refuse those after it alike.
*/
const ANSWER_AT_FAULT: ReadonlySet<number> = new Set([400, 413, 414, 422]);

/** How many times the pod refuses one answer as at fault (ANSWER_AT_FAULT) before it is set aside. */
const AT_FAULT_ATTEMPTS = 3;

/** Whether the pod refused an answer as at fault (ANSWER_AT_FAULT). */
function refusedAsAtFault(error: unknown): boolean {
return (
error instanceof AppError &&
error.code === "addFailed" &&
ANSWER_AT_FAULT.has(Number(error.vars.status))
);
}

/**
* The answer log as the use cases share it: each answer graded queued
* for the log, added after its review is saved, and each month read
Expand Down Expand Up @@ -103,11 +136,15 @@ export function createAnswerStore({
* changes its log (flush, loadAll, removeDay), tries again. Each
* instance's answers wait only for its own: one whose log refuses or
* never answers holds up no other's, and an add that gets no answer
* in time fails (AnswerLog.append). An answer the write check refuses
* (dataNotConforming) would be refused on every try, so it alone is
* set aside, and those after it go on.
* in time fails (AnswerLog.append). An answer refused before it is
* sent (REFUSED_BEFORE_SENDING) would be refused on every try, so it
* alone is set aside, and those after it go on; so is one the pod
* refused as at fault a few times (AT_FAULT_ATTEMPTS), which
* `refusals` counts. No answer from the pod, a failed login, a busy
* server or any other failure leaves it at the head, to be tried again.
*/
const queues = new Map<string, { unlogged: Answer[]; logging: Promise<void> }>();
const refusals = new WeakMap<Answer, number>();
function queueOf(instanceUrl: string) {
let queue = queues.get(instanceUrl);
if (queue === undefined) {
Expand All @@ -120,11 +157,17 @@ export function createAnswerStore({
const queue = queueOf(instanceUrl);
queue.logging = queue.logging.then(async () => {
while (queue.unlogged.length > 0) {
const head = queue.unlogged[0]!;
try {
await answerLog.append(instanceUrl, queue.unlogged[0]!);
await answerLog.append(instanceUrl, head);
logWrites += 1;
} catch (error) {
if (!(error instanceof AppError && error.code === "dataNotConforming")) return;
if (!(error instanceof AppError && REFUSED_BEFORE_SENDING.has(error.code))) {
if (!refusedAsAtFault(error)) return;
const refused = (refusals.get(head) ?? 0) + 1;
refusals.set(head, refused);
if (refused < AT_FAULT_ATTEMPTS) return;
}
}
queue.unlogged.shift();
}
Expand Down
16 changes: 16 additions & 0 deletions packages/application/src/courses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,22 @@ describe("courses", () => {
]);
});

it("answerCourseQuestion logs a wrong answer without a wrong option whose IRI in the deck's copy would be too long", async () => {
const { useCases, appended } = setup();
// An id that fits a release's short address, past the bound on a term in the deck's cards document.
const distractorId = "x".repeat(16_383 - "https://a.io/c#".length);
await useCases.answerCourseQuestion(
instance.url,
courseDeck,
question,
{ correct: false, distractorId },
noon,
);
await useCases.refreshStudyDigest(instance.url, courseDeck);
expect(appended).toEqual([expect.objectContaining({ grade: 1, mode: "multiple-choice" })]);
expect(appended[0]).not.toHaveProperty("chosenDistractor");
});

it("answerCourseQuestion says a card the deck already had was reviewed, not added", async () => {
const { deps, useCases } = setup();
vi.mocked(deps.deckRepository.addCardIfAbsent).mockResolvedValue(false);
Expand Down
5 changes: 3 additions & 2 deletions packages/application/src/courses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { isCopyOf } from "@solid-memo/domain/library";
import type { Deck } from "@solid-memo/domain/deck";
import type { LibraryCard, LibraryDeck, LibraryDeckContent } from "@solid-memo/domain/library";
import type { ReviewState } from "@solid-memo/domain/review";
import type { ResponseTimes } from "@solid-memo/domain/answer";
import { chosenDistractorIn, type ResponseTimes } from "@solid-memo/domain/answer";
import {
courseAnswerEffect,
courseProgress,
Expand Down Expand Up @@ -213,10 +213,11 @@ export function createCourseUseCases({
const added =
effect.kind === "introduce" &&
(await deckRepository.addCardIfAbsent(deck, { ...card, fromRelease: sourceOf(deck) }));
// A distractor whose IRI in the deck's cards document could not be written is left out: the answer is still logged.
const chosen =
choice.correct || choice.distractorId === undefined
? undefined
: `${deck.cardsDocumentUrl}#${choice.distractorId}`;
: chosenDistractorIn(deck.cardsDocumentUrl, choice.distractorId);
const { state } = await answerStore.grade(
instanceUrl,
deck,
Expand Down
40 changes: 40 additions & 0 deletions packages/application/src/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1373,6 +1373,46 @@ describe("guests", () => {
expect(guestPod.discard).toHaveBeenCalledOnce();
});

it("mergeGuestStudy leaves out a card whose IRI in the deck it is added as would be too long, with its states and answers", async () => {
const { deps, answers } = mergeDeps();
// An id from a release, past the bound on a term under the instance's address.
const long = "c".repeat(16_383);
const longCard: Card = { ...guestCard, url: `${course.cardsDocumentUrl}#${long}`, id: long };
vi.mocked(deps.deckRepository.listCards).mockImplementation(async (of) =>
of.url === course.url ? [guestCard, longCard] : [],
);
vi.mocked(deps.reviewStateRepository.listReviewStates).mockImplementation(async (of) =>
of.url === course.url
? {
sm2: [guestState, { ...guestState, cardId: long }],
fsrs: [{ ...guestFsrs, cardId: long }, guestFsrs],
}
: sm2s([]),
);
vi.mocked(deps.answerLog.readMonth).mockResolvedValue([
...answers,
answerTo(course, "answer-4", { cardUrl: `${course.cardsDocumentUrl}#${long}` }),
]);
const outcome = await createUseCases(deps).mergeGuestStudy(session, guestInstance, target, {
skip: [own.url],
});
const first = added("deck-n1", course);
expect(outcome).toMatchObject({ ok: true, added: [first] });
const { url: _url, formatVersion: _version, ...content } = guestCard;
expect(deps.deckRepository.applyCardChanges).toHaveBeenCalledExactlyOnceWith(
first,
{ save: [content], remove: [] },
{ whole: true },
);
expect(deps.reviewStateRepository.createReviewStates).toHaveBeenCalledExactlyOnceWith(first, {
sm2: [guestState],
fsrs: [guestFsrs],
});
expect(deps.answerLog.appendAll).toHaveBeenCalledExactlyOnceWith(TARGET, [
expect.objectContaining({ id: "answer-1-deck-n1" }),
]);
});

describe("a guest's drafts", () => {
const GUEST_DRAFT = draftUrlOf(GUEST_INSTANCE_URL, "solid", 1);
const guestDraft = rebaseDraft(courseDraft(), GUEST_DRAFT);
Expand Down
Loading
Loading