experiments in a post-browser web
10
fork

Configure Feed

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

feat(reactivity): item CRUD events, debouncing, history/feeds subscriptions

IPC layer (backend/electron/ipc.ts):
- Emit item:created, item:updated, item:deleted on item CRUD operations

Extensions:
- groups/home.js: Debounced refresh (150ms) for all tag + item events
- tags/home.js: Debounced refresh with optimistic local state updates
- cmd/commands/history.js: Subscribe to item:created for new URLs
- feeds/background.js: Subscribe to item:created/deleted for feed items

Docs:
- docs/reactivity-research.md: Full research doc on tag-centric events

Tests:
- 3 new tests for item:created, item:updated, item:deleted events

+461 -42
+35
backend/electron/ipc.ts
··· 495 495 ipcMain.handle('datastore-add-item', async (ev, data) => { 496 496 try { 497 497 const result = addItem(data.type, data.options); 498 + 499 + // Emit item:created event 500 + publish('system', PubSubScopes.GLOBAL, 'item:created', { 501 + itemId: result.id, 502 + itemType: data.type, 503 + content: data.options?.content 504 + }); 505 + if (DEBUG) console.log('[ipc] item:created', result.id, data.type); 506 + 498 507 return { success: true, data: result }; 499 508 } catch (error) { 500 509 const message = error instanceof Error ? error.message : String(error); ··· 515 524 ipcMain.handle('datastore-update-item', async (ev, data) => { 516 525 try { 517 526 const result = updateItem(data.id, data.options); 527 + 528 + // Emit item:updated event if update was successful 529 + if (result) { 530 + const db = getDb(); 531 + const item = db.prepare('SELECT type FROM items WHERE id = ?').get(data.id) as { type: string } | undefined; 532 + publish('system', PubSubScopes.GLOBAL, 'item:updated', { 533 + itemId: data.id, 534 + itemType: item?.type 535 + }); 536 + if (DEBUG) console.log('[ipc] item:updated', data.id, item?.type); 537 + } 538 + 518 539 return { success: true, data: result }; 519 540 } catch (error) { 520 541 const message = error instanceof Error ? error.message : String(error); ··· 524 545 525 546 ipcMain.handle('datastore-delete-item', async (ev, data) => { 526 547 try { 548 + // Fetch item type before deletion (need it for event payload) 549 + const db = getDb(); 550 + const item = db.prepare('SELECT type FROM items WHERE id = ?').get(data.id) as { type: string } | undefined; 551 + 527 552 const result = deleteItem(data.id); 553 + 554 + // Emit item:deleted event if deletion was successful 555 + if (result) { 556 + publish('system', PubSubScopes.GLOBAL, 'item:deleted', { 557 + itemId: data.id, 558 + itemType: item?.type 559 + }); 560 + if (DEBUG) console.log('[ipc] item:deleted', data.id, item?.type); 561 + } 562 + 528 563 return { success: true, data: result }; 529 564 } catch (error) { 530 565 const message = error instanceof Error ? error.message : String(error);
+159
docs/reactivity-research.md
··· 1 + # Reactivity Research: Tag-Centric & Item Events 2 + 3 + Status: **Implemented (Phase 1 & 2)** | Priority: Active 4 + 5 + ## Problem 6 + 7 + Extensions load data once at init and never refresh. Example: tag a URL while groups is open, groups shows stale data until closed and reopened. 8 + 9 + ## Root Cause 10 + 11 + Three disconnected layers: 12 + 1. **Signals** (UI) - implemented but extensions don't use them for datastore state 13 + 2. **PubSub** - works, but no datastore change events were published 14 + 3. **Extensions** - load data once at init, never refresh 15 + 16 + ## Solution: Datastore Event Emission 17 + 18 + Thin event layer on top of existing IPC handlers. When tag/item operations succeed, publish events via existing PubSub system. 19 + 20 + ### Tag Events (Phase 1 - Implemented) 21 + 22 + Emitted from `backend/electron/ipc.ts` IPC handlers: 23 + 24 + ```javascript 25 + 'tag:created' → { tagId, tagName } 26 + 'tag:item-added' → { tagId, tagName, itemId, itemType } 27 + 'tag:item-removed' → { tagId, tagName, itemId } 28 + 'tag:address-added' → { tagId, tagName, addressId } 29 + 'tag:address-removed' → { tagId, tagName, addressId } 30 + ``` 31 + 32 + ### Item Events (Phase 2 - Implemented) 33 + 34 + ```javascript 35 + 'item:created' → { itemId, itemType, content } 36 + 'item:updated' → { itemId, itemType } 37 + 'item:deleted' → { itemId, itemType } 38 + ``` 39 + 40 + ### Why IPC Handler Level 41 + 42 + - Centralizes event emission with IPC routing 43 + - Matches existing patterns (modes:changed, context:changed) 44 + - Single source of truth for all changes 45 + - No changes needed to datastore.ts layer 46 + - PubSub GLOBAL scope broadcasts to all extension windows 47 + 48 + ## Extension Subscriptions 49 + 50 + ### groups/home.js 51 + 52 + Subscribes to tag and item events, debounced: 53 + 54 + ```javascript 55 + const debouncedRefresh = debounce(async () => { 56 + await loadTags(); 57 + if (state.view === VIEW_GROUPS) renderGroups(); 58 + else if (state.view === VIEW_ADDRESSES) renderAddresses(); 59 + }, 150); 60 + 61 + api.subscribe('tag:item-added', debouncedRefresh, api.scopes.GLOBAL); 62 + api.subscribe('tag:item-removed', debouncedRefresh, api.scopes.GLOBAL); 63 + api.subscribe('tag:created', debouncedRefresh, api.scopes.GLOBAL); 64 + api.subscribe('item:created', debouncedRefresh, api.scopes.GLOBAL); 65 + api.subscribe('item:deleted', debouncedRefresh, api.scopes.GLOBAL); 66 + ``` 67 + 68 + ### tags/home.js 69 + 70 + Same pattern with targeted state updates where possible: 71 + 72 + ```javascript 73 + api.subscribe('tag:item-added', (msg) => { 74 + // Update specific item's tags in state 75 + state.itemTags.get(msg.itemId).push({ id: msg.tagId, name: msg.tagName }); 76 + render(); 77 + }, api.scopes.GLOBAL); 78 + 79 + api.subscribe('item:deleted', (msg) => { 80 + state.items = state.items.filter(i => i.id !== msg.itemId); 81 + state.itemTags.delete(msg.itemId); 82 + render(); 83 + }, api.scopes.GLOBAL); 84 + ``` 85 + 86 + ## Debouncing 87 + 88 + Simple debounce wrapper prevents rapid re-renders when multiple events fire in quick succession (e.g., batch tagging): 89 + 90 + ```javascript 91 + function debounce(fn, ms) { 92 + let timer; 93 + return (...args) => { 94 + clearTimeout(timer); 95 + timer = setTimeout(() => fn(...args), ms); 96 + }; 97 + } 98 + ``` 99 + 100 + 150ms debounce balances responsiveness with efficiency. 101 + 102 + ## Design Decisions 103 + 104 + | Decision | Rationale | 105 + |----------|-----------| 106 + | IPC handler level (not datastore) | Reuses PubSub, no datastore changes | 107 + | Separate tag vs item events | Different consumers, different update logic | 108 + | GLOBAL scope | Cross-window reactivity | 109 + | Debounce in extensions | Extensions control their own refresh rate | 110 + | Include names in payloads | Extensions don't need extra lookups | 111 + 112 + ## What This Doesn't Cover 113 + 114 + - **editor:changed** remains a separate extension-level event (correct - editor publishes its own) 115 + - No feed-level events yet (feeds are poll-based) 116 + - No signal-based reactive queries (Phase 3, future) 117 + 118 + ## Approaches Considered & Rejected 119 + 120 + ### Feeds as Tags 121 + Modeling tags as feeds with item_events. Rejected: overcomplicates tags with feed language/APIs. Tags are tags. 122 + 123 + ### editor:changed for Everything 124 + Using the generic editor event. Rejected: too coarse, semantically wrong, causes full reloads. 125 + 126 + ### Datastore-Level Emission 127 + Publishing from datastore.ts functions. Rejected: would require importing PubSub into datastore layer, crossing concerns. 128 + 129 + ## Test Coverage 130 + 131 + Integration tests in `tests/desktop/smoke.spec.ts`: 132 + 133 + **Tag Events:** 134 + - tag:created emitted when new tag created 135 + - tag:item-added emitted when item tagged 136 + - tag:item-removed emitted when item untagged 137 + - tag:item-added NOT emitted for duplicate tag 138 + 139 + **Item Events:** 140 + - item:created emitted when item added 141 + - item:updated emitted when item updated 142 + - item:deleted emitted when item deleted 143 + 144 + ## Files 145 + 146 + | File | Purpose | 147 + |------|---------| 148 + | `backend/electron/ipc.ts` | Event emission (IPC handlers) | 149 + | `backend/electron/pubsub.ts` | PubSub engine (unchanged) | 150 + | `extensions/groups/home.js` | Tag + item event subscriptions | 151 + | `extensions/tags/home.js` | Tag + item event subscriptions | 152 + | `tests/desktop/smoke.spec.ts` | Integration tests | 153 + 154 + ## Future Work 155 + 156 + - **Phase 3**: Reactive queries using Signals 157 + - **Address events**: `address:created`, `address:updated`, `address:deleted` 158 + - **Batch events**: Optional `tag:batch-complete` for large operations 159 + - **Feed change events**: When feeds infrastructure evolves
+37 -15
extensions/cmd/commands/history.js
··· 5 5 import windows from 'peek://app/windows.js'; 6 6 import api from 'peek://app/api.js'; 7 7 8 + const debug = window.app?.debug; 9 + 10 + // Store addCommand callback for reactive updates 11 + let _addCommand = null; 12 + 8 13 /** 9 14 * Get URL items sorted by most recent 10 15 * Optionally filter by search term ··· 104 109 * Initialize history entries as commands 105 110 * Each history entry becomes a searchable command 106 111 */ 107 - export const initializeSources = async (addCommand) => { 108 - const history = await getHistory('', 50); 109 - console.log('Adding history entries as commands:', history.length); 112 + /** 113 + * Register a single item as history commands (URL + title) 114 + */ 115 + const registerHistoryItem = (addCommand, item) => { 116 + const url = item.content; 117 + const title = getItemTitle(item); 110 118 111 - history.forEach(item => { 112 - const url = item.content; 113 - const title = getItemTitle(item); 119 + addCommand({ 120 + name: url, 121 + async execute(ctx) { 122 + await openFromHistory(url); 123 + } 124 + }); 114 125 115 - // Add URL as command 126 + if (title && title !== url) { 116 127 addCommand({ 117 - name: url, 128 + name: title, 118 129 async execute(ctx) { 119 130 await openFromHistory(url); 120 131 } 121 132 }); 133 + } 134 + }; 122 135 123 - // Also add title as a command if different from URL 124 - if (title && title !== url) { 125 - addCommand({ 126 - name: title, 127 - async execute(ctx) { 128 - await openFromHistory(url); 136 + export const initializeSources = async (addCommand) => { 137 + _addCommand = addCommand; 138 + 139 + const history = await getHistory('', 50); 140 + console.log('Adding history entries as commands:', history.length); 141 + history.forEach(item => registerHistoryItem(addCommand, item)); 142 + 143 + // Subscribe to new URL items so history commands stay fresh 144 + api.subscribe('item:created', (msg) => { 145 + if (msg.itemType === 'url' && _addCommand) { 146 + debug && console.log('[history] New URL item, registering command:', msg.content); 147 + _addCommand({ 148 + name: msg.content, 149 + async execute() { 150 + await openFromHistory(msg.content); 129 151 } 130 152 }); 131 153 } 132 - }); 154 + }, api.scopes.GLOBAL); 133 155 }; 134 156 135 157 export default {
+17
extensions/feeds/background.js
··· 369 369 // Initial poll after short delay 370 370 setTimeout(pollAllFeeds, 5000); 371 371 372 + // React to feed item changes from other sources 373 + api.subscribe('item:created', async (msg) => { 374 + if (msg.itemType === 'feed') { 375 + console.log('[feeds] New feed added externally, polling immediately'); 376 + const item = await api.datastore.getItem(msg.itemId); 377 + if (item.success && item.data) { 378 + await pollFeed(msg.itemId, item.data.content); 379 + } 380 + } 381 + }, api.scopes.GLOBAL); 382 + 383 + api.subscribe('item:deleted', (msg) => { 384 + if (msg.itemType === 'feed') { 385 + console.log('[feeds] Feed removed externally:', msg.itemId); 386 + } 387 + }, api.scopes.GLOBAL); 388 + 372 389 console.log('[feeds] Extension loaded'); 373 390 }, 374 391
+36 -16
extensions/groups/home.js
··· 14 14 const debug = api.debug; 15 15 16 16 /** 17 + * Simple debounce helper - collapses rapid calls into one 18 + */ 19 + const debounce = (fn, ms) => { 20 + let timer; 21 + return (...args) => { 22 + clearTimeout(timer); 23 + timer = setTimeout(() => fn(...args), ms); 24 + }; 25 + }; 26 + 27 + /** 17 28 * Check if a URL is a navigable web URL (http/https only) 18 29 * Excludes peek:// and other internal URLs 19 30 */ ··· 214 225 // Keyboard navigation 215 226 document.addEventListener('keydown', handleKeydown); 216 227 228 + // Debounced refresh for reactive updates (collapses rapid events like batch imports) 229 + const debouncedRefresh = debounce(async () => { 230 + debug && console.log('[groups] debounced refresh triggered'); 231 + await loadTags(); 232 + if (state.view === VIEW_GROUPS) renderGroups(); 233 + else if (state.view === VIEW_ADDRESSES) renderAddresses(); 234 + }, 150); 235 + 217 236 // Subscribe to tag events for reactive updates 218 - api.subscribe('tag:item-added', async (msg) => { 237 + api.subscribe('tag:item-added', (msg) => { 219 238 debug && console.log('[groups] tag:item-added event received:', msg); 220 - // Reload tag counts (an item was tagged) 221 - await loadTags(); 222 - if (state.view === VIEW_GROUPS) renderGroups(); 223 - else if (state.view === VIEW_ADDRESSES && state.currentTag?.name === msg.tagName) { 224 - renderAddresses(); 225 - } 239 + debouncedRefresh(); 226 240 }, api.scopes.GLOBAL); 227 241 228 - api.subscribe('tag:item-removed', async (msg) => { 242 + api.subscribe('tag:item-removed', (msg) => { 229 243 debug && console.log('[groups] tag:item-removed event received:', msg); 230 - await loadTags(); 231 - if (state.view === VIEW_GROUPS) renderGroups(); 232 - else if (state.view === VIEW_ADDRESSES && state.currentTag?.name === msg.tagName) { 233 - renderAddresses(); 234 - } 244 + debouncedRefresh(); 235 245 }, api.scopes.GLOBAL); 236 246 237 - api.subscribe('tag:created', async (msg) => { 247 + api.subscribe('tag:created', (msg) => { 238 248 debug && console.log('[groups] tag:created event received:', msg); 239 - await loadTags(); 240 - if (state.view === VIEW_GROUPS) renderGroups(); 249 + debouncedRefresh(); 250 + }, api.scopes.GLOBAL); 251 + 252 + // Subscribe to item events for reactive updates 253 + api.subscribe('item:created', (msg) => { 254 + debug && console.log('[groups] item:created event received:', msg); 255 + debouncedRefresh(); 256 + }, api.scopes.GLOBAL); 257 + 258 + api.subscribe('item:deleted', (msg) => { 259 + debug && console.log('[groups] item:deleted event received:', msg); 260 + debouncedRefresh(); 241 261 }, api.scopes.GLOBAL); 242 262 243 263 // Show groups view
+41 -11
extensions/tags/home.js
··· 11 11 const api = window.app; 12 12 const debug = api?.debug; 13 13 14 + /** 15 + * Simple debounce helper - collapses rapid calls into one 16 + */ 17 + const debounce = (fn, ms) => { 18 + let timer; 19 + return (...args) => { 20 + clearTimeout(timer); 21 + timer = setTimeout(() => fn(...args), ms); 22 + }; 23 + }; 24 + 14 25 // State 15 26 let state = { 16 27 activeFilter: 'all', // 'all' | 'page' | 'text' | 'tagset' | 'image' ··· 117 128 * Set up all event listeners 118 129 */ 119 130 const setupEventListeners = () => { 131 + // Debounced full refresh for reactive updates (collapses rapid events like batch imports) 132 + const debouncedRefresh = debounce(async () => { 133 + debug && console.log('[tags] debounced refresh triggered'); 134 + await loadData(); 135 + render(); 136 + }, 150); 137 + 120 138 // Subscribe to tag events for reactive updates 121 - api.subscribe('tag:item-added', async (msg) => { 139 + api.subscribe('tag:item-added', (msg) => { 122 140 debug && console.log('[tags] tag:item-added event received:', msg); 123 - // Update the specific item's tags in state 141 + // Optimistic local update before debounced full refresh 124 142 const currentTags = state.itemTags.get(msg.itemId) || []; 125 143 if (!currentTags.find(t => t.id === msg.tagId)) { 126 144 currentTags.push({ id: msg.tagId, name: msg.tagName }); 127 145 state.itemTags.set(msg.itemId, currentTags); 128 146 } 129 - // Refresh tag list if new tag 130 - if (!state.tags.find(t => t.id === msg.tagId)) { 131 - await loadTags(); // Just reload tags, not all data 132 - } 133 - render(); 147 + debouncedRefresh(); 134 148 }, api.scopes.GLOBAL); 135 149 136 - api.subscribe('tag:item-removed', async (msg) => { 150 + api.subscribe('tag:item-removed', (msg) => { 137 151 debug && console.log('[tags] tag:item-removed event received:', msg); 152 + // Optimistic local update 138 153 const currentTags = state.itemTags.get(msg.itemId) || []; 139 154 state.itemTags.set(msg.itemId, currentTags.filter(t => t.id !== msg.tagId)); 140 - render(); 155 + debouncedRefresh(); 141 156 }, api.scopes.GLOBAL); 142 157 143 - api.subscribe('tag:created', async (msg) => { 158 + api.subscribe('tag:created', (msg) => { 144 159 debug && console.log('[tags] tag:created event received:', msg); 160 + // Optimistic local update 145 161 if (!state.tags.find(t => t.id === msg.tagId)) { 146 162 state.tags.push({ id: msg.tagId, name: msg.tagName }); 147 163 } 148 - render(); 164 + debouncedRefresh(); 165 + }, api.scopes.GLOBAL); 166 + 167 + // Subscribe to item events for reactive updates 168 + api.subscribe('item:created', (msg) => { 169 + debug && console.log('[tags] item:created event received:', msg); 170 + debouncedRefresh(); 171 + }, api.scopes.GLOBAL); 172 + 173 + api.subscribe('item:deleted', (msg) => { 174 + debug && console.log('[tags] item:deleted event received:', msg); 175 + // Optimistic local update - remove deleted item from state 176 + state.items = state.items.filter(i => i.id !== msg.itemId); 177 + state.itemTags.delete(msg.itemId); 178 + debouncedRefresh(); 149 179 }, api.scopes.GLOBAL); 150 180 151 181 // Search input
+136
tests/desktop/smoke.spec.ts
··· 1126 1126 }); 1127 1127 1128 1128 // ============================================================================ 1129 + // Item Events Tests (uses shared app) 1130 + // ============================================================================ 1131 + 1132 + test.describe('Item Events @desktop', () => { 1133 + let bgWindow: Page; 1134 + 1135 + test.beforeAll(async () => { 1136 + bgWindow = sharedBgWindow; 1137 + }); 1138 + 1139 + test('item:created is emitted when item is added', async () => { 1140 + const timestamp = Date.now(); 1141 + const testUrl = `https://item-created-event-${timestamp}.example.com`; 1142 + 1143 + const result = await bgWindow.evaluate(async (url: string) => { 1144 + const api = (window as any).app; 1145 + 1146 + return new Promise((resolve) => { 1147 + const timeout = setTimeout(() => { 1148 + resolve({ received: false, error: 'timeout' }); 1149 + }, 5000); 1150 + 1151 + api.subscribe('item:created', (msg: any) => { 1152 + if (msg.content === url) { 1153 + clearTimeout(timeout); 1154 + resolve({ 1155 + received: true, 1156 + itemId: msg.itemId, 1157 + itemType: msg.itemType, 1158 + content: msg.content 1159 + }); 1160 + } 1161 + }, api.scopes.GLOBAL); 1162 + 1163 + // Create item to trigger the event 1164 + api.datastore.addItem('url', { 1165 + content: url, 1166 + metadata: JSON.stringify({ title: 'Item Created Event Test' }) 1167 + }); 1168 + }); 1169 + }, testUrl); 1170 + 1171 + expect((result as any).received).toBe(true); 1172 + expect((result as any).itemId).toBeTruthy(); 1173 + expect((result as any).itemType).toBe('url'); 1174 + expect((result as any).content).toBe(testUrl); 1175 + }); 1176 + 1177 + test('item:updated is emitted when item is updated', async () => { 1178 + const timestamp = Date.now(); 1179 + 1180 + const result = await bgWindow.evaluate(async (ts: number) => { 1181 + const api = (window as any).app; 1182 + 1183 + // First create an item 1184 + const itemResult = await api.datastore.addItem('url', { 1185 + content: `https://item-updated-event-${ts}.example.com`, 1186 + metadata: JSON.stringify({ title: 'Item Updated Event Test' }) 1187 + }); 1188 + if (!itemResult.success) { 1189 + return { received: false, error: 'failed to create item' }; 1190 + } 1191 + const itemId = itemResult.data.id; 1192 + 1193 + return new Promise((resolve) => { 1194 + const timeout = setTimeout(() => { 1195 + resolve({ received: false, error: 'timeout' }); 1196 + }, 5000); 1197 + 1198 + api.subscribe('item:updated', (msg: any) => { 1199 + if (msg.itemId === itemId) { 1200 + clearTimeout(timeout); 1201 + resolve({ 1202 + received: true, 1203 + itemId: msg.itemId, 1204 + itemType: msg.itemType 1205 + }); 1206 + } 1207 + }, api.scopes.GLOBAL); 1208 + 1209 + // Update item to trigger the event 1210 + api.datastore.updateItem(itemId, { 1211 + content: `https://item-updated-event-${ts}-modified.example.com` 1212 + }); 1213 + }); 1214 + }, timestamp); 1215 + 1216 + expect((result as any).received).toBe(true); 1217 + expect((result as any).itemId).toBeTruthy(); 1218 + expect((result as any).itemType).toBe('url'); 1219 + }); 1220 + 1221 + test('item:deleted is emitted when item is deleted', async () => { 1222 + const timestamp = Date.now(); 1223 + 1224 + const result = await bgWindow.evaluate(async (ts: number) => { 1225 + const api = (window as any).app; 1226 + 1227 + // First create an item 1228 + const itemResult = await api.datastore.addItem('url', { 1229 + content: `https://item-deleted-event-${ts}.example.com`, 1230 + metadata: JSON.stringify({ title: 'Item Deleted Event Test' }) 1231 + }); 1232 + if (!itemResult.success) { 1233 + return { received: false, error: 'failed to create item' }; 1234 + } 1235 + const itemId = itemResult.data.id; 1236 + 1237 + return new Promise((resolve) => { 1238 + const timeout = setTimeout(() => { 1239 + resolve({ received: false, error: 'timeout' }); 1240 + }, 5000); 1241 + 1242 + api.subscribe('item:deleted', (msg: any) => { 1243 + if (msg.itemId === itemId) { 1244 + clearTimeout(timeout); 1245 + resolve({ 1246 + received: true, 1247 + itemId: msg.itemId, 1248 + itemType: msg.itemType 1249 + }); 1250 + } 1251 + }, api.scopes.GLOBAL); 1252 + 1253 + // Delete item to trigger the event 1254 + api.datastore.deleteItem(itemId); 1255 + }); 1256 + }, timestamp); 1257 + 1258 + expect((result as any).received).toBe(true); 1259 + expect((result as any).itemId).toBeTruthy(); 1260 + expect((result as any).itemType).toBe('url'); 1261 + }); 1262 + }); 1263 + 1264 + // ============================================================================ 1129 1265 // Groups View Tests (uses shared app) 1130 1266 // ============================================================================ 1131 1267