@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.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/dist/agent-tools.js +52 -0
  4. package/dist/client.js +234 -0
  5. package/dist/disk-mirror.js +97 -0
  6. package/dist/fs-core.worker.js +1001 -0
  7. package/dist/fs-query.worker.js +115 -0
  8. package/dist/sync/sync-engine.js +381 -0
  9. package/dist/sync/truth-port.js +1 -0
  10. package/dist/worker-lib/engine-shared.js +25 -0
  11. package/dist/worker-lib/paths.js +17 -0
  12. package/dist/worker-lib/rpc-types.js +1 -0
  13. package/dist/worker-lib/wal-codec.js +111 -0
  14. package/dist/zip.js +60 -0
  15. package/package.json +75 -0
  16. package/src/__checks__/types-smoke.ts +23 -0
  17. package/src/agent-tools.test.ts +151 -0
  18. package/src/agent-tools.ts +117 -0
  19. package/src/client.test.ts +110 -0
  20. package/src/client.ts +284 -0
  21. package/src/disk-mirror.test.ts +190 -0
  22. package/src/disk-mirror.ts +119 -0
  23. package/src/fs-core-recovery.ts +280 -0
  24. package/src/fs-core-write-ops.ts +402 -0
  25. package/src/fs-core.worker.ts +182 -0
  26. package/src/fs-query.worker.ts +152 -0
  27. package/src/import-smoke.test.ts +40 -0
  28. package/src/worker-lib/engine-shared.test.ts +30 -0
  29. package/src/worker-lib/engine-shared.ts +74 -0
  30. package/src/worker-lib/paths.test.ts +39 -0
  31. package/src/worker-lib/paths.ts +14 -0
  32. package/src/worker-lib/rpc-types.ts +67 -0
  33. package/src/worker-lib/wal-codec.test.ts +83 -0
  34. package/src/worker-lib/wal-codec.ts +130 -0
  35. package/src/zip.test.ts +124 -0
  36. package/src/zip.ts +60 -0
  37. package/sync/sync-engine.test.ts +695 -0
  38. package/sync/sync-engine.ts +459 -0
  39. package/sync/truth-port.ts +72 -0
@@ -0,0 +1,459 @@
1
+ /**
2
+ * Sync arbitration engine — the memfs<->disk synchronization core factored
3
+ * out of dimina-kit's workbench `wal-audit.ts` (see
4
+ * devtools-fs-core-feasibility.md §7+§8). A host wires a `client` (the
5
+ * fs-core ledger: write/rm/read/ls) and a `TruthPort` (its external truth
6
+ * source — devtools' `/__fs` bridge + SSE watch, or a future FSA
7
+ * local-directory adapter) and gets back an engine that keeps the ledger,
8
+ * the external truth, and — via `applyToEditor` — the live editor buffer
9
+ * reconciled. `kernel` and turn enforcement never enter this module: a
10
+ * host's own audit-turn surface talks to `client` directly for that (see
11
+ * wal-audit.ts).
12
+ *
13
+ * Echo judgement (both directions):
14
+ * - Outbound (`onHumanSave`): before recording, compare the saved text
15
+ * against the ledger's current record for that path — identical content
16
+ * is a no-op (the ledger already reflects it), skipping a redundant
17
+ * write.
18
+ * - Inbound (`changes` batches, via `handleInboundPath`): first check
19
+ * `pendingWrite` — an entry there was registered by an `onHumanSave` still
20
+ * in flight for the same path, and its presence means this inbound
21
+ * notification is that write's own echo, absorbed with no ledger write
22
+ * and no editor refresh. This check is a structural no-op for a 'push'
23
+ * port (devtools' SSE): registration and clearing both happen inside the
24
+ * SAME `ledgerTurn` FIFO slot as the write they guard (see
25
+ * `onHumanSave`), so by the time any later-queued inbound turn for that
26
+ * path runs, the entry is already gone — the branch exists for a future
27
+ * 'poll' host whose change detection runs OUTSIDE this FIFO (e.g. an
28
+ * mtime/size sweep) and can therefore observe the entry while the write
29
+ * is still in flight. When `pendingWrite` misses, the engine falls back
30
+ * to today's content comparison (truth-source bytes vs. the ledger's own
31
+ * record) — the only judgement a push host ever actually exercises.
32
+ *
33
+ * Inbound-echo consumption (`consumeInboundEcho`, `inboundApplied`): a 'poll'
34
+ * host (e.g. the web local-directory adapter) drives its OWN outbound path
35
+ * OUTSIDE this module — it reads the ledger and writes the truth source
36
+ * directly, not through `onHumanSave` — so a change this engine just applied
37
+ * INBOUND (disk -> ledger, via `handleInboundPath`) can race that host's
38
+ * outbound scan and get echoed straight back to disk before the poll
39
+ * baseline ever settles. `inboundApplied` (`rel -> {kind:'text'|'binary'|
40
+ * 'delete', ...}`) records exactly what `handleInboundPath` just applied,
41
+ * overwriting any prior entry for the same path; `consumeInboundEcho(rel,
42
+ * content)` lets such a host check "is this outbound write about to re-emit
43
+ * an inbound change I haven't published yet?" immediately BEFORE writing to
44
+ * the truth source, consuming (clearing) the record on a match so it is a
45
+ * one-shot suppression, not a standing content cache. A miss (different
46
+ * content, or no record at all) returns false and leaves any existing record
47
+ * untouched — it is not this call's echo to consume.
48
+ *
49
+ * Binary layering (v1): the first 8192 bytes of a file are sniffed for a NUL
50
+ * byte to classify it binary. A binary file never reaches the fs-core
51
+ * ledger (`client.write`/`read`) — it is tracked only in the in-memory
52
+ * `binaryIndex` (`rel -> { size, sha256 }`), and echo judgement for it is
53
+ * size+hash equality instead of a ledger content compare. What/why this is
54
+ * narrower than the text path: binary changes get NO WAL audit and NO
55
+ * rollback (the audit turn surface — `diff`/`restore` — is a string-content
56
+ * contract; v1 does not support an agent writing binary inside a turn), and
57
+ * `binaryIndex` is session-scoped — it is cleared and rebuilt from scratch on
58
+ * every `populateLedger()`, exactly like the ledger's own text reconciliation.
59
+ */
60
+
61
+ import type { TruthPort } from './truth-port.js'
62
+
63
+ export type { TruthPort, TruthPortCapabilities } from './truth-port.js'
64
+
65
+ /**
66
+ * The slice of the fs-core ledger client the sync engine calls. Kept narrow
67
+ * (vs. the full `ProjectFsClient` surface) so a host/test double only has to
68
+ * implement what this module actually uses. Turn enforcement
69
+ * (turnBegin/turnEnd/diff/restore) and destroy() are NOT part of this
70
+ * surface — they belong to a host's own audit-turn surface, which talks to
71
+ * the real client directly (see dimina-kit workbench's wal-audit.ts).
72
+ */
73
+ export interface SyncClientLike {
74
+ write(path: string, content: string, opts?: Record<string, unknown>): Promise<unknown>
75
+ rm(path: string, opts?: Record<string, unknown>): Promise<unknown>
76
+ read(path: string): Promise<{ content: string; rev?: number }>
77
+ ls(): Promise<{ paths: string[] }>
78
+ }
79
+
80
+ export interface SyncEngineOptions {
81
+ /**
82
+ * Host seam: push an inbound change into the live editor buffer.
83
+ * `bytes === null` means the path was deleted. Omitted — the ledger still
84
+ * records the change but the editor buffer is left untouched.
85
+ */
86
+ applyToEditor?: (rel: string, bytes: Uint8Array | null) => Promise<void>
87
+ }
88
+
89
+ export interface SyncEngine {
90
+ /**
91
+ * Walk the port's tree into the ledger and reconcile residue: ledger paths
92
+ * absent from the walk are removed, so the ledger ends up exactly matching
93
+ * the truth source. Call once (per session) before `start()`.
94
+ */
95
+ populateLedger(): Promise<void>
96
+ /**
97
+ * Record a human save's content in the ledger — skipped when it already
98
+ * matches the ledger's current record for that path. Call AFTER the save
99
+ * has landed at the truth source: this is best-effort accounting, never a
100
+ * gate on the save itself.
101
+ *
102
+ * `content` accepts either the already-decoded text (legacy string path)
103
+ * or the raw saved bytes (`Uint8Array`) — pass raw bytes so the engine can
104
+ * sniff for binary content (see this module's "Binary layering" doc) and
105
+ * route it to the session-scoped `binaryIndex` instead of the ledger.
106
+ */
107
+ onHumanSave(rel: string, content: string | Uint8Array): Promise<void>
108
+ /**
109
+ * One-shot check for a 'poll' host's own outbound path (see this module's
110
+ * "Inbound-echo consumption" doc): call this BEFORE writing `content` (the
111
+ * decoded text, raw bytes, or `null` for a delete) for `rel` out to the
112
+ * truth source. Returns `true` when it exactly matches what
113
+ * `handleInboundPath` just applied FROM that same truth source — that
114
+ * write would be a pure echo, so the caller should skip it — and clears
115
+ * the record so it cannot match again (one-shot, not a standing cache).
116
+ * Returns `false` on any mismatch or when there is no record at all,
117
+ * leaving any existing record untouched.
118
+ */
119
+ consumeInboundEcho(rel: string, content: string | Uint8Array | null): boolean
120
+ /** Subscribe to `port.changes` and start reconciling inbound batches. */
121
+ start(): void
122
+ /** Tear down the `port.changes` subscription. Idempotent. */
123
+ stop(): void
124
+ }
125
+
126
+ const BINARY_SNIFF_BYTES = 8192
127
+
128
+ /** True when the first `BINARY_SNIFF_BYTES` of `bytes` contain a NUL byte. */
129
+ function looksBinary(bytes: Uint8Array): boolean {
130
+ const len = Math.min(bytes.length, BINARY_SNIFF_BYTES)
131
+ for (let i = 0; i < len; i++) {
132
+ if (bytes[i] === 0) return true
133
+ }
134
+ return false
135
+ }
136
+
137
+ async function sha256hex(bytes: Uint8Array): Promise<string> {
138
+ const digest = await crypto.subtle.digest('SHA-256', bytes as BufferSource)
139
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('')
140
+ }
141
+
142
+ /** Byte-for-byte equality — used by `consumeInboundEcho`'s binary-content match. */
143
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
144
+ if (a.length !== b.length) return false
145
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
146
+ return true
147
+ }
148
+
149
+ /**
150
+ * True when `error` denotes "path does not exist" per the TruthPort error
151
+ * contract (see truth-port.ts): `error.code === 'not-found'` or
152
+ * `error.status === 404`. Every other rejection is "unavailable" (transient
153
+ * I/O failure, permission loss, dead connection, ...).
154
+ */
155
+ function isNotFoundError(error: unknown): boolean {
156
+ const e = error as { code?: unknown; status?: unknown } | null | undefined
157
+ return Boolean(e) && (e!.code === 'not-found' || e!.status === 404)
158
+ }
159
+
160
+ type InboundApplied =
161
+ | { kind: 'text'; text: string }
162
+ | { kind: 'binary'; bytes: Uint8Array }
163
+ | { kind: 'delete' }
164
+
165
+ export function createSyncEngine(
166
+ client: SyncClientLike,
167
+ port: TruthPort,
168
+ opts: SyncEngineOptions = {},
169
+ ): SyncEngine {
170
+ const decoder = new TextDecoder()
171
+ const applyToEditor = opts.applyToEditor
172
+
173
+ /**
174
+ * Paths with an `onHumanSave` write in flight — see the module doc's
175
+ * "Echo judgement" section. Registered synchronously when `onHumanSave` is
176
+ * called and cleared unconditionally (`finally`) once that write's own
177
+ * ledgerTurn slot finishes, success or failure.
178
+ */
179
+ const pendingWrite = new Set<string>()
180
+
181
+ /**
182
+ * Binary files never enter the fs-core ledger — see the module doc's
183
+ * "Binary layering" section. Session-scoped: rebuilt from scratch on every
184
+ * `populateLedger()`.
185
+ */
186
+ const binaryIndex = new Map<string, { size: number; sha256: string }>()
187
+
188
+ /**
189
+ * `rel -> { kind: 'text', text } | { kind: 'binary', bytes } | { kind: 'delete' }`
190
+ * — see the module doc's "Inbound-echo consumption" section. Written by
191
+ * `handleInboundPath` immediately after it actually applies an inbound
192
+ * change (ledger write/rm, or a binary/delete index update); consumed
193
+ * (checked + cleared on a match) by `consumeInboundEcho`. Session-scoped,
194
+ * same as `binaryIndex` — cleared on every `populateLedger()`.
195
+ */
196
+ const inboundApplied = new Map<string, InboundApplied>()
197
+
198
+ /**
199
+ * FIFO queue serializing every compare-then-record against the ledger
200
+ * (inbound change batches AND the onHumanSave accounting step). Without it
201
+ * the two race: an inbound echo's compare-read can run while an
202
+ * onHumanSave ledger write is still in flight, see stale content, and
203
+ * re-record the identical bytes. Serialized, the loser of the race sees
204
+ * the winner's write and absorbs it as an echo. The chain swallows step
205
+ * rejections (each caller handles its own errors), so one failed step can
206
+ * never wedge the queue.
207
+ */
208
+ let ledgerTurn: Promise<unknown> = Promise.resolve()
209
+ function enqueueLedgerTurn<T>(fn: () => T | Promise<T>): Promise<T> {
210
+ const next = ledgerTurn.then(fn, fn) as Promise<T>
211
+ ledgerTurn = next.catch(() => {})
212
+ return next
213
+ }
214
+
215
+ /** Walk the port's tree into the ledger, then reconcile: ledger paths the
216
+ * walk did not produce are residue (e.g. from a previous session under the
217
+ * same persisted ledger identity) and are removed so the ledger ends up
218
+ * exactly matching the walked tree. */
219
+ async function seedFromDisk(): Promise<void> {
220
+ binaryIndex.clear()
221
+ inboundApplied.clear()
222
+ const seen = new Set<string>()
223
+ await port.walk(async (rel, bytes) => {
224
+ seen.add(rel)
225
+ if (looksBinary(bytes)) {
226
+ binaryIndex.set(rel, { size: bytes.length, sha256: await sha256hex(bytes) })
227
+ return // binary never enters the ledger
228
+ }
229
+ await client.write(rel, decoder.decode(bytes), { actor: 'human' })
230
+ })
231
+ const { paths } = await client.ls()
232
+ for (const p of paths) {
233
+ // Residue from a previous session, OR a path that used to be
234
+ // ledgered as text but is now classified binary (migration cleanup —
235
+ // binaryIndex.has(p) is authoritative once the walk above ran).
236
+ if (!seen.has(p) || binaryIndex.has(p)) await client.rm(p, { actor: 'human' })
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Process one inbound path. `pendingWrite` is checked first (see module
242
+ * doc); a miss falls back to content comparison: identical content is the
243
+ * echo of our own last write and is dropped with no ledger write and no
244
+ * `applyToEditor` call. A `port.read` rejection classified as not-found
245
+ * (see truth-port.ts) is a deletion; any other rejection
246
+ * ("unavailable") is transient and skips the path entirely — inferring a
247
+ * deletion from it would rm the ledger record and close the file in the
248
+ * editor while it still exists at the truth source.
249
+ */
250
+ async function handleInboundPath(rel: string): Promise<void> {
251
+ if (pendingWrite.has(rel)) {
252
+ pendingWrite.delete(rel)
253
+ return
254
+ }
255
+
256
+ let bytes: Uint8Array | null
257
+ try {
258
+ bytes = await port.read(rel)
259
+ } catch (e) {
260
+ if (!isNotFoundError(e)) {
261
+ console.warn('[fs-core/sync] transient port read failure, skipping', rel, e)
262
+ return
263
+ }
264
+ bytes = null
265
+ }
266
+
267
+ if (bytes === null) {
268
+ // Deletion. A path the binaryIndex knows about was never in the
269
+ // ledger (it never took the client.write path), so removal here is
270
+ // index-only — no client.rm.
271
+ if (binaryIndex.has(rel)) {
272
+ binaryIndex.delete(rel)
273
+ inboundApplied.set(rel, { kind: 'delete' })
274
+ await applyToEditor?.(rel, null)
275
+ return
276
+ }
277
+ let ledgerContent: string | undefined
278
+ try {
279
+ ledgerContent = (await client.read(rel)).content
280
+ } catch {
281
+ ledgerContent = undefined
282
+ }
283
+ if (ledgerContent === undefined) return // already absent from both sides
284
+ await client.rm(rel, { actor: 'human' })
285
+ inboundApplied.set(rel, { kind: 'delete' })
286
+ await applyToEditor?.(rel, null)
287
+ return
288
+ }
289
+
290
+ if (looksBinary(bytes)) {
291
+ const prior = binaryIndex.get(rel)
292
+ const sha256 = await sha256hex(bytes)
293
+ if (prior && prior.size === bytes.length && prior.sha256 === sha256) return // echo: same bytes already indexed
294
+ binaryIndex.set(rel, { size: bytes.length, sha256 })
295
+ inboundApplied.set(rel, { kind: 'binary', bytes })
296
+ await applyToEditor?.(rel, bytes)
297
+ return
298
+ }
299
+
300
+ let ledgerContent: string | undefined
301
+ try {
302
+ ledgerContent = (await client.read(rel)).content
303
+ } catch {
304
+ // Not in the ledger yet (or a transient read failure) — treated the
305
+ // same as "no prior recorded content", so a real file at the truth
306
+ // source always gets recorded/applied below rather than silently
307
+ // dropped.
308
+ ledgerContent = undefined
309
+ }
310
+
311
+ const text = decoder.decode(bytes)
312
+ if (ledgerContent !== undefined && text === ledgerContent) return // echo of our own write
313
+
314
+ await client.write(rel, text, { actor: 'human' })
315
+ inboundApplied.set(rel, { kind: 'text', text })
316
+ await applyToEditor?.(rel, bytes)
317
+ }
318
+
319
+ /**
320
+ * One-shot check for a 'poll' host's own outbound path: "is `content` (the
321
+ * bytes/text about to be written to the truth source for `rel`, or `null`
322
+ * for a delete) exactly what `handleInboundPath` just applied FROM that
323
+ * same truth source?" A match means writing it back out would be a pure
324
+ * echo — the host should skip the write entirely — and the record is
325
+ * cleared (consumed) so it cannot match again. See the module doc's
326
+ * "Inbound-echo consumption" section for why a push host (devtools) never
327
+ * needs this: its outbound path goes through `onHumanSave`/`pendingWrite`
328
+ * instead.
329
+ */
330
+ function consumeInboundEcho(rel: string, content: string | Uint8Array | null): boolean {
331
+ const entry = inboundApplied.get(rel)
332
+ if (!entry) return false
333
+ let matches: boolean
334
+ if (content === null) matches = entry.kind === 'delete'
335
+ else if (typeof content === 'string') matches = entry.kind === 'text' && entry.text === content
336
+ else matches = entry.kind === 'binary' && bytesEqual(entry.bytes, content)
337
+ if (matches) inboundApplied.delete(rel)
338
+ return matches
339
+ }
340
+
341
+ /**
342
+ * A batch is trusted as-is: the port adapter (see truth-port.ts's
343
+ * `changes` doc) is responsible for turning a watcher's coalesced/lossy
344
+ * events into the actual set of paths worth re-examining BEFORE calling
345
+ * this engine — devtools' adapter (wal-audit.ts +
346
+ * wal-audit-watch-expand.ts) does that via a stat-level disk compare
347
+ * against a session index, so paths arriving here have already earned
348
+ * their re-examination and no further expansion happens at this layer.
349
+ */
350
+ async function handleBatch(paths: string[]): Promise<void> {
351
+ for (const rel of paths) {
352
+ try {
353
+ // Per-path (not per-batch) queue turns, so a long batch cannot
354
+ // starve an interleaved onHumanSave accounting step of its FIFO
355
+ // position.
356
+ await enqueueLedgerTurn(() => handleInboundPath(rel))
357
+ } catch (e) {
358
+ console.warn('[fs-core/sync] disk sync failed for', rel, e)
359
+ }
360
+ }
361
+ }
362
+
363
+ let pendingInboundPaths = new Set<string>()
364
+ let inboundBatchRunning = false
365
+ function enqueueInboundBatch(paths: string[]): void {
366
+ for (const rel of paths) pendingInboundPaths.add(rel)
367
+ if (inboundBatchRunning) return
368
+ inboundBatchRunning = true
369
+ void (async () => {
370
+ try {
371
+ while (pendingInboundPaths.size) {
372
+ const raw = [...pendingInboundPaths]
373
+ pendingInboundPaths = new Set<string>()
374
+ await handleBatch(raw)
375
+ }
376
+ } finally {
377
+ inboundBatchRunning = false
378
+ if (pendingInboundPaths.size) enqueueInboundBatch([])
379
+ }
380
+ })()
381
+ }
382
+
383
+ let stopWatching = () => {}
384
+
385
+ /** Subscribe to `port.changes`. `active` gates BOTH callbacks so a
386
+ * late/duplicate event delivered after `onDead` (or after a fresh
387
+ * `start()` superseded this subscription) is a guaranteed no-op, not just
388
+ * best-effort. */
389
+ function start(): void {
390
+ let active = true
391
+ const dispose = port.changes(
392
+ (paths) => {
393
+ if (!active) return
394
+ enqueueInboundBatch(paths)
395
+ },
396
+ () => {
397
+ if (!active) return
398
+ active = false
399
+ console.warn('[fs-core/sync] watcher died — reverting to open-time mirror only')
400
+ dispose()
401
+ },
402
+ )
403
+ stopWatching = () => {
404
+ active = false
405
+ dispose()
406
+ }
407
+ }
408
+
409
+ function stop(): void {
410
+ stopWatching()
411
+ stopWatching = () => {}
412
+ }
413
+
414
+ /**
415
+ * Outbound accounting for a human save: registers `rel` in `pendingWrite`
416
+ * for the duration of the ledger compare-then-write (see module doc),
417
+ * skips the ledger write when the saved text already matches the ledger's
418
+ * record, and runs inside the same `ledgerTurn` FIFO as inbound batches so
419
+ * the two can never interleave. Errors propagate to the caller — a host's
420
+ * onSave wrapper decides whether a ledger-write failure should be
421
+ * swallowed (best-effort accounting must never unwind a save that already
422
+ * landed at the truth source).
423
+ *
424
+ * `content` may be the decoded text (string, unchanged legacy path) or the
425
+ * raw saved bytes (`Uint8Array`) — passing raw bytes lets this function
426
+ * sniff for binary (see module doc's "Binary layering" section) before
427
+ * deciding whether to decode. A binary save skips the ledger write
428
+ * entirely and only updates `binaryIndex`.
429
+ */
430
+ async function onHumanSave(rel: string, content: string | Uint8Array): Promise<void> {
431
+ pendingWrite.add(rel)
432
+ try {
433
+ if (content instanceof Uint8Array && looksBinary(content)) {
434
+ await enqueueLedgerTurn(async () => {
435
+ binaryIndex.set(rel, { size: content.length, sha256: await sha256hex(content) })
436
+ })
437
+ return
438
+ }
439
+ const text = typeof content === 'string' ? content : decoder.decode(content)
440
+ await enqueueLedgerTurn(async () => {
441
+ const unchanged = await client
442
+ .read(rel)
443
+ .then((r) => r.content === text)
444
+ .catch(() => false)
445
+ if (!unchanged) await client.write(rel, text, { actor: 'human' })
446
+ })
447
+ } finally {
448
+ pendingWrite.delete(rel)
449
+ }
450
+ }
451
+
452
+ return {
453
+ populateLedger: seedFromDisk,
454
+ onHumanSave,
455
+ consumeInboundEcho,
456
+ start,
457
+ stop,
458
+ }
459
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * TruthPort: the external-truth-source adapter the sync engine (see
3
+ * sync-engine.ts) talks to. Concrete adapters live with their host: devtools'
4
+ * is `/__fs` HTTP bridge reads/writes + an SSE change stream (workbench's
5
+ * wal-audit.ts); a future web local-directory adapter would wrap the File
6
+ * System Access API with poll-based change detection instead.
7
+ *
8
+ * Error contract for `read`/`delete`: a rejection MUST be classifiable as
9
+ * either "not-found" (the path does not exist — safe to treat as a deletion)
10
+ * or "unavailable" (anything else: a transient I/O failure, a permission
11
+ * loss, a dead connection, ...). A rejection counts as not-found when either:
12
+ * - `error.code === 'not-found'`, or
13
+ * - `error.status === 404`
14
+ * every other rejection shape is unavailable. The engine never infers a
15
+ * deletion from an "unavailable" rejection.
16
+ */
17
+ export interface TruthPortCapabilities {
18
+ /**
19
+ * How this port delivers change notifications: 'push' (an event stream —
20
+ * devtools' SSE `/__fs/watch`) or 'poll' (no push channel; a future FSA
21
+ * local-directory adapter would need its own polling loop outside the
22
+ * engine). The two channels do not commit to equivalent latency/ordering
23
+ * guarantees.
24
+ */
25
+ watch: 'push' | 'poll'
26
+ }
27
+
28
+ export interface TruthPort {
29
+ readonly capabilities: TruthPortCapabilities
30
+
31
+ /**
32
+ * Read a path's current bytes. Rejects per the error contract above: a
33
+ * not-found rejection means the path does not exist; any other rejection
34
+ * is unavailable (transient) and must NOT be read as a deletion.
35
+ */
36
+ read(rel: string): Promise<Uint8Array>
37
+
38
+ /** Write bytes to a path (create or overwrite). */
39
+ write(rel: string, bytes: Uint8Array): Promise<void>
40
+
41
+ /** Delete a path. Same not-found/unavailable error contract as {@link read}. */
42
+ delete(rel: string): Promise<void>
43
+
44
+ /**
45
+ * Walk every file under the port's root, calling `onFile` once per file
46
+ * (awaited before moving on to the next). Directories are not reported
47
+ * individually — only the files within them.
48
+ */
49
+ walk(onFile: (rel: string, bytes: Uint8Array) => Promise<void>): Promise<void>
50
+
51
+ /**
52
+ * Subscribe to inbound change batches. `onBatch` receives a batch of
53
+ * changed relative paths; `onDead` fires at most once when the change
54
+ * channel is permanently gone (the host should treat this as a downgrade
55
+ * to no live sync, not a transient hiccup — see sync-engine.ts's `stop`).
56
+ * Returns a `dispose` that tears down the subscription.
57
+ *
58
+ * Compensation for a lossy/coalescing underlying watcher (e.g. macOS
59
+ * FSEvents merging a write burst into ancestor-directory events, or
60
+ * dropping some of a burst entirely) is THIS PORT'S responsibility, not
61
+ * the engine's: the adapter is the one that knows its own watcher's
62
+ * failure modes and how to recover the true changed set (typically:
63
+ * treat a reported path as a scope hint, stat-compare the disk under
64
+ * that scope against a session index, and report every path whose stat
65
+ * actually moved, plus ledger paths in scope that vanished from disk).
66
+ * `handleBatch` in sync-engine.ts takes `onBatch`'s paths at face value
67
+ * and does no further expansion — see devtools' adapter
68
+ * (dimina-kit workbench's wal-audit.ts + wal-audit-watch-expand.ts) for a
69
+ * concrete implementation of this contract.
70
+ */
71
+ changes(onBatch: (paths: string[]) => void, onDead: () => void): () => void
72
+ }