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
9 changes: 6 additions & 3 deletions PROJECT_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@ passed 607 tests across 95 files; the final rollback test passed in the
11-test handler/import run.

Apply additive D1 migration `0003_record_sync_receipts.sql` before a future
approved deployment. Hosted qualification, mobile/editor interaction, and
concurrent-tab/cross-device behavior remain
approved deployment. The outbox now merges shared-storage envelopes so a second
tab cannot silently drop another tab's undelivered operations, and the handler
replays prove concurrent-tab, cross-device, and sign-out-mid-write recovery end
to end against real handlers and SQLite. Hosted qualification, real Google
login, physical mobile/editor interaction, and live AI explain-back remain
[#97](https://github.com/Significant-Hobbies/swe-interview-prep/issues/97).
The repair covers one active tab and excludes notes/mastery/ELO sync.
Notes/mastery/ELO sync stays outside the receipt contract.
No deployment or existing learner-data mutation occurred.

## Why/What
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,10 @@ attributed to whichever account signs in next.

**Rollout prerequisite:** apply additive D1 migration
`0003_record_sync_receipts.sql` before deploying the new handlers/client. No
remote migration or deployment has been performed. This contract covers one
active browser tab; concurrent-tab and cross-device conflict resolution, notes,
mastery, and ELO are outside this repair. Hosted qualification remains #97.
remote migration or deployment has been performed. Concurrent-tab and
cross-device record recovery is replay-tested against the real handlers;
conflict resolution stays server-ordered last-writer-wins, and notes, mastery,
and ELO remain outside this repair. Hosted qualification remains #97.

The task reconciliation found no prior open Issues or PRs; no historical
product intention was closed as complete.
Expand Down
64 changes: 62 additions & 2 deletions docs/knowledge/exercise-persistence-qualification.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,72 @@ authentication and network responses, not a Google account browser login.
The new `0003_record_sync_receipts.sql` must be applied before deploying. No
remote migration or deployment occurred. This repair qualifies one active tab;
concurrent-tab and cross-device conflict resolution and other stores
(notes/mastery/ELO) remain outside its contract. #97 retains hosted workflow
qualification.
(notes/mastery/ELO) remain outside its contract — the record-store replay
coverage added later is in the 2026-09-19 section below. #97 retains hosted
workflow qualification.

Validation: full `pnpm quality` passed 607 tests across 95 files; the final
transaction rollback addition passed with all 11 handler/import tests.

## Concurrent-tab and cross-device replay — 2026-09-19 (#97 source phase)

Auditing the #98 contract against #97 found one real gap: `persist()` wrote the
whole `{data, pending}` envelope, so a second tab's save overwrote the first
tab's undelivered operations. An edit queued offline in a tab that then closed
before flushing was silently dropped from the outbox. `persist()` now merges
instead of overwriting: it adopts unknown pending operations by `operationId`,
defers to the stored copy for records this tab never touched, and reapplies the
records this tab authored or adopted from the server. A `seen` set of known
operation IDs keeps acknowledged operations from resurrecting out of a stale
envelope, so a deduplicated retry cannot double-count attempts or activity.

`handlers/record-sync.integration.test.mjs` replays the cases against the real
drill/artifact/project handlers and native SQLite with the real migrations:

- A tab closing with an undelivered write no longer loses it — the surviving
tab adopts the pending operation and delivers it on reconnect; a later reload
inherits a clean envelope and rehydrates both records from the server.
- Two tabs writing the same record produce one attempt per queued operation,
one receipt per operation, and converge to the last committed write.
- Two devices (separate storage maps) merge through the server: each device
picks up the other's committed record on its next reconcile.
- A write in flight during sign-out still commits under the original account;
the retained pending retry is a receipt-deduplicated no-op, and the second
account sees and owns nothing.

`src/components/FeynmanGate.test.tsx` proves the explain-back unavailability
contract at source level: a failed grading request reports the failure, leaves
the drafted explanation in the open gate, and issues no mastery write.

Residual limits, honestly stated: simultaneous same-millisecond persists from
two tabs can still interleave (localStorage has no compare-and-swap), adopted
operations wait for the adopting tab's next flush, and cross-device freshness
is reconcile-driven rather than live. Truly overlapping read/merge/write calls
can still lose a queued operation; the sequential replay tests do not prove
atomic cross-tab persistence. This remains an open qualification gap. Failed
storage writes now leave adopted operations eligible for retry, covered by a
regression that failed before the repair. Notes, concept mastery,
review-question mastery, and ELO remain
fire-and-forget stores outside the receipt/outbox contract.

### Migration order and rollback

`0003_record_sync_receipts.sql` is additive (`CREATE TABLE IF NOT EXISTS`) and
must be applied **before** the code deploys: without the table, every receipted
write fails inside `recordSync.commit` and the client parks work as `failed`.
Deploy-then-migrate is therefore the broken order; migrate-then-deploy and
migrate-only are both safe. Rolling the code back is safe with the table in
place — pre-receipt handlers never reference it. Do not drop the table while
receipted code is deployed. The hosted gate remains owner-approved: apply via
`pnpm db:migrate:remote` (or the deploy workflow's `apply_migrations` gate),
then deploy, then run the browser handoff script recorded in issue #97.

Validation: `pnpm quality` — 967 tests across 119 files, coverage floors,
lint/typecheck, code-health ratchets, docs validation, production build, and
bundle-size limits all pass. The build was verified with a placeholder
`VITE_GOOGLE_CLIENT_ID` because this worktree has no `.env.local`; no real
credential was used or needed. No remote migration or deployment occurred.

## Hosted guest checkpoint — 2026-09-08

An isolated Chrome context at `https://learn.significanthobbies.com` started
Expand Down
173 changes: 172 additions & 1 deletion handlers/record-sync.integration.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,15 @@ const handlers = { drills, artifacts, projects };
let database;
let account = 'alice';
let stores;
let storage;
function store(user = 'alice') {
const result = new RecordSyncStore(config, user);
stores.push(result);
return result;
}
function savedEnvelope(user = 'alice') {
return JSON.parse(storage.get(`test-drills:account:${encodeURIComponent(user)}:v1`) || 'null');
}
async function network(url, init = {}) {
const action = new URL(String(url), 'http://local').searchParams.get('action');
return handlers[action]({
Expand All @@ -42,7 +46,7 @@ async function network(url, init = {}) {
beforeEach(() => {
account = 'alice';
stores = [];
const storage = new Map();
storage = new Map();
vi.stubGlobal('localStorage', {
getItem: (key) => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, value),
Expand Down Expand Up @@ -87,6 +91,31 @@ afterEach(() => {
database.close();
vi.unstubAllGlobals();
});
it('retains adopted operations when storage fails before the merged envelope is durable', () => {
const surviving = store();
const closedTab = store();
closedTab.set('other-tab', content('must survive'));
vi.stubGlobal('localStorage', {
getItem: (key) => storage.get(key) ?? null,
setItem: () => {
throw new Error('quota exceeded');
},
});
surviving.set('own-edit', content('also retained'));
expect(surviving.getSnapshot().status).toBe('failed');
vi.stubGlobal('localStorage', {
getItem: (key) => storage.get(key) ?? null,
setItem: (key, value) => storage.set(key, value),
});
surviving.retry();
expect(
savedEnvelope()
.pending.map((operation) => operation.id)
.sort()
).toEqual(['other-tab', 'own-edit']);
expect(surviving.getSnapshot().pending).toHaveLength(2);
});

it('retains failed writes through reload and retries a lost acknowledgment without duplicate attempts', async () => {
let fail = true;
let loseAcknowledgment = false;
Expand Down Expand Up @@ -248,3 +277,145 @@ it('rolls back the record and receipt together when the activity write fails', a
expect((await network('/api/learning?action=drills', init)).status).toBe(200);
expect(database.prepare('SELECT attempts FROM user_drills').get()?.attempts).toBe(1);
});
it("delivers a dead tab's pending write after reconnect instead of dropping it", async () => {
let offline = true;
vi.stubGlobal(
'fetch',
vi.fn(async (url, init) => {
if (init?.method === 'POST' && offline) return new Response('', { status: 503 });
return network(url, init);
})
);
const tabA = store();
const tabB = store();
tabA.set('first-tab', content('code from tab A'));
tabB.set('second-tab', content('code from tab B'));
tabA.setActive(true);
tabB.setActive(true);
await vi.waitFor(() => {
expect(tabA.getSnapshot().status).toBe('failed');
expect(tabB.getSnapshot().status).toBe('failed');
});
// Both tabs' undelivered operations must survive in the shared envelope.
const queued = savedEnvelope();
expect(queued.pending).toHaveLength(2);
expect(queued.data['first-tab'].lastCode).toBe('code from tab A');
expect(queued.data['second-tab'].lastCode).toBe('code from tab B');
// Tab A closes with its edit still undelivered; only tab B stays open.
tabA.setActive(false);
offline = false;
tabB.retry();
await vi.waitFor(() => expect(tabB.getSnapshot().status).toBe('synced'));
expect(
database
.prepare('SELECT drill_id, last_code, attempts FROM user_drills ORDER BY drill_id')
.all()
).toEqual([
{ drill_id: 'first-tab', last_code: 'code from tab A', attempts: 1 },
{ drill_id: 'second-tab', last_code: 'code from tab B', attempts: 1 },
]);
expect(database.prepare('SELECT COUNT(*) AS n FROM record_sync_receipts').get()?.n).toBe(2);
expect(database.prepare('SELECT COUNT(*) AS n FROM activity_log').get()?.n).toBe(2);
// A reload inherits a clean envelope and hydrates both records remotely.
const reloaded = store();
expect(reloaded.getSnapshot().pending).toHaveLength(0);
reloaded.setActive(true);
await vi.waitFor(() => expect(reloaded.getSnapshot().status).toBe('synced'));
expect(reloaded.getSnapshot().data['first-tab'].lastCode).toBe('code from tab A');
expect(reloaded.getSnapshot().data['second-tab'].lastCode).toBe('code from tab B');
});
it('serializes same-record writes from two tabs without losing or duplicating attempts', async () => {
const tabA = store();
const tabB = store();
tabA.set('shared', content('version from tab A'));
tabB.set('shared', content('version from tab B'));
tabA.setActive(true);
await vi.waitFor(() => expect(tabA.getSnapshot().status).toBe('synced'));
tabB.setActive(true);
await vi.waitFor(() => {
expect(tabA.getSnapshot().status).toBe('synced');
expect(tabB.getSnapshot().status).toBe('synced');
});
// Every queued edit is one real attempt; receipts stop any double-count.
expect(database.prepare('SELECT attempts, last_code FROM user_drills').get()).toMatchObject({
attempts: 2,
});
expect(database.prepare('SELECT COUNT(*) AS n FROM record_sync_receipts').get()?.n).toBe(2);
expect(database.prepare('SELECT COUNT(*) AS n FROM activity_log').get()?.n).toBe(2);
// A fresh tab converges to exactly what the server recorded.
const reloaded = store();
reloaded.setActive(true);
await vi.waitFor(() => expect(reloaded.getSnapshot().status).toBe('synced'));
const committed = database.prepare('SELECT last_code FROM user_drills').get()?.last_code;
expect(reloaded.getSnapshot().data.shared.lastCode).toBe(committed);
expect(['version from tab A', 'version from tab B']).toContain(committed);
});
it('merges same-account writes from two devices through the server without loss', async () => {
const deviceA = storage;
const tabA = store();
tabA.set('device-a', content('written on device A'));
tabA.setActive(true);
await vi.waitFor(() => expect(tabA.getSnapshot().status).toBe('synced'));
// A second device has its own browser storage; the server is the merge point.
storage = new Map();
const tabB = store();
expect(tabB.getSnapshot().data['device-a']).toBeUndefined();
tabB.set('device-b', content('written on device B'));
tabB.setActive(true);
await vi.waitFor(() => expect(tabB.getSnapshot().status).toBe('synced'));
expect(tabB.getSnapshot().data['device-a'].lastCode).toBe('written on device A');
// Device A picks up the other device's committed record on its next reconcile.
storage = deviceA;
const backOnA = store();
backOnA.setActive(true);
await vi.waitFor(() => expect(backOnA.getSnapshot().status).toBe('synced'));
expect(backOnA.getSnapshot().data['device-b'].lastCode).toBe('written on device B');
expect(database.prepare('SELECT COUNT(*) AS n FROM user_drills').get()?.n).toBe(2);
expect(database.prepare('SELECT COUNT(*) AS n FROM record_sync_receipts').get()?.n).toBe(2);
});
it('commits an in-flight write under the original account through sign-out and deduplicates its retry', async () => {
let release;
const delayed = new Promise((resolve) => {
release = resolve;
});
let posted = false;
vi.stubGlobal(
'fetch',
vi.fn(async (url, init) => {
// The handler runs under the cookie that was present when the request
// was sent; only the response is delayed past the sign-out.
const response = network(url, init);
if (init?.method === 'POST') {
posted = true;
await delayed;
}
return response;
})
);
const aliceStore = store();
aliceStore.set('in-flight', content('committed during sign-out'));
aliceStore.setActive(true);
await vi.waitFor(() => expect(posted).toBe(true));
aliceStore.setActive(false);
account = 'bob';
release();
await vi.waitFor(() =>
expect(database.prepare('SELECT COUNT(*) AS n FROM user_drills').get()?.n).toBe(1)
);
const bob = store('bob');
bob.setActive(true);
await vi.waitFor(() => expect(bob.getSnapshot().status).toBe('synced'));
expect(bob.getSnapshot().data).toEqual({});
// The write landed once, under Alice; Bob sees and owns nothing.
expect(database.prepare('SELECT user_id, attempts FROM user_drills').get()).toMatchObject({
user_id: 'alice',
attempts: 1,
});
// Alice's retained pending operation retries as a deduplicated no-op.
account = 'alice';
aliceStore.setActive(true);
await vi.waitFor(() => expect(aliceStore.getSnapshot().status).toBe('synced'));
expect(database.prepare('SELECT attempts FROM user_drills').get()?.attempts).toBe(1);
expect(database.prepare('SELECT COUNT(*) AS n FROM activity_log').get()?.n).toBe(1);
expect(database.prepare('SELECT COUNT(*) AS n FROM record_sync_receipts').get()?.n).toBe(1);
});
68 changes: 68 additions & 0 deletions src/components/FeynmanGate.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// @vitest-environment happy-dom
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import FeynmanGate from './FeynmanGate';

vi.mock('../hooks/useReviewMastery', () => ({ useReviewMastery: () => ({ review: vi.fn() }) }));
vi.mock('../hooks/useAI', () => ({ loadAIConfig: () => null }));
vi.mock('./MarkdownViewer', () => ({ default: () => null }));

const fetchMock = vi.hoisted(() => vi.fn());
const alerts = vi.hoisted(() => [] as string[]);

function typeText(element: HTMLTextAreaElement, value: string) {
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set?.call(
element,
value
);
element.dispatchEvent(new Event('input', { bubbles: true }));
}

let container: HTMLDivElement;
let root: Root;

beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const storage = new Map([['dsa-prep-profile', '{"id":"learner"}']]);
vi.stubGlobal('localStorage', {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
});
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
alerts.length = 0;
vi.stubGlobal('alert', (message: string) => alerts.push(message));
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
});

afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.unstubAllGlobals();
});

it('reports grading unavailability and keeps the drafted explanation and gate open', async () => {
fetchMock.mockResolvedValue(new Response('upstream unavailable', { status: 503 }));
await act(async () =>
root.render(
<FeynmanGate open onClose={() => {}} problem="Explain the drill" problemId="synthetic" />
)
);
const textarea = container.querySelector('textarea')!;
const draft = 'The approach is a sliding window over tokens with a stop-word set.';
await act(async () => typeText(textarea, draft));
const grade = [...container.querySelectorAll('button')].find((b) =>
b.textContent?.includes('Grade me')
)!;
await act(async () => grade.dispatchEvent(new MouseEvent('click', { bubbles: true })));
// Exactly one grading request was attempted; no mastery write followed it.
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0][0])).toContain('action=feynman');
expect(alerts.join()).toContain('Grade failed: 503');
// The draft and the gate survive so the learner can retry instead of losing work.
expect(textarea.value).toBe(draft);
expect(container.textContent).toContain('Feynman Gate');
});
Loading
Loading