@dimina-kit/fs-core 0.2.0-dev.20260711062001 → 0.3.0-dev.6-dev.20260711140249

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 (51) hide show
  1. package/README.md +205 -18
  2. package/dist/client.js +23 -11
  3. package/dist/disk-mirror.js +2 -2
  4. package/dist/fs-core.worker.js +101 -21
  5. package/dist/sync/binary-sidecar.js +147 -0
  6. package/dist/sync/sync-engine.js +119 -169
  7. package/dist/sync/watch-expander.js +202 -0
  8. package/dist/worker-files.cjs +32 -0
  9. package/dist/worker-files.js +35 -0
  10. package/dist/worker-lib/.tsbuildinfo +1 -0
  11. package/dist/worker-lib/engine-shared.d.ts +79 -0
  12. package/dist/worker-lib/engine-shared.d.ts.map +1 -0
  13. package/dist/worker-lib/engine-shared.js +6 -3
  14. package/dist/worker-lib/paths.d.ts +4 -0
  15. package/dist/worker-lib/paths.d.ts.map +1 -0
  16. package/dist/worker-lib/protocol.d.ts +119 -0
  17. package/dist/worker-lib/protocol.d.ts.map +1 -0
  18. package/dist/worker-lib/protocol.js +73 -0
  19. package/dist/worker-lib/rpc-types.d.ts +68 -0
  20. package/dist/worker-lib/rpc-types.d.ts.map +1 -0
  21. package/dist/worker-lib/wal-codec.d.ts +58 -0
  22. package/dist/worker-lib/wal-codec.d.ts.map +1 -0
  23. package/dist/worker-lib/wal-codec.js +8 -5
  24. package/dist/zip.js +1 -1
  25. package/package.json +25 -2
  26. package/src/__checks__/types-smoke.ts +61 -0
  27. package/src/agent-tools.ts +3 -3
  28. package/src/client-retry.test.ts +76 -0
  29. package/src/client.ts +112 -45
  30. package/src/disk-mirror.ts +2 -2
  31. package/src/fs-core-handover.test.ts +215 -0
  32. package/src/fs-core-opid-replay.test.ts +158 -0
  33. package/src/fs-core-recovery-lock.test.ts +225 -0
  34. package/src/fs-core-recovery.ts +115 -13
  35. package/src/fs-core-write-ops.ts +14 -11
  36. package/src/fs-core.worker.ts +26 -11
  37. package/src/worker-files.test.ts +39 -0
  38. package/src/worker-files.ts +43 -0
  39. package/src/worker-lib/engine-shared.ts +19 -5
  40. package/src/worker-lib/protocol.test.ts +37 -0
  41. package/src/worker-lib/protocol.ts +184 -0
  42. package/src/worker-lib/wal-codec.ts +8 -5
  43. package/src/zip.ts +1 -1
  44. package/sync/binary-sidecar.test.ts +150 -0
  45. package/sync/binary-sidecar.ts +187 -0
  46. package/sync/sync-engine-degraded.test.ts +309 -0
  47. package/sync/sync-engine.test.ts +20 -91
  48. package/sync/sync-engine.ts +134 -181
  49. package/sync/truth-port.ts +3 -3
  50. package/sync/watch-expander.test.ts +96 -0
  51. package/sync/watch-expander.ts +236 -0
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Watch-batch expansion — an OPTIONAL port-side helper for assembling a
3
+ * `TruthPort.changes` adapter (see truth-port.ts: turning a watcher's
4
+ * coalesced/lossy events into the actual set of paths worth re-examining is
5
+ * the PORT's responsibility; sync-engine.ts's `handleBatch` trusts the
6
+ * expanded batch as-is and does no expansion of its own). It turns raw
7
+ * `fs.watch`-style paths (which may be coalesced ancestor-DIRECTORY events,
8
+ * an overflow `'.'` full-tree rescan, or the name of a file that no longer
9
+ * exists) into the set of paths the sync engine should actually re-examine.
10
+ * The only dependency is a stat-capable `readdir` (one listing call, no
11
+ * content bytes) — see {@link WatchExpanderReaddir}.
12
+ *
13
+ * Why events are only a HINT, not the truth: FSEvents-style recursive
14
+ * watchers COALESCE a write burst into ancestor-DIRECTORY events (and an
15
+ * overflow surfaces as a null filename, reported as `'.'`) — observed: a
16
+ * 200-file external burst delivered only 132 per-file events, the rest
17
+ * arriving as their parent directory. So a watched path is not necessarily a
18
+ * file, and "nothing was reported for path X" does not mean X is unchanged.
19
+ *
20
+ * How stat-level truth-checking works (the git-index/rsync pattern): for
21
+ * every watch path, this module lists the TRUTH SOURCE's current
22
+ * (size, mtimeMs) for every file in that path's scope (itself + its parent
23
+ * directory — the same scope FSEvents coalescing collapses onto) and diffs it
24
+ * against a session-scoped index of what it last saw for each file:
25
+ * - a ledger path inside the scanned scope that is now missing from the
26
+ * disk listing is reported as a deletion (this is what recovers a
27
+ * coalesced `rm -rf`, which a real watcher may name only a few of the
28
+ * removed children for, or none) — unconditional, since a deletion has
29
+ * no "stat" to compare;
30
+ * - a disk file whose stat is NEW or DIFFERENT from the index is reported,
31
+ * and the index is updated to match;
32
+ * - a disk file whose stat is UNCHANGED is a stat-confirmed survivor and is
33
+ * never reported. This is the whole point: an N-file directory with one
34
+ * real change no longer costs the engine N content reads (readdir stats
35
+ * are cheap — one listing round trip per directory level, no bytes —
36
+ * while the engine's `handleInboundPath` does a full `port.read` per
37
+ * reported path);
38
+ * - the event path `p` ITSELF is additionally, unconditionally reported
39
+ * UNLESS it is currently a confirmed live, listable directory with no
40
+ * ledger record sitting at that exact path. Content can change without a
41
+ * stat move (same-size in-place edit inside one filesystem timestamp
42
+ * tick — the classic "racy git" case), and this module never reads file
43
+ * CONTENT to judge that — only the engine's own read+compare can, which
44
+ * is why a plain file / deleted path / transient probe failure always
45
+ * gets reported. A CONFIRMED live directory has no content of its own to
46
+ * mis-judge that way, so reporting it would normally just cost a wasted
47
+ * `port.read` (404 → EISDIR) — EXCEPT when the ledger still holds a
48
+ * record AT THAT EXACT PATH (a stale FILE record from before the path
49
+ * was replaced by a directory — see "Same-named file→directory
50
+ * replacement" below): that record needs the same EISDIR→404 retirement
51
+ * path, and for a ROOT-LEVEL replaced path (no parent directory of its
52
+ * own) the ledger-deletion sweep below cannot reach it by prefix at all,
53
+ * so this is the only path that retires it.
54
+ * Over-reporting is always safe (the engine content-compares every reported
55
+ * path, so a false positive is a no-op); under-reporting is what loses data,
56
+ * which is why the ledger-deletion sweep and the point-named path (per the
57
+ * exception above) are unconditional rather than stat-gated.
58
+ *
59
+ * Same-named file→directory replacement: EVERY scope gets the readdir probe,
60
+ * even when the path is a ledger-known FILE — a file can have been replaced
61
+ * by a same-named directory, and skipping the probe for "known files" would
62
+ * silently drop that directory's whole subtree (the watcher only names the
63
+ * parent). The stale ledger FILE record retires via whichever of two paths
64
+ * applies: the point-named-path exception above (when the watcher names the
65
+ * replaced path directly — the only option when it has no parent directory
66
+ * of its own to register a scope prefix), or the ledger-deletion sweep's
67
+ * PARENT-scope prefix (when the watcher instead names a sibling or the
68
+ * parent directory — the record no longer matches the replaced path's own
69
+ * now-a-directory prefix, only the parent's). Either way the engine's
70
+ * `port.read` gets EISDIR (mapped to a not-found by e.g. the devtools
71
+ * bridge), which its not-found discipline treats as a deletion.
72
+ *
73
+ * Index lifecycle: `resetIndex()` clears the session-scoped stat index —
74
+ * call it whenever the ledger itself is reseeded (e.g. dimina-kit
75
+ * wal-audit.ts's `initLedger`), so a stale index can never survive a project
76
+ * switch or ledger rebuild. `warmFromDisk()` then seeds it directly from the
77
+ * CURRENT disk tree right after the reseed (called once the ledger walk
78
+ * itself has completed) — without this, the first watch batch after every
79
+ * project open would see an all-cold index and re-report every file it
80
+ * touches, defeating the point of stat-diffing for exactly the common case
81
+ * (a directory-level event shortly after open). A path this module has never
82
+ * seen at all (warm-up included) is always reported on its first sighting —
83
+ * nothing is missed by a partial/failed warm-up, it just costs that one
84
+ * file's content read instead of being skipped.
85
+ */
86
+
87
+ /** One truth-source directory entry: `[name, type, size?, mtimeMs?]` with
88
+ * `type === 2` meaning directory. `size`/`mtimeMs` are expected for FILE
89
+ * entries — a stat-less entry safely degrades to "always report" (see
90
+ * {@link toDiskStat}). */
91
+ export type WatchExpanderEntry = [name: string, type: number, size?: number, mtimeMs?: number]
92
+
93
+ /** The single dependency: list one directory (project-relative path, `'.'`
94
+ * or `''` = the root) of the truth source, with per-file stats. */
95
+ export type WatchExpanderReaddir = (rel: string) => Promise<WatchExpanderEntry[]>
96
+
97
+ interface DiskStat {
98
+ size: number
99
+ mtimeMs: number
100
+ }
101
+
102
+ export interface WatchExpander {
103
+ /** Expand one raw watch batch into the paths worth re-examining, given the
104
+ * ledger's current path list (used to detect coalesced deletions). */
105
+ expandWatchBatch(paths: string[], ledgerPaths: string[]): Promise<string[]>
106
+ /** Clear the session-scoped stat index — call on every ledger reseed. */
107
+ resetIndex(): void
108
+ /** Seed the stat index directly from the current disk tree (no reporting)
109
+ * — call once right after the ledger reseed completes; see this module's
110
+ * doc "Index lifecycle". A (partial or total) failure just leaves the
111
+ * index short of some paths, which safely degrades to "always report on
112
+ * first sighting" for those — never a correctness issue. */
113
+ warmFromDisk(): Promise<void>
114
+ }
115
+
116
+ /** A stat-less disk entry (a readdir/stat race on the truth-source side)
117
+ * never matches a cached stat: `NaN !== NaN` unconditionally, so a racy
118
+ * entry is always reported rather than risking a false "unchanged" skip. */
119
+ function toDiskStat(size: number | undefined, mtimeMs: number | undefined): DiskStat {
120
+ return { size: size ?? Number.NaN, mtimeMs: mtimeMs ?? Number.NaN }
121
+ }
122
+
123
+ /** `rel`'s scope prefixes for the ledger-deletion sweep: itself-as-a-directory
124
+ * and its parent directory — the same two listings {@link createWatchExpander}'s
125
+ * `listDiskStats` probes. `''` (the whole-ledger prefix) only ever arises from
126
+ * the `'.'` overflow rescan, handled by the caller before this is reached. */
127
+ function scopePrefixes(rel: string): { prefixes: Set<string>; parentRel: string; slash: number } {
128
+ const slash = rel.lastIndexOf('/')
129
+ const parentRel = slash >= 0 ? rel.slice(0, slash) : ''
130
+ const prefixes = new Set<string>([rel ? `${rel}/` : ''])
131
+ if (slash >= 0) prefixes.add(parentRel ? `${parentRel}/` : '')
132
+ return { prefixes, parentRel, slash }
133
+ }
134
+
135
+ /** `true` when `q` falls under one of `prefixes` (`''` matches everything —
136
+ * the `'.'` overflow case). */
137
+ function inScope(q: string, prefixes: Set<string>): boolean {
138
+ for (const prefix of prefixes) {
139
+ if (prefix === '' || q.startsWith(prefix)) return true
140
+ }
141
+ return false
142
+ }
143
+
144
+ export function createWatchExpander(readdir: WatchExpanderReaddir): WatchExpander {
145
+ /** Session-scoped: `rel -> last stat this module reported for it`. Cleared
146
+ * by `resetIndex()`, seeded by `warmFromDisk()`; see this module's doc
147
+ * "Index lifecycle". */
148
+ let statIndex = new Map<string, DiskStat>()
149
+
150
+ /** List every FILE's current (size, mtimeMs) under `startRel`, recursively
151
+ * — NO content reads (a content walk would turn each expansion pass back
152
+ * into a content-read storm). */
153
+ async function listDiskStats(startRel: string, out: Map<string, DiskStat>): Promise<void> {
154
+ async function walk(rel: string): Promise<void> {
155
+ const entries = await readdir(rel || '.')
156
+ for (const [name, type, size, mtimeMs] of entries) {
157
+ const childRel = rel ? `${rel}/${name}` : name
158
+ if (type === 2) await walk(childRel)
159
+ else out.set(childRel, toDiskStat(size, mtimeMs))
160
+ }
161
+ }
162
+ await walk(startRel)
163
+ }
164
+
165
+ /** One swallowed stat-listing probe. Returns whether `rel` is CURRENTLY a
166
+ * live, listable directory (a plain file, a deleted path, or any other
167
+ * probe failure all report `false`) — the caller uses this to decide
168
+ * whether the point-named path itself needs an unconditional report (see
169
+ * this module's doc). Failure otherwise changes nothing: the caller's own
170
+ * ledger-deletion sweep still covers a deleted/replaced path, and a tree
171
+ * mutating mid-walk just means the stats collected so far are what count. */
172
+ async function probeDiskStats(rel: string, out: Map<string, DiskStat>): Promise<boolean> {
173
+ try {
174
+ await listDiskStats(rel, out)
175
+ return true
176
+ } catch {
177
+ return false
178
+ }
179
+ }
180
+
181
+ /** Expand ONE watch path into `out` — see this module's doc for the full
182
+ * algorithm. `p === '.'` is the overflow full-tree rescan: scope is the
183
+ * whole tree (`rel = ''`, no parent probe, ledger prefix `''` matches
184
+ * every path). `ledgerPathSet` is `ledgerPaths` as a `Set` for the O(1)
185
+ * exact-path membership check the point-named-path exception needs. */
186
+ async function expandWatchPath(
187
+ p: string,
188
+ ledgerPaths: string[],
189
+ ledgerPathSet: ReadonlySet<string>,
190
+ out: Set<string>,
191
+ ): Promise<void> {
192
+ const rel = p === '.' ? '' : p
193
+ const diskStats = new Map<string, DiskStat>()
194
+ const isLiveDirectory = await probeDiskStats(rel, diskStats)
195
+ const { prefixes, parentRel, slash } = scopePrefixes(rel)
196
+ if (p !== '.' && slash >= 0) await probeDiskStats(parentRel, diskStats)
197
+ // See this module's doc for why a confirmed live directory is excluded
198
+ // UNLESS the ledger still has a stale record at that exact path.
199
+ if (p !== '.' && (!isLiveDirectory || ledgerPathSet.has(p))) out.add(p)
200
+
201
+ // Ledger paths in scope but missing from the disk listing — coalesced
202
+ // deletions (see this module's doc).
203
+ for (const q of ledgerPaths) {
204
+ if (!inScope(q, prefixes)) continue
205
+ if (!diskStats.has(q)) {
206
+ out.add(q)
207
+ statIndex.delete(q)
208
+ }
209
+ }
210
+
211
+ // Disk files in scope: report new/changed, skip stat-confirmed survivors.
212
+ for (const [q, stat] of diskStats) {
213
+ const known = statIndex.get(q)
214
+ if (known && known.size === stat.size && known.mtimeMs === stat.mtimeMs) continue
215
+ out.add(q)
216
+ statIndex.set(q, stat)
217
+ }
218
+ }
219
+
220
+ return {
221
+ async expandWatchBatch(paths, ledgerPaths) {
222
+ const out = new Set<string>()
223
+ const ledgerPathSet = new Set(ledgerPaths)
224
+ for (const p of paths) await expandWatchPath(p, ledgerPaths, ledgerPathSet, out)
225
+ return [...out]
226
+ },
227
+ resetIndex() {
228
+ statIndex = new Map<string, DiskStat>()
229
+ },
230
+ async warmFromDisk() {
231
+ const warm = new Map<string, DiskStat>()
232
+ await probeDiskStats('', warm)
233
+ statIndex = warm
234
+ },
235
+ }
236
+ }