@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.
- package/README.md +22 -1
- package/browser-extension/artifacts/{gno-browser-clipper-v1.34.5.zip → gno-browser-clipper-v1.35.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.35.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +4 -1
- package/spec/cli.md +43 -0
- package/spec/output-schemas/mcp-job-status.schema.json +6 -2
- package/src/config/index.ts +4 -0
- package/src/config/types.ts +14 -0
- package/src/core/path-rules.ts +34 -0
- package/src/ingestion/index.ts +21 -0
- package/src/ingestion/record-container.ts +23 -1
- package/src/ingestion/source-availability/darwin-io.ts +295 -0
- package/src/ingestion/source-availability/darwin-path.ts +58 -0
- package/src/ingestion/source-availability/directory.ts +402 -0
- package/src/ingestion/source-availability/index.ts +74 -0
- package/src/ingestion/source-availability/readers.ts +360 -0
- package/src/ingestion/source-availability/resolve.ts +28 -0
- package/src/ingestion/source-availability/types.ts +170 -0
- package/src/ingestion/sync.ts +565 -108
- package/src/ingestion/types.ts +45 -3
- package/src/ingestion/walker.ts +263 -5
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/watch-reconciliation-fallback-disk.ts +359 -0
- package/src/serve/watch-reconciliation-fallback.ts +434 -0
- package/src/serve/watch-reconciliation-shared.ts +348 -0
- package/src/serve/watch-reconciliation.ts +129 -0
- package/src/serve/watch-service-events.ts +261 -0
- package/src/serve/watch-service-flush-generation.ts +140 -0
- package/src/serve/watch-service-flush-helpers.ts +147 -0
- package/src/serve/watch-service-flush.ts +443 -0
- package/src/serve/watch-service-hosts.ts +109 -0
- package/src/serve/watch-service-lifecycle.ts +221 -0
- package/src/serve/watch-service-run-flush.ts +236 -0
- package/src/serve/watch-service-snapshot.ts +125 -0
- package/src/serve/watch-service-state.ts +146 -0
- package/src/serve/watch-service.ts +266 -306
- package/src/serve/watch-snapshot-availability.ts +51 -0
- package/src/serve/watch-snapshot-handles.ts +365 -0
- package/src/serve/watch-snapshot-libc.ts +510 -0
- package/src/serve/watch-snapshot-ops.ts +541 -0
- package/src/serve/watch-snapshot-resolve.ts +246 -0
- package/src/serve/watch-snapshot-scan.ts +300 -0
- package/src/serve/watch-snapshot-types.ts +392 -0
- package/src/serve/watch-snapshot.ts +51 -0
- package/src/store/index.ts +1 -1
- package/src/store/sqlite/adapter.ts +191 -0
- package/src/store/types.ts +66 -0
- 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";
|
package/src/store/index.ts
CHANGED
|
@@ -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[]>> {
|