Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 40 additions & 10 deletions app/src/lib/notebookDataController.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})),
}
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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({
Expand All @@ -320,17 +347,20 @@ 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)

expect(notebookData.isReviewPending()).toBe(false)
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'
Expand All @@ -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)

Expand Down
10 changes: 5 additions & 5 deletions app/src/lib/notebookDataController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
243 changes: 241 additions & 2 deletions app/src/storage/local.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -8841,3 +8845,238 @@ 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('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
const blocked = new Promise<void>((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')
}
)
})
Loading
Loading