From 4ea17610edb3882037880422e00de1ce23ef0663 Mon Sep 17 00:00:00 2001 From: m1ngsama Date: Mon, 19 Oct 2026 09:47:12 +0800 Subject: [PATCH] fix: preserve offline access and tolerate slow mirrors --- README.md | 6 ++- package-lock.json | 22 +++++------ package.json | 2 +- src/__tests__/client.test.ts | 71 +++++++++++++++++++++++++++++++++++- src/client.ts | 17 ++++----- 5 files changed, 92 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 6b72ebd..406430b 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ const matches = await docs.search('repair', { pathPrefix: 'repair', limit: 10 }) After a transient failure the client serves stale cached data. With none, it throws `DocsFetchError`, which exposes `path` and HTTP `status`. +With a persistent `store`, listing and searching cached documents also work after +restarting offline. The first load still needs a network connection. ## API @@ -64,8 +66,8 @@ refetched. Store errors and corrupt entries are ignored; eviction is up to the s `mirror` is tried before GitHub. It serves `index.json` in the shape of GitHub's recursive tree response, each file at `raw/`, and optionally `bundle.json` as `{ files: [{ path, sha, content }] }`. A search that would fetch many uncached documents loads the -bundle first. Any mirror failure, invalid index, or wait past 5 seconds falls back to GitHub. The -GitHub token is never sent to the mirror. +bundle first. Mirror index and file requests wait up to 15 seconds before falling back to GitHub; +the optional bundle gets 30 seconds. The GitHub token is never sent to the mirror. ## License diff --git a/package-lock.json b/package-lock.json index d4d48ac..3d6ffe6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@nbtca/docs", - "version": "0.3.4", + "version": "0.3.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@nbtca/docs", - "version": "0.3.4", + "version": "0.3.5", "license": "MIT", "devDependencies": { "@eslint/js": "^9.39.5", @@ -1353,9 +1353,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1603,9 +1603,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "license": "MIT", "dependencies": { @@ -2620,9 +2620,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { diff --git a/package.json b/package.json index b7c227c..bfb5861 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@nbtca/docs", - "version": "0.3.4", + "version": "0.3.5", "description": "GitHub-backed document client for NBTCA", "type": "module", "main": "./dist/index.js", diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 10b3fd6..4e2b982 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -830,6 +830,44 @@ describe('persistent store', () => { expect(spy).not.toHaveBeenCalled(); }); + it('lists and searches persisted documents after restarting offline', async () => { + vi.stubGlobal( + 'fetch', + vi + .fn() + .mockResolvedValueOnce({ ok: true, json: async () => tree(guideSha) }) + .mockResolvedValueOnce({ ok: true, text: async () => '# Guide' }), + ); + const store = memoryStore(); + const online = createDocsClient({ store }); + const items = await online.listAll(); + await online.getDocument('repair/guide.md'); + + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed'))); + const offline = createDocsClient({ store }); + const cached = await offline.listAll(); + expect(cached).toEqual(items); + cached.length = 0; + await expect(offline.listAll()).resolves.toEqual(items); + await expect(offline.listSections()).resolves.toEqual([{ path: 'repair', count: 1 }]); + await expect(offline.search('Guide')).resolves.toMatchObject([ + { path: 'repair/guide.md', title: 'Guide' }, + ]); + }); + + it.each([408, 429, 503])('uses the persisted tree after a transient HTTP %s', async (status) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status }))); + const items = [{ name: 'guide.md', path: 'repair/guide.md', type: 'file', sha: guideSha }]; + const store = memoryStore({ tree: JSON.stringify(items) }); + await expect(createDocsClient({ store }).listAll()).resolves.toEqual(items); + }); + + it.each([401, 403, 404])('does not hide HTTP %s behind a persisted tree', async (status) => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response(null, { status }))); + const store = memoryStore({ tree: '[]' }); + await expect(createDocsClient({ store }).listAll()).rejects.toMatchObject({ status }); + }); + it('refetches a changed file and stores it under its own sha', async () => { vi.stubGlobal( 'fetch', @@ -1187,7 +1225,7 @@ describe('mirror', () => { expect(fetchMock).not.toHaveBeenCalled(); }); - it('gives up on a slow mirror after five seconds', async () => { + it('falls back to GitHub when the mirror exceeds fifteen seconds', async () => { vi.useFakeTimers(); try { const fetchMock = vi.fn().mockImplementation((url: string, init: RequestInit) => { @@ -1201,7 +1239,7 @@ describe('mirror', () => { }); vi.stubGlobal('fetch', fetchMock); const request = createDocsClient({ mirror }).listAll(); - await vi.advanceTimersByTimeAsync(5_000); + await vi.advanceTimersByTimeAsync(15_000); await expect(request).resolves.toHaveLength(3); } finally { vi.useRealTimers(); @@ -1221,6 +1259,35 @@ describe('mirror', () => { }); } + it('loads a bundle through a slow mirror without contacting GitHub', async () => { + vi.useFakeTimers(); + try { + const fetchMock = routes({ + mirror: (url) => + new Promise((resolve) => { + setTimeout(() => { + resolve( + url.endsWith('/index.json') + ? { ok: true, json: async () => mirrorTree } + : bundleOf([{ path: 'repair/guide.md', sha: guideSha, content: '# Guide' }]), + ); + }, 8_000); + }), + }); + const client = createDocsClient({ mirror }); + const request = client.prefetch(); + await vi.advanceTimersByTimeAsync(16_000); + await expect(request).resolves.toBe(1); + await expect(client.getFile('repair/guide.md')).resolves.toBe('# Guide'); + expect(fetchMock.mock.calls.map(([url]) => String(url))).toEqual([ + 'https://docs.example.org/docs-api/index.json', + 'https://docs.example.org/docs-api/bundle.json', + ]); + } finally { + vi.useRealTimers(); + } + }); + it('loads every verified file from the bundle in one request', async () => { const fetchMock = mirrorWith( bundleOf([{ path: 'repair/guide.md', sha: guideSha, content: '# Guide' }]), diff --git a/src/client.ts b/src/client.ts index 5d4e3ca..42ec5ec 100644 --- a/src/client.ts +++ b/src/client.ts @@ -44,7 +44,7 @@ const SKIP = new Set([ const TREE_KEY = 'tree'; const BUNDLE_KEY = 'bundle'; -const MIRROR_TIMEOUT_MS = 5_000; +const MIRROR_TIMEOUT_MS = 15_000; const BUNDLE_TIMEOUT_MS = 30_000; const SHA = /^[0-9a-f]{40}$/; const SEARCH_CONCURRENCY = 6; @@ -491,13 +491,11 @@ export function createDocsClient(options: DocsClientOptions = {}): DocsClient { } function recoverFailure( - cache: TtlCache, - key: string, + stale: T | undefined, path: string, error: unknown, copy: (value: T) => T = (value) => value, ): T { - const stale = cache.getStale(key); if (stale !== undefined) return copy(stale); const message = error instanceof Error && error.name === 'AbortError' ? 'Request timed out' : String(error); @@ -536,7 +534,7 @@ export function createDocsClient(options: DocsClientOptions = {}): DocsClient { }); } catch (error) { if (error instanceof DocsFetchError) throw error; - return recoverFailure(dirCache, path, path, error, copyItems); + return recoverFailure(dirCache.getStale(path), path, error, copyItems); } } @@ -552,7 +550,6 @@ export function createDocsClient(options: DocsClientOptions = {}): DocsClient { } async function loadAll(generation: number): Promise { - const key = TREE_KEY; if (mirror !== undefined) { const mirrored = await fromMirror(mirror, 'index.json', async (response) => { const data = parseTreeResponse(await response.json()); @@ -564,13 +561,13 @@ export function createDocsClient(options: DocsClientOptions = {}): DocsClient { try { return await withResponse(url, 20_000, async (response) => { if (!response.ok) { - const stale = treeCache.getStale(key); + const stale = knownTree(); if (stale !== undefined && (await isTransientResponse(response))) return copyItems(stale); throw new DocsFetchError('', response.status, `HTTP ${String(response.status)}`); } const data = parseTreeResponse(await response.json()); if (data.truncated) { - const stale = treeCache.getStale(key); + const stale = knownTree(); if (stale !== undefined) return copyItems(stale); throw new DocsFetchError( '', @@ -582,7 +579,7 @@ export function createDocsClient(options: DocsClientOptions = {}): DocsClient { }); } catch (error) { if (error instanceof DocsFetchError) throw error; - return recoverFailure(treeCache, key, '', error, copyItems); + return recoverFailure(knownTree(), '', error, copyItems); } } @@ -632,7 +629,7 @@ export function createDocsClient(options: DocsClientOptions = {}): DocsClient { }); } catch (error) { if (error instanceof DocsFetchError) throw error; - return recoverFailure(fileCache, path, path, error); + return recoverFailure(fileCache.getStale(path), path, error); } }