@gmickel/gno 1.34.5 → 1.35.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.
Files changed (49) hide show
  1. package/README.md +22 -1
  2. package/browser-extension/artifacts/{gno-browser-clipper-v1.34.5.zip → gno-browser-clipper-v1.35.0.zip} +0 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.35.0.zip.sha256 +1 -0
  4. package/browser-extension/dist/manifest.json +1 -1
  5. package/package.json +4 -1
  6. package/spec/cli.md +43 -0
  7. package/spec/output-schemas/mcp-job-status.schema.json +6 -2
  8. package/src/config/index.ts +4 -0
  9. package/src/config/types.ts +14 -0
  10. package/src/core/path-rules.ts +34 -0
  11. package/src/ingestion/index.ts +21 -0
  12. package/src/ingestion/record-container.ts +23 -1
  13. package/src/ingestion/source-availability/darwin-io.ts +295 -0
  14. package/src/ingestion/source-availability/darwin-path.ts +58 -0
  15. package/src/ingestion/source-availability/directory.ts +402 -0
  16. package/src/ingestion/source-availability/index.ts +74 -0
  17. package/src/ingestion/source-availability/readers.ts +360 -0
  18. package/src/ingestion/source-availability/resolve.ts +28 -0
  19. package/src/ingestion/source-availability/types.ts +170 -0
  20. package/src/ingestion/sync.ts +565 -108
  21. package/src/ingestion/types.ts +45 -3
  22. package/src/ingestion/walker.ts +263 -5
  23. package/src/serve/public/globals.built.css +1 -1
  24. package/src/serve/watch-reconciliation-fallback-disk.ts +359 -0
  25. package/src/serve/watch-reconciliation-fallback.ts +434 -0
  26. package/src/serve/watch-reconciliation-shared.ts +348 -0
  27. package/src/serve/watch-reconciliation.ts +129 -0
  28. package/src/serve/watch-service-events.ts +261 -0
  29. package/src/serve/watch-service-flush-generation.ts +140 -0
  30. package/src/serve/watch-service-flush-helpers.ts +147 -0
  31. package/src/serve/watch-service-flush.ts +443 -0
  32. package/src/serve/watch-service-hosts.ts +109 -0
  33. package/src/serve/watch-service-lifecycle.ts +221 -0
  34. package/src/serve/watch-service-run-flush.ts +236 -0
  35. package/src/serve/watch-service-snapshot.ts +125 -0
  36. package/src/serve/watch-service-state.ts +146 -0
  37. package/src/serve/watch-service.ts +266 -306
  38. package/src/serve/watch-snapshot-availability.ts +51 -0
  39. package/src/serve/watch-snapshot-handles.ts +365 -0
  40. package/src/serve/watch-snapshot-libc.ts +510 -0
  41. package/src/serve/watch-snapshot-ops.ts +541 -0
  42. package/src/serve/watch-snapshot-resolve.ts +246 -0
  43. package/src/serve/watch-snapshot-scan.ts +300 -0
  44. package/src/serve/watch-snapshot-types.ts +392 -0
  45. package/src/serve/watch-snapshot.ts +51 -0
  46. package/src/store/index.ts +1 -1
  47. package/src/store/sqlite/adapter.ts +191 -0
  48. package/src/store/types.ts +66 -0
  49. package/browser-extension/artifacts/gno-browser-clipper-v1.34.5.zip.sha256 +0 -1
@@ -0,0 +1,392 @@
1
+ /**
2
+ * Watcher snapshot types, fingerprints, and pure path helpers.
3
+ *
4
+ * @module src/serve/watch-snapshot-types
5
+ */
6
+
7
+ // node:path — Bun has no path utilities
8
+ import { isAbsolute } from "node:path";
9
+
10
+ import type { DirectoryAvailabilityPort } from "../ingestion/source-availability";
11
+
12
+ /** Fixed service-wide maximum entries retained in one collection snapshot. */
13
+ export const WATCHER_SNAPSHOT_ENTRY_CEILING = 100_000;
14
+
15
+ /**
16
+ * Result of a bounded directory enumeration.
17
+ * Overflow means more than `maxNames` child names were observed; callers must
18
+ * not treat a partial name list as a successful directory image.
19
+ */
20
+ export type WatcherReadDirResult =
21
+ | { status: "ok"; names: string[] }
22
+ | { status: "overflow" };
23
+
24
+ export type SnapshotEntryKind = "file" | "directory" | "symlink" | "other";
25
+
26
+ /**
27
+ * No-follow entry fingerprint used only for candidate discovery.
28
+ * Equality of fingerprints means "not a discovery candidate", never
29
+ * "content is unchanged".
30
+ */
31
+ export interface SnapshotEntryFingerprint {
32
+ kind: SnapshotEntryKind;
33
+ device: bigint;
34
+ inode: bigint;
35
+ size: number;
36
+ mtimeNs: bigint;
37
+ ctimeNs: bigint;
38
+ }
39
+
40
+ /** Hierarchical snapshot indexed by directory (POSIX collection-relative). */
41
+ export interface WatcherSnapshot {
42
+ /** dirRelPath → (entry name → fingerprint). `""` is the collection root. */
43
+ readonly directories: ReadonlyMap<
44
+ string,
45
+ ReadonlyMap<string, SnapshotEntryFingerprint>
46
+ >;
47
+ /** Total no-follow entries across every directory map. */
48
+ readonly entryCount: number;
49
+ /**
50
+ * Directory roots observed in a parent listing but not enumerated because
51
+ * source availability was unproven. Their stored descendant inventory is
52
+ * incomplete, so a later proven removal requires full reconciliation.
53
+ */
54
+ readonly unprovenSubtrees?: ReadonlySet<string>;
55
+ }
56
+
57
+ /** Injectable lstat view. `unreliable` forces the correctness-preserving fallback. */
58
+ export interface WatcherSnapshotStat {
59
+ isFile(): boolean;
60
+ isDirectory(): boolean;
61
+ isSymbolicLink(): boolean;
62
+ dev: number | bigint;
63
+ ino: number | bigint;
64
+ size: number | bigint;
65
+ mtimeNs?: bigint | number;
66
+ ctimeNs?: bigint | number;
67
+ mtimeMs?: number;
68
+ ctimeMs?: number;
69
+ /** When true, metadata cannot be trusted for discovery. */
70
+ unreliable?: boolean;
71
+ }
72
+
73
+ /**
74
+ * Opaque directory handle for anchored (openat-style) enumeration.
75
+ * Production pins a real directory inode; tests may use path-backed handles.
76
+ */
77
+ export type WatcherDirHandle = {
78
+ readonly __watcherDirHandle: unique symbol;
79
+ };
80
+
81
+ /**
82
+ * Injectable filesystem surface for watcher snapshots.
83
+ *
84
+ * Production must set `supportsAnchoredHandles` and implement handle ops so
85
+ * child metadata is resolved relative to a stable directory fd (no path
86
+ * re-walk after the parent is opened). When handles are unavailable, scans
87
+ * return `scan_failed` / `unreliable_metadata` rather than claiming
88
+ * strict no-follow safety.
89
+ */
90
+ export interface WatcherSnapshotFs {
91
+ /**
92
+ * True only when openDir/readDir/lstatChild/openChildDir/closeDir form a
93
+ * safe anchored scan path for this runtime/platform.
94
+ */
95
+ readonly supportsAnchoredHandles: boolean;
96
+
97
+ /** Open an absolute path as a real directory without following a final symlink. */
98
+ openDir(absPath: string): Promise<WatcherDirHandle>;
99
+
100
+ /**
101
+ * Enumerate direct child names via the open directory handle, capped at
102
+ * `maxNames`. Implementations must stop after observing `maxNames + 1`
103
+ * names (overflow) without storing or returning more, and must not claim
104
+ * success with a truncated list.
105
+ */
106
+ readDir(
107
+ handle: WatcherDirHandle,
108
+ maxNames: number
109
+ ): Promise<WatcherReadDirResult>;
110
+
111
+ /**
112
+ * No-follow lstat of a direct child relative to an open directory handle.
113
+ * Must not re-resolve intermediate path components outside the handle.
114
+ */
115
+ lstatChild(
116
+ handle: WatcherDirHandle,
117
+ name: string
118
+ ): Promise<WatcherSnapshotStat>;
119
+
120
+ /**
121
+ * Open a direct child as a real directory relative to the parent handle
122
+ * (no-follow). Rejects symlink children.
123
+ */
124
+ openChildDir(
125
+ handle: WatcherDirHandle,
126
+ name: string
127
+ ): Promise<WatcherDirHandle>;
128
+
129
+ /** Deterministic close; safe to call once per open. */
130
+ closeDir(handle: WatcherDirHandle): Promise<void>;
131
+
132
+ /**
133
+ * Optional production-grade synchronous scan used only while a process-wide
134
+ * no-materialization policy is active. Missing support fails local mode closed.
135
+ */
136
+ readDirectChildrenSync?: (
137
+ rootAbs: string,
138
+ dirRel: string,
139
+ maxEntries: number
140
+ ) =>
141
+ | { status: "present"; entries: Map<string, SnapshotEntryFingerprint> }
142
+ | { status: "missing" }
143
+ | ScanFailure;
144
+
145
+ /** Synchronous anchored child lstat for guarded local-mode presence checks. */
146
+ lstatChildByRelSync?: (
147
+ rootAbs: string,
148
+ parentRel: string,
149
+ name: string
150
+ ) => WatcherSnapshotStat;
151
+ }
152
+
153
+ export interface WatcherSnapshotClock {
154
+ nowMs(): number;
155
+ }
156
+
157
+ /** Test/prod hooks for map-visit complexity instrumentation. */
158
+ export interface SnapshotMapHooks {
159
+ /** Invoked once per directory-map visit during hierarchical subtree walks. */
160
+ onDirectoryMapVisit?: () => void;
161
+ }
162
+
163
+ export type SnapshotFallbackReason =
164
+ | "overflow"
165
+ | "scan_failed"
166
+ | "unreliable_metadata"
167
+ | "unproven_subtree";
168
+
169
+ export type WatcherSnapshotBuildResult =
170
+ | {
171
+ status: "ok";
172
+ snapshot: WatcherSnapshot;
173
+ durationMs: number;
174
+ }
175
+ | {
176
+ status: "fallback";
177
+ reason: SnapshotFallbackReason;
178
+ durationMs: number;
179
+ cause?: unknown;
180
+ };
181
+
182
+ /** True when a snapshot entry kind can be an indexed document source. */
183
+ export function isWatcherSourceKind(
184
+ kind: SnapshotEntryKind
185
+ ): kind is "file" | "symlink" {
186
+ return kind === "file" || kind === "symlink";
187
+ }
188
+
189
+ export type WatcherSnapshotDiffResult =
190
+ | {
191
+ status: "ok";
192
+ /**
193
+ * Present/changed file and symlink paths that still exist and need
194
+ * content-hash consideration (added, edited, or new under a directory).
195
+ * Never includes `other` (FIFO/socket/device) or directories.
196
+ * Does not include proven removals.
197
+ */
198
+ candidates: string[];
199
+ /**
200
+ * Proven removable source paths: deleted files/symlinks, prior files
201
+ * replaced by a directory or other special entry, and expanded nested
202
+ * files under removed or directory→non-directory transitions.
203
+ * Never includes `other` entries (they were never indexed as sources).
204
+ */
205
+ removals: string[];
206
+ nextSnapshot: WatcherSnapshot;
207
+ discoveryMs: number;
208
+ }
209
+ | {
210
+ status: "fallback";
211
+ reason: SnapshotFallbackReason;
212
+ discoveryMs: number;
213
+ cause?: unknown;
214
+ };
215
+
216
+ export interface WatcherSnapshotOptions {
217
+ fs?: WatcherSnapshotFs;
218
+ clock?: WatcherSnapshotClock;
219
+ /** Override the service-wide ceiling (tests only). */
220
+ entryCeiling?: number;
221
+ /** Map-visit instrumentation (tests / complexity regression). */
222
+ mapHooks?: SnapshotMapHooks;
223
+ /**
224
+ * Optional directory-availability classifier for local-mode collections.
225
+ * When set, dataless / availability-unknown directories are not descended;
226
+ * previously observed subtrees are preserved rather than proven removed.
227
+ */
228
+ directoryAvailability?: DirectoryAvailabilityPort;
229
+ }
230
+
231
+ export type ScanFailure =
232
+ | { status: "overflow" }
233
+ | { status: "scan_failed"; cause: unknown }
234
+ | { status: "unreliable_metadata" };
235
+
236
+ export type DiffWorkResult =
237
+ | { status: "ok" }
238
+ | ScanFailure
239
+ | { status: "unproven_subtree" };
240
+
241
+ export function toBigInt(value: number | bigint | undefined): bigint | null {
242
+ if (value === undefined) {
243
+ return null;
244
+ }
245
+ if (typeof value === "bigint") {
246
+ return value;
247
+ }
248
+ if (!Number.isFinite(value)) {
249
+ return null;
250
+ }
251
+ return BigInt(Math.trunc(value));
252
+ }
253
+
254
+ /**
255
+ * Require actual finite nanosecond timestamps. Millisecond fields alone are
256
+ * insufficient for the fast path (no ms→ns fabrication).
257
+ */
258
+ export function nsFromStat(ns: bigint | number | undefined): bigint | null {
259
+ return toBigInt(ns);
260
+ }
261
+
262
+ export function kindOf(stat: WatcherSnapshotStat): SnapshotEntryKind {
263
+ if (stat.isSymbolicLink()) {
264
+ return "symlink";
265
+ }
266
+ if (stat.isDirectory()) {
267
+ return "directory";
268
+ }
269
+ if (stat.isFile()) {
270
+ return "file";
271
+ }
272
+ return "other";
273
+ }
274
+
275
+ export function fingerprintFromStat(
276
+ stat: WatcherSnapshotStat
277
+ ):
278
+ | { ok: true; fingerprint: SnapshotEntryFingerprint }
279
+ | { ok: false; reason: "unreliable_metadata" } {
280
+ if (stat.unreliable) {
281
+ return { ok: false, reason: "unreliable_metadata" };
282
+ }
283
+ const device = toBigInt(stat.dev);
284
+ const inode = toBigInt(stat.ino);
285
+ const sizeValue = toBigInt(stat.size);
286
+ const mtimeNs = nsFromStat(stat.mtimeNs);
287
+ const ctimeNs = nsFromStat(stat.ctimeNs);
288
+ if (
289
+ device === null ||
290
+ inode === null ||
291
+ sizeValue === null ||
292
+ mtimeNs === null ||
293
+ ctimeNs === null
294
+ ) {
295
+ return { ok: false, reason: "unreliable_metadata" };
296
+ }
297
+ // size may exceed Number.MAX_SAFE_INTEGER on huge files; clamp via Number is
298
+ // fine for discovery equality against the same platform read.
299
+ const size =
300
+ sizeValue > BigInt(Number.MAX_SAFE_INTEGER)
301
+ ? Number.MAX_SAFE_INTEGER
302
+ : Number(sizeValue);
303
+ return {
304
+ ok: true,
305
+ fingerprint: {
306
+ kind: kindOf(stat),
307
+ device,
308
+ inode,
309
+ size,
310
+ mtimeNs,
311
+ ctimeNs,
312
+ },
313
+ };
314
+ }
315
+
316
+ export function fingerprintsEqual(
317
+ left: SnapshotEntryFingerprint,
318
+ right: SnapshotEntryFingerprint
319
+ ): boolean {
320
+ return (
321
+ left.kind === right.kind &&
322
+ left.device === right.device &&
323
+ left.inode === right.inode &&
324
+ left.size === right.size &&
325
+ left.mtimeNs === right.mtimeNs &&
326
+ left.ctimeNs === right.ctimeNs
327
+ );
328
+ }
329
+
330
+ /**
331
+ * Normalize an untrusted watcher hint to a POSIX collection-relative path.
332
+ * Returns null for absolute, escaping, empty-invalid, or NUL-bearing values.
333
+ */
334
+ export function normalizeWatcherRelPath(relPath: string): string | null {
335
+ if (relPath.includes("\0")) {
336
+ return null;
337
+ }
338
+ const normalized = relPath.replaceAll("\\", "/");
339
+ if (normalized.startsWith("/") || isAbsolute(relPath)) {
340
+ return null;
341
+ }
342
+ // Windows drive-shaped absolute escapes (`C:/...`) after slash normalization.
343
+ if (/^[A-Za-z]:(\/|$)/.test(normalized)) {
344
+ return null;
345
+ }
346
+ const segments: string[] = [];
347
+ for (const segment of normalized.split("/")) {
348
+ if (segment === "" || segment === ".") {
349
+ continue;
350
+ }
351
+ if (segment === "..") {
352
+ return null;
353
+ }
354
+ segments.push(segment);
355
+ }
356
+ return segments.join("/");
357
+ }
358
+
359
+ /** Parent directory of a POSIX relative path; `null` when path is the root. */
360
+ export function parentWatcherDir(relPath: string): string | null {
361
+ if (relPath === "") {
362
+ return null;
363
+ }
364
+ const index = relPath.lastIndexOf("/");
365
+ return index === -1 ? "" : relPath.slice(0, index);
366
+ }
367
+
368
+ export function joinWatcherRelPath(dir: string, name: string): string {
369
+ return dir === "" ? name : `${dir}/${name}`;
370
+ }
371
+
372
+ export function createEmptyWatcherSnapshot(): WatcherSnapshot {
373
+ return {
374
+ directories: new Map(),
375
+ entryCount: 0,
376
+ unprovenSubtrees: new Set(),
377
+ };
378
+ }
379
+
380
+ export function isMissingFsError(error: unknown): boolean {
381
+ const code =
382
+ error && typeof error === "object" && "code" in error
383
+ ? String(error.code)
384
+ : "";
385
+ return code === "ENOENT" || code === "ENOTDIR";
386
+ }
387
+
388
+ export function sortPathList(paths: Iterable<string>): string[] {
389
+ return [...paths].sort((left, right) =>
390
+ left < right ? -1 : left > right ? 1 : 0
391
+ );
392
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Watcher-owned hierarchical no-follow filesystem snapshot.
3
+ *
4
+ * Fingerprints identify ambiguous candidates only. They never prove that
5
+ * indexed content is unchanged; exact eligible paths still content-hash.
6
+ *
7
+ * Public contract only — implementation is split across focused modules.
8
+ * This facade re-exports the concrete public surface (not an unrelated barrel).
9
+ *
10
+ * @module src/serve/watch-snapshot
11
+ */
12
+
13
+ export {
14
+ WATCHER_SNAPSHOT_ENTRY_CEILING,
15
+ createEmptyWatcherSnapshot,
16
+ fingerprintsEqual,
17
+ joinWatcherRelPath,
18
+ normalizeWatcherRelPath,
19
+ parentWatcherDir,
20
+ } from "./watch-snapshot-types";
21
+
22
+ export type {
23
+ SnapshotEntryFingerprint,
24
+ SnapshotEntryKind,
25
+ SnapshotFallbackReason,
26
+ SnapshotMapHooks,
27
+ WatcherDirHandle,
28
+ WatcherReadDirResult,
29
+ WatcherSnapshot,
30
+ WatcherSnapshotBuildResult,
31
+ WatcherSnapshotClock,
32
+ WatcherSnapshotDiffResult,
33
+ WatcherSnapshotFs,
34
+ WatcherSnapshotOptions,
35
+ WatcherSnapshotStat,
36
+ } from "./watch-snapshot-types";
37
+
38
+ export {
39
+ buildWatcherSnapshot,
40
+ diffWatcherSnapshot,
41
+ } from "./watch-snapshot-ops";
42
+
43
+ export {
44
+ reconcileWatcherHints,
45
+ resolveWatcherDirtyDirectory,
46
+ } from "./watch-snapshot-resolve";
47
+
48
+ export {
49
+ createPathBackedWatcherFs,
50
+ removeSubtreeFromMaps,
51
+ } from "./watch-snapshot-scan";
@@ -98,4 +98,4 @@ export type {
98
98
  StorePort,
99
99
  StoreResult,
100
100
  } from "./types";
101
- export { err, ok } from "./types";
101
+ export { err, ok, WATCHER_ACTIVE_SOURCE_PATH_MAX } from "./types";
@@ -211,6 +211,51 @@ const SINGLE_LINE_QUERY_PATTERN = /[\r\n]/;
211
211
  const DOUBLE_QUOTE_PATTERN = /"/g;
212
212
  const DOC_EDGE_TYPE_PATTERN = /^[a-z][a-z0-9_]*$/;
213
213
  const SQLITE_SAFE_PARAMETER_BATCH_SIZE = 900;
214
+
215
+ /**
216
+ * Effective physical source path for watcher fallback queries.
217
+ * Record-container logical rows resolve to their source container path.
218
+ */
219
+ const WATCHER_SOURCE_PATH_SQL =
220
+ "COALESCE(NULLIF(record_source_path, ''), rel_path)";
221
+
222
+ /**
223
+ * Parent directory of the effective source path (POSIX), with the collection
224
+ * root represented as the empty string.
225
+ */
226
+ const WATCHER_SOURCE_PARENT_SQL = `CASE WHEN instr(${WATCHER_SOURCE_PATH_SQL}, '/') = 0 THEN '' ELSE substr(${WATCHER_SOURCE_PATH_SQL}, 1, length(rtrim(${WATCHER_SOURCE_PATH_SQL}, replace(${WATCHER_SOURCE_PATH_SQL}, '/', ''))) - 1) END`;
227
+
228
+ /**
229
+ * Normalize a collection-relative directory argument for watcher source-path
230
+ * queries. Returns null for absolute, drive-shaped, or escaping paths.
231
+ */
232
+ function normalizeWatcherSourceDirRelPath(dirRelPath: string): string | null {
233
+ const normalized = dirRelPath.replaceAll("\\", "/");
234
+ if (normalized.startsWith("/")) {
235
+ return null;
236
+ }
237
+ const segments: string[] = [];
238
+ for (const segment of normalized.split("/")) {
239
+ if (segment === "" || segment === ".") {
240
+ continue;
241
+ }
242
+ if (segment === "..") {
243
+ return null;
244
+ }
245
+ segments.push(segment);
246
+ }
247
+ const canonical = segments.join("/");
248
+ // `C:` / `C:/foo` after stripping `.` is a Windows absolute escape.
249
+ // A single segment like `a:notes` (no slash after the colon) stays legal.
250
+ if (
251
+ /^[A-Za-z]:(\/|$)/.test(canonical) &&
252
+ (canonical.length === 2 || canonical[2] === "/")
253
+ ) {
254
+ return null;
255
+ }
256
+ return canonical;
257
+ }
258
+
214
259
  const FTS5_FIELD_WEIGHTS = {
215
260
  filepath: 1.5,
216
261
  title: 4.0,
@@ -1664,6 +1709,152 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
1664
1709
  }
1665
1710
  }
1666
1711
 
1712
+ async listActiveDirectChildSourcePaths(
1713
+ collection: string,
1714
+ dirRelPath: string,
1715
+ max: number
1716
+ ): Promise<StoreResult<string[]>> {
1717
+ if (!Number.isInteger(max) || max <= 0) {
1718
+ return err("INVALID_INPUT", "max must be a positive integer");
1719
+ }
1720
+ const parentPath = normalizeWatcherSourceDirRelPath(dirRelPath);
1721
+ if (parentPath === null) {
1722
+ return err(
1723
+ "INVALID_INPUT",
1724
+ `Directory path escapes the collection root: ${dirRelPath}`
1725
+ );
1726
+ }
1727
+
1728
+ try {
1729
+ const db = this.ensureOpen();
1730
+ // Effective source path: record containers resolve to physical container.
1731
+ // Parent key: empty string for root-level sources.
1732
+ // LIMIT max+1 detects overflow without returning a truncated success.
1733
+ const rows = db
1734
+ .query<{ source_path: string }, [string, string, number]>(
1735
+ `SELECT DISTINCT ${WATCHER_SOURCE_PATH_SQL} AS source_path
1736
+ FROM documents
1737
+ WHERE collection = ?
1738
+ AND active = 1
1739
+ AND ${WATCHER_SOURCE_PARENT_SQL} = ?
1740
+ ORDER BY source_path ASC
1741
+ LIMIT ?`
1742
+ )
1743
+ .all(collection, parentPath, max + 1);
1744
+
1745
+ if (rows.length > max) {
1746
+ return err(
1747
+ "OVERFLOW",
1748
+ `Active direct-child source paths exceed max=${max}`
1749
+ );
1750
+ }
1751
+ return ok(rows.map((row) => row.source_path));
1752
+ } catch (cause) {
1753
+ return err(
1754
+ "QUERY_FAILED",
1755
+ cause instanceof Error
1756
+ ? cause.message
1757
+ : "Failed to list active direct child source paths",
1758
+ cause
1759
+ );
1760
+ }
1761
+ }
1762
+
1763
+ async listActiveDescendantSourcePaths(
1764
+ collection: string,
1765
+ dirRelPath: string,
1766
+ max: number
1767
+ ): Promise<StoreResult<string[]>> {
1768
+ if (!Number.isInteger(max) || max <= 0) {
1769
+ return err("INVALID_INPUT", "max must be a positive integer");
1770
+ }
1771
+ const directory = normalizeWatcherSourceDirRelPath(dirRelPath);
1772
+ if (directory === null) {
1773
+ return err(
1774
+ "INVALID_INPUT",
1775
+ `Directory path escapes the collection root: ${dirRelPath}`
1776
+ );
1777
+ }
1778
+ if (directory === "") {
1779
+ return err(
1780
+ "INVALID_INPUT",
1781
+ "Descendant lookup requires a directory below the collection root"
1782
+ );
1783
+ }
1784
+
1785
+ try {
1786
+ const db = this.ensureOpen();
1787
+ // Exact prefix boundary: `dir1/` never matches `dir10/...`.
1788
+ // length(?) is code-point-safe (JS prefix.length is UTF-16 and breaks non-BMP).
1789
+ // LIMIT max+1 detects overflow without returning a truncated success.
1790
+ const prefix = `${directory}/`;
1791
+ const rows = db
1792
+ .query<{ source_path: string }, [string, string, string, number]>(
1793
+ `SELECT DISTINCT ${WATCHER_SOURCE_PATH_SQL} AS source_path
1794
+ FROM documents
1795
+ WHERE collection = ?
1796
+ AND active = 1
1797
+ AND substr(${WATCHER_SOURCE_PATH_SQL}, 1, length(?)) = ?
1798
+ ORDER BY source_path ASC
1799
+ LIMIT ?`
1800
+ )
1801
+ .all(collection, prefix, prefix, max + 1);
1802
+
1803
+ if (rows.length > max) {
1804
+ return err(
1805
+ "OVERFLOW",
1806
+ `Active descendant source paths exceed max=${max}`
1807
+ );
1808
+ }
1809
+ return ok(rows.map((row) => row.source_path));
1810
+ } catch (cause) {
1811
+ return err(
1812
+ "QUERY_FAILED",
1813
+ cause instanceof Error
1814
+ ? cause.message
1815
+ : "Failed to list active descendant source paths",
1816
+ cause
1817
+ );
1818
+ }
1819
+ }
1820
+
1821
+ async listActiveSourcePaths(
1822
+ collection: string,
1823
+ max: number
1824
+ ): Promise<StoreResult<string[]>> {
1825
+ if (!Number.isInteger(max) || max <= 0) {
1826
+ return err("INVALID_INPUT", "max must be a positive integer");
1827
+ }
1828
+
1829
+ try {
1830
+ const db = this.ensureOpen();
1831
+ // Root-wide DISTINCT physical sources; overflow after collapse (max+1).
1832
+ const rows = db
1833
+ .query<{ source_path: string }, [string, number]>(
1834
+ `SELECT DISTINCT ${WATCHER_SOURCE_PATH_SQL} AS source_path
1835
+ FROM documents
1836
+ WHERE collection = ?
1837
+ AND active = 1
1838
+ ORDER BY source_path ASC
1839
+ LIMIT ?`
1840
+ )
1841
+ .all(collection, max + 1);
1842
+
1843
+ if (rows.length > max) {
1844
+ return err("OVERFLOW", `Active source paths exceed max=${max}`);
1845
+ }
1846
+ return ok(rows.map((row) => row.source_path));
1847
+ } catch (cause) {
1848
+ return err(
1849
+ "QUERY_FAILED",
1850
+ cause instanceof Error
1851
+ ? cause.message
1852
+ : "Failed to list active source paths",
1853
+ cause
1854
+ );
1855
+ }
1856
+ }
1857
+
1667
1858
  async listActiveDocumentsForBrowse(
1668
1859
  collection?: string
1669
1860
  ): Promise<StoreResult<DocumentRow[]>> {