@3sln/trove 0.0.18 → 0.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -356,6 +356,44 @@ create databases on demand and a scope key contains the user's id, so per-scope
356
356
  databases are not expressible here. Their tables sit side by side, which is weaker
357
357
  isolation than the file-per-scope a self-hosted run gets.
358
358
 
359
+ #### Plugin storage
360
+
361
+ A plugin with the `storage` capability gets an isolated SQLite database per scope — its
362
+ own, and optionally one shared with the rest of its vendor's plugins. The scope key
363
+ embeds the user *and* the plugin (`pstore:<principal>:plg:<pluginId>`), so it can never be
364
+ pre-bound: **D1 cannot create a database on demand.**
365
+
366
+ So a Worker deployment has two options, and they are not equivalent.
367
+
368
+ **A Durable Object per scope** — bind `PLUGIN_STORE` to the `TrovePluginStore` class. A
369
+ Durable Object is addressable by name, so each `(user, plugin)` becomes its own object with
370
+ its own SQLite database, created on first use. The isolation is structural.
371
+
372
+ ```toml
373
+ [[durable_objects.bindings]]
374
+ name = "PLUGIN_STORE"
375
+ class_name = "TrovePluginStore"
376
+
377
+ [[migrations]]
378
+ tag = "v1"
379
+ new_sqlite_classes = ["TroveTasks", "TrovePluginStore"]
380
+ ```
381
+
382
+ and export it beside the fetch handler:
383
+
384
+ ```js
385
+ export { default, TroveTasks, TrovePluginStore } from '@3sln/trove/server/adapters/worker.js';
386
+ ```
387
+
388
+ **One shared D1** — bind `PLUGIN_DB` and every plugin's tables for every user live in it
389
+ side by side. The keys stay distinct; the boundary is a naming convention rather than a
390
+ wall. It works, and it is what you get without the Durable Object, and it is worth knowing
391
+ which of the two you have.
392
+
393
+ Core stores — metadata, KV, install records, the keyword index — stay on D1 either way.
394
+ They are one per deployment, so they can simply be bound, and routing the whole drive
395
+ through one single-threaded object would be a bottleneck rather than an isolation win.
396
+
359
397
  #### Work that outlives a request
360
398
 
361
399
  A Worker isolate is not a server: it may be discarded as soon as the response resolves,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3sln/trove",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
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": {
@@ -82,6 +82,9 @@ export {
82
82
  export { SqliteDatabase, SqliteProvider, LocalSqliteProvider, assertSafePluginSql, stripSqlLiterals } from './sqlite.js';
83
83
  // SQLite on Cloudflare D1, so a Worker deployment has a metadata store that exists.
84
84
  export { D1SqliteProvider } from './sqlite-d1.js';
85
+ // A Durable Object per plugin scope — what D1 cannot do, because a scope key embeds the
86
+ // runtime principal and D1 cannot create a database on demand. See sqlite-do.js.
87
+ export { DurableObjectSqliteProvider, createPluginStore, isPluginScope } from './sqlite-do.js';
85
88
 
86
89
  // Identity (BYO IdP — Cloudflare Access / Zero Trust / a proxy).
87
90
  export {
@@ -144,6 +144,12 @@ export class D1SqliteProvider extends SqliteProvider {
144
144
  // the local provider's file-per-scope — the tables live side by side — but the keys
145
145
  // are still distinct per (user, plugin) and it is the strongest thing D1's model
146
146
  // allows. Stated here rather than discovered.
147
+ // ONE BINDING FOR EVERY PLUGIN SCOPE, and it is a compromise rather than a design.
148
+ // The tables live side by side: distinct names per (user, plugin), no boundary
149
+ // between them. `DurableObjectSqliteProvider` is the one that gets this right — a DO
150
+ // is addressable by name, so each scope is its own object with its own database, and
151
+ // creating one on demand is the thing D1 cannot do. Prefer it where a Durable Object
152
+ // is available; this stays for deployments that have only D1.
147
153
  if (this.pluginStore) return this.pluginStore;
148
154
  // Plugin scopes are an isolation boundary. Handing back the main database would
149
155
  // put a plugin's tables next to the drive's metadata, which is precisely what the
@@ -0,0 +1,182 @@
1
+ // A Durable Object per scope — plugin storage on Workers, done properly.
2
+ //
3
+ // `D1SqliteProvider` cannot do this, and its own comment says why: a plugin scope key
4
+ // embeds the runtime principal (`pstore:alice@x.com:plg:acme/notes`), so it can never be
5
+ // pre-bound, and D1 cannot create a database on demand. What shipped instead was one
6
+ // nominated binding holding every plugin's tables for every user side by side — weaker
7
+ // isolation than the local provider's file-per-scope, kept only because it was the
8
+ // strongest thing D1's model allowed. The keys stayed distinct; the boundary was a naming
9
+ // convention.
10
+ //
11
+ // A Durable Object is addressable BY NAME, which is exactly the missing primitive:
12
+ // `obtain({ key })` becomes a lookup, the object is created on first use, and each one
13
+ // carries its own SQLite storage. The isolation is structural rather than a prefix.
14
+ //
15
+ // The seam does not change. This is a third sibling next to the local and D1 providers,
16
+ // returning the same `SqliteDatabase` surface, and a deployment picks it by declaring the
17
+ // binding.
18
+ //
19
+ // WHAT A DO COSTS, so nobody is surprised: it is single-threaded and lives in one place,
20
+ // so every query for a scope routes there. For per-user plugin state — a listening
21
+ // position, a reader's notes — that is exactly right. For something read from everywhere
22
+ // at once it would be a bottleneck, and that is a different problem than this one.
23
+
24
+ import { TroveError } from './errors.js';
25
+ import { SqliteDatabase, SqliteProvider } from './sqlite.js';
26
+
27
+ /**
28
+ * One scope's database, reached through its Durable Object.
29
+ *
30
+ * Every call is a `fetch` at the stub. Batched where the interface allows it, because a
31
+ * round trip per statement to a single-threaded object is the one thing that would make
32
+ * this slower than the shared table it replaces.
33
+ */
34
+ class DurableObjectDatabase extends SqliteDatabase {
35
+ constructor(stub, key) {
36
+ super();
37
+ this.stub = stub;
38
+ this.key = key;
39
+ }
40
+
41
+ async #call(op, sql, params) {
42
+ const res = await this.stub.fetch('https://trove.store/sql', {
43
+ method: 'POST',
44
+ headers: { 'content-type': 'application/json' },
45
+ body: JSON.stringify({ op, sql, params }),
46
+ });
47
+ const body = await res.json().catch(() => ({ error: `store returned ${res.status}` }));
48
+ // The DO reports a SQL error as data rather than as a 500, so the message survives the
49
+ // trip — a plugin that wrote bad SQL should read its own error, not "500".
50
+ if (!res.ok || body?.error) throw TroveError.invalid(body?.error || `store returned ${res.status}`);
51
+ return body.result;
52
+ }
53
+
54
+ async exec(sql) { await this.#call('exec', sql); }
55
+ async run(sql, ...params) { return this.#call('run', sql, params); }
56
+ async get(sql, ...params) { return this.#call('get', sql, params); }
57
+ async all(sql, ...params) { return this.#call('all', sql, params); }
58
+ async batch(statements) { return this.#call('batch', null, statements); }
59
+ }
60
+
61
+ export class DurableObjectSqliteProvider extends SqliteProvider {
62
+ /**
63
+ * @param {object} opts
64
+ * @param {{idFromName: Function, get: Function}} opts.namespace a DurableObjectNamespace
65
+ * @param {SqliteProvider} [opts.core] where the CORE keys go — metadata, kv, installs,
66
+ * search. Those are one-per-deployment and already have a home; this provider exists
67
+ * for the keys that are one-per-(user, plugin).
68
+ */
69
+ constructor({ namespace, core = null } = {}) {
70
+ super();
71
+ if (!namespace?.idFromName) throw TroveError.invalid('DurableObjectSqliteProvider requires a Durable Object namespace');
72
+ this.namespace = namespace;
73
+ this.core = core;
74
+ this._dbs = new Map();
75
+ }
76
+
77
+ // A Durable Object's storage survives the isolate, the deploy and the restart. That is
78
+ // the whole question this flag answers.
79
+ get durable() { return true; }
80
+
81
+ async obtain({ key }) {
82
+ if (!key) throw TroveError.invalid('a scope key is required');
83
+ // Core keys keep whatever the deployment already gave them. Routing the metadata
84
+ // store through a DO would put the whole drive behind one single-threaded object,
85
+ // which is the bottleneck this file's header warns about.
86
+ if (this.core && !isPluginScope(key)) return this.core.obtain({ key });
87
+
88
+ let db = this._dbs.get(key);
89
+ if (!db) {
90
+ // NAMED BY THE SCOPE KEY, which is what makes the isolation structural: two scopes
91
+ // are two objects with two databases, not two prefixes in one.
92
+ const stub = this.namespace.get(this.namespace.idFromName(key));
93
+ db = new DurableObjectDatabase(stub, key);
94
+ this._dbs.set(key, db);
95
+ }
96
+ return db;
97
+ }
98
+
99
+ async drop({ key }) {
100
+ if (this.core && !isPluginScope(key)) return this.core.drop({ key });
101
+ const db = this._dbs.get(key);
102
+ this._dbs.delete(key);
103
+ // Ask the object to empty itself. Its storage outlives this process, so forgetting the
104
+ // handle here would leave the data behind — which for an uninstalled plugin is the
105
+ // difference between "removed" and "invisible".
106
+ if (db) await db.stub.fetch('https://trove.store/drop', { method: 'POST' }).catch(() => {});
107
+ }
108
+
109
+ async close() { this._dbs.clear(); }
110
+ }
111
+
112
+ /** Plugin scopes are the ones that cannot be pre-bound — see the header. */
113
+ export function isPluginScope(key) {
114
+ return typeof key === 'string' && key.startsWith('pstore:');
115
+ }
116
+
117
+ /**
118
+ * The Durable Object class itself.
119
+ *
120
+ * Deliberately thin: it owns `state.storage.sql` and answers the verbs above. Everything
121
+ * that decides WHAT may run — `assertSafePluginSql`, the scope gate — already happened on
122
+ * the way in, and repeating it here would put two authorities on one question.
123
+ *
124
+ * Export it from the Worker entry and declare it in wrangler.toml with a
125
+ * `new_sqlite_classes` migration, the same way TroveTasks is declared.
126
+ */
127
+ export function createPluginStore() {
128
+ return class TrovePluginStore {
129
+ constructor(state) {
130
+ this.state = state;
131
+ this.sql = state.storage.sql;
132
+ }
133
+
134
+ async fetch(request) {
135
+ const url = new URL(request.url);
136
+ if (url.pathname === '/drop') {
137
+ // `deleteAll` takes the tables with it, which is the point: an uninstalled
138
+ // plugin's data should stop existing rather than stop being addressed.
139
+ await this.state.storage.deleteAll();
140
+ return json({ result: { ok: true } });
141
+ }
142
+ const { op, sql, params } = await request.json().catch(() => ({}));
143
+ try {
144
+ return json({ result: this.#run(op, sql, params) });
145
+ } catch (err) {
146
+ // As DATA, not a 500: the message is the plugin author's own SQL error and is the
147
+ // only useful thing anyone will get back.
148
+ return json({ error: err?.message || String(err) });
149
+ }
150
+ }
151
+
152
+ #run(op, sql, params) {
153
+ const bind = (s, p) => [...this.sql.exec(s, ...(p || []))];
154
+ switch (op) {
155
+ case 'exec':
156
+ // Multi-statement schema setup. `exec` takes one statement at a time here, so a
157
+ // schema arrives split — which is also what makes it safe to run per statement.
158
+ for (const s of String(sql).split(';').map((x) => x.trim()).filter(Boolean)) this.sql.exec(s);
159
+ return { ok: true };
160
+ case 'run': {
161
+ bind(sql, params);
162
+ return { changes: this.sql.rowsWritten, lastInsertRowid: null };
163
+ }
164
+ case 'get': return bind(sql, params)[0] ?? null;
165
+ case 'all': return bind(sql, params);
166
+ case 'batch': {
167
+ // ATOMIC. A DO's storage transaction is what makes a batch mean what it says;
168
+ // running the statements loose would leave a half-applied schema behind on the
169
+ // first bad one.
170
+ const out = [];
171
+ this.state.storage.transactionSync(() => {
172
+ for (const st of params || []) out.push(bind(st.sql, st.params));
173
+ });
174
+ return out;
175
+ }
176
+ default: throw new Error(`Unknown store op "${op}"`);
177
+ }
178
+ }
179
+ };
180
+ }
181
+
182
+ const json = (body) => new Response(JSON.stringify(body), { headers: { 'content-type': 'application/json' } });
@@ -15,7 +15,7 @@
15
15
  // [[durable_objects.bindings]] name = "TASKS" class_name = "TroveTasks"
16
16
  // -> owns scans and reindexes (see below)
17
17
 
18
- import { D1SqliteProvider } from '@3sln/trove/core';
18
+ import { D1SqliteProvider, DurableObjectSqliteProvider, createPluginStore } from '@3sln/trove/core';
19
19
  import { createServer, configFromEnv } from '../index.js';
20
20
  import { createTaskHost, remoteBackground } from './worker-tasks.js';
21
21
 
@@ -42,14 +42,24 @@ async function getServer(env, buildVfs, { delegate = true } = {}) {
42
42
  // Worker falls back to in-memory everything, which looks like it works right up until
43
43
  // the isolate is recycled and the drive is empty. Bind `DB` and it persists.
44
44
  if (env.DB && !config.sqlite) {
45
- config.sqlite = new D1SqliteProvider({
45
+ const d1 = new D1SqliteProvider({
46
46
  db: env.DB,
47
- // Plugin storage. `scopes: { plugins: … }` named a key that is already a CORE key
48
- // (the install-record store), so the binding was silently ignored and every
49
- // /api/plugins/:id/sql call was a 501 — the real keys look like
50
- // `pstore:<user>:plg:<pluginId>` and cannot be pre-bound at all.
47
+ // Plugin storage of last resort. `scopes: { plugins: … }` named a key that is
48
+ // already a CORE key (the install-record store), so the binding was silently
49
+ // ignored and every /api/plugins/:id/sql call was a 501 — the real keys look like
50
+ // `pstore:<user>:plg:<pluginId>` and cannot be pre-bound at all. One binding then
51
+ // holds every plugin's tables side by side, which is a compromise, not a design.
51
52
  pluginStore: env.PLUGIN_DB || null,
52
53
  });
54
+ // A DURABLE OBJECT PER SCOPE where one is available, which is what plugin storage
55
+ // actually wants: a DO is addressable by name, so each (user, plugin) is its own
56
+ // object with its own SQLite database, created on demand. That is the primitive D1
57
+ // does not have. Core stores — metadata, kv, installs, search — stay on D1: routing
58
+ // the whole drive through one single-threaded object would be a bottleneck, and they
59
+ // are one-per-deployment so they can simply be bound.
60
+ config.sqlite = env.PLUGIN_STORE
61
+ ? new DurableObjectSqliteProvider({ namespace: env.PLUGIN_STORE, core: d1 })
62
+ : d1;
53
63
  config.metadata = { driver: 'sqlite' };
54
64
  }
55
65
  // Cloudflare Vectorize binding → first-class vector store (no REST creds needed).
@@ -162,4 +172,13 @@ export default {
162
172
  */
163
173
  export const TroveTasks = createTaskHost((env) => getServer(env, undefined, { delegate: false }));
164
174
 
175
+ /**
176
+ * One SQLite database per plugin scope, addressed by name.
177
+ *
178
+ * Export it alongside TroveTasks and declare it in wrangler.toml with a
179
+ * `new_sqlite_classes` migration; `PLUGIN_STORE` is then picked up above. Without the
180
+ * binding a drive falls back to the shared D1 store, which still works and isolates less.
181
+ */
182
+ export const TrovePluginStore = createPluginStore();
183
+
165
184
  export { getServer };