@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,340 @@
1
+ // IndexingCoordinator — the indexing subsystem, extracted from the Vfs façade. Owns
2
+ // the indexer registry, the search index, and the per-file contribution flow:
3
+ // • indexNode(node) — run every matching indexer on upload/write
4
+ // • indexContributions(...) — apply one contributor's {semanticTexts, tags, metadata}
5
+ // • removeContributions(...) — clear one contributor from a node
6
+ // • backfillIndexer(...) — re-run an indexer over the whole drive (on install)
7
+ // • purgeIndexer(...) — remove an indexer's contributions everywhere (uninstall)
8
+ //
9
+ // Vfs holds one of these and delegates; it needs only `storageFor` (collection → blob
10
+ // backend) injected so this stays free of the tree/path concerns.
11
+
12
+ import { TroveError } from './errors.js';
13
+ import { readAll } from './util.js';
14
+ import { normalizeContribution, clampContribution, DEFAULT_CAPS } from './indexers/contribution.js';
15
+
16
+ export class IndexingCoordinator {
17
+ /**
18
+ * @param {object} deps
19
+ * @param {import('./metadata/interface.js').MetadataStore} deps.metadata
20
+ * @param {object|null} deps.search SearchService (or null when search is disabled)
21
+ * @param {import('./indexers/registry.js').IndexerRegistry} deps.indexers
22
+ * @param {(collectionId: string) => Promise<object>} deps.storageFor
23
+ * @param {number} deps.maxIndexBytes
24
+ * @param {object} [deps.caps] contribution size caps (see indexers/contribution.js)
25
+ * @param {import('./issues.js').IssueRegistry} [deps.issues] where a failure to index
26
+ * becomes a standing, retryable problem instead of a console line nobody reads
27
+ */
28
+ constructor({ metadata, search, indexers, storageFor, maxIndexBytes, caps, issues, mintUrl = null }) {
29
+ this.metadata = metadata;
30
+ this.search = search ?? null;
31
+ this.indexers = indexers;
32
+ this.storageFor = storageFor;
33
+ this.maxIndexBytes = maxIndexBytes;
34
+ this.caps = { ...DEFAULT_CAPS, ...caps };
35
+ this.issues = issues ?? null;
36
+ // How a time-limited read URL is made. Not this file's business any more — it is one
37
+ // caller of a general primitive (see signedUrls.js), not the owner of a private one.
38
+ this.mintUrl = mintUrl;
39
+ }
40
+
41
+ /**
42
+ * Apply one contributor's indexed contribution to a node. A contribution has up
43
+ * to three scopes: `semanticTexts` (→ search index), `tags` (filterable), and
44
+ * `metadata` (arbitrary, e.g. a chapter index). Each contributor is namespaced,
45
+ * so its contribution replaces only its own and can be removed independently.
46
+ * The legacy `{ documents, facet }` shape is accepted (documents→semanticTexts,
47
+ * facet→metadata).
48
+ */
49
+ async indexContributions(nodeId, contributorId, contribution) {
50
+ const node = await this.metadata.getById(nodeId);
51
+ if (!node) throw TroveError.notFound('Node');
52
+ await this.#applyContribution(nodeId, contributorId, contribution);
53
+ return { ok: true };
54
+ }
55
+
56
+ /**
57
+ * THE choke point: every contribution reaches storage through here, whether it came
58
+ * from a built-in indexer, a plugin indexer in the isolate, or a sandboxed plugin
59
+ * pushing over the API. So this is where the size caps have to be applied — clamping
60
+ * inside any one producer would leave the other two unbounded, and the API push is
61
+ * the one most exposed to untrusted code.
62
+ */
63
+ async #applyContribution(nodeId, contributorId, contribution) {
64
+ const { semanticTexts, tags, metadata } = normalizeContribution(clampContribution(contribution, this.caps));
65
+ // ALWAYS write the search half, including when it is empty. `indexDocuments` clears
66
+ // this (node, contributor)'s prior docs before it writes, so passing [] is how you
67
+ // say "this contributor has nothing to say about this node any more". Skipping the
68
+ // call when the list is empty meant overwriting a document with blank content left
69
+ // its old chunks in the index for good — search kept returning the file, with a
70
+ // snippet of text that was no longer in it.
71
+ if (this.search) await this.search.indexDocuments(nodeId, contributorId, semanticTexts);
72
+ // Indexer contributions live in the queryable metadata store (not the sidecar),
73
+ // so they show up in list/stat and drive tag filtering. Tags MERGE within a
74
+ // contributor's namespace by design (see applyContribution) — but a contribution
75
+ // with no scopes at all is that contributor withdrawing, so its facets go too,
76
+ // rather than leaving an excerpt describing content that no longer exists.
77
+ if (tags || metadata) await this.metadata.setContribution(nodeId, contributorId, { tags, metadata });
78
+ else if (!semanticTexts.length) await this.metadata.clearContribution?.(nodeId, contributorId);
79
+ }
80
+
81
+ /** Clear this contributor's search entries for a node (an empty re-index). */
82
+ async #clearSearch(nodeId, contributorId) {
83
+ if (this.search) await this.search.indexDocuments(nodeId, contributorId, []).catch(() => {});
84
+ }
85
+
86
+ /** Remove everything a contributor added to a node (search + tags + metadata). */
87
+ async removeContributions(nodeId, contributorId) {
88
+ // Re-indexing with no docs clears this (node, contributor)'s vectors + keywords.
89
+ await this.#clearSearch(nodeId, contributorId);
90
+ await this.metadata.clearContribution(nodeId, contributorId);
91
+ }
92
+
93
+ /**
94
+ * Index a freshly written/uploaded node: its name (for keyword search) + every
95
+ * matching indexer's contribution.
96
+ *
97
+ * Whether this succeeded is recorded, not just logged. An item that fails to index is
98
+ * an item that cannot be found, and in a drive with no folders that is indistinguishable
99
+ * from an item that isn't there — so the failure becomes a standing issue the user can
100
+ * see and retry, and the next success clears it.
101
+ */
102
+ async indexNode(node) {
103
+ let failure = null;
104
+ try {
105
+ if (this.search) await this.search.indexName(node);
106
+ const matching = this.indexers.matching(node);
107
+ if (matching.length) {
108
+ const storage = await this.storageFor(node.collectionId);
109
+ const ctx = this.#indexCtx(node, storage);
110
+ // #runOneIndexer already contains a single indexer's failure; what escapes here
111
+ // is the shared work (reading the object, the search store itself).
112
+ for (const indexer of matching) {
113
+ const err = await this.#runOneIndexer(indexer, node, ctx);
114
+ if (err && !failure) failure = err;
115
+ }
116
+ }
117
+ } catch (err) {
118
+ failure = err;
119
+ }
120
+ await this.#recordIndexOutcome(node, failure);
121
+ if (failure) throw failure;
122
+ }
123
+
124
+ /** Raise or clear the standing "this item isn't findable" issue for a node. */
125
+ async #recordIndexOutcome(node, failure) {
126
+ if (!this.issues) return;
127
+ try {
128
+ if (!failure) {
129
+ await this.issues.clear('index', node.id);
130
+ return;
131
+ }
132
+ await this.issues.raise({
133
+ kind: 'index',
134
+ subject: node.id,
135
+ collectionId: node.collectionId,
136
+ title: `“${node.name}” could not be indexed — it won't turn up in search`,
137
+ detail: failure.message || String(failure),
138
+ retry: { op: 'reindex-node', nodeId: node.id },
139
+ });
140
+ } catch (err) {
141
+ // The issue store failing is not a reason to fail the write that triggered it.
142
+ console.error('could not record an indexing issue:', err.message);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Re-index just the NAME of a node. Split out from `indexNode` because a rename
148
+ * changes nothing about the content: re-running every content indexer (re-reading the
149
+ * blob, re-embedding it) to correct one keyword document would make renaming a file
150
+ * as expensive as uploading it.
151
+ */
152
+ async reindexName(node) {
153
+ if (this.search) await this.search.indexName(node);
154
+ }
155
+
156
+ /**
157
+ * Re-index a node by id — the retry behind an indexing issue, and the full-content
158
+ * counterpart to `reindexName`.
159
+ */
160
+ async reindexNode(nodeId) {
161
+ const node = await this.metadata.getById(nodeId);
162
+ if (!node) throw TroveError.notFound('Item');
163
+ await this.indexNode(node);
164
+ return { ok: true };
165
+ }
166
+
167
+ /** Build the context an indexer gets: capped reads + a time-limited read URL. */
168
+ #indexCtx(node, storage) {
169
+ // No Range on an object that already fits: a 0-byte file has no satisfiable range at
170
+ // all, and asking for one made every empty file permanently un-indexable — a standing
171
+ // issue whose Retry button re-ran the identical failure forever.
172
+ const readRange = () => (node.size > this.maxIndexBytes
173
+ ? storage.get(node.storageKey, { range: { start: 0, end: this.maxIndexBytes - 1 } })
174
+ : storage.get(node.storageKey));
175
+ return {
176
+ node: { id: node.id, name: node.name, collectionId: node.collectionId, size: node.size, contentType: node.contentType },
177
+ maxBytes: this.maxIndexBytes,
178
+ readBytes: async () => readAll((await readRange()).stream),
179
+ readText: async () => new TextDecoder().decode(await readAll((await readRange()).stream)),
180
+ // A time-limited URL a remote/isolated indexer can fetch the bytes from. S3-class
181
+ // backends presign; everything else gets a URL this server signed. Either way it
182
+ // must be ABSOLUTE — whoever receives it is not in this browser and not on this
183
+ // box, so a relative URL would be a link to nowhere.
184
+ presignRead: async ({ expiresIn } = {}) => {
185
+ if (!this.mintUrl) throw TroveError.unsupported('This server cannot mint read URLs for indexers');
186
+ const { url } = await this.mintUrl(node.id, { op: 'index', expiresIn, absolute: true });
187
+ return url;
188
+ },
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Run a single indexer against a node and apply its contribution. One misbehaving
194
+ * indexer must not stop the others, so the error is returned rather than thrown —
195
+ * the caller decides whether the node as a whole counts as failed.
196
+ * @returns {Promise<Error|null>}
197
+ */
198
+ async #runOneIndexer(indexer, node, ctx) {
199
+ try {
200
+ const contribution = await indexer.index(node, ctx ?? this.#indexCtx(node, await this.storageFor(node.collectionId)));
201
+ await this.#applyContribution(node.id, indexer.id, contribution);
202
+ return null;
203
+ } catch (err) {
204
+ console.error(`indexer ${indexer.id} failed on ${node.name}:`, err.message);
205
+ return err;
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Re-run one indexer over every file it matches (e.g. right after an indexer is
211
+ * installed/enabled). Walks the metadata store in pages so a large drive doesn't
212
+ * load at once. Returns how many nodes it contributed to.
213
+ */
214
+ async backfillIndexer(indexer, { limit = Infinity, pageSize = 200 } = {}) {
215
+ let done = 0;
216
+ let afterId = null;
217
+ while (done < limit) {
218
+ const files = await this.metadata.scanItems({ afterId, limit: Math.min(pageSize, limit - done) });
219
+ if (!files.length) break;
220
+ for (const node of files) {
221
+ afterId = node.id;
222
+ let matches = false;
223
+ try { matches = indexer.match(node); } catch { matches = false; }
224
+ if (!matches) continue;
225
+ await this.#runOneIndexer(indexer, node);
226
+ done++;
227
+ if (done >= limit) break;
228
+ }
229
+ if (files.length < pageSize) break;
230
+ }
231
+ return { indexed: done };
232
+ }
233
+
234
+ /**
235
+ * Rebuild the whole search index from the drive's contents.
236
+ *
237
+ * The search index is derived state — every document in it can be recomputed from
238
+ * the file it came from — so losing it is recoverable, but only if something
239
+ * actually recovers it. This is that something: the server calls it at startup when
240
+ * a non-empty drive meets an empty index (a store that was in-memory, a dropped
241
+ * vector table after an embedding change, a restore from a metadata-only backup).
242
+ *
243
+ * Every file is re-read and every matching indexer re-run, so this is proportional
244
+ * to the drive, not to what changed. It pages, tolerates a per-file failure, and
245
+ * takes an `onProgress` so a caller can report on a long rebuild instead of going
246
+ * quiet.
247
+ *
248
+ * `shouldStop` exists because a rebuild outlives the request that started it: a
249
+ * server shutting down mid-rebuild would otherwise fail on every remaining file
250
+ * against a closing database, turning one event into a page of errors. Stopping is
251
+ * safe — the index is still empty, so the next start simply rebuilds it again.
252
+ *
253
+ * @param {{pageSize?: number, onProgress?: Function, shouldStop?: () => boolean}} [opts]
254
+ */
255
+ async reindexAll({ pageSize = 200, onProgress, shouldStop } = {}) {
256
+ // Ask for a total so progress can be honest about how far along it is. A store that
257
+ // can't count leaves this null, and the caller shows an indeterminate indicator —
258
+ // which is the right answer. Inventing a total would produce a progress bar that
259
+ // lies, and that is worse than a spinner.
260
+ const total = await this.metadata.countItems?.().catch(() => null) ?? null;
261
+ let indexed = 0;
262
+ let failed = 0;
263
+ let afterId = null;
264
+ let stopped = false;
265
+ onProgress?.({ indexed, failed, total });
266
+ outer: for (;;) {
267
+ const files = await this.metadata.scanItems({ afterId, limit: pageSize });
268
+ if (!files.length) break;
269
+ for (const node of files) {
270
+ if (shouldStop?.()) { stopped = true; break outer; }
271
+ afterId = node.id;
272
+ try {
273
+ await this.indexNode(node);
274
+ indexed++;
275
+ } catch (err) {
276
+ failed++;
277
+ console.error(`reindex failed for ${node.name}:`, err.message);
278
+ }
279
+ }
280
+ onProgress?.({ indexed, failed, total });
281
+ if (files.length < pageSize) break;
282
+ }
283
+ const result = { indexed, failed, stopped, total };
284
+ await this.#recordScanOutcome(result);
285
+ return result;
286
+ }
287
+
288
+ /**
289
+ * A scan that couldn't index everything is a standing problem about the DRIVE, not
290
+ * about any one file: search is incomplete and the user has no way to know unless
291
+ * we say so. Cleared by the next clean scan — which is what makes the retry button
292
+ * on it meaningful.
293
+ */
294
+ async #recordScanOutcome({ indexed, failed, stopped }) {
295
+ if (!this.issues) return;
296
+ try {
297
+ if (!failed && !stopped) {
298
+ await this.issues.clear('reindex');
299
+ return;
300
+ }
301
+ if (stopped) return; // interrupted by shutdown; the next start picks it up
302
+ await this.issues.raise({
303
+ kind: 'reindex',
304
+ title: `${failed} item${failed === 1 ? '' : 's'} could not be indexed — search is incomplete`,
305
+ detail: `${indexed} indexed, ${failed} failed. Individual items are listed separately where they could be identified.`,
306
+ retry: { op: 'reindex-all' },
307
+ });
308
+ } catch (err) {
309
+ console.error('could not record a reindex issue:', err.message);
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Remove one contributor's contributions from every node (e.g. on uninstall).
315
+ *
316
+ * The search index is dropped in ONE bulk call — the stores can delete by indexer
317
+ * directly, so paging every node just to clear it per-node would be the same work
318
+ * done N times. Metadata still has to be walked, since a contribution lives on the
319
+ * node record.
320
+ */
321
+ async purgeIndexer(contributorId, { pageSize = 500 } = {}) {
322
+ if (this.search) await this.search.removeIndexer(contributorId).catch(() => {});
323
+ let cleared = 0;
324
+ let afterId = null;
325
+ for (;;) {
326
+ const files = await this.metadata.scanItems({ afterId, limit: pageSize });
327
+ if (!files.length) break;
328
+ for (const node of files) {
329
+ afterId = node.id;
330
+ if (node.contributions && node.contributions[contributorId]) {
331
+ await this.metadata.clearContribution(node.id, contributorId);
332
+ cleared++;
333
+ }
334
+ }
335
+ if (files.length < pageSize) break;
336
+ }
337
+ return { cleared };
338
+ }
339
+ }
340
+
@@ -0,0 +1,150 @@
1
+ // IssueRegistry — standing problems: something that was expected to happen didn't, and
2
+ // is still not fixed.
3
+ //
4
+ // This is the counterpart to TaskRegistry, and the split between them is by LIFETIME:
5
+ //
6
+ // Task in-flight, ephemeral, per-process. "A reindex is running."
7
+ // Issue standing, durable, outlives the process. "welcome.md failed to index."
8
+ //
9
+ // The distinction matters because of how these fail. A task that dies with its process
10
+ // is correctly forgotten — it isn't running any more. But a file that failed to index
11
+ // is *still* unindexed after a restart, and a console line that scrolled away three
12
+ // deploys ago is not a record of that. So issues go in the KeyValueStore, which is
13
+ // durable whenever the drive is.
14
+ //
15
+ // Three properties make this a registry rather than a log:
16
+ //
17
+ // 1. Keyed by (kind, subject), so raising the same problem twice UPDATES it. A file
18
+ // that fails to index on every retry is one issue with a count, not fifty rows.
19
+ // 2. Cleared by the success that fixes it — `clear('index', nodeId)` on a successful
20
+ // index. An issue list nobody can empty gets ignored, and then it's just a log.
21
+ // 3. Carries a declarative `retry` descriptor, so the thing that reports the problem
22
+ // also says how to attempt the fix. Most standing problems are retryable; one that
23
+ // makes the user go find the right button is only half-reported.
24
+
25
+ import { TroveError } from './errors.js';
26
+
27
+ const NS = 'issues';
28
+ /** Bounded: a systemic failure (storage down mid-reindex) must not fill the store. */
29
+ const MAX_ISSUES = 500;
30
+
31
+ /** A stable, human-readable id. Clients must encodeURIComponent it for a path segment. */
32
+ export function issueId(kind, subject) {
33
+ return subject ? `${kind}:${subject}` : kind;
34
+ }
35
+
36
+ export class IssueRegistry {
37
+ /**
38
+ * @param {{kv: import('./kv.js').KeyValueStore, now?: () => number}} deps
39
+ */
40
+ constructor({ kv, now = () => Date.now() } = {}) {
41
+ if (!kv) throw TroveError.invalid('IssueRegistry needs a KeyValueStore');
42
+ this.kv = kv;
43
+ this.now = now;
44
+ this._handlers = new Map(); // retry op -> (issue) => Promise
45
+ }
46
+
47
+ /**
48
+ * Register how to retry one class of issue. Kept as a registration rather than a
49
+ * switch inside this class so core doesn't need to know what a reindex is — the
50
+ * server, which owns the Vfs, supplies the verbs.
51
+ *
52
+ * @param {string} op
53
+ * @param {(issue: object) => Promise<any>} handler
54
+ */
55
+ handle(op, handler) {
56
+ this._handlers.set(op, handler);
57
+ }
58
+ canRetry(issue) {
59
+ return !!(issue?.retry?.op && this._handlers.has(issue.retry.op));
60
+ }
61
+
62
+ /**
63
+ * Record (or refresh) a standing problem.
64
+ *
65
+ * @param {object} spec
66
+ * @param {string} spec.kind 'index', 'reindex', 'upload' — the class of problem
67
+ * @param {string} [spec.subject] what it is about (a node id); omit for drive-wide
68
+ * @param {string} spec.title one line, in the user's terms
69
+ * @param {string} [spec.detail] the underlying error, for someone who wants it
70
+ * @param {string} [spec.severity] 'error' (default) | 'warning'
71
+ * @param {string} [spec.collectionId] scopes visibility; null = drive-wide (admin)
72
+ * @param {{op: string}} [spec.retry] declarative retry, executed by a registered handler
73
+ */
74
+ async raise(spec) {
75
+ if (!spec?.kind || !spec?.title) throw TroveError.invalid('An issue needs a kind and a title');
76
+ const id = issueId(spec.kind, spec.subject);
77
+ const existing = await this.kv.get(NS, id);
78
+ const at = this.now();
79
+ const issue = {
80
+ id,
81
+ kind: spec.kind,
82
+ subject: spec.subject ?? null,
83
+ title: spec.title,
84
+ detail: spec.detail ?? null,
85
+ severity: spec.severity || 'error',
86
+ collectionId: spec.collectionId ?? null,
87
+ retry: spec.retry ?? null,
88
+ // Keep the FIRST sighting: "failing since 09:14" is the useful fact, and it would
89
+ // be lost if every recurrence reset the clock.
90
+ firstAt: existing?.firstAt ?? at,
91
+ lastAt: at,
92
+ count: (existing?.count ?? 0) + 1,
93
+ };
94
+ await this.kv.set(NS, id, issue);
95
+ if (!existing) await this.#enforceCap();
96
+ return issue;
97
+ }
98
+
99
+ /** Remove the issue for (kind, subject) — call this on the success that resolves it. */
100
+ async clear(kind, subject) {
101
+ await this.kv.delete(NS, issueId(kind, subject));
102
+ }
103
+
104
+ async get(id) {
105
+ return this.kv.get(NS, id);
106
+ }
107
+ async remove(id) {
108
+ await this.kv.delete(NS, id);
109
+ }
110
+
111
+ /**
112
+ * @param {{collectionIds?: string[], includeGlobal?: boolean, limit?: number}} [opts]
113
+ * `collectionIds` restricts to issues about collections the caller can read. A
114
+ * drive-wide issue (collectionId null) is only for someone allowed the whole drive,
115
+ * because its title can name things a scoped reader shouldn't learn about.
116
+ */
117
+ async list({ collectionIds, includeGlobal = true, limit = 100 } = {}) {
118
+ const rows = await this.kv.list(NS, '');
119
+ return rows
120
+ .map((r) => r.value)
121
+ .filter(Boolean)
122
+ .filter((i) => (i.collectionId == null ? includeGlobal : !collectionIds || collectionIds.includes(i.collectionId)))
123
+ .sort((a, b) => b.lastAt - a.lastAt)
124
+ .slice(0, limit);
125
+ }
126
+
127
+ /**
128
+ * Attempt the fix this issue describes. The issue is NOT cleared here — it is cleared
129
+ * by whatever succeeds, so a retry that reports success while the underlying problem
130
+ * persists can't quietly hide it.
131
+ */
132
+ async retry(id) {
133
+ const issue = await this.get(id);
134
+ if (!issue) throw TroveError.notFound('Issue');
135
+ const handler = this._handlers.get(issue.retry?.op);
136
+ if (!handler) throw TroveError.invalid('This problem cannot be retried automatically');
137
+ return handler(issue);
138
+ }
139
+
140
+ async #enforceCap() {
141
+ const rows = await this.kv.list(NS, '');
142
+ if (rows.length <= MAX_ISSUES) return;
143
+ const oldest = rows
144
+ .map((r) => r.value)
145
+ .filter(Boolean)
146
+ .sort((a, b) => a.lastAt - b.lastAt)
147
+ .slice(0, rows.length - MAX_ISSUES);
148
+ for (const issue of oldest) await this.kv.delete(NS, issue.id);
149
+ }
150
+ }
Binary file
@@ -0,0 +1,141 @@
1
+ // `trove:` — the URI that addresses an ITEM in the drive.
2
+ //
3
+ // There is no folder hierarchy. A collection is a flat namespace of uniquely-named
4
+ // items, and structure comes from items linking to each other — a markdown document
5
+ // that links its sources is what a folder used to be, except it can say *why* things
6
+ // belong together, an item can appear in several of them, and the grouping is
7
+ // searchable content rather than an invisible container.
8
+ //
9
+ // So a link has to be something a person can type into a markdown file:
10
+ //
11
+ // trove:default?name=sailing.txt canonical, by name
12
+ // trove:default/sailing.txt shorthand for the same thing
13
+ // trove:default?id=fil_01H2X8F4KQ by id — survives a rename, unreadable
14
+ //
15
+ // The selector is EXPLICIT (`?name=` vs `?id=`) rather than inferred from the shape
16
+ // of the segment: a name that happens to look like an id would otherwise resolve to
17
+ // something else entirely, and a link that silently retargets is worse than one that
18
+ // visibly breaks. The `/name` shorthand is unambiguous for the same reason — that
19
+ // slot only ever means a name.
20
+ //
21
+ // Names are unique per collection, which is what makes `?name=` resolve to exactly one
22
+ // item. Renaming therefore breaks inbound links, on purpose and visibly: the alternative
23
+ // is opaque ids nobody can hand-write, which would put linking back in the UI's hands.
24
+ // `?id=` is there for links a tool inserts, where stability matters more than legibility.
25
+
26
+ import { TroveError } from './errors.js';
27
+
28
+ export const TROVE_SCHEME = 'trove:';
29
+
30
+ // An item name is capped at 255 chars (isValidItemName), so a longer selector can never
31
+ // resolve to anything. Refusing it at parse time keeps garbage out of the links index
32
+ // rather than storing kilobytes that will never match.
33
+ const MAX_SELECTOR = 255;
34
+ // How many distinct links one item may record. A document is a grouping, not a database
35
+ // dump; past this the value is in the search index, not the link graph.
36
+ export const MAX_LINKS_PER_ITEM = 500;
37
+
38
+ // Collection ids are slugs (see CollectionService); a name is anything but a slash,
39
+ // since the pathname shorthand has to stop somewhere.
40
+ const COLLECTION_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
41
+
42
+ /**
43
+ * Parse a `trove:` URI into `{ collection, by: 'name'|'id', value }`, or null if it
44
+ * isn't one. Never throws — callers parse untrusted document text with this, where a
45
+ * malformed link is a thing to render as broken, not an exception.
46
+ */
47
+ export function parseTroveUri(uri) {
48
+ if (typeof uri !== 'string' || !uri.toLowerCase().startsWith(TROVE_SCHEME)) return null;
49
+ let url;
50
+ try {
51
+ url = new URL(uri);
52
+ } catch {
53
+ return null;
54
+ }
55
+ // `trove:default/sailing.txt` → pathname "default/sailing.txt"; the slash-free case
56
+ // is `trove:default?name=…`, where the whole pathname is the collection.
57
+ const path = url.pathname;
58
+ const slash = path.indexOf('/');
59
+ const collection = slash < 0 ? path : path.slice(0, slash);
60
+ if (!COLLECTION_RE.test(collection)) return null;
61
+
62
+ const id = url.searchParams.get('id');
63
+ const name = url.searchParams.get('name');
64
+ let shorthand = '';
65
+ if (slash >= 0) {
66
+ // A malformed percent-escape is a malformed link, not an exception to propagate
67
+ // out of a document render.
68
+ try { shorthand = decodeURIComponent(path.slice(slash + 1)); } catch { return null; }
69
+ }
70
+
71
+ // Both selectors at once is a contradiction, not something to silently pick between.
72
+ if (id != null && (name != null || shorthand)) return null;
73
+ const by = id != null ? 'id' : 'name';
74
+ const value = id != null ? id : (name != null ? name : shorthand);
75
+ if (!value || value.length > MAX_SELECTOR) return null;
76
+ return { collection, by, value };
77
+ }
78
+
79
+ /** Whether `uri` is a well-formed trove: reference. */
80
+ export function isTroveUri(uri) {
81
+ return parseTroveUri(uri) != null;
82
+ }
83
+
84
+ /**
85
+ * Build a `trove:` URI. `by` defaults to 'name' — the legible form, which is what
86
+ * gets written into documents; pass 'id' for a link that must survive a rename.
87
+ */
88
+ export function troveUri(node, by = 'name') {
89
+ const collection = node?.collectionId || 'default';
90
+ if (by === 'id') {
91
+ if (!node?.id) throw TroveError.invalid('trove: link by id needs a node id');
92
+ return `${TROVE_SCHEME}${collection}?id=${encodeURIComponent(node.id)}`;
93
+ }
94
+ if (!node?.name) throw TroveError.invalid('trove: link by name needs a node name');
95
+ return `${TROVE_SCHEME}${collection}?name=${encodeURIComponent(node.name)}`;
96
+ }
97
+
98
+ /**
99
+ * Every distinct `trove:` reference in a block of text, in first-appearance order.
100
+ *
101
+ * Deliberately scans raw text rather than parsing markdown: a link is just as real in
102
+ * an HTML `href`, a bare mention, or a front-matter list, and the point of extraction
103
+ * is to know what an item references — not to reproduce one renderer's idea of a link.
104
+ * Trailing punctuation is trimmed so `see trove:default/a.md.` doesn't capture the dot.
105
+ */
106
+ export function extractTroveLinks(text, { limit = MAX_LINKS_PER_ITEM } = {}) {
107
+ if (typeof text !== 'string' || !text) return [];
108
+ const out = [];
109
+ const seen = new Set();
110
+ // Stop at whitespace and at the delimiters that wrap a URL in markdown/HTML.
111
+ const re = /trove:[^\s<>"'`)\]}]+/gi;
112
+ for (const m of text.matchAll(re)) {
113
+ if (out.length >= limit) break;
114
+ const raw = m[0].replace(/[.,;:!?]+$/, '');
115
+ const parsed = parseTroveUri(raw);
116
+ if (!parsed) continue;
117
+ const canonical = `${TROVE_SCHEME}${parsed.collection}?${parsed.by}=${encodeURIComponent(parsed.value)}`;
118
+ if (seen.has(canonical)) continue;
119
+ seen.add(canonical);
120
+ out.push({ uri: canonical, raw, ...parsed });
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /**
126
+ * The canonical form of a reference, for storing and comparing. Two links that address
127
+ * the same item by the same selector must produce the same string, or backlinks would
128
+ * miss `trove:default/a.md` when the target stored `trove:default?name=a.md`.
129
+ */
130
+ export function canonicalTroveUri(uri) {
131
+ const p = parseTroveUri(uri);
132
+ return p ? `${TROVE_SCHEME}${p.collection}?${p.by}=${encodeURIComponent(p.value)}` : null;
133
+ }
134
+
135
+ /** Both canonical forms an item can be addressed by — what a backlink query looks for. */
136
+ export function troveUrisFor(node) {
137
+ const out = [];
138
+ if (node?.collectionId && node?.name) out.push(troveUri(node, 'name'));
139
+ if (node?.collectionId && node?.id) out.push(troveUri(node, 'id'));
140
+ return out;
141
+ }