openpencil/src/app/storage/sync/engine.ts
Danila Poyarkov dde7376bd3 feat(storage): show workspace document previews
- Load embedded Figma thumbnails through bounded S3 byte-range requests

- Add a headless Vue workspace composable with lazy previews and refresh lifecycle

- Cache local previews and refresh the workspace after saves and synchronization
2026-08-10 17:21:36 +03:00

327 lines
11 KiB
TypeScript

import { IS_BROWSER } from '@open-pencil/core/constants'
import {
activeStorageProviderID,
createActiveStorageAdapter,
storageCredentialStatuses,
storagePreferencesComplete,
storageProviderRegistry
} from '@/app/integrations/storage'
import { evictLocalFigCache } from '@/app/storage/cache-eviction'
import { getLocalCanvasStore } from '@/app/storage/local-store'
import { getOutbox } from '@/app/storage/sync/outbox'
import { setUploadProgress } from '@/app/storage/sync/progress'
import { setPendingSyncCount, setSyncUI } from '@/app/storage/sync/status'
import type { OutboxJob } from '@/app/storage/sync/types'
import { emitStorageWorkspaceEvent } from '@/app/storage/workspace/events'
const MAX_ATTEMPTS = 8
const BASE_BACKOFF_MS = 1500
const MAX_BACKOFF_MS = 60_000
class StorageSyncBlockedError extends Error {
constructor(message: string) {
super(message)
this.name = 'StorageSyncBlockedError'
}
}
let pumping = false
let wakeTimer: ReturnType<typeof setTimeout> | null = null
let onlineBound = false
function isOnline(): boolean {
if (typeof navigator === 'undefined') return true
return navigator.onLine
}
function backoffMs(attempts: number): number {
const exp = Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * 2 ** Math.max(0, attempts - 1))
const jitter = Math.floor(exp * 0.2 * ((crypto.getRandomValues(new Uint8Array(1))[0] ?? 0) / 255))
return exp + jitter
}
export function nextSyncWakeDelay(jobs: OutboxJob[], now = Date.now()): number | null {
if (jobs.length === 0) return null
const nextAt = Math.min(...jobs.map((job) => job.nextAttemptAt))
return nextAt === Number.MAX_SAFE_INTEGER ? null : Math.max(250, nextAt - now)
}
function isPermanentError(error: unknown): boolean {
if (!(error instanceof Error)) return false
const msg = error.message.toLowerCase()
return (
msg.includes('403') ||
msg.includes('401') ||
msg.includes('access denied') ||
msg.includes('invalid access key') ||
msg.includes('not configured')
)
}
async function runJob(job: OutboxJob): Promise<void> {
const store = getLocalCanvasStore()
const meta = await store.getMeta(job.canvasId)
const providerID = meta?.providerId ?? activeStorageProviderID.value
if (!storagePreferencesComplete(providerID)) {
throw new StorageSyncBlockedError('Storage is not configured')
}
const provider = storageProviderRegistry.get(providerID)
const statuses = await storageCredentialStatuses(providerID)
const missingCredential = provider.credentialFields.some(
(field) => field.required && statuses[field.id] !== 'configured'
)
if (missingCredential) {
throw new StorageSyncBlockedError('Storage credentials are unavailable')
}
const adapter = createActiveStorageAdapter(providerID)
if (job.type === 'deleteCanvas') {
await adapter.deleteDocument(job.canvasId)
// Keep the tombstoned row: reconcile purges it once the remote listing
// confirms the object is gone. Removing it here opened a race where a
// concurrent reconcile re-seeded the canvas from a stale remote listing.
await store.updateMeta(job.canvasId, { syncStatus: 'synced', lastSyncError: null })
return
}
if (!meta || meta.tombstoned) {
// Nothing to put
return
}
if (job.type === 'putCanvas') {
// Superseded by a newer local revision already on disk
if (meta.revision > job.revision) return
if (!meta.hasFig) return
const fig = await store.readFig(job.canvasId)
if (!fig || fig.byteLength === 0) throw new Error('Local document missing for sync')
setUploadProgress(job.canvasId, 0)
try {
await adapter.putDocument(
job.canvasId,
fig,
{
name: meta.name,
updatedAt: meta.updatedAt
},
({ transferredBytes, totalBytes }) => {
if (totalBytes) setUploadProgress(job.canvasId, transferredBytes / totalBytes)
}
)
} finally {
setUploadProgress(job.canvasId, null)
}
// Only mark synced if still on this revision and no other pending work for newer rev
const latest = await store.getMeta(job.canvasId)
if (latest && latest.revision === job.revision && !latest.tombstoned) {
await store.updateMeta(
job.canvasId,
{
syncStatus: 'synced',
lastSyncedAt: new Date().toISOString(),
lastSyncError: null
},
{ expectedRevision: job.revision }
)
await evictLocalFigCache(new Set([job.canvasId]))
emitStorageWorkspaceEvent({
providerId: providerID,
documentId: job.canvasId,
kind: 'synced'
})
}
return
}
// Remaining job type: putThumb
if (!adapter.putThumbnail) return
const thumb = await store.readThumb(job.canvasId)
if (!thumb) return
await adapter.putThumbnail(job.canvasId, thumb)
}
async function pumpOnce(): Promise<void> {
const outbox = getOutbox()
const jobs = await outbox.list()
setPendingSyncCount(jobs.length)
if (jobs.length === 0) {
if (isOnline()) setSyncUI('idle')
return
}
if (!isOnline()) {
setSyncUI('offline')
scheduleWake(5000)
return
}
setSyncUI('syncing')
const now = Date.now()
// Single-flight globally for simplicity (large figs)
const job = jobs.find((j) => j.nextAttemptAt <= now)
if (!job) {
const delay = nextSyncWakeDelay(jobs, now)
if (delay != null) scheduleWake(delay)
return
}
try {
await runJob(job)
await outbox.remove(job.id)
const remaining = await outbox.list()
setPendingSyncCount(remaining.length)
if (remaining.length === 0) setSyncUI('idle')
else scheduleWake(50)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (error instanceof StorageSyncBlockedError) {
await outbox.update({
...job,
nextAttemptAt: Number.MAX_SAFE_INTEGER
})
setSyncUI('error', message)
return
}
const attempts = job.attempts + 1
const permanent = isPermanentError(error) || attempts >= MAX_ATTEMPTS
console.warn('[Storage sync] job failed:', job.type, job.canvasId, message)
if (permanent) {
// A failed thumbnail upload must not poison the document's sync status —
// only canvas/delete jobs reflect into the meta row.
if (job.type !== 'putThumb') {
await getLocalCanvasStore().updateMeta(job.canvasId, {
syncStatus: 'error',
lastSyncError: message
})
setSyncUI('error', message.slice(0, 120))
} else {
// Keep a record without touching syncStatus so the stale remote
// thumbnail is at least diagnosable.
await getLocalCanvasStore().updateMeta(job.canvasId, { lastSyncError: message })
}
if (job.type === 'putThumb') {
await outbox.remove(job.id)
const remaining = await outbox.list()
setPendingSyncCount(remaining.length)
if (remaining.length > 0) scheduleWake(1000)
else setSyncUI('idle')
} else {
// Never discard a document mutation. Keep it durable until the user
// repairs credentials/permissions and explicitly wakes synchronization.
await outbox.update({
...job,
attempts,
nextAttemptAt: Number.MAX_SAFE_INTEGER
})
}
return
}
const updated: OutboxJob = {
...job,
attempts,
nextAttemptAt: Date.now() + backoffMs(attempts)
}
await outbox.update(updated)
if (job.type !== 'putThumb') {
await getLocalCanvasStore().updateMeta(job.canvasId, {
syncStatus: 'pending',
lastSyncError: message
})
}
// Wake for the next ready job across the whole queue — not this job's
// full backoff, which starved other jobs that were ready sooner.
const all = await outbox.list()
const nextAt = Math.min(...all.map((j) => j.nextAttemptAt))
scheduleWake(Math.max(250, nextAt - Date.now()))
}
}
function scheduleWake(ms: number) {
if (wakeTimer != null) clearTimeout(wakeTimer)
wakeTimer = setTimeout(() => {
wakeTimer = null
void kickSyncEngine()
}, ms)
}
function ensureOnlineListeners() {
if (onlineBound || !IS_BROWSER) return
onlineBound = true
window.addEventListener('online', () => {
setSyncUI('syncing')
void kickSyncEngine()
})
window.addEventListener('offline', () => {
setSyncUI('offline')
})
}
/** Start or continue draining the outbox. Safe to call often. */
export async function kickSyncEngine(): Promise<void> {
ensureOnlineListeners()
if (pumping) return
pumping = true
let pumpFailed = false
try {
// Drain a few jobs per kick to avoid long tight loops blocking the tab.
for (let i = 0; i < 3; i++) {
const before = (await getOutbox().list()).length
await pumpOnce()
const after = (await getOutbox().list()).length
if (after === 0 || after >= before) break
}
} catch (error) {
// Never let an escaped rejection strand the queue — retry shortly.
pumpFailed = true
console.warn('[Storage sync] pump failed:', error)
scheduleWake(5000)
} finally {
pumping = false
}
// A job enqueued mid-pump can slip past the loop's exit check while its
// kick was swallowed by the pumping guard — re-wake if work is already due.
// (Skip offline — pumpOnce owns those wakes — and errors, which keep their
// 5s backoff; re-waking would clobber it into a tight retry loop.)
if (pumpFailed || !isOnline()) return
const jobs = await getOutbox().list()
if (jobs.some((job) => job.nextAttemptAt <= Date.now())) scheduleWake(250)
}
export async function enqueuePutCanvas(canvasId: string, revision: number): Promise<void> {
await getOutbox().enqueue({ canvasId, type: 'putCanvas', revision })
void kickSyncEngine()
}
export async function enqueuePutThumb(canvasId: string, revision: number): Promise<void> {
await getOutbox().enqueue({ canvasId, type: 'putThumb', revision })
void kickSyncEngine()
}
export async function enqueueDeleteCanvas(canvasId: string): Promise<void> {
await getOutbox().enqueue({ canvasId, type: 'deleteCanvas', revision: 0 })
void kickSyncEngine()
}
/** Retry durable work immediately after storage settings or credentials change. */
export async function resumeStorageSync(): Promise<void> {
const outbox = getOutbox()
const jobs = await outbox.list()
const now = Date.now()
await Promise.all(jobs.map((job) => outbox.update({ ...job, nextAttemptAt: now })))
if (jobs.length > 0) setSyncUI('syncing')
void kickSyncEngine()
}
/** After credentials cleared — drop local mirror + outbox (optional safety). */
export async function clearStorageLocalMirror(): Promise<void> {
await getLocalCanvasStore().clearAll()
await getOutbox().clear()
setPendingSyncCount(0)
setSyncUI('idle')
}