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:
parent
d315547f2b
commit
56a3b77ac5
51
src/app/storage/cache-eviction.ts
Normal file
51
src/app/storage/cache-eviction.ts
Normal 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
|
||||
}
|
||||
14
src/app/storage/format-bytes.ts
Normal file
14
src/app/storage/format-bytes.ts
Normal 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
9
src/app/storage/id.ts
Normal 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)}`
|
||||
}
|
||||
34
src/app/storage/idb-util.ts
Normal file
34
src/app/storage/idb-util.ts
Normal 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'))
|
||||
})
|
||||
}
|
||||
188
src/app/storage/local-store/idb.ts
Normal file
188
src/app/storage/local-store/idb.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/app/storage/local-store/index.ts
Normal file
12
src/app/storage/local-store/index.ts
Normal 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'
|
||||
112
src/app/storage/local-store/memory.ts
Normal file
112
src/app/storage/local-store/memory.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
58
src/app/storage/local-store/meta.ts
Normal file
58
src/app/storage/local-store/meta.ts
Normal 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
|
||||
}
|
||||
}
|
||||
57
src/app/storage/local-store/store.ts
Normal file
57
src/app/storage/local-store/store.ts
Normal 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
|
||||
}
|
||||
46
src/app/storage/local-store/types.ts
Normal file
46
src/app/storage/local-store/types.ts
Normal 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
|
||||
}
|
||||
158
src/app/storage/sync/outbox.ts
Normal file
158
src/app/storage/sync/outbox.ts
Normal 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
|
||||
}
|
||||
32
src/app/storage/sync/types.ts
Normal file
32
src/app/storage/sync/types.ts
Normal 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('')
|
||||
}
|
||||
28
src/app/storage/unique-name.ts
Normal file
28
src/app/storage/unique-name.ts
Normal 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
|
||||
}
|
||||
}
|
||||
137
src/app/storage/validate-import.ts
Normal file
137
src/app/storage/validate-import.ts
Normal 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.' }
|
||||
}
|
||||
76
tests/engine/app/storage/cache-eviction.test.ts
Normal file
76
tests/engine/app/storage/cache-eviction.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
12
tests/engine/app/storage/format-bytes.test.ts
Normal file
12
tests/engine/app/storage/format-bytes.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
67
tests/engine/app/storage/local-store.test.ts
Normal file
67
tests/engine/app/storage/local-store.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
51
tests/engine/app/storage/sync-outbox.test.ts
Normal file
51
tests/engine/app/storage/sync-outbox.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
35
tests/engine/app/storage/unique-name.test.ts
Normal file
35
tests/engine/app/storage/unique-name.test.ts
Normal 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)')
|
||||
})
|
||||
})
|
||||
70
tests/engine/app/storage/validate-import.test.ts
Normal file
70
tests/engine/app/storage/validate-import.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue