diff --git a/doc/web-server-compatibility.md b/doc/web-server-compatibility.md index 039ecbab9..bb1714fc7 100644 --- a/doc/web-server-compatibility.md +++ b/doc/web-server-compatibility.md @@ -50,3 +50,38 @@ server, or whether a client upgrade can help resets dismissal; a confirmed compatible response clears it. Reloading the tab starts a new session. An old web client offers a reload action; contradictory requirements ask the administrator to update both components. + +## Database page loading + +The authenticated view loader requests +`GET /api/workspace/{workspace_id}/page-view/{view_id}?include_rows=false`. +It applies only the page's `encoded_collab`; database row snapshots are loaded by +the existing seed/realtime pipeline. The response retains the full database +metadata, including all view row orders, and returns an empty `row_data` map. +View authorization and database identity resolution remain on the page-view +endpoint. This transport option does not change the loader's view ownership or +sync binding behavior. + +Omitting the parameter keeps the full row response for other callers. Request +deduplication includes the normalized row option so a legacy caller cannot +receive an empty-row response from a simultaneous optimized load. Older servers +that ignore the query continue returning rows, which this loader already ignores. +Published pages keep their separate static row payloads. Writable database pages +still walk blob-diff pages (256 items / 16 MiB per page) before exposing a complete +seed set for filtering, sorting, grouping and row counts; only visible rows acquire +realtime subscriptions. This change removes duplicate page-view reads and does +not introduce partial database semantics or change read-only/guest fallback behavior. + +The blob-diff binary endpoint can also return JSON application errors, including +HTTP 200 with code 1079 when the server's admission queue is full. Web detects the +JSON envelope before protobuf decoding and retries admission overload (1079 or +HTTP 429) at most three times per page request. Backoff bases are 1/2/4 seconds, +with 100–200% jitter and a server retry hint as a minimum. Hints over 30 seconds +surface the retry action immediately rather than retaining the walk or retrying +early. Cursor, original RID and provisional pages stay unchanged across retries; +seeds and the RID become visible only after the terminal Ready page. Concurrent +views of the same database share the prefetch, and the last owner's release +cancels unfinished network/backoff work. After overload exhaustion, the database +shows a retry action and keeps its row seed gate closed, avoiding a second wave +of individual row-sync requests. Permission and protocol failures retain their +existing handling. diff --git a/src/application/__tests__/view-loader.test.ts b/src/application/__tests__/view-loader.test.ts index 27ccc1642..b772f2388 100644 --- a/src/application/__tests__/view-loader.test.ts +++ b/src/application/__tests__/view-loader.test.ts @@ -160,7 +160,7 @@ describe('view-loader database cache identity', () => { expect(mockOpenCollabDB).not.toHaveBeenCalledWith(viewId); }); - it('fetches by viewId into the canonical databaseId cache when local cache is empty', async () => { + it('fetches metadata by viewId into the canonical database cache without requesting row snapshots', async () => { const viewId = '00000000-0000-4000-8000-000000000003'; const databaseId = '00000000-0000-4000-8000-000000000004'; const canonicalDoc = createEmptyDoc(databaseId); @@ -193,7 +193,34 @@ describe('view-loader database cache identity', () => { expect(result.doc).toBe(canonicalDoc); expect(result.fromCache).toBe(false); expect(getDatabaseIdFromDoc(canonicalDoc)).toBe(databaseId); - expect(mockFetchPageCollab).toHaveBeenCalledWith('workspace-id', viewId); + expect(mockFetchPageCollab).toHaveBeenCalledWith('workspace-id', viewId, { includeRows: false }); + expect(mockFetchDatabaseCollab).not.toHaveBeenCalled(); + }); + + it('loads complete database row orders without row snapshots before the layout or database ID is known', async () => { + const viewId = 'unknown-database-view'; + const databaseId = 'canonical-database'; + const doc = createEmptyDoc(viewId); + const serverDoc = createCompleteDatabaseDoc(databaseId, databaseId, viewId); + const database = serverDoc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database) as Y.Map; + const views = database.get(YjsDatabaseKey.views) as Y.Map>; + const rowOrders = new Y.Array(); + + rowOrders.push([{ id: 'row-1' }, { id: 'row-2' }]); + views.get(viewId)?.set(YjsDatabaseKey.row_orders, rowOrders); + mockOpenCollabDB.mockResolvedValue(doc); + mockFetchPageCollab.mockResolvedValue({ data: Y.encodeStateAsUpdate(serverDoc), rows: {} }); + + const result = await openView('workspace-id', viewId); + const loadedDatabase = result.doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database) as Y.Map; + const loadedViews = loadedDatabase.get(YjsDatabaseKey.views) as Y.Map>; + const loadedRowOrders = loadedViews.get(viewId)?.get(YjsDatabaseKey.row_orders) as Y.Array; + + expect(result.collabType).toBe(Types.Database); + expect(getDatabaseIdFromDoc(result.doc)).toBe(databaseId); + expect(loadedRowOrders.toJSON()).toEqual([{ id: 'row-1' }, { id: 'row-2' }]); + expect(mockFetchPageCollab).toHaveBeenCalledWith('workspace-id', viewId, { includeRows: false }); + expect(mockFetchDatabaseCollab).not.toHaveBeenCalled(); }); it('fetches only the canonical database collab for metadata-only relation loads', async () => { diff --git a/src/application/database-blob/__tests__/prefetch-dedup.test.ts b/src/application/database-blob/__tests__/prefetch-dedup.test.ts index 3d4b727c2..727e67a1f 100644 --- a/src/application/database-blob/__tests__/prefetch-dedup.test.ts +++ b/src/application/database-blob/__tests__/prefetch-dedup.test.ts @@ -2,6 +2,8 @@ import { prefetchDatabaseBlobDiff, clearDatabaseRowDocSeedCache, invalidateDatabaseRowDocSeed, + retainDatabaseRowDocSeedCache, + releaseDatabaseRowDocSeedCache, takeDatabaseRowDocSeed, } from '@/application/database-blob'; import * as pageStageModule from '@/application/database-blob/page-stage'; @@ -359,6 +361,164 @@ describe('database blob prefetch deduplication', () => { }); }); + it('shares an admission retry and commits no seeds until the unchanged continuation succeeds', async () => { + jest.useFakeTimers(); + jest.spyOn(Math, 'random').mockReturnValue(0.5); + const workspaceId = 'workspace-overloaded'; + const databaseId = 'database-overloaded'; + const nextCursor = new Uint8Array([4, 5]); + const originalRid = { timestamp: 10, seqNo: 1 }; + const onSeedsReady = jest.fn(); + + databaseIds.add(databaseId); + localStorage.setItem(`af_database_blob_rid:${databaseId}`, JSON.stringify(originalRid)); + mockedDatabaseBlobDiff + .mockResolvedValueOnce(persistablePage({ timestamp: 20, seqNo: 1 }, { hasMore: true, nextCursor })) + .mockRejectedValueOnce({ code: 1079, httpStatus: 200, message: 'Busy', retryAfterSecs: 2 }) + .mockResolvedValueOnce(readyDiff()); + const first = prefetchDatabaseBlobDiff(workspaceId, databaseId, { onSeedsReady }); + const second = prefetchDatabaseBlobDiff(workspaceId, databaseId); + + await jest.advanceTimersByTimeAsync(2999); + expect(mockedDatabaseBlobDiff).toHaveBeenCalledTimes(2); + expect(onSeedsReady).not.toHaveBeenCalled(); + expect(mockedOpenRowCollabDB).not.toHaveBeenCalled(); + expect(takeDatabaseRowDocSeed(`${databaseId}_rows_${VALID_ROW_ID}`)).toBeNull(); + expect(JSON.parse(localStorage.getItem(`af_database_blob_rid:${databaseId}`) ?? 'null')).toEqual(originalRid); + await jest.advanceTimersByTimeAsync(1); + await Promise.all([first, second]); + expect(mockedDatabaseBlobDiff).toHaveBeenCalledTimes(3); + expect(mockedDatabaseBlobDiff.mock.calls[2][2]).toBe(mockedDatabaseBlobDiff.mock.calls[1][2]); + expect(mockedDatabaseBlobDiff.mock.calls[2][2].maxKnownRid).toMatchObject(originalRid); + expect(onSeedsReady).toHaveBeenCalledTimes(1); + expect(mockedOpenRowCollabDB).toHaveBeenCalledTimes(1); + expect(JSON.parse(localStorage.getItem(`af_database_blob_rid:${databaseId}`) ?? 'null')).toEqual({ + timestamp: 20, + seqNo: 1, + }); + }); + + it('bounds admission retries and discards provisional pages without signaling row fallback', async () => { + jest.useFakeTimers(); + jest.spyOn(Math, 'random').mockReturnValue(0); + const databaseId = 'database-overload-exhausted'; + const onSeedsReady = jest.fn(); + const overloaded = { code: 1079, message: 'Busy' }; + const { stage, clear } = createMockPageStage(); + + jest.spyOn(pageStageModule, 'createDatabaseBlobDiffPageStage').mockReturnValueOnce(stage); + databaseIds.add(databaseId); + mockedDatabaseBlobDiff + .mockResolvedValueOnce( + persistablePage({ timestamp: 20, seqNo: 1 }, { hasMore: true, nextCursor: new Uint8Array([1]) }) + ) + .mockRejectedValue(overloaded); + const result = prefetchDatabaseBlobDiff('workspace', databaseId, { onSeedsReady }).catch((error) => error); + + await jest.runAllTimersAsync(); + expect(await result).toBe(overloaded); + expect(mockedDatabaseBlobDiff).toHaveBeenCalledTimes(5); + expect(onSeedsReady).not.toHaveBeenCalled(); + expect(mockedOpenRowCollabDB).not.toHaveBeenCalled(); + expect(localStorage.getItem(`af_database_blob_rid:${databaseId}`)).toBeNull(); + expect(takeDatabaseRowDocSeed(`${databaseId}_rows_${VALID_ROW_ID}`)).toBeNull(); + expect(clear).toHaveBeenCalledTimes(1); + }); + + it('cancels a backpressure wait only after the last database owner releases it', async () => { + jest.useFakeTimers(); + const databaseId = 'database-overload-cancel'; + const onSeedsReady = jest.fn(); + + databaseIds.add(databaseId); + retainDatabaseRowDocSeedCache(databaseId); + retainDatabaseRowDocSeedCache(databaseId); + mockedDatabaseBlobDiff.mockRejectedValue({ code: 1079, message: 'Busy' }); + const result = prefetchDatabaseBlobDiff('workspace', databaseId, { onSeedsReady }).catch((error) => error); + + await jest.advanceTimersByTimeAsync(0); + const signal = mockedDatabaseBlobDiff.mock.calls[0][3]?.signal; + + releaseDatabaseRowDocSeedCache(databaseId); + expect(signal?.aborted).toBe(false); + releaseDatabaseRowDocSeedCache(databaseId); + expect(signal?.aborted).toBe(true); + expect(await result).toMatchObject({ name: 'AbortError' }); + await jest.runAllTimersAsync(); + expect(mockedDatabaseBlobDiff).toHaveBeenCalledTimes(1); + expect(onSeedsReady).not.toHaveBeenCalled(); + expect(mockedOpenRowCollabDB).not.toHaveBeenCalled(); + + mockedDatabaseBlobDiff.mockResolvedValueOnce(readyDiff()); + await prefetchDatabaseBlobDiff('workspace', databaseId); + expect(mockedDatabaseBlobDiff).toHaveBeenCalledTimes(2); + }); + + it.each([ + { code: 1012, message: 'Denied' }, + { code: 1079, message: 'Busy', retryAfterSecs: 60 }, + ])('does not retry permanent errors or violate a long server cooldown: %p', async (error) => { + const databaseId = `database-no-retry-${error.code}`; + + databaseIds.add(databaseId); + mockedDatabaseBlobDiff.mockRejectedValue(error); + await expect(prefetchDatabaseBlobDiff('workspace', databaseId)).rejects.toBe(error); + expect(mockedDatabaseBlobDiff).toHaveBeenCalledTimes(1); + }); + + it('reuses a terminal walk during close and reopen while staged seeds are being committed', async () => { + const databaseId = 'database-reopen-during-commit'; + const diff = persistablePage({ timestamp: 20, seqNo: 1 }); + const stagedRead = createDeferred(); + const { stage, read } = createMockPageStage(); + const reopenedSeedsReady = jest.fn(); + + jest.spyOn(pageStageModule, 'createDatabaseBlobDiffPageStage').mockReturnValueOnce(stage); + read.mockReturnValueOnce(stagedRead.promise); + databaseIds.add(databaseId); + retainDatabaseRowDocSeedCache(databaseId); + mockedDatabaseBlobDiff.mockResolvedValueOnce(diff); + const first = prefetchDatabaseBlobDiff('workspace', databaseId); + + await flushPendingWork(); + expect(read).toHaveBeenCalledTimes(1); + const signal = mockedDatabaseBlobDiff.mock.calls[0][3]?.signal; + + releaseDatabaseRowDocSeedCache(databaseId); + expect(signal?.aborted).toBe(false); + retainDatabaseRowDocSeedCache(databaseId); + const reopened = prefetchDatabaseBlobDiff('workspace', databaseId, { onSeedsReady: reopenedSeedsReady }); + + expect(mockedDatabaseBlobDiff).toHaveBeenCalledTimes(1); + stagedRead.resolve(database_blob.DatabaseBlobDiffResponse.encode(diff).finish()); + await Promise.all([first, reopened]); + expect(reopenedSeedsReady).toHaveBeenCalledTimes(1); + expect(mockedOpenRowCollabDB).toHaveBeenCalledTimes(1); + expect(JSON.parse(localStorage.getItem(`af_database_blob_rid:${databaseId}`) ?? 'null')).toEqual({ + timestamp: 20, + seqNo: 1, + }); + releaseDatabaseRowDocSeedCache(databaseId); + }); + + it('discards a late response after its last owner cancels the in-flight request', async () => { + const databaseId = 'database-cancel-in-flight'; + const onSeedsReady = jest.fn(); + const response = createDeferred(); + + databaseIds.add(databaseId); + retainDatabaseRowDocSeedCache(databaseId); + mockedDatabaseBlobDiff.mockReturnValueOnce(response.promise); + const result = prefetchDatabaseBlobDiff('workspace', databaseId, { onSeedsReady }).catch((error) => error); + + releaseDatabaseRowDocSeedCache(databaseId); + response.resolve(persistablePage({ timestamp: 20, seqNo: 1 })); + expect(await result).toMatchObject({ name: 'AbortError' }); + expect(onSeedsReady).not.toHaveBeenCalled(); + expect(mockedOpenRowCollabDB).not.toHaveBeenCalled(); + expect(localStorage.getItem(`af_database_blob_rid:${databaseId}`)).toBeNull(); + }); + it('retries a Pending page with the same cursor', async () => { jest.useFakeTimers(); diff --git a/src/application/database-blob/index.ts b/src/application/database-blob/index.ts index cdd017e0a..4767f383b 100644 --- a/src/application/database-blob/index.ts +++ b/src/application/database-blob/index.ts @@ -18,6 +18,11 @@ import { database_blob } from '@/proto/database_blob'; import { Log } from '@/utils/log'; import { createDatabaseBlobDiffPageStage, type DatabaseBlobDiffPageStage } from './page-stage'; +import { + throwIfDatabaseBlobAborted, + waitForDatabaseBlobRetry, + withDatabaseBlobBackpressureRetry, +} from './request-retry'; import { createDatabaseRowDocSeed, invalidateDatabaseRowDocSeedGeneration, @@ -43,6 +48,9 @@ type PrefetchOptions = { }; type SharedPrefetchEntry = { + abortController: AbortController; + /** A terminal page walk must finish committing before a replacement starts. */ + fetching: boolean; priorityRowIds: Set; /** Rows reset after this prefetch started must not consume its stale snapshot. */ invalidatedRowIds: Set; @@ -127,10 +135,6 @@ function sharedPrefetchEntryMatchesDatabase(sharedKey: string, databaseId: strin return sharedKey.includes(`:${databaseId}:`) || sharedKey.endsWith(`:${databaseId}`); } -function sleep(ms: number): Promise { - return new Promise((resolve) => window.setTimeout(resolve, ms)); -} - function retryDelayMs(retryAfterSecs?: number | null): number { if (!retryAfterSecs || retryAfterSecs <= 0) return BLOB_DIFF_DEFAULT_RETRY_MS; return Math.min(retryAfterSecs * 1000, BLOB_DIFF_MAX_RETRY_MS); @@ -423,6 +427,12 @@ export function clearDatabaseRowDocSeedCache(databaseId: string) { entry.onSeedsReadyCallbacks.clear(); if (entry.promise && !entry.settled) { + // A terminal walk may still persist its committed seeds. Cancel only + // provisional network work when no view retains this database. + if (entry.fetching && (rowDocSeedCacheRetainCounts.get(databaseId) ?? 0) === 0) { + entry.abortController.abort(); + } + clearSharedPrefetchEntryAfterSettle(databaseId, key, entry); hasUnsettledPrefetch = true; } @@ -1060,6 +1070,7 @@ async function fetchReadyDiff( options: { cachedRid: DatabaseBlobRowRid | null; forceFullSync?: boolean; + signal: AbortSignal; } ): Promise { const cachedRid = options.cachedRid; @@ -1098,7 +1109,10 @@ async function fetchReadyDiff( for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { const attemptStartedAt = Date.now(); - const diff = await databaseBlobDiff(workspaceId, databaseId, request); + const diff = await withDatabaseBlobBackpressureRetry( + () => databaseBlobDiff(workspaceId, databaseId, request, { signal: options.signal }), + options.signal + ); Log.debug('[Database] blob diff page response', { databaseId, @@ -1187,6 +1201,7 @@ async function fetchReadyDiff( } if (!page.hasMore) { + throwIfDatabaseBlobAborted(options.signal); return { diff, ready: true, stagedPages }; } @@ -1225,7 +1240,7 @@ async function fetchReadyDiff( message: diff.message ?? null, }); - await sleep(delayMs); + await waitForDatabaseBlobRetry(delayMs, options.signal); } } } catch (error) { @@ -1245,7 +1260,10 @@ export async function prefetchDatabaseBlobDiff(workspaceId: string, databaseId: existingEntry.reuseSettled && existingEntry.settled && existingEntry.hasCompleteSeedSet ); - if (!existingEntry.settled || canReuseSettledFullSeed || canReuseSettledSeed) { + if ( + !existingEntry.abortController.signal.aborted && + (!existingEntry.settled || canReuseSettledFullSeed || canReuseSettledSeed) + ) { applyPrefetchOptions(existingEntry, options); if (canReuseSettledSeed && !options?.reuseSettled) { @@ -1267,6 +1285,8 @@ export async function prefetchDatabaseBlobDiff(workspaceId: string, databaseId: const outboxSession = getCurrentOutboxSession(workspaceId); const cachedRid = options?.forceFullSync ? null : readCachedRid(databaseId); const entry: SharedPrefetchEntry = { + abortController: new AbortController(), + fetching: true, priorityRowIds: new Set(), invalidatedRowIds: new Set(), onSeedsReadyCallbacks: new Set(), @@ -1300,9 +1320,12 @@ export async function prefetchDatabaseBlobDiff(workspaceId: string, databaseId: const { diff, ready, stagedPages } = await fetchReadyDiff(workspaceId, databaseId, { cachedRid, forceFullSync: options?.forceFullSync, + signal: entry.abortController.signal, }); + entry.fetching = false; if (!ready) { + throwIfDatabaseBlobAborted(entry.abortController.signal); notifySeedsReady(entry); return diff; } @@ -1316,6 +1339,9 @@ export async function prefetchDatabaseBlobDiff(workspaceId: string, databaseId: } try { + // Cancellation can race the terminal response's promise continuation. + // Once replay starts, retain this entry until its seeds and RID commit. + throwIfDatabaseBlobAborted(entry.abortController.signal); if (pageCount === 0) { throw new Error('database blob diff paging protocol error: Ready walk did not stage any pages'); } diff --git a/src/application/database-blob/request-retry.ts b/src/application/database-blob/request-retry.ts new file mode 100644 index 000000000..29d91ff81 --- /dev/null +++ b/src/application/database-blob/request-retry.ts @@ -0,0 +1,61 @@ +import type { APIError } from '@/application/services/js-services/http/core'; +import { Log } from '@/utils/log'; + +const BACKPRESSURE_RETRY_DELAYS_MS = [1000, 2000, 4000]; +const MAX_SERVER_RETRY_AFTER_SECS = 30; + +/** Admission rejection is not a corrupt blob and must not fan out into row sync. */ +export function isDatabaseBlobBackpressure(error: unknown): error is APIError { + if (!error || typeof error !== 'object') return false; + const value = error as Partial; + + return value.code === 1079 || value.code === 429 || value.httpStatus === 429; +} + +export function throwIfDatabaseBlobAborted(signal: AbortSignal) { + if (signal.aborted) throw new DOMException('The database prefetch was cancelled', 'AbortError'); +} + +export function waitForDatabaseBlobRetry(delayMs: number, signal: AbortSignal): Promise { + throwIfDatabaseBlobAborted(signal); + return new Promise((resolve, reject) => { + const abort = () => { + clearTimeout(timer); + reject(new DOMException('The database prefetch was cancelled', 'AbortError')); + }; + + const timer = setTimeout(() => { + signal.removeEventListener('abort', abort); + resolve(); + }, delayMs); + + signal.addEventListener('abort', abort, { once: true }); + }); +} + +/** Retry the same page; its cursor and watermark remain owned by the page walk. */ +export async function withDatabaseBlobBackpressureRetry(request: () => Promise, signal: AbortSignal): Promise { + for (let attempt = 0; ; attempt += 1) { + throwIfDatabaseBlobAborted(signal); + try { + const result = await request(); + + throwIfDatabaseBlobAborted(signal); + return result; + } catch (error) { + throwIfDatabaseBlobAborted(signal); + if (!isDatabaseBlobBackpressure(error) || attempt >= BACKPRESSURE_RETRY_DELAYS_MS.length) throw error; + const retryAfterSecs = + Number.isFinite(error.retryAfterSecs) && (error.retryAfterSecs ?? 0) > 0 ? error.retryAfterSecs : undefined; + + // Do not retry earlier than a long server cooldown, or retain an abandoned + // page walk for minutes. Surface the retry action instead. + if (retryAfterSecs && retryAfterSecs > MAX_SERVER_RETRY_AFTER_SECS) throw error; + const baseMs = Math.max(BACKPRESSURE_RETRY_DELAYS_MS[attempt], (retryAfterSecs ?? 0) * 1000); + const delayMs = Math.round(baseMs * (1 + Math.random())); + + Log.debug('[Database] blob admission busy; retrying unchanged page', { attempt: attempt + 1, delayMs }); + await waitForDatabaseBlobRetry(delayMs, signal); + } + } +} diff --git a/src/application/services/js-services/__tests__/fetch.test.ts b/src/application/services/js-services/__tests__/fetch.test.ts index 7c13b914f..024ef31bd 100644 --- a/src/application/services/js-services/__tests__/fetch.test.ts +++ b/src/application/services/js-services/__tests__/fetch.test.ts @@ -1,7 +1,14 @@ import { expect } from '@jest/globals'; -import { fetchPublishView, fetchPublishViewMeta, fetchRowDocumentCollab, fetchViewInfo } from '../fetch'; +import { + fetchPageCollab, + fetchPublishView, + fetchPublishViewMeta, + fetchRowDocumentCollab, + fetchViewInfo, +} from '../fetch'; import { getCollab, + getPageCollab, getPublishView, getPublishInfoWithViewId, getPublishViewMeta, @@ -23,6 +30,43 @@ describe('Collab fetch functions with deduplication', () => { jest.clearAllMocks(); }); + describe('fetchPageCollab', () => { + it('normalizes omitted and explicit full-row options for deduplication', async () => { + const response = { data: new Uint8Array([1]), rows: { 'row-id': [2] } }; + + jest.mocked(getPageCollab).mockResolvedValue(response); + + const defaultRequest = fetchPageCollab('workspace-id', 'view-id'); + const explicitRequest = fetchPageCollab('workspace-id', 'view-id', { includeRows: true }); + + expect(defaultRequest).toBe(explicitRequest); + await expect(defaultRequest).resolves.toEqual(response); + expect(getPageCollab).toHaveBeenCalledTimes(1); + expect(getPageCollab).toHaveBeenCalledWith('workspace-id', 'view-id', { includeRows: true }); + }); + + it('deduplicates no-row loads without sharing their response with full-row callers', async () => { + const fullResponse = { data: new Uint8Array([1]), rows: { 'row-id': [2] } }; + const noRowsResponse = { data: new Uint8Array([1]), rows: {} }; + + jest.mocked(getPageCollab).mockImplementation(async (_workspaceId, _viewId, options) => { + return options?.includeRows === false ? noRowsResponse : fullResponse; + }); + + const noRowsRequest = fetchPageCollab('workspace-id', 'view-id', { includeRows: false }); + const duplicateNoRowsRequest = fetchPageCollab('workspace-id', 'view-id', { includeRows: false }); + const fullRequest = fetchPageCollab('workspace-id', 'view-id'); + + expect(noRowsRequest).toBe(duplicateNoRowsRequest); + expect(noRowsRequest).not.toBe(fullRequest); + await expect(noRowsRequest).resolves.toEqual(noRowsResponse); + await expect(fullRequest).resolves.toEqual(fullResponse); + expect(getPageCollab).toHaveBeenCalledTimes(2); + expect(getPageCollab).toHaveBeenCalledWith('workspace-id', 'view-id', { includeRows: false }); + expect(getPageCollab).toHaveBeenCalledWith('workspace-id', 'view-id', { includeRows: true }); + }); + }); + describe('fetchPublishView', () => { it('should fetch publish view without duplicating requests', async () => { const namespace = 'namespace1'; diff --git a/src/application/services/js-services/fetch.ts b/src/application/services/js-services/fetch.ts index 83cfa52dd..7aa2fc71d 100644 --- a/src/application/services/js-services/fetch.ts +++ b/src/application/services/js-services/fetch.ts @@ -4,12 +4,13 @@ import { getPageCollab, getPublishInfoWithViewId, getPublishViewMeta as getPublishViewMetaAPI, + PageCollabFetchOptions, } from '@/application/services/js-services/http'; import { RowDocumentSourcePayload, Types } from '@/application/types'; const pendingRequests = new Map(); -function generateRequestKey (url: string, params: T) { +function generateRequestKey(url: string, params: T) { if (!params) return url; try { @@ -22,7 +23,7 @@ function generateRequestKey (url: string, params: T) { // Deduplication fetch requests // When multiple requests are made to the same URL with the same params, only one request is made // and the result is shared with all the requests -function fetchWithDeduplication (url: string, params: Req, fetchFunction: () => Promise): Promise { +function fetchWithDeduplication(url: string, params: Req, fetchFunction: () => Promise): Promise { const requestKey = generateRequestKey(url, params); if (pendingRequests.has(requestKey)) { @@ -37,37 +38,38 @@ function fetchWithDeduplication (url: string, params: Req, fetchFuncti return fetchPromise; } -export function fetchPublishView (namespace: string, publishName: string) { +export function fetchPublishView(namespace: string, publishName: string) { const fetchFunction = () => getPublishViewAPI(namespace, publishName); return fetchWithDeduplication(`fetchPublishView_${namespace}`, { publishName }, fetchFunction); } -export function fetchPageCollab (workspaceId: string, viewId: string) { - const fetchFunction = () => getPageCollab(workspaceId, viewId); +export function fetchPageCollab(workspaceId: string, viewId: string, options: PageCollabFetchOptions = {}) { + const includeRows = options.includeRows ?? true; + const fetchFunction = () => getPageCollab(workspaceId, viewId, { includeRows }); - return fetchWithDeduplication(`fetchPageCollab_${workspaceId}`, { viewId }, fetchFunction); + return fetchWithDeduplication(`fetchPageCollab_${workspaceId}`, { viewId, includeRows }, fetchFunction); } -export function fetchDatabaseCollab (workspaceId: string, databaseId: string) { +export function fetchDatabaseCollab(workspaceId: string, databaseId: string) { const fetchFunction = () => getCollab(workspaceId, databaseId, Types.Database); return fetchWithDeduplication(`fetchDatabaseCollab_${workspaceId}`, { databaseId }, fetchFunction); } -export function fetchRowDocumentCollab (workspaceId: string, documentId: string, source?: RowDocumentSourcePayload) { +export function fetchRowDocumentCollab(workspaceId: string, documentId: string, source?: RowDocumentSourcePayload) { const fetchFunction = () => getCollab(workspaceId, documentId, Types.Document, source); return fetchWithDeduplication(`fetchRowDocumentCollab_${workspaceId}`, { documentId, source }, fetchFunction); } -export function fetchViewInfo (viewId: string) { +export function fetchViewInfo(viewId: string) { const fetchFunction = () => getPublishInfoWithViewId(viewId); return fetchWithDeduplication(`fetchViewInfo`, { viewId }, fetchFunction); } -export function fetchPublishViewMeta (namespace: string, publishName: string) { +export function fetchPublishViewMeta(namespace: string, publishName: string) { const fetchFunction = () => getPublishViewMetaAPI(namespace, publishName); return fetchWithDeduplication(`fetchPublishViewMeta_${namespace}`, { publishName }, fetchFunction); diff --git a/src/application/services/js-services/http/__tests__/collab-api.test.ts b/src/application/services/js-services/http/__tests__/collab-api.test.ts index f0dc4dc8a..880dd2306 100644 --- a/src/application/services/js-services/http/__tests__/collab-api.test.ts +++ b/src/application/services/js-services/http/__tests__/collab-api.test.ts @@ -1,4 +1,5 @@ import { Blob as NodeBlob } from 'node:buffer'; +import { TextDecoder, TextEncoder } from 'node:util'; import { CompressionStream as NodeCompressionStream, DecompressionStream as NodeDecompressionStream, @@ -9,11 +10,12 @@ import { gunzipSync, gzipSync } from 'node:zlib'; import { collab } from '@/proto/messages'; import { database_blob } from '@/proto/database_blob'; -import { getAxios } from '@/application/services/js-services/http/core'; +import { executeAPIRequest, getAxios } from '@/application/services/js-services/http/core'; import { collabFullSyncBatch, databaseBlobDiff, + getPageCollab, getSlowSyncUploadTimeoutMs, SLOW_SYNC_PROBE_TIMEOUT_MS, } from '../collab-api'; @@ -31,6 +33,45 @@ jest.mock('@/application/services/js-services/http/core', () => ({ const mockGetAxios = getAxios as unknown as jest.Mock; +describe('getPageCollab', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(executeAPIRequest).mockImplementation(async (request) => { + const response = await request(); + + return response.data.data; + }); + }); + + it.each([undefined, { includeRows: true }])('preserves full row responses with options %p', async (options) => { + const get = jest.fn().mockResolvedValue({ + data: { data: { data: { encoded_collab: [1, 2], row_data: { 'row-id': [3, 4] } } } }, + }); + + mockGetAxios.mockReturnValue({ get }); + + const result = await getPageCollab('workspace-id', 'view-id', options); + + expect(get).toHaveBeenCalledWith('/api/workspace/workspace-id/page-view/view-id', undefined); + expect(result).toMatchObject({ data: new Uint8Array([1, 2]), rows: { 'row-id': [3, 4] } }); + }); + + it('opts out of row snapshots without changing the authorized page-view route', async () => { + const get = jest.fn().mockResolvedValue({ + data: { data: { data: { encoded_collab: [1, 2], row_data: {} } } }, + }); + + mockGetAxios.mockReturnValue({ get }); + + const result = await getPageCollab('workspace-id', 'view-id', { includeRows: false }); + + expect(get).toHaveBeenCalledWith('/api/workspace/workspace-id/page-view/view-id', { + params: { include_rows: false }, + }); + expect(result).toMatchObject({ data: new Uint8Array([1, 2]), rows: {} }); + }); +}); + function installStalledGzipTransform(name: 'CompressionStream' | 'DecompressionStream') { let markStarted!: () => void; const started = new Promise((resolve) => { @@ -364,8 +405,51 @@ describe('collabFullSyncBatch', () => { }); describe('databaseBlobDiff', () => { + const originalTextDecoder = globalThis.TextDecoder; + beforeEach(() => { jest.clearAllMocks(); + Object.defineProperty(globalThis, 'TextDecoder', { configurable: true, value: TextDecoder }); + }); + + afterEach(() => { + Object.defineProperty(globalThis, 'TextDecoder', { configurable: true, value: originalTextDecoder }); + }); + + it.each([1079, 1012])( + 'preserves HTTP-200 JSON application error %s instead of decoding it as protobuf', + async (code) => { + const post = jest.fn().mockResolvedValue({ + status: 200, + data: new Uint8Array( + new TextEncoder().encode(JSON.stringify({ code, message: 'Request declined', retry_after_secs: 2 })) + ).buffer, + headers: { 'content-type': 'application/json; charset=utf-8' }, + }); + + mockGetAxios.mockReturnValue({ post }); + await expect(databaseBlobDiff('workspace', 'database', { version: 3 })).rejects.toEqual({ + code, + message: 'Request declined', + httpStatus: 200, + retryAfterSecs: 2, + }); + expect(post).toHaveBeenCalledTimes(1); + } + ); + + it('preserves an HTTP overload and passes cancellation to the transport', async () => { + const controller = new AbortController(); + const post = jest.fn().mockRejectedValue({ + isAxiosError: true, + response: { status: 429, data: new Uint8Array(), headers: {} }, + }); + + mockGetAxios.mockReturnValue({ post }); + await expect( + databaseBlobDiff('workspace', 'database', { version: 3 }, { signal: controller.signal }) + ).rejects.toMatchObject({ code: 429, httpStatus: 429 }); + expect(post.mock.calls[0][2].signal).toBe(controller.signal); }); it('round-trips a paged protobuf request and response', async () => { diff --git a/src/application/services/js-services/http/collab-api.ts b/src/application/services/js-services/http/collab-api.ts index e6f3610fc..b4361c68e 100644 --- a/src/application/services/js-services/http/collab-api.ts +++ b/src/application/services/js-services/http/collab-api.ts @@ -1,3 +1,4 @@ +import axios from 'axios'; import { toBase64 } from 'lib0/buffer'; import { getOrCreateDeviceId } from '@/application/services/js-services/device-id'; @@ -359,7 +360,12 @@ export async function getCollab( }; } -export async function getPageCollab(workspaceId: string, viewId: string) { +export interface PageCollabFetchOptions { + /** Omit row snapshots when the caller loads rows through a separate pipeline. Defaults to true. */ + includeRows?: boolean; +} + +export async function getPageCollab(workspaceId: string, viewId: string, options: PageCollabFetchOptions = {}) { const url = `/api/workspace/${workspaceId}/page-view/${viewId}`; const response = await executeAPIRequest<{ @@ -381,7 +387,7 @@ export async function getPageCollab(workspaceId: string, viewId: string) { last_editor?: User; }; }> - >(url) + >(url, options.includeRows === false ? { params: { include_rows: false } } : undefined) ); const { encoded_collab, row_data, owner, last_editor } = response.data; @@ -414,7 +420,8 @@ export async function duplicateRowDocument( export async function databaseBlobDiff( workspaceId: string, databaseId: string, - request: database_blob.IDatabaseBlobDiffRequest + request: database_blob.IDatabaseBlobDiffRequest, + options?: { signal?: AbortSignal } ) { const axiosInstance = getAxios(); @@ -428,18 +435,60 @@ export async function databaseBlobDiff( const url = `/api/workspace/${workspaceId}/database/${databaseId}/blob/diff`; const payload = database_blob.DatabaseBlobDiffRequest.encode(request).finish(); - const response = await axiosInstance.post(url, payload, { - responseType: 'arraybuffer', - headers: { - 'Content-Type': 'application/octet-stream', - }, - transformRequest: [(data) => data], - validateStatus: (status) => status === 200 || status === 202, - }); + let response; + + try { + response = await axiosInstance.post(url, payload, { + responseType: 'arraybuffer', + headers: { + 'Content-Type': 'application/octet-stream', + }, + signal: options?.signal, + transformRequest: [(data) => data], + validateStatus: (status) => status === 200 || status === 202, + }); + } catch (error) { + if (!axios.isAxiosError(error) || !error.response) throw error; + throw databaseBlobResponseError(error.response); + } + + if (String(response.headers?.['content-type'] ?? '').includes('application/json')) { + throw databaseBlobResponseError(response); + } + + return database_blob.DatabaseBlobDiffResponse.decode(new Uint8Array(response.data)); +} - const bytes = new Uint8Array(response.data); +/** Binary endpoints can still return the ordinary JSON application error envelope. */ +function databaseBlobResponseError(response: { + status: number; + data: unknown; + headers?: Record; +}): APIError { + let body: Partial = {}; - return database_blob.DatabaseBlobDiffResponse.decode(bytes); + try { + const data = response.data; + const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data; + + const parsed = + bytes instanceof Uint8Array + ? JSON.parse(new TextDecoder().decode(bytes)) + : typeof data === 'string' + ? JSON.parse(data) + : data; + + if (parsed && typeof parsed === 'object') body = parsed; + } catch { + // Preserve the transport status even if a proxy supplies a non-JSON body. + } + + return { + code: typeof body.code === 'number' ? body.code : response.status, + message: typeof body.message === 'string' ? body.message : 'Database rows could not be loaded', + httpStatus: response.status, + retryAfterSecs: parseRetryAfterSecs(response.headers) ?? body.retry_after_secs, + }; } export async function getCollabVersions(workspaceId: string, objectId: string, since?: Date) { diff --git a/src/application/view-loader/index.ts b/src/application/view-loader/index.ts index c7abb8722..7ad6a34c9 100644 --- a/src/application/view-loader/index.ts +++ b/src/application/view-loader/index.ts @@ -252,7 +252,10 @@ async function fetchAndApply( if (options.databaseMetadataOnly && options.databaseId) { ({ data } = await fetchDatabaseCollab(workspaceId, options.databaseId)); } else { - const pageCollab = await fetchPageCollab(workspaceId, viewId); + // This loader only applies the page collab. Database rows have their own + // seed/realtime pipeline, so fetching row_data here duplicates that work. + // Keep the page-view endpoint to preserve view-specific authorization. + const pageCollab = await fetchPageCollab(workspaceId, viewId, { includeRows: false }); data = pageCollab.data; rowCount = pageCollab.rows ? Object.keys(pageCollab.rows).length : 0; diff --git a/src/components/database/Database.tsx b/src/components/database/Database.tsx index 2565b39bc..864901456 100644 --- a/src/components/database/Database.tsx +++ b/src/components/database/Database.tsx @@ -1,6 +1,7 @@ import EventEmitter from 'events'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { useTranslation } from 'react-i18next'; import { APP_EVENTS } from '@/application/constants'; import { @@ -10,6 +11,7 @@ import { releaseDatabaseRowDocSeedCache, retainDatabaseRowDocSeedCache, } from '@/application/database-blob'; +import { isDatabaseBlobBackpressure } from '@/application/database-blob/request-retry'; import { hasRowConditionData } from '@/application/database-yjs/condition-value-cache'; import { hasEffectiveFilters } from '@/application/database-yjs/filter'; import { registerDatabaseHistoryRowDoc, registerDatabaseHistoryRowDocs } from '@/application/database-yjs/history'; @@ -241,6 +243,7 @@ export interface Database2Props { } function Database(props: Database2Props) { + const { t } = useTranslation(); const { doc, createRow, @@ -288,6 +291,7 @@ function Database(props: Database2Props) { const rowMapRef = useRef(rowMap); const pendingRowDocsRef = useRef>>(new Map()); const prefetchPromisesRef = useRef>>(new Map()); + const activePrefetchKeyRef = useRef(null); const blobPrefetchPromiseRef = useRef | null>(null); const localCachePrimedRef = useRef(false); const rowSyncRegistrationsRef = useRef>(new Map()); @@ -300,6 +304,7 @@ function Database(props: Database2Props) { // Gate that ensureRow awaits. Resolves after batch preload (or immediately in readOnly). const seedsGateRef = useRef(createDeferredGate()); const [blobPrefetchComplete, setBlobPrefetchComplete] = useState(false); + const [blobPrefetchBlocked, setBlobPrefetchBlocked] = useState(false); const [seedsReady, setSeedsReady] = useState(false); const registerRowDocWithHistory = useCallback( (rowId: RowId, rowDoc: YDoc) => { @@ -881,13 +886,15 @@ function Database(props: Database2Props) { const ensureBlobPrefetch = useCallback(() => { const prefetchGeneration = blobPrefetchGenerationRef.current; const gate = seedsGateRef.current; - const isCurrentPrefetch = () => + const isCurrentLifecycle = () => blobPrefetchGenerationRef.current === prefetchGeneration && seedsGateRef.current === gate; // Skip blob prefetch in read-only mode (publish view) // The publish API doesn't support blob/diff endpoint if (readOnly) { + activePrefetchKeyRef.current = null; gate.resolve(); + setBlobPrefetchBlocked(false); setBlobPrefetchComplete(true); setSeedsReady(true); return null; @@ -902,15 +909,29 @@ function Database(props: Database2Props) { const forceFullSync = activeViewNeedsFullRowData; const prefetchKey = `${databaseId}:${forceFullSync ? 'full' : 'delta'}`; + const isCurrentPrefetch = () => isCurrentLifecycle() && activePrefetchKeyRef.current === prefetchKey; + + activePrefetchKeyRef.current = prefetchKey; const existingPromise = prefetchPromisesRef.current.get(prefetchKey); if (existingPromise) { blobPrefetchPromiseRef.current = existingPromise; - return existingPromise; + // Another view mode may have reset readiness or encountered overload. + // Restore this mode only after its work succeeds; failures remove the + // promise from the map even though their rejection is handled below. + return existingPromise.then(() => { + if (!isCurrentPrefetch() || prefetchPromisesRef.current.get(prefetchKey) !== existingPromise) return; + + setBlobPrefetchBlocked(false); + setBlobPrefetchComplete(true); + setSeedsReady(true); + runBatchPreload(prefetchGeneration); + }); } const priorityRowIds = getPriorityRowIds(); + setBlobPrefetchBlocked(false); if (forceFullSync) { setBlobPrefetchComplete(false); setSeedsReady(false); @@ -934,11 +955,20 @@ function Database(props: Database2Props) { setBlobPrefetchComplete(true); }) - .catch(() => { - if (!isCurrentPrefetch()) return; + .catch((error) => { + if (!isCurrentLifecycle()) return; prefetchPromisesRef.current.delete(prefetchKey); - gate.resolve(); // Unblock ensureRow on failure + if (!isCurrentPrefetch()) return; + + if (isDatabaseBlobBackpressure(error)) { + // Opening thousands of individual row syncs would amplify the same + // overload. Keep the seed gate closed and let the user retry the batch. + setBlobPrefetchBlocked(true); + return; + } + + gate.resolve(); // Unblock ensureRow on non-admission failure setBlobPrefetchComplete(true); setSeedsReady(true); }); @@ -1323,6 +1353,7 @@ function Database(props: Database2Props) { rowMapRef.current = {}; pendingRowDocsRef.current.clear(); prefetchPromisesRef.current.clear(); + activePrefetchKeyRef.current = null; // A remote update can hydrate the database id and append row orders in the // same Yjs transaction. Carry those markers into the real-id lifecycle; // unrelated document/workspace lifecycle changes still start empty. @@ -1338,6 +1369,7 @@ function Database(props: Database2Props) { registerDatabaseHistoryRowDocs(doc, initialRowMap); setRowMap(initialRowMap); setBlobPrefetchComplete(false); + setBlobPrefetchBlocked(false); setSeedsReady(false); return () => { @@ -1617,8 +1649,16 @@ function Database(props: Database2Props) { } return ( -
+
+ {blobPrefetchBlocked && !readOnly && ( +
+ {t('landingPage.serverError.description')} + +
+ )} {rowId ? ( ) : ( diff --git a/src/components/database/__tests__/Database.prefetch-lifecycle.test.tsx b/src/components/database/__tests__/Database.prefetch-lifecycle.test.tsx index 1819695e0..752c991ec 100644 --- a/src/components/database/__tests__/Database.prefetch-lifecycle.test.tsx +++ b/src/components/database/__tests__/Database.prefetch-lifecycle.test.tsx @@ -10,6 +10,8 @@ import { getCachedRowDoc, openRowDoc } from '@/application/services/js-services/ import { DatabaseViewLayout, UIVariant, YDoc, YjsDatabaseKey, YjsEditorKey } from '@/application/types'; import Database, { Database2Props } from '@/components/database/Database'; +jest.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }) })); + const mockSeedLoadPromises: Array> = []; const mockEnsureRowPromises: Array | void> = []; let mockDatabaseContext: DatabaseContextState | undefined; @@ -331,6 +333,155 @@ describe('Database blob prefetch lifecycle', () => { mockedPrefetch.mockImplementation(() => new Promise(() => undefined)); }); + it('keeps row fallback paused after overload and allows an explicit blob retry', async () => { + const doc = createDatabaseDoc('overload-database'); + const rowDoc = createHydratedRowDoc('database-id_rows_row-id'); + const createRow = jest.fn().mockResolvedValue(rowDoc); + const retry = createDeferred>>(); + + mockedPrefetch.mockRejectedValueOnce({ code: 1079, message: 'Busy' }).mockReturnValueOnce(retry.promise); + const { unmount } = render(); + + await waitFor(() => expect(screen.getByRole('alert')).toBeTruthy()); + const ensured = requestEnsureRow(); + + await act(async () => { + await Promise.resolve(); + }); + expect(createRow).not.toHaveBeenCalled(); + expect(mockedOpenRowDoc).not.toHaveBeenCalled(); + expect(mockDatabaseContext?.seedsReady).toBe(false); + expect(mockDatabaseContext?.blobPrefetchComplete).toBe(false); + + fireEvent.click(screen.getByRole('button', { name: 'landingPage.serverError.retry' })); + expect(mockedPrefetch).toHaveBeenCalledTimes(2); + expect(screen.queryByRole('alert')).toBeNull(); + expect(createRow).not.toHaveBeenCalled(); + await act(async () => { + mockedPrefetch.mock.calls[1][2]?.onSeedsReady?.(); + retry.resolve({} as Awaited>); + await retry.promise; + }); + await act(async () => { + await ensured; + }); + expect(createRow).toHaveBeenCalledTimes(1); + expect(mockDatabaseContext?.blobPrefetchComplete).toBe(true); + unmount(); + doc.destroy(); + rowDoc.destroy(); + }); + + it.each(['settled', 'in-flight'])( + 'restores a successful delta after an %s full-prefetch overload and can retry full mode later', + async (phase) => { + const doc = createDatabaseDoc('overload-mode-switch'); + const database = doc.getMap(YjsEditorKey.data_section).get(YjsEditorKey.database); + const view = database?.get(YjsDatabaseKey.views)?.get('view-id'); + const groups = new Y.Array(); + const overload = createDeferred(); + const retry = createDeferred>>(); + + view?.set(YjsDatabaseKey.layout, DatabaseViewLayout.Grid); + view?.set(YjsDatabaseKey.groups, groups); + mockedPrefetch + .mockImplementationOnce(async (_workspaceId, _databaseId, options) => { + options?.onSeedsReady?.(); + return {} as Awaited>; + }) + .mockImplementationOnce(async () => { + await overload.promise; + throw Object.assign(new Error('Busy'), { code: 1079 }); + }) + .mockRejectedValueOnce({ code: 1079, message: 'Busy' }) + .mockReturnValueOnce(retry.promise); + const { unmount } = render(); + + try { + await waitFor(() => expect(mockDatabaseContext?.blobPrefetchComplete).toBe(true)); + expect(mockDatabaseContext?.seedsReady).toBe(true); + expect(mockedPrefetch).toHaveBeenCalledTimes(1); + expect(mockedPrefetch.mock.calls[0][2]?.forceFullSync).toBe(false); + + await act(async () => { + groups.push([new Y.Map()]); + }); + expect(mockedPrefetch).toHaveBeenCalledTimes(2); + expect(mockedPrefetch.mock.calls[1][2]?.forceFullSync).toBe(true); + expect(mockDatabaseContext?.seedsReady).toBe(false); + expect(mockDatabaseContext?.blobPrefetchComplete).toBe(false); + + if (phase === 'settled') { + await act(async () => overload.resolve()); + await waitFor(() => expect(screen.getByRole('alert')).toBeTruthy()); + } + + await act(async () => { + groups.delete(0, groups.length); + }); + await waitFor(() => { + expect(screen.queryByRole('alert')).toBeNull(); + expect(mockDatabaseContext?.seedsReady).toBe(true); + expect(mockDatabaseContext?.blobPrefetchComplete).toBe(true); + }); + expect(mockedPrefetch).toHaveBeenCalledTimes(2); + + await act(async () => overload.resolve()); + expect(screen.queryByRole('alert')).toBeNull(); + expect(mockDatabaseContext?.seedsReady).toBe(true); + expect(mockDatabaseContext?.blobPrefetchComplete).toBe(true); + + await act(async () => { + groups.push([new Y.Map()]); + }); + await waitFor(() => expect(screen.getByRole('alert')).toBeTruthy()); + expect(mockedPrefetch).toHaveBeenCalledTimes(3); + expect(mockedPrefetch.mock.calls[2][2]?.forceFullSync).toBe(true); + + fireEvent.click(screen.getByRole('button', { name: 'landingPage.serverError.retry' })); + expect(mockedPrefetch).toHaveBeenCalledTimes(4); + expect(mockedPrefetch.mock.calls[3][2]?.forceFullSync).toBe(true); + expect(screen.queryByRole('alert')).toBeNull(); + await act(async () => { + mockedPrefetch.mock.calls[3][2]?.onSeedsReady?.(); + retry.resolve({} as Awaited>); + await retry.promise; + }); + expect(mockDatabaseContext?.seedsReady).toBe(true); + expect(mockDatabaseContext?.blobPrefetchComplete).toBe(true); + expect(screen.queryByRole('alert')).toBeNull(); + } finally { + unmount(); + doc.destroy(); + } + } + ); + + it.each(['settled', 'in-flight'])('hides an %s overload when switching to the read-only path', async (phase) => { + const doc = createDatabaseDoc('overload-to-readonly'); + const response = createDeferred(); + + mockedPrefetch.mockImplementationOnce(async () => { + await response.promise; + throw Object.assign(new Error('Busy'), { code: 1079 }); + }); + const { rerender, unmount } = render(); + + if (phase === 'settled') { + await act(async () => response.resolve()); + await waitFor(() => expect(screen.getByRole('alert')).toBeTruthy()); + } + + rerender(); + await act(async () => response.resolve()); + await waitFor(() => expect(screen.queryByRole('alert')).toBeNull()); + expect(mockDatabaseContext?.seedsReady).toBe(true); + expect(mockDatabaseContext?.blobPrefetchComplete).toBe(true); + expect(mockedPrefetch).toHaveBeenCalledTimes(1); + unmount(); + doc.destroy(); + }); + it.each([ ['Board', DatabaseViewLayout.Board], ['List', DatabaseViewLayout.List], @@ -365,11 +516,7 @@ describe('Database blob prefetch lifecycle', () => { ); const scheduleDeferredCleanup = jest.fn(); const { unmount } = render( - + ); try {