/** * The "connector" is the bridge between the local sync engine and the app's * own server. The sync engine asks the connector two questions: * * 1. fetchCredentials() — "what JWT and endpoint should I use to open the * sync WebSocket?" Called on first connect and again whenever the token * is close to expiring. * 2. uploadData() — "I've batched the user's pending local writes * into a transaction. Send them somewhere durable and tell me when * they're persisted." * * Everything else — reads, conflict resolution, retries, reconnection — is * handled by the sync engine. The connector intentionally has no read path. */ import { type AbstractPowerSyncDatabase, type PowerSyncBackendConnector, type CrudEntry, } from '@powersync/web'; // Endpoint paths can be overridden at build time via Vite env vars. In dev, // the defaults work because `vite.config.ts` proxies `/sync*` to the server // component. In prod, both surfaces are served from the same origin so the // relative paths just work. const TOKEN_ENDPOINT = import.meta.env.VITE_APP_SYNC_TOKEN_ENDPOINT || '/sync/token'; const SYNC_UPLOAD_ENDPOINT = import.meta.env.VITE_SYNC_UPLOAD_ENDPOINT || '/sync'; export class AppConnector implements PowerSyncBackendConnector { /** * Mint a fresh JWT by asking our own server. We never store the token — * the sync engine caches it internally and calls back here when it needs * a new one, so refresh-on-expiry is automatic. */ async fetchCredentials() { const res = await fetch(TOKEN_ENDPOINT); if (!res.ok) throw new Error(`Token fetch failed: ${res.status}`); const { token, endpoint } = await res.json(); return { endpoint, token }; } /** * Push one batch of local writes to the server. * * The sync engine guarantees: * - We're only called when there's work to do. * - Each `tx` is a coherent unit — if the engine called this with five * ops and we ack four, the unacked one will be retried. * - `tx.complete()` MUST be called only after the server has confirmed * durability. If we ack before the POST succeeds, a crash here loses * the user's write. * * If `uploadData` throws, the sync engine backs off and retries the same * transaction later. That's the desired behaviour for transient errors * (network blip, server restart). For permanent errors (validation * failure, schema mismatch) you'd want to either tx.complete() to drop * the bad write or surface it to the user — neither is implemented here * because the server route accepts everything the schema allows. */ async uploadData(database: AbstractPowerSyncDatabase) { const tx = await database.getNextCrudTransaction(); if (!tx) return; const ops = tx.crud.map((entry: CrudEntry) => ({ table: entry.table, op: entry.op, id: entry.id, data: entry.opData, })); const res = await fetch(SYNC_UPLOAD_ENDPOINT, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ops }), }); if (!res.ok) throw new Error(`Sync upload failed: ${res.status}`); await tx.complete(); } }