@3sln/trove 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +1227 -0
  2. package/package.json +75 -0
  3. package/packages/core/src/collections/index.js +249 -0
  4. package/packages/core/src/errors.js +186 -0
  5. package/packages/core/src/identity/discovery.js +210 -0
  6. package/packages/core/src/identity/index.js +188 -0
  7. package/packages/core/src/identity/jwt.js +199 -0
  8. package/packages/core/src/index.js +104 -0
  9. package/packages/core/src/indexers/contribution.js +115 -0
  10. package/packages/core/src/indexers/registry.js +162 -0
  11. package/packages/core/src/indexing.js +340 -0
  12. package/packages/core/src/issues.js +150 -0
  13. package/packages/core/src/kv.js +0 -0
  14. package/packages/core/src/links.js +141 -0
  15. package/packages/core/src/metadata/cursor.js +73 -0
  16. package/packages/core/src/metadata/interface.js +244 -0
  17. package/packages/core/src/metadata/memory.js +270 -0
  18. package/packages/core/src/metadata/sqlite.js +412 -0
  19. package/packages/core/src/notifications/index.js +139 -0
  20. package/packages/core/src/notifications/webpush.js +217 -0
  21. package/packages/core/src/plugins/contributions.js +177 -0
  22. package/packages/core/src/plugins/identity.js +98 -0
  23. package/packages/core/src/plugins/index.js +225 -0
  24. package/packages/core/src/plugins/indexers.js +142 -0
  25. package/packages/core/src/plugins/installStore.js +134 -0
  26. package/packages/core/src/plugins/package.js +102 -0
  27. package/packages/core/src/plugins/packageStore.js +61 -0
  28. package/packages/core/src/plugins/runtime.js +101 -0
  29. package/packages/core/src/plugins/sql.js +52 -0
  30. package/packages/core/src/retry.js +74 -0
  31. package/packages/core/src/scan.js +302 -0
  32. package/packages/core/src/search/embeddings.js +128 -0
  33. package/packages/core/src/search/index.js +200 -0
  34. package/packages/core/src/search/keywordStore.js +107 -0
  35. package/packages/core/src/search/sqliteStores.js +455 -0
  36. package/packages/core/src/search/tagMatch.js +59 -0
  37. package/packages/core/src/search/transformer.js +195 -0
  38. package/packages/core/src/search/vectorStore.js +274 -0
  39. package/packages/core/src/search/vectorize.js +249 -0
  40. package/packages/core/src/sidecar/document.js +213 -0
  41. package/packages/core/src/sidecar/index.js +174 -0
  42. package/packages/core/src/sidecar/manager.js +239 -0
  43. package/packages/core/src/sidecar/store.js +46 -0
  44. package/packages/core/src/signedUrls.js +170 -0
  45. package/packages/core/src/sqlite-d1.js +162 -0
  46. package/packages/core/src/sqlite-driver.js +42 -0
  47. package/packages/core/src/sqlite.js +162 -0
  48. package/packages/core/src/storage/filesystem.js +283 -0
  49. package/packages/core/src/storage/interface.js +222 -0
  50. package/packages/core/src/storage/memory.js +113 -0
  51. package/packages/core/src/storage/prefixed.js +75 -0
  52. package/packages/core/src/storage/s3.js +316 -0
  53. package/packages/core/src/storage/s3sigv4.js +185 -0
  54. package/packages/core/src/tasks.js +228 -0
  55. package/packages/core/src/uploads.js +386 -0
  56. package/packages/core/src/util.js +125 -0
  57. package/packages/core/src/vfs.js +666 -0
  58. package/packages/plugin-sdk/src/browser.js +316 -0
  59. package/packages/plugin-sdk/src/index.js +32 -0
  60. package/packages/plugin-sdk/src/protocol.js +59 -0
  61. package/packages/plugin-sdk/src/rpc.js +95 -0
  62. package/packages/server/src/adapters/bun.js +78 -0
  63. package/packages/server/src/adapters/node.js +115 -0
  64. package/packages/server/src/adapters/staticAssets.js +123 -0
  65. package/packages/server/src/adapters/webDist.js +70 -0
  66. package/packages/server/src/adapters/worker-tasks.js +206 -0
  67. package/packages/server/src/adapters/worker.js +159 -0
  68. package/packages/server/src/cachePolicy.js +34 -0
  69. package/packages/server/src/engine/README.md +88 -0
  70. package/packages/server/src/engine/actions/scanCollection.js +114 -0
  71. package/packages/server/src/engine/index.js +95 -0
  72. package/packages/server/src/engine/lazy.js +25 -0
  73. package/packages/server/src/engine/providers/access.js +363 -0
  74. package/packages/server/src/engine/providers/core.js +405 -0
  75. package/packages/server/src/engine/providers/scan.js +67 -0
  76. package/packages/server/src/index.js +698 -0
  77. package/packages/server/src/manifest.js +98 -0
  78. package/packages/server/src/mcp/auth.js +40 -0
  79. package/packages/server/src/mcp/index.js +213 -0
  80. package/packages/server/src/mcp/protocol.js +181 -0
  81. package/packages/server/src/mcp/tools.js +351 -0
  82. package/packages/server/src/router.js +229 -0
  83. package/packages/server/src/routes.js +1066 -0
  84. package/packages/server/src/scope.js +43 -0
  85. package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
  86. package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
  87. package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
  88. package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
  89. package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
  90. package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
  91. package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
  92. package/packages/web/dist/icon.svg +11 -0
  93. package/packages/web/dist/index.html +16 -0
  94. package/packages/web/dist/sql-wasm.wasm +0 -0
  95. package/packages/web/dist/sw.js +186 -0
  96. package/packages/web/src/bl/actions.js +410 -0
  97. package/packages/web/src/bl/activity.js +306 -0
  98. package/packages/web/src/bl/commands.js +274 -0
  99. package/packages/web/src/bl/fileType.js +49 -0
  100. package/packages/web/src/bl/index.js +70 -0
  101. package/packages/web/src/bl/links.js +54 -0
  102. package/packages/web/src/bl/offline.js +268 -0
  103. package/packages/web/src/bl/openers.js +71 -0
  104. package/packages/web/src/bl/pluginInstall.js +59 -0
  105. package/packages/web/src/bl/services.js +143 -0
  106. package/packages/web/src/bl/social.js +234 -0
  107. package/packages/web/src/bl/tagQuery.js +44 -0
  108. package/packages/web/src/main.js +10 -0
  109. package/packages/web/src/platform/api.js +529 -0
  110. package/packages/web/src/platform/commands.js +89 -0
  111. package/packages/web/src/platform/context.js +77 -0
  112. package/packages/web/src/platform/contributions.js +156 -0
  113. package/packages/web/src/platform/index.js +150 -0
  114. package/packages/web/src/platform/keybindings.js +199 -0
  115. package/packages/web/src/platform/mediaUrls.js +137 -0
  116. package/packages/web/src/platform/navigation.js +131 -0
  117. package/packages/web/src/platform/notifications.js +50 -0
  118. package/packages/web/src/platform/overlay.js +81 -0
  119. package/packages/web/src/platform/pluginClientDb.js +132 -0
  120. package/packages/web/src/platform/pluginDock.js +141 -0
  121. package/packages/web/src/platform/pluginFrames.js +194 -0
  122. package/packages/web/src/platform/pluginHost.js +648 -0
  123. package/packages/web/src/platform/pluginMedia.js +62 -0
  124. package/packages/web/src/platform/pluginModules.js +90 -0
  125. package/packages/web/src/platform/pluginNet.js +71 -0
  126. package/packages/web/src/platform/pluginPackage.js +247 -0
  127. package/packages/web/src/platform/pluginRpc.js +377 -0
  128. package/packages/web/src/platform/pluginSigning.js +168 -0
  129. package/packages/web/src/platform/pluginStore.js +67 -0
  130. package/packages/web/src/platform/settings.js +101 -0
  131. package/packages/web/src/platform/spatialNav.js +286 -0
  132. package/packages/web/src/platform/viewport.js +123 -0
  133. package/packages/web/src/platform/voice.js +133 -0
  134. package/packages/web/src/platform/voiceSearch.js +155 -0
  135. package/packages/web/src/platform/whenclause.js +162 -0
  136. package/packages/web/src/platform/workbench.js +156 -0
  137. package/packages/web/src/runtime.js +73 -0
  138. package/packages/web/src/styles.css +1382 -0
  139. package/packages/web/src/ui/components/activityBar.js +35 -0
  140. package/packages/web/src/ui/components/activityPanel.js +132 -0
  141. package/packages/web/src/ui/components/commandPalette.js +154 -0
  142. package/packages/web/src/ui/components/editorArea.js +75 -0
  143. package/packages/web/src/ui/components/launcher.js +392 -0
  144. package/packages/web/src/ui/components/openers/index.js +212 -0
  145. package/packages/web/src/ui/components/openers/markdown.js +222 -0
  146. package/packages/web/src/ui/components/overlays.js +255 -0
  147. package/packages/web/src/ui/components/phoneChrome.js +188 -0
  148. package/packages/web/src/ui/components/pluginReview.js +151 -0
  149. package/packages/web/src/ui/components/pluginsView.js +120 -0
  150. package/packages/web/src/ui/components/settingsView.js +258 -0
  151. package/packages/web/src/ui/components/social.js +290 -0
  152. package/packages/web/src/ui/components/statusBar.js +198 -0
  153. package/packages/web/src/ui/components/views/grid.js +115 -0
  154. package/packages/web/src/ui/components/views/index.js +155 -0
  155. package/packages/web/src/ui/components/views/list.js +50 -0
  156. package/packages/web/src/ui/components/views/parts.js +58 -0
  157. package/packages/web/src/ui/compositions/workbench.js +125 -0
  158. package/packages/web/src/ui/format.js +33 -0
  159. package/packages/web/src/ui/icon.js +81 -0
  160. package/packages/web/src/ui/media.js +114 -0
  161. package/packages/web/src/ui/sanitize.js +86 -0
  162. package/packages/web/src/workbench.js +205 -0
@@ -0,0 +1,412 @@
1
+ // SQLite-backed MetadataStore using Node's built-in `node:sqlite` (no native build
2
+ // step). Suitable for single-node self-hosting on filesystem/NAS/S3. There is no
3
+ // hierarchy to model: one flat table of items, unique per (collectionId, name).
4
+ // `meta` and `facets` are JSON columns, and the outbound `trove:` links the links
5
+ // indexer records live inside `facets`, so backlinks are a json_each query rather
6
+ // than a second table. FTS over names is a LIKE index; SearchService layers semantic
7
+ // search on top.
8
+
9
+ import {
10
+ MetadataStore, MERGED_TAGS, LINKS_CONTRIBUTOR, LINKS_KEY,
11
+ mergeContributionTags, splitContributions, applyContribution, rawFacetsFromNode,
12
+ } from './interface.js';
13
+ import { TroveError, wrapError } from '../errors.js';
14
+ import { newId } from '../util.js';
15
+ import { decodeCursor, encodeCursor } from './cursor.js';
16
+
17
+ export class SqliteStore extends MetadataStore {
18
+ /**
19
+ * @param {{ provider?: object, key?: string, database?: object }} opts
20
+ * `provider` is a SqliteProvider; `key` names this store's db (default
21
+ * 'metadata'). Or pass a ready `database` (a SqliteDatabase) directly.
22
+ */
23
+ constructor(opts = {}) {
24
+ super();
25
+ this._opts = opts;
26
+ this.key = opts.key ?? 'metadata';
27
+ this.db = opts.database ?? null;
28
+ }
29
+
30
+ async init() {
31
+ if (!this.db) {
32
+ if (!this._opts.provider) throw TroveError.invalid('SqliteStore needs a provider or database');
33
+ this.db = await this._opts.provider.obtain({ key: this.key });
34
+ }
35
+ await this.db.exec(`
36
+ PRAGMA journal_mode = WAL;
37
+ PRAGMA foreign_keys = ON;
38
+ CREATE TABLE IF NOT EXISTS nodes (
39
+ id TEXT PRIMARY KEY,
40
+ collectionId TEXT NOT NULL DEFAULT 'default',
41
+ name TEXT NOT NULL,
42
+ size INTEGER NOT NULL DEFAULT 0,
43
+ contentType TEXT,
44
+ storageKey TEXT,
45
+ etag TEXT,
46
+ createdAt INTEGER NOT NULL,
47
+ updatedAt INTEGER NOT NULL,
48
+ meta TEXT NOT NULL DEFAULT '{}',
49
+ facets TEXT NOT NULL DEFAULT '{}'
50
+ );
51
+ CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
52
+ CREATE INDEX IF NOT EXISTS idx_nodes_updated ON nodes(updatedAt);
53
+ `);
54
+ await this.#migrate();
55
+ }
56
+
57
+ /**
58
+ * Add `deletedAt` (the trash) to a database that predates it.
59
+ *
60
+ * The name-uniqueness index has to become PARTIAL as part of the same step. A trashed
61
+ * item still holds its row, so under the old unconditional index deleting `notes.md`
62
+ * would block ever creating another `notes.md` — the trash would take the name hostage.
63
+ * `WHERE deletedAt IS NULL` scopes uniqueness to the live drive, which is where it
64
+ * means something.
65
+ */
66
+ async #migrate() {
67
+ const cols = await this.db.all('PRAGMA table_info(nodes)');
68
+ if (!cols.some((c) => c.name === 'deletedAt')) {
69
+ await this.db.exec('ALTER TABLE nodes ADD COLUMN deletedAt INTEGER');
70
+ }
71
+ // Recreating the index is cheap and idempotent; naming the new one differently is
72
+ // what makes "has this run?" answerable without a migrations table.
73
+ await this.db.exec(`
74
+ DROP INDEX IF EXISTS idx_nodes_coll_name;
75
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_nodes_live_name ON nodes(collectionId, name) WHERE deletedAt IS NULL;
76
+ CREATE INDEX IF NOT EXISTS idx_nodes_deleted ON nodes(deletedAt);
77
+ `);
78
+ }
79
+
80
+ async getById(id) {
81
+ return row(await this.db.get('SELECT * FROM nodes WHERE id = ?', id));
82
+ }
83
+ async getByName(collectionId = 'default', name) {
84
+ // Live items only: a trashed `notes.md` must not answer a link or a name lookup, or
85
+ // deleting something would leave it silently reachable.
86
+ return row(await this.db.get(
87
+ 'SELECT * FROM nodes WHERE collectionId = ? AND name = ? AND deletedAt IS NULL', collectionId, name,
88
+ ));
89
+ }
90
+
91
+ async listItems(collectionId = 'default', opts = {}) {
92
+ // Resolve ONCE and use the resolved name everywhere, cursor included. Encoding the
93
+ // cursor under the caller's raw string while querying by the mapped column made
94
+ // `?sort=createdAt` compare `name > <timestamp>` — and TEXT always sorts above
95
+ // INTEGER in SQLite, so the predicate was always true and paging never terminated:
96
+ // the same first row, forever.
97
+ const sortCol = { name: 'name', size: 'size', updatedAt: 'updatedAt' }[opts.sort] || 'name';
98
+ const dir = opts.order === 'desc' ? 'DESC' : 'ASC';
99
+ const cmp = dir === 'DESC' ? '<' : '>';
100
+ // NOCASE is a text collation and a no-op on the numeric columns, so one expression
101
+ // serves all three — and it has to be the SAME expression in ORDER BY and in the
102
+ // keyset comparison, or the page boundary won't line up with the ordering.
103
+ const key = sortCol === 'name' ? `${sortCol} COLLATE NOCASE` : sortCol;
104
+ const limit = opts.limit ?? 500;
105
+ const at = decodeCursor(sortCol, opts.cursor);
106
+ // Keyset, not OFFSET: resume from the last row of the previous page, so an insert
107
+ // or delete before the cut can't shift a row past it unseen. `id` breaks ties.
108
+ const where = at ? `AND (${key} ${cmp} ? OR (${key} = ? AND id ${cmp} ?))` : '';
109
+ const args = at ? [at.value, at.value, at.id] : [];
110
+ const rows = await this.db.all(
111
+ `SELECT * FROM nodes WHERE collectionId = ? AND deletedAt IS NULL ${where}
112
+ ORDER BY ${key} ${dir}, id ${dir}
113
+ LIMIT ?`,
114
+ collectionId, ...args, limit + 1,
115
+ );
116
+ const hasMore = rows.length > limit;
117
+ const page = rows.slice(0, limit).map(row);
118
+ return {
119
+ items: page,
120
+ nextCursor: hasMore ? encodeCursor(sortCol, page[page.length - 1]) : null,
121
+ };
122
+ }
123
+
124
+ async create(node) {
125
+ if (!node.name) throw TroveError.invalid('An item needs a name');
126
+ const now = Date.now();
127
+ const full = {
128
+ id: node.id || newId('itm'),
129
+ collectionId: node.collectionId || 'default', name: node.name,
130
+ size: node.size ?? 0, contentType: node.contentType ?? null,
131
+ storageKey: node.storageKey ?? null, etag: node.etag ?? null,
132
+ createdAt: now, updatedAt: now, meta: node.meta ?? {}, facets: rawFacetsFromNode(node),
133
+ };
134
+ try {
135
+ await this.db.run(
136
+ `INSERT INTO nodes (id,collectionId,name,size,contentType,storageKey,etag,createdAt,updatedAt,meta,facets)
137
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
138
+ full.id, full.collectionId, full.name, full.size,
139
+ full.contentType, full.storageKey, full.etag, full.createdAt, full.updatedAt,
140
+ JSON.stringify(full.meta), JSON.stringify(full.facets),
141
+ );
142
+ return full;
143
+ } catch (err) {
144
+ if (String(err?.message || '').includes('UNIQUE')) throw TroveError.alreadyExists(node.name, { cause: err });
145
+ throw wrapError(err);
146
+ }
147
+ }
148
+
149
+ async update(id, patch) {
150
+ const node = await this.getById(id);
151
+ if (!node) throw TroveError.notFound('Node');
152
+ const next = { ...node };
153
+ for (const k of ['size', 'contentType', 'storageKey', 'etag', 'meta']) {
154
+ if (k in patch) next[k] = patch[k];
155
+ }
156
+ next.updatedAt = Date.now();
157
+ await this.db.run(
158
+ `UPDATE nodes SET size=?, contentType=?, storageKey=?, etag=?, meta=?, updatedAt=? WHERE id=?`,
159
+ next.size, next.contentType, next.storageKey, next.etag, JSON.stringify(next.meta), next.updatedAt, id,
160
+ );
161
+ return next;
162
+ }
163
+
164
+ /** Permanently forget an item. The trash is a VFS concern; this is the real delete. */
165
+ async remove(id) {
166
+ await this.db.run('DELETE FROM nodes WHERE id = ?', id);
167
+ }
168
+
169
+ async softDelete(id, at = Date.now()) {
170
+ await this.db.run('UPDATE nodes SET deletedAt = ?, updatedAt = ? WHERE id = ?', at, at, id);
171
+ return this.getById(id);
172
+ }
173
+ async restore(id, newName = null) {
174
+ try {
175
+ if (newName) {
176
+ await this.db.run('UPDATE nodes SET deletedAt = NULL, name = ?, updatedAt = ? WHERE id = ?', newName, Date.now(), id);
177
+ } else {
178
+ await this.db.run('UPDATE nodes SET deletedAt = NULL, updatedAt = ? WHERE id = ?', Date.now(), id);
179
+ }
180
+ } catch (err) {
181
+ // The partial unique index fires here when the name was taken while it was away.
182
+ if (String(err?.message || '').includes('UNIQUE')) throw TroveError.alreadyExists(newName || id, { cause: err });
183
+ throw wrapError(err);
184
+ }
185
+ return this.getById(id);
186
+ }
187
+ /** Trashed items, newest first — the order someone looking for a mistake wants. */
188
+ async listTrash(collectionId, { limit = 200, before = null } = {}) {
189
+ const where = ['deletedAt IS NOT NULL'];
190
+ const params = [];
191
+ if (collectionId) { where.push('collectionId = ?'); params.push(collectionId); }
192
+ if (before) { where.push('deletedAt < ?'); params.push(before); }
193
+ params.push(limit);
194
+ const rows = await this.db.all(
195
+ `SELECT * FROM nodes WHERE ${where.join(' AND ')} ORDER BY deletedAt DESC LIMIT ?`, ...params,
196
+ );
197
+ return rows.map(row);
198
+ }
199
+ /** Items trashed before `cutoff` — what the purge sweep collects. */
200
+ async trashedStorageKeys(collectionId) {
201
+ const rows = await this.db.all(
202
+ 'SELECT storageKey FROM nodes WHERE collectionId = ? AND deletedAt IS NOT NULL AND storageKey IS NOT NULL',
203
+ collectionId,
204
+ );
205
+ return new Set(rows.map((r) => r.storageKey));
206
+ }
207
+
208
+ async trashedBefore(cutoff, limit = 500) {
209
+ const rows = await this.db.all(
210
+ 'SELECT * FROM nodes WHERE deletedAt IS NOT NULL AND deletedAt < ? ORDER BY deletedAt ASC LIMIT ?',
211
+ cutoff, limit,
212
+ );
213
+ return rows.map(row);
214
+ }
215
+
216
+ async rename(id, newName) {
217
+ const node = await this.getById(id);
218
+ if (!node) throw TroveError.notFound('Item');
219
+ if (!newName) throw TroveError.invalid('An item needs a name');
220
+ if (newName === node.name) return node;
221
+ try {
222
+ await this.db.run('UPDATE nodes SET name=?, updatedAt=? WHERE id=?', newName, Date.now(), id);
223
+ } catch (err) {
224
+ if (String(err?.message || '').includes('UNIQUE')) throw TroveError.alreadyExists(newName, { cause: err });
225
+ throw wrapError(err);
226
+ }
227
+ return this.getById(id);
228
+ }
229
+
230
+ // Read the raw contributions JSON (with the reserved merged-tags key intact).
231
+ async #rawFacets(id) {
232
+ const r = await this.db.get('SELECT facets FROM nodes WHERE id=?', id);
233
+ if (!r) throw TroveError.notFound('Node');
234
+ return r.facets ? JSON.parse(r.facets) : {};
235
+ }
236
+
237
+ async setContribution(id, contributorId, contribution) {
238
+ const raw = applyContribution(await this.#rawFacets(id), contributorId, contribution);
239
+ await this.db.run('UPDATE nodes SET facets=?, updatedAt=? WHERE id=?', JSON.stringify(raw), Date.now(), id);
240
+ return this.getById(id);
241
+ }
242
+
243
+ async clearContribution(id, contributorId) {
244
+ let raw;
245
+ try { raw = await this.#rawFacets(id); } catch { return; }
246
+ const { [contributorId]: _drop, ...rest } = raw;
247
+ rest[MERGED_TAGS] = mergeContributionTags(rest);
248
+ await this.db.run('UPDATE nodes SET facets=? WHERE id=?', JSON.stringify(rest), id);
249
+ }
250
+
251
+ async searchByName(query, opts = {}) {
252
+ const clause = opts.collectionId ? 'AND collectionId = ?' : '';
253
+ const params = ['%' + escapeLike(query) + '%'];
254
+ if (opts.collectionId) params.push(opts.collectionId);
255
+ params.push(opts.limit ?? 50);
256
+ const rows = await this.db.all(
257
+ `SELECT * FROM nodes WHERE deletedAt IS NULL AND name LIKE ? ESCAPE '\\' COLLATE NOCASE ${clause} LIMIT ?`,
258
+ ...params,
259
+ );
260
+ return rows.map(row);
261
+ }
262
+
263
+ async scanItems({ afterId = null, limit = 200 } = {}) {
264
+ const where = [];
265
+ const params = [];
266
+ if (afterId) { where.push('id > ?'); params.push(afterId); }
267
+ params.push(limit);
268
+ where.push('deletedAt IS NULL'); // sweeps operate on the live drive
269
+ const rows = await this.db.all(
270
+ `SELECT * FROM nodes WHERE ${where.join(' AND ')} ORDER BY id ASC LIMIT ?`, ...params,
271
+ );
272
+ return rows.map(row);
273
+ }
274
+
275
+ async countItems(collectionId) {
276
+ const row = collectionId
277
+ ? await this.db.get('SELECT COUNT(*) AS n FROM nodes WHERE collectionId = ? AND deletedAt IS NULL', collectionId)
278
+ : await this.db.get('SELECT COUNT(*) AS n FROM nodes WHERE deletedAt IS NULL');
279
+ return row?.n ?? 0;
280
+ }
281
+
282
+ async collectionStats(collectionId = 'default') {
283
+ const row = await this.db.get(
284
+ 'SELECT COUNT(*) AS items, COALESCE(SUM(size), 0) AS bytes FROM nodes WHERE collectionId = ? AND deletedAt IS NULL',
285
+ collectionId,
286
+ );
287
+ const trash = await this.db.get(
288
+ 'SELECT COUNT(*) AS n FROM nodes WHERE collectionId = ? AND deletedAt IS NOT NULL', collectionId,
289
+ );
290
+ return { items: row?.items ?? 0, bytes: row?.bytes ?? 0, trashed: trash?.n ?? 0 };
291
+ }
292
+
293
+ async findByTags(filters = [], opts = {}) {
294
+ const where = ['deletedAt IS NULL']; // the trash is not part of the drive
295
+
296
+ const params = [];
297
+ for (const f of filters) {
298
+ const { sql, args } = tagCondition(f);
299
+ where.push(sql);
300
+ params.push(...args);
301
+ }
302
+ if (opts.q) { where.push(`name LIKE ? ESCAPE '\\' COLLATE NOCASE`); params.push('%' + escapeLike(opts.q) + '%'); }
303
+ // `?.length` here was a whole-drive read: the server passes [] to mean "you may see
304
+ // NOTHING" (a collection you can't read, or one that doesn't exist), and a falsy
305
+ // length turned that into "don't scope at all". Undefined means unscoped; an array
306
+ // — empty or not — is the exact set allowed.
307
+ if (opts.collectionIds) {
308
+ // `IN ()` is a syntax error in SQLite, so the empty case is spelled out.
309
+ if (!opts.collectionIds.length) where.push('1 = 0');
310
+ else {
311
+ where.push(`collectionId IN (${opts.collectionIds.map(() => '?').join(',')})`);
312
+ params.push(...opts.collectionIds);
313
+ }
314
+ }
315
+ params.push(opts.limit ?? 100);
316
+ const rows = await this.db.all(`SELECT * FROM nodes WHERE ${where.join(' AND ')} ORDER BY updatedAt DESC LIMIT ?`, ...params);
317
+ return rows.map(row);
318
+ }
319
+
320
+ /**
321
+ * Backlinks. The links indexer stores an item's outbound `trove:` URIs as a JSON
322
+ * array inside `facets`, so "who links here" is an EXISTS over json_each of that
323
+ * array — no join table to keep in step with the contribution that owns the data.
324
+ */
325
+ async findLinksTo(uris = [], opts = {}) {
326
+ if (!uris.length) return [];
327
+ const linksPath = `$."${LINKS_CONTRIBUTOR}".metadata."${LINKS_KEY}"`;
328
+ const where = [
329
+ 'deletedAt IS NULL', // a trashed document must not still be listed as linking here
330
+ `EXISTS (SELECT 1 FROM json_each(json_extract(facets, ?)) WHERE json_each.value IN (${uris.map(() => '?').join(',')}))`,
331
+ ];
332
+ const params = [linksPath, ...uris];
333
+ if (opts.collectionIds) { // empty array = nothing readable; see findByTags
334
+ if (!opts.collectionIds.length) where.push('1 = 0');
335
+ else {
336
+ where.push(`collectionId IN (${opts.collectionIds.map(() => '?').join(',')})`);
337
+ params.push(...opts.collectionIds);
338
+ }
339
+ }
340
+ params.push(opts.limit ?? 100);
341
+ const rows = await this.db.all(
342
+ `SELECT * FROM nodes WHERE ${where.join(' AND ')} ORDER BY updatedAt DESC LIMIT ?`, ...params,
343
+ );
344
+ return rows.map(row);
345
+ }
346
+
347
+ // The provider owns the db handle's lifecycle; just drop our reference.
348
+ async close() {
349
+ this.db = null;
350
+ }
351
+ }
352
+
353
+ function row(r) {
354
+ if (!r) return null;
355
+ const { contributions, tags } = splitContributions(r.facets ? JSON.parse(r.facets) : {});
356
+ const out = { ...r, meta: r.meta ? JSON.parse(r.meta) : {}, contributions, tags };
357
+ delete out.facets; // internal column name; exposed as contributions + tags
358
+ return out;
359
+ }
360
+
361
+ function escapeLike(s) {
362
+ return s.replace(/[\\%_]/g, (c) => '\\' + c);
363
+ }
364
+
365
+ /**
366
+ * One filter, as SQL — written to agree with `search/tagMatch.js`, which is the single
367
+ * definition of what a tag filter means.
368
+ *
369
+ * Three ways this used to disagree with every other matcher in the drive, all of them
370
+ * silent wrong answers rather than errors:
371
+ *
372
+ * - `String(f.value)` was bound against `json_extract`, which PRESERVES JSON types. A
373
+ * numeric tag (`pages: 120`, or the built-in `links` count) compared integer against
374
+ * text, so `#pages:120` matched nothing and `#pages:!=120` matched the file.
375
+ * - `present` was `IS NOT NULL`, so a tag explicitly set to `false` counted as present.
376
+ * - `meta` was never consulted at all, though the store interface documents a filter as
377
+ * matching "a node's merged tags (+ meta)" and both other matchers include it.
378
+ */
379
+ function tagCondition(f) {
380
+ const key = String(f.key).replace(/["\\]/g, '');
381
+ const tagPath = '$."' + MERGED_TAGS + '"."' + key + '"';
382
+ const metaPath = '$."' + key + '"';
383
+ // Merged tags win over meta — the same precedence as `{ ...meta, ...tags }`.
384
+ const val = 'COALESCE(json_extract(facets, ?), json_extract(meta, ?))';
385
+ const type = "COALESCE(json_type(facets, ?), json_type(meta, ?))";
386
+ const paths = [tagPath, metaPath];
387
+
388
+ if (f.present) {
389
+ // Exists, and is neither `false` nor the empty string.
390
+ return {
391
+ sql: `(${type} IS NOT NULL AND ${type} != 'false' AND ${val} != '')`,
392
+ args: [...paths, ...paths, ...paths],
393
+ };
394
+ }
395
+
396
+ const nb = Number(f.value);
397
+ const numeric = f.value !== '' && f.value != null && !Number.isNaN(nb);
398
+ const op = { '=': '=', '!=': '!=', '<': '<', '<=': '<=', '>': '>', '>=': '>=' }[f.op] || '=';
399
+ const textCmp = `LOWER(CAST(${val} AS TEXT)) ${op} LOWER(?)`;
400
+ const textArgs = [...paths, String(f.value)];
401
+
402
+ // A non-numeric filter value can only ever match textually, so emit only that branch.
403
+ if (!numeric) return { sql: `(${val} IS NOT NULL AND ${textCmp})`, args: [...paths, ...textArgs] };
404
+
405
+ // Numeric filter value: compare numerically when the STORED value is a number too,
406
+ // textually otherwise — exactly what matchesFilter does.
407
+ return {
408
+ sql: `(${val} IS NOT NULL AND CASE WHEN ${type} IN ('integer','real')`
409
+ + ` THEN ${val} ${op} ? ELSE ${textCmp} END)`,
410
+ args: [...paths, ...paths, ...paths, nb, ...textArgs],
411
+ };
412
+ }
@@ -0,0 +1,139 @@
1
+ // NotificationCenter — batches @mentions and flushes them on a configurable
2
+ // interval, exactly as requested: as conversations mutate, mentions accumulate
3
+ // per user; every `flushIntervalMs` we drain the batch, drop it into each user's
4
+ // inbox, and fire a (bodyless) web push so their service worker wakes and pulls
5
+ // the inbox. Push subscriptions, pending batches, and inboxes live in the
6
+ // pluggable KeyValueStore, so this survives restarts and works multi-instance.
7
+ //
8
+ // Bodyless push (see webpush.js) means we never put mention text on a third-party
9
+ // push service — the client fetches /api/notifications over its authenticated
10
+ // channel instead.
11
+
12
+ import { assertPublicUrl } from '../util.js';
13
+
14
+ const NS_SUBS = 'push-subs'; // userId -> [subscription]
15
+ const NS_PENDING = 'mentions-pending'; // userId -> [mention]
16
+ const NS_INBOX = 'notifications-inbox'; // userId -> [notification]
17
+
18
+ export class NotificationCenter {
19
+ /**
20
+ * @param {object} deps
21
+ * @param {import('../kv.js').KeyValueStore} deps.kv
22
+ import { assertPublicUrl } from '../util.js';
23
+ * @param {import('./webpush.js').WebPushService} [deps.push]
24
+ * @param {number} [deps.flushIntervalMs] default 30s
25
+ * @param {number} [deps.inboxCap] keep at most N per user (default 200)
26
+ */
27
+ constructor({ kv, push, flushIntervalMs = 30_000, inboxCap = 200 }) {
28
+ this.kv = kv;
29
+ this.push = push || null;
30
+ this.flushIntervalMs = flushIntervalMs;
31
+ this.inboxCap = inboxCap;
32
+ this._timer = null;
33
+ }
34
+
35
+ vapidPublicKey() {
36
+ return this.push?.publicKey || null;
37
+ }
38
+
39
+ /** Queue mention events (from SidecarService.onMentions). */
40
+ async enqueue(mentions) {
41
+ for (const m of mentions || []) {
42
+ const pending = (await this.kv.get(NS_PENDING, m.userId)) || [];
43
+ pending.push(m);
44
+ await this.kv.set(NS_PENDING, m.userId, pending);
45
+ }
46
+ }
47
+
48
+ /** Drain all pending batches: inbox + push. Returns how many users notified. */
49
+ async flush(now = Date.now()) {
50
+ const pendingUsers = await this.kv.list(NS_PENDING);
51
+ let notified = 0;
52
+ for (const { key: userId, value: mentions } of pendingUsers) {
53
+ if (!mentions?.length) {
54
+ await this.kv.delete(NS_PENDING, userId);
55
+ continue;
56
+ }
57
+ // Collapse into one notification summarising the batch.
58
+ const note = {
59
+ id: `note_${now}_${userId}`,
60
+ kind: 'mentions',
61
+ count: mentions.length,
62
+ items: mentions,
63
+ title: mentions.length === 1
64
+ ? `${mentions[0].by?.name || 'Someone'} mentioned you`
65
+ : `${mentions.length} new mentions`,
66
+ createdAt: now,
67
+ read: false,
68
+ };
69
+ const inbox = (await this.kv.get(NS_INBOX, userId)) || [];
70
+ inbox.unshift(note);
71
+ await this.kv.set(NS_INBOX, userId, inbox.slice(0, this.inboxCap));
72
+ await this.kv.delete(NS_PENDING, userId);
73
+ await this.#pushTo(userId, note);
74
+ notified++;
75
+ }
76
+ return notified;
77
+ }
78
+
79
+ async #pushTo(userId, note) {
80
+ if (!this.push) return;
81
+ const subs = (await this.kv.get(NS_SUBS, userId)) || [];
82
+ const alive = [];
83
+ for (const sub of subs) {
84
+ try {
85
+ const res = await this.push.send(sub, { topic: 'mentions', urgency: 'normal' });
86
+ if (!res.gone) alive.push(sub);
87
+ } catch {
88
+ alive.push(sub); // transient — keep the subscription, retry next flush
89
+ }
90
+ }
91
+ if (alive.length !== subs.length) await this.kv.set(NS_SUBS, userId, alive);
92
+ }
93
+
94
+ // --- subscriptions & inbox (called by routes) ------------------------------
95
+
96
+ async subscribePush(userId, subscription) {
97
+ if (!subscription?.endpoint) throw new Error('Invalid push subscription');
98
+ // The server POSTs to this endpoint, from inside its own network, on every flush.
99
+ // Left unchecked it is a request forgery primitive any user can register — cloud
100
+ // instance metadata being the obvious target. A real push service is on the public
101
+ // internet, so nothing legitimate is lost by refusing the rest.
102
+ assertPublicUrl(subscription.endpoint, 'Push endpoint');
103
+ const subs = (await this.kv.get(NS_SUBS, userId)) || [];
104
+ if (!subs.some((s) => s.endpoint === subscription.endpoint)) {
105
+ subs.push(subscription);
106
+ await this.kv.set(NS_SUBS, userId, subs);
107
+ }
108
+ return { ok: true };
109
+ }
110
+ async unsubscribePush(userId, endpoint) {
111
+ const subs = (await this.kv.get(NS_SUBS, userId)) || [];
112
+ await this.kv.set(NS_SUBS, userId, subs.filter((s) => s.endpoint !== endpoint));
113
+ return { ok: true };
114
+ }
115
+
116
+ async inbox(userId) {
117
+ const items = (await this.kv.get(NS_INBOX, userId)) || [];
118
+ return { items, unread: items.filter((n) => !n.read).length };
119
+ }
120
+ async markRead(userId, ids) {
121
+ const items = (await this.kv.get(NS_INBOX, userId)) || [];
122
+ const set = ids ? new Set(ids) : null;
123
+ for (const n of items) if (!set || set.has(n.id)) n.read = true;
124
+ await this.kv.set(NS_INBOX, userId, items);
125
+ return { ok: true };
126
+ }
127
+
128
+ // --- lifecycle -------------------------------------------------------------
129
+
130
+ start() {
131
+ if (this._timer) return;
132
+ this._timer = setInterval(() => this.flush().catch((e) => console.error('mention flush failed', e)), this.flushIntervalMs);
133
+ if (this._timer.unref) this._timer.unref();
134
+ }
135
+ stop() {
136
+ if (this._timer) clearInterval(this._timer);
137
+ this._timer = null;
138
+ }
139
+ }