amalgm 0.1.261 → 0.1.263
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/lib/cli.js +9 -3
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/lib/prepared.js +27 -0
- package/runtime/scripts/amalgm-mcp/observer/README.md +4 -1
- package/runtime/scripts/amalgm-mcp/observer/coverage.js +52 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +186 -44
- package/runtime/scripts/amalgm-mcp/observer/store.js +41 -27
- package/runtime/scripts/amalgm-mcp/observer/watch.js +6 -2
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +54 -19
- package/runtime/scripts/amalgm-mcp/registration/index.js +58 -12
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +43 -4
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +15 -5
- package/runtime/scripts/amalgm-mcp/registration/service.js +121 -16
- package/runtime/scripts/amalgm-mcp/registration/tree.js +104 -20
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +34 -13
- package/runtime/scripts/amalgm-mcp/registry/store.js +30 -26
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +45 -3
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +65 -10
- package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +90 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +236 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.coverage.test.js +197 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +30 -3
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +10 -6
- package/runtime/scripts/amalgm-mcp/tests/repocard.scope.test.js +182 -0
|
@@ -26,6 +26,8 @@
|
|
|
26
26
|
* (address), never by name, so two trees may share a basename.
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
+
const { preparer } = require('../lib/prepared');
|
|
30
|
+
|
|
29
31
|
const SCHEMA = `
|
|
30
32
|
CREATE TABLE IF NOT EXISTS registry_meta (
|
|
31
33
|
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
|
@@ -246,56 +248,58 @@ function createStore(db) {
|
|
|
246
248
|
db.exec(SCHEMA);
|
|
247
249
|
db.pragma(`user_version = ${GENERATION}`);
|
|
248
250
|
|
|
251
|
+
const prepared = preparer(db);
|
|
252
|
+
|
|
249
253
|
return {
|
|
250
254
|
meta() {
|
|
251
|
-
const row =
|
|
255
|
+
const row = prepared('SELECT * FROM registry_meta WHERE singleton = 1').get();
|
|
252
256
|
return row ? { epochUuid: row.epoch_uuid, createdAtMs: row.created_at_ms } : null;
|
|
253
257
|
},
|
|
254
258
|
initMeta({ epochUuid, createdAtMs }) {
|
|
255
|
-
|
|
259
|
+
prepared('INSERT INTO registry_meta (singleton, epoch_uuid, created_at_ms) VALUES (1, ?, ?)')
|
|
256
260
|
.run(epochUuid, createdAtMs);
|
|
257
261
|
},
|
|
258
262
|
|
|
259
263
|
byUuid(uuid) {
|
|
260
|
-
return rowToEntity(
|
|
264
|
+
return rowToEntity(prepared('SELECT * FROM entities WHERE uuid = ?').get(uuid));
|
|
261
265
|
},
|
|
262
266
|
/** Every registered tree's top record: the active parentless rows. */
|
|
263
267
|
activeRoots() {
|
|
264
|
-
return
|
|
268
|
+
return prepared("SELECT * FROM entities WHERE parent_uuid IS NULL AND status = 'active' ORDER BY uuid")
|
|
265
269
|
.all().map(rowToEntity);
|
|
266
270
|
},
|
|
267
271
|
activeAtSlot(parentUuid, name) {
|
|
268
272
|
return rowToEntity(
|
|
269
|
-
|
|
273
|
+
prepared("SELECT * FROM entities WHERE parent_uuid IS ? AND name = ? AND status = 'active'")
|
|
270
274
|
.get(parentUuid, name),
|
|
271
275
|
);
|
|
272
276
|
},
|
|
273
277
|
childrenOf(parentUuid) {
|
|
274
|
-
return
|
|
278
|
+
return prepared('SELECT * FROM entities WHERE parent_uuid = ? ORDER BY uuid').all(parentUuid)
|
|
275
279
|
.map(rowToEntity);
|
|
276
280
|
},
|
|
277
281
|
activeChildrenOf(parentUuid) {
|
|
278
|
-
return
|
|
282
|
+
return prepared("SELECT * FROM entities WHERE parent_uuid = ? AND status = 'active' ORDER BY uuid")
|
|
279
283
|
.all(parentUuid).map(rowToEntity);
|
|
280
284
|
},
|
|
281
285
|
activeByType(type) {
|
|
282
|
-
return
|
|
286
|
+
return prepared("SELECT * FROM entities WHERE type = ? AND status = 'active' ORDER BY uuid")
|
|
283
287
|
.all(type).map(rowToEntity);
|
|
284
288
|
},
|
|
285
289
|
/** Logical graph rows for the cloud registry. This deliberately excludes
|
|
286
290
|
* observer bindings and every machine-local table. */
|
|
287
291
|
allEntities() {
|
|
288
|
-
return
|
|
292
|
+
return prepared('SELECT * FROM entities ORDER BY uuid').all().map(rowToEntity);
|
|
289
293
|
},
|
|
290
294
|
|
|
291
295
|
insert(entity) {
|
|
292
|
-
|
|
296
|
+
prepared(`
|
|
293
297
|
INSERT INTO entities (uuid, type, parent_uuid, name, status, payload_version)
|
|
294
298
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
295
299
|
`).run(entity.uuid, entity.type, entity.parentUuid, entity.name, entity.status, entity.payloadVersion);
|
|
296
300
|
},
|
|
297
301
|
update(uuid, { type, parentUuid, name, status, payloadVersion }) {
|
|
298
|
-
|
|
302
|
+
prepared(`
|
|
299
303
|
UPDATE entities SET type = ?, parent_uuid = ?, name = ?, status = ?, payload_version = ?
|
|
300
304
|
WHERE uuid = ?
|
|
301
305
|
`).run(type, parentUuid, name, status, payloadVersion, uuid);
|
|
@@ -313,17 +317,17 @@ function createStore(db) {
|
|
|
313
317
|
*/
|
|
314
318
|
rebindIdentities(mappings) {
|
|
315
319
|
const retired = mappings.filter((mapping) => mapping.oldUuid !== mapping.newUuid);
|
|
316
|
-
const retire =
|
|
317
|
-
const insert =
|
|
320
|
+
const retire = prepared("UPDATE entities SET status = 'trashed' WHERE uuid = ?");
|
|
321
|
+
const insert = prepared(`
|
|
318
322
|
INSERT INTO entities (uuid, type, parent_uuid, name, status, payload_version)
|
|
319
323
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
320
324
|
`);
|
|
321
|
-
const reparent =
|
|
322
|
-
const rebindChannels =
|
|
323
|
-
const rebindClaims =
|
|
324
|
-
const rebindRepoState =
|
|
325
|
-
const rebindReference =
|
|
326
|
-
const remove =
|
|
325
|
+
const reparent = prepared('UPDATE entities SET parent_uuid = ? WHERE parent_uuid = ?');
|
|
326
|
+
const rebindChannels = prepared('UPDATE channel_bindings SET entity_uuid = ? WHERE entity_uuid = ?');
|
|
327
|
+
const rebindClaims = prepared('UPDATE contested_ground SET claimant_uuid = ? WHERE claimant_uuid = ?');
|
|
328
|
+
const rebindRepoState = prepared('UPDATE repo_states SET entity_uuid = ? WHERE entity_uuid = ?');
|
|
329
|
+
const rebindReference = prepared("UPDATE entities SET payload_version = ? WHERE type = 'reference' AND payload_version = ?");
|
|
330
|
+
const remove = prepared('DELETE FROM entities WHERE uuid = ?');
|
|
327
331
|
|
|
328
332
|
db.transaction(() => {
|
|
329
333
|
for (const mapping of retired) retire.run(mapping.oldUuid);
|
|
@@ -365,10 +369,10 @@ function createStore(db) {
|
|
|
365
369
|
* for an exchange. One transaction: the in-between never exists.
|
|
366
370
|
*/
|
|
367
371
|
applyMoves(moves) {
|
|
368
|
-
const toTemp =
|
|
372
|
+
const toTemp = prepared('UPDATE entities SET name = ? WHERE uuid = ?');
|
|
369
373
|
// An optional type rides the same transition (adoption lands a
|
|
370
374
|
// workspace as a folder) — a separate write could tear.
|
|
371
|
-
const toFinal =
|
|
375
|
+
const toFinal = prepared('UPDATE entities SET parent_uuid = ?, name = ?, type = COALESCE(?, type) WHERE uuid = ?');
|
|
372
376
|
db.transaction(() => {
|
|
373
377
|
for (const move of moves) toTemp.run(`\0${move.uuid}`, move.uuid);
|
|
374
378
|
for (const move of moves) toFinal.run(move.parentUuid, move.name, move.type ?? null, move.uuid);
|
|
@@ -376,7 +380,7 @@ function createStore(db) {
|
|
|
376
380
|
},
|
|
377
381
|
|
|
378
382
|
cloudReplica() {
|
|
379
|
-
const row =
|
|
383
|
+
const row = prepared('SELECT * FROM entity_cloud_replica WHERE singleton = 1').get();
|
|
380
384
|
if (!row) return null;
|
|
381
385
|
return {
|
|
382
386
|
resourceId: row.resource_id,
|
|
@@ -389,7 +393,7 @@ function createStore(db) {
|
|
|
389
393
|
};
|
|
390
394
|
},
|
|
391
395
|
saveCloudReplica(replica) {
|
|
392
|
-
|
|
396
|
+
prepared(`
|
|
393
397
|
INSERT INTO entity_cloud_replica (
|
|
394
398
|
singleton, resource_id, device_id, authority_epoch, head_version,
|
|
395
399
|
state, snapshot_cutoff, updated_at
|
|
@@ -413,11 +417,11 @@ function createStore(db) {
|
|
|
413
417
|
);
|
|
414
418
|
},
|
|
415
419
|
cloudMutationCutoff() {
|
|
416
|
-
const row =
|
|
420
|
+
const row = prepared('SELECT COALESCE(MAX(sequence), 0) AS sequence FROM entity_cloud_mutations').get();
|
|
417
421
|
return Number(row.sequence);
|
|
418
422
|
},
|
|
419
423
|
enqueueCloudMutation(mutation) {
|
|
420
|
-
|
|
424
|
+
prepared(`
|
|
421
425
|
INSERT INTO entity_cloud_mutations (
|
|
422
426
|
resource_id, mutation_id, operation_json, mutation_state,
|
|
423
427
|
created_at, updated_at
|
|
@@ -431,7 +435,7 @@ function createStore(db) {
|
|
|
431
435
|
);
|
|
432
436
|
},
|
|
433
437
|
listCloudMutations() {
|
|
434
|
-
return
|
|
438
|
+
return prepared('SELECT * FROM entity_cloud_mutations ORDER BY sequence').all();
|
|
435
439
|
},
|
|
436
440
|
};
|
|
437
441
|
}
|
|
@@ -62,13 +62,50 @@ function remoteList(repoPath) {
|
|
|
62
62
|
}).sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
/** Is `rel` named by the suspect list — itself, or under a named directory? */
|
|
66
|
+
function makeSuspectTest(suspects) {
|
|
67
|
+
const named = new Set(suspects);
|
|
68
|
+
return (rel) => {
|
|
69
|
+
if (named.has(rel)) return true;
|
|
70
|
+
let prefix = rel;
|
|
71
|
+
for (;;) {
|
|
72
|
+
const cut = prefix.lastIndexOf('/');
|
|
73
|
+
if (cut < 0) return false;
|
|
74
|
+
prefix = prefix.slice(0, cut);
|
|
75
|
+
if (named.has(prefix)) return true;
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The warm half of the checkpoint. Git keeps the Card's ledger itself and
|
|
82
|
+
* its index makes the tracked diffs cheap — the one cost with no cache was
|
|
83
|
+
* untracked files, re-read in full on every capture. With `suspects` (the
|
|
84
|
+
* doorbell's named paths since the last settled state) and the previous
|
|
85
|
+
* state's untracked entries, an unsuspected file's (mode, sha256) is
|
|
86
|
+
* REUSED without opening it: silence from the doorbell is the proof the
|
|
87
|
+
* written answer is still true. Everything else stays a fresh read — a
|
|
88
|
+
* suspect, a new path, or any capture without both inputs (suspects null
|
|
89
|
+
* means "everything is suspect": first capture, flush, catch-up, a command
|
|
90
|
+
* ending). Reused entries carry `content: null`; ids derive from
|
|
91
|
+
* (path, mode, sha256) alone, so a scoped capture's ids are byte-identical
|
|
92
|
+
* to a from-scratch one over the same ground.
|
|
93
|
+
*/
|
|
94
|
+
function untrackedFiles(repoPath, { suspects = null, previousUntracked = null } = {}) {
|
|
95
|
+
const reusable = suspects !== null && previousUntracked
|
|
96
|
+
? new Map(previousUntracked.map((file) => [file.path, file]))
|
|
97
|
+
: null;
|
|
98
|
+
const suspect = reusable ? makeSuspectTest(suspects) : null;
|
|
66
99
|
const out = runGit(repoPath, ['ls-files', '--others', '--exclude-standard', '-z']).toString('utf8');
|
|
67
100
|
return out.split('\0').filter(Boolean).sort().flatMap((rel) => {
|
|
68
101
|
// A trailing slash is git marking a repo boundary it will not enter — a
|
|
69
102
|
// nested repository or linked worktree. That is its own territory with
|
|
70
103
|
// its own card; the parent's checkpoint must not contain it.
|
|
71
104
|
if (rel.endsWith('/')) return [];
|
|
105
|
+
if (reusable && !suspect(rel)) {
|
|
106
|
+
const prev = reusable.get(rel);
|
|
107
|
+
if (prev) return [{ path: rel, mode: prev.mode, content: null, sha256: prev.sha256, reused: true }];
|
|
108
|
+
}
|
|
72
109
|
const abs = path.join(repoPath, rel);
|
|
73
110
|
try {
|
|
74
111
|
const stat = fs.lstatSync(abs);
|
|
@@ -123,9 +160,14 @@ function deriveCheckpointId(checkpoint) {
|
|
|
123
160
|
}));
|
|
124
161
|
}
|
|
125
162
|
|
|
126
|
-
function capture(repoPath, { withBundle = true } = {}) {
|
|
163
|
+
function capture(repoPath, { withBundle = true, suspects = null, previousUntracked = null } = {}) {
|
|
127
164
|
const busy = busyReason(repoPath);
|
|
128
165
|
if (busy) return { busy };
|
|
166
|
+
// Untracked reuse is bundle-free (local) captures only: cloud cargo
|
|
167
|
+
// (prepareRepo) consumes untracked CONTENT, and a reused entry carries
|
|
168
|
+
// none. Teaching the cargo path to dedupe by sha256 is the named next
|
|
169
|
+
// step; until then a withBundle capture always reads every byte.
|
|
170
|
+
const scoped = withBundle ? {} : { suspects, previousUntracked };
|
|
129
171
|
|
|
130
172
|
const branch = tryGitText(repoPath, ['symbolic-ref', '-q', '--short', 'HEAD']); // null = detached
|
|
131
173
|
const commit = tryGitText(repoPath, ['rev-parse', '-q', '--verify', 'HEAD']); // null = unborn
|
|
@@ -146,7 +188,7 @@ function capture(repoPath, { withBundle = true } = {}) {
|
|
|
146
188
|
// raw non-UTF-8 bytes, and a string conversion would corrupt it silently.
|
|
147
189
|
stagedPatch: runGit(repoPath, ['diff', '--cached', '--binary', '--full-index', commit ? 'HEAD' : EMPTY_TREE]),
|
|
148
190
|
unstagedPatch: runGit(repoPath, ['diff', '--binary', '--full-index']),
|
|
149
|
-
untracked: untrackedFiles(repoPath),
|
|
191
|
+
untracked: untrackedFiles(repoPath, scoped),
|
|
150
192
|
};
|
|
151
193
|
|
|
152
194
|
const cardId = deriveCardId(card);
|
|
@@ -95,12 +95,24 @@ function createFollower(options) {
|
|
|
95
95
|
last = null, // resume seed: { cardId, checkpointId }
|
|
96
96
|
// May return a promise (e.g. a capture worker): the follower awaits it,
|
|
97
97
|
// and any doorbell that lands mid-pair re-arms a retry (`pendingWake`).
|
|
98
|
-
|
|
98
|
+
// Called with { suspects }: the worktree paths rung since the last
|
|
99
|
+
// settled state, or null when everything is suspect.
|
|
100
|
+
captureFn = (repo, { suspects } = {}) => capture(repo, { withBundle: false, suspects }),
|
|
99
101
|
} = options;
|
|
100
102
|
if (!repoPath) throw new Error('createFollower requires { repoPath }');
|
|
101
103
|
if (typeof emit !== 'function') throw new Error('createFollower requires { emit }');
|
|
102
104
|
|
|
103
105
|
let known = last ? { cardId: last.cardId, checkpointId: last.checkpointId } : null;
|
|
106
|
+
// The warm-checkpoint scope: which worktree paths the doorbell has named
|
|
107
|
+
// since the last settled state. Each entry remembers the GENERATION of
|
|
108
|
+
// the ring that named it, so a path rung again mid-pair keeps its
|
|
109
|
+
// suspicion when the pair's success consumes the older hints. A nameless
|
|
110
|
+
// ring — or any entrance that may follow missed doorbells (birth, flush,
|
|
111
|
+
// a command ending) — makes EVERYTHING suspect until a pair settles.
|
|
112
|
+
const SCOPE_CAP = 512;
|
|
113
|
+
const scope = new Map(); // repo-relative path -> generation
|
|
114
|
+
let scopeGen = 0;
|
|
115
|
+
let scopeAllGen = 0; // >0: everything is suspect (set at birth, flush, command end, nameless ring)
|
|
104
116
|
let owned = 0; // depth of amalgm-owned commands in flight
|
|
105
117
|
let blocked = null; // lock name while the last capture attempt met a held lock
|
|
106
118
|
let blockedPath = null;
|
|
@@ -210,12 +222,15 @@ function createFollower(options) {
|
|
|
210
222
|
|
|
211
223
|
// The torn-read guard: two consecutive reads must agree before a state
|
|
212
224
|
// may travel — a repo mid-mutation cannot produce the same ids twice.
|
|
225
|
+
// Both halves of the pair read the SAME scope: the paths rung so far.
|
|
226
|
+
const genNow = scopeGen;
|
|
227
|
+
const suspects = scopeAllGen > 0 ? null : [...scope.keys()];
|
|
213
228
|
let first;
|
|
214
229
|
let second;
|
|
215
230
|
try {
|
|
216
|
-
first = await captureFn(repoPath);
|
|
231
|
+
first = await captureFn(repoPath, { suspects });
|
|
217
232
|
if (closed || owned > 0) return; // the pair is stale before it finished
|
|
218
|
-
second = first && !first.busy ? await captureFn(repoPath) : null;
|
|
233
|
+
second = first && !first.busy ? await captureFn(repoPath, { suspects }) : null;
|
|
219
234
|
} catch (error) {
|
|
220
235
|
if (closed) return;
|
|
221
236
|
// A read tripping over a mid-flight writer (a file vanishing between
|
|
@@ -234,11 +249,34 @@ function createFollower(options) {
|
|
|
234
249
|
retry(settleMs);
|
|
235
250
|
return;
|
|
236
251
|
}
|
|
237
|
-
if (!speak(second))
|
|
252
|
+
if (!speak(second)) {
|
|
253
|
+
retry(capMs); // consumer failure: patient, loud, never lost
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// The settled pair proved the ground the scope described: hints from
|
|
257
|
+
// BEFORE the pair began are consumed; anything rung mid-pair keeps its
|
|
258
|
+
// newer generation and stays suspect for the pendingWake retry.
|
|
259
|
+
if (scopeAllGen > 0 && scopeAllGen <= genNow) scopeAllGen = 0;
|
|
260
|
+
for (const [rel, gen] of scope) {
|
|
261
|
+
if (gen <= genNow) scope.delete(rel);
|
|
262
|
+
}
|
|
238
263
|
}
|
|
239
264
|
|
|
240
|
-
function ring() {
|
|
265
|
+
function ring(hint) {
|
|
241
266
|
if (closed || owned > 0) return; // the command's end is the boundary
|
|
267
|
+
// Accumulate the warm-checkpoint scope. A .git-area hint never touches
|
|
268
|
+
// untracked ground, so it schedules a capture without widening the
|
|
269
|
+
// scope; a nameless hint (or an overflowing scope) suspects everything.
|
|
270
|
+
scopeGen += 1;
|
|
271
|
+
if (hint === null || hint === undefined) {
|
|
272
|
+
scopeAllGen = scopeGen;
|
|
273
|
+
} else if (hint !== '.git' && !hint.startsWith('.git/')) {
|
|
274
|
+
scope.set(hint, scopeGen);
|
|
275
|
+
if (scope.size > SCOPE_CAP) {
|
|
276
|
+
scope.clear();
|
|
277
|
+
scopeAllGen = scopeGen;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
242
280
|
if (settleTimer) clearTimeout(settleTimer);
|
|
243
281
|
settleTimer = setTimeout(() => {
|
|
244
282
|
settleTimer = null;
|
|
@@ -262,7 +300,13 @@ function createFollower(options) {
|
|
|
262
300
|
|
|
263
301
|
function endCommand() {
|
|
264
302
|
owned = Math.max(0, owned - 1);
|
|
265
|
-
|
|
303
|
+
// Rings were suppressed while the command ran: its writes are nobody's
|
|
304
|
+
// hints, so the capture at the boundary suspects everything.
|
|
305
|
+
if (owned === 0 && !closed) {
|
|
306
|
+
scopeGen += 1;
|
|
307
|
+
scopeAllGen = scopeGen;
|
|
308
|
+
fire();
|
|
309
|
+
}
|
|
266
310
|
}
|
|
267
311
|
|
|
268
312
|
/** Run an amalgm-owned command: no captures while it runs, one at its end. */
|
|
@@ -284,10 +328,21 @@ function createFollower(options) {
|
|
|
284
328
|
|
|
285
329
|
return {
|
|
286
330
|
ring,
|
|
287
|
-
/** Birth: capture once, immediately
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
331
|
+
/** Birth: capture once, immediately, suspecting everything — rings
|
|
332
|
+
* before birth were nobody's. With a seeded `last`, an unchanged
|
|
333
|
+
* repo is silence. */
|
|
334
|
+
start: () => {
|
|
335
|
+
scopeGen += 1;
|
|
336
|
+
scopeAllGen = scopeGen;
|
|
337
|
+
return fire();
|
|
338
|
+
},
|
|
339
|
+
/** Consumer-requested capture — the "scanNow" of this layer. Its whole
|
|
340
|
+
* purpose is catching a LOST doorbell, so it never trusts the scope. */
|
|
341
|
+
flush: () => {
|
|
342
|
+
scopeGen += 1;
|
|
343
|
+
scopeAllGen = scopeGen;
|
|
344
|
+
return fire();
|
|
345
|
+
},
|
|
291
346
|
beginCommand,
|
|
292
347
|
endCommand,
|
|
293
348
|
command,
|
|
@@ -371,3 +371,93 @@ test('add follows in-order mirrored mutations and refuses after a version gap un
|
|
|
371
371
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
372
372
|
}
|
|
373
373
|
});
|
|
374
|
+
|
|
375
|
+
// --- the repo travel law -----------------------------------------------------
|
|
376
|
+
|
|
377
|
+
function activeCloud({ content = { has: () => true, ensureUpload: () => {} }, repoTransportFor = null } = {}) {
|
|
378
|
+
const db = new Database(':memory:');
|
|
379
|
+
let registry = null;
|
|
380
|
+
const cloud = createEntityCloud({
|
|
381
|
+
db,
|
|
382
|
+
userId: 'user-1',
|
|
383
|
+
deviceId: 'device-1',
|
|
384
|
+
content,
|
|
385
|
+
repoTransportFor,
|
|
386
|
+
recordAt: (uuid) => {
|
|
387
|
+
try { return registry.syncRecord(uuid); } catch { return null; }
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
registry = createRegistry({ db, ephemeral: true, emit: () => {}, journal: cloud.journal });
|
|
391
|
+
const root = registry.enrollRoot({ type: 'workspace', name: 'ground' });
|
|
392
|
+
const bootstrap = cloud.bootstrap(registry.syncRecords());
|
|
393
|
+
cloud.install({
|
|
394
|
+
registry,
|
|
395
|
+
resourceId: bootstrap.resourceId,
|
|
396
|
+
authorityEpoch: 1,
|
|
397
|
+
headVersion: 0,
|
|
398
|
+
snapshotBase64: bootstrap.snapshotBase64,
|
|
399
|
+
snapshotChecksum: bootstrap.snapshotChecksum,
|
|
400
|
+
});
|
|
401
|
+
return { db, cloud, registry, root, resourceId: bootstrap.resourceId };
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
test('a record enclosed by a repository never journals its own cloud mutation — the repo state parcel is the carrier', () => {
|
|
405
|
+
const transportHead = 'a'.repeat(64);
|
|
406
|
+
const { db, registry, root, resourceId } = activeCloud({
|
|
407
|
+
repoTransportFor: () => transportHead,
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const repo = registry.create({ type: 'repo.git', parentUUID: root.uuid, name: 'engine', payloadVersion: 'state-1' });
|
|
411
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 1,
|
|
412
|
+
'the repository record itself travels');
|
|
413
|
+
|
|
414
|
+
const folder = registry.create({ type: 'folder', parentUUID: repo.uuid, name: 'src' });
|
|
415
|
+
registry.create({ type: 'file.text', parentUUID: folder.uuid, name: 'index.js', payloadVersion: 'b'.repeat(64) });
|
|
416
|
+
registry.create({ type: 'link', parentUUID: repo.uuid, name: 'current', payloadVersion: 'src' });
|
|
417
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 1,
|
|
418
|
+
'worktree children mail no postcards: a register or a rebase is one parcel on the repo entity');
|
|
419
|
+
|
|
420
|
+
const catalog = db.prepare('SELECT entity_uuid FROM entity_cloud_catalog WHERE resource_id = ? ORDER BY entity_uuid').all(resourceId)
|
|
421
|
+
.map((row) => row.entity_uuid).sort();
|
|
422
|
+
assert.deepEqual(catalog, [root.uuid, repo.uuid].sort(),
|
|
423
|
+
'the cloud catalog holds the traveling graph only — no rows for repo-enclosed children');
|
|
424
|
+
|
|
425
|
+
// A later Detect correction on the child (type/payload) stays local too.
|
|
426
|
+
const inner = registry.activeAt(folder.uuid, 'index.js');
|
|
427
|
+
registry.attest(inner.uuid, { type: 'file.binary', payloadVersion: 'c'.repeat(64) });
|
|
428
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 1,
|
|
429
|
+
'the census drip is dead: child corrections never reach the outbox');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test('an atomic snapshot replacement excludes repo-enclosed records and never demands their bytes', () => {
|
|
433
|
+
const transportHead = 'd'.repeat(64);
|
|
434
|
+
const childHead = 'e'.repeat(64);
|
|
435
|
+
const { db, cloud, registry, root, resourceId } = activeCloud({
|
|
436
|
+
repoTransportFor: () => transportHead,
|
|
437
|
+
content: {
|
|
438
|
+
ensureUpload: () => {},
|
|
439
|
+
has: ({ contentHash }) => {
|
|
440
|
+
assert.notEqual(contentHash, childHead, 'an enclosed child head must never be demanded as separate cloud cargo');
|
|
441
|
+
return true;
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
registry.withoutJournal(() => {
|
|
447
|
+
const repo = registry.create({ type: 'repo.git', parentUUID: root.uuid, name: 'engine', payloadVersion: 'state-1' });
|
|
448
|
+
registry.create({ type: 'file.text', parentUUID: repo.uuid, name: 'inner.md', payloadVersion: childHead });
|
|
449
|
+
registry.create({ type: 'file.text', parentUUID: root.uuid, name: 'loose.md', payloadVersion: 'f'.repeat(64) });
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
cloud.replaceSnapshot(registry.syncRecords());
|
|
453
|
+
const operation = JSON.parse(db.prepare(`
|
|
454
|
+
SELECT operation_json FROM entity_cloud_mutations
|
|
455
|
+
WHERE resource_id = ? ORDER BY sequence DESC LIMIT 1
|
|
456
|
+
`).get(resourceId).operation_json);
|
|
457
|
+
assert.equal(operation.kind, 'entity.snapshot.replace');
|
|
458
|
+
const types = operation.snapshot.records.map((record) => record.type).sort();
|
|
459
|
+
assert.deepEqual(types, ['file.text', 'repo.git', 'workspace'],
|
|
460
|
+
'the atomic graph carries the workspace, the loose file, and ONE record per repository');
|
|
461
|
+
assert.ok(!operation.snapshot.records.some((record) => record.name === 'inner.md'),
|
|
462
|
+
'repo children stay out of the published graph');
|
|
463
|
+
});
|