diff --git a/src/app/document/io/create.ts b/src/app/document/io/create.ts index bb8bba9b3..08a9538bb 100644 --- a/src/app/document/io/create.ts +++ b/src/app/document/io/create.ts @@ -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, diff --git a/src/app/document/io/save.ts b/src/app/document/io/save.ts index cc6baef87..87cf0c9c9 100644 --- a/src/app/document/io/save.ts +++ b/src/app/document/io/save.ts @@ -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') diff --git a/src/app/document/io/source-state.ts b/src/app/document/io/source-state.ts index 43d49b9bc..de34d9c37 100644 --- a/src/app/document/io/source-state.ts +++ b/src/app/document/io/source-state.ts @@ -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 diff --git a/src/app/document/io/source.ts b/src/app/document/io/source.ts index 7467ec78f..40b3b1326 100644 --- a/src/app/document/io/source.ts +++ b/src/app/document/io/source.ts @@ -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 } } diff --git a/src/app/document/io/write.ts b/src/app/document/io/write.ts index daacf2c34..7fadf42b3 100644 --- a/src/app/document/io/write.ts +++ b/src/app/document/io/write.ts @@ -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 { 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()) { diff --git a/src/app/integrations/storage/index.ts b/src/app/integrations/storage/index.ts index 10fc5fa07..1ff6e759e 100644 --- a/src/app/integrations/storage/index.ts +++ b/src/app/integrations/storage/index.ts @@ -21,6 +21,7 @@ export type { StorageConnectionResult, StorageCredentialField, StorageDocument, + StorageDocumentBinding, StorageDocumentMetadata, StorageFieldID, StoragePreferenceField, diff --git a/src/app/integrations/storage/types.ts b/src/app/integrations/storage/types.ts index da97ba73c..5d9c7c5f7 100644 --- a/src/app/integrations/storage/types.ts +++ b/src/app/integrations/storage/types.ts @@ -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 diff --git a/src/app/storage/sync/index.ts b/src/app/storage/sync/index.ts index 678bc70ef..4bf9f5533 100644 --- a/src/app/storage/sync/index.ts +++ b/src/app/storage/sync/index.ts @@ -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, diff --git a/src/app/storage/sync/persist.ts b/src/app/storage/sync/persist.ts new file mode 100644 index 000000000..507658da8 --- /dev/null +++ b/src/app/storage/sync/persist.ts @@ -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 +} + +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 { + 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 + }) +} diff --git a/tests/engine/app/document/io/source-identity.test.ts b/tests/engine/app/document/io/source-identity.test.ts index 73447536d..04f9ecf56 100644 --- a/tests/engine/app/document/io/source-identity.test.ts +++ b/tests/engine/app/document/io/source-identity.test.ts @@ -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) diff --git a/tests/engine/app/storage/persist.test.ts b/tests/engine/app/storage/persist.test.ts new file mode 100644 index 000000000..c32e84b6b --- /dev/null +++ b/tests/engine/app/storage/persist.test.ts @@ -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' + }) + }) +}) diff --git a/tests/engine/tauri/document-io.test.ts b/tests/engine/tauri/document-io.test.ts index 7c32d898e..1b4f19ceb 100644 --- a/tests/engine/tauri/document-io.test.ts +++ b/tests/engine/tauri/document-io.test.ts @@ -40,6 +40,7 @@ describe('Tauri document IO helpers', () => { state: { sceneVersion: 42 } as Parameters[0]['state'], getFilePath: () => '/tmp/document.fig', getFileHandle: () => null, + getStorageBinding: () => null, setSavedVersion: (version) => savedVersions.push(version), setLastWriteTime: () => undefined })