experiments in a post-browser web
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}