Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: IndexedDB document cache for offline access (#84)' (#119) from feat/indexeddb-cache into main

scott b7e693fa badffb64

+219 -2
+2 -2
package-lock.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.7.1", 3 + "version": "0.10.0", 4 4 "lockfileVersion": 3, 5 5 "requires": true, 6 6 "packages": { 7 7 "": { 8 8 "name": "tools", 9 - "version": "0.7.1", 9 + "version": "0.10.0", 10 10 "dependencies": { 11 11 "@tiptap/core": "^2.11.0", 12 12 "@tiptap/extension-collaboration": "^2.11.0",
+113
src/doc-cache.ts
··· 1 + /** 2 + * Document Cache — IndexedDB storage for encrypted snapshots. 3 + * 4 + * Caches encrypted document snapshots locally for offline access. 5 + * Snapshots remain encrypted (AES-256-GCM) — the cache stores 6 + * the same binary the server holds. 7 + */ 8 + 9 + const STORE_NAME = 'snapshots'; 10 + const DB_VERSION = 1; 11 + 12 + export interface CachedSnapshot { 13 + docId: string; 14 + type: 'doc' | 'sheet'; 15 + snapshot: ArrayBuffer; 16 + updatedAt: number; 17 + } 18 + 19 + /** 20 + * Open (or create) the IndexedDB cache database. 21 + */ 22 + export function openCacheDb(name = 'tools-doc-cache'): Promise<IDBDatabase> { 23 + return new Promise((resolve, reject) => { 24 + const request = indexedDB.open(name, DB_VERSION); 25 + request.onupgradeneeded = () => { 26 + const db = request.result; 27 + if (!db.objectStoreNames.contains(STORE_NAME)) { 28 + db.createObjectStore(STORE_NAME, { keyPath: 'docId' }); 29 + } 30 + }; 31 + request.onsuccess = () => resolve(request.result); 32 + request.onerror = () => reject(request.error); 33 + }); 34 + } 35 + 36 + /** 37 + * Store an encrypted snapshot in the cache. 38 + */ 39 + export function putSnapshot( 40 + db: IDBDatabase, 41 + docId: string, 42 + snapshot: Uint8Array, 43 + type: 'doc' | 'sheet', 44 + ): Promise<void> { 45 + return new Promise((resolve, reject) => { 46 + const tx = db.transaction(STORE_NAME, 'readwrite'); 47 + const store = tx.objectStore(STORE_NAME); 48 + const entry: CachedSnapshot = { 49 + docId, 50 + type, 51 + snapshot: snapshot.buffer.slice(snapshot.byteOffset, snapshot.byteOffset + snapshot.byteLength), 52 + updatedAt: Date.now(), 53 + }; 54 + const request = store.put(entry); 55 + request.onsuccess = () => resolve(); 56 + request.onerror = () => reject(request.error); 57 + }); 58 + } 59 + 60 + /** 61 + * Retrieve a cached snapshot. 62 + */ 63 + export function getSnapshot(db: IDBDatabase, docId: string): Promise<CachedSnapshot | null> { 64 + return new Promise((resolve, reject) => { 65 + const tx = db.transaction(STORE_NAME, 'readonly'); 66 + const store = tx.objectStore(STORE_NAME); 67 + const request = store.get(docId); 68 + request.onsuccess = () => resolve(request.result || null); 69 + request.onerror = () => reject(request.error); 70 + }); 71 + } 72 + 73 + /** 74 + * Delete a cached snapshot. 75 + */ 76 + export function deleteSnapshot(db: IDBDatabase, docId: string): Promise<void> { 77 + return new Promise((resolve, reject) => { 78 + const tx = db.transaction(STORE_NAME, 'readwrite'); 79 + const store = tx.objectStore(STORE_NAME); 80 + const request = store.delete(docId); 81 + request.onsuccess = () => resolve(); 82 + request.onerror = () => reject(request.error); 83 + }); 84 + } 85 + 86 + /** 87 + * List all cached document entries (without full snapshot data). 88 + */ 89 + export function listCachedDocs(db: IDBDatabase): Promise<Array<{ docId: string; type: string; updatedAt: number }>> { 90 + return new Promise((resolve, reject) => { 91 + const tx = db.transaction(STORE_NAME, 'readonly'); 92 + const store = tx.objectStore(STORE_NAME); 93 + const request = store.getAll(); 94 + request.onsuccess = () => { 95 + const entries = (request.result || []) as CachedSnapshot[]; 96 + resolve(entries.map(e => ({ docId: e.docId, type: e.type, updatedAt: e.updatedAt }))); 97 + }; 98 + request.onerror = () => reject(request.error); 99 + }); 100 + } 101 + 102 + /** 103 + * Clear all cached snapshots. 104 + */ 105 + export function clearCache(db: IDBDatabase): Promise<void> { 106 + return new Promise((resolve, reject) => { 107 + const tx = db.transaction(STORE_NAME, 'readwrite'); 108 + const store = tx.objectStore(STORE_NAME); 109 + const request = store.clear(); 110 + request.onsuccess = () => resolve(); 111 + request.onerror = () => reject(request.error); 112 + }); 113 + }
+104
tests/doc-cache.test.ts
··· 1 + import { describe, it, expect, beforeEach } from 'vitest'; 2 + import { 3 + openCacheDb, 4 + putSnapshot, 5 + getSnapshot, 6 + deleteSnapshot, 7 + listCachedDocs, 8 + clearCache, 9 + type CachedSnapshot, 10 + } from '../src/doc-cache.js'; 11 + 12 + // Use fake-indexeddb (vitest runs in Node) 13 + import 'fake-indexeddb/auto'; 14 + 15 + describe('Document Cache (IndexedDB)', () => { 16 + let db: IDBDatabase; 17 + 18 + beforeEach(async () => { 19 + // Each test gets a unique database name to avoid state leaking 20 + db = await openCacheDb('test-cache-' + Math.random().toString(36).slice(2)); 21 + }); 22 + 23 + describe('putSnapshot / getSnapshot', () => { 24 + it('stores and retrieves a snapshot', async () => { 25 + const data = new Uint8Array([1, 2, 3, 4, 5]); 26 + await putSnapshot(db, 'doc1', data, 'doc'); 27 + 28 + const result = await getSnapshot(db, 'doc1'); 29 + expect(result).not.toBeNull(); 30 + expect(result!.docId).toBe('doc1'); 31 + expect(result!.type).toBe('doc'); 32 + expect(new Uint8Array(result!.snapshot)).toEqual(data); 33 + }); 34 + 35 + it('returns null for non-existent doc', async () => { 36 + const result = await getSnapshot(db, 'nonexistent'); 37 + expect(result).toBeNull(); 38 + }); 39 + 40 + it('overwrites existing snapshot', async () => { 41 + const data1 = new Uint8Array([1, 2, 3]); 42 + const data2 = new Uint8Array([4, 5, 6]); 43 + await putSnapshot(db, 'doc1', data1, 'doc'); 44 + await putSnapshot(db, 'doc1', data2, 'doc'); 45 + 46 + const result = await getSnapshot(db, 'doc1'); 47 + expect(new Uint8Array(result!.snapshot)).toEqual(data2); 48 + }); 49 + 50 + it('stores updatedAt timestamp', async () => { 51 + const before = Date.now(); 52 + await putSnapshot(db, 'doc1', new Uint8Array([1]), 'doc'); 53 + const after = Date.now(); 54 + 55 + const result = await getSnapshot(db, 'doc1'); 56 + expect(result!.updatedAt).toBeGreaterThanOrEqual(before); 57 + expect(result!.updatedAt).toBeLessThanOrEqual(after); 58 + }); 59 + }); 60 + 61 + describe('deleteSnapshot', () => { 62 + it('removes a cached snapshot', async () => { 63 + await putSnapshot(db, 'doc1', new Uint8Array([1]), 'doc'); 64 + await deleteSnapshot(db, 'doc1'); 65 + 66 + const result = await getSnapshot(db, 'doc1'); 67 + expect(result).toBeNull(); 68 + }); 69 + 70 + it('no-ops for non-existent doc', async () => { 71 + // Should not throw 72 + await deleteSnapshot(db, 'nonexistent'); 73 + }); 74 + }); 75 + 76 + describe('listCachedDocs', () => { 77 + it('returns all cached doc IDs with metadata', async () => { 78 + await putSnapshot(db, 'doc1', new Uint8Array([1]), 'doc'); 79 + await putSnapshot(db, 'sheet1', new Uint8Array([2]), 'sheet'); 80 + 81 + const list = await listCachedDocs(db); 82 + expect(list).toHaveLength(2); 83 + 84 + const ids = list.map(e => e.docId).sort(); 85 + expect(ids).toEqual(['doc1', 'sheet1']); 86 + }); 87 + 88 + it('returns empty array when cache is empty', async () => { 89 + const list = await listCachedDocs(db); 90 + expect(list).toEqual([]); 91 + }); 92 + }); 93 + 94 + describe('clearCache', () => { 95 + it('removes all entries', async () => { 96 + await putSnapshot(db, 'doc1', new Uint8Array([1]), 'doc'); 97 + await putSnapshot(db, 'doc2', new Uint8Array([2]), 'doc'); 98 + await clearCache(db); 99 + 100 + const list = await listCachedDocs(db); 101 + expect(list).toEqual([]); 102 + }); 103 + }); 104 + });