@gmickel/gno 1.34.4 → 1.34.6
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 +11 -1
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v1.34.4.zip → gno-browser-clipper-v1.34.6.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.34.6.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +5 -1
- package/spec/cli.md +16 -10
- package/src/cli/pager.ts +29 -14
- package/src/ingestion/sync.ts +368 -84
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/watch-reconciliation-fallback-disk.ts +220 -0
- package/src/serve/watch-reconciliation-fallback.ts +404 -0
- package/src/serve/watch-reconciliation-shared.ts +343 -0
- package/src/serve/watch-reconciliation.ts +122 -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 +433 -0
- package/src/serve/watch-service-hosts.ts +109 -0
- package/src/serve/watch-service-lifecycle.ts +219 -0
- package/src/serve/watch-service-run-flush.ts +236 -0
- package/src/serve/watch-service-snapshot.ts +101 -0
- package/src/serve/watch-service-state.ts +146 -0
- package/src/serve/watch-service.ts +265 -306
- package/src/serve/watch-snapshot-handles.ts +285 -0
- package/src/serve/watch-snapshot-libc.ts +391 -0
- package/src/serve/watch-snapshot-ops.ts +399 -0
- package/src/serve/watch-snapshot-resolve.ts +246 -0
- package/src/serve/watch-snapshot-scan.ts +297 -0
- package/src/serve/watch-snapshot-types.ts +350 -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/vendor/fts5-snowball/README.md +5 -1
- package/vendor/fts5-snowball/darwin-x64/fts5stemmer.dylib +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v1.34.4.zip.sha256 +0 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity-aware no-follow directory scanning and mutable snapshot maps.
|
|
3
|
+
*
|
|
4
|
+
* @module src/serve/watch-snapshot-scan
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
ScanFailure,
|
|
9
|
+
SnapshotEntryFingerprint,
|
|
10
|
+
SnapshotMapHooks,
|
|
11
|
+
WatcherDirHandle,
|
|
12
|
+
WatcherSnapshot,
|
|
13
|
+
WatcherSnapshotClock,
|
|
14
|
+
WatcherSnapshotFs,
|
|
15
|
+
WatcherSnapshotStat,
|
|
16
|
+
} from "./watch-snapshot-types";
|
|
17
|
+
|
|
18
|
+
import { createDefaultWatcherFs } from "./watch-snapshot-handles";
|
|
19
|
+
import {
|
|
20
|
+
fingerprintFromStat,
|
|
21
|
+
isMissingFsError,
|
|
22
|
+
isWatcherSourceKind,
|
|
23
|
+
joinWatcherRelPath,
|
|
24
|
+
} from "./watch-snapshot-types";
|
|
25
|
+
|
|
26
|
+
export { createPathBackedWatcherFs } from "./watch-snapshot-handles";
|
|
27
|
+
|
|
28
|
+
export const defaultClock: WatcherSnapshotClock = {
|
|
29
|
+
nowMs: () => performance.now(),
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const defaultFs: WatcherSnapshotFs = createDefaultWatcherFs();
|
|
33
|
+
|
|
34
|
+
/** Mutable hierarchical maps with incremental entry accounting. */
|
|
35
|
+
export interface MutableSnapshotMaps {
|
|
36
|
+
directories: Map<string, Map<string, SnapshotEntryFingerprint>>;
|
|
37
|
+
entryCount: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function cloneDirectoryMaps(
|
|
41
|
+
source: WatcherSnapshot
|
|
42
|
+
): MutableSnapshotMaps {
|
|
43
|
+
const directories = new Map<string, Map<string, SnapshotEntryFingerprint>>();
|
|
44
|
+
for (const [dir, entries] of source.directories) {
|
|
45
|
+
directories.set(dir, new Map(entries));
|
|
46
|
+
}
|
|
47
|
+
return {
|
|
48
|
+
directories,
|
|
49
|
+
entryCount: source.entryCount,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function freezeSnapshot(state: MutableSnapshotMaps): WatcherSnapshot {
|
|
54
|
+
const frozen = new Map<
|
|
55
|
+
string,
|
|
56
|
+
ReadonlyMap<string, SnapshotEntryFingerprint>
|
|
57
|
+
>();
|
|
58
|
+
for (const [dir, entries] of state.directories) {
|
|
59
|
+
frozen.set(dir, entries);
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
directories: frozen,
|
|
63
|
+
entryCount: state.entryCount,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Replace one directory's entry map; O(1) entryCount update. */
|
|
68
|
+
export function setDirectoryEntries(
|
|
69
|
+
state: MutableSnapshotMaps,
|
|
70
|
+
dirRel: string,
|
|
71
|
+
entries: Map<string, SnapshotEntryFingerprint>
|
|
72
|
+
): void {
|
|
73
|
+
const previous = state.directories.get(dirRel);
|
|
74
|
+
const previousSize = previous?.size ?? 0;
|
|
75
|
+
state.directories.set(dirRel, entries);
|
|
76
|
+
state.entryCount += entries.size - previousSize;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Hierarchical O(subtree-size) removal using stored child relationships.
|
|
81
|
+
* Single pass: collect non-directory paths and delete maps — no full-map
|
|
82
|
+
* prefix scan and no separate collect-then-remove phases.
|
|
83
|
+
*/
|
|
84
|
+
export function removeSubtreeFromMaps(
|
|
85
|
+
state: MutableSnapshotMaps,
|
|
86
|
+
dirRel: string,
|
|
87
|
+
hooks?: SnapshotMapHooks
|
|
88
|
+
): string[] {
|
|
89
|
+
const removedCandidates: string[] = [];
|
|
90
|
+
const stack: string[] = [dirRel];
|
|
91
|
+
|
|
92
|
+
while (stack.length > 0) {
|
|
93
|
+
const dir = stack.pop() as string;
|
|
94
|
+
hooks?.onDirectoryMapVisit?.();
|
|
95
|
+
const entries = state.directories.get(dir);
|
|
96
|
+
if (!entries) {
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
for (const [name, fingerprint] of entries) {
|
|
100
|
+
const childRel = joinWatcherRelPath(dir, name);
|
|
101
|
+
if (fingerprint.kind === "directory") {
|
|
102
|
+
stack.push(childRel);
|
|
103
|
+
} else if (isWatcherSourceKind(fingerprint.kind)) {
|
|
104
|
+
// Only file/symlink sources are indexable; ignore FIFO/socket/device.
|
|
105
|
+
removedCandidates.push(childRel);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
state.entryCount -= entries.size;
|
|
109
|
+
state.directories.delete(dir);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return removedCandidates;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Hierarchical collect of file/symlink source paths under a stored directory.
|
|
117
|
+
* O(subtree-size) via child relationships — not a full-map prefix scan.
|
|
118
|
+
* Special `other` entries are never collected (never indexed as sources).
|
|
119
|
+
*/
|
|
120
|
+
export function collectSnapshotFilesUnder(
|
|
121
|
+
directories: ReadonlyMap<
|
|
122
|
+
string,
|
|
123
|
+
ReadonlyMap<string, SnapshotEntryFingerprint>
|
|
124
|
+
>,
|
|
125
|
+
dirRel: string,
|
|
126
|
+
hooks?: SnapshotMapHooks
|
|
127
|
+
): string[] {
|
|
128
|
+
const out: string[] = [];
|
|
129
|
+
const stack: string[] = [dirRel];
|
|
130
|
+
while (stack.length > 0) {
|
|
131
|
+
const dir = stack.pop() as string;
|
|
132
|
+
hooks?.onDirectoryMapVisit?.();
|
|
133
|
+
const entries = directories.get(dir);
|
|
134
|
+
if (!entries) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
for (const [name, fingerprint] of entries) {
|
|
138
|
+
const childRel = joinWatcherRelPath(dir, name);
|
|
139
|
+
if (fingerprint.kind === "directory") {
|
|
140
|
+
stack.push(childRel);
|
|
141
|
+
} else if (isWatcherSourceKind(fingerprint.kind)) {
|
|
142
|
+
out.push(childRel);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Open a collection-relative directory by walking components from the root
|
|
151
|
+
* handle. Never opens a full joined path that could traverse intermediate
|
|
152
|
+
* symlinks.
|
|
153
|
+
*/
|
|
154
|
+
export async function openDirByRel(
|
|
155
|
+
rootAbs: string,
|
|
156
|
+
dirRel: string,
|
|
157
|
+
fs: WatcherSnapshotFs
|
|
158
|
+
): Promise<
|
|
159
|
+
| { status: "ok"; handle: WatcherDirHandle }
|
|
160
|
+
| { status: "missing" }
|
|
161
|
+
| ScanFailure
|
|
162
|
+
> {
|
|
163
|
+
if (!fs.supportsAnchoredHandles) {
|
|
164
|
+
return {
|
|
165
|
+
status: "scan_failed",
|
|
166
|
+
cause: new Error(
|
|
167
|
+
"Anchored no-follow directory handles unavailable; refusing path-based scan"
|
|
168
|
+
),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let handle: WatcherDirHandle;
|
|
173
|
+
try {
|
|
174
|
+
handle = await fs.openDir(rootAbs);
|
|
175
|
+
} catch (cause) {
|
|
176
|
+
if (isMissingFsError(cause)) {
|
|
177
|
+
return { status: "missing" };
|
|
178
|
+
}
|
|
179
|
+
return { status: "scan_failed", cause };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (dirRel === "") {
|
|
183
|
+
return { status: "ok", handle };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const segments = dirRel.split("/");
|
|
187
|
+
for (const segment of segments) {
|
|
188
|
+
let child: WatcherDirHandle;
|
|
189
|
+
try {
|
|
190
|
+
// Reject non-directory / symlink components by requiring openChildDir.
|
|
191
|
+
child = await fs.openChildDir(handle, segment);
|
|
192
|
+
} catch (cause) {
|
|
193
|
+
await fs.closeDir(handle);
|
|
194
|
+
if (isMissingFsError(cause)) {
|
|
195
|
+
return { status: "missing" };
|
|
196
|
+
}
|
|
197
|
+
return { status: "scan_failed", cause };
|
|
198
|
+
}
|
|
199
|
+
await fs.closeDir(handle);
|
|
200
|
+
handle = child;
|
|
201
|
+
}
|
|
202
|
+
return { status: "ok", handle };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Enumerate direct children via an anchored directory handle.
|
|
207
|
+
* Child metadata is resolved relative to the handle — a path swap after open
|
|
208
|
+
* cannot redirect lstat outside the pinned directory.
|
|
209
|
+
*
|
|
210
|
+
* `maxEntries` is the remaining entry budget for this directory map. Enumeration
|
|
211
|
+
* and stats stop after observing `maxEntries + 1` children so overflow is proven
|
|
212
|
+
* without materializing an unbounded name/stat map.
|
|
213
|
+
*/
|
|
214
|
+
export async function readDirectChildren(
|
|
215
|
+
rootAbs: string,
|
|
216
|
+
dirRel: string,
|
|
217
|
+
fs: WatcherSnapshotFs,
|
|
218
|
+
maxEntries: number
|
|
219
|
+
): Promise<
|
|
220
|
+
| { status: "present"; entries: Map<string, SnapshotEntryFingerprint> }
|
|
221
|
+
| { status: "missing" }
|
|
222
|
+
| ScanFailure
|
|
223
|
+
> {
|
|
224
|
+
if (!fs.supportsAnchoredHandles) {
|
|
225
|
+
return {
|
|
226
|
+
status: "scan_failed",
|
|
227
|
+
cause: new Error(
|
|
228
|
+
"Anchored no-follow directory handles unavailable; refusing path-based scan"
|
|
229
|
+
),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
if (!Number.isInteger(maxEntries) || maxEntries < 0) {
|
|
233
|
+
return {
|
|
234
|
+
status: "scan_failed",
|
|
235
|
+
cause: new Error("maxEntries must be a non-negative integer"),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const opened = await openDirByRel(rootAbs, dirRel, fs);
|
|
240
|
+
if (opened.status !== "ok") {
|
|
241
|
+
return opened;
|
|
242
|
+
}
|
|
243
|
+
const { handle } = opened;
|
|
244
|
+
|
|
245
|
+
try {
|
|
246
|
+
let listed;
|
|
247
|
+
try {
|
|
248
|
+
// Cap names at remaining budget; maxEntries+1th name → overflow.
|
|
249
|
+
listed = await fs.readDir(handle, maxEntries);
|
|
250
|
+
} catch (cause) {
|
|
251
|
+
if (isMissingFsError(cause)) {
|
|
252
|
+
return { status: "missing" };
|
|
253
|
+
}
|
|
254
|
+
return { status: "scan_failed", cause };
|
|
255
|
+
}
|
|
256
|
+
if (listed.status === "overflow") {
|
|
257
|
+
return { status: "overflow" };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const names = listed.names;
|
|
261
|
+
const entries = new Map<string, SnapshotEntryFingerprint>();
|
|
262
|
+
// Stable order keeps overflow selection deterministic across platforms.
|
|
263
|
+
names.sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
|
264
|
+
for (const name of names) {
|
|
265
|
+
if (name === "" || name === "." || name === "..") {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
|
|
269
|
+
return {
|
|
270
|
+
status: "scan_failed",
|
|
271
|
+
cause: new Error(`Invalid directory entry name: ${name}`),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
// Defense in depth: never stat/map more than the remaining budget.
|
|
275
|
+
if (entries.size >= maxEntries) {
|
|
276
|
+
return { status: "overflow" };
|
|
277
|
+
}
|
|
278
|
+
let stat: WatcherSnapshotStat;
|
|
279
|
+
try {
|
|
280
|
+
stat = await fs.lstatChild(handle, name);
|
|
281
|
+
} catch (cause) {
|
|
282
|
+
// Observed-then-missing (ENOENT/ENOTDIR after readdir listed the name)
|
|
283
|
+
// must fail closed: silently skipping would accept a partial directory
|
|
284
|
+
// image and can prove false removals against the previous snapshot.
|
|
285
|
+
return { status: "scan_failed", cause };
|
|
286
|
+
}
|
|
287
|
+
const fingerprinted = fingerprintFromStat(stat);
|
|
288
|
+
if (!fingerprinted.ok) {
|
|
289
|
+
return { status: "unreliable_metadata" };
|
|
290
|
+
}
|
|
291
|
+
entries.set(name, fingerprinted.fingerprint);
|
|
292
|
+
}
|
|
293
|
+
return { status: "present", entries };
|
|
294
|
+
} finally {
|
|
295
|
+
await fs.closeDir(handle);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
@@ -0,0 +1,350 @@
|
|
|
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
|
+
/** Fixed service-wide maximum entries retained in one collection snapshot. */
|
|
11
|
+
export const WATCHER_SNAPSHOT_ENTRY_CEILING = 100_000;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Result of a bounded directory enumeration.
|
|
15
|
+
* Overflow means more than `maxNames` child names were observed; callers must
|
|
16
|
+
* not treat a partial name list as a successful directory image.
|
|
17
|
+
*/
|
|
18
|
+
export type WatcherReadDirResult =
|
|
19
|
+
| { status: "ok"; names: string[] }
|
|
20
|
+
| { status: "overflow" };
|
|
21
|
+
|
|
22
|
+
export type SnapshotEntryKind = "file" | "directory" | "symlink" | "other";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* No-follow entry fingerprint used only for candidate discovery.
|
|
26
|
+
* Equality of fingerprints means "not a discovery candidate", never
|
|
27
|
+
* "content is unchanged".
|
|
28
|
+
*/
|
|
29
|
+
export interface SnapshotEntryFingerprint {
|
|
30
|
+
kind: SnapshotEntryKind;
|
|
31
|
+
device: bigint;
|
|
32
|
+
inode: bigint;
|
|
33
|
+
size: number;
|
|
34
|
+
mtimeNs: bigint;
|
|
35
|
+
ctimeNs: bigint;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Hierarchical snapshot indexed by directory (POSIX collection-relative). */
|
|
39
|
+
export interface WatcherSnapshot {
|
|
40
|
+
/** dirRelPath → (entry name → fingerprint). `""` is the collection root. */
|
|
41
|
+
readonly directories: ReadonlyMap<
|
|
42
|
+
string,
|
|
43
|
+
ReadonlyMap<string, SnapshotEntryFingerprint>
|
|
44
|
+
>;
|
|
45
|
+
/** Total no-follow entries across every directory map. */
|
|
46
|
+
readonly entryCount: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Injectable lstat view. `unreliable` forces the correctness-preserving fallback. */
|
|
50
|
+
export interface WatcherSnapshotStat {
|
|
51
|
+
isFile(): boolean;
|
|
52
|
+
isDirectory(): boolean;
|
|
53
|
+
isSymbolicLink(): boolean;
|
|
54
|
+
dev: number | bigint;
|
|
55
|
+
ino: number | bigint;
|
|
56
|
+
size: number | bigint;
|
|
57
|
+
mtimeNs?: bigint | number;
|
|
58
|
+
ctimeNs?: bigint | number;
|
|
59
|
+
mtimeMs?: number;
|
|
60
|
+
ctimeMs?: number;
|
|
61
|
+
/** When true, metadata cannot be trusted for discovery. */
|
|
62
|
+
unreliable?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Opaque directory handle for anchored (openat-style) enumeration.
|
|
67
|
+
* Production pins a real directory inode; tests may use path-backed handles.
|
|
68
|
+
*/
|
|
69
|
+
export type WatcherDirHandle = {
|
|
70
|
+
readonly __watcherDirHandle: unique symbol;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Injectable filesystem surface for watcher snapshots.
|
|
75
|
+
*
|
|
76
|
+
* Production must set `supportsAnchoredHandles` and implement handle ops so
|
|
77
|
+
* child metadata is resolved relative to a stable directory fd (no path
|
|
78
|
+
* re-walk after the parent is opened). When handles are unavailable, scans
|
|
79
|
+
* return `scan_failed` / `unreliable_metadata` rather than claiming
|
|
80
|
+
* strict no-follow safety.
|
|
81
|
+
*/
|
|
82
|
+
export interface WatcherSnapshotFs {
|
|
83
|
+
/**
|
|
84
|
+
* True only when openDir/readDir/lstatChild/openChildDir/closeDir form a
|
|
85
|
+
* safe anchored scan path for this runtime/platform.
|
|
86
|
+
*/
|
|
87
|
+
readonly supportsAnchoredHandles: boolean;
|
|
88
|
+
|
|
89
|
+
/** Open an absolute path as a real directory without following a final symlink. */
|
|
90
|
+
openDir(absPath: string): Promise<WatcherDirHandle>;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Enumerate direct child names via the open directory handle, capped at
|
|
94
|
+
* `maxNames`. Implementations must stop after observing `maxNames + 1`
|
|
95
|
+
* names (overflow) without storing or returning more, and must not claim
|
|
96
|
+
* success with a truncated list.
|
|
97
|
+
*/
|
|
98
|
+
readDir(
|
|
99
|
+
handle: WatcherDirHandle,
|
|
100
|
+
maxNames: number
|
|
101
|
+
): Promise<WatcherReadDirResult>;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* No-follow lstat of a direct child relative to an open directory handle.
|
|
105
|
+
* Must not re-resolve intermediate path components outside the handle.
|
|
106
|
+
*/
|
|
107
|
+
lstatChild(
|
|
108
|
+
handle: WatcherDirHandle,
|
|
109
|
+
name: string
|
|
110
|
+
): Promise<WatcherSnapshotStat>;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Open a direct child as a real directory relative to the parent handle
|
|
114
|
+
* (no-follow). Rejects symlink children.
|
|
115
|
+
*/
|
|
116
|
+
openChildDir(
|
|
117
|
+
handle: WatcherDirHandle,
|
|
118
|
+
name: string
|
|
119
|
+
): Promise<WatcherDirHandle>;
|
|
120
|
+
|
|
121
|
+
/** Deterministic close; safe to call once per open. */
|
|
122
|
+
closeDir(handle: WatcherDirHandle): Promise<void>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface WatcherSnapshotClock {
|
|
126
|
+
nowMs(): number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Test/prod hooks for map-visit complexity instrumentation. */
|
|
130
|
+
export interface SnapshotMapHooks {
|
|
131
|
+
/** Invoked once per directory-map visit during hierarchical subtree walks. */
|
|
132
|
+
onDirectoryMapVisit?: () => void;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export type SnapshotFallbackReason =
|
|
136
|
+
| "overflow"
|
|
137
|
+
| "scan_failed"
|
|
138
|
+
| "unreliable_metadata";
|
|
139
|
+
|
|
140
|
+
export type WatcherSnapshotBuildResult =
|
|
141
|
+
| {
|
|
142
|
+
status: "ok";
|
|
143
|
+
snapshot: WatcherSnapshot;
|
|
144
|
+
durationMs: number;
|
|
145
|
+
}
|
|
146
|
+
| {
|
|
147
|
+
status: "fallback";
|
|
148
|
+
reason: SnapshotFallbackReason;
|
|
149
|
+
durationMs: number;
|
|
150
|
+
cause?: unknown;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/** True when a snapshot entry kind can be an indexed document source. */
|
|
154
|
+
export function isWatcherSourceKind(
|
|
155
|
+
kind: SnapshotEntryKind
|
|
156
|
+
): kind is "file" | "symlink" {
|
|
157
|
+
return kind === "file" || kind === "symlink";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export type WatcherSnapshotDiffResult =
|
|
161
|
+
| {
|
|
162
|
+
status: "ok";
|
|
163
|
+
/**
|
|
164
|
+
* Present/changed file and symlink paths that still exist and need
|
|
165
|
+
* content-hash consideration (added, edited, or new under a directory).
|
|
166
|
+
* Never includes `other` (FIFO/socket/device) or directories.
|
|
167
|
+
* Does not include proven removals.
|
|
168
|
+
*/
|
|
169
|
+
candidates: string[];
|
|
170
|
+
/**
|
|
171
|
+
* Proven removable source paths: deleted files/symlinks, prior files
|
|
172
|
+
* replaced by a directory or other special entry, and expanded nested
|
|
173
|
+
* files under removed or directory→non-directory transitions.
|
|
174
|
+
* Never includes `other` entries (they were never indexed as sources).
|
|
175
|
+
*/
|
|
176
|
+
removals: string[];
|
|
177
|
+
nextSnapshot: WatcherSnapshot;
|
|
178
|
+
discoveryMs: number;
|
|
179
|
+
}
|
|
180
|
+
| {
|
|
181
|
+
status: "fallback";
|
|
182
|
+
reason: SnapshotFallbackReason;
|
|
183
|
+
discoveryMs: number;
|
|
184
|
+
cause?: unknown;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export interface WatcherSnapshotOptions {
|
|
188
|
+
fs?: WatcherSnapshotFs;
|
|
189
|
+
clock?: WatcherSnapshotClock;
|
|
190
|
+
/** Override the service-wide ceiling (tests only). */
|
|
191
|
+
entryCeiling?: number;
|
|
192
|
+
/** Map-visit instrumentation (tests / complexity regression). */
|
|
193
|
+
mapHooks?: SnapshotMapHooks;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export type ScanFailure =
|
|
197
|
+
| { status: "overflow" }
|
|
198
|
+
| { status: "scan_failed"; cause: unknown }
|
|
199
|
+
| { status: "unreliable_metadata" };
|
|
200
|
+
|
|
201
|
+
export type DiffWorkResult = { status: "ok" } | ScanFailure;
|
|
202
|
+
|
|
203
|
+
export function toBigInt(value: number | bigint | undefined): bigint | null {
|
|
204
|
+
if (value === undefined) {
|
|
205
|
+
return null;
|
|
206
|
+
}
|
|
207
|
+
if (typeof value === "bigint") {
|
|
208
|
+
return value;
|
|
209
|
+
}
|
|
210
|
+
if (!Number.isFinite(value)) {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
return BigInt(Math.trunc(value));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Require actual finite nanosecond timestamps. Millisecond fields alone are
|
|
218
|
+
* insufficient for the fast path (no ms→ns fabrication).
|
|
219
|
+
*/
|
|
220
|
+
export function nsFromStat(ns: bigint | number | undefined): bigint | null {
|
|
221
|
+
return toBigInt(ns);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export function kindOf(stat: WatcherSnapshotStat): SnapshotEntryKind {
|
|
225
|
+
if (stat.isSymbolicLink()) {
|
|
226
|
+
return "symlink";
|
|
227
|
+
}
|
|
228
|
+
if (stat.isDirectory()) {
|
|
229
|
+
return "directory";
|
|
230
|
+
}
|
|
231
|
+
if (stat.isFile()) {
|
|
232
|
+
return "file";
|
|
233
|
+
}
|
|
234
|
+
return "other";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
export function fingerprintFromStat(
|
|
238
|
+
stat: WatcherSnapshotStat
|
|
239
|
+
):
|
|
240
|
+
| { ok: true; fingerprint: SnapshotEntryFingerprint }
|
|
241
|
+
| { ok: false; reason: "unreliable_metadata" } {
|
|
242
|
+
if (stat.unreliable) {
|
|
243
|
+
return { ok: false, reason: "unreliable_metadata" };
|
|
244
|
+
}
|
|
245
|
+
const device = toBigInt(stat.dev);
|
|
246
|
+
const inode = toBigInt(stat.ino);
|
|
247
|
+
const sizeValue = toBigInt(stat.size);
|
|
248
|
+
const mtimeNs = nsFromStat(stat.mtimeNs);
|
|
249
|
+
const ctimeNs = nsFromStat(stat.ctimeNs);
|
|
250
|
+
if (
|
|
251
|
+
device === null ||
|
|
252
|
+
inode === null ||
|
|
253
|
+
sizeValue === null ||
|
|
254
|
+
mtimeNs === null ||
|
|
255
|
+
ctimeNs === null
|
|
256
|
+
) {
|
|
257
|
+
return { ok: false, reason: "unreliable_metadata" };
|
|
258
|
+
}
|
|
259
|
+
// size may exceed Number.MAX_SAFE_INTEGER on huge files; clamp via Number is
|
|
260
|
+
// fine for discovery equality against the same platform read.
|
|
261
|
+
const size =
|
|
262
|
+
sizeValue > BigInt(Number.MAX_SAFE_INTEGER)
|
|
263
|
+
? Number.MAX_SAFE_INTEGER
|
|
264
|
+
: Number(sizeValue);
|
|
265
|
+
return {
|
|
266
|
+
ok: true,
|
|
267
|
+
fingerprint: {
|
|
268
|
+
kind: kindOf(stat),
|
|
269
|
+
device,
|
|
270
|
+
inode,
|
|
271
|
+
size,
|
|
272
|
+
mtimeNs,
|
|
273
|
+
ctimeNs,
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function fingerprintsEqual(
|
|
279
|
+
left: SnapshotEntryFingerprint,
|
|
280
|
+
right: SnapshotEntryFingerprint
|
|
281
|
+
): boolean {
|
|
282
|
+
return (
|
|
283
|
+
left.kind === right.kind &&
|
|
284
|
+
left.device === right.device &&
|
|
285
|
+
left.inode === right.inode &&
|
|
286
|
+
left.size === right.size &&
|
|
287
|
+
left.mtimeNs === right.mtimeNs &&
|
|
288
|
+
left.ctimeNs === right.ctimeNs
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Normalize an untrusted watcher hint to a POSIX collection-relative path.
|
|
294
|
+
* Returns null for absolute, escaping, empty-invalid, or NUL-bearing values.
|
|
295
|
+
*/
|
|
296
|
+
export function normalizeWatcherRelPath(relPath: string): string | null {
|
|
297
|
+
if (relPath.includes("\0")) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
const normalized = relPath.replaceAll("\\", "/");
|
|
301
|
+
if (normalized.startsWith("/") || isAbsolute(relPath)) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
// Windows drive-shaped absolute escapes (`C:/...`) after slash normalization.
|
|
305
|
+
if (/^[A-Za-z]:(\/|$)/.test(normalized)) {
|
|
306
|
+
return null;
|
|
307
|
+
}
|
|
308
|
+
const segments: string[] = [];
|
|
309
|
+
for (const segment of normalized.split("/")) {
|
|
310
|
+
if (segment === "" || segment === ".") {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (segment === "..") {
|
|
314
|
+
return null;
|
|
315
|
+
}
|
|
316
|
+
segments.push(segment);
|
|
317
|
+
}
|
|
318
|
+
return segments.join("/");
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Parent directory of a POSIX relative path; `null` when path is the root. */
|
|
322
|
+
export function parentWatcherDir(relPath: string): string | null {
|
|
323
|
+
if (relPath === "") {
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
const index = relPath.lastIndexOf("/");
|
|
327
|
+
return index === -1 ? "" : relPath.slice(0, index);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export function joinWatcherRelPath(dir: string, name: string): string {
|
|
331
|
+
return dir === "" ? name : `${dir}/${name}`;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
export function createEmptyWatcherSnapshot(): WatcherSnapshot {
|
|
335
|
+
return { directories: new Map(), entryCount: 0 };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function isMissingFsError(error: unknown): boolean {
|
|
339
|
+
const code =
|
|
340
|
+
error && typeof error === "object" && "code" in error
|
|
341
|
+
? String(error.code)
|
|
342
|
+
: "";
|
|
343
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function sortPathList(paths: Iterable<string>): string[] {
|
|
347
|
+
return [...paths].sort((left, right) =>
|
|
348
|
+
left < right ? -1 : left > right ? 1 : 0
|
|
349
|
+
);
|
|
350
|
+
}
|
|
@@ -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