amalgm 0.1.245 → 0.1.246
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 +12 -0
- package/lib/cli.js +146 -8
- package/lib/layout.js +89 -4
- package/lib/shared-realtime-tunnel.js +384 -43
- package/lib/supervisor.js +0 -3
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
- package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
- package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
- package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
- package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
- package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
- package/runtime/scripts/amalgm-mcp/index.js +4 -0
- package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
- package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
- package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
- package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
- package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
- package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
- package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
- package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
- package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
- package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
- package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
- package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
- package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
- package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
- package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
- package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
- package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
- package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
- package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
- package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
- package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
- package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
- package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
- package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
|
@@ -0,0 +1,657 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The observer's CORE exams: enrollment births channels, and the compare
|
|
5
|
+
* function decides what a change is — content (an edit is one event
|
|
6
|
+
* carrying both hashes), identity (a rename keeps the channel, a path
|
|
7
|
+
* claims over an inode), echo suppression, doorbell routing, and
|
|
8
|
+
* recovery from a dead watcher. The other seams have their own suites:
|
|
9
|
+
* links, roots, territory — all over the shared rig.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const test = require('node:test');
|
|
13
|
+
const assert = require('node:assert/strict');
|
|
14
|
+
const { EventEmitter } = require('events');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
const Database = require('better-sqlite3');
|
|
19
|
+
const { createObserver } = require('../observer');
|
|
20
|
+
const { diffRootState } = require('../observer/scan');
|
|
21
|
+
const {
|
|
22
|
+
cleanup, eventsOf, makeDir, makeObserver, nudgeUntil,
|
|
23
|
+
} = require('./observer.rig');
|
|
24
|
+
|
|
25
|
+
test.after(cleanup);
|
|
26
|
+
|
|
27
|
+
// --- enrollment -----------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
test('enrollment births one channel per enrollable file, with content hashes', () => {
|
|
30
|
+
const dir = makeDir('enroll');
|
|
31
|
+
fs.writeFileSync(path.join(dir, 'notes.md'), 'hello');
|
|
32
|
+
fs.mkdirSync(path.join(dir, 'sub'));
|
|
33
|
+
fs.writeFileSync(path.join(dir, 'sub', 'plan.md'), 'world');
|
|
34
|
+
|
|
35
|
+
const { observer, events } = makeObserver();
|
|
36
|
+
const root = observer.enrollRoot(dir);
|
|
37
|
+
|
|
38
|
+
const created = eventsOf(events, 'file.created');
|
|
39
|
+
assert.deepEqual(created.map((event) => event.path).sort(), ['notes.md', 'sub/plan.md']);
|
|
40
|
+
assert.ok(created.every((event) => /^[0-9a-f]{64}$/.test(event.hash)));
|
|
41
|
+
assert.ok(created.every((event) => event.rootId === root.rootId));
|
|
42
|
+
assert.deepEqual(eventsOf(events, 'dir.created').map((event) => event.path), ['sub'],
|
|
43
|
+
'a directory is a channel too: enrollment births it beside the files');
|
|
44
|
+
assert.equal(events.length, 3);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('two directories exchange names between scans: both channels follow their inodes', () => {
|
|
48
|
+
const dir = makeDir('dir-swap');
|
|
49
|
+
fs.mkdirSync(path.join(dir, 'north'));
|
|
50
|
+
fs.mkdirSync(path.join(dir, 'south'));
|
|
51
|
+
const { observer, events } = makeObserver();
|
|
52
|
+
const root = observer.enrollRoot(dir);
|
|
53
|
+
const before = new Map(observer.dirsForRoot(root.rootId).map((row) => [row.relPath, row.dirId]));
|
|
54
|
+
|
|
55
|
+
// The swap crosses between two scans; the walk sees only the endpoints.
|
|
56
|
+
fs.renameSync(path.join(dir, 'north'), path.join(dir, 'limbo'));
|
|
57
|
+
fs.renameSync(path.join(dir, 'south'), path.join(dir, 'north'));
|
|
58
|
+
fs.renameSync(path.join(dir, 'limbo'), path.join(dir, 'south'));
|
|
59
|
+
const mark = events.length;
|
|
60
|
+
observer.scanNow(root.rootId);
|
|
61
|
+
|
|
62
|
+
const renamed = eventsOf(events.slice(mark), 'dir.renamed');
|
|
63
|
+
assert.deepEqual(renamed.map((event) => [event.from, event.to, event.dirId]).sort(),
|
|
64
|
+
[['north', 'south', before.get('north')], ['south', 'north', before.get('south')]].sort(),
|
|
65
|
+
'physical continuity claims first: a known inode at a new address IS the move');
|
|
66
|
+
assert.equal(events.length - mark, renamed.length, 'nothing else spoke');
|
|
67
|
+
assert.deepEqual(observer.verify(root.rootId), []);
|
|
68
|
+
|
|
69
|
+
// The other rule is untouched: a never-seen inode at a known address is
|
|
70
|
+
// recreation — the same channel wearing a new inode, silently.
|
|
71
|
+
fs.rmSync(path.join(dir, 'north'), { recursive: true });
|
|
72
|
+
fs.mkdirSync(path.join(dir, 'north'));
|
|
73
|
+
const quiet = events.length;
|
|
74
|
+
observer.scanNow(root.rootId);
|
|
75
|
+
assert.equal(events.length, quiet, 'recreation under an old name is silence');
|
|
76
|
+
assert.equal(observer.dirsForRoot(root.rootId).find((row) => row.relPath === 'north').dirId,
|
|
77
|
+
before.get('south'), 'the recreated directory wears the same channel');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('a torn dir batch changes nothing: the swap lands as one transaction or not at all', () => {
|
|
81
|
+
const dir = makeDir('dir-swap-torn');
|
|
82
|
+
fs.mkdirSync(path.join(dir, 'north'));
|
|
83
|
+
fs.mkdirSync(path.join(dir, 'south'));
|
|
84
|
+
const db = new Database(':memory:');
|
|
85
|
+
const { observer, events } = makeObserver({ db });
|
|
86
|
+
const root = observer.enrollRoot(dir);
|
|
87
|
+
const before = new Map(observer.dirsForRoot(root.rootId).map((row) => [row.relPath, row.dirId]));
|
|
88
|
+
|
|
89
|
+
fs.renameSync(path.join(dir, 'north'), path.join(dir, 'limbo'));
|
|
90
|
+
fs.renameSync(path.join(dir, 'south'), path.join(dir, 'north'));
|
|
91
|
+
fs.renameSync(path.join(dir, 'limbo'), path.join(dir, 'south'));
|
|
92
|
+
|
|
93
|
+
// The crash strikes after the first mover has landed, injected through
|
|
94
|
+
// the database's own law so the rollback is proven where it must hold.
|
|
95
|
+
db.exec(`CREATE TRIGGER torn BEFORE INSERT ON observer_dirs
|
|
96
|
+
WHEN NEW.rel_path = 'south' BEGIN SELECT RAISE(ABORT, 'injected mid-batch failure'); END`);
|
|
97
|
+
const mark = events.length;
|
|
98
|
+
assert.throws(() => observer.scanNow(root.rootId), /injected mid-batch failure/);
|
|
99
|
+
assert.equal(events.length, mark, 'no doorbell rang for truth that never landed');
|
|
100
|
+
assert.deepEqual(
|
|
101
|
+
new Map(observer.dirsForRoot(root.rootId).map((row) => [row.relPath, row.dirId])), before,
|
|
102
|
+
'a torn batch changes nothing: the previous truth is whole');
|
|
103
|
+
|
|
104
|
+
// The retry is an ordinary scan: the same batch, both original channels.
|
|
105
|
+
db.exec('DROP TRIGGER torn');
|
|
106
|
+
observer.scanNow(root.rootId);
|
|
107
|
+
const renamed = eventsOf(events.slice(mark), 'dir.renamed');
|
|
108
|
+
assert.deepEqual(renamed.map((event) => [event.from, event.to, event.dirId]).sort(),
|
|
109
|
+
[['north', 'south', before.get('north')], ['south', 'north', before.get('south')]].sort(),
|
|
110
|
+
'identity survives the crash: the retry crosses with the original channels');
|
|
111
|
+
assert.deepEqual(observer.verify(root.rootId), []);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('an observer store with some of its tables is loss, never a fresh start', () => {
|
|
115
|
+
const dir = makeDir('partial-store');
|
|
116
|
+
fs.mkdirSync(path.join(dir, 'alpha'));
|
|
117
|
+
fs.writeFileSync(path.join(dir, 'alpha', 'note.txt'), 'hello');
|
|
118
|
+
const db = new Database(':memory:');
|
|
119
|
+
const { observer } = makeObserver({ db });
|
|
120
|
+
observer.enrollRoot(dir);
|
|
121
|
+
|
|
122
|
+
// A complete schema reopens freely: the same database carries a second
|
|
123
|
+
// observer (a restart) without ceremony.
|
|
124
|
+
assert.doesNotThrow(() => createObserver({ db, emit: () => {} }),
|
|
125
|
+
'an established store with its whole schema is an ordinary reopen');
|
|
126
|
+
|
|
127
|
+
// One table gone while the others survive: CREATE IF NOT EXISTS would
|
|
128
|
+
// silently regrow it empty — every channel it held ended behind a root
|
|
129
|
+
// id that still checks out as the witness. The store convicts at open
|
|
130
|
+
// instead: an established store either has its complete schema or it
|
|
131
|
+
// is loss.
|
|
132
|
+
db.exec('DROP TABLE observer_dirs');
|
|
133
|
+
assert.throws(() => createObserver({ db, emit: () => {} }), /identity evidence lost/,
|
|
134
|
+
'a partial store is loss, never a fresh start');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('an unstamped pre-links store is an older schema generation, refused distinctly from loss', () => {
|
|
138
|
+
const db = new Database(':memory:');
|
|
139
|
+
db.exec(`CREATE TABLE observer_roots (root_id TEXT PRIMARY KEY, path TEXT NOT NULL UNIQUE, kind TEXT NOT NULL);
|
|
140
|
+
CREATE TABLE observer_files (file_id TEXT PRIMARY KEY);
|
|
141
|
+
CREATE TABLE observer_dirs (dir_id TEXT PRIMARY KEY);`);
|
|
142
|
+
assert.throws(() => createObserver({ db, emit: () => {} }), /schema generation 0/,
|
|
143
|
+
'a legitimate older schema is never confused with partial loss — migration is an explicit act');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('enrollment skips excluded directories and secret files', () => {
|
|
147
|
+
const dir = makeDir('exclude');
|
|
148
|
+
fs.writeFileSync(path.join(dir, 'keep.md'), 'keep');
|
|
149
|
+
fs.writeFileSync(path.join(dir, '.env'), 'SECRET=1');
|
|
150
|
+
fs.writeFileSync(path.join(dir, 'id_rsa'), 'private');
|
|
151
|
+
fs.mkdirSync(path.join(dir, 'node_modules'));
|
|
152
|
+
fs.writeFileSync(path.join(dir, 'node_modules', 'dep.js'), 'x');
|
|
153
|
+
|
|
154
|
+
const { observer, events } = makeObserver();
|
|
155
|
+
observer.enrollRoot(dir);
|
|
156
|
+
|
|
157
|
+
assert.deepEqual(eventsOf(events, 'file.created').map((event) => event.path), ['keep.md']);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('re-enrolling the same root (any spelling) is idempotent', () => {
|
|
161
|
+
const dir = makeDir('idempotent');
|
|
162
|
+
fs.writeFileSync(path.join(dir, 'a.md'), 'a');
|
|
163
|
+
|
|
164
|
+
const { observer, events } = makeObserver();
|
|
165
|
+
const first = observer.enrollRoot(dir);
|
|
166
|
+
const second = observer.enrollRoot(`${dir}${path.sep}`);
|
|
167
|
+
assert.equal(first.rootId, second.rootId);
|
|
168
|
+
assert.equal(eventsOf(events, 'file.created').length, 1);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// --- the compare function: content ----------------------------------------
|
|
172
|
+
|
|
173
|
+
test('an external edit is exactly one file.changed carrying old and new hash', () => {
|
|
174
|
+
const dir = makeDir('edit');
|
|
175
|
+
const file = path.join(dir, 'notes.md');
|
|
176
|
+
fs.writeFileSync(file, 'v1');
|
|
177
|
+
|
|
178
|
+
const { observer, events } = makeObserver();
|
|
179
|
+
observer.enrollRoot(dir);
|
|
180
|
+
const born = eventsOf(events, 'file.created')[0];
|
|
181
|
+
events.length = 0;
|
|
182
|
+
|
|
183
|
+
fs.writeFileSync(file, 'v2 with more bytes');
|
|
184
|
+
observer.scanNow();
|
|
185
|
+
|
|
186
|
+
assert.equal(events.length, 1);
|
|
187
|
+
const [changed] = events;
|
|
188
|
+
assert.equal(changed.type, 'file.changed');
|
|
189
|
+
assert.equal(changed.fileId, born.fileId);
|
|
190
|
+
assert.equal(changed.previousHash, born.hash);
|
|
191
|
+
assert.notEqual(changed.hash, born.hash);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('a burst of writes between scans collapses to one event with final content', () => {
|
|
195
|
+
const dir = makeDir('burst');
|
|
196
|
+
const file = path.join(dir, 'notes.md');
|
|
197
|
+
fs.writeFileSync(file, 'v1');
|
|
198
|
+
|
|
199
|
+
const { observer, events } = makeObserver();
|
|
200
|
+
observer.enrollRoot(dir);
|
|
201
|
+
events.length = 0;
|
|
202
|
+
|
|
203
|
+
fs.writeFileSync(file, 'v2');
|
|
204
|
+
fs.writeFileSync(file, 'v3');
|
|
205
|
+
fs.writeFileSync(file, 'v4-final');
|
|
206
|
+
observer.scanNow();
|
|
207
|
+
|
|
208
|
+
assert.equal(eventsOf(events, 'file.changed').length, 1);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('a touch that rewrites identical bytes is silence', () => {
|
|
212
|
+
const dir = makeDir('touch');
|
|
213
|
+
const file = path.join(dir, 'notes.md');
|
|
214
|
+
fs.writeFileSync(file, 'same');
|
|
215
|
+
|
|
216
|
+
const { observer, events } = makeObserver();
|
|
217
|
+
observer.enrollRoot(dir);
|
|
218
|
+
events.length = 0;
|
|
219
|
+
|
|
220
|
+
const future = new Date(Date.now() + 5_000);
|
|
221
|
+
fs.utimesSync(file, future, future); // mtime moves, bytes do not
|
|
222
|
+
observer.scanNow();
|
|
223
|
+
assert.equal(events.length, 0);
|
|
224
|
+
|
|
225
|
+
observer.scanNow(); // and the stat advance persisted: still silence
|
|
226
|
+
assert.equal(events.length, 0);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('an atomic save (write temp, rename over) keeps the channel id', () => {
|
|
230
|
+
const dir = makeDir('atomic');
|
|
231
|
+
const file = path.join(dir, 'notes.md');
|
|
232
|
+
fs.writeFileSync(file, 'v1');
|
|
233
|
+
|
|
234
|
+
const { observer, events } = makeObserver();
|
|
235
|
+
observer.enrollRoot(dir);
|
|
236
|
+
const born = eventsOf(events, 'file.created')[0];
|
|
237
|
+
events.length = 0;
|
|
238
|
+
|
|
239
|
+
// New inode lands at the same path — editors do this on every save.
|
|
240
|
+
const temp = path.join(dir, 'notes.md.tmp-manual');
|
|
241
|
+
fs.writeFileSync(temp, 'v2');
|
|
242
|
+
fs.renameSync(temp, file);
|
|
243
|
+
observer.scanNow();
|
|
244
|
+
|
|
245
|
+
const changed = eventsOf(events, 'file.changed');
|
|
246
|
+
assert.equal(changed.length, 1);
|
|
247
|
+
assert.equal(changed[0].fileId, born.fileId);
|
|
248
|
+
assert.equal(eventsOf(events, 'file.created').length, 0);
|
|
249
|
+
assert.equal(eventsOf(events, 'file.deleted').length, 0);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// --- the compare function: identity ----------------------------------------
|
|
253
|
+
|
|
254
|
+
test('a rename keeps the channel id: one file.renamed, no delete, no create', () => {
|
|
255
|
+
const dir = makeDir('rename');
|
|
256
|
+
fs.writeFileSync(path.join(dir, 'old.md'), 'content');
|
|
257
|
+
|
|
258
|
+
const { observer, events } = makeObserver();
|
|
259
|
+
observer.enrollRoot(dir);
|
|
260
|
+
const born = eventsOf(events, 'file.created')[0];
|
|
261
|
+
events.length = 0;
|
|
262
|
+
|
|
263
|
+
fs.renameSync(path.join(dir, 'old.md'), path.join(dir, 'new.md'));
|
|
264
|
+
observer.scanNow();
|
|
265
|
+
|
|
266
|
+
assert.deepEqual(events.map((event) => event.type), ['file.renamed']);
|
|
267
|
+
assert.equal(events[0].fileId, born.fileId);
|
|
268
|
+
assert.equal(events[0].from, 'old.md');
|
|
269
|
+
assert.equal(events[0].to, 'new.md');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test('a move across directories with a simultaneous edit is rename + changed on one channel', () => {
|
|
273
|
+
const dir = makeDir('move-edit');
|
|
274
|
+
fs.mkdirSync(path.join(dir, 'a'));
|
|
275
|
+
fs.mkdirSync(path.join(dir, 'b'));
|
|
276
|
+
fs.writeFileSync(path.join(dir, 'a', 'doc.md'), 'v1');
|
|
277
|
+
|
|
278
|
+
const { observer, events } = makeObserver();
|
|
279
|
+
observer.enrollRoot(dir);
|
|
280
|
+
const born = eventsOf(events, 'file.created')[0];
|
|
281
|
+
events.length = 0;
|
|
282
|
+
|
|
283
|
+
fs.renameSync(path.join(dir, 'a', 'doc.md'), path.join(dir, 'b', 'doc.md'));
|
|
284
|
+
fs.appendFileSync(path.join(dir, 'b', 'doc.md'), ' plus v2');
|
|
285
|
+
observer.scanNow();
|
|
286
|
+
|
|
287
|
+
assert.deepEqual(events.map((event) => event.type), ['file.renamed', 'file.changed']);
|
|
288
|
+
assert.ok(events.every((event) => event.fileId === born.fileId));
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test('deletion removes the channel: one file.deleted', () => {
|
|
292
|
+
const dir = makeDir('delete');
|
|
293
|
+
fs.writeFileSync(path.join(dir, 'gone.md'), 'bye');
|
|
294
|
+
|
|
295
|
+
const { observer, events } = makeObserver();
|
|
296
|
+
observer.enrollRoot(dir);
|
|
297
|
+
const born = eventsOf(events, 'file.created')[0];
|
|
298
|
+
events.length = 0;
|
|
299
|
+
|
|
300
|
+
fs.rmSync(path.join(dir, 'gone.md'));
|
|
301
|
+
observer.scanNow();
|
|
302
|
+
|
|
303
|
+
assert.deepEqual(events.map((event) => event.type), ['file.deleted']);
|
|
304
|
+
assert.equal(events[0].fileId, born.fileId);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('path claims the channel over inode: swap-in at an old path is a change, not a rename', () => {
|
|
308
|
+
const dir = makeDir('path-wins');
|
|
309
|
+
fs.writeFileSync(path.join(dir, 'a.md'), 'original a');
|
|
310
|
+
|
|
311
|
+
const { observer, events } = makeObserver();
|
|
312
|
+
observer.enrollRoot(dir);
|
|
313
|
+
const born = eventsOf(events, 'file.created')[0];
|
|
314
|
+
events.length = 0;
|
|
315
|
+
|
|
316
|
+
// a.md renamed away AND an unrelated file lands at a.md in the same window.
|
|
317
|
+
fs.renameSync(path.join(dir, 'a.md'), path.join(dir, 'archived.md'));
|
|
318
|
+
fs.writeFileSync(path.join(dir, 'a.md'), 'brand new occupant');
|
|
319
|
+
observer.scanNow();
|
|
320
|
+
|
|
321
|
+
// The path a.md is still occupied, so it keeps its channel (rule 1); the
|
|
322
|
+
// inode rescue never runs because the row was never orphaned. archived.md
|
|
323
|
+
// is a new channel. Deterministic, and no bytes are ever lost.
|
|
324
|
+
const renames = eventsOf(events, 'file.renamed');
|
|
325
|
+
assert.equal(renames.length, 0);
|
|
326
|
+
const changed = eventsOf(events, 'file.changed');
|
|
327
|
+
assert.equal(changed.length, 1);
|
|
328
|
+
assert.equal(changed[0].fileId, born.fileId);
|
|
329
|
+
assert.equal(changed[0].path, 'a.md');
|
|
330
|
+
const created = eventsOf(events, 'file.created');
|
|
331
|
+
assert.equal(created.length, 1);
|
|
332
|
+
assert.equal(created[0].path, 'archived.md');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test('diffRootState never guesses on ambiguous inode matches', () => {
|
|
336
|
+
const row = (fileId, relPath, inode) => ({
|
|
337
|
+
fileId, relPath, device: 1, inode, size: 1, mtimeMs: 1, contentHash: 'h',
|
|
338
|
+
});
|
|
339
|
+
const entry = (relPath, inode) => ({ relPath, device: 1, inode, size: 2, mtimeMs: 2 });
|
|
340
|
+
|
|
341
|
+
// Two orphan rows share an inode; one orphan entry matches it: no rescue.
|
|
342
|
+
const ambiguous = diffRootState(
|
|
343
|
+
[row('f1', 'a.md', 7), row('f2', 'b.md', 7)],
|
|
344
|
+
[entry('c.md', 7)],
|
|
345
|
+
);
|
|
346
|
+
assert.equal(ambiguous.renames.length, 0);
|
|
347
|
+
assert.equal(ambiguous.creates.length, 1);
|
|
348
|
+
assert.equal(ambiguous.deletes.length, 2);
|
|
349
|
+
|
|
350
|
+
// Exactly one on each side: rescued.
|
|
351
|
+
const clean = diffRootState([row('f1', 'a.md', 7)], [entry('c.md', 7)]);
|
|
352
|
+
assert.equal(clean.renames.length, 1);
|
|
353
|
+
assert.equal(clean.creates.length, 0);
|
|
354
|
+
assert.equal(clean.deletes.length, 0);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('stats alone never prove unchanged: a same-tick write is always re-checked', () => {
|
|
358
|
+
// The racy case: the file's mtime equals the instant we last read its
|
|
359
|
+
// bytes, so a same-size write may hide behind identical stats. The stat
|
|
360
|
+
// shortcut must refuse; only mtime strictly before the read is proof.
|
|
361
|
+
const row = {
|
|
362
|
+
fileId: 'f', relPath: 'a.md', device: 1, inode: 5,
|
|
363
|
+
size: 2, mtimeMs: 1000, contentHash: 'h', hashedAtMs: 1000,
|
|
364
|
+
};
|
|
365
|
+
const entry = { relPath: 'a.md', device: 1, inode: 5, size: 2, mtimeMs: 1000 };
|
|
366
|
+
|
|
367
|
+
const racy = diffRootState([row], [entry]);
|
|
368
|
+
assert.equal(racy.checks.length, 1);
|
|
369
|
+
assert.equal(racy.unchanged, 0);
|
|
370
|
+
|
|
371
|
+
const proven = diffRootState([{ ...row, hashedAtMs: 1001 }], [entry]);
|
|
372
|
+
assert.equal(proven.checks.length, 0);
|
|
373
|
+
assert.equal(proven.unchanged, 1);
|
|
374
|
+
|
|
375
|
+
// A row with no recorded read instant can prove nothing.
|
|
376
|
+
const unknown = diffRootState([{ ...row, hashedAtMs: null }], [entry]);
|
|
377
|
+
assert.equal(unknown.checks.length, 1);
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('a doorbell-named path overrules the stat shortcut', () => {
|
|
381
|
+
const row = {
|
|
382
|
+
fileId: 'f', relPath: 'a.md', device: 1, inode: 5,
|
|
383
|
+
size: 4, mtimeMs: 1000, contentHash: 'h', hashedAtMs: 2000,
|
|
384
|
+
};
|
|
385
|
+
const entry = { relPath: 'a.md', device: 1, inode: 5, size: 4, mtimeMs: 1000 };
|
|
386
|
+
|
|
387
|
+
// Quiet scan: stats identical, mtime provably before our read — skipped.
|
|
388
|
+
assert.equal(diffRootState([row], [entry]).unchanged, 1);
|
|
389
|
+
|
|
390
|
+
// The watcher named this exact path: hashed, no matter what stats claim.
|
|
391
|
+
const hinted = diffRootState([row], [entry], new Set(['a.md']));
|
|
392
|
+
assert.equal(hinted.checks.length, 1);
|
|
393
|
+
assert.equal(hinted.unchanged, 0);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('rapid same-size writes between scans are never missed', () => {
|
|
397
|
+
const dir = makeDir('racy-e2e');
|
|
398
|
+
const file = path.join(dir, 'notes.md');
|
|
399
|
+
fs.writeFileSync(file, 'v0');
|
|
400
|
+
|
|
401
|
+
const { observer, events } = makeObserver();
|
|
402
|
+
observer.enrollRoot(dir);
|
|
403
|
+
events.length = 0;
|
|
404
|
+
|
|
405
|
+
// Same byte count every write, scans issued as fast as possible — the
|
|
406
|
+
// window where stats cannot distinguish writes. Every version must land.
|
|
407
|
+
for (let i = 1; i <= 20; i += 1) {
|
|
408
|
+
fs.writeFileSync(file, `v${String(i).padStart(2, '0')}`);
|
|
409
|
+
observer.scanNow();
|
|
410
|
+
const changed = eventsOf(events, 'file.changed');
|
|
411
|
+
assert.equal(changed.length, i, `write ${i} was missed by detection`);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// --- echo suppression -------------------------------------------------------
|
|
416
|
+
|
|
417
|
+
test('noteWrite makes our own write invisible to the next scan', () => {
|
|
418
|
+
const dir = makeDir('echo');
|
|
419
|
+
const file = path.join(dir, 'notes.md');
|
|
420
|
+
fs.writeFileSync(file, 'v1');
|
|
421
|
+
|
|
422
|
+
const { observer, events } = makeObserver();
|
|
423
|
+
observer.enrollRoot(dir);
|
|
424
|
+
events.length = 0;
|
|
425
|
+
|
|
426
|
+
fs.writeFileSync(file, 'v2 written by the runtime itself');
|
|
427
|
+
observer.noteWrite(file);
|
|
428
|
+
observer.scanNow();
|
|
429
|
+
|
|
430
|
+
assert.equal(events.length, 0);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test('noteWrite on a brand-new file registers the channel without an event', () => {
|
|
434
|
+
const dir = makeDir('echo-create');
|
|
435
|
+
const { observer, events } = makeObserver();
|
|
436
|
+
observer.enrollRoot(dir);
|
|
437
|
+
events.length = 0;
|
|
438
|
+
|
|
439
|
+
const file = path.join(dir, 'made-by-us.md');
|
|
440
|
+
fs.writeFileSync(file, 'runtime artifact');
|
|
441
|
+
observer.noteWrite(file);
|
|
442
|
+
observer.scanNow();
|
|
443
|
+
|
|
444
|
+
assert.equal(events.length, 0);
|
|
445
|
+
const [root] = observer.listRoots();
|
|
446
|
+
assert.equal(observer.filesForRoot(root.rootId).length, 1);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// --- doorbells --------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
test('the doorbell detects an external edit without scanNow being called', async () => {
|
|
452
|
+
const dir = makeDir('doorbell');
|
|
453
|
+
const file = path.join(dir, 'notes.md');
|
|
454
|
+
fs.writeFileSync(file, 'v1');
|
|
455
|
+
|
|
456
|
+
const { observer, events } = makeObserver({ settleMs: 50 });
|
|
457
|
+
observer.enrollRoot(dir);
|
|
458
|
+
observer.start();
|
|
459
|
+
events.length = 0;
|
|
460
|
+
|
|
461
|
+
await nudgeUntil(
|
|
462
|
+
() => fs.writeFileSync(file, 'v2 from outside'),
|
|
463
|
+
() => eventsOf(events, 'file.changed').length > 0,
|
|
464
|
+
);
|
|
465
|
+
observer.stop();
|
|
466
|
+
|
|
467
|
+
assert.equal(eventsOf(events, 'file.changed').length, 1);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test('a same-size edit with a backdated mtime is still detected via the doorbell', async () => {
|
|
471
|
+
const dir = makeDir('backdate');
|
|
472
|
+
const file = path.join(dir, 'notes.md');
|
|
473
|
+
fs.writeFileSync(file, 'aaaa');
|
|
474
|
+
|
|
475
|
+
const { observer, events } = makeObserver({ settleMs: 50 });
|
|
476
|
+
observer.enrollRoot(dir);
|
|
477
|
+
observer.start();
|
|
478
|
+
events.length = 0;
|
|
479
|
+
|
|
480
|
+
// Same size, same inode, and the mtime set BACK to its old value: every
|
|
481
|
+
// stat looks untouched, so only the watcher's word says anything happened.
|
|
482
|
+
const before = fs.statSync(file);
|
|
483
|
+
await nudgeUntil(
|
|
484
|
+
() => {
|
|
485
|
+
fs.writeFileSync(file, 'bbbb');
|
|
486
|
+
fs.utimesSync(file, before.atime, before.mtime);
|
|
487
|
+
},
|
|
488
|
+
() => eventsOf(events, 'file.changed').length > 0,
|
|
489
|
+
);
|
|
490
|
+
observer.stop();
|
|
491
|
+
|
|
492
|
+
assert.equal(eventsOf(events, 'file.changed').length, 1);
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
test('a repo root forwards raw doorbells instantly with classified areas', async () => {
|
|
496
|
+
const dir = makeDir('repo');
|
|
497
|
+
fs.mkdirSync(path.join(dir, '.git'));
|
|
498
|
+
fs.writeFileSync(path.join(dir, '.git', 'HEAD'), 'ref: refs/heads/main');
|
|
499
|
+
fs.writeFileSync(path.join(dir, 'main.ts'), 'v1');
|
|
500
|
+
|
|
501
|
+
const { observer, events } = makeObserver({ settleMs: 50 });
|
|
502
|
+
observer.enrollRoot(dir, { kind: 'repo' });
|
|
503
|
+
observer.start();
|
|
504
|
+
// Birth is one discovery; start()'s catch-up rings the repo unconditionally
|
|
505
|
+
// (the observer cannot know what happened while nobody watched — the
|
|
506
|
+
// follower's ids turn "maybe" into silence).
|
|
507
|
+
assert.equal(eventsOf(events, 'repo.discovered').length, 1);
|
|
508
|
+
assert.equal(eventsOf(events, 'repo.changed').length, 1);
|
|
509
|
+
events.length = 0;
|
|
510
|
+
|
|
511
|
+
await nudgeUntil(
|
|
512
|
+
() => {
|
|
513
|
+
fs.writeFileSync(path.join(dir, 'main.ts'), 'v2');
|
|
514
|
+
fs.writeFileSync(path.join(dir, '.git', 'HEAD'), 'ref: refs/heads/other');
|
|
515
|
+
},
|
|
516
|
+
() => new Set(eventsOf(events, 'repo.changed').flatMap((event) => event.areas)).size === 2,
|
|
517
|
+
);
|
|
518
|
+
observer.stop();
|
|
519
|
+
|
|
520
|
+
const areas = new Set(eventsOf(events, 'repo.changed').flatMap((event) => event.areas));
|
|
521
|
+
assert.deepEqual([...areas].sort(), ['git', 'worktree']);
|
|
522
|
+
assert.equal(events.filter((event) => event.type.startsWith('file.')).length, 0);
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test('a repo born inside a live workspace is detected and claims its ground', async () => {
|
|
526
|
+
const dir = makeDir('live-birth');
|
|
527
|
+
fs.writeFileSync(path.join(dir, 'notes.md'), 'files territory');
|
|
528
|
+
|
|
529
|
+
const { observer, events } = makeObserver({ settleMs: 25 });
|
|
530
|
+
observer.enrollRoot(dir);
|
|
531
|
+
observer.start();
|
|
532
|
+
events.length = 0;
|
|
533
|
+
|
|
534
|
+
// The audited blind spot: a repo appears inside a watched workspace.
|
|
535
|
+
fs.mkdirSync(path.join(dir, 'proj'));
|
|
536
|
+
fs.writeFileSync(path.join(dir, 'proj', 'app.ts'), 'code');
|
|
537
|
+
fs.mkdirSync(path.join(dir, 'proj', '.git'));
|
|
538
|
+
await nudgeUntil(
|
|
539
|
+
() => fs.writeFileSync(path.join(dir, 'proj', '.git', 'HEAD'), 'ref: refs/heads/main'),
|
|
540
|
+
() => eventsOf(events, 'repo.discovered').length > 0,
|
|
541
|
+
);
|
|
542
|
+
const discovered = eventsOf(events, 'repo.discovered');
|
|
543
|
+
assert.equal(discovered.length, 1, 'the new repo was never discovered');
|
|
544
|
+
assert.equal(discovered[0].path, path.join(dir, 'proj'));
|
|
545
|
+
events.length = 0;
|
|
546
|
+
|
|
547
|
+
// Churn inside the new territory rings the repo doorbell, never files.
|
|
548
|
+
await nudgeUntil(
|
|
549
|
+
() => fs.writeFileSync(path.join(dir, 'proj', 'app.ts'), 'checkout churn'),
|
|
550
|
+
() => eventsOf(events, 'repo.changed').length > 0,
|
|
551
|
+
);
|
|
552
|
+
observer.stop();
|
|
553
|
+
|
|
554
|
+
assert.ok(eventsOf(events, 'repo.changed').length >= 1);
|
|
555
|
+
assert.ok(eventsOf(events, 'repo.changed').every((event) => event.rootId === discovered[0].rootId));
|
|
556
|
+
assert.equal(events.filter((event) => event.type.startsWith('file.')).length, 0);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// --- recovery ---------------------------------------------------------------
|
|
560
|
+
|
|
561
|
+
test('a dead watcher degrades honestly and the rescan still finds the change', () => {
|
|
562
|
+
const dir = makeDir('degraded');
|
|
563
|
+
fs.writeFileSync(path.join(dir, 'notes.md'), 'v1');
|
|
564
|
+
|
|
565
|
+
const { observer, events } = makeObserver();
|
|
566
|
+
observer.enrollRoot(dir);
|
|
567
|
+
observer.start();
|
|
568
|
+
events.length = 0;
|
|
569
|
+
|
|
570
|
+
// Simulate total watcher loss: stop() closes watchers; an explicit scan
|
|
571
|
+
// (any consumer-driven event) still finds the truth.
|
|
572
|
+
observer.stop();
|
|
573
|
+
fs.writeFileSync(path.join(dir, 'notes.md'), 'v2 missed by any doorbell');
|
|
574
|
+
observer.scanNow();
|
|
575
|
+
|
|
576
|
+
assert.equal(eventsOf(events, 'file.changed').length, 1);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
test('a fresh observer over the same store resumes channels instead of re-birthing them', () => {
|
|
580
|
+
const dir = makeDir('resume');
|
|
581
|
+
fs.writeFileSync(path.join(dir, 'notes.md'), 'v1');
|
|
582
|
+
|
|
583
|
+
const db = new Database(':memory:');
|
|
584
|
+
const firstEvents = [];
|
|
585
|
+
const first = createObserver({ db, emit: (event) => firstEvents.push(event) });
|
|
586
|
+
first.enrollRoot(dir);
|
|
587
|
+
const born = firstEvents.find((event) => event.type === 'file.created');
|
|
588
|
+
|
|
589
|
+
// New process, same db: the edit made "while we were dead" is one change
|
|
590
|
+
// on the same channel — not a delete + create, not a re-birth.
|
|
591
|
+
fs.writeFileSync(path.join(dir, 'notes.md'), 'v2 while offline');
|
|
592
|
+
const secondEvents = [];
|
|
593
|
+
const second = createObserver({ db, emit: (event) => secondEvents.push(event) });
|
|
594
|
+
second.enrollRoot(dir);
|
|
595
|
+
second.scanNow();
|
|
596
|
+
|
|
597
|
+
assert.deepEqual(secondEvents.map((event) => event.type), ['file.changed']);
|
|
598
|
+
assert.equal(secondEvents[0].fileId, born.fileId);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test('start() is the catch-up event: offline edits surface without any explicit scan', () => {
|
|
602
|
+
const dir = makeDir('catch-up');
|
|
603
|
+
const file = path.join(dir, 'notes.md');
|
|
604
|
+
fs.writeFileSync(file, 'v1');
|
|
605
|
+
|
|
606
|
+
const db = new Database(':memory:');
|
|
607
|
+
const firstEvents = [];
|
|
608
|
+
const first = createObserver({ db, emit: (event) => firstEvents.push(event) });
|
|
609
|
+
first.enrollRoot(dir);
|
|
610
|
+
const born = firstEvents.find((event) => event.type === 'file.created');
|
|
611
|
+
|
|
612
|
+
fs.writeFileSync(file, 'v2 while nobody was running');
|
|
613
|
+
const events = [];
|
|
614
|
+
const second = createObserver({ db, emit: (event) => events.push(event) });
|
|
615
|
+
second.start(); // no scanNow call: starting IS the event
|
|
616
|
+
second.stop();
|
|
617
|
+
|
|
618
|
+
assert.deepEqual(events.map((event) => event.type), ['file.changed']);
|
|
619
|
+
assert.equal(events[0].fileId, born.fileId);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test('watcher death rebuilds the doorbell and reconciles the gap once', () => {
|
|
623
|
+
const realWatch = fs.watch;
|
|
624
|
+
const fakes = [];
|
|
625
|
+
fs.watch = () => {
|
|
626
|
+
const fake = new EventEmitter();
|
|
627
|
+
fake.close = () => {};
|
|
628
|
+
fakes.push(fake);
|
|
629
|
+
return fake;
|
|
630
|
+
};
|
|
631
|
+
try {
|
|
632
|
+
const dir = makeDir('revive');
|
|
633
|
+
const file = path.join(dir, 'notes.md');
|
|
634
|
+
fs.writeFileSync(file, 'v1');
|
|
635
|
+
|
|
636
|
+
const { observer, events } = makeObserver();
|
|
637
|
+
observer.enrollRoot(dir);
|
|
638
|
+
observer.start();
|
|
639
|
+
assert.equal(fakes.length, 2, 'two doorbells per top-level root: content (recursive) and address (parent)');
|
|
640
|
+
events.length = 0;
|
|
641
|
+
|
|
642
|
+
// The change lands while the doorbell is broken. The death itself is
|
|
643
|
+
// the event: rebuild, then one reconciling scan finds the change.
|
|
644
|
+
fs.writeFileSync(file, 'v2 during the dead window');
|
|
645
|
+
fakes[0].emit('error', new Error('watcher lost'));
|
|
646
|
+
|
|
647
|
+
assert.deepEqual(events.map((event) => event.type), ['file.changed']);
|
|
648
|
+
assert.equal(fakes.length, 3); // a fresh content doorbell was installed
|
|
649
|
+
assert.equal(observer.status()[0].watching, true);
|
|
650
|
+
|
|
651
|
+
fakes[0].emit('error', new Error('echo of the same death'));
|
|
652
|
+
assert.equal(fakes.length, 3); // dead is dead: no rebuild storm
|
|
653
|
+
observer.stop();
|
|
654
|
+
} finally {
|
|
655
|
+
fs.watch = realWatch;
|
|
656
|
+
}
|
|
657
|
+
});
|