@3sln/trove 0.0.16 → 0.0.17

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3sln/trove",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "type": "module",
5
5
  "description": "Trove — a self-hostable, plugin-extensible Google Drive. Semantic search, pluggable storage (S3 / filesystem / NAS), and a VS Code-style contribution system with sandboxed plugins.",
6
6
  "repository": {
@@ -36,7 +36,7 @@ export { EmbeddingProvider, LocalHashEmbedding, HttpEmbedding } from './search/e
36
36
  export { SqliteVectorStore, SqliteKeywordStore, SEARCH_DB_KEY } from './search/sqliteStores.js';
37
37
 
38
38
  export { IndexerRegistry, textIndexer, chunkText } from './indexers/registry.js';
39
- export { PluginService, PackageStore, StoragePackageStore, PluginInstallStore, SqlitePluginInstallStore, MemoryPluginInstallStore, parsePluginPackage, capabilityList, ALL_CAPABILITIES, IndexerRuntime, InProcessIndexerRuntime, PluginIndexers, matchFromSelector } from './plugins/index.js';
39
+ export { PluginService, PackageStore, StoragePackageStore, PluginInstallStore, SqlitePluginInstallStore, MemoryPluginInstallStore, parsePluginPackage, capabilityList, ALL_CAPABILITIES, IndexerRuntime, InProcessIndexerRuntime, WorkerLoaderIndexerRuntime, PluginIndexers, matchFromSelector } from './plugins/index.js';
40
40
  export { UploadManager, KvSessionStore, DEFAULT_PART_SIZE } from './uploads.js';
41
41
  // Encryption at rest: the bucket holds ciphertext, the drive holds the key. Protects
42
42
  // against the STORAGE host (a leaked bucket credential, a storage vendor who is not the
@@ -64,9 +64,20 @@ export class IndexerRegistry {
64
64
  unregister(id) {
65
65
  this.indexers.delete(id);
66
66
  }
67
+ /**
68
+ * The redacted view — what `/api/indexers` answers with. Deliberately not the indexer
69
+ * itself: an indexer holds a `match` and an `index` that runs plugin code, and neither
70
+ * belongs in a response.
71
+ */
67
72
  list() {
68
73
  return [...this.indexers.values()].map((i) => ({ id: i.id, displayName: i.displayName || i.id }));
69
74
  }
75
+
76
+ /**
77
+ * The LIVE indexer, for a caller that means to run it — `backfillIndexer` after an
78
+ * install, which needs the real `match` and `index` that `list()` strips.
79
+ */
80
+ get(id) { return this.indexers.get(id) || null; }
70
81
  matching(node) {
71
82
  return [...this.indexers.values()].filter((i) => {
72
83
  try {
@@ -15,6 +15,7 @@ export { parsePluginPackage, capabilityList, ALL_CAPABILITIES, serverIndexers, d
15
15
  export * from './identity.js';
16
16
  export { CONTRIBUTION_TYPES, contributionsOfType } from './contributions.js';
17
17
  export { IndexerRuntime, InProcessIndexerRuntime } from './runtime.js';
18
+ export { WorkerLoaderIndexerRuntime } from './workerLoaderRuntime.js';
18
19
  export { clampContribution, DEFAULT_CAPS } from '../indexers/contribution.js';
19
20
  export { PluginIndexers, matchFromSelector } from './indexers.js';
20
21
 
@@ -79,6 +80,16 @@ export class PluginService {
79
80
  if (pkg.indexers.length && !this.indexers) {
80
81
  throw TroveError.unsupported('Server indexers are disabled on this deployment');
81
82
  }
83
+ // ...or when it HAS one that cannot run here — the in-process runner on workerd,
84
+ // which cannot import a `data:` URL. That case DEGRADES rather than refusing: a
85
+ // plugin is more than its indexer, and refusing the install would take the viewers,
86
+ // commands and everything else away over one part the deployment cannot host.
87
+ //
88
+ // What it must not do is what it used to: run anyway and fail once per file forever,
89
+ // with nothing said. So the reason is recorded ON the record — `indexersSkipped` —
90
+ // and travels to the client, which is what turns "my search is empty" into a sentence
91
+ // someone can act on. Asked once per install, not per node.
92
+ const probe = pkg.indexers.length ? (await this.indexers?.probe?.() ?? { ok: true }) : { ok: true };
82
93
 
83
94
  const version = pkg.manifest.version || '0';
84
95
  // CONTENT-addressed. The ref used to be `<account>/<pluginId>/<version>.zip`, written
@@ -112,21 +123,48 @@ export class PluginService {
112
123
  if (prev?.packageRef && prev.packageRef !== ref && !(await this.#refIsShared(prev.packageRef, account))) {
113
124
  await this.packages.delete(prev.packageRef).catch(() => {});
114
125
  }
115
- // Register + backfill any server indexers this package ships.
116
- if (this.indexers && pkg.indexers.length) {
117
- try { await this.indexers.activate(record); }
126
+ // Register + backfill any server indexers this package ships — unless the probe
127
+ // just said this deployment cannot run them, in which case registering would only
128
+ // queue up a failure per file.
129
+ if (this.indexers && pkg.indexers.length && probe.ok) {
130
+ // REGISTER ONLY. The backfill used to run here, inline and awaited inside the
131
+ // install request, which is the same mistake `completeUpload` made at a larger
132
+ // scale: re-reading every matching file in a drive is not work a request can
133
+ // finish, and on Workers the isolate goes before it gets far. The caller schedules
134
+ // it instead — `beginBackfill`, which lands in the same Durable Object that owns
135
+ // scans and reindexes, and reports through the same task record.
136
+ try { await this.indexers.activate(record, { backfill: false }); }
118
137
  catch (err) { console.error(`activating indexers for ${record.pluginId} failed:`, err.message); }
138
+ } else if (!probe.ok) {
139
+ console.warn(`server indexers for ${record.pluginId} are not running: ${probe.reason}`);
119
140
  }
120
- return this.#publicRecord(record);
141
+ return this.#annotate(this.#publicRecord(record));
142
+ }
143
+
144
+ /**
145
+ * Why this record's indexers are not running, or null when they are.
146
+ *
147
+ * DERIVED, not stored. The reason is a property of the DEPLOYMENT, not of the install:
148
+ * a drive that gains a Worker Loader binding should stop reporting "skipped" the moment
149
+ * it restarts, without anyone rewriting rows — and a drive that loses one should start.
150
+ * Storing it at install would freeze an answer that is only true until the next deploy.
151
+ *
152
+ * The probe caches its own result, so this costs nothing after the first call.
153
+ */
154
+ async #annotate(record) {
155
+ if (!record?.indexers?.length) return record;
156
+ const probe = await this.indexers?.probe?.() ?? { ok: true };
157
+ return probe.ok ? record : { ...record, indexersSkipped: probe.reason };
121
158
  }
122
159
 
123
160
  /** An account's installed plugins (secrets stripped). */
124
161
  async list(principal) {
125
- return (await this.installs.list(accountOf(principal))).map((r) => this.#publicRecord(r));
162
+ const rows = (await this.installs.list(accountOf(principal))).map((r) => this.#publicRecord(r));
163
+ return Promise.all(rows.map((r) => this.#annotate(r)));
126
164
  }
127
165
  async get(principal, pluginId) {
128
166
  const r = await this.installs.get(accountOf(principal), pluginId);
129
- return r ? this.#publicRecord(r) : null;
167
+ return r ? this.#annotate(this.#publicRecord(r)) : null;
130
168
  }
131
169
 
132
170
  /** Download the package blob (for a device to sync + enable). */
@@ -34,6 +34,14 @@ export class PluginIndexers {
34
34
  */
35
35
  async activate(record, { backfill = true } = {}) {
36
36
  const specs = record.indexers || [];
37
+ // The probe first, so a deployment that cannot run this plugin's indexers says so
38
+ // once, here, instead of failing per file — and so the saying of it is undone the
39
+ // moment the deployment can. A record with no indexers has nothing to diagnose.
40
+ if (specs.length) {
41
+ const probe = await this.probe();
42
+ await this.#diagnose(record, probe);
43
+ if (!probe.ok) return 0;
44
+ }
37
45
  // Retire anything this plugin used to declare and no longer does. Both this and
38
46
  // deactivate() iterated only the record in hand, so an indexer dropped by an
39
47
  // upgrade stayed registered — running code the user upgraded away from on every
@@ -66,6 +74,50 @@ export class PluginIndexers {
66
74
  return specs.length;
67
75
  }
68
76
 
77
+ /**
78
+ * Can indexers actually run on this deployment? Passed straight through to the
79
+ * runtime — PluginIndexers is the coordinator, not the thing that knows.
80
+ */
81
+ async probe() { return this.runtime?.probe?.() ?? { ok: true }; }
82
+
83
+ /**
84
+ * Say — or stop saying — that this plugin's indexers are not running.
85
+ *
86
+ * A standing DIAGNOSTIC rather than a field nobody draws, because the symptom is an
87
+ * absence: a drive whose plugin indexers never ran looks exactly like a drive with
88
+ * nothing to index. The issues list is where the drive already admits to things it
89
+ * cannot do, and it carries a retry, so the answer to "why is my search empty" arrives
90
+ * in the same place as every other one.
91
+ *
92
+ * Raised and cleared at ACTIVATION, which happens on install and again at every boot —
93
+ * so a deployment that gains a Worker Loader binding drops the diagnostic on its next
94
+ * start, and one that loses it picks the diagnostic back up, with nobody rewriting a
95
+ * stored answer.
96
+ */
97
+ async #diagnose(record, probe) {
98
+ const issues = this.vfs?.issues;
99
+ if (!issues) return;
100
+ try {
101
+ if (probe.ok) {
102
+ await issues.clear('plugin-indexers', record.pluginId);
103
+ return;
104
+ }
105
+ await issues.raise({
106
+ kind: 'plugin-indexers',
107
+ subject: record.pluginId,
108
+ severity: 'warning',
109
+ title: `“${record.pluginId}” cannot index on this deployment — what it would add to search is missing`,
110
+ detail: probe.reason,
111
+ remedy: 'Bind a Worker Loader (`[[worker_loaders]]` in wrangler.toml) so plugin indexers can run in their own isolate.',
112
+ // Retrying re-activates, which re-probes: the honest way to ask again after the
113
+ // binding has been added, rather than a button that only clears the message.
114
+ retry: { op: 'reactivate-indexers', pluginId: record.pluginId },
115
+ });
116
+ } catch (err) {
117
+ console.error('could not record the plugin-indexer diagnostic:', err.message);
118
+ }
119
+ }
120
+
69
121
  /** Unregister + purge every indexer a record declared. */
70
122
  async deactivate(record) {
71
123
  for (const spec of record.indexers || []) {
@@ -33,6 +33,27 @@ export class IndexerRuntime {
33
33
  */
34
34
  async run(spec, node, ctx) { throw TroveError.unsupported('IndexerRuntime.run'); }
35
35
  async close() {}
36
+
37
+ /**
38
+ * Can this runtime actually execute an indexer HERE, on this deployment?
39
+ *
40
+ * Asked once, at install, because the alternative is what shipped: a runtime that
41
+ * installs happily and then fails on every single file, silently, for the life of the
42
+ * deployment. The in-process runner loads code by importing a `data:` URL — which
43
+ * Node and Bun allow and **workerd does not** — so on Cloudflare every plugin indexer
44
+ * failed with `No such module "data:text/javascript;base64,…"`, once per node, with
45
+ * nothing to see at install time and nothing in the UI to explain the empty index.
46
+ *
47
+ * The design doc's provider matrix already called for this (§7, the `CF plain / none`
48
+ * row: *"install-scope check refuses server-indexer plugins on this deployment, with a
49
+ * clear message"*). It could not fire, because it keyed on the runtime being ABSENT
50
+ * and the broken runtime is present — it just cannot run.
51
+ *
52
+ * Default true: a runtime that does not implement a probe is one that works.
53
+ *
54
+ * @returns {Promise<{ok: true} | {ok: false, reason: string}>}
55
+ */
56
+ async probe() { return { ok: true }; }
36
57
  }
37
58
 
38
59
  /**
@@ -72,6 +93,26 @@ export class InProcessIndexerRuntime extends IndexerRuntime {
72
93
  return p;
73
94
  }
74
95
 
96
+ /**
97
+ * Try the loader itself, once, on a module that does nothing.
98
+ *
99
+ * The probe imports a real `data:` URL rather than sniffing for a runtime name.
100
+ * Feature-detection over branding: workerd is the case that prompted this, but the
101
+ * question is "does dynamic import of a data: URL work here", and only doing it
102
+ * answers that. Cached — including the failure, which is a property of the runtime
103
+ * and will not change while the process lives.
104
+ */
105
+ async probe() {
106
+ this._probe ||= import(/* @vite-ignore */ 'data:text/javascript;base64,' + btoa('export default 1'))
107
+ .then(() => ({ ok: true }))
108
+ .catch((err) => ({
109
+ ok: false,
110
+ reason: `this deployment's JavaScript runtime cannot load plugin code dynamically (${err?.message || err}). `
111
+ + 'Server indexers need an isolate runtime — on Cloudflare, a Worker Loader binding.',
112
+ }));
113
+ return this._probe;
114
+ }
115
+
75
116
  async run(spec, node, ctx) {
76
117
  const fn = await this.#load(spec);
77
118
  const result = await withTimeout(
@@ -0,0 +1,160 @@
1
+ // WorkerLoaderIndexerRuntime — the Cloudflare arm of the provider matrix.
2
+ //
3
+ // This is step 4 of docs/design/server-plugins-and-indexers.md §11, and the reason the
4
+ // in-process runner is not the answer on Workers: it loads indexer code by importing a
5
+ // `data:` URL, which workerd refuses outright. Every plugin indexer on a Worker
6
+ // deployment failed with `No such module "data:text/javascript;base64,…"`, once per
7
+ // file, silently. See `InProcessIndexerRuntime.probe()`.
8
+ //
9
+ // A Worker Loader is the fix the doc already chose over Workers-for-Platforms dispatch
10
+ // namespaces: no per-install upload step, so the install flow stays identical across
11
+ // runtimes — every deployment stores the same package blob, and only execution differs.
12
+ // The parent Worker never evaluates plugin code at all; the loader builds a genuinely
13
+ // separate isolate for it.
14
+ //
15
+ // THE BYTES PROBLEM, and why it is solved with a URL rather than RPC. An indexer reads
16
+ // its file adaptively — the audiobook one walks MP4 boxes, so which range it wants next
17
+ // depends on what the last range said. That is a conversation, and a conversation across
18
+ // an isolate boundary is either RPC plumbing on every deployment or one thing the
19
+ // sandbox can already do: fetch. So the host mints a time-limited presigned read URL
20
+ // (§8, `ctx.presignRead()`) and the shim implements `readRange` as a ranged GET against
21
+ // it. The sandbox reaches exactly one object, for a few minutes, and the host holds no
22
+ // open channel it has to police.
23
+ //
24
+ // What the sandbox is allowed to do is therefore precisely: fetch that URL. `env` carries
25
+ // no bindings — no storage, no database, no secrets beyond the plugin's own config — and
26
+ // the entrypoint is called once with one node.
27
+
28
+ import { TroveError } from '../errors.js';
29
+ import { withTimeout } from '../retry.js';
30
+ import { clampContribution, DEFAULT_CAPS } from '../indexers/contribution.js';
31
+ import { IndexerRuntime } from './runtime.js';
32
+
33
+ /**
34
+ * The module that runs INSIDE the sandbox, wrapping the plugin's own entry.
35
+ *
36
+ * It exists because the plugin's `index(node, ctx)` contract is a function call and a
37
+ * dynamic Worker's contract is `fetch(request)`. This is the adapter between them, and
38
+ * it is the only host-authored code in there.
39
+ *
40
+ * `readRange` is deliberately the same shape the in-process context offers, so an
41
+ * indexer cannot tell which runtime it is on — that is what makes the audiobook indexer
42
+ * work unchanged on both.
43
+ */
44
+ export const SHIM = `
45
+ import * as entry from './entry.js';
46
+
47
+ const fn = entry.default || entry.index;
48
+
49
+ export default {
50
+ async fetch(request) {
51
+ const { node, url, maxBytes, config, secrets } = await request.json();
52
+ if (typeof fn !== 'function') {
53
+ return Response.json({ error: 'no default/index export' }, { status: 500 });
54
+ }
55
+ // One ranged GET per call, against the presigned URL and nothing else. \`end\` is
56
+ // exclusive here (as it is host-side) and inclusive in an HTTP Range header — the
57
+ // off-by-one that would silently drop the last byte of every read.
58
+ const readRange = async (start = 0, end = node.size) => {
59
+ const from = Math.max(0, Math.min(start, node.size));
60
+ const to = Math.min(end, from + maxBytes, node.size);
61
+ if (to <= from) return new Uint8Array(0);
62
+ // Concatenation rather than a template literal, deliberately: this whole module is
63
+ // itself a template literal, and a nested one has to be escaped through two levels.
64
+ // The first version got that wrong and every sandbox died with "Failed to start
65
+ // Worker" — a syntax error in generated code, which is invisible at the call site.
66
+ const res = await fetch(url, { headers: { Range: 'bytes=' + from + '-' + (to - 1) } });
67
+ if (!res.ok && res.status !== 206) throw new Error('range read failed: ' + res.status);
68
+ return new Uint8Array(await res.arrayBuffer());
69
+ };
70
+ const ctx = {
71
+ readRange,
72
+ readBytes: () => readRange(0, node.size),
73
+ readText: async () => new TextDecoder().decode(await readRange(0, node.size)),
74
+ maxBytes, config, secrets,
75
+ };
76
+ try {
77
+ return Response.json({ contribution: (await fn(node, ctx)) ?? null });
78
+ } catch (err) {
79
+ return Response.json({ error: err?.message || String(err) }, { status: 500 });
80
+ }
81
+ },
82
+ };
83
+ `;
84
+
85
+ export class WorkerLoaderIndexerRuntime extends IndexerRuntime {
86
+ /**
87
+ * @param {object} opts
88
+ * @param {{get: Function}} opts.loader the `worker_loaders` binding (env.LOADER)
89
+ * @param {string} [opts.compatibilityDate] what the sandbox is compiled against
90
+ * @param {number} [opts.timeoutMs]
91
+ * @param {object} [opts.caps]
92
+ */
93
+ constructor({ loader, compatibilityDate = '2025-01-01', timeoutMs = 10_000, caps = DEFAULT_CAPS } = {}) {
94
+ super();
95
+ if (!loader?.get) throw TroveError.invalid('WorkerLoaderIndexerRuntime needs a worker_loaders binding');
96
+ this.loader = loader;
97
+ this.compatibilityDate = compatibilityDate;
98
+ this.timeoutMs = timeoutMs;
99
+ this.caps = { ...DEFAULT_CAPS, ...caps };
100
+ }
101
+
102
+ /**
103
+ * The binding either exists or it does not, and if it does it works — unlike the
104
+ * in-process runner, there is no capability here that a runtime might withhold.
105
+ * Constructing this class already refused a missing binding.
106
+ */
107
+ async probe() { return { ok: true }; }
108
+
109
+ async run(spec, node, ctx) {
110
+ // A presigned URL is the whole mechanism, so its absence is a hard error rather
111
+ // than a degraded path. It fails LOUDLY and per-install-shaped: a backend that
112
+ // cannot presign cannot host server indexers on Workers, and saying that is more
113
+ // use than an empty contribution.
114
+ if (typeof ctx.presignRead !== 'function') {
115
+ throw TroveError.unsupported(
116
+ `Indexer "${spec.id}" needs a presigned read URL to run in a sandbox, and this storage backend cannot mint one`,
117
+ );
118
+ }
119
+ const url = await ctx.presignRead();
120
+
121
+ const code = spec.files?.[spec.entry];
122
+ if (!code) throw TroveError.invalid(`Indexer "${spec.id}" is missing its entry "${spec.entry}"`);
123
+
124
+ // Keyed by cacheKey, which embeds the package DIGEST — so a reinstall at the same
125
+ // version gets a different isolate rather than the old code kept warm. The loader
126
+ // may reuse an isolate for a repeated id; that is exactly what we want within a
127
+ // digest and exactly what we must not have across one.
128
+ const stub = this.loader.get(spec.cacheKey || spec.id, async () => ({
129
+ compatibilityDate: this.compatibilityDate,
130
+ mainModule: 'shim.js',
131
+ modules: {
132
+ 'shim.js': SHIM,
133
+ 'entry.js': new TextDecoder().decode(code),
134
+ },
135
+ // No bindings. The sandbox gets its instructions in the request body and its bytes
136
+ // from one URL; there is nothing else for it to reach.
137
+ env: {},
138
+ }));
139
+
140
+ const res = await withTimeout(
141
+ stub.getEntrypoint().fetch('https://indexer.invalid/run', {
142
+ method: 'POST',
143
+ headers: { 'content-type': 'application/json' },
144
+ body: JSON.stringify({
145
+ node: { id: node.id, name: node.name, contentType: node.contentType, size: node.size },
146
+ url,
147
+ maxBytes: ctx.maxBytes ?? 2 * 1024 * 1024,
148
+ config: ctx.config || {},
149
+ secrets: ctx.secrets || {},
150
+ }),
151
+ }),
152
+ this.timeoutMs,
153
+ `Indexer "${spec.id}" timed out after ${this.timeoutMs}ms`,
154
+ );
155
+
156
+ const body = await res.json().catch(() => ({ error: 'indexer returned a non-JSON response' }));
157
+ if (!res.ok || body.error) throw TroveError.invalid(`Indexer "${spec.id}" failed: ${body.error || res.status}`);
158
+ return clampContribution(body.contribution, this.caps);
159
+ }
160
+ }
@@ -345,8 +345,9 @@ export class Vfs {
345
345
  collectionId, name, storageKey, size: plaintextSize ?? info.size, contentType: ct,
346
346
  etag: info.etag, encryption,
347
347
  });
348
- // Small server-side writes index synchronously (search is ready on return);
349
- // large client uploads (completeUpload) index in the background instead.
348
+ // Indexing is awaited on BOTH write paths here and in `completeUpload` — so search
349
+ // is ready when the call returns. See the note there for why the background variant
350
+ // could not work on a request-scoped runtime.
350
351
  await this.indexing.indexNode(node).catch((e) => console.error('index error', e));
351
352
  return node;
352
353
  }
@@ -763,7 +764,17 @@ export class Vfs {
763
764
  size: obj.size, contentType: obj.contentType, etag: obj.etag,
764
765
  overwrite: obj.overwrite, encryption: obj.encryption || null,
765
766
  });
766
- this.indexing.indexNode(node).catch((e) => console.error('index error', e));
767
+ // AWAITED, like the small-write path above. It used to be fire-and-forget, and the
768
+ // reasoning ("large client uploads index in the background") only holds on a
769
+ // runtime with a process that outlives the request. On Workers there is none: the
770
+ // response returns, the isolate goes, and the indexing is cut off mid-read — so an
771
+ // uploaded file got no contributions, no search entry and NO ISSUE either, because
772
+ // nothing failed. It simply never ran.
773
+ //
774
+ // Awaiting is bounded rather than open-ended: an indexer reads through `readRange`,
775
+ // capped at `maxIndexBytes` (2 MiB by default), so this costs a couple of megabytes
776
+ // of reads regardless of whether the file is 400 KB or 400 MB.
777
+ await this.indexing.indexNode(node).catch((e) => console.error('index error', e));
767
778
  return node;
768
779
  } catch (err) {
769
780
  await (await this.storageFor(obj.collectionId)).delete(obj.storageKey).catch(() => {});
@@ -85,6 +85,15 @@ export function createTaskHost(getServer) {
85
85
  return { task, alreadyRunning };
86
86
  }
87
87
 
88
+ async #beginBackfill(indexerIds, reason) {
89
+ const server = await this.#boot();
90
+ const { task, alreadyRunning, done } = await server.beginBackfill({ indexerIds, reason });
91
+ // Same shape as a reindex: nothing to resume from, so it either finishes in this
92
+ // object's lifetime or the files stay unindexed until something asks again.
93
+ if (!alreadyRunning) this.state.waitUntil?.(done.catch(() => null));
94
+ return { task, alreadyRunning };
95
+ }
96
+
88
97
  async #beginReindex(reason) {
89
98
  const server = await this.#boot();
90
99
  const { task, alreadyRunning, done } = await server.beginReindex({ reason });
@@ -102,6 +111,7 @@ export function createTaskHost(getServer) {
102
111
  const server = await this.#boot();
103
112
  switch (url.pathname) {
104
113
  case '/begin':
114
+ if (body.kind === 'backfill') return json(await this.#beginBackfill(body.indexerIds, body.reason));
105
115
  return json(body.kind === 'index'
106
116
  ? await this.#beginReindex(body.reason)
107
117
  : await this.#beginScan(body.collectionId || 'default', body.reason));
@@ -204,6 +214,7 @@ export function remoteBackground(namespace) {
204
214
  background: {
205
215
  beginScan: (collectionId, { reason } = {}) => begin({ kind: 'scan', collectionId, reason }),
206
216
  beginReindex: ({ reason } = {}) => begin({ kind: 'index', reason }),
217
+ beginBackfill: ({ indexerIds, reason } = {}) => begin({ kind: 'backfill', indexerIds, reason }),
207
218
  },
208
219
  maintain: (budgetMs) => stub()
209
220
  .fetch('https://trove.tasks/maintain', {
@@ -39,7 +39,7 @@ import {
39
39
  ApiKeyService, CapabilityProvider, ApiKeyCapabilityProvider,
40
40
  CollectionService,
41
41
  PluginService, PackageStore, StoragePackageStore, SqlitePluginInstallStore,
42
- IndexerRuntime, InProcessIndexerRuntime, PluginIndexers,
42
+ IndexerRuntime, InProcessIndexerRuntime, WorkerLoaderIndexerRuntime, PluginIndexers,
43
43
  TaskRegistry, IssueRegistry,
44
44
  Vfs, TroveError,
45
45
  resolveAuthDiscovery,
@@ -225,6 +225,7 @@ export function coreProviders(config, lifecycleState) {
225
225
  backgroundWork: Provider.fromSingleton({
226
226
  beginScan: (collectionId, opts) => lifecycleState.background.beginScan(collectionId, opts),
227
227
  beginReindex: (opts) => lifecycleState.background.beginReindex(opts),
228
+ beginBackfill: (opts) => lifecycleState.background.beginBackfill(opts),
228
229
  }),
229
230
 
230
231
  /**
@@ -611,10 +612,16 @@ export function coreProviders(config, lifecycleState) {
611
612
 
612
613
  // Server indexer sub-packages run through a pluggable runtime. The default is
613
614
  // the in-process (trusted) runner; a deployment swaps in an isolate runtime.
614
- indexerRuntime: Provider.fromLazySingleton(() =>
615
- (config.serverIndexers === false
616
- ? null
617
- : resolve(config.indexerRuntime, IndexerRuntime, () => new InProcessIndexerRuntime()))),
615
+ indexerRuntime: Provider.fromLazySingleton(() => {
616
+ if (config.serverIndexers === false) return null;
617
+ // `{ loader }` — a Worker Loader binding, which configFromEnv sets when the
618
+ // deployment declares one. Built here rather than there so core stays the only
619
+ // place that knows what a runtime is.
620
+ if (config.indexerRuntime?.loader && !(config.indexerRuntime instanceof IndexerRuntime)) {
621
+ return new WorkerLoaderIndexerRuntime(config.indexerRuntime);
622
+ }
623
+ return resolve(config.indexerRuntime, IndexerRuntime, () => new InProcessIndexerRuntime());
624
+ }),
618
625
 
619
626
  plugins: Provider.fromLazySingleton(
620
627
  async (deps) => {
@@ -140,6 +140,47 @@ export async function createServer(config = {}) {
140
140
  }
141
141
  };
142
142
  const startReindex = async (opts) => (await beginReindex(opts)).done;
143
+
144
+ /**
145
+ * Re-run named indexers over the files they match — what a freshly installed plugin
146
+ * needs, and the reason it is a TASK rather than part of the install.
147
+ *
148
+ * Scoped to `indexerIds` rather than rebuilding the drive: installing one plugin should
149
+ * not re-embed every file for every other indexer. That is also why it does not take
150
+ * the drive-wide `reindex` claim a full rebuild takes — two different plugins
151
+ * backfilling at once are doing disjoint work.
152
+ */
153
+ const beginBackfill = async ({ indexerIds = [], reason, title } = {}) => {
154
+ const wanted = indexerIds.filter(Boolean);
155
+ if (!wanted.length) {
156
+ return { task: null, alreadyRunning: false, done: Promise.resolve({ indexed: 0 }) };
157
+ }
158
+ const begun = tasks.begin(
159
+ {
160
+ kind: 'index',
161
+ title: title || (wanted.length === 1 ? 'Indexing existing files' : `Indexing existing files for ${wanted.length} indexers`),
162
+ detail: reason || null,
163
+ unit: 'items',
164
+ cancellable: true,
165
+ },
166
+ async (task) => {
167
+ let indexed = 0;
168
+ for (const id of wanted) {
169
+ const indexer = vfs.indexers.get(id);
170
+ // A named indexer that is not registered is not an error worth failing the
171
+ // task over: the usual cause is a deployment that cannot run it, which has
172
+ // already said so through the plugin-indexers issue.
173
+ if (!indexer?.match) continue;
174
+ const r = await vfs.backfillIndexer(indexer, { shouldStop: () => task.cancelled || closing() });
175
+ indexed += r?.indexed ?? 0;
176
+ task.progress?.({ done: indexed });
177
+ if (task.cancelled || closing()) break;
178
+ }
179
+ return { indexed };
180
+ },
181
+ );
182
+ return { task: begun.task, alreadyRunning: false, done: begun.done };
183
+ };
143
184
  // Retrying an issue runs the same work as everything else, and reports it the same
144
185
  // way. The issue is not cleared here — it is cleared by the indexing that succeeds,
145
186
  // so a retry can't report success over a problem that is still there.
@@ -165,10 +206,25 @@ export async function createServer(config = {}) {
165
206
  lifecycleState.background = {
166
207
  beginScan: config.background?.beginScan || beginScan,
167
208
  beginReindex: config.background?.beginReindex || beginReindex,
209
+ beginBackfill: config.background?.beginBackfill || beginBackfill,
168
210
  };
169
211
  const routeBeginScan = lifecycleState.background.beginScan;
170
212
  const routeBeginReindex = lifecycleState.background.beginReindex;
213
+ const routeBeginBackfill = lifecycleState.background.beginBackfill;
171
214
  issues.handle('scan-collection', (issue) => startScan(issue.retry.collectionId, { reason: 'Retrying after a failed scan' }));
215
+ // "I have added the binding — try again." Re-activating re-probes, which is the only
216
+ // honest way to answer: a button that merely dismissed the diagnostic would leave the
217
+ // drive exactly as unable to index as it was, with nothing saying so.
218
+ issues.handle('reactivate-indexers', async (issue) => {
219
+ const record = (await plugins?.installs?.all?.() ?? []).find((r) => r.pluginId === issue.retry.pluginId);
220
+ if (!record) return { ok: false };
221
+ await plugins.indexers?.activate(record, { backfill: false });
222
+ // Registered again? Then catch the existing files up, which is what the install
223
+ // would have scheduled had the deployment been able to run them at the time.
224
+ const ids = (record.indexers || []).map((i) => i.id).filter((id) => vfs.indexers.get(id));
225
+ if (ids.length) await routeBeginBackfill({ indexerIds: ids, reason: 'Retrying after the indexer runtime became available' });
226
+ return { ok: true };
227
+ });
172
228
  issues.handle('storage-check', (issue) => storageCheck.run({ origin: issue.retry?.origin || config.publicUrl || null }));
173
229
  // The one retry that matters most: the user has been told a comment saved and it exists
174
230
  // only in memory. The op was raised for years with no handler registered for it — and in
@@ -529,6 +585,7 @@ export async function createServer(config = {}) {
529
585
  // inside a Durable Object want. `begin*` goes wherever `config.background` says,
530
586
  // which for a front-line Worker isolate is the object rather than itself.
531
587
  startScan, startReindex, beginScan: routeBeginScan, beginReindex: routeBeginReindex,
588
+ beginBackfill: routeBeginBackfill,
532
589
  runMaintenance, checkStorage: (opts) => storageCheck.run(opts), rotation, mcp, auth, close };
533
590
  }
534
591
 
@@ -902,6 +959,17 @@ export function configFromEnv(env = (typeof process !== 'undefined' ? process.en
902
959
  // TROVE_SERVER_INDEXERS=0/false refuses server-indexer plugins on this deployment.
903
960
  if (env.TROVE_SERVER_INDEXERS === '0' || env.TROVE_SERVER_INDEXERS === 'false') config.serverIndexers = false;
904
961
 
962
+ // A `worker_loaders` binding means this is a Worker that CAN run plugin code, in a
963
+ // real isolate — so it does, rather than falling back to the in-process runner, which
964
+ // cannot import a `data:` URL on workerd and fails on every file. Discovered from the
965
+ // binding rather than configured: a deployment that declared the binding wants it, and
966
+ // one that did not gets the honest install-time refusal from the probe.
967
+ //
968
+ // Any binding name is accepted so the wrangler config can call it what it likes;
969
+ // TROVE_WORKER_LOADER names it explicitly when there is more than one.
970
+ const loader = env[env.TROVE_WORKER_LOADER || 'LOADER'] ?? findWorkerLoader(env);
971
+ if (loader && config.serverIndexers !== false) config.indexerRuntime = { loader };
972
+
905
973
  // Plugin package blob store: defaults to the primary storage backend (prefixed).
906
974
  // Point it at a separate bucket/root with TROVE_PACKAGE_STORE (+ its own settings).
907
975
  if (env.TROVE_PACKAGE_STORE) {
@@ -955,3 +1023,25 @@ export function configFromEnv(env = (typeof process !== 'undefined' ? process.en
955
1023
  }
956
1024
 
957
1025
  export { createRouter };
1026
+
1027
+ /**
1028
+ * A `worker_loaders` binding, found by shape.
1029
+ *
1030
+ * Bindings arrive as an untyped bag and a loader is only identifiable by having `get`
1031
+ * and nothing else — deliberately narrow, because a false positive here would hand
1032
+ * plugin code to whatever object happened to match. Skips the names of bindings that
1033
+ * also expose `get` and are emphatically not loaders (KV, R2, D1, Durable Objects are
1034
+ * declared under their own config keys and reached by name, so a drive that wants this
1035
+ * can always be explicit with TROVE_WORKER_LOADER).
1036
+ */
1037
+ function findWorkerLoader(env) {
1038
+ for (const [name, value] of Object.entries(env || {})) {
1039
+ if (!value || typeof value !== 'object') continue;
1040
+ if (typeof value.get !== 'function') continue;
1041
+ // A loader has exactly one method. KV has `put`/`list`, R2 has `head`/`delete`,
1042
+ // a DO namespace has `idFromName`, D1 has `prepare` — all disqualifying.
1043
+ if (value.put || value.list || value.head || value.delete || value.idFromName || value.prepare) continue;
1044
+ if (/loader/i.test(name)) return value;
1045
+ }
1046
+ return null;
1047
+ }