@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
package/sync/sync-engine.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sync arbitration engine — the memfs<->disk synchronization core
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Sync arbitration engine — the memfs<->disk synchronization core shared by
|
|
3
|
+
* every host that pairs an fs-core ledger with an external truth source.
|
|
4
|
+
* A host wires a `client` (the
|
|
5
5
|
* fs-core ledger: write/rm/read/ls) and a `TruthPort` (its external truth
|
|
6
6
|
* source — devtools' `/__fs` bridge + SSE watch, or a future FSA
|
|
7
7
|
* local-directory adapter) and gets back an engine that keeps the ledger,
|
|
@@ -10,54 +10,43 @@
|
|
|
10
10
|
* host's own audit-turn surface talks to `client` directly for that (see
|
|
11
11
|
* wal-audit.ts).
|
|
12
12
|
*
|
|
13
|
-
* Echo judgement (both directions
|
|
13
|
+
* Echo judgement (both directions, content comparison against the ledger's
|
|
14
|
+
* own record):
|
|
14
15
|
* - Outbound (`onHumanSave`): before recording, compare the saved text
|
|
15
16
|
* against the ledger's current record for that path — identical content
|
|
16
17
|
* is a no-op (the ledger already reflects it), skipping a redundant
|
|
17
|
-
* write.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* is still in flight. When `pendingWrite` misses, the engine falls back
|
|
30
|
-
* to today's content comparison (truth-source bytes vs. the ledger's own
|
|
31
|
-
* record) — the only judgement a push host ever actually exercises.
|
|
18
|
+
* write. The onHumanSave accounting step and inbound batches run through
|
|
19
|
+
* the same `ledgerTurn` FIFO, so an inbound notification for a path whose
|
|
20
|
+
* save is still in flight always observes the completed ledger write and
|
|
21
|
+
* absorbs itself via that same comparison.
|
|
22
|
+
* - Inbound (`changes` batches, via `handleInboundPath`): truth-source
|
|
23
|
+
* bytes equal to the ledger's record are the echo of our own last write —
|
|
24
|
+
* dropped with no ledger write and no editor refresh.
|
|
25
|
+
* This engine serves 'push' ports only (the port notifies; the host's
|
|
26
|
+
* outbound writes go through `onHumanSave`). A future 'poll' host whose
|
|
27
|
+
* outbound scan runs outside this module must own its own one-shot echo
|
|
28
|
+
* suppression — see the README's「两套磁盘机制的划界」section for the
|
|
29
|
+
* invariants such an adapter has to satisfy.
|
|
32
30
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* outbound scan and get echoed straight back to disk before the poll
|
|
39
|
-
* baseline ever settles. `inboundApplied` (`rel -> {kind:'text'|'binary'|
|
|
40
|
-
* 'delete', ...}`) records exactly what `handleInboundPath` just applied,
|
|
41
|
-
* overwriting any prior entry for the same path; `consumeInboundEcho(rel,
|
|
42
|
-
* content)` lets such a host check "is this outbound write about to re-emit
|
|
43
|
-
* an inbound change I haven't published yet?" immediately BEFORE writing to
|
|
44
|
-
* the truth source, consuming (clearing) the record on a match so it is a
|
|
45
|
-
* one-shot suppression, not a standing content cache. A miss (different
|
|
46
|
-
* content, or no record at all) returns false and leaves any existing record
|
|
47
|
-
* untouched — it is not this call's echo to consume.
|
|
31
|
+
* Degradation is loud (`onDegraded`): a dead watcher and a path that failed
|
|
32
|
+
* to sync (truth-source read failure, or a ledger write/rm failure while
|
|
33
|
+
* reconciling) are surfaced to the host through {@link SyncDegradation} in
|
|
34
|
+
* addition to the console warning — silently skipping them would let the
|
|
35
|
+
* ledger drift with no operator-visible signal.
|
|
48
36
|
*
|
|
49
|
-
* Binary layering (
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
37
|
+
* Binary layering: classification (NUL sniff), the `rel -> {size, sha256}`
|
|
38
|
+
* index, and echo judgement (size+hash equality instead of a ledger content
|
|
39
|
+
* compare) are all owned by ./binary-sidecar.ts — see that module's doc; this
|
|
40
|
+
* engine holds an INDEX-ONLY sidecar. A binary file never reaches the fs-core
|
|
41
|
+
* ledger (`client.write`/`read`). What/why this is narrower than the text
|
|
42
|
+
* path: binary changes get NO WAL audit and NO rollback (the audit turn
|
|
43
|
+
* surface — `diff`/`restore` — is a string-content contract; the engine does
|
|
44
|
+
* not support an agent writing binary inside a turn), and the sidecar is
|
|
45
|
+
* session-scoped — cleared and rebuilt from scratch on every
|
|
46
|
+
* `populateLedger()`, exactly like the ledger's own text reconciliation.
|
|
59
47
|
*/
|
|
60
48
|
|
|
49
|
+
import { createBinarySidecar, looksBinary } from './binary-sidecar.js'
|
|
61
50
|
import type { TruthPort } from './truth-port.js'
|
|
62
51
|
|
|
63
52
|
export type { TruthPort, TruthPortCapabilities } from './truth-port.js'
|
|
@@ -77,6 +66,20 @@ export interface SyncClientLike {
|
|
|
77
66
|
ls(): Promise<{ paths: string[] }>
|
|
78
67
|
}
|
|
79
68
|
|
|
69
|
+
/**
|
|
70
|
+
* A host-visible sync degradation (see the module doc's "Degradation is
|
|
71
|
+
* loud" paragraph):
|
|
72
|
+
* - `watcher-dead`: the port's change subscription died — from now on the
|
|
73
|
+
* ledger only reflects the open-time mirror plus already-processed
|
|
74
|
+
* batches.
|
|
75
|
+
* - `path-sync-failed`: one path failed to reconcile and was skipped;
|
|
76
|
+
* `stage` says where — `truth-read` (the truth source's read rejected
|
|
77
|
+
* transiently) or `reconcile` (the ledger write/rm itself failed).
|
|
78
|
+
*/
|
|
79
|
+
export type SyncDegradation =
|
|
80
|
+
| { kind: 'watcher-dead' }
|
|
81
|
+
| { kind: 'path-sync-failed'; rel: string; stage: 'truth-read' | 'reconcile'; error: unknown }
|
|
82
|
+
|
|
80
83
|
export interface SyncEngineOptions {
|
|
81
84
|
/**
|
|
82
85
|
* Host seam: push an inbound change into the live editor buffer.
|
|
@@ -84,6 +87,12 @@ export interface SyncEngineOptions {
|
|
|
84
87
|
* records the change but the editor buffer is left untouched.
|
|
85
88
|
*/
|
|
86
89
|
applyToEditor?: (rel: string, bytes: Uint8Array | null) => Promise<void>
|
|
90
|
+
/**
|
|
91
|
+
* Host seam: surface a {@link SyncDegradation}. Called in addition to the
|
|
92
|
+
* console warning, never instead of it; a throwing callback is the host's
|
|
93
|
+
* own bug and never breaks the engine.
|
|
94
|
+
*/
|
|
95
|
+
onDegraded?: (degradation: SyncDegradation) => void
|
|
87
96
|
}
|
|
88
97
|
|
|
89
98
|
export interface SyncEngine {
|
|
@@ -105,47 +114,12 @@ export interface SyncEngine {
|
|
|
105
114
|
* route it to the session-scoped `binaryIndex` instead of the ledger.
|
|
106
115
|
*/
|
|
107
116
|
onHumanSave(rel: string, content: string | Uint8Array): Promise<void>
|
|
108
|
-
/**
|
|
109
|
-
* One-shot check for a 'poll' host's own outbound path (see this module's
|
|
110
|
-
* "Inbound-echo consumption" doc): call this BEFORE writing `content` (the
|
|
111
|
-
* decoded text, raw bytes, or `null` for a delete) for `rel` out to the
|
|
112
|
-
* truth source. Returns `true` when it exactly matches what
|
|
113
|
-
* `handleInboundPath` just applied FROM that same truth source — that
|
|
114
|
-
* write would be a pure echo, so the caller should skip it — and clears
|
|
115
|
-
* the record so it cannot match again (one-shot, not a standing cache).
|
|
116
|
-
* Returns `false` on any mismatch or when there is no record at all,
|
|
117
|
-
* leaving any existing record untouched.
|
|
118
|
-
*/
|
|
119
|
-
consumeInboundEcho(rel: string, content: string | Uint8Array | null): boolean
|
|
120
117
|
/** Subscribe to `port.changes` and start reconciling inbound batches. */
|
|
121
118
|
start(): void
|
|
122
119
|
/** Tear down the `port.changes` subscription. Idempotent. */
|
|
123
120
|
stop(): void
|
|
124
121
|
}
|
|
125
122
|
|
|
126
|
-
const BINARY_SNIFF_BYTES = 8192
|
|
127
|
-
|
|
128
|
-
/** True when the first `BINARY_SNIFF_BYTES` of `bytes` contain a NUL byte. */
|
|
129
|
-
function looksBinary(bytes: Uint8Array): boolean {
|
|
130
|
-
const len = Math.min(bytes.length, BINARY_SNIFF_BYTES)
|
|
131
|
-
for (let i = 0; i < len; i++) {
|
|
132
|
-
if (bytes[i] === 0) return true
|
|
133
|
-
}
|
|
134
|
-
return false
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function sha256hex(bytes: Uint8Array): Promise<string> {
|
|
138
|
-
const digest = await crypto.subtle.digest('SHA-256', bytes as BufferSource)
|
|
139
|
-
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/** Byte-for-byte equality — used by `consumeInboundEcho`'s binary-content match. */
|
|
143
|
-
function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
|
144
|
-
if (a.length !== b.length) return false
|
|
145
|
-
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
|
|
146
|
-
return true
|
|
147
|
-
}
|
|
148
|
-
|
|
149
123
|
/**
|
|
150
124
|
* True when `error` denotes "path does not exist" per the TruthPort error
|
|
151
125
|
* contract (see truth-port.ts): `error.code === 'not-found'` or
|
|
@@ -157,11 +131,6 @@ function isNotFoundError(error: unknown): boolean {
|
|
|
157
131
|
return Boolean(e) && (e!.code === 'not-found' || e!.status === 404)
|
|
158
132
|
}
|
|
159
133
|
|
|
160
|
-
type InboundApplied =
|
|
161
|
-
| { kind: 'text'; text: string }
|
|
162
|
-
| { kind: 'binary'; bytes: Uint8Array }
|
|
163
|
-
| { kind: 'delete' }
|
|
164
|
-
|
|
165
134
|
export function createSyncEngine(
|
|
166
135
|
client: SyncClientLike,
|
|
167
136
|
port: TruthPort,
|
|
@@ -170,30 +139,22 @@ export function createSyncEngine(
|
|
|
170
139
|
const decoder = new TextDecoder()
|
|
171
140
|
const applyToEditor = opts.applyToEditor
|
|
172
141
|
|
|
173
|
-
/**
|
|
174
|
-
*
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
142
|
+
/** Surface a degradation to the host — a throwing callback is the host's
|
|
143
|
+
* own bug and must not break the reconciliation that reported it. */
|
|
144
|
+
function degrade(degradation: SyncDegradation): void {
|
|
145
|
+
try {
|
|
146
|
+
opts.onDegraded?.(degradation)
|
|
147
|
+
} catch (e) {
|
|
148
|
+
console.warn('[fs-core/sync] onDegraded callback threw', e)
|
|
149
|
+
}
|
|
150
|
+
}
|
|
180
151
|
|
|
181
152
|
/**
|
|
182
153
|
* Binary files never enter the fs-core ledger — see the module doc's
|
|
183
|
-
* "Binary layering" section.
|
|
184
|
-
* `populateLedger()`.
|
|
185
|
-
*/
|
|
186
|
-
const binaryIndex = new Map<string, { size: number; sha256: string }>()
|
|
187
|
-
|
|
188
|
-
/**
|
|
189
|
-
* `rel -> { kind: 'text', text } | { kind: 'binary', bytes } | { kind: 'delete' }`
|
|
190
|
-
* — see the module doc's "Inbound-echo consumption" section. Written by
|
|
191
|
-
* `handleInboundPath` immediately after it actually applies an inbound
|
|
192
|
-
* change (ledger write/rm, or a binary/delete index update); consumed
|
|
193
|
-
* (checked + cleared on a match) by `consumeInboundEcho`. Session-scoped,
|
|
194
|
-
* same as `binaryIndex` — cleared on every `populateLedger()`.
|
|
154
|
+
* "Binary layering" section. Index-only (this engine never needs the bytes
|
|
155
|
+
* back); session-scoped: rebuilt from scratch on every `populateLedger()`.
|
|
195
156
|
*/
|
|
196
|
-
const
|
|
157
|
+
const binaryIndex = createBinarySidecar()
|
|
197
158
|
|
|
198
159
|
/**
|
|
199
160
|
* FIFO queue serializing every compare-then-record against the ledger
|
|
@@ -217,13 +178,12 @@ export function createSyncEngine(
|
|
|
217
178
|
* same persisted ledger identity) and are removed so the ledger ends up
|
|
218
179
|
* exactly matching the walked tree. */
|
|
219
180
|
async function seedFromDisk(): Promise<void> {
|
|
220
|
-
binaryIndex.clear()
|
|
221
|
-
inboundApplied.clear()
|
|
181
|
+
await binaryIndex.clear()
|
|
222
182
|
const seen = new Set<string>()
|
|
223
183
|
await port.walk(async (rel, bytes) => {
|
|
224
184
|
seen.add(rel)
|
|
225
185
|
if (looksBinary(bytes)) {
|
|
226
|
-
binaryIndex.
|
|
186
|
+
await binaryIndex.put(rel, bytes)
|
|
227
187
|
return // binary never enters the ledger
|
|
228
188
|
}
|
|
229
189
|
await client.write(rel, decoder.decode(bytes), { actor: 'human' })
|
|
@@ -238,27 +198,23 @@ export function createSyncEngine(
|
|
|
238
198
|
}
|
|
239
199
|
|
|
240
200
|
/**
|
|
241
|
-
* Process one inbound path
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
* `applyToEditor` call. A `port.read` rejection classified as not-found
|
|
201
|
+
* Process one inbound path via content comparison: identical content is
|
|
202
|
+
* the echo of our own last write and is dropped with no ledger write and
|
|
203
|
+
* no `applyToEditor` call. A `port.read` rejection classified as not-found
|
|
245
204
|
* (see truth-port.ts) is a deletion; any other rejection
|
|
246
|
-
* ("unavailable") is transient and skips the path entirely
|
|
247
|
-
* deletion from it would rm the
|
|
248
|
-
* editor while it still exists at
|
|
205
|
+
* ("unavailable") is transient and skips the path entirely (surfaced as a
|
|
206
|
+
* `truth-read` degradation) — inferring a deletion from it would rm the
|
|
207
|
+
* ledger record and close the file in the editor while it still exists at
|
|
208
|
+
* the truth source.
|
|
249
209
|
*/
|
|
250
210
|
async function handleInboundPath(rel: string): Promise<void> {
|
|
251
|
-
if (pendingWrite.has(rel)) {
|
|
252
|
-
pendingWrite.delete(rel)
|
|
253
|
-
return
|
|
254
|
-
}
|
|
255
|
-
|
|
256
211
|
let bytes: Uint8Array | null
|
|
257
212
|
try {
|
|
258
213
|
bytes = await port.read(rel)
|
|
259
214
|
} catch (e) {
|
|
260
215
|
if (!isNotFoundError(e)) {
|
|
261
216
|
console.warn('[fs-core/sync] transient port read failure, skipping', rel, e)
|
|
217
|
+
degrade({ kind: 'path-sync-failed', rel, stage: 'truth-read', error: e })
|
|
262
218
|
return
|
|
263
219
|
}
|
|
264
220
|
bytes = null
|
|
@@ -269,30 +225,35 @@ export function createSyncEngine(
|
|
|
269
225
|
// ledger (it never took the client.write path), so removal here is
|
|
270
226
|
// index-only — no client.rm.
|
|
271
227
|
if (binaryIndex.has(rel)) {
|
|
272
|
-
binaryIndex.
|
|
273
|
-
inboundApplied.set(rel, { kind: 'delete' })
|
|
228
|
+
await binaryIndex.remove(rel)
|
|
274
229
|
await applyToEditor?.(rel, null)
|
|
275
230
|
return
|
|
276
231
|
}
|
|
277
232
|
let ledgerContent: string | undefined
|
|
278
233
|
try {
|
|
279
234
|
ledgerContent = (await client.read(rel)).content
|
|
280
|
-
} catch {
|
|
235
|
+
} catch (e) {
|
|
236
|
+
if (!isNotFoundError(e)) {
|
|
237
|
+
// A transient ledger read failure is NOT "already absent" — rm'ing
|
|
238
|
+
// (or silently skipping) on unknown ledger state would either drop
|
|
239
|
+
// a record we could not confirm or leave it stale with no signal.
|
|
240
|
+
// Skip the path and surface it; the next change event retries.
|
|
241
|
+
console.warn('[fs-core/sync] ledger read failed while confirming a deletion, skipping', rel, e)
|
|
242
|
+
degrade({ kind: 'path-sync-failed', rel, stage: 'reconcile', error: e })
|
|
243
|
+
return
|
|
244
|
+
}
|
|
281
245
|
ledgerContent = undefined
|
|
282
246
|
}
|
|
283
247
|
if (ledgerContent === undefined) return // already absent from both sides
|
|
284
248
|
await client.rm(rel, { actor: 'human' })
|
|
285
|
-
inboundApplied.set(rel, { kind: 'delete' })
|
|
286
249
|
await applyToEditor?.(rel, null)
|
|
287
250
|
return
|
|
288
251
|
}
|
|
289
252
|
|
|
290
253
|
if (looksBinary(bytes)) {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (
|
|
294
|
-
binaryIndex.set(rel, { size: bytes.length, sha256 })
|
|
295
|
-
inboundApplied.set(rel, { kind: 'binary', bytes })
|
|
254
|
+
// Echo judgement is the sidecar's put(): `false` = same size+sha256
|
|
255
|
+
// already indexed, i.e. the echo of our own last write.
|
|
256
|
+
if (!(await binaryIndex.put(rel, bytes))) return
|
|
296
257
|
await applyToEditor?.(rel, bytes)
|
|
297
258
|
return
|
|
298
259
|
}
|
|
@@ -312,39 +273,16 @@ export function createSyncEngine(
|
|
|
312
273
|
if (ledgerContent !== undefined && text === ledgerContent) return // echo of our own write
|
|
313
274
|
|
|
314
275
|
await client.write(rel, text, { actor: 'human' })
|
|
315
|
-
inboundApplied.set(rel, { kind: 'text', text })
|
|
316
276
|
await applyToEditor?.(rel, bytes)
|
|
317
277
|
}
|
|
318
278
|
|
|
319
|
-
/**
|
|
320
|
-
* One-shot check for a 'poll' host's own outbound path: "is `content` (the
|
|
321
|
-
* bytes/text about to be written to the truth source for `rel`, or `null`
|
|
322
|
-
* for a delete) exactly what `handleInboundPath` just applied FROM that
|
|
323
|
-
* same truth source?" A match means writing it back out would be a pure
|
|
324
|
-
* echo — the host should skip the write entirely — and the record is
|
|
325
|
-
* cleared (consumed) so it cannot match again. See the module doc's
|
|
326
|
-
* "Inbound-echo consumption" section for why a push host (devtools) never
|
|
327
|
-
* needs this: its outbound path goes through `onHumanSave`/`pendingWrite`
|
|
328
|
-
* instead.
|
|
329
|
-
*/
|
|
330
|
-
function consumeInboundEcho(rel: string, content: string | Uint8Array | null): boolean {
|
|
331
|
-
const entry = inboundApplied.get(rel)
|
|
332
|
-
if (!entry) return false
|
|
333
|
-
let matches: boolean
|
|
334
|
-
if (content === null) matches = entry.kind === 'delete'
|
|
335
|
-
else if (typeof content === 'string') matches = entry.kind === 'text' && entry.text === content
|
|
336
|
-
else matches = entry.kind === 'binary' && bytesEqual(entry.bytes, content)
|
|
337
|
-
if (matches) inboundApplied.delete(rel)
|
|
338
|
-
return matches
|
|
339
|
-
}
|
|
340
|
-
|
|
341
279
|
/**
|
|
342
280
|
* A batch is trusted as-is: the port adapter (see truth-port.ts's
|
|
343
281
|
* `changes` doc) is responsible for turning a watcher's coalesced/lossy
|
|
344
282
|
* events into the actual set of paths worth re-examining BEFORE calling
|
|
345
|
-
* this engine — devtools' adapter (wal-audit.ts +
|
|
346
|
-
*
|
|
347
|
-
* against a session index, so paths arriving here have already earned
|
|
283
|
+
* this engine — devtools' adapter (dimina-kit workbench's wal-audit.ts +
|
|
284
|
+
* this package's ./watch-expander.ts) does that via a stat-level disk
|
|
285
|
+
* compare against a session index, so paths arriving here have already earned
|
|
348
286
|
* their re-examination and no further expansion happens at this layer.
|
|
349
287
|
*/
|
|
350
288
|
async function handleBatch(paths: string[]): Promise<void> {
|
|
@@ -356,6 +294,7 @@ export function createSyncEngine(
|
|
|
356
294
|
await enqueueLedgerTurn(() => handleInboundPath(rel))
|
|
357
295
|
} catch (e) {
|
|
358
296
|
console.warn('[fs-core/sync] disk sync failed for', rel, e)
|
|
297
|
+
degrade({ kind: 'path-sync-failed', rel, stage: 'reconcile', error: e })
|
|
359
298
|
}
|
|
360
299
|
}
|
|
361
300
|
}
|
|
@@ -385,10 +324,27 @@ export function createSyncEngine(
|
|
|
385
324
|
/** Subscribe to `port.changes`. `active` gates BOTH callbacks so a
|
|
386
325
|
* late/duplicate event delivered after `onDead` (or after a fresh
|
|
387
326
|
* `start()` superseded this subscription) is a guaranteed no-op, not just
|
|
388
|
-
* best-effort.
|
|
327
|
+
* best-effort. The subscription is torn down through `disposeOnce`: the
|
|
328
|
+
* TruthPort contract does not require an idempotent dispose, and a host's
|
|
329
|
+
* `onDegraded` callback may synchronously call `engine.stop()` — without
|
|
330
|
+
* the guard that re-enters the same dispose a second time. */
|
|
389
331
|
function start(): void {
|
|
390
332
|
let active = true
|
|
391
|
-
|
|
333
|
+
let disposed = false
|
|
334
|
+
// "dispose requested" and "dispose implementation available" are split:
|
|
335
|
+
// a port may fire onDead synchronously DURING the changes() call, before
|
|
336
|
+
// its dispose function even exists — the request is recorded via
|
|
337
|
+
// `disposed` and the implementation is invoked right after the
|
|
338
|
+
// subscription call returns it (see below). Declared-then-assigned (not
|
|
339
|
+
// `const`) precisely so that a during-subscription disposeOnce() reads
|
|
340
|
+
// `undefined` instead of hitting a temporal dead zone.
|
|
341
|
+
let disposeImpl: (() => void) | undefined = undefined
|
|
342
|
+
function disposeOnce(): void {
|
|
343
|
+
if (disposed) return
|
|
344
|
+
disposed = true
|
|
345
|
+
disposeImpl?.()
|
|
346
|
+
}
|
|
347
|
+
disposeImpl = port.changes(
|
|
392
348
|
(paths) => {
|
|
393
349
|
if (!active) return
|
|
394
350
|
enqueueInboundBatch(paths)
|
|
@@ -397,12 +353,16 @@ export function createSyncEngine(
|
|
|
397
353
|
if (!active) return
|
|
398
354
|
active = false
|
|
399
355
|
console.warn('[fs-core/sync] watcher died — reverting to open-time mirror only')
|
|
400
|
-
|
|
356
|
+
disposeOnce()
|
|
357
|
+
degrade({ kind: 'watcher-dead' })
|
|
401
358
|
},
|
|
402
359
|
)
|
|
360
|
+
// Dispose was requested during subscription (synchronous onDead): the
|
|
361
|
+
// implementation exists now — honor the request exactly once.
|
|
362
|
+
if (disposed) disposeImpl()
|
|
403
363
|
stopWatching = () => {
|
|
404
364
|
active = false
|
|
405
|
-
|
|
365
|
+
disposeOnce()
|
|
406
366
|
}
|
|
407
367
|
}
|
|
408
368
|
|
|
@@ -412,12 +372,11 @@ export function createSyncEngine(
|
|
|
412
372
|
}
|
|
413
373
|
|
|
414
374
|
/**
|
|
415
|
-
* Outbound accounting for a human save:
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
* onSave wrapper decides whether a ledger-write failure should be
|
|
375
|
+
* Outbound accounting for a human save: skips the ledger write when the
|
|
376
|
+
* saved text already matches the ledger's record, and runs inside the same
|
|
377
|
+
* `ledgerTurn` FIFO as inbound batches so the two can never interleave
|
|
378
|
+
* (see module doc's "Echo judgement"). Errors propagate to the caller — a
|
|
379
|
+
* host's onSave wrapper decides whether a ledger-write failure should be
|
|
421
380
|
* swallowed (best-effort accounting must never unwind a save that already
|
|
422
381
|
* landed at the truth source).
|
|
423
382
|
*
|
|
@@ -428,31 +387,25 @@ export function createSyncEngine(
|
|
|
428
387
|
* entirely and only updates `binaryIndex`.
|
|
429
388
|
*/
|
|
430
389
|
async function onHumanSave(rel: string, content: string | Uint8Array): Promise<void> {
|
|
431
|
-
|
|
432
|
-
try {
|
|
433
|
-
if (content instanceof Uint8Array && looksBinary(content)) {
|
|
434
|
-
await enqueueLedgerTurn(async () => {
|
|
435
|
-
binaryIndex.set(rel, { size: content.length, sha256: await sha256hex(content) })
|
|
436
|
-
})
|
|
437
|
-
return
|
|
438
|
-
}
|
|
439
|
-
const text = typeof content === 'string' ? content : decoder.decode(content)
|
|
390
|
+
if (content instanceof Uint8Array && looksBinary(content)) {
|
|
440
391
|
await enqueueLedgerTurn(async () => {
|
|
441
|
-
|
|
442
|
-
.read(rel)
|
|
443
|
-
.then((r) => r.content === text)
|
|
444
|
-
.catch(() => false)
|
|
445
|
-
if (!unchanged) await client.write(rel, text, { actor: 'human' })
|
|
392
|
+
await binaryIndex.put(rel, content)
|
|
446
393
|
})
|
|
447
|
-
|
|
448
|
-
pendingWrite.delete(rel)
|
|
394
|
+
return
|
|
449
395
|
}
|
|
396
|
+
const text = typeof content === 'string' ? content : decoder.decode(content)
|
|
397
|
+
await enqueueLedgerTurn(async () => {
|
|
398
|
+
const unchanged = await client
|
|
399
|
+
.read(rel)
|
|
400
|
+
.then((r) => r.content === text)
|
|
401
|
+
.catch(() => false)
|
|
402
|
+
if (!unchanged) await client.write(rel, text, { actor: 'human' })
|
|
403
|
+
})
|
|
450
404
|
}
|
|
451
405
|
|
|
452
406
|
return {
|
|
453
407
|
populateLedger: seedFromDisk,
|
|
454
408
|
onHumanSave,
|
|
455
|
-
consumeInboundEcho,
|
|
456
409
|
start,
|
|
457
410
|
stop,
|
|
458
411
|
}
|
package/sync/truth-port.ts
CHANGED
|
@@ -64,9 +64,9 @@ export interface TruthPort {
|
|
|
64
64
|
* that scope against a session index, and report every path whose stat
|
|
65
65
|
* actually moved, plus ledger paths in scope that vanished from disk).
|
|
66
66
|
* `handleBatch` in sync-engine.ts takes `onBatch`'s paths at face value
|
|
67
|
-
* and does no further expansion —
|
|
68
|
-
*
|
|
69
|
-
*
|
|
67
|
+
* and does no further expansion — ./watch-expander.ts is the optional
|
|
68
|
+
* helper implementing exactly that recovery; see devtools' adapter
|
|
69
|
+
* (dimina-kit workbench's wal-audit.ts) for a concrete assembly.
|
|
70
70
|
*/
|
|
71
71
|
changes(onBatch: (paths: string[]) => void, onDead: () => void): () => void
|
|
72
72
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the watch-batch expander's stat-diff contract, against a
|
|
3
|
+
* fake stat-capable readdir. The full black-box behavior (through a real
|
|
4
|
+
* TruthPort assembly) stays covered by dimina-kit workbench's
|
|
5
|
+
* wal-audit-disk-stat-expansion.test.ts — these pin the module's own
|
|
6
|
+
* algorithmic promises at the fs-core boundary.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it } from 'vitest'
|
|
9
|
+
import { createWatchExpander } from './watch-expander.js'
|
|
10
|
+
import type { WatchExpanderEntry } from './watch-expander.js'
|
|
11
|
+
|
|
12
|
+
/** In-memory tree: rel -> {size, mtimeMs}. Directories are implied by their
|
|
13
|
+
* children's paths, same as a real filesystem listing would show them. */
|
|
14
|
+
function makeDisk(files: Record<string, { size: number; mtimeMs: number }>) {
|
|
15
|
+
const readdir = async (rel: string): Promise<WatchExpanderEntry[]> => {
|
|
16
|
+
const prefix = rel === '.' || rel === '' ? '' : `${rel}/`
|
|
17
|
+
const names = new Map<string, WatchExpanderEntry>()
|
|
18
|
+
let matchedScope = prefix === ''
|
|
19
|
+
for (const [p, stat] of Object.entries(files)) {
|
|
20
|
+
if (!p.startsWith(prefix)) continue
|
|
21
|
+
matchedScope = true
|
|
22
|
+
const rest = p.slice(prefix.length)
|
|
23
|
+
const slash = rest.indexOf('/')
|
|
24
|
+
if (slash === -1) names.set(rest, [rest, 1, stat.size, stat.mtimeMs])
|
|
25
|
+
else names.set(rest.slice(0, slash), [rest.slice(0, slash), 2])
|
|
26
|
+
}
|
|
27
|
+
// A path that is neither a file nor an ancestor of one does not exist —
|
|
28
|
+
// and listing a FILE path is an error too, like a real readdir(file).
|
|
29
|
+
if (!matchedScope || rel in files) throw new Error(`ENOTDIR/ENOENT: ${rel}`)
|
|
30
|
+
return [...names.values()]
|
|
31
|
+
}
|
|
32
|
+
return { files, readdir }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('createWatchExpander', () => {
|
|
36
|
+
it('reports only the stat-changed file for a coalesced directory event (after warm-up)', async () => {
|
|
37
|
+
const disk = makeDisk({
|
|
38
|
+
'src/a.txt': { size: 3, mtimeMs: 100 },
|
|
39
|
+
'src/b.txt': { size: 3, mtimeMs: 100 },
|
|
40
|
+
'src/c.txt': { size: 3, mtimeMs: 100 },
|
|
41
|
+
})
|
|
42
|
+
const ex = createWatchExpander(disk.readdir)
|
|
43
|
+
await ex.warmFromDisk()
|
|
44
|
+
disk.files['src/b.txt'] = { size: 4, mtimeMs: 200 }
|
|
45
|
+
const out = await ex.expandWatchBatch(['src'], ['src/a.txt', 'src/b.txt', 'src/c.txt'])
|
|
46
|
+
expect(out).toEqual(['src/b.txt'])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('recovers a coalesced deletion: ledger paths missing from the listed scope are reported', async () => {
|
|
50
|
+
const disk = makeDisk({ 'src/a.txt': { size: 3, mtimeMs: 100 } })
|
|
51
|
+
const ex = createWatchExpander(disk.readdir)
|
|
52
|
+
await ex.warmFromDisk()
|
|
53
|
+
// rm -rf took b.txt and c.txt; the watcher only named the directory.
|
|
54
|
+
const out = await ex.expandWatchBatch(['src'], ['src/a.txt', 'src/b.txt', 'src/c.txt'])
|
|
55
|
+
expect(out.sort()).toEqual(['src/b.txt', 'src/c.txt'])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it("expands the '.' overflow rescan against the whole tree + whole ledger", async () => {
|
|
59
|
+
const disk = makeDisk({ 'kept.txt': { size: 1, mtimeMs: 1 }, 'new.txt': { size: 1, mtimeMs: 1 } })
|
|
60
|
+
const ex = createWatchExpander(disk.readdir)
|
|
61
|
+
// warm only knows kept.txt
|
|
62
|
+
delete disk.files['new.txt']
|
|
63
|
+
await ex.warmFromDisk()
|
|
64
|
+
disk.files['new.txt'] = { size: 1, mtimeMs: 1 }
|
|
65
|
+
const out = await ex.expandWatchBatch(['.'], ['kept.txt', 'gone.txt'])
|
|
66
|
+
expect(out.sort()).toEqual(['gone.txt', 'new.txt'])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('always reports a point-named plain file (content can change without a stat move)', async () => {
|
|
70
|
+
const disk = makeDisk({ 'a.txt': { size: 3, mtimeMs: 100 } })
|
|
71
|
+
const ex = createWatchExpander(disk.readdir)
|
|
72
|
+
await ex.warmFromDisk()
|
|
73
|
+
// stat unchanged — the file itself must still be reported.
|
|
74
|
+
const out = await ex.expandWatchBatch(['a.txt'], ['a.txt'])
|
|
75
|
+
expect(out).toEqual(['a.txt'])
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('excludes a point-named live directory UNLESS the ledger still records that exact path', async () => {
|
|
79
|
+
const disk = makeDisk({ 'src/a.txt': { size: 3, mtimeMs: 100 } })
|
|
80
|
+
const ex = createWatchExpander(disk.readdir)
|
|
81
|
+
await ex.warmFromDisk()
|
|
82
|
+
expect(await ex.expandWatchBatch(['src'], ['src/a.txt'])).toEqual([])
|
|
83
|
+
// Same-named file→directory replacement: a stale ledger FILE record at
|
|
84
|
+
// 'src' must be reported so the engine's read can retire it.
|
|
85
|
+
expect(await ex.expandWatchBatch(['src'], ['src', 'src/a.txt'])).toEqual(['src'])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('resetIndex() forgets the session index: every disk file reports again', async () => {
|
|
89
|
+
const disk = makeDisk({ 'src/a.txt': { size: 3, mtimeMs: 100 } })
|
|
90
|
+
const ex = createWatchExpander(disk.readdir)
|
|
91
|
+
await ex.warmFromDisk()
|
|
92
|
+
expect(await ex.expandWatchBatch(['src'], ['src/a.txt'])).toEqual([])
|
|
93
|
+
ex.resetIndex()
|
|
94
|
+
expect(await ex.expandWatchBatch(['src'], ['src/a.txt'])).toEqual(['src/a.txt'])
|
|
95
|
+
})
|
|
96
|
+
})
|