@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,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared constants and pure helpers for watcher reconciliation.
|
|
3
|
+
*
|
|
4
|
+
* @module src/serve/watch-reconciliation-shared
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// node:fs/promises — structure ops; no Bun equivalent for no-follow lstat
|
|
8
|
+
import { lstat } from "node:fs/promises";
|
|
9
|
+
// node:path — Bun has no path utilities
|
|
10
|
+
import { join, normalize } from "node:path";
|
|
11
|
+
|
|
12
|
+
import type { Collection } from "../config/types";
|
|
13
|
+
import type { CollectionSyncResult } from "../ingestion";
|
|
14
|
+
|
|
15
|
+
import { matchesCollectionExclusion } from "../core/path-rules";
|
|
16
|
+
import { collectionToWalkConfig, matchesWalkPath } from "../ingestion";
|
|
17
|
+
import {
|
|
18
|
+
normalizeWatcherRelPath,
|
|
19
|
+
parentWatcherDir,
|
|
20
|
+
type WatcherSnapshot,
|
|
21
|
+
} from "./watch-snapshot";
|
|
22
|
+
|
|
23
|
+
/** Quiet period before a collection flushes queued watcher work. */
|
|
24
|
+
export const WATCHER_FLUSH_DEBOUNCE_MS = 300;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Hard ceiling on how long resettable debounce may postpone a flush.
|
|
28
|
+
* Sustained unique-temp churn re-arms debounce; this deadline forces drain.
|
|
29
|
+
*/
|
|
30
|
+
export const WATCHER_MAX_FLUSH_DELAY_MS = 2_000;
|
|
31
|
+
|
|
32
|
+
/** Cap on pending exact eligible paths per collection. */
|
|
33
|
+
export const WATCHER_MAX_EXACT_PATHS = 8_192;
|
|
34
|
+
|
|
35
|
+
/** Cap on pending dirty-directory hints per collection. */
|
|
36
|
+
export const WATCHER_MAX_DIRTY_HINTS = 4_096;
|
|
37
|
+
|
|
38
|
+
/** Cap on application-write suppression history entries. */
|
|
39
|
+
export const WATCHER_MAX_SUPPRESSION_ENTRIES = 4_096;
|
|
40
|
+
|
|
41
|
+
/** Bounded retry delay after failed classification/sync. */
|
|
42
|
+
export const WATCHER_RETRY_BACKOFF_MS = 500;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Single fixed budget for fallback classification across visited directories,
|
|
46
|
+
* candidates, removals, dirty dirs, and aggregate store rows.
|
|
47
|
+
*/
|
|
48
|
+
export const WATCHER_FALLBACK_BUDGET = 8_192;
|
|
49
|
+
|
|
50
|
+
export type WatcherEventClassification =
|
|
51
|
+
| { kind: "reject" }
|
|
52
|
+
| { kind: "ignore" }
|
|
53
|
+
| { kind: "exact"; relPath: string }
|
|
54
|
+
| { kind: "dirty"; hint: string };
|
|
55
|
+
|
|
56
|
+
/** No-follow entry kind for exact-path widening (file/symlink stay exact). */
|
|
57
|
+
export type ExactPathKind = "file" | "directory" | "symlink" | "other";
|
|
58
|
+
|
|
59
|
+
export type PathPresence =
|
|
60
|
+
| { status: "present"; kind: ExactPathKind }
|
|
61
|
+
| { status: "missing" }
|
|
62
|
+
| { status: "error"; cause: unknown };
|
|
63
|
+
|
|
64
|
+
export type ClassificationFullReconcileReason =
|
|
65
|
+
| "unsupported_fs"
|
|
66
|
+
| "budget_overflow"
|
|
67
|
+
| "snapshot_overflow";
|
|
68
|
+
|
|
69
|
+
export type ClassificationResult =
|
|
70
|
+
| {
|
|
71
|
+
status: "ok";
|
|
72
|
+
candidates: string[];
|
|
73
|
+
removals: string[];
|
|
74
|
+
nextSnapshot: WatcherSnapshot | null;
|
|
75
|
+
usedFallback: boolean;
|
|
76
|
+
}
|
|
77
|
+
| {
|
|
78
|
+
/**
|
|
79
|
+
* Durable full-collection reconciliation required (unsupported FS,
|
|
80
|
+
* classification budget overflow, or snapshot ceiling overflow).
|
|
81
|
+
* Callers must use syncCollection — never retry the identical dirty scan.
|
|
82
|
+
*/
|
|
83
|
+
status: "full_reconcile";
|
|
84
|
+
reason: ClassificationFullReconcileReason;
|
|
85
|
+
}
|
|
86
|
+
| {
|
|
87
|
+
status: "error";
|
|
88
|
+
cause: unknown;
|
|
89
|
+
stage: "scan" | "store";
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/** Paths that successfully changed during a sync (added/updated only). */
|
|
93
|
+
export function successfulChangedPaths(result: CollectionSyncResult): string[] {
|
|
94
|
+
if (result.files) {
|
|
95
|
+
return result.files
|
|
96
|
+
.filter((file) => file.status === "added" || file.status === "updated")
|
|
97
|
+
.map((file) => file.relPath);
|
|
98
|
+
}
|
|
99
|
+
return [];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Paths that failed during a sync. When file-level detail is missing, falls
|
|
104
|
+
* back to every submitted path so work is never silently dropped.
|
|
105
|
+
*/
|
|
106
|
+
export function failedSyncPaths(
|
|
107
|
+
result: CollectionSyncResult,
|
|
108
|
+
submittedPaths: readonly string[]
|
|
109
|
+
): string[] {
|
|
110
|
+
if (result.files && result.files.length > 0) {
|
|
111
|
+
return result.files
|
|
112
|
+
.filter((file) => file.status === "error")
|
|
113
|
+
.map((file) => file.relPath);
|
|
114
|
+
}
|
|
115
|
+
if (result.errors.length > 0) {
|
|
116
|
+
const fromErrors = result.errors
|
|
117
|
+
.map((entry) => entry.relPath)
|
|
118
|
+
.filter((path) => path.length > 0);
|
|
119
|
+
if (fromErrors.length > 0) {
|
|
120
|
+
return [...new Set(fromErrors)];
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return [...submittedPaths];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Classify an untrusted watcher filename for one collection.
|
|
128
|
+
* Rejects absolute/escaping/NUL paths before joins or suppression lookups.
|
|
129
|
+
* Permanently excluded subtrees are ignored when safely classifiable.
|
|
130
|
+
*/
|
|
131
|
+
export function classifyWatcherFilename(
|
|
132
|
+
filename: string | Buffer | null | undefined,
|
|
133
|
+
collection: Collection
|
|
134
|
+
): WatcherEventClassification {
|
|
135
|
+
if (filename === null || filename === undefined) {
|
|
136
|
+
return { kind: "dirty", hint: "" };
|
|
137
|
+
}
|
|
138
|
+
const raw = filename.toString();
|
|
139
|
+
if (raw.length === 0) {
|
|
140
|
+
return { kind: "dirty", hint: "" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const relPath = normalizeWatcherRelPath(raw.replaceAll("\\", "/"));
|
|
144
|
+
if (relPath === null) {
|
|
145
|
+
return { kind: "reject" };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const walkConfig = collectionToWalkConfig(collection, 0);
|
|
149
|
+
if (
|
|
150
|
+
relPath !== "" &&
|
|
151
|
+
matchesCollectionExclusion(relPath, walkConfig.exclude)
|
|
152
|
+
) {
|
|
153
|
+
return { kind: "ignore" };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (relPath === "") {
|
|
157
|
+
return { kind: "dirty", hint: "" };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (matchesWalkPath(relPath, walkConfig)) {
|
|
161
|
+
return { kind: "exact", relPath };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return { kind: "dirty", hint: relPath };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** True when any per-file result is an error (partial sync must not commit). */
|
|
168
|
+
export function hasFileLevelSyncError(result: CollectionSyncResult): boolean {
|
|
169
|
+
if (result.filesErrored > 0) {
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
if (result.files?.some((file) => file.status === "error")) {
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
return result.errors.length > 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Add `value` to a capped set. Overflow signals callers to degrade to
|
|
180
|
+
* bounded fallback (e.g. root dirty) rather than drop work silently.
|
|
181
|
+
*/
|
|
182
|
+
export function addToCappedSet(
|
|
183
|
+
set: Set<string>,
|
|
184
|
+
value: string,
|
|
185
|
+
max: number
|
|
186
|
+
): "added" | "exists" | "overflow" {
|
|
187
|
+
if (set.has(value)) {
|
|
188
|
+
return "exists";
|
|
189
|
+
}
|
|
190
|
+
if (set.size >= max) {
|
|
191
|
+
return "overflow";
|
|
192
|
+
}
|
|
193
|
+
set.add(value);
|
|
194
|
+
return "added";
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Prune expired suppression entries; enforce a hard size ceiling. */
|
|
198
|
+
export function pruneSuppressionMap(
|
|
199
|
+
map: Map<string, number>,
|
|
200
|
+
nowMs: number,
|
|
201
|
+
maxEntries: number
|
|
202
|
+
): void {
|
|
203
|
+
for (const [key, until] of map) {
|
|
204
|
+
if (until <= nowMs) {
|
|
205
|
+
map.delete(key);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (map.size <= maxEntries) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
const ordered = [...map.entries()].sort((a, b) => a[1] - b[1]);
|
|
212
|
+
const drop = map.size - maxEntries;
|
|
213
|
+
for (let i = 0; i < drop; i += 1) {
|
|
214
|
+
const key = ordered[i]?.[0];
|
|
215
|
+
if (key !== undefined) {
|
|
216
|
+
map.delete(key);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function kindFromLstat(info: {
|
|
222
|
+
isSymbolicLink(): boolean;
|
|
223
|
+
isDirectory(): boolean;
|
|
224
|
+
isFile(): boolean;
|
|
225
|
+
}): ExactPathKind {
|
|
226
|
+
if (info.isSymbolicLink()) {
|
|
227
|
+
return "symlink";
|
|
228
|
+
}
|
|
229
|
+
if (info.isDirectory()) {
|
|
230
|
+
return "directory";
|
|
231
|
+
}
|
|
232
|
+
if (info.isFile()) {
|
|
233
|
+
return "file";
|
|
234
|
+
}
|
|
235
|
+
// FIFO / socket / device — never indexable file sources.
|
|
236
|
+
return "other";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Contained no-follow presence check for exact-path widening.
|
|
241
|
+
* Uses lstat so symlink vs directory vs special (FIFO/socket/device) is exact.
|
|
242
|
+
*/
|
|
243
|
+
export async function inspectPathPresence(
|
|
244
|
+
rootAbs: string,
|
|
245
|
+
relPath: string
|
|
246
|
+
): Promise<PathPresence> {
|
|
247
|
+
const abs = normalize(join(rootAbs, ...relPath.split("/").filter(Boolean)));
|
|
248
|
+
try {
|
|
249
|
+
const info = await lstat(abs);
|
|
250
|
+
return { status: "present", kind: kindFromLstat(info) };
|
|
251
|
+
} catch (cause) {
|
|
252
|
+
const code =
|
|
253
|
+
cause && typeof cause === "object" && "code" in cause
|
|
254
|
+
? String(cause.code)
|
|
255
|
+
: "";
|
|
256
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
257
|
+
return { status: "missing" };
|
|
258
|
+
}
|
|
259
|
+
return { status: "error", cause };
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Widen exact eligible paths before targeted sync.
|
|
265
|
+
*
|
|
266
|
+
* - Regular file / symlink sources stay exact (content-hash authority).
|
|
267
|
+
* - Directory or non-indexable special (FIFO/socket/device) leave exact and
|
|
268
|
+
* become dirty-only so snapshot/fallback can prove removals + children.
|
|
269
|
+
* - Missing paths keep exact (ENOENT inactivation) and dirty parent discovery.
|
|
270
|
+
* - Uncertain lstat failures never stay exact-only (avoids NOT_FILE loops);
|
|
271
|
+
* they dirty + force fallback for durable reclassification.
|
|
272
|
+
*/
|
|
273
|
+
export async function widenVanishedExactPaths(
|
|
274
|
+
rootAbs: string,
|
|
275
|
+
exactPaths: readonly string[]
|
|
276
|
+
): Promise<{
|
|
277
|
+
keepExact: string[];
|
|
278
|
+
extraDirty: string[];
|
|
279
|
+
/** Non-source present paths (dir/other) or uncertain — force store/disk. */
|
|
280
|
+
directoryDirty: string[];
|
|
281
|
+
}> {
|
|
282
|
+
const keepExact: string[] = [];
|
|
283
|
+
const extraDirty: string[] = [];
|
|
284
|
+
const directoryDirty: string[] = [];
|
|
285
|
+
for (const relPath of exactPaths) {
|
|
286
|
+
const presence = await inspectPathPresence(rootAbs, relPath);
|
|
287
|
+
if (presence.status === "error") {
|
|
288
|
+
// Uncertainty: durable dirty/full path — never exact NOT_FILE churn.
|
|
289
|
+
extraDirty.push(relPath);
|
|
290
|
+
directoryDirty.push(relPath);
|
|
291
|
+
const parent = parentWatcherDir(relPath);
|
|
292
|
+
if (parent !== null) {
|
|
293
|
+
extraDirty.push(parent);
|
|
294
|
+
}
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
if (presence.status === "missing") {
|
|
298
|
+
keepExact.push(relPath);
|
|
299
|
+
extraDirty.push(relPath);
|
|
300
|
+
const parent = parentWatcherDir(relPath);
|
|
301
|
+
if (parent !== null) {
|
|
302
|
+
extraDirty.push(parent);
|
|
303
|
+
}
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (presence.kind === "directory" || presence.kind === "other") {
|
|
307
|
+
// Not an indexable file source; dirty-only so proven removals can land.
|
|
308
|
+
extraDirty.push(relPath);
|
|
309
|
+
directoryDirty.push(relPath);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
// file | symlink — retain exact content-hash authority.
|
|
313
|
+
keepExact.push(relPath);
|
|
314
|
+
}
|
|
315
|
+
return { keepExact, extraDirty, directoryDirty };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function filterEligiblePaths(
|
|
319
|
+
paths: readonly string[],
|
|
320
|
+
collection: Collection
|
|
321
|
+
): string[] {
|
|
322
|
+
const walkConfig = collectionToWalkConfig(collection, 0);
|
|
323
|
+
return paths.filter((relPath) => matchesWalkPath(relPath, walkConfig));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Merge exact + reconciled paths into one deduped targeted batch (sorted). */
|
|
327
|
+
export function mergeSyncPathBatch(
|
|
328
|
+
exactPaths: readonly string[],
|
|
329
|
+
candidates: readonly string[],
|
|
330
|
+
removals: readonly string[]
|
|
331
|
+
): string[] {
|
|
332
|
+
const batch = new Set<string>();
|
|
333
|
+
for (const path of exactPaths) {
|
|
334
|
+
batch.add(path);
|
|
335
|
+
}
|
|
336
|
+
for (const path of candidates) {
|
|
337
|
+
batch.add(path);
|
|
338
|
+
}
|
|
339
|
+
for (const path of removals) {
|
|
340
|
+
batch.add(path);
|
|
341
|
+
}
|
|
342
|
+
return [...batch].sort();
|
|
343
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact/ambiguous watcher event classification and snapshot-backed reconcile.
|
|
3
|
+
*
|
|
4
|
+
* Snapshot fingerprints discover candidates only; exact eligible paths always
|
|
5
|
+
* retain content-hash authority via targeted `syncPaths`.
|
|
6
|
+
*
|
|
7
|
+
* @module src/serve/watch-reconciliation
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Collection } from "../config/types";
|
|
11
|
+
import type { SqliteAdapter } from "../store/sqlite/adapter";
|
|
12
|
+
|
|
13
|
+
import { WATCHER_ACTIVE_SOURCE_PATH_MAX } from "../store/types";
|
|
14
|
+
import { fallbackClassifyDirtyHints } from "./watch-reconciliation-fallback";
|
|
15
|
+
import {
|
|
16
|
+
filterEligiblePaths,
|
|
17
|
+
type ClassificationResult,
|
|
18
|
+
} from "./watch-reconciliation-shared";
|
|
19
|
+
import {
|
|
20
|
+
reconcileWatcherHints,
|
|
21
|
+
type WatcherSnapshot,
|
|
22
|
+
type WatcherSnapshotOptions,
|
|
23
|
+
} from "./watch-snapshot";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
WATCHER_FALLBACK_BUDGET,
|
|
27
|
+
WATCHER_FLUSH_DEBOUNCE_MS,
|
|
28
|
+
WATCHER_MAX_DIRTY_HINTS,
|
|
29
|
+
WATCHER_MAX_EXACT_PATHS,
|
|
30
|
+
WATCHER_MAX_FLUSH_DELAY_MS,
|
|
31
|
+
WATCHER_MAX_SUPPRESSION_ENTRIES,
|
|
32
|
+
WATCHER_RETRY_BACKOFF_MS,
|
|
33
|
+
addToCappedSet,
|
|
34
|
+
classifyWatcherFilename,
|
|
35
|
+
failedSyncPaths,
|
|
36
|
+
filterEligiblePaths,
|
|
37
|
+
hasFileLevelSyncError,
|
|
38
|
+
inspectPathPresence,
|
|
39
|
+
mergeSyncPathBatch,
|
|
40
|
+
pruneSuppressionMap,
|
|
41
|
+
successfulChangedPaths,
|
|
42
|
+
widenVanishedExactPaths,
|
|
43
|
+
type ClassificationResult,
|
|
44
|
+
type ExactPathKind,
|
|
45
|
+
type PathPresence,
|
|
46
|
+
type WatcherEventClassification,
|
|
47
|
+
} from "./watch-reconciliation-shared";
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Snapshot-first classification of dirty hints. On overflow/scan/metadata
|
|
51
|
+
* failure, uses bounded store + disk enumeration without inferring deletes
|
|
52
|
+
* from failed queries.
|
|
53
|
+
*/
|
|
54
|
+
export async function classifyDirtyHints(options: {
|
|
55
|
+
collection: Collection;
|
|
56
|
+
store: SqliteAdapter;
|
|
57
|
+
rootAbs: string;
|
|
58
|
+
previous: WatcherSnapshot | null;
|
|
59
|
+
dirtyHints: readonly string[];
|
|
60
|
+
/**
|
|
61
|
+
* When true (init-time ambiguous absorption risk), skip snapshot diff and use
|
|
62
|
+
* bounded store/disk so present eligible finals always reach syncPaths.
|
|
63
|
+
*/
|
|
64
|
+
forceFallback?: boolean;
|
|
65
|
+
snapshotOptions?: WatcherSnapshotOptions;
|
|
66
|
+
sourcePathMax?: number;
|
|
67
|
+
}): Promise<ClassificationResult> {
|
|
68
|
+
const {
|
|
69
|
+
collection,
|
|
70
|
+
store,
|
|
71
|
+
rootAbs,
|
|
72
|
+
previous,
|
|
73
|
+
dirtyHints,
|
|
74
|
+
forceFallback = false,
|
|
75
|
+
snapshotOptions,
|
|
76
|
+
sourcePathMax = WATCHER_ACTIVE_SOURCE_PATH_MAX,
|
|
77
|
+
} = options;
|
|
78
|
+
|
|
79
|
+
if (dirtyHints.length === 0) {
|
|
80
|
+
return {
|
|
81
|
+
status: "ok",
|
|
82
|
+
candidates: [],
|
|
83
|
+
removals: [],
|
|
84
|
+
nextSnapshot: previous,
|
|
85
|
+
usedFallback: false,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (previous && !forceFallback) {
|
|
90
|
+
const diff = await reconcileWatcherHints(
|
|
91
|
+
rootAbs,
|
|
92
|
+
previous,
|
|
93
|
+
dirtyHints,
|
|
94
|
+
snapshotOptions
|
|
95
|
+
);
|
|
96
|
+
if (diff.status === "ok") {
|
|
97
|
+
return {
|
|
98
|
+
status: "ok",
|
|
99
|
+
candidates: filterEligiblePaths(diff.candidates, collection),
|
|
100
|
+
removals: filterEligiblePaths(diff.removals, collection),
|
|
101
|
+
nextSnapshot: diff.nextSnapshot,
|
|
102
|
+
usedFallback: false,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// Snapshot ceiling overflow cannot be repaired by re-diffing the same
|
|
106
|
+
// dirty set — escalate to durable full-collection reconciliation.
|
|
107
|
+
if (diff.status === "fallback" && diff.reason === "overflow") {
|
|
108
|
+
return { status: "full_reconcile", reason: "snapshot_overflow" };
|
|
109
|
+
}
|
|
110
|
+
// Fall through for scan/metadata failure — previous snapshot uncommitted.
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return fallbackClassifyDirtyHints({
|
|
114
|
+
collection,
|
|
115
|
+
store,
|
|
116
|
+
rootAbs,
|
|
117
|
+
dirtyHints,
|
|
118
|
+
sourcePathMax,
|
|
119
|
+
// Only anchored FS may walk; unsupported injects fail-closed handles.
|
|
120
|
+
fs: snapshotOptions?.fs,
|
|
121
|
+
});
|
|
122
|
+
}
|