From 7949bef22cf1ec7d2cf7e147497a90491e279af6 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:39:44 +0000 Subject: [PATCH 01/13] Keep per-account saved copies of what the app reads offline-cache.ts reads through the network and keeps a copy in AsyncStorage under the user id; when the read fails, or expo-network says the device is offline, the copy answers. Copies are removed when the account changes, a slow answer never replaces a newer copy, and each group keeps its most recently fetched entries only. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/package.json | 1 + apps/mobile/src/lib/network-status.ts | 69 +++++++++ apps/mobile/src/lib/offline-cache.test.ts | 160 ++++++++++++++++++++ apps/mobile/src/lib/offline-cache.ts | 175 ++++++++++++++++++++++ package-lock.json | 11 ++ 5 files changed, 416 insertions(+) create mode 100644 apps/mobile/src/lib/network-status.ts create mode 100644 apps/mobile/src/lib/offline-cache.test.ts create mode 100644 apps/mobile/src/lib/offline-cache.ts diff --git a/apps/mobile/package.json b/apps/mobile/package.json index db9201929..a6d67dbbf 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -28,6 +28,7 @@ "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", + "expo-network": "~57.0.2", "expo-print": "~57.0.2", "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", diff --git a/apps/mobile/src/lib/network-status.ts b/apps/mobile/src/lib/network-status.ts new file mode 100644 index 000000000..9dec47e09 --- /dev/null +++ b/apps/mobile/src/lib/network-status.ts @@ -0,0 +1,69 @@ +import { addNetworkStateListener, getNetworkStateAsync, type NetworkState } from 'expo-network'; +import { useSyncExternalStore } from 'react'; + +/** + * Whether the app is reading saved copies instead of the network. expo-network + * says when the device is offline; a read that fell back to a saved copy says + * so too, which covers a network that is up but cannot reach the server. The + * next read that reaches the server clears that. + */ + +export interface OfflineStatus { + /** expo-network reports no connection. */ + offline: boolean; + /** The last read was answered from a saved copy. */ + savedCopies: boolean; +} + +let status: OfflineStatus = { offline: false, savedCopies: false }; +const listeners = new Set<() => void>(); + +function update(patch: Partial) { + const next = { ...status, ...patch }; + if (next.offline === status.offline && next.savedCopies === status.savedCopies) return; + status = next; + listeners.forEach((listener) => listener()); +} + +// iOS reports isInternetReachable as null until it knows; only a definite false means offline. +function isOffline(state: NetworkState): boolean { + return state.isConnected === false || state.isInternetReachable === false; +} + +export function isKnownOffline(): boolean { + return status.offline; +} + +export function noteSavedCopy() { + update({ savedCopies: true }); +} + +export function noteNetworkRead() { + update({ savedCopies: false }); +} + +/** Follows the device's connection until the returned function is called. */ +export function watchNetwork(): () => void { + let active = true; + const subscription = addNetworkStateListener((state) => update({ offline: isOffline(state) })); + getNetworkStateAsync() + .then((state) => { + if (active) update({ offline: isOffline(state) }); + }) + .catch((cause: unknown) => console.warn('Could not read the network state:', cause instanceof Error ? cause.message : cause)); + return () => { + active = false; + subscription.remove(); + }; +} + +function subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function useOfflineStatus(): OfflineStatus { + return useSyncExternalStore(subscribe, () => status, () => status); +} diff --git a/apps/mobile/src/lib/offline-cache.test.ts b/apps/mobile/src/lib/offline-cache.test.ts new file mode 100644 index 000000000..804594aa6 --- /dev/null +++ b/apps/mobile/src/lib/offline-cache.test.ts @@ -0,0 +1,160 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const storage = vi.hoisted(() => new Map()); +const network = vi.hoisted(() => ({ listener: null as ((state: { isConnected: boolean; isInternetReachable: boolean }) => void) | null })); + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: async (key: string) => storage.get(key) ?? null, + setItem: async (key: string, value: string) => void storage.set(key, value), + removeItem: async (key: string) => void storage.delete(key), + getAllKeys: async () => [...storage.keys()], + multiSet: async (pairs: [string, string][]) => pairs.forEach(([key, value]) => storage.set(key, value)), + multiRemove: async (keys: string[]) => keys.forEach((key) => storage.delete(key)), + }, +})); + +vi.mock('expo-network', () => ({ + addNetworkStateListener: (listener: typeof network.listener) => { + network.listener = listener; + return { remove: () => undefined }; + }, + getNetworkStateAsync: async () => ({ isConnected: true, isInternetReachable: true }), +})); + +const offlineFailure = () => Promise.reject(new Error('Network request failed')); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +// A fresh module per test: the owner and tickets are module state, as in the app. +async function load() { + vi.resetModules(); + const cache = await import('./offline-cache'); + const status = await import('./network-status'); + return { ...cache, ...status }; +} + +beforeEach(() => { + storage.clear(); +}); + +describe('saved copies between accounts', () => { + it('never answers account B from account A, and drops A on the switch', async () => { + const { cachedRead, setCacheOwner } = await load(); + setCacheOwner('user-a'); + await cachedRead('user-a', 'sources', 'debate-1', async () => ['A secret source']); + await expect(cachedRead('user-a', 'sources', 'debate-1', offlineFailure)).resolves.toEqual(['A secret source']); + + setCacheOwner('user-b'); + await expect(cachedRead('user-b', 'sources', 'debate-1', offlineFailure)).rejects.toThrow('Network request failed'); + // A's own key is not readable either once B owns the cache. + await expect(cachedRead('user-a', 'sources', 'debate-1', offlineFailure)).rejects.toThrow('Network request failed'); + expect([...storage.keys()].some((key) => key.includes('user-a'))).toBe(false); + expect([...storage.values()].some((value) => value.includes('A secret source'))).toBe(false); + }); + + it('removes every copy on sign-out and keeps them for the same account after a restart', async () => { + let cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'debates', 'all', async () => ({ debates: ['motion'] })); + + // A restart signed in as the same account still reads its copies offline. + cache = await load(); + cache.setCacheOwner('user-a'); + await expect(cache.cachedRead('user-a', 'debates', 'all', offlineFailure)).resolves.toEqual({ debates: ['motion'] }); + + cache.setCacheOwner(null); + cache.setCacheOwner('user-a'); + await expect(cache.cachedRead('user-a', 'debates', 'all', offlineFailure)).rejects.toThrow(); + expect([...storage.keys()].filter((key) => key !== 'debatelab.cache.v1.owner')).toEqual([]); + }); + + it('drops copies from a restart with a different account, even without a sign-out', async () => { + let cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'result:case', 'case-1', async () => ({ title: 'A case' })); + + cache = await load(); + cache.setCacheOwner('user-b'); + await expect(cache.cachedRead('user-b', 'result:case', 'case-1', offlineFailure)).rejects.toThrow(); + expect([...storage.values()].some((value) => value.includes('A case'))).toBe(false); + }); + + it('does not keep an answer for A that lands after the switch to B', async () => { + const { cachedRead, setCacheOwner } = await load(); + setCacheOwner('user-a'); + const slow = deferred(); + const read = cachedRead('user-a', 'sources', 'debate-1', () => slow.promise); + setCacheOwner('user-b'); + slow.resolve(['A late source']); + await read; + await cachedRead('user-b', 'sources', 'other', async () => []); + expect([...storage.values()].some((value) => value.includes('A late source'))).toBe(false); + }); +}); + +describe('stale answers', () => { + it('a slow answer never overwrites a newer saved copy', async () => { + const { cachedRead, setCacheOwner } = await load(); + setCacheOwner('user-a'); + const slow = deferred(); + const older = cachedRead('user-a', 'flowchart', 'map-1', () => slow.promise); + await cachedRead('user-a', 'flowchart', 'map-1', async () => 'newer'); + slow.resolve('older'); + await older; + await expect(cachedRead('user-a', 'flowchart', 'map-1', offlineFailure)).resolves.toBe('newer'); + }); + + it('a read already running when a row is deleted does not bring its copy back', async () => { + const { cachedRead, forgetCopy, setCacheOwner } = await load(); + setCacheOwner('user-a'); + await cachedRead('user-a', 'result:case', 'case-1', async () => 'saved case'); + const slow = deferred(); + const running = cachedRead('user-a', 'result:case', 'case-1', () => slow.promise); + forgetCopy('user-a', 'result:case', 'case-1'); + slow.resolve('saved case'); + await running; + await expect(cachedRead('user-a', 'result:case', 'case-1', offlineFailure)).rejects.toThrow(); + }); + + it('keeps a saved null apart from no copy', async () => { + const { cachedRead, setCacheOwner } = await load(); + setCacheOwner('user-a'); + await cachedRead('user-a', 'source', 'gone', async () => null); + await expect(cachedRead('user-a', 'source', 'gone', offlineFailure)).resolves.toBeNull(); + }); +}); + +describe('offline', () => { + it('answers from the copy without waiting on the network, and says so', async () => { + const { cachedRead, setCacheOwner, watchNetwork, isKnownOffline } = await load(); + setCacheOwner('user-a'); + await cachedRead('user-a', 'chats', 'debate-1', async () => ['chat']); + watchNetwork(); + network.listener?.({ isConnected: false, isInternetReachable: false }); + expect(isKnownOffline()).toBe(true); + const never = vi.fn(() => new Promise(() => undefined)); + await expect(cachedRead('user-a', 'chats', 'debate-1', never)).resolves.toEqual(['chat']); + expect(never).not.toHaveBeenCalled(); + }); + + it('drops the least recently fetched copies beyond a group limit', async () => { + const { cachedRead, setCacheOwner } = await load(); + setCacheOwner('user-a'); + const now = vi.spyOn(Date, 'now'); + for (let i = 0; i < 21; i += 1) { + now.mockReturnValue(1_000 + i); + await cachedRead('user-a', 'sources', `debate-${i}`, async () => [i]); + } + now.mockRestore(); + await cachedRead('user-a', 'sources', 'flush', offlineFailure).catch(() => undefined); + await expect(cachedRead('user-a', 'sources', 'debate-0', offlineFailure)).rejects.toThrow(); + await expect(cachedRead('user-a', 'sources', 'debate-20', offlineFailure)).resolves.toEqual([20]); + }); +}); diff --git a/apps/mobile/src/lib/offline-cache.ts b/apps/mobile/src/lib/offline-cache.ts new file mode 100644 index 000000000..c5c5d5620 --- /dev/null +++ b/apps/mobile/src/lib/offline-cache.ts @@ -0,0 +1,175 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { isKnownOffline, noteNetworkRead, noteSavedCopy } from './network-status'; +import { createSerialQueue } from './serial-queue'; + +/** + * Saved copies of what the app reads, so debates, sources, saved results and + * past rounds stay readable without a connection. A read goes to the network + * first and keeps a copy of the answer; when the network fails, or the device + * is known to be offline, the copy answers instead. Nothing is written + * offline: saving waits for the network. + * + * The copies belong to one account. Each is stored under its user id and + * checked against it when read, and all of them are removed when the account + * changes, including signing out, before the next account's first read: every + * storage step runs in one queue, behind the removal. + */ + +/** How many copies each group keeps; the least recently fetched beyond that are dropped. */ +const LIMITS = { + /** The debates list with the Current Debate. */ + debates: 1, + /** Counts and recent work per debate and side. */ + 'debate-lists': 60, + /** A debate's Library, with each source's text. */ + sources: 20, + source: 60, + 'result:case': 40, + 'result:attack': 40, + 'result:brainstorm': 40, + 'result:factoids': 40, + flowcharts: 20, + flowchart: 40, + chats: 20, + chat: 40, +} as const; + +export type CacheGroup = keyof typeof LIMITS; + +/** A copy larger than this is not kept; web storage holds about 5 MB in all. */ +const MAX_ENTRY_CHARS = 512_000; + +const PREFIX = 'debatelab.cache.v1'; +const OWNER_KEY = `${PREFIX}.owner`; +const indexKey = (userId: string) => `${PREFIX}.index:${userId}`; +const entryKey = (userId: string, group: CacheGroup, key: string) => `${PREFIX}:${userId}:${group}:${key}`; + +type Index = Record; + +const queue = createSerialQueue(); +// undefined until the auth provider names the first owner, so that first call always checks storage. +let owner: string | null | undefined; +// Bumped on every owner change; work started for an earlier owner never writes. +let generation = 0; +// Every read takes a ticket when it starts. A copy is only replaced by an answer +// from a read that started later, so a slow answer never overwrites a newer one. +let tickets = 0; +const newest = new Map(); +let index: { userId: string; entries: Index } | null = null; + +function warn(what: string) { + return (cause: unknown) => console.warn(`${what}:`, cause instanceof Error ? cause.message : cause); +} + +/** + * Called by the auth provider before it adopts a session. Copies kept for + * another account, or for no known account, are removed; the same account + * after a restart keeps them. + */ +export function setCacheOwner(userId: string | null) { + if (userId === owner) return; + owner = userId; + generation += 1; + newest.clear(); + index = null; + void queue(async () => { + if ((await AsyncStorage.getItem(OWNER_KEY)) === userId && userId !== null) return; + const keys = (await AsyncStorage.getAllKeys()).filter((key) => key.startsWith(`${PREFIX}:`) || key.startsWith(`${PREFIX}.index:`)); + if (keys.length > 0) await AsyncStorage.multiRemove(keys); + if (userId) await AsyncStorage.setItem(OWNER_KEY, userId); + else await AsyncStorage.removeItem(OWNER_KEY); + }).catch(warn('Could not clear saved copies')); +} + +async function loadIndex(userId: string): Promise { + if (index?.userId === userId) return index.entries; + let entries: Index = {}; + try { + const raw = await AsyncStorage.getItem(indexKey(userId)); + const parsed: unknown = raw ? JSON.parse(raw) : null; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) entries = parsed as Index; + } catch { + // An unreadable index starts again; its copies are dropped by the next account change. + } + index = { userId, entries }; + return entries; +} + +/** The saved copy, or undefined when there is none. A saved null (a row that was gone) is a copy too. */ +function readCopy(userId: string, group: CacheGroup, key: string): Promise<{ value: T } | undefined> { + return queue(async () => { + if (userId !== owner) return undefined; + const raw = await AsyncStorage.getItem(entryKey(userId, group, key)); + if (!raw || userId !== owner) return undefined; + const entry = JSON.parse(raw) as { userId?: unknown; value: T }; + return entry.userId === userId ? { value: entry.value } : undefined; + }); +} + +function writeCopy(userId: string, group: CacheGroup, key: string, value: unknown, ticket: number, gen: number): Promise { + const storageKey = entryKey(userId, group, key); + return queue(async () => { + if (gen !== generation || userId !== owner || ticket < (newest.get(storageKey) ?? 0)) return; + newest.set(storageKey, ticket); + const raw = JSON.stringify({ userId, value }); + const entries = await loadIndex(userId); + if (raw.length > MAX_ENTRY_CHARS) { + delete entries[storageKey]; + await AsyncStorage.multiSet([[indexKey(userId), JSON.stringify(entries)]]); + await AsyncStorage.removeItem(storageKey); + return; + } + entries[storageKey] = { group, at: Date.now() }; + const dropped = Object.entries(entries) + .filter(([, entry]) => entry.group === group) + .sort(([, a], [, b]) => b.at - a.at) + .slice(LIMITS[group]) + .map(([droppedKey]) => droppedKey); + for (const droppedKey of dropped) delete entries[droppedKey]; + await AsyncStorage.multiSet([[storageKey, raw], [indexKey(userId), JSON.stringify(entries)]]); + if (dropped.length > 0) await AsyncStorage.multiRemove(dropped); + }); +} + +/** + * Reads through the network and keeps a copy for this account. Offline, or + * when the read fails, answers from the copy; with no copy, fails as the read did. + */ +export async function cachedRead(userId: string, group: CacheGroup, key: string, load: () => Promise): Promise { + const ticket = ++tickets; + const gen = generation; + if (isKnownOffline()) { + const copy = await readCopy(userId, group, key).catch(() => undefined); + if (copy) { + noteSavedCopy(); + return copy.value; + } + } + let value: T; + try { + value = await load(); + } catch (cause) { + const copy = await readCopy(userId, group, key).catch(() => undefined); + if (!copy) throw cause; + noteSavedCopy(); + return copy.value; + } + noteNetworkRead(); + writeCopy(userId, group, key, value, ticket, gen).catch(warn('Could not keep a saved copy')); + return value; +} + +/** Drops a copy after the row was deleted, and stops any read already running from saving it again. */ +export function forgetCopy(userId: string, group: CacheGroup, key: string) { + const storageKey = entryKey(userId, group, key); + const gen = generation; + newest.set(storageKey, ++tickets); + void queue(async () => { + if (gen !== generation || userId !== owner) return; + const entries = await loadIndex(userId); + delete entries[storageKey]; + await AsyncStorage.multiSet([[indexKey(userId), JSON.stringify(entries)]]); + await AsyncStorage.removeItem(storageKey); + }).catch(warn('Could not drop a saved copy')); +} diff --git a/package-lock.json b/package-lock.json index b7a6f6c45..9ca2b1eef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -137,6 +137,7 @@ "expo-linear-gradient": "~57.0.2", "expo-linking": "~57.0.12", "expo-localization": "~57.0.2", + "expo-network": "~57.0.2", "expo-print": "~57.0.2", "expo-router": "~57.0.25", "expo-secure-store": "~57.0.4", @@ -724,6 +725,16 @@ "react-native": "*" } }, + "apps/mobile/node_modules/expo-network": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-network/-/expo-network-57.0.2.tgz", + "integrity": "sha512-BfK1kdftZcM6o9o1FAG7/y0l6vWX0n8td0M5lwkvaMeRXZ9LkRwOi9CQXwvsMTQGQiPwtQXgwiAze13cqjt75g==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react": "*" + } + }, "apps/mobile/node_modules/expo-print": { "version": "57.0.2", "resolved": "https://registry.npmjs.org/expo-print/-/expo-print-57.0.2.tgz", From 9f565c07150650852ca72cb286821778dd3f4853 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:39:44 +0000 Subject: [PATCH 02/13] Read debates, sources and saved results through the offline cache The Current Debate and debates list, counts and recent work, Library sources with their text, saved cases, attacks, brainstorms, factoids, flowcharts and chats now fall back to their saved copies. Deleting a row drops its copy. The auth provider names the cache owner before it adopts a session, next to the session memory. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/auth.tsx | 6 +++-- apps/mobile/src/lib/chats.ts | 13 ++++++++-- apps/mobile/src/lib/debates.ts | 38 ++++++++++++++++++++--------- apps/mobile/src/lib/flowcharts.ts | 14 +++++++++-- apps/mobile/src/lib/results.test.ts | 1 + apps/mobile/src/lib/results.ts | 9 ++++++- apps/mobile/src/lib/sources.test.ts | 2 ++ apps/mobile/src/lib/sources.ts | 16 +++++++++--- 8 files changed, 78 insertions(+), 21 deletions(-) diff --git a/apps/mobile/src/lib/auth.tsx b/apps/mobile/src/lib/auth.tsx index 85ab720d0..120607da9 100644 --- a/apps/mobile/src/lib/auth.tsx +++ b/apps/mobile/src/lib/auth.tsx @@ -2,6 +2,7 @@ import type { Session } from '@supabase/supabase-js'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { getInstallId, sessionStorage } from './device-storage'; +import { setCacheOwner } from './offline-cache'; import { setSessionOwner } from './session-memory'; import { supabase } from './supabase'; import { sessionStorageKey } from './supabase-config'; @@ -78,10 +79,11 @@ async function signOutError(scope: 'global' | 'local'): Promise { export function AuthProvider({ children }: { children: ReactNode }) { const [session, setSessionState] = useState(null); - // The session memory is cleared before an account change renders, so no - // screen ever shows the previous account's forms or results. + // The session memory and the saved copies are cleared before an account + // change renders, so no screen ever shows the previous account's work. const setSession = useCallback((next: Session | null) => { setSessionOwner(next?.user.id ?? null); + setCacheOwner(next?.user.id ?? null); setSessionState(next); }, []); const [loading, setLoading] = useState(true); diff --git a/apps/mobile/src/lib/chats.ts b/apps/mobile/src/lib/chats.ts index 7f2d76fbf..47addcd2e 100644 --- a/apps/mobile/src/lib/chats.ts +++ b/apps/mobile/src/lib/chats.ts @@ -1,5 +1,6 @@ import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; +import { cachedRead } from './offline-cache'; import { supabase } from './supabase'; /** @@ -54,7 +55,11 @@ interface MessageRow { */ /** Chats in a debate, or the unsorted ones in one-off mode, newest first, as the website's saved chats list. */ -export async function fetchChats(userId: string, debateId: string | null): Promise { +export function fetchChats(userId: string, debateId: string | null): Promise { + return cachedRead(userId, 'chats', debateId ?? 'one-off', () => loadChats(userId, debateId)); +} + +async function loadChats(userId: string, debateId: string | null): Promise { const query = supabase .from('chats') .select('id,title,created_at,messages(count)') @@ -70,7 +75,11 @@ export async function fetchChats(userId: string, debateId: string | null): Promi })); } -export async function fetchChat(userId: string, chatId: string): Promise<{ debateId: string | null; messages: ChatMessage[] }> { +export function fetchChat(userId: string, chatId: string): Promise<{ debateId: string | null; messages: ChatMessage[] }> { + return cachedRead(userId, 'chat', chatId, () => loadChat(userId, chatId)); +} + +async function loadChat(userId: string, chatId: string): Promise<{ debateId: string | null; messages: ChatMessage[] }> { const [chat, messages] = await Promise.all([ supabase.from('chats').select('debate_id').eq('user_id', userId).eq('id', chatId).single(), supabase.from('messages').select('id,role,content,output,created_at').eq('chat_id', chatId).order('created_at', { ascending: true }), diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index 97fa167b9..056671633 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -1,3 +1,4 @@ +import { cachedRead } from './offline-cache'; import { supabase } from './supabase'; /** @@ -76,8 +77,12 @@ async function setCurrentPointer(userId: string, debateId: string) { * the real boundary; the filter keeps a wrong session from mixing accounts. */ -/** Every debate, newest activity first, and the current one when it is still in the list. */ -export async function fetchDebates(userId: string): Promise<{ debates: Debate[]; currentId: string | null }> { +/** Every debate, newest activity first, and the current one when it is still in the list. Offline, the saved copy. */ +export function fetchDebates(userId: string): Promise<{ debates: Debate[]; currentId: string | null }> { + return cachedRead(userId, 'debates', 'all', () => loadDebates(userId)); +} + +async function loadDebates(userId: string): Promise<{ debates: Debate[]; currentId: string | null }> { const [debatesResult, currentResult] = await Promise.all([ supabase.from('debates').select(DEBATE_COLUMNS).eq('user_id', userId).order('last_activity_at', { ascending: false }), supabase.from('current_debates').select('debate_id').eq('user_id', userId).maybeSingle(), @@ -169,7 +174,11 @@ async function countRows(userId: string, table: string, debateId: string, sides? const sum = (counts: number[]) => counts.reduce((total, count) => total + count, 0); /** Sources, saved results per side and Sparring rounds in one debate. One-off work has no debate and never counts. */ -export async function fetchDebateCounts(userId: string, debateId: string): Promise { +export function fetchDebateCounts(userId: string, debateId: string): Promise { + return cachedRead(userId, 'debate-lists', `counts:${debateId}`, () => loadDebateCounts(userId, debateId)); +} + +async function loadDebateCounts(userId: string, debateId: string): Promise { const [sources, rounds, pro, con] = await Promise.all([ countRows(userId, 'sources', debateId), countRows(userId, 'sparring_sessions', debateId), @@ -180,7 +189,11 @@ export async function fetchDebateCounts(userId: string, debateId: string): Promi } /** Library sources per debate, for the switcher's list. */ -export async function fetchSourceCounts(userId: string, debateIds: string[]): Promise> { +export function fetchSourceCounts(userId: string, debateIds: string[]): Promise> { + return cachedRead(userId, 'debate-lists', `source-counts:${debateIds.join(',')}`, () => loadSourceCounts(userId, debateIds)); +} + +async function loadSourceCounts(userId: string, debateIds: string[]): Promise> { const counts = await Promise.all(debateIds.map((id) => countRows(userId, 'sources', id))); return Object.fromEntries(debateIds.map((id, index) => [id, counts[index]])); } @@ -212,12 +225,11 @@ async function fetchResultRows(userId: string, debateId: string, table: ResultTa const newestFirst = (lists: DebateItem[][], limit: number) => lists.flat().sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); /** The newest saved results and rounds for one side of a debate, like the website's side lists. */ -export async function fetchRecentWork( - userId: string, - debateId: string, - side: DebateSide, - limit: number, -): Promise { +export function fetchRecentWork(userId: string, debateId: string, side: DebateSide, limit: number): Promise { + return cachedRead(userId, 'debate-lists', `recent:${debateId}:${side}:${limit}`, () => loadRecentWork(userId, debateId, side, limit)); +} + +async function loadRecentWork(userId: string, debateId: string, side: DebateSide, limit: number): Promise { const lists = await Promise.all([ ...RESULT_TABLES.map((table) => fetchResultRows(userId, debateId, table, SIDES_FOR[side], limit)), (async () => { @@ -246,7 +258,11 @@ export async function fetchRecentWork( const PREPARE_TABLES = ['cases', 'brainstorms', 'factoids', 'flowcharts'] as const satisfies readonly ResultTable[]; /** A debate's saved Prepare results for both sides, newest first, for the Prepare tab. */ -export async function fetchPrepareWork(userId: string, debateId: string, limit: number): Promise { +export function fetchPrepareWork(userId: string, debateId: string, limit: number): Promise { + return cachedRead(userId, 'debate-lists', `prepare:${debateId}:${limit}`, () => loadPrepareWork(userId, debateId, limit)); +} + +async function loadPrepareWork(userId: string, debateId: string, limit: number): Promise { const lists = await Promise.all(PREPARE_TABLES.map((table) => fetchResultRows(userId, debateId, table, null, limit))); return newestFirst(lists, limit); } diff --git a/apps/mobile/src/lib/flowcharts.ts b/apps/mobile/src/lib/flowcharts.ts index 11bff836d..a7eee4949 100644 --- a/apps/mobile/src/lib/flowcharts.ts +++ b/apps/mobile/src/lib/flowcharts.ts @@ -1,5 +1,6 @@ import { readFlowchart, type FlowchartDocument } from '@debatelab/core/flowchart'; +import { cachedRead, forgetCopy } from './offline-cache'; import { supabase } from './supabase'; /** @@ -44,7 +45,11 @@ function fromRow(row: FlowchartRow): SavedFlowchart { } /** The maps saved in one debate, or one-off maps for null, newest first, as the website's saved list shows them. */ -export async function fetchFlowchartList(userId: string, debateId: string | null): Promise { +export function fetchFlowchartList(userId: string, debateId: string | null): Promise { + return cachedRead(userId, 'flowcharts', debateId ?? 'one-off', () => loadFlowchartList(userId, debateId)); +} + +async function loadFlowchartList(userId: string, debateId: string | null): Promise { const query = supabase.from('flowcharts').select('id,title,created_at').eq('user_id', userId); const { data, error } = await (debateId ? query.eq('debate_id', debateId) : query.is('debate_id', null)).order('created_at', { ascending: false }); if (error) throw new Error(error.message); @@ -55,7 +60,11 @@ export async function fetchFlowchartList(userId: string, debateId: string | null })); } -export async function fetchFlowchart(userId: string, id: string): Promise { +export function fetchFlowchart(userId: string, id: string): Promise { + return cachedRead(userId, 'flowchart', id, () => loadFlowchart(userId, id)); +} + +async function loadFlowchart(userId: string, id: string): Promise { const { data, error } = await supabase.from('flowcharts').select(COLUMNS).eq('user_id', userId).eq('id', id).maybeSingle(); if (error) throw new Error(error.message); return data ? fromRow(data as FlowchartRow) : null; @@ -87,4 +96,5 @@ export async function updateFlowchart(userId: string, id: string, doc: Flowchart export async function deleteFlowchart(userId: string, id: string): Promise { const { error } = await supabase.from('flowcharts').delete().eq('user_id', userId).eq('id', id); if (error) throw new Error(error.message); + forgetCopy(userId, 'flowchart', id); } diff --git a/apps/mobile/src/lib/results.test.ts b/apps/mobile/src/lib/results.test.ts index 2641a42dc..1f4b761bf 100644 --- a/apps/mobile/src/lib/results.test.ts +++ b/apps/mobile/src/lib/results.test.ts @@ -4,6 +4,7 @@ const addSource = vi.hoisted(() => vi.fn()); vi.mock('./supabase', () => ({ supabase: {} })); vi.mock('expo/fetch', () => ({ fetch: vi.fn() })); +vi.mock('./offline-cache', () => ({ cachedRead: vi.fn(), forgetCopy: vi.fn() })); vi.mock('./sources', () => ({ addSource, applySourceAssessments: vi.fn(), fetchSourceIdsInFolder: vi.fn(), removeSource: vi.fn() })); import { autosaveSources, readBrainstorm, readFactoids } from './results'; diff --git a/apps/mobile/src/lib/results.ts b/apps/mobile/src/lib/results.ts index 267dd43dc..985e29082 100644 --- a/apps/mobile/src/lib/results.ts +++ b/apps/mobile/src/lib/results.ts @@ -18,6 +18,7 @@ import type { DebateContextRequest } from '@debatelab/core/contract/debate-conte import { AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; import type { FeatureConfig } from './feature-config'; +import { cachedRead, forgetCopy } from './offline-cache'; import { addSource, applySourceAssessments, fetchSourceIdsInFolder, removeSource } from './sources'; import { supabase } from './supabase'; @@ -247,7 +248,12 @@ interface ResultRow { output: unknown; } -export async function fetchResult(userId: string, kind: K, id: string): Promise | null> { +/** A saved result; offline, the saved copy. */ +export function fetchResult(userId: string, kind: K, id: string): Promise | null> { + return cachedRead(userId, `result:${kind}`, id, () => loadResult(userId, kind, id)); +} + +async function loadResult(userId: string, kind: K, id: string): Promise | null> { const { data, error } = await supabase .from(TABLES[kind]) .select('id,title,side,debate_id,created_at,input,output') @@ -272,6 +278,7 @@ export async function fetchResult(userId: string, kind: K, export async function deleteResult(userId: string, kind: ResultKind, id: string): Promise { const { error } = await supabase.from(TABLES[kind]).delete().eq('user_id', userId).eq('id', id); if (error) throw new Error(error.message); + forgetCopy(userId, `result:${kind}`, id); } /** The guard the website's results pages use: an old or broken row shows a notice instead of a crash. */ diff --git a/apps/mobile/src/lib/sources.test.ts b/apps/mobile/src/lib/sources.test.ts index c07b1ae2a..20ac8fec3 100644 --- a/apps/mobile/src/lib/sources.test.ts +++ b/apps/mobile/src/lib/sources.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it, vi } from 'vitest'; const supabase = vi.hoisted(() => ({ from: vi.fn() })); vi.mock('./supabase', () => ({ supabase })); +// Reads go straight to the network here; offline-cache.test.ts covers the saved copies. +vi.mock('./offline-cache', () => ({ cachedRead: (_user: string, _group: string, _key: string, load: () => Promise) => load(), forgetCopy: vi.fn() })); import { applySourceAssessments } from './sources'; diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts index 9a6283402..f2223808b 100644 --- a/apps/mobile/src/lib/sources.ts +++ b/apps/mobile/src/lib/sources.ts @@ -3,6 +3,7 @@ import { SourceImportOutputSchema, type SourceImportOutput } from '@debatelab/co import { assessedSemantic, isAssessedSource, type SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; import { getEdgeFunctionErrorMessage } from '@debatelab/core/supabase/edge-function-error'; +import { cachedRead, forgetCopy } from './offline-cache'; import { supabase } from './supabase'; /** @@ -61,8 +62,12 @@ function fromRow(row: SourceRow): Source { * the real boundary; the filter keeps a wrong session from mixing accounts. */ -/** One debate's sources, newest first, as the website's Library lists them by default. */ -export async function fetchSources(userId: string, debateId: string): Promise { +/** One debate's sources, newest first, as the website's Library lists them by default. Offline, the saved copy. */ +export function fetchSources(userId: string, debateId: string): Promise { + return cachedRead(userId, 'sources', debateId, () => loadSources(userId, debateId)); +} + +async function loadSources(userId: string, debateId: string): Promise { const { data, error } = await supabase .from('sources') .select(SOURCE_COLUMNS) @@ -73,7 +78,11 @@ export async function fetchSources(userId: string, debateId: string): Promise { +export function fetchSource(userId: string, id: string): Promise { + return cachedRead(userId, 'source', id, () => loadSource(userId, id)); +} + +async function loadSource(userId: string, id: string): Promise { const { data, error } = await supabase .from('sources') .select(SOURCE_COLUMNS) @@ -144,6 +153,7 @@ export async function removeSource(userId: string, id: string, debateId?: string const query = supabase.from('sources').delete().eq('user_id', userId).eq('id', id); const { error } = await (debateId ? query.eq('debate_id', debateId) : query); if (error) throw new Error(error.message); + forgetCopy(userId, 'source', id); notifySourcesChanged(); } From 6fde4a73225566d66569322c2c85ccf7f7946bee Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:39:44 +0000 Subject: [PATCH 03/13] Say when the app shows saved copies A notice leads the tabs, result documents and the flowchart while the device is offline or a read fell back to a saved copy: "Offline: showing saved copies. AI and saving wait for the connection." AI and saving keep failing with their existing network errors. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/_layout.tsx | 4 ++ apps/mobile/src/app/flowchart/index.tsx | 2 + apps/mobile/src/components/document.tsx | 2 + apps/mobile/src/components/icon-paths.ts | 2 + apps/mobile/src/components/offline-notice.tsx | 44 +++++++++++++++++++ apps/mobile/src/components/screen.tsx | 6 ++- src/locales/en.json | 3 ++ src/locales/es.json | 3 ++ src/locales/hu.json | 3 ++ 9 files changed, 68 insertions(+), 1 deletion(-) create mode 100644 apps/mobile/src/components/offline-notice.tsx diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index af126945e..ce74aa4e0 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -11,6 +11,7 @@ import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { AuthProvider, useAuth } from '@/lib/auth'; import { DebateProvider } from '@/lib/debate-context'; +import { watchNetwork } from '@/lib/network-status'; import { SettingsProvider, useSettings } from '@/lib/settings'; import { interFonts } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; @@ -41,6 +42,9 @@ function Root() { if (ready) void SplashScreen.hideAsync(); }, [ready]); + // Offline, reads answer from saved copies and the screens say so. + useEffect(() => watchNetwork(), []); + if (!ready) return null; // Native form sheets with a grabber on iOS. The web preview shows them as full pages. diff --git a/apps/mobile/src/app/flowchart/index.tsx b/apps/mobile/src/app/flowchart/index.tsx index 7393d4863..e5232c7ce 100644 --- a/apps/mobile/src/app/flowchart/index.tsx +++ b/apps/mobile/src/app/flowchart/index.tsx @@ -10,6 +10,7 @@ import { TextButton } from '@/components/buttons'; import { DocumentHeader, type HeaderAction } from '@/components/document-header'; import { FlowColumnsView, FlowLegend, MapTreeView } from '@/components/flowchart-views'; import { Icon } from '@/components/icon'; +import { OfflineNotice } from '@/components/offline-notice'; import { OneOffNotice } from '@/components/one-off-notice'; import { SegmentedControl } from '@/components/segmented-control'; import { Text } from '@/components/text'; @@ -296,6 +297,7 @@ export default function FlowchartScreen() { ) : null} {oneOff ? : null} + + {children} {notice ? ( diff --git a/apps/mobile/src/components/icon-paths.ts b/apps/mobile/src/components/icon-paths.ts index c74077206..675976ed2 100644 --- a/apps/mobile/src/components/icon-paths.ts +++ b/apps/mobile/src/components/icon-paths.ts @@ -51,6 +51,8 @@ export const iconPaths = { send: '', mic: '', alert: '', + cloudOff: + '', } as const; export type IconName = keyof typeof iconPaths; diff --git a/apps/mobile/src/components/offline-notice.tsx b/apps/mobile/src/components/offline-notice.tsx new file mode 100644 index 000000000..69b1c4c74 --- /dev/null +++ b/apps/mobile/src/components/offline-notice.tsx @@ -0,0 +1,44 @@ +import { useTranslation } from 'react-i18next'; +import { StyleSheet, View, type StyleProp, type ViewStyle } from 'react-native'; + +import { Icon } from '@/components/icon'; +import { Text } from '@/components/text'; +import { useOfflineStatus } from '@/lib/network-status'; +import { radius } from '@/theme/colors'; +import { useTheme } from '@/theme/use-theme'; + +/** + * Shown while the device is offline or the screen's data came from saved + * copies. AI and saving stay where they are and fail with their network + * error, so the notice says they wait for the connection. + */ +export function OfflineNotice({ style }: { style?: StyleProp }) { + const { t } = useTranslation(); + const { colors } = useTheme(); + const { offline, savedCopies } = useOfflineStatus(); + if (!offline && !savedCopies) return null; + return ( + + + + {t('mobile.offline.notice')} + + + ); +} + +const styles = StyleSheet.create({ + notice: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + borderRadius: radius.field, + paddingHorizontal: 12, + paddingVertical: 8, + }, + text: { flex: 1, fontSize: 13.5, lineHeight: 18 }, +}); diff --git a/apps/mobile/src/components/screen.tsx b/apps/mobile/src/components/screen.tsx index e747b1e27..a316bbfc6 100644 --- a/apps/mobile/src/components/screen.tsx +++ b/apps/mobile/src/components/screen.tsx @@ -2,9 +2,12 @@ import type { ReactNode } from 'react'; import { ScrollView, StyleSheet } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { OfflineNotice } from './offline-notice'; + /** * A tab's scrolling content. On iOS the native header and tab bar adjust the - * insets automatically; `safeTop` is for screens without a header. + * insets automatically; `safeTop` is for screens without a header. The + * offline notice leads every tab while saved copies are shown. */ export function Screen({ children, safeTop, gap }: { children: ReactNode; safeTop?: boolean; gap?: number }) { const insets = useSafeAreaInsets(); @@ -12,6 +15,7 @@ export function Screen({ children, safeTop, gap }: { children: ReactNode; safeTo + {children} ); diff --git a/src/locales/en.json b/src/locales/en.json index b995e085e..ffb5f98e4 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -2427,6 +2427,9 @@ "common": { "done": "Done" }, + "offline": { + "notice": "Offline: showing saved copies. AI and saving wait for the connection." + }, "debates": { "search": "Search motions", "noMatches": "No debates match your search.", diff --git a/src/locales/es.json b/src/locales/es.json index 99eb6035b..452020620 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -2427,6 +2427,9 @@ "common": { "done": "Listo" }, + "offline": { + "notice": "Sin conexión: se muestran las copias guardadas. La IA y el guardado esperan a la conexión." + }, "debates": { "search": "Buscar mociones", "noMatches": "Ningún debate coincide con tu búsqueda.", diff --git a/src/locales/hu.json b/src/locales/hu.json index aa83b303b..6831bab16 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -2427,6 +2427,9 @@ "common": { "done": "Kész" }, + "offline": { + "notice": "Offline: a mentett másolatokat látod. Az MI és a mentés megvárja a kapcsolatot." + }, "debates": { "search": "Tételek keresése", "noMatches": "Nincs a keresésnek megfelelő vita.", From 462f55ad4600ec2c09a19ae6524518a895eeeb42 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 12:54:29 +0000 Subject: [PATCH 04/13] Prove offline reading in the web export ONLY=offline signs in to staging, reads the Debate tab, the Library and a source, a saved case and a flowchart, then shows the same data under the offline notice twice: with the browser offline while navigating inside the app, and after a reload with every staging request failing. It also plants another account's copies and checks they are removed unseen, and that signing out removes every copy. The logout call is answered locally, so the shared staging session stays valid. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 + apps/mobile/scripts/screenshots.ts | 218 ++++++++++++++++++++++++++++- 2 files changed, 214 insertions(+), 6 deletions(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 5c2f84e46..7ae009822 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -28,6 +28,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Crossfire is the website's typed mode: `crossfire.ts` sends `crossfire` and its signal requests as `src/lib/ai/client.ts` does (Ask's model settings, 75 s and 5 s deadlines) and saves a finished round to `crossfires` in its debate, as CrossfirePage's `recordResult` call; the reducer and schemas are `packages/core/src/crossfire`. The unfinished round is a session slot, so leaving the screen keeps it. Voice rounds need the development build, so the voice switch shows disabled. The website cannot reopen a saved round, so neither can the app. - Flowchart mirrors the website's `FlowchartPage.tsx` and the session store's `saveFlowchart` and `deleteSavedFlowchart` without React Flow: the saved `flowcharts` row keeps the website's React Flow node and edge JSON, and `packages/core/src/flowchart.ts` reads and edits it (starter map, edge kinds from the label or stroke the website writes, role columns, the tree, and edits that keep every field they do not touch). `flowcharts.ts` reads and writes the rows; Save over an opened map updates it, Save as inserts a row as the website's Save does. `flowchart-drafts.ts` holds each debate's open map and unsaved edits in session memory, shared by the screen and its sheets. Maps follow `activeDebateId`: one-off maps are saved with no debate, as on the website. The website's PNG export is a PDF of the Map tree here (`flowchartExportHtml`). - One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm, Factoids and Crossfire forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. +- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts` and `chats.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps and chats) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when it fails, or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` and the Flowchart. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. Nothing is written offline: AI and saving fail with their network errors, and there is no write queue. A read added to those modules goes through `cachedRead`; a delete calls `forgetCopy`. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. @@ -39,6 +40,7 @@ Root `npm run lint` and `npm run typecheck` cover the app. From the repo root: npm run -w @debatelab/mobile typecheck npm run -w @debatelab/mobile export:web PORT= npx tsx apps/mobile/scripts/screenshots.ts +ONLY=offline PORT=18080 STAGING_DEMO_CODE=... npx tsx apps/mobile/scripts/screenshots.ts # offline reading, signed in ``` Screenshots go to `.verify/mobile-shots/`. For signed-in screens, see the header of `scripts/screenshots.ts`. To load the app on a phone, see [docs/development.md](../../docs/development.md#mobile-app). diff --git a/apps/mobile/scripts/screenshots.ts b/apps/mobile/scripts/screenshots.ts index d4c27ca49..a04722783 100644 --- a/apps/mobile/scripts/screenshots.ts +++ b/apps/mobile/scripts/screenshots.ts @@ -8,6 +8,7 @@ * PORT= npx tsx apps/mobile/scripts/screenshots.ts * * ONLY=iphone-light-en limits a run to the combinations whose name starts with it. + * ONLY=offline runs only the offline reading check (see shootOfflineScreens). * * Signed-in screens need a staging session. With STAGING_DEMO_CODE set, the * script signs in once through the real sign-in screen and also checks that a @@ -57,7 +58,7 @@ import { chromium, type Browser, type BrowserContext, type BrowserContextOptions import { createReadStream, existsSync, statSync } from 'node:fs'; import { mkdir, writeFile } from 'node:fs/promises'; import { createServer, type Server } from 'node:http'; -import { extname, join, normalize } from 'node:path'; +import { dirname, extname, join, normalize } from 'node:path'; import { SUPABASE_PROJECTS } from '../../../src/lib/supabase/projects'; @@ -95,7 +96,8 @@ const TYPES: Record = { function serve(): Promise { const server = createServer((req, res) => { const path = normalize(decodeURIComponent(new URL(req.url ?? '/', ORIGIN).pathname)).replace(/^(\.\.[/\\])+/, ''); - const candidates = [join(DIST, path), join(DIST, `${path}.html`), join(DIST, path, 'index.html')]; + // A dynamic route such as /case/ is exported once, as case/[id].html. + const candidates = [join(DIST, path), join(DIST, `${path}.html`), join(DIST, path, 'index.html'), join(DIST, dirname(path), '[id].html')]; const file = candidates.find((candidate) => candidate.startsWith(DIST) && existsSync(candidate) && statSync(candidate).isFile()); if (!file) { res.writeHead(404).end('Not found'); @@ -110,11 +112,16 @@ function serve(): Promise { }); } -/** Fails the run on page errors and console errors, which leave plausible-looking but broken screenshots. */ -function watchErrors(page: Page, problems: string[], label: string) { +/** + * Fails the run on page errors and console errors, which leave plausible-looking but broken screenshots. + * While `offline()` is true, the browser's own "Failed to load resource" errors are expected. + */ +function watchErrors(page: Page, problems: string[], label: string, offline = () => false) { page.on('pageerror', (error) => problems.push(`${label}: ${error.message}`)); page.on('console', (message) => { - if (message.type() === 'error') problems.push(`${label}: ${message.text()}`); + if (message.type() !== 'error') return; + if (offline() && message.text().startsWith('Failed to load resource')) return; + problems.push(`${label}: ${message.text()}`); }); } @@ -981,6 +988,203 @@ async function checkOfflineColdStart(browser: Browser, state: StorageState, prob if (!tabsShown || signInShown || !stored || !refreshed) throw new Error('Offline cold start signed the user out'); } +const OFFLINE_RUNS = [ + { prefix: 'iphone-light-en', scheme: 'light', language: 'en' }, + { prefix: 'iphone-dark-hu', scheme: 'dark', language: 'hu' }, +] as const; +const TABS = { + debate: /^(Debate|Vita)$/, + prepare: /^(Prepare|Felkészülés)$/, + library: /^(Library|Könyvtár)$/, + you: /^(You|Profil)$/, +}; +const CACHE_PREFIX = 'debatelab.cache.v1'; +const SAVED_CASE_ID = '00000000-0000-4000-8000-0000000ca5ed'; +// Stands in for another account whose saved copies are still on the device. +const OTHER_USER_ID = '00000000-0000-4000-8000-0000000a11ce'; + +/** Answers the read of one made-up saved case (FAKE_CASE). Nothing is written. */ +async function fakeSavedCase(page: Page) { + await page.route(`${REST}/cases*`, (route) => { + const request = route.request(); + if (request.method() !== 'GET' || new URL(request.url()).searchParams.get('id') !== `eq.${SAVED_CASE_ID}`) return route.continue(); + const row = { id: SAVED_CASE_ID, title: 'Car-free city centres', side: 'pro', debate_id: null, created_at: FAKE_CASE.metadata.generatedAt, input: {}, output: FAKE_CASE }; + const single = (request.headers().accept ?? '').includes('vnd.pgrst.object'); + return route.fulfill({ status: 200, json: single ? row : [row] }); + }); +} + +// Screens under the current one stay mounted on web, with their own notice. +const shownNotice = (page: Page) => page.locator('[data-testid="offline-notice"]:visible').first(); +const cacheKeys = (page: Page) => page.evaluate((prefix) => Object.keys(localStorage).filter((key) => key.startsWith(prefix)), CACHE_PREFIX); + +async function openFlowchart(page: Page) { + await page.getByTestId('prepare-tool-flowchart').waitFor({ timeout: 30_000 }); + await settle(page); + await page.getByTestId('prepare-tool-flowchart').click(); + await page.getByTestId('flow-card').first().waitFor({ timeout: 30_000 }); +} + +/** + * Offline reading (ONLY=offline). Online, the run reads the Debate tab, the + * Library and a source, a saved case and a flowchart, so the app keeps + * copies. Then, first with the device offline (expo-network sees + * navigator.onLine turn false) and navigating inside the app, and then + * online but with every staging request failing and the page reloaded, the + * same screens must show the same data under the offline notice. The case + * and the maps are made up (FAKE_CASE, FAKE_FLOWCHART_ROWS); the debate and + * the Library are the account's own. Nothing is written to staging. + * Playwright's offline mode also blocks the local server, so the reload + * fails staging requests instead. + */ +async function shootOfflineScreens(browser: Browser, state: StorageState, problems: string[], run: (typeof OFFLINE_RUNS)[number], isolation: boolean) { + const { prefix, scheme, language } = run; + const context = await browser.newContext({ ...DEVICES.iphone, colorScheme: scheme, locale: LOCALES[language], storageState: state }); + const page = await context.newPage(); + try { + return await readOffline(context, page, problems, run, isolation); + } catch (error) { + await shoot(page, `${prefix}-offline-failed`); + const spinners = await page.evaluate(() => Array.from(document.querySelectorAll('[role="progressbar"]'), (node) => node.getAttribute('aria-label') ?? '')); + console.error(`${prefix}: failed at ${page.url()} with ${spinners.length} progress indicators ${JSON.stringify(spinners)}`); + throw error; + } finally { + await context.close(); + } +} + +async function readOffline(context: BrowserContext, page: Page, problems: string[], run: (typeof OFFLINE_RUNS)[number], isolation: boolean) { + const { prefix, language } = run; + let offline = false; + watchErrors(page, problems, `${prefix} offline`, () => offline); + page.on('dialog', (dialog) => void dialog.dismiss()); + await pinSettings(page, language); + await fakeSavedCase(page); + await fakeFlowcharts(page); + + // Online: read each screen once. + await page.goto(`${ORIGIN}/`); + await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 }); + await page.waitForFunction(() => !document.querySelector('[data-testid="debate-counts"]')?.textContent?.includes('–')); + await settle(page); + const motion = await page.getByTestId('current-debate-motion').textContent(); + const counts = await page.getByTestId('debate-counts').textContent(); + await page.getByRole('tab', { name: TABS.library }).click(); + await page.getByTestId('source-row').first().waitFor({ timeout: 30_000 }); + await settle(page); + const firstSource = await page.getByTestId('source-row').first().textContent(); + await page.getByTestId('source-row').first().click(); + await page.getByTestId('source-sheet').getByRole('heading').waitFor({ timeout: 30_000 }); + await settle(page); + await page.goto(`${ORIGIN}/case/${SAVED_CASE_ID}`); + await page.getByTestId('case-document').waitFor({ timeout: 30_000 }); + await settle(page); + await page.goto(`${ORIGIN}/prepare`); + await openFlowchart(page); + await settle(page); + if (await page.getByTestId('offline-notice').count()) throw new Error('The offline notice showed while online'); + + const userId = await page.evaluate((key) => (JSON.parse(localStorage.getItem(key) ?? '{}') as { user?: { id?: string } }).user?.id ?? '', SESSION_KEY); + const kept = await cacheKeys(page); + const foreign = kept.filter((key) => key !== `${CACHE_PREFIX}.owner` && !key.includes(userId)); + console.log(`${prefix}: ${kept.length} saved-copy keys after reading online, ${foreign.length} not under the signed-in account`); + if (!userId || kept.length < 5 || foreign.length > 0) throw new Error('The app did not keep its saved copies under the signed-in account'); + + const sameData = async (label: string) => { + await page.waitForFunction(() => !document.querySelector('[data-testid="debate-counts"]')?.textContent?.includes('–'), undefined, { timeout: 30_000 }); + await settle(page); + if ((await page.getByTestId('current-debate-motion').textContent()) !== motion) throw new Error(`${label}: the motion differs from the online read`); + if ((await page.getByTestId('debate-counts').textContent()) !== counts) throw new Error(`${label}: the counts differ from the online read`); + }; + + // The device goes offline; the app navigates without reloading. + offline = true; + await context.setOffline(true); + await shownNotice(page).waitFor({ timeout: 10_000 }); + await page.goBack(); + await page.getByRole('tab', { name: TABS.debate }).click(); + await page.getByTestId('current-debate-motion').waitFor({ timeout: 15_000 }); + await shownNotice(page).waitFor(); + await sameData('device offline'); + await shoot(page, `${prefix}-offline-debate`); + await page.getByRole('tab', { name: TABS.library }).click(); + await page.getByTestId('source-row').first().waitFor({ timeout: 15_000 }); + if ((await page.getByTestId('source-row').first().textContent()) !== firstSource) throw new Error('device offline: the Library differs'); + await shoot(page, `${prefix}-offline-library`); + await page.getByTestId('source-row').first().click(); + await page.getByTestId('source-sheet').getByRole('heading').waitFor({ timeout: 15_000 }); + await page.waitForTimeout(700); + await shoot(page, `${prefix}-offline-source`); + + // Back online, but staging cannot be reached, and the page reloads. + await context.setOffline(false); + const toBackend = (url: URL) => url.host === STAGING_HOST; + await page.route(toBackend, (route) => route.abort('internetdisconnected')); + await page.goto(`${ORIGIN}/`); + await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 }); + await shownNotice(page).waitFor({ timeout: 15_000 }); + await sameData('staging unreachable'); + await shoot(page, `${prefix}-offline-reload-debate`); + await page.getByRole('tab', { name: TABS.library }).click(); + await page.getByTestId('source-row').first().waitFor({ timeout: 15_000 }); + await shoot(page, `${prefix}-offline-reload-library`); + await page.goto(`${ORIGIN}/case/${SAVED_CASE_ID}`); + await page.getByTestId('case-document').waitFor({ timeout: 30_000 }); + await shownNotice(page).waitFor(); + await page.waitForTimeout(400); + await shoot(page, `${prefix}-offline-case`); + await page.goto(`${ORIGIN}/prepare`); + await openFlowchart(page); + await page.getByTestId('flowchart-screen').locator('[data-testid="offline-notice"]:visible').waitFor(); + await shoot(page, `${prefix}-offline-flowchart`); + let shots = 7; + + if (isolation) { + // Another account's copies on this device: move every copy to OTHER_USER_ID and make it the owner. + await page.evaluate(({ prefix: cachePrefix, from, to }) => { + for (const key of Object.keys(localStorage)) { + if (!key.startsWith(cachePrefix) || !key.includes(from)) continue; + const value = localStorage.getItem(key) ?? ''; + localStorage.setItem(key.replaceAll(from, to), value.replaceAll(from, to)); + localStorage.removeItem(key); + } + localStorage.setItem(`${cachePrefix}.owner`, to); + }, { prefix: CACHE_PREFIX, from: userId, to: OTHER_USER_ID }); + const planted = (await cacheKeys(page)).filter((key) => key.includes(OTHER_USER_ID)).length; + await page.goto(`${ORIGIN}/`); + await page.getByTestId('auth-settled').waitFor({ state: 'attached', timeout: 60_000 }); + await settle(page); + await page.waitForFunction( + ({ prefix: cachePrefix, other }) => !Object.keys(localStorage).some((key) => key.startsWith(cachePrefix) && key.includes(other)), + { prefix: CACHE_PREFIX, other: OTHER_USER_ID }, + { timeout: 15_000 }, + ); + const leaked = (await page.locator('body').innerText()).includes(motion ?? '\u0000'); + const motionShown = await page.getByTestId('current-debate-motion').count(); + await shoot(page, `${prefix}-offline-other-account`); + shots += 1; + console.log(`${prefix}: planted ${planted} copies for another account; after a start as this account: left ${(await cacheKeys(page)).filter((key) => key.includes(OTHER_USER_ID)).length}, motion shown ${motionShown > 0}`); + if (planted === 0 || leaked || motionShown > 0) throw new Error("This account was shown another account's saved copies"); + + // Signing out removes every copy. The logout request is answered here, so the staging session stays valid for other runs. + await page.unroute(toBackend); + offline = false; + await page.route(`https://${STAGING_HOST}/auth/v1/logout*`, (route) => route.fulfill({ status: 204 })); + await page.goto(`${ORIGIN}/`); + await page.getByTestId('current-debate-motion').waitFor({ timeout: 30_000 }); + await settle(page); + const before = (await cacheKeys(page)).length; + await page.getByRole('tab', { name: TABS.you }).click(); + await page.getByRole('button', { name: /^(Sign out|Kijelentkezés)$/ }).click(); + await page.waitForURL('**/sign-in', { timeout: 30_000 }); + await page.waitForFunction((prefix) => !Object.keys(localStorage).some((key) => key.startsWith(prefix)), CACHE_PREFIX, { timeout: 15_000 }); + console.log(`${prefix}: ${before} saved-copy keys before sign-out, 0 after`); + if (before === 0) throw new Error('No saved copies to clear before sign-out'); + } + + return shots; +} + async function main() { if (!Number.isInteger(PORT) || PORT <= 0) throw new Error('Set PORT to a free port'); if (!existsSync(join(DIST, 'index.html'))) throw new Error('Run `npm run -w @debatelab/mobile export:web` first'); @@ -992,7 +1196,9 @@ async function main() { let shots = 0; try { const session = DEMO_CODE ? await signIn(browser, problems) : null; - if (session) await checkOfflineColdStart(browser, session, problems); + if (session && ONLY === 'offline') { + for (const [index, run] of OFFLINE_RUNS.entries()) shots += await shootOfflineScreens(browser, session, problems, run, index === 0); + } else if (session) await checkOfflineColdStart(browser, session, problems); else console.log('STAGING_DEMO_CODE is not set: signed-in screens skipped'); for (const [device, options] of Object.entries(DEVICES)) { From ad4ee4d87a5a9f92361ec42b4b97e7aeb917df94 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:08:26 +0000 Subject: [PATCH 05/13] Keep judged Sparring rounds readable offline Sparring landed after the offline cache, so its saved rounds and the Practice list now read through the same per-account cache, and a deleted round drops its copy. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/src/lib/offline-cache.ts | 3 +++ apps/mobile/src/lib/sparring.ts | 12 +++++++++++- 3 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index a6601a05a..67f629f45 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -29,7 +29,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens the Flowchart on the Current Debate's map, above the round, which keeps running. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them. Voice rounds need the development build, so the voice switch shows disabled. - Flowchart mirrors the website's `FlowchartPage.tsx` and the session store's `saveFlowchart` and `deleteSavedFlowchart` without React Flow: the saved `flowcharts` row keeps the website's React Flow node and edge JSON, and `packages/core/src/flowchart.ts` reads and edits it (starter map, edge kinds from the label or stroke the website writes, role columns, the tree, and edits that keep every field they do not touch). `flowcharts.ts` reads and writes the rows; Save over an opened map updates it, Save as inserts a row as the website's Save does. `flowchart-drafts.ts` holds each debate's open map and unsaved edits in session memory, shared by the screen and its sheets. Maps follow `activeDebateId`: one-off maps are saved with no debate, as on the website. The website's PNG export is a PDF of the Map tree here (`flowchartExportHtml`). - One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm, Factoids, Crossfire and Sparring forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. -- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts` and `chats.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps and chats) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when it fails, or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` and the Flowchart. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. Nothing is written offline: AI and saving fail with their network errors, and there is no write queue. A read added to those modules goes through `cachedRead`; a delete calls `forgetCopy`. +- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts` and `sparring.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats and judged Sparring rounds) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when it fails, or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` and the Flowchart. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. Nothing is written offline: AI and saving fail with their network errors, and there is no write queue. A read added to those modules goes through `cachedRead`; a delete calls `forgetCopy`. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. diff --git a/apps/mobile/src/lib/offline-cache.ts b/apps/mobile/src/lib/offline-cache.ts index c5c5d5620..989e8676c 100644 --- a/apps/mobile/src/lib/offline-cache.ts +++ b/apps/mobile/src/lib/offline-cache.ts @@ -33,6 +33,9 @@ const LIMITS = { flowchart: 40, chats: 20, chat: 40, + /** Judged Sparring rounds per debate, and each saved round. */ + 'sparring-rounds': 20, + sparring: 40, } as const; export type CacheGroup = keyof typeof LIMITS; diff --git a/apps/mobile/src/lib/sparring.ts b/apps/mobile/src/lib/sparring.ts index fec9c74ab..17fed3c2b 100644 --- a/apps/mobile/src/lib/sparring.ts +++ b/apps/mobile/src/lib/sparring.ts @@ -14,6 +14,7 @@ import * as Crypto from 'expo-crypto'; import { AIError, functionOutput, postFunction, PROVIDER } from './ai-request'; import type { FeatureConfig } from './feature-config'; import { sessionSlot } from './session-memory'; +import { cachedRead, forgetCopy } from './offline-cache'; import { supabase } from './supabase'; /** @@ -87,8 +88,12 @@ export async function attachSparring(id: string, debateId: string, side: Sparrin if (error) throw new Error(error.message); } -/** A saved, judged round; null when there is none or it does not check out. */ +/** A saved, judged round; null when there is none or it does not check out. Offline, the saved copy. */ export async function fetchSparringRound(userId: string, id: string): Promise { + return cachedRead(userId, 'sparring', id, () => loadSparringRound(userId, id)); +} + +async function loadSparringRound(userId: string, id: string): Promise { const { data, error } = await supabase.from('sparring_sessions').select(SPARRING_SESSION_COLUMNS).eq('user_id', userId).eq('id', id).maybeSingle(); if (error) throw new Error(error.message); return data ? parseSavedSparringRows([data])[0] ?? null : null; @@ -106,6 +111,10 @@ export interface SparringRoundSummary { * null, newest first, as the website's history lists them. */ export async function fetchSparringRounds(userId: string, debateId: string | null, limit: number): Promise { + return cachedRead(userId, 'sparring-rounds', `${debateId ?? 'one-off'}:${limit}`, () => loadSparringRounds(userId, debateId, limit)); +} + +async function loadSparringRounds(userId: string, debateId: string | null, limit: number): Promise { let query = supabase.from('sparring_sessions').select('id,motion,user_side,updated_at').eq('user_id', userId).eq('status', 'complete'); query = debateId ? query.eq('debate_id', debateId) : query.is('debate_id', null); const { data, error } = await query.order('updated_at', { ascending: false }).limit(limit); @@ -121,6 +130,7 @@ export async function fetchSparringRounds(userId: string, debateId: string | nul export async function deleteSparringRound(userId: string, id: string): Promise { const { error } = await supabase.from('sparring_sessions').delete().eq('user_id', userId).eq('id', id); if (error) throw new Error(error.message); + forgetCopy(userId, 'sparring', id); } /** A new round's id. The server takes it as the saved row's id, so it must be a UUID. */ From e51911ae179276ce23a0903f6be80269ab4d691e Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 13:56:27 +0000 Subject: [PATCH 06/13] Fall back to saved copies only when the server can't be reached A read used to answer from its saved copy for any error, so an expired session, a policy refusal or a server error looked like being offline and showed old data. The cached loaders now throw readFailed(error, status), and cachedRead falls back only when supabase-js got no answer (status 0: no connection, DNS failure, timeout). Everything else is thrown as it is. The saved-copies notice now counts each copy a screen shows, and a copy stops counting only when that same data is read from the server again, so a successful read elsewhere no longer hides it. Also adds afterWrite and updateCopy, which the writers use in the next commit; forgetCopy now goes through afterWrite. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/chats.ts | 10 +- apps/mobile/src/lib/debates.ts | 18 +-- apps/mobile/src/lib/flowcharts.ts | 10 +- apps/mobile/src/lib/network-status.ts | 22 +-- apps/mobile/src/lib/offline-cache.test.ts | 48 +++++- apps/mobile/src/lib/offline-cache.ts | 175 ++++++++++++++++++---- apps/mobile/src/lib/results.ts | 6 +- apps/mobile/src/lib/sources.ts | 10 +- apps/mobile/src/lib/sparring.ts | 10 +- 9 files changed, 230 insertions(+), 79 deletions(-) diff --git a/apps/mobile/src/lib/chats.ts b/apps/mobile/src/lib/chats.ts index 47addcd2e..d047719f3 100644 --- a/apps/mobile/src/lib/chats.ts +++ b/apps/mobile/src/lib/chats.ts @@ -1,6 +1,6 @@ import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; -import { cachedRead } from './offline-cache'; +import { cachedRead, readFailed } from './offline-cache'; import { supabase } from './supabase'; /** @@ -65,8 +65,8 @@ async function loadChats(userId: string, debateId: string | null): Promise ({ id: row.id, title: row.title, @@ -84,8 +84,8 @@ async function loadChat(userId: string, chatId: string): Promise<{ debateId: str supabase.from('chats').select('debate_id').eq('user_id', userId).eq('id', chatId).single(), supabase.from('messages').select('id,role,content,output,created_at').eq('chat_id', chatId).order('created_at', { ascending: true }), ]); - if (chat.error) throw new Error(chat.error.message); - if (messages.error) throw new Error(messages.error.message); + if (chat.error) throw readFailed(chat.error, chat.status); + if (messages.error) throw readFailed(messages.error, messages.status); return { debateId: (chat.data as { debate_id: string | null }).debate_id ?? null, messages: ((messages.data ?? []) as MessageRow[]).map((row) => ({ diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index 17a22e383..a1ddd17be 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -1,4 +1,4 @@ -import { cachedRead } from './offline-cache'; +import { cachedRead, readFailed } from './offline-cache'; import { supabase } from './supabase'; /** @@ -87,8 +87,8 @@ async function loadDebates(userId: string): Promise<{ debates: Debate[]; current supabase.from('debates').select(DEBATE_COLUMNS).eq('user_id', userId).order('last_activity_at', { ascending: false }), supabase.from('current_debates').select('debate_id').eq('user_id', userId).maybeSingle(), ]); - if (debatesResult.error) throw new Error(debatesResult.error.message); - if (currentResult.error) throw new Error(currentResult.error.message); + if (debatesResult.error) throw readFailed(debatesResult.error, debatesResult.status); + if (currentResult.error) throw readFailed(currentResult.error, currentResult.status); const debates = ((debatesResult.data ?? []) as DebateRow[]).map(fromRow); const currentId = (currentResult.data as { debate_id?: string } | null)?.debate_id ?? null; return { @@ -166,8 +166,8 @@ async function countRows(userId: string, table: string, debateId: string, sides? .eq('user_id', userId) .eq('debate_id', debateId); if (sides) query = query.in('side', sides); - const { count, error } = await query; - if (error) throw new Error(error.message); + const { count, error, status } = await query; + if (error) throw readFailed(error, status); return count ?? 0; } @@ -212,8 +212,8 @@ async function fetchResultRows(userId: string, debateId: string, table: ResultTa .eq('user_id', userId) .eq('debate_id', debateId); if (sides) query = query.in('side', sides); - const { data, error } = await query.order('created_at', { ascending: false }).limit(limit); - if (error) throw new Error(error.message); + const { data, error, status } = await query.order('created_at', { ascending: false }).limit(limit); + if (error) throw readFailed(error, status); return ((data ?? []) as { id: string; title: string; created_at: string }[]).map((row): DebateItem => ({ id: row.id, kind: KIND_BY_TABLE[table], @@ -234,7 +234,7 @@ async function loadRecentWork(userId: string, debateId: string, side: DebateSide ...RESULT_TABLES.map((table) => fetchResultRows(userId, debateId, table, SIDES_FOR[side], limit)), (async () => { // The website lists a judged round under its motion and the side the student spoke, ordered by its last update. - const { data, error } = await supabase + const { data, error, status } = await supabase .from('sparring_sessions') .select('id,motion,updated_at') .eq('user_id', userId) @@ -243,7 +243,7 @@ async function loadRecentWork(userId: string, debateId: string, side: DebateSide .eq('status', 'complete') .order('updated_at', { ascending: false }) .limit(limit); - if (error) throw new Error(error.message); + if (error) throw readFailed(error, status); return ((data ?? []) as { id: string; motion: string; updated_at: string }[]).map((row): DebateItem => ({ id: row.id, kind: 'sparring', diff --git a/apps/mobile/src/lib/flowcharts.ts b/apps/mobile/src/lib/flowcharts.ts index a7eee4949..05c0113e0 100644 --- a/apps/mobile/src/lib/flowcharts.ts +++ b/apps/mobile/src/lib/flowcharts.ts @@ -1,6 +1,6 @@ import { readFlowchart, type FlowchartDocument } from '@debatelab/core/flowchart'; -import { cachedRead, forgetCopy } from './offline-cache'; +import { cachedRead, forgetCopy, readFailed } from './offline-cache'; import { supabase } from './supabase'; /** @@ -51,8 +51,8 @@ export function fetchFlowchartList(userId: string, debateId: string | null): Pro async function loadFlowchartList(userId: string, debateId: string | null): Promise { const query = supabase.from('flowcharts').select('id,title,created_at').eq('user_id', userId); - const { data, error } = await (debateId ? query.eq('debate_id', debateId) : query.is('debate_id', null)).order('created_at', { ascending: false }); - if (error) throw new Error(error.message); + const { data, error, status } = await (debateId ? query.eq('debate_id', debateId) : query.is('debate_id', null)).order('created_at', { ascending: false }); + if (error) throw readFailed(error, status); return ((data ?? []) as { id: string; title: string; created_at: string }[]).map((row) => ({ id: row.id, title: row.title, @@ -65,8 +65,8 @@ export function fetchFlowchart(userId: string, id: string): Promise { - const { data, error } = await supabase.from('flowcharts').select(COLUMNS).eq('user_id', userId).eq('id', id).maybeSingle(); - if (error) throw new Error(error.message); + const { data, error, status } = await supabase.from('flowcharts').select(COLUMNS).eq('user_id', userId).eq('id', id).maybeSingle(); + if (error) throw readFailed(error, status); return data ? fromRow(data as FlowchartRow) : null; } diff --git a/apps/mobile/src/lib/network-status.ts b/apps/mobile/src/lib/network-status.ts index 9dec47e09..829f34996 100644 --- a/apps/mobile/src/lib/network-status.ts +++ b/apps/mobile/src/lib/network-status.ts @@ -3,15 +3,15 @@ import { useSyncExternalStore } from 'react'; /** * Whether the app is reading saved copies instead of the network. expo-network - * says when the device is offline; a read that fell back to a saved copy says - * so too, which covers a network that is up but cannot reach the server. The - * next read that reaches the server clears that. + * says when the device is offline; offline-cache.ts says while any screen's + * data is a saved copy, which covers a network that is up but cannot reach + * the server. Each copy counts until its own data is read from the server again. */ export interface OfflineStatus { /** expo-network reports no connection. */ offline: boolean; - /** The last read was answered from a saved copy. */ + /** Some data on screen is a saved copy, not the server's answer. */ savedCopies: boolean; } @@ -34,12 +34,8 @@ export function isKnownOffline(): boolean { return status.offline; } -export function noteSavedCopy() { - update({ savedCopies: true }); -} - -export function noteNetworkRead() { - update({ savedCopies: false }); +export function setSavedCopiesShown(savedCopies: boolean) { + update({ savedCopies }); } /** Follows the device's connection until the returned function is called. */ @@ -64,6 +60,10 @@ function subscribe(listener: () => void) { }; } +export function getOfflineStatus(): OfflineStatus { + return status; +} + export function useOfflineStatus(): OfflineStatus { - return useSyncExternalStore(subscribe, () => status, () => status); + return useSyncExternalStore(subscribe, getOfflineStatus, getOfflineStatus); } diff --git a/apps/mobile/src/lib/offline-cache.test.ts b/apps/mobile/src/lib/offline-cache.test.ts index 804594aa6..1094c1160 100644 --- a/apps/mobile/src/lib/offline-cache.test.ts +++ b/apps/mobile/src/lib/offline-cache.test.ts @@ -22,7 +22,8 @@ vi.mock('expo-network', () => ({ getNetworkStateAsync: async () => ({ isConnected: true, isInternetReachable: true }), })); -const offlineFailure = () => Promise.reject(new Error('Network request failed')); +// A read that got no answer from the server, as supabase-js reports it (status 0). Set by load(). +let offlineFailure: () => Promise; function deferred() { let resolve!: (value: T) => void; @@ -37,6 +38,7 @@ async function load() { vi.resetModules(); const cache = await import('./offline-cache'); const status = await import('./network-status'); + offlineFailure = () => Promise.reject(cache.readFailed({ message: 'TypeError: Network request failed' }, 0)); return { ...cache, ...status }; } @@ -52,9 +54,9 @@ describe('saved copies between accounts', () => { await expect(cachedRead('user-a', 'sources', 'debate-1', offlineFailure)).resolves.toEqual(['A secret source']); setCacheOwner('user-b'); - await expect(cachedRead('user-b', 'sources', 'debate-1', offlineFailure)).rejects.toThrow('Network request failed'); + await expect(cachedRead('user-b', 'sources', 'debate-1', offlineFailure)).rejects.toThrow('TypeError: Network request failed'); // A's own key is not readable either once B owns the cache. - await expect(cachedRead('user-a', 'sources', 'debate-1', offlineFailure)).rejects.toThrow('Network request failed'); + await expect(cachedRead('user-a', 'sources', 'debate-1', offlineFailure)).rejects.toThrow('TypeError: Network request failed'); expect([...storage.keys()].some((key) => key.includes('user-a'))).toBe(false); expect([...storage.values()].some((value) => value.includes('A secret source'))).toBe(false); }); @@ -158,3 +160,43 @@ describe('offline', () => { await expect(cachedRead('user-a', 'sources', 'debate-20', offlineFailure)).resolves.toEqual([20]); }); }); + +describe('which failures fall back', () => { + it('serves the copy when no answer came back from the server', async () => { + const { cachedRead, setCacheOwner } = await load(); + setCacheOwner('user-a'); + await cachedRead('user-a', 'sources', 'debate-1', async () => ['source']); + await expect(cachedRead('user-a', 'sources', 'debate-1', offlineFailure)).resolves.toEqual(['source']); + }); + + it('throws a refused session, a policy error or any other server answer instead of serving the copy', async () => { + const { cachedRead, readFailed, setCacheOwner } = await load(); + setCacheOwner('user-a'); + await cachedRead('user-a', 'sources', 'debate-1', async () => ['source']); + const refusals = [ + readFailed({ message: 'JWT expired' }, 401), + readFailed({ message: 'permission denied for table sources' }, 403), + readFailed({ message: 'Server error' }, 500), + new Error('Flowchart rows did not check out'), + ]; + for (const refusal of refusals) { + await expect(cachedRead('user-a', 'sources', 'debate-1', () => Promise.reject(refusal))).rejects.toBe(refusal); + } + }); +}); + +describe('the saved-copies notice', () => { + it('stays while another screen still shows a copy, and goes when that copy is read from the server', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'source', 'source-1', async () => 'text'); + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => ['source']); + await cache.cachedRead('user-a', 'source', 'source-1', offlineFailure); + const shown = () => cache.getOfflineStatus().savedCopies; + expect(shown()).toBe(true); + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => ['source']); + expect(shown()).toBe(true); + await cache.cachedRead('user-a', 'source', 'source-1', async () => 'text'); + expect(shown()).toBe(false); + }); +}); diff --git a/apps/mobile/src/lib/offline-cache.ts b/apps/mobile/src/lib/offline-cache.ts index 989e8676c..f11fece0f 100644 --- a/apps/mobile/src/lib/offline-cache.ts +++ b/apps/mobile/src/lib/offline-cache.ts @@ -1,14 +1,17 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -import { isKnownOffline, noteNetworkRead, noteSavedCopy } from './network-status'; +import { isKnownOffline, setSavedCopiesShown } from './network-status'; import { createSerialQueue } from './serial-queue'; /** * Saved copies of what the app reads, so debates, sources, saved results and * past rounds stay readable without a connection. A read goes to the network - * first and keeps a copy of the answer; when the network fails, or the device - * is known to be offline, the copy answers instead. Nothing is written - * offline: saving waits for the network. + * first and keeps a copy of the answer; when no answer comes back from the + * server, or the device is known to be offline, the copy answers instead. A + * server that answers with an error (a refused session, a policy, a bad + * request) is not a connection problem, so that error is thrown as it is. + * Nothing is written offline: saving waits for the network, and every write + * that succeeds updates or drops the copies it changed (`afterWrite`). * * The copies belong to one account. Each is stored under its user id and * checked against it when read, and all of them are removed when the account @@ -50,6 +53,28 @@ const entryKey = (userId: string, group: CacheGroup, key: string) => `${PREFIX}: type Index = Record; +/** + * A read that failed. `unreachable` when no answer came back from the server: + * no connection, a DNS failure or a timeout. Only those fall back to a copy. + */ +export class ReadError extends Error { + constructor(message: string, readonly unreachable: boolean) { + super(message); + this.name = 'ReadError'; + } +} + +/** + * The error to throw for a failed Supabase read. supabase-js answers a request + * that never reached the server with status 0, and the server's own refusals + * (401, 403, 404, 406, 5xx) with their HTTP status. + */ +export function readFailed(error: { message: string }, status: number): ReadError { + return new ReadError(error.message, status === 0); +} + +const isUnreachable = (cause: unknown) => cause instanceof ReadError && cause.unreachable; + const queue = createSerialQueue(); // undefined until the auth provider names the first owner, so that first call always checks storage. let owner: string | null | undefined; @@ -59,6 +84,11 @@ let generation = 0; // from a read that started later, so a slow answer never overwrites a newer one. let tickets = 0; const newest = new Map(); +// A write that changed many keys of a group at once stops every read that started before it. +const floors = new Map(); +// The copies screens are showing right now instead of the server's answer. +// A key leaves when it is read from the server again, or when a write drops it. +const shown = new Map(); let index: { userId: string; entries: Index } | null = null; function warn(what: string) { @@ -75,6 +105,9 @@ export function setCacheOwner(userId: string | null) { owner = userId; generation += 1; newest.clear(); + floors.clear(); + shown.clear(); + setSavedCopiesShown(false); index = null; void queue(async () => { if ((await AsyncStorage.getItem(OWNER_KEY)) === userId && userId !== null) return; @@ -113,39 +146,59 @@ function readCopy(userId: string, group: CacheGroup, key: string): Promise<{ function writeCopy(userId: string, group: CacheGroup, key: string, value: unknown, ticket: number, gen: number): Promise { const storageKey = entryKey(userId, group, key); return queue(async () => { - if (gen !== generation || userId !== owner || ticket < (newest.get(storageKey) ?? 0)) return; + if (gen !== generation || userId !== owner || ticket < (newest.get(storageKey) ?? 0) || ticket < (floors.get(group) ?? 0)) return; newest.set(storageKey, ticket); - const raw = JSON.stringify({ userId, value }); - const entries = await loadIndex(userId); - if (raw.length > MAX_ENTRY_CHARS) { - delete entries[storageKey]; - await AsyncStorage.multiSet([[indexKey(userId), JSON.stringify(entries)]]); - await AsyncStorage.removeItem(storageKey); - return; - } - entries[storageKey] = { group, at: Date.now() }; - const dropped = Object.entries(entries) - .filter(([, entry]) => entry.group === group) - .sort(([, a], [, b]) => b.at - a.at) - .slice(LIMITS[group]) - .map(([droppedKey]) => droppedKey); - for (const droppedKey of dropped) delete entries[droppedKey]; - await AsyncStorage.multiSet([[storageKey, raw], [indexKey(userId), JSON.stringify(entries)]]); - if (dropped.length > 0) await AsyncStorage.multiRemove(dropped); + await storeEntry(userId, group, storageKey, value); }); } +/** Stores a copy and drops the group's oldest beyond its limit. Runs inside the queue. */ +async function storeEntry(userId: string, group: CacheGroup, storageKey: string, value: unknown) { + const raw = JSON.stringify({ userId, value }); + const entries = await loadIndex(userId); + if (raw.length > MAX_ENTRY_CHARS) { + await removeEntries(userId, [storageKey]); + return; + } + entries[storageKey] = { group, at: Date.now() }; + const dropped = Object.entries(entries) + .filter(([, entry]) => entry.group === group) + .sort(([, a], [, b]) => b.at - a.at) + .slice(LIMITS[group]) + .map(([droppedKey]) => droppedKey); + for (const droppedKey of dropped) delete entries[droppedKey]; + await AsyncStorage.multiSet([[storageKey, raw], [indexKey(userId), JSON.stringify(entries)]]); + if (dropped.length > 0) await AsyncStorage.multiRemove(dropped); +} + +/** Removes copies and their index entries. Runs inside the queue. */ +async function removeEntries(userId: string, storageKeys: string[]) { + if (storageKeys.length === 0) return; + const entries = await loadIndex(userId); + for (const storageKey of storageKeys) delete entries[storageKey]; + await AsyncStorage.multiSet([[indexKey(userId), JSON.stringify(entries)]]); + await AsyncStorage.multiRemove(storageKeys); +} + +function noteShown(storageKey: string, group: CacheGroup, fromCopy: boolean) { + if (fromCopy) shown.set(storageKey, group); + else shown.delete(storageKey); + setSavedCopiesShown(shown.size > 0); +} + /** * Reads through the network and keeps a copy for this account. Offline, or - * when the read fails, answers from the copy; with no copy, fails as the read did. + * when no answer came back from the server, answers from the copy; with no + * copy, or when the server answered with an error, fails as the read did. */ export async function cachedRead(userId: string, group: CacheGroup, key: string, load: () => Promise): Promise { const ticket = ++tickets; const gen = generation; + const storageKey = entryKey(userId, group, key); if (isKnownOffline()) { const copy = await readCopy(userId, group, key).catch(() => undefined); if (copy) { - noteSavedCopy(); + if (gen === generation) noteShown(storageKey, group, true); return copy.value; } } @@ -153,26 +206,82 @@ export async function cachedRead(userId: string, group: CacheGroup, key: stri try { value = await load(); } catch (cause) { + if (!isUnreachable(cause)) throw cause; const copy = await readCopy(userId, group, key).catch(() => undefined); if (!copy) throw cause; - noteSavedCopy(); + if (gen === generation) noteShown(storageKey, group, true); return copy.value; } - noteNetworkRead(); + if (gen === generation) noteShown(storageKey, group, false); writeCopy(userId, group, key, value, ticket, gen).catch(warn('Could not keep a saved copy')); return value; } -/** Drops a copy after the row was deleted, and stops any read already running from saving it again. */ -export function forgetCopy(userId: string, group: CacheGroup, key: string) { +/** + * The copies a write changed: one key, every key of the group that names an + * id (a debate, `one-off` or a row), or the whole group. + */ +export type CopyScope = { group: CacheGroup; key: string } | { group: CacheGroup; about: string } | { group: CacheGroup }; + +const keyOf = (userId: string, group: CacheGroup, storageKey: string) => storageKey.slice(entryKey(userId, group, '').length); + +function inScope(userId: string, scope: CopyScope, group: CacheGroup, storageKey: string): boolean { + if (scope.group !== group) return false; + const key = keyOf(userId, group, storageKey); + if ('key' in scope) return key === scope.key; + if ('about' in scope) return key.split(/[:,]/).includes(scope.about); + return true; +} + +/** + * Called after a write succeeded. Drops the copies it changed, so an offline + * restart cannot show what was there before, and stops every read that + * started before the write from saving its older answer. + */ +export function afterWrite(userId: string, scopes: CopyScope[]) { + const gen = generation; + if (userId !== owner) return; + const ticket = ++tickets; + for (const scope of scopes) { + if ('key' in scope) newest.set(entryKey(userId, scope.group, scope.key), ticket); + else floors.set(scope.group, ticket); + } + for (const [storageKey, group] of shown) { + if (scopes.some((scope) => inScope(userId, scope, group, storageKey))) shown.delete(storageKey); + } + setSavedCopiesShown(shown.size > 0); + void queue(async () => { + if (gen !== generation || userId !== owner) return; + const entries = await loadIndex(userId); + const dropped = Object.entries(entries) + .filter(([storageKey, entry]) => scopes.some((scope) => inScope(userId, scope, entry.group, storageKey))) + .map(([storageKey]) => storageKey); + await removeEntries(userId, dropped); + }).catch(warn('Could not drop saved copies')); +} + +/** + * Called after a write succeeded when the new value of one copy is known: + * `update` gets the copy (undefined when there is none) and returns its + * replacement, or undefined to drop it. Reads that started before this call + * cannot save over it. + */ +export function updateCopy(userId: string, group: CacheGroup, key: string, update: (copy: T | undefined) => T | undefined) { const storageKey = entryKey(userId, group, key); const gen = generation; + if (userId !== owner) return; newest.set(storageKey, ++tickets); void queue(async () => { if (gen !== generation || userId !== owner) return; - const entries = await loadIndex(userId); - delete entries[storageKey]; - await AsyncStorage.multiSet([[indexKey(userId), JSON.stringify(entries)]]); - await AsyncStorage.removeItem(storageKey); - }).catch(warn('Could not drop a saved copy')); + const raw = await AsyncStorage.getItem(storageKey); + const entry = raw ? (JSON.parse(raw) as { userId?: unknown; value: T }) : null; + const next = update(entry?.userId === userId ? entry.value : undefined); + if (next === undefined) await removeEntries(userId, [storageKey]); + else await storeEntry(userId, group, storageKey, next); + }).catch(warn('Could not update a saved copy')); +} + +/** Drops one copy after its row was deleted. */ +export function forgetCopy(userId: string, group: CacheGroup, key: string) { + afterWrite(userId, [{ group, key }]); } diff --git a/apps/mobile/src/lib/results.ts b/apps/mobile/src/lib/results.ts index 985e29082..c2c5b497f 100644 --- a/apps/mobile/src/lib/results.ts +++ b/apps/mobile/src/lib/results.ts @@ -18,7 +18,7 @@ import type { DebateContextRequest } from '@debatelab/core/contract/debate-conte import { AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; import type { FeatureConfig } from './feature-config'; -import { cachedRead, forgetCopy } from './offline-cache'; +import { cachedRead, forgetCopy, readFailed } from './offline-cache'; import { addSource, applySourceAssessments, fetchSourceIdsInFolder, removeSource } from './sources'; import { supabase } from './supabase'; @@ -254,13 +254,13 @@ export function fetchResult(userId: string, kind: K, id: s } async function loadResult(userId: string, kind: K, id: string): Promise | null> { - const { data, error } = await supabase + const { data, error, status } = await supabase .from(TABLES[kind]) .select('id,title,side,debate_id,created_at,input,output') .eq('user_id', userId) .eq('id', id) .maybeSingle(); - if (error) throw new Error(error.message); + if (error) throw readFailed(error, status); if (!data) return null; const row = data as ResultRow; return { diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts index f2223808b..594eda48a 100644 --- a/apps/mobile/src/lib/sources.ts +++ b/apps/mobile/src/lib/sources.ts @@ -3,7 +3,7 @@ import { SourceImportOutputSchema, type SourceImportOutput } from '@debatelab/co import { assessedSemantic, isAssessedSource, type SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; import { getEdgeFunctionErrorMessage } from '@debatelab/core/supabase/edge-function-error'; -import { cachedRead, forgetCopy } from './offline-cache'; +import { cachedRead, forgetCopy, readFailed } from './offline-cache'; import { supabase } from './supabase'; /** @@ -68,13 +68,13 @@ export function fetchSources(userId: string, debateId: string): Promise { - const { data, error } = await supabase + const { data, error, status } = await supabase .from('sources') .select(SOURCE_COLUMNS) .eq('user_id', userId) .eq('debate_id', debateId) .order('created_at', { ascending: false }); - if (error) throw new Error(error.message); + if (error) throw readFailed(error, status); return ((data ?? []) as SourceRow[]).map(fromRow); } @@ -83,13 +83,13 @@ export function fetchSource(userId: string, id: string): Promise } async function loadSource(userId: string, id: string): Promise { - const { data, error } = await supabase + const { data, error, status } = await supabase .from('sources') .select(SOURCE_COLUMNS) .eq('user_id', userId) .eq('id', id) .maybeSingle(); - if (error) throw new Error(error.message); + if (error) throw readFailed(error, status); return data ? fromRow(data as SourceRow) : null; } diff --git a/apps/mobile/src/lib/sparring.ts b/apps/mobile/src/lib/sparring.ts index 17fed3c2b..b49c03287 100644 --- a/apps/mobile/src/lib/sparring.ts +++ b/apps/mobile/src/lib/sparring.ts @@ -14,7 +14,7 @@ import * as Crypto from 'expo-crypto'; import { AIError, functionOutput, postFunction, PROVIDER } from './ai-request'; import type { FeatureConfig } from './feature-config'; import { sessionSlot } from './session-memory'; -import { cachedRead, forgetCopy } from './offline-cache'; +import { cachedRead, forgetCopy, readFailed } from './offline-cache'; import { supabase } from './supabase'; /** @@ -94,8 +94,8 @@ export async function fetchSparringRound(userId: string, id: string): Promise { - const { data, error } = await supabase.from('sparring_sessions').select(SPARRING_SESSION_COLUMNS).eq('user_id', userId).eq('id', id).maybeSingle(); - if (error) throw new Error(error.message); + const { data, error, status } = await supabase.from('sparring_sessions').select(SPARRING_SESSION_COLUMNS).eq('user_id', userId).eq('id', id).maybeSingle(); + if (error) throw readFailed(error, status); return data ? parseSavedSparringRows([data])[0] ?? null : null; } @@ -117,8 +117,8 @@ export async function fetchSparringRounds(userId: string, debateId: string | nul async function loadSparringRounds(userId: string, debateId: string | null, limit: number): Promise { let query = supabase.from('sparring_sessions').select('id,motion,user_side,updated_at').eq('user_id', userId).eq('status', 'complete'); query = debateId ? query.eq('debate_id', debateId) : query.is('debate_id', null); - const { data, error } = await query.order('updated_at', { ascending: false }).limit(limit); - if (error) throw new Error(error.message); + const { data, error, status } = await query.order('updated_at', { ascending: false }).limit(limit); + if (error) throw readFailed(error, status); return ((data ?? []) as { id: string; motion: string; user_side: SparringSide; updated_at: string }[]).map((row) => ({ id: row.id, motion: row.motion, From 31a5aff2f12a453052ff2dc1afd8196cf0d5822d Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 14:01:49 +0000 Subject: [PATCH 07/13] Update saved copies after every successful write Writes left the saved copies as they were, so offline the app showed the Current Debate before a switch, a map before it was saved over, and Library lists, counts and recent work that still held deleted rows. Every write that succeeds now calls afterWrite with the copies it changed: one key, or the keys of a group that start with a prefix and name a debate. Those copies are dropped, and reads of them that started before the write cannot save their older answer. Where the new value is known, updateCopy stores it instead: switching or creating a debate updates the saved list and Current Debate, and saving a map stores the map as saved. Deletes read back the row's debate so its lists drop too. Judging a Sparring round and filing it in a debate drop the round lists it appears in. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/app/sparring/index.tsx | 6 +- apps/mobile/src/lib/chats.ts | 6 +- apps/mobile/src/lib/crossfire.test.ts | 1 + apps/mobile/src/lib/crossfire.ts | 3 + apps/mobile/src/lib/debates.ts | 35 ++++++++- apps/mobile/src/lib/flowcharts.ts | 23 ++++-- apps/mobile/src/lib/offline-cache.test.ts | 87 ++++++++++++++++++++++- apps/mobile/src/lib/offline-cache.ts | 54 +++++++++----- apps/mobile/src/lib/results.test.ts | 2 +- apps/mobile/src/lib/results.ts | 9 ++- apps/mobile/src/lib/sources.test.ts | 2 +- apps/mobile/src/lib/sources.ts | 12 +++- apps/mobile/src/lib/sparring.ts | 23 ++++-- 13 files changed, 218 insertions(+), 45 deletions(-) diff --git a/apps/mobile/src/app/sparring/index.tsx b/apps/mobile/src/app/sparring/index.tsx index 455139cca..d95a5279f 100644 --- a/apps/mobile/src/app/sparring/index.tsx +++ b/apps/mobile/src/app/sparring/index.tsx @@ -41,7 +41,7 @@ import { fetchFeatureConfig } from '@/lib/feature-config'; import { decideMotionTarget, shouldPrefillMotion } from '@/lib/motion'; import { chooseMotionTarget } from '@/lib/motion-gate'; import { useSettings } from '@/lib/settings'; -import { attachSparring, judgeSparring, newSparringId, SparringResponseError, sparringDraft, sparringTurn } from '@/lib/sparring'; +import { attachSparring, judgeSparring, newSparringId, noteJudgedRound, SparringResponseError, sparringDraft, sparringTurn } from '@/lib/sparring'; import { formatClock } from '@/lib/timer'; import { tapTurnStart, useKeepAwakeWhile } from '@/lib/timer-signals'; import { radius } from '@/theme/colors'; @@ -152,6 +152,8 @@ export default function SparringScreen() { { action: 'score', sessionId: snapshot.id, motion: snapshot.motion, userSide: snapshot.userSide, format: snapshot.format, difficulty: snapshot.difficulty, speeches: snapshot.speeches, language: outputLanguage }, { config, debateId: snapshot.debateId ?? null, signal: controller.signal }, ); + // The server saved the round as judged, so this account's saved lists change whatever happens next. + noteJudgedRound(forUser, snapshot.id, snapshot.debateId ?? null); if (controller.signal.aborted || owner.current !== forUser) return; const latest = sessionRef.current; const completed = latest?.id === snapshot.id ? completeSparringSession(latest, scorecard, Date.now()) : null; @@ -160,7 +162,7 @@ export default function SparringScreen() { commit(completed); // The server saved the judged round before it answered; file it in its debate. if (completed.debateId) { - attachSparring(completed.id, completed.debateId, completed.userSide).catch((cause: unknown) => { + attachSparring(forUser, completed.id, completed.debateId, completed.userSide).catch((cause: unknown) => { console.warn('Could not file the round in its debate:', cause instanceof Error ? cause.message : cause); if (live.current && owner.current === forUser) setNotice(t('debate.recordFailed')); }); diff --git a/apps/mobile/src/lib/chats.ts b/apps/mobile/src/lib/chats.ts index d047719f3..68bd28f64 100644 --- a/apps/mobile/src/lib/chats.ts +++ b/apps/mobile/src/lib/chats.ts @@ -1,6 +1,7 @@ import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; -import { cachedRead, readFailed } from './offline-cache'; +import { debateListScopes } from './debates'; +import { afterWrite, cachedRead, readFailed } from './offline-cache'; import { supabase } from './supabase'; /** @@ -117,6 +118,7 @@ export async function saveChat(userId: string, record: ChatRecord, messages: Cha if (error || !data) throw new Error(error?.message ?? 'Could not save the chat'); chatId = (data as { id: string }).id; debateId = activeDebateId; + afterWrite(userId, [{ group: 'chats', key: debateId }, ...debateListScopes(debateId, 'chat')]); } const pending = messages.slice(record.persisted); const { error } = await supabase.from('messages').insert(pending.map((message) => ({ @@ -128,5 +130,7 @@ export async function saveChat(userId: string, record: ChatRecord, messages: Cha }))); // The chat exists now; keep it so the next save adds these messages to it instead of creating another. if (error) throw new ChatSaveError(error.message, { chatId, debateId, persisted: record.persisted }); + // The conversation and the message count in its debate's list changed. + afterWrite(userId, [{ group: 'chat', key: chatId }, { group: 'chats', key: debateId ?? 'one-off' }]); return { chatId, debateId, persisted: messages.length }; } diff --git a/apps/mobile/src/lib/crossfire.test.ts b/apps/mobile/src/lib/crossfire.test.ts index 3e7aa4b0f..b19160531 100644 --- a/apps/mobile/src/lib/crossfire.test.ts +++ b/apps/mobile/src/lib/crossfire.test.ts @@ -6,6 +6,7 @@ const fetchMock = vi.hoisted(() => vi.fn()); const getSession = vi.hoisted(() => vi.fn(() => new Promise(() => {}))); vi.mock('./supabase', () => ({ supabase: { auth: { getSession } } })); vi.mock('expo/fetch', () => ({ fetch: fetchMock })); +vi.mock('./offline-cache', () => ({ afterWrite: vi.fn(), cachedRead: vi.fn(), readFailed: vi.fn(), updateCopy: vi.fn() })); import { AIError, postFunction, untilAborted } from './ai-request'; import { withTurnDeadline } from './crossfire'; diff --git a/apps/mobile/src/lib/crossfire.ts b/apps/mobile/src/lib/crossfire.ts index 496fa5403..ac5b3b14c 100644 --- a/apps/mobile/src/lib/crossfire.ts +++ b/apps/mobile/src/lib/crossfire.ts @@ -14,7 +14,9 @@ import { import type { DebateContextRequest } from '@debatelab/core/contract/debate-context'; import { abortError, AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; +import { debateListScopes } from './debates'; import type { FeatureConfig } from './feature-config'; +import { afterWrite } from './offline-cache'; import { sessionSlot } from './session-memory'; import { supabase } from './supabase'; @@ -180,6 +182,7 @@ export async function recordCrossfire( .select('id') .single(); if (error || !data) throw new Error(error?.message ?? 'Could not save the crossfire'); + afterWrite(userId, debateListScopes(debateId, 'crossfire')); return (data as { id: string }).id; } diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index a1ddd17be..d68dceda7 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -1,4 +1,4 @@ -import { cachedRead, readFailed } from './offline-cache'; +import { cachedRead, readFailed, updateCopy, type CopyScope } from './offline-cache'; import { supabase } from './supabase'; /** @@ -65,11 +65,22 @@ export function sortDebates(debates: Debate[]): Debate[] { return [...debates].sort((a, b) => b.lastActivityAt - a.lastActivityAt); } +interface DebateList { + debates: Debate[]; + currentId: string | null; +} + +/** Keeps the saved debates list in step with a write, so an offline restart opens what the student chose. */ +function keepInCopy(userId: string, change: (list: DebateList) => DebateList) { + updateCopy(userId, 'debates', 'all', (copy) => copy && change(copy)); +} + async function setCurrentPointer(userId: string, debateId: string) { const { error } = await supabase .from('current_debates') .upsert({ user_id: userId, debate_id: debateId, updated_at: new Date().toISOString() }, { onConflict: 'user_id' }); if (error) throw new Error(error.message); + keepInCopy(userId, (list) => ({ ...list, currentId: list.debates.some((debate) => debate.id === debateId) ? debateId : list.currentId })); } /** @@ -78,11 +89,11 @@ async function setCurrentPointer(userId: string, debateId: string) { */ /** Every debate, newest activity first, and the current one when it is still in the list. Offline, the saved copy. */ -export function fetchDebates(userId: string): Promise<{ debates: Debate[]; currentId: string | null }> { +export function fetchDebates(userId: string): Promise { return cachedRead(userId, 'debates', 'all', () => loadDebates(userId)); } -async function loadDebates(userId: string): Promise<{ debates: Debate[]; currentId: string | null }> { +async function loadDebates(userId: string): Promise { const [debatesResult, currentResult] = await Promise.all([ supabase.from('debates').select(DEBATE_COLUMNS).eq('user_id', userId).order('last_activity_at', { ascending: false }), supabase.from('current_debates').select('debate_id').eq('user_id', userId).maybeSingle(), @@ -111,6 +122,7 @@ export async function createDebate( .single(); if (error || !data) throw new Error(error?.message ?? 'Could not create debate'); const debate = fromRow(data as DebateRow); + keepInCopy(userId, (list) => ({ ...list, debates: sortDebates([debate, ...list.debates.filter((item) => item.id !== debate.id)]) })); await setCurrentPointer(userId, debate.id); return debate; } @@ -123,6 +135,8 @@ export async function switchDebate(userId: string, debateId: string): Promise ({ ...list, debates: sortDebates(list.debates.map((item) => (item.id === debateId ? { ...item, lastActivityAt: now } : item))) })); return now; } @@ -257,6 +271,21 @@ async function loadRecentWork(userId: string, debateId: string, side: DebateSide /** The Prepare tools' tables. */ const PREPARE_TABLES = ['cases', 'brainstorms', 'factoids', 'flowcharts'] as const satisfies readonly ResultTable[]; +const PREPARE_KINDS = new Set(PREPARE_TABLES.map((table) => KIND_BY_TABLE[table])); + +/** + * The saved counts and lists of one debate that a write changed: adding or + * removing a source changes its counts; a saved result or round also changes + * the recent work, and a Prepare result the Prepare list. + */ +export function debateListScopes(debateId: string | null, change: 'source' | DebateItemKind): CopyScope[] { + if (!debateId) return []; + const scopes: CopyScope[] = [{ group: 'debate-lists', key: `counts:${debateId}` }]; + if (change === 'source') return [...scopes, { group: 'debate-lists', prefix: 'source-counts:', about: debateId }]; + scopes.push({ group: 'debate-lists', prefix: `recent:${debateId}:` }); + if (PREPARE_KINDS.has(change)) scopes.push({ group: 'debate-lists', prefix: `prepare:${debateId}:` }); + return scopes; +} /** A debate's saved Prepare results for both sides, newest first, for the Prepare tab. */ export function fetchPrepareWork(userId: string, debateId: string, limit: number): Promise { diff --git a/apps/mobile/src/lib/flowcharts.ts b/apps/mobile/src/lib/flowcharts.ts index 05c0113e0..28b3b48ac 100644 --- a/apps/mobile/src/lib/flowcharts.ts +++ b/apps/mobile/src/lib/flowcharts.ts @@ -1,6 +1,7 @@ import { readFlowchart, type FlowchartDocument } from '@debatelab/core/flowchart'; -import { cachedRead, forgetCopy, readFailed } from './offline-cache'; +import { debateListScopes } from './debates'; +import { afterWrite, cachedRead, readFailed, updateCopy } from './offline-cache'; import { supabase } from './supabase'; /** @@ -78,7 +79,10 @@ export async function insertFlowchart(userId: string, input: { title: string; de .select(COLUMNS) .single(); if (error || !data) throw new Error(error?.message ?? 'Could not save the flowchart'); - return fromRow(data as FlowchartRow); + const saved = fromRow(data as FlowchartRow); + afterWrite(userId, [{ group: 'flowcharts', key: saved.debateId ?? 'one-off' }, ...debateListScopes(saved.debateId, 'flowchart')]); + updateCopy(userId, 'flowchart', saved.id, () => saved); + return saved; } /** Saves over a map the app opened. Resolves false when the row is gone, for example deleted on the website. */ @@ -88,13 +92,20 @@ export async function updateFlowchart(userId: string, id: string, doc: Flowchart .update({ nodes: doc.nodes, edges: doc.edges, updated_at: new Date().toISOString() }) .eq('user_id', userId) .eq('id', id) - .select('id'); + .select(COLUMNS); if (error) throw new Error(error.message); - return (data ?? []).length > 0; + const row = ((data ?? []) as FlowchartRow[])[0]; + // The saved copy becomes the map as saved, so offline it opens this version; a gone row drops it. + updateCopy(userId, 'flowchart', id, () => (row ? fromRow(row) : undefined)); + return Boolean(row); } export async function deleteFlowchart(userId: string, id: string): Promise { - const { error } = await supabase.from('flowcharts').delete().eq('user_id', userId).eq('id', id); + const { data, error } = await supabase.from('flowcharts').delete().eq('user_id', userId).eq('id', id).select('debate_id'); if (error) throw new Error(error.message); - forgetCopy(userId, 'flowchart', id); + const deletedFrom = ((data ?? []) as { debate_id: string | null }[]).map((row) => row.debate_id); + afterWrite(userId, [ + { group: 'flowchart', key: id }, + ...deletedFrom.flatMap((debate) => [{ group: 'flowcharts' as const, key: debate ?? 'one-off' }, ...debateListScopes(debate, 'flowchart')]), + ]); } diff --git a/apps/mobile/src/lib/offline-cache.test.ts b/apps/mobile/src/lib/offline-cache.test.ts index 1094c1160..7f33062e0 100644 --- a/apps/mobile/src/lib/offline-cache.test.ts +++ b/apps/mobile/src/lib/offline-cache.test.ts @@ -1,6 +1,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const storage = vi.hoisted(() => new Map()); +// A Supabase client whose queries answer with the next queued result, for the modules that write. +const supabase = vi.hoisted(() => ({ results: [] as unknown[], from: () => undefined as unknown })); +vi.mock('./supabase', () => ({ supabase })); const network = vi.hoisted(() => ({ listener: null as ((state: { isConnected: boolean; isInternetReachable: boolean }) => void) | null })); vi.mock('@react-native-async-storage/async-storage', () => ({ @@ -33,17 +36,27 @@ function deferred() { return { promise, resolve }; } +function query(result: unknown): unknown { + const builder: unknown = new Proxy({}, { + get: (_target, prop) => (prop === 'then' ? (resolve: (value: unknown) => void) => resolve(result) : () => builder), + }); + return builder; +} +supabase.from = () => query(supabase.results.shift() ?? { data: null, error: null, status: 200 }); + // A fresh module per test: the owner and tickets are module state, as in the app. async function load() { vi.resetModules(); const cache = await import('./offline-cache'); const status = await import('./network-status'); + const writers = { ...(await import('./debates')), ...(await import('./sources')), ...(await import('./flowcharts')) }; offlineFailure = () => Promise.reject(cache.readFailed({ message: 'TypeError: Network request failed' }, 0)); - return { ...cache, ...status }; + return { ...cache, ...status, ...writers }; } beforeEach(() => { storage.clear(); + supabase.results = []; }); describe('saved copies between accounts', () => { @@ -114,12 +127,12 @@ describe('stale answers', () => { }); it('a read already running when a row is deleted does not bring its copy back', async () => { - const { cachedRead, forgetCopy, setCacheOwner } = await load(); + const { cachedRead, afterWrite, setCacheOwner } = await load(); setCacheOwner('user-a'); await cachedRead('user-a', 'result:case', 'case-1', async () => 'saved case'); const slow = deferred(); const running = cachedRead('user-a', 'result:case', 'case-1', () => slow.promise); - forgetCopy('user-a', 'result:case', 'case-1'); + afterWrite('user-a', [{ group: 'result:case', key: 'case-1' }]); slow.resolve('saved case'); await running; await expect(cachedRead('user-a', 'result:case', 'case-1', offlineFailure)).rejects.toThrow(); @@ -200,3 +213,71 @@ describe('the saved-copies notice', () => { expect(shown()).toBe(false); }); }); + +describe('after a write', () => { + const flowchartRow = (title: string) => ({ id: 'map-1', title, debate_id: 'debate-1', nodes: [], edges: [], created_at: '2026-10-14T10:00:00.000Z' }); + + it('a read started before saving over a map cannot bring the old map back, and offline opens the saved one', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + supabase.results.push({ data: flowchartRow('Before'), error: null, status: 200 }); + await cache.fetchFlowchart('user-a', 'map-1'); + const slow = deferred(); + const before = cache.cachedRead('user-a', 'flowchart', 'map-1', () => slow.promise); + supabase.results.push({ data: [flowchartRow('After')], error: null, status: 200 }); + await expect(cache.updateFlowchart('user-a', 'map-1', { nodes: [], edges: [] })).resolves.toBe(true); + slow.resolve({ title: 'Before' }); + await before; + await expect(cache.cachedRead('user-a', 'flowchart', 'map-1', offlineFailure)).resolves.toMatchObject({ title: 'After' }); + }); + + it('a change to a debate stops a list read already running for it, and leaves other debates alone', async () => { + const { cachedRead, afterWrite, debateListScopes, setCacheOwner } = await load(); + setCacheOwner('user-a'); + await cachedRead('user-a', 'debate-lists', 'recent:debate-1:pro:5', async () => ['old']); + await cachedRead('user-a', 'debate-lists', 'recent:debate-2:pro:5', async () => ['other']); + const slow = deferred(); + const running = cachedRead('user-a', 'debate-lists', 'recent:debate-1:pro:5', () => slow.promise); + afterWrite('user-a', debateListScopes('debate-1', 'case')); + slow.resolve(['old']); + await running; + await expect(cachedRead('user-a', 'debate-lists', 'recent:debate-1:pro:5', offlineFailure)).rejects.toThrow(); + await expect(cachedRead('user-a', 'debate-lists', 'recent:debate-2:pro:5', offlineFailure)).resolves.toEqual(['other']); + }); + + it('deleting a source drops it from the Library and the counts that held it', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => [{ id: 'source-1', content: 'full text' }]); + await cache.cachedRead('user-a', 'source', 'source-1', async () => ({ id: 'source-1', content: 'full text' })); + await cache.cachedRead('user-a', 'debate-lists', 'counts:debate-1', async () => ({ sources: 1 })); + await cache.cachedRead('user-a', 'debate-lists', 'source-counts:debate-1,debate-2', async () => ({ 'debate-1': 1 })); + await cache.cachedRead('user-a', 'sources', 'debate-2', async () => [{ id: 'source-2' }]); + supabase.results.push({ data: [{ debate_id: 'debate-1' }], error: null, status: 200 }); + await cache.removeSource('user-a', 'source-1'); + await cache.cachedRead('user-a', 'sources', 'flush', offlineFailure).catch(() => undefined); + expect([...storage.values()].some((value) => value.includes('full text'))).toBe(false); + await expect(cache.cachedRead('user-a', 'debate-lists', 'counts:debate-1', offlineFailure)).rejects.toThrow(); + await expect(cache.cachedRead('user-a', 'debate-lists', 'source-counts:debate-1,debate-2', offlineFailure)).rejects.toThrow(); + await expect(cache.cachedRead('user-a', 'sources', 'debate-2', offlineFailure)).resolves.toEqual([{ id: 'source-2' }]); + }); + + it('switching debates changes the saved Current Debate, so an offline restart opens the new one', async () => { + let cache = await load(); + cache.setCacheOwner('user-a'); + const row = (id: string, at: string) => ({ id, motion: id, settings: {}, instructions: '', last_activity_at: at, created_at: at }); + supabase.results.push( + { data: [row('debate-1', '2026-10-14T10:00:00.000Z'), row('debate-2', '2026-10-13T10:00:00.000Z')], error: null, status: 200 }, + { data: { debate_id: 'debate-1' }, error: null, status: 200 }, + ); + await cache.fetchDebates('user-a'); + await cache.switchDebate('user-a', 'debate-2'); + await cache.cachedRead('user-a', 'sources', 'flush', offlineFailure).catch(() => undefined); + + cache = await load(); + cache.setCacheOwner('user-a'); + const offline = await cache.cachedRead<{ debates: { id: string }[]; currentId: string }>('user-a', 'debates', 'all', offlineFailure); + expect(offline.currentId).toBe('debate-2'); + expect(offline.debates[0].id).toBe('debate-2'); + }); +}); diff --git a/apps/mobile/src/lib/offline-cache.ts b/apps/mobile/src/lib/offline-cache.ts index f11fece0f..15d9cfb6e 100644 --- a/apps/mobile/src/lib/offline-cache.ts +++ b/apps/mobile/src/lib/offline-cache.ts @@ -84,8 +84,10 @@ let generation = 0; // from a read that started later, so a slow answer never overwrites a newer one. let tickets = 0; const newest = new Map(); -// A write that changed many keys of a group at once stops every read that started before it. -const floors = new Map(); +// A write that changed several keys at once stops the reads of those keys that +// started before it. Kept only while such a read is still running. +let floors: { scope: CopyScope; ticket: number }[] = []; +const running = new Set(); // The copies screens are showing right now instead of the server's answer. // A key leaves when it is read from the server again, or when a write drops it. const shown = new Map(); @@ -105,7 +107,7 @@ export function setCacheOwner(userId: string | null) { owner = userId; generation += 1; newest.clear(); - floors.clear(); + floors = []; shown.clear(); setSavedCopiesShown(false); index = null; @@ -146,12 +148,24 @@ function readCopy(userId: string, group: CacheGroup, key: string): Promise<{ function writeCopy(userId: string, group: CacheGroup, key: string, value: unknown, ticket: number, gen: number): Promise { const storageKey = entryKey(userId, group, key); return queue(async () => { - if (gen !== generation || userId !== owner || ticket < (newest.get(storageKey) ?? 0) || ticket < (floors.get(group) ?? 0)) return; - newest.set(storageKey, ticket); - await storeEntry(userId, group, storageKey, value); + try { + if (gen !== generation || userId !== owner || ticket < (newest.get(storageKey) ?? 0)) return; + if (floors.some((floor) => floor.ticket > ticket && inScope(userId, floor.scope, group, storageKey))) return; + newest.set(storageKey, ticket); + await storeEntry(userId, group, storageKey, value); + } finally { + settle(ticket); + } }); } +/** A read is done with its ticket; floors that no running read started before are dropped. */ +function settle(ticket: number) { + running.delete(ticket); + const oldest = running.size > 0 ? Math.min(...running) : Infinity; + floors = floors.filter((floor) => floor.ticket > oldest); +} + /** Stores a copy and drops the group's oldest beyond its limit. Runs inside the queue. */ async function storeEntry(userId: string, group: CacheGroup, storageKey: string, value: unknown) { const raw = JSON.stringify({ userId, value }); @@ -195,9 +209,11 @@ export async function cachedRead(userId: string, group: CacheGroup, key: stri const ticket = ++tickets; const gen = generation; const storageKey = entryKey(userId, group, key); + running.add(ticket); if (isKnownOffline()) { const copy = await readCopy(userId, group, key).catch(() => undefined); if (copy) { + settle(ticket); if (gen === generation) noteShown(storageKey, group, true); return copy.value; } @@ -206,6 +222,7 @@ export async function cachedRead(userId: string, group: CacheGroup, key: stri try { value = await load(); } catch (cause) { + settle(ticket); if (!isUnreachable(cause)) throw cause; const copy = await readCopy(userId, group, key).catch(() => undefined); if (!copy) throw cause; @@ -218,19 +235,24 @@ export async function cachedRead(userId: string, group: CacheGroup, key: stri } /** - * The copies a write changed: one key, every key of the group that names an - * id (a debate, `one-off` or a row), or the whole group. + * The copies a write changed: one `key`, or every key of the group that starts + * with `prefix` and names the id `about` (a debate, `one-off` or a row), or + * the whole group. */ -export type CopyScope = { group: CacheGroup; key: string } | { group: CacheGroup; about: string } | { group: CacheGroup }; +export interface CopyScope { + group: CacheGroup; + key?: string; + prefix?: string; + about?: string; +} const keyOf = (userId: string, group: CacheGroup, storageKey: string) => storageKey.slice(entryKey(userId, group, '').length); function inScope(userId: string, scope: CopyScope, group: CacheGroup, storageKey: string): boolean { if (scope.group !== group) return false; const key = keyOf(userId, group, storageKey); - if ('key' in scope) return key === scope.key; - if ('about' in scope) return key.split(/[:,]/).includes(scope.about); - return true; + if (scope.key !== undefined) return key === scope.key; + return (!scope.prefix || key.startsWith(scope.prefix)) && (!scope.about || key.split(/[:,]/).includes(scope.about)); } /** @@ -243,8 +265,8 @@ export function afterWrite(userId: string, scopes: CopyScope[]) { if (userId !== owner) return; const ticket = ++tickets; for (const scope of scopes) { - if ('key' in scope) newest.set(entryKey(userId, scope.group, scope.key), ticket); - else floors.set(scope.group, ticket); + if (scope.key !== undefined) newest.set(entryKey(userId, scope.group, scope.key), ticket); + else if (running.size > 0) floors.push({ scope, ticket }); } for (const [storageKey, group] of shown) { if (scopes.some((scope) => inScope(userId, scope, group, storageKey))) shown.delete(storageKey); @@ -281,7 +303,3 @@ export function updateCopy(userId: string, group: CacheGroup, key: string, up }).catch(warn('Could not update a saved copy')); } -/** Drops one copy after its row was deleted. */ -export function forgetCopy(userId: string, group: CacheGroup, key: string) { - afterWrite(userId, [{ group, key }]); -} diff --git a/apps/mobile/src/lib/results.test.ts b/apps/mobile/src/lib/results.test.ts index 1f4b761bf..0ec8300db 100644 --- a/apps/mobile/src/lib/results.test.ts +++ b/apps/mobile/src/lib/results.test.ts @@ -4,7 +4,7 @@ const addSource = vi.hoisted(() => vi.fn()); vi.mock('./supabase', () => ({ supabase: {} })); vi.mock('expo/fetch', () => ({ fetch: vi.fn() })); -vi.mock('./offline-cache', () => ({ cachedRead: vi.fn(), forgetCopy: vi.fn() })); +vi.mock('./offline-cache', () => ({ cachedRead: vi.fn(), afterWrite: vi.fn(), updateCopy: vi.fn(), readFailed: vi.fn() })); vi.mock('./sources', () => ({ addSource, applySourceAssessments: vi.fn(), fetchSourceIdsInFolder: vi.fn(), removeSource: vi.fn() })); import { autosaveSources, readBrainstorm, readFactoids } from './results'; diff --git a/apps/mobile/src/lib/results.ts b/apps/mobile/src/lib/results.ts index c2c5b497f..3ed56ca91 100644 --- a/apps/mobile/src/lib/results.ts +++ b/apps/mobile/src/lib/results.ts @@ -18,7 +18,8 @@ import type { DebateContextRequest } from '@debatelab/core/contract/debate-conte import { AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; import type { FeatureConfig } from './feature-config'; -import { cachedRead, forgetCopy, readFailed } from './offline-cache'; +import { debateListScopes } from './debates'; +import { afterWrite, cachedRead, readFailed } from './offline-cache'; import { addSource, applySourceAssessments, fetchSourceIdsInFolder, removeSource } from './sources'; import { supabase } from './supabase'; @@ -224,6 +225,7 @@ export async function recordResult( .select('id') .single(); if (error || !data) throw new Error(error?.message ?? `Could not save the ${kind}`); + afterWrite(userId, debateListScopes(record.debateId, kind)); return (data as { id: string }).id; } @@ -276,9 +278,10 @@ async function loadResult(userId: string, kind: K, id: str } export async function deleteResult(userId: string, kind: ResultKind, id: string): Promise { - const { error } = await supabase.from(TABLES[kind]).delete().eq('user_id', userId).eq('id', id); + const { data, error } = await supabase.from(TABLES[kind]).delete().eq('user_id', userId).eq('id', id).select('debate_id'); if (error) throw new Error(error.message); - forgetCopy(userId, `result:${kind}`, id); + const deletedFrom = ((data ?? []) as { debate_id: string | null }[]).map((row) => row.debate_id); + afterWrite(userId, [{ group: `result:${kind}`, key: id }, ...deletedFrom.flatMap((debate) => debateListScopes(debate, kind))]); } /** The guard the website's results pages use: an old or broken row shows a notice instead of a crash. */ diff --git a/apps/mobile/src/lib/sources.test.ts b/apps/mobile/src/lib/sources.test.ts index 20ac8fec3..3c08a7d52 100644 --- a/apps/mobile/src/lib/sources.test.ts +++ b/apps/mobile/src/lib/sources.test.ts @@ -4,7 +4,7 @@ const supabase = vi.hoisted(() => ({ from: vi.fn() })); vi.mock('./supabase', () => ({ supabase })); // Reads go straight to the network here; offline-cache.test.ts covers the saved copies. -vi.mock('./offline-cache', () => ({ cachedRead: (_user: string, _group: string, _key: string, load: () => Promise) => load(), forgetCopy: vi.fn() })); +vi.mock('./offline-cache', () => ({ cachedRead: (_user: string, _group: string, _key: string, load: () => Promise) => load(), afterWrite: vi.fn(), updateCopy: vi.fn(), readFailed: vi.fn() })); import { applySourceAssessments } from './sources'; diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts index 594eda48a..3eb516138 100644 --- a/apps/mobile/src/lib/sources.ts +++ b/apps/mobile/src/lib/sources.ts @@ -3,7 +3,8 @@ import { SourceImportOutputSchema, type SourceImportOutput } from '@debatelab/co import { assessedSemantic, isAssessedSource, type SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; import { getEdgeFunctionErrorMessage } from '@debatelab/core/supabase/edge-function-error'; -import { cachedRead, forgetCopy, readFailed } from './offline-cache'; +import { debateListScopes } from './debates'; +import { afterWrite, cachedRead, readFailed } from './offline-cache'; import { supabase } from './supabase'; /** @@ -132,6 +133,7 @@ export async function addSource( .select(SOURCE_COLUMNS) .single(); if (error || !data) throw new Error(error?.message ?? 'Could not save the source'); + afterWrite(userId, [{ group: 'sources', key: input.debateId }, ...debateListScopes(input.debateId, 'source')]); notifySourcesChanged(); return fromRow(data as SourceRow); } @@ -151,9 +153,11 @@ export async function fetchSourceIdsInFolder(userId: string, debateId: string, f /** Removes a source; with a debate, only while the source is still in that debate. */ export async function removeSource(userId: string, id: string, debateId?: string): Promise { const query = supabase.from('sources').delete().eq('user_id', userId).eq('id', id); - const { error } = await (debateId ? query.eq('debate_id', debateId) : query); + const { data, error } = await (debateId ? query.eq('debate_id', debateId) : query).select('debate_id'); if (error) throw new Error(error.message); - forgetCopy(userId, 'source', id); + // The deleted row names its debate, whose Library and counts change. + const deletedFrom = ((data ?? []) as { debate_id: string }[]).map((row) => row.debate_id); + afterWrite(userId, [{ group: 'source', key: id }, ...deletedFrom.flatMap((debate) => [{ group: 'sources' as const, key: debate }, ...debateListScopes(debate, 'source')])]); notifySourcesChanged(); } @@ -185,6 +189,8 @@ export async function applySourceAssessments(userId: string, debateId: string, a const results = await Promise.all( Array.from(updates, ([id, metadata]) => supabase.from('sources').update({ metadata }).eq('user_id', userId).eq('debate_id', debateId).eq('id', id)), ); + // The Library and the source sheet show how often a source was used. + afterWrite(userId, [{ group: 'sources', key: debateId }, ...Array.from(updates.keys(), (id) => ({ group: 'source' as const, key: id }))]); notifySourcesChanged(); const failed = results.find((result) => result.error); if (failed?.error) throw new Error(failed.error.message); diff --git a/apps/mobile/src/lib/sparring.ts b/apps/mobile/src/lib/sparring.ts index b49c03287..615fcab73 100644 --- a/apps/mobile/src/lib/sparring.ts +++ b/apps/mobile/src/lib/sparring.ts @@ -14,7 +14,8 @@ import * as Crypto from 'expo-crypto'; import { AIError, functionOutput, postFunction, PROVIDER } from './ai-request'; import type { FeatureConfig } from './feature-config'; import { sessionSlot } from './session-memory'; -import { cachedRead, forgetCopy, readFailed } from './offline-cache'; +import { debateListScopes } from './debates'; +import { afterWrite, cachedRead, readFailed, type CopyScope } from './offline-cache'; import { supabase } from './supabase'; /** @@ -82,10 +83,23 @@ export function judgeSparring(input: SparringScoreInput, options: RequestOptions }); } +/** The saved lists a judged round appears in, or leaves, in this debate or outside every debate. */ +const roundListScopes = (debateId: string | null): CopyScope[] => [{ group: 'sparring-rounds', prefix: `${debateId ?? 'one-off'}:` }]; + +/** + * Called once the judge answered: the server saved the round as judged, so it + * now shows in the lists of the debate it was started in, or outside every debate. + */ +export function noteJudgedRound(userId: string, id: string, debateId: string | null) { + afterWrite(userId, [{ group: 'sparring', key: id }, ...roundListScopes(debateId), ...debateListScopes(debateId, 'sparring')]); +} + /** Files a judged round in its debate under the side the student spoke, as the store's attachSessionToDebate. */ -export async function attachSparring(id: string, debateId: string, side: SparringSide): Promise { +export async function attachSparring(userId: string, id: string, debateId: string, side: SparringSide): Promise { const { error } = await supabase.rpc('attach_to_debate', { p_kind: 'sparring', p_id: id, p_debate_id: debateId, p_side: side }); if (error) throw new Error(error.message); + // The round may have been outside every debate until now. + afterWrite(userId, [{ group: 'sparring', key: id }, ...roundListScopes(debateId), ...roundListScopes(null), ...debateListScopes(debateId, 'sparring')]); } /** A saved, judged round; null when there is none or it does not check out. Offline, the saved copy. */ @@ -128,9 +142,10 @@ async function loadSparringRounds(userId: string, debateId: string | null, limit } export async function deleteSparringRound(userId: string, id: string): Promise { - const { error } = await supabase.from('sparring_sessions').delete().eq('user_id', userId).eq('id', id); + const { data, error } = await supabase.from('sparring_sessions').delete().eq('user_id', userId).eq('id', id).select('debate_id'); if (error) throw new Error(error.message); - forgetCopy(userId, 'sparring', id); + const deletedFrom = ((data ?? []) as { debate_id: string | null }[]).map((row) => row.debate_id); + afterWrite(userId, [{ group: 'sparring', key: id }, ...deletedFrom.flatMap((debate) => [...roundListScopes(debate), ...debateListScopes(debate, 'sparring')])]); } /** A new round's id. The server takes it as the saved row's id, so it must be a UUID. */ From 7629d7c20f97c3fbea8374391fe8c27d8cb57ea9 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 14:02:47 +0000 Subject: [PATCH 08/13] Describe when saved copies answer and how writes update them Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 67f629f45..09966427f 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -29,7 +29,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens the Flowchart on the Current Debate's map, above the round, which keeps running. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them. Voice rounds need the development build, so the voice switch shows disabled. - Flowchart mirrors the website's `FlowchartPage.tsx` and the session store's `saveFlowchart` and `deleteSavedFlowchart` without React Flow: the saved `flowcharts` row keeps the website's React Flow node and edge JSON, and `packages/core/src/flowchart.ts` reads and edits it (starter map, edge kinds from the label or stroke the website writes, role columns, the tree, and edits that keep every field they do not touch). `flowcharts.ts` reads and writes the rows; Save over an opened map updates it, Save as inserts a row as the website's Save does. `flowchart-drafts.ts` holds each debate's open map and unsaved edits in session memory, shared by the screen and its sheets. Maps follow `activeDebateId`: one-off maps are saved with no debate, as on the website. The website's PNG export is a PDF of the Map tree here (`flowchartExportHtml`). - One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm, Factoids, Crossfire and Sparring forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. -- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts` and `sparring.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats and judged Sparring rounds) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when it fails, or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` and the Flowchart. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. Nothing is written offline: AI and saving fail with their network errors, and there is no write queue. A read added to those modules goes through `cachedRead`; a delete calls `forgetCopy`. +- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts` and `sparring.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats and judged Sparring rounds) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when no answer comes back (supabase-js status 0: no connection, DNS, timeout), or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` and the Flowchart until each copy shown is read from the server again. A server error (an expired session, a policy, a 5xx) is thrown, never answered from a copy. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. Nothing is written offline: AI and saving fail with their network errors, and there is no write queue. A read added to those modules goes through `cachedRead` and throws `readFailed(error, status)`. Every write that succeeds calls `afterWrite` with the copies it changed (`debateListScopes` names a debate's counts and lists), which drops them and stops older reads from saving over them, or `updateCopy` when it knows the new value. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. From a587dae461ac8e6fad8520bc716b96d0452be293 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 14:03:32 +0000 Subject: [PATCH 09/13] Skip a deleted legacy source's missing debate when dropping its lists Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/sources.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts index 3eb516138..4cd521d5a 100644 --- a/apps/mobile/src/lib/sources.ts +++ b/apps/mobile/src/lib/sources.ts @@ -156,7 +156,7 @@ export async function removeSource(userId: string, id: string, debateId?: string const { data, error } = await (debateId ? query.eq('debate_id', debateId) : query).select('debate_id'); if (error) throw new Error(error.message); // The deleted row names its debate, whose Library and counts change. - const deletedFrom = ((data ?? []) as { debate_id: string }[]).map((row) => row.debate_id); + const deletedFrom = ((data ?? []) as { debate_id: string | null }[]).flatMap((row) => (row.debate_id ? [row.debate_id] : [])); afterWrite(userId, [{ group: 'source', key: id }, ...deletedFrom.flatMap((debate) => [{ group: 'sources' as const, key: debate }, ...debateListScopes(debate, 'source')])]); notifySourcesChanged(); } From f7af0a0849f5a976aaa6e592908364502d555fe5 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 17:09:36 +0000 Subject: [PATCH 10/13] Drop every saved list a delete may touch when no row comes back A delete that succeeds with no row (already deleted on the website) does not name the debate that held the item, so the copies of every debate's Library, maps, rounds, counts and recent lists of that kind are dropped instead of none. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/debates.ts | 23 +++++++++++++++++++---- apps/mobile/src/lib/flowcharts.ts | 6 ++++-- apps/mobile/src/lib/offline-cache.test.ts | 18 ++++++++++++++++++ apps/mobile/src/lib/results.ts | 4 ++-- apps/mobile/src/lib/sources.ts | 13 ++++++++----- apps/mobile/src/lib/sparring.ts | 6 ++++-- 6 files changed, 55 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index d68dceda7..b64996c1e 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -279,11 +279,26 @@ const PREPARE_KINDS = new Set(PREPARE_TABLES.map((table) => KIND * the recent work, and a Prepare result the Prepare list. */ export function debateListScopes(debateId: string | null, change: 'source' | DebateItemKind): CopyScope[] { - if (!debateId) return []; - const scopes: CopyScope[] = [{ group: 'debate-lists', key: `counts:${debateId}` }]; + return debateId ? listScopes(debateId, change) : []; +} + +/** + * The saved counts and lists a delete changed, from the debates its deleted + * rows name. A delete that returned no row (the row was already gone, say + * deleted on the website) does not say which debate held it, so every + * debate's counts and lists of that kind are dropped. + */ +export function deletedListScopes(deletedFrom: (string | null)[], change: 'source' | DebateItemKind): CopyScope[] { + return deletedFrom.length > 0 ? deletedFrom.flatMap((debateId) => debateListScopes(debateId, change)) : listScopes(undefined, change); +} + +/** One debate's counts and lists, or every debate's when `debateId` is undefined. */ +function listScopes(debateId: string | undefined, change: 'source' | DebateItemKind): CopyScope[] { + const of = debateId ? `${debateId}:` : ''; + const scopes: CopyScope[] = [debateId ? { group: 'debate-lists', key: `counts:${debateId}` } : { group: 'debate-lists', prefix: 'counts:' }]; if (change === 'source') return [...scopes, { group: 'debate-lists', prefix: 'source-counts:', about: debateId }]; - scopes.push({ group: 'debate-lists', prefix: `recent:${debateId}:` }); - if (PREPARE_KINDS.has(change)) scopes.push({ group: 'debate-lists', prefix: `prepare:${debateId}:` }); + scopes.push({ group: 'debate-lists', prefix: `recent:${of}` }); + if (PREPARE_KINDS.has(change)) scopes.push({ group: 'debate-lists', prefix: `prepare:${of}` }); return scopes; } diff --git a/apps/mobile/src/lib/flowcharts.ts b/apps/mobile/src/lib/flowcharts.ts index 28b3b48ac..94539fc36 100644 --- a/apps/mobile/src/lib/flowcharts.ts +++ b/apps/mobile/src/lib/flowcharts.ts @@ -1,6 +1,6 @@ import { readFlowchart, type FlowchartDocument } from '@debatelab/core/flowchart'; -import { debateListScopes } from './debates'; +import { debateListScopes, deletedListScopes } from './debates'; import { afterWrite, cachedRead, readFailed, updateCopy } from './offline-cache'; import { supabase } from './supabase'; @@ -104,8 +104,10 @@ export async function deleteFlowchart(userId: string, id: string): Promise const { data, error } = await supabase.from('flowcharts').delete().eq('user_id', userId).eq('id', id).select('debate_id'); if (error) throw new Error(error.message); const deletedFrom = ((data ?? []) as { debate_id: string | null }[]).map((row) => row.debate_id); + // With no row, it was already gone and any debate's list, or the one-off list, may still hold it. afterWrite(userId, [ { group: 'flowchart', key: id }, - ...deletedFrom.flatMap((debate) => [{ group: 'flowcharts' as const, key: debate ?? 'one-off' }, ...debateListScopes(debate, 'flowchart')]), + ...(deletedFrom.length > 0 ? deletedFrom.map((debate) => ({ group: 'flowcharts' as const, key: debate ?? 'one-off' })) : [{ group: 'flowcharts' as const }]), + ...deletedListScopes(deletedFrom, 'flowchart'), ]); } diff --git a/apps/mobile/src/lib/offline-cache.test.ts b/apps/mobile/src/lib/offline-cache.test.ts index 7f33062e0..74422d1c8 100644 --- a/apps/mobile/src/lib/offline-cache.test.ts +++ b/apps/mobile/src/lib/offline-cache.test.ts @@ -262,6 +262,24 @@ describe('after a write', () => { await expect(cache.cachedRead('user-a', 'sources', 'debate-2', offlineFailure)).resolves.toEqual([{ id: 'source-2' }]); }); + it('a delete whose row was already gone still drops every list and count that may hold it', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'sources', 'debate-1', async () => [{ id: 'source-1' }]); + await cache.cachedRead('user-a', 'debate-lists', 'counts:debate-1', async () => ({ sources: 1 })); + await cache.cachedRead('user-a', 'debate-lists', 'source-counts:debate-1', async () => ({ 'debate-1': 1 })); + await cache.cachedRead('user-a', 'flowcharts', 'one-off', async () => [{ id: 'map-1' }]); + await cache.cachedRead('user-a', 'debate-lists', 'prepare:debate-1:20', async () => [{ id: 'map-1' }]); + // Deleted on the website first: the delete succeeds and returns no row. + supabase.results.push({ data: [], error: null, status: 200 }, { data: [], error: null, status: 200 }); + await cache.removeSource('user-a', 'source-1'); + await cache.deleteFlowchart('user-a', 'map-1'); + await cache.cachedRead('user-a', 'sources', 'flush', offlineFailure).catch(() => undefined); + for (const [group, key] of [['sources', 'debate-1'], ['debate-lists', 'counts:debate-1'], ['debate-lists', 'source-counts:debate-1'], ['flowcharts', 'one-off'], ['debate-lists', 'prepare:debate-1:20']] as const) { + await expect(cache.cachedRead('user-a', group, key, offlineFailure), `${group} ${key}`).rejects.toThrow(); + } + }); + it('switching debates changes the saved Current Debate, so an offline restart opens the new one', async () => { let cache = await load(); cache.setCacheOwner('user-a'); diff --git a/apps/mobile/src/lib/results.ts b/apps/mobile/src/lib/results.ts index 3ed56ca91..c33c822f9 100644 --- a/apps/mobile/src/lib/results.ts +++ b/apps/mobile/src/lib/results.ts @@ -18,7 +18,7 @@ import type { DebateContextRequest } from '@debatelab/core/contract/debate-conte import { AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; import type { FeatureConfig } from './feature-config'; -import { debateListScopes } from './debates'; +import { debateListScopes, deletedListScopes } from './debates'; import { afterWrite, cachedRead, readFailed } from './offline-cache'; import { addSource, applySourceAssessments, fetchSourceIdsInFolder, removeSource } from './sources'; import { supabase } from './supabase'; @@ -281,7 +281,7 @@ export async function deleteResult(userId: string, kind: ResultKind, id: string) const { data, error } = await supabase.from(TABLES[kind]).delete().eq('user_id', userId).eq('id', id).select('debate_id'); if (error) throw new Error(error.message); const deletedFrom = ((data ?? []) as { debate_id: string | null }[]).map((row) => row.debate_id); - afterWrite(userId, [{ group: `result:${kind}`, key: id }, ...deletedFrom.flatMap((debate) => debateListScopes(debate, kind))]); + afterWrite(userId, [{ group: `result:${kind}`, key: id }, ...deletedListScopes(deletedFrom, kind)]); } /** The guard the website's results pages use: an old or broken row shows a notice instead of a crash. */ diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts index 4cd521d5a..27e01af9b 100644 --- a/apps/mobile/src/lib/sources.ts +++ b/apps/mobile/src/lib/sources.ts @@ -3,8 +3,8 @@ import { SourceImportOutputSchema, type SourceImportOutput } from '@debatelab/co import { assessedSemantic, isAssessedSource, type SourceAssessmentUpdate } from '@debatelab/core/ai/source-assessments'; import { getEdgeFunctionErrorMessage } from '@debatelab/core/supabase/edge-function-error'; -import { debateListScopes } from './debates'; -import { afterWrite, cachedRead, readFailed } from './offline-cache'; +import { debateListScopes, deletedListScopes } from './debates'; +import { afterWrite, cachedRead, readFailed, type CopyScope } from './offline-cache'; import { supabase } from './supabase'; /** @@ -155,9 +155,12 @@ export async function removeSource(userId: string, id: string, debateId?: string const query = supabase.from('sources').delete().eq('user_id', userId).eq('id', id); const { data, error } = await (debateId ? query.eq('debate_id', debateId) : query).select('debate_id'); if (error) throw new Error(error.message); - // The deleted row names its debate, whose Library and counts change. - const deletedFrom = ((data ?? []) as { debate_id: string | null }[]).flatMap((row) => (row.debate_id ? [row.debate_id] : [])); - afterWrite(userId, [{ group: 'source', key: id }, ...deletedFrom.flatMap((debate) => [{ group: 'sources' as const, key: debate }, ...debateListScopes(debate, 'source')])]); + // The deleted row names its debate, whose Library and counts change. With no row, every Library may hold it. + const deletedFrom = ((data ?? []) as { debate_id: string | null }[]).map((row) => row.debate_id); + const libraries: CopyScope[] = deletedFrom.length > 0 + ? deletedFrom.flatMap((debate) => (debate ? [{ group: 'sources' as const, key: debate }] : [])) + : [{ group: 'sources' }]; + afterWrite(userId, [{ group: 'source', key: id }, ...libraries, ...deletedListScopes(deletedFrom, 'source')]); notifySourcesChanged(); } diff --git a/apps/mobile/src/lib/sparring.ts b/apps/mobile/src/lib/sparring.ts index 615fcab73..319b29de3 100644 --- a/apps/mobile/src/lib/sparring.ts +++ b/apps/mobile/src/lib/sparring.ts @@ -14,7 +14,7 @@ import * as Crypto from 'expo-crypto'; import { AIError, functionOutput, postFunction, PROVIDER } from './ai-request'; import type { FeatureConfig } from './feature-config'; import { sessionSlot } from './session-memory'; -import { debateListScopes } from './debates'; +import { debateListScopes, deletedListScopes } from './debates'; import { afterWrite, cachedRead, readFailed, type CopyScope } from './offline-cache'; import { supabase } from './supabase'; @@ -145,7 +145,9 @@ export async function deleteSparringRound(userId: string, id: string): Promise row.debate_id); - afterWrite(userId, [{ group: 'sparring', key: id }, ...deletedFrom.flatMap((debate) => [...roundListScopes(debate), ...debateListScopes(debate, 'sparring')])]); + // With no row, it was already gone and any debate's rounds, or the one-off rounds, may still list it. + const roundLists: CopyScope[] = deletedFrom.length > 0 ? deletedFrom.flatMap(roundListScopes) : [{ group: 'sparring-rounds' }]; + afterWrite(userId, [{ group: 'sparring', key: id }, ...roundLists, ...deletedListScopes(deletedFrom, 'sparring')]); } /** A new round's id. The server takes it as the saved row's id, so it must be a UUID. */ From 55433c99154badf423b852e98b753e2900cb9790 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 17:10:57 +0000 Subject: [PATCH 11/13] Let a server refusal win over a missing answer in parallel reads A read built from several queries rejected with whichever failed first, so one query that got no answer could hide another's 401 and serve the saved copy. The debates list, a chat, the debate counts and the recent and Prepare lists now wait for every query and throw the refusal when there is one. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/chats.ts | 6 +++--- apps/mobile/src/lib/debates.ts | 18 +++++++++--------- apps/mobile/src/lib/offline-cache.test.ts | 16 ++++++++++++++++ apps/mobile/src/lib/offline-cache.ts | 19 +++++++++++++++++++ 4 files changed, 47 insertions(+), 12 deletions(-) diff --git a/apps/mobile/src/lib/chats.ts b/apps/mobile/src/lib/chats.ts index 68bd28f64..4fa598c8f 100644 --- a/apps/mobile/src/lib/chats.ts +++ b/apps/mobile/src/lib/chats.ts @@ -1,7 +1,7 @@ import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; import { debateListScopes } from './debates'; -import { afterWrite, cachedRead, readFailed } from './offline-cache'; +import { afterWrite, cachedRead, pickFailure, readFailed } from './offline-cache'; import { supabase } from './supabase'; /** @@ -85,8 +85,8 @@ async function loadChat(userId: string, chatId: string): Promise<{ debateId: str supabase.from('chats').select('debate_id').eq('user_id', userId).eq('id', chatId).single(), supabase.from('messages').select('id,role,content,output,created_at').eq('chat_id', chatId).order('created_at', { ascending: true }), ]); - if (chat.error) throw readFailed(chat.error, chat.status); - if (messages.error) throw readFailed(messages.error, messages.status); + const failures = [chat, messages].flatMap((result) => (result.error ? [readFailed(result.error, result.status)] : [])); + if (failures.length > 0) throw pickFailure(failures); return { debateId: (chat.data as { debate_id: string | null }).debate_id ?? null, messages: ((messages.data ?? []) as MessageRow[]).map((row) => ({ diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index b64996c1e..f3a9c07be 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -1,4 +1,4 @@ -import { cachedRead, readFailed, updateCopy, type CopyScope } from './offline-cache'; +import { cachedRead, pickFailure, readAll, readFailed, updateCopy, type CopyScope } from './offline-cache'; import { supabase } from './supabase'; /** @@ -98,8 +98,8 @@ async function loadDebates(userId: string): Promise { supabase.from('debates').select(DEBATE_COLUMNS).eq('user_id', userId).order('last_activity_at', { ascending: false }), supabase.from('current_debates').select('debate_id').eq('user_id', userId).maybeSingle(), ]); - if (debatesResult.error) throw readFailed(debatesResult.error, debatesResult.status); - if (currentResult.error) throw readFailed(currentResult.error, currentResult.status); + const failures = [debatesResult, currentResult].flatMap((result) => (result.error ? [readFailed(result.error, result.status)] : [])); + if (failures.length > 0) throw pickFailure(failures); const debates = ((debatesResult.data ?? []) as DebateRow[]).map(fromRow); const currentId = (currentResult.data as { debate_id?: string } | null)?.debate_id ?? null; return { @@ -193,11 +193,11 @@ export function fetchDebateCounts(userId: string, debateId: string): Promise { - const [sources, rounds, pro, con] = await Promise.all([ + const [sources, rounds, pro, con] = await readAll([ countRows(userId, 'sources', debateId), countRows(userId, 'sparring_sessions', debateId), - Promise.all(RESULT_TABLES.map((table) => countRows(userId, table, debateId, SIDES_FOR.pro))), - Promise.all(RESULT_TABLES.map((table) => countRows(userId, table, debateId, SIDES_FOR.con))), + readAll(RESULT_TABLES.map((table) => countRows(userId, table, debateId, SIDES_FOR.pro))), + readAll(RESULT_TABLES.map((table) => countRows(userId, table, debateId, SIDES_FOR.con))), ]); return { sources, rounds, results: { pro: sum(pro), con: sum(con) } }; } @@ -208,7 +208,7 @@ export function fetchSourceCounts(userId: string, debateIds: string[]): Promise< } async function loadSourceCounts(userId: string, debateIds: string[]): Promise> { - const counts = await Promise.all(debateIds.map((id) => countRows(userId, 'sources', id))); + const counts = await readAll(debateIds.map((id) => countRows(userId, 'sources', id))); return Object.fromEntries(debateIds.map((id, index) => [id, counts[index]])); } @@ -244,7 +244,7 @@ export function fetchRecentWork(userId: string, debateId: string, side: DebateSi } async function loadRecentWork(userId: string, debateId: string, side: DebateSide, limit: number): Promise { - const lists = await Promise.all([ + const lists = await readAll([ ...RESULT_TABLES.map((table) => fetchResultRows(userId, debateId, table, SIDES_FOR[side], limit)), (async () => { // The website lists a judged round under its motion and the side the student spoke, ordered by its last update. @@ -308,6 +308,6 @@ export function fetchPrepareWork(userId: string, debateId: string, limit: number } async function loadPrepareWork(userId: string, debateId: string, limit: number): Promise { - const lists = await Promise.all(PREPARE_TABLES.map((table) => fetchResultRows(userId, debateId, table, null, limit))); + const lists = await readAll(PREPARE_TABLES.map((table) => fetchResultRows(userId, debateId, table, null, limit))); return newestFirst(lists, limit); } diff --git a/apps/mobile/src/lib/offline-cache.test.ts b/apps/mobile/src/lib/offline-cache.test.ts index 74422d1c8..3b3ab67c6 100644 --- a/apps/mobile/src/lib/offline-cache.test.ts +++ b/apps/mobile/src/lib/offline-cache.test.ts @@ -196,6 +196,22 @@ describe('which failures fall back', () => { await expect(cachedRead('user-a', 'sources', 'debate-1', () => Promise.reject(refusal))).rejects.toBe(refusal); } }); + + it('throws a refusal from one of several queries even when another got no answer', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + const unreachable = { error: { message: 'Network request failed' }, status: 0 }; + const refused = { error: { message: 'JWT expired' }, status: 401 }; + supabase.results.push({ count: 1, error: null, status: 200 }, { count: 2, error: null, status: 200 }); + await cache.fetchSourceCounts('user-a', ['debate-1', 'debate-2']); + supabase.results.push(unreachable, refused); + await expect(cache.fetchSourceCounts('user-a', ['debate-1', 'debate-2'])).rejects.toThrow('JWT expired'); + + supabase.results.push({ data: [], error: null, status: 200 }, { data: null, error: null, status: 200 }); + await cache.fetchDebates('user-a'); + supabase.results.push(unreachable, refused); + await expect(cache.fetchDebates('user-a')).rejects.toThrow('JWT expired'); + }); }); describe('the saved-copies notice', () => { diff --git a/apps/mobile/src/lib/offline-cache.ts b/apps/mobile/src/lib/offline-cache.ts index 15d9cfb6e..08d026712 100644 --- a/apps/mobile/src/lib/offline-cache.ts +++ b/apps/mobile/src/lib/offline-cache.ts @@ -75,6 +75,25 @@ export function readFailed(error: { message: string }, status: number): ReadErro const isUnreachable = (cause: unknown) => cause instanceof ReadError && cause.unreachable; +/** + * The failure to throw when several queries of one read failed: a server's + * refusal wins over a query that got no answer, so a saved copy never hides it. + */ +export function pickFailure(causes: unknown[]): unknown { + return causes.find((cause) => !isUnreachable(cause)) ?? causes[0]; +} + +/** + * Promise.all for the queries of one read. It waits for all of them, so a + * refusal from one is thrown even when another got no answer first. + */ +export async function readAll(reads: T): Promise<{ -readonly [K in keyof T]: Awaited }> { + const results = await Promise.allSettled(reads); + const failures = results.flatMap((result) => (result.status === 'rejected' ? [result.reason as unknown] : [])); + if (failures.length > 0) throw pickFailure(failures); + return results.map((result) => (result as PromiseFulfilledResult).value) as { -readonly [K in keyof T]: Awaited }; +} + const queue = createSerialQueue(); // undefined until the auth provider names the first owner, so that first call always checks storage. let owner: string | null | undefined; From 7a45e578246aae33ec39d0b630d451e12b277725 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 17:16:51 +0000 Subject: [PATCH 12/13] Stop counting a saved copy once its screen closes or moves on The offline notice tracked shown copies by key only, so a copy kept the notice up after reconnecting until that exact item was read again. Each screen now reads with a CopyHolder mark: a newer load lets go of what its older loads showed, a late read from a closed screen no longer counts, and closing a screen, leaving a tab or clearing its data releases its copies. The Flowchart screen shares its holder with the saved maps sheet that opens maps for it. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/AGENTS.md | 2 +- apps/mobile/src/app/(tabs)/(debate)/index.tsx | 16 ++-- .../src/app/(tabs)/(library)/library.tsx | 10 ++- .../src/app/(tabs)/(practice)/practice.tsx | 11 ++- .../src/app/(tabs)/(prepare)/prepare.tsx | 11 ++- apps/mobile/src/app/add-link.tsx | 4 +- apps/mobile/src/app/ask.tsx | 16 +++- apps/mobile/src/app/attack/index.tsx | 6 +- apps/mobile/src/app/debates.tsx | 6 +- apps/mobile/src/app/flowchart/index.tsx | 13 +-- apps/mobile/src/app/flowchart/maps.tsx | 11 ++- apps/mobile/src/app/source/[id].tsx | 6 +- apps/mobile/src/app/sparring/[id].tsx | 6 +- apps/mobile/src/components/ask-history.tsx | 11 ++- apps/mobile/src/lib/chats.ts | 10 +-- apps/mobile/src/lib/debates.ts | 18 ++--- apps/mobile/src/lib/flowcharts.ts | 13 +-- apps/mobile/src/lib/network-status.ts | 3 +- apps/mobile/src/lib/offline-cache.test.ts | 26 ++++++ apps/mobile/src/lib/offline-cache.ts | 80 ++++++++++++++++--- apps/mobile/src/lib/results.ts | 6 +- apps/mobile/src/lib/sources.ts | 10 +-- apps/mobile/src/lib/sparring.ts | 10 +-- apps/mobile/src/lib/use-copy-holder.ts | 14 ++++ apps/mobile/src/lib/use-result-document.ts | 12 ++- 25 files changed, 240 insertions(+), 91 deletions(-) create mode 100644 apps/mobile/src/lib/use-copy-holder.ts diff --git a/apps/mobile/AGENTS.md b/apps/mobile/AGENTS.md index 09966427f..b44fdc53e 100644 --- a/apps/mobile/AGENTS.md +++ b/apps/mobile/AGENTS.md @@ -29,7 +29,7 @@ The iOS app (`@debatelab/mobile`): Expo SDK 57, React Native 0.86, React 19, Exp - Sparring is the website's typed mode: `sparring.ts` sends the opponent's turns and the judge as `generateSparringTurn` and `judgeSparringSession` in `src/lib/ai/client.ts` do (the Sparring model settings, a transcript-only debate context), and the round's moves (turn order, the deadline clock, judging, saved rows) are `packages/core/src/sparring/session.ts`, shared with the website's sparring store. The server saves the judged round to `sparring_sessions` when it scores it; the app then files it in its debate with `attach_to_debate` and the student's side, as the store does. The side is asked when the round starts. The unfinished round is a session slot (`sparringDraft`), so leaving the screen keeps it, and a request cut off by leaving is offered again. The flow button opens the Flowchart on the Current Debate's map, above the round, which keeps running. Practice lists the Current Debate's judged rounds and the Debate tab lists them by side; both open `sparring/[id]`, which can delete them. Voice rounds need the development build, so the voice switch shows disabled. - Flowchart mirrors the website's `FlowchartPage.tsx` and the session store's `saveFlowchart` and `deleteSavedFlowchart` without React Flow: the saved `flowcharts` row keeps the website's React Flow node and edge JSON, and `packages/core/src/flowchart.ts` reads and edits it (starter map, edge kinds from the label or stroke the website writes, role columns, the tree, and edits that keep every field they do not touch). `flowcharts.ts` reads and writes the rows; Save over an opened map updates it, Save as inserts a row as the website's Save does. `flowchart-drafts.ts` holds each debate's open map and unsaved edits in session memory, shared by the screen and its sheets. Maps follow `activeDebateId`: one-off maps are saved with no debate, as on the website. The website's PNG export is a PDF of the Map tree here (`flowchartExportHtml`). - One-off mode is session-only and never written, as on the website; only maps saved in it are kept, with no debate, as the website keeps them. The switcher turns it on with or without a Current Debate, and `OneOffNotice` shows it on the Debate tab, Prepare, Ask, Library and the Generate, Attack, Brainstorm, Factoids, Crossfire and Sparring forms (through `WorkingIn`) and the Flowchart. The Debate tab, Prepare and Library still list the Current Debate's work in one-off mode, as the website's home does. Students prepare both sides of a motion, so a debate has no side: the Debate tab's In favor / Against toggle only chooses which side's work it shows, using the website's rule (`'both'` counts for either side, neutral and side-less work for neither). It starts on In favor and is never stored. -- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts` and `sparring.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats and judged Sparring rounds) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when no answer comes back (supabase-js status 0: no connection, DNS, timeout), or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` and the Flowchart until each copy shown is read from the server again. A server error (an expired session, a policy, a 5xx) is thrown, never answered from a copy. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. Nothing is written offline: AI and saving fail with their network errors, and there is no write queue. A read added to those modules goes through `cachedRead` and throws `readFailed(error, status)`. Every write that succeeds calls `afterWrite` with the copies it changed (`debateListScopes` names a debate's counts and lists), which drops them and stops older reads from saving over them, or `updateCopy` when it knows the new value. +- Offline reading: `offline-cache.ts` keeps a copy of every read in `debates.ts`, `sources.ts`, `results.ts`, `flowcharts.ts`, `chats.ts` and `sparring.ts` (the debates list, counts and recent work, Library sources with their text, saved results, maps, chats and judged Sparring rounds) in AsyncStorage under the user id. A read goes to the network first and saves its answer; when no answer comes back (supabase-js status 0: no connection, DNS, timeout), or `network-status.ts` (expo-network) says the device is offline, the copy answers and `OfflineNotice` shows at the top of `Screen`, `DocumentScreen` and the Flowchart until each copy shown is read from the server again or the screen showing it closes, moves on or leaves view (a screen passes `useCopyHolder().mark()` with its reads). A server error (an expired session, a policy, a 5xx) is thrown, never answered from a copy; a read made of several queries runs them through `readAll` (or `pickFailure`), so one query's refusal wins over another's missing answer. A slower answer never replaces a newer copy, each group keeps its most recently fetched copies only (`LIMITS`), and the auth provider calls `setCacheOwner` before it adopts a session, so another account's copies are removed before its first read. Nothing is written offline: AI and saving fail with their network errors, and there is no write queue. A read added to those modules goes through `cachedRead`, takes an optional `CopyMark`, and throws `readFailed(error, status)`. Every write that succeeds calls `afterWrite` with the copies it changed (`debateListScopes` names a debate's counts and lists; a delete uses `deletedListScopes`, which drops every debate's when no row came back), which drops them and stops older reads from saving over them, or `updateCopy` when it knows the new value. - `src/theme/`: colours from the mocks and the website's dark palette, Inter fonts. - `scripts/screenshots.ts`: web-export screenshots. `scripts/render-tab-icons.ts` redraws the tab bar PNGs. diff --git a/apps/mobile/src/app/(tabs)/(debate)/index.tsx b/apps/mobile/src/app/(tabs)/(debate)/index.tsx index 5e9f94c68..cebb274d8 100644 --- a/apps/mobile/src/app/(tabs)/(debate)/index.tsx +++ b/apps/mobile/src/app/(tabs)/(debate)/index.tsx @@ -23,6 +23,7 @@ import { type DebateItem, type DebateSide, } from '@/lib/debates'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { mainActionGradient, radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -128,15 +129,17 @@ function DebateDetails({ debateId }: { debateId: string }) { const [counts, setCounts] = useState(null); const [recent, setRecent] = useState | null>(null); const [failed, setFailed] = useState(false); + const copies = useCopyHolder(); // Both sides load together, so switching the view is instant. const load = useCallback(async (isCurrent: () => boolean) => { if (!userId) return; + const mark = copies.mark(); try { const [nextCounts, pro, con] = await Promise.all([ - fetchDebateCounts(userId, debateId), - fetchRecentWork(userId, debateId, 'pro', RECENT_LIMIT), - fetchRecentWork(userId, debateId, 'con', RECENT_LIMIT), + fetchDebateCounts(userId, debateId, mark), + fetchRecentWork(userId, debateId, 'pro', RECENT_LIMIT, mark), + fetchRecentWork(userId, debateId, 'con', RECENT_LIMIT, mark), ]); if (!isCurrent()) return; setCounts(nextCounts); @@ -147,17 +150,18 @@ function DebateDetails({ debateId }: { debateId: string }) { console.warn('Could not load the debate:', cause instanceof Error ? cause.message : cause); setFailed(true); } - }, [userId, debateId]); + }, [userId, debateId, copies]); - // Reload on focus, so work saved on the website shows up. + // Reload on focus, so work saved on the website shows up. Out of view, its saved copies no longer count. useFocusEffect( useCallback(() => { let active = true; void load(() => active); return () => { active = false; + copies.release(); }; - }, [load]), + }, [load, copies]), ); const results = counts?.results[view]; diff --git a/apps/mobile/src/app/(tabs)/(library)/library.tsx b/apps/mobile/src/app/(tabs)/(library)/library.tsx index 43a74edba..19fef2639 100644 --- a/apps/mobile/src/app/(tabs)/(library)/library.tsx +++ b/apps/mobile/src/app/(tabs)/(library)/library.tsx @@ -18,6 +18,7 @@ import { useAuth } from '@/lib/auth'; import { confirmDestructive } from '@/lib/confirm'; import { useDebates } from '@/lib/debate-context'; import { fetchSources, removeSource, subscribeSourcesChanged, type Source, type SourceType } from '@/lib/sources'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; type Filter = 'all' | SourceType; @@ -75,13 +76,14 @@ function SourceList({ debateId }: { debateId: string }) { const [query, setQuery] = useState(''); const [filter, setFilter] = useState('all'); const loads = useRef(0); + const copies = useCopyHolder(); const userId = session?.user.id ?? null; const load = useCallback(async () => { if (!userId) return; const load = ++loads.current; try { - const next = await fetchSources(userId, debateId); + const next = await fetchSources(userId, debateId, copies.mark()); if (load !== loads.current) return; setSources(next); setFailed(false); @@ -90,13 +92,15 @@ function SourceList({ debateId }: { debateId: string }) { console.warn('Could not load sources:', cause instanceof Error ? cause.message : cause); setFailed(true); } - }, [userId, debateId]); + }, [userId, debateId, copies]); // Sources added on the website show up when the tab comes back into view. + // Out of view, its saved copies no longer count for the offline notice. useFocusEffect( useCallback(() => { void load(); - }, [load]), + return () => copies.release(); + }, [load, copies]), ); useEffect(() => subscribeSourcesChanged(() => void load()), [load]); diff --git a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx index 1a9a9d2d2..b57cbfdac 100644 --- a/apps/mobile/src/app/(tabs)/(practice)/practice.tsx +++ b/apps/mobile/src/app/(tabs)/(practice)/practice.tsx @@ -14,6 +14,7 @@ import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { keepIfSame } from '@/lib/keep-if-same'; import { fetchSparringRounds, type SparringRoundSummary } from '@/lib/sparring'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; const ROUNDS_LIMIT = 5; @@ -44,13 +45,14 @@ export default function PracticeScreen() { // Every load takes a number; only the newest may write, so a slow load for another debate never replaces this one's. const loads = useRef(0); + const copies = useCopyHolder(); const load = useCallback(async () => { if (!userId) return; const forDebate = debateId; const request = ++loads.current; const isCurrent = () => loads.current === request; try { - const items = await fetchSparringRounds(userId, forDebate, ROUNDS_LIMIT); + const items = await fetchSparringRounds(userId, forDebate, ROUNDS_LIMIT, copies.mark()); if (!isCurrent()) return; setRounds((previous) => keepIfSame(previous, { debateId: forDebate, items, failed: false })); } catch (cause) { @@ -58,16 +60,17 @@ export default function PracticeScreen() { console.warn('Could not load sparring rounds:', cause instanceof Error ? cause.message : cause); setRounds({ debateId: forDebate, items: null, failed: true }); } - }, [userId, debateId]); + }, [userId, debateId, copies]); - // Reload on focus, so a round just judged or deleted shows up. + // Reload on focus, so a round just judged or deleted shows up. Out of view, its saved copies no longer count. useFocusEffect( useCallback(() => { void load(); return () => { loads.current += 1; + copies.release(); }; - }, [load]), + }, [load, copies]), ); const items = rounds?.debateId === debateId ? rounds.items : null; diff --git a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx index 625c6f5fa..398100fe4 100644 --- a/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx +++ b/apps/mobile/src/app/(tabs)/(prepare)/prepare.tsx @@ -15,6 +15,7 @@ import { WorkRow, workHref } from '@/components/work-row'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { fetchPrepareWork, type DebateItem } from '@/lib/debates'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { keepIfSame } from '@/lib/keep-if-same'; import { useTheme } from '@/theme/use-theme'; @@ -38,13 +39,14 @@ export default function PrepareScreen() { // Every load, from focus or Retry, takes a number; only the newest may write, so a late Retry for another debate never replaces this one's. const loads = useRef(0); + const copies = useCopyHolder(); const load = useCallback(async () => { if (!userId || !debateId) return; const forDebate = debateId; const request = ++loads.current; const isCurrent = () => loads.current === request; try { - const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT); + const next = await fetchPrepareWork(userId, forDebate, SAVED_LIMIT, copies.mark()); if (!isCurrent()) return; // Every focus rereads the list; an unchanged one keeps its identity, so the rows do not re-render. setLoaded((previous) => keepIfSame(previous, { debateId: forDebate, items: next, failed: false })); @@ -53,16 +55,17 @@ export default function PrepareScreen() { console.warn('Could not load saved results:', cause instanceof Error ? cause.message : cause); setLoaded({ debateId: forDebate, items: null, failed: true }); } - }, [userId, debateId]); + }, [userId, debateId, copies]); - // Reload on focus, so a case generated a moment ago, or on the website, shows up. + // Reload on focus, so a case generated a moment ago, or on the website, shows up. Out of view, its saved copies no longer count. useFocusEffect( useCallback(() => { void load(); return () => { loads.current += 1; + copies.release(); }; - }, [load]), + }, [load, copies]), ); return ( diff --git a/apps/mobile/src/app/add-link.tsx b/apps/mobile/src/app/add-link.tsx index 0d141eb40..6ea07b105 100644 --- a/apps/mobile/src/app/add-link.tsx +++ b/apps/mobile/src/app/add-link.tsx @@ -12,6 +12,7 @@ import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { addSource, fetchSources, importSourceFromUrl, isDuplicateOrigin } from '@/lib/sources'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; /** @@ -30,6 +31,7 @@ export default function AddLinkSheet() { const [title, setTitle] = useState(''); const [busy, setBusy] = useState<'import' | 'save' | null>(null); const [error, setError] = useState(''); + const copies = useCopyHolder(); const runImport = async () => { const trimmed = url.trim(); @@ -57,7 +59,7 @@ export default function AddLinkSheet() { setBusy('save'); setError(''); try { - if (isDuplicateOrigin(await fetchSources(userId, current.id), draft.url)) { + if (isDuplicateOrigin(await fetchSources(userId, current.id, copies.mark()), draft.url)) { setError(`${t('libraryPage.duplicateSource')}. ${t('libraryPage.duplicateSourceDesc')}`); setBusy(null); return; diff --git a/apps/mobile/src/app/ask.tsx b/apps/mobile/src/app/ask.tsx index 9b9678d0c..346c48baf 100644 --- a/apps/mobile/src/app/ask.tsx +++ b/apps/mobile/src/app/ask.tsx @@ -23,6 +23,7 @@ import { useDebates } from '@/lib/debate-context'; import { keepSourceAssessments } from '@/lib/results'; import { useSettings } from '@/lib/settings'; import { fetchSources, type Source } from '@/lib/sources'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { radius } from '@/theme/colors'; import { fontFamily } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; @@ -76,6 +77,8 @@ export default function AskScreen() { const config = useRef | null>(null); // The conversation the saved record belongs to; replacing it drops late writes. const conversation = useRef(0); + // The opened chat's saved copy counts until another conversation starts. + const chatCopies = useCopyHolder(); const recordRef = useRef(record); const saves = useRef>(Promise.resolve()); const activeDebateRef = useRef(activeDebateId); @@ -103,6 +106,7 @@ export default function AskScreen() { const startConversation = (messagesNext: ChatMessage[], recordNext: ChatRecord) => { abortActive(); conversation.current += 1; + chatCopies.release(); setMessages(messagesNext); setRecord(recordNext); setStreaming(null); @@ -126,12 +130,16 @@ export default function AskScreen() { // The debate this conversation reads from: a loaded chat keeps its own. const chatDebateId = record.chatId ? record.debateId : activeDebateId; + const libraryCopies = useCopyHolder(); useEffect(() => { // The previous debate's sources never stand in for this one's while they load. setLibrary([]); - if (!chatDebateId || !userId) return; + if (!chatDebateId || !userId) { + libraryCopies.release(); + return; + } let active = true; - fetchSources(userId, chatDebateId) + fetchSources(userId, chatDebateId, libraryCopies.mark()) .then((next) => { if (active) setLibrary(next); }) @@ -139,7 +147,7 @@ export default function AskScreen() { return () => { active = false; }; - }, [userId, chatDebateId]); + }, [userId, chatDebateId, libraryCopies]); // Each finished exchange is saved to the debate, as on the website. const lastRole = messages[messages.length - 1]?.role; @@ -306,7 +314,7 @@ export default function AskScreen() { const owner = conversation.current; setOpening(true); try { - const chat = await fetchChat(userId, chatId); + const chat = await fetchChat(userId, chatId, chatCopies.mark()); if (owner !== conversation.current) return; setMessages(chat.messages); setRecord({ chatId, debateId: chat.debateId, persisted: chat.messages.length }); diff --git a/apps/mobile/src/app/attack/index.tsx b/apps/mobile/src/app/attack/index.tsx index 696f6ee0b..89c9d97f1 100644 --- a/apps/mobile/src/app/attack/index.tsx +++ b/apps/mobile/src/app/attack/index.tsx @@ -22,6 +22,7 @@ import { caseToText } from '@/lib/result-text'; import { arrivalNotices, attackCase, autosaveSources, fetchResult, isCompleteCase, keepSourceAssessments, recordResult } from '@/lib/results'; import { sessionSlot } from '@/lib/session-memory'; import { useSettings } from '@/lib/settings'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; // The website's defaults (AttackForm). The last form stays for the session, as on the website. @@ -54,6 +55,7 @@ export default function AttackScreen() { const [phase, setPhase] = useState<'form' | 'attacking'>('form'); const [error, setError] = useState(null); const requests = useRef(0); + const copies = useCopyHolder(); const abort = useRef(null); const live = useRef(true); @@ -85,7 +87,7 @@ export default function AttackScreen() { } if (!userId || caseKey.startsWith('local-')) return; let active = true; - fetchResult(userId, 'case', caseKey) + fetchResult(userId, 'case', caseKey, copies.mark()) .then((row) => { if (active && row && isCompleteCase(row.output)) setForm({ caseText: caseToText(row.output) }); }) @@ -93,7 +95,7 @@ export default function AttackScreen() { return () => { active = false; }; - }, [caseKey, userId, setForm]); + }, [caseKey, userId, setForm, copies]); const submit = async () => { if (!userId) return; diff --git a/apps/mobile/src/app/debates.tsx b/apps/mobile/src/app/debates.tsx index 753e3baab..1dad451a9 100644 --- a/apps/mobile/src/app/debates.tsx +++ b/apps/mobile/src/app/debates.tsx @@ -12,6 +12,7 @@ import { useAuth } from '@/lib/auth'; import { useDebates } from '@/lib/debate-context'; import { fetchSourceCounts, type Debate } from '@/lib/debates'; import { formatRelativeDate, isToday } from '@/lib/dates'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { radius } from '@/theme/colors'; import { fontFamily } from '@/theme/fonts'; import { useTheme } from '@/theme/use-theme'; @@ -32,6 +33,7 @@ export default function DebatesSheet() { const [sourceCounts, setSourceCounts] = useState>({}); const [switching, setSwitching] = useState(null); const [error, setError] = useState(''); + const copies = useCopyHolder(); useEffect(() => { void refresh(); @@ -41,7 +43,7 @@ export default function DebatesSheet() { useEffect(() => { if (!userId || !ids) return; let active = true; - fetchSourceCounts(userId, ids.split(',')) + fetchSourceCounts(userId, ids.split(','), copies.mark()) .then((counts) => { if (active) setSourceCounts(counts); }) @@ -49,7 +51,7 @@ export default function DebatesSheet() { return () => { active = false; }; - }, [userId, ids]); + }, [userId, ids, copies]); const visible = useMemo(() => { const needle = normalize(query); diff --git a/apps/mobile/src/app/flowchart/index.tsx b/apps/mobile/src/app/flowchart/index.tsx index e5232c7ce..8c6d7db61 100644 --- a/apps/mobile/src/app/flowchart/index.tsx +++ b/apps/mobile/src/app/flowchart/index.tsx @@ -35,7 +35,8 @@ import { type FlowchartDraft, } from '@/lib/flowchart-drafts'; import { flowchartToText } from '@/lib/flowchart-text'; -import { deleteFlowchart, fetchFlowchart, fetchFlowchartList, insertFlowchart, updateFlowchart } from '@/lib/flowcharts'; +import { deleteFlowchart, fetchFlowchart, fetchFlowchartList, flowchartScreenCopies, insertFlowchart, updateFlowchart } from '@/lib/flowcharts'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; const NOTICE_MS = 3500; @@ -71,6 +72,7 @@ export default function FlowchartScreen() { const live = useRef(true); const owner = useRef(userId); + const copies = useCopyHolder(flowchartScreenCopies); owner.current = userId; const loads = useRef(0); const noticeTimer = useRef | null>(null); @@ -98,6 +100,7 @@ export default function FlowchartScreen() { if (!userId) return; const forUser = userId; const isCurrent = () => loads.current === request && live.current && owner.current === forUser; + const mark = copies.mark(); setFailure(null); const run = async () => { if (requestedId) { @@ -107,7 +110,7 @@ export default function FlowchartScreen() { setFound({ id: requestedId, scope: held.scope }); return; } - const row = await fetchFlowchart(forUser, requestedId); + const row = await fetchFlowchart(forUser, requestedId, mark); if (!isCurrent()) return; if (!row) { setFailure({ scope: null, kind: 'missing' }); @@ -120,9 +123,9 @@ export default function FlowchartScreen() { } const target = scopeKey(activeDebateId); if (openDraft(target)) return; - const list = await fetchFlowchartList(forUser, activeDebateId); + const list = await fetchFlowchartList(forUser, activeDebateId, mark); if (!isCurrent() || openDraft(target)) return; - const row = list.length > 0 ? await fetchFlowchart(forUser, list[0].id) : null; + const row = list.length > 0 ? await fetchFlowchart(forUser, list[0].id, mark) : null; if (!isCurrent() || openDraft(target)) return; showDraft(target, row ? savedDraft(row) : newDraft(activeDebateId, starterFlowchart(tRef.current))); }; @@ -131,7 +134,7 @@ export default function FlowchartScreen() { console.warn('Could not load flowcharts:', cause instanceof Error ? cause.message : cause); setFailure({ scope: requestedId ? null : scopeKey(activeDebateId), kind: 'failed' }); }); - }, [userId, activeDebateId, requestedId, attempt]); + }, [userId, activeDebateId, requestedId, attempt, copies]); const open = scope ? openDraft(scope) : null; const draft = open?.draft ?? null; diff --git a/apps/mobile/src/app/flowchart/maps.tsx b/apps/mobile/src/app/flowchart/maps.tsx index f6f1a9aa6..fae0f1976 100644 --- a/apps/mobile/src/app/flowchart/maps.tsx +++ b/apps/mobile/src/app/flowchart/maps.tsx @@ -12,7 +12,8 @@ import { useAuth } from '@/lib/auth'; import { confirmDestructive } from '@/lib/confirm'; import { formatRelativeDate } from '@/lib/dates'; import { getDraft, NEW_MAP, newDraft, openDraft, savedDraft, scopeDebateId, showDraft, useFlowchartDrafts } from '@/lib/flowchart-drafts'; -import { fetchFlowchart, fetchFlowchartList, type FlowchartSummary } from '@/lib/flowcharts'; +import { fetchFlowchart, fetchFlowchartList, flowchartScreenCopies, type FlowchartSummary } from '@/lib/flowcharts'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { keepIfSame } from '@/lib/keep-if-same'; import { radius } from '@/theme/colors'; import { useTheme } from '@/theme/use-theme'; @@ -35,6 +36,7 @@ export default function SavedMapsSheet() { const [opening, setOpening] = useState(null); const [failedOpen, setFailedOpen] = useState(false); const loads = useRef(0); + const copies = useCopyHolder(); // Only the latest tap may open a map; an earlier one still loading is dropped. const selections = useRef(0); const live = useRef(true); @@ -52,13 +54,13 @@ export default function SavedMapsSheet() { const forScope = scope; const isCurrent = () => loads.current === request && live.current && owner.current === forUser; try { - const items = await fetchFlowchartList(forUser, debateId); + const items = await fetchFlowchartList(forUser, debateId, copies.mark()); if (isCurrent()) setList((previous) => keepIfSame(previous, { for: `${forUser}:${forScope}`, items, failed: false })); } catch (cause) { console.warn('Could not load saved flowcharts:', cause instanceof Error ? cause.message : cause); if (isCurrent()) setList((previous) => (previous?.for === `${forUser}:${forScope}` && previous.items ? previous : { for: `${forUser}:${forScope}`, items: null, failed: true })); } - }, [debateId, scope, userId]); + }, [debateId, scope, userId, copies]); useEffect(() => { void load(); @@ -82,7 +84,8 @@ export default function SavedMapsSheet() { setOpening(index); setFailedOpen(false); try { - const row = await fetchFlowchart(forUser, item.id); + // The map shows on the Flowchart screen once this sheet closes, so it counts there. + const row = await fetchFlowchart(forUser, item.id, flowchartScreenCopies.mark()); if (!live.current || owner.current !== forUser || selections.current !== selection) return; if (!row) { setFailedOpen(true); diff --git a/apps/mobile/src/app/source/[id].tsx b/apps/mobile/src/app/source/[id].tsx index c15de2591..def0477f3 100644 --- a/apps/mobile/src/app/source/[id].tsx +++ b/apps/mobile/src/app/source/[id].tsx @@ -12,6 +12,7 @@ import { SOURCE_ICONS, sourceMeta } from '@/components/source-display'; import { Text } from '@/components/text'; import { useAuth } from '@/lib/auth'; import { fetchSource, type Source } from '@/lib/sources'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { useTheme } from '@/theme/use-theme'; function webUrl(origin: string | null): string | null { @@ -28,11 +29,12 @@ export default function SourceSheet() { const userId = session?.user.id ?? null; const [source, setSource] = useState(undefined); const [failed, setFailed] = useState(false); + const copies = useCopyHolder(); useEffect(() => { if (!userId) return; let active = true; - fetchSource(userId, id) + fetchSource(userId, id, copies.mark()) .then((next) => { if (active) setSource(next); }) @@ -43,7 +45,7 @@ export default function SourceSheet() { return () => { active = false; }; - }, [userId, id]); + }, [userId, id, copies]); const url = webUrl(source?.origin ?? null); diff --git a/apps/mobile/src/app/sparring/[id].tsx b/apps/mobile/src/app/sparring/[id].tsx index de72bae29..4b9575e87 100644 --- a/apps/mobile/src/app/sparring/[id].tsx +++ b/apps/mobile/src/app/sparring/[id].tsx @@ -11,6 +11,7 @@ import { SparringScorecardScreen } from '@/components/sparring-views'; import { useAuth } from '@/lib/auth'; import { backLabel } from '@/lib/back-label'; import { fetchSparringRound, sparringDraft } from '@/lib/sparring'; +import { useCopyHolder } from '@/lib/use-copy-holder'; type Judged = SparringSession & { scorecard: SparringScorecard }; type State = { status: 'loading' } | { status: 'missing' } | { status: 'failed' } | { status: 'ready'; round: Judged }; @@ -36,12 +37,13 @@ export default function SavedSparringScreen() { return { owner: userId, state: kept ? { status: 'ready', round: kept } : { status: 'loading' } }; }); const state: State = held.owner === userId ? held.state : { status: 'loading' }; + const copies = useCopyHolder(); useEffect(() => { if (judged(sparringDraft.get(), id) || !userId) return; const forUser = userId; let active = true; - fetchSparringRound(forUser, id) + fetchSparringRound(forUser, id, copies.mark()) .then((round) => { if (!active) return; const ready = judged(round, id); @@ -54,7 +56,7 @@ export default function SavedSparringScreen() { return () => { active = false; }; - }, [id, userId]); + }, [id, userId, copies]); if (state.status !== 'ready') { return ( diff --git a/apps/mobile/src/components/ask-history.tsx b/apps/mobile/src/components/ask-history.tsx index 8d8fb4c75..3d5e95ff6 100644 --- a/apps/mobile/src/components/ask-history.tsx +++ b/apps/mobile/src/components/ask-history.tsx @@ -10,6 +10,7 @@ import { Text } from './text'; import { useAuth } from '@/lib/auth'; import { fetchChats, type ChatSummary } from '@/lib/chats'; +import { useCopyHolder } from '@/lib/use-copy-holder'; import { formatDate } from '@/lib/dates'; import { useTheme } from '@/theme/use-theme'; @@ -36,13 +37,17 @@ export function AskHistory({ const userId = session?.user.id ?? null; const [chats, setChats] = useState(null); const [failed, setFailed] = useState(false); + const copies = useCopyHolder(); useEffect(() => { - if (!visible || !userId) return; + if (!visible || !userId) { + copies.release(); + return; + } let active = true; setChats(null); setFailed(false); - fetchChats(userId, debateId) + fetchChats(userId, debateId, copies.mark()) .then((next) => { if (active) setChats(next); }) @@ -53,7 +58,7 @@ export function AskHistory({ return () => { active = false; }; - }, [visible, userId, debateId]); + }, [visible, userId, debateId, copies]); return ( diff --git a/apps/mobile/src/lib/chats.ts b/apps/mobile/src/lib/chats.ts index 4fa598c8f..cf131e39c 100644 --- a/apps/mobile/src/lib/chats.ts +++ b/apps/mobile/src/lib/chats.ts @@ -1,7 +1,7 @@ import type { AskOutput } from '@debatelab/core/ai/schemas/ask'; import { debateListScopes } from './debates'; -import { afterWrite, cachedRead, pickFailure, readFailed } from './offline-cache'; +import { afterWrite, cachedRead, pickFailure, readFailed, type CopyMark } from './offline-cache'; import { supabase } from './supabase'; /** @@ -56,8 +56,8 @@ interface MessageRow { */ /** Chats in a debate, or the unsorted ones in one-off mode, newest first, as the website's saved chats list. */ -export function fetchChats(userId: string, debateId: string | null): Promise { - return cachedRead(userId, 'chats', debateId ?? 'one-off', () => loadChats(userId, debateId)); +export function fetchChats(userId: string, debateId: string | null, mark?: CopyMark): Promise { + return cachedRead(userId, 'chats', debateId ?? 'one-off', () => loadChats(userId, debateId), mark); } async function loadChats(userId: string, debateId: string | null): Promise { @@ -76,8 +76,8 @@ async function loadChats(userId: string, debateId: string | null): Promise { - return cachedRead(userId, 'chat', chatId, () => loadChat(userId, chatId)); +export function fetchChat(userId: string, chatId: string, mark?: CopyMark): Promise<{ debateId: string | null; messages: ChatMessage[] }> { + return cachedRead(userId, 'chat', chatId, () => loadChat(userId, chatId), mark); } async function loadChat(userId: string, chatId: string): Promise<{ debateId: string | null; messages: ChatMessage[] }> { diff --git a/apps/mobile/src/lib/debates.ts b/apps/mobile/src/lib/debates.ts index f3a9c07be..287019c0e 100644 --- a/apps/mobile/src/lib/debates.ts +++ b/apps/mobile/src/lib/debates.ts @@ -1,4 +1,4 @@ -import { cachedRead, pickFailure, readAll, readFailed, updateCopy, type CopyScope } from './offline-cache'; +import { cachedRead, pickFailure, readAll, readFailed, updateCopy, type CopyMark, type CopyScope } from './offline-cache'; import { supabase } from './supabase'; /** @@ -188,8 +188,8 @@ async function countRows(userId: string, table: string, debateId: string, sides? const sum = (counts: number[]) => counts.reduce((total, count) => total + count, 0); /** Sources, saved results per side and Sparring rounds in one debate. One-off work has no debate and never counts. */ -export function fetchDebateCounts(userId: string, debateId: string): Promise { - return cachedRead(userId, 'debate-lists', `counts:${debateId}`, () => loadDebateCounts(userId, debateId)); +export function fetchDebateCounts(userId: string, debateId: string, mark?: CopyMark): Promise { + return cachedRead(userId, 'debate-lists', `counts:${debateId}`, () => loadDebateCounts(userId, debateId), mark); } async function loadDebateCounts(userId: string, debateId: string): Promise { @@ -203,8 +203,8 @@ async function loadDebateCounts(userId: string, debateId: string): Promise> { - return cachedRead(userId, 'debate-lists', `source-counts:${debateIds.join(',')}`, () => loadSourceCounts(userId, debateIds)); +export function fetchSourceCounts(userId: string, debateIds: string[], mark?: CopyMark): Promise> { + return cachedRead(userId, 'debate-lists', `source-counts:${debateIds.join(',')}`, () => loadSourceCounts(userId, debateIds), mark); } async function loadSourceCounts(userId: string, debateIds: string[]): Promise> { @@ -239,8 +239,8 @@ async function fetchResultRows(userId: string, debateId: string, table: ResultTa const newestFirst = (lists: DebateItem[][], limit: number) => lists.flat().sort((a, b) => b.createdAt - a.createdAt).slice(0, limit); /** The newest saved results and rounds for one side of a debate, like the website's side lists. */ -export function fetchRecentWork(userId: string, debateId: string, side: DebateSide, limit: number): Promise { - return cachedRead(userId, 'debate-lists', `recent:${debateId}:${side}:${limit}`, () => loadRecentWork(userId, debateId, side, limit)); +export function fetchRecentWork(userId: string, debateId: string, side: DebateSide, limit: number, mark?: CopyMark): Promise { + return cachedRead(userId, 'debate-lists', `recent:${debateId}:${side}:${limit}`, () => loadRecentWork(userId, debateId, side, limit), mark); } async function loadRecentWork(userId: string, debateId: string, side: DebateSide, limit: number): Promise { @@ -303,8 +303,8 @@ function listScopes(debateId: string | undefined, change: 'source' | DebateItemK } /** A debate's saved Prepare results for both sides, newest first, for the Prepare tab. */ -export function fetchPrepareWork(userId: string, debateId: string, limit: number): Promise { - return cachedRead(userId, 'debate-lists', `prepare:${debateId}:${limit}`, () => loadPrepareWork(userId, debateId, limit)); +export function fetchPrepareWork(userId: string, debateId: string, limit: number, mark?: CopyMark): Promise { + return cachedRead(userId, 'debate-lists', `prepare:${debateId}:${limit}`, () => loadPrepareWork(userId, debateId, limit), mark); } async function loadPrepareWork(userId: string, debateId: string, limit: number): Promise { diff --git a/apps/mobile/src/lib/flowcharts.ts b/apps/mobile/src/lib/flowcharts.ts index 94539fc36..12d866403 100644 --- a/apps/mobile/src/lib/flowcharts.ts +++ b/apps/mobile/src/lib/flowcharts.ts @@ -1,7 +1,7 @@ import { readFlowchart, type FlowchartDocument } from '@debatelab/core/flowchart'; import { debateListScopes, deletedListScopes } from './debates'; -import { afterWrite, cachedRead, readFailed, updateCopy } from './offline-cache'; +import { afterWrite, cachedRead, CopyHolder, readFailed, updateCopy, type CopyMark } from './offline-cache'; import { supabase } from './supabase'; /** @@ -46,8 +46,8 @@ function fromRow(row: FlowchartRow): SavedFlowchart { } /** The maps saved in one debate, or one-off maps for null, newest first, as the website's saved list shows them. */ -export function fetchFlowchartList(userId: string, debateId: string | null): Promise { - return cachedRead(userId, 'flowcharts', debateId ?? 'one-off', () => loadFlowchartList(userId, debateId)); +export function fetchFlowchartList(userId: string, debateId: string | null, mark?: CopyMark): Promise { + return cachedRead(userId, 'flowcharts', debateId ?? 'one-off', () => loadFlowchartList(userId, debateId), mark); } async function loadFlowchartList(userId: string, debateId: string | null): Promise { @@ -61,8 +61,11 @@ async function loadFlowchartList(userId: string, debateId: string | null): Promi })); } -export function fetchFlowchart(userId: string, id: string): Promise { - return cachedRead(userId, 'flowchart', id, () => loadFlowchart(userId, id)); +/** The copies the Flowchart screen shows, including a map the saved maps sheet opened for it. */ +export const flowchartScreenCopies = new CopyHolder(); + +export function fetchFlowchart(userId: string, id: string, mark?: CopyMark): Promise { + return cachedRead(userId, 'flowchart', id, () => loadFlowchart(userId, id), mark); } async function loadFlowchart(userId: string, id: string): Promise { diff --git a/apps/mobile/src/lib/network-status.ts b/apps/mobile/src/lib/network-status.ts index 829f34996..2ede8725a 100644 --- a/apps/mobile/src/lib/network-status.ts +++ b/apps/mobile/src/lib/network-status.ts @@ -5,7 +5,8 @@ import { useSyncExternalStore } from 'react'; * Whether the app is reading saved copies instead of the network. expo-network * says when the device is offline; offline-cache.ts says while any screen's * data is a saved copy, which covers a network that is up but cannot reach - * the server. Each copy counts until its own data is read from the server again. + * the server. Each copy counts until its own data is read from the server + * again, or the screens showing it close or move on (offline-cache.ts, CopyHolder). */ export interface OfflineStatus { diff --git a/apps/mobile/src/lib/offline-cache.test.ts b/apps/mobile/src/lib/offline-cache.test.ts index 3b3ab67c6..cf6a5ac32 100644 --- a/apps/mobile/src/lib/offline-cache.test.ts +++ b/apps/mobile/src/lib/offline-cache.test.ts @@ -228,6 +228,32 @@ describe('the saved-copies notice', () => { await cache.cachedRead('user-a', 'source', 'source-1', async () => 'text'); expect(shown()).toBe(false); }); + + it('goes when the screen showing a copy closes or reads something else', async () => { + const cache = await load(); + cache.setCacheOwner('user-a'); + await cache.cachedRead('user-a', 'source', 'source-1', async () => 'text'); + await cache.cachedRead('user-a', 'source', 'source-2', async () => 'other'); + const shown = () => cache.getOfflineStatus().savedCopies; + const sheet = new cache.CopyHolder(); + await cache.cachedRead('user-a', 'source', 'source-1', offlineFailure, sheet.mark()); + expect(shown()).toBe(true); + sheet.release(); + expect(shown()).toBe(false); + + // The screen moves on to another source, read from the server: the first copy no longer counts. + await cache.cachedRead('user-a', 'source', 'source-1', offlineFailure, sheet.mark()); + await cache.cachedRead('user-a', 'source', 'source-2', async () => 'other', sheet.mark()); + expect(shown()).toBe(false); + + // A read still running when the screen closed does not count once it lands. + const slow = deferred(); + const late = cache.cachedRead('user-a', 'source', 'source-1', () => slow.promise.then(offlineFailure), sheet.mark()); + sheet.release(); + slow.resolve(); + await expect(late).resolves.toBe('text'); + expect(shown()).toBe(false); + }); }); describe('after a write', () => { diff --git a/apps/mobile/src/lib/offline-cache.ts b/apps/mobile/src/lib/offline-cache.ts index 08d026712..c3dc6426e 100644 --- a/apps/mobile/src/lib/offline-cache.ts +++ b/apps/mobile/src/lib/offline-cache.ts @@ -107,9 +107,11 @@ const newest = new Map(); // started before it. Kept only while such a read is still running. let floors: { scope: CopyScope; ticket: number }[] = []; const running = new Set(); -// The copies screens are showing right now instead of the server's answer. -// A key leaves when it is read from the server again, or when a write drops it. -const shown = new Map(); +// The copies screens are showing right now instead of the server's answer, and +// who shows each: a screen's CopyHolder, or null for a read that named none. A +// key leaves when it is read from the server again, when a write drops it, or +// when the last screen showing it lets it go. +const shown = new Map }>(); let index: { userId: string; entries: Index } | null = null; function warn(what: string) { @@ -213,9 +215,64 @@ async function removeEntries(userId: string, storageKeys: string[]) { await AsyncStorage.multiRemove(storageKeys); } -function noteShown(storageKey: string, group: CacheGroup, fromCopy: boolean) { - if (fromCopy) shown.set(storageKey, group); - else shown.delete(storageKey); +/** + * The reads of one screen, or of one part of it, so the saved copies it shows + * stop counting once it moves on. Each load takes a `mark()` and passes it to + * its reads; the first of them to finish lets go of what the screen's earlier + * loads showed, and a read from an earlier load finishing late no longer + * counts. `release()` lets go of everything, for when the screen closes or + * stops showing that data. + */ +export class CopyHolder { + loads = 0; + settled = 0; + readonly keys = new Set(); + + mark(): CopyMark { + return { holder: this, load: ++this.loads }; + } + + release() { + this.settled = ++this.loads; + letGo(this); + setSavedCopiesShown(shown.size > 0); + } +} + +export interface CopyMark { + holder: CopyHolder; + load: number; +} + +function letGo(holder: CopyHolder) { + for (const storageKey of holder.keys) { + const entry = shown.get(storageKey); + entry?.holders.delete(holder); + if (entry?.holders.size === 0) shown.delete(storageKey); + } + holder.keys.clear(); +} + +/** Whether a finished read of this load still counts for its screen; a newer load first lets go of the older ones' copies. */ +function settleMark({ holder, load }: CopyMark): boolean { + if (load < holder.settled) return false; + if (load > holder.settled) { + holder.settled = load; + letGo(holder); + } + return true; +} + +function noteShown(storageKey: string, group: CacheGroup, fromCopy: boolean, mark: CopyMark | undefined) { + const counts = !mark || settleMark(mark); + if (!fromCopy) shown.delete(storageKey); + else if (counts) { + const holder = mark?.holder ?? null; + const entry = shown.get(storageKey) ?? { group, holders: new Set() }; + entry.holders.add(holder); + shown.set(storageKey, entry); + holder?.keys.add(storageKey); + } setSavedCopiesShown(shown.size > 0); } @@ -223,8 +280,9 @@ function noteShown(storageKey: string, group: CacheGroup, fromCopy: boolean) { * Reads through the network and keeps a copy for this account. Offline, or * when no answer came back from the server, answers from the copy; with no * copy, or when the server answered with an error, fails as the read did. + * A screen passes its load's `mark` so a copy it shows stops counting once it moves on. */ -export async function cachedRead(userId: string, group: CacheGroup, key: string, load: () => Promise): Promise { +export async function cachedRead(userId: string, group: CacheGroup, key: string, load: () => Promise, mark?: CopyMark): Promise { const ticket = ++tickets; const gen = generation; const storageKey = entryKey(userId, group, key); @@ -233,7 +291,7 @@ export async function cachedRead(userId: string, group: CacheGroup, key: stri const copy = await readCopy(userId, group, key).catch(() => undefined); if (copy) { settle(ticket); - if (gen === generation) noteShown(storageKey, group, true); + if (gen === generation) noteShown(storageKey, group, true, mark); return copy.value; } } @@ -245,10 +303,10 @@ export async function cachedRead(userId: string, group: CacheGroup, key: stri if (!isUnreachable(cause)) throw cause; const copy = await readCopy(userId, group, key).catch(() => undefined); if (!copy) throw cause; - if (gen === generation) noteShown(storageKey, group, true); + if (gen === generation) noteShown(storageKey, group, true, mark); return copy.value; } - if (gen === generation) noteShown(storageKey, group, false); + if (gen === generation) noteShown(storageKey, group, false, mark); writeCopy(userId, group, key, value, ticket, gen).catch(warn('Could not keep a saved copy')); return value; } @@ -287,7 +345,7 @@ export function afterWrite(userId: string, scopes: CopyScope[]) { if (scope.key !== undefined) newest.set(entryKey(userId, scope.group, scope.key), ticket); else if (running.size > 0) floors.push({ scope, ticket }); } - for (const [storageKey, group] of shown) { + for (const [storageKey, { group }] of shown) { if (scopes.some((scope) => inScope(userId, scope, group, storageKey))) shown.delete(storageKey); } setSavedCopiesShown(shown.size > 0); diff --git a/apps/mobile/src/lib/results.ts b/apps/mobile/src/lib/results.ts index c33c822f9..c03b44cd9 100644 --- a/apps/mobile/src/lib/results.ts +++ b/apps/mobile/src/lib/results.ts @@ -19,7 +19,7 @@ import type { DebateContextRequest } from '@debatelab/core/contract/debate-conte import { AIError, functionOutput, postFunction, PROVIDER, responseExtras, type ResponseExtras } from './ai-request'; import type { FeatureConfig } from './feature-config'; import { debateListScopes, deletedListScopes } from './debates'; -import { afterWrite, cachedRead, readFailed } from './offline-cache'; +import { afterWrite, cachedRead, readFailed, type CopyMark } from './offline-cache'; import { addSource, applySourceAssessments, fetchSourceIdsInFolder, removeSource } from './sources'; import { supabase } from './supabase'; @@ -251,8 +251,8 @@ interface ResultRow { } /** A saved result; offline, the saved copy. */ -export function fetchResult(userId: string, kind: K, id: string): Promise | null> { - return cachedRead(userId, `result:${kind}`, id, () => loadResult(userId, kind, id)); +export function fetchResult(userId: string, kind: K, id: string, mark?: CopyMark): Promise | null> { + return cachedRead(userId, `result:${kind}`, id, () => loadResult(userId, kind, id), mark); } async function loadResult(userId: string, kind: K, id: string): Promise | null> { diff --git a/apps/mobile/src/lib/sources.ts b/apps/mobile/src/lib/sources.ts index 27e01af9b..eaa5b6f94 100644 --- a/apps/mobile/src/lib/sources.ts +++ b/apps/mobile/src/lib/sources.ts @@ -4,7 +4,7 @@ import { assessedSemantic, isAssessedSource, type SourceAssessmentUpdate } from import { getEdgeFunctionErrorMessage } from '@debatelab/core/supabase/edge-function-error'; import { debateListScopes, deletedListScopes } from './debates'; -import { afterWrite, cachedRead, readFailed, type CopyScope } from './offline-cache'; +import { afterWrite, cachedRead, readFailed, type CopyMark, type CopyScope } from './offline-cache'; import { supabase } from './supabase'; /** @@ -64,8 +64,8 @@ function fromRow(row: SourceRow): Source { */ /** One debate's sources, newest first, as the website's Library lists them by default. Offline, the saved copy. */ -export function fetchSources(userId: string, debateId: string): Promise { - return cachedRead(userId, 'sources', debateId, () => loadSources(userId, debateId)); +export function fetchSources(userId: string, debateId: string, mark?: CopyMark): Promise { + return cachedRead(userId, 'sources', debateId, () => loadSources(userId, debateId), mark); } async function loadSources(userId: string, debateId: string): Promise { @@ -79,8 +79,8 @@ async function loadSources(userId: string, debateId: string): Promise return ((data ?? []) as SourceRow[]).map(fromRow); } -export function fetchSource(userId: string, id: string): Promise { - return cachedRead(userId, 'source', id, () => loadSource(userId, id)); +export function fetchSource(userId: string, id: string, mark?: CopyMark): Promise { + return cachedRead(userId, 'source', id, () => loadSource(userId, id), mark); } async function loadSource(userId: string, id: string): Promise { diff --git a/apps/mobile/src/lib/sparring.ts b/apps/mobile/src/lib/sparring.ts index 319b29de3..f40fe61d8 100644 --- a/apps/mobile/src/lib/sparring.ts +++ b/apps/mobile/src/lib/sparring.ts @@ -15,7 +15,7 @@ import { AIError, functionOutput, postFunction, PROVIDER } from './ai-request'; import type { FeatureConfig } from './feature-config'; import { sessionSlot } from './session-memory'; import { debateListScopes, deletedListScopes } from './debates'; -import { afterWrite, cachedRead, readFailed, type CopyScope } from './offline-cache'; +import { afterWrite, cachedRead, readFailed, type CopyMark, type CopyScope } from './offline-cache'; import { supabase } from './supabase'; /** @@ -103,8 +103,8 @@ export async function attachSparring(userId: string, id: string, debateId: strin } /** A saved, judged round; null when there is none or it does not check out. Offline, the saved copy. */ -export async function fetchSparringRound(userId: string, id: string): Promise { - return cachedRead(userId, 'sparring', id, () => loadSparringRound(userId, id)); +export async function fetchSparringRound(userId: string, id: string, mark?: CopyMark): Promise { + return cachedRead(userId, 'sparring', id, () => loadSparringRound(userId, id), mark); } async function loadSparringRound(userId: string, id: string): Promise { @@ -124,8 +124,8 @@ export interface SparringRoundSummary { * The judged rounds in one debate, or outside every debate when `debateId` is * null, newest first, as the website's history lists them. */ -export async function fetchSparringRounds(userId: string, debateId: string | null, limit: number): Promise { - return cachedRead(userId, 'sparring-rounds', `${debateId ?? 'one-off'}:${limit}`, () => loadSparringRounds(userId, debateId, limit)); +export async function fetchSparringRounds(userId: string, debateId: string | null, limit: number, mark?: CopyMark): Promise { + return cachedRead(userId, 'sparring-rounds', `${debateId ?? 'one-off'}:${limit}`, () => loadSparringRounds(userId, debateId, limit), mark); } async function loadSparringRounds(userId: string, debateId: string | null, limit: number): Promise { diff --git a/apps/mobile/src/lib/use-copy-holder.ts b/apps/mobile/src/lib/use-copy-holder.ts new file mode 100644 index 000000000..c23bc4dd0 --- /dev/null +++ b/apps/mobile/src/lib/use-copy-holder.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from 'react'; + +import { CopyHolder } from './offline-cache'; + +/** + * A CopyHolder for one screen's reads; the copies it shows stop counting when + * the screen closes. A screen that also shows what another screen read for it + * passes the holder they share. + */ +export function useCopyHolder(shared?: CopyHolder): CopyHolder { + const [holder] = useState(() => shared ?? new CopyHolder()); + useEffect(() => () => holder.release(), [holder]); + return holder; +} diff --git a/apps/mobile/src/lib/use-result-document.ts b/apps/mobile/src/lib/use-result-document.ts index 144d80206..4143bd8ff 100644 --- a/apps/mobile/src/lib/use-result-document.ts +++ b/apps/mobile/src/lib/use-result-document.ts @@ -9,6 +9,7 @@ import { useDebates } from './debate-context'; import { forgetOpenResult, keepOpenResult, openResult, updateOpenResult, type OpenResult } from './open-results'; import { deleteResult, fetchAutoSavedSourceIds, fetchResult, removeSources, saveCase, type ArrivalNotice, type ResultKind } from './results'; import { fetchSources, subscribeSourcesChanged, type Source } from './sources'; +import { useCopyHolder } from './use-copy-holder'; export type DocumentState = | { status: 'loading' } @@ -64,6 +65,8 @@ export function useResultDocument(kind: K, id: string) { const [busy, setBusy] = useState(false); const live = useRef(true); const owner = useRef(userId); + const resultCopies = useCopyHolder(); + const libraryCopies = useCopyHolder(); owner.current = userId; const noticeTimer = useRef | null>(null); @@ -89,7 +92,7 @@ export function useResultDocument(kind: K, id: string) { return; } let active = true; - fetchResult(userId, kind, id) + fetchResult(userId, kind, id, resultCopies.mark()) .then((row) => { if (!active) return; if (!row) { @@ -107,7 +110,7 @@ export function useResultDocument(kind: K, id: string) { return () => { active = false; }; - }, [userId, kind, id]); + }, [userId, kind, id, resultCopies]); const result = state.status === 'ready' ? state.result : null; const resultDebateId = result?.debateId ?? null; @@ -116,11 +119,12 @@ export function useResultDocument(kind: K, id: string) { useEffect(() => { if (!userId || !resultDebateId) { setLibrary([]); + libraryCopies.release(); return; } let active = true; const load = () => - fetchSources(userId, resultDebateId) + fetchSources(userId, resultDebateId, libraryCopies.mark()) .then((next) => { if (active) setLibrary(next); }) @@ -131,7 +135,7 @@ export function useResultDocument(kind: K, id: string) { active = false; unsubscribe(); }; - }, [userId, resultDebateId]); + }, [userId, resultDebateId, libraryCopies]); // The website's undo covers the Current Debate's Auto-saved folder, also in one-off mode. const currentId = current?.id ?? null; From 95313b1128ebcfe9cab51f40970872fe706851fa Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 14 Oct 2026 21:53:01 +0000 Subject: [PATCH 13/13] Throw a server refusal at once instead of waiting for a stalled query readAll held a refusal until every other query settled, so a 401 behind a query that never answered left the screen loading. A refusal now rejects as soon as it arrives; only queries that got no answer wait for the rest. Co-Authored-By: Claude Opus 5.5 --- apps/mobile/src/lib/offline-cache.test.ts | 8 +++++ apps/mobile/src/lib/offline-cache.ts | 39 +++++++++++++++++++---- 2 files changed, 40 insertions(+), 7 deletions(-) diff --git a/apps/mobile/src/lib/offline-cache.test.ts b/apps/mobile/src/lib/offline-cache.test.ts index cf6a5ac32..1eb1a2a12 100644 --- a/apps/mobile/src/lib/offline-cache.test.ts +++ b/apps/mobile/src/lib/offline-cache.test.ts @@ -212,6 +212,14 @@ describe('which failures fall back', () => { supabase.results.push(unreachable, refused); await expect(cache.fetchDebates('user-a')).rejects.toThrow('JWT expired'); }); + + it('throws a refusal at once instead of waiting for a query that never answers', async () => { + const cache = await load(); + const refused = Promise.reject(cache.readFailed({ message: 'JWT expired' }, 401)); + const outcome = cache.readAll([refused, new Promise(() => {})]).catch((cause: unknown) => cause); + const first = await Promise.race([outcome, new Promise((resolve) => setTimeout(() => resolve('still waiting'), 50))]); + expect(first).toMatchObject({ message: 'JWT expired' }); + }); }); describe('the saved-copies notice', () => { diff --git a/apps/mobile/src/lib/offline-cache.ts b/apps/mobile/src/lib/offline-cache.ts index c3dc6426e..65cc4e539 100644 --- a/apps/mobile/src/lib/offline-cache.ts +++ b/apps/mobile/src/lib/offline-cache.ts @@ -84,14 +84,39 @@ export function pickFailure(causes: unknown[]): unknown { } /** - * Promise.all for the queries of one read. It waits for all of them, so a - * refusal from one is thrown even when another got no answer first. + * Promise.all for the queries of one read. A server's refusal is thrown as + * soon as it arrives; a query that got no answer is held until the others + * settle, so a refusal from one of them still wins over it. */ -export async function readAll(reads: T): Promise<{ -readonly [K in keyof T]: Awaited }> { - const results = await Promise.allSettled(reads); - const failures = results.flatMap((result) => (result.status === 'rejected' ? [result.reason as unknown] : [])); - if (failures.length > 0) throw pickFailure(failures); - return results.map((result) => (result as PromiseFulfilledResult).value) as { -readonly [K in keyof T]: Awaited }; +export function readAll(reads: T): Promise<{ -readonly [K in keyof T]: Awaited }> { + return new Promise((resolve, reject) => { + const values: unknown[] = new Array(reads.length); + const unanswered: unknown[] = []; + let pending = reads.length; + const settle = () => { + pending -= 1; + if (pending > 0) return; + if (unanswered.length > 0) reject(unanswered[0]); + else resolve(values as { -readonly [K in keyof T]: Awaited }); + }; + if (pending === 0) { + resolve(values as { -readonly [K in keyof T]: Awaited }); + return; + } + reads.forEach((read, i) => { + Promise.resolve(read).then( + (value) => { + values[i] = value; + settle(); + }, + (cause: unknown) => { + if (!isUnreachable(cause)) reject(cause); + else unanswered.push(cause); + settle(); + }, + ); + }); + }); } const queue = createSerialQueue();