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:
parent
f31341d488
commit
b4a82239a3
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export type {
|
|||
StorageConnectionResult,
|
||||
StorageCredentialField,
|
||||
StorageDocument,
|
||||
StorageDocumentBinding,
|
||||
StorageDocumentMetadata,
|
||||
StorageFieldID,
|
||||
StoragePreferenceField,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
66
src/app/storage/sync/persist.ts
Normal file
66
src/app/storage/sync/persist.ts
Normal 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
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
33
tests/engine/app/storage/persist.test.ts
Normal file
33
tests/engine/app/storage/persist.test.ts
Normal 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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue