Skip to content

Commit 89e51eb

Browse files
committed
Add offline navigation IndexedDB cache primitives
1 parent 0627f51 commit 89e51eb

2 files changed

Lines changed: 583 additions & 0 deletions

File tree

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
import {
2+
createOfflineNavigationCache,
3+
deleteOfflineNavigationCacheEntry,
4+
normalizeOfflineNavigationCacheUrl,
5+
readOfflineNavigationCacheEntry,
6+
writeOfflineNavigationCacheEntry,
7+
type OfflineNavigationCacheEntry,
8+
type OfflineNavigationCacheStorage,
9+
} from './offline-navigation-cache'
10+
11+
type CacheKey = [buildId: string, url: string]
12+
13+
class MemoryOfflineNavigationCacheStorage
14+
implements OfflineNavigationCacheStorage
15+
{
16+
entries = new Map<string, OfflineNavigationCacheEntry>()
17+
18+
async get(key: CacheKey): Promise<OfflineNavigationCacheEntry | undefined> {
19+
return this.entries.get(this.getKey(key))
20+
}
21+
22+
async put(entry: OfflineNavigationCacheEntry): Promise<void> {
23+
this.entries.set(this.getKey([entry.buildId, entry.url]), entry)
24+
}
25+
26+
async delete(key: CacheKey): Promise<void> {
27+
this.entries.delete(this.getKey(key))
28+
}
29+
30+
async deleteBuild(buildId: string): Promise<void> {
31+
for (const [key, entry] of this.entries) {
32+
if (entry.buildId === buildId) {
33+
this.entries.delete(key)
34+
}
35+
}
36+
}
37+
38+
private getKey(key: CacheKey): string {
39+
return `${key[0]}\0${key[1]}`
40+
}
41+
}
42+
43+
class FailingOfflineNavigationCacheStorage
44+
implements OfflineNavigationCacheStorage
45+
{
46+
async get(): Promise<OfflineNavigationCacheEntry | undefined> {
47+
throw new Error('get failed')
48+
}
49+
50+
async put(): Promise<void> {
51+
throw new Error('put failed')
52+
}
53+
54+
async delete(): Promise<void> {
55+
throw new Error('delete failed')
56+
}
57+
58+
async deleteBuild(): Promise<void> {
59+
throw new Error('delete build failed')
60+
}
61+
}
62+
63+
describe('offline navigation cache', () => {
64+
it('normalizes exact URL keys without fragments', () => {
65+
expect(
66+
normalizeOfflineNavigationCacheUrl(
67+
'https://example.com/dashboard?tab=activity#settings'
68+
)
69+
).toBe('https://example.com/dashboard?tab=activity')
70+
})
71+
72+
it('writes and reads exact URL entries for the current build', async () => {
73+
const storage = new MemoryOfflineNavigationCacheStorage()
74+
const cache = createOfflineNavigationCache(storage)
75+
76+
await expect(
77+
cache.write({
78+
buildId: 'build-a',
79+
url: 'https://example.com/dashboard?tab=activity#settings',
80+
now: 100,
81+
staleAt: 200,
82+
expiresAt: 300,
83+
payload: { tree: 'payload' },
84+
})
85+
).resolves.toBe(true)
86+
87+
await expect(
88+
cache.read('https://example.com/dashboard?tab=activity', {
89+
buildId: 'build-a',
90+
now: 150,
91+
})
92+
).resolves.toEqual({
93+
version: 1,
94+
kind: 'exact-url',
95+
buildId: 'build-a',
96+
url: 'https://example.com/dashboard?tab=activity',
97+
createdAt: 100,
98+
staleAt: 200,
99+
expiresAt: 300,
100+
payload: { tree: 'payload' },
101+
})
102+
})
103+
104+
it('deletes exact URL entries', async () => {
105+
const storage = new MemoryOfflineNavigationCacheStorage()
106+
const cache = createOfflineNavigationCache(storage)
107+
const url = 'https://example.com/dashboard'
108+
109+
await cache.write({
110+
buildId: 'build-a',
111+
url,
112+
now: 100,
113+
staleAt: 200,
114+
expiresAt: 300,
115+
payload: 'payload',
116+
})
117+
await expect(cache.delete(url, { buildId: 'build-a' })).resolves.toBe(true)
118+
await expect(cache.read(url, { buildId: 'build-a' })).resolves.toBe(null)
119+
})
120+
121+
it('ignores and deletes entries whose stored build id does not match', async () => {
122+
const storage = new MemoryOfflineNavigationCacheStorage()
123+
const cache = createOfflineNavigationCache(storage)
124+
const url = 'https://example.com/dashboard'
125+
126+
await storage.put({
127+
version: 1,
128+
kind: 'exact-url',
129+
buildId: 'build-b',
130+
url,
131+
createdAt: 100,
132+
staleAt: 200,
133+
expiresAt: 300,
134+
payload: 'payload',
135+
})
136+
storage.entries.set(
137+
`build-a\0${url}`,
138+
storage.entries.get(`build-b\0${url}`)!
139+
)
140+
141+
await expect(
142+
cache.read(url, {
143+
buildId: 'build-a',
144+
now: 150,
145+
})
146+
).resolves.toBe(null)
147+
expect(storage.entries.has(`build-a\0${url}`)).toBe(false)
148+
})
149+
150+
it('expires entries past their hard expiry time', async () => {
151+
const storage = new MemoryOfflineNavigationCacheStorage()
152+
const cache = createOfflineNavigationCache(storage)
153+
const url = 'https://example.com/dashboard'
154+
155+
await cache.write({
156+
buildId: 'build-a',
157+
url,
158+
now: 100,
159+
staleAt: 150,
160+
expiresAt: 200,
161+
payload: 'payload',
162+
})
163+
164+
await expect(
165+
cache.read(url, {
166+
buildId: 'build-a',
167+
now: 250,
168+
})
169+
).resolves.toBe(null)
170+
expect(storage.entries.size).toBe(0)
171+
})
172+
173+
it('can delete all entries for a build without touching other builds', async () => {
174+
const storage = new MemoryOfflineNavigationCacheStorage()
175+
const cache = createOfflineNavigationCache(storage)
176+
177+
await cache.write({
178+
buildId: 'build-a',
179+
url: 'https://example.com/a',
180+
staleAt: 200,
181+
expiresAt: 300,
182+
payload: 'a',
183+
})
184+
await cache.write({
185+
buildId: 'build-b',
186+
url: 'https://example.com/b',
187+
staleAt: 200,
188+
expiresAt: 300,
189+
payload: 'b',
190+
})
191+
192+
await expect(cache.deleteBuild('build-a')).resolves.toBe(true)
193+
await expect(
194+
cache.read('https://example.com/a', { buildId: 'build-a', now: 150 })
195+
).resolves.toBe(null)
196+
await expect(
197+
cache.read('https://example.com/b', { buildId: 'build-b', now: 150 })
198+
).resolves.toMatchObject({ payload: 'b' })
199+
})
200+
201+
it('treats missing build ids as no-ops', async () => {
202+
const storage = new MemoryOfflineNavigationCacheStorage()
203+
const cache = createOfflineNavigationCache(storage)
204+
205+
await expect(
206+
cache.write({
207+
url: 'https://example.com/dashboard',
208+
staleAt: 200,
209+
expiresAt: 300,
210+
payload: 'payload',
211+
})
212+
).resolves.toBe(false)
213+
await expect(cache.read('https://example.com/dashboard')).resolves.toBe(
214+
null
215+
)
216+
await expect(cache.delete('https://example.com/dashboard')).resolves.toBe(
217+
false
218+
)
219+
await expect(cache.deleteBuild()).resolves.toBe(false)
220+
})
221+
222+
it('treats storage failures as non-fatal misses', async () => {
223+
const cache = createOfflineNavigationCache(
224+
new FailingOfflineNavigationCacheStorage()
225+
)
226+
227+
await expect(
228+
cache.write({
229+
buildId: 'build-a',
230+
url: 'https://example.com/dashboard',
231+
staleAt: 200,
232+
expiresAt: 300,
233+
payload: 'payload',
234+
})
235+
).resolves.toBe(false)
236+
await expect(
237+
cache.read('https://example.com/dashboard', { buildId: 'build-a' })
238+
).resolves.toBe(null)
239+
await expect(
240+
cache.delete('https://example.com/dashboard', { buildId: 'build-a' })
241+
).resolves.toBe(false)
242+
await expect(cache.deleteBuild('build-a')).resolves.toBe(false)
243+
})
244+
245+
it('is a no-op when IndexedDB is unavailable', async () => {
246+
const originalIndexedDB = Object.getOwnPropertyDescriptor(
247+
globalThis,
248+
'indexedDB'
249+
)
250+
Object.defineProperty(globalThis, 'indexedDB', {
251+
configurable: true,
252+
value: undefined,
253+
})
254+
255+
try {
256+
await expect(
257+
writeOfflineNavigationCacheEntry({
258+
buildId: 'build-a',
259+
url: 'https://example.com/dashboard',
260+
staleAt: 200,
261+
expiresAt: 300,
262+
payload: 'payload',
263+
})
264+
).resolves.toBe(false)
265+
await expect(
266+
readOfflineNavigationCacheEntry('https://example.com/dashboard', {
267+
buildId: 'build-a',
268+
})
269+
).resolves.toBe(null)
270+
await expect(
271+
deleteOfflineNavigationCacheEntry('https://example.com/dashboard', {
272+
buildId: 'build-a',
273+
})
274+
).resolves.toBe(false)
275+
} finally {
276+
if (originalIndexedDB) {
277+
Object.defineProperty(globalThis, 'indexedDB', originalIndexedDB)
278+
} else {
279+
delete (globalThis as typeof globalThis & { indexedDB?: IDBFactory })
280+
.indexedDB
281+
}
282+
}
283+
})
284+
})

0 commit comments

Comments
 (0)