From f28e5271a32328294dbe9f90e30c34f888cbd062 Mon Sep 17 00:00:00 2001 From: Jeremy Lewi Date: Wed, 23 Sep 2026 23:45:36 +0000 Subject: [PATCH 1/2] Open cached notebooks without waiting for Drive reconciliation Signed-off-by: Jeremy Lewi --- app/src/storage/local.test.ts | 175 +++++++++++++++++++++++- app/src/storage/local.ts | 38 ++--- app/test/browser/storage-owner-smoke.ts | 74 +++++++++- docs-dev/CUJs/drive-sync-recovery.md | 36 +++++ 4 files changed, 302 insertions(+), 21 deletions(-) diff --git a/app/src/storage/local.test.ts b/app/src/storage/local.test.ts index 789b2fbc..e15e7c7e 100644 --- a/app/src/storage/local.test.ts +++ b/app/src/storage/local.test.ts @@ -2303,7 +2303,7 @@ describe('LocalNotebooks operation-log storage', () => { expect(reviews).toHaveLength(1) }) - it('loads stale Drive-backed .runme data by merging local and remote operations', async () => { + it('opens stale local .runme data before separately merging remote operations', async () => { const header: NotebookLogHeader = { record_type: 'runme.notebook', format_version: 1, @@ -2419,9 +2419,13 @@ describe('LocalNotebooks operation-log storage', () => { }) const loaded = await store.load('local://file/shared') + expect(loaded.cells.map((cell) => cell.value)).toEqual(['Bob']) + await store.sync('local://file/shared') + const reconciled = await store.load('local://file/shared') + store.stopSyncQueue() const localAfter = await store.loadContent('local://file/shared') - expect(new Set(loaded.cells.map((cell) => cell.value))).toEqual( + expect(new Set(reconciled.cells.map((cell) => cell.value))).toEqual( new Set(['Alice', 'Bob']) ) expect( @@ -8841,3 +8845,170 @@ describe('SharedWorker metadata discovery', () => { expect(read).not.toHaveBeenCalled() }) }) + +describe('LocalNotebooks local-first open', () => { + /** Seed a real local journal, then make its Drive baseline stale. */ + async function cachedNotebook() { + const store = createTestStore({}) + await store.folders.put({ + id: LOCAL_FOLDER_URI, + name: 'Local', + remoteId: '', + children: [], + lastSynced: '', + }) + const file = await store.create(LOCAL_FOLDER_URI, 'cached.runme') + const journal = await store.createOperationLogSaveStore(file.uri, { + actorId: 'local-first-test', + }) + await journal.save( + file.uri, + create(parser_pb.NotebookSchema, { + cells: [ + create(parser_pb.CellSchema, { + refId: 'cell', + kind: parser_pb.CellKind.CODE, + languageId: 'python', + value: 'print("local")', + }), + ], + }) + ) + store.stopSyncQueue() + await store.files.update(file.uri, { + remoteId: 'https://drive.google.com/file/d/cached/view', + lastSynced: '2020-01-01T00:00:00Z', + }) + return { store, uri: file.uri } + } + + it('opens cached OPFS content while an unrelated Drive queue item is blocked', async () => { + const { store, uri } = await cachedNotebook() + let release!: () => void + const blocked = new Promise((resolve) => { + release = resolve + }) + const run = vi.fn(() => blocked) + const sync = (store as any).queueDriveWork('source', 'other', run, { + immediate: true, + }) + await vi.waitFor(() => expect(run).toHaveBeenCalled()) + try { + const opened = vi.fn() + const load = store.load(uri).then(opened) + await vi.waitFor(() => expect(opened).toHaveBeenCalled()) + expect(opened.mock.calls[0][0].cells[0].value).toBe('print("local")') + await load + } finally { + store.stopSyncQueue() + release() + await sync + } + }) + + it('opens offline without calling Drive and preserves a pending reconciliation', async () => { + const { store, uri } = await cachedNotebook() + store.setDriveSyncAvailable(false) + const source = vi.spyOn(store as any, 'performSyncFile') + try { + expect((await store.load(uri)).cells[0].value).toBe('print("local")') + expect(source).not.toHaveBeenCalled() + expect( + (store as any).workQueue.nextAttempt(`source:${uri}`) + ).toBeDefined() + } finally { + store.stopSyncQueue() + } + }) + + it('creates, opens, edits and reopens locally while upstream creation is blocked', async () => { + const drive = { create: vi.fn(() => new Promise(() => {})) } + const store = createTestStore(drive) + store.setDriveSyncAvailable(false) + const parent = 'local://folder/drive' + await store.folders.put({ + id: parent, + name: 'Drive', + remoteId: 'https://drive.google.com/drive/folders/parent', + children: [], + lastSynced: '', + }) + try { + const file = await store.create(parent, 'new.runme') + const opened = vi.fn() + const load = store.load(file.uri).then(opened) + await vi.waitFor(() => expect(opened).toHaveBeenCalled()) + await load + const notebook = opened.mock.calls[0][0] + const journal = await store.createOperationLogSaveStore(file.uri, { + actorId: 'offline-editor', + }) + notebook.cells.push( + create(parser_pb.CellSchema, { + refId: 'offline-cell', + kind: parser_pb.CellKind.MARKUP, + value: 'written before Drive creation', + }) + ) + await journal.save(file.uri, notebook) + expect((await store.load(file.uri)).cells[0].value).toBe( + 'written before Drive creation' + ) + expect((await store.getSyncState(file.uri)).status).toBe( + 'pending-upstream-create' + ) + expect(drive.create).not.toHaveBeenCalled() + expect( + (await store.files.get(file.uri))?.driveCreateOperationId + ).toBeTruthy() + } finally { + store.stopSyncQueue() + } + }) + + it.each(['json', 'ipynb'])( + 'opens a cached %s notebook without awaiting sync', + async (format) => { + const store = createTestStore({}) + const uri = await store.addFile( + 'https://drive.google.com/file/d/cached/view', + `cached.${format}` + ) + await store.files.update(uri, { + doc: notebookJson('cached legacy content'), + }) + const sync = vi + .spyOn(store as any, 'syncFile') + .mockImplementation(() => new Promise(() => {})) + try { + const opened = vi.fn() + const load = store.load(uri).then(opened) + await vi.waitFor(() => expect(opened).toHaveBeenCalled()) + await load + expect(opened.mock.calls[0][0].cells[0].value).toBe( + 'cached legacy content' + ) + expect(sync).not.toHaveBeenCalled() + } finally { + store.stopSyncQueue() + } + } + ) + + it.each(['runme', 'json', 'ipynb'])( + 'propagates a first-download failure for uncached %s instead of returning an empty notebook', + async (format) => { + const store = createTestStore({}) + const uri = await store.addFile( + 'https://drive.google.com/file/d/uncached/view', + `uncached.${format}` + ) + // A metadata timestamp alone cannot establish that content is cached. + await store.files.update(uri, { lastSynced: new Date().toISOString() }) + vi.spyOn(store as any, 'syncFile').mockRejectedValue( + new Error('offline first download') + ) + await expect(store.load(uri)).rejects.toThrow('offline first download') + } + ) +}) diff --git a/app/src/storage/local.ts b/app/src/storage/local.ts index 5ca94d6b..ba4ef66e 100644 --- a/app/src/storage/local.ts +++ b/app/src/storage/local.ts @@ -3282,27 +3282,29 @@ export class LocalNotebooks extends Dexie { ) } - const shouldSync = needsSync(existing.lastSynced, 8 * 60 * 60 * 1000) + const operationLog = + detectNotebookFileFormat(existing.name) === 'runme-operation-log' + // A .runme file keeps its content in OPFS, not the empty IndexedDB doc + // placeholder. Pending Drive creation does not make that local log a miss. + const hasLocalContent = operationLog + ? Boolean(existing.operationLogRef) + : Boolean(existing.doc) || isLocalFileUpstream(existing.remoteId, uri) let record = existing - if (shouldSync) { - // Best-effort attempt to ensure the local cache reflects the latest remote state - // before we hydrate the notebook for the caller. - try { - await this.syncFile(uri) - } catch (error) { - appLogger.warn( - 'Continuing with local notebook after sync-on-load failed', - { - attrs: { - scope: 'storage.local.sync', - localUri: uri, - error: String(error), - }, - } - ) + if (hasLocalContent) { + // Reconciliation must never gate offline editing. Use the background queue + // so auth deferrals/backoff survive opening a tab, and do not replace its + // mounted causal view when upstream work eventually completes. + if ( + needsSync(existing.lastSynced, 8 * 60 * 60 * 1000) && + !isLocalFileUpstream(existing.remoteId, uri) + ) { + this.enqueueSync(uri) } - + } else { + // Only the first download needs foreground upstream I/O. Propagate its + // failure rather than presenting an empty notebook as a successful load. + await this.syncFile(uri) const refreshed = await this.files.get(uri) if (!refreshed) { throw new Error(`Local notebook record missing for ${uri} after sync.`) diff --git a/app/test/browser/storage-owner-smoke.ts b/app/test/browser/storage-owner-smoke.ts index 8d8b82c9..2752ee8f 100644 --- a/app/test/browser/storage-owner-smoke.ts +++ b/app/test/browser/storage-owner-smoke.ts @@ -16,7 +16,9 @@ async function connect(page: Page, baseUrl: string) { ) ;(window as any).store = createSharedNotebookStore( new DriveNotebookStore(async () => { - throw new Error('No credentials in storage smoke test') + // Intentionally stall credential delivery when the test starts a sync. + // No real Drive request or production credentials are used. + return new Promise(() => {}) }) ) }) @@ -103,6 +105,74 @@ async function main() { throw new Error('Concurrent edits did not converge') if (result.checksum !== '') throw new Error('Local save eagerly published checksum') + // Make a real worker sync stall before network I/O, then prove that cached + // opens and newly created pending notebooks still work through MessagePorts. + await a.evaluate(async (uri) => { + const store = (window as any).store + await store.files.update(uri, { + remoteId: + 'https://drive.google.com/file/d/storage-owner-test-blocked/view', + lastSynced: '', + }) + store.setDriveSyncAvailable(true) + void store.sync(uri).catch(() => {}) + }, uri) + await a.waitForFunction( + async (uri) => + (await (window as any).store.getSyncState(uri)).status === 'syncing', + uri + ) + const offline = await b.evaluate(async (uri) => { + const store = (window as any).store + // Fail quickly instead of waiting for the five-minute RPC timeout. + const withinDeadline = (operation: Promise): Promise => { + let timer: ReturnType + return Promise.race([ + operation, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('Local open waited for upstream work')), + 2000 + ) + }), + ]).finally(() => clearTimeout(timer)) + } + const cached = await withinDeadline(store.load(uri)) + // Turning auth off must not wait for the already running sync either. + store.setDriveSyncAvailable(false) + await store.folders.put({ + id: 'local://folder/offline-test', + name: 'Offline test', + remoteId: 'https://drive.google.com/drive/folders/storage-owner-test', + children: [], + lastSynced: '', + }) + const created = await withinDeadline( + store.create('local://folder/offline-test', 'offline.runme') + ) + const notebook = await withinDeadline(store.load(created.uri)) + const view = await store.createOperationLogSaveStore(created.uri) + notebook.cells.push({ + ...cached.cells[0], + refId: 'offline-cell', + value: 'Saved before upstream creation', + }) + await view.save(created.uri, notebook) + const reopened = await withinDeadline(store.load(created.uri)) + return { + cached: cached.cells.map((cell: any) => cell.value).sort(), + value: reopened.cells[0].value, + state: (await store.getSyncState(created.uri)).status, + } + }, uri) + if ( + JSON.stringify(offline.cached) !== JSON.stringify(result.cells) || + offline.value !== 'Saved before upstream creation' || + offline.state !== 'pending-upstream-create' + ) + throw new Error( + 'Offline open/create/edit/reopen did not preserve local content' + ) await context.close() context = await launch() const restored = await context.newPage() @@ -128,6 +198,8 @@ async function main() { 'real SharedWorker + two MessagePorts', 'concurrent causal edits preserved', 'checksum remains unset', + 'cached open bypasses stalled worker reconciliation', + 'offline Drive-folder create/edit/reopen before upstream creation', 'browser restart restores exact OPFS bytes', ], cells: recovered.cells, diff --git a/docs-dev/CUJs/drive-sync-recovery.md b/docs-dev/CUJs/drive-sync-recovery.md index d9cdb845..154cafb8 100644 --- a/docs-dev/CUJs/drive-sync-recovery.md +++ b/docs-dev/CUJs/drive-sync-recovery.md @@ -86,3 +86,39 @@ Record the commit, origin, identity type (no tokens), status and upstream conten Permission/quota failures need corrective action; retries cannot grant either. Different origins/profiles are outside the same-origin coordination guarantee. + +## Local-first notebook opening + +Opening a cached notebook must not wait for Drive, including an already running +sync for a different notebook. A `.runme` operation-log reference identifies local +content even though its IndexedDB `doc` is empty. Cached JSON/IPYNB models also +open locally. An old or missing successful-sync timestamp schedules background +reconciliation; it does not make available content an initial-download miss. +Only an uncached notebook waits for upstream content, and a failed first download +must surface the error instead of showing an empty notebook. + +Creating a file in a mounted Drive folder already persists it locally and starts +Drive creation asynchronously. Its subsequent open must honor that contract: +users can edit, save, close and reopen while `pending-upstream-create` remains. +The existing operation ID and retry state own eventual upstream creation. + +Automated coverage: + +- `local.test.ts`, `LocalNotebooks local-first open`: blocked shared queue, cached + OPFS and legacy models, offline pending creation/edit/reopen, and failed first + downloads even when metadata has a recent successful-sync timestamp. +- The stale operation-log merge test opens local content first, then explicitly + reconciles and verifies convergence without rewriting the loaded snapshot. +- `storage-owner-smoke.ts` stalls credential delivery in an isolated Chromium + profile so no real Drive request is sent. While that worker reconciliation is + pending, a second tab opens cached OPFS content and creates/edits/reopens a new + notebook in a test Drive folder. Each local open has a two-second deadline, + shorter than credential or RPC timeouts. Browser restart preserves exact bytes. + +Manual acceptance: with a development build and disposable notebooks, take Drive +credentials/connectivity offline, reopen an existing cached notebook, and create a +new file in an already mounted Drive folder. Edit and reopen both. Restore Drive +and verify eventual convergence and a single upstream identity. A notebook never +downloaded locally must still report the unavailable dependency. Background +reconciliation does not replace an editor's mounted causal view; explicit refresh +continues to read the local log without upstream I/O. From b72b076890801cf61bb3e40bb27ede321a23dcc3 Mon Sep 17 00:00:00 2001 From: Jeremy Lewi Date: Thu, 24 Sep 2026 00:21:23 +0000 Subject: [PATCH 2/2] Bind notebook views to their save baseline and open empty local files Signed-off-by: Jeremy Lewi --- app/src/lib/notebookDataController.test.ts | 50 ++++++++++++---- app/src/lib/notebookDataController.ts | 10 ++-- app/src/storage/local.test.ts | 68 ++++++++++++++++++++++ app/src/storage/local.ts | 8 ++- app/src/storage/storageOwner.test.ts | 3 + app/src/storage/storageOwnerClient.ts | 4 ++ app/src/storage/storageOwnerHost.ts | 6 +- app/src/storage/storageOwnerProtocol.ts | 2 +- app/test/browser/storage-owner-smoke.ts | 2 +- docs-dev/CUJs/drive-sync-recovery.md | 16 ++++- 10 files changed, 148 insertions(+), 21 deletions(-) diff --git a/app/src/lib/notebookDataController.test.ts b/app/src/lib/notebookDataController.test.ts index b0c55573..1c2e7ebd 100644 --- a/app/src/lib/notebookDataController.test.ts +++ b/app/src/lib/notebookDataController.test.ts @@ -100,7 +100,10 @@ function createFakeLocalNotebooks() { sync: vi.fn(async () => undefined), operationLogSupportsConcurrentWriters: vi.fn(() => true), save: vi.fn(), - createOperationLogSaveStore: vi.fn(async () => ({ save: vi.fn() })), + createOperationLogSaveStore: vi.fn(async (uri: string) => ({ + save: vi.fn(), + initialNotebook: records.get(uri)!.notebook, + })), } } @@ -271,6 +274,34 @@ describe('NotebookDataController', () => { ) }) + it('renders the save baseline when sync appends between load and view creation', async () => { + const uri = 'local://file/shared' + const localStore = createFakeLocalNotebooks() + localStore.records.set(uri, { + id: uri, + name: 'shared.runme', + remoteId: 'https://drive.google.com/file/d/shared/view', + notebook: createNotebook('before sync'), + }) + localStore.load.mockImplementationOnce(async () => { + const beforeSync = localStore.records.get(uri)!.notebook + localStore.records.get(uri)!.notebook = createNotebook('after sync') + return beforeSync + }) + const controller = getNotebookDataController() + controller.configureOwnershipManager(createFakeOwnershipManager()) + controller.configureStores({ + localNotebooks: localStore as unknown as LocalNotebooks, + }) + + const result = await controller.openNotebook(uri) + + expect(result.entry.state).toBe('loaded') + expect(controller.getNotebookData(uri)?.getNotebook().cells[0]?.value).toBe( + 'after sync' + ) + }) + it('fails closed for .runme when Web Locks are unavailable', async () => { const localStore = createFakeLocalNotebooks() localStore.operationLogSupportsConcurrentWriters.mockReturnValue(false) @@ -306,10 +337,6 @@ describe('NotebookDataController', () => { remoteId: 'https://drive.google.com/file/d/shared/view', notebook: createNotebook('before'), }) - localStore.loadOperationLogSnapshot.mockImplementation(async () => { - localStore.records.get(uri)!.notebook = createNotebook('after') - return localStore.records.get(uri)!.notebook - }) const controller = getNotebookDataController() controller.configureOwnershipManager(createFakeOwnershipManager()) controller.configureStores({ @@ -320,6 +347,7 @@ describe('NotebookDataController', () => { const flushPendingPersist = vi.spyOn(notebookData, 'flushPendingPersist') notebookData.setReviewPending(true) notebookData.setReviewReloadRequired(true) + localStore.records.get(uri)!.notebook = createNotebook('after') await controller.refreshReadOnlyNotebook(uri) @@ -327,10 +355,12 @@ describe('NotebookDataController', () => { expect(notebookData.isReviewReloadRequired()).toBe(false) expect(localStore.sync).not.toHaveBeenCalled() - expect(localStore.loadOperationLogSnapshot).toHaveBeenCalledWith(uri) + expect(localStore.loadOperationLogSnapshot).not.toHaveBeenCalled() + expect(localStore.load).toHaveBeenCalledOnce() + expect(localStore.createOperationLogSaveStore).toHaveBeenCalledTimes(2) expect(flushPendingPersist).toHaveBeenCalledOnce() expect( - localStore.loadOperationLogSnapshot.mock.invocationCallOrder.at(-1) + localStore.createOperationLogSaveStore.mock.invocationCallOrder.at(-1) ).toBeGreaterThan(flushPendingPersist.mock.invocationCallOrder.at(-1)!) expect(controller.getNotebookData(uri)?.getNotebook().cells[0]?.value).toBe( 'after' @@ -346,15 +376,15 @@ describe('NotebookDataController', () => { remoteId: 'https://drive.google.com/file/d/shared/view', notebook: createNotebook('current'), }) - localStore.loadOperationLogSnapshot.mockRejectedValue( - new Error('OPFS unavailable') - ) const controller = getNotebookDataController() controller.configureOwnershipManager(createFakeOwnershipManager()) controller.configureStores({ localNotebooks: localStore as unknown as LocalNotebooks, }) await controller.openNotebook(uri) + localStore.createOperationLogSaveStore.mockRejectedValueOnce( + new Error('OPFS unavailable') + ) await controller.refreshReadOnlyNotebook(uri) diff --git a/app/src/lib/notebookDataController.ts b/app/src/lib/notebookDataController.ts index c1b514a9..014506b9 100644 --- a/app/src/lib/notebookDataController.ts +++ b/app/src/lib/notebookDataController.ts @@ -225,10 +225,12 @@ export class NotebookDataController { return { localUri, entry } } try { - const notebook = await this.localNotebooks.load(localUri) + await this.localNotebooks.load(localUri) const store = await this.localNotebooks.createOperationLogSaveStore(localUri) - handle.data.loadNotebook(notebook, { persist: false }) + // Sync may append between load and view creation. Render exactly the + // history captured by this adapter so a save cannot delete unseen cells. + handle.data.loadNotebook(store.initialNotebook, { persist: false }) handle.data.setNotebookStore(store) handle.data.setReadOnly(false) handle.loaded = true @@ -512,11 +514,9 @@ export class NotebookDataController { // Refresh only materializes the shared OPFS journal. Upstream Drive // synchronization is an independent action exposed by the tab status // control. - const notebook = - await this.localNotebooks.loadOperationLogSnapshot(localUri) const store = await this.localNotebooks.createOperationLogSaveStore(localUri) - handle.data.loadNotebook(notebook, { persist: false }) + handle.data.loadNotebook(store.initialNotebook, { persist: false }) handle.data.setNotebookStore(store) // Recover an undo whose append committed but whose editor reload failed. handle.data.setReviewReloadRequired(false) diff --git a/app/src/storage/local.test.ts b/app/src/storage/local.test.ts index e15e7c7e..b8084a91 100644 --- a/app/src/storage/local.test.ts +++ b/app/src/storage/local.test.ts @@ -8882,6 +8882,74 @@ describe('LocalNotebooks local-first open', () => { return { store, uri: file.uri } } + it('keeps edits made through a captured view without deleting later unseen cells', async () => { + const { store, uri } = await cachedNotebook() + store.setDriveSyncAvailable(false) + try { + const view = await store.createOperationLogSaveStore(uri, { + actorId: 'view', + }) + const other = await store.createOperationLogSaveStore(uri, { + actorId: 'other', + }) + other.initialNotebook.cells.push( + create(parser_pb.CellSchema, { + refId: 'unseen-cell', + kind: parser_pb.CellKind.MARKUP, + value: 'appended after the view was captured', + }) + ) + await other.save(uri, other.initialNotebook) + // Mutating the returned snapshot must not mutate the adapter baseline. + view.initialNotebook.cells[0].value = 'edited captured cell' + await view.save(uri, view.initialNotebook) + + const reopened = await store.load(uri) + expect(reopened.cells.map((cell) => cell.value)).toEqual([ + 'edited captured cell', + 'appended after the view was captured', + ]) + } finally { + store.stopSyncQueue() + } + }) + + it.each(['json', 'ipynb'])( + 'opens a newly created empty %s notebook while offline', + async (format) => { + const drive = { create: vi.fn() } + const store = createTestStore(drive) + store.setDriveSyncAvailable(false) + const parent = 'local://folder/drive' + await store.folders.put({ + id: parent, + name: 'Drive', + remoteId: 'https://drive.google.com/drive/folders/parent', + children: [], + lastSynced: '', + }) + const sync = vi.spyOn(store as any, 'syncFile').mockImplementation( + () => new Promise(() => {}) + ) + try { + const file = await store.create(parent, `empty.${format}`) + sync.mockClear() // Creation itself schedules an asynchronous sync. + const opened = vi.fn() + const load = store.load(file.uri).then(opened) + await vi.waitFor(() => expect(opened).toHaveBeenCalled()) + await load + expect(opened.mock.calls[0][0].cells).toEqual([]) + expect(sync).not.toHaveBeenCalled() + expect(drive.create).not.toHaveBeenCalled() + expect((await store.getSyncState(file.uri)).status).toBe( + 'pending-upstream-create' + ) + } finally { + store.stopSyncQueue() + } + } + ) + it('opens cached OPFS content while an unrelated Drive queue item is blocked', async () => { const { store, uri } = await cachedNotebook() let release!: () => void diff --git a/app/src/storage/local.ts b/app/src/storage/local.ts index ba4ef66e..91760549 100644 --- a/app/src/storage/local.ts +++ b/app/src/storage/local.ts @@ -1714,6 +1714,7 @@ export class LocalNotebooks extends Dexie { ): Promise<{ save(saveUri: string, notebook: parser_pb.Notebook): Promise getObservedOperationHeads(): string[] + initialNotebook: parser_pb.Notebook }> { const record = await this.files.get(uri) if (!record || !record.operationLogRef) { @@ -1738,6 +1739,9 @@ export class LocalNotebooks extends Dexie { let queue = Promise.resolve() return { + // Render the same captured history used as the first save baseline. Keep + // it detached because editors mutate their notebook model in place. + initialNotebook: cloneNotebook(previous), getObservedOperationHeads: () => snapshotHeads(view.operations, captureReviewRevision(view.operations)), save: async (saveUri: string, notebook: parser_pb.Notebook) => { @@ -3288,7 +3292,9 @@ export class LocalNotebooks extends Dexie { // placeholder. Pending Drive creation does not make that local log a miss. const hasLocalContent = operationLog ? Boolean(existing.operationLogRef) - : Boolean(existing.doc) || isLocalFileUpstream(existing.remoteId, uri) + : Boolean(existing.doc) || + isLocalFileUpstream(existing.remoteId, uri) || + (existing.remoteId === '' && Boolean(existing.parentRemoteIdWhenCreated)) let record = existing if (hasLocalContent) { diff --git a/app/src/storage/storageOwner.test.ts b/app/src/storage/storageOwner.test.ts index 92e1a433..9b825331 100644 --- a/app/src/storage/storageOwner.test.ts +++ b/app/src/storage/storageOwner.test.ts @@ -35,6 +35,7 @@ function setup() { createOperationLogSaveStore: vi.fn(async () => ({ save: vi.fn(async () => {}), getObservedOperationHeads: () => ['op-1'], + initialNotebook: { cells: [{ refId: 'captured-cell' }] }, })), } return { @@ -157,8 +158,10 @@ describe('SharedWorker message boundary', () => { const view = (await first.request('createView', ['a'])) as { id: string heads: string[] + initialNotebook: { cells: { refId: string }[] } } expect(view.heads).toEqual(['op-1']) + expect(view.initialNotebook.cells).toEqual([{ refId: 'captured-cell' }]) await expect( second.request('saveView', [view.id, 'a', {}]) ).rejects.toThrow('view disconnected') diff --git a/app/src/storage/storageOwnerClient.ts b/app/src/storage/storageOwnerClient.ts index 48d784d9..0519f5c0 100644 --- a/app/src/storage/storageOwnerClient.ts +++ b/app/src/storage/storageOwnerClient.ts @@ -264,8 +264,12 @@ export function createSharedNotebookStore( const view = (await call('createView', [uri, options])) as { id: string heads: string[] + initialNotebook: Awaited< + ReturnType + >['initialNotebook'] } return { + initialNotebook: view.initialNotebook, dispose: () => { void call('releaseView', [view.id]).catch(() => {}) }, diff --git a/app/src/storage/storageOwnerHost.ts b/app/src/storage/storageOwnerHost.ts index fa8fe2a3..74f89d45 100644 --- a/app/src/storage/storageOwnerHost.ts +++ b/app/src/storage/storageOwnerHost.ts @@ -222,7 +222,11 @@ export class StorageOwnerHost { ) const id = crypto.randomUUID() this.views.get(port)!.set(id, view) - value = { id, heads: view.getObservedOperationHeads() } + value = { + id, + heads: view.getObservedOperationHeads(), + initialNotebook: view.initialNotebook, + } break } case 'saveView': { diff --git a/app/src/storage/storageOwnerProtocol.ts b/app/src/storage/storageOwnerProtocol.ts index 0776a949..6676acd4 100644 --- a/app/src/storage/storageOwnerProtocol.ts +++ b/app/src/storage/storageOwnerProtocol.ts @@ -1,7 +1,7 @@ import type LocalNotebooks from './local' /** Bump whenever the wire contract or storage ownership assumptions change. */ -export const STORAGE_OWNER_VERSION = 1 +export const STORAGE_OWNER_VERSION = 2 export const STORAGE_OWNER_NAME = 'runme-storage-owner' /** Explicit RPC boundary: never expose arbitrary Dexie/prototype methods. */ diff --git a/app/test/browser/storage-owner-smoke.ts b/app/test/browser/storage-owner-smoke.ts index 2752ee8f..00e0b7a5 100644 --- a/app/test/browser/storage-owner-smoke.ts +++ b/app/test/browser/storage-owner-smoke.ts @@ -59,7 +59,7 @@ async function main() { page.evaluate(async (uri) => { const store = (window as any).store ;(window as any).view = await store.createOperationLogSaveStore(uri) - ;(window as any).notebook = await store.loadOperationLogSnapshot(uri) + ;(window as any).notebook = (window as any).view.initialNotebook }, uri) ) ) diff --git a/docs-dev/CUJs/drive-sync-recovery.md b/docs-dev/CUJs/drive-sync-recovery.md index 154cafb8..61b07e61 100644 --- a/docs-dev/CUJs/drive-sync-recovery.md +++ b/docs-dev/CUJs/drive-sync-recovery.md @@ -91,8 +91,8 @@ Different origins/profiles are outside the same-origin coordination guarantee. Opening a cached notebook must not wait for Drive, including an already running sync for a different notebook. A `.runme` operation-log reference identifies local -content even though its IndexedDB `doc` is empty. Cached JSON/IPYNB models also -open locally. An old or missing successful-sync timestamp schedules background +content even though its IndexedDB `doc` is empty. Cached JSON/IPYNB models and +new empty notebooks awaiting upstream creation also open locally. An old or missing successful-sync timestamp schedules background reconciliation; it does not make available content an initial-download miss. Only an uncached notebook waits for upstream content, and a failed first download must surface the error instead of showing an empty notebook. @@ -122,3 +122,15 @@ and verify eventual convergence and a single upstream identity. A notebook never downloaded locally must still report the unavailable dependency. Background reconciliation does not replace an editor's mounted causal view; explicit refresh continues to read the local log without upstream I/O. + +The editor and its save adapter must start from the same captured log. A sync or +another tab can append between the initial load and adapter creation. Open and +local refresh therefore render the adapter's `initialNotebook`, captured with its +causal heads, instead of combining separately read snapshots. Test an append in +that interval, then edit/save/reopen and verify that the unseen cells survive. +Mutating the returned model must not mutate the adapter's baseline. + +The `createView` worker response now carries that snapshot, so the storage owner +protocol is version 2. Mixed-version clients must fail with the existing reload +message. After deployment, close/reopen all Runme tabs on that origin if an old +worker remains alive; do not clear browser storage.