experiments in a post-browser web
1/**
2 * In-Memory Storage Adapter
3 *
4 * Map-based storage for fast unit tests. Implements the full StorageAdapter interface.
5 * No external dependencies.
6 */
7
8export function createMemoryAdapter() {
9 let items = new Map();
10 let tags = new Map();
11 let itemTags = []; // { itemId, tagId, createdAt }
12 let settings = new Map();
13
14 return {
15 // ==================== Lifecycle ====================
16
17 async open() {
18 items = new Map();
19 tags = new Map();
20 itemTags = [];
21 settings = new Map();
22 },
23
24 async close() {
25 items.clear();
26 tags.clear();
27 itemTags = [];
28 settings.clear();
29 },
30
31 // ==================== Items ====================
32
33 async getItem(id) {
34 const item = items.get(id);
35 if (!item || item.deletedAt) return null;
36 return { ...item };
37 },
38
39 async getItems(filter = {}) {
40 let results = [...items.values()];
41 if (!filter.includeDeleted) {
42 results = results.filter(i => !i.deletedAt);
43 }
44 if (filter.type) {
45 results = results.filter(i => i.type === filter.type);
46 }
47 if (filter.since) {
48 results = results.filter(i => i.updatedAt > filter.since);
49 }
50 return results.map(i => ({ ...i }));
51 },
52
53 async insertItem(item) {
54 items.set(item.id, { ...item });
55 },
56
57 async updateItem(id, fields) {
58 const item = items.get(id);
59 if (!item) return;
60 for (const [key, value] of Object.entries(fields)) {
61 if (value !== undefined) {
62 item[key] = value;
63 }
64 }
65 },
66
67 async deleteItem(id) {
68 const item = items.get(id);
69 if (!item || item.deletedAt) return;
70 const timestamp = Date.now();
71 item.deletedAt = timestamp;
72 item.updatedAt = timestamp;
73 },
74
75 async hardDeleteItem(id) {
76 items.delete(id);
77 itemTags = itemTags.filter(link => link.itemId !== id);
78 },
79
80 // ==================== Tags ====================
81
82 async getTag(id) {
83 const tag = tags.get(id);
84 return tag ? { ...tag } : null;
85 },
86
87 async getTagByName(name) {
88 const lower = name.toLowerCase();
89 for (const tag of tags.values()) {
90 if (tag.name.toLowerCase() === lower) {
91 return { ...tag };
92 }
93 }
94 return null;
95 },
96
97 async insertTag(tag) {
98 tags.set(tag.id, { ...tag });
99 },
100
101 async updateTag(id, fields) {
102 const tag = tags.get(id);
103 if (!tag) return;
104 for (const [key, value] of Object.entries(fields)) {
105 if (value !== undefined) {
106 tag[key] = value;
107 }
108 }
109 },
110
111 // ==================== Item-Tags ====================
112
113 async getItemTags(itemId) {
114 const tagIds = itemTags
115 .filter(l => l.itemId === itemId)
116 .map(l => l.tagId);
117 return tagIds
118 .map(id => tags.get(id))
119 .filter(Boolean)
120 .map(t => ({ ...t }));
121 },
122
123 async getItemsByTag(tagId) {
124 const itemIds = itemTags
125 .filter(l => l.tagId === tagId)
126 .map(l => l.itemId);
127 return itemIds
128 .map(id => items.get(id))
129 .filter(i => i && !i.deletedAt)
130 .map(i => ({ ...i }));
131 },
132
133 async tagItem(itemId, tagId) {
134 const exists = itemTags.some(
135 l => l.itemId === itemId && l.tagId === tagId
136 );
137 if (!exists) {
138 itemTags.push({ itemId, tagId, createdAt: Date.now() });
139 }
140 },
141
142 async untagItem(itemId, tagId) {
143 itemTags = itemTags.filter(
144 l => !(l.itemId === itemId && l.tagId === tagId)
145 );
146 },
147
148 async clearItemTags(itemId) {
149 itemTags = itemTags.filter(l => l.itemId !== itemId);
150 },
151
152 // ==================== Settings ====================
153
154 async getSetting(key) {
155 return settings.get(key) ?? null;
156 },
157
158 async setSetting(key, value) {
159 settings.set(key, value);
160 },
161
162 // ==================== Query Helpers ====================
163
164 async findItemBySyncId(syncId) {
165 // Check by direct ID first (device re-pushes with server-assigned ID)
166 const byId = items.get(syncId);
167 if (byId) return { ...byId };
168
169 // Check by syncId field
170 for (const item of items.values()) {
171 if (item.syncId === syncId) {
172 return { ...item };
173 }
174 }
175 return null;
176 },
177
178 async getAllTags() {
179 return [...tags.values()].map(t => ({ ...t }));
180 },
181 };
182}