@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,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collection watcher lifecycle: fingerprinting and updateCollections.
|
|
3
|
+
*
|
|
4
|
+
* @module src/serve/watch-service-lifecycle
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { FSWatcher } from "node:fs";
|
|
8
|
+
|
|
9
|
+
// node:path — Bun has no path utilities
|
|
10
|
+
import { normalize } from "node:path";
|
|
11
|
+
|
|
12
|
+
import type { Collection } from "../config/types";
|
|
13
|
+
import type { SyncOptions } from "../ingestion";
|
|
14
|
+
import type { WatcherSnapshot } from "./watch-snapshot";
|
|
15
|
+
|
|
16
|
+
import { emptyPending, type CollectionPending } from "./watch-service-state";
|
|
17
|
+
|
|
18
|
+
export interface WatchLifecycleHost {
|
|
19
|
+
disposed: () => boolean;
|
|
20
|
+
getCollections: () => Collection[];
|
|
21
|
+
setCollections: (collections: Collection[]) => void;
|
|
22
|
+
getSyncOptions: () => SyncOptions;
|
|
23
|
+
setSyncOptions: (syncOptions: SyncOptions) => void;
|
|
24
|
+
watchers: Map<string, FSWatcher>;
|
|
25
|
+
watchRoots: Map<string, string>;
|
|
26
|
+
collectionFingerprints: Map<string, string>;
|
|
27
|
+
collectionGenerations: Map<string, number>;
|
|
28
|
+
nextGeneration: () => number;
|
|
29
|
+
failedCollections: Map<string, string>;
|
|
30
|
+
snapshots: Map<string, WatcherSnapshot>;
|
|
31
|
+
snapshotReady: Map<string, boolean>;
|
|
32
|
+
snapshotInit: Map<string, Promise<void>>;
|
|
33
|
+
/** Collections currently mid-flush (ABA ownership). */
|
|
34
|
+
syncing: Set<string>;
|
|
35
|
+
/**
|
|
36
|
+
* Pending work by collection. Config/generation invalidation marks dirty work
|
|
37
|
+
* forceFallback + durable generation reconcile so a new baseline cannot absorb it.
|
|
38
|
+
*/
|
|
39
|
+
pendingByCollection: Map<string, CollectionPending>;
|
|
40
|
+
clearCollectionRuntimeState: (collectionName: string) => void;
|
|
41
|
+
beginSnapshotInit: (collection: Collection) => void;
|
|
42
|
+
watchFactory: (
|
|
43
|
+
path: string,
|
|
44
|
+
options: { recursive: boolean },
|
|
45
|
+
listener: (
|
|
46
|
+
eventType: string,
|
|
47
|
+
filename: string | Buffer | null | undefined
|
|
48
|
+
) => void
|
|
49
|
+
) => FSWatcher;
|
|
50
|
+
onWatchEvent: (
|
|
51
|
+
collectionName: string,
|
|
52
|
+
watchedRoot: string,
|
|
53
|
+
filename: string | Buffer | null | undefined
|
|
54
|
+
) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function watcherCollectionFingerprint(
|
|
58
|
+
collection: Collection,
|
|
59
|
+
syncOptions: SyncOptions
|
|
60
|
+
): string {
|
|
61
|
+
return JSON.stringify({
|
|
62
|
+
path: normalize(collection.path),
|
|
63
|
+
pattern: collection.pattern,
|
|
64
|
+
include: collection.include,
|
|
65
|
+
exclude: collection.exclude,
|
|
66
|
+
languageHint: collection.languageHint ?? null,
|
|
67
|
+
recordAdapters: collection.recordAdapters ?? null,
|
|
68
|
+
limits: syncOptions.limits ?? null,
|
|
69
|
+
concurrency: syncOptions.concurrency ?? null,
|
|
70
|
+
contentTypeRules: syncOptions.contentTypeRules ?? null,
|
|
71
|
+
contentTypeRulesFingerprint:
|
|
72
|
+
syncOptions.contentTypeRulesFingerprint ?? null,
|
|
73
|
+
projectTypedEdges: syncOptions.projectTypedEdges ?? null,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Drop generation/failed tombstones when the name is no longer configured and
|
|
79
|
+
* no in-flight flush still needs the ownership token.
|
|
80
|
+
*/
|
|
81
|
+
export function clearLifecycleTombstones(
|
|
82
|
+
host: WatchLifecycleHost,
|
|
83
|
+
collectionName: string
|
|
84
|
+
): void {
|
|
85
|
+
if (host.syncing.has(collectionName)) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (host.getCollections().some((entry) => entry.name === collectionName)) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
host.collectionGenerations.delete(collectionName);
|
|
92
|
+
host.failedCollections.delete(collectionName);
|
|
93
|
+
host.collectionFingerprints.delete(collectionName);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Reconcile active watchers with the desired collection set.
|
|
98
|
+
* Closes removed/moved roots, bumps generations on config change, and starts
|
|
99
|
+
* new recursive watchers (capturing events before snapshot baseline init).
|
|
100
|
+
*/
|
|
101
|
+
export function applyCollectionUpdate(
|
|
102
|
+
host: WatchLifecycleHost,
|
|
103
|
+
collections: Collection[],
|
|
104
|
+
syncOptions?: SyncOptions
|
|
105
|
+
): void {
|
|
106
|
+
if (host.disposed()) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (syncOptions) {
|
|
110
|
+
host.setSyncOptions(syncOptions);
|
|
111
|
+
}
|
|
112
|
+
const nextByName = new Map(
|
|
113
|
+
collections.map((collection) => [collection.name, collection])
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
for (const [collectionName, watcher] of host.watchers) {
|
|
117
|
+
const nextCollection = nextByName.get(collectionName);
|
|
118
|
+
const nextRoot = nextCollection
|
|
119
|
+
? normalize(nextCollection.path)
|
|
120
|
+
: undefined;
|
|
121
|
+
if (
|
|
122
|
+
nextRoot === undefined ||
|
|
123
|
+
nextRoot !== host.watchRoots.get(collectionName)
|
|
124
|
+
) {
|
|
125
|
+
watcher.close();
|
|
126
|
+
host.watchers.delete(collectionName);
|
|
127
|
+
host.clearCollectionRuntimeState(collectionName);
|
|
128
|
+
if (nextRoot === undefined) {
|
|
129
|
+
// Removed: bump generation for in-flight ABA, then clear if idle.
|
|
130
|
+
host.collectionGenerations.set(collectionName, host.nextGeneration());
|
|
131
|
+
host.failedCollections.delete(collectionName);
|
|
132
|
+
clearLifecycleTombstones(host, collectionName);
|
|
133
|
+
} else {
|
|
134
|
+
host.failedCollections.delete(collectionName);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Fingerprints / generations / failed for names no longer configured.
|
|
140
|
+
for (const collectionName of [
|
|
141
|
+
...host.collectionFingerprints.keys(),
|
|
142
|
+
...host.collectionGenerations.keys(),
|
|
143
|
+
...host.failedCollections.keys(),
|
|
144
|
+
]) {
|
|
145
|
+
if (nextByName.has(collectionName)) {
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
host.collectionFingerprints.delete(collectionName);
|
|
149
|
+
if (!host.collectionGenerations.has(collectionName)) {
|
|
150
|
+
host.collectionGenerations.set(collectionName, host.nextGeneration());
|
|
151
|
+
} else if (!host.syncing.has(collectionName)) {
|
|
152
|
+
host.collectionGenerations.set(collectionName, host.nextGeneration());
|
|
153
|
+
}
|
|
154
|
+
host.clearCollectionRuntimeState(collectionName);
|
|
155
|
+
clearLifecycleTombstones(host, collectionName);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
host.setCollections(collections);
|
|
159
|
+
for (const collection of collections) {
|
|
160
|
+
const fingerprint = watcherCollectionFingerprint(
|
|
161
|
+
collection,
|
|
162
|
+
host.getSyncOptions()
|
|
163
|
+
);
|
|
164
|
+
const previousFingerprint = host.collectionFingerprints.get(
|
|
165
|
+
collection.name
|
|
166
|
+
);
|
|
167
|
+
if (previousFingerprint === fingerprint) {
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
host.collectionFingerprints.set(collection.name, fingerprint);
|
|
171
|
+
host.collectionGenerations.set(collection.name, host.nextGeneration());
|
|
172
|
+
host.snapshots.delete(collection.name);
|
|
173
|
+
host.snapshotReady.set(collection.name, false);
|
|
174
|
+
host.snapshotInit.delete(collection.name);
|
|
175
|
+
// Initial start (no prior fingerprint): snapshot only — no full sync.
|
|
176
|
+
// Every later material change (idle same-root, root replacement, exact-only
|
|
177
|
+
// pending, dirty work) durably enqueues generation reconciliation.
|
|
178
|
+
if (previousFingerprint !== undefined) {
|
|
179
|
+
const pending =
|
|
180
|
+
host.pendingByCollection.get(collection.name) ?? emptyPending();
|
|
181
|
+
pending.generationReconcile = true;
|
|
182
|
+
// Queued ambiguous work must not be absorbed by the new baseline.
|
|
183
|
+
if (pending.dirty.size > 0 || pending.overflow || pending.forceFallback) {
|
|
184
|
+
pending.forceFallback = true;
|
|
185
|
+
}
|
|
186
|
+
host.pendingByCollection.set(collection.name, pending);
|
|
187
|
+
}
|
|
188
|
+
if (host.watchers.has(collection.name)) {
|
|
189
|
+
host.beginSnapshotInit(collection);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const collection of host.getCollections()) {
|
|
194
|
+
if (host.watchers.has(collection.name)) {
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const watchedRoot = normalize(collection.path);
|
|
199
|
+
// Capture events BEFORE async snapshot baseline construction.
|
|
200
|
+
const watcher = host.watchFactory(
|
|
201
|
+
collection.path,
|
|
202
|
+
{ recursive: true },
|
|
203
|
+
(_eventType, filename) => {
|
|
204
|
+
host.onWatchEvent(collection.name, watchedRoot, filename);
|
|
205
|
+
}
|
|
206
|
+
);
|
|
207
|
+
host.watchers.set(collection.name, watcher);
|
|
208
|
+
host.watchRoots.set(collection.name, watchedRoot);
|
|
209
|
+
host.failedCollections.delete(collection.name);
|
|
210
|
+
host.snapshotReady.set(collection.name, false);
|
|
211
|
+
host.beginSnapshotInit(collection);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
host.failedCollections.set(
|
|
214
|
+
collection.name,
|
|
215
|
+
error instanceof Error ? error.message : "watch unavailable"
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owned collection flush orchestration for CollectionWatchService.
|
|
3
|
+
*
|
|
4
|
+
* @module src/serve/watch-service-run-flush
|
|
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, SyncOptions } from "../ingestion";
|
|
12
|
+
import type { SqliteAdapter } from "../store/sqlite/adapter";
|
|
13
|
+
import type { WatchQueueHost } from "./watch-service-events";
|
|
14
|
+
import type { CollectionPending } from "./watch-service-state";
|
|
15
|
+
import type { WatcherSnapshot, WatcherSnapshotFs } from "./watch-snapshot";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
requeueAfterFailure,
|
|
19
|
+
requeueGenerationReconcile,
|
|
20
|
+
startFlush,
|
|
21
|
+
} from "./watch-service-events";
|
|
22
|
+
import { flushCollectionOnce } from "./watch-service-flush";
|
|
23
|
+
import {
|
|
24
|
+
emptyPending,
|
|
25
|
+
pendingHasWork,
|
|
26
|
+
takePending,
|
|
27
|
+
} from "./watch-service-state";
|
|
28
|
+
|
|
29
|
+
export interface RunFlushContext {
|
|
30
|
+
collectionName: string;
|
|
31
|
+
disposed: () => boolean;
|
|
32
|
+
collections: () => Collection[];
|
|
33
|
+
store: SqliteAdapter;
|
|
34
|
+
syncOptions: () => SyncOptions;
|
|
35
|
+
pendingByCollection: Map<string, CollectionPending>;
|
|
36
|
+
flushDeadlineAt: Map<string, number>;
|
|
37
|
+
syncing: Set<string>;
|
|
38
|
+
retryScheduled: Set<string>;
|
|
39
|
+
collectionGenerations: Map<string, number>;
|
|
40
|
+
snapshots: Map<string, WatcherSnapshot>;
|
|
41
|
+
snapshotReady: Map<string, boolean>;
|
|
42
|
+
snapshotInit: Map<string, Promise<void>>;
|
|
43
|
+
suppressedPaths: Map<string, number>;
|
|
44
|
+
clock: () => number;
|
|
45
|
+
queueHost: WatchQueueHost;
|
|
46
|
+
callbacks: {
|
|
47
|
+
onSyncStart?: (event: { collection: string; relPaths: string[] }) => void;
|
|
48
|
+
onSyncComplete?: (event: {
|
|
49
|
+
collection: string;
|
|
50
|
+
relPaths: string[];
|
|
51
|
+
result: CollectionSyncResult;
|
|
52
|
+
}) => void;
|
|
53
|
+
onSyncError?: (event: {
|
|
54
|
+
collection: string;
|
|
55
|
+
relPaths: string[];
|
|
56
|
+
error: unknown;
|
|
57
|
+
}) => void;
|
|
58
|
+
} | null;
|
|
59
|
+
onAfterSync: (collection: Collection, relPaths: string[]) => void;
|
|
60
|
+
beginSnapshotInit: (collection: Collection) => void;
|
|
61
|
+
clearLifecycleTombstones: (collectionName: string) => void;
|
|
62
|
+
pruneSuppression: () => void;
|
|
63
|
+
notifySettledIfIdle: () => void;
|
|
64
|
+
/** Optional injectable FS for unsupported-handle / test seams. */
|
|
65
|
+
snapshotFs?: WatcherSnapshotFs;
|
|
66
|
+
/** Test seam: lower snapshot entry ceiling for overflow→full proofs. */
|
|
67
|
+
snapshotEntryCeiling?: number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Drain one collection's pending work under a generation+root ownership token.
|
|
72
|
+
*/
|
|
73
|
+
export async function runOwnedCollectionFlush(
|
|
74
|
+
ctx: RunFlushContext
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const { collectionName } = ctx;
|
|
77
|
+
if (ctx.disposed()) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const pending = ctx.pendingByCollection.get(collectionName);
|
|
81
|
+
if (!pendingHasWork(pending)) {
|
|
82
|
+
ctx.flushDeadlineAt.delete(collectionName);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
if (ctx.syncing.has(collectionName) || !pending) {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const collection = ctx
|
|
90
|
+
.collections()
|
|
91
|
+
.find((entry) => entry.name === collectionName);
|
|
92
|
+
if (!collection) {
|
|
93
|
+
ctx.pendingByCollection.delete(collectionName);
|
|
94
|
+
ctx.flushDeadlineAt.delete(collectionName);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const taken = takePending(pending);
|
|
99
|
+
ctx.pendingByCollection.set(collectionName, emptyPending());
|
|
100
|
+
ctx.flushDeadlineAt.delete(collectionName);
|
|
101
|
+
ctx.syncing.add(collectionName);
|
|
102
|
+
|
|
103
|
+
const ownerGeneration = ctx.collectionGenerations.get(collectionName) ?? 0;
|
|
104
|
+
const ownerRoot = normalize(collection.path);
|
|
105
|
+
const stillOwner = (): boolean => {
|
|
106
|
+
if (ctx.disposed()) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
const current = ctx
|
|
110
|
+
.collections()
|
|
111
|
+
.find((entry) => entry.name === collectionName);
|
|
112
|
+
return (
|
|
113
|
+
current !== undefined &&
|
|
114
|
+
(ctx.collectionGenerations.get(collectionName) ?? 0) ===
|
|
115
|
+
ownerGeneration &&
|
|
116
|
+
normalize(current.path) === ownerRoot
|
|
117
|
+
);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const outcome = await flushCollectionOnce({
|
|
122
|
+
collection,
|
|
123
|
+
collectionName,
|
|
124
|
+
store: ctx.store,
|
|
125
|
+
syncOptions: ctx.syncOptions(),
|
|
126
|
+
exactTaken: taken.exact,
|
|
127
|
+
dirtyTaken: taken.dirty,
|
|
128
|
+
forceFallback: taken.forceFallback,
|
|
129
|
+
overflow: taken.overflow,
|
|
130
|
+
generationReconcile: taken.generationReconcile,
|
|
131
|
+
previousSnapshot: ctx.snapshots.get(collectionName) ?? null,
|
|
132
|
+
ownerGeneration,
|
|
133
|
+
ownerRoot,
|
|
134
|
+
disposed: ctx.disposed,
|
|
135
|
+
getCurrentCollection: () =>
|
|
136
|
+
ctx.collections().find((entry) => entry.name === collectionName),
|
|
137
|
+
getCurrentGeneration: () =>
|
|
138
|
+
ctx.collectionGenerations.get(collectionName) ?? 0,
|
|
139
|
+
getCurrentSyncOptions: ctx.syncOptions,
|
|
140
|
+
clock: ctx.clock,
|
|
141
|
+
suppressedPaths: ctx.suppressedPaths,
|
|
142
|
+
snapshotFs: ctx.snapshotFs,
|
|
143
|
+
snapshotEntryCeiling: ctx.snapshotEntryCeiling,
|
|
144
|
+
onSyncStart: (relPaths) => {
|
|
145
|
+
if (!stillOwner()) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
ctx.callbacks?.onSyncStart?.({
|
|
149
|
+
collection: collection.name,
|
|
150
|
+
relPaths,
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
onSyncComplete: (relPaths, result) => {
|
|
154
|
+
ctx.callbacks?.onSyncComplete?.({
|
|
155
|
+
collection: collectionName,
|
|
156
|
+
relPaths,
|
|
157
|
+
result,
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
onSyncError: (relPaths, error) => {
|
|
161
|
+
ctx.callbacks?.onSyncError?.({
|
|
162
|
+
collection: collectionName,
|
|
163
|
+
relPaths,
|
|
164
|
+
error,
|
|
165
|
+
});
|
|
166
|
+
},
|
|
167
|
+
onAfterSync: ctx.onAfterSync,
|
|
168
|
+
commitSnapshot: (snapshot) => {
|
|
169
|
+
if (!stillOwner()) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
ctx.snapshots.set(collectionName, snapshot);
|
|
173
|
+
},
|
|
174
|
+
invalidateSnapshot: (current) => {
|
|
175
|
+
const live = ctx
|
|
176
|
+
.collections()
|
|
177
|
+
.find((entry) => entry.name === collectionName);
|
|
178
|
+
if (!live || normalize(live.path) !== normalize(current.path)) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
ctx.snapshots.delete(collectionName);
|
|
182
|
+
ctx.snapshotReady.set(collectionName, false);
|
|
183
|
+
ctx.snapshotInit.delete(collectionName);
|
|
184
|
+
ctx.beginSnapshotInit(current);
|
|
185
|
+
},
|
|
186
|
+
requeue: (exact, dirty, forceFlags) => {
|
|
187
|
+
if (!stillOwner()) {
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
requeueAfterFailure(
|
|
191
|
+
ctx.queueHost,
|
|
192
|
+
collectionName,
|
|
193
|
+
exact,
|
|
194
|
+
dirty,
|
|
195
|
+
forceFlags
|
|
196
|
+
);
|
|
197
|
+
},
|
|
198
|
+
requeueGeneration: () => {
|
|
199
|
+
const live = ctx
|
|
200
|
+
.collections()
|
|
201
|
+
.find((entry) => entry.name === collectionName);
|
|
202
|
+
if (!live) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
requeueGenerationReconcile(ctx.queueHost, collectionName);
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
if (
|
|
209
|
+
outcome.status === "failed" &&
|
|
210
|
+
outcome.error &&
|
|
211
|
+
!(
|
|
212
|
+
outcome.error instanceof Error &&
|
|
213
|
+
(outcome.error.message ===
|
|
214
|
+
"One or more paths failed during watcher sync" ||
|
|
215
|
+
outcome.error.message ===
|
|
216
|
+
"One or more paths failed during watcher generation reconcile")
|
|
217
|
+
)
|
|
218
|
+
) {
|
|
219
|
+
throw outcome.error;
|
|
220
|
+
}
|
|
221
|
+
} finally {
|
|
222
|
+
ctx.syncing.delete(collectionName);
|
|
223
|
+
ctx.clearLifecycleTombstones(collectionName);
|
|
224
|
+
ctx.pruneSuppression();
|
|
225
|
+
if (!ctx.disposed()) {
|
|
226
|
+
if (
|
|
227
|
+
pendingHasWork(ctx.pendingByCollection.get(collectionName)) &&
|
|
228
|
+
!ctx.retryScheduled.has(collectionName)
|
|
229
|
+
) {
|
|
230
|
+
startFlush(ctx.queueHost, collectionName);
|
|
231
|
+
} else if (!pendingHasWork(ctx.pendingByCollection.get(collectionName))) {
|
|
232
|
+
ctx.notifySettledIfIdle();
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-collection snapshot baseline initialization for the resident watcher.
|
|
3
|
+
*
|
|
4
|
+
* @module src/serve/watch-service-snapshot
|
|
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 {
|
|
12
|
+
WatcherSnapshot,
|
|
13
|
+
WatcherSnapshotBuildResult,
|
|
14
|
+
WatcherSnapshotOptions,
|
|
15
|
+
} from "./watch-snapshot";
|
|
16
|
+
|
|
17
|
+
import { buildWatcherSnapshot } from "./watch-snapshot";
|
|
18
|
+
|
|
19
|
+
export interface SnapshotInitHost {
|
|
20
|
+
disposed: () => boolean;
|
|
21
|
+
getGeneration: (collectionName: string) => number;
|
|
22
|
+
getRoot: (collectionName: string) => string | undefined;
|
|
23
|
+
setSnapshot: (collectionName: string, snapshot: WatcherSnapshot) => void;
|
|
24
|
+
clearSnapshot: (collectionName: string) => void;
|
|
25
|
+
setReady: (collectionName: string, ready: boolean) => void;
|
|
26
|
+
getInit: (collectionName: string) => Promise<void> | undefined;
|
|
27
|
+
setInit: (collectionName: string, init: Promise<void> | undefined) => void;
|
|
28
|
+
onReadyWithPending: (collectionName: string) => void;
|
|
29
|
+
/** Optional injectable builder for hung/slow-init tests. */
|
|
30
|
+
buildSnapshot?: (
|
|
31
|
+
rootAbs: string,
|
|
32
|
+
options?: WatcherSnapshotOptions
|
|
33
|
+
) => Promise<WatcherSnapshotBuildResult>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Start (or supersede) baseline construction; events may already be buffering. */
|
|
37
|
+
export function beginSnapshotInit(
|
|
38
|
+
host: SnapshotInitHost,
|
|
39
|
+
collection: Collection
|
|
40
|
+
): void {
|
|
41
|
+
if (host.disposed()) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const generation = host.getGeneration(collection.name);
|
|
45
|
+
const root = normalize(collection.path);
|
|
46
|
+
let init!: Promise<void>;
|
|
47
|
+
init = runSnapshotInit(host, collection.name, root, generation, () => init);
|
|
48
|
+
host.setInit(collection.name, init);
|
|
49
|
+
void init.catch(() => undefined);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function runSnapshotInit(
|
|
53
|
+
host: SnapshotInitHost,
|
|
54
|
+
collectionName: string,
|
|
55
|
+
root: string,
|
|
56
|
+
generation: number,
|
|
57
|
+
getInit: () => Promise<void>
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
try {
|
|
60
|
+
const builder = host.buildSnapshot ?? buildWatcherSnapshot;
|
|
61
|
+
const built = await builder(root);
|
|
62
|
+
if (host.disposed()) {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (
|
|
66
|
+
host.getGeneration(collectionName) !== generation ||
|
|
67
|
+
host.getRoot(collectionName) !== root
|
|
68
|
+
) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (built.status === "ok") {
|
|
72
|
+
host.setSnapshot(collectionName, built.snapshot);
|
|
73
|
+
} else {
|
|
74
|
+
host.clearSnapshot(collectionName);
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
if (
|
|
78
|
+
!host.disposed() &&
|
|
79
|
+
host.getGeneration(collectionName) === generation &&
|
|
80
|
+
host.getRoot(collectionName) === root
|
|
81
|
+
) {
|
|
82
|
+
host.clearSnapshot(collectionName);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (host.getInit(collectionName) !== getInit()) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
host.setInit(collectionName, undefined);
|
|
90
|
+
if (host.disposed()) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
// Readiness flips even on init failure so forceFallback classification can run.
|
|
94
|
+
if (
|
|
95
|
+
host.getGeneration(collectionName) === generation &&
|
|
96
|
+
host.getRoot(collectionName) === root
|
|
97
|
+
) {
|
|
98
|
+
host.setReady(collectionName, true);
|
|
99
|
+
host.onReadyWithPending(collectionName);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-collection pending queues, caps, and flush scheduling helpers.
|
|
3
|
+
*
|
|
4
|
+
* @module src/serve/watch-service-state
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
WATCHER_MAX_DIRTY_HINTS,
|
|
9
|
+
WATCHER_MAX_EXACT_PATHS,
|
|
10
|
+
WATCHER_MAX_FLUSH_DELAY_MS,
|
|
11
|
+
WATCHER_FLUSH_DEBOUNCE_MS,
|
|
12
|
+
addToCappedSet,
|
|
13
|
+
} from "./watch-reconciliation";
|
|
14
|
+
|
|
15
|
+
export interface CollectionPending {
|
|
16
|
+
exact: Set<string>;
|
|
17
|
+
dirty: Set<string>;
|
|
18
|
+
/** When true, dirty overflow forced a root-wide fallback hint. */
|
|
19
|
+
overflow: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Ambiguous work arrived before snapshot readiness for this generation.
|
|
22
|
+
* Classification must use store/disk fallback so baseline absorption cannot
|
|
23
|
+
* suppress content-hash of present eligible finals.
|
|
24
|
+
*/
|
|
25
|
+
forceFallback: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Config-generation full reconcile failed or was interrupted; next flush must
|
|
28
|
+
* retry even if exact/dirty event hints are exhausted.
|
|
29
|
+
*/
|
|
30
|
+
generationReconcile: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function emptyPending(): CollectionPending {
|
|
34
|
+
return {
|
|
35
|
+
exact: new Set(),
|
|
36
|
+
dirty: new Set(),
|
|
37
|
+
overflow: false,
|
|
38
|
+
forceFallback: false,
|
|
39
|
+
generationReconcile: false,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function pendingHasWork(
|
|
44
|
+
pending: CollectionPending | undefined
|
|
45
|
+
): boolean {
|
|
46
|
+
return Boolean(
|
|
47
|
+
pending &&
|
|
48
|
+
(pending.exact.size > 0 ||
|
|
49
|
+
pending.dirty.size > 0 ||
|
|
50
|
+
pending.generationReconcile)
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function queueExactPath(
|
|
55
|
+
pending: CollectionPending,
|
|
56
|
+
relPath: string,
|
|
57
|
+
maxExact: number = WATCHER_MAX_EXACT_PATHS
|
|
58
|
+
): CollectionPending {
|
|
59
|
+
const result = addToCappedSet(pending.exact, relPath, maxExact);
|
|
60
|
+
if (result === "overflow") {
|
|
61
|
+
pending.overflow = true;
|
|
62
|
+
pending.dirty.add("");
|
|
63
|
+
}
|
|
64
|
+
return pending;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function queueDirtyHint(
|
|
68
|
+
pending: CollectionPending,
|
|
69
|
+
hint: string,
|
|
70
|
+
maxDirty: number = WATCHER_MAX_DIRTY_HINTS
|
|
71
|
+
): CollectionPending {
|
|
72
|
+
const result = addToCappedSet(pending.dirty, hint, maxDirty);
|
|
73
|
+
if (result === "overflow") {
|
|
74
|
+
pending.overflow = true;
|
|
75
|
+
pending.dirty.clear();
|
|
76
|
+
pending.dirty.add("");
|
|
77
|
+
}
|
|
78
|
+
return pending;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Flags that must survive take → failed flush → requeue. */
|
|
82
|
+
export interface PendingForceFlags {
|
|
83
|
+
forceFallback: boolean;
|
|
84
|
+
overflow: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Drain pending work for one flush attempt. */
|
|
88
|
+
export function takePending(pending: CollectionPending): {
|
|
89
|
+
exact: string[];
|
|
90
|
+
dirty: string[];
|
|
91
|
+
forceFallback: boolean;
|
|
92
|
+
overflow: boolean;
|
|
93
|
+
generationReconcile: boolean;
|
|
94
|
+
} {
|
|
95
|
+
return {
|
|
96
|
+
exact: [...pending.exact],
|
|
97
|
+
dirty: pending.overflow ? ["", ...pending.dirty] : [...pending.dirty],
|
|
98
|
+
forceFallback: pending.forceFallback || pending.overflow,
|
|
99
|
+
overflow: pending.overflow,
|
|
100
|
+
generationReconcile: pending.generationReconcile,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Restore durable force flags after a failed classification/sync attempt. */
|
|
105
|
+
export function applyPendingForceFlags(
|
|
106
|
+
pending: CollectionPending,
|
|
107
|
+
flags: PendingForceFlags | undefined
|
|
108
|
+
): CollectionPending {
|
|
109
|
+
if (!flags) {
|
|
110
|
+
return pending;
|
|
111
|
+
}
|
|
112
|
+
if (flags.forceFallback) {
|
|
113
|
+
pending.forceFallback = true;
|
|
114
|
+
}
|
|
115
|
+
if (flags.overflow) {
|
|
116
|
+
pending.overflow = true;
|
|
117
|
+
pending.dirty.add("");
|
|
118
|
+
}
|
|
119
|
+
return pending;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface FlushSchedule {
|
|
123
|
+
delayMs: number;
|
|
124
|
+
deadlineAt: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Compute debounce delay respecting the hard maximum flush deadline.
|
|
129
|
+
* `deadlineAt` is created on the first event of a window and retained until drain.
|
|
130
|
+
*/
|
|
131
|
+
export function computeFlushDelay(options: {
|
|
132
|
+
nowMs: number;
|
|
133
|
+
existingDeadlineAt: number | undefined;
|
|
134
|
+
debounceMs?: number;
|
|
135
|
+
maxFlushDelayMs?: number;
|
|
136
|
+
}): FlushSchedule {
|
|
137
|
+
const debounceMs = options.debounceMs ?? WATCHER_FLUSH_DEBOUNCE_MS;
|
|
138
|
+
const maxFlushDelayMs = options.maxFlushDelayMs ?? WATCHER_MAX_FLUSH_DELAY_MS;
|
|
139
|
+
const deadlineAt =
|
|
140
|
+
options.existingDeadlineAt ?? options.nowMs + maxFlushDelayMs;
|
|
141
|
+
const untilDeadline = Math.max(0, deadlineAt - options.nowMs);
|
|
142
|
+
return {
|
|
143
|
+
delayMs: Math.min(debounceMs, untilDeadline),
|
|
144
|
+
deadlineAt,
|
|
145
|
+
};
|
|
146
|
+
}
|