feat(app): persist storage-bound documents locally first

- Track remote storage bindings without replacing local file identity

- Route saves and autosaves through the durable local cache before enqueueing uploads

- Clear storage bindings on Save As and cover ordering and identity behavior

Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com>
This commit is contained in:
Danila Poyarkov 2026-07-26 14:46:38 +03:00
parent f31341d488
commit b4a82239a3
12 changed files with 190 additions and 6 deletions

View file

@ -69,7 +69,9 @@ export function createDocumentIOActions(
fitCurrentPageToViewport,
getDocumentFilePath: sourceState.getFilePath,
getSourceIdentity: sourceState.getSourceIdentity,
getStorageBinding: sourceState.getStorageBinding,
setDocumentSource: sourceActions.setDocumentSource,
setStorageDocumentSource: sourceActions.setStorageDocumentSource,
setPlannedFilePath: sourceActions.setPlannedFilePath,
startWatchingCurrentFile: sourceActions.startWatchingCurrentFile,
disposeDocumentIO: sourceActions.disposeDocumentIO,

View file

@ -5,6 +5,7 @@ import { documentNameFromFigPath } from '@/app/document/io/names'
import { chooseBrowserFigSaveHandle, chooseTauriFigSavePath } from '@/app/document/io/save-targets'
import type { DocumentSourceIdentity } from '@/app/document/io/types'
import { createDocumentWriter } from '@/app/document/io/write'
import type { StorageDocumentBinding } from '@/app/integrations/storage/types'
import { IS_TAURI } from '@/constants'
type SaveDocumentState = EditorState & { documentName: string }
@ -18,6 +19,8 @@ type SaveActionsOptions = {
setFileHandle: (handle: FileSystemFileHandle | null) => void
getDownloadName: () => string | null
setDownloadName: (name: string | null) => void
getStorageBinding: () => StorageDocumentBinding | null
setStorageBinding: (binding: StorageDocumentBinding | null) => void
setSourceIdentity: (identity: DocumentSourceIdentity) => void
setSavedVersion: (version: number) => void
setLastWriteTime: (time: number) => void
@ -33,6 +36,8 @@ export function createSaveActions({
setFileHandle,
getDownloadName,
setDownloadName,
getStorageBinding,
setStorageBinding,
setSourceIdentity,
setSavedVersion,
setLastWriteTime,
@ -42,6 +47,7 @@ export function createSaveActions({
state,
getFilePath,
getFileHandle,
getStorageBinding,
setSavedVersion,
setLastWriteTime
})
@ -49,10 +55,11 @@ export function createSaveActions({
async function saveFigFile() {
const filePath = getFilePath()
const fileHandle = getFileHandle()
const storageBinding = getStorageBinding()
const downloadName = getDownloadName()
if (filePath || fileHandle) {
if (storageBinding || filePath || fileHandle) {
const wrote = await writeFile(await buildFigFile())
if (wrote) setSourceIdentity({ handle: fileHandle, path: filePath })
if (wrote && !storageBinding) setSourceIdentity({ handle: fileHandle, path: filePath })
} else if (downloadName) {
downloadBlob(new Uint8Array(await buildFigFile()), downloadName, 'application/octet-stream')
} else {
@ -66,6 +73,7 @@ export function createSaveActions({
if (IS_TAURI) {
const path = await chooseTauriFigSavePath()
if (!path) return
setStorageBinding(null)
setFilePath(path)
setFileHandle(null)
state.documentName = documentNameFromFigPath(path)
@ -77,6 +85,7 @@ export function createSaveActions({
if (window.showSaveFilePicker) {
const handle = await chooseBrowserFigSaveHandle()
if (!handle) return
setStorageBinding(null)
setFileHandle(handle)
setFilePath(null)
state.documentName = documentNameFromFigPath(handle.name)
@ -87,6 +96,7 @@ export function createSaveActions({
const filename = prompt('Save as:', getDownloadName() ?? 'Untitled.fig')
if (!filename) return
setStorageBinding(null)
setDownloadName(filename)
state.documentName = documentNameFromFigPath(filename)
downloadBlob(new Uint8Array(data), filename, 'application/octet-stream')

View file

@ -1,10 +1,12 @@
import type { DocumentSourceIdentity } from '@/app/document/io/types'
import type { StorageDocumentBinding } from '@/app/integrations/storage/types'
export function createDocumentSourceState() {
let fileHandle: FileSystemFileHandle | null = null
let filePath: string | null = null
let downloadName: string | null = null
let sourceIdentity: DocumentSourceIdentity = { handle: null, path: null }
let storageBinding: StorageDocumentBinding | null = null
let savedVersion = 0
let lastWriteTime = 0
@ -25,6 +27,10 @@ export function createDocumentSourceState() {
setSourceIdentity: (identity: DocumentSourceIdentity) => {
sourceIdentity = identity
},
getStorageBinding: () => storageBinding,
setStorageBinding: (binding: StorageDocumentBinding | null) => {
storageBinding = binding
},
getSavedVersion: () => savedVersion,
setSavedVersion: (version: number) => {
savedVersion = version

View file

@ -10,6 +10,7 @@ import {
import { createSaveActions } from '@/app/document/io/save'
import { createDocumentSourceState } from '@/app/document/io/source-state'
import type { DocumentSourceIdentity } from '@/app/document/io/types'
import type { StorageDocumentBinding } from '@/app/integrations/storage/types'
type DocumentSourceState = EditorState & {
documentName: string
@ -29,6 +30,8 @@ type DocumentSourceOptions = {
setFilePath: (path: string | null) => void
getDownloadName: () => string | null
setDownloadName: (name: string | null) => void
getStorageBinding: () => StorageDocumentBinding | null
setStorageBinding: (binding: StorageDocumentBinding | null) => void
setSourceIdentity: (identity: DocumentSourceIdentity) => void
getSavedVersion: () => number
setSavedVersion: (version: number) => void
@ -47,6 +50,8 @@ export function createDocumentSourceActions({
setFilePath,
getDownloadName,
setDownloadName,
getStorageBinding,
setStorageBinding,
setSourceIdentity,
getSavedVersion,
setSavedVersion,
@ -66,6 +71,8 @@ export function createDocumentSourceActions({
setFileHandle,
getDownloadName,
setDownloadName,
getStorageBinding,
setStorageBinding,
setSourceIdentity,
setSavedVersion,
setLastWriteTime,
@ -77,7 +84,7 @@ export function createDocumentSourceActions({
const { disposeAutosave } = createAutosave({
state,
getSavedVersion,
hasWritableSource: () => !!getFileHandle() || !!getFilePath(),
hasWritableSource: () => !!getFileHandle() || !!getFilePath() || !!getStorageBinding(),
saveCurrentDocument: async () => {
await writeFile(await buildFigFile())
}
@ -90,6 +97,7 @@ export function createDocumentSourceActions({
path?: string
) {
stopWatchingFile()
setStorageBinding(null)
const isFig = sourceFormat === 'fig'
setFileHandle(isFig ? (handle ?? null) : null)
setFilePath(isFig ? (path ?? null) : null)
@ -101,8 +109,21 @@ export function createDocumentSourceActions({
}
}
function setStorageDocumentSource(binding: StorageDocumentBinding, documentName: string) {
stopWatchingFile()
setFileHandle(null)
setFilePath(null)
setDownloadName(`${documentName}.fig`)
setSourceIdentity({ handle: null, path: null })
setStorageBinding(binding)
state.documentName = documentName
state.autosaveEnabled = true
setSavedVersion(state.sceneVersion)
}
function setPlannedFilePath(path: string) {
stopWatchingFile()
setStorageBinding(null)
setFileHandle(null)
setFilePath(path)
const downloadName = downloadNameFromPath(path)
@ -121,10 +142,12 @@ export function createDocumentSourceActions({
return {
setDocumentSource,
setStorageDocumentSource,
setPlannedFilePath,
startWatchingCurrentFile,
disposeDocumentIO,
saveFigFile,
saveFigFileAs
saveFigFileAs,
getStorageBinding
}
}

View file

@ -1,13 +1,16 @@
import type { EditorState } from '@open-pencil/core/editor'
import type { StorageDocumentBinding } from '@/app/integrations/storage/types'
import { persistStorageCanvasLocally } from '@/app/storage/sync/persist'
import { isTauri } from '@/app/tauri/env'
type WriteDocumentState = EditorState
type WriteDocumentState = EditorState & { documentName: string }
type DocumentWriterOptions = {
state: WriteDocumentState
getFilePath: () => string | null
getFileHandle: () => FileSystemFileHandle | null
getStorageBinding: () => StorageDocumentBinding | null
setSavedVersion: (version: number) => void
setLastWriteTime: (time: number) => void
}
@ -16,11 +19,24 @@ export function createDocumentWriter({
state,
getFilePath,
getFileHandle,
getStorageBinding,
setSavedVersion,
setLastWriteTime
}: DocumentWriterOptions) {
return async function writeFile(data: Uint8Array) {
return async function writeFile(data: Uint8Array): Promise<boolean> {
setLastWriteTime(Date.now())
const storage = getStorageBinding()
if (storage) {
await persistStorageCanvasLocally({
providerId: storage.providerId,
canvasId: storage.documentId,
name: state.documentName || 'Untitled',
figBytes: data
})
setSavedVersion(state.sceneVersion)
return true
}
const filePath = getFilePath()
const fileHandle = getFileHandle()
if (filePath && isTauri()) {

View file

@ -21,6 +21,7 @@ export type {
StorageConnectionResult,
StorageCredentialField,
StorageDocument,
StorageDocumentBinding,
StorageDocumentMetadata,
StorageFieldID,
StoragePreferenceField,

View file

@ -3,6 +3,11 @@ import type { CredentialResolver } from '@/app/settings/credentials/types'
export type StorageProviderID = string
export type StorageFieldID = string
export type StorageDocumentBinding = {
providerId: StorageProviderID
documentId: string
}
export type StorageTransferProgress = {
transferredBytes: number
totalBytes: number | null

View file

@ -6,6 +6,12 @@ export {
kickSyncEngine
} from './engine'
export { createMemoryOutbox, getOutbox, resetOutboxForTests } from './outbox'
export {
persistStorageCanvasLocally,
seedStorageCanvasFromRemote,
type PersistStorageCanvasOptions,
type SeedStorageCanvasOptions
} from './persist'
export { setUploadProgress, uploadProgressByCanvas } from './progress'
export {
pendingSyncCount,

View file

@ -0,0 +1,66 @@
import type { StorageProviderID } from '@/app/integrations/storage/types'
import { getLocalCanvasStore } from '@/app/storage/local-store'
import type { LocalCanvasStore } from '@/app/storage/local-store/store'
import { enqueuePutCanvas } from '@/app/storage/sync/engine'
export type StoragePersistenceDependencies = {
store: LocalCanvasStore
enqueueCanvas(canvasId: string, revision: number): Promise<void>
}
export type PersistStorageCanvasOptions = {
providerId: StorageProviderID
canvasId: string
name: string
figBytes: Uint8Array
}
/** Write locally before scheduling remote synchronization. */
export async function persistStorageCanvasLocally(
options: PersistStorageCanvasOptions,
dependencies?: StoragePersistenceDependencies
): Promise<{ revision: number }> {
const runtime = dependencies ?? {
store: getLocalCanvasStore(),
enqueueCanvas: enqueuePutCanvas
}
const metadata = await runtime.store.writeCanvas({
id: options.canvasId,
providerId: options.providerId,
name: options.name,
figBytes: options.figBytes,
syncStatus: 'pending'
})
await runtime.enqueueCanvas(options.canvasId, metadata.revision)
return { revision: metadata.revision }
}
export type SeedStorageCanvasOptions = {
providerId: StorageProviderID
canvasId: string
name: string
updatedAt: string
figBytes: Uint8Array
thumbnailBytes?: Uint8Array | null
markSynced?: boolean
}
export async function seedStorageCanvasFromRemote(
options: SeedStorageCanvasOptions
): Promise<void> {
await getLocalCanvasStore().writeCanvas({
id: options.canvasId,
providerId: options.providerId,
name: options.name,
updatedAt: options.updatedAt,
figBytes: options.figBytes,
thumbBytes: options.thumbnailBytes,
syncStatus: options.markSynced === false ? 'pending' : 'synced'
})
if (options.markSynced === false) return
await getLocalCanvasStore().updateMeta(options.canvasId, {
lastSyncedAt: options.updatedAt || new Date().toISOString(),
syncStatus: 'synced',
lastSyncError: null
})
}

View file

@ -3,6 +3,7 @@ import { describe, expect, test, vi } from 'bun:test'
import { createDefaultEditorState } from '@open-pencil/core/editor'
import { createSaveActions } from '@/app/document/io/save'
import { createDocumentSourceState } from '@/app/document/io/source-state'
function makeWritableHandle(name: string): FileSystemFileHandle {
return {
@ -30,6 +31,8 @@ function createSaveHarness(handle: FileSystemFileHandle) {
setFileHandle: vi.fn(),
getDownloadName: () => null,
setDownloadName: vi.fn(),
getStorageBinding: () => null,
setStorageBinding: vi.fn(),
setSourceIdentity,
setSavedVersion: vi.fn(),
setLastWriteTime: vi.fn(),
@ -39,6 +42,18 @@ function createSaveHarness(handle: FileSystemFileHandle) {
}
describe('saved document identity', () => {
test('tracks storage binding alongside local source identity', () => {
const source = createDocumentSourceState()
source.setSourceIdentity({ handle: null, path: '/tmp/local.fig' })
source.setStorageBinding({ providerId: 's3-compatible', documentId: 'remote-1' })
expect(source.getSourceIdentity()).toEqual({ handle: null, path: '/tmp/local.fig' })
expect(source.getStorageBinding()).toEqual({
providerId: 's3-compatible',
documentId: 'remote-1'
})
})
test('publishes the writable handle after a successful save', async () => {
const handle = makeWritableHandle('saved.fig')
const { actions, setSourceIdentity } = createSaveHarness(handle)

View file

@ -0,0 +1,33 @@
import { describe, expect, test, vi } from 'bun:test'
import { createMemoryLocalCanvasStore } from '@/app/storage/local-store'
import { persistStorageCanvasLocally } from '@/app/storage/sync/persist'
describe('local-first storage persistence', () => {
test('writes document bytes before enqueueing remote synchronization', async () => {
const store = createMemoryLocalCanvasStore()
const observations: string[] = []
const enqueueCanvas = vi.fn(async (canvasId: string, revision: number) => {
const bytes = await store.readFig(canvasId)
observations.push(`${revision}:${bytes?.join(',')}`)
})
const result = await persistStorageCanvasLocally(
{
providerId: 's3-compatible',
canvasId: 'canvas-1',
name: 'Stored design',
figBytes: new Uint8Array([1, 2, 3])
},
{ store, enqueueCanvas }
)
expect(result.revision).toBe(1)
expect(observations).toEqual(['1:1,2,3'])
expect(await store.getMeta('canvas-1')).toMatchObject({
name: 'Stored design',
syncStatus: 'pending',
providerId: 's3-compatible'
})
})
})

View file

@ -40,6 +40,7 @@ describe('Tauri document IO helpers', () => {
state: { sceneVersion: 42 } as Parameters<typeof createDocumentWriter>[0]['state'],
getFilePath: () => '/tmp/document.fig',
getFileHandle: () => null,
getStorageBinding: () => null,
setSavedVersion: (version) => savedVersions.push(version),
setLastWriteTime: () => undefined
})