@3sln/trove 0.0.16 → 0.0.18

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 (29) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/index.js +1 -1
  3. package/packages/core/src/indexers/registry.js +11 -0
  4. package/packages/core/src/plugins/index.js +44 -6
  5. package/packages/core/src/plugins/indexers.js +52 -0
  6. package/packages/core/src/plugins/runtime.js +101 -4
  7. package/packages/core/src/plugins/workerLoaderRuntime.js +160 -0
  8. package/packages/core/src/sidecar/document.js +60 -2
  9. package/packages/core/src/sidecar/index.js +28 -1
  10. package/packages/core/src/vfs.js +14 -3
  11. package/packages/plugin-sdk/src/browser.js +257 -26
  12. package/packages/plugin-sdk/src/protocol.js +9 -0
  13. package/packages/server/src/adapters/worker-tasks.js +11 -0
  14. package/packages/server/src/engine/providers/core.js +12 -5
  15. package/packages/server/src/index.js +90 -0
  16. package/packages/server/src/routes.js +92 -2
  17. package/packages/web/dist/assets/main-b9jd0fyt.js +742 -0
  18. package/packages/web/dist/assets/{main-wpfmmbfd.js.map → main-b9jd0fyt.js.map} +13 -12
  19. package/packages/web/dist/index.html +1 -1
  20. package/packages/web/dist/sw.js +33 -3
  21. package/packages/web/src/bl/fileType.js +8 -0
  22. package/packages/web/src/bl/launcher.js +15 -0
  23. package/packages/web/src/platform/index.js +8 -0
  24. package/packages/web/src/platform/itemData.js +161 -0
  25. package/packages/web/src/platform/navigation.js +16 -2
  26. package/packages/web/src/platform/pluginRpc.js +55 -0
  27. package/packages/web/src/ui/components/launcher.js +1 -1
  28. package/packages/web/src/ui/components/views/index.js +14 -1
  29. package/packages/web/dist/assets/main-wpfmmbfd.js +0 -511
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3sln/trove",
3
- "version": "0.0.16",
3
+ "version": "0.0.18",
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
  /**
@@ -59,10 +80,7 @@ export class InProcessIndexerRuntime extends IndexerRuntime {
59
80
  if (!p) {
60
81
  const code = spec.files?.[spec.entry];
61
82
  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) => {
83
+ p = importModule(code).then((mod) => {
66
84
  const fn = mod.default || mod.index;
67
85
  if (typeof fn !== 'function') throw TroveError.invalid(`Indexer "${spec.id}" has no default/index export`);
68
86
  return fn;
@@ -72,6 +90,33 @@ export class InProcessIndexerRuntime extends IndexerRuntime {
72
90
  return p;
73
91
  }
74
92
 
93
+ /**
94
+ * Try the loader itself, once, on a module that does nothing.
95
+ *
96
+ * The probe imports a real `data:` URL rather than sniffing for a runtime name.
97
+ * Feature-detection over branding: workerd is the case that prompted this, but the
98
+ * question is "does dynamic import of a data: URL work here", and only doing it
99
+ * answers that. Cached — including the failure, which is a property of the runtime
100
+ * and will not change while the process lives.
101
+ */
102
+ async probe() {
103
+ // A REALISTICALLY SIZED module, not `export default 1`. The tiny version answered a
104
+ // question nobody was asking: Bun imports a small data: URL happily and refuses one
105
+ // over ~1.5 KB with ENAMETOOLONG, so a 12-byte probe passed and every real indexer —
106
+ // the audiobook one is 34 KB — failed per file. That is the same silent shape this
107
+ // probe exists to prevent, so it now loads something the size of a real entry.
108
+ const padding = 'x'.repeat(PROBE_BYTES);
109
+ const code = new TextEncoder().encode(`export default 1; //${padding}`);
110
+ this._probe ||= importModule(code)
111
+ .then(() => ({ ok: true }))
112
+ .catch((err) => ({
113
+ ok: false,
114
+ reason: `this deployment's JavaScript runtime cannot load plugin code dynamically (${err?.message || err}). `
115
+ + 'Server indexers need an isolate runtime — on Cloudflare, a Worker Loader binding.',
116
+ }));
117
+ return this._probe;
118
+ }
119
+
75
120
  async run(spec, node, ctx) {
76
121
  const fn = await this.#load(spec);
77
122
  const result = await withTimeout(
@@ -83,6 +128,58 @@ export class InProcessIndexerRuntime extends IndexerRuntime {
83
128
  }
84
129
  }
85
130
 
131
+ /**
132
+ * How big a module the probe pretends to load. Larger than any plausible bundled indexer
133
+ * entry (the audiobook one is 34 KB), because the failure being detected is a SIZE limit
134
+ * and a probe under it proves nothing.
135
+ */
136
+ export const PROBE_BYTES = 64 * 1024;
137
+
138
+ /**
139
+ * Import a module from bytes, on whichever runtime this is.
140
+ *
141
+ * The two schemes are exactly complementary, which is why both are here:
142
+ *
143
+ * data: Node takes it at any size (2.6 MB tested). Bun refuses over ~1.5 KB with
144
+ * `NameTooLong` — it resolves the URL as a path, so ENAMETOOLONG.
145
+ * blob: Bun takes it at any size. Node's ESM loader supports exactly `file:`, `data:`
146
+ * and `node:`, so it throws ERR_UNSUPPORTED_ESM_URL_SCHEME.
147
+ *
148
+ * data: first because it needs no cleanup and the same bytes give the same URL, so the
149
+ * engine's module cache makes a re-import free. An object URL is a live registry entry
150
+ * that leaks until revoked, which is the only reason it is the fallback rather than the
151
+ * default.
152
+ *
153
+ * Neither works on workerd. That is what `WorkerLoaderIndexerRuntime` is for, and what
154
+ * `probe()` reports when it is missing.
155
+ */
156
+ async function importModule(code) {
157
+ // base64 rather than percent-encoded: some runtimes' data: loader treats
158
+ // percent-encoded JS as a text module instead of code.
159
+ try {
160
+ return await import(/* @vite-ignore */ 'data:text/javascript;base64,' + bytesToBase64(code));
161
+ } catch (dataErr) {
162
+ if (typeof URL.createObjectURL !== 'function' || typeof Blob !== 'function') throw dataErr;
163
+ let url;
164
+ try {
165
+ url = URL.createObjectURL(new Blob([code], { type: 'text/javascript' }));
166
+ } catch {
167
+ throw dataErr; // report the data: failure — it is the one that describes the runtime
168
+ }
169
+ try {
170
+ return await import(/* @vite-ignore */ url);
171
+ } catch {
172
+ throw dataErr;
173
+ } finally {
174
+ // Safe here and only here: an ESM module is fully instantiated by the time the
175
+ // import resolves, and `#load` caches the promise so this URL is never resolved
176
+ // again. Without it every indexer would pin its own source in memory for the life
177
+ // of the process.
178
+ URL.revokeObjectURL?.(url);
179
+ }
180
+ }
181
+ }
182
+
86
183
  function bytesToBase64(bytes) {
87
184
  let bin = '';
88
185
  const CHUNK = 0x8000;
@@ -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
+ }
@@ -22,7 +22,7 @@
22
22
  export const SIDECAR_VERSION = 1;
23
23
 
24
24
  export function emptyDoc(nodeId) {
25
- return { v: SIDECAR_VERSION, nodeId, clock: 0, tags: {}, comments: {}, subscribers: {} };
25
+ return { v: SIDECAR_VERSION, nodeId, clock: 0, tags: {}, comments: {}, subscribers: {}, data: {} };
26
26
  }
27
27
 
28
28
  // A stamp orders and tie-breaks a write. Higher clock wins; equal clock → higher
@@ -101,6 +101,53 @@ export function removeTag(doc, name, { actor, at } = {}) {
101
101
  if (newer(s, cur)) doc.tags[name] = { present: false, value: cur?.value, ...s };
102
102
  }
103
103
 
104
+ /**
105
+ * A plugin's own key/value data for this item.
106
+ *
107
+ * SCOPED, and the scope is not the plugin's to choose: the caller passes the plugin id it
108
+ * has already authenticated, exactly as `/api/index/:indexerId` does for contributions.
109
+ * Two plugins writing `position` to the same book are writing two different things, and a
110
+ * flat namespace would make one silently overwrite the other.
111
+ *
112
+ * Same LWW register as a tag, for the same reason: a listener finishes a chapter on their
113
+ * phone and opens the book on a laptop that has been offline for a day. Both wrote. The
114
+ * later write wins, and `newer` breaks a tie the same total, deterministic way it does
115
+ * everywhere else in this document — so both devices converge on the same answer without
116
+ * either having to be authoritative.
117
+ */
118
+ export function setData(doc, scope, key, value, { actor, at } = {}) {
119
+ if (!scope || !key) return null;
120
+ doc.data ||= {};
121
+ doc.data[scope] ||= {};
122
+ const s = stamp(doc, actor, at);
123
+ const cur = doc.data[scope][key];
124
+ if (newer(s, cur)) doc.data[scope][key] = { present: true, value, ...s };
125
+ return doc.data[scope][key];
126
+ }
127
+
128
+ /**
129
+ * Forget one key. A TOMBSTONE rather than a delete, because a delete that removed the
130
+ * entry would be silently undone by any replica that still had the old value — the
131
+ * absence has to be a fact with a stamp, or it cannot win a merge.
132
+ */
133
+ export function removeData(doc, scope, key, { actor, at } = {}) {
134
+ if (!scope || !key) return;
135
+ doc.data ||= {};
136
+ doc.data[scope] ||= {};
137
+ const s = stamp(doc, actor, at);
138
+ const cur = doc.data[scope][key];
139
+ if (newer(s, cur)) doc.data[scope][key] = { present: false, value: cur?.value, ...s };
140
+ }
141
+
142
+ /** What a plugin sees of its own scope: present keys, values only. */
143
+ export function dataOf(doc, scope) {
144
+ const out = {};
145
+ for (const [key, cell] of Object.entries(doc?.data?.[scope] || {})) {
146
+ if (cell?.present) out[key] = cell.value;
147
+ }
148
+ return out;
149
+ }
150
+
104
151
  export function subscribe(doc, userId, { muted = false, actor, at } = {}) {
105
152
  if (!userId) return;
106
153
  const s = stamp(doc, actor ?? userId, at);
@@ -119,7 +166,7 @@ export function unsubscribe(doc, userId, { actor, at } = {}) {
119
166
  export function mergeDoc(a, b) {
120
167
  if (!a) return structuredCloneSafe(b);
121
168
  if (!b) return structuredCloneSafe(a);
122
- const out = { v: SIDECAR_VERSION, nodeId: a.nodeId || b.nodeId, clock: Math.max(a.clock || 0, b.clock || 0), tags: {}, comments: {}, subscribers: {} };
169
+ const out = { v: SIDECAR_VERSION, nodeId: a.nodeId || b.nodeId, clock: Math.max(a.clock || 0, b.clock || 0), tags: {}, comments: {}, subscribers: {}, data: {} };
123
170
 
124
171
  for (const key of union(a.tags, b.tags)) out.tags[key] = pick(a.tags[key], b.tags[key]);
125
172
  // Documents written before the register was removed still carry one, and a merge that
@@ -131,6 +178,17 @@ export function mergeDoc(a, b) {
131
178
  }
132
179
  for (const key of union(a.subscribers, b.subscribers)) out.subscribers[key] = pick(a.subscribers[key], b.subscribers[key]);
133
180
 
181
+ // Data is a map of maps — scope, then key — so it merges a level deeper than the rest.
182
+ // Merged per KEY rather than per scope: two devices that each wrote a different key in
183
+ // the same scope must end up with both, and picking a whole scope would drop one.
184
+ out.data = {};
185
+ for (const scope of union(a.data, b.data)) {
186
+ out.data[scope] = {};
187
+ for (const key of union(a.data?.[scope], b.data?.[scope])) {
188
+ out.data[scope][key] = pick(a.data?.[scope]?.[key], b.data?.[scope]?.[key]);
189
+ }
190
+ }
191
+
134
192
  for (const id of union(a.comments, b.comments)) {
135
193
  out.comments[id] = mergeComment(a.comments[id], b.comments[id]);
136
194
  }
@@ -8,7 +8,7 @@ import { SidecarStore } from './store.js';
8
8
  import { SidecarManager } from './manager.js';
9
9
  import {
10
10
  addComment, editComment, deleteComment, react, setTag, removeTag,
11
- subscribe, unsubscribe, viewDoc, extractMentions,
11
+ subscribe, unsubscribe, viewDoc, extractMentions, setData, removeData, dataOf,
12
12
  } from './document.js';
13
13
  import { newId } from '../util.js';
14
14
  import { TroveError } from '../errors.js';
@@ -115,6 +115,33 @@ export class SidecarService {
115
115
  return this.view(nodeId);
116
116
  }
117
117
 
118
+ // --- per-plugin item data --------------------------------------------------
119
+ //
120
+ // A viewer's state about an ITEM, which is neither a contribution nor a setting. A
121
+ // contribution is derived from the file and is rewritten whenever it is re-indexed; a
122
+ // setting is per-device, and a listening position that does not follow you to your phone
123
+ // is the one people notice missing. This is the third thing, and the sidecar is where it
124
+ // belongs because the sidecar is already a per-item CRDT.
125
+ //
126
+ // `scope` is passed in by the caller and is NOT the plugin's to choose — the route
127
+ // derives it from the plugin it has already authenticated, the same way
128
+ // `/api/index/:indexerId` derives a contributor namespace.
129
+
130
+ async setData(nodeId, scope, key, value, principal) {
131
+ await this.manager.mutate(nodeId, (doc) => setData(doc, scope, key, value, { actor: principal?.id }));
132
+ return { ok: true };
133
+ }
134
+
135
+ async removeData(nodeId, scope, key, principal) {
136
+ await this.manager.mutate(nodeId, (doc) => removeData(doc, scope, key, { actor: principal?.id }));
137
+ return { ok: true };
138
+ }
139
+
140
+ /** One scope's present keys. Never another scope's — see `dataOf`. */
141
+ async data(nodeId, scope) {
142
+ return dataOf(await this.manager.get(nodeId), scope);
143
+ }
144
+
118
145
  // --- subscriptions ---------------------------------------------------------
119
146
 
120
147
  async subscribe(nodeId, principal, muted = false) {
@@ -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(() => {});