experiments in a post-browser web
10
fork

Configure Feed

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

feat(schema): add feeds and series architecture

- Add 'series' and 'feed' types to items table
- Add item_events table for time-series data and feed entries
- Document architecture in notes/feeds-series-architecture.md

Series = scalar values over time (numeric or text)
Feed = entries from external sources (RSS, ATProto, APIs, logs)
Both share item_events table, terminology differs by context

+181 -3
+124
notes/feeds-series-architecture.md
··· 1 + # Feeds & Series Architecture 2 + 3 + Generalized system for time-series data and external feeds. 4 + 5 + ## Core Model 6 + 7 + Two new item types, one shared events table: 8 + 9 + - **series** = scalar values over time (numeric or text) 10 + - **feed** = entries from a source (RSS, ATProto, APIs, internal logs) 11 + 12 + Both store their data in `item_events`. Terminology differs by context: 13 + - series → events 14 + - feed → entries 15 + 16 + ## Schema 17 + 18 + ```sql 19 + -- items table: add 'series' and 'feed' to type check 20 + -- type IN ('url', 'text', 'tagset', 'image', 'series', 'feed') 21 + 22 + -- New table for events/entries 23 + item_events ( 24 + id TEXT PRIMARY KEY, 25 + item_id TEXT NOT NULL, -- FK to series or feed item 26 + content TEXT, -- text value, URL, or message 27 + value REAL, -- numeric value (optional) 28 + occurred_at INTEGER NOT NULL, -- when it happened (Unix ms) 29 + metadata TEXT DEFAULT '{}', -- JSON extras 30 + created_at INTEGER NOT NULL, 31 + FOREIGN KEY (item_id) REFERENCES items(id) 32 + ) 33 + 34 + CREATE INDEX idx_item_events_item_time ON item_events(item_id, occurred_at DESC); 35 + CREATE INDEX idx_item_events_occurred ON item_events(occurred_at DESC); 36 + ``` 37 + 38 + ## Series Examples 39 + 40 + ```javascript 41 + // Stock price 42 + item: { type: 'series', content: 'AAPL', metadata: { unit: 'USD', source_url: '...' } } 43 + event: { value: 156.32, occurred_at: ... } 44 + 45 + // Mood (text value) 46 + item: { type: 'series', content: 'Mood', metadata: { input: 'tag-board' } } 47 + event: { content: 'meh', occurred_at: ... } 48 + 49 + // Pushups (from tag input) 50 + item: { type: 'series', content: 'Pushups', metadata: { tag: 'pushups' } } 51 + event: { value: 20, occurred_at: ... } 52 + 53 + // Ticket availability (trigger = app-layer logic over events) 54 + item: { type: 'series', content: 'Taylor Swift tickets', metadata: { source_url: '...', condition: { exists: '.buy-button' } } } 55 + event: { value: 1, content: 'Available!', occurred_at: ... } 56 + ``` 57 + 58 + ## Feed Examples 59 + 60 + ```javascript 61 + // RSS 62 + item: { type: 'feed', content: 'https://example.com/rss', metadata: { name: 'Example Blog', format: 'rss' } } 63 + entry: { content: 'https://example.com/article', metadata: { title: '...', author: '...' }, occurred_at: pubDate } 64 + 65 + // ATProto (Bluesky) 66 + item: { type: 'feed', content: 'at://did:plc:xxx/app.bsky.feed.generator/whats-hot', metadata: { format: 'atproto' } } 67 + entry: { content: 'at://did:plc:yyy/app.bsky.feed.post/abc', metadata: { text: '...', author: '...' }, occurred_at: ... } 68 + 69 + // Spotify listening history 70 + item: { type: 'feed', content: 'spotify:me:recently-played', metadata: { format: 'api', oauth: '...' } } 71 + entry: { content: 'spotify:track:abc123', metadata: { title: 'Song', artist: 'Artist' }, occurred_at: played_at } 72 + 73 + // Internal action log 74 + item: { type: 'feed', content: 'peek:system:actions', metadata: { format: 'internal' } } 75 + entry: { content: 'opened settings', metadata: { action: 'navigate', target: 'settings' }, occurred_at: ... } 76 + ``` 77 + 78 + ## Key Design Decisions 79 + 80 + 1. **items = definitions** (singleton, the thing you care about) 81 + 2. **item_events = facts** (append-only, many per item) 82 + 3. **Terminology is API/docs level** - schema doesn't distinguish event vs entry 83 + 4. **Triggers, streaks, alerts = app-layer queries** over events, not special types 84 + 5. **Tag input → series event** = future transformer/pipeline concern 85 + 86 + ## Queries 87 + 88 + ```javascript 89 + // Last 7 days of stock prices 90 + db.item_events.query({ 91 + item_id: stockItem.id, 92 + occurred_at: { $gte: now - 7 * DAY } 93 + }) 94 + 95 + // Recent entries across all feeds 96 + db.item_events.query({ 97 + item_id: { $in: feedItemIds }, 98 + occurred_at: { $gte: now - 24 * HOUR }, 99 + order: 'occurred_at DESC' 100 + }) 101 + 102 + // Streak calculation (app layer) 103 + const events = await db.item_events.query({ item_id: pushupsSeries.id, order: 'occurred_at DESC' }) 104 + const streak = calculateConsecutiveDays(events) 105 + ``` 106 + 107 + ## Sync Considerations 108 + 109 + - `item_events` can sync like items (has timestamps, soft-delete possible) 110 + - Or keep local-only if volume is high (regenerate from source) 111 + - Decision per-feed via `metadata.sync: true|false` 112 + 113 + ## Use-Case Summary 114 + 115 + | Use-case | Item type | Item content | Event content | Event value | 116 + |----------|-----------|--------------|---------------|-------------| 117 + | Stock price | series | "AAPL" | - | 156.32 | 118 + | Mood tracking | series | "Mood" | "meh" | - | 119 + | Habit (pushups) | series | "Pushups" | - | 20 | 120 + | Ticket trigger | series | "Taylor Swift tickets" | "Available!" | 1 | 121 + | RSS feed | feed | "https://.../rss" | article URL | - | 122 + | ATProto | feed | "at://..." | post URI | - | 123 + | Spotify history | feed | "spotify:me:recently-played" | track URI | - | 124 + | System log | feed | "peek:system:actions" | log message | - |
+57 -3
schema/v1.json
··· 16 16 "type": { 17 17 "type": "text", 18 18 "not_null": true, 19 - "check": "type IN ('url', 'text', 'tagset', 'image')", 19 + "check": "type IN ('url', 'text', 'tagset', 'image', 'series', 'feed')", 20 20 "sync": true, 21 - "description": "Content type: url, text, tagset, or image" 21 + "description": "Content type: url, text, tagset, image, series, or feed" 22 22 }, 23 23 "content": { 24 24 "type": "text", ··· 256 256 { "name": "idx_item_tags_tagId", "columns": ["tagId"], "sync": true }, 257 257 { "name": "idx_item_tags_unique", "columns": ["itemId", "tagId"], "unique": true, "sync": true } 258 258 ] 259 + }, 260 + 261 + "item_events": { 262 + "description": "Events/entries for series and feeds - append-only time-series data", 263 + "columns": { 264 + "id": { 265 + "type": "text", 266 + "primary_key": true, 267 + "not_null": true, 268 + "sync": true, 269 + "description": "Unique identifier (UUID)" 270 + }, 271 + "itemId": { 272 + "type": "text", 273 + "not_null": true, 274 + "sync": true, 275 + "description": "Reference to parent series or feed item" 276 + }, 277 + "content": { 278 + "type": "text", 279 + "nullable": true, 280 + "sync": true, 281 + "description": "Text value, URL, or message" 282 + }, 283 + "value": { 284 + "type": "real", 285 + "nullable": true, 286 + "sync": true, 287 + "description": "Numeric value (for series observations)" 288 + }, 289 + "occurredAt": { 290 + "type": "integer", 291 + "not_null": true, 292 + "sync": true, 293 + "description": "When the event happened (Unix ms)" 294 + }, 295 + "metadata": { 296 + "type": "text", 297 + "default": "'{}'", 298 + "sync": true, 299 + "description": "JSON metadata object" 300 + }, 301 + "createdAt": { 302 + "type": "integer", 303 + "not_null": true, 304 + "sync": true, 305 + "description": "Creation timestamp (Unix ms)" 306 + } 307 + }, 308 + "indexes": [ 309 + { "name": "idx_item_events_item_time", "columns": ["itemId", "occurredAt"], "order": "DESC", "sync": true }, 310 + { "name": "idx_item_events_occurred", "columns": ["occurredAt"], "order": "DESC", "sync": true } 311 + ] 259 312 } 260 313 }, 261 314 ··· 266 319 "required_sync_columns": { 267 320 "items": ["id", "type", "content", "syncId", "syncSource", "syncedAt", "createdAt", "updatedAt", "deletedAt"], 268 321 "tags": ["id", "name", "frequency", "lastUsed", "frecencyScore", "createdAt", "updatedAt"], 269 - "item_tags": ["itemId", "tagId", "createdAt"] 322 + "item_tags": ["itemId", "tagId", "createdAt"], 323 + "item_events": ["id", "itemId", "content", "value", "occurredAt", "metadata", "createdAt"] 270 324 } 271 325 } 272 326 }