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,387 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The truth half of detection: walk a root, then compare what disk says
|
|
5
|
+
* against what the store remembers. `diffRootState` is the one compare
|
|
6
|
+
* function — doorbells and rescans both end here.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
const { lookFailed } = require('../adapters/truth');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Does a .git hold this ground? Three answers, like every look — and
|
|
16
|
+
* only present or absent may conclude: territory is never born,
|
|
17
|
+
* buried, or converted on a look that failed.
|
|
18
|
+
*/
|
|
19
|
+
function gitDirTruth(absDir) {
|
|
20
|
+
try {
|
|
21
|
+
fs.lstatSync(path.join(absDir, '.git'));
|
|
22
|
+
return 'present';
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return lookFailed(error).truth;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every enrollable entity under rootPath, as
|
|
30
|
+
* { files: [{ relPath, device, inode, size, mtimeMs }],
|
|
31
|
+
* dirs: [{ relPath, device, inode }],
|
|
32
|
+
* links: [{ relPath, device, inode, target }], repoDirs: [relDir],
|
|
33
|
+
* unsettled: [relPath] }.
|
|
34
|
+
* `unsettled` lists ground the walk could not read: an unlistable
|
|
35
|
+
* directory (its whole subtree is unknown, not empty) or an entry whose
|
|
36
|
+
* own read failed. Ground that vanished mid-walk is genuinely absent
|
|
37
|
+
* and is simply not listed; only a failed LOOK is unsettled — the walk
|
|
38
|
+
* reports it so the consumer concludes nothing there (only present or
|
|
39
|
+
* absent may conclude) and reads again.
|
|
40
|
+
* Directories are channels too — physical identity, no content of their
|
|
41
|
+
* own — listed at walk time so folder detection sees them exactly like
|
|
42
|
+
* files ('' is the root, never listed; a territory's dir belongs to
|
|
43
|
+
* repoDirs, never dirs). Symlinks are channels whose whole payload is the
|
|
44
|
+
* target string exactly as disk holds it (a symlink stores a written
|
|
45
|
+
* address, not the target's inode) — read here, NEVER followed: the real
|
|
46
|
+
* target is watched at its real enrolled location, and traversing the
|
|
47
|
+
* pointer would walk the same ground twice. `repoDirs` lists every
|
|
48
|
+
* subtree a .git claims ('' when the root itself does): repo territory,
|
|
49
|
+
* reported instead of walked.
|
|
50
|
+
*/
|
|
51
|
+
function walkRoot(rootPath, shouldEnroll, { allowRootRepository = false } = {}) {
|
|
52
|
+
const files = [];
|
|
53
|
+
const dirs = [];
|
|
54
|
+
const links = [];
|
|
55
|
+
const repoDirs = [];
|
|
56
|
+
const unsettled = [];
|
|
57
|
+
// A failed look concludes nothing: vanished mid-walk is honest absence
|
|
58
|
+
// (simply not listed), an unreadable look marks the ground unsettled.
|
|
59
|
+
const vanished = (error, relPath) => {
|
|
60
|
+
if (lookFailed(error).truth === 'absent') return true;
|
|
61
|
+
unsettled.push(relPath);
|
|
62
|
+
return false;
|
|
63
|
+
};
|
|
64
|
+
const pending = [''];
|
|
65
|
+
while (pending.length > 0) {
|
|
66
|
+
const relDir = pending.pop();
|
|
67
|
+
const absDir = relDir ? path.join(rootPath, relDir) : rootPath;
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
71
|
+
} catch (error) {
|
|
72
|
+
vanished(error, relDir);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// A .git claims its subtree: an embedded repository is repo territory,
|
|
76
|
+
// never files territory. Its changes travel as one card, not as files.
|
|
77
|
+
if (entries.some((entry) => entry.name === '.git') && !(allowRootRepository && relDir === '')) {
|
|
78
|
+
repoDirs.push(relDir);
|
|
79
|
+
if (!relDir) return { files: [], dirs: [], links: [], repoDirs, unsettled }; // the whole root is repo territory
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (relDir) {
|
|
83
|
+
try {
|
|
84
|
+
const stat = fs.lstatSync(absDir);
|
|
85
|
+
dirs.push({ relPath: relDir, device: stat.dev, inode: stat.ino });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
vanished(error, relDir);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const entry of entries) {
|
|
92
|
+
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
93
|
+
if (!shouldEnroll(relPath)) continue;
|
|
94
|
+
if (entry.isDirectory()) {
|
|
95
|
+
pending.push(relPath);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (entry.isSymbolicLink()) {
|
|
99
|
+
try {
|
|
100
|
+
const stat = fs.lstatSync(path.join(rootPath, relPath));
|
|
101
|
+
const target = fs.readlinkSync(path.join(rootPath, relPath));
|
|
102
|
+
links.push({ relPath, device: stat.dev, inode: stat.ino, target });
|
|
103
|
+
} catch (error) {
|
|
104
|
+
vanished(error, relPath);
|
|
105
|
+
}
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
if (!entry.isFile()) continue;
|
|
109
|
+
let stat;
|
|
110
|
+
try {
|
|
111
|
+
stat = fs.lstatSync(path.join(rootPath, relPath));
|
|
112
|
+
} catch (error) {
|
|
113
|
+
vanished(error, relPath);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
if (!stat.isFile()) continue;
|
|
117
|
+
files.push({
|
|
118
|
+
relPath,
|
|
119
|
+
device: stat.dev,
|
|
120
|
+
inode: stat.ino,
|
|
121
|
+
size: stat.size,
|
|
122
|
+
mtimeMs: Math.floor(stat.mtimeMs),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { files, dirs, links, repoDirs, unsettled };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function defaultShouldEnroll(relPath) {
|
|
130
|
+
return require('../workspace/tree/inclusion').shouldEnroll(relPath);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Every nested repository boundary under a repo root: directories beneath
|
|
135
|
+
* rootPath holding their own `.git` (directory or file — linked worktrees
|
|
136
|
+
* and initialized submodules count). Descent follows amalgm's OWN
|
|
137
|
+
* enrollment policy (`shouldEnroll`) — the exact rule walkRoot applies to
|
|
138
|
+
* files territory. Git's ignore rules never decide enrollment (entity
|
|
139
|
+
* axiom 2: external conventions may inform the manual policy, never decide
|
|
140
|
+
* it implicitly) — so a gitignored worktree is a territory and a repo in
|
|
141
|
+
* amalgm-excluded ground is not. The walk stops at each boundary — a
|
|
142
|
+
* child's children belong to the child's own census. Symlinks are never
|
|
143
|
+
* followed, matching walkRoot. The census speaks three states like every
|
|
144
|
+
* walk: `unsettled` names ground it could not list — an unreadable
|
|
145
|
+
* directory may hide a boundary, so its silence concludes nothing.
|
|
146
|
+
*/
|
|
147
|
+
function censusBoundaries(rootPath, shouldEnroll = defaultShouldEnroll) {
|
|
148
|
+
const boundaries = [];
|
|
149
|
+
const unsettled = [];
|
|
150
|
+
const pending = [''];
|
|
151
|
+
while (pending.length > 0) {
|
|
152
|
+
const relDir = pending.pop();
|
|
153
|
+
const absDir = relDir ? path.join(rootPath, relDir) : rootPath;
|
|
154
|
+
let entries;
|
|
155
|
+
try {
|
|
156
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
157
|
+
} catch (error) {
|
|
158
|
+
// Vanished mid-walk is a settled fact; a failed LOOK is not.
|
|
159
|
+
if (lookFailed(error).truth === 'unsettled') unsettled.push(relDir);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
if (relDir && entries.some((entry) => entry.name === '.git')) {
|
|
163
|
+
boundaries.push(relDir);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
if (!entry.isDirectory() || entry.name === '.git') continue;
|
|
168
|
+
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
169
|
+
if (!shouldEnroll(rel)) continue;
|
|
170
|
+
pending.push(rel);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { boundaries: boundaries.sort(), unsettled: unsettled.sort() };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Every symlink edge in a repo root's worktree: { relPath, target }. A
|
|
178
|
+
* repo's CHANGES travel as one Card + Checkpoint state — raw doorbells
|
|
179
|
+
* never publish child changes — but its registered child channels are
|
|
180
|
+
* refreshed after that state settles. This census remains an
|
|
181
|
+
* enrollment-policy input like any other root's, so it reads fresh on
|
|
182
|
+
* demand. Same walk law as censusBoundaries: descent follows
|
|
183
|
+
* amalgm's enrollment policy, stops at nested `.git` boundaries (a
|
|
184
|
+
* child's edges belong to the child's own census), never follows a
|
|
185
|
+
* pointer, and speaks three states — `unsettled` names ground it could
|
|
186
|
+
* not read, because an unlisted directory may hide an edge.
|
|
187
|
+
*/
|
|
188
|
+
function censusLinks(rootPath, shouldEnroll = defaultShouldEnroll) {
|
|
189
|
+
const links = [];
|
|
190
|
+
const unsettled = [];
|
|
191
|
+
const pending = [''];
|
|
192
|
+
while (pending.length > 0) {
|
|
193
|
+
const relDir = pending.pop();
|
|
194
|
+
const absDir = relDir ? path.join(rootPath, relDir) : rootPath;
|
|
195
|
+
let entries;
|
|
196
|
+
try {
|
|
197
|
+
entries = fs.readdirSync(absDir, { withFileTypes: true });
|
|
198
|
+
} catch (error) {
|
|
199
|
+
// Vanished mid-walk is a settled fact; a failed LOOK is not.
|
|
200
|
+
if (lookFailed(error).truth === 'unsettled') unsettled.push(relDir);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
if (relDir && entries.some((entry) => entry.name === '.git')) continue;
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
if (entry.name === '.git') continue;
|
|
206
|
+
const rel = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
207
|
+
if (!shouldEnroll(rel)) continue;
|
|
208
|
+
if (entry.isDirectory()) {
|
|
209
|
+
pending.push(rel);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (!entry.isSymbolicLink()) continue;
|
|
213
|
+
try {
|
|
214
|
+
links.push({ relPath: rel, target: fs.readlinkSync(path.join(rootPath, rel)) });
|
|
215
|
+
} catch (error) {
|
|
216
|
+
if (lookFailed(error).truth === 'unsettled') unsettled.push(rel);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
links: links.sort((a, b) => (a.relPath < b.relPath ? -1 : 1)),
|
|
222
|
+
unsettled: unsettled.sort(),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Physical continuity, shared by both compare functions: a row and an entry
|
|
228
|
+
* sharing one device:inode, exactly one match on both sides → the same
|
|
229
|
+
* physical thing. Anything else is never guessed. For files this rescues
|
|
230
|
+
* path-orphans (rule 2); for directories it IS rule 1.
|
|
231
|
+
*/
|
|
232
|
+
function matchByInode(rows, entries) {
|
|
233
|
+
const one = (map, key, value) => map.set(key, map.has(key) ? 'many' : value);
|
|
234
|
+
const rowsByInode = new Map();
|
|
235
|
+
for (const row of rows) one(rowsByInode, `${row.device}:${row.inode}`, row);
|
|
236
|
+
const entriesByInode = new Map();
|
|
237
|
+
for (const entry of entries) one(entriesByInode, `${entry.device}:${entry.inode}`, entry);
|
|
238
|
+
const matched = [];
|
|
239
|
+
for (const [key, entry] of entriesByInode) {
|
|
240
|
+
const row = rowsByInode.get(key);
|
|
241
|
+
if (entry === 'many' || !row || row === 'many') continue;
|
|
242
|
+
matched.push({ row, entry });
|
|
243
|
+
}
|
|
244
|
+
return matched;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The compare function for directory channels — the file identity rules in
|
|
249
|
+
* the OPPOSITE order, for the mirror reason. A file's inode is replaced in
|
|
250
|
+
* place on every atomic save, so for files the path must absorb that churn
|
|
251
|
+
* (path claims first). Nothing replaces a directory's inode in place: a
|
|
252
|
+
* known inode at a new address IS the directory, moved — even when its old
|
|
253
|
+
* address was reoccupied in the same interval (two directories exchanging
|
|
254
|
+
* names cross by inode, exactly like the files inside them). So:
|
|
255
|
+
* 1. Physical continuity claims the channel: unique device:inode, whole
|
|
256
|
+
* sets, same path = continued, new path = rename.
|
|
257
|
+
* 2. The address claims what no inode claims — recreation under an old
|
|
258
|
+
* name is the same channel wearing a never-seen inode (`retags`,
|
|
259
|
+
* silent).
|
|
260
|
+
* 3. Leftover entries are creates; leftover rows are deletes.
|
|
261
|
+
* No content: a directory's membership is derived state upstream, so there
|
|
262
|
+
* is nothing to hash or check.
|
|
263
|
+
*/
|
|
264
|
+
function diffDirState(rows, entries) {
|
|
265
|
+
const matched = matchByInode(rows, entries);
|
|
266
|
+
const matchedRowIds = new Set(matched.map((pair) => pair.row.dirId));
|
|
267
|
+
const matchedEntries = new Set(matched.map((pair) => pair.entry));
|
|
268
|
+
const rowsByPath = new Map(rows.filter((row) => !matchedRowIds.has(row.dirId))
|
|
269
|
+
.map((row) => [row.relPath, row]));
|
|
270
|
+
const retags = [];
|
|
271
|
+
const creates = [];
|
|
272
|
+
for (const entry of entries) {
|
|
273
|
+
if (matchedEntries.has(entry)) continue;
|
|
274
|
+
const row = rowsByPath.get(entry.relPath);
|
|
275
|
+
if (row) {
|
|
276
|
+
rowsByPath.delete(entry.relPath);
|
|
277
|
+
retags.push({ row, entry });
|
|
278
|
+
} else {
|
|
279
|
+
creates.push(entry);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
renames: matched.filter((pair) => pair.row.relPath !== pair.entry.relPath),
|
|
284
|
+
retags,
|
|
285
|
+
creates,
|
|
286
|
+
deletes: [...rowsByPath.values()],
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* The compare function for link channels — the FILE identity rules, for the
|
|
292
|
+
* file reason: a symlink cannot be retargeted in place (retarget is unlink +
|
|
293
|
+
* recreate, so the inode churns exactly like an atomic save), so the path
|
|
294
|
+
* absorbs that churn and claims first; the inode rescues path-orphans (`mv`
|
|
295
|
+
* preserves a symlink's inode). The payload is the target string, already
|
|
296
|
+
* read at walk time — as cheap as the lstat beside it, so there is no
|
|
297
|
+
* stat-excuse machinery and nothing deferred. A consequence worth naming:
|
|
298
|
+
* two links exchanging names read as two retargets (path-first), because a
|
|
299
|
+
* link's meaning IS its written address.
|
|
300
|
+
* 1. Path claims the channel: same target = continued (a new inode under
|
|
301
|
+
* an old name with an old target is silent recreation), different
|
|
302
|
+
* target = changed.
|
|
303
|
+
* 2. Inode rescues path-orphans: renames.
|
|
304
|
+
* 3. Leftover entries are creates; leftover rows are deletes.
|
|
305
|
+
*/
|
|
306
|
+
function diffLinkState(rows, entries) {
|
|
307
|
+
const changes = [];
|
|
308
|
+
const retags = [];
|
|
309
|
+
const rowsByPath = new Map(rows.map((row) => [row.relPath, row]));
|
|
310
|
+
const claimedRowIds = new Set();
|
|
311
|
+
const orphanEntries = [];
|
|
312
|
+
for (const entry of entries) {
|
|
313
|
+
const row = rowsByPath.get(entry.relPath);
|
|
314
|
+
if (!row) {
|
|
315
|
+
orphanEntries.push(entry);
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
claimedRowIds.add(row.linkId);
|
|
319
|
+
if (row.target !== entry.target) changes.push({ row, entry });
|
|
320
|
+
else if (row.device !== entry.device || row.inode !== entry.inode) retags.push({ row, entry });
|
|
321
|
+
}
|
|
322
|
+
const orphanRows = rows.filter((row) => !claimedRowIds.has(row.linkId));
|
|
323
|
+
const renames = matchByInode(orphanRows, orphanEntries);
|
|
324
|
+
const renamedRowIds = new Set(renames.map((pair) => pair.row.linkId));
|
|
325
|
+
const renamedEntries = new Set(renames.map((pair) => pair.entry));
|
|
326
|
+
return {
|
|
327
|
+
changes,
|
|
328
|
+
retags,
|
|
329
|
+
renames,
|
|
330
|
+
creates: orphanEntries.filter((entry) => !renamedEntries.has(entry)),
|
|
331
|
+
deletes: orphanRows.filter((row) => !renamedRowIds.has(row.linkId)),
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* The compare function. Pure: rows (store memory) + entries (disk truth) in,
|
|
337
|
+
* actions out. Hashing is deferred — `checks` name the files whose content
|
|
338
|
+
* must be read before an event may be claimed.
|
|
339
|
+
*
|
|
340
|
+
* Identity rules, in order:
|
|
341
|
+
* 1. Path claims the channel. Same rel_path = same file_id, even when the
|
|
342
|
+
* inode changed (atomic saves replace the inode on every write).
|
|
343
|
+
* 2. Inode rescues path-orphans (matchByInode above).
|
|
344
|
+
* 3. Leftover entries are creates; leftover rows are deletes.
|
|
345
|
+
*
|
|
346
|
+
* The single stat-shortcut this layer allows itself: a (size, mtime, inode)
|
|
347
|
+
* triple identical to the stored one is skipped as unchanged — but only when
|
|
348
|
+
* the mtime is strictly older than the moment we last read the bytes
|
|
349
|
+
* (git's "racily clean" rule), and only when nothing pointed at the file.
|
|
350
|
+
* A path named in `mustCheck` (the doorbell said so) is always hashed:
|
|
351
|
+
* timestamps can lie, a delivered watcher event cannot be un-delivered.
|
|
352
|
+
*/
|
|
353
|
+
function diffRootState(rows, entries, mustCheck = null) {
|
|
354
|
+
const actions = { checks: [], renames: [], creates: [], deletes: [], unchanged: 0 };
|
|
355
|
+
const rowsByPath = new Map(rows.map((row) => [row.relPath, row]));
|
|
356
|
+
const claimedRowIds = new Set();
|
|
357
|
+
const orphanEntries = [];
|
|
358
|
+
|
|
359
|
+
for (const entry of entries) {
|
|
360
|
+
const row = rowsByPath.get(entry.relPath);
|
|
361
|
+
if (!row) {
|
|
362
|
+
orphanEntries.push(entry);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
claimedRowIds.add(row.fileId);
|
|
366
|
+
const statsMatch = row.size === entry.size && row.mtimeMs === entry.mtimeMs && row.inode === entry.inode;
|
|
367
|
+
const provablyBeforeLastRead = typeof row.hashedAtMs === 'number' && entry.mtimeMs < row.hashedAtMs;
|
|
368
|
+
const named = mustCheck !== null && mustCheck.has(entry.relPath);
|
|
369
|
+
if (statsMatch && provablyBeforeLastRead && !named) {
|
|
370
|
+
actions.unchanged += 1;
|
|
371
|
+
} else {
|
|
372
|
+
actions.checks.push({ row, entry });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
const orphanRows = rows.filter((row) => !claimedRowIds.has(row.fileId));
|
|
377
|
+
actions.renames = matchByInode(orphanRows, orphanEntries);
|
|
378
|
+
const renamedRowIds = new Set(actions.renames.map((pair) => pair.row.fileId));
|
|
379
|
+
const renamedEntries = new Set(actions.renames.map((pair) => pair.entry));
|
|
380
|
+
|
|
381
|
+
actions.creates = orphanEntries.filter((entry) => !renamedEntries.has(entry));
|
|
382
|
+
actions.deletes = orphanRows.filter((row) => !renamedRowIds.has(row.fileId));
|
|
383
|
+
|
|
384
|
+
return actions;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
module.exports = { censusBoundaries, censusLinks, diffDirState, diffLinkState, diffRootState, gitDirTruth, walkRoot };
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The observer's whole memory: enrolled roots and the last-known disk state
|
|
5
|
+
* per channel — files with their content, directories with identity alone
|
|
6
|
+
* (a directory has no bytes of its own), links with the written address
|
|
7
|
+
* that IS their content. The database handle is injected — this module
|
|
8
|
+
* owns only its four tables and never opens a connection itself.
|
|
9
|
+
*
|
|
10
|
+
* This store IS the identity evidence: channels live and die with it.
|
|
11
|
+
* Losing it re-detects bytes harmlessly (equal hashes are silence), but it
|
|
12
|
+
* ends every channel — a fresh store is a new witness, wearing a new root
|
|
13
|
+
* id — and the evidence layer above pins the witness and convicts an
|
|
14
|
+
* unknown one loudly rather than let replacements mint. Identity evidence
|
|
15
|
+
* is durable, or its loss is explicit.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const SCHEMA = `
|
|
19
|
+
CREATE TABLE IF NOT EXISTS observer_roots (
|
|
20
|
+
root_id TEXT PRIMARY KEY,
|
|
21
|
+
path TEXT NOT NULL UNIQUE,
|
|
22
|
+
kind TEXT NOT NULL CHECK (kind IN ('files', 'repo', 'file')),
|
|
23
|
+
device INTEGER,
|
|
24
|
+
inode INTEGER,
|
|
25
|
+
present INTEGER NOT NULL DEFAULT 1
|
|
26
|
+
);
|
|
27
|
+
CREATE TABLE IF NOT EXISTS observer_files (
|
|
28
|
+
file_id TEXT PRIMARY KEY,
|
|
29
|
+
root_id TEXT NOT NULL REFERENCES observer_roots(root_id) ON DELETE CASCADE,
|
|
30
|
+
rel_path TEXT NOT NULL,
|
|
31
|
+
device INTEGER,
|
|
32
|
+
inode INTEGER,
|
|
33
|
+
size INTEGER,
|
|
34
|
+
mtime_ms INTEGER,
|
|
35
|
+
content_hash TEXT,
|
|
36
|
+
is_binary INTEGER,
|
|
37
|
+
hashed_at_ms INTEGER,
|
|
38
|
+
UNIQUE (root_id, rel_path)
|
|
39
|
+
);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS observer_files_inode
|
|
41
|
+
ON observer_files (root_id, device, inode);
|
|
42
|
+
CREATE TABLE IF NOT EXISTS observer_dirs (
|
|
43
|
+
dir_id TEXT PRIMARY KEY,
|
|
44
|
+
root_id TEXT NOT NULL REFERENCES observer_roots(root_id) ON DELETE CASCADE,
|
|
45
|
+
rel_path TEXT NOT NULL,
|
|
46
|
+
device INTEGER,
|
|
47
|
+
inode INTEGER,
|
|
48
|
+
UNIQUE (root_id, rel_path)
|
|
49
|
+
);
|
|
50
|
+
CREATE INDEX IF NOT EXISTS observer_dirs_inode
|
|
51
|
+
ON observer_dirs (root_id, device, inode);
|
|
52
|
+
CREATE TABLE IF NOT EXISTS observer_links (
|
|
53
|
+
link_id TEXT PRIMARY KEY,
|
|
54
|
+
root_id TEXT NOT NULL REFERENCES observer_roots(root_id) ON DELETE CASCADE,
|
|
55
|
+
rel_path TEXT NOT NULL,
|
|
56
|
+
device INTEGER,
|
|
57
|
+
inode INTEGER,
|
|
58
|
+
target TEXT NOT NULL,
|
|
59
|
+
UNIQUE (root_id, rel_path)
|
|
60
|
+
);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS observer_links_inode
|
|
62
|
+
ON observer_links (root_id, device, inode);
|
|
63
|
+
`;
|
|
64
|
+
|
|
65
|
+
function rowToFile(row) {
|
|
66
|
+
if (!row) return null;
|
|
67
|
+
return {
|
|
68
|
+
fileId: row.file_id,
|
|
69
|
+
rootId: row.root_id,
|
|
70
|
+
relPath: row.rel_path,
|
|
71
|
+
device: row.device,
|
|
72
|
+
inode: row.inode,
|
|
73
|
+
size: row.size,
|
|
74
|
+
mtimeMs: row.mtime_ms,
|
|
75
|
+
contentHash: row.content_hash,
|
|
76
|
+
binary: Boolean(row.is_binary),
|
|
77
|
+
hashedAtMs: row.hashed_at_ms,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const TABLES = ['observer_roots', 'observer_files', 'observer_dirs', 'observer_links'];
|
|
82
|
+
|
|
83
|
+
function rowToRoot(row) {
|
|
84
|
+
return {
|
|
85
|
+
rootId: row.root_id, path: row.path, kind: row.kind,
|
|
86
|
+
device: row.device, inode: row.inode, present: Boolean(row.present),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The schema generation this code speaks, stamped as PRAGMA user_version at
|
|
91
|
+
// creation so completeness is judged against a declared shape, never
|
|
92
|
+
// guessed. Generation 2 added observer_links; generation 3 added the
|
|
93
|
+
// 'file' root kind (a single registered file is a root of one channel);
|
|
94
|
+
// generation 4 added the root's own physical identity (device, inode) —
|
|
95
|
+
// a root has a permanent identity and a mutable local address, so a
|
|
96
|
+
// moved root is rescued by continuity exactly like the directories
|
|
97
|
+
// inside it; generation 5 added `present` — identity persists while
|
|
98
|
+
// local presence is separate evidence, so a root whose address is
|
|
99
|
+
// undecided reports unavailable instead of falling silent; generation 6
|
|
100
|
+
// added `is_binary` beside `content_hash` — a file's type is a claim
|
|
101
|
+
// about the bytes its hash fingerprints, so the two are produced by ONE
|
|
102
|
+
// read and stored together, never re-derived later from a second look.
|
|
103
|
+
// The pre-links three-table store never stamped a version and reads 0 —
|
|
104
|
+
// an older generation, refused distinctly from loss until an explicit
|
|
105
|
+
// migration exists.
|
|
106
|
+
const GENERATION = 6;
|
|
107
|
+
|
|
108
|
+
function createStore(db) {
|
|
109
|
+
// An established store either has its complete schema or it is loss:
|
|
110
|
+
// a missing table would be silently recreated empty, ending every
|
|
111
|
+
// channel it held while the surviving tables vouch for the store — so
|
|
112
|
+
// an older generation or some-but-not-all tables convicts before
|
|
113
|
+
// anything is created. Zero tables is a genuinely fresh store.
|
|
114
|
+
const present = db.prepare(
|
|
115
|
+
`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${TABLES.map(() => '?').join(', ')})`,
|
|
116
|
+
).all(...TABLES).map((row) => row.name);
|
|
117
|
+
if (present.length > 0) {
|
|
118
|
+
const generation = db.pragma('user_version', { simple: true });
|
|
119
|
+
if (generation !== GENERATION) {
|
|
120
|
+
throw new Error(`observer store is schema generation ${generation}, this code speaks ${GENERATION} — migration is an explicit act, never a silent table creation`);
|
|
121
|
+
}
|
|
122
|
+
if (present.length < TABLES.length) {
|
|
123
|
+
const missing = TABLES.filter((name) => !present.includes(name));
|
|
124
|
+
throw new Error(`identity evidence lost: ${missing.join(', ')} missing from an established observer store — a partial store is loss, never a fresh start`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
db.exec(SCHEMA);
|
|
128
|
+
db.pragma(`user_version = ${GENERATION}`);
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
addRoot({ rootId, path, kind, device = null, inode = null }) {
|
|
132
|
+
db.prepare('INSERT INTO observer_roots (root_id, path, kind, device, inode) VALUES (?, ?, ?, ?, ?)')
|
|
133
|
+
.run(rootId, path, kind, device, inode);
|
|
134
|
+
},
|
|
135
|
+
rootByPath(path) {
|
|
136
|
+
const row = db.prepare('SELECT * FROM observer_roots WHERE path = ?').get(path);
|
|
137
|
+
return row ? rowToRoot(row) : null;
|
|
138
|
+
},
|
|
139
|
+
listRoots() {
|
|
140
|
+
return db.prepare('SELECT * FROM observer_roots ORDER BY path').all().map(rowToRoot);
|
|
141
|
+
},
|
|
142
|
+
setRootKind(rootId, kind) {
|
|
143
|
+
db.prepare('UPDATE observer_roots SET kind = ? WHERE root_id = ?').run(kind, rootId);
|
|
144
|
+
},
|
|
145
|
+
/** Identity persists; local presence is separate evidence. */
|
|
146
|
+
setRootPresent(rootId, present) {
|
|
147
|
+
db.prepare('UPDATE observer_roots SET present = ? WHERE root_id = ?').run(present ? 1 : 0, rootId);
|
|
148
|
+
},
|
|
149
|
+
/**
|
|
150
|
+
* A root's identity is permanent; its address is not — and a family
|
|
151
|
+
* of roots (a tree with the nested territories inside it) changes
|
|
152
|
+
* address as ONE durable transition: every move lands or none does,
|
|
153
|
+
* so a failure mid-move can never tear a parent from its children.
|
|
154
|
+
* Device and inode are continuity itself and never churn on a rename.
|
|
155
|
+
*/
|
|
156
|
+
moveRoots(moves) {
|
|
157
|
+
const move = db.prepare('UPDATE observer_roots SET path = ? WHERE root_id = ?');
|
|
158
|
+
db.transaction(() => {
|
|
159
|
+
for (const { rootId, path } of moves) move.run(path, rootId);
|
|
160
|
+
})();
|
|
161
|
+
},
|
|
162
|
+
/** Adoption: an enrollment moment deliberately named ground wearing a
|
|
163
|
+
* new identity at a known address (see enrollRoot) — the one write
|
|
164
|
+
* where a root's own device:inode may change. */
|
|
165
|
+
setRootIdentity(rootId, device, inode) {
|
|
166
|
+
db.prepare('UPDATE observer_roots SET device = ?, inode = ? WHERE root_id = ?')
|
|
167
|
+
.run(device, inode, rootId);
|
|
168
|
+
},
|
|
169
|
+
removeRoot(rootId) {
|
|
170
|
+
db.prepare('DELETE FROM observer_files WHERE root_id = ?').run(rootId);
|
|
171
|
+
db.prepare('DELETE FROM observer_dirs WHERE root_id = ?').run(rootId);
|
|
172
|
+
db.prepare('DELETE FROM observer_links WHERE root_id = ?').run(rootId);
|
|
173
|
+
db.prepare('DELETE FROM observer_roots WHERE root_id = ?').run(rootId);
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
filesForRoot(rootId) {
|
|
177
|
+
return db.prepare('SELECT * FROM observer_files WHERE root_id = ? ORDER BY rel_path').all(rootId)
|
|
178
|
+
.map(rowToFile);
|
|
179
|
+
},
|
|
180
|
+
fileByPath(rootId, relPath) {
|
|
181
|
+
return rowToFile(
|
|
182
|
+
db.prepare('SELECT * FROM observer_files WHERE root_id = ? AND rel_path = ?').get(rootId, relPath),
|
|
183
|
+
);
|
|
184
|
+
},
|
|
185
|
+
insertFile(file) {
|
|
186
|
+
db.prepare(`
|
|
187
|
+
INSERT INTO observer_files (file_id, root_id, rel_path, device, inode, size, mtime_ms, content_hash, is_binary, hashed_at_ms)
|
|
188
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
189
|
+
`).run(
|
|
190
|
+
file.fileId, file.rootId, file.relPath,
|
|
191
|
+
file.device, file.inode, file.size, file.mtimeMs, file.contentHash, file.binary ? 1 : 0, file.hashedAtMs,
|
|
192
|
+
);
|
|
193
|
+
},
|
|
194
|
+
updateFile(fileId, { relPath, device, inode, size, mtimeMs, contentHash, binary, hashedAtMs }) {
|
|
195
|
+
db.prepare(`
|
|
196
|
+
UPDATE observer_files
|
|
197
|
+
SET rel_path = ?, device = ?, inode = ?, size = ?, mtime_ms = ?, content_hash = ?, is_binary = ?, hashed_at_ms = ?
|
|
198
|
+
WHERE file_id = ?
|
|
199
|
+
`).run(relPath, device, inode, size, mtimeMs, contentHash, binary ? 1 : 0, hashedAtMs, fileId);
|
|
200
|
+
},
|
|
201
|
+
removeFile(fileId) {
|
|
202
|
+
db.prepare('DELETE FROM observer_files WHERE file_id = ?').run(fileId);
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
dirsForRoot(rootId) {
|
|
206
|
+
return db.prepare('SELECT * FROM observer_dirs WHERE root_id = ? ORDER BY rel_path').all(rootId)
|
|
207
|
+
.map((row) => ({
|
|
208
|
+
dirId: row.dir_id, rootId: row.root_id, relPath: row.rel_path,
|
|
209
|
+
device: row.device, inode: row.inode,
|
|
210
|
+
}));
|
|
211
|
+
},
|
|
212
|
+
/**
|
|
213
|
+
* One compare result is ONE truth transition. Movers may exchange
|
|
214
|
+
* addresses and land on freshly-deleted ones (directories cross by
|
|
215
|
+
* inode), so uniqueness holds only at the batch boundary: deletes free
|
|
216
|
+
* their slots and every mover vacates before any lands — all inside a
|
|
217
|
+
* single transaction. Committed state is always some truth disk
|
|
218
|
+
* actually had; a crash mid-batch leaves the previous truth whole, and
|
|
219
|
+
* the next scan re-detects the same batch.
|
|
220
|
+
*/
|
|
221
|
+
applyDirDiff({ deletes, moves, retags, creates }) {
|
|
222
|
+
const remove = db.prepare('DELETE FROM observer_dirs WHERE dir_id = ?');
|
|
223
|
+
const insert = db.prepare('INSERT INTO observer_dirs (dir_id, root_id, rel_path, device, inode) VALUES (?, ?, ?, ?, ?)');
|
|
224
|
+
const retag = db.prepare('UPDATE observer_dirs SET rel_path = ?, device = ?, inode = ? WHERE dir_id = ?');
|
|
225
|
+
db.transaction(() => {
|
|
226
|
+
for (const dirId of deletes) remove.run(dirId);
|
|
227
|
+
for (const dir of moves) remove.run(dir.dirId);
|
|
228
|
+
for (const dir of moves) insert.run(dir.dirId, dir.rootId, dir.relPath, dir.device, dir.inode);
|
|
229
|
+
for (const dir of retags) retag.run(dir.relPath, dir.device, dir.inode, dir.dirId);
|
|
230
|
+
for (const dir of creates) insert.run(dir.dirId, dir.rootId, dir.relPath, dir.device, dir.inode);
|
|
231
|
+
})();
|
|
232
|
+
},
|
|
233
|
+
removeDir(dirId) {
|
|
234
|
+
db.prepare('DELETE FROM observer_dirs WHERE dir_id = ?').run(dirId);
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
// Link channels: a pointer's whole payload is its target string, read
|
|
238
|
+
// fresh every scan. Renames never cross (the address claims first, so
|
|
239
|
+
// a rename only ever targets a row-free slot), so link rows commit
|
|
240
|
+
// per-row like files — every commit is a valid last-known truth.
|
|
241
|
+
linksForRoot(rootId) {
|
|
242
|
+
return db.prepare('SELECT * FROM observer_links WHERE root_id = ? ORDER BY rel_path').all(rootId)
|
|
243
|
+
.map((row) => ({
|
|
244
|
+
linkId: row.link_id, rootId: row.root_id, relPath: row.rel_path,
|
|
245
|
+
device: row.device, inode: row.inode, target: row.target,
|
|
246
|
+
}));
|
|
247
|
+
},
|
|
248
|
+
insertLink({ linkId, rootId, relPath, device, inode, target }) {
|
|
249
|
+
db.prepare('INSERT INTO observer_links (link_id, root_id, rel_path, device, inode, target) VALUES (?, ?, ?, ?, ?, ?)')
|
|
250
|
+
.run(linkId, rootId, relPath, device, inode, target);
|
|
251
|
+
},
|
|
252
|
+
updateLink(linkId, { relPath, device, inode, target }) {
|
|
253
|
+
db.prepare('UPDATE observer_links SET rel_path = ?, device = ?, inode = ?, target = ? WHERE link_id = ?')
|
|
254
|
+
.run(relPath, device, inode, target, linkId);
|
|
255
|
+
},
|
|
256
|
+
removeLink(linkId) {
|
|
257
|
+
db.prepare('DELETE FROM observer_links WHERE link_id = ?').run(linkId);
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
module.exports = { createStore };
|