Files
readest/apps/readest-app/src/libs/replicaSyncClient.ts
T
Huang Xin 1eae2af23e feat(sync): batch replica sync into one /api/sync/replicas request (#4109)
Auto-sync triggers (boot non-settings, focus, visibilitychange, online,
periodic) used to fan out N parallel `GET /api/sync/replicas?kind=…`
requests, one per replica kind. With 5 kinds today and the focus path
firing on every foreground transition, that's 5x the Cloudflare Worker
invocations of what the work actually requires.

Server: extend POST /api/sync/replicas to accept a batched-pull body
(`{ cursors: [{kind, since}, …] }`) alongside the existing push
(`{ rows: […] }`). Per-kind queries fan out via Promise.all — Supabase
calls inside the Worker aren't billed as Cloudflare requests, so DB
load is unchanged while Worker invocations collapse from N to 1.

Client/manager: add `client.pullBatch` and `manager.pullMany` that
share the existing cursor/HLC machinery. The boot path's `since=null`
override carries over via `pullMany(kinds, { since: null })`.

Orchestrator: `triggerIncrementalPullAll` now does ONE pullMany call
then fans out per-kind apply via Promise.allSettled. Boot does the
same for non-settings kinds (settings stays a single call to preserve
its apply-first ordering invariant).

Foreground triggers: listen to BOTH `focus` and `visibilitychange`,
sharing one throttle. focus is fastest on iOS Tauri WKWebView (~T=0,
~400ms ahead of visibilitychange). visibilitychange is the only
signal that fires on browser tab switching — focus does not. Drops
the Supabase user-ref-change listener (was the slowest of the three
foreground signals; redundant with the DOM events).

Bonus: `useBooksSync` now serializes `handleAutoSync` against
`pullLibrary` via the shared `isPullingRef` gate. The two paths used
to fire two concurrent `/api/sync?type=books` requests on the same
`since` value at startup; now whichever runs first claims the gate
and the other skips (throttle's `emitLast` retries afterwards).

Per session: boot 5→2 Worker calls. Per foreground trigger: 5→1.
2026-05-09 16:18:46 +02:00

278 lines
9.6 KiB
TypeScript

import { getAccessToken } from '@/utils/access';
import { getAPIBaseUrl } from '@/services/environment';
import { SyncError } from '@/libs/errors';
import type { Hlc, ReplicaRow } from '@/types/replica';
import type { SyncErrorCode } from '@/libs/errors';
const ENDPOINT = () => `${getAPIBaseUrl()}/sync/replicas`;
const KEYS_ENDPOINT = () => `${getAPIBaseUrl()}/sync/replica-keys`;
export interface ReplicaKeyRow {
saltId: string;
alg: string;
salt: string;
createdAt: string;
}
interface ErrorBody {
error?: string;
code?: SyncErrorCode;
offendingIndex?: number;
}
const statusToDefaultCode = (status: number): SyncErrorCode => {
if (status === 401 || status === 403) return 'AUTH';
if (status === 402 || status === 507) return 'QUOTA_EXCEEDED';
if (status === 409) return 'CLOCK_SKEW';
if (status === 413) return 'VALIDATION';
if (status === 422) return 'VALIDATION';
if (status >= 500) return 'SERVER';
return 'VALIDATION';
};
const parseErrorBody = async (response: Response): Promise<ErrorBody> => {
try {
return (await response.json()) as ErrorBody;
} catch {
return {};
}
};
const requireToken = async (): Promise<string> => {
const token = await getAccessToken();
if (!token) throw new SyncError('AUTH', 'Not authenticated');
return token;
};
export class ReplicaSyncClient {
async push(rows: ReplicaRow[]): Promise<ReplicaRow[]> {
if (rows.length === 0) return [];
const token = await requireToken();
let response: Response;
try {
response = await fetch(ENDPOINT(), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ rows }),
});
} catch (cause) {
throw new SyncError('SERVER', 'Network failure during push', { cause });
}
if (!response.ok) {
const body = await parseErrorBody(response);
const code = body.code ?? statusToDefaultCode(response.status);
throw new SyncError(code, body.error ?? `Push failed with status ${response.status}`, {
status: response.status,
});
}
const data = (await response.json()) as { rows: ReplicaRow[] };
return data.rows ?? [];
}
async pull(kind: string, since: Hlc | null): Promise<ReplicaRow[]> {
const token = await requireToken();
const params = new URLSearchParams({ kind });
if (since) params.set('since', since);
const url = `${ENDPOINT()}?${params.toString()}`;
let response: Response;
try {
response = await fetch(url, {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
});
} catch (cause) {
throw new SyncError('SERVER', 'Network failure during pull', { cause });
}
if (response.status === 404) return [];
if (!response.ok) {
const body = await parseErrorBody(response);
const code = body.code ?? statusToDefaultCode(response.status);
throw new SyncError(code, body.error ?? `Pull failed with status ${response.status}`, {
status: response.status,
});
}
const data = (await response.json()) as { rows: ReplicaRow[] };
return data.rows ?? [];
}
/**
* Batched pull for the incremental auto-sync path. Collapses what
* used to be N parallel `GET /api/sync/replicas?kind=…&since=…`
* requests (one per replica kind, fired on every focus / online /
* periodic trigger) into a single POST round-trip. The boot path
* still uses `pull()` per kind to preserve the settings-first
* ordering invariant.
*
* The endpoint is the same `/sync/replicas` route — `{ cursors: [...] }`
* in the body discriminates batched-pull from `{ rows: [...] }` push.
*/
async pullBatch(
cursors: { kind: string; since: Hlc | null }[],
): Promise<{ kind: string; rows: ReplicaRow[] }[]> {
if (cursors.length === 0) return [];
const token = await requireToken();
let response: Response;
try {
response = await fetch(ENDPOINT(), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ cursors }),
});
} catch (cause) {
throw new SyncError('SERVER', 'Network failure during batch pull', { cause });
}
if (!response.ok) {
const body = await parseErrorBody(response);
const code = body.code ?? statusToDefaultCode(response.status);
throw new SyncError(code, body.error ?? `Batch pull failed with status ${response.status}`, {
status: response.status,
});
}
const data = (await response.json()) as { results: { kind: string; rows: ReplicaRow[] }[] };
return data.results ?? [];
}
/**
* The replica_keys list rarely changes — only on `createReplicaKey`
* (passphrase setup / rotation) and `forgetReplicaKeys` (forgot-
* passphrase wipe), both of which we own and invalidate explicitly.
* Without a cache here, every consumer that needs the salt list
* (CryptoSession.unlock, tryRestoreFromStore, deriveKeyFor on a
* cache-miss saltId, passphraseGate.ensurePassphraseUnlocked,
* SyncPassphraseSection.refreshStatus) issues its own fetch in
* parallel — observed as 5+ identical concurrent GETs at boot.
*
* Two-layer dedupe:
* * `replicaKeysInflight` coalesces concurrent calls onto the same
* in-flight promise — no duplicate network round trips even
* before the first response lands.
* * `replicaKeysCache` holds the resolved value indefinitely for
* subsequent calls. Invalidated on every mutation we issue.
*/
private replicaKeysCache: ReplicaKeyRow[] | null = null;
private replicaKeysInflight: Promise<ReplicaKeyRow[]> | null = null;
/** Discard the cached replica_keys list. Auth flows / sign-out should call this. */
invalidateReplicaKeysCache(): void {
this.replicaKeysCache = null;
this.replicaKeysInflight = null;
}
private async fetchReplicaKeys(): Promise<ReplicaKeyRow[]> {
const token = await requireToken();
let response: Response;
try {
response = await fetch(KEYS_ENDPOINT(), {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
});
} catch (cause) {
throw new SyncError('SERVER', 'Network failure during replica-keys list', { cause });
}
if (!response.ok) {
const body = await parseErrorBody(response);
const code = body.code ?? statusToDefaultCode(response.status);
throw new SyncError(
code,
body.error ?? `replica-keys list failed with status ${response.status}`,
{ status: response.status },
);
}
const data = (await response.json()) as { rows: ReplicaKeyRow[] };
return data.rows ?? [];
}
async listReplicaKeys(): Promise<ReplicaKeyRow[]> {
if (this.replicaKeysCache !== null) {
// Defensive copy — callers occasionally mutate the returned array
// (e.g., `[...rows].sort(...)`). Returning the cache directly
// would let one caller's mutation poison another's view.
return [...this.replicaKeysCache];
}
if (this.replicaKeysInflight) {
const rows = await this.replicaKeysInflight;
return [...rows];
}
this.replicaKeysInflight = this.fetchReplicaKeys()
.then((rows) => {
this.replicaKeysCache = rows;
return rows;
})
.finally(() => {
this.replicaKeysInflight = null;
});
const rows = await this.replicaKeysInflight;
return [...rows];
}
async forgetReplicaKeys(): Promise<void> {
const token = await requireToken();
let response: Response;
try {
response = await fetch(KEYS_ENDPOINT(), {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
} catch (cause) {
throw new SyncError('SERVER', 'Network failure during replica-keys forget', { cause });
}
if (!response.ok) {
const body = await parseErrorBody(response);
const code = body.code ?? statusToDefaultCode(response.status);
throw new SyncError(
code,
body.error ?? `replica-keys forget failed with status ${response.status}`,
{ status: response.status },
);
}
// Server side: every salt + every encrypted envelope is gone.
// Local cache is now lying — clear it.
this.replicaKeysCache = [];
}
async createReplicaKey(alg: string): Promise<ReplicaKeyRow> {
const token = await requireToken();
let response: Response;
try {
response = await fetch(KEYS_ENDPOINT(), {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ alg }),
});
} catch (cause) {
throw new SyncError('SERVER', 'Network failure during replica-keys create', { cause });
}
if (!response.ok) {
const body = await parseErrorBody(response);
const code = body.code ?? statusToDefaultCode(response.status);
throw new SyncError(
code,
body.error ?? `replica-keys create failed with status ${response.status}`,
{ status: response.status },
);
}
const data = (await response.json()) as { row: ReplicaKeyRow };
if (!data.row) {
throw new SyncError('SERVER', 'replica-keys create returned no row');
}
// Splice the new salt into the cache so the next listReplicaKeys
// call sees it without another round trip. Rotation appends — old
// salts stay accessible for decrypting envelopes still under them.
if (this.replicaKeysCache !== null) {
this.replicaKeysCache = [...this.replicaKeysCache, data.row];
}
return data.row;
}
}
export const replicaSyncClient = new ReplicaSyncClient();