forked from akai/readest
1eae2af23e
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.
278 lines
9.6 KiB
TypeScript
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();
|