feat(app): add local-first storage substrate

- Recover the IndexedDB and in-memory document cache behind the storage domain

- Add persistent outbox supersession, cache eviction, import validation, and naming helpers

- Keep the substrate provider-neutral and cover its pure behavior

Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com>
This commit is contained in:
Danila Poyarkov 2026-07-26 14:30:39 +03:00
parent d315547f2b
commit 56a3b77ac5
20 changed files with 1247 additions and 0 deletions

View file

@ -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<string> = new Set(),
budgetBytes = FIG_CACHE_BUDGET_BYTES
): Promise<number> {
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
}

View file

@ -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]}`
}

9
src/app/storage/id.ts Normal file
View file

@ -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)}`
}

View file

@ -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<IDBDatabase> {
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<T>(req: IDBRequest<T>): Promise<T> {
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<void> {
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'))
})
}

View file

@ -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<IDBDatabase> {
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<Uint8Array | null> {
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<IDBDatabase> | null = null
function db() {
if (!dbPromise) dbPromise = openDb()
return dbPromise
}
async function readBlob(storeName: string, id: string): Promise<Uint8Array | null> {
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<LocalCanvasMeta>) {
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)
}
}
}

View file

@ -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'

View file

@ -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<string, LocalCanvasMeta>()
const figs = new Map<string, Uint8Array>()
const thumbs = new Map<string, Uint8Array>()
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<LocalCanvasMeta>) {
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()
}
}
}

View file

@ -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
}
}

View file

@ -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<LocalCanvasMeta[]>
getMeta(id: string): Promise<LocalCanvasMeta | null>
readFig(id: string): Promise<Uint8Array | null>
readThumb(id: string): Promise<Uint8Array | null>
writeCanvas(input: LocalCanvasWriteInput): Promise<LocalCanvasMeta>
/** Index-only row for remote canvases not yet downloaded (no fig body). */
upsertIndexMeta(meta: LocalCanvasIndexInput): Promise<LocalCanvasMeta>
writeThumb(id: string, thumbBytes: Uint8Array): Promise<LocalCanvasMeta | null>
updateMeta(id: string, patch: Partial<LocalCanvasMeta>): Promise<LocalCanvasMeta | null>
tombstone(id: string): Promise<LocalCanvasMeta | null>
/** Drop only the cached fig blob (eviction) — meta and thumb stay. */
clearFig(id: string): Promise<LocalCanvasMeta | null>
remove(id: string): Promise<void>
clearAll(): Promise<void>
}
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
}

View file

@ -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
}

View file

@ -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<OutboxJob[]>
enqueue(job: OutboxEnqueueInput): Promise<OutboxJob>
update(job: OutboxJob): Promise<void>
remove(id: string): Promise<void>
clear(): Promise<void>
}
function openDb(): Promise<IDBDatabase> {
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<IDBDatabase> | 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
}

View file

@ -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('')
}

View file

@ -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>): string {
const used = new Set<string>()
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
}
}

View file

@ -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<string, Uint8Array>
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.' }
}

View file

@ -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)
})
})

View file

@ -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')
})
})

View file

@ -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()
})
})

View file

@ -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)
})
})

View file

@ -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)')
})
})

View file

@ -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)
})
})