@dimina-kit/fs-core 0.2.0-dev.20260710085051
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/LICENSE +21 -0
- package/README.md +45 -0
- package/dist/agent-tools.js +52 -0
- package/dist/client.js +234 -0
- package/dist/disk-mirror.js +97 -0
- package/dist/fs-core.worker.js +1001 -0
- package/dist/fs-query.worker.js +115 -0
- package/dist/sync/sync-engine.js +381 -0
- package/dist/sync/truth-port.js +1 -0
- package/dist/worker-lib/engine-shared.js +25 -0
- package/dist/worker-lib/paths.js +17 -0
- package/dist/worker-lib/rpc-types.js +1 -0
- package/dist/worker-lib/wal-codec.js +111 -0
- package/dist/zip.js +60 -0
- package/package.json +75 -0
- package/src/__checks__/types-smoke.ts +23 -0
- package/src/agent-tools.test.ts +151 -0
- package/src/agent-tools.ts +117 -0
- package/src/client.test.ts +110 -0
- package/src/client.ts +284 -0
- package/src/disk-mirror.test.ts +190 -0
- package/src/disk-mirror.ts +119 -0
- package/src/fs-core-recovery.ts +280 -0
- package/src/fs-core-write-ops.ts +402 -0
- package/src/fs-core.worker.ts +182 -0
- package/src/fs-query.worker.ts +152 -0
- package/src/import-smoke.test.ts +40 -0
- package/src/worker-lib/engine-shared.test.ts +30 -0
- package/src/worker-lib/engine-shared.ts +74 -0
- package/src/worker-lib/paths.test.ts +39 -0
- package/src/worker-lib/paths.ts +14 -0
- package/src/worker-lib/rpc-types.ts +67 -0
- package/src/worker-lib/wal-codec.test.ts +83 -0
- package/src/worker-lib/wal-codec.ts +130 -0
- package/src/zip.test.ts +124 -0
- package/src/zip.ts +60 -0
- package/sync/sync-engine.test.ts +695 -0
- package/sync/sync-engine.ts +459 -0
- package/sync/truth-port.ts +72 -0
|
@@ -0,0 +1,695 @@
|
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { createSyncEngine } from './sync-engine.js'
|
|
12
|
+
import type { SyncClientLike } from './sync-engine.js'
|
|
13
|
+
import type { TruthPort } from './truth-port.js'
|
|
14
|
+
|
|
15
|
+
function makeClient(overrides: Partial<SyncClientLike> = {}): SyncClientLike {
|
|
16
|
+
return {
|
|
17
|
+
write: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
rm: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
read: vi.fn().mockRejectedValue(Object.assign(new Error('not found'), { code: 'not-found' })),
|
|
20
|
+
ls: vi.fn().mockResolvedValue({ paths: [] }),
|
|
21
|
+
...overrides,
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function makePort(overrides: Partial<TruthPort> = {}): TruthPort {
|
|
26
|
+
return {
|
|
27
|
+
capabilities: { watch: 'push' },
|
|
28
|
+
read: vi.fn().mockResolvedValue(new Uint8Array()),
|
|
29
|
+
write: vi.fn().mockResolvedValue(undefined),
|
|
30
|
+
delete: vi.fn().mockResolvedValue(undefined),
|
|
31
|
+
walk: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
changes: vi.fn().mockReturnValue(() => {}),
|
|
33
|
+
...overrides,
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Fake `port.changes`: captures the `onBatch`/`onDead` callbacks `start()`
|
|
38
|
+
* registers and exposes them as `emitBatch`/`emitDead`, so a test can drive
|
|
39
|
+
* the engine without a real event stream. */
|
|
40
|
+
function makeWatch(): {
|
|
41
|
+
changes: TruthPort['changes']
|
|
42
|
+
stop: ReturnType<typeof vi.fn>
|
|
43
|
+
emitBatch: (paths: string[]) => void
|
|
44
|
+
emitDead: () => void
|
|
45
|
+
} {
|
|
46
|
+
let batchHandler: (paths: string[]) => void = () => {}
|
|
47
|
+
let deadHandler: () => void = () => {}
|
|
48
|
+
const stop = vi.fn()
|
|
49
|
+
const changes: TruthPort['changes'] = (onBatch, onDead) => {
|
|
50
|
+
batchHandler = onBatch
|
|
51
|
+
deadHandler = onDead
|
|
52
|
+
return stop
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
changes,
|
|
56
|
+
stop,
|
|
57
|
+
emitBatch: (paths) => batchHandler(paths),
|
|
58
|
+
emitDead: () => deadHandler(),
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Flush the microtask queue enough times for a batch's chained port/ledger
|
|
63
|
+
* reads+writes to settle before assertions run. */
|
|
64
|
+
const flush = () => new Promise((r) => setTimeout(r, 0))
|
|
65
|
+
|
|
66
|
+
describe('createSyncEngine — disk sync: external new content', () => {
|
|
67
|
+
it('records the change and refreshes the editor exactly once', async () => {
|
|
68
|
+
const watch = makeWatch()
|
|
69
|
+
const applyToEditor = vi.fn().mockResolvedValue(undefined)
|
|
70
|
+
const port = makePort({
|
|
71
|
+
changes: watch.changes,
|
|
72
|
+
read: vi.fn().mockResolvedValue(new TextEncoder().encode('new content')),
|
|
73
|
+
})
|
|
74
|
+
const client = makeClient({ read: vi.fn().mockResolvedValue({ content: 'old content' }) })
|
|
75
|
+
const engine = createSyncEngine(client, port, { applyToEditor })
|
|
76
|
+
|
|
77
|
+
await engine.populateLedger()
|
|
78
|
+
engine.start()
|
|
79
|
+
watch.emitBatch(['a.js'])
|
|
80
|
+
await flush()
|
|
81
|
+
|
|
82
|
+
expect(client.write).toHaveBeenCalledTimes(1)
|
|
83
|
+
expect(client.write).toHaveBeenCalledWith('a.js', 'new content', { actor: 'human' })
|
|
84
|
+
expect(applyToEditor).toHaveBeenCalledTimes(1)
|
|
85
|
+
expect(applyToEditor).toHaveBeenCalledWith('a.js', new TextEncoder().encode('new content'))
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('createSyncEngine — disk sync: echo absorption', () => {
|
|
90
|
+
it('performs zero ledger writes and zero editor refreshes when content matches the ledger', async () => {
|
|
91
|
+
const watch = makeWatch()
|
|
92
|
+
const applyToEditor = vi.fn().mockResolvedValue(undefined)
|
|
93
|
+
const port = makePort({
|
|
94
|
+
changes: watch.changes,
|
|
95
|
+
read: vi.fn().mockResolvedValue(new TextEncoder().encode('same content')),
|
|
96
|
+
})
|
|
97
|
+
const client = makeClient({ read: vi.fn().mockResolvedValue({ content: 'same content' }) })
|
|
98
|
+
const engine = createSyncEngine(client, port, { applyToEditor })
|
|
99
|
+
|
|
100
|
+
await engine.populateLedger()
|
|
101
|
+
engine.start()
|
|
102
|
+
watch.emitBatch(['a.js'])
|
|
103
|
+
await flush()
|
|
104
|
+
|
|
105
|
+
expect(client.write).toHaveBeenCalledTimes(0)
|
|
106
|
+
expect(client.rm).toHaveBeenCalledTimes(0)
|
|
107
|
+
expect(applyToEditor).toHaveBeenCalledTimes(0)
|
|
108
|
+
})
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
describe('createSyncEngine — disk sync: external delete', () => {
|
|
112
|
+
it('removes the path from the ledger and applies a null delete to the editor', async () => {
|
|
113
|
+
const watch = makeWatch()
|
|
114
|
+
const applyToEditor = vi.fn().mockResolvedValue(undefined)
|
|
115
|
+
// Only a not-found rejection (status 404, per the TruthPort error
|
|
116
|
+
// contract) may be read as a deletion.
|
|
117
|
+
const port = makePort({
|
|
118
|
+
changes: watch.changes,
|
|
119
|
+
read: vi.fn().mockRejectedValue(Object.assign(new Error('read gone.js: 404'), { status: 404 })),
|
|
120
|
+
})
|
|
121
|
+
const client = makeClient({ read: vi.fn().mockResolvedValue({ content: 'was here' }) })
|
|
122
|
+
const engine = createSyncEngine(client, port, { applyToEditor })
|
|
123
|
+
|
|
124
|
+
await engine.populateLedger()
|
|
125
|
+
engine.start()
|
|
126
|
+
watch.emitBatch(['gone.js'])
|
|
127
|
+
await flush()
|
|
128
|
+
|
|
129
|
+
expect(client.rm).toHaveBeenCalledTimes(1)
|
|
130
|
+
expect(client.rm).toHaveBeenCalledWith('gone.js', { actor: 'human' })
|
|
131
|
+
expect(applyToEditor).toHaveBeenCalledTimes(1)
|
|
132
|
+
expect(applyToEditor).toHaveBeenCalledWith('gone.js', null)
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('createSyncEngine — disk sync: transient port failure ("unavailable")', () => {
|
|
137
|
+
it('skips the path — no ledger rm, no ledger write, no editor apply', async () => {
|
|
138
|
+
const watch = makeWatch()
|
|
139
|
+
const applyToEditor = vi.fn().mockResolvedValue(undefined)
|
|
140
|
+
// No `status`/`code` on the rejection (worker crash / network hiccup
|
|
141
|
+
// shape) — must NOT be classified as not-found.
|
|
142
|
+
const port = makePort({
|
|
143
|
+
changes: watch.changes,
|
|
144
|
+
read: vi.fn().mockRejectedValue(new Error('port gone')),
|
|
145
|
+
})
|
|
146
|
+
const client = makeClient({ read: vi.fn().mockResolvedValue({ content: 'still here' }) })
|
|
147
|
+
const engine = createSyncEngine(client, port, { applyToEditor })
|
|
148
|
+
|
|
149
|
+
await engine.populateLedger()
|
|
150
|
+
engine.start()
|
|
151
|
+
watch.emitBatch(['flaky.js'])
|
|
152
|
+
await flush()
|
|
153
|
+
|
|
154
|
+
expect(client.rm).toHaveBeenCalledTimes(0)
|
|
155
|
+
expect(client.write).toHaveBeenCalledTimes(0)
|
|
156
|
+
expect(applyToEditor).toHaveBeenCalledTimes(0)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
describe('createSyncEngine — disk sync: dead watcher', () => {
|
|
161
|
+
it('stops processing events once onDead fires', async () => {
|
|
162
|
+
const watch = makeWatch()
|
|
163
|
+
const applyToEditor = vi.fn().mockResolvedValue(undefined)
|
|
164
|
+
const port = makePort({
|
|
165
|
+
changes: watch.changes,
|
|
166
|
+
read: vi.fn().mockResolvedValue(new TextEncoder().encode('content')),
|
|
167
|
+
})
|
|
168
|
+
const client = makeClient({ read: vi.fn().mockResolvedValue({ content: 'different' }) })
|
|
169
|
+
const engine = createSyncEngine(client, port, { applyToEditor })
|
|
170
|
+
|
|
171
|
+
await engine.populateLedger()
|
|
172
|
+
engine.start()
|
|
173
|
+
watch.emitDead()
|
|
174
|
+
watch.emitBatch(['a.js'])
|
|
175
|
+
await flush()
|
|
176
|
+
|
|
177
|
+
expect(client.write).toHaveBeenCalledTimes(0)
|
|
178
|
+
expect(applyToEditor).toHaveBeenCalledTimes(0)
|
|
179
|
+
})
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
describe('createSyncEngine — outbound echo consolidation on save', () => {
|
|
183
|
+
it('skips the ledger write when the saved content already matches the ledger record', async () => {
|
|
184
|
+
const port = makePort()
|
|
185
|
+
const client = makeClient({ read: vi.fn().mockResolvedValue({ content: 'already recorded' }) })
|
|
186
|
+
const engine = createSyncEngine(client, port)
|
|
187
|
+
|
|
188
|
+
await engine.populateLedger()
|
|
189
|
+
await engine.onHumanSave('a.js', 'already recorded')
|
|
190
|
+
|
|
191
|
+
expect(client.write).toHaveBeenCalledTimes(0)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('createSyncEngine — populateLedger reconciles ledger residue', () => {
|
|
196
|
+
it('removes ledger paths absent from the walked tree and keeps paths that still exist', async () => {
|
|
197
|
+
const rmMock = vi.fn().mockResolvedValue(undefined)
|
|
198
|
+
const client = makeClient({
|
|
199
|
+
rm: rmMock,
|
|
200
|
+
ls: vi.fn().mockResolvedValue({ paths: ['a.js', 'stale-from-old-project.js'] }),
|
|
201
|
+
})
|
|
202
|
+
const port = makePort({
|
|
203
|
+
walk: vi.fn(async (onFile: (rel: string, bytes: Uint8Array) => Promise<void>) => {
|
|
204
|
+
await onFile('a.js', new TextEncoder().encode('content'))
|
|
205
|
+
}),
|
|
206
|
+
})
|
|
207
|
+
const engine = createSyncEngine(client, port)
|
|
208
|
+
|
|
209
|
+
await engine.populateLedger()
|
|
210
|
+
|
|
211
|
+
expect(rmMock).toHaveBeenCalledWith('stale-from-old-project.js', expect.objectContaining({ actor: 'human' }))
|
|
212
|
+
const removedPaths = rmMock.mock.calls.map((call) => call[0])
|
|
213
|
+
expect(removedPaths).not.toContain('a.js')
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
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 () => {
|
|
219
|
+
// 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.
|
|
228
|
+
let resolveWrite: () => void = () => {}
|
|
229
|
+
const writeGate = new Promise<void>((resolve) => {
|
|
230
|
+
resolveWrite = resolve
|
|
231
|
+
})
|
|
232
|
+
const client = makeClient({
|
|
233
|
+
read: vi.fn().mockResolvedValue({ content: 'old' }),
|
|
234
|
+
write: vi.fn(async () => {
|
|
235
|
+
await writeGate
|
|
236
|
+
}),
|
|
237
|
+
})
|
|
238
|
+
const applyToEditor = vi.fn().mockResolvedValue(undefined)
|
|
239
|
+
const watch = makeWatch()
|
|
240
|
+
const port = makePort({
|
|
241
|
+
changes: watch.changes,
|
|
242
|
+
read: vi.fn().mockResolvedValue(new TextEncoder().encode('externally new')),
|
|
243
|
+
})
|
|
244
|
+
const engine = createSyncEngine(client, port, { applyToEditor })
|
|
245
|
+
|
|
246
|
+
await engine.populateLedger()
|
|
247
|
+
engine.start()
|
|
248
|
+
|
|
249
|
+
const savePromise = engine.onHumanSave('a.js', 'new from human')
|
|
250
|
+
// Queue an inbound batch for the SAME path while the save's ledger write
|
|
251
|
+
// is still gated open.
|
|
252
|
+
watch.emitBatch(['a.js'])
|
|
253
|
+
resolveWrite()
|
|
254
|
+
await savePromise
|
|
255
|
+
await flush()
|
|
256
|
+
|
|
257
|
+
// If the inbound turn had observed a pendingWrite hit, it would have
|
|
258
|
+
// absorbed the batch silently (zero client.write / applyToEditor calls
|
|
259
|
+
// 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.
|
|
265
|
+
expect(client.write).toHaveBeenCalledWith('a.js', 'new from human', { actor: 'human' })
|
|
266
|
+
expect(client.write).toHaveBeenCalledWith('a.js', 'externally new', { actor: 'human' })
|
|
267
|
+
expect(applyToEditor).toHaveBeenCalledWith('a.js', new TextEncoder().encode('externally new'))
|
|
268
|
+
})
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
describe('createSyncEngine — binary layering: sniff boundary', () => {
|
|
272
|
+
it('classifies a NUL inside the first 8192 bytes as binary but a NUL beyond it as text', async () => {
|
|
273
|
+
const withinSniff = new Uint8Array(100).fill(65) // 'A'
|
|
274
|
+
withinSniff[50] = 0
|
|
275
|
+
const beyondSniff = new Uint8Array(8192 + 10).fill(65)
|
|
276
|
+
beyondSniff[8192 + 5] = 0 // NUL sits outside the first 8192 bytes
|
|
277
|
+
|
|
278
|
+
const watch = makeWatch()
|
|
279
|
+
const applyToEditor = vi.fn().mockResolvedValue(undefined)
|
|
280
|
+
const port = makePort({
|
|
281
|
+
changes: watch.changes,
|
|
282
|
+
read: vi
|
|
283
|
+
.fn()
|
|
284
|
+
.mockResolvedValueOnce(withinSniff)
|
|
285
|
+
.mockResolvedValueOnce(beyondSniff),
|
|
286
|
+
})
|
|
287
|
+
const client = makeClient({ read: vi.fn().mockRejectedValue(Object.assign(new Error('not found'), { code: 'not-found' })) })
|
|
288
|
+
const engine = createSyncEngine(client, port, { applyToEditor })
|
|
289
|
+
|
|
290
|
+
await engine.populateLedger()
|
|
291
|
+
engine.start()
|
|
292
|
+
watch.emitBatch(['within.bin'])
|
|
293
|
+
// sha256hex uses the real crypto.subtle.digest — a genuinely async
|
|
294
|
+
// native op, not a same-tick mock resolution like the rest of this
|
|
295
|
+
// file's fakes, so a single `flush()` macrotask isn't a reliable wait.
|
|
296
|
+
// Poll instead of guessing a fixed extra delay.
|
|
297
|
+
await vi.waitFor(() => expect(applyToEditor).toHaveBeenCalledWith('within.bin', withinSniff))
|
|
298
|
+
watch.emitBatch(['beyond.txt'])
|
|
299
|
+
await vi.waitFor(() => expect(client.write).toHaveBeenCalledWith('beyond.txt', expect.any(String), { actor: 'human' }))
|
|
300
|
+
|
|
301
|
+
// within.bin: NUL inside the sniff window — binary path, ledger untouched, editor gets raw bytes.
|
|
302
|
+
expect(client.write).toHaveBeenCalledTimes(1) // only beyond.txt goes through the text path
|
|
303
|
+
// beyond.txt: NUL outside the sniff window — treated as text (decoded, possibly with a replacement char).
|
|
304
|
+
expect(applyToEditor).toHaveBeenCalledWith('beyond.txt', beyondSniff)
|
|
305
|
+
})
|
|
306
|
+
})
|
|
307
|
+
|
|
308
|
+
describe('createSyncEngine — binary layering: inbound new/changed binary content', () => {
|
|
309
|
+
it('does not touch the ledger, updates binaryIndex, and applies the raw bytes to the editor exactly once', async () => {
|
|
310
|
+
const watch = makeWatch()
|
|
311
|
+
const applyToEditor = vi.fn().mockResolvedValue(undefined)
|
|
312
|
+
const bytes = new Uint8Array([0, 1, 2, 3, 4])
|
|
313
|
+
const port = makePort({ changes: watch.changes, read: vi.fn().mockResolvedValue(bytes) })
|
|
314
|
+
const client = makeClient()
|
|
315
|
+
const engine = createSyncEngine(client, port, { applyToEditor })
|
|
316
|
+
|
|
317
|
+
await engine.populateLedger()
|
|
318
|
+
engine.start()
|
|
319
|
+
watch.emitBatch(['image.png'])
|
|
320
|
+
await vi.waitFor(() => expect(applyToEditor).toHaveBeenCalledTimes(1))
|
|
321
|
+
|
|
322
|
+
expect(client.write).toHaveBeenCalledTimes(0)
|
|
323
|
+
expect(client.read).not.toHaveBeenCalledWith('image.png')
|
|
324
|
+
expect(applyToEditor).toHaveBeenCalledWith('image.png', bytes)
|
|
325
|
+
})
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
describe('createSyncEngine — binary layering: inbound echo (identical hash+size)', () => {
|
|
329
|
+
it('performs zero actions when the same binary bytes are observed again', async () => {
|
|
330
|
+
const watch = makeWatch()
|
|
331
|
+
const applyToEditor = vi.fn().mockResolvedValue(undefined)
|
|
332
|
+
const bytes = new Uint8Array([0, 9, 8, 7])
|
|
333
|
+
const port = makePort({ changes: watch.changes, read: vi.fn().mockResolvedValue(bytes) })
|
|
334
|
+
const client = makeClient()
|
|
335
|
+
const engine = createSyncEngine(client, port, { applyToEditor })
|
|
336
|
+
|
|
337
|
+
await engine.populateLedger()
|
|
338
|
+
engine.start()
|
|
339
|
+
watch.emitBatch(['image.png'])
|
|
340
|
+
await vi.waitFor(() => expect(applyToEditor).toHaveBeenCalledTimes(1))
|
|
341
|
+
applyToEditor.mockClear()
|
|
342
|
+
|
|
343
|
+
// Same bytes observed again (e.g. a duplicate/coalesced fs event) — same size+hash, so it's an echo.
|
|
344
|
+
watch.emitBatch(['image.png'])
|
|
345
|
+
await flush()
|
|
346
|
+
|
|
347
|
+
expect(client.write).toHaveBeenCalledTimes(0)
|
|
348
|
+
expect(applyToEditor).toHaveBeenCalledTimes(0)
|
|
349
|
+
})
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
describe('createSyncEngine — binary layering: inbound deletion', () => {
|
|
353
|
+
it('applies a null delete to the editor and does not call client.rm for a binaryIndex-only path', async () => {
|
|
354
|
+
const watch = makeWatch()
|
|
355
|
+
const applyToEditor = vi.fn().mockResolvedValue(undefined)
|
|
356
|
+
const bytes = new Uint8Array([0, 1, 2, 3])
|
|
357
|
+
const port = makePort({
|
|
358
|
+
changes: watch.changes,
|
|
359
|
+
read: vi.fn().mockResolvedValueOnce(bytes).mockRejectedValueOnce(Object.assign(new Error('gone'), { status: 404 })),
|
|
360
|
+
})
|
|
361
|
+
const client = makeClient()
|
|
362
|
+
const engine = createSyncEngine(client, port, { applyToEditor })
|
|
363
|
+
|
|
364
|
+
await engine.populateLedger()
|
|
365
|
+
engine.start()
|
|
366
|
+
watch.emitBatch(['image.png']) // first observed: enters binaryIndex
|
|
367
|
+
await vi.waitFor(() => expect(applyToEditor).toHaveBeenCalledTimes(1))
|
|
368
|
+
applyToEditor.mockClear()
|
|
369
|
+
|
|
370
|
+
watch.emitBatch(['image.png']) // now deleted at the truth source
|
|
371
|
+
await flush()
|
|
372
|
+
|
|
373
|
+
expect(client.rm).toHaveBeenCalledTimes(0)
|
|
374
|
+
expect(applyToEditor).toHaveBeenCalledTimes(1)
|
|
375
|
+
expect(applyToEditor).toHaveBeenCalledWith('image.png', null)
|
|
376
|
+
})
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
describe('createSyncEngine — binary layering: onHumanSave with binary content', () => {
|
|
380
|
+
it('skips the ledger write and does not call applyToEditor for a binary save', async () => {
|
|
381
|
+
const port = makePort()
|
|
382
|
+
const client = makeClient()
|
|
383
|
+
const engine = createSyncEngine(client, port)
|
|
384
|
+
|
|
385
|
+
await engine.populateLedger()
|
|
386
|
+
await engine.onHumanSave('image.png', new Uint8Array([0, 1, 2, 3]))
|
|
387
|
+
|
|
388
|
+
expect(client.write).toHaveBeenCalledTimes(0)
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('still records a plain-string save exactly as before (text path unaffected)', async () => {
|
|
392
|
+
const port = makePort()
|
|
393
|
+
const client = makeClient({ read: vi.fn().mockResolvedValue({ content: 'old' }) })
|
|
394
|
+
const engine = createSyncEngine(client, port)
|
|
395
|
+
|
|
396
|
+
await engine.populateLedger()
|
|
397
|
+
await engine.onHumanSave('a.js', 'new text')
|
|
398
|
+
|
|
399
|
+
expect(client.write).toHaveBeenCalledWith('a.js', 'new text', { actor: 'human' })
|
|
400
|
+
})
|
|
401
|
+
})
|
|
402
|
+
|
|
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
|
+
/** Stateful world for contention tests: a truth-source `disk` and a ledger
|
|
471
|
+
* that actually HOLD content, so interleaving tests can assert final-state
|
|
472
|
+
* convergence (ledger == disk) instead of just call counts. `ledgerWrites`
|
|
473
|
+
* records every (rel, text) the engine committed, in order — the lost-update
|
|
474
|
+
* detector: a value that should have been recorded exactly once must appear
|
|
475
|
+
* exactly once, an echo must not appear at all. */
|
|
476
|
+
function makeStatefulWorld(initialDisk: Record<string, string> = {}) {
|
|
477
|
+
const encoder = new TextEncoder()
|
|
478
|
+
const disk = new Map<string, Uint8Array>(
|
|
479
|
+
Object.entries(initialDisk).map(([rel, text]) => [rel, encoder.encode(text)]),
|
|
480
|
+
)
|
|
481
|
+
const ledger = new Map<string, string>()
|
|
482
|
+
const ledgerWrites: Array<[string, string]> = []
|
|
483
|
+
const client: SyncClientLike = {
|
|
484
|
+
write: vi.fn(async (rel: string, text: string) => {
|
|
485
|
+
ledger.set(rel, text)
|
|
486
|
+
ledgerWrites.push([rel, text])
|
|
487
|
+
}),
|
|
488
|
+
rm: vi.fn(async (rel: string) => {
|
|
489
|
+
ledger.delete(rel)
|
|
490
|
+
}),
|
|
491
|
+
read: vi.fn(async (rel: string) => {
|
|
492
|
+
if (!ledger.has(rel)) throw Object.assign(new Error('not in ledger'), { code: 'not-found' })
|
|
493
|
+
return { content: ledger.get(rel) as string }
|
|
494
|
+
}),
|
|
495
|
+
ls: vi.fn(async () => ({ paths: [...ledger.keys()] })),
|
|
496
|
+
}
|
|
497
|
+
const watch = makeWatch()
|
|
498
|
+
const port = makePort({
|
|
499
|
+
capabilities: { watch: 'poll' },
|
|
500
|
+
read: vi.fn(async (rel: string) => {
|
|
501
|
+
const bytes = disk.get(rel)
|
|
502
|
+
if (!bytes) throw Object.assign(new Error('not on disk'), { code: 'not-found' })
|
|
503
|
+
return bytes
|
|
504
|
+
}),
|
|
505
|
+
walk: vi.fn(async (onFile: (rel: string, bytes: Uint8Array) => Promise<void>) => {
|
|
506
|
+
for (const [rel, bytes] of disk) await onFile(rel, bytes)
|
|
507
|
+
}),
|
|
508
|
+
changes: watch.changes,
|
|
509
|
+
})
|
|
510
|
+
return {
|
|
511
|
+
disk,
|
|
512
|
+
ledger,
|
|
513
|
+
ledgerWrites,
|
|
514
|
+
client,
|
|
515
|
+
port,
|
|
516
|
+
watch,
|
|
517
|
+
setDisk(rel: string, text: string) {
|
|
518
|
+
disk.set(rel, encoder.encode(text))
|
|
519
|
+
},
|
|
520
|
+
delDisk(rel: string) {
|
|
521
|
+
disk.delete(rel)
|
|
522
|
+
},
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
describe('createSyncEngine — extreme contention: bidirectional same-path churn', () => {
|
|
527
|
+
it('converges ledger == disk with no lost update and no echo re-record across an inbound/save/inbound interleave', async () => {
|
|
528
|
+
const w = makeStatefulWorld({ 'f.js': 'v1' })
|
|
529
|
+
const applyToEditor = vi.fn().mockResolvedValue(undefined)
|
|
530
|
+
const engine = createSyncEngine(w.client, w.port, { applyToEditor })
|
|
531
|
+
|
|
532
|
+
await engine.populateLedger() // seeds v1
|
|
533
|
+
engine.start()
|
|
534
|
+
|
|
535
|
+
// Human save: per the host contract the truth source is written FIRST,
|
|
536
|
+
// then onHumanSave records. The scanner may then report that same disk
|
|
537
|
+
// mtime change — the echo the engine must absorb.
|
|
538
|
+
w.setDisk('f.js', 'v2-human')
|
|
539
|
+
await engine.onHumanSave('f.js', 'v2-human')
|
|
540
|
+
w.watch.emitBatch(['f.js']) // poll echo of our own save
|
|
541
|
+
|
|
542
|
+
// External edit races right behind the echo.
|
|
543
|
+
w.setDisk('f.js', 'v3-external')
|
|
544
|
+
w.watch.emitBatch(['f.js'])
|
|
545
|
+
|
|
546
|
+
await vi.waitFor(() => expect(w.ledger.get('f.js')).toBe('v3-external'))
|
|
547
|
+
|
|
548
|
+
// Convergence: ledger text == disk bytes.
|
|
549
|
+
expect(new TextDecoder().decode(w.disk.get('f.js'))).toBe('v3-external')
|
|
550
|
+
// No lost update, no duplicate: seed v1, save v2, inbound v3 — each once.
|
|
551
|
+
expect(w.ledgerWrites.filter(([rel]) => rel === 'f.js').map(([, text]) => text)).toEqual([
|
|
552
|
+
'v1',
|
|
553
|
+
'v2-human',
|
|
554
|
+
'v3-external',
|
|
555
|
+
])
|
|
556
|
+
// The echo batch produced no editor apply; the external edit produced one.
|
|
557
|
+
expect(applyToEditor).toHaveBeenCalledTimes(1)
|
|
558
|
+
expect(applyToEditor).toHaveBeenCalledWith('f.js', expect.anything())
|
|
559
|
+
})
|
|
560
|
+
|
|
561
|
+
it('serializes an inbound batch against an in-flight onHumanSave ledger write on the same path (FIFO, no interleaved corruption)', async () => {
|
|
562
|
+
const w = makeStatefulWorld({ 'f.js': 'v1' })
|
|
563
|
+
// Hold the save's ledger write mid-flight so the inbound batch provably
|
|
564
|
+
// queues BEHIND it rather than interleaving.
|
|
565
|
+
let releaseSave!: () => void
|
|
566
|
+
const gate = new Promise<void>((r) => {
|
|
567
|
+
releaseSave = r
|
|
568
|
+
})
|
|
569
|
+
const realWrite = w.client.write as ReturnType<typeof vi.fn>
|
|
570
|
+
let held = false
|
|
571
|
+
realWrite.mockImplementation(async (rel: string, text: string) => {
|
|
572
|
+
if (!held && text === 'v2-human') {
|
|
573
|
+
held = true
|
|
574
|
+
await gate
|
|
575
|
+
}
|
|
576
|
+
w.ledger.set(rel, text)
|
|
577
|
+
w.ledgerWrites.push([rel, text])
|
|
578
|
+
})
|
|
579
|
+
const engine = createSyncEngine(w.client, w.port)
|
|
580
|
+
|
|
581
|
+
await engine.populateLedger()
|
|
582
|
+
engine.start()
|
|
583
|
+
|
|
584
|
+
w.setDisk('f.js', 'v2-human')
|
|
585
|
+
const savePromise = engine.onHumanSave('f.js', 'v2-human') // blocks on gate inside its FIFO slot
|
|
586
|
+
w.setDisk('f.js', 'v-ext') // external write lands while the save is still in flight
|
|
587
|
+
w.watch.emitBatch(['f.js'])
|
|
588
|
+
|
|
589
|
+
await new Promise((r) => setTimeout(r, 20)) // give the batch every chance to (wrongly) overtake
|
|
590
|
+
expect(w.ledger.get('f.js')).toBe('v1') // nothing committed while the save holds the FIFO
|
|
591
|
+
|
|
592
|
+
releaseSave()
|
|
593
|
+
await savePromise
|
|
594
|
+
await vi.waitFor(() => expect(w.ledger.get('f.js')).toBe('v-ext'))
|
|
595
|
+
// Strict FIFO order: seed, then the save, then the external content.
|
|
596
|
+
expect(w.ledgerWrites.map(([, text]) => text)).toEqual(['v1', 'v2-human', 'v-ext'])
|
|
597
|
+
})
|
|
598
|
+
})
|
|
599
|
+
|
|
600
|
+
describe('createSyncEngine — extreme contention: write→delete→write collapse within one notification window', () => {
|
|
601
|
+
it('a path that ended at new content after intermediate states records ONLY the final content, never an rm', async () => {
|
|
602
|
+
const w = makeStatefulWorld({ 'f.js': 'v1' })
|
|
603
|
+
const applyToEditor = vi.fn().mockResolvedValue(undefined)
|
|
604
|
+
const engine = createSyncEngine(w.client, w.port, { applyToEditor })
|
|
605
|
+
await engine.populateLedger()
|
|
606
|
+
engine.start()
|
|
607
|
+
|
|
608
|
+
// Disk raced through v1 -> (deleted) -> v-final before the (debounced/
|
|
609
|
+
// polled) notification is processed; the engine reads AT APPLY TIME and
|
|
610
|
+
// must see only the final state.
|
|
611
|
+
w.delDisk('f.js')
|
|
612
|
+
w.setDisk('f.js', 'v-final')
|
|
613
|
+
w.watch.emitBatch(['f.js'])
|
|
614
|
+
|
|
615
|
+
await vi.waitFor(() => expect(w.ledger.get('f.js')).toBe('v-final'))
|
|
616
|
+
expect(w.client.rm).not.toHaveBeenCalled()
|
|
617
|
+
expect(applyToEditor).toHaveBeenCalledTimes(1)
|
|
618
|
+
})
|
|
619
|
+
|
|
620
|
+
it('a path that ended deleted after intermediate rewrites performs exactly one rm and one editor null-apply', async () => {
|
|
621
|
+
const w = makeStatefulWorld({ 'f.js': 'v1' })
|
|
622
|
+
const applyToEditor = vi.fn().mockResolvedValue(undefined)
|
|
623
|
+
const engine = createSyncEngine(w.client, w.port, { applyToEditor })
|
|
624
|
+
await engine.populateLedger()
|
|
625
|
+
engine.start()
|
|
626
|
+
|
|
627
|
+
w.setDisk('f.js', 'v2') // intermediate state nobody will ever observe
|
|
628
|
+
w.delDisk('f.js')
|
|
629
|
+
w.watch.emitBatch(['f.js'])
|
|
630
|
+
|
|
631
|
+
await vi.waitFor(() => expect(w.client.rm).toHaveBeenCalledTimes(1))
|
|
632
|
+
expect(w.client.rm).toHaveBeenCalledWith('f.js', { actor: 'human' })
|
|
633
|
+
expect(applyToEditor).toHaveBeenCalledTimes(1)
|
|
634
|
+
expect(applyToEditor).toHaveBeenCalledWith('f.js', null)
|
|
635
|
+
expect(w.ledger.has('f.js')).toBe(false)
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
it('a duplicated path within one batch commits exactly once (second occurrence absorbs as its own echo)', async () => {
|
|
639
|
+
const w = makeStatefulWorld()
|
|
640
|
+
const applyToEditor = vi.fn().mockResolvedValue(undefined)
|
|
641
|
+
const engine = createSyncEngine(w.client, w.port, { applyToEditor })
|
|
642
|
+
await engine.populateLedger()
|
|
643
|
+
engine.start()
|
|
644
|
+
|
|
645
|
+
w.setDisk('f.js', 'v1')
|
|
646
|
+
w.watch.emitBatch(['f.js', 'f.js', 'f.js'])
|
|
647
|
+
|
|
648
|
+
await vi.waitFor(() => expect(w.ledger.get('f.js')).toBe('v1'))
|
|
649
|
+
await new Promise((r) => setTimeout(r, 10))
|
|
650
|
+
expect(w.ledgerWrites.filter(([rel]) => rel === 'f.js')).toHaveLength(1)
|
|
651
|
+
expect(applyToEditor).toHaveBeenCalledTimes(1)
|
|
652
|
+
})
|
|
653
|
+
})
|
|
654
|
+
|
|
655
|
+
describe('createSyncEngine — bulk inbound batches', () => {
|
|
656
|
+
it('a 300-path batch lands every file exactly once, and its full echo re-delivery commits nothing', async () => {
|
|
657
|
+
const w = makeStatefulWorld()
|
|
658
|
+
for (let i = 0; i < 300; i++) w.setDisk(`bulk/f${i}.js`, `content-${i}`)
|
|
659
|
+
const engine = createSyncEngine(w.client, w.port)
|
|
660
|
+
await engine.populateLedger()
|
|
661
|
+
// populateLedger already seeded all 300 — reset the write log so the
|
|
662
|
+
// batch-driven commits below are counted from zero. (A REAL bulk batch
|
|
663
|
+
// arrives post-seed, e.g. `git checkout` switching branches.)
|
|
664
|
+
for (let i = 0; i < 300; i++) w.setDisk(`bulk/f${i}.js`, `checkout-${i}`)
|
|
665
|
+
engine.start()
|
|
666
|
+
const paths = [...w.disk.keys()]
|
|
667
|
+
;(w.client.write as ReturnType<typeof vi.fn>).mockClear()
|
|
668
|
+
w.ledgerWrites.length = 0
|
|
669
|
+
|
|
670
|
+
w.watch.emitBatch(paths)
|
|
671
|
+
await vi.waitFor(() => expect(w.ledger.get('bulk/f299.js')).toBe('checkout-299'), { timeout: 10_000 })
|
|
672
|
+
|
|
673
|
+
expect(w.ledgerWrites).toHaveLength(300)
|
|
674
|
+
expect(new Set(w.ledgerWrites.map(([rel]) => rel)).size).toBe(300)
|
|
675
|
+
|
|
676
|
+
// Full echo re-delivery (the watcher reporting our own already-recorded
|
|
677
|
+
// tree, e.g. after a debounce hiccup): zero additional commits. The
|
|
678
|
+
// negative assertion waits for REAL quiescence (two consecutive samples
|
|
679
|
+
// with no new ledger writes) instead of a fixed sleep, so a slower
|
|
680
|
+
// implementation cannot slip a late duplicate past the check.
|
|
681
|
+
w.ledgerWrites.length = 0
|
|
682
|
+
w.watch.emitBatch(paths)
|
|
683
|
+
let lastCount = -1
|
|
684
|
+
await vi.waitFor(
|
|
685
|
+
() => {
|
|
686
|
+
if (w.ledgerWrites.length !== lastCount) {
|
|
687
|
+
lastCount = w.ledgerWrites.length
|
|
688
|
+
throw new Error('ledger still settling')
|
|
689
|
+
}
|
|
690
|
+
},
|
|
691
|
+
{ timeout: 10_000, interval: 100 },
|
|
692
|
+
)
|
|
693
|
+
expect(w.ledgerWrites).toHaveLength(0)
|
|
694
|
+
})
|
|
695
|
+
})
|