experiments in a post-browser web
at main 312 lines 8.4 kB view raw
1/** 2 * Data Engine 3 * 4 * All business logic for items, tags, and frecency. 5 * Runtime-agnostic — operates through a StorageAdapter. 6 * No SQL, no IndexedDB, no platform APIs. 7 */ 8 9import { calculateFrecency } from './frecency.js'; 10 11/** 12 * Generate a UUID v4 identifier. 13 * Uses crypto.randomUUID() where available, with fallback. 14 */ 15function generateId() { 16 if (typeof crypto !== 'undefined' && crypto.randomUUID) { 17 return crypto.randomUUID(); 18 } 19 return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { 20 const r = (Math.random() * 16) | 0; 21 const v = c === 'x' ? r : (r & 0x3) | 0x8; 22 return v.toString(16); 23 }); 24} 25 26export class DataEngine { 27 /** @param {import('./adapters/interface.js').StorageAdapter} adapter */ 28 constructor(adapter) { 29 this.adapter = adapter; 30 } 31 32 // ==================== Items ==================== 33 34 /** 35 * Add a new item. 36 * @param {string} type - 'url' | 'text' | 'tagset' | 'image' 37 * @param {Object} [options] 38 * @param {string|null} [options.content] 39 * @param {string|null} [options.metadata] - JSON string 40 * @param {string} [options.syncId] 41 * @param {string} [options.syncSource] 42 * @param {number} [options.createdAt] - Override creation timestamp (for imports) 43 * @returns {Promise<{id: string}>} 44 */ 45 async addItem(type, options = {}) { 46 const id = generateId(); 47 const now = Date.now(); 48 const createdAt = options.createdAt || now; 49 50 let metadata = null; 51 if (options.metadata !== undefined && options.metadata !== null) { 52 metadata = 53 typeof options.metadata === 'string' 54 ? options.metadata 55 : JSON.stringify(options.metadata); 56 } 57 58 await this.adapter.insertItem({ 59 id, 60 type, 61 content: options.content ?? null, 62 metadata, 63 syncId: options.syncId || '', 64 syncSource: options.syncSource || '', 65 syncedAt: 0, 66 createdAt, 67 updatedAt: now, 68 deletedAt: 0, 69 }); 70 71 return { id }; 72 } 73 74 /** 75 * Get a single item by ID (excludes soft-deleted). 76 * @param {string} id 77 * @returns {Promise<import('./adapters/interface.js').Item|null>} 78 */ 79 async getItem(id) { 80 return this.adapter.getItem(id); 81 } 82 83 /** 84 * Update an existing item's content and/or metadata. 85 * @param {string} id 86 * @param {Object} fields 87 * @param {string} [fields.content] 88 * @param {string} [fields.metadata] - JSON string 89 */ 90 async updateItem(id, fields = {}) { 91 const updates = { updatedAt: Date.now() }; 92 93 if (fields.content !== undefined) updates.content = fields.content; 94 if (fields.metadata !== undefined) { 95 updates.metadata = 96 typeof fields.metadata === 'string' 97 ? fields.metadata 98 : JSON.stringify(fields.metadata); 99 } 100 101 await this.adapter.updateItem(id, updates); 102 } 103 104 /** 105 * Soft-delete an item (sets deletedAt). 106 * @param {string} id 107 */ 108 async deleteItem(id) { 109 await this.adapter.deleteItem(id); 110 } 111 112 /** 113 * Physically remove an item from storage. 114 * @param {string} id 115 */ 116 async hardDeleteItem(id) { 117 await this.adapter.hardDeleteItem(id); 118 } 119 120 /** 121 * Query items with optional filters. 122 * @param {import('./adapters/interface.js').ItemFilter} [filter] 123 * @returns {Promise<import('./adapters/interface.js').Item[]>} 124 */ 125 async queryItems(filter = {}) { 126 return this.adapter.getItems(filter); 127 } 128 129 // ==================== Tags ==================== 130 131 /** 132 * Get or create a tag by name. Increments frequency on existing tags. 133 * @param {string} name 134 * @returns {Promise<{tag: import('./adapters/interface.js').Tag, created: boolean}>} 135 */ 136 async getOrCreateTag(name) { 137 const trimmed = name.trim(); 138 const existing = await this.adapter.getTagByName(trimmed); 139 const timestamp = Date.now(); 140 141 if (existing) { 142 const newFrequency = existing.frequency + 1; 143 const frecencyScore = calculateFrecency(newFrequency, timestamp); 144 await this.adapter.updateTag(existing.id, { 145 frequency: newFrequency, 146 lastUsed: timestamp, 147 frecencyScore, 148 updatedAt: timestamp, 149 }); 150 return { 151 tag: { 152 ...existing, 153 frequency: newFrequency, 154 lastUsed: timestamp, 155 frecencyScore, 156 updatedAt: timestamp, 157 }, 158 created: false, 159 }; 160 } 161 162 const tag = { 163 id: generateId(), 164 name: trimmed, 165 frequency: 1, 166 lastUsed: timestamp, 167 frecencyScore: calculateFrecency(1, timestamp), 168 createdAt: timestamp, 169 updatedAt: timestamp, 170 }; 171 await this.adapter.insertTag(tag); 172 return { tag, created: true }; 173 } 174 175 /** 176 * Associate a tag with an item. 177 */ 178 async tagItem(itemId, tagId) { 179 await this.adapter.tagItem(itemId, tagId); 180 } 181 182 /** 183 * Remove a tag association from an item. 184 */ 185 async untagItem(itemId, tagId) { 186 await this.adapter.untagItem(itemId, tagId); 187 } 188 189 /** 190 * Get all tags for an item. 191 * @param {string} itemId 192 * @returns {Promise<import('./adapters/interface.js').Tag[]>} 193 */ 194 async getItemTags(itemId) { 195 return this.adapter.getItemTags(itemId); 196 } 197 198 /** 199 * Get all tags sorted by frecency score descending. 200 * @returns {Promise<import('./adapters/interface.js').Tag[]>} 201 */ 202 async getTagsByFrecency() { 203 const tags = await this.adapter.getAllTags(); 204 return tags.sort((a, b) => b.frecencyScore - a.frecencyScore); 205 } 206 207 // ==================== Save with Sync Dedup ==================== 208 209 /** 210 * Save an item with sync-based deduplication. 211 * 212 * Sync path (syncId provided): match by syncId only, update if found. 213 * Non-sync path: always create a new item (no content matching). 214 * 215 * @param {string} type 216 * @param {string|null} content 217 * @param {string[]} [tags] 218 * @param {Object|null} [metadata] 219 * @param {string|null} [syncId] 220 * @returns {Promise<{id: string, created: boolean}>} 221 */ 222 async saveItem(type, content, tags = [], metadata = null, syncId = null) { 223 const timestamp = Date.now(); 224 const metadataStr = metadata ? JSON.stringify(metadata) : null; 225 let itemId = null; 226 let created = false; 227 228 if (syncId) { 229 // Sync path: match by syncId only. No content-based fallback. 230 const existing = await this.adapter.findItemBySyncId(syncId); 231 if (existing) { 232 itemId = existing.id; 233 await this.adapter.updateItem(itemId, { 234 type, 235 content, 236 metadata: metadataStr !== null ? metadataStr : undefined, 237 updatedAt: timestamp, 238 }); 239 await this.adapter.clearItemTags(itemId); 240 } 241 } 242 243 // Create new item if no match found 244 if (!itemId) { 245 itemId = generateId(); 246 created = true; 247 await this.adapter.insertItem({ 248 id: itemId, 249 type, 250 content: content ?? null, 251 metadata: metadataStr, 252 syncId: syncId || '', 253 syncSource: '', 254 syncedAt: 0, 255 createdAt: timestamp, 256 updatedAt: timestamp, 257 deletedAt: 0, 258 }); 259 } 260 261 // Tag the item 262 for (const tagName of tags) { 263 const { tag } = await this.getOrCreateTag(tagName); 264 await this.adapter.tagItem(itemId, tag.id); 265 } 266 267 return { id: itemId, created }; 268 } 269 270 // ==================== Settings ==================== 271 272 /** 273 * Get a setting value. 274 * @param {string} key 275 * @returns {Promise<string|null>} 276 */ 277 async getSetting(key) { 278 return this.adapter.getSetting(key); 279 } 280 281 /** 282 * Set a setting value. 283 * @param {string} key 284 * @param {string} value 285 */ 286 async setSetting(key, value) { 287 await this.adapter.setSetting(key, value); 288 } 289 290 // ==================== Stats ==================== 291 292 /** 293 * Get datastore statistics. 294 */ 295 async getStats() { 296 const allItems = await this.adapter.getItems({ includeDeleted: false }); 297 const allWithDeleted = await this.adapter.getItems({ includeDeleted: true }); 298 const allTags = await this.adapter.getAllTags(); 299 300 return { 301 totalItems: allItems.length, 302 deletedItems: allWithDeleted.length - allItems.length, 303 totalTags: allTags.length, 304 itemsByType: { 305 url: allItems.filter(i => i.type === 'url').length, 306 text: allItems.filter(i => i.type === 'text').length, 307 tagset: allItems.filter(i => i.type === 'tagset').length, 308 image: allItems.filter(i => i.type === 'image').length, 309 }, 310 }; 311 } 312}