@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,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
|
+
}
|