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 'fix: provider listener leak, fetch error handling, batch resolve validation' (#235) from fix/fit-finish-round3 into main

scott 954128dc 1d86ed4c

+36 -8
+3 -2
server/index.ts
··· 772 772 app.post('/api/v1/documents/resolve', (req: Request<Record<string, string>, unknown, { ids: string[] }>, res: Response) => { 773 773 const { ids } = req.body; 774 774 if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' }); 775 - const capped = ids.slice(0, 50); 775 + const capped = ids.slice(0, 50).filter(id => typeof id === 'string' && id.length > 0); 776 + if (capped.length === 0) return res.status(400).json({ error: 'ids must be non-empty strings' }); 776 777 const placeholders = capped.map(() => '?').join(','); 777 - const rows = db.prepare(`SELECT id, type, name_encrypted, updated_at FROM documents WHERE id IN (${placeholders}) AND trashed = 0`).all(...capped); 778 + const rows = db.prepare(`SELECT id, type, name_encrypted, updated_at FROM documents WHERE id IN (${placeholders}) AND deleted_at IS NULL`).all(...capped); 778 779 res.json({ data: rows }); 779 780 }); 780 781
+7 -1
src/landing.ts
··· 214 214 headers: { 'Content-Type': 'application/json' }, 215 215 body: JSON.stringify({ type, name_encrypted: nameB64 }), 216 216 }); 217 + if (!res.ok) { showToast('Failed to create document', 4000, true); return; } 217 218 const { id } = await res.json(); 218 219 219 220 storeKey(id, keyStr); ··· 248 249 headers: { 'Content-Type': 'application/json' }, 249 250 body: JSON.stringify({ type: template.type, name_encrypted: nameB64 }), 250 251 }); 252 + if (!res.ok) { showToast('Failed to create document', 4000, true); return; } 251 253 const { id } = await res.json(); 252 254 253 255 storeKey(id, keyStr); ··· 304 306 headers: { 'Content-Type': 'application/json' }, 305 307 body: JSON.stringify({ type: 'doc', name_encrypted: nameB64 }), 306 308 }); 309 + if (!res.ok) { showToast('Failed to create document', 4000, true); return; } 307 310 const { id } = await res.json(); 308 311 309 312 storeKey(id, keyStr); ··· 750 753 e.preventDefault(); 751 754 e.stopPropagation(); 752 755 const id = btn.dataset.id; 753 - await fetch(`/api/documents/${id}/trash`, { method: 'PUT' }); 756 + const trashRes = await fetch(`/api/documents/${id}/trash`, { method: 'PUT' }); 757 + if (!trashRes.ok) { showToast('Failed to trash document', 4000, true); return; } 754 758 // Move from active to trashed client-side for instant UI 755 759 const doc = allDocs.find(d => d.id === id); 756 760 if (doc) { ··· 796 800 headers: { 'Content-Type': 'application/json' }, 797 801 body: JSON.stringify({ type: originalDoc.type, name_encrypted: originalDoc.name_encrypted }), 798 802 }); 803 + if (!res.ok) throw new Error('Create failed'); 799 804 const { id: newId } = await res.json(); 800 805 801 806 // Copy the snapshot (encrypted blob — no need to decrypt/re-encrypt) ··· 1192 1197 headers: { 'Content-Type': 'application/json' }, 1193 1198 body: JSON.stringify({ type: docType, name_encrypted: nameB64 }), 1194 1199 }); 1200 + if (!res.ok) { showToast('Failed to create document', 4000, true); return; } 1195 1201 const { id } = await res.json(); 1196 1202 1197 1203 // Store encryption key
+10 -3
src/lib/provider.ts
··· 98 98 _onDocUpdate: (update: Uint8Array, origin: unknown) => void; 99 99 _onAwarenessUpdate: (payload: AwarenessUpdatePayload) => void; 100 100 _onBeforeUnload: (event: BeforeUnloadEvent) => void; 101 + _onVisibilityChange: () => void; 101 102 102 103 constructor(doc: Y.Doc, roomId: string, cryptoKey: CryptoKey, opts: ProviderOptions = {}) { 103 104 this.doc = doc; ··· 131 132 this.awareness.on('update', this._onAwarenessUpdate); 132 133 133 134 // Save before tab close or switch 135 + this._onVisibilityChange = () => { 136 + if (document.hidden) this._saveSnapshot(); 137 + }; 134 138 if (typeof window !== 'undefined') { 135 139 window.addEventListener('beforeunload', this._onBeforeUnload); 136 140 window.addEventListener('pagehide', this._handlePageHide); 137 - document.addEventListener('visibilitychange', () => { 138 - if (document.hidden) this._saveSnapshot(); 139 - }); 141 + if (typeof document !== 'undefined' && document.addEventListener) { 142 + document.addEventListener('visibilitychange', this._onVisibilityChange); 143 + } 140 144 } 141 145 142 146 this.connect(); ··· 605 609 if (typeof window !== 'undefined') { 606 610 window.removeEventListener('beforeunload', this._onBeforeUnload); 607 611 window.removeEventListener('pagehide', this._handlePageHide); 612 + if (typeof document !== 'undefined' && document.removeEventListener) { 613 + document.removeEventListener('visibilitychange', this._onVisibilityChange); 614 + } 608 615 } 609 616 removeAwarenessStates(this.awareness, [this.doc.clientID], null); 610 617 }
+16 -2
tests/formulas-extended.test.ts
··· 145 145 expect(evalWith('SUBSTITUTE("hello world hello","hello","hi")')).toBe('hi world hi'); 146 146 }); 147 147 148 - it('replaces first occurrence with instance number', () => { 149 - // With 4th arg, replaces only first match (using regex without /g) 148 + it('replaces first occurrence with instance=1', () => { 150 149 const result = evalWith('SUBSTITUTE("aaa","a","b",1)'); 151 150 expect(result).toBe('baa'); 151 + }); 152 + 153 + it('replaces second occurrence with instance=2', () => { 154 + const result = evalWith('SUBSTITUTE("apple apple apple","apple","banana",2)'); 155 + expect(result).toBe('apple banana apple'); 156 + }); 157 + 158 + it('replaces third occurrence with instance=3', () => { 159 + const result = evalWith('SUBSTITUTE("aaa","a","b",3)'); 160 + expect(result).toBe('aab'); 161 + }); 162 + 163 + it('returns unchanged if instance exceeds count', () => { 164 + const result = evalWith('SUBSTITUTE("aaa","a","b",5)'); 165 + expect(result).toBe('aaa'); 152 166 }); 153 167 154 168 it('handles empty replacement', () => {