@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
@@ -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
+ }