@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,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* No-follow disk enumeration for watcher fallback classification.
|
|
3
|
+
* Platforms without genuine anchored handles fail closed — no path-based TOCTOU
|
|
4
|
+
* fallback in production (createPathBackedWatcherFs is test-only).
|
|
5
|
+
*
|
|
6
|
+
* @module src/serve/watch-reconciliation-fallback-disk
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { Collection } from "../config/types";
|
|
10
|
+
|
|
11
|
+
import { matchesCollectionExclusion } from "../core/path-rules";
|
|
12
|
+
import { collectionToWalkConfig, matchesWalkPath } from "../ingestion";
|
|
13
|
+
import { defaultFs, openDirByRel } from "./watch-snapshot-scan";
|
|
14
|
+
import {
|
|
15
|
+
isMissingFsError,
|
|
16
|
+
joinWatcherRelPath,
|
|
17
|
+
parentWatcherDir,
|
|
18
|
+
type WatcherSnapshotFs,
|
|
19
|
+
type WatcherSnapshotStat,
|
|
20
|
+
} from "./watch-snapshot-types";
|
|
21
|
+
|
|
22
|
+
export interface FallbackBudget {
|
|
23
|
+
readonly limit: number;
|
|
24
|
+
visitedDirs: number;
|
|
25
|
+
candidates: number;
|
|
26
|
+
removals: number;
|
|
27
|
+
dirtyDirs: number;
|
|
28
|
+
storeRows: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function budgetExceeded(b: FallbackBudget): boolean {
|
|
32
|
+
return (
|
|
33
|
+
b.visitedDirs > b.limit ||
|
|
34
|
+
b.candidates > b.limit ||
|
|
35
|
+
b.removals > b.limit ||
|
|
36
|
+
b.dirtyDirs > b.limit ||
|
|
37
|
+
b.storeRows > b.limit
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Production fallback FS: anchored handles only. Unsupported platforms surface
|
|
43
|
+
* scan_failed/ENOTSUP via openDirByRel — never claim path-based safety.
|
|
44
|
+
*/
|
|
45
|
+
export function fallbackFs(): WatcherSnapshotFs {
|
|
46
|
+
return defaultFs;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type DiskListResult =
|
|
50
|
+
| { status: "ok"; paths: string[]; rootDirNames: string[] }
|
|
51
|
+
| { status: "overflow" }
|
|
52
|
+
| { status: "error"; cause: unknown };
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Bounded no-follow BFS under `dirRel`. Stops enumeration at remaining+1 names.
|
|
56
|
+
* Never descends through symlinks.
|
|
57
|
+
*/
|
|
58
|
+
export async function listEligibleDiskSources(
|
|
59
|
+
rootAbs: string,
|
|
60
|
+
dirRel: string,
|
|
61
|
+
collection: Collection,
|
|
62
|
+
fs: WatcherSnapshotFs,
|
|
63
|
+
budget: FallbackBudget
|
|
64
|
+
): Promise<DiskListResult> {
|
|
65
|
+
const walkConfig = collectionToWalkConfig(collection, 0);
|
|
66
|
+
const paths: string[] = [];
|
|
67
|
+
const rootDirNames: string[] = [];
|
|
68
|
+
const queue: string[] = [dirRel];
|
|
69
|
+
let head = 0;
|
|
70
|
+
|
|
71
|
+
while (head < queue.length) {
|
|
72
|
+
const current = queue[head] as string;
|
|
73
|
+
head += 1;
|
|
74
|
+
budget.visitedDirs += 1;
|
|
75
|
+
if (budgetExceeded(budget)) {
|
|
76
|
+
return { status: "overflow" };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const remaining = Math.max(0, budget.limit - budget.visitedDirs + 1);
|
|
80
|
+
const opened = await openDirByRel(rootAbs, current, fs);
|
|
81
|
+
if (opened.status === "missing") {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (opened.status !== "ok") {
|
|
85
|
+
return {
|
|
86
|
+
status: "error",
|
|
87
|
+
cause:
|
|
88
|
+
opened.status === "scan_failed"
|
|
89
|
+
? opened.cause
|
|
90
|
+
: new Error(`Disk scan failed under ${current || "."}`),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let listed;
|
|
95
|
+
try {
|
|
96
|
+
listed = await fs.readDir(opened.handle, remaining);
|
|
97
|
+
} catch (cause) {
|
|
98
|
+
await fs.closeDir(opened.handle);
|
|
99
|
+
if (isMissingFsError(cause)) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
return { status: "error", cause };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (listed.status === "overflow") {
|
|
106
|
+
await fs.closeDir(opened.handle);
|
|
107
|
+
return { status: "overflow" };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const names = [...listed.names].sort((a, b) =>
|
|
111
|
+
a < b ? -1 : a > b ? 1 : 0
|
|
112
|
+
);
|
|
113
|
+
for (const name of names) {
|
|
114
|
+
if (name === "" || name === "." || name === "..") {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (name.includes("/") || name.includes("\\") || name.includes("\0")) {
|
|
118
|
+
await fs.closeDir(opened.handle);
|
|
119
|
+
return {
|
|
120
|
+
status: "error",
|
|
121
|
+
cause: new Error(`Invalid directory entry name: ${name}`),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let stat: WatcherSnapshotStat;
|
|
126
|
+
try {
|
|
127
|
+
stat = await fs.lstatChild(opened.handle, name);
|
|
128
|
+
} catch (cause) {
|
|
129
|
+
await fs.closeDir(opened.handle);
|
|
130
|
+
if (isMissingFsError(cause)) {
|
|
131
|
+
return { status: "error", cause };
|
|
132
|
+
}
|
|
133
|
+
return { status: "error", cause };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const childRel = joinWatcherRelPath(current, name);
|
|
137
|
+
if (matchesCollectionExclusion(childRel, walkConfig.exclude)) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Never follow symlinks; eligible link paths stay leaf candidates.
|
|
142
|
+
if (stat.isSymbolicLink()) {
|
|
143
|
+
if (matchesWalkPath(childRel, walkConfig)) {
|
|
144
|
+
paths.push(childRel);
|
|
145
|
+
if (paths.length > budget.limit) {
|
|
146
|
+
await fs.closeDir(opened.handle);
|
|
147
|
+
return { status: "overflow" };
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (stat.isDirectory()) {
|
|
154
|
+
if (current === "") {
|
|
155
|
+
rootDirNames.push(name);
|
|
156
|
+
}
|
|
157
|
+
queue.push(childRel);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (!stat.isFile() || !matchesWalkPath(childRel, walkConfig)) {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
paths.push(childRel);
|
|
165
|
+
if (paths.length > budget.limit) {
|
|
166
|
+
await fs.closeDir(opened.handle);
|
|
167
|
+
return { status: "overflow" };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
await fs.closeDir(opened.handle);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return { status: "ok", paths, rootDirNames };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function inspectNoFollowPresence(
|
|
177
|
+
rootAbs: string,
|
|
178
|
+
relPath: string,
|
|
179
|
+
fs: WatcherSnapshotFs
|
|
180
|
+
): Promise<
|
|
181
|
+
| { status: "present"; indexable: boolean }
|
|
182
|
+
| { status: "missing" }
|
|
183
|
+
| { status: "error"; cause: unknown }
|
|
184
|
+
> {
|
|
185
|
+
const parent = parentWatcherDir(relPath);
|
|
186
|
+
if (parent === null) {
|
|
187
|
+
return { status: "missing" };
|
|
188
|
+
}
|
|
189
|
+
const base = parent === "" ? relPath : relPath.slice(parent.length + 1);
|
|
190
|
+
if (base === "" || base.includes("/")) {
|
|
191
|
+
return { status: "error", cause: new Error(`Invalid path: ${relPath}`) };
|
|
192
|
+
}
|
|
193
|
+
const opened = await openDirByRel(rootAbs, parent, fs);
|
|
194
|
+
if (opened.status === "missing") {
|
|
195
|
+
return { status: "missing" };
|
|
196
|
+
}
|
|
197
|
+
if (opened.status !== "ok") {
|
|
198
|
+
return {
|
|
199
|
+
status: "error",
|
|
200
|
+
cause:
|
|
201
|
+
opened.status === "scan_failed"
|
|
202
|
+
? opened.cause
|
|
203
|
+
: new Error("Failed to open parent for presence check"),
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
const stat = await fs.lstatChild(opened.handle, base);
|
|
208
|
+
// Only regular files / symlinks are indexable sources. Directory, FIFO,
|
|
209
|
+
// device, and other specials prove the prior file source is gone.
|
|
210
|
+
const indexable = stat.isFile() || stat.isSymbolicLink();
|
|
211
|
+
return { status: "present", indexable };
|
|
212
|
+
} catch (cause) {
|
|
213
|
+
if (isMissingFsError(cause)) {
|
|
214
|
+
return { status: "missing" };
|
|
215
|
+
}
|
|
216
|
+
return { status: "error", cause };
|
|
217
|
+
} finally {
|
|
218
|
+
await fs.closeDir(opened.handle);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded store + disk fallback when snapshot classification cannot prove work.
|
|
3
|
+
* Failed queries never imply inactivation. Disk walks use no-follow handles.
|
|
4
|
+
*
|
|
5
|
+
* @module src/serve/watch-reconciliation-fallback
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// node:fs/promises — Bun.file().exists() reports false for directories, so it
|
|
9
|
+
// cannot prove collection-root availability on unsupported watcher platforms.
|
|
10
|
+
import { stat } from "node:fs/promises";
|
|
11
|
+
// node:path — Bun has no path utilities
|
|
12
|
+
import { normalize } from "node:path";
|
|
13
|
+
|
|
14
|
+
import type { Collection } from "../config/types";
|
|
15
|
+
import type { SqliteAdapter } from "../store/sqlite/adapter";
|
|
16
|
+
import type { StoreResult } from "../store/types";
|
|
17
|
+
|
|
18
|
+
import { matchesCollectionExclusion } from "../core/path-rules";
|
|
19
|
+
import { collectionToWalkConfig, matchesWalkPath } from "../ingestion";
|
|
20
|
+
import {
|
|
21
|
+
budgetExceeded,
|
|
22
|
+
fallbackFs,
|
|
23
|
+
inspectNoFollowPresence,
|
|
24
|
+
listEligibleDiskSources,
|
|
25
|
+
type FallbackBudget,
|
|
26
|
+
} from "./watch-reconciliation-fallback-disk";
|
|
27
|
+
import {
|
|
28
|
+
WATCHER_FALLBACK_BUDGET,
|
|
29
|
+
type ClassificationResult,
|
|
30
|
+
} from "./watch-reconciliation-shared";
|
|
31
|
+
import { openDirByRel } from "./watch-snapshot-scan";
|
|
32
|
+
import {
|
|
33
|
+
normalizeWatcherRelPath,
|
|
34
|
+
parentWatcherDir,
|
|
35
|
+
type WatcherSnapshotFs,
|
|
36
|
+
} from "./watch-snapshot-types";
|
|
37
|
+
|
|
38
|
+
export async function fallbackClassifyDirtyHints(options: {
|
|
39
|
+
collection: Collection;
|
|
40
|
+
store: SqliteAdapter;
|
|
41
|
+
rootAbs: string;
|
|
42
|
+
dirtyHints: readonly string[];
|
|
43
|
+
sourcePathMax: number;
|
|
44
|
+
/** Test seam for unsupported-platform fail-closed proofs. */
|
|
45
|
+
fs?: WatcherSnapshotFs;
|
|
46
|
+
}): Promise<ClassificationResult> {
|
|
47
|
+
const { collection, store, rootAbs, dirtyHints } = options;
|
|
48
|
+
const budgetLimit = Math.min(options.sourcePathMax, WATCHER_FALLBACK_BUDGET);
|
|
49
|
+
const budget: FallbackBudget = {
|
|
50
|
+
limit: budgetLimit,
|
|
51
|
+
visitedDirs: 0,
|
|
52
|
+
candidates: 0,
|
|
53
|
+
removals: 0,
|
|
54
|
+
dirtyDirs: 0,
|
|
55
|
+
storeRows: 0,
|
|
56
|
+
};
|
|
57
|
+
const candidates = new Set<string>();
|
|
58
|
+
const removals = new Set<string>();
|
|
59
|
+
const walkConfig = collectionToWalkConfig(collection, 0);
|
|
60
|
+
const diskSeen = new Set<string>();
|
|
61
|
+
const fs = options.fs ?? fallbackFs();
|
|
62
|
+
const root = normalize(rootAbs);
|
|
63
|
+
|
|
64
|
+
// No anchored handles: never path-walk or infer deletions. Caller must use
|
|
65
|
+
// durable full-collection reconciliation (syncCollection) instead. Prove
|
|
66
|
+
// the root is currently available first so a missing mount/root cannot be
|
|
67
|
+
// mistaken for a genuinely empty collection by the full walk.
|
|
68
|
+
if (!fs.supportsAnchoredHandles) {
|
|
69
|
+
try {
|
|
70
|
+
const rootStat = await stat(root);
|
|
71
|
+
if (!rootStat.isDirectory()) {
|
|
72
|
+
throw new Error("Collection root is not a directory");
|
|
73
|
+
}
|
|
74
|
+
} catch (cause) {
|
|
75
|
+
return { status: "error", cause, stage: "scan" };
|
|
76
|
+
}
|
|
77
|
+
return { status: "full_reconcile", reason: "unsupported_fs" };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const dirs = new Set<string>();
|
|
81
|
+
for (const hint of dirtyHints) {
|
|
82
|
+
const normalized =
|
|
83
|
+
hint === "" ? "" : normalizeWatcherRelPath(hint.replaceAll("\\", "/"));
|
|
84
|
+
if (normalized === null) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
dirs.add(normalized);
|
|
88
|
+
const parent = parentWatcherDir(normalized);
|
|
89
|
+
if (parent !== null) {
|
|
90
|
+
dirs.add(parent);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
// Ancestors cover descendants — charge each source path once globally.
|
|
94
|
+
const collapsedDirs = collapseOverlappingDirtyDirs(dirs);
|
|
95
|
+
// Global unique store sources across all dirty dirs (no double-charge).
|
|
96
|
+
const storeSeen = new Set<string>();
|
|
97
|
+
|
|
98
|
+
// Prove collection root is available before any deletion comparison.
|
|
99
|
+
const rootOpen = await openDirByRel(root, "", fs);
|
|
100
|
+
if (rootOpen.status === "missing") {
|
|
101
|
+
return {
|
|
102
|
+
status: "error",
|
|
103
|
+
cause: new Error("Collection root is missing"),
|
|
104
|
+
stage: "scan",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (rootOpen.status !== "ok") {
|
|
108
|
+
return {
|
|
109
|
+
status: "error",
|
|
110
|
+
cause:
|
|
111
|
+
rootOpen.status === "scan_failed"
|
|
112
|
+
? rootOpen.cause
|
|
113
|
+
: new Error("Collection root unavailable"),
|
|
114
|
+
stage: "scan",
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
await fs.closeDir(rootOpen.handle);
|
|
118
|
+
|
|
119
|
+
for (const dir of collapsedDirs) {
|
|
120
|
+
budget.dirtyDirs += 1;
|
|
121
|
+
if (budgetExceeded(budget)) {
|
|
122
|
+
return overflowResult(dir);
|
|
123
|
+
}
|
|
124
|
+
if (dir !== "" && matchesCollectionExclusion(dir, walkConfig.exclude)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const disk = await listEligibleDiskSources(
|
|
129
|
+
root,
|
|
130
|
+
dir,
|
|
131
|
+
collection,
|
|
132
|
+
fs,
|
|
133
|
+
budget
|
|
134
|
+
);
|
|
135
|
+
if (disk.status === "error") {
|
|
136
|
+
return { status: "error", cause: disk.cause, stage: "scan" };
|
|
137
|
+
}
|
|
138
|
+
if (disk.status === "overflow") {
|
|
139
|
+
return overflowResult(dir);
|
|
140
|
+
}
|
|
141
|
+
for (const path of disk.paths) {
|
|
142
|
+
candidates.add(path);
|
|
143
|
+
diskSeen.add(path);
|
|
144
|
+
budget.candidates = candidates.size;
|
|
145
|
+
if (budgetExceeded(budget)) {
|
|
146
|
+
return overflowResult(dir);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const storePaths = await collectStorePathsForDir(
|
|
151
|
+
store,
|
|
152
|
+
collection.name,
|
|
153
|
+
dir,
|
|
154
|
+
disk.rootDirNames,
|
|
155
|
+
budget,
|
|
156
|
+
storeSeen
|
|
157
|
+
);
|
|
158
|
+
if (!storePaths.ok) {
|
|
159
|
+
return {
|
|
160
|
+
status: "error",
|
|
161
|
+
cause: new Error(storePaths.error.message),
|
|
162
|
+
stage: "store",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
if (storePaths.overflow) {
|
|
166
|
+
return overflowResult(dir);
|
|
167
|
+
}
|
|
168
|
+
for (const path of storePaths.value) {
|
|
169
|
+
if (!matchesWalkPath(path, walkConfig)) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (diskSeen.has(path)) {
|
|
173
|
+
candidates.add(path);
|
|
174
|
+
budget.candidates = candidates.size;
|
|
175
|
+
} else {
|
|
176
|
+
removals.add(path);
|
|
177
|
+
budget.removals = removals.size;
|
|
178
|
+
}
|
|
179
|
+
if (budgetExceeded(budget)) {
|
|
180
|
+
return overflowResult(dir);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const provenRemovals: string[] = [];
|
|
186
|
+
for (const path of removals) {
|
|
187
|
+
const presence = await inspectNoFollowPresence(root, path, fs);
|
|
188
|
+
if (presence.status === "missing") {
|
|
189
|
+
provenRemovals.push(path);
|
|
190
|
+
} else if (presence.status === "present") {
|
|
191
|
+
if (presence.indexable) {
|
|
192
|
+
candidates.add(path);
|
|
193
|
+
} else {
|
|
194
|
+
// Path exists as directory/FIFO/device: still a proven-absent source.
|
|
195
|
+
provenRemovals.push(path);
|
|
196
|
+
}
|
|
197
|
+
} else if (presence.status === "error") {
|
|
198
|
+
return { status: "error", cause: presence.cause, stage: "scan" };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
status: "ok",
|
|
204
|
+
candidates: [...candidates].sort(),
|
|
205
|
+
removals: provenRemovals.sort(),
|
|
206
|
+
nextSnapshot: null,
|
|
207
|
+
usedFallback: true,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function overflowResult(_dir: string): ClassificationResult {
|
|
212
|
+
// Identical dirty-scan retry cannot clear a budget ceiling — escalate to
|
|
213
|
+
// the same durable full-collection path used for unsupported platforms.
|
|
214
|
+
return { status: "full_reconcile", reason: "budget_overflow" };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Drop dirty dirs covered by an ancestor so overlapping hints charge once.
|
|
219
|
+
* Root (`""`) absorbs every other dir.
|
|
220
|
+
*/
|
|
221
|
+
export function collapseOverlappingDirtyDirs(dirs: Iterable<string>): string[] {
|
|
222
|
+
const list = [...new Set(dirs)];
|
|
223
|
+
if (list.includes("")) {
|
|
224
|
+
return [""];
|
|
225
|
+
}
|
|
226
|
+
list.sort((a, b) => a.length - b.length || a.localeCompare(b));
|
|
227
|
+
const kept: string[] = [];
|
|
228
|
+
for (const dir of list) {
|
|
229
|
+
const covered = kept.some(
|
|
230
|
+
(parent) => parent === dir || dir.startsWith(`${parent}/`)
|
|
231
|
+
);
|
|
232
|
+
if (!covered) {
|
|
233
|
+
kept.push(dir);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return kept;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
async function collectStorePathsForDir(
|
|
240
|
+
store: SqliteAdapter,
|
|
241
|
+
collection: string,
|
|
242
|
+
dir: string,
|
|
243
|
+
rootDirNames: readonly string[],
|
|
244
|
+
budget: FallbackBudget,
|
|
245
|
+
storeSeen: Set<string>
|
|
246
|
+
): Promise<StoreResult<string[]> & { overflow?: boolean }> {
|
|
247
|
+
const out: string[] = [];
|
|
248
|
+
|
|
249
|
+
const takeRows = (
|
|
250
|
+
rows: string[]
|
|
251
|
+
): { ok: true } | { ok: false; overflow: true } => {
|
|
252
|
+
for (const path of rows) {
|
|
253
|
+
if (storeSeen.has(path)) {
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
storeSeen.add(path);
|
|
257
|
+
out.push(path);
|
|
258
|
+
// Count unique store sources only (record-container logical dups collapse).
|
|
259
|
+
budget.storeRows += 1;
|
|
260
|
+
if (budgetExceeded(budget)) {
|
|
261
|
+
return { ok: false, overflow: true };
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { ok: true };
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const remaining = (): number =>
|
|
268
|
+
Math.max(1, budget.limit - budget.storeRows + 1);
|
|
269
|
+
|
|
270
|
+
// Root: sole bounded DISTINCT inventory — never direct-child then root-wide
|
|
271
|
+
// (that double-counted unique sources and falsely overflowed at scale).
|
|
272
|
+
if (dir === "") {
|
|
273
|
+
const inventory = await listRootActiveSourcePaths(
|
|
274
|
+
store,
|
|
275
|
+
collection,
|
|
276
|
+
remaining()
|
|
277
|
+
);
|
|
278
|
+
if (inventory) {
|
|
279
|
+
if (!inventory.ok) {
|
|
280
|
+
return inventory;
|
|
281
|
+
}
|
|
282
|
+
if (inventory.overflow) {
|
|
283
|
+
return { ok: true, value: [], overflow: true };
|
|
284
|
+
}
|
|
285
|
+
if (!takeRows(inventory.value).ok) {
|
|
286
|
+
return { ok: true, value: [], overflow: true };
|
|
287
|
+
}
|
|
288
|
+
return { ok: true, value: out };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Seam unavailable (stubs): first-level disk-name probes only, no direct-child.
|
|
292
|
+
const firstLevel = new Set<string>(rootDirNames);
|
|
293
|
+
for (const name of firstLevel) {
|
|
294
|
+
if (name === "" || name.includes("/")) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
budget.dirtyDirs += 1;
|
|
298
|
+
if (budgetExceeded(budget)) {
|
|
299
|
+
return { ok: true, value: [], overflow: true };
|
|
300
|
+
}
|
|
301
|
+
const descendants = await safeListDescendants(
|
|
302
|
+
store,
|
|
303
|
+
collection,
|
|
304
|
+
name,
|
|
305
|
+
remaining()
|
|
306
|
+
);
|
|
307
|
+
if (!descendants.ok) {
|
|
308
|
+
if (descendants.error.code === "OVERFLOW") {
|
|
309
|
+
return { ok: true, value: [], overflow: true };
|
|
310
|
+
}
|
|
311
|
+
if (descendants.error.code === "INVALID_INPUT") {
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
return descendants;
|
|
315
|
+
}
|
|
316
|
+
if (!takeRows(descendants.value).ok) {
|
|
317
|
+
return { ok: true, value: [], overflow: true };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return { ok: true, value: out };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// Non-root: descendants alone (includes direct children). Direct-child +
|
|
324
|
+
// descendant double-counted unique sources and falsely overflowed at scale.
|
|
325
|
+
const descendants = await safeListDescendants(
|
|
326
|
+
store,
|
|
327
|
+
collection,
|
|
328
|
+
dir,
|
|
329
|
+
remaining()
|
|
330
|
+
);
|
|
331
|
+
if (!descendants.ok) {
|
|
332
|
+
if (descendants.error.code === "OVERFLOW") {
|
|
333
|
+
return { ok: true, value: [], overflow: true };
|
|
334
|
+
}
|
|
335
|
+
return descendants;
|
|
336
|
+
}
|
|
337
|
+
if (!takeRows(descendants.value).ok) {
|
|
338
|
+
return { ok: true, value: [], overflow: true };
|
|
339
|
+
}
|
|
340
|
+
return { ok: true, value: out };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Bounded root-wide DISTINCT active source inventory.
|
|
345
|
+
* Returns null when the seam is unavailable (tests/stubs use disk probes).
|
|
346
|
+
*/
|
|
347
|
+
async function listRootActiveSourcePaths(
|
|
348
|
+
store: SqliteAdapter,
|
|
349
|
+
collection: string,
|
|
350
|
+
max: number
|
|
351
|
+
): Promise<(StoreResult<string[]> & { overflow?: boolean }) | null> {
|
|
352
|
+
if (typeof store.listActiveSourcePaths !== "function") {
|
|
353
|
+
return null;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const result = await store.listActiveSourcePaths(collection, max);
|
|
357
|
+
if (!result.ok) {
|
|
358
|
+
if (result.error.code === "OVERFLOW") {
|
|
359
|
+
return { ok: true, value: [], overflow: true };
|
|
360
|
+
}
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
return { ok: true, value: result.value };
|
|
364
|
+
} catch (cause) {
|
|
365
|
+
return {
|
|
366
|
+
ok: false,
|
|
367
|
+
error: {
|
|
368
|
+
code: "QUERY_FAILED",
|
|
369
|
+
message:
|
|
370
|
+
cause instanceof Error
|
|
371
|
+
? cause.message
|
|
372
|
+
: "Root store inventory failed",
|
|
373
|
+
cause,
|
|
374
|
+
},
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function safeListDescendants(
|
|
380
|
+
store: SqliteAdapter,
|
|
381
|
+
storeCollection: string,
|
|
382
|
+
dir: string,
|
|
383
|
+
max: number
|
|
384
|
+
): Promise<StoreResult<string[]>> {
|
|
385
|
+
try {
|
|
386
|
+
return await store.listActiveDescendantSourcePaths(
|
|
387
|
+
storeCollection,
|
|
388
|
+
dir,
|
|
389
|
+
max
|
|
390
|
+
);
|
|
391
|
+
} catch (cause) {
|
|
392
|
+
return {
|
|
393
|
+
ok: false,
|
|
394
|
+
error: {
|
|
395
|
+
code: "QUERY_FAILED",
|
|
396
|
+
message:
|
|
397
|
+
cause instanceof Error
|
|
398
|
+
? cause.message
|
|
399
|
+
: "Descendant store query failed",
|
|
400
|
+
cause,
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
}
|