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 CHANGED
@@ -1,12 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import path from 'node:path';
3
- import { spawn } from 'node:child_process';
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] start the viewer (default)
96
- projview search <query> [path] print ranked search results as JSON
97
- projview tree [path] print the file tree as JSON
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; }
@@ -11,7 +11,7 @@ previewer for markdown + mermaid files.
11
11
 
12
12
  | need | version |
13
13
  | --- | --- |
14
- | Node.js | **18+** |
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, 29 tests)
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 `search` and `tree` subcommands are projview's **machine interface** - JSON on
87
- stdout, no server, no browser - built for agents and scripts. See
88
- [ai-tests/search.md](ai-tests/search.md) for a worked test.
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 lives in memory and nothing is
117
- written to disk. Opt in to persistence when you want a faster warm start or a
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 |
@@ -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. Everything is read-only and in-memory.
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 is in-memory by default (ephemeral). Opt into a pluggable store with
91
- `--persist` / `--store` / `--store-url`:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "projview",
3
- "version": "0.5.2",
3
+ "version": "0.7.0",
4
4
  "description": "Ephemeral local previewer for markdown + mermaid files in any directory",
5
5
  "type": "module",
6
6
  "bin": {
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
- try { addToIndex(rel, await fsp.readFile(path.join(theRoot, rel), 'utf8')); }
59
- catch { /* gone between event and read - ignore */ }
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
- sendJson(res, search(url.searchParams.get('q') || ''));
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
- updateFile(rel(p));
125
- broadcast('change', { path: rel(p) });
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 indexed entries so a re-spawn can warm-start. Backends:
4
- * memory - no persistence (ephemeral default; nothing written)
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
- * An entry is { path, name, ext, mtime, content }. Everything above the store
10
- * stays storage-agnostic - search still runs in memory over loaded entries.
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
- try { return JSON.parse(await fsp.readFile(file, 'utf8')); } catch { return null; }
93
- },
94
- async save(entries) {
95
- await fsp.writeFile(file, JSON.stringify({ builtAt: Date.now(), entries }));
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
- 'CREATE TABLE IF NOT EXISTS projview_files (path TEXT PRIMARY KEY, name TEXT, ext TEXT, mtime DOUBLE PRECISION, content TEXT)'
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 close() { if (client) { await client.end(); client = null; } }, // never drop the user's table
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
  }