@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.
Files changed (37) hide show
  1. package/README.md +11 -1
  2. package/THIRD_PARTY_NOTICES.md +16 -0
  3. package/browser-extension/artifacts/{gno-browser-clipper-v1.34.4.zip → gno-browser-clipper-v1.34.6.zip} +0 -0
  4. package/browser-extension/artifacts/gno-browser-clipper-v1.34.6.zip.sha256 +1 -0
  5. package/browser-extension/dist/manifest.json +1 -1
  6. package/package.json +5 -1
  7. package/spec/cli.md +16 -10
  8. package/src/cli/pager.ts +29 -14
  9. package/src/ingestion/sync.ts +368 -84
  10. package/src/serve/public/globals.built.css +1 -1
  11. package/src/serve/watch-reconciliation-fallback-disk.ts +220 -0
  12. package/src/serve/watch-reconciliation-fallback.ts +404 -0
  13. package/src/serve/watch-reconciliation-shared.ts +343 -0
  14. package/src/serve/watch-reconciliation.ts +122 -0
  15. package/src/serve/watch-service-events.ts +261 -0
  16. package/src/serve/watch-service-flush-generation.ts +140 -0
  17. package/src/serve/watch-service-flush-helpers.ts +147 -0
  18. package/src/serve/watch-service-flush.ts +433 -0
  19. package/src/serve/watch-service-hosts.ts +109 -0
  20. package/src/serve/watch-service-lifecycle.ts +219 -0
  21. package/src/serve/watch-service-run-flush.ts +236 -0
  22. package/src/serve/watch-service-snapshot.ts +101 -0
  23. package/src/serve/watch-service-state.ts +146 -0
  24. package/src/serve/watch-service.ts +265 -306
  25. package/src/serve/watch-snapshot-handles.ts +285 -0
  26. package/src/serve/watch-snapshot-libc.ts +391 -0
  27. package/src/serve/watch-snapshot-ops.ts +399 -0
  28. package/src/serve/watch-snapshot-resolve.ts +246 -0
  29. package/src/serve/watch-snapshot-scan.ts +297 -0
  30. package/src/serve/watch-snapshot-types.ts +350 -0
  31. package/src/serve/watch-snapshot.ts +51 -0
  32. package/src/store/index.ts +1 -1
  33. package/src/store/sqlite/adapter.ts +191 -0
  34. package/src/store/types.ts +66 -0
  35. package/vendor/fts5-snowball/README.md +5 -1
  36. package/vendor/fts5-snowball/darwin-x64/fts5stemmer.dylib +0 -0
  37. package/browser-extension/artifacts/gno-browser-clipper-v1.34.4.zip.sha256 +0 -1
@@ -1,17 +1,54 @@
1
+ /**
2
+ * Shared resident collection filesystem watcher for serve and daemon.
3
+ * Exact eligible paths content-hash via syncPaths; ambiguous events use
4
+ * snapshot/fallback reconciliation before a targeted batch.
5
+ *
6
+ * @module src/serve/watch-service
7
+ */
8
+
1
9
  import { watch, type FSWatcher } from "node:fs";
2
- import { join, normalize, sep } from "node:path";
10
+ // node:path Bun has no path utilities
11
+ import { normalize, sep } from "node:path";
3
12
 
4
13
  import type { Collection } from "../config/types";
5
14
  import type { CollectionSyncResult, SyncOptions } from "../ingestion";
6
15
  import type { SqliteAdapter } from "../store/sqlite/adapter";
7
16
  import type { DocumentEvent, DocumentEventBus } from "./doc-events";
8
17
  import type { EmbedScheduler } from "./embed-scheduler";
18
+ import type {
19
+ WatcherSnapshot,
20
+ WatcherSnapshotBuildResult,
21
+ WatcherSnapshotFs,
22
+ WatcherSnapshotOptions,
23
+ } from "./watch-snapshot";
9
24
 
10
25
  import {
11
- collectionToWalkConfig,
12
- defaultSyncService,
13
- matchesWalkPath,
14
- } from "../ingestion";
26
+ WATCHER_MAX_DIRTY_HINTS,
27
+ WATCHER_MAX_EXACT_PATHS,
28
+ WATCHER_MAX_SUPPRESSION_ENTRIES,
29
+ pruneSuppressionMap,
30
+ } from "./watch-reconciliation";
31
+ import {
32
+ enqueueDirtyHint,
33
+ enqueueExactPath,
34
+ handleWatchEvent,
35
+ scheduleFlush,
36
+ WATCHER_FLUSH_DEBOUNCE_MS,
37
+ WATCHER_MAX_FLUSH_DELAY_MS,
38
+ } from "./watch-service-events";
39
+ import {
40
+ buildEventHost,
41
+ buildLifecycleHost,
42
+ buildQueueHost,
43
+ type WatchServiceHostState,
44
+ } from "./watch-service-hosts";
45
+ import {
46
+ applyCollectionUpdate,
47
+ clearLifecycleTombstones,
48
+ } from "./watch-service-lifecycle";
49
+ import { runOwnedCollectionFlush } from "./watch-service-run-flush";
50
+ import { beginSnapshotInit } from "./watch-service-snapshot";
51
+ import { pendingHasWork, type CollectionPending } from "./watch-service-state";
15
52
 
16
53
  export interface CollectionWatchState {
17
54
  expectedCollections: string[];
@@ -47,41 +84,30 @@ interface CollectionWatchServiceOptions {
47
84
  callbacks?: CollectionWatchCallbacks;
48
85
  syncOptions?: SyncOptions;
49
86
  watchFactory?: typeof watch;
50
- }
51
-
52
- function watcherCollectionFingerprint(
53
- collection: Collection,
54
- syncOptions: SyncOptions
55
- ): string {
56
- return JSON.stringify({
57
- path: normalize(collection.path),
58
- pattern: collection.pattern,
59
- include: collection.include,
60
- exclude: collection.exclude,
61
- languageHint: collection.languageHint ?? null,
62
- recordAdapters: collection.recordAdapters ?? null,
63
- limits: syncOptions.limits ?? null,
64
- concurrency: syncOptions.concurrency ?? null,
65
- contentTypeRules: syncOptions.contentTypeRules ?? null,
66
- contentTypeRulesFingerprint:
67
- syncOptions.contentTypeRulesFingerprint ?? null,
68
- projectTypedEdges: syncOptions.projectTypedEdges ?? null,
69
- });
70
- }
71
-
72
- function changedPaths(
73
- result: CollectionSyncResult,
74
- fallbackPaths: string[] = []
75
- ): string[] {
76
- if (result.files) {
77
- return result.files
78
- .filter((file) => file.status === "added" || file.status === "updated")
79
- .map((file) => file.relPath);
80
- }
81
- return result.filesAdded + result.filesUpdated + result.filesMarkedInactive >
82
- 0
83
- ? fallbackPaths
84
- : [];
87
+ /** Test overrides for debounce / hard deadline (milliseconds). */
88
+ flushDebounceMs?: number;
89
+ maxFlushDelayMs?: number;
90
+ maxExactPaths?: number;
91
+ maxDirtyHints?: number;
92
+ clock?: () => number;
93
+ /**
94
+ * Injectable snapshot baseline builder (tests: hung/slow init). Production
95
+ * uses the default buildWatcherSnapshot path via beginSnapshotInit.
96
+ */
97
+ buildSnapshot?: (
98
+ rootAbs: string,
99
+ options?: WatcherSnapshotOptions
100
+ ) => Promise<WatcherSnapshotBuildResult>;
101
+ /**
102
+ * Injectable snapshot/fallback FS (tests: unsupported-handle proofs).
103
+ * Production uses the platform default via classifyDirtyHints.
104
+ */
105
+ snapshotFs?: WatcherSnapshotFs;
106
+ /**
107
+ * Test seam: lower snapshot entry ceiling to force overflow→full reconcile.
108
+ * Production leaves this unset (uses WATCHER_SNAPSHOT_ENTRY_CEILING).
109
+ */
110
+ snapshotEntryCeiling?: number;
85
111
  }
86
112
 
87
113
  export class CollectionWatchService {
@@ -95,13 +121,31 @@ export class CollectionWatchService {
95
121
  readonly #watchRoots = new Map<string, string>();
96
122
  readonly #collectionGenerations = new Map<string, number>();
97
123
  readonly #collectionFingerprints = new Map<string, string>();
98
- readonly #pendingByCollection = new Map<string, Set<string>>();
124
+ readonly #pendingByCollection = new Map<string, CollectionPending>();
99
125
  readonly #timers = new Map<string, ReturnType<typeof setTimeout>>();
126
+ readonly #flushDeadlineAt = new Map<string, number>();
127
+ readonly #retryScheduled = new Set<string>();
100
128
  readonly #syncing = new Set<string>();
101
129
  readonly #inFlightSyncs = new Set<Promise<void>>();
102
130
  readonly #suppressedPaths = new Map<string, number>();
131
+ readonly #snapshots = new Map<string, WatcherSnapshot>();
132
+ readonly #snapshotReady = new Map<string, boolean>();
133
+ readonly #snapshotInit = new Map<string, Promise<void>>();
103
134
  readonly #watchFactory: typeof watch;
104
135
  readonly #failedCollections = new Map<string, string>();
136
+ readonly #flushDebounceMs: number;
137
+ readonly #maxFlushDelayMs: number;
138
+ readonly #maxExactPaths: number;
139
+ readonly #maxDirtyHints: number;
140
+ readonly #clock: () => number;
141
+ readonly #buildSnapshot:
142
+ | ((
143
+ rootAbs: string,
144
+ options?: WatcherSnapshotOptions
145
+ ) => Promise<WatcherSnapshotBuildResult>)
146
+ | undefined;
147
+ readonly #snapshotFs: WatcherSnapshotFs | undefined;
148
+ readonly #snapshotEntryCeiling: number | undefined;
105
149
  #nextCollectionGeneration = 0;
106
150
  #disposed = false;
107
151
  #lastEventAt: string | null = null;
@@ -115,124 +159,43 @@ export class CollectionWatchService {
115
159
  this.#callbacks = options.callbacks ?? null;
116
160
  this.#syncOptions = options.syncOptions ?? {};
117
161
  this.#watchFactory = options.watchFactory ?? watch;
162
+ this.#flushDebounceMs =
163
+ options.flushDebounceMs ?? WATCHER_FLUSH_DEBOUNCE_MS;
164
+ this.#maxFlushDelayMs =
165
+ options.maxFlushDelayMs ?? WATCHER_MAX_FLUSH_DELAY_MS;
166
+ this.#maxExactPaths = options.maxExactPaths ?? WATCHER_MAX_EXACT_PATHS;
167
+ this.#maxDirtyHints = options.maxDirtyHints ?? WATCHER_MAX_DIRTY_HINTS;
168
+ this.#clock = options.clock ?? Date.now;
169
+ this.#buildSnapshot = options.buildSnapshot;
170
+ this.#snapshotFs = options.snapshotFs;
171
+ this.#snapshotEntryCeiling = options.snapshotEntryCeiling;
118
172
  }
119
173
 
120
174
  start(): void {
121
- if (this.#disposed) {
122
- return;
175
+ if (!this.#disposed) {
176
+ this.updateCollections(this.#collections);
123
177
  }
124
- this.updateCollections(this.#collections);
125
178
  }
126
179
 
127
180
  updateCollections(
128
181
  collections: Collection[],
129
182
  syncOptions?: SyncOptions
130
183
  ): void {
131
- if (this.#disposed) {
132
- return;
133
- }
134
- if (syncOptions) {
135
- this.#syncOptions = syncOptions;
136
- }
137
- const nextByName = new Map(
138
- collections.map((collection) => [collection.name, collection])
184
+ applyCollectionUpdate(
185
+ buildLifecycleHost(this.#hostState()),
186
+ collections,
187
+ syncOptions
139
188
  );
140
-
141
- for (const [collectionName, watcher] of this.#watchers) {
142
- const nextCollection = nextByName.get(collectionName);
143
- const nextRoot = nextCollection
144
- ? normalize(nextCollection.path)
145
- : undefined;
146
- if (
147
- nextRoot === undefined ||
148
- nextRoot !== this.#watchRoots.get(collectionName)
149
- ) {
150
- watcher.close();
151
- this.#watchers.delete(collectionName);
152
- this.#watchRoots.delete(collectionName);
153
- this.#failedCollections.delete(collectionName);
154
- this.#pendingByCollection.delete(collectionName);
155
- const timer = this.#timers.get(collectionName);
156
- if (timer) {
157
- clearTimeout(timer);
158
- this.#timers.delete(collectionName);
159
- }
160
- }
161
- }
162
-
163
- for (const collectionName of this.#collectionFingerprints.keys()) {
164
- if (!nextByName.has(collectionName)) {
165
- this.#collectionFingerprints.delete(collectionName);
166
- this.#collectionGenerations.set(
167
- collectionName,
168
- ++this.#nextCollectionGeneration
169
- );
170
- }
171
- }
172
-
173
- this.#collections = collections;
174
- for (const collection of collections) {
175
- const fingerprint = watcherCollectionFingerprint(
176
- collection,
177
- this.#syncOptions
178
- );
179
- if (this.#collectionFingerprints.get(collection.name) !== fingerprint) {
180
- this.#collectionFingerprints.set(collection.name, fingerprint);
181
- this.#collectionGenerations.set(
182
- collection.name,
183
- ++this.#nextCollectionGeneration
184
- );
185
- }
186
- }
187
-
188
- for (const collection of this.#collections) {
189
- if (this.#watchers.has(collection.name)) {
190
- continue;
191
- }
192
- try {
193
- const watchedRoot = normalize(collection.path);
194
- const watcher = this.#watchFactory(
195
- collection.path,
196
- { recursive: true },
197
- (_eventType, filename) => {
198
- if (this.#disposed || !filename) return;
199
- const relPath = filename.toString().replaceAll("\\", "/");
200
- const currentCollection = this.#collections.find(
201
- (entry) => entry.name === collection.name
202
- );
203
- if (
204
- !currentCollection ||
205
- normalize(currentCollection.path) !== watchedRoot ||
206
- !matchesWalkPath(
207
- relPath,
208
- collectionToWalkConfig(currentCollection, 0)
209
- )
210
- ) {
211
- return;
212
- }
213
- const fullPath = normalize(join(watchedRoot, relPath));
214
- const suppressedUntil = this.#suppressedPaths.get(fullPath);
215
- if (suppressedUntil && suppressedUntil > Date.now()) {
216
- return;
217
- }
218
- this.#lastEventAt = new Date().toISOString();
219
- this.#queueChange(collection.name, relPath);
220
- }
221
- );
222
- this.#watchers.set(collection.name, watcher);
223
- this.#watchRoots.set(collection.name, watchedRoot);
224
- this.#failedCollections.delete(collection.name);
225
- } catch (error) {
226
- this.#failedCollections.set(
227
- collection.name,
228
- error instanceof Error ? error.message : "watch unavailable"
229
- );
230
- }
231
- }
232
189
  }
233
190
 
234
191
  suppress(absPath: string, ms = 5_000): void {
235
- this.#suppressedPaths.set(normalize(absPath), Date.now() + ms);
192
+ const now = this.#clock();
193
+ this.#suppressedPaths.set(normalize(absPath), now + ms);
194
+ pruneSuppressionMap(
195
+ this.#suppressedPaths,
196
+ now,
197
+ WATCHER_MAX_SUPPRESSION_ENTRIES
198
+ );
236
199
  }
237
200
 
238
201
  async dispose(): Promise<void> {
@@ -247,198 +210,197 @@ export class CollectionWatchService {
247
210
  watcher.close();
248
211
  }
249
212
  this.#timers.clear();
213
+ this.#flushDeadlineAt.clear();
214
+ this.#retryScheduled.clear();
250
215
  this.#watchers.clear();
251
216
  this.#watchRoots.clear();
252
217
  this.#collectionGenerations.clear();
253
218
  this.#collectionFingerprints.clear();
254
219
  this.#collections = [];
255
220
  this.#pendingByCollection.clear();
221
+ this.#snapshots.clear();
222
+ this.#snapshotReady.clear();
223
+ this.#snapshotInit.clear();
224
+ this.#suppressedPaths.clear();
256
225
  await Promise.allSettled(this.#inFlightSyncs);
257
226
  this.#syncing.clear();
258
227
  }
259
228
 
260
229
  getState(): CollectionWatchState {
261
230
  return {
262
- expectedCollections: this.#collections.map(
263
- (collection) => collection.name
264
- ),
231
+ expectedCollections: this.#collections.map((c) => c.name),
265
232
  activeCollections: [...this.#watchers.keys()],
266
233
  failedCollections: [...this.#failedCollections.entries()].map(
267
234
  ([collection, reason]) => ({ collection, reason })
268
235
  ),
269
236
  queuedCollections: [...this.#pendingByCollection.entries()]
270
- .filter(([, relPaths]) => relPaths.size > 0)
271
- .map(([collectionName]) => collectionName),
237
+ .filter(([, pending]) => pendingHasWork(pending))
238
+ .map(([name]) => name),
272
239
  syncingCollections: [...this.#syncing],
273
240
  lastEventAt: this.#lastEventAt,
274
241
  lastSyncAt: this.#lastSyncAt,
275
242
  };
276
243
  }
277
244
 
278
- #queueChange(collectionName: string, relPath: string): void {
279
- if (this.#disposed) {
280
- return;
281
- }
282
- const pending =
283
- this.#pendingByCollection.get(collectionName) ?? new Set<string>();
284
- pending.add(relPath);
285
- this.#pendingByCollection.set(collectionName, pending);
286
-
287
- const existingTimer = this.#timers.get(collectionName);
288
- if (existingTimer) {
289
- clearTimeout(existingTimer);
290
- }
291
- this.#timers.set(
292
- collectionName,
293
- setTimeout(() => {
294
- this.#startFlush(collectionName);
295
- }, 300)
296
- );
245
+ #hostState(): WatchServiceHostState {
246
+ return {
247
+ disposed: () => this.#disposed,
248
+ getCollections: () => this.#collections,
249
+ setCollections: (collections) => {
250
+ this.#collections = collections;
251
+ },
252
+ getSyncOptions: () => this.#syncOptions,
253
+ setSyncOptions: (syncOptions) => {
254
+ this.#syncOptions = syncOptions;
255
+ },
256
+ watchers: this.#watchers,
257
+ watchRoots: this.#watchRoots,
258
+ collectionFingerprints: this.#collectionFingerprints,
259
+ collectionGenerations: this.#collectionGenerations,
260
+ nextGeneration: () => ++this.#nextCollectionGeneration,
261
+ failedCollections: this.#failedCollections,
262
+ snapshots: this.#snapshots,
263
+ snapshotReady: this.#snapshotReady,
264
+ snapshotInit: this.#snapshotInit,
265
+ syncing: this.#syncing,
266
+ pendingByCollection: this.#pendingByCollection,
267
+ clearCollectionRuntimeState: (name) => {
268
+ this.#clearCollectionRuntimeState(name);
269
+ },
270
+ beginSnapshotInit: (collection) => {
271
+ this.#beginSnapshotInit(collection);
272
+ },
273
+ watchFactory: this.#watchFactory,
274
+ onWatchEvent: (name, root, filename) => {
275
+ handleWatchEvent(
276
+ buildEventHost(this.#hostState()),
277
+ name,
278
+ root,
279
+ filename
280
+ );
281
+ },
282
+ findCollection: (name) =>
283
+ this.#collections.find((entry) => entry.name === name),
284
+ clock: this.#clock,
285
+ suppressedPaths: this.#suppressedPaths,
286
+ setLastEventAt: (iso) => {
287
+ this.#lastEventAt = iso;
288
+ },
289
+ enqueueExact: (name, relPath) => {
290
+ enqueueExactPath(buildQueueHost(this.#hostState()), name, relPath);
291
+ },
292
+ enqueueDirty: (name, hint) => {
293
+ enqueueDirtyHint(buildQueueHost(this.#hostState()), name, hint);
294
+ },
295
+ flushDebounceMs: this.#flushDebounceMs,
296
+ maxFlushDelayMs: this.#maxFlushDelayMs,
297
+ maxExactPaths: this.#maxExactPaths,
298
+ maxDirtyHints: this.#maxDirtyHints,
299
+ flushDeadlineAt: this.#flushDeadlineAt,
300
+ timers: this.#timers,
301
+ retryScheduled: this.#retryScheduled,
302
+ inFlightSyncs: this.#inFlightSyncs,
303
+ runFlush: (name) => this.#flushCollection(name),
304
+ };
297
305
  }
298
306
 
299
- #startFlush(collectionName: string): void {
300
- if (this.#disposed) {
301
- return;
307
+ #clearCollectionRuntimeState(collectionName: string): void {
308
+ this.#watchRoots.delete(collectionName);
309
+ this.#pendingByCollection.delete(collectionName);
310
+ this.#snapshots.delete(collectionName);
311
+ this.#snapshotReady.delete(collectionName);
312
+ this.#snapshotInit.delete(collectionName);
313
+ this.#flushDeadlineAt.delete(collectionName);
314
+ this.#retryScheduled.delete(collectionName);
315
+ const timer = this.#timers.get(collectionName);
316
+ if (timer) {
317
+ clearTimeout(timer);
318
+ this.#timers.delete(collectionName);
302
319
  }
303
- const sync = this.#flushCollection(collectionName);
304
- this.#inFlightSyncs.add(sync);
305
- void sync
306
- .finally(() => {
307
- this.#inFlightSyncs.delete(sync);
308
- })
309
- .catch(() => undefined);
310
320
  }
311
321
 
312
- async #flushCollection(collectionName: string): Promise<void> {
313
- if (this.#disposed) {
314
- return;
315
- }
316
- const pending = this.#pendingByCollection.get(collectionName);
317
- if (!pending || pending.size === 0) {
318
- return;
319
- }
320
- if (this.#syncing.has(collectionName)) {
321
- return;
322
- }
323
-
324
- const collection = this.#collections.find(
325
- (entry) => entry.name === collectionName
326
- );
327
- if (!collection) {
328
- this.#pendingByCollection.delete(collectionName);
329
- return;
330
- }
331
-
332
- const relPaths = [...pending].filter((relPath) =>
333
- matchesWalkPath(relPath, collectionToWalkConfig(collection, 0))
334
- );
335
- let syncGeneration = this.#collectionGenerations.get(collectionName) ?? 0;
336
- this.#pendingByCollection.set(collectionName, new Set<string>());
337
- if (relPaths.length === 0) {
338
- this.#notifySettledIfIdle();
339
- return;
340
- }
341
- this.#syncing.add(collectionName);
342
-
343
- try {
344
- this.#callbacks?.onSyncStart?.({
345
- collection: collection.name,
346
- relPaths,
347
- });
348
- let result = await defaultSyncService.syncPaths(
349
- collection,
350
- this.#store,
351
- relPaths,
352
- {
353
- ...this.#syncOptions,
354
- runUpdateCmd: false,
355
- }
356
- );
357
- if (this.#disposed) {
358
- return;
359
- }
360
- this.#callbacks?.onSyncComplete?.({
361
- collection: collection.name,
362
- relPaths,
363
- result,
364
- });
365
-
366
- let completionCollection = collection;
367
- let completionPaths = changedPaths(result, relPaths);
368
- while (true) {
369
- const currentCollection = this.#collections.find(
370
- (entry) => entry.name === collectionName
371
- );
372
- if (!currentCollection) {
373
- break;
374
- }
375
- const currentGeneration =
376
- this.#collectionGenerations.get(collectionName) ?? 0;
377
- if (currentGeneration === syncGeneration) {
378
- const currentRelPaths =
379
- normalize(currentCollection.path) ===
380
- normalize(completionCollection.path)
381
- ? completionPaths.filter((relPath) =>
382
- matchesWalkPath(
383
- relPath,
384
- collectionToWalkConfig(currentCollection, 0)
385
- )
386
- )
387
- : [];
388
- if (currentRelPaths.length > 0) {
389
- this.#afterSync(currentCollection, currentRelPaths);
322
+ #beginSnapshotInit(collection: Collection): void {
323
+ beginSnapshotInit(
324
+ {
325
+ disposed: () => this.#disposed,
326
+ getGeneration: (name) => this.#collectionGenerations.get(name) ?? 0,
327
+ getRoot: (name) => this.#watchRoots.get(name),
328
+ setSnapshot: (name, snapshot) => {
329
+ this.#snapshots.set(name, snapshot);
330
+ },
331
+ clearSnapshot: (name) => {
332
+ this.#snapshots.delete(name);
333
+ },
334
+ setReady: (name, ready) => {
335
+ this.#snapshotReady.set(name, ready);
336
+ },
337
+ getInit: (name) => this.#snapshotInit.get(name),
338
+ setInit: (name, init) => {
339
+ if (init) {
340
+ this.#snapshotInit.set(name, init);
341
+ } else {
342
+ this.#snapshotInit.delete(name);
390
343
  }
391
- break;
392
- }
393
-
394
- result = await defaultSyncService.syncCollection(
395
- currentCollection,
396
- this.#store,
397
- {
398
- ...this.#syncOptions,
399
- runUpdateCmd: false,
344
+ },
345
+ onReadyWithPending: (name) => {
346
+ if (pendingHasWork(this.#pendingByCollection.get(name))) {
347
+ scheduleFlush(buildQueueHost(this.#hostState()), name);
400
348
  }
349
+ },
350
+ buildSnapshot: this.#buildSnapshot,
351
+ },
352
+ collection
353
+ );
354
+ }
355
+
356
+ async #flushCollection(collectionName: string): Promise<void> {
357
+ await runOwnedCollectionFlush({
358
+ collectionName,
359
+ disposed: () => this.#disposed,
360
+ collections: () => this.#collections,
361
+ store: this.#store,
362
+ syncOptions: () => this.#syncOptions,
363
+ pendingByCollection: this.#pendingByCollection,
364
+ flushDeadlineAt: this.#flushDeadlineAt,
365
+ syncing: this.#syncing,
366
+ retryScheduled: this.#retryScheduled,
367
+ collectionGenerations: this.#collectionGenerations,
368
+ snapshots: this.#snapshots,
369
+ snapshotReady: this.#snapshotReady,
370
+ snapshotInit: this.#snapshotInit,
371
+ suppressedPaths: this.#suppressedPaths,
372
+ clock: this.#clock,
373
+ queueHost: buildQueueHost(this.#hostState()),
374
+ callbacks: this.#callbacks,
375
+ onAfterSync: (current, relPaths) => {
376
+ this.#afterSync(current, relPaths);
377
+ },
378
+ beginSnapshotInit: (current) => {
379
+ this.#beginSnapshotInit(current);
380
+ },
381
+ clearLifecycleTombstones: (name) => {
382
+ clearLifecycleTombstones(buildLifecycleHost(this.#hostState()), name);
383
+ },
384
+ pruneSuppression: () => {
385
+ pruneSuppressionMap(
386
+ this.#suppressedPaths,
387
+ this.#clock(),
388
+ WATCHER_MAX_SUPPRESSION_ENTRIES
401
389
  );
402
- if (this.#disposed) {
403
- return;
404
- }
405
- completionCollection = currentCollection;
406
- completionPaths = changedPaths(result);
407
- syncGeneration = currentGeneration;
408
- this.#callbacks?.onSyncComplete?.({
409
- collection: currentCollection.name,
410
- relPaths: completionPaths,
411
- result,
412
- });
413
- }
414
- } catch (error) {
415
- if (this.#disposed) {
416
- return;
417
- }
418
- this.#callbacks?.onSyncError?.({
419
- collection: collection.name,
420
- relPaths,
421
- error,
422
- });
423
- throw error;
424
- } finally {
425
- this.#syncing.delete(collectionName);
426
- if (!this.#disposed) {
427
- const remaining = this.#pendingByCollection.get(collectionName);
428
- if (remaining && remaining.size > 0) {
429
- this.#startFlush(collectionName);
430
- } else {
431
- this.#notifySettledIfIdle();
432
- }
433
- }
434
- }
390
+ },
391
+ notifySettledIfIdle: () => {
392
+ this.#notifySettledIfIdle();
393
+ },
394
+ snapshotFs: this.#snapshotFs,
395
+ snapshotEntryCeiling: this.#snapshotEntryCeiling,
396
+ });
435
397
  }
436
398
 
437
399
  #notifySettledIfIdle(): void {
438
400
  if (
439
401
  this.#syncing.size === 0 &&
440
- ![...this.#pendingByCollection.values()].some(
441
- (relPaths) => relPaths.size > 0
402
+ ![...this.#pendingByCollection.values()].some((pending) =>
403
+ pendingHasWork(pending)
442
404
  )
443
405
  ) {
444
406
  this.#callbacks?.onSettled?.();
@@ -449,14 +411,11 @@ export class CollectionWatchService {
449
411
  if (this.#disposed || relPaths.length === 0) {
450
412
  return;
451
413
  }
452
-
453
- this.#lastSyncAt = new Date().toISOString();
414
+ this.#lastSyncAt = new Date(this.#clock()).toISOString();
454
415
  this.#scheduler?.notifySyncComplete(relPaths);
455
-
456
416
  if (!this.#eventBus) {
457
417
  return;
458
418
  }
459
-
460
419
  for (const relPath of relPaths) {
461
420
  const event: DocumentEvent = {
462
421
  type: "document-changed",
@@ -464,7 +423,7 @@ export class CollectionWatchService {
464
423
  collection: collection.name,
465
424
  relPath,
466
425
  origin: "watcher",
467
- changedAt: new Date().toISOString(),
426
+ changedAt: new Date(this.#clock()).toISOString(),
468
427
  };
469
428
  this.#eventBus.emit(event);
470
429
  }