@3sln/trove 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +1227 -0
  2. package/package.json +75 -0
  3. package/packages/core/src/collections/index.js +249 -0
  4. package/packages/core/src/errors.js +186 -0
  5. package/packages/core/src/identity/discovery.js +210 -0
  6. package/packages/core/src/identity/index.js +188 -0
  7. package/packages/core/src/identity/jwt.js +199 -0
  8. package/packages/core/src/index.js +104 -0
  9. package/packages/core/src/indexers/contribution.js +115 -0
  10. package/packages/core/src/indexers/registry.js +162 -0
  11. package/packages/core/src/indexing.js +340 -0
  12. package/packages/core/src/issues.js +150 -0
  13. package/packages/core/src/kv.js +0 -0
  14. package/packages/core/src/links.js +141 -0
  15. package/packages/core/src/metadata/cursor.js +73 -0
  16. package/packages/core/src/metadata/interface.js +244 -0
  17. package/packages/core/src/metadata/memory.js +270 -0
  18. package/packages/core/src/metadata/sqlite.js +412 -0
  19. package/packages/core/src/notifications/index.js +139 -0
  20. package/packages/core/src/notifications/webpush.js +217 -0
  21. package/packages/core/src/plugins/contributions.js +177 -0
  22. package/packages/core/src/plugins/identity.js +98 -0
  23. package/packages/core/src/plugins/index.js +225 -0
  24. package/packages/core/src/plugins/indexers.js +142 -0
  25. package/packages/core/src/plugins/installStore.js +134 -0
  26. package/packages/core/src/plugins/package.js +102 -0
  27. package/packages/core/src/plugins/packageStore.js +61 -0
  28. package/packages/core/src/plugins/runtime.js +101 -0
  29. package/packages/core/src/plugins/sql.js +52 -0
  30. package/packages/core/src/retry.js +74 -0
  31. package/packages/core/src/scan.js +302 -0
  32. package/packages/core/src/search/embeddings.js +128 -0
  33. package/packages/core/src/search/index.js +200 -0
  34. package/packages/core/src/search/keywordStore.js +107 -0
  35. package/packages/core/src/search/sqliteStores.js +455 -0
  36. package/packages/core/src/search/tagMatch.js +59 -0
  37. package/packages/core/src/search/transformer.js +195 -0
  38. package/packages/core/src/search/vectorStore.js +274 -0
  39. package/packages/core/src/search/vectorize.js +249 -0
  40. package/packages/core/src/sidecar/document.js +213 -0
  41. package/packages/core/src/sidecar/index.js +174 -0
  42. package/packages/core/src/sidecar/manager.js +239 -0
  43. package/packages/core/src/sidecar/store.js +46 -0
  44. package/packages/core/src/signedUrls.js +170 -0
  45. package/packages/core/src/sqlite-d1.js +162 -0
  46. package/packages/core/src/sqlite-driver.js +42 -0
  47. package/packages/core/src/sqlite.js +162 -0
  48. package/packages/core/src/storage/filesystem.js +283 -0
  49. package/packages/core/src/storage/interface.js +222 -0
  50. package/packages/core/src/storage/memory.js +113 -0
  51. package/packages/core/src/storage/prefixed.js +75 -0
  52. package/packages/core/src/storage/s3.js +316 -0
  53. package/packages/core/src/storage/s3sigv4.js +185 -0
  54. package/packages/core/src/tasks.js +228 -0
  55. package/packages/core/src/uploads.js +386 -0
  56. package/packages/core/src/util.js +125 -0
  57. package/packages/core/src/vfs.js +666 -0
  58. package/packages/plugin-sdk/src/browser.js +316 -0
  59. package/packages/plugin-sdk/src/index.js +32 -0
  60. package/packages/plugin-sdk/src/protocol.js +59 -0
  61. package/packages/plugin-sdk/src/rpc.js +95 -0
  62. package/packages/server/src/adapters/bun.js +78 -0
  63. package/packages/server/src/adapters/node.js +115 -0
  64. package/packages/server/src/adapters/staticAssets.js +123 -0
  65. package/packages/server/src/adapters/webDist.js +70 -0
  66. package/packages/server/src/adapters/worker-tasks.js +206 -0
  67. package/packages/server/src/adapters/worker.js +159 -0
  68. package/packages/server/src/cachePolicy.js +34 -0
  69. package/packages/server/src/engine/README.md +88 -0
  70. package/packages/server/src/engine/actions/scanCollection.js +114 -0
  71. package/packages/server/src/engine/index.js +95 -0
  72. package/packages/server/src/engine/lazy.js +25 -0
  73. package/packages/server/src/engine/providers/access.js +363 -0
  74. package/packages/server/src/engine/providers/core.js +405 -0
  75. package/packages/server/src/engine/providers/scan.js +67 -0
  76. package/packages/server/src/index.js +698 -0
  77. package/packages/server/src/manifest.js +98 -0
  78. package/packages/server/src/mcp/auth.js +40 -0
  79. package/packages/server/src/mcp/index.js +213 -0
  80. package/packages/server/src/mcp/protocol.js +181 -0
  81. package/packages/server/src/mcp/tools.js +351 -0
  82. package/packages/server/src/router.js +229 -0
  83. package/packages/server/src/routes.js +1066 -0
  84. package/packages/server/src/scope.js +43 -0
  85. package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
  86. package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
  87. package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
  88. package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
  89. package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
  90. package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
  91. package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
  92. package/packages/web/dist/icon.svg +11 -0
  93. package/packages/web/dist/index.html +16 -0
  94. package/packages/web/dist/sql-wasm.wasm +0 -0
  95. package/packages/web/dist/sw.js +186 -0
  96. package/packages/web/src/bl/actions.js +410 -0
  97. package/packages/web/src/bl/activity.js +306 -0
  98. package/packages/web/src/bl/commands.js +274 -0
  99. package/packages/web/src/bl/fileType.js +49 -0
  100. package/packages/web/src/bl/index.js +70 -0
  101. package/packages/web/src/bl/links.js +54 -0
  102. package/packages/web/src/bl/offline.js +268 -0
  103. package/packages/web/src/bl/openers.js +71 -0
  104. package/packages/web/src/bl/pluginInstall.js +59 -0
  105. package/packages/web/src/bl/services.js +143 -0
  106. package/packages/web/src/bl/social.js +234 -0
  107. package/packages/web/src/bl/tagQuery.js +44 -0
  108. package/packages/web/src/main.js +10 -0
  109. package/packages/web/src/platform/api.js +529 -0
  110. package/packages/web/src/platform/commands.js +89 -0
  111. package/packages/web/src/platform/context.js +77 -0
  112. package/packages/web/src/platform/contributions.js +156 -0
  113. package/packages/web/src/platform/index.js +150 -0
  114. package/packages/web/src/platform/keybindings.js +199 -0
  115. package/packages/web/src/platform/mediaUrls.js +137 -0
  116. package/packages/web/src/platform/navigation.js +131 -0
  117. package/packages/web/src/platform/notifications.js +50 -0
  118. package/packages/web/src/platform/overlay.js +81 -0
  119. package/packages/web/src/platform/pluginClientDb.js +132 -0
  120. package/packages/web/src/platform/pluginDock.js +141 -0
  121. package/packages/web/src/platform/pluginFrames.js +194 -0
  122. package/packages/web/src/platform/pluginHost.js +648 -0
  123. package/packages/web/src/platform/pluginMedia.js +62 -0
  124. package/packages/web/src/platform/pluginModules.js +90 -0
  125. package/packages/web/src/platform/pluginNet.js +71 -0
  126. package/packages/web/src/platform/pluginPackage.js +247 -0
  127. package/packages/web/src/platform/pluginRpc.js +377 -0
  128. package/packages/web/src/platform/pluginSigning.js +168 -0
  129. package/packages/web/src/platform/pluginStore.js +67 -0
  130. package/packages/web/src/platform/settings.js +101 -0
  131. package/packages/web/src/platform/spatialNav.js +286 -0
  132. package/packages/web/src/platform/viewport.js +123 -0
  133. package/packages/web/src/platform/voice.js +133 -0
  134. package/packages/web/src/platform/voiceSearch.js +155 -0
  135. package/packages/web/src/platform/whenclause.js +162 -0
  136. package/packages/web/src/platform/workbench.js +156 -0
  137. package/packages/web/src/runtime.js +73 -0
  138. package/packages/web/src/styles.css +1382 -0
  139. package/packages/web/src/ui/components/activityBar.js +35 -0
  140. package/packages/web/src/ui/components/activityPanel.js +132 -0
  141. package/packages/web/src/ui/components/commandPalette.js +154 -0
  142. package/packages/web/src/ui/components/editorArea.js +75 -0
  143. package/packages/web/src/ui/components/launcher.js +392 -0
  144. package/packages/web/src/ui/components/openers/index.js +212 -0
  145. package/packages/web/src/ui/components/openers/markdown.js +222 -0
  146. package/packages/web/src/ui/components/overlays.js +255 -0
  147. package/packages/web/src/ui/components/phoneChrome.js +188 -0
  148. package/packages/web/src/ui/components/pluginReview.js +151 -0
  149. package/packages/web/src/ui/components/pluginsView.js +120 -0
  150. package/packages/web/src/ui/components/settingsView.js +258 -0
  151. package/packages/web/src/ui/components/social.js +290 -0
  152. package/packages/web/src/ui/components/statusBar.js +198 -0
  153. package/packages/web/src/ui/components/views/grid.js +115 -0
  154. package/packages/web/src/ui/components/views/index.js +155 -0
  155. package/packages/web/src/ui/components/views/list.js +50 -0
  156. package/packages/web/src/ui/components/views/parts.js +58 -0
  157. package/packages/web/src/ui/compositions/workbench.js +125 -0
  158. package/packages/web/src/ui/format.js +33 -0
  159. package/packages/web/src/ui/icon.js +81 -0
  160. package/packages/web/src/ui/media.js +114 -0
  161. package/packages/web/src/ui/sanitize.js +86 -0
  162. package/packages/web/src/workbench.js +205 -0
@@ -0,0 +1,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
+ }