@dimina-kit/fs-core 0.2.0-dev.20260711062001 → 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,147 @@
|
|
|
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
|
+
import { sha256hex } from '#worker-lib/wal-codec.js';
|
|
33
|
+
const BINARY_SNIFF_BYTES = 8192;
|
|
34
|
+
/** True when the first {@link BINARY_SNIFF_BYTES} of `bytes` contain a NUL
|
|
35
|
+
* byte — the classification gate for the whole binary layering. */
|
|
36
|
+
export function looksBinary(bytes) {
|
|
37
|
+
const len = Math.min(bytes.length, BINARY_SNIFF_BYTES);
|
|
38
|
+
for (let i = 0; i < len; i++) {
|
|
39
|
+
if (bytes[i] === 0)
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
export function createBinarySidecar(opts = {}) {
|
|
45
|
+
const retainBytes = opts.retainBytes === true;
|
|
46
|
+
// `let` (not const): reset() swaps in fully-built replacement maps in one
|
|
47
|
+
// synchronous step — see its body.
|
|
48
|
+
let index = new Map();
|
|
49
|
+
let store = new Map();
|
|
50
|
+
const changeCbs = new Set();
|
|
51
|
+
/**
|
|
52
|
+
* FIFO serializing every MUTATION (put/remove/clear/reset) in caller order.
|
|
53
|
+
* Without it, a put whose (async) hashing straddles an in-flight reset()
|
|
54
|
+
* lands on maps the reset is about to swap away — the mutation silently
|
|
55
|
+
* vanishes — and two overlapping resets resolve as "last swapper wins"
|
|
56
|
+
* instead of "last caller wins". Serialized, a mutation issued after
|
|
57
|
+
* reset() runs after it and lands in the NEW session; one issued before is
|
|
58
|
+
* superseded by it — nothing races across a session boundary. READS
|
|
59
|
+
* (has/entry/bytes/keys/overlay/toRecord) stay synchronous against the
|
|
60
|
+
* currently visible maps: reset's one-step swap keeps them consistent.
|
|
61
|
+
* The chain swallows step rejections so one failed mutation cannot wedge
|
|
62
|
+
* the queue.
|
|
63
|
+
*/
|
|
64
|
+
let mutationTurn = Promise.resolve();
|
|
65
|
+
function enqueueMutation(fn) {
|
|
66
|
+
const next = mutationTurn.then(fn, fn);
|
|
67
|
+
mutationTurn = next.catch(() => { });
|
|
68
|
+
return next;
|
|
69
|
+
}
|
|
70
|
+
function emit(rel, bytes) {
|
|
71
|
+
for (const cb of changeCbs) {
|
|
72
|
+
try {
|
|
73
|
+
cb(rel, bytes);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// A subscriber's failure is its own — it must not break the mutation
|
|
77
|
+
// that already happened, nor starve later subscribers.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
put(rel, bytes) {
|
|
83
|
+
return enqueueMutation(async () => {
|
|
84
|
+
const sha256 = await sha256hex(bytes);
|
|
85
|
+
const prior = index.get(rel);
|
|
86
|
+
if (prior && prior.size === bytes.length && prior.sha256 === sha256)
|
|
87
|
+
return false;
|
|
88
|
+
index.set(rel, { size: bytes.length, sha256 });
|
|
89
|
+
if (retainBytes)
|
|
90
|
+
store.set(rel, bytes);
|
|
91
|
+
emit(rel, bytes);
|
|
92
|
+
return true;
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
remove(rel) {
|
|
96
|
+
return enqueueMutation(() => {
|
|
97
|
+
const existed = index.delete(rel);
|
|
98
|
+
store.delete(rel);
|
|
99
|
+
if (existed)
|
|
100
|
+
emit(rel, null);
|
|
101
|
+
return existed;
|
|
102
|
+
});
|
|
103
|
+
},
|
|
104
|
+
has: (rel) => index.has(rel),
|
|
105
|
+
entry: (rel) => index.get(rel),
|
|
106
|
+
bytes: (rel) => store.get(rel),
|
|
107
|
+
keys: () => [...index.keys()],
|
|
108
|
+
get size() {
|
|
109
|
+
return index.size;
|
|
110
|
+
},
|
|
111
|
+
clear() {
|
|
112
|
+
return enqueueMutation(() => {
|
|
113
|
+
index.clear();
|
|
114
|
+
store.clear();
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
reset(files) {
|
|
118
|
+
return enqueueMutation(async () => {
|
|
119
|
+
// Atomic to readers: hashing runs against LOCAL maps and the visible
|
|
120
|
+
// state is swapped in one synchronous step at the end — a concurrent
|
|
121
|
+
// overlay()/toRecord()/entry() sees either the old set or the new
|
|
122
|
+
// set, never an empty or half-built one. Silent by design (see the
|
|
123
|
+
// module doc): a session reseed fires no per-entry events. Ordering
|
|
124
|
+
// vs other mutations comes from the mutation FIFO above.
|
|
125
|
+
const nextIndex = new Map();
|
|
126
|
+
const nextStore = new Map();
|
|
127
|
+
for (const [rel, bytes] of Object.entries(files)) {
|
|
128
|
+
nextIndex.set(rel, { size: bytes.length, sha256: await sha256hex(bytes) });
|
|
129
|
+
if (retainBytes)
|
|
130
|
+
nextStore.set(rel, bytes);
|
|
131
|
+
}
|
|
132
|
+
index = nextIndex;
|
|
133
|
+
store = nextStore;
|
|
134
|
+
});
|
|
135
|
+
},
|
|
136
|
+
toRecord: () => Object.fromEntries(store),
|
|
137
|
+
overlay(files) {
|
|
138
|
+
if (!retainBytes)
|
|
139
|
+
throw new Error('overlay() requires a retainBytes sidecar — an index-only sidecar has no bytes to merge');
|
|
140
|
+
return { ...Object.fromEntries(store), ...files };
|
|
141
|
+
},
|
|
142
|
+
onChange(cb) {
|
|
143
|
+
changeCbs.add(cb);
|
|
144
|
+
return () => changeCbs.delete(cb);
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
package/dist/sync/sync-engine.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sync arbitration engine — the memfs<->disk synchronization core
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Sync arbitration engine — the memfs<->disk synchronization core shared by
|
|
3
|
+
* every host that pairs an fs-core ledger with an external truth source.
|
|
4
|
+
* A host wires a `client` (the
|
|
5
5
|
* fs-core ledger: write/rm/read/ls) and a `TruthPort` (its external truth
|
|
6
6
|
* source — devtools' `/__fs` bridge + SSE watch, or a future FSA
|
|
7
7
|
* local-directory adapter) and gets back an engine that keeps the ledger,
|
|
@@ -10,76 +10,42 @@
|
|
|
10
10
|
* host's own audit-turn surface talks to `client` directly for that (see
|
|
11
11
|
* wal-audit.ts).
|
|
12
12
|
*
|
|
13
|
-
* Echo judgement (both directions
|
|
13
|
+
* Echo judgement (both directions, content comparison against the ledger's
|
|
14
|
+
* own record):
|
|
14
15
|
* - Outbound (`onHumanSave`): before recording, compare the saved text
|
|
15
16
|
* against the ledger's current record for that path — identical content
|
|
16
17
|
* is a no-op (the ledger already reflects it), skipping a redundant
|
|
17
|
-
* write.
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* is still in flight. When `pendingWrite` misses, the engine falls back
|
|
30
|
-
* to today's content comparison (truth-source bytes vs. the ledger's own
|
|
31
|
-
* record) — the only judgement a push host ever actually exercises.
|
|
18
|
+
* write. The onHumanSave accounting step and inbound batches run through
|
|
19
|
+
* the same `ledgerTurn` FIFO, so an inbound notification for a path whose
|
|
20
|
+
* save is still in flight always observes the completed ledger write and
|
|
21
|
+
* absorbs itself via that same comparison.
|
|
22
|
+
* - Inbound (`changes` batches, via `handleInboundPath`): truth-source
|
|
23
|
+
* bytes equal to the ledger's record are the echo of our own last write —
|
|
24
|
+
* dropped with no ledger write and no editor refresh.
|
|
25
|
+
* This engine serves 'push' ports only (the port notifies; the host's
|
|
26
|
+
* outbound writes go through `onHumanSave`). A future 'poll' host whose
|
|
27
|
+
* outbound scan runs outside this module must own its own one-shot echo
|
|
28
|
+
* suppression — see the README's「两套磁盘机制的划界」section for the
|
|
29
|
+
* invariants such an adapter has to satisfy.
|
|
32
30
|
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
* outbound scan and get echoed straight back to disk before the poll
|
|
39
|
-
* baseline ever settles. `inboundApplied` (`rel -> {kind:'text'|'binary'|
|
|
40
|
-
* 'delete', ...}`) records exactly what `handleInboundPath` just applied,
|
|
41
|
-
* overwriting any prior entry for the same path; `consumeInboundEcho(rel,
|
|
42
|
-
* content)` lets such a host check "is this outbound write about to re-emit
|
|
43
|
-
* an inbound change I haven't published yet?" immediately BEFORE writing to
|
|
44
|
-
* the truth source, consuming (clearing) the record on a match so it is a
|
|
45
|
-
* one-shot suppression, not a standing content cache. A miss (different
|
|
46
|
-
* content, or no record at all) returns false and leaves any existing record
|
|
47
|
-
* untouched — it is not this call's echo to consume.
|
|
31
|
+
* Degradation is loud (`onDegraded`): a dead watcher and a path that failed
|
|
32
|
+
* to sync (truth-source read failure, or a ledger write/rm failure while
|
|
33
|
+
* reconciling) are surfaced to the host through {@link SyncDegradation} in
|
|
34
|
+
* addition to the console warning — silently skipping them would let the
|
|
35
|
+
* ledger drift with no operator-visible signal.
|
|
48
36
|
*
|
|
49
|
-
* Binary layering (
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
37
|
+
* Binary layering: classification (NUL sniff), the `rel -> {size, sha256}`
|
|
38
|
+
* index, and echo judgement (size+hash equality instead of a ledger content
|
|
39
|
+
* compare) are all owned by ./binary-sidecar.ts — see that module's doc; this
|
|
40
|
+
* engine holds an INDEX-ONLY sidecar. A binary file never reaches the fs-core
|
|
41
|
+
* ledger (`client.write`/`read`). What/why this is narrower than the text
|
|
42
|
+
* path: binary changes get NO WAL audit and NO rollback (the audit turn
|
|
43
|
+
* surface — `diff`/`restore` — is a string-content contract; the engine does
|
|
44
|
+
* not support an agent writing binary inside a turn), and the sidecar is
|
|
45
|
+
* session-scoped — cleared and rebuilt from scratch on every
|
|
46
|
+
* `populateLedger()`, exactly like the ledger's own text reconciliation.
|
|
59
47
|
*/
|
|
60
|
-
|
|
61
|
-
/** True when the first `BINARY_SNIFF_BYTES` of `bytes` contain a NUL byte. */
|
|
62
|
-
function looksBinary(bytes) {
|
|
63
|
-
const len = Math.min(bytes.length, BINARY_SNIFF_BYTES);
|
|
64
|
-
for (let i = 0; i < len; i++) {
|
|
65
|
-
if (bytes[i] === 0)
|
|
66
|
-
return true;
|
|
67
|
-
}
|
|
68
|
-
return false;
|
|
69
|
-
}
|
|
70
|
-
async function sha256hex(bytes) {
|
|
71
|
-
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
|
72
|
-
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
|
|
73
|
-
}
|
|
74
|
-
/** Byte-for-byte equality — used by `consumeInboundEcho`'s binary-content match. */
|
|
75
|
-
function bytesEqual(a, b) {
|
|
76
|
-
if (a.length !== b.length)
|
|
77
|
-
return false;
|
|
78
|
-
for (let i = 0; i < a.length; i++)
|
|
79
|
-
if (a[i] !== b[i])
|
|
80
|
-
return false;
|
|
81
|
-
return true;
|
|
82
|
-
}
|
|
48
|
+
import { createBinarySidecar, looksBinary } from './binary-sidecar.js';
|
|
83
49
|
/**
|
|
84
50
|
* True when `error` denotes "path does not exist" per the TruthPort error
|
|
85
51
|
* contract (see truth-port.ts): `error.code === 'not-found'` or
|
|
@@ -93,28 +59,22 @@ function isNotFoundError(error) {
|
|
|
93
59
|
export function createSyncEngine(client, port, opts = {}) {
|
|
94
60
|
const decoder = new TextDecoder();
|
|
95
61
|
const applyToEditor = opts.applyToEditor;
|
|
96
|
-
/**
|
|
97
|
-
*
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
62
|
+
/** Surface a degradation to the host — a throwing callback is the host's
|
|
63
|
+
* own bug and must not break the reconciliation that reported it. */
|
|
64
|
+
function degrade(degradation) {
|
|
65
|
+
try {
|
|
66
|
+
opts.onDegraded?.(degradation);
|
|
67
|
+
}
|
|
68
|
+
catch (e) {
|
|
69
|
+
console.warn('[fs-core/sync] onDegraded callback threw', e);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
103
72
|
/**
|
|
104
73
|
* Binary files never enter the fs-core ledger — see the module doc's
|
|
105
|
-
* "Binary layering" section.
|
|
106
|
-
* `populateLedger()`.
|
|
74
|
+
* "Binary layering" section. Index-only (this engine never needs the bytes
|
|
75
|
+
* back); session-scoped: rebuilt from scratch on every `populateLedger()`.
|
|
107
76
|
*/
|
|
108
|
-
const binaryIndex =
|
|
109
|
-
/**
|
|
110
|
-
* `rel -> { kind: 'text', text } | { kind: 'binary', bytes } | { kind: 'delete' }`
|
|
111
|
-
* — see the module doc's "Inbound-echo consumption" section. Written by
|
|
112
|
-
* `handleInboundPath` immediately after it actually applies an inbound
|
|
113
|
-
* change (ledger write/rm, or a binary/delete index update); consumed
|
|
114
|
-
* (checked + cleared on a match) by `consumeInboundEcho`. Session-scoped,
|
|
115
|
-
* same as `binaryIndex` — cleared on every `populateLedger()`.
|
|
116
|
-
*/
|
|
117
|
-
const inboundApplied = new Map();
|
|
77
|
+
const binaryIndex = createBinarySidecar();
|
|
118
78
|
/**
|
|
119
79
|
* FIFO queue serializing every compare-then-record against the ledger
|
|
120
80
|
* (inbound change batches AND the onHumanSave accounting step). Without it
|
|
@@ -136,13 +96,12 @@ export function createSyncEngine(client, port, opts = {}) {
|
|
|
136
96
|
* same persisted ledger identity) and are removed so the ledger ends up
|
|
137
97
|
* exactly matching the walked tree. */
|
|
138
98
|
async function seedFromDisk() {
|
|
139
|
-
binaryIndex.clear();
|
|
140
|
-
inboundApplied.clear();
|
|
99
|
+
await binaryIndex.clear();
|
|
141
100
|
const seen = new Set();
|
|
142
101
|
await port.walk(async (rel, bytes) => {
|
|
143
102
|
seen.add(rel);
|
|
144
103
|
if (looksBinary(bytes)) {
|
|
145
|
-
binaryIndex.
|
|
104
|
+
await binaryIndex.put(rel, bytes);
|
|
146
105
|
return; // binary never enters the ledger
|
|
147
106
|
}
|
|
148
107
|
await client.write(rel, decoder.decode(bytes), { actor: 'human' });
|
|
@@ -157,20 +116,16 @@ export function createSyncEngine(client, port, opts = {}) {
|
|
|
157
116
|
}
|
|
158
117
|
}
|
|
159
118
|
/**
|
|
160
|
-
* Process one inbound path
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
* `applyToEditor` call. A `port.read` rejection classified as not-found
|
|
119
|
+
* Process one inbound path via content comparison: identical content is
|
|
120
|
+
* the echo of our own last write and is dropped with no ledger write and
|
|
121
|
+
* no `applyToEditor` call. A `port.read` rejection classified as not-found
|
|
164
122
|
* (see truth-port.ts) is a deletion; any other rejection
|
|
165
|
-
* ("unavailable") is transient and skips the path entirely
|
|
166
|
-
* deletion from it would rm the
|
|
167
|
-
* editor while it still exists at
|
|
123
|
+
* ("unavailable") is transient and skips the path entirely (surfaced as a
|
|
124
|
+
* `truth-read` degradation) — inferring a deletion from it would rm the
|
|
125
|
+
* ledger record and close the file in the editor while it still exists at
|
|
126
|
+
* the truth source.
|
|
168
127
|
*/
|
|
169
128
|
async function handleInboundPath(rel) {
|
|
170
|
-
if (pendingWrite.has(rel)) {
|
|
171
|
-
pendingWrite.delete(rel);
|
|
172
|
-
return;
|
|
173
|
-
}
|
|
174
129
|
let bytes;
|
|
175
130
|
try {
|
|
176
131
|
bytes = await port.read(rel);
|
|
@@ -178,6 +133,7 @@ export function createSyncEngine(client, port, opts = {}) {
|
|
|
178
133
|
catch (e) {
|
|
179
134
|
if (!isNotFoundError(e)) {
|
|
180
135
|
console.warn('[fs-core/sync] transient port read failure, skipping', rel, e);
|
|
136
|
+
degrade({ kind: 'path-sync-failed', rel, stage: 'truth-read', error: e });
|
|
181
137
|
return;
|
|
182
138
|
}
|
|
183
139
|
bytes = null;
|
|
@@ -187,8 +143,7 @@ export function createSyncEngine(client, port, opts = {}) {
|
|
|
187
143
|
// ledger (it never took the client.write path), so removal here is
|
|
188
144
|
// index-only — no client.rm.
|
|
189
145
|
if (binaryIndex.has(rel)) {
|
|
190
|
-
binaryIndex.
|
|
191
|
-
inboundApplied.set(rel, { kind: 'delete' });
|
|
146
|
+
await binaryIndex.remove(rel);
|
|
192
147
|
await applyToEditor?.(rel, null);
|
|
193
148
|
return;
|
|
194
149
|
}
|
|
@@ -196,23 +151,29 @@ export function createSyncEngine(client, port, opts = {}) {
|
|
|
196
151
|
try {
|
|
197
152
|
ledgerContent = (await client.read(rel)).content;
|
|
198
153
|
}
|
|
199
|
-
catch {
|
|
154
|
+
catch (e) {
|
|
155
|
+
if (!isNotFoundError(e)) {
|
|
156
|
+
// A transient ledger read failure is NOT "already absent" — rm'ing
|
|
157
|
+
// (or silently skipping) on unknown ledger state would either drop
|
|
158
|
+
// a record we could not confirm or leave it stale with no signal.
|
|
159
|
+
// Skip the path and surface it; the next change event retries.
|
|
160
|
+
console.warn('[fs-core/sync] ledger read failed while confirming a deletion, skipping', rel, e);
|
|
161
|
+
degrade({ kind: 'path-sync-failed', rel, stage: 'reconcile', error: e });
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
200
164
|
ledgerContent = undefined;
|
|
201
165
|
}
|
|
202
166
|
if (ledgerContent === undefined)
|
|
203
167
|
return; // already absent from both sides
|
|
204
168
|
await client.rm(rel, { actor: 'human' });
|
|
205
|
-
inboundApplied.set(rel, { kind: 'delete' });
|
|
206
169
|
await applyToEditor?.(rel, null);
|
|
207
170
|
return;
|
|
208
171
|
}
|
|
209
172
|
if (looksBinary(bytes)) {
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if (
|
|
213
|
-
return;
|
|
214
|
-
binaryIndex.set(rel, { size: bytes.length, sha256 });
|
|
215
|
-
inboundApplied.set(rel, { kind: 'binary', bytes });
|
|
173
|
+
// Echo judgement is the sidecar's put(): `false` = same size+sha256
|
|
174
|
+
// already indexed, i.e. the echo of our own last write.
|
|
175
|
+
if (!(await binaryIndex.put(rel, bytes)))
|
|
176
|
+
return;
|
|
216
177
|
await applyToEditor?.(rel, bytes);
|
|
217
178
|
return;
|
|
218
179
|
}
|
|
@@ -231,42 +192,15 @@ export function createSyncEngine(client, port, opts = {}) {
|
|
|
231
192
|
if (ledgerContent !== undefined && text === ledgerContent)
|
|
232
193
|
return; // echo of our own write
|
|
233
194
|
await client.write(rel, text, { actor: 'human' });
|
|
234
|
-
inboundApplied.set(rel, { kind: 'text', text });
|
|
235
195
|
await applyToEditor?.(rel, bytes);
|
|
236
196
|
}
|
|
237
|
-
/**
|
|
238
|
-
* One-shot check for a 'poll' host's own outbound path: "is `content` (the
|
|
239
|
-
* bytes/text about to be written to the truth source for `rel`, or `null`
|
|
240
|
-
* for a delete) exactly what `handleInboundPath` just applied FROM that
|
|
241
|
-
* same truth source?" A match means writing it back out would be a pure
|
|
242
|
-
* echo — the host should skip the write entirely — and the record is
|
|
243
|
-
* cleared (consumed) so it cannot match again. See the module doc's
|
|
244
|
-
* "Inbound-echo consumption" section for why a push host (devtools) never
|
|
245
|
-
* needs this: its outbound path goes through `onHumanSave`/`pendingWrite`
|
|
246
|
-
* instead.
|
|
247
|
-
*/
|
|
248
|
-
function consumeInboundEcho(rel, content) {
|
|
249
|
-
const entry = inboundApplied.get(rel);
|
|
250
|
-
if (!entry)
|
|
251
|
-
return false;
|
|
252
|
-
let matches;
|
|
253
|
-
if (content === null)
|
|
254
|
-
matches = entry.kind === 'delete';
|
|
255
|
-
else if (typeof content === 'string')
|
|
256
|
-
matches = entry.kind === 'text' && entry.text === content;
|
|
257
|
-
else
|
|
258
|
-
matches = entry.kind === 'binary' && bytesEqual(entry.bytes, content);
|
|
259
|
-
if (matches)
|
|
260
|
-
inboundApplied.delete(rel);
|
|
261
|
-
return matches;
|
|
262
|
-
}
|
|
263
197
|
/**
|
|
264
198
|
* A batch is trusted as-is: the port adapter (see truth-port.ts's
|
|
265
199
|
* `changes` doc) is responsible for turning a watcher's coalesced/lossy
|
|
266
200
|
* events into the actual set of paths worth re-examining BEFORE calling
|
|
267
|
-
* this engine — devtools' adapter (wal-audit.ts +
|
|
268
|
-
*
|
|
269
|
-
* against a session index, so paths arriving here have already earned
|
|
201
|
+
* this engine — devtools' adapter (dimina-kit workbench's wal-audit.ts +
|
|
202
|
+
* this package's ./watch-expander.ts) does that via a stat-level disk
|
|
203
|
+
* compare against a session index, so paths arriving here have already earned
|
|
270
204
|
* their re-examination and no further expansion happens at this layer.
|
|
271
205
|
*/
|
|
272
206
|
async function handleBatch(paths) {
|
|
@@ -279,6 +213,7 @@ export function createSyncEngine(client, port, opts = {}) {
|
|
|
279
213
|
}
|
|
280
214
|
catch (e) {
|
|
281
215
|
console.warn('[fs-core/sync] disk sync failed for', rel, e);
|
|
216
|
+
degrade({ kind: 'path-sync-failed', rel, stage: 'reconcile', error: e });
|
|
282
217
|
}
|
|
283
218
|
}
|
|
284
219
|
}
|
|
@@ -309,10 +244,28 @@ export function createSyncEngine(client, port, opts = {}) {
|
|
|
309
244
|
/** Subscribe to `port.changes`. `active` gates BOTH callbacks so a
|
|
310
245
|
* late/duplicate event delivered after `onDead` (or after a fresh
|
|
311
246
|
* `start()` superseded this subscription) is a guaranteed no-op, not just
|
|
312
|
-
* best-effort.
|
|
247
|
+
* best-effort. The subscription is torn down through `disposeOnce`: the
|
|
248
|
+
* TruthPort contract does not require an idempotent dispose, and a host's
|
|
249
|
+
* `onDegraded` callback may synchronously call `engine.stop()` — without
|
|
250
|
+
* the guard that re-enters the same dispose a second time. */
|
|
313
251
|
function start() {
|
|
314
252
|
let active = true;
|
|
315
|
-
|
|
253
|
+
let disposed = false;
|
|
254
|
+
// "dispose requested" and "dispose implementation available" are split:
|
|
255
|
+
// a port may fire onDead synchronously DURING the changes() call, before
|
|
256
|
+
// its dispose function even exists — the request is recorded via
|
|
257
|
+
// `disposed` and the implementation is invoked right after the
|
|
258
|
+
// subscription call returns it (see below). Declared-then-assigned (not
|
|
259
|
+
// `const`) precisely so that a during-subscription disposeOnce() reads
|
|
260
|
+
// `undefined` instead of hitting a temporal dead zone.
|
|
261
|
+
let disposeImpl = undefined;
|
|
262
|
+
function disposeOnce() {
|
|
263
|
+
if (disposed)
|
|
264
|
+
return;
|
|
265
|
+
disposed = true;
|
|
266
|
+
disposeImpl?.();
|
|
267
|
+
}
|
|
268
|
+
disposeImpl = port.changes((paths) => {
|
|
316
269
|
if (!active)
|
|
317
270
|
return;
|
|
318
271
|
enqueueInboundBatch(paths);
|
|
@@ -321,11 +274,16 @@ export function createSyncEngine(client, port, opts = {}) {
|
|
|
321
274
|
return;
|
|
322
275
|
active = false;
|
|
323
276
|
console.warn('[fs-core/sync] watcher died — reverting to open-time mirror only');
|
|
324
|
-
|
|
277
|
+
disposeOnce();
|
|
278
|
+
degrade({ kind: 'watcher-dead' });
|
|
325
279
|
});
|
|
280
|
+
// Dispose was requested during subscription (synchronous onDead): the
|
|
281
|
+
// implementation exists now — honor the request exactly once.
|
|
282
|
+
if (disposed)
|
|
283
|
+
disposeImpl();
|
|
326
284
|
stopWatching = () => {
|
|
327
285
|
active = false;
|
|
328
|
-
|
|
286
|
+
disposeOnce();
|
|
329
287
|
};
|
|
330
288
|
}
|
|
331
289
|
function stop() {
|
|
@@ -333,12 +291,11 @@ export function createSyncEngine(client, port, opts = {}) {
|
|
|
333
291
|
stopWatching = () => { };
|
|
334
292
|
}
|
|
335
293
|
/**
|
|
336
|
-
* Outbound accounting for a human save:
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
* onSave wrapper decides whether a ledger-write failure should be
|
|
294
|
+
* Outbound accounting for a human save: skips the ledger write when the
|
|
295
|
+
* saved text already matches the ledger's record, and runs inside the same
|
|
296
|
+
* `ledgerTurn` FIFO as inbound batches so the two can never interleave
|
|
297
|
+
* (see module doc's "Echo judgement"). Errors propagate to the caller — a
|
|
298
|
+
* host's onSave wrapper decides whether a ledger-write failure should be
|
|
342
299
|
* swallowed (best-effort accounting must never unwind a save that already
|
|
343
300
|
* landed at the truth source).
|
|
344
301
|
*
|
|
@@ -349,32 +306,25 @@ export function createSyncEngine(client, port, opts = {}) {
|
|
|
349
306
|
* entirely and only updates `binaryIndex`.
|
|
350
307
|
*/
|
|
351
308
|
async function onHumanSave(rel, content) {
|
|
352
|
-
|
|
353
|
-
try {
|
|
354
|
-
if (content instanceof Uint8Array && looksBinary(content)) {
|
|
355
|
-
await enqueueLedgerTurn(async () => {
|
|
356
|
-
binaryIndex.set(rel, { size: content.length, sha256: await sha256hex(content) });
|
|
357
|
-
});
|
|
358
|
-
return;
|
|
359
|
-
}
|
|
360
|
-
const text = typeof content === 'string' ? content : decoder.decode(content);
|
|
309
|
+
if (content instanceof Uint8Array && looksBinary(content)) {
|
|
361
310
|
await enqueueLedgerTurn(async () => {
|
|
362
|
-
|
|
363
|
-
.read(rel)
|
|
364
|
-
.then((r) => r.content === text)
|
|
365
|
-
.catch(() => false);
|
|
366
|
-
if (!unchanged)
|
|
367
|
-
await client.write(rel, text, { actor: 'human' });
|
|
311
|
+
await binaryIndex.put(rel, content);
|
|
368
312
|
});
|
|
313
|
+
return;
|
|
369
314
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
315
|
+
const text = typeof content === 'string' ? content : decoder.decode(content);
|
|
316
|
+
await enqueueLedgerTurn(async () => {
|
|
317
|
+
const unchanged = await client
|
|
318
|
+
.read(rel)
|
|
319
|
+
.then((r) => r.content === text)
|
|
320
|
+
.catch(() => false);
|
|
321
|
+
if (!unchanged)
|
|
322
|
+
await client.write(rel, text, { actor: 'human' });
|
|
323
|
+
});
|
|
373
324
|
}
|
|
374
325
|
return {
|
|
375
326
|
populateLedger: seedFromDisk,
|
|
376
327
|
onHumanSave,
|
|
377
|
-
consumeInboundEcho,
|
|
378
328
|
start,
|
|
379
329
|
stop,
|
|
380
330
|
};
|