@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,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watcher event intake, pending queues, and flush timer scheduling.
|
|
3
|
+
*
|
|
4
|
+
* @module src/serve/watch-service-events
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// node:path — Bun has no path utilities
|
|
8
|
+
import { join, normalize } from "node:path";
|
|
9
|
+
|
|
10
|
+
import type { Collection } from "../config/types";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
WATCHER_FLUSH_DEBOUNCE_MS,
|
|
14
|
+
WATCHER_MAX_FLUSH_DELAY_MS,
|
|
15
|
+
WATCHER_RETRY_BACKOFF_MS,
|
|
16
|
+
classifyWatcherFilename,
|
|
17
|
+
} from "./watch-reconciliation";
|
|
18
|
+
import {
|
|
19
|
+
applyPendingForceFlags,
|
|
20
|
+
computeFlushDelay,
|
|
21
|
+
emptyPending,
|
|
22
|
+
queueDirtyHint,
|
|
23
|
+
queueExactPath,
|
|
24
|
+
type CollectionPending,
|
|
25
|
+
type PendingForceFlags,
|
|
26
|
+
} from "./watch-service-state";
|
|
27
|
+
|
|
28
|
+
export interface WatchEventHost {
|
|
29
|
+
disposed: () => boolean;
|
|
30
|
+
findCollection: (collectionName: string) => Collection | undefined;
|
|
31
|
+
clock: () => number;
|
|
32
|
+
suppressedPaths: Map<string, number>;
|
|
33
|
+
setLastEventAt: (iso: string) => void;
|
|
34
|
+
enqueueExact: (collectionName: string, relPath: string) => void;
|
|
35
|
+
enqueueDirty: (collectionName: string, hint: string) => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface WatchQueueHost {
|
|
39
|
+
disposed: () => boolean;
|
|
40
|
+
clock: () => number;
|
|
41
|
+
flushDebounceMs: number;
|
|
42
|
+
maxFlushDelayMs: number;
|
|
43
|
+
maxExactPaths: number;
|
|
44
|
+
maxDirtyHints: number;
|
|
45
|
+
pendingByCollection: Map<string, CollectionPending>;
|
|
46
|
+
flushDeadlineAt: Map<string, number>;
|
|
47
|
+
timers: Map<string, ReturnType<typeof setTimeout>>;
|
|
48
|
+
/** Collections with an explicit retry timer; finally must not bypass. */
|
|
49
|
+
retryScheduled: Set<string>;
|
|
50
|
+
snapshotReady: Map<string, boolean>;
|
|
51
|
+
inFlightSyncs: Set<Promise<void>>;
|
|
52
|
+
runFlush: (collectionName: string) => Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Classify a filesystem watch callback and enqueue exact or dirty work. */
|
|
56
|
+
export function handleWatchEvent(
|
|
57
|
+
host: WatchEventHost,
|
|
58
|
+
collectionName: string,
|
|
59
|
+
watchedRoot: string,
|
|
60
|
+
filename: string | Buffer | null | undefined
|
|
61
|
+
): void {
|
|
62
|
+
if (host.disposed()) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const currentCollection = host.findCollection(collectionName);
|
|
66
|
+
if (!currentCollection || normalize(currentCollection.path) !== watchedRoot) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const classified = classifyWatcherFilename(filename, currentCollection);
|
|
71
|
+
if (classified.kind === "reject" || classified.kind === "ignore") {
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (classified.kind === "exact") {
|
|
76
|
+
const fullPath = normalize(join(watchedRoot, classified.relPath));
|
|
77
|
+
const suppressedUntil = host.suppressedPaths.get(fullPath);
|
|
78
|
+
if (suppressedUntil && suppressedUntil > host.clock()) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
host.setLastEventAt(new Date(host.clock()).toISOString());
|
|
82
|
+
host.enqueueExact(collectionName, classified.relPath);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (classified.hint !== "") {
|
|
87
|
+
const fullPath = normalize(join(watchedRoot, classified.hint));
|
|
88
|
+
const suppressedUntil = host.suppressedPaths.get(fullPath);
|
|
89
|
+
if (suppressedUntil && suppressedUntil > host.clock()) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
host.setLastEventAt(new Date(host.clock()).toISOString());
|
|
94
|
+
host.enqueueDirty(collectionName, classified.hint);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function enqueueExactPath(
|
|
98
|
+
host: WatchQueueHost,
|
|
99
|
+
collectionName: string,
|
|
100
|
+
relPath: string
|
|
101
|
+
): void {
|
|
102
|
+
const pending =
|
|
103
|
+
host.pendingByCollection.get(collectionName) ?? emptyPending();
|
|
104
|
+
host.pendingByCollection.set(
|
|
105
|
+
collectionName,
|
|
106
|
+
queueExactPath(pending, relPath, host.maxExactPaths)
|
|
107
|
+
);
|
|
108
|
+
scheduleFlush(host, collectionName);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function enqueueDirtyHint(
|
|
112
|
+
host: WatchQueueHost,
|
|
113
|
+
collectionName: string,
|
|
114
|
+
hint: string
|
|
115
|
+
): void {
|
|
116
|
+
const pending =
|
|
117
|
+
host.pendingByCollection.get(collectionName) ?? emptyPending();
|
|
118
|
+
// Ambiguous events before baseline readiness must force store/disk reconcile.
|
|
119
|
+
if (!host.snapshotReady.get(collectionName)) {
|
|
120
|
+
pending.forceFallback = true;
|
|
121
|
+
}
|
|
122
|
+
host.pendingByCollection.set(
|
|
123
|
+
collectionName,
|
|
124
|
+
queueDirtyHint(pending, hint, host.maxDirtyHints)
|
|
125
|
+
);
|
|
126
|
+
scheduleFlush(host, collectionName);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Queue work without arming timers (used by failure requeue). */
|
|
130
|
+
function queueWithoutSchedule(
|
|
131
|
+
host: WatchQueueHost,
|
|
132
|
+
collectionName: string,
|
|
133
|
+
exact: string[],
|
|
134
|
+
dirty: string[],
|
|
135
|
+
forceFlags?: PendingForceFlags
|
|
136
|
+
): void {
|
|
137
|
+
let pending = host.pendingByCollection.get(collectionName) ?? emptyPending();
|
|
138
|
+
for (const path of exact) {
|
|
139
|
+
pending = queueExactPath(pending, path, host.maxExactPaths);
|
|
140
|
+
}
|
|
141
|
+
for (const hint of dirty) {
|
|
142
|
+
pending = queueDirtyHint(pending, hint, host.maxDirtyHints);
|
|
143
|
+
}
|
|
144
|
+
pending = applyPendingForceFlags(pending, forceFlags);
|
|
145
|
+
host.pendingByCollection.set(collectionName, pending);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function scheduleFlush(
|
|
149
|
+
host: WatchQueueHost,
|
|
150
|
+
collectionName: string
|
|
151
|
+
): void {
|
|
152
|
+
if (host.disposed()) {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
// Explicit retry owns the single timer; do not replace it with debounce.
|
|
156
|
+
if (host.retryScheduled.has(collectionName)) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const schedule = computeFlushDelay({
|
|
160
|
+
nowMs: host.clock(),
|
|
161
|
+
existingDeadlineAt: host.flushDeadlineAt.get(collectionName),
|
|
162
|
+
debounceMs: host.flushDebounceMs,
|
|
163
|
+
maxFlushDelayMs: host.maxFlushDelayMs,
|
|
164
|
+
});
|
|
165
|
+
host.flushDeadlineAt.set(collectionName, schedule.deadlineAt);
|
|
166
|
+
|
|
167
|
+
const existingTimer = host.timers.get(collectionName);
|
|
168
|
+
if (existingTimer) {
|
|
169
|
+
clearTimeout(existingTimer);
|
|
170
|
+
}
|
|
171
|
+
host.timers.set(
|
|
172
|
+
collectionName,
|
|
173
|
+
setTimeout(() => {
|
|
174
|
+
host.timers.delete(collectionName);
|
|
175
|
+
startFlush(host, collectionName);
|
|
176
|
+
}, schedule.delayMs)
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Begin a flush when the debounce fires. Dirty/overflow work waits for snapshot
|
|
182
|
+
* readiness; exact-only paths content-hash without a baseline.
|
|
183
|
+
*/
|
|
184
|
+
export function startFlush(host: WatchQueueHost, collectionName: string): void {
|
|
185
|
+
if (host.disposed()) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
const pending = host.pendingByCollection.get(collectionName);
|
|
189
|
+
const exactOnly =
|
|
190
|
+
pending !== undefined &&
|
|
191
|
+
pending.exact.size > 0 &&
|
|
192
|
+
pending.dirty.size === 0 &&
|
|
193
|
+
!pending.overflow &&
|
|
194
|
+
!pending.forceFallback &&
|
|
195
|
+
!pending.generationReconcile;
|
|
196
|
+
// Exact eligible paths content-hash without a snapshot baseline. Dirty /
|
|
197
|
+
// overflow / init-fallback / generation work waits so init-time ambiguous
|
|
198
|
+
// events reconcile against a newer generation rather than empty unproven state.
|
|
199
|
+
// Leave work queued with no timer; onReadyWithPending schedules exactly once.
|
|
200
|
+
if (!host.snapshotReady.get(collectionName) && !exactOnly) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const sync = host.runFlush(collectionName);
|
|
204
|
+
host.inFlightSyncs.add(sync);
|
|
205
|
+
void sync
|
|
206
|
+
.finally(() => {
|
|
207
|
+
host.inFlightSyncs.delete(sync);
|
|
208
|
+
})
|
|
209
|
+
.catch(() => undefined);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Re-arm pending work after a file-level failure with retry backoff.
|
|
214
|
+
* At most one retry timer per collection; finally must not startFlush while set.
|
|
215
|
+
* forceFallback/overflow survive until forced classification + sync succeed.
|
|
216
|
+
*/
|
|
217
|
+
export function requeueAfterFailure(
|
|
218
|
+
host: WatchQueueHost,
|
|
219
|
+
collectionName: string,
|
|
220
|
+
exact: string[],
|
|
221
|
+
dirty: string[],
|
|
222
|
+
forceFlags?: PendingForceFlags
|
|
223
|
+
): void {
|
|
224
|
+
queueWithoutSchedule(host, collectionName, exact, dirty, forceFlags);
|
|
225
|
+
if (host.disposed()) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
if (host.retryScheduled.has(collectionName)) {
|
|
229
|
+
// Pending already merged; existing retry timer remains the sole attempt.
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
host.retryScheduled.add(collectionName);
|
|
233
|
+
const existingTimer = host.timers.get(collectionName);
|
|
234
|
+
if (existingTimer) {
|
|
235
|
+
clearTimeout(existingTimer);
|
|
236
|
+
}
|
|
237
|
+
host.flushDeadlineAt.set(collectionName, host.clock() + host.maxFlushDelayMs);
|
|
238
|
+
host.timers.set(
|
|
239
|
+
collectionName,
|
|
240
|
+
setTimeout(() => {
|
|
241
|
+
host.timers.delete(collectionName);
|
|
242
|
+
host.retryScheduled.delete(collectionName);
|
|
243
|
+
startFlush(host, collectionName);
|
|
244
|
+
}, WATCHER_RETRY_BACKOFF_MS)
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** Mark durable generation-reconcile work and schedule a single retry. */
|
|
249
|
+
export function requeueGenerationReconcile(
|
|
250
|
+
host: WatchQueueHost,
|
|
251
|
+
collectionName: string
|
|
252
|
+
): void {
|
|
253
|
+
const pending =
|
|
254
|
+
host.pendingByCollection.get(collectionName) ?? emptyPending();
|
|
255
|
+
pending.generationReconcile = true;
|
|
256
|
+
host.pendingByCollection.set(collectionName, pending);
|
|
257
|
+
requeueAfterFailure(host, collectionName, [], []);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Re-export defaults used by the service constructor for a single import site.
|
|
261
|
+
export { WATCHER_FLUSH_DEBOUNCE_MS, WATCHER_MAX_FLUSH_DELAY_MS };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config-generation / overflow / unsupported-FS full collection reconcile.
|
|
3
|
+
*
|
|
4
|
+
* @module src/serve/watch-service-flush-generation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// node:path — Bun has no path utilities
|
|
8
|
+
import { normalize } from "node:path";
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
FlushCollectionInput,
|
|
12
|
+
FlushCollectionOutcome,
|
|
13
|
+
} from "./watch-service-flush";
|
|
14
|
+
|
|
15
|
+
import { defaultSyncService } from "../ingestion";
|
|
16
|
+
import { hasFileLevelSyncError } from "./watch-reconciliation";
|
|
17
|
+
import {
|
|
18
|
+
contentChangedPaths,
|
|
19
|
+
notifyCompletedSync,
|
|
20
|
+
} from "./watch-service-flush-helpers";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* When collection generation advanced during/before flush, run full
|
|
24
|
+
* syncCollection. Failures leave durable generation work and never advance
|
|
25
|
+
* snapshot ownership. Options are read fresh each iteration.
|
|
26
|
+
*
|
|
27
|
+
* If generation/root/options change again after a completed syncCollection,
|
|
28
|
+
* continue with the latest collection/options rather than returning stale with
|
|
29
|
+
* empty pending.
|
|
30
|
+
*/
|
|
31
|
+
export async function runGenerationReconcile(
|
|
32
|
+
input: FlushCollectionInput
|
|
33
|
+
): Promise<FlushCollectionOutcome | null> {
|
|
34
|
+
let completedGeneration: number | null = null;
|
|
35
|
+
let needsWork =
|
|
36
|
+
input.generationReconcile ||
|
|
37
|
+
input.getCurrentGeneration() !== input.ownerGeneration;
|
|
38
|
+
|
|
39
|
+
while (needsWork) {
|
|
40
|
+
const currentCollection = input.getCurrentCollection();
|
|
41
|
+
if (!currentCollection) {
|
|
42
|
+
return { status: "stale" };
|
|
43
|
+
}
|
|
44
|
+
const currentGeneration = input.getCurrentGeneration();
|
|
45
|
+
const currentRoot = normalize(currentCollection.path);
|
|
46
|
+
|
|
47
|
+
// Already reconciled this generation and nothing newer is pending.
|
|
48
|
+
if (
|
|
49
|
+
completedGeneration !== null &&
|
|
50
|
+
completedGeneration === currentGeneration
|
|
51
|
+
) {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
// Always use live options — content rules may change mid-generation.
|
|
57
|
+
const result = await defaultSyncService.syncCollection(
|
|
58
|
+
currentCollection,
|
|
59
|
+
input.store,
|
|
60
|
+
{
|
|
61
|
+
...input.getCurrentSyncOptions(),
|
|
62
|
+
runUpdateCmd: false,
|
|
63
|
+
}
|
|
64
|
+
);
|
|
65
|
+
if (input.disposed()) {
|
|
66
|
+
return { status: "disposed" };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const stillCurrent = input.getCurrentCollection();
|
|
70
|
+
if (!stillCurrent) {
|
|
71
|
+
return { status: "stale" };
|
|
72
|
+
}
|
|
73
|
+
const latestGen = input.getCurrentGeneration();
|
|
74
|
+
const latestRoot = normalize(stillCurrent.path);
|
|
75
|
+
|
|
76
|
+
// Root replaced mid-reconcile: durable requeue for the new owner.
|
|
77
|
+
if (latestRoot !== currentRoot) {
|
|
78
|
+
input.requeueGeneration();
|
|
79
|
+
return { status: "stale" };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Generation advanced during syncCollection: continue with latest, no
|
|
83
|
+
// intermediate snapshot/callback commit for the superseded generation.
|
|
84
|
+
if (latestGen !== currentGeneration) {
|
|
85
|
+
needsWork = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ownership = {
|
|
90
|
+
generation: currentGeneration,
|
|
91
|
+
root: currentRoot,
|
|
92
|
+
};
|
|
93
|
+
// Operation scope: file receipts when present, else empty (full coll).
|
|
94
|
+
const operationPaths =
|
|
95
|
+
result.files?.map((file) => file.relPath) ??
|
|
96
|
+
contentChangedPaths(result);
|
|
97
|
+
|
|
98
|
+
if (hasFileLevelSyncError(result)) {
|
|
99
|
+
// Notify completed sync once; keep durable generation work.
|
|
100
|
+
notifyCompletedSync(input, operationPaths, result, ownership);
|
|
101
|
+
const error = new Error(
|
|
102
|
+
"One or more paths failed during watcher generation reconcile"
|
|
103
|
+
);
|
|
104
|
+
input.onSyncError([], error);
|
|
105
|
+
input.requeueGeneration();
|
|
106
|
+
return { status: "failed", error };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Only after successful full reconcile: rebuild snapshot ownership.
|
|
110
|
+
input.invalidateSnapshot(stillCurrent);
|
|
111
|
+
notifyCompletedSync(input, operationPaths, result, ownership);
|
|
112
|
+
completedGeneration = currentGeneration;
|
|
113
|
+
needsWork = input.getCurrentGeneration() !== completedGeneration;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
if (input.disposed()) {
|
|
116
|
+
return { status: "disposed" };
|
|
117
|
+
}
|
|
118
|
+
const stillCurrent = input.getCurrentCollection();
|
|
119
|
+
if (!stillCurrent) {
|
|
120
|
+
return { status: "stale" };
|
|
121
|
+
}
|
|
122
|
+
const latestGen = input.getCurrentGeneration();
|
|
123
|
+
const latestRoot = normalize(stillCurrent.path);
|
|
124
|
+
if (latestRoot !== currentRoot) {
|
|
125
|
+
input.requeueGeneration();
|
|
126
|
+
return { status: "stale" };
|
|
127
|
+
}
|
|
128
|
+
// Gen advanced under a thrown reconcile: continue toward latest rather
|
|
129
|
+
// than return stale with empty pending.
|
|
130
|
+
if (latestGen !== currentGeneration) {
|
|
131
|
+
needsWork = true;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
input.onSyncError([], error);
|
|
135
|
+
input.requeueGeneration();
|
|
136
|
+
return { status: "failed", error };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settlement helpers for one collection watcher flush.
|
|
3
|
+
*
|
|
4
|
+
* @module src/serve/watch-service-flush-helpers
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// node:path — Bun has no path utilities
|
|
8
|
+
import { normalize } from "node:path";
|
|
9
|
+
|
|
10
|
+
import type { Collection } from "../config/types";
|
|
11
|
+
import type { CollectionSyncResult } from "../ingestion";
|
|
12
|
+
|
|
13
|
+
import { collectionToWalkConfig, matchesWalkPath } from "../ingestion";
|
|
14
|
+
import {
|
|
15
|
+
failedSyncPaths,
|
|
16
|
+
successfulChangedPaths,
|
|
17
|
+
} from "./watch-reconciliation";
|
|
18
|
+
|
|
19
|
+
export interface FlushNotifyInput {
|
|
20
|
+
disposed: () => boolean;
|
|
21
|
+
getCurrentCollection: () => Collection | undefined;
|
|
22
|
+
getCurrentGeneration: () => number;
|
|
23
|
+
ownerGeneration: number;
|
|
24
|
+
ownerRoot: string;
|
|
25
|
+
onSyncComplete: (relPaths: string[], result: CollectionSyncResult) => void;
|
|
26
|
+
onAfterSync: (collection: Collection, relPaths: string[]) => void;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Paths that content-changed (added/updated). Used for scheduler/events.
|
|
31
|
+
* Does not include unchanged or pure inactive-only summary counts.
|
|
32
|
+
*/
|
|
33
|
+
export function contentChangedPaths(
|
|
34
|
+
result: CollectionSyncResult,
|
|
35
|
+
fallbackPaths: string[] = []
|
|
36
|
+
): string[] {
|
|
37
|
+
const fromFiles = successfulChangedPaths(result);
|
|
38
|
+
if (result.files) {
|
|
39
|
+
return fromFiles;
|
|
40
|
+
}
|
|
41
|
+
return result.filesAdded + result.filesUpdated > 0 ? fallbackPaths : [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* onSyncComplete fires exactly once per completed sync result with the
|
|
46
|
+
* operation path scope. onAfterSync/scheduler remain ownership-checked and
|
|
47
|
+
* limited to successful added/updated paths.
|
|
48
|
+
*/
|
|
49
|
+
export function notifyCompletedSync(
|
|
50
|
+
input: FlushNotifyInput,
|
|
51
|
+
operationPaths: string[],
|
|
52
|
+
result: CollectionSyncResult,
|
|
53
|
+
ownership?: { generation: number; root: string }
|
|
54
|
+
): string[] {
|
|
55
|
+
input.onSyncComplete(operationPaths, result);
|
|
56
|
+
const changed = contentChangedPaths(result, operationPaths);
|
|
57
|
+
if (changed.length === 0 || input.disposed()) {
|
|
58
|
+
return changed;
|
|
59
|
+
}
|
|
60
|
+
const currentCollection = input.getCurrentCollection();
|
|
61
|
+
if (!currentCollection) {
|
|
62
|
+
return changed;
|
|
63
|
+
}
|
|
64
|
+
const expectedGen = ownership?.generation ?? input.ownerGeneration;
|
|
65
|
+
const expectedRoot = ownership?.root ?? input.ownerRoot;
|
|
66
|
+
if (
|
|
67
|
+
input.getCurrentGeneration() !== expectedGen ||
|
|
68
|
+
normalize(currentCollection.path) !== expectedRoot
|
|
69
|
+
) {
|
|
70
|
+
return changed;
|
|
71
|
+
}
|
|
72
|
+
const filtered = changed.filter((relPath) =>
|
|
73
|
+
matchesWalkPath(relPath, collectionToWalkConfig(currentCollection, 0))
|
|
74
|
+
);
|
|
75
|
+
if (filtered.length > 0) {
|
|
76
|
+
input.onAfterSync(currentCollection, filtered);
|
|
77
|
+
}
|
|
78
|
+
return changed;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Retry authority after a targeted sync. Top-level result.errors retain the
|
|
83
|
+
* original exact operation even when every file receipt succeeded, unless
|
|
84
|
+
* durable dirty or generation authority already covers the work.
|
|
85
|
+
*
|
|
86
|
+
* Dirty-derived candidate/removal failures retain the original dirty hints and
|
|
87
|
+
* force flags until a full classified generation succeeds and nextSnapshot
|
|
88
|
+
* commits — never replace dirty authority solely with the failed exact path.
|
|
89
|
+
*/
|
|
90
|
+
export function computeTargetedRetry(options: {
|
|
91
|
+
result: CollectionSyncResult;
|
|
92
|
+
submittedPaths: readonly string[];
|
|
93
|
+
liveExact: readonly string[];
|
|
94
|
+
settled: ReadonlySet<string>;
|
|
95
|
+
dirtyHints: readonly string[];
|
|
96
|
+
dirtyFailed: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* True when this submission included dirty-classified candidates or
|
|
99
|
+
* proven removals (not merely live exact paths).
|
|
100
|
+
*/
|
|
101
|
+
dirtyDerivedSubmission: boolean;
|
|
102
|
+
generationAuthority: boolean;
|
|
103
|
+
}): { retryExact: string[]; retainDirty: boolean } {
|
|
104
|
+
const {
|
|
105
|
+
result,
|
|
106
|
+
submittedPaths,
|
|
107
|
+
liveExact,
|
|
108
|
+
settled,
|
|
109
|
+
dirtyHints,
|
|
110
|
+
dirtyFailed,
|
|
111
|
+
dirtyDerivedSubmission,
|
|
112
|
+
generationAuthority,
|
|
113
|
+
} = options;
|
|
114
|
+
const failed = failedSyncPaths(result, submittedPaths).filter(
|
|
115
|
+
(path) => !settled.has(path)
|
|
116
|
+
);
|
|
117
|
+
const topLevelErrors = result.errors.length > 0;
|
|
118
|
+
const dirtyDerivedFailure =
|
|
119
|
+
dirtyDerivedSubmission &&
|
|
120
|
+
(failed.length > 0 || topLevelErrors || result.filesErrored > 0);
|
|
121
|
+
// Retain original dirty hints until classified work fully succeeds.
|
|
122
|
+
const retainDirty =
|
|
123
|
+
dirtyHints.length > 0 &&
|
|
124
|
+
(dirtyFailed || dirtyDerivedFailure || topLevelErrors);
|
|
125
|
+
|
|
126
|
+
if (generationAuthority) {
|
|
127
|
+
// Full-collection reconcile covers exact + dirty authority.
|
|
128
|
+
return { retryExact: failed, retainDirty: false };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (topLevelErrors) {
|
|
132
|
+
// Projection/top-level failure: requeue original exact paths unless
|
|
133
|
+
// dirty-only operation (no exact) already retains authority via dirty.
|
|
134
|
+
if (liveExact.length === 0) {
|
|
135
|
+
return { retryExact: failed, retainDirty: true };
|
|
136
|
+
}
|
|
137
|
+
// Exact (and mixed exact+dirty): requeue all original exact for projection.
|
|
138
|
+
// Successful content changes settle via afterSync only once; requeue still
|
|
139
|
+
// re-runs authority so top-level errors can clear.
|
|
140
|
+
return {
|
|
141
|
+
retryExact: [...new Set([...failed, ...liveExact])],
|
|
142
|
+
retainDirty,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return { retryExact: failed, retainDirty };
|
|
147
|
+
}
|