projview 0.5.2 → 0.7.0
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/bin/projview.js +98 -7
- package/docs/how-to-set-up.md +15 -7
- package/docs/reference/api.md +34 -3
- package/package.json +1 -1
- package/src/search.js +8 -3
- package/src/server.js +22 -9
- package/src/store.js +254 -16
package/bin/projview.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { spawn, execSync } from 'node:child_process';
|
|
4
5
|
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
6
7
|
import { startServer } from '../src/server.js';
|
|
7
8
|
import { buildTree, countFiles } from '../src/walk.js';
|
|
8
9
|
import { buildIndex, search } from '../src/search.js';
|
|
9
|
-
import { resolveStoreConfig, createStore } from '../src/store.js';
|
|
10
|
+
import { resolveStoreConfig, createStore, isPersistent } from '../src/store.js';
|
|
10
11
|
|
|
11
12
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
13
|
const DEMO_DIR = path.join(__dirname, '..', 'docs'); // bundled sample docs
|
|
@@ -50,6 +51,8 @@ function parseSubArgs(args) {
|
|
|
50
51
|
else if (a === '--store-url') o.storeUrl = args[++i];
|
|
51
52
|
else if (a === '--persist') o.persist = true;
|
|
52
53
|
else if (a === '--demo') o.demo = true;
|
|
54
|
+
else if (a === '--heading') o.heading = args[++i];
|
|
55
|
+
else if (a === '--author') o.author = args[++i];
|
|
53
56
|
else if (a.startsWith('-')) { /* ignore unknown flag in a subcommand */ }
|
|
54
57
|
else o.positional.push(a);
|
|
55
58
|
}
|
|
@@ -80,6 +83,84 @@ function cmdTree(args) {
|
|
|
80
83
|
process.stdout.write(JSON.stringify({ root: path.basename(root) || root, files: countFiles(tree), tree }, null, 2) + '\n');
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
const print = (obj) => process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
|
|
87
|
+
|
|
88
|
+
// Resolve the comment author: explicit flag > git user.name > OS username.
|
|
89
|
+
// Agents pass --author (e.g. "claude") so AI-authored comments are distinguishable.
|
|
90
|
+
function resolveAuthor(explicit) {
|
|
91
|
+
if (explicit) return explicit;
|
|
92
|
+
try {
|
|
93
|
+
const name = execSync('git config user.name', { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
|
|
94
|
+
if (name) return name;
|
|
95
|
+
} catch { /* no git / no user.name */ }
|
|
96
|
+
try { return os.userInfo().username || null; } catch { return null; }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// projview comment <add|resolve|reopen|rm> ... (writes ride the store)
|
|
100
|
+
async function cmdComment(args) {
|
|
101
|
+
const sub = args[0];
|
|
102
|
+
const o = parseSubArgs(args.slice(1));
|
|
103
|
+
const cfg = resolveStoreConfig(o);
|
|
104
|
+
const root = o.demo ? DEMO_DIR : process.cwd();
|
|
105
|
+
const store = createStore(cfg, root);
|
|
106
|
+
try {
|
|
107
|
+
if (sub === 'add') {
|
|
108
|
+
const [file, body] = o.positional;
|
|
109
|
+
if (!file || !body) {
|
|
110
|
+
console.error('usage: projview comment add <file> "<body>" [--heading <id>] [--author <name>] [--persist | --store-url <url>]');
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
if (!isPersistent(cfg)) {
|
|
114
|
+
console.error('⚠ no persistent store - this comment will NOT be saved on exit. Re-run with --persist or --store-url <url>.');
|
|
115
|
+
}
|
|
116
|
+
if (!existsSync(path.resolve(root, file))) {
|
|
117
|
+
console.error(`note: "${file}" not found under ${root} - saving the comment anyway.`);
|
|
118
|
+
}
|
|
119
|
+
const anchor = o.heading ? { type: 'heading', id: o.heading } : { type: 'file' };
|
|
120
|
+
print(await store.addComment({ file, body, anchor, author: resolveAuthor(o.author) }));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (sub === 'resolve' || sub === 'reopen') {
|
|
124
|
+
const id = o.positional[0];
|
|
125
|
+
if (!id) { console.error(`usage: projview comment ${sub} <id> [--persist | --store-url <url>]`); process.exit(1); }
|
|
126
|
+
const c = await store.updateComment(id, { resolved: sub === 'resolve' });
|
|
127
|
+
if (!c) { console.error(`no comment with id ${id} in this store`); process.exit(1); }
|
|
128
|
+
print(c);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (sub === 'rm' || sub === 'delete') {
|
|
132
|
+
const id = o.positional[0];
|
|
133
|
+
if (!id) { console.error('usage: projview comment rm <id> [--persist | --store-url <url>]'); process.exit(1); }
|
|
134
|
+
if (!(await store.deleteComment(id))) { console.error(`no comment with id ${id} in this store`); process.exit(1); }
|
|
135
|
+
print({ deleted: id });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
console.error('usage: projview comment <add|resolve|reopen|rm> ...');
|
|
139
|
+
process.exit(1);
|
|
140
|
+
} finally {
|
|
141
|
+
await store.close();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// projview comments [file] -> list comments (all, or for one file) as JSON
|
|
146
|
+
async function cmdComments(args) {
|
|
147
|
+
const o = parseSubArgs(args);
|
|
148
|
+
const root = o.demo ? DEMO_DIR : process.cwd();
|
|
149
|
+
const store = createStore(resolveStoreConfig(o), root);
|
|
150
|
+
const list = await store.listComments(o.positional[0]);
|
|
151
|
+
await store.close();
|
|
152
|
+
print(list);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// projview usage [path] -> aggregated usage stats (views / searches) as JSON
|
|
156
|
+
async function cmdUsage(args) {
|
|
157
|
+
const o = parseSubArgs(args);
|
|
158
|
+
const store = createStore(resolveStoreConfig(o), subRoot(o, 0));
|
|
159
|
+
const stats = await store.usageStats();
|
|
160
|
+
await store.close();
|
|
161
|
+
print(stats);
|
|
162
|
+
}
|
|
163
|
+
|
|
83
164
|
function openBrowser(url) {
|
|
84
165
|
const cmd = process.platform === 'darwin' ? 'open'
|
|
85
166
|
: process.platform === 'win32' ? 'cmd' : 'xdg-open';
|
|
@@ -92,9 +173,13 @@ function openBrowser(url) {
|
|
|
92
173
|
const HELP = `projview - ephemeral previewer for markdown + mermaid
|
|
93
174
|
|
|
94
175
|
usage:
|
|
95
|
-
projview [path] [options]
|
|
96
|
-
projview search <query> [path]
|
|
97
|
-
projview tree [path]
|
|
176
|
+
projview [path] [options] start the viewer (default)
|
|
177
|
+
projview search <query> [path] print ranked search results as JSON
|
|
178
|
+
projview tree [path] print the file tree as JSON
|
|
179
|
+
projview comment add <file> "<text>" leave a comment (--heading <id> for a section)
|
|
180
|
+
projview comments [file] list comments as JSON
|
|
181
|
+
projview comment resolve|reopen|rm <id> resolve, reopen, or delete a comment
|
|
182
|
+
projview usage [path] print usage stats (views / searches) as JSON
|
|
98
183
|
|
|
99
184
|
(subcommands print to stdout and exit - no server. --demo works with them too,
|
|
100
185
|
e.g. projview search "websockets" --demo)
|
|
@@ -109,11 +194,14 @@ options:
|
|
|
109
194
|
--persist keep the index in ~/.projview/cache and reuse it next run
|
|
110
195
|
--store <kind> store backend: memory | superlite | lite (default: memory)
|
|
111
196
|
--store-url <url> custom store: sqlite://<file> or postgres://<...> (or set DATABASE_URL)
|
|
197
|
+
--heading <id> (comment add) anchor the comment to a heading/section id
|
|
198
|
+
--author <name> (comment add) author to record (default: git/OS user)
|
|
112
199
|
-v, --version print the version and exit
|
|
113
200
|
-h, --help show this help
|
|
114
201
|
|
|
115
202
|
ephemeral by default - nothing is persisted unless you opt in with --persist /
|
|
116
|
-
--store / --store-url. ctrl-c removes any ephemeral store
|
|
203
|
+
--store / --store-url. ctrl-c removes any ephemeral store. Comments + usage need a
|
|
204
|
+
persistent store to survive (you'll be warned when adding a comment without one).`;
|
|
117
205
|
|
|
118
206
|
async function main() {
|
|
119
207
|
const argv = process.argv.slice(2);
|
|
@@ -121,6 +209,9 @@ async function main() {
|
|
|
121
209
|
// machine subcommands -> JSON to stdout, then exit (no server)
|
|
122
210
|
if (argv[0] === 'search') return cmdSearch(argv.slice(1));
|
|
123
211
|
if (argv[0] === 'tree') return cmdTree(argv.slice(1));
|
|
212
|
+
if (argv[0] === 'comment') return cmdComment(argv.slice(1));
|
|
213
|
+
if (argv[0] === 'comments') return cmdComments(argv.slice(1));
|
|
214
|
+
if (argv[0] === 'usage') return cmdUsage(argv.slice(1));
|
|
124
215
|
|
|
125
216
|
const opts = parseArgs(argv);
|
|
126
217
|
if (opts.help) { console.log(HELP); return; }
|
package/docs/how-to-set-up.md
CHANGED
|
@@ -11,7 +11,7 @@ previewer for markdown + mermaid files.
|
|
|
11
11
|
|
|
12
12
|
| need | version |
|
|
13
13
|
| --- | --- |
|
|
14
|
-
| Node.js | **
|
|
14
|
+
| Node.js | **22+** (the `lite` sqlite store uses the built-in `node:sqlite`) |
|
|
15
15
|
| npm | ships with Node |
|
|
16
16
|
|
|
17
17
|
No build step. By default indexing is in-memory and nothing is written to disk -
|
|
@@ -67,7 +67,7 @@ npm install
|
|
|
67
67
|
npm run demo # preview the bundled docs/ (alias for --demo)
|
|
68
68
|
npm start # index the current directory
|
|
69
69
|
node bin/projview.js ./docs # or call the CLI directly
|
|
70
|
-
npm test # run the test suite (node:test,
|
|
70
|
+
npm test # run the test suite (node:test, 45 tests)
|
|
71
71
|
```
|
|
72
72
|
|
|
73
73
|
See the [project README](README.md) for the architecture and the
|
|
@@ -82,10 +82,15 @@ See the [project README](README.md) for the architecture and the
|
|
|
82
82
|
| `projview [path]` | start the viewer (default) |
|
|
83
83
|
| `projview search <query> [path]` | print ranked search results as JSON, then exit |
|
|
84
84
|
| `projview tree [path]` | print the file tree as JSON, then exit |
|
|
85
|
+
| `projview comment add <file> "<body>"` | leave a comment (`--heading <id>` for a section) |
|
|
86
|
+
| `projview comments [file]` | list comments as JSON |
|
|
87
|
+
| `projview comment resolve\|reopen\|rm <id>` | resolve, reopen, or delete a comment |
|
|
88
|
+
| `projview usage [path]` | print usage stats (views / searches) as JSON |
|
|
85
89
|
|
|
86
|
-
The
|
|
87
|
-
|
|
88
|
-
|
|
90
|
+
The subcommands are projview's **machine interface** - JSON on stdout, no server,
|
|
91
|
+
no browser - built for agents and scripts. `search`/`tree` are read-only;
|
|
92
|
+
`comment` writes ride a persistent store (you're warned if you comment without
|
|
93
|
+
one). See [ai-tests/search.md](ai-tests/search.md) for a worked test.
|
|
89
94
|
|
|
90
95
|
**Options**
|
|
91
96
|
|
|
@@ -98,6 +103,8 @@ stdout, no server, no browser - built for agents and scripts. See
|
|
|
98
103
|
| `--persist` | keep the index in `~/.projview/cache` and reuse it next run |
|
|
99
104
|
| `--store <kind>` | store backend: `memory` \| `superlite` \| `lite` (default: `memory`; `lite` when `--persist`) |
|
|
100
105
|
| `--store-url <url>` | use a custom store: `sqlite://<file>` or `postgres://<...>` |
|
|
106
|
+
| `--heading <id>` | (`comment add`) anchor the comment to a heading/section id |
|
|
107
|
+
| `--author <name>` | (`comment add`) author to record (default: git/OS user) |
|
|
101
108
|
| `-v`, `--version` | print the version and exit |
|
|
102
109
|
| `-h`, `--help` | show help |
|
|
103
110
|
|
|
@@ -113,8 +120,9 @@ stdout, no server, no browser - built for agents and scripts. See
|
|
|
113
120
|
|
|
114
121
|
## persistence & stores
|
|
115
122
|
|
|
116
|
-
By default projview is **ephemeral** - the index
|
|
117
|
-
written to disk. Opt in to persistence
|
|
123
|
+
By default projview is **ephemeral** - the index (plus any comments and usage
|
|
124
|
+
events) lives in memory and nothing is written to disk. Opt in to persistence
|
|
125
|
+
when you want a faster warm start, comments that survive a restart, or a
|
|
118
126
|
queryable store an AI can read:
|
|
119
127
|
|
|
120
128
|
| backend | what | how |
|
package/docs/reference/api.md
CHANGED
|
@@ -5,7 +5,8 @@ and scroll-spy (the active section should track as you scroll).
|
|
|
5
5
|
|
|
6
6
|
## overview
|
|
7
7
|
|
|
8
|
-
projview exposes a tiny HTTP surface.
|
|
8
|
+
projview exposes a tiny HTTP surface. It never writes your files - the only
|
|
9
|
+
state it records is its own usage events (views / searches) into the store.
|
|
9
10
|
|
|
10
11
|
## endpoints
|
|
11
12
|
|
|
@@ -37,6 +38,35 @@ line-numbered snippets. See [the AI search test](../ai-tests/search.md).
|
|
|
37
38
|
`projview tree [path]` returns `{ root, files, tree[] }` - the same structure
|
|
38
39
|
`/api/tree` serves.
|
|
39
40
|
|
|
41
|
+
### projview comment
|
|
42
|
+
|
|
43
|
+
Leave / manage comments (they ride a persistent store - use `--persist` or
|
|
44
|
+
`--store-url`, else you'll be warned the comment won't survive exit):
|
|
45
|
+
|
|
46
|
+
| command | meaning |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `projview comment add <file> "<body>"` | add a comment (whole file) |
|
|
49
|
+
| `projview comment add <file> "<body>" --heading <id>` | anchor it to a section |
|
|
50
|
+
| `projview comment resolve\|reopen <id>` | toggle resolved |
|
|
51
|
+
| `projview comment rm <id>` | delete it |
|
|
52
|
+
|
|
53
|
+
`--author <name>` overrides the recorded author (default: git/OS user). Agents
|
|
54
|
+
pass e.g. `--author claude` so AI-authored comments are distinguishable. A
|
|
55
|
+
comment is `{ id, file, anchor, body, author, createdAt, resolved }`; the
|
|
56
|
+
`anchor` is `{type:'file'}`, `{type:'heading', id}`, or a `{type:'text', exact,
|
|
57
|
+
prefix, suffix, section}` quote selector (text anchors come from the viewer).
|
|
58
|
+
|
|
59
|
+
### projview comments
|
|
60
|
+
|
|
61
|
+
`projview comments [file]` lists comments as JSON - all of them, or just one
|
|
62
|
+
file's.
|
|
63
|
+
|
|
64
|
+
### projview usage
|
|
65
|
+
|
|
66
|
+
`projview usage [path]` returns `{ total, byType, topFiles[], recentSearches[] }`.
|
|
67
|
+
The viewer records a `view` per file opened and a `search` per query, so usage
|
|
68
|
+
accumulates over a session (persisted only with a persistent store).
|
|
69
|
+
|
|
40
70
|
## the walker
|
|
41
71
|
|
|
42
72
|
### ignored directories
|
|
@@ -87,8 +117,9 @@ A chokidar watcher broadcasts change/tree events over SSE.
|
|
|
87
117
|
|
|
88
118
|
## stores (persistence)
|
|
89
119
|
|
|
90
|
-
The index
|
|
91
|
-
`--persist` / `--store` /
|
|
120
|
+
The index - plus **comments** and **usage events** - is in-memory by default
|
|
121
|
+
(ephemeral). Opt into a pluggable store with `--persist` / `--store` /
|
|
122
|
+
`--store-url` so comments and usage survive a restart:
|
|
92
123
|
|
|
93
124
|
### backends
|
|
94
125
|
|
package/package.json
CHANGED
package/src/search.js
CHANGED
|
@@ -53,10 +53,15 @@ export async function buildIndex(root, store) {
|
|
|
53
53
|
return { store, total: entries.length, reindexed, warm: !!cached };
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
// Re-read one file into the in-memory index; return its store entry (or null).
|
|
56
57
|
export async function updateFile(rel) {
|
|
57
|
-
if (!theRoot) return;
|
|
58
|
-
|
|
59
|
-
|
|
58
|
+
if (!theRoot) return null;
|
|
59
|
+
const abs = path.join(theRoot, rel);
|
|
60
|
+
try {
|
|
61
|
+
const [content, stat] = await Promise.all([fsp.readFile(abs, 'utf8'), fsp.stat(abs)]);
|
|
62
|
+
addToIndex(rel, content);
|
|
63
|
+
return { path: rel, name: rel.split('/').pop(), ext: path.extname(rel).toLowerCase(), mtime: stat.mtimeMs, content };
|
|
64
|
+
} catch { return null; }
|
|
60
65
|
}
|
|
61
66
|
|
|
62
67
|
export function removeFile(rel) { index.delete(rel); }
|
package/src/server.js
CHANGED
|
@@ -80,7 +80,10 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1', store
|
|
|
80
80
|
|
|
81
81
|
// --- search (fuzzy filenames + full-text contents) ---
|
|
82
82
|
if (pathname === '/api/search') {
|
|
83
|
-
|
|
83
|
+
const q = url.searchParams.get('q') || '';
|
|
84
|
+
const results = search(q);
|
|
85
|
+
if (q.trim()) { try { await store.recordEvent({ type: 'search', query: q.trim(), count: results.files.length + results.content.length }); } catch { /* usage is best-effort */ } }
|
|
86
|
+
sendJson(res, results);
|
|
84
87
|
return;
|
|
85
88
|
}
|
|
86
89
|
|
|
@@ -94,6 +97,7 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1', store
|
|
|
94
97
|
}
|
|
95
98
|
try {
|
|
96
99
|
const content = await fsp.readFile(abs, 'utf8');
|
|
100
|
+
try { await store.recordEvent({ type: 'view', file: rel }); } catch { /* usage is best-effort */ }
|
|
97
101
|
sendJson(res, { path: rel, ext: path.extname(abs).toLowerCase(), html: renderFile(path.extname(abs).toLowerCase(), content) });
|
|
98
102
|
} catch {
|
|
99
103
|
sendJson(res, { error: 'not found' }, 404);
|
|
@@ -113,24 +117,33 @@ export async function startServer(root, { port = 4321, host = '127.0.0.1', store
|
|
|
113
117
|
});
|
|
114
118
|
|
|
115
119
|
// --- file watcher -> live reload ---
|
|
120
|
+
const store = createStore(storeConfig, root);
|
|
121
|
+
const indexInfo = await buildIndex(root, store); // load-or-build the search index
|
|
122
|
+
|
|
123
|
+
// file watcher -> keep the in-memory index, the persisted store, AND the
|
|
124
|
+
// browser in sync as files change during a session
|
|
116
125
|
const watcher = chokidar.watch(root, {
|
|
117
126
|
ignoreInitial: true,
|
|
118
127
|
ignored: (p) => p.split(path.sep).some(isIgnoredDir)
|
|
119
128
|
});
|
|
120
129
|
const rel = (p) => path.relative(root, p).split(path.sep).join('/');
|
|
130
|
+
const touch = async (p, event) => {
|
|
131
|
+
if (!isPreviewable(p)) return;
|
|
132
|
+
const entry = await updateFile(rel(p)); // refresh in-memory index, get the entry
|
|
133
|
+
if (entry) await store.update(entry); // mirror the change into the store
|
|
134
|
+
broadcast(event, { path: rel(p) });
|
|
135
|
+
};
|
|
121
136
|
watcher
|
|
122
|
-
.on('change', (p) =>
|
|
137
|
+
.on('change', (p) => touch(p, 'change'))
|
|
138
|
+
.on('add', (p) => touch(p, 'tree'))
|
|
139
|
+
.on('unlink', async (p) => {
|
|
123
140
|
if (!isPreviewable(p)) return;
|
|
124
|
-
|
|
125
|
-
|
|
141
|
+
removeFile(rel(p));
|
|
142
|
+
await store.remove(rel(p));
|
|
143
|
+
broadcast('tree');
|
|
126
144
|
})
|
|
127
|
-
.on('add', (p) => { if (isPreviewable(p)) { updateFile(rel(p)); broadcast('tree'); } })
|
|
128
|
-
.on('unlink', (p) => { if (isPreviewable(p)) { removeFile(rel(p)); broadcast('tree'); } })
|
|
129
145
|
.on('addDir', () => broadcast('tree'))
|
|
130
146
|
.on('unlinkDir', () => broadcast('tree'));
|
|
131
|
-
|
|
132
|
-
const store = createStore(storeConfig, root);
|
|
133
|
-
const indexInfo = await buildIndex(root, store); // load-or-build the search index
|
|
134
147
|
const listenPort = await listenWithRetry(server, port, host);
|
|
135
148
|
|
|
136
149
|
const close = () => new Promise((resolve) => {
|
package/src/store.js
CHANGED
|
@@ -1,20 +1,30 @@
|
|
|
1
|
-
/* pluggable persistence for the index.
|
|
1
|
+
/* pluggable persistence for the index, comments + usage.
|
|
2
2
|
*
|
|
3
|
-
* A Store persists
|
|
4
|
-
*
|
|
3
|
+
* A Store persists three things so a re-spawn can warm-start and so comments /
|
|
4
|
+
* usage survive a session:
|
|
5
|
+
* entries - indexed files { path, name, ext, mtime, content }
|
|
6
|
+
* comments - notes on a file or a portion of it (see anchor model below)
|
|
7
|
+
* events - usage signals { ts, type, file?, query?, count? }
|
|
8
|
+
*
|
|
9
|
+
* Backends:
|
|
10
|
+
* memory - no disk persistence (ephemeral default; lives only this session)
|
|
5
11
|
* superlite - a JSON file
|
|
6
12
|
* lite - sqlite via node:sqlite (built-in)
|
|
7
13
|
* custom - a connection URL: sqlite://<file> or postgres://<...> (lazy `pg`)
|
|
8
14
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
15
|
+
* A comment's `anchor` is one of:
|
|
16
|
+
* { type: 'file' } whole document
|
|
17
|
+
* { type: 'heading', id } a section (stable pv- id)
|
|
18
|
+
* { type: 'text', exact, prefix, suffix, section } a quoted range (re-anchored
|
|
19
|
+
* client-side; see 3c)
|
|
20
|
+
* Everything above the store stays storage-agnostic.
|
|
11
21
|
*/
|
|
12
22
|
|
|
13
23
|
import fs from 'node:fs';
|
|
14
24
|
import fsp from 'node:fs/promises';
|
|
15
25
|
import path from 'node:path';
|
|
16
26
|
import os from 'node:os';
|
|
17
|
-
import { createHash } from 'node:crypto';
|
|
27
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
18
28
|
|
|
19
29
|
// node:sqlite is flagged "experimental" and emits a noisy warning on use; we
|
|
20
30
|
// opted into it deliberately, so quiet just that one notice (others pass through).
|
|
@@ -33,6 +43,42 @@ const keyFor = (root) => createHash('sha256').update(path.resolve(root)).digest(
|
|
|
33
43
|
const ensureDir = (d) => fs.mkdirSync(d, { recursive: true });
|
|
34
44
|
const redact = (url) => url.replace(/\/\/[^@/]*@/, '//***@'); // hide creds in logs
|
|
35
45
|
|
|
46
|
+
// Normalize loose input into a full comment / event record (defaults + ids).
|
|
47
|
+
const newComment = (c) => ({
|
|
48
|
+
id: c.id || randomUUID(),
|
|
49
|
+
file: c.file,
|
|
50
|
+
anchor: c.anchor || { type: 'file' },
|
|
51
|
+
body: c.body,
|
|
52
|
+
author: c.author ?? null,
|
|
53
|
+
createdAt: c.createdAt || Date.now(),
|
|
54
|
+
resolved: !!c.resolved
|
|
55
|
+
});
|
|
56
|
+
const newEvent = (e) => ({
|
|
57
|
+
id: randomUUID(),
|
|
58
|
+
ts: e.ts || Date.now(),
|
|
59
|
+
type: e.type,
|
|
60
|
+
file: e.file ?? null,
|
|
61
|
+
query: e.query ?? null,
|
|
62
|
+
count: e.count ?? null
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Aggregate raw events into something a human / agent can read at a glance.
|
|
66
|
+
function summarizeEvents(events) {
|
|
67
|
+
const byType = {};
|
|
68
|
+
const fileCounts = {};
|
|
69
|
+
const searches = [];
|
|
70
|
+
for (const e of events) {
|
|
71
|
+
byType[e.type] = (byType[e.type] || 0) + 1;
|
|
72
|
+
if (e.file) fileCounts[e.file] = (fileCounts[e.file] || 0) + 1;
|
|
73
|
+
if (e.type === 'search' && e.query) searches.push(e.query);
|
|
74
|
+
}
|
|
75
|
+
const topFiles = Object.entries(fileCounts)
|
|
76
|
+
.sort((a, b) => b[1] - a[1])
|
|
77
|
+
.slice(0, 10)
|
|
78
|
+
.map(([file, count]) => ({ file, count }));
|
|
79
|
+
return { total: events.length, byType, topFiles, recentSearches: searches.slice(-10).reverse() };
|
|
80
|
+
}
|
|
81
|
+
|
|
36
82
|
function readConfigFile() {
|
|
37
83
|
try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; }
|
|
38
84
|
}
|
|
@@ -65,19 +111,77 @@ export function createStore(config, root) {
|
|
|
65
111
|
}
|
|
66
112
|
}
|
|
67
113
|
|
|
114
|
+
// True when a store keeps comments/usage across restarts. The CLI/server warn
|
|
115
|
+
// when it's false so a comment isn't silently lost on exit.
|
|
116
|
+
export function isPersistent(config) {
|
|
117
|
+
const cfg = config || { kind: 'memory' };
|
|
118
|
+
if (cfg.kind === 'memory') return false;
|
|
119
|
+
if (cfg.kind === 'custom') return true; // the user's own DB
|
|
120
|
+
return !!cfg.persist; // superlite/lite: only with --persist
|
|
121
|
+
}
|
|
122
|
+
|
|
68
123
|
function fileFor(root, persist, ext) {
|
|
69
124
|
const dir = persist ? CACHE_DIR : EPHEMERAL_DIR;
|
|
70
125
|
ensureDir(dir);
|
|
71
126
|
return path.join(dir, `${keyFor(root)}.${ext}`);
|
|
72
127
|
}
|
|
73
128
|
|
|
129
|
+
/* ---------- shared in-memory comment/event layer (memory + superlite) ---------- */
|
|
130
|
+
|
|
131
|
+
// Holds comments + events in memory; calls flush() after each mutation so a
|
|
132
|
+
// disk-backed wrapper (superlite) can persist. memory passes a no-op flush.
|
|
133
|
+
function inMemoryDocs(flush = async () => {}) {
|
|
134
|
+
const comments = new Map(); // id -> comment
|
|
135
|
+
const events = []; // append-only (stable ref - never reassigned)
|
|
136
|
+
return {
|
|
137
|
+
comments,
|
|
138
|
+
events,
|
|
139
|
+
async addComment(c) {
|
|
140
|
+
const comment = newComment(c);
|
|
141
|
+
comments.set(comment.id, comment);
|
|
142
|
+
await flush();
|
|
143
|
+
return comment;
|
|
144
|
+
},
|
|
145
|
+
async listComments(file) {
|
|
146
|
+
const all = [...comments.values()].sort((a, b) => a.createdAt - b.createdAt);
|
|
147
|
+
return file ? all.filter((c) => c.file === file) : all;
|
|
148
|
+
},
|
|
149
|
+
async updateComment(id, patch) {
|
|
150
|
+
const c = comments.get(id);
|
|
151
|
+
if (!c) return null;
|
|
152
|
+
Object.assign(c, patch, { id: c.id }); // id is immutable
|
|
153
|
+
await flush();
|
|
154
|
+
return c;
|
|
155
|
+
},
|
|
156
|
+
async deleteComment(id) {
|
|
157
|
+
const ok = comments.delete(id);
|
|
158
|
+
await flush();
|
|
159
|
+
return ok;
|
|
160
|
+
},
|
|
161
|
+
async recordEvent(e) {
|
|
162
|
+
events.push(newEvent(e));
|
|
163
|
+
await flush();
|
|
164
|
+
},
|
|
165
|
+
async usageStats() { return summarizeEvents(events); }
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
74
169
|
/* ---------- backends ---------- */
|
|
75
170
|
|
|
76
171
|
function memoryStore() {
|
|
172
|
+
const docs = inMemoryDocs();
|
|
77
173
|
return {
|
|
78
174
|
kind: 'memory',
|
|
79
175
|
async load() { return null; },
|
|
80
176
|
async save() {},
|
|
177
|
+
async update() {},
|
|
178
|
+
async remove() {},
|
|
179
|
+
addComment: docs.addComment,
|
|
180
|
+
listComments: docs.listComments,
|
|
181
|
+
updateComment: docs.updateComment,
|
|
182
|
+
deleteComment: docs.deleteComment,
|
|
183
|
+
recordEvent: docs.recordEvent,
|
|
184
|
+
usageStats: docs.usageStats,
|
|
81
185
|
async close() {},
|
|
82
186
|
describe() { return 'in-memory (ephemeral - nothing persisted)'; }
|
|
83
187
|
};
|
|
@@ -85,15 +189,38 @@ function memoryStore() {
|
|
|
85
189
|
|
|
86
190
|
function superliteStore(root, persist) {
|
|
87
191
|
const file = fileFor(root, persist, 'json');
|
|
192
|
+
const entries = new Map(); // path -> entry
|
|
193
|
+
let loaded = false;
|
|
194
|
+
const flush = () => fsp.writeFile(file, JSON.stringify({
|
|
195
|
+
builtAt: Date.now(),
|
|
196
|
+
entries: [...entries.values()],
|
|
197
|
+
comments: [...docs.comments.values()],
|
|
198
|
+
events: docs.events
|
|
199
|
+
}));
|
|
200
|
+
const docs = inMemoryDocs(() => (loaded ? flush() : Promise.resolve()));
|
|
201
|
+
const ensure = async () => {
|
|
202
|
+
if (loaded) return;
|
|
203
|
+
try {
|
|
204
|
+
const d = JSON.parse(await fsp.readFile(file, 'utf8'));
|
|
205
|
+
for (const e of d.entries || []) entries.set(e.path, e);
|
|
206
|
+
for (const c of d.comments || []) docs.comments.set(c.id, c);
|
|
207
|
+
for (const ev of d.events || []) docs.events.push(ev);
|
|
208
|
+
} catch { /* fresh */ }
|
|
209
|
+
loaded = true;
|
|
210
|
+
};
|
|
88
211
|
return {
|
|
89
212
|
kind: 'superlite',
|
|
90
213
|
file,
|
|
91
|
-
async load() {
|
|
92
|
-
|
|
93
|
-
},
|
|
94
|
-
async
|
|
95
|
-
|
|
96
|
-
},
|
|
214
|
+
async load() { await ensure(); return entries.size ? { entries: [...entries.values()] } : null; },
|
|
215
|
+
async save(es) { await ensure(); entries.clear(); for (const e of es) entries.set(e.path, e); await flush(); },
|
|
216
|
+
async update(entry) { await ensure(); entries.set(entry.path, entry); await flush(); },
|
|
217
|
+
async remove(p) { await ensure(); entries.delete(p); await flush(); },
|
|
218
|
+
async addComment(c) { await ensure(); return docs.addComment(c); },
|
|
219
|
+
async listComments(f) { await ensure(); return docs.listComments(f); },
|
|
220
|
+
async updateComment(id, patch) { await ensure(); return docs.updateComment(id, patch); },
|
|
221
|
+
async deleteComment(id) { await ensure(); return docs.deleteComment(id); },
|
|
222
|
+
async recordEvent(e) { await ensure(); return docs.recordEvent(e); },
|
|
223
|
+
async usageStats() { await ensure(); return docs.usageStats(); },
|
|
97
224
|
async close({ keep } = {}) {
|
|
98
225
|
if (!persist && !keep) { try { await fsp.unlink(file); } catch { /* gone */ } }
|
|
99
226
|
},
|
|
@@ -104,13 +231,22 @@ function superliteStore(root, persist) {
|
|
|
104
231
|
// sqlite-backed store at an explicit file. cleanup=true removes the file on close.
|
|
105
232
|
function sqliteAt(file, { cleanup }) {
|
|
106
233
|
let db = null;
|
|
234
|
+
const toComment = (r) => ({
|
|
235
|
+
id: r.id, file: r.file, anchor: JSON.parse(r.anchor || '{"type":"file"}'),
|
|
236
|
+
body: r.body, author: r.author, createdAt: r.createdAt, resolved: !!r.resolved
|
|
237
|
+
});
|
|
107
238
|
const open = async () => {
|
|
108
239
|
if (db) return db;
|
|
109
240
|
const { DatabaseSync } = await import('node:sqlite');
|
|
110
241
|
db = new DatabaseSync(file);
|
|
111
242
|
db.exec('CREATE TABLE IF NOT EXISTS files (path TEXT PRIMARY KEY, name TEXT, ext TEXT, mtime REAL, content TEXT)');
|
|
243
|
+
db.exec('CREATE TABLE IF NOT EXISTS comments (id TEXT PRIMARY KEY, file TEXT, anchor TEXT, body TEXT, author TEXT, createdAt REAL, resolved INTEGER)');
|
|
244
|
+
db.exec('CREATE TABLE IF NOT EXISTS events (id TEXT PRIMARY KEY, ts REAL, type TEXT, file TEXT, query TEXT, count INTEGER)');
|
|
112
245
|
return db;
|
|
113
246
|
};
|
|
247
|
+
const writeComment = (d, c) => d
|
|
248
|
+
.prepare('INSERT OR REPLACE INTO comments (id, file, anchor, body, author, createdAt, resolved) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
249
|
+
.run(c.id, c.file, JSON.stringify(c.anchor), c.body, c.author, c.createdAt, c.resolved ? 1 : 0);
|
|
114
250
|
return {
|
|
115
251
|
kind: 'lite',
|
|
116
252
|
file,
|
|
@@ -127,6 +263,50 @@ function sqliteAt(file, { cleanup }) {
|
|
|
127
263
|
for (const e of entries) ins.run(e.path, e.name, e.ext, e.mtime, e.content);
|
|
128
264
|
d.exec('COMMIT');
|
|
129
265
|
},
|
|
266
|
+
async update(entry) {
|
|
267
|
+
const d = await open();
|
|
268
|
+
d.prepare('INSERT OR REPLACE INTO files (path, name, ext, mtime, content) VALUES (?, ?, ?, ?, ?)')
|
|
269
|
+
.run(entry.path, entry.name, entry.ext, entry.mtime, entry.content);
|
|
270
|
+
},
|
|
271
|
+
async remove(p) {
|
|
272
|
+
const d = await open();
|
|
273
|
+
d.prepare('DELETE FROM files WHERE path = ?').run(p);
|
|
274
|
+
},
|
|
275
|
+
async addComment(c) {
|
|
276
|
+
const d = await open();
|
|
277
|
+
const comment = newComment(c);
|
|
278
|
+
writeComment(d, comment);
|
|
279
|
+
return comment;
|
|
280
|
+
},
|
|
281
|
+
async listComments(file) {
|
|
282
|
+
const d = await open();
|
|
283
|
+
const rows = file
|
|
284
|
+
? d.prepare('SELECT * FROM comments WHERE file = ? ORDER BY createdAt').all(file)
|
|
285
|
+
: d.prepare('SELECT * FROM comments ORDER BY createdAt').all();
|
|
286
|
+
return rows.map(toComment);
|
|
287
|
+
},
|
|
288
|
+
async updateComment(id, patch) {
|
|
289
|
+
const d = await open();
|
|
290
|
+
const row = d.prepare('SELECT * FROM comments WHERE id = ?').get(id);
|
|
291
|
+
if (!row) return null;
|
|
292
|
+
const c = { ...toComment(row), ...patch, id };
|
|
293
|
+
writeComment(d, c);
|
|
294
|
+
return c;
|
|
295
|
+
},
|
|
296
|
+
async deleteComment(id) {
|
|
297
|
+
const d = await open();
|
|
298
|
+
return d.prepare('DELETE FROM comments WHERE id = ?').run(id).changes > 0;
|
|
299
|
+
},
|
|
300
|
+
async recordEvent(e) {
|
|
301
|
+
const d = await open();
|
|
302
|
+
const ev = newEvent(e);
|
|
303
|
+
d.prepare('INSERT INTO events (id, ts, type, file, query, count) VALUES (?, ?, ?, ?, ?, ?)')
|
|
304
|
+
.run(ev.id, ev.ts, ev.type, ev.file, ev.query, ev.count);
|
|
305
|
+
},
|
|
306
|
+
async usageStats() {
|
|
307
|
+
const d = await open();
|
|
308
|
+
return summarizeEvents(d.prepare('SELECT type, file, query FROM events').all());
|
|
309
|
+
},
|
|
130
310
|
async close({ keep } = {}) {
|
|
131
311
|
if (db) { db.close(); db = null; }
|
|
132
312
|
if (cleanup && !keep) { try { await fsp.unlink(file); } catch { /* gone */ } }
|
|
@@ -151,6 +331,11 @@ function customStore(url) {
|
|
|
151
331
|
|
|
152
332
|
function postgresStore(url) {
|
|
153
333
|
let client = null;
|
|
334
|
+
const toComment = (r) => ({
|
|
335
|
+
id: r.id, file: r.file,
|
|
336
|
+
anchor: typeof r.anchor === 'string' ? JSON.parse(r.anchor) : (r.anchor || { type: 'file' }),
|
|
337
|
+
body: r.body, author: r.author, createdAt: Number(r.createdat), resolved: !!r.resolved
|
|
338
|
+
});
|
|
154
339
|
const open = async () => {
|
|
155
340
|
if (client) return client;
|
|
156
341
|
let pg;
|
|
@@ -158,11 +343,16 @@ function postgresStore(url) {
|
|
|
158
343
|
catch { throw new Error('the postgres store needs the "pg" package - run: npm i pg'); }
|
|
159
344
|
client = new pg.Client({ connectionString: url });
|
|
160
345
|
await client.connect();
|
|
161
|
-
await client.query(
|
|
162
|
-
|
|
163
|
-
);
|
|
346
|
+
await client.query('CREATE TABLE IF NOT EXISTS projview_files (path TEXT PRIMARY KEY, name TEXT, ext TEXT, mtime DOUBLE PRECISION, content TEXT)');
|
|
347
|
+
await client.query('CREATE TABLE IF NOT EXISTS projview_comments (id TEXT PRIMARY KEY, file TEXT, anchor TEXT, body TEXT, author TEXT, createdat DOUBLE PRECISION, resolved BOOLEAN)');
|
|
348
|
+
await client.query('CREATE TABLE IF NOT EXISTS projview_events (id TEXT PRIMARY KEY, ts DOUBLE PRECISION, type TEXT, file TEXT, query TEXT, count INTEGER)');
|
|
164
349
|
return client;
|
|
165
350
|
};
|
|
351
|
+
const writeComment = (c) => client.query(
|
|
352
|
+
'INSERT INTO projview_comments (id, file, anchor, body, author, createdat, resolved) VALUES ($1, $2, $3, $4, $5, $6, $7)' +
|
|
353
|
+
' ON CONFLICT (id) DO UPDATE SET file = $2, anchor = $3, body = $4, author = $5, createdat = $6, resolved = $7',
|
|
354
|
+
[c.id, c.file, JSON.stringify(c.anchor), c.body, c.author, c.createdAt, c.resolved]
|
|
355
|
+
);
|
|
166
356
|
return {
|
|
167
357
|
kind: 'custom',
|
|
168
358
|
async load() {
|
|
@@ -183,7 +373,55 @@ function postgresStore(url) {
|
|
|
183
373
|
}
|
|
184
374
|
await c.query('COMMIT');
|
|
185
375
|
},
|
|
186
|
-
async
|
|
376
|
+
async update(entry) {
|
|
377
|
+
const c = await open();
|
|
378
|
+
await c.query(
|
|
379
|
+
'INSERT INTO projview_files (path, name, ext, mtime, content) VALUES ($1, $2, $3, $4, $5)' +
|
|
380
|
+
' ON CONFLICT (path) DO UPDATE SET name = $2, ext = $3, mtime = $4, content = $5',
|
|
381
|
+
[entry.path, entry.name, entry.ext, entry.mtime, entry.content]
|
|
382
|
+
);
|
|
383
|
+
},
|
|
384
|
+
async remove(p) {
|
|
385
|
+
const c = await open();
|
|
386
|
+
await c.query('DELETE FROM projview_files WHERE path = $1', [p]);
|
|
387
|
+
},
|
|
388
|
+
async addComment(c) {
|
|
389
|
+
await open();
|
|
390
|
+
const comment = newComment(c);
|
|
391
|
+
await writeComment(comment);
|
|
392
|
+
return comment;
|
|
393
|
+
},
|
|
394
|
+
async listComments(file) {
|
|
395
|
+
const c = await open();
|
|
396
|
+
const { rows } = file
|
|
397
|
+
? await c.query('SELECT * FROM projview_comments WHERE file = $1 ORDER BY createdat', [file])
|
|
398
|
+
: await c.query('SELECT * FROM projview_comments ORDER BY createdat');
|
|
399
|
+
return rows.map(toComment);
|
|
400
|
+
},
|
|
401
|
+
async updateComment(id, patch) {
|
|
402
|
+
const c = await open();
|
|
403
|
+
const { rows } = await c.query('SELECT * FROM projview_comments WHERE id = $1', [id]);
|
|
404
|
+
if (!rows.length) return null;
|
|
405
|
+
const merged = { ...toComment(rows[0]), ...patch, id };
|
|
406
|
+
await writeComment(merged);
|
|
407
|
+
return merged;
|
|
408
|
+
},
|
|
409
|
+
async deleteComment(id) {
|
|
410
|
+
const c = await open();
|
|
411
|
+
return (await c.query('DELETE FROM projview_comments WHERE id = $1', [id])).rowCount > 0;
|
|
412
|
+
},
|
|
413
|
+
async recordEvent(e) {
|
|
414
|
+
const c = await open();
|
|
415
|
+
const ev = newEvent(e);
|
|
416
|
+
await c.query('INSERT INTO projview_events (id, ts, type, file, query, count) VALUES ($1, $2, $3, $4, $5, $6)',
|
|
417
|
+
[ev.id, ev.ts, ev.type, ev.file, ev.query, ev.count]);
|
|
418
|
+
},
|
|
419
|
+
async usageStats() {
|
|
420
|
+
const c = await open();
|
|
421
|
+
const { rows } = await c.query('SELECT type, file, query FROM projview_events');
|
|
422
|
+
return summarizeEvents(rows);
|
|
423
|
+
},
|
|
424
|
+
async close() { if (client) { await client.end(); client = null; } }, // never drop the user's tables
|
|
187
425
|
describe() { return `custom/postgres ${redact(url)}`; }
|
|
188
426
|
};
|
|
189
427
|
}
|