@dimina-kit/fs-core 0.2.0-dev.20260710085051 → 0.3.0-dev.6-dev.20260711133419

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,187 @@
1
+ /**
2
+ * Binary side-car — the authoritative owner of "binary files live OUTSIDE the
3
+ * fs-core string ledger". The ledger's write/read surface is a string-content
4
+ * contract (WAL audit, diff/restore all reason over text), so every host that
5
+ * meets a binary file needs the same three things this module owns exactly
6
+ * once:
7
+ *
8
+ * - classification: {@link looksBinary} (a NUL byte in the first 8192 bytes);
9
+ * - a unified index: `rel -> { size, sha256 }`, maintained for every entry
10
+ * regardless of whether bytes are retained;
11
+ * - echo judgement: {@link BinarySidecar.put} compares size+sha256 against
12
+ * the prior entry and reports `false` (unchanged) for a re-arrival of the
13
+ * same bytes — the exact judgement the sync engine uses to absorb a binary
14
+ * file's own write echo.
15
+ *
16
+ * Two host shapes, one abstraction:
17
+ * - dimina-kit's sync engine (sync-engine.ts) keeps an INDEX-ONLY sidecar —
18
+ * it never needs the bytes back, only membership + echo judgement;
19
+ * - a host whose ledger snapshot must be re-joined with the binary set
20
+ * (qdmp-web-workbench feeding compile/export/disk-mirror) constructs it
21
+ * with `retainBytes: true` and uses {@link BinarySidecar.overlay} as the
22
+ * ONE place that merges "ledger text + binary side-car" (ledger text wins
23
+ * on a path present in both — the ledger is the authority for anything it
24
+ * holds).
25
+ *
26
+ * Change events (`onChange`) fire on every effective mutation (`put` that
27
+ * actually changed, `remove` of an existing entry) — `clear()`/`reset()` are
28
+ * session reseeds (project switch / ledger repopulate) and deliberately do
29
+ * NOT emit per-entry removals; a subscriber that survives a reseed should
30
+ * re-read the sidecar wholesale, same as it re-reads the ledger.
31
+ */
32
+
33
+ import { sha256hex } from '#worker-lib/wal-codec.js'
34
+
35
+ const BINARY_SNIFF_BYTES = 8192
36
+
37
+ /** True when the first {@link BINARY_SNIFF_BYTES} of `bytes` contain a NUL
38
+ * byte — the classification gate for the whole binary layering. */
39
+ export function looksBinary(bytes: Uint8Array): boolean {
40
+ const len = Math.min(bytes.length, BINARY_SNIFF_BYTES)
41
+ for (let i = 0; i < len; i++) {
42
+ if (bytes[i] === 0) return true
43
+ }
44
+ return false
45
+ }
46
+
47
+ export interface BinarySidecarEntry {
48
+ size: number
49
+ sha256: string
50
+ }
51
+
52
+ export interface BinarySidecarOptions {
53
+ /** Keep the actual bytes alongside the index (for hosts that re-join the
54
+ * binary set with ledger snapshots — see the module doc). Default: index
55
+ * only. */
56
+ retainBytes?: boolean
57
+ }
58
+
59
+ export interface BinarySidecar {
60
+ /** Record `bytes` for `rel`. Returns `false` when the entry is an echo of
61
+ * what is already recorded (same size + sha256) — nothing changes and no
62
+ * event fires — and `true` when the entry was created or updated. */
63
+ put(rel: string, bytes: Uint8Array): Promise<boolean>
64
+ /** Remove `rel`. Resolves to whether an entry actually existed. */
65
+ remove(rel: string): Promise<boolean>
66
+ has(rel: string): boolean
67
+ entry(rel: string): BinarySidecarEntry | undefined
68
+ /** The retained bytes for `rel` — always `undefined` without `retainBytes`. */
69
+ bytes(rel: string): Uint8Array | undefined
70
+ keys(): string[]
71
+ readonly size: number
72
+ /** Session reseed: drop everything, silently (see the module doc). */
73
+ clear(): Promise<void>
74
+ /** Bulk session reseed: replace the whole content with `files` — the reseed
75
+ * a host's project-open path uses. */
76
+ reset(files: Record<string, Uint8Array>): Promise<void>
77
+ /** Snapshot of the retained bytes (`retainBytes` hosts only; empty otherwise). */
78
+ toRecord(): Record<string, Uint8Array>
79
+ /** THE merge of "ledger text + binary side-car": entries of `files` win
80
+ * over sidecar bytes for a path present in both. Requires `retainBytes`. */
81
+ overlay<T>(files: Record<string, T>): Record<string, T | Uint8Array>
82
+ /** Subscribe to effective mutations (`bytes === null` = removal). Returns
83
+ * an unsubscribe function. */
84
+ onChange(cb: (rel: string, bytes: Uint8Array | null) => void): () => void
85
+ }
86
+
87
+ export function createBinarySidecar(opts: BinarySidecarOptions = {}): BinarySidecar {
88
+ const retainBytes = opts.retainBytes === true
89
+ // `let` (not const): reset() swaps in fully-built replacement maps in one
90
+ // synchronous step — see its body.
91
+ let index = new Map<string, BinarySidecarEntry>()
92
+ let store = new Map<string, Uint8Array>()
93
+ const changeCbs = new Set<(rel: string, bytes: Uint8Array | null) => void>()
94
+
95
+ /**
96
+ * FIFO serializing every MUTATION (put/remove/clear/reset) in caller order.
97
+ * Without it, a put whose (async) hashing straddles an in-flight reset()
98
+ * lands on maps the reset is about to swap away — the mutation silently
99
+ * vanishes — and two overlapping resets resolve as "last swapper wins"
100
+ * instead of "last caller wins". Serialized, a mutation issued after
101
+ * reset() runs after it and lands in the NEW session; one issued before is
102
+ * superseded by it — nothing races across a session boundary. READS
103
+ * (has/entry/bytes/keys/overlay/toRecord) stay synchronous against the
104
+ * currently visible maps: reset's one-step swap keeps them consistent.
105
+ * The chain swallows step rejections so one failed mutation cannot wedge
106
+ * the queue.
107
+ */
108
+ let mutationTurn: Promise<unknown> = Promise.resolve()
109
+ function enqueueMutation<T>(fn: () => T | Promise<T>): Promise<T> {
110
+ const next = mutationTurn.then(fn, fn) as Promise<T>
111
+ mutationTurn = next.catch(() => {})
112
+ return next
113
+ }
114
+
115
+ function emit(rel: string, bytes: Uint8Array | null): void {
116
+ for (const cb of changeCbs) {
117
+ try {
118
+ cb(rel, bytes)
119
+ } catch {
120
+ // A subscriber's failure is its own — it must not break the mutation
121
+ // that already happened, nor starve later subscribers.
122
+ }
123
+ }
124
+ }
125
+
126
+ return {
127
+ put(rel, bytes) {
128
+ return enqueueMutation(async () => {
129
+ const sha256 = await sha256hex(bytes)
130
+ const prior = index.get(rel)
131
+ if (prior && prior.size === bytes.length && prior.sha256 === sha256) return false
132
+ index.set(rel, { size: bytes.length, sha256 })
133
+ if (retainBytes) store.set(rel, bytes)
134
+ emit(rel, bytes)
135
+ return true
136
+ })
137
+ },
138
+ remove(rel) {
139
+ return enqueueMutation(() => {
140
+ const existed = index.delete(rel)
141
+ store.delete(rel)
142
+ if (existed) emit(rel, null)
143
+ return existed
144
+ })
145
+ },
146
+ has: (rel) => index.has(rel),
147
+ entry: (rel) => index.get(rel),
148
+ bytes: (rel) => store.get(rel),
149
+ keys: () => [...index.keys()],
150
+ get size() {
151
+ return index.size
152
+ },
153
+ clear() {
154
+ return enqueueMutation(() => {
155
+ index.clear()
156
+ store.clear()
157
+ })
158
+ },
159
+ reset(files) {
160
+ return enqueueMutation(async () => {
161
+ // Atomic to readers: hashing runs against LOCAL maps and the visible
162
+ // state is swapped in one synchronous step at the end — a concurrent
163
+ // overlay()/toRecord()/entry() sees either the old set or the new
164
+ // set, never an empty or half-built one. Silent by design (see the
165
+ // module doc): a session reseed fires no per-entry events. Ordering
166
+ // vs other mutations comes from the mutation FIFO above.
167
+ const nextIndex = new Map<string, BinarySidecarEntry>()
168
+ const nextStore = new Map<string, Uint8Array>()
169
+ for (const [rel, bytes] of Object.entries(files)) {
170
+ nextIndex.set(rel, { size: bytes.length, sha256: await sha256hex(bytes) })
171
+ if (retainBytes) nextStore.set(rel, bytes)
172
+ }
173
+ index = nextIndex
174
+ store = nextStore
175
+ })
176
+ },
177
+ toRecord: () => Object.fromEntries(store),
178
+ overlay(files) {
179
+ if (!retainBytes) throw new Error('overlay() requires a retainBytes sidecar — an index-only sidecar has no bytes to merge')
180
+ return { ...Object.fromEntries(store), ...files }
181
+ },
182
+ onChange(cb) {
183
+ changeCbs.add(cb)
184
+ return () => changeCbs.delete(cb)
185
+ },
186
+ }
187
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Contract tests for `createSyncEngine`'s `onDegraded` reporting: every
3
+ * silent-fallback path (dead watcher, a transient truth-source read failure,
4
+ * a ledger write failure while reconciling an inbound change) must surface
5
+ * through the host-supplied `onDegraded` callback instead of only a
6
+ * `console.warn`, and a host callback that itself throws must never wedge
7
+ * the engine's own processing.
8
+ */
9
+ import { describe, expect, it, vi } from 'vitest'
10
+ import { createSyncEngine } from './sync-engine.js'
11
+ import type { SyncClientLike, SyncDegradation } from './sync-engine.js'
12
+ import type { TruthPort } from './truth-port.js'
13
+
14
+ function makeClient(overrides: Partial<SyncClientLike> = {}): SyncClientLike {
15
+ return {
16
+ write: vi.fn().mockResolvedValue(undefined),
17
+ rm: vi.fn().mockResolvedValue(undefined),
18
+ read: vi.fn().mockRejectedValue(Object.assign(new Error('not found'), { code: 'not-found' })),
19
+ ls: vi.fn().mockResolvedValue({ paths: [] }),
20
+ ...overrides,
21
+ }
22
+ }
23
+
24
+ function makePort(overrides: Partial<TruthPort> = {}): TruthPort {
25
+ return {
26
+ capabilities: { watch: 'push' },
27
+ read: vi.fn().mockResolvedValue(new Uint8Array()),
28
+ write: vi.fn().mockResolvedValue(undefined),
29
+ delete: vi.fn().mockResolvedValue(undefined),
30
+ walk: vi.fn().mockResolvedValue(undefined),
31
+ changes: vi.fn().mockReturnValue(() => {}),
32
+ ...overrides,
33
+ }
34
+ }
35
+
36
+ /** Fake `port.changes`: captures the `onBatch`/`onDead` callbacks `start()`
37
+ * registers and exposes them as `emitBatch`/`emitDead`, so a test can drive
38
+ * the engine without a real event stream. */
39
+ function makeWatch(): {
40
+ changes: TruthPort['changes']
41
+ stop: ReturnType<typeof vi.fn>
42
+ emitBatch: (paths: string[]) => void
43
+ emitDead: () => void
44
+ } {
45
+ let batchHandler: (paths: string[]) => void = () => {}
46
+ let deadHandler: () => void = () => {}
47
+ const stop = vi.fn()
48
+ const changes: TruthPort['changes'] = (onBatch, onDead) => {
49
+ batchHandler = onBatch
50
+ deadHandler = onDead
51
+ return stop
52
+ }
53
+ return {
54
+ changes,
55
+ stop,
56
+ emitBatch: (paths) => batchHandler(paths),
57
+ emitDead: () => deadHandler(),
58
+ }
59
+ }
60
+
61
+ /** Flush the microtask queue enough times for a batch's chained port/ledger
62
+ * reads+writes to settle before assertions run. */
63
+ const flush = () => new Promise((r) => setTimeout(r, 0))
64
+
65
+ describe('createSyncEngine — onDegraded: dead watcher', () => {
66
+ it('reports {kind: "watcher-dead"} exactly once and still disposes the subscription', async () => {
67
+ const watch = makeWatch()
68
+ const onDegraded = vi.fn<(d: SyncDegradation) => void>()
69
+ const port = makePort({ changes: watch.changes })
70
+ const client = makeClient()
71
+ const engine = createSyncEngine(client, port, { onDegraded })
72
+
73
+ await engine.populateLedger()
74
+ engine.start()
75
+ watch.emitDead()
76
+
77
+ expect(onDegraded).toHaveBeenCalledTimes(1)
78
+ expect(onDegraded).toHaveBeenCalledWith({ kind: 'watcher-dead' })
79
+ // Dispose is the pre-existing contract this test must not regress.
80
+ expect(watch.stop).toHaveBeenCalledTimes(1)
81
+ })
82
+ })
83
+
84
+ describe('createSyncEngine — onDegraded: reentrant stop() from the host callback', () => {
85
+ it('a host calling engine.stop() synchronously from onDegraded never double-disposes the watch subscription', async () => {
86
+ // The TruthPort `changes` contract does not require the returned dispose
87
+ // to be idempotent — a custom port may legitimately throw on a second
88
+ // call. This fake enforces that: the engine must guarantee at most ONE
89
+ // dispose call per subscription, even when the host reacts to
90
+ // {kind:'watcher-dead'} by synchronously tearing the engine down.
91
+ let disposeCalls = 0
92
+ let deadHandler: () => void = () => {}
93
+ const changes: TruthPort['changes'] = (_onBatch, onDead) => {
94
+ deadHandler = onDead
95
+ return () => {
96
+ disposeCalls += 1
97
+ if (disposeCalls > 1) throw new Error('dispose is not idempotent — called twice')
98
+ }
99
+ }
100
+ const port = makePort({ changes })
101
+ const client = makeClient()
102
+ const engineRef: { current?: ReturnType<typeof createSyncEngine> } = {}
103
+ const onDegraded = vi.fn<(d: SyncDegradation) => void>(() => {
104
+ engineRef.current?.stop()
105
+ })
106
+ const engine = createSyncEngine(client, port, { onDegraded })
107
+ engineRef.current = engine
108
+
109
+ await engine.populateLedger()
110
+ engine.start()
111
+ expect(() => deadHandler()).not.toThrow()
112
+
113
+ expect(onDegraded).toHaveBeenCalledTimes(1)
114
+ expect(onDegraded).toHaveBeenCalledWith({ kind: 'watcher-dead' })
115
+ expect(disposeCalls).toBe(1)
116
+ })
117
+ })
118
+
119
+ describe('createSyncEngine — onDegraded: onDead fired synchronously during subscription', () => {
120
+ it('a port firing onDead synchronously during subscription still tears down exactly once, without throwing', async () => {
121
+ // The TruthPort `changes` contract does not forbid invoking the callbacks
122
+ // DURING the subscription call itself — a port may discover its watcher
123
+ // is already dead and fire onDead before returning the dispose function.
124
+ // The engine must survive that ordering: start() must not throw (the
125
+ // dispose reference must not be dereferenced before the subscription call
126
+ // returns it), the host gets a single {kind:'watcher-dead'} degradation,
127
+ // and the dispose is called exactly once after it exists — never twice,
128
+ // neither here nor via a later stop().
129
+ let disposeCalls = 0
130
+ const changes: TruthPort['changes'] = (_onBatch, onDead) => {
131
+ onDead()
132
+ return () => {
133
+ disposeCalls += 1
134
+ if (disposeCalls > 1) throw new Error('dispose is not idempotent — called twice')
135
+ }
136
+ }
137
+ const port = makePort({ changes })
138
+ const client = makeClient()
139
+ const onDegraded = vi.fn<(d: SyncDegradation) => void>()
140
+ const engine = createSyncEngine(client, port, { onDegraded })
141
+
142
+ await engine.populateLedger()
143
+ expect(() => engine.start()).not.toThrow()
144
+
145
+ expect(onDegraded).toHaveBeenCalledTimes(1)
146
+ expect(onDegraded).toHaveBeenCalledWith({ kind: 'watcher-dead' })
147
+ expect(disposeCalls).toBe(1)
148
+
149
+ expect(() => engine.stop()).not.toThrow()
150
+ expect(disposeCalls).toBe(1)
151
+ })
152
+ })
153
+
154
+ describe('createSyncEngine — onDegraded: transient truth-read failure', () => {
155
+ it('reports {kind: "path-sync-failed", stage: "truth-read"} with the original error and skips the path', async () => {
156
+ const watch = makeWatch()
157
+ const onDegraded = vi.fn<(d: SyncDegradation) => void>()
158
+ const readError = new Error('port gone')
159
+ const port = makePort({ changes: watch.changes, read: vi.fn().mockRejectedValue(readError) })
160
+ const client = makeClient({ read: vi.fn().mockResolvedValue({ content: 'still here' }) })
161
+ const engine = createSyncEngine(client, port, { onDegraded })
162
+
163
+ await engine.populateLedger()
164
+ engine.start()
165
+ watch.emitBatch(['flaky.js'])
166
+ await flush()
167
+
168
+ expect(onDegraded).toHaveBeenCalledWith({
169
+ kind: 'path-sync-failed',
170
+ rel: 'flaky.js',
171
+ stage: 'truth-read',
172
+ error: readError,
173
+ })
174
+ expect(client.rm).not.toHaveBeenCalled()
175
+ expect(client.write).not.toHaveBeenCalled()
176
+ })
177
+ })
178
+
179
+ describe('createSyncEngine — onDegraded: ledger write failure while reconciling', () => {
180
+ it('reports {kind: "path-sync-failed", stage: "reconcile"} with the original error', async () => {
181
+ const watch = makeWatch()
182
+ const onDegraded = vi.fn<(d: SyncDegradation) => void>()
183
+ const writeError = new Error('ledger write failed')
184
+ const port = makePort({
185
+ changes: watch.changes,
186
+ read: vi.fn().mockResolvedValue(new TextEncoder().encode('new content')),
187
+ })
188
+ const client = makeClient({
189
+ read: vi.fn().mockResolvedValue({ content: 'old content' }),
190
+ write: vi.fn().mockRejectedValue(writeError),
191
+ })
192
+ const engine = createSyncEngine(client, port, { onDegraded })
193
+
194
+ await engine.populateLedger()
195
+ engine.start()
196
+ watch.emitBatch(['a.js'])
197
+ await flush()
198
+
199
+ expect(onDegraded).toHaveBeenCalledWith({
200
+ kind: 'path-sync-failed',
201
+ rel: 'a.js',
202
+ stage: 'reconcile',
203
+ error: writeError,
204
+ })
205
+ })
206
+ })
207
+
208
+ describe('createSyncEngine — onDegraded: ledger read failure while confirming a deletion', () => {
209
+ it('a transient ledger read failure during an inbound delete reports stage "reconcile" and never rms the ledger', async () => {
210
+ // Inbound delete path: the truth source says not-found, and the engine
211
+ // then reads the ledger to decide between "remove the record" and
212
+ // "already absent from both sides". A TRANSIENT ledger read failure
213
+ // (worker hiccup/timeout — no not-found code) leaves the ledger state
214
+ // unknown: treating it as "already absent" would silently strand a stale
215
+ // ledger record with the host none the wiser. The engine must degrade
216
+ // instead, and must never rm against an unknown ledger state.
217
+ const watch = makeWatch()
218
+ const onDegraded = vi.fn<(d: SyncDegradation) => void>()
219
+ const ledgerError = new Error('worker hiccup')
220
+ const port = makePort({
221
+ changes: watch.changes,
222
+ read: vi.fn().mockRejectedValue(Object.assign(new Error('gone'), { code: 'not-found' })),
223
+ })
224
+ const client = makeClient({ read: vi.fn().mockRejectedValue(ledgerError) })
225
+ const engine = createSyncEngine(client, port, { onDegraded })
226
+
227
+ await engine.populateLedger()
228
+ engine.start()
229
+ watch.emitBatch(['gone.js'])
230
+ await flush()
231
+
232
+ expect(onDegraded).toHaveBeenCalledWith({
233
+ kind: 'path-sync-failed',
234
+ rel: 'gone.js',
235
+ stage: 'reconcile',
236
+ error: ledgerError,
237
+ })
238
+ expect(client.rm).not.toHaveBeenCalled()
239
+ })
240
+
241
+ it('a genuine both-sides-absent deletion (ledger read rejects not-found) stays silent — no degradation, no rm', async () => {
242
+ const watch = makeWatch()
243
+ const onDegraded = vi.fn<(d: SyncDegradation) => void>()
244
+ const port = makePort({
245
+ changes: watch.changes,
246
+ read: vi.fn().mockRejectedValue(Object.assign(new Error('gone'), { code: 'not-found' })),
247
+ })
248
+ const client = makeClient({
249
+ read: vi.fn().mockRejectedValue(Object.assign(new Error('not in ledger'), { code: 'not-found' })),
250
+ })
251
+ const engine = createSyncEngine(client, port, { onDegraded })
252
+
253
+ await engine.populateLedger()
254
+ engine.start()
255
+ watch.emitBatch(['gone.js'])
256
+ await flush()
257
+
258
+ expect(onDegraded).not.toHaveBeenCalled()
259
+ expect(client.rm).not.toHaveBeenCalled()
260
+ })
261
+ })
262
+
263
+ describe('createSyncEngine — onDegraded: a throwing host callback never wedges the engine', () => {
264
+ it('processes a later, unrelated batch normally after onDegraded itself throws', async () => {
265
+ const watch = makeWatch()
266
+ const onDegraded = vi.fn<(d: SyncDegradation) => void>(() => {
267
+ throw new Error('host callback blew up')
268
+ })
269
+ const readError = new Error('port gone')
270
+ const port = makePort({
271
+ changes: watch.changes,
272
+ read: vi
273
+ .fn()
274
+ .mockRejectedValueOnce(readError)
275
+ .mockResolvedValueOnce(new TextEncoder().encode('new content')),
276
+ })
277
+ const client = makeClient({ read: vi.fn().mockResolvedValue({ content: 'old content' }) })
278
+ const engine = createSyncEngine(client, port, { onDegraded })
279
+
280
+ await engine.populateLedger()
281
+ engine.start()
282
+ watch.emitBatch(['flaky.js'])
283
+ await flush()
284
+
285
+ expect(onDegraded).toHaveBeenCalledTimes(1)
286
+
287
+ watch.emitBatch(['b.js'])
288
+ await flush()
289
+
290
+ expect(client.write).toHaveBeenCalledWith('b.js', 'new content', { actor: 'human' })
291
+ })
292
+ })
293
+
294
+ describe('createSyncEngine — onDegraded: omitted option', () => {
295
+ it('a host that never supplies onDegraded still gets today\'s skip/dead-watcher behavior with no crash', async () => {
296
+ const watch = makeWatch()
297
+ const port = makePort({ changes: watch.changes, read: vi.fn().mockRejectedValue(new Error('port gone')) })
298
+ const client = makeClient()
299
+ const engine = createSyncEngine(client, port)
300
+
301
+ await engine.populateLedger()
302
+ engine.start()
303
+ watch.emitBatch(['flaky.js'])
304
+ await flush()
305
+ watch.emitDead()
306
+
307
+ expect(client.write).not.toHaveBeenCalled()
308
+ })
309
+ })
@@ -1,11 +1,10 @@
1
1
  /**
2
- * Contract tests for `createSyncEngine` the engine extracted from
3
- * dimina-kit workbench's `wal-audit.ts` (devtools-fs-core-feasibility.md
4
- * §7+§8). These replay, against fake `client`/`port` doubles, the SAME six
5
- * disk<->editor sync scenarios workbench's `disk-sync.test.ts` covers against
6
- * `walAuditSource` the equivalence judge for this extraction: the engine
7
- * must produce byte-identical observable behavior (ledger writes/rms,
8
- * `applyToEditor` calls) for the same inputs.
2
+ * Contract tests for `createSyncEngine`. These replay, against fake
3
+ * `client`/`port` doubles, the SAME six disk<->editor sync scenarios
4
+ * workbench's `disk-sync.test.ts` covers against `walAuditSource` the
5
+ * cross-package equivalence judge: the engine must produce byte-identical
6
+ * observable behavior (ledger writes/rms, `applyToEditor` calls) for the
7
+ * same inputs as the wal-audit layer built on it.
9
8
  */
10
9
  import { describe, expect, it, vi } from 'vitest'
11
10
  import { createSyncEngine } from './sync-engine.js'
@@ -214,17 +213,16 @@ describe('createSyncEngine — populateLedger reconciles ledger residue', () =>
214
213
  })
215
214
  })
216
215
 
217
- describe('createSyncEngine — pendingWrite is a structural no-op for a push port', () => {
218
- it('an in-flight onHumanSave never lets a same-path inbound batch observe a pendingWrite hit', async () => {
216
+ describe('createSyncEngine — ledgerTurn FIFO orders an in-flight save before a same-path inbound batch', () => {
217
+ it('a same-path inbound batch queued behind a slow onHumanSave still gets content-compared and recorded', async () => {
219
218
  // Both onHumanSave and an inbound batch enqueue onto the SAME ledgerTurn
220
- // FIFO. Registering pendingWrite happens synchronously when onHumanSave
221
- // is called, and it is cleared inside onHumanSave's own queued turn —
222
- // strictly before any LATER-queued inbound turn for the same path can
223
- // run. This test proves that guarantee: even when onHumanSave's ledger
224
- // write is deliberately slow, a same-path inbound batch queued right
225
- // after it still falls through to ordinary content comparison (the disk
226
- // content differs from the ledger's post-save record), not a
227
- // pendingWrite short-circuit.
219
+ // FIFO, so a later-queued inbound turn for the same path always runs
220
+ // AFTER the save's ledger write completed. This test proves that
221
+ // guarantee: even when onHumanSave's ledger write is deliberately slow,
222
+ // a same-path inbound batch queued right after it is judged by ordinary
223
+ // content comparison against the post-save ledger record (the disk
224
+ // content differs, so it must be recorded) never silently absorbed as
225
+ // the save's own echo.
228
226
  let resolveWrite: () => void = () => {}
229
227
  const writeGate = new Promise<void>((resolve) => {
230
228
  resolveWrite = resolve
@@ -254,14 +252,12 @@ describe('createSyncEngine — pendingWrite is a structural no-op for a push por
254
252
  await savePromise
255
253
  await flush()
256
254
 
257
- // If the inbound turn had observed a pendingWrite hit, it would have
258
- // absorbed the batch silently (zero client.write / applyToEditor calls
255
+ // If the inbound turn had been misjudged as the save's echo, it would
256
+ // have been absorbed silently (zero client.write / applyToEditor calls
259
257
  // for 'a.js' beyond the save itself). Instead it ran AFTER onHumanSave's
260
- // turn cleared pendingWrite, fell through to ordinary content
261
- // comparison (ledger's mocked, unchanged 'old' record vs. the port's
262
- // 'externally new' bytes), and recorded + applied the inbound change
263
- // proving the pendingWrite branch is a structural no-op for this push
264
- // port, exactly as it is for the real devtools adapter.
258
+ // queued turn, was content-compared (ledger's mocked, unchanged 'old'
259
+ // record vs. the port's 'externally new' bytes), and recorded + applied
260
+ // the inbound change.
265
261
  expect(client.write).toHaveBeenCalledWith('a.js', 'new from human', { actor: 'human' })
266
262
  expect(client.write).toHaveBeenCalledWith('a.js', 'externally new', { actor: 'human' })
267
263
  expect(applyToEditor).toHaveBeenCalledWith('a.js', new TextEncoder().encode('externally new'))
@@ -400,73 +396,6 @@ describe('createSyncEngine — binary layering: onHumanSave with binary content'
400
396
  })
401
397
  })
402
398
 
403
- describe('createSyncEngine — consumeInboundEcho (poll host outbound echo suppression)', () => {
404
- it('matches the exact text an inbound batch just applied, once — a second consume misses', async () => {
405
- const watch = makeWatch()
406
- const port = makePort({
407
- changes: watch.changes,
408
- read: vi.fn().mockResolvedValue(new TextEncoder().encode('from disk')),
409
- })
410
- const client = makeClient({ read: vi.fn().mockResolvedValue({ content: 'old' }) })
411
- const engine = createSyncEngine(client, port)
412
-
413
- await engine.populateLedger()
414
- engine.start()
415
- watch.emitBatch(['a.js'])
416
- await vi.waitFor(() => expect(client.write).toHaveBeenCalledWith('a.js', 'from disk', { actor: 'human' }))
417
-
418
- expect(engine.consumeInboundEcho('a.js', 'from disk')).toBe(true)
419
- expect(engine.consumeInboundEcho('a.js', 'from disk')).toBe(false)
420
- })
421
-
422
- it('does not match different content, and leaves the record intact for a later correct match', async () => {
423
- const watch = makeWatch()
424
- const port = makePort({
425
- changes: watch.changes,
426
- read: vi.fn().mockResolvedValue(new TextEncoder().encode('from disk')),
427
- })
428
- const client = makeClient({ read: vi.fn().mockResolvedValue({ content: 'old' }) })
429
- const engine = createSyncEngine(client, port)
430
-
431
- await engine.populateLedger()
432
- engine.start()
433
- watch.emitBatch(['a.js'])
434
- await vi.waitFor(() => expect(client.write).toHaveBeenCalledWith('a.js', 'from disk', { actor: 'human' }))
435
-
436
- expect(engine.consumeInboundEcho('a.js', 'something else')).toBe(false)
437
- expect(engine.consumeInboundEcho('a.js', 'from disk')).toBe(true)
438
- })
439
-
440
- it('matches a delete marker only with content === null', async () => {
441
- const watch = makeWatch()
442
- const port = makePort({
443
- changes: watch.changes,
444
- read: vi.fn().mockRejectedValue(Object.assign(new Error('gone'), { code: 'not-found' })),
445
- })
446
- const client = makeClient({ read: vi.fn().mockResolvedValue({ content: 'was here' }) })
447
- const engine = createSyncEngine(client, port)
448
-
449
- await engine.populateLedger()
450
- engine.start()
451
- watch.emitBatch(['gone.js'])
452
- await vi.waitFor(() => expect(client.rm).toHaveBeenCalledWith('gone.js', { actor: 'human' }))
453
-
454
- expect(engine.consumeInboundEcho('gone.js', 'not a delete')).toBe(false)
455
- expect(engine.consumeInboundEcho('gone.js', null)).toBe(true)
456
- expect(engine.consumeInboundEcho('gone.js', null)).toBe(false)
457
- })
458
-
459
- it('returns false for a path with no inbound record at all', async () => {
460
- const port = makePort()
461
- const client = makeClient()
462
- const engine = createSyncEngine(client, port)
463
-
464
- await engine.populateLedger()
465
-
466
- expect(engine.consumeInboundEcho('never-touched.js', 'anything')).toBe(false)
467
- })
468
- })
469
-
470
399
  /** Stateful world for contention tests: a truth-source `disk` and a ledger
471
400
  * that actually HOLD content, so interleaving tests can assert final-state
472
401
  * convergence (ledger == disk) instead of just call counts. `ledgerWrites`