@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.
- package/README.md +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- 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
|
+
}
|