diff --git a/src/app/storage/cache-eviction.ts b/src/app/storage/cache-eviction.ts new file mode 100644 index 000000000..efedcb29c --- /dev/null +++ b/src/app/storage/cache-eviction.ts @@ -0,0 +1,51 @@ +import { getLocalCanvasStore } from '@/app/storage/local-store' + +/** Keep at most this much cached fig data on device (metas/thumbs are tiny and stay). */ +export const FIG_CACHE_BUDGET_BYTES = 500 * 1024 * 1024 + +/** + * Evict least-recently-opened fig blobs until the cache fits the budget. + * Only fully synced, non-tombstoned, not-currently-open canvases qualify — + * evicting never loses data, it just forces a re-download on next open. + * Returns the number of evicted figs. + */ +export async function evictLocalFigCache( + excludeIds: ReadonlySet = new Set(), + budgetBytes = FIG_CACHE_BUDGET_BYTES +): Promise { + const local = getLocalCanvasStore() + const metas = await local.listMetas(true) + + let totalBytes = 0 + const candidates: { id: string; size: number; lastUsed: string }[] = [] + for (const m of metas) { + if (!m.hasFig) continue + let size = m.figSize + if (size == null) { + // Legacy row from before size tracking — measure once and persist + const fig = await local.readFig(m.id) + size = fig?.byteLength ?? 0 + await local.updateMeta(m.id, { figSize: size }) + } + totalBytes += size + if (m.tombstoned || m.syncStatus !== 'synced' || excludeIds.has(m.id)) continue + candidates.push({ + id: m.id, + size, + lastUsed: m.lastOpenedAt ?? m.lastSyncedAt ?? m.updatedAt + }) + } + + if (totalBytes <= budgetBytes) return 0 + + candidates.sort((a, b) => a.lastUsed.localeCompare(b.lastUsed)) + let evicted = 0 + for (const candidate of candidates) { + if (totalBytes <= budgetBytes) break + await local.clearFig(candidate.id) + totalBytes -= candidate.size + evicted += 1 + } + if (evicted > 0) console.warn(`[Storage] Evicted ${evicted} cached fig(s) to fit cache budget`) + return evicted +} diff --git a/src/app/storage/format-bytes.ts b/src/app/storage/format-bytes.ts new file mode 100644 index 000000000..abbe677f1 --- /dev/null +++ b/src/app/storage/format-bytes.ts @@ -0,0 +1,14 @@ +/** Human-readable byte size (e.g. 1.2 MB). */ +export function formatStorageBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return '0 B' + if (bytes < 1024) return `${Math.round(bytes)} B` + const units = ['KB', 'MB', 'GB', 'TB'] as const + let value = bytes / 1024 + let unitIndex = 0 + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024 + unitIndex += 1 + } + const digits = value >= 10 || unitIndex === 0 ? 0 : 1 + return `${value.toFixed(digits)} ${units[unitIndex]}` +} diff --git a/src/app/storage/id.ts b/src/app/storage/id.ts new file mode 100644 index 000000000..3e9ab376e --- /dev/null +++ b/src/app/storage/id.ts @@ -0,0 +1,9 @@ +/** Random UUID without Math.random (project convention). */ +export function createCanvasId(): string { + const bytes = new Uint8Array(16) + crypto.getRandomValues(bytes) + bytes[6] = (bytes[6] & 0x0f) | 0x40 + bytes[8] = (bytes[8] & 0x3f) | 0x80 + const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} diff --git a/src/app/storage/idb-util.ts b/src/app/storage/idb-util.ts new file mode 100644 index 000000000..9e23296c7 --- /dev/null +++ b/src/app/storage/idb-util.ts @@ -0,0 +1,34 @@ +/** Shared IndexedDB plumbing for the local canvas store and the sync outbox. */ + +export function openIdb( + name: string, + version: number, + upgrade: (db: IDBDatabase) => void +): Promise { + return new Promise((resolve, reject) => { + if (typeof indexedDB === 'undefined') { + reject(new Error('IndexedDB is not available')) + return + } + const req = indexedDB.open(name, version) + req.onerror = () => reject(req.error ?? new Error(`Failed to open ${name}`)) + req.onblocked = () => reject(new Error(`Opening ${name} blocked by another tab's connection`)) + req.onsuccess = () => resolve(req.result) + req.onupgradeneeded = () => upgrade(req.result) + }) +} + +export function reqToPromise(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error ?? new Error('IndexedDB request failed')) + }) +} + +export function txDone(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error ?? new Error('IndexedDB transaction failed')) + tx.onabort = () => reject(tx.error ?? new Error('IndexedDB transaction aborted')) + }) +} diff --git a/src/app/storage/local-store/idb.ts b/src/app/storage/local-store/idb.ts new file mode 100644 index 000000000..322157bc6 --- /dev/null +++ b/src/app/storage/local-store/idb.ts @@ -0,0 +1,188 @@ +import { openIdb, reqToPromise, txDone } from '@/app/storage/idb-util' +import { buildIndexMeta, buildWriteMeta, sortAndFilterMetas } from '@/app/storage/local-store/meta' +import type { LocalCanvasStore } from '@/app/storage/local-store/store' +import type { LocalCanvasMeta, LocalCanvasWriteInput } from '@/app/storage/local-store/types' + +const DB_NAME = 'open-pencil-cloud-local' +const DB_VERSION = 1 + +const STORE_META = 'meta' +const STORE_FIG = 'fig' +const STORE_THUMB = 'thumb' + +function openDb(): Promise { + return openIdb(DB_NAME, DB_VERSION, (db) => { + if (!db.objectStoreNames.contains(STORE_META)) { + db.createObjectStore(STORE_META, { keyPath: 'id' }) + } + if (!db.objectStoreNames.contains(STORE_FIG)) { + db.createObjectStore(STORE_FIG) + } + if (!db.objectStoreNames.contains(STORE_THUMB)) { + db.createObjectStore(STORE_THUMB) + } + }) +} + +/** Stored rows may be ArrayBuffer, typed array, or Blob depending on writer/browser. */ +async function rowToBytes(row: unknown): Promise { + if (row == null) return null + if (row instanceof ArrayBuffer) return new Uint8Array(row) + if (row instanceof Uint8Array) return new Uint8Array(row) + if (row instanceof Blob) return new Uint8Array(await row.arrayBuffer()) + return null +} + +function bytesToBuffer(bytes: Uint8Array) { + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) +} + +/** IndexedDB-backed local canvas store (meta + fig/thumb blobs). */ +export function createIdbLocalCanvasStore(): LocalCanvasStore { + let dbPromise: Promise | null = null + + function db() { + if (!dbPromise) dbPromise = openDb() + return dbPromise + } + + async function readBlob(storeName: string, id: string): Promise { + const database = await db() + const tx = database.transaction(storeName, 'readonly') + const row = await reqToPromise(tx.objectStore(storeName).get(id)) + await txDone(tx) + return rowToBytes(row) + } + + return { + async listMetas(includeTombstones = false) { + const database = await db() + const tx = database.transaction(STORE_META, 'readonly') + const all = (await reqToPromise(tx.objectStore(STORE_META).getAll())) as LocalCanvasMeta[] + await txDone(tx) + return sortAndFilterMetas(all, includeTombstones) + }, + + async getMeta(id: string) { + const database = await db() + const tx = database.transaction(STORE_META, 'readonly') + const row = (await reqToPromise(tx.objectStore(STORE_META).get(id))) as + | LocalCanvasMeta + | undefined + await txDone(tx) + return row ?? null + }, + + async readFig(id: string) { + return readBlob(STORE_FIG, id) + }, + + async readThumb(id: string) { + return readBlob(STORE_THUMB, id) + }, + + async writeCanvas(input: LocalCanvasWriteInput) { + const database = await db() + const existing = await this.getMeta(input.id) + + let hasThumb = existing?.hasThumb ?? false + const tx = database.transaction([STORE_META, STORE_FIG, STORE_THUMB], 'readwrite') + const figStore = tx.objectStore(STORE_FIG) + const thumbStore = tx.objectStore(STORE_THUMB) + const metaStore = tx.objectStore(STORE_META) + + figStore.put(bytesToBuffer(input.figBytes), input.id) + + if (input.thumbBytes != null) { + if (input.thumbBytes.byteLength > 0) { + thumbStore.put(bytesToBuffer(input.thumbBytes), input.id) + hasThumb = true + } else { + thumbStore.delete(input.id) + hasThumb = false + } + } + + const meta = buildWriteMeta(input, existing, hasThumb) + metaStore.put(meta) + await txDone(tx) + return meta + }, + + async upsertIndexMeta(input) { + const existing = await this.getMeta(input.id) + const meta = buildIndexMeta(input, existing) + const database = await db() + const tx = database.transaction(STORE_META, 'readwrite') + tx.objectStore(STORE_META).put(meta) + await txDone(tx) + return meta + }, + + async writeThumb(id: string, thumbBytes: Uint8Array) { + const existing = await this.getMeta(id) + if (!existing) return null + const database = await db() + const tx = database.transaction([STORE_META, STORE_THUMB], 'readwrite') + tx.objectStore(STORE_THUMB).put(bytesToBuffer(thumbBytes), id) + // Thumb freshness is tracked by its own outbox job — never demote the + // document's syncStatus here (it orphaned rows as 'pending' forever). + const meta: LocalCanvasMeta = { + ...existing, + hasThumb: true + } + tx.objectStore(STORE_META).put(meta) + await txDone(tx) + return meta + }, + + async updateMeta(id: string, patch: Partial) { + const existing = await this.getMeta(id) + if (!existing) return null + const next = { ...existing, ...patch, id: existing.id } + const database = await db() + const tx = database.transaction(STORE_META, 'readwrite') + tx.objectStore(STORE_META).put(next) + await txDone(tx) + return next + }, + + async tombstone(id: string) { + return this.updateMeta(id, { + tombstoned: true, + syncStatus: 'pending', + updatedAt: new Date().toISOString() + }) + }, + + async clearFig(id: string) { + const existing = await this.getMeta(id) + if (!existing) return null + const database = await db() + const tx = database.transaction([STORE_META, STORE_FIG], 'readwrite') + tx.objectStore(STORE_FIG).delete(id) + const meta: LocalCanvasMeta = { ...existing, hasFig: false, figSize: 0 } + tx.objectStore(STORE_META).put(meta) + await txDone(tx) + return meta + }, + + async remove(id: string) { + const database = await db() + const tx = database.transaction([STORE_META, STORE_FIG, STORE_THUMB], 'readwrite') + tx.objectStore(STORE_META).delete(id) + tx.objectStore(STORE_FIG).delete(id) + tx.objectStore(STORE_THUMB).delete(id) + await txDone(tx) + }, + + async clearAll() { + const database = await db() + const tx = database.transaction([STORE_META, STORE_FIG, STORE_THUMB], 'readwrite') + tx.objectStore(STORE_META).clear() + tx.objectStore(STORE_FIG).clear() + tx.objectStore(STORE_THUMB).clear() + await txDone(tx) + } + } +} diff --git a/src/app/storage/local-store/index.ts b/src/app/storage/local-store/index.ts new file mode 100644 index 000000000..f1e25b902 --- /dev/null +++ b/src/app/storage/local-store/index.ts @@ -0,0 +1,12 @@ +export type { + LocalCanvasMeta, + LocalCanvasWriteInput, + LocalSyncStatus +} from '@/app/storage/local-store/types' +export type { LocalCanvasStore } from '@/app/storage/local-store/store' +export { + getLocalCanvasStore, + isLocalCanvasStoreMemoryFallback, + resetLocalCanvasStoreForTests +} from '@/app/storage/local-store/store' +export { createMemoryLocalCanvasStore } from '@/app/storage/local-store/memory' diff --git a/src/app/storage/local-store/memory.ts b/src/app/storage/local-store/memory.ts new file mode 100644 index 000000000..4065c2ffb --- /dev/null +++ b/src/app/storage/local-store/memory.ts @@ -0,0 +1,112 @@ +import { buildIndexMeta, buildWriteMeta, sortAndFilterMetas } from '@/app/storage/local-store/meta' +import type { LocalCanvasStore } from '@/app/storage/local-store/store' +import type { LocalCanvasMeta, LocalCanvasWriteInput } from '@/app/storage/local-store/types' + +/** In-memory store for unit tests and environments without IndexedDB. */ +export function createMemoryLocalCanvasStore(): LocalCanvasStore { + const metas = new Map() + const figs = new Map() + const thumbs = new Map() + + return { + async listMetas(includeTombstones = false) { + return sortAndFilterMetas([...metas.values()], includeTombstones) + }, + + async getMeta(id: string) { + return metas.get(id) ?? null + }, + + async readFig(id: string) { + const bytes = figs.get(id) + return bytes ? new Uint8Array(bytes) : null + }, + + async readThumb(id: string) { + const bytes = thumbs.get(id) + return bytes ? new Uint8Array(bytes) : null + }, + + async writeCanvas(input: LocalCanvasWriteInput) { + const existing = metas.get(input.id) ?? null + figs.set(input.id, new Uint8Array(input.figBytes)) + + let hasThumb = existing?.hasThumb ?? false + if (input.thumbBytes != null) { + if (input.thumbBytes.byteLength > 0) { + thumbs.set(input.id, new Uint8Array(input.thumbBytes)) + hasThumb = true + } else { + thumbs.delete(input.id) + hasThumb = false + } + } + + const meta = buildWriteMeta(input, existing, hasThumb) + metas.set(input.id, meta) + return meta + }, + + async upsertIndexMeta(input) { + const meta = buildIndexMeta(input, metas.get(input.id) ?? null) + metas.set(input.id, meta) + return meta + }, + + async writeThumb(id: string, thumbBytes: Uint8Array) { + const existing = metas.get(id) + if (!existing) return null + thumbs.set(id, new Uint8Array(thumbBytes)) + // Thumb freshness is tracked by its own outbox job — never demote the + // document's syncStatus here (it orphaned rows as 'pending' forever). + const meta: LocalCanvasMeta = { + ...existing, + hasThumb: true + } + metas.set(id, meta) + return meta + }, + + async updateMeta(id: string, patch: Partial) { + const existing = metas.get(id) + if (!existing) return null + const next = { ...existing, ...patch, id: existing.id } + metas.set(id, next) + return next + }, + + async tombstone(id: string) { + const existing = metas.get(id) + if (!existing) return null + const next: LocalCanvasMeta = { + ...existing, + tombstoned: true, + syncStatus: 'pending', + updatedAt: new Date().toISOString() + } + metas.set(id, next) + return next + }, + + async clearFig(id: string) { + const existing = metas.get(id) + if (!existing) return null + figs.delete(id) + const meta: LocalCanvasMeta = { ...existing, hasFig: false, figSize: 0 } + metas.set(id, meta) + return meta + }, + + async remove(id: string) { + metas.delete(id) + figs.delete(id) + thumbs.delete(id) + }, + + async clearAll() { + metas.clear() + figs.clear() + thumbs.clear() + } + } +} diff --git a/src/app/storage/local-store/meta.ts b/src/app/storage/local-store/meta.ts new file mode 100644 index 000000000..458ec4ef0 --- /dev/null +++ b/src/app/storage/local-store/meta.ts @@ -0,0 +1,58 @@ +import type { + LocalCanvasIndexInput, + LocalCanvasMeta, + LocalCanvasWriteInput +} from '@/app/storage/local-store/types' + +/** Newest-first, tombstones hidden unless asked for. */ +export function sortAndFilterMetas( + all: LocalCanvasMeta[], + includeTombstones: boolean +): LocalCanvasMeta[] { + const filtered = includeTombstones ? all : all.filter((m) => !m.tombstoned) + return filtered.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)) +} + +/** Meta row for a full canvas write (fig bytes present). */ +export function buildWriteMeta( + input: LocalCanvasWriteInput, + existing: LocalCanvasMeta | null, + hasThumb: boolean +): LocalCanvasMeta { + return { + id: input.id, + providerId: input.providerId, + name: input.name, + updatedAt: input.updatedAt ?? new Date().toISOString(), + revision: input.revision ?? (existing ? existing.revision + 1 : 1), + syncStatus: input.syncStatus ?? 'pending', + lastSyncedAt: existing?.lastSyncedAt ?? null, + lastSyncError: input.syncStatus === 'synced' ? null : (existing?.lastSyncError ?? null), + // A deleted canvas stays deleted — an in-flight autosave must not resurrect it + tombstoned: existing?.tombstoned ?? false, + hasFig: true, + hasThumb, + figSize: input.figBytes.byteLength, + lastOpenedAt: existing?.lastOpenedAt + } +} + +/** Meta row for an index-only upsert (remote canvas, no local fig). */ +export function buildIndexMeta( + input: LocalCanvasIndexInput, + existing: LocalCanvasMeta | null +): LocalCanvasMeta { + return { + id: input.id, + providerId: input.providerId, + name: input.name, + updatedAt: input.updatedAt, + revision: input.revision ?? existing?.revision ?? 1, + syncStatus: input.syncStatus, + lastSyncedAt: input.lastSyncedAt, + lastSyncError: input.lastSyncError, + tombstoned: false, + hasFig: input.hasFig ?? existing?.hasFig ?? false, + hasThumb: input.hasThumb ?? existing?.hasThumb ?? false + } +} diff --git a/src/app/storage/local-store/store.ts b/src/app/storage/local-store/store.ts new file mode 100644 index 000000000..16ab138b4 --- /dev/null +++ b/src/app/storage/local-store/store.ts @@ -0,0 +1,57 @@ +import { createIdbLocalCanvasStore } from '@/app/storage/local-store/idb' +import { createMemoryLocalCanvasStore } from '@/app/storage/local-store/memory' +import type { + LocalCanvasIndexInput, + LocalCanvasMeta, + LocalCanvasWriteInput +} from '@/app/storage/local-store/types' + +export type LocalCanvasStore = { + listMetas(includeTombstones?: boolean): Promise + getMeta(id: string): Promise + readFig(id: string): Promise + readThumb(id: string): Promise + writeCanvas(input: LocalCanvasWriteInput): Promise + /** Index-only row for remote canvases not yet downloaded (no fig body). */ + upsertIndexMeta(meta: LocalCanvasIndexInput): Promise + writeThumb(id: string, thumbBytes: Uint8Array): Promise + updateMeta(id: string, patch: Partial): Promise + tombstone(id: string): Promise + /** Drop only the cached fig blob (eviction) — meta and thumb stay. */ + clearFig(id: string): Promise + remove(id: string): Promise + clearAll(): Promise +} + +let singleton: LocalCanvasStore | null = null +let usingMemoryFallback = false + +export function isLocalCanvasStoreMemoryFallback(): boolean { + return usingMemoryFallback +} + +/** Reset singleton (tests). */ +export function resetLocalCanvasStoreForTests(store?: LocalCanvasStore) { + singleton = store ?? null + usingMemoryFallback = false +} + +/** + * Process-wide local canvas store. + * Prefers IndexedDB; falls back to memory (logged) if IDB is unavailable. + */ +export function getLocalCanvasStore(): LocalCanvasStore { + if (singleton) return singleton + try { + if (typeof indexedDB !== 'undefined') { + singleton = createIdbLocalCanvasStore() + usingMemoryFallback = false + return singleton + } + } catch (error) { + console.warn('[Storage] IndexedDB local store unavailable, using memory:', error) + } + singleton = createMemoryLocalCanvasStore() + usingMemoryFallback = true + return singleton +} diff --git a/src/app/storage/local-store/types.ts b/src/app/storage/local-store/types.ts new file mode 100644 index 000000000..55c52a800 --- /dev/null +++ b/src/app/storage/local-store/types.ts @@ -0,0 +1,46 @@ +import type { StorageProviderID } from '@/app/integrations/storage' + +export type LocalSyncStatus = 'synced' | 'pending' | 'error' | 'conflict' + +/** Metadata for a stored canvas cached on device (document bytes stored separately). */ +export type LocalCanvasMeta = { + id: string + providerId: StorageProviderID + name: string + updatedAt: string + /** Monotonic local revision; increments on each local write. */ + revision: number + syncStatus: LocalSyncStatus + lastSyncedAt: string | null + lastSyncError: string | null + /** Soft-deleted; hidden from UI until remote delete completes. */ + tombstoned: boolean + hasFig: boolean + hasThumb: boolean + /** Size of the cached fig blob in bytes (set on write; backfilled by eviction). */ + figSize?: number + /** Last time this canvas was opened on this device (LRU eviction key). */ + lastOpenedAt?: string +} + +/** Index-only row for remote canvases not yet downloaded (no fig body). */ +export type LocalCanvasIndexInput = Omit< + LocalCanvasMeta, + 'hasFig' | 'hasThumb' | 'tombstoned' | 'revision' +> & { + revision?: number + hasFig?: boolean + hasThumb?: boolean +} + +export type LocalCanvasWriteInput = { + id: string + providerId: StorageProviderID + name: string + updatedAt?: string + figBytes: Uint8Array + thumbBytes?: Uint8Array | null + /** If set, keep this revision; otherwise increment from existing. */ + revision?: number + syncStatus?: LocalSyncStatus +} diff --git a/src/app/storage/sync/outbox.ts b/src/app/storage/sync/outbox.ts new file mode 100644 index 000000000..13971d294 --- /dev/null +++ b/src/app/storage/sync/outbox.ts @@ -0,0 +1,158 @@ +import { openIdb, reqToPromise, txDone } from '@/app/storage/idb-util' +import { makeJobId, supersedePutCanvasJobs, type OutboxJob } from '@/app/storage/sync/types' + +const DB_NAME = 'open-pencil-cloud-outbox' +const DB_VERSION = 1 +const STORE = 'jobs' + +export type OutboxEnqueueInput = Omit< + OutboxJob, + 'id' | 'createdAt' | 'attempts' | 'nextAttemptAt' +> & { + id?: string + attempts?: number + nextAttemptAt?: number +} + +export type Outbox = { + list(): Promise + enqueue(job: OutboxEnqueueInput): Promise + update(job: OutboxJob): Promise + remove(id: string): Promise + clear(): Promise +} + +function openDb(): Promise { + return openIdb(DB_NAME, DB_VERSION, (db) => { + if (!db.objectStoreNames.contains(STORE)) { + db.createObjectStore(STORE, { keyPath: 'id' }) + } + }) +} + +function buildJob(partial: OutboxEnqueueInput): OutboxJob { + return { + id: partial.id ?? makeJobId(), + canvasId: partial.canvasId, + type: partial.type, + revision: partial.revision, + createdAt: Date.now(), + attempts: partial.attempts ?? 0, + nextAttemptAt: partial.nextAttemptAt ?? Date.now() + } +} + +/** + * Queue with the new job applied: putCanvas supersedes older revisions, + * and only one putThumb/delete per canvas survives (latest wins). + */ +function withJobQueued(queue: OutboxJob[], job: OutboxJob): OutboxJob[] { + let next = queue + if (job.type === 'putCanvas') { + next = supersedePutCanvasJobs(next, job.canvasId, job.revision) + } + next = next.filter( + (j) => !(j.canvasId === job.canvasId && j.type === job.type && j.type !== 'putCanvas') + ) + return [...next, job] +} + +export function createMemoryOutbox(): Outbox { + let jobs: OutboxJob[] = [] + + return { + async list() { + return [...jobs].sort((a, b) => a.createdAt - b.createdAt) + }, + async enqueue(partial) { + const job = buildJob(partial) + jobs = withJobQueued(jobs, job) + return job + }, + async update(job) { + jobs = jobs.map((j) => (j.id === job.id ? job : j)) + }, + async remove(id) { + jobs = jobs.filter((j) => j.id !== id) + }, + async clear() { + jobs = [] + } + } +} + +export function createIdbOutbox(): Outbox { + let dbPromise: Promise | null = null + function db() { + if (!dbPromise) dbPromise = openDb() + return dbPromise + } + + return { + async list() { + const database = await db() + const tx = database.transaction(STORE, 'readonly') + const all = (await reqToPromise(tx.objectStore(STORE).getAll())) as OutboxJob[] + await txDone(tx) + return all.sort((a, b) => a.createdAt - b.createdAt) + }, + + async enqueue(partial) { + const job = buildJob(partial) + // Read and write in ONE transaction so concurrent enqueues can't compute + // supersession from the same stale snapshot (duplicate/stale jobs). + const database = await db() + const tx = database.transaction(STORE, 'readwrite') + const store = tx.objectStore(STORE) + const existing = (await reqToPromise(store.getAll())) as OutboxJob[] + const next = withJobQueued(existing, job) + for (const j of existing) { + if (!next.some((n) => n.id === j.id)) store.delete(j.id) + } + store.put(job) + await txDone(tx) + return job + }, + + async update(job) { + const database = await db() + const tx = database.transaction(STORE, 'readwrite') + tx.objectStore(STORE).put(job) + await txDone(tx) + }, + + async remove(id) { + const database = await db() + const tx = database.transaction(STORE, 'readwrite') + tx.objectStore(STORE).delete(id) + await txDone(tx) + }, + + async clear() { + const database = await db() + const tx = database.transaction(STORE, 'readwrite') + tx.objectStore(STORE).clear() + await txDone(tx) + } + } +} + +let outboxSingleton: Outbox | null = null + +export function resetOutboxForTests(outbox?: Outbox) { + outboxSingleton = outbox ?? null +} + +export function getOutbox(): Outbox { + if (outboxSingleton) return outboxSingleton + try { + if (typeof indexedDB !== 'undefined') { + outboxSingleton = createIdbOutbox() + return outboxSingleton + } + } catch (error) { + console.warn('[Storage] Outbox IDB unavailable, using memory:', error) + } + outboxSingleton = createMemoryOutbox() + return outboxSingleton +} diff --git a/src/app/storage/sync/types.ts b/src/app/storage/sync/types.ts new file mode 100644 index 000000000..9b38953cc --- /dev/null +++ b/src/app/storage/sync/types.ts @@ -0,0 +1,32 @@ +export type OutboxJobType = 'putCanvas' | 'putThumb' | 'deleteCanvas' + +export type OutboxJob = { + id: string + canvasId: string + type: OutboxJobType + /** Local revision for putCanvas; used to supersede older puts. */ + revision: number + createdAt: number + attempts: number + nextAttemptAt: number +} + +export type SyncUiState = 'idle' | 'syncing' | 'offline' | 'error' + +/** Pure helper: drop older putCanvas jobs for same canvas when a newer revision is enqueued. */ +export function supersedePutCanvasJobs( + jobs: OutboxJob[], + canvasId: string, + revision: number +): OutboxJob[] { + return jobs.filter((job) => { + if (job.canvasId !== canvasId || job.type !== 'putCanvas') return true + return job.revision >= revision + }) +} + +export function makeJobId(): string { + const bytes = new Uint8Array(8) + crypto.getRandomValues(bytes) + return [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('') +} diff --git a/src/app/storage/unique-name.ts b/src/app/storage/unique-name.ts new file mode 100644 index 000000000..89e36d6eb --- /dev/null +++ b/src/app/storage/unique-name.ts @@ -0,0 +1,28 @@ +/** + * Pick a display name that does not collide with existing stored document names. + * First free: `Name`, then `Name (1)`, `Name (2)`, … + */ +export function nextUniqueStorageName(desired: string, taken: Iterable): string { + const used = new Set() + for (const name of taken) { + const trimmed = name.trim() + if (trimmed) used.add(trimmed) + } + + const base = desired.trim() || 'Untitled' + if (!used.has(base)) return base + + for (let n = 1; n < 10_000; n++) { + const candidate = `${base} (${n})` + if (!used.has(candidate)) return candidate + } + + // Extremely unlikely — still avoid silent overwrite of the display name. + for (;;) { + const bytes = new Uint8Array(4) + crypto.getRandomValues(bytes) + const suffix = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('') + const candidate = `${base} (${suffix})` + if (!used.has(candidate)) return candidate + } +} diff --git a/src/app/storage/validate-import.ts b/src/app/storage/validate-import.ts new file mode 100644 index 000000000..a567e6728 --- /dev/null +++ b/src/app/storage/validate-import.ts @@ -0,0 +1,137 @@ +import { unzipSync } from 'fflate' + +export type DesignImportValidation = { ok: true } | { ok: false; message: string } + +const FIG_KIWI_MAGIC = 'fig-kiwi' +/** ZIP local-file / empty / spanned signatures (PK..) */ +const ZIP_LOCAL_SIG = [0x50, 0x4b] as const + +function isZipBytes(bytes: Uint8Array): boolean { + return bytes.byteLength >= 4 && bytes[0] === ZIP_LOCAL_SIG[0] && bytes[1] === ZIP_LOCAL_SIG[1] +} + +function hasFigKiwiMagic(bytes: Uint8Array): boolean { + if (bytes.byteLength < 8) return false + return new TextDecoder().decode(bytes.subarray(0, 8)) === FIG_KIWI_MAGIC +} + +/** Pull canvas payload from a Figma ZIP the same way the core parser prefers. */ +function extractFigCanvasBytes(zipBytes: Uint8Array): Uint8Array | null { + let zip: Record + try { + zip = unzipSync(zipBytes, { + filter: (file) => + file.name === 'canvas.fig' || + file.name === 'canvas' || + (file.name.startsWith('images/') && file.name !== 'images/') + }) + } catch { + return null + } + + const entries = Object.keys(zip) + for (const name of entries) { + if (name === 'canvas.fig' || name === 'canvas') return zip[name] ?? null + } + + let best: Uint8Array | null = null + let maxSize = 0 + for (const name of entries) { + const lower = name.toLowerCase() + if (lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.json')) continue + const entry = zip[name] + if (entry.byteLength > maxSize) { + maxSize = entry.byteLength + best = entry + } + } + return best +} + +/** + * Cheap structural check before cloud upload. + * Real .fig files are a ZIP with a `fig-kiwi` canvas payload (or raw fig-kiwi). + */ +export function validateFigBytes(bytes: Uint8Array): DesignImportValidation { + if (bytes.byteLength < 8) { + return { ok: false, message: 'This is not a valid .fig file (file is too small).' } + } + + if (isZipBytes(bytes)) { + const canvas = extractFigCanvasBytes(bytes) + if (!canvas) { + return { + ok: false, + message: 'This is not a valid .fig file (missing canvas data).' + } + } + if (!hasFigKiwiMagic(canvas)) { + return { + ok: false, + message: 'This is not a valid .fig file (canvas is not fig-kiwi).' + } + } + return { ok: true } + } + + if (hasFigKiwiMagic(bytes)) return { ok: true } + + return { + ok: false, + message: 'This is not a valid .fig file (expected a Figma document).' + } +} + +/** + * Cheap structural check: UTF-8 JSON object with a `children` array (Pencil .pen shape). + */ +export function validatePenBytes(bytes: Uint8Array): DesignImportValidation { + if (bytes.byteLength === 0) { + return { ok: false, message: 'This is not a valid .pen file (file is empty).' } + } + + let text: string + try { + text = new TextDecoder('utf-8', { fatal: true }).decode(bytes) + } catch { + return { + ok: false, + message: 'This is not a valid .pen file (not valid UTF-8 text).' + } + } + + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + return { ok: false, message: 'This is not a valid .pen file (invalid JSON).' } + } + + if (parsed == null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { + ok: false, + message: 'This is not a valid .pen file (expected a JSON object).' + } + } + + const children = (parsed as { children?: unknown }).children + if (!Array.isArray(children)) { + return { + ok: false, + message: 'This is not a valid .pen file (missing a children array).' + } + } + + return { ok: true } +} + +/** Validate import bytes by file extension (.fig / .pen). */ +export function validateDesignImportBytes( + fileName: string, + bytes: Uint8Array +): DesignImportValidation { + const lower = fileName.toLowerCase() + if (lower.endsWith('.fig')) return validateFigBytes(bytes) + if (lower.endsWith('.pen')) return validatePenBytes(bytes) + return { ok: false, message: 'Only .fig and .pen files can be imported.' } +} diff --git a/tests/engine/app/storage/cache-eviction.test.ts b/tests/engine/app/storage/cache-eviction.test.ts new file mode 100644 index 000000000..6db54671d --- /dev/null +++ b/tests/engine/app/storage/cache-eviction.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, test } from 'bun:test' + +import { evictLocalFigCache } from '@/app/storage/cache-eviction' +import { + createMemoryLocalCanvasStore, + getLocalCanvasStore, + resetLocalCanvasStoreForTests +} from '@/app/storage/local-store' +import type { LocalSyncStatus } from '@/app/storage/local-store' + +const MB = 1024 * 1024 + +async function seed( + id: string, + sizeMb: number, + lastOpenedAt: string, + syncStatus: LocalSyncStatus = 'synced' +) { + const local = getLocalCanvasStore() + await local.writeCanvas({ + id, + providerId: 's3-compatible', + name: id, + figBytes: new Uint8Array(sizeMb * MB), + syncStatus + }) + await local.updateMeta(id, { lastOpenedAt }) +} + +describe('evictLocalFigCache', () => { + beforeEach(() => { + resetLocalCanvasStoreForTests(createMemoryLocalCanvasStore()) + }) + + test('does nothing under budget', async () => { + await seed('a', 2, '2026-01-01') + const evicted = await evictLocalFigCache(new Set(), 10 * MB) + expect(evicted).toBe(0) + }) + + test('evicts least-recently-opened synced figs until under budget', async () => { + await seed('old', 4, '2026-01-01') + await seed('mid', 4, '2026-02-01') + await seed('new', 4, '2026-03-01') + const evicted = await evictLocalFigCache(new Set(), 8 * MB) + expect(evicted).toBe(1) + const local = getLocalCanvasStore() + const oldMeta = await local.getMeta('old') + expect(oldMeta?.hasFig).toBe(false) + expect(await local.readFig('old')).toBeNull() + // meta row and identity survive — the card stays listed + expect(oldMeta?.name).toBe('old') + expect((await local.getMeta('new'))?.hasFig).toBe(true) + }) + + test('never evicts unsynced or open canvases', async () => { + await seed('dirty', 4, '2026-01-01', 'pending') + await seed('open', 4, '2026-01-02') + await seed('fresh', 4, '2026-03-01') + const evicted = await evictLocalFigCache(new Set(['open']), 4 * MB) + const local = getLocalCanvasStore() + expect((await local.getMeta('dirty'))?.hasFig).toBe(true) + expect((await local.getMeta('open'))?.hasFig).toBe(true) + // only 'fresh' was evictable + expect(evicted).toBe(1) + expect((await local.getMeta('fresh'))?.hasFig).toBe(false) + }) + + test('backfills figSize for legacy rows instead of skipping them', async () => { + await seed('legacy', 6, '2026-01-01') + const local = getLocalCanvasStore() + await local.updateMeta('legacy', { figSize: undefined }) + const evicted = await evictLocalFigCache(new Set(), 1 * MB) + expect(evicted).toBe(1) + }) +}) diff --git a/tests/engine/app/storage/format-bytes.test.ts b/tests/engine/app/storage/format-bytes.test.ts new file mode 100644 index 000000000..bdb5b7959 --- /dev/null +++ b/tests/engine/app/storage/format-bytes.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, test } from 'bun:test' + +import { formatStorageBytes } from '@/app/storage/format-bytes' + +describe('formatStorageBytes', () => { + test('formats common sizes', () => { + expect(formatStorageBytes(0)).toBe('0 B') + expect(formatStorageBytes(512)).toBe('512 B') + expect(formatStorageBytes(2048)).toBe('2 KB') + expect(formatStorageBytes(1024 * 1024 * 3)).toBe('3.0 MB') + }) +}) diff --git a/tests/engine/app/storage/local-store.test.ts b/tests/engine/app/storage/local-store.test.ts new file mode 100644 index 000000000..87122c90c --- /dev/null +++ b/tests/engine/app/storage/local-store.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from 'bun:test' + +import { + createMemoryLocalCanvasStore, + resetLocalCanvasStoreForTests +} from '@/app/storage/local-store' + +import { expectDefined } from '#tests/helpers/assert' + +describe('local canvas store (memory)', () => { + test('writes and reads fig bytes outside localStorage', async () => { + const store = createMemoryLocalCanvasStore() + resetLocalCanvasStoreForTests(store) + const fig = new Uint8Array([1, 2, 3, 4, 5]) + const meta = await store.writeCanvas({ + id: 'c1', + providerId: 's3-compatible', + name: 'Demo', + figBytes: fig + }) + expect(meta.revision).toBe(1) + expect(meta.syncStatus).toBe('pending') + expect(meta.hasFig).toBe(true) + + const read = expectDefined(await store.readFig('c1')) + expect([...read]).toEqual([1, 2, 3, 4, 5]) + + const list = await store.listMetas() + expect(list.map((m) => m.id)).toEqual(['c1']) + }) + + test('increments revision and hides tombstones from list', async () => { + const store = createMemoryLocalCanvasStore() + await store.writeCanvas({ + id: 'c1', + providerId: 's3-compatible', + name: 'A', + figBytes: new Uint8Array([9]) + }) + const second = await store.writeCanvas({ + id: 'c1', + providerId: 's3-compatible', + name: 'A2', + figBytes: new Uint8Array([9, 9]) + }) + expect(second.revision).toBe(2) + await store.tombstone('c1') + expect((await store.listMetas(false)).length).toBe(0) + expect((await store.listMetas(true)).length).toBe(1) + }) + + test('upsertIndexMeta does not require fig body', async () => { + const store = createMemoryLocalCanvasStore() + const meta = await store.upsertIndexMeta({ + id: 'remote-1', + providerId: 's3-compatible', + name: 'From bucket', + updatedAt: '2026-01-01T00:00:00.000Z', + syncStatus: 'synced', + lastSyncedAt: '2026-01-01T00:00:00.000Z', + lastSyncError: null, + hasFig: false + }) + expect(meta.hasFig).toBe(false) + expect(await store.readFig('remote-1')).toBeNull() + }) +}) diff --git a/tests/engine/app/storage/sync-outbox.test.ts b/tests/engine/app/storage/sync-outbox.test.ts new file mode 100644 index 000000000..f452e4dd4 --- /dev/null +++ b/tests/engine/app/storage/sync-outbox.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test' + +import { createMemoryOutbox } from '@/app/storage/sync/outbox' +import { supersedePutCanvasJobs, type OutboxJob } from '@/app/storage/sync/types' + +describe('supersedePutCanvasJobs', () => { + test('drops older putCanvas jobs for same canvas', () => { + const jobs: OutboxJob[] = [ + { + id: 'a', + canvasId: 'c1', + type: 'putCanvas', + revision: 1, + createdAt: 1, + attempts: 0, + nextAttemptAt: 1 + }, + { + id: 'b', + canvasId: 'c1', + type: 'putThumb', + revision: 1, + createdAt: 2, + attempts: 0, + nextAttemptAt: 2 + }, + { + id: 'c', + canvasId: 'c2', + type: 'putCanvas', + revision: 3, + createdAt: 3, + attempts: 0, + nextAttemptAt: 3 + } + ] + const next = supersedePutCanvasJobs(jobs, 'c1', 5) + expect(next.map((j) => j.id).sort()).toEqual(['b', 'c']) + }) +}) + +describe('memory outbox', () => { + test('enqueues and supersedes putCanvas', async () => { + const outbox = createMemoryOutbox() + await outbox.enqueue({ canvasId: 'c1', type: 'putCanvas', revision: 1 }) + await outbox.enqueue({ canvasId: 'c1', type: 'putCanvas', revision: 2 }) + const list = await outbox.list() + expect(list.filter((j) => j.type === 'putCanvas')).toHaveLength(1) + expect(list[0]?.revision).toBe(2) + }) +}) diff --git a/tests/engine/app/storage/unique-name.test.ts b/tests/engine/app/storage/unique-name.test.ts new file mode 100644 index 000000000..e682dd9be --- /dev/null +++ b/tests/engine/app/storage/unique-name.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from 'bun:test' + +import { nextUniqueStorageName } from '@/app/storage/unique-name' + +describe('nextUniqueStorageName', () => { + test('returns desired name when free', () => { + expect(nextUniqueStorageName('KonversioDesigns', [])).toBe('KonversioDesigns') + expect(nextUniqueStorageName('KonversioDesigns', ['Other'])).toBe('KonversioDesigns') + }) + + test('appends (1), (2), … when name is taken', () => { + expect(nextUniqueStorageName('File', ['File'])).toBe('File (1)') + expect(nextUniqueStorageName('File', ['File', 'File (1)'])).toBe('File (2)') + expect(nextUniqueStorageName('File', ['File', 'File (1)', 'File (2)'])).toBe('File (3)') + }) + + test('skips gaps and picks the first free number', () => { + expect(nextUniqueStorageName('File', ['File', 'File (2)'])).toBe('File (1)') + }) + + test('trims whitespace and falls back to Untitled', () => { + expect(nextUniqueStorageName(' Draft ', ['Draft'])).toBe('Draft (1)') + expect(nextUniqueStorageName(' ', ['Untitled'])).toBe('Untitled (1)') + expect(nextUniqueStorageName('', [])).toBe('Untitled') + }) + + test('ignores blank taken names', () => { + expect(nextUniqueStorageName('A', ['', ' ', 'A'])).toBe('A (1)') + }) + + test('treats Name (1) as a distinct base', () => { + // Importing a file already named "File (1)" when that display name exists. + expect(nextUniqueStorageName('File (1)', ['File (1)'])).toBe('File (1) (1)') + }) +}) diff --git a/tests/engine/app/storage/validate-import.test.ts b/tests/engine/app/storage/validate-import.test.ts new file mode 100644 index 000000000..0990ee08d --- /dev/null +++ b/tests/engine/app/storage/validate-import.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { + validateDesignImportBytes, + validateFigBytes, + validatePenBytes +} from '@/app/storage/validate-import' + +const fixtureFig = join(import.meta.dir, '../../../fixtures/gold-preview.fig') + +describe('validateFigBytes', () => { + test('accepts a real Figma ZIP fixture', () => { + const bytes = new Uint8Array(readFileSync(fixtureFig)) + expect(validateFigBytes(bytes)).toEqual({ ok: true }) + }) + + test('accepts raw fig-kiwi payload', () => { + const raw = new TextEncoder().encode('fig-kiwi' + '\0'.repeat(8)) + expect(validateFigBytes(raw).ok).toBe(true) + }) + + test('rejects empty / random / plain text', () => { + expect(validateFigBytes(new Uint8Array()).ok).toBe(false) + expect(validateFigBytes(new TextEncoder().encode('hello world')).ok).toBe(false) + expect(validateFigBytes(new TextEncoder().encode('{"foo":1}')).ok).toBe(false) + }) + + test('rejects a ZIP that is not a Figma document', () => { + // Minimal empty ZIP (end of central directory only) — not a fig container. + const emptyZip = Uint8Array.from([ + 0x50, 0x4b, 0x05, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + ]) + expect(validateFigBytes(emptyZip).ok).toBe(false) + }) +}) + +describe('validatePenBytes', () => { + test('accepts a minimal pen document shape', () => { + const bytes = new TextEncoder().encode( + JSON.stringify({ + version: '2.6', + children: [{ type: 'frame', id: '1', name: 'Page 1' }] + }) + ) + expect(validatePenBytes(bytes)).toEqual({ ok: true }) + }) + + test('rejects invalid JSON and wrong shapes', () => { + expect(validatePenBytes(new TextEncoder().encode('not json')).ok).toBe(false) + expect(validatePenBytes(new TextEncoder().encode('[]')).ok).toBe(false) + expect(validatePenBytes(new TextEncoder().encode('{"name":"x"}')).ok).toBe(false) + expect(validatePenBytes(new TextEncoder().encode('{"children":{}}')).ok).toBe(false) + expect(validatePenBytes(new Uint8Array()).ok).toBe(false) + }) +}) + +describe('validateDesignImportBytes', () => { + test('routes by extension', () => { + const fig = new Uint8Array(readFileSync(fixtureFig)) + expect(validateDesignImportBytes('a.fig', fig).ok).toBe(true) + expect( + validateDesignImportBytes('a.pen', new TextEncoder().encode(JSON.stringify({ children: [] }))) + .ok + ).toBe(true) + expect(validateDesignImportBytes('a.txt', fig).ok).toBe(false) + }) +})