import { isReplicaRowAlive } from '@/libs/replicaInterpret'; import type { ReplicaRow } from '@/types/replica'; import type { ReplicaTransferFile } from '@/store/transferStore'; import type { BaseDir } from '@/types/system'; import type { ReplicaAdapter } from './replicaRegistry'; import { captureCipherTexts, cipherTextsChanged, collectDecryptSuccess, decryptRowFields, } from './replicaCryptoMiddleware'; import { ensurePassphraseUnlocked } from './passphraseGate'; import { isCredentialsSyncEnabled } from './syncCategories'; import { cryptoSession } from '@/libs/crypto/session'; export interface ReplicaLocalRecord { /** * Per-record on-disk directory under the kind's base. Required for * sync-era records; legacy entries (created before replica sync) may * have it unset and the orchestrator treats them as non-syncable. */ bundleDir?: string; name: string; deletedAt?: number; /** * Per-field cipher fingerprint of the last successfully-decrypted * pull. Used to skip the passphrase prompt when nothing's changed * since last apply. See replicaCryptoMiddleware.captureCipherTexts. */ lastSeenCipher?: Record; } export interface PullAndApplyDeps { /** Replica adapter for this kind. Provides unpackRow + binary base dir. */ adapter: ReplicaAdapter; /** Pulls rows for this kind. Boot caller passes since=null for full sync. */ pull(): Promise; /** Looks up an existing local record by its cross-device contentId. */ findByContentId(contentId: string): T | undefined; /** Adds a remote-sourced record to the local store WITHOUT republishing. */ applyRemote(record: T): void; /** * Tombstones the local entry whose contentId matches. Implementer * looks up by contentId and removes it from the local store, but * skips re-publishing the tombstone — the row is already tombstoned * server-side; we just observed that fact. */ softDeleteByContentId(contentId: string): void; /** * Mints a fresh local bundleDir, creates the directory on disk under * the kind's base dir, returns the directory name (relative). */ createBundleDir(): Promise; /** * Hands the manifest's binary files off to TransferManager for * download. Returns the transfer id (or null if the queue isn't * ready). Caller arguments mirror transferManager.queueReplicaDownload. */ queueReplicaDownload( contentId: string, displayTitle: string, files: ReplicaTransferFile[], bundleDir: string, base: BaseDir, ): string | null; /** * Returns true iff EVERY filename exists on disk under * `/` in the kind's base dir. Lets the * orchestrator skip the download queue when the binaries from a * previous session are still around. */ filesExist(bundleDir: string, filenames: string[]): Promise; /** * Hydrates the local store from disk before the orchestrator queries * findByContentId. Without this, applyRemote's auto-persist round- * trip overwrites persisted entries that hadn't yet been pulled into * the in-memory store by a feature mount. */ hydrateLocalStore?(): Promise; /** * Reconciliation hook for "server has the row but no manifest, and * we're the device with the local binaries". The orchestrator * invokes this when applyRow finds an alive row with empty * `manifest_jsonb` AND a matching local record. Implementation * should fan out to the binary-upload pipeline (typically * `queueReplicaBinaryUpload(kind, record, appService)`), which in * turn fires `replica-transfer-complete` and commits the manifest. * Without this, transient upload failures or "TM not ready at * import time" leave the server row stuck with manifest_jsonb=null * forever — a refresh wouldn't recover it. */ queueLocalBinaryUpload?(record: T): Promise; /** * Optional auth precheck. When provided and resolves to false, the * orchestrator skips the entire pull (no network call, no warnings). */ isAuthenticated?(): Promise; /** * When true, encrypted-field cipher payloads are decrypted * best-effort but the orchestrator NEVER triggers the passphrase * gate for this kind. Cipher fields silently drop when the session * is locked, leaving the local copy intact. Use for kinds where * spam-prompting on every pull would be jarring (e.g., the bundled * `settings` row, which pulls on every library mount). The user * unlocks via an explicit Settings → Sync action; the next pull * cycle then decrypts cleanly. */ silentDecrypt?: boolean; /** * Called when one or more cipher fields failed to decrypt because * the cipher's `saltId` no longer exists in `replica_keys` (orphan * after an out-of-band server reset). Adapters that persist a * "previously published" fingerprint per encrypted path should * clear it so the next save re-encrypts the still-locally-held * plaintext under the current salt — overwriting the orphan * cipher on the server. No-op when the kind has no such fingerprint * to invalidate. */ onSaltNotFound?(paths: readonly string[]): void; } const MANIFEST_FILE_TO_TRANSFER = ( filename: string, byteSize: number, bundleDir: string, ): ReplicaTransferFile => ({ logical: filename, lfp: `${bundleDir}/${filename}`, byteSize, }); const applyRow = async ( row: ReplicaRow, deps: PullAndApplyDeps, ): Promise => { const local = deps.findByContentId(row.replica_id); // Decrypt encrypted-field cipher payloads in place so unpackRow sees // plaintext. The prompt-decision heuristic compares the row's // incoming ciphers against the local record's last-seen-cipher // fingerprint: // * fingerprint matches → we already have the plaintext for this // exact ciphertext; skip the prompt + decrypt entirely // * fingerprint differs (or local has no fingerprint yet) AND // session is locked → prompt the gate so we can decrypt the new // value (covers fresh device + password rotation on Device A) // * session already unlocked → just decrypt; no prompt // Cancel / failure leaves the field absent; the store's applyRemote // merge preserves any local plaintext copy. const encryptedFields = deps.adapter.encryptedFields; // Credentials meta-toggle (default OFF): when the user hasn't opted // in, every cipher payload in this row's encrypted slots gets stripped // before we even capture the cipher fingerprint. The decrypt loop // sees nothing, the prompt never fires, and the adapter unpacks // without the credential fields. The store's applyRemote merge keeps // any local plaintext copy intact (same code path as the locked- // session case). if (encryptedFields && encryptedFields.length > 0 && !isCredentialsSyncEnabled()) { for (const field of encryptedFields) { delete row.fields_jsonb[field]; } } const beforeDecrypt = captureCipherTexts(row.fields_jsonb, encryptedFields); const localLastSeen = local?.lastSeenCipher; const needsPrompt = !deps.silentDecrypt && !cryptoSession.isUnlocked() && Object.keys(beforeDecrypt).length > 0 && cipherTextsChanged(beforeDecrypt, localLastSeen); const onLocked = needsPrompt ? () => ensurePassphraseUnlocked() : undefined; const decryptResult = await decryptRowFields( row.fields_jsonb, encryptedFields, undefined, onLocked, ); if (decryptResult.saltNotFound.length > 0 && deps.onSaltNotFound) { deps.onSaltNotFound(decryptResult.saltNotFound); } // Build the lastSeenCipher fingerprint to attach to the unpacked // record. Only fields whose decrypt succeeded contribute — a failed // decrypt leaves the previous fingerprint in place so we'll re-try // (and re-prompt) on the next pull. const decryptedThisRound = collectDecryptSuccess(row.fields_jsonb, beforeDecrypt); const mergedLastSeen = Object.keys(decryptedThisRound).length > 0 || localLastSeen ? { ...(localLastSeen ?? {}), ...decryptedThisRound } : undefined; const alive = isReplicaRowAlive(row); if (!alive) { // Always invoke softDeleteByContentId on a tombstoned row, even // when no local record matches. The dict store uses this hook to // scrub companion state (dictionarySettings.providerOrder / // providerEnabled) that may have been seeded by the settings // replica without a matching local row — a fresh device often hits // this path because the settings replica lands before the dict // replica, and a contentId may be referenced in providerEnabled // even though its dict row arrives tombstoned. Other kinds (font, // texture, opds_catalog) self-no-op when no local exists, so the // unconditional call is safe. deps.softDeleteByContentId(row.replica_id); return; } // Decide bundleDir + display name. If a local entry already maps this // contentId, reuse its bundleDir so we don't orphan the previously // downloaded binaries; otherwise mint a fresh dir and apply the remote // record to the local store. Legacy binary-kind records (pre-replica- // sync) may carry no bundleDir — skip them; they aren't sync-eligible. // Metadata-only kinds (no `binary` capability, e.g. opds_catalog) have // no on-disk anchor at all, so the bundleDir requirement is dropped. const needsBundleDir = !!deps.adapter.binary; let bundleDir: string; let displayName: string; if (local) { if (needsBundleDir && !local.bundleDir) return; bundleDir = local.bundleDir ?? ''; displayName = deps.adapter.getDisplayName?.(local) ?? local.name; // For metadata-only kinds, always re-apply the unpacked row so // per-field updates merge into the local copy: renames pushed // from another device, newly-decrypted credentials that weren't // available on the previous pull (session was locked then), etc. // The store's applyRemote merge preserves identity-stable local // state. Binary kinds keep the skip-rebuild semantic so we don't // re-download files we already have. if (!needsBundleDir) { const record = deps.adapter.unpackRow(row, bundleDir); if (record) { if (mergedLastSeen) record.lastSeenCipher = mergedLastSeen; deps.applyRemote(record); } } } else { bundleDir = needsBundleDir ? await deps.createBundleDir() : ''; const record = deps.adapter.unpackRow(row, bundleDir); if (!record) return; if (mergedLastSeen) record.lastSeenCipher = mergedLastSeen; deps.applyRemote(record); displayName = deps.adapter.getDisplayName?.(record) ?? record.name; } // Metadata-only kinds: nothing more to do. The orchestrator's manifest // / binary-download path is a no-op for them. if (!needsBundleDir) return; if (!row.manifest_jsonb || row.manifest_jsonb.files.length === 0) { // Server row has no manifest yet — typically the device that // wrote the metadata never finished the binary upload (TM wasn't // ready, transient failure, app close mid-upload). If we're the // device with the local copy, push the binaries now so the // manifest commits via replica-transfer-complete. if (local && deps.queueLocalBinaryUpload) { await deps.queueLocalBinaryUpload(local); } return; } if (!deps.adapter.binary) return; // Skip the download queue if every manifest file is already on disk // under the resolved bundle dir. Refresh-the-page is a no-op rather // than a re-download; partial-download recovery still queues because // some files would be missing. const filenames = row.manifest_jsonb.files.map((f) => f.filename); const allPresent = await deps.filesExist(bundleDir, filenames); if (allPresent) return; const files = row.manifest_jsonb.files.map((f) => MANIFEST_FILE_TO_TRANSFER(f.filename, f.byteSize, bundleDir), ); deps.queueReplicaDownload( row.replica_id, displayName, files, bundleDir, deps.adapter.binary.localBaseDir, ); }; /** * Generic pull-side dispatcher for any replica kind. Walks rows since * the last cursor advance and applies each via applyRow. Errors per * row are isolated — one bad row never blocks the others. * * The dictionary adapter and (future) font / texture adapters share * this orchestrator; per-kind translation lives entirely in the * adapter's unpackRow + binary capability. */ export const replicaPullAndApply = async ( deps: PullAndApplyDeps, ): Promise => { if (deps.isAuthenticated && !(await deps.isAuthenticated())) return; if (deps.hydrateLocalStore) { await deps.hydrateLocalStore(); } const rows = await deps.pull(); for (const row of rows) { try { await applyRow(row, deps); } catch (err) { console.warn('replica pull row apply failed', { replicaId: row.replica_id, err }); } } };