@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,101 @@
|
|
|
1
|
+
// IndexerRuntime — executes a server-side indexer sub-package against a node and
|
|
2
|
+
// returns a *clamped* contribution. It's the seam where untrusted plugin code runs,
|
|
3
|
+
// so it's pluggable: a deployment picks a runtime that matches its isolation needs.
|
|
4
|
+
//
|
|
5
|
+
// InProcessIndexerRuntime — imports the entry module and calls it directly. NO
|
|
6
|
+
// isolation; the indexer runs with the server's full
|
|
7
|
+
// authority. Only safe for TRUSTED code (first-party
|
|
8
|
+
// indexers, an admin-vetted package). It's the reference
|
|
9
|
+
// runtime and what tests use.
|
|
10
|
+
//
|
|
11
|
+
// Real isolation (a Node isolated-vm worker, a Bun worker_thread, a Cloudflare
|
|
12
|
+
// dynamic Worker loader) is a drop-in subclass that implements the same run() — see
|
|
13
|
+
// docs/design/server-plugins-and-indexers.md §5. Whatever the runtime, its output is
|
|
14
|
+
// funnelled through clampContribution() so a misbehaving indexer can't flood the
|
|
15
|
+
// index or the metadata store.
|
|
16
|
+
|
|
17
|
+
import { TroveError } from '../errors.js';
|
|
18
|
+
import { withTimeout } from '../retry.js';
|
|
19
|
+
// The contribution contract (shape + caps) lives with contributions, not with one of
|
|
20
|
+
// the runtimes that produce them. Clamping here bounds what crosses the isolate
|
|
21
|
+
// boundary into host memory; the indexing coordinator clamps again as the authority
|
|
22
|
+
// for what is actually stored.
|
|
23
|
+
import { clampContribution, DEFAULT_CAPS } from '../indexers/contribution.js';
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
export class IndexerRuntime {
|
|
27
|
+
/**
|
|
28
|
+
* Run an indexer sub-package against a node.
|
|
29
|
+
* @param {{ id: string, entry: string, files: Record<string, Uint8Array>, cacheKey?: string }} spec
|
|
30
|
+
* @param {object} node
|
|
31
|
+
* @param {object} ctx index context (readBytes/readText/presignRead/maxBytes/config/secrets)
|
|
32
|
+
* @returns {Promise<object>} a clamped contribution
|
|
33
|
+
*/
|
|
34
|
+
async run(spec, node, ctx) { throw TroveError.unsupported('IndexerRuntime.run'); }
|
|
35
|
+
async close() {}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Trusted, in-process runtime. Imports the entry module via a data: URL and invokes
|
|
40
|
+
* its default (or named `index`) export as `index(node, ctx)`. The engine caches a
|
|
41
|
+
* data: URL module, and we cache the import promise by cacheKey so re-runs are cheap.
|
|
42
|
+
*
|
|
43
|
+
* Limitations (accepted for the trusted reference runtime): the entry file must be
|
|
44
|
+
* self-contained — relative `import` between sub-package files won't resolve through a
|
|
45
|
+
* data: URL. Bundlers already collapse an indexer to one entry; multi-module loading
|
|
46
|
+
* belongs to the isolate runtimes.
|
|
47
|
+
*/
|
|
48
|
+
export class InProcessIndexerRuntime extends IndexerRuntime {
|
|
49
|
+
constructor({ timeoutMs = 10_000, caps = DEFAULT_CAPS } = {}) {
|
|
50
|
+
super();
|
|
51
|
+
this.timeoutMs = timeoutMs;
|
|
52
|
+
this.caps = { ...DEFAULT_CAPS, ...caps };
|
|
53
|
+
this._mods = new Map(); // cacheKey -> Promise<indexFn>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
#load(spec) {
|
|
57
|
+
const key = spec.cacheKey || spec.id;
|
|
58
|
+
let p = this._mods.get(key);
|
|
59
|
+
if (!p) {
|
|
60
|
+
const code = spec.files?.[spec.entry];
|
|
61
|
+
if (!code) return Promise.reject(TroveError.invalid(`Indexer "${spec.id}" is missing its entry "${spec.entry}"`));
|
|
62
|
+
// base64 data URL — percent-encoded JS confuses some runtimes' data: loader
|
|
63
|
+
// (they fall back to a text module), whereas base64 imports reliably.
|
|
64
|
+
const url = 'data:text/javascript;base64,' + bytesToBase64(code);
|
|
65
|
+
p = import(/* @vite-ignore */ url).then((mod) => {
|
|
66
|
+
const fn = mod.default || mod.index;
|
|
67
|
+
if (typeof fn !== 'function') throw TroveError.invalid(`Indexer "${spec.id}" has no default/index export`);
|
|
68
|
+
return fn;
|
|
69
|
+
});
|
|
70
|
+
this._mods.set(key, p);
|
|
71
|
+
}
|
|
72
|
+
return p;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async run(spec, node, ctx) {
|
|
76
|
+
const fn = await this.#load(spec);
|
|
77
|
+
const result = await withTimeout(
|
|
78
|
+
Promise.resolve().then(() => fn(node, ctx)),
|
|
79
|
+
this.timeoutMs,
|
|
80
|
+
`Indexer "${spec.id}" timed out after ${this.timeoutMs}ms`,
|
|
81
|
+
);
|
|
82
|
+
return clampContribution(result, this.caps);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function bytesToBase64(bytes) {
|
|
87
|
+
let bin = '';
|
|
88
|
+
const CHUNK = 0x8000;
|
|
89
|
+
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
90
|
+
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
|
|
91
|
+
}
|
|
92
|
+
return btoa(bin);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Bound an indexer's output to the caps. Never throws — a runaway or malformed
|
|
98
|
+
* contribution is trimmed to something safe rather than rejected, so one bad chunk
|
|
99
|
+
* doesn't sink the whole indexing pass.
|
|
100
|
+
*/
|
|
101
|
+
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Which SQL a plugin may run against its own database.
|
|
2
|
+
//
|
|
3
|
+
// Its own module rather than part of sqlite.js because BOTH sides of the mirror need it
|
|
4
|
+
// and only one of them can load a driver: the server checks before touching the on-disk
|
|
5
|
+
// store, and the browser checks before touching the wasm one. Importing sqlite.js from
|
|
6
|
+
// the web bundle would drag `bun:sqlite` in behind it.
|
|
7
|
+
|
|
8
|
+
import { TroveError } from '../errors.js';
|
|
9
|
+
|
|
10
|
+
// Plugins run SQL against their OWN isolated database, but on a shared-filesystem
|
|
11
|
+
// provider the sibling scope files are guessable, so `ATTACH DATABASE` would be an
|
|
12
|
+
// isolation escape (and `DETACH` its pair). Strip comments + string/identifier
|
|
13
|
+
// literals first so the keyword can't hide inside a value, then reject.
|
|
14
|
+
|
|
15
|
+
// ATTACH/DETACH would reach a sibling scope's file. VACUUM INTO is worse and less
|
|
16
|
+
// obvious: it writes a complete SQLite database to any path the server process can
|
|
17
|
+
// create, whose pages contain rows the caller chose — attacker-chosen bytes at an
|
|
18
|
+
// attacker-chosen path (a cron file, a webroot, authorized_keys). PRAGMA is refused
|
|
19
|
+
// because several of them (database_list, temp_store_directory) either disclose host
|
|
20
|
+
// paths or move where files land.
|
|
21
|
+
const DANGEROUS_SQL = /\b(ATTACH|DETACH|VACUUM|PRAGMA)\b/i;
|
|
22
|
+
|
|
23
|
+
/** Blank out --/**-comments and '..'/".."/`..`/[..] literals, preserving length-ish. */
|
|
24
|
+
export function stripSqlLiterals(sql) {
|
|
25
|
+
let out = '';
|
|
26
|
+
let i = 0;
|
|
27
|
+
while (i < sql.length) {
|
|
28
|
+
const c = sql[i];
|
|
29
|
+
if (c === '-' && sql[i + 1] === '-') { const nl = sql.indexOf('\n', i); i = nl < 0 ? sql.length : nl; continue; }
|
|
30
|
+
if (c === '/' && sql[i + 1] === '*') { const e = sql.indexOf('*/', i + 2); i = e < 0 ? sql.length : e + 2; out += ' '; continue; }
|
|
31
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
32
|
+
const q = c; i++;
|
|
33
|
+
while (i < sql.length) {
|
|
34
|
+
if (sql[i] === q) { if (sql[i + 1] === q) { i += 2; continue; } i++; break; }
|
|
35
|
+
i++;
|
|
36
|
+
}
|
|
37
|
+
out += ' '; continue;
|
|
38
|
+
}
|
|
39
|
+
if (c === '[') { const e = sql.indexOf(']', i); i = e < 0 ? sql.length : e + 1; out += ' '; continue; }
|
|
40
|
+
out += c; i++;
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Throw if plugin-supplied SQL tries to escape its isolated database. */
|
|
46
|
+
export function assertSafePluginSql(sql) {
|
|
47
|
+
if (typeof sql !== 'string' || !sql) throw TroveError.invalid('SQL statement is required');
|
|
48
|
+
if (DANGEROUS_SQL.test(stripSqlLiterals(sql))) {
|
|
49
|
+
throw TroveError.invalid('ATTACH, DETACH, VACUUM and PRAGMA are not permitted in plugin storage');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Retry with exponential backoff + full jitter. Only retries errors classified
|
|
2
|
+
// retryable (see errors.js), respects an AbortSignal between attempts, and
|
|
3
|
+
// surfaces the last error untouched when it gives up — so the caller still sees
|
|
4
|
+
// a clean TroveError, not a generic "retries exhausted".
|
|
5
|
+
|
|
6
|
+
import { isRetryable, wrapError, TroveError } from './errors.js';
|
|
7
|
+
|
|
8
|
+
const DEFAULTS = {
|
|
9
|
+
retries: 4, // attempts after the first = 5 total tries
|
|
10
|
+
minDelayMs: 250,
|
|
11
|
+
maxDelayMs: 8000,
|
|
12
|
+
factor: 2,
|
|
13
|
+
jitter: true,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function sleep(ms, signal) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
if (signal?.aborted) return reject(TroveError.aborted());
|
|
19
|
+
// `{ once: true }` removes the listener when it FIRES — not when the timer wins,
|
|
20
|
+
// which is the normal case. One long-lived signal driving a few retried operations
|
|
21
|
+
// therefore accumulated a listener per attempt and released none of them.
|
|
22
|
+
const done = (fn) => (arg) => {
|
|
23
|
+
clearTimeout(t);
|
|
24
|
+
signal?.removeEventListener('abort', onAbort);
|
|
25
|
+
fn(arg);
|
|
26
|
+
};
|
|
27
|
+
const t = setTimeout(() => done(resolve)(), ms);
|
|
28
|
+
const onAbort = () => done(reject)(TroveError.aborted());
|
|
29
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @template T
|
|
35
|
+
* @param {(attempt: number) => Promise<T>} fn
|
|
36
|
+
* @param {object} [opts]
|
|
37
|
+
* @param {number} [opts.retries]
|
|
38
|
+
* @param {AbortSignal} [opts.signal]
|
|
39
|
+
* @param {(info: {attempt: number, delayMs: number, error: TroveError}) => void} [opts.onRetry]
|
|
40
|
+
* @param {(err: unknown) => boolean} [opts.shouldRetry] override classification
|
|
41
|
+
* @returns {Promise<T>}
|
|
42
|
+
*/
|
|
43
|
+
export async function withRetry(fn, opts = {}) {
|
|
44
|
+
const cfg = { ...DEFAULTS, ...opts };
|
|
45
|
+
const shouldRetry = cfg.shouldRetry ?? isRetryable;
|
|
46
|
+
let attempt = 0;
|
|
47
|
+
|
|
48
|
+
for (;;) {
|
|
49
|
+
if (cfg.signal?.aborted) throw TroveError.aborted();
|
|
50
|
+
try {
|
|
51
|
+
return await fn(attempt);
|
|
52
|
+
} catch (raw) {
|
|
53
|
+
const err = wrapError(raw);
|
|
54
|
+
const canRetry = attempt < cfg.retries && shouldRetry(err) && err.code !== 'aborted';
|
|
55
|
+
if (!canRetry) throw err;
|
|
56
|
+
|
|
57
|
+
const base = Math.min(cfg.maxDelayMs, cfg.minDelayMs * cfg.factor ** attempt);
|
|
58
|
+
const delayMs = cfg.jitter ? Math.random() * base : base;
|
|
59
|
+
cfg.onRetry?.({ attempt: attempt + 1, delayMs, error: err });
|
|
60
|
+
await sleep(delayMs, cfg.signal);
|
|
61
|
+
attempt++;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Reject after `ms`, unless `promise` settles first. Cleans up its timer. */
|
|
67
|
+
export function withTimeout(promise, ms, message = 'Operation timed out') {
|
|
68
|
+
if (!ms || ms <= 0) return promise;
|
|
69
|
+
let t;
|
|
70
|
+
const timeout = new Promise((_, reject) => {
|
|
71
|
+
t = setTimeout(() => reject(TroveError.timeout(message)), ms);
|
|
72
|
+
});
|
|
73
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(t));
|
|
74
|
+
}
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
// CollectionScanner — reconcile a collection against the bytes actually in its store.
|
|
2
|
+
//
|
|
3
|
+
// Trove is not the only thing that can touch a bucket. Another tool writes to it, a
|
|
4
|
+
// teammate drags a folder in with the S3 console, a sync client replaces an object in
|
|
5
|
+
// place, a lifecycle rule expires something. Every one of those leaves the drive
|
|
6
|
+
// describing a world that no longer exists — and because there are no folders here,
|
|
7
|
+
// "it isn't in the list" is indistinguishable from "it was never there".
|
|
8
|
+
//
|
|
9
|
+
// So this walks both sides and names the four things that can be true of an object:
|
|
10
|
+
//
|
|
11
|
+
// known & unchanged nothing to do (the overwhelming majority — keep it cheap)
|
|
12
|
+
// in the store only ADOPT: create an item for it, named from its key
|
|
13
|
+
// changed in place REFRESH: same key, different etag/size → re-read and re-index
|
|
14
|
+
// in metadata only ORPHANED: the bytes are gone. Reported, never auto-deleted.
|
|
15
|
+
//
|
|
16
|
+
// The asymmetry in that last pair is deliberate. Adopting a file is additive and
|
|
17
|
+
// reversible; deleting an item because a LIST call didn't mention it is neither, and
|
|
18
|
+
// list calls are exactly the operation that goes wrong in interesting ways — a
|
|
19
|
+
// misconfigured prefix, an eventually-consistent replica, a credential scoped to the
|
|
20
|
+
// wrong path. Trove will happily invent an item from bytes it can see. It will not
|
|
21
|
+
// destroy a record because it briefly couldn't see any.
|
|
22
|
+
|
|
23
|
+
import { TroveError } from './errors.js';
|
|
24
|
+
import { extname } from './util.js';
|
|
25
|
+
import { PACKAGE_PREFIX } from './plugins/packageStore.js';
|
|
26
|
+
|
|
27
|
+
/** Objects Trove wrote itself. Anything else in the store arrived some other way. */
|
|
28
|
+
const TROVE_KEY = /^obj_[0-9a-f]+$/i;
|
|
29
|
+
/**
|
|
30
|
+
* Keys that are Trove's own bookkeeping, not user data.
|
|
31
|
+
*
|
|
32
|
+
* These MUST match what the writers actually use, and one of them didn't: the package
|
|
33
|
+
* store writes under `_plugins/` (StoragePackageStore's default prefix) while this list
|
|
34
|
+
* said `plugins/`. Since that store wraps the primary backend — which is also the
|
|
35
|
+
* `default` collection's — every account's uploaded plugin zip sat in the default
|
|
36
|
+
* collection's key space as an unreserved object, and a scan adopted it: the package
|
|
37
|
+
* bytes became a file anyone with read on `default` could download, its name leaked the
|
|
38
|
+
* installing account's id (usually an email), its contents went into the shared search
|
|
39
|
+
* index, and because the adopted item's storageKey IS the real blob key, deleting it
|
|
40
|
+
* destroyed the owner's package.
|
|
41
|
+
*
|
|
42
|
+
* `plugins/` and `packages/` are kept as well — they cost nothing and a store
|
|
43
|
+
* constructed with a different prefix should still be skipped.
|
|
44
|
+
*/
|
|
45
|
+
const RESERVED_PREFIXES = ['sidecars/', PACKAGE_PREFIX, 'packages/', 'plugins/'];
|
|
46
|
+
|
|
47
|
+
export class CollectionScanner {
|
|
48
|
+
/**
|
|
49
|
+
* @param {object} deps
|
|
50
|
+
* @param {import('./vfs.js').Vfs} deps.vfs
|
|
51
|
+
* @param {import('./issues.js').IssueRegistry} [deps.issues]
|
|
52
|
+
*/
|
|
53
|
+
constructor({ vfs, issues = null }) {
|
|
54
|
+
this.vfs = vfs;
|
|
55
|
+
this.issues = issues;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reconcile one collection.
|
|
60
|
+
*
|
|
61
|
+
* @param {string} collectionId
|
|
62
|
+
* @param {object} [opts]
|
|
63
|
+
* @param {boolean} [opts.adopt] create items for unknown objects (default true)
|
|
64
|
+
* @param {boolean} [opts.refresh] re-index items whose bytes changed (default true)
|
|
65
|
+
* @param {number} [opts.pageSize]
|
|
66
|
+
* @param {(p: object) => void} [opts.onProgress]
|
|
67
|
+
* @param {() => boolean} [opts.shouldStop]
|
|
68
|
+
* @param {string|null} [opts.cursor] resume a scan that stopped early (see below)
|
|
69
|
+
* @returns {Promise<{scanned, adopted, refreshed, orphaned, skipped, failed, stopped, nextCursor}>}
|
|
70
|
+
*
|
|
71
|
+
* A scan that stops early returns the `nextCursor` it had reached, so the next call
|
|
72
|
+
* can pick the bucket up where this one left off. That is what makes this usable on a
|
|
73
|
+
* runtime with a hard execution budget — Cloudflare Workers, where a request has a CPU
|
|
74
|
+
* ceiling measured in seconds and a bucket has no ceiling at all. Without it the only
|
|
75
|
+
* options were "finishes" and "starts from the beginning again forever".
|
|
76
|
+
*/
|
|
77
|
+
async scan(collectionId = 'default', opts = {}) {
|
|
78
|
+
const { adopt = true, refresh = true, pageSize = 500, onProgress, shouldStop } = opts;
|
|
79
|
+
const storage = await this.vfs.storageFor(collectionId);
|
|
80
|
+
if (!storage.capabilities?.list) {
|
|
81
|
+
throw TroveError.unsupported('This collection\'s storage backend cannot list its contents');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// One pass over metadata first, so the comparison is a map lookup per object rather
|
|
85
|
+
// than a query per object — a bucket scan that did a round trip per key would take
|
|
86
|
+
// hours on a real drive.
|
|
87
|
+
const byKey = new Map();
|
|
88
|
+
for (const node of await this.#allItems(collectionId)) {
|
|
89
|
+
if (node.storageKey) byKey.set(node.storageKey, node);
|
|
90
|
+
}
|
|
91
|
+
// The trash holds rows too, and their bytes are deliberately still in the store —
|
|
92
|
+
// that is what makes a delete undoable. `listItems` is live-only by design, so
|
|
93
|
+
// without this a trashed file's object looks exactly like one that arrived from
|
|
94
|
+
// outside: the scan adopts it AGAIN, resurrecting the deleted file under a new id
|
|
95
|
+
// that shares the original's storage key. Emptying the trash then deletes the live
|
|
96
|
+
// copy's bytes, leaving an item that lists, opens, and 404s forever.
|
|
97
|
+
const trashedKeys = await this.vfs.metadata.trashedStorageKeys?.(collectionId) ?? new Set();
|
|
98
|
+
|
|
99
|
+
const result = {
|
|
100
|
+
scanned: 0, adopted: 0, refreshed: 0, orphaned: 0, skipped: 0, failed: 0,
|
|
101
|
+
stopped: false, unaddressable: 0, nextCursor: null, resumed: !!opts.cursor,
|
|
102
|
+
};
|
|
103
|
+
const seen = new Set();
|
|
104
|
+
let cursor = opts.cursor || null;
|
|
105
|
+
|
|
106
|
+
outer: for (;;) {
|
|
107
|
+
const page = await storage.list({ cursor, limit: pageSize });
|
|
108
|
+
result.unaddressable += page.unaddressable || 0;
|
|
109
|
+
for (const object of page.objects) {
|
|
110
|
+
// Stop at a PAGE boundary when we can, so the cursor we hand back is one the
|
|
111
|
+
// store will honour. Mid-page there is no cursor to express "and 37 objects in",
|
|
112
|
+
// so those are re-examined next time — which is cheap and idempotent.
|
|
113
|
+
if (shouldStop?.()) { result.stopped = true; result.nextCursor = cursor; break outer; }
|
|
114
|
+
result.scanned++;
|
|
115
|
+
if (this.#reserved(object.key)) { result.skipped++; continue; }
|
|
116
|
+
seen.add(object.key);
|
|
117
|
+
// Bytes belonging to something in the trash are accounted for. Not adopted (it
|
|
118
|
+
// is already ours), and not orphaned (`seen` covers that below).
|
|
119
|
+
if (trashedKeys.has(object.key)) { result.skipped++; continue; }
|
|
120
|
+
const node = byKey.get(object.key);
|
|
121
|
+
try {
|
|
122
|
+
if (!node) {
|
|
123
|
+
// #adopt declines Trove's own orphaned blobs, so count what it actually did
|
|
124
|
+
// rather than assuming — otherwise a drive full of leftover `obj_` keys
|
|
125
|
+
// would report a large, entirely fictional adoption.
|
|
126
|
+
const created = adopt ? await this.#adopt(collectionId, object) : null;
|
|
127
|
+
if (created) result.adopted++;
|
|
128
|
+
else result.skipped++;
|
|
129
|
+
} else if (refresh && this.#changed(node, object)) {
|
|
130
|
+
await this.#refresh(node, object);
|
|
131
|
+
result.refreshed++;
|
|
132
|
+
}
|
|
133
|
+
} catch (err) {
|
|
134
|
+
result.failed++;
|
|
135
|
+
console.error(`scan failed on ${object.key}:`, err.message);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
onProgress?.({ ...result });
|
|
139
|
+
cursor = page.nextCursor;
|
|
140
|
+
if (!cursor) break;
|
|
141
|
+
// Between pages is the cheapest place to give up, and the only place a resume is
|
|
142
|
+
// exact.
|
|
143
|
+
if (shouldStop?.()) { result.stopped = true; result.nextCursor = cursor; break; }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Orphans are only meaningful after a COMPLETE pass — and "complete" now means this
|
|
147
|
+
// call reached the end of the bucket having STARTED at the beginning. A scan that was
|
|
148
|
+
// cut short, or one resuming from a cursor, has not seen the whole store, and calling
|
|
149
|
+
// the items it didn't reach orphaned would be a false alarm about data loss.
|
|
150
|
+
if (!result.stopped && !result.resumed) {
|
|
151
|
+
for (const [key, node] of byKey) {
|
|
152
|
+
if (!seen.has(key) && !this.#reserved(key)) result.orphaned++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
await this.#report(collectionId, result);
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Every item in the collection, paged so a large drive doesn't load at once. */
|
|
161
|
+
async #allItems(collectionId) {
|
|
162
|
+
const items = [];
|
|
163
|
+
let cursor = null;
|
|
164
|
+
for (;;) {
|
|
165
|
+
const page = await this.vfs.metadata.listItems(collectionId, { limit: 500, cursor });
|
|
166
|
+
items.push(...page.items);
|
|
167
|
+
if (!page.nextCursor) return items;
|
|
168
|
+
cursor = page.nextCursor;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#reserved(key) {
|
|
173
|
+
return RESERVED_PREFIXES.some((p) => key.startsWith(p));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Did the bytes behind an item change without us? ETag first (it is what object
|
|
178
|
+
* stores actually promise), size as the fallback for backends that don't give one.
|
|
179
|
+
* An item we have no etag for is left alone rather than re-indexed on every scan.
|
|
180
|
+
*/
|
|
181
|
+
#changed(node, object) {
|
|
182
|
+
if (object.etag && node.etag) return normalizeEtag(object.etag) !== normalizeEtag(node.etag);
|
|
183
|
+
if (typeof object.size === 'number' && typeof node.size === 'number') return object.size !== node.size;
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Create an item for an object that arrived without us.
|
|
189
|
+
*
|
|
190
|
+
* The key becomes the name, because in a bucket the key IS what a human called the
|
|
191
|
+
* thing — `holiday/2019/beach.jpg` is a name someone chose, and flattening it to
|
|
192
|
+
* `beach.jpg` would collide with every other year's. Trove's own `obj_<hex>` keys are
|
|
193
|
+
* excluded: one of those with no metadata row is a leftover from a failed write, not
|
|
194
|
+
* a file someone put there, and adopting it would surface `obj_9fc0…` as a document.
|
|
195
|
+
*/
|
|
196
|
+
async #adopt(collectionId, object) {
|
|
197
|
+
if (TROVE_KEY.test(object.key)) return null; // orphaned blob from an interrupted upload
|
|
198
|
+
const name = await this.#uniqueName(collectionId, object.key);
|
|
199
|
+
const node = await this.vfs.metadata.create({
|
|
200
|
+
collectionId,
|
|
201
|
+
name,
|
|
202
|
+
storageKey: object.key,
|
|
203
|
+
size: object.size ?? 0,
|
|
204
|
+
etag: object.etag ?? null,
|
|
205
|
+
contentType: this.vfs.guessContentType(name),
|
|
206
|
+
meta: { adopted: true, adoptedAt: Date.now() },
|
|
207
|
+
});
|
|
208
|
+
// Adopted files are indexed like any other, so they are findable immediately —
|
|
209
|
+
// an item you can't search for is barely an item in a drive with no folders.
|
|
210
|
+
await this.vfs.indexing.indexNode(node).catch(() => {});
|
|
211
|
+
return node;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Re-read an item whose bytes were replaced in place. */
|
|
215
|
+
async #refresh(node, object) {
|
|
216
|
+
const updated = await this.vfs.metadata.update(node.id, {
|
|
217
|
+
size: object.size ?? node.size,
|
|
218
|
+
etag: object.etag ?? null,
|
|
219
|
+
});
|
|
220
|
+
await this.vfs.indexing.indexNode(updated).catch(() => {});
|
|
221
|
+
return updated;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async #uniqueName(collectionId, base) {
|
|
225
|
+
if (!(await this.vfs.metadata.getByName(collectionId, base))) return base;
|
|
226
|
+
const ext = extname(base);
|
|
227
|
+
const stem = ext ? base.slice(0, -ext.length) : base;
|
|
228
|
+
for (let i = 1; i < 1000; i++) {
|
|
229
|
+
const candidate = `${stem} (${i})${ext}`;
|
|
230
|
+
if (!(await this.vfs.metadata.getByName(collectionId, candidate))) return candidate;
|
|
231
|
+
}
|
|
232
|
+
throw TroveError.invalid(`Cannot find a free name for "${base}"`);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Turn the outcome into something a human sees.
|
|
237
|
+
*
|
|
238
|
+
* Orphans and unreachable files are raised as a standing issue rather than logged,
|
|
239
|
+
* because they are exactly the kind of fact that matters days later: "17 items point
|
|
240
|
+
* at bytes that are gone" is a data-loss report, and it should still be on screen
|
|
241
|
+
* tomorrow if nobody has dealt with it. A clean scan clears it.
|
|
242
|
+
*/
|
|
243
|
+
async #report(collectionId, result) {
|
|
244
|
+
if (!this.issues || result.stopped) return;
|
|
245
|
+
try {
|
|
246
|
+
if (result.orphaned) {
|
|
247
|
+
await this.issues.raise({
|
|
248
|
+
kind: 'orphaned',
|
|
249
|
+
subject: collectionId,
|
|
250
|
+
collectionId,
|
|
251
|
+
severity: 'warning',
|
|
252
|
+
title: result.orphaned === 1
|
|
253
|
+
? `1 item in “${collectionId}” points at a file that is no longer in the store`
|
|
254
|
+
: `${result.orphaned} items in “${collectionId}” point at files that are no longer in the store`,
|
|
255
|
+
detail: 'Their bytes were removed outside Trove. Nothing was deleted automatically — '
|
|
256
|
+
+ 'the records are kept so you can decide, because a listing that briefly misses objects '
|
|
257
|
+
+ 'must never be able to destroy data.',
|
|
258
|
+
retry: { op: 'scan-collection', collectionId },
|
|
259
|
+
});
|
|
260
|
+
} else {
|
|
261
|
+
await this.issues.clear('orphaned', collectionId);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (result.unaddressable) {
|
|
265
|
+
await this.issues.raise({
|
|
266
|
+
kind: 'unaddressable',
|
|
267
|
+
subject: collectionId,
|
|
268
|
+
collectionId,
|
|
269
|
+
severity: 'warning',
|
|
270
|
+
title: result.unaddressable === 1
|
|
271
|
+
? '1 file in the data directory is not where Trove can read it'
|
|
272
|
+
: `${result.unaddressable} files in the data directory are not where Trove can read them`,
|
|
273
|
+
detail: 'Files copied directly into the storage root have to sit at the path their name maps to. '
|
|
274
|
+
+ 'Upload them through Trove instead, or move them into place and scan again.',
|
|
275
|
+
retry: { op: 'scan-collection', collectionId },
|
|
276
|
+
});
|
|
277
|
+
} else {
|
|
278
|
+
await this.issues.clear('unaddressable', collectionId);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (result.failed) {
|
|
282
|
+
await this.issues.raise({
|
|
283
|
+
kind: 'scan',
|
|
284
|
+
subject: collectionId,
|
|
285
|
+
collectionId,
|
|
286
|
+
title: `${result.failed} object${result.failed === 1 ? '' : 's'} in “${collectionId}” could not be reconciled`,
|
|
287
|
+
detail: `${result.scanned} scanned, ${result.adopted} adopted, ${result.refreshed} refreshed, ${result.failed} failed.`,
|
|
288
|
+
retry: { op: 'scan-collection', collectionId },
|
|
289
|
+
});
|
|
290
|
+
} else {
|
|
291
|
+
await this.issues.clear('scan', collectionId);
|
|
292
|
+
}
|
|
293
|
+
} catch (err) {
|
|
294
|
+
console.error('could not record a scan issue:', err.message);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** S3 etags are quoted and some backends aren't; compare the value, not the quoting. */
|
|
300
|
+
function normalizeEtag(etag) {
|
|
301
|
+
return String(etag).replace(/^W\//, '').replace(/^"|"$/g, '');
|
|
302
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// EmbeddingProvider — turns text into vectors. Pluggable so deployments choose
|
|
2
|
+
// their model/cost tradeoff. Two are bundled:
|
|
3
|
+
// - LocalHashEmbedding: zero-dependency, offline, deterministic. A hashed
|
|
4
|
+
// bag-of-bigrams projected to a fixed dim and L2-normalised. Not as good as
|
|
5
|
+
// a real model, but makes semantic search work out of the box and in tests.
|
|
6
|
+
// - HttpEmbedding: POSTs to any OpenAI-compatible /embeddings endpoint (OpenAI,
|
|
7
|
+
// Ollama, LM Studio, a self-hosted model). This is the production path.
|
|
8
|
+
// Both expose `dimensions` and `embed(texts) -> number[][]`.
|
|
9
|
+
|
|
10
|
+
import { withRetry } from '../retry.js';
|
|
11
|
+
import { TroveError, wrapError } from '../errors.js';
|
|
12
|
+
|
|
13
|
+
export class EmbeddingProvider {
|
|
14
|
+
get dimensions() {
|
|
15
|
+
return 0;
|
|
16
|
+
}
|
|
17
|
+
/** @param {string[]} texts @returns {Promise<number[][]>} */
|
|
18
|
+
async embed(texts) {
|
|
19
|
+
throw TroveError.unsupported('embed not implemented');
|
|
20
|
+
}
|
|
21
|
+
async embedOne(text) {
|
|
22
|
+
return (await this.embed([text]))[0];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const STOP = new Set('a an the of to in on for and or is are be as at by with from this that it'.split(' '));
|
|
27
|
+
|
|
28
|
+
function tokenize(text) {
|
|
29
|
+
return String(text)
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9\s]/g, ' ')
|
|
32
|
+
.split(/\s+/)
|
|
33
|
+
.filter((t) => t.length > 1 && !STOP.has(t));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// FNV-1a → 32-bit, used to bucket features into vector dims.
|
|
37
|
+
function fnv(str) {
|
|
38
|
+
let h = 2166136261;
|
|
39
|
+
for (let i = 0; i < str.length; i++) {
|
|
40
|
+
h ^= str.charCodeAt(i);
|
|
41
|
+
h = Math.imul(h, 16777619);
|
|
42
|
+
}
|
|
43
|
+
return h >>> 0;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export class LocalHashEmbedding extends EmbeddingProvider {
|
|
47
|
+
constructor({ dimensions = 256 } = {}) {
|
|
48
|
+
super();
|
|
49
|
+
this._dim = dimensions;
|
|
50
|
+
}
|
|
51
|
+
get dimensions() {
|
|
52
|
+
return this._dim;
|
|
53
|
+
}
|
|
54
|
+
async embed(texts) {
|
|
55
|
+
return texts.map((t) => this.#vec(t));
|
|
56
|
+
}
|
|
57
|
+
#vec(text) {
|
|
58
|
+
const v = new Float64Array(this._dim);
|
|
59
|
+
const tokens = tokenize(text);
|
|
60
|
+
// Unigrams + bigrams give a little word-order sensitivity.
|
|
61
|
+
const feats = [...tokens];
|
|
62
|
+
for (let i = 0; i < tokens.length - 1; i++) feats.push(tokens[i] + '_' + tokens[i + 1]);
|
|
63
|
+
for (const f of feats) {
|
|
64
|
+
const h = fnv(f);
|
|
65
|
+
const idx = h % this._dim;
|
|
66
|
+
const sign = (h >> 31) & 1 ? -1 : 1; // signed hashing reduces collisions
|
|
67
|
+
v[idx] += sign;
|
|
68
|
+
}
|
|
69
|
+
// L2 normalise so dot product == cosine similarity.
|
|
70
|
+
let norm = 0;
|
|
71
|
+
for (let i = 0; i < v.length; i++) norm += v[i] * v[i];
|
|
72
|
+
norm = Math.sqrt(norm) || 1;
|
|
73
|
+
return Array.from(v, (x) => x / norm);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class HttpEmbedding extends EmbeddingProvider {
|
|
78
|
+
/**
|
|
79
|
+
* @param {object} cfg
|
|
80
|
+
* @param {string} cfg.url e.g. https://api.openai.com/v1/embeddings
|
|
81
|
+
* @param {string} [cfg.apiKey]
|
|
82
|
+
* @param {string} [cfg.model] e.g. text-embedding-3-small
|
|
83
|
+
* @param {number} cfg.dimensions the model's output dim (must match your index)
|
|
84
|
+
* @param {number} [cfg.batchSize]
|
|
85
|
+
*/
|
|
86
|
+
constructor(cfg) {
|
|
87
|
+
super();
|
|
88
|
+
if (!cfg?.url || !cfg?.dimensions) throw TroveError.invalid('HttpEmbedding requires url and dimensions');
|
|
89
|
+
this.cfg = cfg;
|
|
90
|
+
}
|
|
91
|
+
get dimensions() {
|
|
92
|
+
return this.cfg.dimensions;
|
|
93
|
+
}
|
|
94
|
+
async embed(texts) {
|
|
95
|
+
const batchSize = this.cfg.batchSize ?? 64;
|
|
96
|
+
const out = [];
|
|
97
|
+
for (let i = 0; i < texts.length; i += batchSize) {
|
|
98
|
+
const batch = texts.slice(i, i + batchSize);
|
|
99
|
+
out.push(...(await this.#embedBatch(batch)));
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
async #embedBatch(batch) {
|
|
104
|
+
return withRetry(async () => {
|
|
105
|
+
let res;
|
|
106
|
+
try {
|
|
107
|
+
res = await fetch(this.cfg.url, {
|
|
108
|
+
method: 'POST',
|
|
109
|
+
headers: {
|
|
110
|
+
'content-type': 'application/json',
|
|
111
|
+
...(this.cfg.apiKey ? { authorization: `Bearer ${this.cfg.apiKey}` } : {}),
|
|
112
|
+
},
|
|
113
|
+
body: JSON.stringify({ model: this.cfg.model, input: batch }),
|
|
114
|
+
});
|
|
115
|
+
} catch (err) {
|
|
116
|
+
throw wrapError(err);
|
|
117
|
+
}
|
|
118
|
+
if (res.status === 429 || res.status >= 500) {
|
|
119
|
+
throw TroveError.transient(`Embedding endpoint ${res.status}`);
|
|
120
|
+
}
|
|
121
|
+
if (!res.ok) throw TroveError.internal(`Embedding endpoint failed: ${res.status}`);
|
|
122
|
+
const json = await res.json();
|
|
123
|
+
// OpenAI shape: { data: [{ embedding: [...] }, ...] }
|
|
124
|
+
const data = json.data ?? json.embeddings ?? [];
|
|
125
|
+
return data.map((d) => d.embedding ?? d);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|