experiments in a post-browser web
1/**
2 * Comprehensive tests for the Unified Sync Engine.
3 *
4 * Covers all data + sync behavior against the memory adapter.
5 * Run: node --test sync/test.js
6 */
7
8import { describe, it, before, beforeEach } from 'node:test';
9import assert from 'node:assert';
10import { createEngine } from './index.js';
11import { createMemoryAdapter } from './adapters/memory.js';
12import { calculateFrecency } from './frecency.js';
13import { DATASTORE_VERSION, PROTOCOL_VERSION } from './version.js';
14
15// ==================== Helpers ====================
16
17function createTestEngine() {
18 const adapter = createMemoryAdapter();
19 const { data } = createEngine(adapter);
20 return { adapter, data };
21}
22
23function createSyncTestEngine(serverItems = [], pushResponses = []) {
24 const adapter = createMemoryAdapter();
25 let syncConfig = {
26 serverUrl: 'http://test-server.local',
27 apiKey: 'test-api-key',
28 serverProfileId: 'test-profile',
29 lastSyncTime: 0,
30 };
31
32 let pushIndex = 0;
33 const fetchedUrls = [];
34
35 // Mock fetch
36 const mockFetch = async (url, options) => {
37 fetchedUrls.push(url);
38 const method = options?.method || 'GET';
39
40 if (method === 'GET') {
41 // Pull response
42 return {
43 ok: true,
44 status: 200,
45 headers: new Map([
46 ['X-Peek-Datastore-Version', String(DATASTORE_VERSION)],
47 ['X-Peek-Protocol-Version', String(PROTOCOL_VERSION)],
48 ]),
49 text: async () => '',
50 json: async () => ({ items: serverItems }),
51 };
52 }
53
54 if (method === 'POST') {
55 // Push response
56 const body = JSON.parse(options.body);
57 const response = pushResponses[pushIndex] || {
58 id: `server-${body.sync_id}`,
59 created: true,
60 };
61 pushIndex++;
62 return {
63 ok: true,
64 status: 200,
65 headers: new Map([
66 ['X-Peek-Datastore-Version', String(DATASTORE_VERSION)],
67 ['X-Peek-Protocol-Version', String(PROTOCOL_VERSION)],
68 ]),
69 text: async () => '',
70 json: async () => response,
71 };
72 }
73
74 return { ok: false, status: 404, text: async () => 'Not found' };
75 };
76
77 // Mock headers.get
78 mockFetch._patchHeaders = true;
79
80 const { data, sync } = createEngine(adapter, {
81 getConfig: () => syncConfig,
82 setConfig: (updates) => {
83 syncConfig = { ...syncConfig, ...updates };
84 },
85 fetch: mockFetch,
86 });
87
88 return { adapter, data, sync, getConfig: () => syncConfig, fetchedUrls };
89}
90
91// Small delay to ensure different timestamps
92function tick() {
93 return new Promise(resolve => setTimeout(resolve, 2));
94}
95
96// ==================== Version Tests ====================
97
98describe('Version Constants', () => {
99 it('should export version constants', () => {
100 assert.strictEqual(typeof DATASTORE_VERSION, 'number');
101 assert.strictEqual(typeof PROTOCOL_VERSION, 'number');
102 assert.strictEqual(DATASTORE_VERSION, 1);
103 assert.strictEqual(PROTOCOL_VERSION, 1);
104 });
105});
106
107// ==================== Frecency Tests ====================
108
109describe('Frecency', () => {
110 it('should calculate positive score for recent usage', () => {
111 const score = calculateFrecency(1, Date.now());
112 assert.ok(score > 0, 'score should be positive');
113 assert.strictEqual(score, 10); // frequency(1) * 10 * decayFactor(~1.0)
114 });
115
116 it('should calculate higher score for higher frequency', () => {
117 const now = Date.now();
118 const score1 = calculateFrecency(1, now);
119 const score3 = calculateFrecency(3, now);
120 assert.ok(score3 > score1);
121 });
122
123 it('should decay score over time', () => {
124 const now = Date.now();
125 const recent = calculateFrecency(5, now);
126 const weekAgo = calculateFrecency(5, now - 7 * 24 * 60 * 60 * 1000);
127 assert.ok(recent > weekAgo, 'recent should score higher than week-old');
128 });
129});
130
131// ==================== Data Engine: Item CRUD ====================
132
133describe('DataEngine: Items', () => {
134 let adapter, data;
135
136 beforeEach(async () => {
137 ({ adapter, data } = createTestEngine());
138 await adapter.open();
139 });
140
141 it('should add an item and return id', async () => {
142 const { id } = await data.addItem('url', { content: 'https://example.com' });
143 assert.ok(id, 'should return an id');
144 assert.match(id, /^[0-9a-f-]{36}$/, 'id should be a UUID');
145 });
146
147 it('should get an item by id', async () => {
148 const { id } = await data.addItem('url', { content: 'https://example.com' });
149 const item = await data.getItem(id);
150 assert.ok(item);
151 assert.strictEqual(item.type, 'url');
152 assert.strictEqual(item.content, 'https://example.com');
153 assert.strictEqual(item.deletedAt, 0);
154 });
155
156 it('should return null for non-existent item', async () => {
157 const item = await data.getItem('non-existent');
158 assert.strictEqual(item, null);
159 });
160
161 it('should update an item', async () => {
162 const { id } = await data.addItem('text', { content: 'original' });
163 await data.updateItem(id, { content: 'updated' });
164 const item = await data.getItem(id);
165 assert.strictEqual(item.content, 'updated');
166 });
167
168 it('should soft-delete an item', async () => {
169 const { id } = await data.addItem('url', { content: 'https://example.com' });
170 await data.deleteItem(id);
171 const item = await data.getItem(id);
172 assert.strictEqual(item, null, 'soft-deleted item should not be returned');
173 });
174
175 it('should hard-delete an item', async () => {
176 const { id } = await data.addItem('url', { content: 'https://example.com' });
177 await data.hardDeleteItem(id);
178 const item = await data.getItem(id);
179 assert.strictEqual(item, null);
180 });
181
182 it('should query all items', async () => {
183 await data.addItem('url', { content: 'https://example.com' });
184 await data.addItem('text', { content: 'A note' });
185 await data.addItem('tagset', {});
186
187 const items = await data.queryItems();
188 assert.strictEqual(items.length, 3);
189 });
190
191 it('should filter items by type', async () => {
192 await data.addItem('url', { content: 'https://example.com' });
193 await data.addItem('text', { content: 'A note' });
194 await data.addItem('tagset', {});
195
196 const urls = await data.queryItems({ type: 'url' });
197 assert.strictEqual(urls.length, 1);
198 assert.strictEqual(urls[0].type, 'url');
199 });
200
201 it('should exclude soft-deleted items by default', async () => {
202 const { id } = await data.addItem('url', { content: 'https://example.com' });
203 await data.addItem('text', { content: 'A note' });
204 await data.deleteItem(id);
205
206 const items = await data.queryItems();
207 assert.strictEqual(items.length, 1);
208 });
209
210 it('should include deleted items when requested', async () => {
211 const { id } = await data.addItem('url', { content: 'https://example.com' });
212 await data.addItem('text', { content: 'A note' });
213 await data.deleteItem(id);
214
215 const items = await data.queryItems({ includeDeleted: true });
216 assert.strictEqual(items.length, 2);
217 });
218
219 it('should store metadata as JSON string', async () => {
220 const { id } = await data.addItem('url', {
221 content: 'https://example.com',
222 metadata: JSON.stringify({ title: 'Example' }),
223 });
224 const item = await data.getItem(id);
225 assert.strictEqual(item.metadata, '{"title":"Example"}');
226 });
227
228 it('should handle null content for tagsets', async () => {
229 const { id } = await data.addItem('tagset', {});
230 const item = await data.getItem(id);
231 assert.strictEqual(item.content, null);
232 });
233
234 it('should set sync fields on creation', async () => {
235 const { id } = await data.addItem('url', {
236 content: 'https://example.com',
237 syncId: 'server-123',
238 syncSource: 'server',
239 });
240 const item = await data.getItem(id);
241 assert.strictEqual(item.syncId, 'server-123');
242 assert.strictEqual(item.syncSource, 'server');
243 });
244});
245
246// ==================== Data Engine: Tags ====================
247
248describe('DataEngine: Tags', () => {
249 let adapter, data;
250
251 beforeEach(async () => {
252 ({ adapter, data } = createTestEngine());
253 await adapter.open();
254 });
255
256 it('should create a new tag', async () => {
257 const { tag, created } = await data.getOrCreateTag('test');
258 assert.ok(tag.id);
259 assert.strictEqual(tag.name, 'test');
260 assert.strictEqual(tag.frequency, 1);
261 assert.strictEqual(created, true);
262 });
263
264 it('should return existing tag and increment frequency', async () => {
265 const { tag: first } = await data.getOrCreateTag('test');
266 const { tag: second, created } = await data.getOrCreateTag('test');
267 assert.strictEqual(second.id, first.id);
268 assert.strictEqual(second.frequency, 2);
269 assert.strictEqual(created, false);
270 });
271
272 it('should be case-insensitive for tag lookup', async () => {
273 await data.getOrCreateTag('Test');
274 const { tag, created } = await data.getOrCreateTag('test');
275 assert.strictEqual(created, false);
276 assert.strictEqual(tag.name, 'Test'); // preserves original casing
277 });
278
279 it('should trim tag names', async () => {
280 const { tag } = await data.getOrCreateTag(' spaced ');
281 assert.strictEqual(tag.name, 'spaced');
282 });
283
284 it('should tag an item', async () => {
285 const { id } = await data.addItem('url', { content: 'https://example.com' });
286 const { tag } = await data.getOrCreateTag('web');
287 await data.tagItem(id, tag.id);
288
289 const tags = await data.getItemTags(id);
290 assert.strictEqual(tags.length, 1);
291 assert.strictEqual(tags[0].name, 'web');
292 });
293
294 it('should untag an item', async () => {
295 const { id } = await data.addItem('url', { content: 'https://example.com' });
296 const { tag } = await data.getOrCreateTag('web');
297 await data.tagItem(id, tag.id);
298 await data.untagItem(id, tag.id);
299
300 const tags = await data.getItemTags(id);
301 assert.strictEqual(tags.length, 0);
302 });
303
304 it('should get tags sorted by frecency', async () => {
305 // Create tags with different frequencies
306 await data.getOrCreateTag('rare');
307 await data.getOrCreateTag('common');
308 await data.getOrCreateTag('common');
309 await data.getOrCreateTag('common');
310
311 const tags = await data.getTagsByFrecency();
312 assert.strictEqual(tags[0].name, 'common');
313 assert.strictEqual(tags[1].name, 'rare');
314 assert.ok(tags[0].frecencyScore > tags[1].frecencyScore);
315 });
316
317 it('should return empty array when no tags', async () => {
318 const tags = await data.getTagsByFrecency();
319 assert.deepStrictEqual(tags, []);
320 });
321
322 it('should track tag frequency through saveItem', async () => {
323 await data.saveItem('url', 'https://example1.com', ['common']);
324 await data.saveItem('url', 'https://example2.com', ['common']);
325 await data.saveItem('url', 'https://example3.com', ['common']);
326 await data.saveItem('url', 'https://example4.com', ['rare']);
327
328 const tags = await data.getTagsByFrecency();
329 const common = tags.find(t => t.name === 'common');
330 const rare = tags.find(t => t.name === 'rare');
331 assert.strictEqual(common.frequency, 3);
332 assert.strictEqual(rare.frequency, 1);
333 });
334});
335
336// ==================== Data Engine: saveItem ====================
337
338describe('DataEngine: saveItem', () => {
339 let adapter, data;
340
341 beforeEach(async () => {
342 ({ adapter, data } = createTestEngine());
343 await adapter.open();
344 });
345
346 // --- URL saves ---
347
348 it('should save a URL without tags', async () => {
349 const { id } = await data.saveItem('url', 'https://example.com');
350 assert.ok(id);
351 assert.match(id, /^[0-9a-f-]{36}$/);
352 });
353
354 it('should save a URL with tags', async () => {
355 const { id } = await data.saveItem('url', 'https://example.com', [
356 'test',
357 'demo',
358 ]);
359 const tags = await data.getItemTags(id);
360 assert.strictEqual(tags.length, 2);
361 const names = tags.map(t => t.name).sort();
362 assert.deepStrictEqual(names, ['demo', 'test']);
363 });
364
365 it('should create separate items for same URL content (no content dedup)', async () => {
366 const { id: id1 } = await data.saveItem('url', 'https://example.com', [
367 'tag1',
368 ]);
369 const { id: id2 } = await data.saveItem('url', 'https://example.com', [
370 'tag2',
371 ]);
372 assert.notStrictEqual(id1, id2, 'should create separate items without syncId');
373
374 const items = await data.queryItems({ type: 'url' });
375 assert.strictEqual(items.length, 2);
376 });
377
378 it('should save multiple different URLs', async () => {
379 await data.saveItem('url', 'https://example1.com');
380 await data.saveItem('url', 'https://example2.com');
381 await data.saveItem('url', 'https://example3.com');
382 const items = await data.queryItems({ type: 'url' });
383 assert.strictEqual(items.length, 3);
384 });
385
386 // --- Text saves ---
387
388 it('should save text with tags', async () => {
389 const { id } = await data.saveItem('text', 'My note', ['personal', 'todo']);
390 const item = await data.getItem(id);
391 assert.strictEqual(item.content, 'My note');
392 const tags = await data.getItemTags(id);
393 assert.strictEqual(tags.length, 2);
394 });
395
396 it('should create separate items for same text content (no content dedup)', async () => {
397 const { id: id1 } = await data.saveItem('text', 'Same content', ['tag1']);
398 const { id: id2 } = await data.saveItem('text', 'Same content', ['tag2']);
399 assert.notStrictEqual(id1, id2);
400
401 const items = await data.queryItems({ type: 'text' });
402 assert.strictEqual(items.length, 2);
403 });
404
405 // --- Tagset saves ---
406
407 it('should save a tagset', async () => {
408 const { id } = await data.saveItem('tagset', null, ['pushups', '10']);
409 assert.ok(id);
410 assert.match(id, /^[0-9a-f-]{36}$/);
411 });
412
413 it('should create separate items for tagsets with same tags (no content dedup)', async () => {
414 const { id: id1 } = await data.saveItem('tagset', null, ['pushups', '10']);
415 const { id: id2 } = await data.saveItem('tagset', null, ['pushups', '10']);
416 assert.notStrictEqual(id1, id2);
417
418 const items = await data.queryItems({ type: 'tagset' });
419 assert.strictEqual(items.length, 2);
420 });
421
422 it('should not deduplicate tagsets with different tags', async () => {
423 const { id: id1 } = await data.saveItem('tagset', null, ['pushups', '10']);
424 const { id: id2 } = await data.saveItem('tagset', null, ['pushups', '20']);
425 assert.notStrictEqual(id1, id2);
426 });
427
428 it('should retrieve tagset with its tags', async () => {
429 const { id } = await data.saveItem('tagset', null, [
430 'exercise',
431 'pushups',
432 '20',
433 ]);
434 const tags = await data.getItemTags(id);
435 const names = tags.map(t => t.name).sort();
436 assert.deepStrictEqual(names, ['20', 'exercise', 'pushups']);
437 });
438
439 // --- Metadata ---
440
441 it('should save item with metadata', async () => {
442 const { id } = await data.saveItem(
443 'url',
444 'https://example.com',
445 [],
446 { title: 'Example' }
447 );
448 const item = await data.getItem(id);
449 assert.strictEqual(item.metadata, '{"title":"Example"}');
450 });
451
452 // --- created flag ---
453
454 it('should report created=true for new items', async () => {
455 const { created } = await data.saveItem('url', 'https://example.com');
456 assert.strictEqual(created, true);
457 });
458
459 it('should report created=true for same content without syncId', async () => {
460 await data.saveItem('url', 'https://example.com');
461 const { created } = await data.saveItem('url', 'https://example.com');
462 assert.strictEqual(created, true);
463 });
464});
465
466// ==================== Data Engine: syncId Dedup ====================
467
468describe('DataEngine: syncId Deduplication', () => {
469 let adapter, data;
470
471 beforeEach(async () => {
472 ({ adapter, data } = createTestEngine());
473 await adapter.open();
474 });
475
476 it('should deduplicate by sync_id', async () => {
477 const syncId = 'client-item-abc123';
478 const { id: id1 } = await data.saveItem(
479 'url', 'https://example.com', ['tag1'], null, syncId
480 );
481 const { id: id2 } = await data.saveItem(
482 'url', 'https://example.com', ['tag2'], null, syncId
483 );
484
485 assert.strictEqual(id1, id2, 'should return same id for same sync_id');
486
487 const items = await data.queryItems();
488 assert.strictEqual(items.length, 1);
489
490 const tags = await data.getItemTags(id1);
491 assert.strictEqual(tags.length, 1);
492 assert.strictEqual(tags[0].name, 'tag2');
493 });
494
495 it('should deduplicate by sync_id even with different content', async () => {
496 const syncId = 'client-item-xyz789';
497 const { id: id1 } = await data.saveItem(
498 'url', 'https://old-url.com', [], null, syncId
499 );
500 const { id: id2 } = await data.saveItem(
501 'url', 'https://new-url.com', [], null, syncId
502 );
503
504 assert.strictEqual(id1, id2);
505 const items = await data.queryItems();
506 assert.strictEqual(items.length, 1);
507 // Content should be updated
508 assert.strictEqual(items[0].content, 'https://new-url.com');
509 });
510
511 it('should create separate items when no sync_id (no content dedup)', async () => {
512 const { id: id1 } = await data.saveItem('url', 'https://example.com', ['tag1']);
513 const { id: id2 } = await data.saveItem('url', 'https://example.com', ['tag2']);
514 assert.notStrictEqual(id1, id2);
515 });
516
517 it('should create new items for different sync_ids', async () => {
518 const { id: id1 } = await data.saveItem(
519 'url', 'https://first.com', [], null, 'sync-1'
520 );
521 const { id: id2 } = await data.saveItem(
522 'url', 'https://second.com', [], null, 'sync-2'
523 );
524
525 assert.notStrictEqual(id1, id2);
526 const items = await data.queryItems();
527 assert.strictEqual(items.length, 2);
528 });
529
530 it('should not use content dedup in sync path', async () => {
531 const { id: id1 } = await data.saveItem(
532 'url', 'https://example.com', [], null, 'device-a-id'
533 );
534 const { id: id2 } = await data.saveItem(
535 'url', 'https://example.com', [], null, 'device-b-id'
536 );
537
538 assert.notStrictEqual(id1, id2);
539 const items = await data.queryItems();
540 assert.strictEqual(items.length, 2);
541 });
542
543 it('should not match deleted items by sync_id', async () => {
544 const { id: id1 } = await data.saveItem(
545 'url', 'https://example.com', [], null, 'deleted-sync-id'
546 );
547 // Server deleteItem does hard delete in test.js
548 await data.hardDeleteItem(id1);
549
550 const { id: id2 } = await data.saveItem(
551 'url', 'https://example.com', [], null, 'deleted-sync-id'
552 );
553 assert.notStrictEqual(id1, id2);
554 });
555
556 it('should match when device re-pushes with server ID as sync_id', async () => {
557 const { id: id1 } = await data.saveItem(
558 'url', 'https://shared.com', ['v1'], null, 'device-local-id'
559 );
560
561 // Device re-pushes with the server-assigned ID (id1)
562 const { id: id2 } = await data.saveItem(
563 'url', 'https://shared.com/updated', ['v2'], null, id1
564 );
565
566 assert.strictEqual(id1, id2);
567 const items = await data.queryItems();
568 assert.strictEqual(items.length, 1);
569 assert.strictEqual(items[0].content, 'https://shared.com/updated');
570 });
571});
572
573
574// ==================== Data Engine: Settings ====================
575
576describe('DataEngine: Settings', () => {
577 let adapter, data;
578
579 beforeEach(async () => {
580 ({ adapter, data } = createTestEngine());
581 await adapter.open();
582 });
583
584 it('should save and retrieve a setting', async () => {
585 await data.setSetting('test_key', 'test_value');
586 const value = await data.getSetting('test_key');
587 assert.strictEqual(value, 'test_value');
588 });
589
590 it('should return null for non-existent setting', async () => {
591 const value = await data.getSetting('non_existent');
592 assert.strictEqual(value, null);
593 });
594
595 it('should update existing setting', async () => {
596 await data.setSetting('key', 'value1');
597 await data.setSetting('key', 'value2');
598 const value = await data.getSetting('key');
599 assert.strictEqual(value, 'value2');
600 });
601});
602
603// ==================== Data Engine: Stats ====================
604
605describe('DataEngine: Stats', () => {
606 let adapter, data;
607
608 beforeEach(async () => {
609 ({ adapter, data } = createTestEngine());
610 await adapter.open();
611 });
612
613 it('should return correct stats', async () => {
614 await data.saveItem('url', 'https://example.com');
615 await data.saveItem('text', 'A note');
616 await data.saveItem('tagset', null, ['tag1', 'tag2']);
617
618 const stats = await data.getStats();
619 assert.strictEqual(stats.totalItems, 3);
620 assert.strictEqual(stats.deletedItems, 0);
621 assert.strictEqual(stats.totalTags, 2);
622 assert.strictEqual(stats.itemsByType.url, 1);
623 assert.strictEqual(stats.itemsByType.text, 1);
624 assert.strictEqual(stats.itemsByType.tagset, 1);
625 assert.strictEqual(stats.itemsByType.image, 0);
626 });
627
628 it('should count deleted items separately', async () => {
629 const { id } = await data.saveItem('url', 'https://example.com');
630 await data.saveItem('text', 'A note');
631 await data.deleteItem(id);
632
633 const stats = await data.getStats();
634 assert.strictEqual(stats.totalItems, 1);
635 assert.strictEqual(stats.deletedItems, 1);
636 });
637});
638
639// ==================== Sync Engine: Pull ====================
640
641describe('SyncEngine: Pull', () => {
642 it('should pull new items from server', async () => {
643 const serverItems = [
644 {
645 id: 'server-1',
646 type: 'url',
647 content: 'https://from-server.com',
648 tags: ['imported'],
649 metadata: null,
650 createdAt: new Date(1000).toISOString(),
651 updatedAt: new Date(2000).toISOString(),
652 },
653 ];
654 const { adapter, data, sync } = createSyncTestEngine(serverItems);
655 await adapter.open();
656
657 const result = await sync.pullFromServer();
658 assert.strictEqual(result.pulled, 1);
659 assert.strictEqual(result.conflicts, 0);
660
661 const items = await data.queryItems();
662 assert.strictEqual(items.length, 1);
663 assert.strictEqual(items[0].content, 'https://from-server.com');
664 assert.strictEqual(items[0].syncId, 'server-1');
665 assert.strictEqual(items[0].syncSource, 'server');
666
667 const tags = await data.getItemTags(items[0].id);
668 assert.strictEqual(tags.length, 1);
669 assert.strictEqual(tags[0].name, 'imported');
670 });
671
672 it('should update local item when server is newer', async () => {
673 const serverItems = [
674 {
675 id: 'server-1',
676 type: 'url',
677 content: 'https://updated.com',
678 tags: ['new-tag'],
679 metadata: null,
680 createdAt: new Date(1000).toISOString(),
681 updatedAt: new Date(Date.now() + 10000).toISOString(), // future = newer
682 },
683 ];
684 const { adapter, data, sync } = createSyncTestEngine(serverItems);
685 await adapter.open();
686
687 // Insert local item with same syncId
688 await adapter.insertItem({
689 id: 'local-1',
690 type: 'url',
691 content: 'https://old.com',
692 metadata: null,
693 syncId: 'server-1',
694 syncSource: 'server',
695 syncedAt: 1000,
696 createdAt: 1000,
697 updatedAt: 2000,
698 deletedAt: 0,
699 });
700
701 const result = await sync.pullFromServer();
702 assert.strictEqual(result.pulled, 1);
703
704 const item = await data.getItem('local-1');
705 assert.strictEqual(item.content, 'https://updated.com');
706 });
707
708 it('should report conflict when local is newer', async () => {
709 const serverItems = [
710 {
711 id: 'server-1',
712 type: 'url',
713 content: 'https://server-old.com',
714 tags: [],
715 metadata: null,
716 createdAt: new Date(1000).toISOString(),
717 updatedAt: new Date(1000).toISOString(), // very old
718 },
719 ];
720 const { adapter, data, sync } = createSyncTestEngine(serverItems);
721 await adapter.open();
722
723 // Local item is newer
724 await adapter.insertItem({
725 id: 'local-1',
726 type: 'url',
727 content: 'https://local-new.com',
728 metadata: null,
729 syncId: 'server-1',
730 syncSource: 'server',
731 syncedAt: 500,
732 createdAt: 500,
733 updatedAt: Date.now() + 5000, // much newer
734 deletedAt: 0,
735 });
736
737 const result = await sync.pullFromServer();
738 assert.strictEqual(result.conflicts, 1);
739 assert.strictEqual(result.pulled, 0);
740
741 // Local content should be unchanged
742 const item = await data.getItem('local-1');
743 assert.strictEqual(item.content, 'https://local-new.com');
744 });
745
746 it('should return zeros when not configured', async () => {
747 const { adapter, sync } = createSyncTestEngine();
748 await adapter.open();
749
750 // Override config to remove server URL
751 sync.getConfig = () => ({ serverUrl: '', apiKey: '', lastSyncTime: 0 });
752
753 const result = await sync.pullFromServer();
754 assert.strictEqual(result.pulled, 0);
755 assert.strictEqual(result.conflicts, 0);
756 });
757
758 it('should pull multiple items', async () => {
759 const serverItems = [
760 {
761 id: 'server-1', type: 'url', content: 'https://first.com',
762 tags: [], metadata: null,
763 createdAt: new Date(1000).toISOString(),
764 updatedAt: new Date(2000).toISOString(),
765 },
766 {
767 id: 'server-2', type: 'text', content: 'Server note',
768 tags: ['note'], metadata: null,
769 createdAt: new Date(1000).toISOString(),
770 updatedAt: new Date(2000).toISOString(),
771 },
772 ];
773 const { adapter, data, sync } = createSyncTestEngine(serverItems);
774 await adapter.open();
775
776 const result = await sync.pullFromServer();
777 assert.strictEqual(result.pulled, 2);
778
779 const items = await data.queryItems();
780 assert.strictEqual(items.length, 2);
781 });
782
783 it('should include includeDeleted=true in pull URL', async () => {
784 const { adapter, sync, fetchedUrls } = createSyncTestEngine();
785 await adapter.open();
786
787 await sync.pullFromServer();
788 const pullUrl = fetchedUrls.find(u => u.includes('/items'));
789 assert.ok(pullUrl, 'should have fetched items URL');
790 assert.ok(pullUrl.includes('includeDeleted=true'), 'pull URL should include includeDeleted=true');
791 });
792
793 it('should use correct query param separators with profile', async () => {
794 const { adapter, sync, fetchedUrls } = createSyncTestEngine();
795 await adapter.open();
796
797 await sync.pullFromServer();
798 const pullUrl = fetchedUrls.find(u => u.includes('/items'));
799 assert.ok(pullUrl, 'should have fetched items URL');
800 // Should have ?profile=...&includeDeleted=true (not ??profile or ?&profile)
801 assert.ok(!pullUrl.includes('??'), 'should not have double question marks');
802 assert.ok(!pullUrl.includes('?&'), 'should not have ?& sequence');
803 const qCount = (pullUrl.match(/\?/g) || []).length;
804 assert.strictEqual(qCount, 1, 'should have exactly one ? in query string');
805 });
806});
807
808// ==================== Sync Engine: Push ====================
809
810describe('SyncEngine: Push', () => {
811 it('should push unsynced items to server', async () => {
812 const { adapter, data, sync } = createSyncTestEngine();
813 await adapter.open();
814
815 await data.saveItem('url', 'https://local.com', ['local-tag']);
816
817 const result = await sync.pushToServer();
818 assert.strictEqual(result.pushed, 1);
819 assert.strictEqual(result.failed, 0);
820
821 // Item should now have sync info
822 const items = await data.queryItems();
823 assert.strictEqual(items[0].syncSource, 'server');
824 assert.ok(items[0].syncedAt > 0);
825 });
826
827 it('should not push server-sourced items', async () => {
828 const { adapter, data, sync } = createSyncTestEngine();
829 await adapter.open();
830
831 // Insert an item that came from server
832 await adapter.insertItem({
833 id: 'from-server',
834 type: 'url',
835 content: 'https://server.com',
836 metadata: null,
837 syncId: 'server-id',
838 syncSource: 'server',
839 syncedAt: Date.now(),
840 createdAt: 1000,
841 updatedAt: 1000,
842 deletedAt: 0,
843 });
844
845 const result = await sync.pushToServer();
846 assert.strictEqual(result.pushed, 0);
847 });
848
849 it('should return zeros when not configured', async () => {
850 const { adapter, sync } = createSyncTestEngine();
851 await adapter.open();
852 sync.getConfig = () => ({ serverUrl: '', apiKey: '', lastSyncTime: 0 });
853
854 const result = await sync.pushToServer();
855 assert.strictEqual(result.pushed, 0);
856 assert.strictEqual(result.failed, 0);
857 });
858});
859
860// ==================== Sync Engine: syncAll ====================
861
862describe('SyncEngine: syncAll', () => {
863 it('should perform full pull + push cycle', async () => {
864 const serverItems = [
865 {
866 id: 'server-1', type: 'url', content: 'https://from-server.com',
867 tags: [], metadata: null,
868 createdAt: new Date(1000).toISOString(),
869 updatedAt: new Date(2000).toISOString(),
870 },
871 ];
872 const { adapter, data, sync, getConfig } = createSyncTestEngine(serverItems);
873 await adapter.open();
874
875 // Add a local item to push
876 await data.saveItem('text', 'Local note');
877
878 const result = await sync.syncAll();
879 assert.strictEqual(result.pulled, 1);
880 assert.strictEqual(result.pushed, 1);
881 assert.ok(result.lastSyncTime > 0);
882
883 // Config should be updated
884 assert.ok(getConfig().lastSyncTime > 0);
885 });
886
887 it('should save sync server config after sync', async () => {
888 const { adapter, data, sync } = createSyncTestEngine();
889 await adapter.open();
890
891 await sync.syncAll();
892
893 const storedUrl = await data.getSetting('sync_lastSyncServerUrl');
894 assert.strictEqual(JSON.parse(storedUrl), 'http://test-server.local');
895 });
896
897 it('should return zeros when no server configured', async () => {
898 const { adapter, sync } = createSyncTestEngine();
899 await adapter.open();
900 sync.getConfig = () => ({
901 serverUrl: '',
902 apiKey: '',
903 lastSyncTime: 0,
904 });
905
906 const result = await sync.syncAll();
907 assert.strictEqual(result.pulled, 0);
908 assert.strictEqual(result.pushed, 0);
909 assert.strictEqual(result.lastSyncTime, 0);
910 });
911});
912
913// ==================== Sync Engine: Status ====================
914
915describe('SyncEngine: Status', () => {
916 it('should report sync status', async () => {
917 const { adapter, data, sync } = createSyncTestEngine();
918 await adapter.open();
919
920 await data.saveItem('url', 'https://local.com');
921
922 const status = await sync.getSyncStatus();
923 assert.strictEqual(status.configured, true);
924 assert.strictEqual(status.pendingCount, 1);
925 });
926
927 it('should report unconfigured when no server URL', async () => {
928 const { adapter, sync } = createSyncTestEngine();
929 await adapter.open();
930 sync.getConfig = () => ({
931 serverUrl: '',
932 apiKey: '',
933 lastSyncTime: 0,
934 });
935
936 const status = await sync.getSyncStatus();
937 assert.strictEqual(status.configured, false);
938 });
939});
940
941// ==================== Sync Engine: Delete Propagation ====================
942
943describe('SyncEngine: Delete Propagation', () => {
944 it('should pull a deleted item (tombstone) and soft-delete locally', async () => {
945 const serverItems = [
946 {
947 id: 'server-del-1',
948 type: 'url',
949 content: 'https://deleted-on-server.com',
950 tags: ['old'],
951 metadata: null,
952 createdAt: new Date(1000).toISOString(),
953 updatedAt: new Date(Date.now() + 10000).toISOString(),
954 deleted_at: Date.now() + 5000,
955 },
956 ];
957 const { adapter, data, sync } = createSyncTestEngine(serverItems);
958 await adapter.open();
959
960 // Insert local item with matching syncId (not deleted locally)
961 await adapter.insertItem({
962 id: 'local-del-1',
963 type: 'url',
964 content: 'https://deleted-on-server.com',
965 metadata: null,
966 syncId: 'server-del-1',
967 syncSource: 'server',
968 syncedAt: 1000,
969 createdAt: 1000,
970 updatedAt: 2000,
971 deletedAt: 0,
972 });
973
974 const result = await sync.pullFromServer();
975 assert.strictEqual(result.pulled, 1);
976
977 // Item should now be soft-deleted locally
978 const item = await data.getItem('local-del-1');
979 assert.strictEqual(item, null, 'soft-deleted item should not appear in getItem');
980
981 // But should still exist when querying with includeDeleted
982 const allItems = await data.queryItems({ includeDeleted: true });
983 const deletedItem = allItems.find(i => i.id === 'local-del-1');
984 assert.ok(deletedItem, 'deleted item should exist with includeDeleted');
985 assert.ok(deletedItem.deletedAt > 0, 'deletedAt should be set');
986 });
987
988 it('should skip pulling a tombstone when no local item exists', async () => {
989 const serverItems = [
990 {
991 id: 'server-del-orphan',
992 type: 'url',
993 content: 'https://never-existed-locally.com',
994 tags: [],
995 metadata: null,
996 createdAt: new Date(1000).toISOString(),
997 updatedAt: new Date(2000).toISOString(),
998 deleted_at: 3000,
999 },
1000 ];
1001 const { adapter, data, sync } = createSyncTestEngine(serverItems);
1002 await adapter.open();
1003
1004 const result = await sync.pullFromServer();
1005 // Should skip (no local item to delete)
1006 assert.strictEqual(result.pulled, 0);
1007
1008 const items = await data.queryItems({ includeDeleted: true });
1009 assert.strictEqual(items.length, 0);
1010 });
1011
1012 it('should undelete local item when server item is active and newer', async () => {
1013 const serverItems = [
1014 {
1015 id: 'server-undelete-1',
1016 type: 'url',
1017 content: 'https://undeleted.com',
1018 tags: ['restored'],
1019 metadata: null,
1020 createdAt: new Date(1000).toISOString(),
1021 updatedAt: new Date(Date.now() + 10000).toISOString(), // future = newer
1022 },
1023 ];
1024 const { adapter, data, sync } = createSyncTestEngine(serverItems);
1025 await adapter.open();
1026
1027 // Insert locally deleted item with matching syncId
1028 await adapter.insertItem({
1029 id: 'local-undelete-1',
1030 type: 'url',
1031 content: 'https://undeleted.com',
1032 metadata: null,
1033 syncId: 'server-undelete-1',
1034 syncSource: 'server',
1035 syncedAt: 1000,
1036 createdAt: 1000,
1037 updatedAt: 2000,
1038 deletedAt: 3000,
1039 });
1040
1041 const result = await sync.pullFromServer();
1042 assert.strictEqual(result.pulled, 1);
1043
1044 // Item should be undeleted
1045 const item = await data.getItem('local-undelete-1');
1046 assert.ok(item, 'item should be undeleted');
1047 assert.strictEqual(item.deletedAt, 0);
1048 assert.strictEqual(item.content, 'https://undeleted.com');
1049 });
1050
1051 it('should report conflict when local delete is newer than server active item', async () => {
1052 const serverItems = [
1053 {
1054 id: 'server-conflict-1',
1055 type: 'url',
1056 content: 'https://conflict.com',
1057 tags: [],
1058 metadata: null,
1059 createdAt: new Date(1000).toISOString(),
1060 updatedAt: new Date(1000).toISOString(), // very old
1061 },
1062 ];
1063 const { adapter, data, sync } = createSyncTestEngine(serverItems);
1064 await adapter.open();
1065
1066 // Local item deleted recently (much newer)
1067 await adapter.insertItem({
1068 id: 'local-conflict-1',
1069 type: 'url',
1070 content: 'https://conflict.com',
1071 metadata: null,
1072 syncId: 'server-conflict-1',
1073 syncSource: 'server',
1074 syncedAt: 500,
1075 createdAt: 500,
1076 updatedAt: Date.now() + 5000, // much newer
1077 deletedAt: Date.now() + 5000,
1078 });
1079
1080 const result = await sync.pullFromServer();
1081 assert.strictEqual(result.conflicts, 1);
1082 assert.strictEqual(result.pulled, 0);
1083
1084 // Local item should still be deleted (local is newer)
1085 const items = await data.queryItems({ includeDeleted: true });
1086 const item = items.find(i => i.id === 'local-conflict-1');
1087 assert.ok(item.deletedAt > 0, 'local delete should be preserved');
1088 });
1089
1090 it('should push deleted items with syncId as tombstones', async () => {
1091 let pushedBodies = [];
1092 const adapter = createMemoryAdapter();
1093 let syncConfig = {
1094 serverUrl: 'http://test-server.local',
1095 apiKey: 'test-api-key',
1096 serverProfileId: 'test-profile',
1097 lastSyncTime: 0,
1098 };
1099
1100 const mockFetch = async (url, options) => {
1101 const method = options?.method || 'GET';
1102 if (method === 'GET') {
1103 return {
1104 ok: true, status: 200,
1105 headers: new Map([
1106 ['X-Peek-Datastore-Version', String(DATASTORE_VERSION)],
1107 ['X-Peek-Protocol-Version', String(PROTOCOL_VERSION)],
1108 ]),
1109 text: async () => '',
1110 json: async () => ({ items: [] }),
1111 };
1112 }
1113 if (method === 'POST') {
1114 const body = JSON.parse(options.body);
1115 pushedBodies.push(body);
1116 return {
1117 ok: true, status: 200,
1118 headers: new Map([
1119 ['X-Peek-Datastore-Version', String(DATASTORE_VERSION)],
1120 ['X-Peek-Protocol-Version', String(PROTOCOL_VERSION)],
1121 ]),
1122 text: async () => '',
1123 json: async () => ({ id: `server-${body.sync_id}`, created: true }),
1124 };
1125 }
1126 return { ok: false, status: 404, text: async () => 'Not found' };
1127 };
1128 mockFetch._patchHeaders = true;
1129
1130 const { data, sync } = createEngine(adapter, {
1131 getConfig: () => syncConfig,
1132 setConfig: (updates) => { syncConfig = { ...syncConfig, ...updates }; },
1133 fetch: mockFetch,
1134 });
1135
1136 await adapter.open();
1137
1138 // Insert a previously-synced item that is now deleted
1139 await adapter.insertItem({
1140 id: 'deleted-local-1',
1141 type: 'url',
1142 content: 'https://was-deleted.com',
1143 metadata: null,
1144 syncId: 'server-id-123',
1145 syncSource: 'server',
1146 syncedAt: 1000,
1147 createdAt: 1000,
1148 updatedAt: 2000,
1149 deletedAt: 2000,
1150 });
1151
1152 const result = await sync.pushToServer();
1153 assert.strictEqual(result.pushed, 1);
1154 assert.strictEqual(pushedBodies.length, 1);
1155 assert.ok(pushedBodies[0].deleted_at > 0, 'should include deleted_at in push body');
1156 });
1157
1158 it('should not push deleted items without syncId (never synced)', async () => {
1159 const { adapter, data, sync } = createSyncTestEngine();
1160 await adapter.open();
1161
1162 // Insert a deleted item that was never synced (no syncId)
1163 await adapter.insertItem({
1164 id: 'never-synced-del',
1165 type: 'url',
1166 content: 'https://never-synced.com',
1167 metadata: null,
1168 syncId: '',
1169 syncSource: '',
1170 syncedAt: 0,
1171 createdAt: 1000,
1172 updatedAt: 2000,
1173 deletedAt: 2000,
1174 });
1175
1176 const result = await sync.pushToServer();
1177 assert.strictEqual(result.pushed, 0);
1178 });
1179
1180 it('should include deleted tombstones in pending count', async () => {
1181 const { adapter, data, sync } = createSyncTestEngine();
1182 await adapter.open();
1183
1184 // Add a regular unsynced item
1185 await data.saveItem('url', 'https://local.com');
1186
1187 // Add a deleted item with syncId (pending tombstone)
1188 await adapter.insertItem({
1189 id: 'pending-tombstone',
1190 type: 'url',
1191 content: 'https://pending-delete.com',
1192 metadata: null,
1193 syncId: 'server-xyz',
1194 syncSource: 'server',
1195 syncedAt: 1000,
1196 createdAt: 1000,
1197 updatedAt: 2000,
1198 deletedAt: 2000,
1199 });
1200
1201 const status = await sync.getSyncStatus();
1202 assert.strictEqual(status.pendingCount, 2, 'should count both regular and tombstone items');
1203 });
1204});
1205
1206// ==================== Sync Engine: Server Change Detection ====================
1207
1208describe('SyncEngine: Server Change Detection', () => {
1209 it('should reset sync state when server URL changes', async () => {
1210 const { adapter, data, sync } = createSyncTestEngine();
1211 await adapter.open();
1212
1213 // Save server config from previous sync
1214 await data.setSetting('sync_lastSyncServerUrl', JSON.stringify('http://old-server.local'));
1215 await data.setSetting('sync_lastSyncProfileId', JSON.stringify('test-profile'));
1216
1217 // Add a server-sourced item
1218 await adapter.insertItem({
1219 id: 'synced-item',
1220 type: 'url',
1221 content: 'https://synced.com',
1222 metadata: null,
1223 syncId: 'remote-id',
1224 syncSource: 'server',
1225 syncedAt: 1000,
1226 createdAt: 1000,
1227 updatedAt: 1000,
1228 deletedAt: 0,
1229 });
1230
1231 // Server URL changed (current config says test-server.local, stored says old-server.local)
1232 const reset = await sync.resetSyncStateIfServerChanged('http://test-server.local');
1233 assert.strictEqual(reset, true);
1234
1235 // Item sync markers should be cleared
1236 const item = await data.getItem('synced-item');
1237 assert.strictEqual(item.syncSource, '');
1238 assert.strictEqual(item.syncedAt, 0);
1239 assert.strictEqual(item.syncId, '');
1240 });
1241
1242 it('should not reset when server URL is the same', async () => {
1243 const { adapter, data, sync } = createSyncTestEngine();
1244 await adapter.open();
1245
1246 await data.setSetting('sync_lastSyncServerUrl', JSON.stringify('http://test-server.local'));
1247 await data.setSetting('sync_lastSyncProfileId', JSON.stringify('test-profile'));
1248
1249 const reset = await sync.resetSyncStateIfServerChanged('http://test-server.local');
1250 assert.strictEqual(reset, false);
1251 });
1252
1253 it('should not reset on first run with no stored config', async () => {
1254 const { adapter, data, sync } = createSyncTestEngine();
1255 await adapter.open();
1256
1257 // No stored server config, but items exist with syncSource='server'
1258 await adapter.insertItem({
1259 id: 'orphan',
1260 type: 'url',
1261 content: 'https://orphan.com',
1262 metadata: null,
1263 syncId: 'old-server-id',
1264 syncSource: 'server',
1265 syncedAt: 1000,
1266 createdAt: 1000,
1267 updatedAt: 1000,
1268 deletedAt: 0,
1269 });
1270
1271 // First run — no stored config means we haven't tracked the server yet.
1272 // Don't reset items that may have been pulled in a prior pull-only sync.
1273 const reset = await sync.resetSyncStateIfServerChanged('http://test-server.local');
1274 assert.strictEqual(reset, false);
1275
1276 const item = await data.getItem('orphan');
1277 assert.strictEqual(item.syncSource, 'server');
1278 });
1279});
1280
1281// ==================== Memory Adapter: Edge Cases ====================
1282
1283describe('Memory Adapter', () => {
1284 it('should support open/close cycle', async () => {
1285 const adapter = createMemoryAdapter();
1286 await adapter.open();
1287
1288 await adapter.insertItem({
1289 id: 'test', type: 'url', content: 'https://test.com',
1290 metadata: null, syncId: '', syncSource: '', syncedAt: 0,
1291 createdAt: 1000, updatedAt: 1000, deletedAt: 0,
1292 });
1293
1294 assert.ok(await adapter.getItem('test'));
1295
1296 await adapter.close();
1297 await adapter.open();
1298
1299 // Data should be cleared after close+open
1300 assert.strictEqual(await adapter.getItem('test'), null);
1301 });
1302
1303 it('should not duplicate item-tag links', async () => {
1304 const adapter = createMemoryAdapter();
1305 await adapter.open();
1306
1307 await adapter.tagItem('item-1', 'tag-1');
1308 await adapter.tagItem('item-1', 'tag-1'); // duplicate
1309
1310 // Should have inserted a tag to check
1311 await adapter.insertTag({
1312 id: 'tag-1', name: 'test', frequency: 1, lastUsed: 1000,
1313 frecencyScore: 10, createdAt: 1000, updatedAt: 1000,
1314 });
1315
1316 const tags = await adapter.getItemTags('item-1');
1317 assert.strictEqual(tags.length, 1);
1318 });
1319
1320 it('should find item by sync_id field', async () => {
1321 const adapter = createMemoryAdapter();
1322 await adapter.open();
1323
1324 await adapter.insertItem({
1325 id: 'local-id', type: 'url', content: 'https://test.com',
1326 metadata: null, syncId: 'remote-id', syncSource: 'server', syncedAt: 1000,
1327 createdAt: 1000, updatedAt: 1000, deletedAt: 0,
1328 });
1329
1330 // Should find by syncId field
1331 const bySync = await adapter.findItemBySyncId('remote-id');
1332 assert.ok(bySync);
1333 assert.strictEqual(bySync.id, 'local-id');
1334
1335 // Should find by direct ID
1336 const byId = await adapter.findItemBySyncId('local-id');
1337 assert.ok(byId);
1338 assert.strictEqual(byId.id, 'local-id');
1339 });
1340
1341 it('should find deleted items by sync_id (needed for tombstone matching)', async () => {
1342 const adapter = createMemoryAdapter();
1343 await adapter.open();
1344
1345 await adapter.insertItem({
1346 id: 'del', type: 'url', content: 'https://deleted.com',
1347 metadata: null, syncId: 'del-sync', syncSource: '', syncedAt: 0,
1348 createdAt: 1000, updatedAt: 1000, deletedAt: 2000,
1349 });
1350
1351 const result = await adapter.findItemBySyncId('del-sync');
1352 assert.ok(result, 'should find deleted items for tombstone matching');
1353 assert.strictEqual(result.id, 'del');
1354 assert.strictEqual(result.deletedAt, 2000);
1355 });
1356});
1357
1358// ==================== Integration: Full Workflow ====================
1359
1360describe('Integration: Full Workflow', () => {
1361 it('should handle save → tag → query → sync lifecycle', async () => {
1362 const serverItems = [];
1363 const { adapter, data, sync } = createSyncTestEngine(serverItems);
1364 await adapter.open();
1365
1366 // Save items
1367 const { id: url1 } = await data.saveItem('url', 'https://example.com', ['web']);
1368 const { id: url2 } = await data.saveItem('url', 'https://other.com', ['web', 'dev']);
1369 const { id: ts1 } = await data.saveItem('tagset', null, ['pushups', '10']);
1370
1371 // Verify queries
1372 const allItems = await data.queryItems();
1373 assert.strictEqual(allItems.length, 3);
1374
1375 const urls = await data.queryItems({ type: 'url' });
1376 assert.strictEqual(urls.length, 2);
1377
1378 // Verify tags
1379 const tags = await data.getTagsByFrecency();
1380 assert.ok(tags.length >= 2);
1381 // 'web' used twice should be highest
1382 assert.strictEqual(tags[0].name, 'web');
1383
1384 // Stats
1385 const stats = await data.getStats();
1386 assert.strictEqual(stats.totalItems, 3);
1387 assert.strictEqual(stats.itemsByType.url, 2);
1388 assert.strictEqual(stats.itemsByType.tagset, 1);
1389
1390 // Sync push
1391 const pushResult = await sync.pushToServer();
1392 assert.strictEqual(pushResult.pushed, 3);
1393
1394 // Verify all items are now synced
1395 const status = await sync.getSyncStatus();
1396 assert.strictEqual(status.pendingCount, 0);
1397 });
1398});
1399
1400// ==================== better-sqlite3 Adapter ====================
1401
1402// Only run if better-sqlite3 is available (skip gracefully in environments without it)
1403let Database;
1404let betterSqliteWorks = false;
1405try {
1406 Database = (await import('better-sqlite3')).default;
1407 // Test that the native module actually loads (may fail if compiled for Electron)
1408 const testDb = new Database(':memory:');
1409 testDb.close();
1410 betterSqliteWorks = true;
1411} catch {
1412 Database = null;
1413}
1414
1415if (betterSqliteWorks) {
1416 const { createBetterSqliteAdapter } = await import('./adapters/better-sqlite3.js');
1417
1418 describe('BetterSqlite3 Adapter', () => {
1419 let db, adapter;
1420
1421 beforeEach(() => {
1422 db = new Database(':memory:');
1423 adapter = createBetterSqliteAdapter(db);
1424 });
1425
1426 it('should open and create schema', async () => {
1427 await adapter.open();
1428 const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
1429 const tableNames = tables.map(t => t.name);
1430 assert.ok(tableNames.includes('items'));
1431 assert.ok(tableNames.includes('tags'));
1432 assert.ok(tableNames.includes('item_tags'));
1433 assert.ok(tableNames.includes('settings'));
1434 });
1435
1436 it('should insert and get an item', async () => {
1437 await adapter.open();
1438 const item = {
1439 id: 'test-1', type: 'url', content: 'https://example.com',
1440 metadata: null, syncId: '', syncSource: '', syncedAt: 0,
1441 createdAt: 1000, updatedAt: 1000, deletedAt: 0,
1442 };
1443 await adapter.insertItem(item);
1444 const retrieved = await adapter.getItem('test-1');
1445 assert.ok(retrieved);
1446 assert.strictEqual(retrieved.content, 'https://example.com');
1447 });
1448
1449 it('should not return soft-deleted items', async () => {
1450 await adapter.open();
1451 await adapter.insertItem({
1452 id: 'del-1', type: 'url', content: 'https://deleted.com',
1453 metadata: null, syncId: '', syncSource: '', syncedAt: 0,
1454 createdAt: 1000, updatedAt: 1000, deletedAt: 2000,
1455 });
1456 const item = await adapter.getItem('del-1');
1457 assert.strictEqual(item, null);
1458 });
1459
1460 it('should update item fields', async () => {
1461 await adapter.open();
1462 await adapter.insertItem({
1463 id: 'upd-1', type: 'text', content: 'original',
1464 metadata: null, syncId: '', syncSource: '', syncedAt: 0,
1465 createdAt: 1000, updatedAt: 1000, deletedAt: 0,
1466 });
1467 await adapter.updateItem('upd-1', { content: 'updated', updatedAt: 2000 });
1468 const item = await adapter.getItem('upd-1');
1469 assert.strictEqual(item.content, 'updated');
1470 assert.strictEqual(item.updatedAt, 2000);
1471 });
1472
1473 it('should soft-delete an item', async () => {
1474 await adapter.open();
1475 await adapter.insertItem({
1476 id: 'sd-1', type: 'url', content: 'https://test.com',
1477 metadata: null, syncId: '', syncSource: '', syncedAt: 0,
1478 createdAt: 1000, updatedAt: 1000, deletedAt: 0,
1479 });
1480 await adapter.deleteItem('sd-1');
1481 assert.strictEqual(await adapter.getItem('sd-1'), null);
1482 });
1483
1484 it('should hard-delete an item and its tags', async () => {
1485 await adapter.open();
1486 await adapter.insertItem({
1487 id: 'hd-1', type: 'url', content: 'https://test.com',
1488 metadata: null, syncId: '', syncSource: '', syncedAt: 0,
1489 createdAt: 1000, updatedAt: 1000, deletedAt: 0,
1490 });
1491 await adapter.insertTag({
1492 id: 'tag-1', name: 'test', frequency: 1, lastUsed: 1000,
1493 frecencyScore: 10, createdAt: 1000, updatedAt: 1000,
1494 });
1495 await adapter.tagItem('hd-1', 'tag-1');
1496 await adapter.hardDeleteItem('hd-1');
1497
1498 // Item gone
1499 const items = await adapter.getItems({ includeDeleted: true });
1500 assert.strictEqual(items.length, 0);
1501 // Tag links gone
1502 const tags = await adapter.getItemTags('hd-1');
1503 assert.strictEqual(tags.length, 0);
1504 });
1505
1506 it('should manage tags', async () => {
1507 await adapter.open();
1508 await adapter.insertTag({
1509 id: 'tag-a', name: 'Alpha', frequency: 1, lastUsed: 1000,
1510 frecencyScore: 10, createdAt: 1000, updatedAt: 1000,
1511 });
1512 const byName = await adapter.getTagByName('alpha');
1513 assert.ok(byName);
1514 assert.strictEqual(byName.name, 'Alpha');
1515
1516 await adapter.updateTag('tag-a', { frequency: 5, updatedAt: 2000 });
1517 const updated = await adapter.getTag('tag-a');
1518 assert.strictEqual(updated.frequency, 5);
1519 });
1520
1521 it('should manage item-tag associations', async () => {
1522 await adapter.open();
1523 await adapter.insertItem({
1524 id: 'it-1', type: 'url', content: 'https://test.com',
1525 metadata: null, syncId: '', syncSource: '', syncedAt: 0,
1526 createdAt: 1000, updatedAt: 1000, deletedAt: 0,
1527 });
1528 await adapter.insertTag({
1529 id: 'tag-b', name: 'Beta', frequency: 1, lastUsed: 1000,
1530 frecencyScore: 10, createdAt: 1000, updatedAt: 1000,
1531 });
1532
1533 await adapter.tagItem('it-1', 'tag-b');
1534 let tags = await adapter.getItemTags('it-1');
1535 assert.strictEqual(tags.length, 1);
1536 assert.strictEqual(tags[0].name, 'Beta');
1537
1538 // Duplicate tagItem should be ignored
1539 await adapter.tagItem('it-1', 'tag-b');
1540 tags = await adapter.getItemTags('it-1');
1541 assert.strictEqual(tags.length, 1);
1542
1543 await adapter.untagItem('it-1', 'tag-b');
1544 tags = await adapter.getItemTags('it-1');
1545 assert.strictEqual(tags.length, 0);
1546 });
1547
1548 it('should clear all tags for an item', async () => {
1549 await adapter.open();
1550 await adapter.insertItem({
1551 id: 'ct-1', type: 'url', content: 'https://test.com',
1552 metadata: null, syncId: '', syncSource: '', syncedAt: 0,
1553 createdAt: 1000, updatedAt: 1000, deletedAt: 0,
1554 });
1555 await adapter.insertTag({
1556 id: 'tag-c1', name: 'C1', frequency: 1, lastUsed: 1000,
1557 frecencyScore: 10, createdAt: 1000, updatedAt: 1000,
1558 });
1559 await adapter.insertTag({
1560 id: 'tag-c2', name: 'C2', frequency: 1, lastUsed: 1000,
1561 frecencyScore: 10, createdAt: 1000, updatedAt: 1000,
1562 });
1563 await adapter.tagItem('ct-1', 'tag-c1');
1564 await adapter.tagItem('ct-1', 'tag-c2');
1565 await adapter.clearItemTags('ct-1');
1566 const tags = await adapter.getItemTags('ct-1');
1567 assert.strictEqual(tags.length, 0);
1568 });
1569
1570 it('should manage settings', async () => {
1571 await adapter.open();
1572 assert.strictEqual(await adapter.getSetting('missing'), null);
1573 await adapter.setSetting('key1', 'value1');
1574 assert.strictEqual(await adapter.getSetting('key1'), 'value1');
1575 await adapter.setSetting('key1', 'value2');
1576 assert.strictEqual(await adapter.getSetting('key1'), 'value2');
1577 });
1578
1579 it('should find items by syncId', async () => {
1580 await adapter.open();
1581 await adapter.insertItem({
1582 id: 'local-1', type: 'url', content: 'https://test.com',
1583 metadata: null, syncId: 'remote-1', syncSource: 'server', syncedAt: 1000,
1584 createdAt: 1000, updatedAt: 1000, deletedAt: 0,
1585 });
1586
1587 // By syncId field
1588 const bySync = await adapter.findItemBySyncId('remote-1');
1589 assert.ok(bySync);
1590 assert.strictEqual(bySync.id, 'local-1');
1591
1592 // By direct ID
1593 const byId = await adapter.findItemBySyncId('local-1');
1594 assert.ok(byId);
1595 assert.strictEqual(byId.id, 'local-1');
1596
1597 // Not found
1598 const missing = await adapter.findItemBySyncId('nonexistent');
1599 assert.strictEqual(missing, null);
1600 });
1601
1602 it('should find deleted items by syncId (needed for tombstone matching)', async () => {
1603 await adapter.open();
1604 await adapter.insertItem({
1605 id: 'del-sync', type: 'url', content: 'https://deleted.com',
1606 metadata: null, syncId: 'del-remote', syncSource: '', syncedAt: 0,
1607 createdAt: 1000, updatedAt: 1000, deletedAt: 2000,
1608 });
1609 const bySyncId = await adapter.findItemBySyncId('del-remote');
1610 assert.ok(bySyncId, 'should find deleted items by syncId field');
1611 assert.strictEqual(bySyncId.id, 'del-sync');
1612 assert.strictEqual(bySyncId.deletedAt, 2000);
1613
1614 const byId = await adapter.findItemBySyncId('del-sync');
1615 assert.ok(byId, 'should find deleted items by direct ID');
1616 assert.strictEqual(byId.id, 'del-sync');
1617 });
1618
1619 it('should filter items by type and since', async () => {
1620 await adapter.open();
1621 await adapter.insertItem({
1622 id: 'f-1', type: 'url', content: 'https://a.com',
1623 metadata: null, syncId: '', syncSource: '', syncedAt: 0,
1624 createdAt: 1000, updatedAt: 1000, deletedAt: 0,
1625 });
1626 await adapter.insertItem({
1627 id: 'f-2', type: 'text', content: 'note',
1628 metadata: null, syncId: '', syncSource: '', syncedAt: 0,
1629 createdAt: 2000, updatedAt: 2000, deletedAt: 0,
1630 });
1631 await adapter.insertItem({
1632 id: 'f-3', type: 'url', content: 'https://b.com',
1633 metadata: null, syncId: '', syncSource: '', syncedAt: 0,
1634 createdAt: 3000, updatedAt: 3000, deletedAt: 0,
1635 });
1636
1637 const urls = await adapter.getItems({ type: 'url' });
1638 assert.strictEqual(urls.length, 2);
1639
1640 const since = await adapter.getItems({ since: 1500 });
1641 assert.strictEqual(since.length, 2);
1642 });
1643
1644 it('should work with DataEngine for full workflow', async () => {
1645 await adapter.open();
1646 const { createEngine } = await import('./index.js');
1647 const { data } = createEngine(adapter);
1648
1649 const { id } = await data.saveItem('url', 'https://example.com', ['test']);
1650 assert.ok(id);
1651
1652 const item = await data.getItem(id);
1653 assert.strictEqual(item.content, 'https://example.com');
1654
1655 const tags = await data.getItemTags(id);
1656 assert.strictEqual(tags.length, 1);
1657 assert.strictEqual(tags[0].name, 'test');
1658 });
1659 });
1660}