@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.
- 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,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
|
+
})
|
package/sync/sync-engine.test.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Contract tests for `createSyncEngine
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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 —
|
|
218
|
-
it('
|
|
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
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
//
|
|
225
|
-
//
|
|
226
|
-
//
|
|
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
|
|
258
|
-
//
|
|
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
|
|
261
|
-
//
|
|
262
|
-
//
|
|
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`
|