@3sln/trove 0.0.19 → 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 +38 -0
- package/package.json +1 -1
- package/packages/core/src/index.js +3 -0
- package/packages/core/src/sqlite-d1.js +6 -0
- package/packages/core/src/sqlite-do.js +182 -0
- package/packages/server/src/adapters/worker.js +25 -6
- package/packages/web/dist/assets/main-6tn55drx.js +742 -0
- package/packages/web/dist/assets/{main-axerf2vw.js.map → main-6tn55drx.js.map} +15 -15
- package/packages/web/dist/assets/{styles-hskanqc0.css → styles-15b7sgsm.css} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +100 -7
- package/packages/web/src/bl/commands.js +3 -1
- package/packages/web/src/bl/fileType.js +31 -0
- package/packages/web/src/bl/launcher.js +17 -4
- package/packages/web/src/bl/openers.js +16 -0
- package/packages/web/src/bl/state.js +4 -0
- package/packages/web/src/styles.css +27 -0
- package/packages/web/src/ui/components/launcher.js +3 -1
- package/packages/web/src/ui/components/overlays.js +20 -1
- package/packages/web/src/ui/components/statusBar.js +20 -6
- package/packages/web/src/ui/components/views/grid.js +4 -4
- package/packages/web/src/ui/components/views/list.js +4 -4
- package/packages/web/src/ui/components/views/parts.js +47 -2
- package/packages/web/src/ui/compositions/workbench.js +3 -1
- package/packages/web/dist/assets/main-axerf2vw.js +0 -742
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.
|
|
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
|
-
|
|
45
|
+
const d1 = new D1SqliteProvider({
|
|
46
46
|
db: env.DB,
|
|
47
|
-
// Plugin storage. `scopes: { plugins: … }` named a key that is
|
|
48
|
-
// (the install-record store), so the binding was silently
|
|
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 };
|