@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,115 @@
1
+ // Node adapter. Uses the standard http server and converts between Node's
2
+ // req/res and Web Request/Response (Node 20+ has global fetch/Request/Response
3
+ // and Readable.toWeb/fromWeb). Serves the built web app from disk when present,
4
+ // with SPA fallback, so `node adapters/node.js` runs the whole thing.
5
+ //
6
+ // TROVE_STORAGE=filesystem TROVE_FS_ROOT=./data/objects \
7
+ // TROVE_METADATA=sqlite TROVE_DB_PATH=./data/trove.db \
8
+ // node packages/server/src/adapters/node.js
9
+
10
+ import http from 'node:http';
11
+ import fs, { readFileSync } from 'node:fs';
12
+ import fsp from 'node:fs/promises';
13
+ import { Readable } from 'node:stream';
14
+ import { createServer, configFromEnv, warnOnOpenAccess } from '../index.js';
15
+ import { findWebDist } from './webDist.js';
16
+ import { createStaticAssets } from './staticAssets.js';
17
+
18
+ // A JWKS held in a file rather than inlined in the environment: multi-line JSON is
19
+ // awkward in env vars and shows up in `docker inspect`, while a mounted secret file
20
+ // does not. Read here rather than in configFromEnv, which has to stay loadable on
21
+ // Workers where there is no filesystem.
22
+ if (process.env.TROVE_JWT_JWKS_FILE && !process.env.TROVE_JWT_JWKS) {
23
+ process.env.TROVE_JWT_JWKS = readFileSync(process.env.TROVE_JWT_JWKS_FILE, 'utf8');
24
+ }
25
+
26
+
27
+ // TROVE_-prefixed to match every other setting; bare PORT/HOST still work, since that
28
+ // is what most platforms inject and breaking them would be gratuitous.
29
+ const PORT = Number(process.env.TROVE_PORT || process.env.PORT || 8787);
30
+ const HOST = process.env.TROVE_HOST || process.env.HOST || '0.0.0.0';
31
+
32
+ // Built web assets, if the app has been built — see webDist.js for why this is a
33
+ // resolution rather than a relative path.
34
+ const { dir: WEB_DIST, source: WEB_DIST_SOURCE } = findWebDist();
35
+
36
+ // Where to look, what to refuse and what to say about caching is shared with the Bun
37
+ // adapter — see staticAssets.js. All that differs here is how a file is read.
38
+ const staticAssets = WEB_DIST && createStaticAssets({
39
+ dir: WEB_DIST,
40
+ read: async (filePath) => {
41
+ try {
42
+ const stat = await fsp.stat(filePath);
43
+ if (!stat.isFile()) return null;
44
+ return {
45
+ size: stat.size,
46
+ mtime: stat.mtimeMs,
47
+ open: () => Readable.toWeb(fs.createReadStream(filePath)),
48
+ };
49
+ } catch {
50
+ return null;
51
+ }
52
+ },
53
+ });
54
+
55
+ async function toWebRequest(nodeReq) {
56
+ const url = `http://${nodeReq.headers.host || 'localhost'}${nodeReq.url}`;
57
+ const method = nodeReq.method;
58
+ const headers = new Headers();
59
+ for (const [k, v] of Object.entries(nodeReq.headers)) if (v) headers.set(k, Array.isArray(v) ? v.join(',') : v);
60
+ const hasBody = method !== 'GET' && method !== 'HEAD';
61
+ return new Request(url, {
62
+ method, headers,
63
+ body: hasBody ? Readable.toWeb(nodeReq) : undefined,
64
+ duplex: hasBody ? 'half' : undefined,
65
+ });
66
+ }
67
+
68
+ async function writeWebResponse(res, webRes) {
69
+ res.statusCode = webRes.status;
70
+ webRes.headers.forEach((v, k) => res.setHeader(k, v));
71
+ if (webRes.body) {
72
+ await new Promise((resolve, reject) =>
73
+ Readable.fromWeb(webRes.body).pipe(res).on('finish', resolve).on('error', reject),
74
+ );
75
+ } else {
76
+ res.end();
77
+ }
78
+ }
79
+
80
+ const hasWeb = !!WEB_DIST;
81
+ const envConfig = configFromEnv();
82
+ warnOnOpenAccess(envConfig);
83
+ const { handle, close } = await createServer({
84
+ ...envConfig,
85
+ assets: hasWeb ? staticAssets : undefined,
86
+ });
87
+
88
+ const server = http.createServer(async (nodeReq, nodeRes) => {
89
+ try {
90
+ const webReq = await toWebRequest(nodeReq);
91
+ const webRes = await handle(webReq);
92
+ await writeWebResponse(nodeRes, webRes);
93
+ } catch (err) {
94
+ console.error('server error', err);
95
+ if (!nodeRes.headersSent) nodeRes.statusCode = 500;
96
+ nodeRes.end('Internal error');
97
+ }
98
+ });
99
+
100
+ server.listen(PORT, HOST, () => {
101
+ console.log(`Trove server on http://${HOST}:${PORT} (web assets: ${hasWeb ? WEB_DIST : `none — ${WEB_DIST_SOURCE}; run npm run build:web`})`);
102
+ });
103
+
104
+ // Graceful shutdown: stop accepting connections, then flush notifications, dispose
105
+ // the sidecar, and close SQLite cleanly so a redeploy doesn't lose in-flight work.
106
+ let shuttingDown = false;
107
+ async function shutdown(signal) {
108
+ if (shuttingDown) return;
109
+ shuttingDown = true;
110
+ console.log(`Trove shutting down (${signal})…`);
111
+ server.close();
112
+ try { await close(); } catch (err) { console.error('shutdown error', err); }
113
+ process.exit(0);
114
+ }
115
+ for (const sig of ['SIGTERM', 'SIGINT']) process.on(sig, () => shutdown(sig));
@@ -0,0 +1,123 @@
1
+ // Serving the built web app.
2
+ //
3
+ // This was copied into the Node and Bun adapters, and had already drifted — one of them
4
+ // had a content-type fallback the other did not. What differs between the two runtimes
5
+ // is how you read a file; everything else (where to look, what to refuse, what to say
6
+ // about caching) is one policy, so it lives here and each adapter passes in its reader.
7
+ //
8
+ // Three things this gets right that the copies did not:
9
+ //
10
+ // A miss under /assets/ is a 404, not the SPA fallback. The fallback answered EVERY
11
+ // non-/api miss with index.html at status 200 — including a request for a hashed
12
+ // asset. After a deploy the old hashed names are gone, so a client still running the
13
+ // previous index.html asked for `/assets/main-OLD.js` and got HTML with
14
+ // `content-type: text/html`. The module fails to load, and because the service worker
15
+ // caches anything that came back 200, the HTML was stored under the JS URL and served
16
+ // from cache forever after. A 404 is a miss the browser and the worker both
17
+ // understand.
18
+ //
19
+ // Cache-Control is claimed where it is true and nowhere else. Everything under
20
+ // /assets/ is content-addressed — the filename changes when the bytes do — so it can
21
+ // be kept for a year and never revalidated. Everything else keeps a stable name and
22
+ // must be revalidated on every load. Marking the second group immutable is what turns
23
+ // a deploy into a blank page; leaving the first group unmarked is what makes the
24
+ // hashing pointless, which is what was happening: the responses carried no
25
+ // Cache-Control at all.
26
+ //
27
+ // There are validators. `no-cache` does not mean "do not cache", it means "revalidate
28
+ // before use" — but a revalidation with nothing to revalidate AGAINST is just a fresh
29
+ // download. sql-wasm.wasm is 650 kB and sits at a stable name, so without an ETag it
30
+ // came down in full every time a plugin touched client storage.
31
+
32
+ import path from 'node:path';
33
+ // The policy lives in its own module because index.js needs it too and has to stay
34
+ // loadable on Workers, where `node:path` is not there to be imported.
35
+ import { IMMUTABLE_PREFIX, cacheControlFor } from '../cachePolicy.js';
36
+
37
+ export { IMMUTABLE_PREFIX, cacheControlFor };
38
+
39
+ export const MIME = {
40
+ '.html': 'text/html', '.js': 'text/javascript', '.mjs': 'text/javascript', '.css': 'text/css',
41
+ '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.jpg': 'image/jpeg',
42
+ '.ico': 'image/x-icon', '.woff2': 'font/woff2', '.map': 'application/json',
43
+ '.webmanifest': 'application/manifest+json', '.wasm': 'application/wasm',
44
+ };
45
+
46
+ /**
47
+ * Should a miss here fall back to the SPA entry point?
48
+ *
49
+ * The client never puts a path in the URL — `navigation.js` calls pushState with no URL
50
+ * argument, so the whole app lives at `/`. The fallback is therefore for robustness
51
+ * (a refresh somewhere unexpected), never for deep links, which is what makes it safe
52
+ * to refuse anything that looks like a file rather than a route.
53
+ *
54
+ * @param {string} pathname
55
+ */
56
+ export function shouldFallBack(pathname) {
57
+ if (pathname.startsWith('/api/')) return false;
58
+ // Content-addressed: a miss is a stale reference to a build that no longer exists.
59
+ // Answering it with HTML is what poisons a service worker cache.
60
+ if (pathname.startsWith(IMMUTABLE_PREFIX)) return false;
61
+ // Anything with an extension is asking for a file, not a view.
62
+ if (path.extname(pathname)) return false;
63
+ return true;
64
+ }
65
+
66
+ /** A weak validator from what a stat already tells us — no hashing, no read. */
67
+ export const etagFor = ({ size, mtime }) => `W/"${size.toString(16)}-${Math.floor(mtime).toString(16)}"`;
68
+
69
+ /**
70
+ * Build the `assets` fetcher the server takes.
71
+ *
72
+ * @param {object} opts
73
+ * @param {string} opts.dir the built web app
74
+ * @param {(filePath: string) => Promise<{size: number, mtime: number, type?: string, open: () => any}|null>} opts.read
75
+ * runtime-specific: resolve to null when the path is not a readable file. `open` is
76
+ * separate from the stat so a 304 or a HEAD does not open a handle it will not read.
77
+ */
78
+ export function createStaticAssets({ dir, read }) {
79
+ return async function staticAssets(req) {
80
+ const url = new URL(req.url);
81
+ let pathname;
82
+ try {
83
+ pathname = decodeURIComponent(url.pathname);
84
+ } catch {
85
+ return null; // a bad escape names nothing
86
+ }
87
+
88
+ const served = await serve(pathname === '/' ? '/index.html' : pathname);
89
+ if (served) return served;
90
+
91
+ if ((req.method === 'GET' || req.method === 'HEAD') && shouldFallBack(pathname)) {
92
+ return serve('/index.html', { as: pathname });
93
+ }
94
+ return null;
95
+
96
+ async function serve(rel, { as = rel } = {}) {
97
+ const filePath = path.join(dir, path.normalize(rel));
98
+ // path.normalize collapses `..`, but only a prefix check proves the result is
99
+ // still inside the directory we meant.
100
+ if (filePath !== dir && !filePath.startsWith(dir + path.sep)) return null;
101
+
102
+ const file = await read(filePath);
103
+ if (!file) return null;
104
+
105
+ const type = MIME[path.extname(filePath)] || file.type || 'application/octet-stream';
106
+ const etag = etagFor(file);
107
+ const headers = {
108
+ 'content-type': type,
109
+ // The URL the client asked for decides the policy, not the file that answered:
110
+ // index.html served as an SPA fallback must still be revalidated.
111
+ 'cache-control': cacheControlFor(as),
112
+ etag,
113
+ };
114
+
115
+ // A validator is only worth attaching if we also honour it — otherwise every
116
+ // revalidation is a full download of something the client already has.
117
+ if (req.headers.get('if-none-match') === etag) {
118
+ return new Response(null, { status: 304, headers });
119
+ }
120
+ return new Response(req.method === 'HEAD' ? null : file.open(), { headers });
121
+ }
122
+ };
123
+ }
@@ -0,0 +1,70 @@
1
+ // Where the built web app is.
2
+ //
3
+ // This was once a guess about the shape of the tree three levels above the file doing
4
+ // the guessing:
5
+ //
6
+ // path.resolve(__dirname, '../../../web/dist')
7
+ //
8
+ // It was right inside the repo and right again when installed, but only while
9
+ // `@trove/server` and `@trove/web` happened to land as siblings under one `@trove/`
10
+ // directory — a fact about a particular installer's layout rather than anything either
11
+ // package promised. Nest the install, vendor one of them, or hoist differently and the
12
+ // drive silently served no web app, with a 404 on `/` as the only clue.
13
+ //
14
+ // Trove now ships as a single package with the built app inside it, which retires the
15
+ // problem rather than working around it: server and web are no longer two things an
16
+ // installer arranges relative to each other, they are two directories in one tarball,
17
+ // and their arrangement is fixed by the package that contains both. So the relative
18
+ // path below is no longer a guess — it is the layout this package's own `files` field
19
+ // guarantees.
20
+ //
21
+ // Resolution is still tried first, because it is the one that keeps working when this
22
+ // file is *not* where it thinks it is: a bundler that flattened the tree, or a copy
23
+ // vendored somewhere else. The two answers agree whenever both are available, and each
24
+ // covers the other's failure mode.
25
+
26
+ import path from 'node:path';
27
+ import { existsSync } from 'node:fs';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { createRequire } from 'node:module';
30
+
31
+ const WEB_DIST_IN_PACKAGE = 'packages/web/dist';
32
+
33
+ /**
34
+ * Locate the built web assets, or null when the app has not been built.
35
+ *
36
+ * Order matters: an explicit setting is never second-guessed, and if it points
37
+ * somewhere empty that is reported rather than quietly worked around — an operator who
38
+ * set TROVE_WEB_DIST wants to know it was wrong, not to be silently given a different
39
+ * directory that happened to exist.
40
+ *
41
+ * @param {string} [envDist] the value of TROVE_WEB_DIST
42
+ * @returns {{dir: string|null, source: string}} where it looked and how it decided
43
+ */
44
+ export function findWebDist(envDist = process.env.TROVE_WEB_DIST) {
45
+ if (envDist) {
46
+ const dir = path.resolve(envDist);
47
+ return existsSync(dir)
48
+ ? { dir, source: 'TROVE_WEB_DIST' }
49
+ : { dir: null, source: `TROVE_WEB_DIST=${envDist} (no such directory)` };
50
+ }
51
+
52
+ // The package root, wherever the resolver says it is. A package can reference itself
53
+ // by name, so this resolves both from a checkout and from inside `node_modules` —
54
+ // and it reaches the manifest because `./package.json` is one of the subpaths
55
+ // `exports` names. If that ever stops being true this throws rather than returning
56
+ // something wrong, which is why it is wrapped.
57
+ try {
58
+ const require = createRequire(import.meta.url);
59
+ const root = path.dirname(require.resolve('@3sln/trove/package.json'));
60
+ const dir = path.join(root, WEB_DIST_IN_PACKAGE);
61
+ if (existsSync(dir)) return { dir, source: '@3sln/trove' };
62
+ } catch { /* not resolvable from here — fall through to the layout we ship */ }
63
+
64
+ // Four levels up from packages/server/src/adapters/ is the package root.
65
+ const here = path.dirname(fileURLToPath(import.meta.url));
66
+ const dir = path.resolve(here, '../../../..', WEB_DIST_IN_PACKAGE);
67
+ if (existsSync(dir)) return { dir, source: 'relative to this file' };
68
+
69
+ return { dir: null, source: 'not built' };
70
+ }
@@ -0,0 +1,206 @@
1
+ // Background work on Workers, in a Durable Object.
2
+ //
3
+ // The TaskRegistry is in-memory and per-process, and that is the right lifetime: a task
4
+ // describes work in flight, and work in flight does not survive the thing doing it.
5
+ // What breaks on Workers is not the lifetime — it is that the process boundary stopped
6
+ // matching the work boundary. On a long-lived server one process runs the scan AND
7
+ // answers the polls, so "in memory" and "where the work is" are the same place. On
8
+ // Workers they come apart: the scan runs in whichever isolate served the POST, and the
9
+ // GET that asks about it lands wherever the router feels like.
10
+ //
11
+ // The fix is NOT to make the task list durable. A stored record saying "running" after
12
+ // the isolate that owned it was evicted is a phantom that nothing can ever correct —
13
+ // on a server a restart clears it, in durable storage it is forever. The fix is to give
14
+ // the work a real process again. A Durable Object is one: addressable by name, so every
15
+ // request reaches the same instance; single-threaded, so "is one already running?" can
16
+ // be answered truthfully; and long-lived, with `setAlarm` to pick work back up.
17
+ //
18
+ // So the registry stays exactly as it is, and moves in here with the work it describes.
19
+ //
20
+ // Three things this fixes that `waitUntil` alone does not:
21
+ // • progress polling — /api/tasks reaches the isolate that owns the task
22
+ // • cancel — the AbortController is in the same place as the work it aborts
23
+ // • the "already running" guard — one instance, not one per isolate
24
+ //
25
+ // Eviction is still possible mid-slice, and that is what the alarm is for: the scan
26
+ // cursor is persisted, `pending:` says the collection is not finished, and the alarm
27
+ // brings the object back to continue. Losing the isolate costs a slice, not the scan.
28
+
29
+ import { TaskRegistry } from '@3sln/trove/core';
30
+
31
+ /** How long one scan slice may run before it yields and stores its cursor. */
32
+ const DEFAULT_SLICE_MS = 20_000;
33
+ /** Gap between slices. Long enough not to hammer the store, short enough to feel live. */
34
+ const SLICE_GAP_MS = 5_000;
35
+ const PENDING = 'pending:';
36
+
37
+ /**
38
+ * The Durable Object. Export it from the Worker entry module and declare it in
39
+ * wrangler.toml; see the README section "Work that outlives a request".
40
+ *
41
+ * @param {(env: object) => Promise<object>} getServer builds (and caches) the server
42
+ */
43
+ export function createTaskHost(getServer) {
44
+ return class TroveTasks {
45
+ constructor(state, env) {
46
+ this.state = state;
47
+ this.env = env;
48
+ this.server = null;
49
+ this.sliceMs = Number(env?.TROVE_SLICE_MS || DEFAULT_SLICE_MS);
50
+ }
51
+
52
+ async #boot() {
53
+ this.server ||= await getServer(this.env);
54
+ return this.server;
55
+ }
56
+
57
+ /** Wake up in `ms` — unless we are already due sooner. */
58
+ async #arm(ms) {
59
+ const at = Date.now() + ms;
60
+ const current = await this.state.storage.getAlarm();
61
+ if (current == null || current > at) await this.state.storage.setAlarm(at);
62
+ }
63
+
64
+ async #beginScan(collectionId, reason) {
65
+ const server = await this.#boot();
66
+ const { task, alreadyRunning, done } = await server.beginScan(collectionId, {
67
+ reason, deadlineMs: this.sliceMs,
68
+ });
69
+ if (!alreadyRunning) {
70
+ // Recorded BEFORE the slice runs. If this object is evicted half way through,
71
+ // the alarm is what brings it back — and it can only do that if it knows the
72
+ // collection is unfinished. Writing this after the slice would mean an eviction
73
+ // silently ends the scan, which is the failure this whole file is about.
74
+ await this.state.storage.put(`${PENDING}${collectionId}`, { reason: reason || 'Continuing' });
75
+ this.state.waitUntil?.(done.then(
76
+ // `stopped`, not `nextCursor`: a slice that runs out of budget before it
77
+ // processes anything stops with a null cursor — correctly, since it got
78
+ // nowhere and must resume from the start. Reading that as "finished" would
79
+ // drop the collection off the list having scanned none of it.
80
+ (r) => (r && !r.stopped ? this.state.storage.delete(`${PENDING}${collectionId}`) : null),
81
+ () => null, // a failed slice stays pending; the alarm retries it
82
+ ));
83
+ }
84
+ await this.#arm(SLICE_GAP_MS);
85
+ return { task, alreadyRunning };
86
+ }
87
+
88
+ async #beginReindex(reason) {
89
+ const server = await this.#boot();
90
+ const { task, alreadyRunning, done } = await server.beginReindex({ reason });
91
+ // A reindex has no cursor to resume from, so there is nothing to continue — it
92
+ // either finishes in this object's lifetime or it is retried, and a failure
93
+ // leaves an Issue behind either way.
94
+ if (!alreadyRunning) this.state.waitUntil?.(done.catch(() => null));
95
+ return { task, alreadyRunning };
96
+ }
97
+
98
+ async fetch(request) {
99
+ const url = new URL(request.url);
100
+ const body = request.method === 'POST' ? await request.json().catch(() => ({})) : {};
101
+ const json = (v) => new Response(JSON.stringify(v), { headers: { 'content-type': 'application/json' } });
102
+ const server = await this.#boot();
103
+ switch (url.pathname) {
104
+ case '/begin':
105
+ return json(body.kind === 'index'
106
+ ? await this.#beginReindex(body.reason)
107
+ : await this.#beginScan(body.collectionId || 'default', body.reason));
108
+ case '/tasks':
109
+ return json({ tasks: server.tasks.list(body) });
110
+ case '/task':
111
+ return json({ task: server.tasks.get(body.id) });
112
+ case '/cancel':
113
+ return json({ cancelled: server.tasks.cancel(body.id) });
114
+ case '/dismiss':
115
+ server.tasks.dismiss(body.id);
116
+ return json({ ok: true });
117
+ case '/maintain':
118
+ return json({ result: await server.runMaintenance({ budgetMs: body.budgetMs ?? this.sliceMs }) });
119
+ default:
120
+ return new Response('not found', { status: 404 });
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Continue every collection that has not finished its pass. This is the durable
126
+ * half: a Cron Trigger starts things, but it is the alarm that keeps a bucket too
127
+ * large for one slice moving, without waiting for the next cron tick.
128
+ */
129
+ async alarm() {
130
+ const server = await this.#boot();
131
+ const pending = await this.state.storage.list({ prefix: PENDING });
132
+ for (const [key, meta] of pending) {
133
+ const collectionId = key.slice(PENDING.length);
134
+ const result = await server
135
+ .startScan(collectionId, { reason: meta?.reason || 'Continuing', deadlineMs: this.sliceMs })
136
+ .catch(() => null);
137
+ // Done only when a pass ran to the end. `alreadyRunning` means another slice
138
+ // holds the claim right now — still unfinished, so it stays on the list.
139
+ if (result && !result.alreadyRunning && !result.stopped) await this.state.storage.delete(key);
140
+ }
141
+ if ((await this.state.storage.list({ prefix: PENDING })).size) await this.#arm(SLICE_GAP_MS);
142
+ }
143
+ };
144
+ }
145
+
146
+ /**
147
+ * The other side: a TaskRegistry that answers from the Durable Object rather than from
148
+ * this isolate's memory. Injected as `config.tasks`, so every route that reads tasks
149
+ * gets the truth without knowing where it lives.
150
+ */
151
+ export class RemoteTasks extends TaskRegistry {
152
+ constructor(stub) {
153
+ super();
154
+ this.stub = stub;
155
+ }
156
+ async #rpc(path, payload) {
157
+ const res = await this.stub.fetch(`https://trove.tasks${path}`, {
158
+ method: 'POST',
159
+ headers: { 'content-type': 'application/json' },
160
+ body: JSON.stringify(payload || {}),
161
+ });
162
+ return res.json();
163
+ }
164
+ async list(opts = {}) { return (await this.#rpc('/tasks', opts)).tasks; }
165
+ async get(id) { return (await this.#rpc('/task', { id })).task; }
166
+ async cancel(id) { return (await this.#rpc('/cancel', { id })).cancelled; }
167
+ async dismiss(id) { await this.#rpc('/dismiss', { id }); }
168
+ /**
169
+ * Nothing to wait on here. The work is in the Durable Object, which keeps itself
170
+ * alive; this isolate only forwarded a message. Returning the base class's answer
171
+ * would make `ctx.waitUntil` hold the request open for work it does not own.
172
+ */
173
+ pending() { return null; }
174
+ }
175
+
176
+ /**
177
+ * Everything the front-line Worker needs to hand background work to the object.
178
+ * Pass into `createServer` as `{ tasks, background }`.
179
+ */
180
+ export function remoteBackground(namespace) {
181
+ // One instance for the whole drive, by name. Tasks are few and long, so there is no
182
+ // throughput argument for sharding — and one instance is what makes GET /api/tasks a
183
+ // complete answer rather than a per-shard sample.
184
+ const stub = namespace.get(namespace.idFromName('trove-tasks'));
185
+ const begin = (payload) => stub
186
+ .fetch('https://trove.tasks/begin', {
187
+ method: 'POST',
188
+ headers: { 'content-type': 'application/json' },
189
+ body: JSON.stringify(payload),
190
+ })
191
+ .then((r) => r.json());
192
+ return {
193
+ tasks: new RemoteTasks(stub),
194
+ background: {
195
+ beginScan: (collectionId, { reason } = {}) => begin({ kind: 'scan', collectionId, reason }),
196
+ beginReindex: ({ reason } = {}) => begin({ kind: 'index', reason }),
197
+ },
198
+ maintain: (budgetMs) => stub
199
+ .fetch('https://trove.tasks/maintain', {
200
+ method: 'POST',
201
+ headers: { 'content-type': 'application/json' },
202
+ body: JSON.stringify({ budgetMs }),
203
+ })
204
+ .then((r) => r.json()),
205
+ };
206
+ }
@@ -0,0 +1,159 @@
1
+ // Cloudflare Worker adapter. A Worker's fetch handler is already Web-standard,
2
+ // so this is thin. Storage should be S3-compatible (R2 via the S3 API) so the
3
+ // SigV4 signer and presigned URLs work. Bind a D1 database as `DB` and metadata, the
4
+ // KV store, plugin installs, and keyword search persist there; bind `VECTORIZE` for the
5
+ // semantic half. Without `DB` everything falls back to memory, which looks fine until
6
+ // the isolate is recycled. Static assets are served from the ASSETS binding.
7
+ //
8
+ // wrangler.toml sketch:
9
+ // [vars] TROVE_S3_BUCKET = "..." TROVE_S3_REGION = "auto"
10
+ // TROVE_S3_ENDPOINT = "https://<acct>.r2.cloudflarestorage.com"
11
+ // (secrets: TROVE_S3_ACCESS_KEY_ID, TROVE_S3_SECRET_ACCESS_KEY)
12
+ // [[d1_databases]] binding = "DB" -> metadata, kv, keyword search
13
+ // [[vectorize]] binding = "VECTORIZE" -> semantic search
14
+ // [ai] binding = "AI" -> LLM query understanding (optional)
15
+ // [[durable_objects.bindings]] name = "TASKS" class_name = "TroveTasks"
16
+ // -> owns scans and reindexes (see below)
17
+
18
+ import { D1SqliteProvider } from '@3sln/trove/core';
19
+ import { createServer, configFromEnv } from '../index.js';
20
+ import { createTaskHost, remoteBackground } from './worker-tasks.js';
21
+
22
+ // Two DIFFERENT servers can be asked for here: the front-line one, which hands long
23
+ // work to the Durable Object, and the one INSIDE that object, which does it. Keyed,
24
+ // because they cannot share a slot — a delegating server handed back inside the object
25
+ // would have it forward work to itself. Cloudflare gives a Durable Object its own
26
+ // isolate today, so this has not been reachable; an invariant that costs one line to
27
+ // state is cheaper than one that holds by luck.
28
+ const cached = new Map();
29
+
30
+ /**
31
+ * @param {object} env Worker env bindings
32
+ * @param {(env) => object} [buildVfs] optional: return { storage, metadata } (e.g. D1)
33
+ * @param {{delegate?: boolean}} [opts] delegate=false builds the server that DOES the
34
+ * background work (inside the Durable Object) rather than one that hands it off
35
+ */
36
+ async function getServer(env, buildVfs, { delegate = true } = {}) {
37
+ const slot = delegate ? 'edge' : 'worker';
38
+ if (cached.has(slot)) return cached.get(slot);
39
+ const config = configFromEnv(env);
40
+ if (buildVfs) config.vfs = buildVfs(env);
41
+ // D1 → metadata, kv, plugin installs, and the keyword half of search. Without this a
42
+ // Worker falls back to in-memory everything, which looks like it works right up until
43
+ // the isolate is recycled and the drive is empty. Bind `DB` and it persists.
44
+ if (env.DB && !config.sqlite) {
45
+ config.sqlite = new D1SqliteProvider({
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.
51
+ pluginStore: env.PLUGIN_DB || null,
52
+ });
53
+ config.metadata = { driver: 'sqlite' };
54
+ }
55
+ // Cloudflare Vectorize binding → first-class vector store (no REST creds needed).
56
+ if (env.VECTORIZE) {
57
+ config.vectorStore = { driver: 'vectorize', binding: env.VECTORIZE };
58
+ }
59
+ // Cloudflare Workers AI binding → LLM-assisted search transformer (human text →
60
+ // semantic text + tag filters). Enabled just by binding `AI`; picks a cheap model.
61
+ if (env.AI) {
62
+ config.searchTransformer = { driver: 'workers-ai', ai: env.AI, model: env.TROVE_SEARCH_MODEL };
63
+ }
64
+ // Serve static assets from the ASSETS binding (Workers Sites / assets).
65
+ if (env.ASSETS) {
66
+ config.assets = async (req) => {
67
+ const res = await env.ASSETS.fetch(req);
68
+ return res.status === 404 ? null : res;
69
+ };
70
+ }
71
+ // The Durable Object that owns long work. Bound as TASKS, it becomes the one place
72
+ // scans and reindexes run and the one place their tasks are listed — so a client
73
+ // polling /api/tasks reaches the isolate that actually has the task, and Cancel
74
+ // reaches the AbortController it is meant to abort. Without the binding the drive
75
+ // still works: the work runs in the request isolate under waitUntil, which is what
76
+ // it did before, with the caveats in the README.
77
+ if (delegate && env.TASKS) {
78
+ const remote = remoteBackground(env.TASKS);
79
+ config.tasks = remote.tasks;
80
+ config.background = remote.background;
81
+ config.maintain = remote.maintain;
82
+ // Timers do not survive a request here, and the object has its own alarm loop.
83
+ config.startFlusher = false;
84
+ }
85
+ const server = await createServer(config);
86
+ server.maintain = config.maintain || null;
87
+ cached.set(slot, server);
88
+ return server;
89
+ }
90
+
91
+ /**
92
+ * Default export usable directly as a Worker module.
93
+ *
94
+ * Two things here are Workers-specific and neither is optional.
95
+ *
96
+ * `ctx.waitUntil` — a Worker's isolate may be torn down the moment the response
97
+ * resolves. Work that was started and not awaited (a scan, a reindex) is not merely
98
+ * slow after that, it is CANCELLED, at whatever point it had reached. `waitUntil` is
99
+ * the only way to say "this response is done but I am not", so anything the request
100
+ * kicked off is handed to it.
101
+ *
102
+ * `scheduled` — a Cron Trigger. `setInterval` is how a long-lived process does periodic
103
+ * work and it does not work here at all: a timer registered inside a request does not
104
+ * outlive the request, so the maintenance and scan intervals never fire on Workers.
105
+ * Point a cron at the Worker and this runs one bounded slice per firing.
106
+ *
107
+ * [triggers]
108
+ * crons = ["*​/5 * * * *"]
109
+ *
110
+ * A third piece, `TroveTasks`, is exported below: `waitUntil` can keep work alive but
111
+ * cannot let another isolate SEE it, which is what progress polling and Cancel need.
112
+ */
113
+ export default {
114
+ async fetch(request, env, ctx) {
115
+ const server = await getServer(env);
116
+ const res = await server.handle(request);
117
+ // Anything the request started but did not await — see `pendingWork` below.
118
+ const pending = server.tasks.pending?.();
119
+ if (pending && ctx?.waitUntil) ctx.waitUntil(pending);
120
+ return res;
121
+ },
122
+
123
+ /**
124
+ * Cron Trigger. Sweeps expired uploads and idle sidecars, applies trash retention,
125
+ * and advances each collection's scan by one time-boxed slice — the scanner stores
126
+ * the cursor it reached, so the next firing continues rather than starting over.
127
+ */
128
+ async scheduled(event, env, ctx) {
129
+ const server = await getServer(env);
130
+ const budgetMs = Number(env.TROVE_CRON_BUDGET_MS || 20_000);
131
+ // AWAITED, not fire-and-forget: `scheduled` gets its own budget, and the runtime
132
+ // keeps the isolate alive exactly as long as this promise is pending. With the
133
+ // Durable Object bound this hands the slice to it instead — the object then keeps
134
+ // itself going on its own alarm, so a bucket too large for one firing does not
135
+ // wait for the next cron tick to continue.
136
+ const work = Promise.resolve(
137
+ server.maintain ? server.maintain(budgetMs) : server.runMaintenance({ budgetMs }),
138
+ ).catch((e) => console.error('[trove] scheduled maintenance failed', e));
139
+ if (ctx?.waitUntil) ctx.waitUntil(work);
140
+ await work;
141
+ },
142
+ };
143
+
144
+ /**
145
+ * The Durable Object class. Declare it in wrangler.toml and it becomes the home of
146
+ * every scan and reindex — see worker-tasks.js for why that is the fix rather than
147
+ * making the task list durable.
148
+ *
149
+ * [[durable_objects.bindings]]
150
+ * name = "TASKS"
151
+ * class_name = "TroveTasks"
152
+ *
153
+ * [[migrations]]
154
+ * tag = "v1"
155
+ * new_sqlite_classes = ["TroveTasks"]
156
+ */
157
+ export const TroveTasks = createTaskHost((env) => getServer(env, undefined, { delegate: false }));
158
+
159
+ export { getServer };