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.
Files changed (157) hide show
  1. package/README.md +12 -0
  2. package/lib/cli.js +146 -8
  3. package/lib/layout.js +89 -4
  4. package/lib/shared-realtime-tunnel.js +384 -43
  5. package/lib/supervisor.js +0 -3
  6. package/package.json +2 -2
  7. package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
  8. package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
  9. package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
  10. package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
  11. package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
  12. package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
  13. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
  14. package/runtime/scripts/amalgm-mcp/index.js +4 -0
  15. package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
  16. package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
  17. package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
  18. package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
  19. package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
  20. package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
  21. package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
  22. package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
  23. package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
  24. package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
  25. package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
  26. package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
  27. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
  28. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
  29. package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
  30. package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
  31. package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
  32. package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
  33. package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
  34. package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
  35. package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
  36. package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
  37. package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
  38. package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
  39. package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
  40. package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
  41. package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
  42. package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
  43. package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
  44. package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
  45. package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
  46. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
  47. package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
  48. package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
  49. package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
  50. package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
  51. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
  52. package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
  53. package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
  54. package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
  55. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
  56. package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
  57. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
  58. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
  59. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
  60. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
  61. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
  62. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
  63. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
  64. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
  65. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
  66. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
  67. package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
  68. package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
  69. package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
  70. package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
  71. package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
  72. package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
  73. package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
  74. package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
  75. package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
  76. package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
  77. package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
  78. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
  79. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
  80. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
  81. package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
  82. package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
  83. package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
  84. package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
  85. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
  86. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
  87. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
  88. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
  89. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
  90. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
  91. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
  92. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
  93. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
  94. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
  95. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
  96. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
  97. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
  98. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
  99. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
  100. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
  101. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
  102. package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
  103. package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
  104. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
  105. package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
  106. package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
  107. package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
  108. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
  109. package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
  110. package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
  111. package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
  112. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
  113. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
  114. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
  115. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
  116. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
  117. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
  118. package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
  119. package/runtime/scripts/local-gateway.js +1 -0
  120. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
  121. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
  122. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
  123. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
  124. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
  125. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
  126. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
  127. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
  128. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
  129. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
  130. package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
  131. package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
  132. package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
  133. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
  134. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  135. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  136. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
  137. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
  138. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
  139. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
  140. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
  141. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
  142. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
  143. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
  144. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
  145. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
  146. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
  147. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
  148. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
  149. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
  150. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
  151. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
  152. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
  153. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
  154. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
  155. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
  156. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
  157. 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 };