@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.
- package/README.md +205 -18
- package/dist/client.js +23 -11
- package/dist/disk-mirror.js +2 -2
- package/dist/fs-core.worker.js +101 -21
- package/dist/sync/binary-sidecar.js +147 -0
- package/dist/sync/sync-engine.js +119 -169
- package/dist/sync/watch-expander.js +202 -0
- package/dist/worker-files.cjs +32 -0
- package/dist/worker-files.js +35 -0
- package/dist/worker-lib/.tsbuildinfo +1 -0
- package/dist/worker-lib/engine-shared.d.ts +79 -0
- package/dist/worker-lib/engine-shared.d.ts.map +1 -0
- package/dist/worker-lib/engine-shared.js +6 -3
- package/dist/worker-lib/paths.d.ts +4 -0
- package/dist/worker-lib/paths.d.ts.map +1 -0
- package/dist/worker-lib/protocol.d.ts +119 -0
- package/dist/worker-lib/protocol.d.ts.map +1 -0
- package/dist/worker-lib/protocol.js +73 -0
- package/dist/worker-lib/rpc-types.d.ts +68 -0
- package/dist/worker-lib/rpc-types.d.ts.map +1 -0
- package/dist/worker-lib/wal-codec.d.ts +58 -0
- package/dist/worker-lib/wal-codec.d.ts.map +1 -0
- package/dist/worker-lib/wal-codec.js +8 -5
- package/dist/zip.js +1 -1
- package/package.json +25 -2
- package/src/__checks__/types-smoke.ts +61 -0
- package/src/agent-tools.ts +3 -3
- package/src/client-retry.test.ts +76 -0
- package/src/client.ts +112 -45
- package/src/disk-mirror.ts +2 -2
- package/src/fs-core-handover.test.ts +215 -0
- package/src/fs-core-opid-replay.test.ts +158 -0
- package/src/fs-core-recovery-lock.test.ts +225 -0
- package/src/fs-core-recovery.ts +115 -13
- package/src/fs-core-write-ops.ts +14 -11
- package/src/fs-core.worker.ts +26 -11
- package/src/worker-files.test.ts +39 -0
- package/src/worker-files.ts +43 -0
- package/src/worker-lib/engine-shared.ts +19 -5
- package/src/worker-lib/protocol.test.ts +37 -0
- package/src/worker-lib/protocol.ts +184 -0
- package/src/worker-lib/wal-codec.ts +8 -5
- package/src/zip.ts +1 -1
- package/sync/binary-sidecar.test.ts +150 -0
- package/sync/binary-sidecar.ts +187 -0
- package/sync/sync-engine-degraded.test.ts +309 -0
- package/sync/sync-engine.test.ts +20 -91
- package/sync/sync-engine.ts +134 -181
- package/sync/truth-port.ts +3 -3
- package/sync/watch-expander.test.ts +96 -0
- 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
|
+
}
|