@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,666 @@
1
+ // Vfs — the façade the server talks to. It binds storage blobs, item metadata,
2
+ // search, and sidecars into user-level operations, and is collection-aware: every item
3
+ // belongs to a collection, and its bytes live in that collection's backing store. When
4
+ // a CollectionService is provided, storage is resolved per collection (so different
5
+ // collections can sit on different buckets/prefixes/filesystems); without one, a single
6
+ // storage backend serves the lone 'default' collection (the library/zero-config path).
7
+ // Nothing here is HTTP-aware.
8
+ //
9
+ // A collection is FLAT: a set of uniquely-named items, no folders and no paths.
10
+ // Grouping comes from items linking to each other with `trove:` URIs (see links.js)
11
+ // and from search. An item is addressed by id, or by `?name=` within its collection.
12
+
13
+ import { TroveError, isOutOfSpace } from './errors.js';
14
+ import { UploadManager } from './uploads.js';
15
+ import { IndexerRegistry } from './indexers/registry.js';
16
+ import { ParsingSearchTransformer, matchTagFilters } from './search/transformer.js';
17
+ import { extname } from './util.js';
18
+ import { IndexingCoordinator } from './indexing.js';
19
+ import { parseTroveUri, troveUrisFor } from './links.js';
20
+ import { CollectionScanner } from './scan.js';
21
+ import { URL_PURPOSES } from './signedUrls.js';
22
+
23
+ /** The same cap the signer applies, so a storage presign and ours agree on lifetime. */
24
+ function clampAge(op, expiresIn) {
25
+ const purpose = URL_PURPOSES[op];
26
+ if (!purpose) throw TroveError.invalid(`Unknown signed-URL purpose "${op}"`);
27
+ return Math.min(Math.max(1, expiresIn || purpose.defaultAge), purpose.maxAge);
28
+ }
29
+
30
+ const CONTENT_TYPES = {
31
+ '.txt': 'text/plain', '.md': 'text/markdown', '.html': 'text/html', '.css': 'text/css',
32
+ '.js': 'text/javascript', '.json': 'application/json', '.pdf': 'application/pdf',
33
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
34
+ '.webp': 'image/webp', '.svg': 'image/svg+xml', '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4',
35
+ '.m4b': 'audio/mp4', '.opus': 'audio/opus', '.flac': 'audio/flac', '.wav': 'audio/wav',
36
+ '.mp4': 'video/mp4', '.webm': 'video/webm', '.zip': 'application/zip',
37
+ };
38
+
39
+ export class Vfs {
40
+ constructor({ storage, metadata, search, indexers, sidecar, collections, searchTransformer, issues, signedUrls = null, publicUrl = '', maxIndexBytes = 2 * 1024 * 1024, maxUploadBytes = null, uploadPartSize = undefined }) {
41
+ if (!storage && !collections) throw TroveError.invalid('Vfs requires a storage backend or a CollectionService');
42
+ if (!metadata) throw TroveError.invalid('Vfs requires a metadata store');
43
+ this.storage = storage; // primary backend (default collection + capability reporting)
44
+ this.metadata = metadata;
45
+ this.search = search ?? null;
46
+ // Turns a raw user query into { semanticText, tagFilters } we actually dispatch.
47
+ this.searchTransformer = searchTransformer ?? new ParsingSearchTransformer();
48
+ this.sidecar = sidecar ?? null;
49
+ this.collections = collections ?? null;
50
+ this.indexers = indexers ?? new IndexerRegistry();
51
+ // One UploadManager; it resolves the right backend per session's collection.
52
+ this.uploads = new UploadManager({ storageFor: (cid) => this.storageFor(cid), maxBytes: maxUploadBytes, partSize: uploadPartSize });
53
+ this.maxIndexBytes = maxIndexBytes;
54
+ // The indexing subsystem (run/backfill/purge/contributions) lives here.
55
+ // Where a failure to index becomes a standing, retryable problem rather than a
56
+ // console line. Optional — core works without one, it just can't report.
57
+ this.issues = issues ?? null;
58
+ // Signing for the backends that cannot presign. Optional: without it, a filesystem
59
+ // collection simply has no signed URLs, exactly as before — it does not break, it
60
+ // declines. Which is also why `presignRead` still checks rather than assumes.
61
+ this.signedUrls = signedUrls;
62
+ this.publicUrl = publicUrl || '';
63
+ this.indexing = new IndexingCoordinator({
64
+ metadata: this.metadata, search: this.search, indexers: this.indexers,
65
+ storageFor: (cid) => this.storageFor(cid), maxIndexBytes, issues: this.issues,
66
+ mintUrl: (id, opts) => this.mintUrl(id, opts),
67
+ });
68
+ }
69
+
70
+ async init() {
71
+ await this.metadata.init();
72
+ if (this.collections) await this.collections.init();
73
+ }
74
+
75
+ /** Resolve the storage backend for a collection. */
76
+ async storageFor(collectionId = 'default') {
77
+ if (this.collections) return this.collections.storageFor(collectionId);
78
+ return this.storage;
79
+ }
80
+
81
+ /**
82
+ * How much room a collection's store has left, or null when it can't say.
83
+ *
84
+ * Also the place a full disk becomes a STANDING problem rather than one failed
85
+ * upload: running out of space is not a transient blip, it is a condition that will
86
+ * break every write until someone acts, and the person who needs to know may not be
87
+ * the one whose upload failed.
88
+ */
89
+ async storageUsage(collectionId = 'default') {
90
+ const storage = await this.storageFor(collectionId);
91
+ if (!storage.capabilities?.usage) return null;
92
+ const usage = await storage.usage().catch(() => null);
93
+ if (usage) await this.#reportSpace(collectionId, usage);
94
+ return usage;
95
+ }
96
+
97
+ /** Raise or clear the "running out of room" issue for a collection. */
98
+ async #reportSpace(collectionId, usage) {
99
+ if (!this.issues || !usage?.total) return;
100
+ const freeRatio = usage.available / usage.total;
101
+ const kind = 'storage-space';
102
+ try {
103
+ // Two thresholds, because "nearly full" and "full" need different words. Warning
104
+ // early is the whole point — by the time writes fail it is too late to be useful.
105
+ if (usage.available <= 0) {
106
+ await this.issues.raise({
107
+ kind, subject: collectionId, collectionId,
108
+ title: `“${collectionId}” has run out of storage — uploads will fail`,
109
+ detail: `${fmtBytes(usage.used)} used of ${fmtBytes(usage.total)}. Free some space or add capacity.`,
110
+ });
111
+ } else if (freeRatio < 0.05) {
112
+ await this.issues.raise({
113
+ kind, subject: collectionId, collectionId, severity: 'warning',
114
+ title: `“${collectionId}” is nearly out of storage — ${fmtBytes(usage.available)} left`,
115
+ detail: `${fmtBytes(usage.used)} used of ${fmtBytes(usage.total)} (${Math.round(freeRatio * 100)}% free).`,
116
+ });
117
+ } else {
118
+ await this.issues.clear(kind, collectionId);
119
+ }
120
+ } catch (err) {
121
+ console.error('could not record a storage-space issue:', err.message);
122
+ }
123
+ }
124
+
125
+ guessContentType(name) {
126
+ return CONTENT_TYPES[extname(name)] || 'application/octet-stream';
127
+ }
128
+
129
+ // --- reads -----------------------------------------------------------------
130
+
131
+ /**
132
+ * Resolve a reference to an item: an id, a `trove:` URI, or a bare name within
133
+ * `collectionId`. These are the only three ways to name something now — there are
134
+ * no paths to walk.
135
+ */
136
+ async resolve(ref, collectionId = 'default') {
137
+ const node = await this.find(ref, collectionId);
138
+ if (!node) throw TroveError.notFound('Item');
139
+ return node;
140
+ }
141
+
142
+ /** Like resolve(), but returns null instead of throwing — for link resolution,
143
+ * where "no such item" is a broken link to render, not an error. */
144
+ async find(ref, collectionId = 'default') {
145
+ // Trashed items are not part of the drive: a deleted file must not answer a link, a
146
+ // name, or a download-by-id just because someone kept the id. The trash reaches them
147
+ // through listTrash/restore, which go to the metadata store directly.
148
+ const node = await this.#findAny(ref, collectionId);
149
+ return node && !node.deletedAt ? node : null;
150
+ }
151
+
152
+ /** Resolve a reference INCLUDING trashed items — for restore and permanent delete. */
153
+ async #findAny(ref, collectionId = 'default') {
154
+ if (!ref) return null;
155
+ const link = parseTroveUri(ref);
156
+ if (link) {
157
+ return link.by === 'id'
158
+ ? this.metadata.getById(link.value)
159
+ : this.metadata.getByName(link.collection, link.value);
160
+ }
161
+ return (await this.metadata.getById(ref)) || this.metadata.getByName(collectionId, ref);
162
+ }
163
+
164
+ /** The items in a collection. */
165
+ async list(collectionId = 'default', opts = {}) {
166
+ return this.metadata.listItems(collectionId, opts);
167
+ }
168
+
169
+ async stat(ref, collectionId) {
170
+ return this.resolve(ref, collectionId);
171
+ }
172
+
173
+ /**
174
+ * Like `stat`, but the trash is visible.
175
+ *
176
+ * Restore and permanent-delete act on items `stat` deliberately cannot see, and the
177
+ * callers that needed them were reaching past this object into `metadata.getById` —
178
+ * which is a different resolver: no `trove:` URIs, no lookup by name. This is the
179
+ * one `remove` and `restore` themselves already use, said out loud.
180
+ */
181
+ async statAny(ref, collectionId) {
182
+ const node = await this.#findAny(ref, collectionId);
183
+ if (!node) throw TroveError.notFound('Item');
184
+ return node;
185
+ }
186
+
187
+ /** Items whose content links to this one — see MetadataStore.findLinksTo. */
188
+ async backlinks(id, opts = {}) {
189
+ const node = await this.resolve(id);
190
+ return this.metadata.findLinksTo(troveUrisFor(node), opts);
191
+ }
192
+
193
+ // --- mutations -------------------------------------------------------------
194
+
195
+ async writeFile(name, body, { contentType, signal, collectionId = 'default' } = {}) {
196
+ const storageKey = `obj_${cryptoId()}`;
197
+ const ct = contentType || this.guessContentType(name);
198
+ const storage = await this.storageFor(collectionId);
199
+ let info;
200
+ try {
201
+ info = await storage.put(storageKey, body, { contentType: ct, signal });
202
+ } catch (err) {
203
+ // A write that failed for lack of room is a standing condition, not one bad
204
+ // request: the next upload will fail the same way. Record it so it is visible
205
+ // before someone else hits it, then let the original error surface unchanged.
206
+ if (isOutOfSpace(err)) await this.storageUsage(collectionId).catch(() => {});
207
+ throw err;
208
+ }
209
+ const node = await this.#upsertItem({ collectionId, name, storageKey, size: info.size, contentType: ct, etag: info.etag });
210
+ // Small server-side writes index synchronously (search is ready on return);
211
+ // large client uploads (completeUpload) index in the background instead.
212
+ await this.indexing.indexNode(node).catch((e) => console.error('index error', e));
213
+ return node;
214
+ }
215
+
216
+ /**
217
+ * Attach freshly-written bytes to an item.
218
+ *
219
+ * `overwrite` decides what happens when the name is already taken. A direct
220
+ * `writeFile` replaces (the caller named an item and handed over its new contents),
221
+ * but an upload must not: two uploads of the same name can be negotiated before
222
+ * either completes — both are told the name is free, because neither item exists yet
223
+ * — and an unconditional replace at completion silently destroys whichever landed
224
+ * first. So an upload re-resolves the collision at the moment it commits.
225
+ */
226
+ async #upsertItem({ collectionId, name, storageKey, size, contentType, etag, overwrite = true }) {
227
+ let finalName = name;
228
+ const existing = await this.metadata.getByName(collectionId, name);
229
+ if (existing && overwrite) {
230
+ const oldKey = existing.storageKey;
231
+ const updated = await this.metadata.update(existing.id, { storageKey, size, contentType, etag });
232
+ if (oldKey && oldKey !== storageKey) (await this.storageFor(collectionId)).delete(oldKey).catch(() => {});
233
+ return updated;
234
+ }
235
+ if (existing) finalName = await this.#uniqueName(collectionId, name);
236
+ return this.metadata.create({ collectionId, name: finalName, storageKey, size, contentType, etag });
237
+ }
238
+
239
+ /**
240
+ * Rename an item. Inbound `trove:?name=` links break — that is the accepted cost of
241
+ * links people can write by hand, and it's visible (they render as broken) rather
242
+ * than silently retargeting at whatever later takes the name.
243
+ */
244
+ async rename(id, newName) {
245
+ const node = await this.resolve(id);
246
+ const renamed = await this.metadata.rename(node.id, newName);
247
+ // The name is INDEXED (it's how "find the file I called X" works), so a rename that
248
+ // doesn't re-index leaves the item findable only under a name it no longer has —
249
+ // which, in a drive with no folders, is the item disappearing. Awaited: renaming is
250
+ // a small, interactive operation, and it would be strange for the result to be
251
+ // stale by the time the rename returns.
252
+ await this.indexing.reindexName(renamed).catch((e) => console.error('reindex after rename failed', e));
253
+ return renamed;
254
+ }
255
+
256
+ /**
257
+ * Delete an item.
258
+ *
259
+ * By default this moves it to the TRASH: the bytes stay exactly where they are and
260
+ * the record keeps its id, but the item leaves the drive — gone from listings, from
261
+ * search, from name lookups, from backlinks. A misclick on a file you cannot get back
262
+ * is the worst thing a drive can do, and "are you sure?" is not a safety net, it is a
263
+ * dialog people click through.
264
+ *
265
+ * `permanent: true` is the old behaviour, and it is what the retention sweep and an
266
+ * explicit "delete forever" use. A store with no trash (softDelete unimplemented)
267
+ * falls back to it — better a permanent delete than a delete that silently doesn't
268
+ * happen.
269
+ *
270
+ * @param {string} id
271
+ * @param {{permanent?: boolean}} [opts]
272
+ */
273
+ async remove(id, { permanent = false } = {}) {
274
+ // #findAny, not resolve: emptying the trash deletes items that are already out of
275
+ // the drive, and resolve() correctly refuses to see those.
276
+ const node = await this.#findAny(id);
277
+ if (!node) throw TroveError.notFound('Item');
278
+ if (!permanent && this.metadata.softDelete) {
279
+ const trashed = await this.metadata.softDelete(node.id).catch((err) => {
280
+ if (err?.code !== 'unsupported') throw err;
281
+ return null;
282
+ });
283
+ if (trashed) {
284
+ // Out of the index, but NOT out of storage. Everything here is derived and is
285
+ // rebuilt on restore; the bytes are the one thing that can't be.
286
+ //
287
+ // The soft delete has already committed, so a failure here must not undo it or
288
+ // throw the whole call away — the item IS in the trash. A stale index entry is
289
+ // caught by searchQuery, which skips trashed nodes precisely because this can
290
+ // fail. Noted AFTER the clear below, under its own kind: `index` means "this
291
+ // item failed to index", which is meaningless once it's deleted and is cleared
292
+ // on the next line — raising into that kind would erase the note immediately.
293
+ const err = await this.#tryRemoveFromIndex(node.id);
294
+ await this.issues?.clear('index', node.id).catch(() => {});
295
+ if (err) await this.#note('search-cleanup', node.id, 'Removing a trashed item from the search index failed', err);
296
+ return { ok: true, trashed: true, id: node.id };
297
+ }
298
+ }
299
+ // ORDER MATTERS, and it is the opposite of the obvious one. Losing the record while
300
+ // the bytes survive is an orphan blob: wasted space nobody sees. Losing the bytes
301
+ // while the record survives is an item that lists, opens, and 404s forever — with no
302
+ // way back. So everything derived goes first, the authoritative record next, and the
303
+ // bytes last, where a failure is a leak instead of a corpse.
304
+ const searchErr = await this.#tryRemoveFromIndex(node.id);
305
+ await this.sidecar?.remove(node.id).catch(() => {});
306
+ // A deleted item can't be "failing to index" any more — leaving the issue behind
307
+ // would leave an un-fixable row pointing at nothing. This clear is also why the
308
+ // failure above is noted under a different kind, and only once this has run.
309
+ await this.issues?.clear('index', node.id).catch(() => {});
310
+ if (searchErr) await this.#note('search-cleanup', node.id, 'Removing a deleted item from the search index failed', searchErr);
311
+ await this.metadata.remove(node.id);
312
+ if (node.storageKey) {
313
+ // The catch has to cover BOTH calls. Guarding only `delete()` meant a failure in
314
+ // `storageFor` — a collection record that has gone — rejected `remove()` AFTER the
315
+ // metadata row was already gone, so the caller saw an error for a delete that had
316
+ // in fact succeeded, and the orphan-bytes issue that exists for exactly this case
317
+ // never fired.
318
+ await (async () => {
319
+ const storage = await this.storageFor(node.collectionId);
320
+ return storage.delete(node.storageKey);
321
+ })().catch((err) => this.#note(
322
+ 'orphan-bytes', node.storageKey,
323
+ `Deleted "${node.name}" but its stored bytes could not be removed`, err));
324
+ }
325
+ return { ok: true, trashed: false, id: node.id };
326
+ }
327
+
328
+ /** Drop a node from the search index, returning the error instead of throwing it. */
329
+ async #tryRemoveFromIndex(nodeId) {
330
+ try {
331
+ await this.search?.removeNode(nodeId);
332
+ return null;
333
+ } catch (err) {
334
+ return err;
335
+ }
336
+ }
337
+
338
+ /** Record a background failure as a standing problem, never throwing from the attempt. */
339
+ async #note(kind, subject, title, err) {
340
+ try {
341
+ await this.issues?.raise({
342
+ kind, subject, severity: 'warning', title,
343
+ detail: err?.message || String(err), retryable: false,
344
+ });
345
+ } catch { /* the issue registry is itself best-effort here */ }
346
+ }
347
+
348
+ /** What's in the trash, newest first. */
349
+ async listTrash(collectionId, opts) {
350
+ if (!this.metadata.listTrash) return [];
351
+ return this.metadata.listTrash(collectionId, opts);
352
+ }
353
+
354
+ /**
355
+ * Bring a trashed item back, re-indexing it so it is findable again.
356
+ *
357
+ * If its name was taken while it was in the trash, it comes back under a free one
358
+ * rather than failing — someone restoring a file wants the file, and refusing because
359
+ * of a name collision leaves them with no way to get it except to rename the other.
360
+ */
361
+ async restore(id) {
362
+ if (!this.metadata.restore) throw TroveError.unsupported('This drive has no trash');
363
+ const node = await this.metadata.getById(id);
364
+ if (!node) throw TroveError.notFound('Item');
365
+ if (!node.deletedAt) return node; // already live; restoring twice is not an error
366
+ let restored;
367
+ try {
368
+ restored = await this.metadata.restore(id);
369
+ } catch (err) {
370
+ if (err?.code !== 'already_exists') throw err;
371
+ restored = await this.metadata.restore(id, await this.#uniqueName(node.collectionId, node.name));
372
+ }
373
+ await this.indexing.indexNode(restored).catch((e) => console.error('reindex after restore failed', e));
374
+ return restored;
375
+ }
376
+
377
+ /**
378
+ * Permanently delete everything trashed before `cutoff`. Returns what it freed.
379
+ *
380
+ * This is the only thing that destroys data on a timer, so it is deliberately narrow:
381
+ * it takes an explicit cutoff rather than reading a policy, and the caller decides.
382
+ */
383
+ async purgeTrash({ before, limit = 500 } = {}) {
384
+ if (!this.metadata.trashedBefore) return { purged: 0, bytes: 0 };
385
+ const doomed = await this.metadata.trashedBefore(before, limit);
386
+ let purged = 0;
387
+ let bytes = 0;
388
+ for (const node of doomed) {
389
+ try {
390
+ await this.remove(node.id, { permanent: true });
391
+ purged++;
392
+ bytes += node.size || 0;
393
+ } catch (err) {
394
+ console.error(`purging ${node.name} failed:`, err.message);
395
+ }
396
+ }
397
+ return { purged, bytes };
398
+ }
399
+
400
+ // --- download --------------------------------------------------------------
401
+
402
+ async getDownload(id, { expiresIn, download } = {}) {
403
+ const node = await this.resolve(id);
404
+ const storage = await this.storageFor(node.collectionId);
405
+ if (storage.capabilities.presignDownload) {
406
+ const url = await storage.presignGet(node.storageKey, {
407
+ expiresIn, responseContentType: node.contentType,
408
+ downloadName: download ? node.name : undefined,
409
+ });
410
+ return { mode: 'redirect', url, node };
411
+ }
412
+ return { mode: 'proxy', node };
413
+ }
414
+
415
+ /**
416
+ * A URL that carries its own authorization, for one object, for a while.
417
+ *
418
+ * The two implementations of the same promise — see docs/design/signed-urls.md. Where
419
+ * the backend can presign, the bytes never touch this server; where it cannot, we sign
420
+ * a grant against our own download route. The CALLER cannot tell the difference, which
421
+ * is the point: an `<img src>` works on S3 and on a NAS, and an indexer can hand a file
422
+ * to an external API either way.
423
+ *
424
+ * Returns `{ url, expiresAt, node }`. `url` is relative for the self-signed case unless
425
+ * the deployment configured a `publicUrl` — a relative URL is right for a browser
426
+ * subresource and useless to an external service, so a caller that needs an absolute
427
+ * one has to ask for it (`absolute: true`) and gets a clear failure rather than a URL
428
+ * nothing off-box can fetch.
429
+ */
430
+ async mintUrl(id, { op = 'download', expiresIn, download = false, absolute = false } = {}) {
431
+ const node = await this.resolve(id);
432
+ if (!node.storageKey) throw TroveError.notFound('File content');
433
+ const storage = await this.storageFor(node.collectionId);
434
+ if (storage.capabilities.presignDownload) {
435
+ const seconds = clampAge(op, expiresIn);
436
+ const url = await storage.presignGet(node.storageKey, {
437
+ expiresIn: seconds, responseContentType: node.contentType,
438
+ downloadName: download ? node.name : undefined,
439
+ });
440
+ return { url, expiresAt: Date.now() + seconds * 1000, node, signed: 'storage' };
441
+ }
442
+ if (!this.signedUrls) {
443
+ throw TroveError.unsupported('This server cannot mint signed URLs (no signing secret configured)');
444
+ }
445
+ if (absolute && !this.publicUrl) {
446
+ throw TroveError.unsupported(
447
+ 'An absolute signed URL needs the server\'s public address — set TROVE_PUBLIC_URL',
448
+ );
449
+ }
450
+ const g = await this.signedUrls.grant(node.id, { op, expiresIn });
451
+ const q = new URLSearchParams({ id: g.id, op: g.op, exp: String(g.exp), sig: g.sig });
452
+ if (download) q.set('disposition', 'attachment');
453
+ const base = absolute ? this.publicUrl.replace(/\/+$/, '') : '';
454
+ return { url: `${base}/api/items/download?${q}`, expiresAt: g.expiresAt, node, signed: 'trove' };
455
+ }
456
+
457
+ async readStream(id, { range, signal } = {}) {
458
+ const node = await this.resolve(id);
459
+ if (!node.storageKey) throw TroveError.notFound('File content');
460
+ return (await this.storageFor(node.collectionId)).get(node.storageKey, { range, signal });
461
+ }
462
+
463
+ // --- uploads ---------------------------------------------------------------
464
+
465
+ async createUpload(req) {
466
+ const collectionId = req.collectionId || 'default';
467
+ // Never silently overwrite an existing same-named item (data loss). Disambiguate
468
+ // to "name (1).ext" so the drop is non-destructive; the client tells the user the
469
+ // final name differs. `overwrite:true` opts back into replacing in place.
470
+ const name = req.overwrite ? req.name : await this.#uniqueName(collectionId, req.name);
471
+ return this.uploads.create({
472
+ ...req, name, collectionId,
473
+ contentType: req.contentType || this.guessContentType(req.name),
474
+ });
475
+ }
476
+
477
+ /** A name that isn't taken in the collection, appending " (n)" before the extension. */
478
+ async #uniqueName(collectionId, name) {
479
+ const taken = async (n) => !!(await this.metadata.getByName(collectionId, n));
480
+ if (!(await taken(name))) return name;
481
+ const dot = name.lastIndexOf('.');
482
+ const stem = dot > 0 ? name.slice(0, dot) : name;
483
+ const ext = dot > 0 ? name.slice(dot) : '';
484
+ for (let i = 1; i < 1000; i++) {
485
+ const candidate = `${stem} (${i})${ext}`;
486
+ if (!(await taken(candidate))) return candidate;
487
+ }
488
+ return `${stem} (${Date.now()})${ext}`;
489
+ }
490
+ signUploadPart(uploadId, partNumber) {
491
+ return this.uploads.signPart(uploadId, partNumber);
492
+ }
493
+ reportUploadPart(uploadId, partNumber, etag) {
494
+ return this.uploads.reportPart(uploadId, partNumber, etag);
495
+ }
496
+ uploadPart(uploadId, partNumber, body, opts) {
497
+ return this.uploads.uploadPart(uploadId, partNumber, body, opts);
498
+ }
499
+ uploadStatus(uploadId) {
500
+ return this.uploads.status(uploadId);
501
+ }
502
+ abortUpload(uploadId) {
503
+ return this.uploads.abort(uploadId);
504
+ }
505
+
506
+ async completeUpload(uploadId, parts) {
507
+ const obj = await this.uploads.complete(uploadId, parts);
508
+ // The bytes are now committed to storage. If we can't attach them to an item
509
+ // (collection gone, name taken), delete the object so it doesn't leak.
510
+ try {
511
+ const node = await this.#upsertItem({
512
+ collectionId: obj.collectionId, name: obj.name, storageKey: obj.storageKey,
513
+ size: obj.size, contentType: obj.contentType, etag: obj.etag,
514
+ overwrite: obj.overwrite,
515
+ });
516
+ this.indexing.indexNode(node).catch((e) => console.error('index error', e));
517
+ return node;
518
+ } catch (err) {
519
+ await (await this.storageFor(obj.collectionId)).delete(obj.storageKey).catch(() => {});
520
+ throw err;
521
+ }
522
+ }
523
+
524
+ // --- user tags (sidecar CRDT + queryable mirror) ---------------------------
525
+ // A user tag lives in the sidecar (a CRDT, so concurrent edits merge) AND is
526
+ // mirrored into the queryable `user` contribution so `#tag` filters find it. Both
527
+ // sides are the façade's job — keeping the invariant here means no caller (route or
528
+ // otherwise) can set one without the other, and a mirror failure is no longer
529
+ // silently swallowed (the tag would be set but unfilterable).
530
+
531
+ async setTag(nodeId, name, value, principal) {
532
+ if (!this.sidecar) throw TroveError.unsupported('Conversations are not enabled');
533
+ const res = await this.sidecar.setTag(nodeId, name, value, principal);
534
+ await this.metadata.setContribution(nodeId, 'user', { tags: { [name]: value ?? true } });
535
+ return res;
536
+ }
537
+ async removeTag(nodeId, name, principal) {
538
+ if (!this.sidecar) throw TroveError.unsupported('Conversations are not enabled');
539
+ const res = await this.sidecar.removeTag(nodeId, name, principal);
540
+ await this.metadata.setContribution(nodeId, 'user', { tags: { [name]: null } });
541
+ return res;
542
+ }
543
+ /** Drive-wide tag/property query (delegates to the metadata store). */
544
+ findByTags(filters, opts) {
545
+ return this.metadata.findByTags(filters, opts);
546
+ }
547
+
548
+ // --- search & indexing -----------------------------------------------------
549
+
550
+ /**
551
+ * Run a raw user query: transform it into { semanticText, tagFilters } (default =
552
+ * parse `#tag` syntax; a plugged-in transformer may use an LLM), dispatch semantic
553
+ * search narrowed by the tag filters (or a pure tag filter when there's no text),
554
+ * and return the results together with the `resolved` query the client can show.
555
+ */
556
+ async query(rawQuery, opts = {}) {
557
+ let resolved;
558
+ try {
559
+ // `views` is what the CLIENT can draw with. A transformer that suggests how the
560
+ // results want to be looked at can only name one of these — the server has no
561
+ // other way to know which views a given build registered.
562
+ resolved = await this.searchTransformer.transform(rawQuery, { tagKeys: opts.tagKeys, views: opts.views });
563
+ } catch {
564
+ resolved = { semanticText: rawQuery, tagFilters: [], source: 'parse', note: 'transform-failed' };
565
+ }
566
+ const { semanticText, tagFilters = [] } = resolved;
567
+ let results;
568
+ if (semanticText && semanticText.trim()) {
569
+ // The tag filter goes INSIDE searchQuery's widening loop, not after it. Applied
570
+ // here it ran on an already-truncated page, so `sailing #draft` — one of the
571
+ // examples the transformer itself advertises — returned nothing while `#draft`
572
+ // alone returned the file.
573
+ results = await this.searchQuery(semanticText, {
574
+ ...opts,
575
+ postFilter: tagFilters.length ? (node) => matchTagFilters(node, tagFilters) : null,
576
+ });
577
+ } else if (tagFilters.length) {
578
+ const nodes = await this.metadata.findByTags(tagFilters, { collectionIds: opts.collectionIds, limit: opts.limit });
579
+ results = nodes.map((node) => ({ nodeId: node.id, score: 1, node, snippet: null }));
580
+ } else {
581
+ results = [];
582
+ }
583
+ return { results, resolved };
584
+ }
585
+
586
+ async searchQuery(query, opts = {}) {
587
+ if (!this.search) {
588
+ const items = await this.metadata.searchByName(query, opts);
589
+ return items.map((n) => ({ nodeId: n.id, score: 1, node: n, snippet: null }));
590
+ }
591
+ const want = opts.limit ?? 20;
592
+ // The index ranks across the WHOLE drive and applies its limit before we get to
593
+ // filter, so every trashed row and every collection this caller can't read still
594
+ // occupies one of the N slots. On a drive where someone else's large collection
595
+ // outranks yours, all N can be rows you can't see — and the answer that comes back
596
+ // is a confident "no matches" for files you can. So widen the ask until there are
597
+ // `want` VISIBLE results, or the index has nothing more to give. It only escalates
598
+ // when filtering actually removed something, so the common case is one query.
599
+ const ceiling = Math.min(Math.max(want, 1) * 16, 500);
600
+ let out = [];
601
+ let lastCount = -1;
602
+ for (let fetch = want; ; fetch = Math.min(fetch * 4, ceiling)) {
603
+ const results = await this.search.search(query, { ...opts, limit: fetch });
604
+ out = [];
605
+ for (const r of results) {
606
+ const node = await this.metadata.getById(r.nodeId);
607
+ if (!node) continue;
608
+ // A trashed item must never surface in search. Deleting removes it from the
609
+ // index, but that removal can fail — and when it does, the alternative to this
610
+ // check is a result the user clicks and gets a 404 for, on a file they
611
+ // deliberately deleted. getById deliberately does NOT filter (restore and purge
612
+ // both need to see it), so the filtering belongs here.
613
+ if (node.deletedAt) continue;
614
+ // Scope to the requested collections (permission-filtered by the server).
615
+ if (opts.collectionIds && !opts.collectionIds.includes(node.collectionId)) continue;
616
+ // Anything else the caller wants excluded — tag filters, for one. It has to be
617
+ // counted against `want` here, or widening chases a target it can never reach.
618
+ if (opts.postFilter && !opts.postFilter(node)) continue;
619
+ out.push({ ...r, node });
620
+ }
621
+ // Enough, or we have widened as far as we are willing to. NOT "the index returned
622
+ // fewer rows than we asked for": `search()` slices `limit` NODES out of `limit*4`
623
+ // DOCUMENTS, so one heavily-chunked file legitimately yields a short list while
624
+ // plenty more matches wait behind it — and reading that as exhaustion stopped the
625
+ // widening early, which is the failure this loop exists to prevent.
626
+ if (out.length >= want || fetch >= ceiling) break;
627
+ // Exhaustion is when widening stopped adding anything.
628
+ if (results.length === lastCount) break;
629
+ lastCount = results.length;
630
+ }
631
+ return out.slice(0, want);
632
+ }
633
+
634
+ // Indexing lives in IndexingCoordinator (this.indexing); these thin delegations keep
635
+ // the historical Vfs surface for existing callers.
636
+ indexContributions(nodeId, contributorId, contribution) { return this.indexing.indexContributions(nodeId, contributorId, contribution); }
637
+ removeContributions(nodeId, contributorId) { return this.indexing.removeContributions(nodeId, contributorId); }
638
+ backfillIndexer(indexer, opts) { return this.indexing.backfillIndexer(indexer, opts); }
639
+ purgeIndexer(contributorId, opts) { return this.indexing.purgeIndexer(contributorId, opts); }
640
+ reindexAll(opts) { return this.indexing.reindexAll(opts); }
641
+ reindexNode(nodeId) { return this.indexing.reindexNode(nodeId); }
642
+
643
+ /**
644
+ * Reconcile a collection against the bytes actually in its store — what picks up
645
+ * files added, replaced, or removed by anything that isn't Trove. See scan.js.
646
+ */
647
+ scanCollection(collectionId, opts) {
648
+ this._scanner ||= new CollectionScanner({ vfs: this, issues: this.issues });
649
+ return this._scanner.scan(collectionId, opts);
650
+ }
651
+ }
652
+
653
+ function cryptoId() {
654
+ return (globalThis.crypto?.randomUUID?.() ?? Math.random().toString(36).slice(2)).replace(/-/g, '');
655
+ }
656
+
657
+ export { CONTENT_TYPES };
658
+
659
+ /** Bytes in the units a person reads, for messages that name a real quantity. */
660
+ function fmtBytes(n) {
661
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
662
+ let v = Number(n) || 0;
663
+ let i = 0;
664
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
665
+ return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${units[i]}`;
666
+ }