projview 0.6.0 → 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.6.0",
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/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);
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,21 +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() {},
81
177
  async update() {},
82
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,
83
185
  async close() {},
84
186
  describe() { return 'in-memory (ephemeral - nothing persisted)'; }
85
187
  };
@@ -87,29 +189,38 @@ function memoryStore() {
87
189
 
88
190
  function superliteStore(root, persist) {
89
191
  const file = fileFor(root, persist, 'json');
90
- let map = null; // path -> entry, lazily loaded
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()));
91
201
  const ensure = async () => {
92
- if (map) return map;
202
+ if (loaded) return;
93
203
  try {
94
204
  const d = JSON.parse(await fsp.readFile(file, 'utf8'));
95
- map = new Map((d.entries || []).map((e) => [e.path, e]));
96
- } catch { map = new Map(); }
97
- return map;
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;
98
210
  };
99
- const flush = () => fsp.writeFile(file, JSON.stringify({ builtAt: Date.now(), entries: [...map.values()] }));
100
211
  return {
101
212
  kind: 'superlite',
102
213
  file,
103
- async load() {
104
- const m = await ensure();
105
- return m.size ? { entries: [...m.values()] } : null;
106
- },
107
- async save(entries) {
108
- map = new Map(entries.map((e) => [e.path, e]));
109
- await flush();
110
- },
111
- async update(entry) { (await ensure()).set(entry.path, entry); await flush(); },
112
- async remove(p) { (await ensure()).delete(p); await flush(); },
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(); },
113
224
  async close({ keep } = {}) {
114
225
  if (!persist && !keep) { try { await fsp.unlink(file); } catch { /* gone */ } }
115
226
  },
@@ -120,13 +231,22 @@ function superliteStore(root, persist) {
120
231
  // sqlite-backed store at an explicit file. cleanup=true removes the file on close.
121
232
  function sqliteAt(file, { cleanup }) {
122
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
+ });
123
238
  const open = async () => {
124
239
  if (db) return db;
125
240
  const { DatabaseSync } = await import('node:sqlite');
126
241
  db = new DatabaseSync(file);
127
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)');
128
245
  return db;
129
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);
130
250
  return {
131
251
  kind: 'lite',
132
252
  file,
@@ -152,6 +272,41 @@ function sqliteAt(file, { cleanup }) {
152
272
  const d = await open();
153
273
  d.prepare('DELETE FROM files WHERE path = ?').run(p);
154
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
+ },
155
310
  async close({ keep } = {}) {
156
311
  if (db) { db.close(); db = null; }
157
312
  if (cleanup && !keep) { try { await fsp.unlink(file); } catch { /* gone */ } }
@@ -176,6 +331,11 @@ function customStore(url) {
176
331
 
177
332
  function postgresStore(url) {
178
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
+ });
179
339
  const open = async () => {
180
340
  if (client) return client;
181
341
  let pg;
@@ -183,11 +343,16 @@ function postgresStore(url) {
183
343
  catch { throw new Error('the postgres store needs the "pg" package - run: npm i pg'); }
184
344
  client = new pg.Client({ connectionString: url });
185
345
  await client.connect();
186
- await client.query(
187
- 'CREATE TABLE IF NOT EXISTS projview_files (path TEXT PRIMARY KEY, name TEXT, ext TEXT, mtime DOUBLE PRECISION, content TEXT)'
188
- );
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)');
189
349
  return client;
190
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
+ );
191
356
  return {
192
357
  kind: 'custom',
193
358
  async load() {
@@ -220,7 +385,43 @@ function postgresStore(url) {
220
385
  const c = await open();
221
386
  await c.query('DELETE FROM projview_files WHERE path = $1', [p]);
222
387
  },
223
- async close() { if (client) { await client.end(); client = null; } }, // never drop the user's table
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
224
425
  describe() { return `custom/postgres ${redact(url)}`; }
225
426
  };
226
427
  }