@3sln/trove 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (162) hide show
  1. package/README.md +1227 -0
  2. package/package.json +75 -0
  3. package/packages/core/src/collections/index.js +249 -0
  4. package/packages/core/src/errors.js +186 -0
  5. package/packages/core/src/identity/discovery.js +210 -0
  6. package/packages/core/src/identity/index.js +188 -0
  7. package/packages/core/src/identity/jwt.js +199 -0
  8. package/packages/core/src/index.js +104 -0
  9. package/packages/core/src/indexers/contribution.js +115 -0
  10. package/packages/core/src/indexers/registry.js +162 -0
  11. package/packages/core/src/indexing.js +340 -0
  12. package/packages/core/src/issues.js +150 -0
  13. package/packages/core/src/kv.js +0 -0
  14. package/packages/core/src/links.js +141 -0
  15. package/packages/core/src/metadata/cursor.js +73 -0
  16. package/packages/core/src/metadata/interface.js +244 -0
  17. package/packages/core/src/metadata/memory.js +270 -0
  18. package/packages/core/src/metadata/sqlite.js +412 -0
  19. package/packages/core/src/notifications/index.js +139 -0
  20. package/packages/core/src/notifications/webpush.js +217 -0
  21. package/packages/core/src/plugins/contributions.js +177 -0
  22. package/packages/core/src/plugins/identity.js +98 -0
  23. package/packages/core/src/plugins/index.js +225 -0
  24. package/packages/core/src/plugins/indexers.js +142 -0
  25. package/packages/core/src/plugins/installStore.js +134 -0
  26. package/packages/core/src/plugins/package.js +102 -0
  27. package/packages/core/src/plugins/packageStore.js +61 -0
  28. package/packages/core/src/plugins/runtime.js +101 -0
  29. package/packages/core/src/plugins/sql.js +52 -0
  30. package/packages/core/src/retry.js +74 -0
  31. package/packages/core/src/scan.js +302 -0
  32. package/packages/core/src/search/embeddings.js +128 -0
  33. package/packages/core/src/search/index.js +200 -0
  34. package/packages/core/src/search/keywordStore.js +107 -0
  35. package/packages/core/src/search/sqliteStores.js +455 -0
  36. package/packages/core/src/search/tagMatch.js +59 -0
  37. package/packages/core/src/search/transformer.js +195 -0
  38. package/packages/core/src/search/vectorStore.js +274 -0
  39. package/packages/core/src/search/vectorize.js +249 -0
  40. package/packages/core/src/sidecar/document.js +213 -0
  41. package/packages/core/src/sidecar/index.js +174 -0
  42. package/packages/core/src/sidecar/manager.js +239 -0
  43. package/packages/core/src/sidecar/store.js +46 -0
  44. package/packages/core/src/signedUrls.js +170 -0
  45. package/packages/core/src/sqlite-d1.js +162 -0
  46. package/packages/core/src/sqlite-driver.js +42 -0
  47. package/packages/core/src/sqlite.js +162 -0
  48. package/packages/core/src/storage/filesystem.js +283 -0
  49. package/packages/core/src/storage/interface.js +222 -0
  50. package/packages/core/src/storage/memory.js +113 -0
  51. package/packages/core/src/storage/prefixed.js +75 -0
  52. package/packages/core/src/storage/s3.js +316 -0
  53. package/packages/core/src/storage/s3sigv4.js +185 -0
  54. package/packages/core/src/tasks.js +228 -0
  55. package/packages/core/src/uploads.js +386 -0
  56. package/packages/core/src/util.js +125 -0
  57. package/packages/core/src/vfs.js +666 -0
  58. package/packages/plugin-sdk/src/browser.js +316 -0
  59. package/packages/plugin-sdk/src/index.js +32 -0
  60. package/packages/plugin-sdk/src/protocol.js +59 -0
  61. package/packages/plugin-sdk/src/rpc.js +95 -0
  62. package/packages/server/src/adapters/bun.js +78 -0
  63. package/packages/server/src/adapters/node.js +115 -0
  64. package/packages/server/src/adapters/staticAssets.js +123 -0
  65. package/packages/server/src/adapters/webDist.js +70 -0
  66. package/packages/server/src/adapters/worker-tasks.js +206 -0
  67. package/packages/server/src/adapters/worker.js +159 -0
  68. package/packages/server/src/cachePolicy.js +34 -0
  69. package/packages/server/src/engine/README.md +88 -0
  70. package/packages/server/src/engine/actions/scanCollection.js +114 -0
  71. package/packages/server/src/engine/index.js +95 -0
  72. package/packages/server/src/engine/lazy.js +25 -0
  73. package/packages/server/src/engine/providers/access.js +363 -0
  74. package/packages/server/src/engine/providers/core.js +405 -0
  75. package/packages/server/src/engine/providers/scan.js +67 -0
  76. package/packages/server/src/index.js +698 -0
  77. package/packages/server/src/manifest.js +98 -0
  78. package/packages/server/src/mcp/auth.js +40 -0
  79. package/packages/server/src/mcp/index.js +213 -0
  80. package/packages/server/src/mcp/protocol.js +181 -0
  81. package/packages/server/src/mcp/tools.js +351 -0
  82. package/packages/server/src/router.js +229 -0
  83. package/packages/server/src/routes.js +1066 -0
  84. package/packages/server/src/scope.js +43 -0
  85. package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
  86. package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
  87. package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
  88. package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
  89. package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
  90. package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
  91. package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
  92. package/packages/web/dist/icon.svg +11 -0
  93. package/packages/web/dist/index.html +16 -0
  94. package/packages/web/dist/sql-wasm.wasm +0 -0
  95. package/packages/web/dist/sw.js +186 -0
  96. package/packages/web/src/bl/actions.js +410 -0
  97. package/packages/web/src/bl/activity.js +306 -0
  98. package/packages/web/src/bl/commands.js +274 -0
  99. package/packages/web/src/bl/fileType.js +49 -0
  100. package/packages/web/src/bl/index.js +70 -0
  101. package/packages/web/src/bl/links.js +54 -0
  102. package/packages/web/src/bl/offline.js +268 -0
  103. package/packages/web/src/bl/openers.js +71 -0
  104. package/packages/web/src/bl/pluginInstall.js +59 -0
  105. package/packages/web/src/bl/services.js +143 -0
  106. package/packages/web/src/bl/social.js +234 -0
  107. package/packages/web/src/bl/tagQuery.js +44 -0
  108. package/packages/web/src/main.js +10 -0
  109. package/packages/web/src/platform/api.js +529 -0
  110. package/packages/web/src/platform/commands.js +89 -0
  111. package/packages/web/src/platform/context.js +77 -0
  112. package/packages/web/src/platform/contributions.js +156 -0
  113. package/packages/web/src/platform/index.js +150 -0
  114. package/packages/web/src/platform/keybindings.js +199 -0
  115. package/packages/web/src/platform/mediaUrls.js +137 -0
  116. package/packages/web/src/platform/navigation.js +131 -0
  117. package/packages/web/src/platform/notifications.js +50 -0
  118. package/packages/web/src/platform/overlay.js +81 -0
  119. package/packages/web/src/platform/pluginClientDb.js +132 -0
  120. package/packages/web/src/platform/pluginDock.js +141 -0
  121. package/packages/web/src/platform/pluginFrames.js +194 -0
  122. package/packages/web/src/platform/pluginHost.js +648 -0
  123. package/packages/web/src/platform/pluginMedia.js +62 -0
  124. package/packages/web/src/platform/pluginModules.js +90 -0
  125. package/packages/web/src/platform/pluginNet.js +71 -0
  126. package/packages/web/src/platform/pluginPackage.js +247 -0
  127. package/packages/web/src/platform/pluginRpc.js +377 -0
  128. package/packages/web/src/platform/pluginSigning.js +168 -0
  129. package/packages/web/src/platform/pluginStore.js +67 -0
  130. package/packages/web/src/platform/settings.js +101 -0
  131. package/packages/web/src/platform/spatialNav.js +286 -0
  132. package/packages/web/src/platform/viewport.js +123 -0
  133. package/packages/web/src/platform/voice.js +133 -0
  134. package/packages/web/src/platform/voiceSearch.js +155 -0
  135. package/packages/web/src/platform/whenclause.js +162 -0
  136. package/packages/web/src/platform/workbench.js +156 -0
  137. package/packages/web/src/runtime.js +73 -0
  138. package/packages/web/src/styles.css +1382 -0
  139. package/packages/web/src/ui/components/activityBar.js +35 -0
  140. package/packages/web/src/ui/components/activityPanel.js +132 -0
  141. package/packages/web/src/ui/components/commandPalette.js +154 -0
  142. package/packages/web/src/ui/components/editorArea.js +75 -0
  143. package/packages/web/src/ui/components/launcher.js +392 -0
  144. package/packages/web/src/ui/components/openers/index.js +212 -0
  145. package/packages/web/src/ui/components/openers/markdown.js +222 -0
  146. package/packages/web/src/ui/components/overlays.js +255 -0
  147. package/packages/web/src/ui/components/phoneChrome.js +188 -0
  148. package/packages/web/src/ui/components/pluginReview.js +151 -0
  149. package/packages/web/src/ui/components/pluginsView.js +120 -0
  150. package/packages/web/src/ui/components/settingsView.js +258 -0
  151. package/packages/web/src/ui/components/social.js +290 -0
  152. package/packages/web/src/ui/components/statusBar.js +198 -0
  153. package/packages/web/src/ui/components/views/grid.js +115 -0
  154. package/packages/web/src/ui/components/views/index.js +155 -0
  155. package/packages/web/src/ui/components/views/list.js +50 -0
  156. package/packages/web/src/ui/components/views/parts.js +58 -0
  157. package/packages/web/src/ui/compositions/workbench.js +125 -0
  158. package/packages/web/src/ui/format.js +33 -0
  159. package/packages/web/src/ui/icon.js +81 -0
  160. package/packages/web/src/ui/media.js +114 -0
  161. package/packages/web/src/ui/sanitize.js +86 -0
  162. package/packages/web/src/workbench.js +205 -0
@@ -0,0 +1,239 @@
1
+ // SidecarManager — the "hot" side of the cold/hot split. It keeps recently-used
2
+ // sidecar documents in memory, applies mutations against the live copy for
3
+ // instant reads, and flushes to cold storage on a short debounce. Every flush
4
+ // does a read-merge-write against the cold copy (the doc is a CRDT), so a
5
+ // concurrent writer on another instance never clobbers this one's changes.
6
+ // Idle documents are flushed and evicted to bound memory.
7
+
8
+ import { mergeDoc } from './document.js';
9
+
10
+ export class SidecarManager {
11
+ /**
12
+ * @param {object} deps
13
+ * @param {import('./store.js').SidecarStore} deps.store
14
+ * @param {number} [deps.flushDelayMs] debounce before writing back (default 1500)
15
+ * @param {number} [deps.idleEvictMs] evict after this idle time (default 60s)
16
+ */
17
+ constructor({ store, flushDelayMs = 1500, idleEvictMs = 60_000, issues = null, maxFlushRetries = 5 }) {
18
+ this.store = store;
19
+ this.flushDelayMs = flushDelayMs;
20
+ this.idleEvictMs = idleEvictMs;
21
+ this.issues = issues;
22
+ this.maxFlushRetries = maxFlushRetries;
23
+ this.hot = new Map(); // nodeId -> { doc, dirty, timer, loading, lastAccess, retries }
24
+ }
25
+
26
+ async #entry(nodeId) {
27
+ let e = this.hot.get(nodeId);
28
+ if (e) {
29
+ if (e.loading) await e.loading;
30
+ e.lastAccess = now();
31
+ return e;
32
+ }
33
+ e = { doc: null, dirty: false, timer: null, lastAccess: now() };
34
+ this.hot.set(nodeId, e);
35
+ e.loading = (async () => {
36
+ e.doc = (await this.store.load(nodeId)) || this.store.emptyDoc(nodeId);
37
+ })();
38
+ try {
39
+ await e.loading;
40
+ } catch (err) {
41
+ // Drop the entry rather than leaving a permanently-rejecting `loading` in the map.
42
+ // A single transient read failure otherwise made that file's conversation and tags
43
+ // unreadable AND unwritable until the sweeper happened to evict it — indefinitely
44
+ // with maintenance off — every later call re-throwing the very same error.
45
+ this.hot.delete(nodeId);
46
+ throw err;
47
+ } finally {
48
+ e.loading = null;
49
+ }
50
+ return e;
51
+ }
52
+
53
+ /** Read the live document (loading it if cold). */
54
+ async get(nodeId) {
55
+ return (await this.#entry(nodeId)).doc;
56
+ }
57
+
58
+ /**
59
+ * Apply `fn(doc)` to the live document, mark dirty, and schedule a flush.
60
+ * @returns whatever `fn` returns (e.g. the affected comment).
61
+ */
62
+ async mutate(nodeId, fn) {
63
+ const e = await this.#entry(nodeId);
64
+ const result = await fn(e.doc);
65
+ e.dirty = true;
66
+ // Stamp the change. A flush that is already awaiting `store.save()` computed its
67
+ // merged document BEFORE this ran, so it must not adopt that result wholesale or
68
+ // mark the entry clean — see #flushOnce.
69
+ e.gen = (e.gen || 0) + 1;
70
+ this.#schedule(nodeId, e);
71
+ return result;
72
+ }
73
+
74
+ /**
75
+ * Debounce a write-back, and keep trying if it fails.
76
+ *
77
+ * Between `mutate` and a successful flush, memory holds the ONLY copy of what the
78
+ * user just wrote — the API has already replied 200 and their comment is on screen.
79
+ * A flush that fails and is merely logged means that copy is unreferenced by anything
80
+ * that will ever write it, so the retry here is not politeness, it is the difference
81
+ * between a durable comment and one that disappears when the process does.
82
+ */
83
+ #schedule(nodeId, e, delay = this.flushDelayMs) {
84
+ if (e.timer) return;
85
+ e.timer = setTimeout(() => {
86
+ e.timer = null;
87
+ this.flush(nodeId).then(() => {
88
+ if (e.retries) {
89
+ e.retries = 0;
90
+ this.issues?.clear?.('sidecar-flush', nodeId).catch(() => {});
91
+ }
92
+ }).catch((err) => {
93
+ e.retries = (e.retries || 0) + 1;
94
+ console.error(`sidecar flush failed for ${nodeId} (attempt ${e.retries})`, err);
95
+ if (e.retries <= this.maxFlushRetries) {
96
+ // Back off, but stay dirty and stay hot — sweep() will not evict it.
97
+ this.#schedule(nodeId, e, Math.min(this.flushDelayMs * 2 ** e.retries, 60_000));
98
+ } else {
99
+ // Out of retries. The comment is still in memory and still served, but it will
100
+ // not survive a restart, and the person who wrote it has been told it saved.
101
+ // That is a standing problem, which is exactly what the issue registry is for.
102
+ this.issues?.raise?.({
103
+ kind: 'sidecar-flush',
104
+ subject: nodeId,
105
+ severity: 'error',
106
+ title: 'A comment or tag could not be saved',
107
+ detail: `Changes to this item's conversation are held in memory only — ${err?.message || err}`,
108
+ // The op the server registers via issues.handle('sidecar-flush', …), which
109
+ // is what makes the Retry button appear and do the right thing.
110
+ retry: 'sidecar-flush',
111
+ }).catch(() => {});
112
+ }
113
+ });
114
+ }, delay);
115
+ if (e.timer.unref) e.timer.unref();
116
+ }
117
+
118
+ /** Retry every document that is still holding unsaved changes. */
119
+ async retryPending() {
120
+ const pending = [...this.hot.entries()].filter(([, e]) => e.dirty);
121
+ for (const [id, e] of pending) {
122
+ e.retries = 0;
123
+ // This IS the write the pending timer was going to do; leaving it armed would
124
+ // keep sweep() from ever evicting the entry.
125
+ if (e.timer) { clearTimeout(e.timer); e.timer = null; }
126
+ await this.flush(id);
127
+ this.issues?.clear?.('sidecar-flush', id).catch(() => {});
128
+ }
129
+ return { flushed: pending.length };
130
+ }
131
+
132
+ /**
133
+ * Write the live doc back, merging with the cold copy first.
134
+ *
135
+ * Serialized per document. Two flushes of the same sidecar overlapping is how one of
136
+ * them computes a merge from a document the other is about to replace — and the
137
+ * replacement wins, silently, after the API has already replied 200.
138
+ */
139
+ flush(nodeId) {
140
+ const e = this.hot.get(nodeId);
141
+ if (!e || !e.doc) return Promise.resolve();
142
+ const run = () => this.#flushOnce(nodeId, e);
143
+ const next = (e.chain || Promise.resolve()).then(run, run);
144
+ // The chain itself must never stay rejected, or one failure poisons every later
145
+ // flush of this document. Callers still see their own rejection through `next`.
146
+ e.chain = next.catch(() => {});
147
+ return next;
148
+ }
149
+
150
+ async #flushOnce(nodeId, e) {
151
+ if (e.loading) await e.loading;
152
+ if (!e.dirty || !e.doc) return;
153
+ // The generation this write covers. `store.load` and `store.save` are object-store
154
+ // round trips — hundreds of milliseconds — and `mutate` applies its change in place
155
+ // on `e.doc` throughout. So a comment accepted during the save lands in `e.doc`,
156
+ // is NOT in `merged` (computed before it), and used to be erased by `e.doc = merged`
157
+ // and then guaranteed never to be retried by `e.dirty = false`.
158
+ const gen = e.gen || 0;
159
+ const cold = await this.store.load(nodeId);
160
+ const merged = cold ? mergeDoc(cold, e.doc) : e.doc;
161
+ await this.store.save(nodeId, merged);
162
+ if ((e.gen || 0) !== gen) {
163
+ // Something arrived while we were saving. Fold what we just persisted back INTO
164
+ // the live document rather than over it, and leave the entry dirty so the next
165
+ // flush carries the newcomer.
166
+ e.doc = merged === e.doc ? e.doc : mergeDoc(merged, e.doc);
167
+ return;
168
+ }
169
+ e.doc = merged;
170
+ e.dirty = false;
171
+ }
172
+
173
+ /** @returns {Promise<{flushed: number, failed: Array<{nodeId, error}>}>} */
174
+ async flushAll() {
175
+ const failed = [];
176
+ let flushed = 0;
177
+ await Promise.all([...this.hot.keys()].map((id) => this.flush(id)
178
+ .then(() => { flushed++; })
179
+ .catch((error) => failed.push({ nodeId: id, error }))));
180
+ return { flushed, failed };
181
+ }
182
+
183
+ /**
184
+ * Flush + drop documents idle longer than idleEvictMs.
185
+ *
186
+ * A document is evicted only once it is CLEAN. Dropping a dirty one discards the only
187
+ * copy of a change the user was told had saved — and the failure that made it dirty is
188
+ * exactly the moment that would happen, so "flush, then delete regardless" turns a
189
+ * transient storage blip into silent data loss a minute later.
190
+ */
191
+ async sweep() {
192
+ const cutoff = now() - this.idleEvictMs;
193
+ for (const [id, e] of this.hot) {
194
+ if (e.lastAccess >= cutoff || e.timer) continue;
195
+ if (e.dirty) {
196
+ try {
197
+ await this.flush(id);
198
+ // Flushing can succeed and leave the entry DIRTY: the generation guard in
199
+ // #flushOnce returns cleanly when a mutation landed mid-save, precisely so
200
+ // the newcomer gets carried by the next write. Evicting on "didn't throw"
201
+ // discards it — the one thing this method exists not to do.
202
+ if (e.dirty) { this.#schedule(id, e); continue; }
203
+ } catch {
204
+ // Still unsaved: keep it in memory and let the retry schedule own it. Memory
205
+ // growth is bounded by the store being broken, which is a loud condition.
206
+ this.#schedule(id, e);
207
+ continue;
208
+ }
209
+ }
210
+ this.hot.delete(id);
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Flush everything and let go.
216
+ *
217
+ * Returns what could not be written rather than swallowing it: the caller is a
218
+ * shutdown path, and exiting 0 after dropping someone's comments is a lie the process
219
+ * tells on its way out.
220
+ */
221
+ async dispose() {
222
+ for (const e of this.hot.values()) if (e.timer) { clearTimeout(e.timer); e.timer = null; }
223
+ const result = await this.flushAll();
224
+ if (result.failed.length) {
225
+ console.error(`[trove] ${result.failed.length} sidecar document(s) could not be saved on shutdown:`,
226
+ result.failed.map((f) => `${f.nodeId}: ${f.error?.message || f.error}`).join('; '));
227
+ }
228
+ this.hot.clear();
229
+ return result;
230
+ }
231
+ }
232
+
233
+ function now() {
234
+ try {
235
+ return Date.now();
236
+ } catch {
237
+ return 0;
238
+ }
239
+ }
@@ -0,0 +1,46 @@
1
+ // SidecarStore — cold persistence for sidecar documents. Each file's sidecar is
2
+ // a single JSON object stored in the SAME pluggable storage backend as the file
3
+ // bytes (S3/filesystem/memory), under a reserved `sidecars/` key space. This
4
+ // keeps social/metadata state next to the data, with zero extra infrastructure,
5
+ // and lets an S3 deployment need no database at all for conversations.
6
+
7
+ import { emptyDoc } from './document.js';
8
+ import { wrapError } from '../errors.js';
9
+ import { readAll } from '../util.js';
10
+
11
+ function keyFor(nodeId) {
12
+ return `sidecars/${nodeId}.json`;
13
+ }
14
+
15
+ export class SidecarStore {
16
+ /** @param {{storage: import('../storage/interface.js').StorageBackend}} deps */
17
+ constructor({ storage }) {
18
+ this.storage = storage;
19
+ }
20
+
21
+ /** @returns {Promise<object|null>} the raw CRDT doc, or null if none yet. */
22
+ async load(nodeId) {
23
+ try {
24
+ const { stream } = await this.storage.get(keyFor(nodeId));
25
+ const bytes = await readAll(stream);
26
+ return JSON.parse(new TextDecoder().decode(bytes));
27
+ } catch (err) {
28
+ const e = wrapError(err);
29
+ if (e.code === 'not_found') return null;
30
+ throw e;
31
+ }
32
+ }
33
+
34
+ async save(nodeId, doc) {
35
+ const bytes = new TextEncoder().encode(JSON.stringify(doc));
36
+ await this.storage.put(keyFor(nodeId), bytes, { contentType: 'application/json' });
37
+ }
38
+
39
+ async remove(nodeId) {
40
+ await this.storage.delete(keyFor(nodeId)).catch(() => {});
41
+ }
42
+
43
+ emptyDoc(nodeId) {
44
+ return emptyDoc(nodeId);
45
+ }
46
+ }
@@ -0,0 +1,170 @@
1
+ // A URL that carries its own authorization.
2
+ //
3
+ // Some things cannot send an `Authorization` header and still need one object's bytes:
4
+ // an `<img src>`, a `<video src>`, `cache.add()` behind a service worker, an external
5
+ // API an indexer wants to hand a file to. For those the grant travels IN the URL.
6
+ //
7
+ // Two implementations, one contract — see docs/design/signed-urls.md:
8
+ //
9
+ // - the backend can presign (S3/R2) → the storage URL, bytes never touch the server
10
+ // - it cannot (filesystem, NAS, …) → ours: ?id=…&op=…&exp=…&sig=<hmac>
11
+ //
12
+ // Both are stateless with the expiry baked in: nothing stored, nothing to revoke,
13
+ // nothing to clean up. They stop verifying at `exp` and that is the whole lifecycle.
14
+ //
15
+ // The signature is a GRANT, not a hint. A request carrying a valid one is served without
16
+ // a principal, because the signature was minted by someone who held `read` on that node
17
+ // at the time. Which is why it covers the id and the op: a valid signature for a file you
18
+ // may see must not be editable into one for a file you may not.
19
+
20
+ import { TroveError } from './errors.js';
21
+
22
+ const MINUTES = 60;
23
+ const HOURS = 60 * MINUTES;
24
+
25
+ /**
26
+ * What a signed URL may be for, and how long each may live.
27
+ *
28
+ * Content URLs are LONG on purpose — longer than it takes to consume almost anything.
29
+ * The failure they prevent is a film that stops forty minutes in, or a download that
30
+ * dies at 90% and cannot resume, and those are much worse than the exposure of a URL
31
+ * that stays fetchable for a day.
32
+ *
33
+ * `index` is the exception and stays short: that one is handed to an external service
34
+ * and leaves our control entirely, so it should be good for one prompt fetch and then
35
+ * nothing.
36
+ */
37
+ export const URL_PURPOSES = {
38
+ index: { maxAge: 15 * MINUTES, defaultAge: 5 * MINUTES },
39
+ // A browser download that stalls and resumes must not find its URL dead. Validated
40
+ // when the transfer STARTS, so an in-flight 4 GB transfer finishes regardless.
41
+ download: { maxAge: 12 * HOURS, defaultAge: 2 * HOURS },
42
+ // A <video> re-requests on every seek, so this outlives the SITTING, not the request:
43
+ // a long film, an audiobook session, an evening of episodes.
44
+ media: { maxAge: 24 * HOURS, defaultAge: 12 * HOURS },
45
+ };
46
+
47
+ const enc = new TextEncoder();
48
+
49
+ /** base64url, because this ends up in a query string. */
50
+ function b64url(bytes) {
51
+ let s = '';
52
+ for (const b of bytes) s += String.fromCharCode(b);
53
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
54
+ }
55
+
56
+ /**
57
+ * Signs and verifies one-object grants.
58
+ *
59
+ * The secret is the server's, never a user's — this is the deployment vouching for a
60
+ * decision it already made, not an identity claim.
61
+ */
62
+ export class SignedUrls {
63
+ /**
64
+ * @param {object} opts
65
+ * @param {string} opts.secret signing secret (see the `urlSecret` provider — configured,
66
+ * or generated once and kept in the KV store so it survives restarts and is shared
67
+ * between instances)
68
+ * @param {() => number} [opts.now] injected clock, for tests
69
+ */
70
+ constructor({ secret, now = () => Date.now() } = {}) {
71
+ if (!secret || typeof secret !== 'string') {
72
+ throw TroveError.invalid('SignedUrls requires a signing secret');
73
+ }
74
+ this._secret = secret;
75
+ this._key = null;
76
+ this.now = now;
77
+ }
78
+
79
+ async #hmacKey() {
80
+ if (!this._key) {
81
+ this._key = await crypto.subtle.importKey(
82
+ 'raw', enc.encode(this._secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
83
+ );
84
+ }
85
+ return this._key;
86
+ }
87
+
88
+ /**
89
+ * The signature over one grant.
90
+ *
91
+ * Length-prefixed rather than joined on a separator: `('a|b', 'c')` and `('a', 'b|c')`
92
+ * join to the same string, so a separator alone lets one field's value be pushed into
93
+ * the next. Nobody has ever been bitten by that here, and nobody will be.
94
+ */
95
+ async sign({ id, op, exp }) {
96
+ const parts = [String(id), String(op), String(exp)];
97
+ const payload = parts.map((p) => `${p.length}:${p}`).join('');
98
+ const sig = await crypto.subtle.sign('HMAC', await this.#hmacKey(), enc.encode(payload));
99
+ return b64url(new Uint8Array(sig));
100
+ }
101
+
102
+ /** The query parameters that make a grant, for `op` on `id`, good for `expiresIn` seconds. */
103
+ async grant(id, { op = 'download', expiresIn } = {}) {
104
+ const purpose = URL_PURPOSES[op];
105
+ if (!purpose) throw TroveError.invalid(`Unknown signed-URL purpose "${op}"`);
106
+ // Capped server-side: `expiresIn` reaches here from a client and a URL good for a
107
+ // year is the one thing this design cannot take back.
108
+ const age = Math.min(Math.max(1, expiresIn || purpose.defaultAge), purpose.maxAge);
109
+ const exp = Math.floor(this.now() / 1000) + age;
110
+ return { id, op, exp, sig: await this.sign({ id, op, exp }), expiresAt: exp * 1000 };
111
+ }
112
+
113
+ /**
114
+ * Whether these parameters are a grant we issued and that is still good.
115
+ *
116
+ * Returns a reason rather than a bare false: "expired" and "not ours" are different
117
+ * events — the first is ordinary and the client should re-mint, the second is someone
118
+ * editing URLs and is worth being able to see in a log.
119
+ */
120
+ async check({ id, op, exp, sig }) {
121
+ if (!id || !op || !exp || !sig) return { ok: false, reason: 'incomplete' };
122
+ if (!URL_PURPOSES[op]) return { ok: false, reason: 'unknown-purpose' };
123
+ const at = Number(exp);
124
+ if (!Number.isFinite(at)) return { ok: false, reason: 'malformed' };
125
+ // Expiry BEFORE the signature check: an expired grant is not a security event, and
126
+ // it is the overwhelmingly common failure. Cheap first.
127
+ if (at * 1000 <= this.now()) return { ok: false, reason: 'expired' };
128
+ const expected = await this.sign({ id, op, exp: at });
129
+ return timingSafeEqual(expected, String(sig))
130
+ ? { ok: true, id: String(id), op: String(op), expiresAt: at * 1000 }
131
+ : { ok: false, reason: 'bad-signature' };
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Constant-time string compare.
137
+ *
138
+ * `a === b` on a signature leaks where the first differing byte is, which over enough
139
+ * requests is a forgery oracle. The cost of not caring is small and the cost of caring is
140
+ * nothing.
141
+ */
142
+ function timingSafeEqual(a, b) {
143
+ if (a.length !== b.length) return false;
144
+ let diff = 0;
145
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
146
+ return diff === 0;
147
+ }
148
+
149
+ /**
150
+ * A secret that survives a restart and is shared between instances.
151
+ *
152
+ * Configured is best. Failing that, one is generated and kept in the KV store — which is
153
+ * what makes the fallback safe rather than convenient: a per-process random secret would
154
+ * work perfectly on one machine and invalidate half the URLs in flight the moment a
155
+ * second instance answered a request.
156
+ */
157
+ const SECRET_NS = 'server';
158
+ const SECRET_KEY = 'urlSecret';
159
+
160
+ export async function resolveUrlSecret({ configured, kv } = {}) {
161
+ if (configured) return configured;
162
+ if (!kv) throw TroveError.invalid('Signed URLs need either a configured secret or a KV store to keep one in');
163
+ const existing = await kv.get(SECRET_NS, SECRET_KEY);
164
+ if (existing) return existing;
165
+ const generated = b64url(crypto.getRandomValues(new Uint8Array(32)));
166
+ await kv.set(SECRET_NS, SECRET_KEY, generated);
167
+ // Re-read rather than trust the write: a racing instance may have got there first, and
168
+ // two instances signing with different secrets reject each other's URLs.
169
+ return (await kv.get(SECRET_NS, SECRET_KEY)) || generated;
170
+ }
@@ -0,0 +1,162 @@
1
+ // SQLite on Cloudflare D1.
2
+ //
3
+ // The Workers deployment was the one with a hole in it: storage had R2, vectors had
4
+ // Vectorize, and metadata said "implement the MetadataStore interface over env.DB" —
5
+ // which is a way of saying it didn't work. The provider interface is six methods, and
6
+ // D1 speaks almost exactly the same dialect, so this closes it.
7
+ //
8
+ // Two things about D1 shape the design, and neither is a detail an operator should have
9
+ // to discover from a stack trace:
10
+ //
11
+ // 1. **A binding is one database.** LocalSqliteProvider hands out sibling FILES for
12
+ // plugin scopes, which is what keeps one plugin's SQL out of another's. There is no
13
+ // "create me another database" call on D1, so a scope with no binding of its own is
14
+ // refused rather than quietly co-located — plugin isolation is a security boundary,
15
+ // and silently collapsing it is the wrong way to save an operator a config line.
16
+ // 2. **No sqlite-vec.** The vector half of search cannot live here. Use Vectorize (the
17
+ // worker adapter wires it from the binding); the keyword half is fine, since D1
18
+ // compiles in FTS5.
19
+
20
+ import { TroveError } from './errors.js';
21
+ import { SqliteDatabase, SqliteProvider } from './sqlite.js';
22
+
23
+ // The keys the server co-locates in one database — see LocalSqliteProvider.
24
+ const CORE_KEYS = new Set(['metadata', 'kv', 'plugins', 'search']);
25
+
26
+ /** Split a multi-statement DDL script into individual statements. */
27
+ function splitStatements(sql) {
28
+ // Good enough for the schema DDL Trove ships, which contains no semicolons inside
29
+ // string literals or triggers. Deliberately not a SQL parser: if that assumption ever
30
+ // stops holding, the failure is a loud syntax error from D1 rather than a silent
31
+ // half-applied migration.
32
+ return String(sql)
33
+ .split(';')
34
+ .map((s) => s.trim())
35
+ .filter(Boolean);
36
+ }
37
+
38
+ class D1Database extends SqliteDatabase {
39
+ /** @param {{prepare: Function, batch: Function, exec?: Function}} d1 a D1 binding */
40
+ constructor(d1) {
41
+ super();
42
+ this.d1 = d1;
43
+ }
44
+
45
+ #stmt(sql, params) {
46
+ const s = this.d1.prepare(sql);
47
+ return params && params.length ? s.bind(...params) : s;
48
+ }
49
+
50
+ async exec(sql) {
51
+ // D1's own exec() is documented as slow and unsuitable for anything hot; it is also
52
+ // inconsistent about multi-statement input across versions. Batching prepared
53
+ // statements is both faster and atomic, which is what schema setup wants.
54
+ const statements = splitStatements(sql);
55
+ if (!statements.length) return;
56
+ if (statements.length === 1) {
57
+ await this.d1.prepare(statements[0]).run();
58
+ return;
59
+ }
60
+ await this.d1.batch(statements.map((s) => this.d1.prepare(s)));
61
+ }
62
+
63
+ async run(sql, ...params) {
64
+ const res = await this.#stmt(sql, params).run();
65
+ // Shaped like better-sqlite3's return so callers can't tell the difference. `meta`
66
+ // is D1's; the field names differ by version, hence the fallbacks.
67
+ return {
68
+ changes: res?.meta?.changes ?? res?.meta?.rows_written ?? 0,
69
+ lastInsertRowid: res?.meta?.last_row_id ?? null,
70
+ };
71
+ }
72
+
73
+ async get(sql, ...params) {
74
+ return (await this.#stmt(sql, params).first()) ?? null;
75
+ }
76
+
77
+ async all(sql, ...params) {
78
+ const res = await this.#stmt(sql, params).all();
79
+ return res?.results || [];
80
+ }
81
+
82
+ async batch(statements) {
83
+ if (!statements?.length) return;
84
+ // D1 batches are atomic — one implicit transaction, rolled back as a unit. That is
85
+ // exactly the guarantee LocalSqliteDatabase gets from BEGIN/COMMIT, so callers keep
86
+ // the same promise on both.
87
+ await this.d1.batch(statements.map(({ sql, params = [] }) =>
88
+ (params.length ? this.d1.prepare(sql).bind(...params) : this.d1.prepare(sql))));
89
+ }
90
+
91
+ async close() { /* D1 bindings are managed by the runtime */ }
92
+ }
93
+
94
+ export class D1SqliteProvider extends SqliteProvider {
95
+ /**
96
+ * @param {object} opts
97
+ * @param {object} opts.db the main D1 binding (metadata, kv, plugin installs)
98
+ * @param {Record<string, object>} [opts.scopes]
99
+ * extra bindings by key, for scopes that need their own database.
100
+ * @param {object} [opts.pluginStore]
101
+ * one binding to hold EVERY plugin scope. A scope key embeds the runtime principal,
102
+ * so it cannot be pre-bound and D1 cannot create databases on demand — without this,
103
+ * plugin storage simply doesn't exist on Workers.
104
+ */
105
+ constructor({ db, scopes = {}, pluginStore = null } = {}) {
106
+ super();
107
+ if (!db) throw TroveError.invalid('D1SqliteProvider requires a D1 binding (env.DB)');
108
+ this.main = new D1Database(db);
109
+ this.scopes = new Map(Object.entries(scopes).map(([k, v]) => [k, new D1Database(v)]));
110
+ // The catch-all for plugin scopes (see obtain).
111
+ this.pluginStore = pluginStore ? new D1Database(pluginStore) : null;
112
+ }
113
+
114
+ // D1 is a real database that survives the isolate being torn down — which is the
115
+ // whole question this flag answers, and the reason the search index may live here.
116
+ get durable() { return true; }
117
+
118
+ async obtain({ key }) {
119
+ if (CORE_KEYS.has(key)) return this.main;
120
+ const scoped = this.scopes.get(key);
121
+ if (scoped) return scoped;
122
+ // One binding for every plugin store.
123
+ //
124
+ // A plugin scope key embeds the runtime principal — `pstore:alice@x.com:plg:acme/notes`
125
+ // — so it can never be pre-bound, and D1 cannot create a database on demand. The
126
+ // adapter's `scopes: { plugins: … }` therefore bound a name that (a) is a CORE key
127
+ // already, so it short-circuited to `main`, and (b) is not what any real store asks
128
+ // for: every /api/plugins/:id/sql call on Workers was a 501.
129
+ //
130
+ // So a deployment may nominate ONE database to hold them all. Weaker isolation than
131
+ // the local provider's file-per-scope — the tables live side by side — but the keys
132
+ // are still distinct per (user, plugin) and it is the strongest thing D1's model
133
+ // allows. Stated here rather than discovered.
134
+ if (this.pluginStore) return this.pluginStore;
135
+ // Plugin scopes are an isolation boundary. Handing back the main database would
136
+ // put a plugin's tables next to the drive's metadata, which is precisely what the
137
+ // scope exists to prevent.
138
+ throw TroveError.unsupported(
139
+ `No D1 binding for the "${key}" store. D1 cannot create databases on demand, so a `
140
+ + 'plugin scope needs its own binding — add one to the Worker and pass it as '
141
+ + `scopes: { "${key}": env.YOUR_BINDING }. Plugin data is not co-located with the `
142
+ + 'drive metadata by design.',
143
+ );
144
+ }
145
+
146
+ async drop({ key }) {
147
+ // Dropping a D1 database is an account-level operation, not something a request can
148
+ // do. Emptying it is the closest honest equivalent, and it is what uninstall means:
149
+ // the plugin's data is gone.
150
+ const db = CORE_KEYS.has(key) ? null : this.scopes.get(key);
151
+ if (!db) return;
152
+ const tables = await db.all(
153
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '_cf_%'",
154
+ );
155
+ if (!tables.length) return;
156
+ await db.batch(tables.map((t) => ({ sql: `DROP TABLE IF EXISTS "${String(t.name).replace(/"/g, '""')}"` })));
157
+ }
158
+
159
+ async close() { /* nothing to release */ }
160
+ }
161
+
162
+ export { D1Database, splitStatements };
@@ -0,0 +1,42 @@
1
+ // Open a SQLite database using whichever driver the runtime provides: Bun's
2
+ // built-in `bun:sqlite` or Node's built-in `node:sqlite` (Node ≥ 22.5). Both
3
+ // expose the same statement API — prepare().run/get/all(...params), exec() for
4
+ // raw/multi-statement SQL, close() — and differ only in the constructor, so
5
+ // callers get one `db` handle that behaves identically under either runtime.
6
+
7
+ import { TroveError } from './errors.js';
8
+
9
+ export async function openDatabase(pathOrMemory = ':memory:') {
10
+ const db = await open(pathOrMemory);
11
+ // Durability/concurrency defaults for file-backed dbs: WAL survives an ungraceful
12
+ // stop far better than the rollback journal, and a busy_timeout avoids spurious
13
+ // "database is locked" under concurrent readers/writers. (No-op for :memory:.)
14
+ if (pathOrMemory !== ':memory:') {
15
+ try {
16
+ db.exec('PRAGMA journal_mode = WAL');
17
+ db.exec('PRAGMA synchronous = NORMAL');
18
+ db.exec('PRAGMA busy_timeout = 5000');
19
+ db.exec('PRAGMA foreign_keys = ON');
20
+ } catch { /* pragmas are best-effort */ }
21
+ }
22
+ return db;
23
+ }
24
+
25
+ async function open(pathOrMemory) {
26
+ if (typeof Bun !== 'undefined') {
27
+ const { Database } = await import('bun:sqlite');
28
+ return new Database(pathOrMemory);
29
+ }
30
+ try {
31
+ const { DatabaseSync } = await import('node:sqlite');
32
+ // `allowExtension` has to be set at CONSTRUCTION — enableLoadExtension() alone
33
+ // isn't enough on node:sqlite. It only permits loading; nothing is loaded unless
34
+ // something asks (the sqlite-vec store does, and degrades if it can't).
35
+ return new DatabaseSync(pathOrMemory, { allowExtension: true });
36
+ } catch (err) {
37
+ throw TroveError.unsupported(
38
+ 'No SQLite driver available — run under Bun (bun:sqlite) or Node ≥ 22.5 (node:sqlite), or supply another store',
39
+ { cause: err },
40
+ );
41
+ }
42
+ }