@staix/vault-mem 0.3.1 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,11 @@
1
+ import { realPath } from "./platform.ts";
1
2
  import { Database } from "bun:sqlite";
2
3
  import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readSync, readdirSync, realpathSync } from "node:fs";
3
4
  import { createHash } from "node:crypto";
4
5
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
5
6
  import type { AnalysisSnapshot } from "./analysis-types.ts";
6
- import type { VaultSpec } from "./config.ts";
7
- import { parseFrontmatter } from "./frontmatter.ts";
7
+ import type { VaultSpec, SpecSourceSpec } from "./config.ts";
8
+ import { parseFrontmatter, type FrontmatterDocument } from "./frontmatter.ts";
8
9
  import { normalizeTarget, noteKey, proseLines, resolveLinks, type LinkNote, type ResolvedLink } from "./links.ts";
9
10
 
10
11
  export const MAX_NOTE_BYTES = 2 * 1024 * 1024;
@@ -41,13 +42,13 @@ function within(root: string, target: string): boolean {
41
42
  const rel = relative(root, target);
42
43
  return rel === "" || (!rel.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) && rel !== ".." && !isAbsolute(rel));
43
44
  }
44
- function canonicalLocation(location: string): string {
45
- try { return realpathSync(location); }
45
+ function canonicalLocation(location: string, canonicalize: (path: string) => string = realpathSync): string {
46
+ try { return canonicalize(location); }
46
47
  catch (error) {
47
48
  if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
48
49
  const parent = dirname(location);
49
50
  if (parent === location) throw error;
50
- return join(canonicalLocation(parent), relative(parent, location));
51
+ return join(canonicalLocation(parent, canonicalize), relative(parent, location));
51
52
  }
52
53
  }
53
54
  export function resultLimit(value = 50): number {
@@ -61,6 +62,15 @@ function aliases(value: unknown): string[] {
61
62
  function metadata(stat: NonNullable<ReturnType<typeof lstatSync>>): string {
62
63
  return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs].join(":");
63
64
  }
65
+ /** Adapter file I/O gets path-specific guidance without rewriting unrelated Git or binding errors. */
66
+ function specFileIO<T>(path: string, action: string, operation: () => T): T {
67
+ try { return operation(); }
68
+ catch (error) {
69
+ const code = (error as NodeJS.ErrnoException)?.code ?? "I/O failure";
70
+ const advice = action === "close" ? "retry the scan and inspect local file I/O if the failure repeats" : "restore read access and a stable file, then retry";
71
+ throw new Error(`spec discovery ${path}: cannot ${action} Markdown (${code}); ${advice}`, { cause: error });
72
+ }
73
+ }
64
74
  function boundedText(value: string, bytes: number): { content: string; truncated: boolean } {
65
75
  const buffer = Buffer.from(value);
66
76
  if (buffer.length <= bytes) return { content: value, truncated: false };
@@ -104,69 +114,90 @@ function headingMetadata(sections: { name: string }[]): Pick<NoteReadResult, "he
104
114
  }
105
115
  return { headings, ...(truncated ? { headingsTruncated: true } : {}) };
106
116
  }
117
+ /** A source changes discovery and record selection, while sharing scan storage and BM25. */
118
+ export interface IndexSourceAdapter {
119
+ validate(): void;
120
+ paths(): string[];
121
+ validatePath(path: string): void;
122
+ accept(path: string, document: FrontmatterDocument, hash: string): boolean;
123
+ invalid(path: string): void;
124
+ }
125
+ export class IndexCacheError extends Error {}
126
+ export interface IndexOptions { memory?: boolean; adapter?: IndexSourceAdapter; reuseCache?: boolean }
107
127
  /** Derived cache only. No vault or application configuration files are written. */
108
- export class VaultIndex {
128
+ export class VaultIndex<Source extends VaultSpec | SpecSourceSpec = VaultSpec> {
109
129
  private readonly db: Database;
110
130
  readonly file: string;
111
131
  private readonly root: string;
112
- constructor(home: string, readonly vault: VaultSpec, vaultRoots: string[] = [vault.path], options: { memory?: boolean } = {}) {
113
- this.root = realpathSync(vault.path);
114
- if (this.root !== vault.path) throw new Error(`vault ${vault.id}: registered path changed`);
132
+ private readonly adapter?: IndexSourceAdapter;
133
+ constructor(home: string, readonly vault: Source, vaultRoots: string[] = [vault.path], options: IndexOptions = {}) {
134
+ this.adapter = options.adapter;
135
+ this.root = this.adapter ? realPath(vault.path) : realpathSync(vault.path);
136
+ if (this.root !== vault.path) throw new Error(this.adapter ? "spec index: worktree path changed; resolve the source again with resolveSpecWorktree" : `vault ${vault.id}: registered path changed`);
115
137
  const notes = join(this.root, "notes");
116
- if (realpathSync(notes) !== notes || !lstatSync(notes).isDirectory()) throw new Error(`vault ${vault.id}: notes must be a plain directory`);
117
- const cache = resolve(home, "indexes");
118
- const canonical = canonicalLocation(cache);
119
- for (const root of vaultRoots) {
120
- if (within(canonicalLocation(resolve(root)), canonical)) throw new Error("index: VMEM_HOME cache must be outside every vault");
121
- }
122
- // Canonical home resolution is allowed, but a linked cache directory/file is not.
123
- if (!options.memory) {
124
- mkdirSync(cache, { recursive: true, mode: 0o700 });
125
- if (lstatSync(cache).isSymbolicLink() || !lstatSync(cache).isDirectory()) throw new Error("index: expected plain cache directory");
126
- this.file = join(cache, indexName(this.root));
127
- try {
128
- const fd = openSync(this.file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
129
- closeSync(fd);
130
- } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
131
- if (!lstatSync(this.file).isFile() || lstatSync(this.file).isSymbolicLink()) throw new Error("index: expected plain database file");
132
- } else this.file = ":memory:";
133
- this.db = new Database(this.file);
134
- this.db.run("PRAGMA busy_timeout=5000; PRAGMA journal_mode=DELETE;");
135
- this.db.run(`CREATE TABLE IF NOT EXISTS notes (
136
- path TEXT PRIMARY KEY, hash TEXT NOT NULL, metadata TEXT NOT NULL,
137
- title TEXT NOT NULL, description TEXT NOT NULL, body TEXT NOT NULL,
138
- type TEXT NOT NULL, domain TEXT NOT NULL, aliases TEXT NOT NULL, source TEXT NOT NULL);
139
- CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(path UNINDEXED, title, description, body, tokenize='unicode61');
140
- CREATE TABLE IF NOT EXISTS analysis_notes (path TEXT PRIMARY KEY, source TEXT NOT NULL, hash TEXT NOT NULL, metadata TEXT NOT NULL);
141
- CREATE TABLE IF NOT EXISTS links (source TEXT NOT NULL, ordinal INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(source, ordinal));`);
142
- // Dry runs reuse the derived cache via a readonly connection, then refresh only changed notes in memory.
143
- if (options.memory) {
144
- const file = join(cache, indexName(this.root));
145
- let exists = false;
146
- try { const stat = lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink()) throw new Error("index: expected plain database file"); exists = true; }
147
- catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
148
- if (exists) {
149
- if (realpathSync(cache) !== cache) throw new Error("index: linked cache directory");
150
- const disk = new Database(file, { readonly: true });
138
+ if (this.adapter) this.adapter.validate();
139
+ else if (realpathSync(notes) !== notes || !lstatSync(notes).isDirectory()) throw new Error(`vault ${vault.id}: notes must be a plain directory`);
140
+ try {
141
+ const cacheName = this.adapter ? `spec-${indexName(this.root)}` : indexName(this.root);
142
+ const cache = resolve(home, "indexes");
143
+ const noDisk = options.memory && options.reuseCache === false;
144
+ const canonicalize = this.adapter ? realPath : realpathSync;
145
+ const canonical = noDisk ? cache : canonicalLocation(cache, canonicalize);
146
+ for (const root of noDisk ? [] : vaultRoots) {
147
+ if (within(canonicalLocation(resolve(root), canonicalize), canonical)) throw new Error(this.adapter ? "spec index: cache is inside a protected source; choose VMEM_HOME outside every repository and vault" : "index: VMEM_HOME cache must be outside every vault");
148
+ }
149
+ // Canonical home resolution is allowed, but a linked cache directory/file is not.
150
+ if (!options.memory) {
151
+ mkdirSync(cache, { recursive: true, mode: 0o700 });
152
+ if (lstatSync(cache).isSymbolicLink() || !lstatSync(cache).isDirectory()) throw new Error("index: expected plain cache directory");
153
+ this.file = join(cache, cacheName);
151
154
  try {
152
- disk.transaction(() => {
153
- const size = disk.query<{count:number;bytes:number}, []>("SELECT count(*) AS count, coalesce(sum(length(CAST(source AS BLOB))),0) AS bytes FROM notes").get()!;
154
- if (size.count > 10000 || size.bytes > 64 * 1024 * 1024) throw new Error("analysis: cached snapshot exceeds bound");
155
- for (const row of disk.query<Omit<StoredNote,"aliases"> & {aliases:string}, []>("SELECT * FROM notes").all()) {
156
- this.db.query("INSERT INTO notes VALUES (?,?,?,?,?,?,?,?,?,?)").run(row.path,row.hash,row.metadata,row.title,row.description,row.body,row.type,row.domain,row.aliases,row.source);
157
- this.db.query("INSERT INTO fts VALUES (?,?,?,?)").run(row.path,row.title,row.description,row.body);
158
- }
159
- for (const row of disk.query<{source:string;ordinal:number;data:string}, []>("SELECT * FROM links").all()) this.db.query("INSERT INTO links VALUES (?,?,?)").run(row.source,row.ordinal,row.data);
160
- this.db.run(`PRAGMA user_version = ${Number(disk.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0)}`);
161
- const exists = disk.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='analysis_notes'").get();
162
- if (exists) {
163
- const size = disk.query<{count:number;bytes:number}, []>("SELECT count(*) AS count, coalesce(sum(length(CAST(source AS BLOB))),0) AS bytes FROM analysis_notes").get()!;
155
+ const fd = openSync(this.file, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600);
156
+ closeSync(fd);
157
+ } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
158
+ if (!lstatSync(this.file).isFile() || lstatSync(this.file).isSymbolicLink() || (this.adapter && lstatSync(this.file).nlink !== 1)) throw new Error("index: expected plain database file");
159
+ } else this.file = ":memory:";
160
+ this.db = new Database(this.file);
161
+ this.db.run("PRAGMA busy_timeout=5000; PRAGMA journal_mode=DELETE;");
162
+ this.db.run(`CREATE TABLE IF NOT EXISTS notes (
163
+ path TEXT PRIMARY KEY, hash TEXT NOT NULL, metadata TEXT NOT NULL,
164
+ title TEXT NOT NULL, description TEXT NOT NULL, body TEXT NOT NULL,
165
+ type TEXT NOT NULL, domain TEXT NOT NULL, aliases TEXT NOT NULL, source TEXT NOT NULL);
166
+ CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(path UNINDEXED, title, description, body, tokenize='unicode61');
167
+ CREATE TABLE IF NOT EXISTS analysis_notes (path TEXT PRIMARY KEY, source TEXT NOT NULL, hash TEXT NOT NULL, metadata TEXT NOT NULL);
168
+ CREATE TABLE IF NOT EXISTS links (source TEXT NOT NULL, ordinal INTEGER NOT NULL, data TEXT NOT NULL, PRIMARY KEY(source, ordinal));`);
169
+ // Dry runs reuse the derived cache via a readonly connection, then refresh only changed notes in memory.
170
+ if (options.memory && options.reuseCache !== false) {
171
+ const file = join(cache, cacheName);
172
+ let exists = false;
173
+ try { const stat = lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink() || (this.adapter && stat.nlink !== 1)) throw new Error("index: expected plain database file"); exists = true; }
174
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; }
175
+ if (exists) {
176
+ if (canonicalize(cache) !== cache) throw new Error("index: linked cache directory");
177
+ const disk = new Database(file, { readonly: true });
178
+ try {
179
+ disk.transaction(() => {
180
+ const size = disk.query<{count:number;bytes:number}, []>("SELECT count(*) AS count, coalesce(sum(length(CAST(source AS BLOB))),0) AS bytes FROM notes").get()!;
164
181
  if (size.count > 10000 || size.bytes > 64 * 1024 * 1024) throw new Error("analysis: cached snapshot exceeds bound");
165
- for (const row of disk.query<{path:string;source:string;hash:string;metadata:string}, []>("SELECT * FROM analysis_notes").all()) this.db.query("INSERT INTO analysis_notes VALUES (?,?,?,?)").run(row.path,row.source,row.hash,row.metadata);
166
- }
167
- })();
168
- } finally { disk.close(); }
182
+ for (const row of disk.query<Omit<StoredNote,"aliases"> & {aliases:string}, []>("SELECT * FROM notes").all()) {
183
+ this.db.query("INSERT INTO notes VALUES (?,?,?,?,?,?,?,?,?,?)").run(row.path,row.hash,row.metadata,row.title,row.description,row.body,row.type,row.domain,row.aliases,row.source);
184
+ this.db.query("INSERT INTO fts VALUES (?,?,?,?)").run(row.path,row.title,row.description,row.body);
185
+ }
186
+ for (const row of disk.query<{source:string;ordinal:number;data:string}, []>("SELECT * FROM links").all()) this.db.query("INSERT INTO links VALUES (?,?,?)").run(row.source,row.ordinal,row.data);
187
+ this.db.run(`PRAGMA user_version = ${Number(disk.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version ?? 0)}`);
188
+ const exists = disk.query("SELECT 1 FROM sqlite_master WHERE type='table' AND name='analysis_notes'").get();
189
+ if (exists) {
190
+ const size = disk.query<{count:number;bytes:number}, []>("SELECT count(*) AS count, coalesce(sum(length(CAST(source AS BLOB))),0) AS bytes FROM analysis_notes").get()!;
191
+ if (size.count > 10000 || size.bytes > 64 * 1024 * 1024) throw new Error("analysis: cached snapshot exceeds bound");
192
+ for (const row of disk.query<{path:string;source:string;hash:string;metadata:string}, []>("SELECT * FROM analysis_notes").all()) this.db.query("INSERT INTO analysis_notes VALUES (?,?,?,?)").run(row.path,row.source,row.hash,row.metadata);
193
+ }
194
+ })();
195
+ } finally { disk.close(); }
196
+ }
169
197
  }
198
+ } catch (error) {
199
+ if (this.adapter) throw new IndexCacheError(error instanceof Error ? error.message : "cache initialization failed", { cause: error });
200
+ throw error;
170
201
  }
171
202
  }
172
203
  close(): void { this.db.close(); }
@@ -177,62 +208,91 @@ export class VaultIndex {
177
208
  scan(rebuild = false, options: { skipInvalidFrontmatter?: boolean; captureInvalidFrontmatter?: boolean } = {}): { notes: number; changed: number; removed: number; skipped?: string[] } {
178
209
  return this.db.transaction(() => {
179
210
  const notesDir = join(this.root, "notes");
180
- if (realpathSync(this.root) !== this.root || realpathSync(notesDir) !== notesDir || !lstatSync(notesDir).isDirectory()) throw new Error(`vault ${this.vault.id}: path or notes directory changed`);
181
- const previous = new Map(this.db.query<{ path: string; hash: string; metadata: string }, []>(options.captureInvalidFrontmatter ? "SELECT path,hash,metadata FROM notes UNION SELECT path,hash,metadata FROM analysis_notes" : "SELECT path,hash,metadata FROM notes").all().map(note => [note.path, note]));
211
+ if (this.adapter) this.adapter.validate();
212
+ else if (realpathSync(this.root) !== this.root || realpathSync(notesDir) !== notesDir || !lstatSync(notesDir).isDirectory()) throw new Error(`vault ${this.vault.id}: path or notes directory changed`);
213
+ const indexed = this.adapter ? new Set(this.db.query<{path:string}, []>("SELECT path FROM notes").all().map(row => row.path)) : undefined;
214
+ const previous = new Map(this.db.query<{ path: string; hash: string; metadata: string }, []>(this.adapter ? "SELECT path,hash,metadata FROM analysis_notes" : options.captureInvalidFrontmatter ? "SELECT path,hash,metadata FROM notes UNION SELECT path,hash,metadata FROM analysis_notes" : "SELECT path,hash,metadata FROM notes").all().map(note => [note.path, note]));
182
215
  if (rebuild) this.db.run("DELETE FROM fts");
183
216
  const live = new Set<string>(), present = new Set<string>();
184
217
  let changed = 0, analysisBytes = 0;
185
218
  const skipped: string[] = [], touched = new Set<string>(), keys = new Set<string>();
186
219
  // Links resolve by path only, so other notes re-resolve only when they target a touched path.
187
220
  const touch = (path: string) => { touched.add(path); keys.add(noteKey(path)); };
188
- for (const entry of readdirSync(notesDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
189
- if (!entry.name.endsWith(".md") || !entry.isFile()) continue;
190
- const path = entry.name, file = join(notesDir, path), before = lstatSync(file);
191
- if (!before.isFile() || before.isSymbolicLink()) throw new Error(`vault ${this.vault.id}: note path changed`);
192
- if (before.size > MAX_NOTE_BYTES) throw new Error(`vault ${this.vault.id}, ${path}: note exceeds ${MAX_NOTE_BYTES} bytes`);
221
+ const paths = this.adapter ? this.adapter.paths() : readdirSync(notesDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)).filter(entry => entry.name.endsWith(".md") && entry.isFile()).map(entry => entry.name);
222
+ for (const path of paths) {
223
+ this.adapter?.validatePath(path);
224
+ const file = join(this.adapter ? this.root : notesDir, path);
225
+ const before = this.adapter ? specFileIO(path, "inspect", () => lstatSync(file)) : lstatSync(file);
226
+ if (!before.isFile() || before.isSymbolicLink()) throw new Error(this.adapter ? `spec discovery ${path}: selected path changed; restore a plain file and retry` : `vault ${this.vault.id}: note path changed`);
227
+ if (before.size > MAX_NOTE_BYTES) throw new Error(this.adapter ? `spec discovery ${path}: Markdown exceeds ${MAX_NOTE_BYTES} bytes; split the file before rebuilding` : `vault ${this.vault.id}, ${path}: note exceeds ${MAX_NOTE_BYTES} bytes`);
193
228
  live.add(path); present.add(path);
194
- if (options.captureInvalidFrontmatter) {
229
+ if (options.captureInvalidFrontmatter || this.adapter) {
195
230
  analysisBytes += before.size;
196
- if (present.size > 10000 || analysisBytes > 64 * 1024 * 1024) throw new Error("analysis: snapshot exceeds bound");
231
+ if (present.size > 10000 || analysisBytes > 64 * 1024 * 1024) throw new Error(this.adapter ? `spec discovery ${path}: more than 10000 Markdown files or 64 MiB of input; ignore large or vendored Markdown via .gitignore and untrack already-tracked excluded files with git rm --cached before rebuilding` : "analysis: snapshot exceeds bound");
197
232
  }
198
233
  const old = previous.get(path), stamp = metadata(before);
199
- if (!rebuild && old?.metadata === stamp && this.db.query("SELECT 1 FROM analysis_notes WHERE path=?").get(path)) continue;
200
- const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
201
- let buffer: Buffer;
202
- try {
203
- const stat = fstatSync(fd);
204
- if (!stat.isFile() || stat.size > MAX_NOTE_BYTES || metadata(stat) !== stamp) throw new Error(`vault ${this.vault.id}, ${path}: note changed during scan`);
205
- // Fixed bound even if a concurrent writer grows the file.
206
- buffer = Buffer.alloc(MAX_NOTE_BYTES + 1);
207
- let size = 0;
208
- for (;;) {
209
- const count = readSync(fd, buffer, size, buffer.length - size, null);
210
- size += count;
211
- if (!count || size === buffer.length) break;
212
- }
213
- buffer = buffer.subarray(0, size);
214
- if (size > MAX_NOTE_BYTES || metadata(fstatSync(fd)) !== stamp) throw new Error(`vault ${this.vault.id}, ${path}: note changed during scan`);
215
- } finally { closeSync(fd); }
216
- const hash = digest(buffer);
217
- this.db.query("INSERT OR REPLACE INTO analysis_notes VALUES (?,?,?,?)").run(path, buffer.toString("utf8"), hash, stamp);
218
- if (!rebuild && old?.hash === hash) { this.db.query("UPDATE notes SET metadata=? WHERE path=?").run(stamp, path); continue; }
234
+ if (!this.adapter && !rebuild && old?.metadata === stamp && this.db.query("SELECT 1 FROM analysis_notes WHERE path=?").get(path)) continue;
235
+ const cached = this.adapter && !rebuild && old?.metadata === stamp ? this.db.query<{source:string}, [string]>("SELECT source FROM analysis_notes WHERE path=?").get(path) : null;
236
+ let source: string, hash: string;
237
+ if (cached && old) { source = cached.source; hash = old.hash; }
238
+ else {
239
+ const fd = this.adapter ? specFileIO(path, "open", () => openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK)) : openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
240
+ let buffer: Buffer;
241
+ try {
242
+ const stat = this.adapter ? specFileIO(path, "inspect", () => fstatSync(fd)) : fstatSync(fd);
243
+ if (!stat.isFile() || stat.size > MAX_NOTE_BYTES || metadata(stat) !== stamp) throw new Error(this.adapter ? `spec discovery ${path}: file changed during scan; retry on a stable worktree` : `vault ${this.vault.id}, ${path}: note changed during scan`);
244
+ // Fixed bound even if a concurrent writer grows the file.
245
+ buffer = Buffer.alloc(MAX_NOTE_BYTES + 1);
246
+ let size = 0;
247
+ for (;;) {
248
+ const count = this.adapter ? specFileIO(path, "read", () => readSync(fd, buffer, size, buffer.length - size, null)) : readSync(fd, buffer, size, buffer.length - size, null);
249
+ size += count;
250
+ if (!count || size === buffer.length) break;
251
+ }
252
+ buffer = buffer.subarray(0, size);
253
+ if (size > MAX_NOTE_BYTES || metadata(this.adapter ? specFileIO(path, "inspect", () => fstatSync(fd)) : fstatSync(fd)) !== stamp) throw new Error(this.adapter ? `spec discovery ${path}: file changed during scan; retry on a stable worktree` : `vault ${this.vault.id}, ${path}: note changed during scan`);
254
+ } finally { if (this.adapter) specFileIO(path, "close", () => closeSync(fd)); else closeSync(fd); }
255
+ if (this.adapter) {
256
+ try { source = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(buffer); }
257
+ catch { throw new Error(`spec discovery ${path}: Markdown is not valid UTF-8; re-encode the file as UTF-8 before rebuilding`); }
258
+ } else source = buffer.toString("utf8");
259
+ hash = digest(buffer);
260
+ this.adapter?.validatePath(path);
261
+ if (this.adapter && metadata(specFileIO(path, "inspect", () => lstatSync(file))) !== stamp) throw new Error(`spec discovery ${path}: file changed during read; retry on a stable worktree`);
262
+ this.db.query("INSERT OR REPLACE INTO analysis_notes VALUES (?,?,?,?)").run(path, source, hash, stamp);
263
+ }
264
+ if (!this.adapter && !rebuild && old?.hash === hash) { this.db.query("UPDATE notes SET metadata=? WHERE path=?").run(stamp, path); continue; }
219
265
  let document: ReturnType<typeof parseFrontmatter>;
220
- try { document = parseFrontmatter(buffer.toString("utf8")); }
266
+ try { document = parseFrontmatter(source); }
221
267
  catch {
222
- if (!options.skipInvalidFrontmatter && !options.captureInvalidFrontmatter) throw new Error(`vault ${this.vault.id}, ${path}: invalid frontmatter`);
268
+ if (!this.adapter && !options.skipInvalidFrontmatter && !options.captureInvalidFrontmatter) throw new Error(`vault ${this.vault.id}, ${path}: invalid frontmatter`);
269
+ this.adapter?.invalid(path);
223
270
  live.delete(path); skipped.push(path); continue;
224
271
  }
272
+ if (this.adapter) {
273
+ this.adapter.validatePath(path);
274
+ if (metadata(specFileIO(path, "inspect", () => lstatSync(file))) !== stamp) throw new Error(`spec discovery ${path}: file changed during scan; retry on a stable worktree`);
275
+ if (!this.adapter.accept(path, document, hash)) { live.delete(path); continue; }
276
+ const row = this.db.query<{hash:string}, [string]>("SELECT hash FROM notes WHERE path=?").get(path);
277
+ if (!rebuild && row?.hash === hash) { this.db.query("UPDATE notes SET metadata=? WHERE path=?").run(stamp, path); continue; }
278
+ }
225
279
  const { data, body } = document;
226
280
  touch(path);
227
281
  this.db.query("INSERT OR REPLACE INTO notes VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)").run(path, hash, stamp,
228
- text(data.title) || path.slice(0, -3), text(data.description), body, text(data.type), text(data.domain), JSON.stringify(aliases(data.aliases ?? data.alias)), buffer.toString("utf8"));
282
+ text(data.title) || path.slice(0, -3), text(data.description), body, text(data.type), text(data.domain), JSON.stringify(aliases(data.aliases ?? data.alias)), source);
229
283
  this.db.query("DELETE FROM fts WHERE path=?").run(path);
230
284
  this.db.query("INSERT INTO fts(path,title,description,body) SELECT path,title,description,body FROM notes WHERE path=?").run(path);
231
285
  changed++;
232
286
  }
233
287
  for (const row of this.db.query<{path:string}, []>("SELECT path FROM analysis_notes").all()) if (!present.has(row.path)) this.db.query("DELETE FROM analysis_notes WHERE path=?").run(row.path);
234
288
  let removed = 0;
235
- for (const path of previous.keys()) if (!live.has(path)) { touch(path); this.db.query("DELETE FROM notes WHERE path=?").run(path); this.db.query("DELETE FROM fts WHERE path=?").run(path); removed++; }
289
+ for (const path of indexed ?? previous.keys()) if (!live.has(path)) { touch(path); this.db.query("DELETE FROM notes WHERE path=?").run(path); this.db.query("DELETE FROM fts WHERE path=?").run(path); removed++; }
290
+ if (this.adapter) {
291
+ // Spec links are resolved by frontmatter id in buildSpecGraph, never by vault paths.
292
+ this.db.run("DELETE FROM links");
293
+ this.adapter.validate();
294
+ return { notes: live.size, changed, removed, ...(skipped.length ? { skipped } : {}) };
295
+ }
236
296
  const stale = this.db.query<{ user_version: number }, []>("PRAGMA user_version").get()?.user_version !== LINK_RESOLUTION;
237
297
  if (rebuild || stale) this.db.run("DELETE FROM links");
238
298
  else if (changed || removed) {
@@ -261,15 +321,17 @@ export class VaultIndex {
261
321
  const notes = this.db.query<{path: string; source: string}, []>("SELECT path,source FROM analysis_notes ORDER BY path").all();
262
322
  return { ...inputs, notes };
263
323
  }
264
- search(query: string, options: SearchOptions = {}): SearchResult[] {
324
+ search(query: string, options: SearchOptions = {}, snapshot?: ReadonlyMap<string, string>): SearchResult[] {
265
325
  const limit = resultLimit(options.limit);
266
326
  if (!query.trim() || Buffer.byteLength(query) > 1024) throw new Error("search: query must contain 1..1024 bytes");
267
327
  // Literal AND terms: users cannot inject FTS operators, columns or SQL.
268
328
  const terms = query.trim().split(/\s+/).map(term => `"${term.replaceAll('"', '""')}"`).join(" AND ");
269
- return this.db.query<SearchResult, [string, string | null, string | null, string | null, string | null, number]>(`SELECT n.path, n.title, substr(n.description,1,512) AS description, n.type, n.domain,
270
- bm25(fts,0,5,2,1) AS score, snippet(fts,3,'','',' ... ',32) AS snippet FROM fts JOIN notes n ON n.path=fts.path
329
+ const hashColumn = snapshot ? ",n.hash AS sourceHash" : "";
330
+ return this.db.query<SearchResult & { sourceHash?: string }, [string, string | null, string | null, string | null, string | null, number]>(`SELECT n.path, n.title, substr(n.description,1,512) AS description, n.type, n.domain,
331
+ bm25(fts,0,5,2,1) AS score, snippet(fts,3,'','',' ... ',32) AS snippet${hashColumn} FROM fts JOIN notes n ON n.path=fts.path
271
332
  WHERE fts MATCH ? AND (? IS NULL OR n.type=?) AND (? IS NULL OR n.domain=?) ORDER BY score,n.path LIMIT ?`).all(terms, options.type ?? null, options.type ?? null, options.domain ?? null, options.domain ?? null, limit)
272
- .map(row => ({ ...row, title: boundedText(row.title, 512).content, snippet: boundedText(row.snippet, 1024).content, type: boundedText(row.type, 256).content, domain: boundedText(row.domain, 256).content }));
333
+ .filter(row => !snapshot || snapshot.get(row.path) === row.sourceHash)
334
+ .map(({ sourceHash: _, ...row }) => ({ ...row, title: boundedText(row.title, 512).content, snippet: boundedText(row.snippet, 1024).content, type: boundedText(row.type, 256).content, domain: boundedText(row.domain, 256).content }));
273
335
  }
274
336
  private find(target: string): string {
275
337
  if (!target || Buffer.byteLength(target) > 1024) throw new Error("note: expected target <=1024 bytes");
@@ -1,3 +1,4 @@
1
+ import { COMMANDS as COMMAND_USAGE } from "./commands-table.ts";
1
2
  import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs";
2
3
  import { canonicalPath, isDenied } from "./platform.ts";
3
4
  import { loadRegistry } from "./registry.ts";
@@ -44,7 +45,7 @@ export function runWriteCommand(command: string, args: string[], home: string, e
44
45
  for (let i = 0; i < args.length; i++) {
45
46
  const arg = args[i]!;
46
47
  if (!arg.startsWith("--")) { positional.push(arg); continue; }
47
- if (Object.hasOwn(flags, arg) || (!values.includes(arg) && !booleans.includes(arg))) throw new Error(`${command}: unknown or repeated option`);
48
+ if (Object.hasOwn(flags, arg) || (!values.includes(arg) && !booleans.includes(arg))) throw new Error(`${command}: unknown or repeated option: ${arg}`);
48
49
  if (booleans.includes(arg)) flags[arg] = true;
49
50
  else {
50
51
  const value = args[++i];
@@ -54,8 +55,12 @@ export function runWriteCommand(command: string, args: string[], home: string, e
54
55
  }
55
56
  const required = (name: string): string => { if (typeof flags[name] !== "string") throw new Error(`${name}: required`); return flags[name] as string; };
56
57
  const optional = (name: string): string | undefined => typeof flags[name] === "string" ? flags[name] as string : undefined;
57
- const count = ["move", "link", "supersede"].includes(command) ? 3 : command === "tidy" ? 1 : 2;
58
- if (positional.length !== count) throw new Error(`${command}: requires vault id${count > 1 ? " and target(s)" : ""}`);
58
+ const usage = COMMAND_USAGE.find(item => item.name === command)!.usage;
59
+ const targets = usage.slice(command.length).split(/\s+(?:--|\[)/, 1)[0]!.match(/<[^>]+>/g) ?? [];
60
+ if (positional.length !== targets.length) {
61
+ const expected = targets.length === 2 ? `${targets[0]} and one ${targets[1]}` : targets.join(" and ");
62
+ throw new Error(`${command}: takes ${expected}; got ${Math.max(0, positional.length - 1)} targets`);
63
+ }
59
64
  const vault = positional[0]!, target = positional[1]!;
60
65
  if (command === "tidy") {
61
66
  const all = flags["--all"] === true, rules = TIDY_RULES.filter(rule => all || flags[`--${rule}`] === true);
@@ -1,3 +1,4 @@
1
+ import { isDeepStrictEqual } from "node:util";
1
2
  import { normalizeSource } from "./source.ts";
2
3
  import { parseFrontmatter, emitFrontmatter } from "./frontmatter.ts";
3
4
  import { normalizeTarget, proseLines, rewriteLinkTargets, extractLinks, resolveLinks, type LinkNote } from "./links.ts";
@@ -45,6 +46,42 @@ export function h2Sections(body: string): { name: string; start: number; end: nu
45
46
  }
46
47
  return sections;
47
48
  }
49
+ /** Replace the last prose `## Topics` section (never one inside a fence) with the given topics; append one if absent. */
50
+ export function replaceTopicsSection(body: string, topics: string[]): string {
51
+ const prose = proseLines(body), raw = body.split("\n");
52
+ let start = -1;
53
+ for (let i = prose.length - 1; i >= 0; i--) if (/^ {0,3}##[ \t]+Topics[ \t]*#*[ \t]*$/i.test(prose[i]!)) { start = i; break; }
54
+ const section = ["## Topics", "", ...topics.map(topic => `- ${topic}`), ""];
55
+ if (start < 0) return `${body.replace(/\n*$/, "")}\n\n${section.join("\n")}`;
56
+ // The section ends at the next prose heading of level 1 or 2.
57
+ let end = raw.length;
58
+ for (let i = start + 1; i < prose.length; i++) if (/^ {0,3}#{1,2}[ \t]/.test(prose[i]!)) { end = i; break; }
59
+ const tail = raw.slice(end);
60
+ return [...raw.slice(0, start), ...section, ...(tail.length ? tail : [])].join("\n").replace(/\n*$/, "\n");
61
+ }
62
+ /** In-place refiling replaces only visible wiki-list lines, preserving all other bytes. */
63
+ function replaceTopicsList(body: string, topics: string[], newline: string): string {
64
+ const visible = proseLines(body), raw = body.match(/[^\n]*\n|[^\n]+$/g) ?? [];
65
+ let start = -1;
66
+ for (let i = visible.length - 1; i >= 0; i--) if (/^ {0,3}##[ \t]+Topics(?:[ \t]+#+)?[ \t]*$/i.test(visible[i]!)) { start = i; break; }
67
+ const list = topics.map(topic => `- ${topic}`).join(newline);
68
+ if (start < 0) return body + (body && !body.endsWith("\n") ? newline : "") + newline + `## Topics${newline}${newline}${list}${newline}`;
69
+ const items = new Set<number>();
70
+ // The Topics list is the leading run of list lines as V05 sees them: blank and fenced lines continue it (fences are kept),
71
+ // the first visible heading or prose line ends it.
72
+ for (let i = start + 1; i < raw.length; i++) {
73
+ const line = visible[i] ?? "";
74
+ if (/^[ \t]*(?:[-*+][ \t]+)?(\[\[[^\]|#]+\]\])[ \t]*$/.test(line)) items.add(i);
75
+ else if (line.trim()) break;
76
+ }
77
+ const first = items.values().next().value;
78
+ if (first === undefined) {
79
+ const addition = (raw[start]!.endsWith("\n") ? "" : newline) + list + (list ? newline : "");
80
+ return raw.slice(0, start + 1).join("") + addition + raw.slice(start + 1).join("");
81
+ }
82
+ const ending = /\r?\n$/.exec(raw[first]!)?.[0] ?? "";
83
+ return raw.map((line, i) => i === first ? list + (list ? ending : "") : items.has(i) ? "" : line).join("");
84
+ }
48
85
  export function appendSection(source: string, section: string, markdown: string, path: string): string {
49
86
  if (typeof section !== "string" || !section.trim() || section.length > 120 || /[\r\n]/.test(section) || section.trim().replace(/[ \t]+#+$/, "").trim().toLowerCase() === "topics") refuse("V05", "append cannot target Topics or an invalid section");
50
87
  if (typeof markdown !== "string" || Buffer.byteLength(markdown) > 16384 || proseLines(markdown).some(line => /^ {0,3}##[ \t]+Topics(?:[ \t]+#+)?[ \t]*$/i.test(line))) refuse("V05", "append cannot introduce a Topics section or exceed 16384 bytes");
@@ -125,7 +162,15 @@ export function planOperation(operation: WriteOp, notes: Map<string, string>, re
125
162
  if (!operation.set || typeof operation.set !== "object" || Array.isArray(operation.set) || (operation.unset !== undefined && (!Array.isArray(operation.unset) || operation.unset.some(key => typeof key !== "string")))) refuse("V01", "invalid frontmatter update");
126
163
  const changes = {...operation.set};
127
164
  for (const key of operation.unset ?? []) changes[key] = undefined;
128
- try { set(path, emitFrontmatter(document(source, path), normalizedSourceChanges(changes))); } catch (error) { if (error instanceof WriteRefusal) throw error; refuse("V01", "cannot emit frontmatter", path); }
165
+ try {
166
+ const before = document(source, path);
167
+ let after = emitFrontmatter(before, normalizedSourceChanges(changes));
168
+ if (Object.hasOwn(operation.set, "topics") && !isDeepStrictEqual(changes.topics, before.data.topics) && Array.isArray(changes.topics) && changes.topics.every((value): value is string => typeof value === "string")) {
169
+ const doc = document(after, path);
170
+ after = (doc.frontmatter ?? "") + replaceTopicsList(doc.body, changes.topics, /\r?\n/.exec(source)?.[0] ?? "\n");
171
+ }
172
+ set(path, after);
173
+ } catch (error) { if (error instanceof WriteRefusal) throw error; refuse("V01", "cannot emit frontmatter", path); }
129
174
  break;
130
175
  }
131
176
  case "link": {
@@ -181,22 +181,34 @@ function topicIssues(note: ParsedNote, index: Map<string, Set<ParsedNote>>, seve
181
181
  }
182
182
  return [];
183
183
  }
184
- function topicPlacement(note: ParsedNote): WriteIssue[] {
185
- if (note.data.type === "moc" && note.data.topics === undefined) return [];
184
+ /** V05 has two independent problems: placement (one Topics H2, and it is the last H2) and content (the last Topics section lists exactly the frontmatter topics). */
185
+ function topicProblems(note: ParsedNote): Set<"placement" | "content"> {
186
+ const problems = new Set<"placement" | "content">();
187
+ if (note.data.type === "moc" && note.data.topics === undefined) return problems;
186
188
  const lines = proseLines(note.body), h2s = lines.flatMap((line, index) => /^ {0,3}##[ \t]+/.test(line) ? [index] : []);
187
189
  const topics = h2s.filter(index => /^ {0,3}##[ \t]+Topics(?:[ \t]+#+)?[ \t]*$/i.test(lines[index]!));
188
- let valid = topics.length === 1 && h2s[h2s.length - 1] === topics[0];
190
+ if (topics.length !== 1 || h2s[h2s.length - 1] !== topics[0]) problems.add("placement");
191
+ const last = topics[topics.length - 1], end = h2s.find(index => last !== undefined && index > last) ?? lines.length;
189
192
  const actual: string[] = [];
190
- if (valid) for (const line of lines.slice(topics[0]! + 1)) {
193
+ let content = last !== undefined;
194
+ if (last !== undefined) for (const line of lines.slice(last + 1, end)) {
191
195
  if (!line.trim()) continue;
192
196
  const wiki = /^[ \t]*(?:[-*+][ \t]+)?(\[\[[^\]|#]+\]\])[ \t]*$/.exec(line);
193
- if (!wiki) { valid = false; break; }
197
+ if (!wiki) { content = false; break; }
194
198
  actual.push(noteKey(wiki[1]!.slice(2, -2)));
195
199
  }
196
200
  const expected = Array.isArray(note.data.topics) ? note.data.topics.map(value => typeof value === "string" ? noteKey(value.slice(2, -2)) : "") : [];
197
201
  const sortedExpected = [...expected].sort();
198
- if (actual.length !== expected.length || [...actual].sort().some((value, index) => value !== sortedExpected[index])) valid = false;
199
- return valid ? [] : [issue("V05", "Topics must be the final H2 and list exactly the frontmatter topics", "warning", note.path)];
202
+ if (!content || actual.length !== expected.length || [...actual].sort().some((value, index) => value !== sortedExpected[index])) problems.add("content");
203
+ return problems;
204
+ }
205
+ /** An error unless every V05 problem of the new note was already present in the prior note (as V01 retains only unchanged findings). */
206
+ function topicPlacement(note: ParsedNote, old: ParsedNote | undefined, context: ValidationContext): WriteIssue[] {
207
+ const problems = topicProblems(note);
208
+ if (!problems.size) return [];
209
+ const before = context.operation.op !== "create" && old !== undefined ? topicProblems(old) : new Set<string>();
210
+ const retained = [...problems].every(problem => before.has(problem));
211
+ return [issue("V05", "Topics must be the final H2 and list exactly the frontmatter topics", retained ? "warning" : "error", note.path)];
200
212
  }
201
213
 
202
214
  /** Validate a complete mutation plan against the final overlaid snapshot, without filesystem writes. */
@@ -226,7 +238,7 @@ export function validateWrite(changes: PlannedChange[], context: ValidationConte
226
238
  const note = newNotes.get(change.path);
227
239
  if (!note) { issues.push(issue("V01", "invalid frontmatter", "error", change.path)); continue; }
228
240
  const prior=context.operation.op==="move"&&noteKey(context.operation.to)===noteKey(note.path)?oldNotes.get(movedFrom(context.operation.from, context)):oldNotes.get(note.path);
229
- issues.push(...schemaIssues(note,prior,context), ...topicPlacement(note));
241
+ issues.push(...schemaIssues(note,prior,context), ...topicPlacement(note,prior,context));
230
242
  const source = note.data.source;
231
243
  let sourceDenied=strings(source).some(entry=>isDenied(entry.replaceAll("\\","/"),context.config.denyPaths));
232
244
  if(source!==undefined&&!sourceDenied)try{