amalgm 0.1.249 → 0.1.251
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 +10 -0
- package/lib/runtime-identity.js +4 -1
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +173 -30
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +29 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +16 -3
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +8 -2
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +26 -4
- package/runtime/scripts/amalgm-mcp/registration/service.js +305 -6
- package/runtime/scripts/amalgm-mcp/registration/tree.js +29 -8
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +4 -5
- package/runtime/scripts/amalgm-mcp/tests/entity-cloud.test.js +50 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +242 -1
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +37 -0
|
@@ -53,11 +53,14 @@ const { classifyFile } = require('./classify');
|
|
|
53
53
|
const { createEntityCloud } = require('./entity-cloud');
|
|
54
54
|
const { createEntityContent } = require('./entity-content');
|
|
55
55
|
const { createRegistration } = require('./index');
|
|
56
|
+
const { capture, apply: applyRepo } = require('../repocard');
|
|
57
|
+
const { transportBytes, localState, unpack } = require('./repo-states');
|
|
56
58
|
const { machineId, userId } = require('../workspace/identity');
|
|
57
59
|
|
|
58
60
|
const ENTITIES_DIR = path.join(STORAGE_DIR, 'entities');
|
|
59
61
|
const USER_HOME_CLOUD_CONTRACT = 'amalgm-user-home@1';
|
|
60
62
|
const USER_HOME_CLOUD_SCHEMA_VERSION = 1;
|
|
63
|
+
const ATOMIC_REGISTRATION_THRESHOLD = 500;
|
|
61
64
|
|
|
62
65
|
let live = null;
|
|
63
66
|
|
|
@@ -80,11 +83,32 @@ function createRegistrationService({
|
|
|
80
83
|
const evidenceDb = new Database(path.join(dir, 'observer.db'));
|
|
81
84
|
const identityDb = new Database(path.join(dir, 'identity.db'));
|
|
82
85
|
const entityContent = createEntityContent({ dir });
|
|
86
|
+
// createRegistration owns the schema migration. Give entity-cloud a stable
|
|
87
|
+
// lookup facade now, then connect it to that one migrated state store once
|
|
88
|
+
// the boundary exists; never open a second registry just to read Git state.
|
|
89
|
+
let repoStates = null;
|
|
90
|
+
const repoStateLookup = { get: (uuid) => repoStates?.get(uuid) || null };
|
|
91
|
+
const repoTransportHeads = new Map(); // semantic stateId -> immutable content hash
|
|
92
|
+
const importedRepoStates = new Map(); // cloud UUID -> verified bundle-free state
|
|
93
|
+
|
|
94
|
+
/** Git transport is immutable content, not identity-database cargo. The
|
|
95
|
+
* semantic repo stateId remains the entity head; this prepares the exact
|
|
96
|
+
* Card + Checkpoint + bundle payload and leaves only its content hash in
|
|
97
|
+
* SQLite so a large clone cannot bloat the device registry. */
|
|
98
|
+
function prepareRepo(captured) {
|
|
99
|
+
if (captured?.busy) return captured;
|
|
100
|
+
const artifact = entityContent.captureBytes({ bytes: transportBytes(captured) });
|
|
101
|
+
repoTransportHeads.set(captured.stateId, artifact.contentHash);
|
|
102
|
+
return localState(captured, artifact.contentHash);
|
|
103
|
+
}
|
|
104
|
+
|
|
83
105
|
const entityCloud = createEntityCloud({
|
|
84
106
|
db: identityDb,
|
|
85
107
|
userId: cloudUserId,
|
|
86
108
|
deviceId: cloudDeviceId,
|
|
87
109
|
content: entityContent,
|
|
110
|
+
repoStates: repoStateLookup,
|
|
111
|
+
repoTransportFor: (record) => repoTransportHeads.get(record.payloadVersion) || null,
|
|
88
112
|
});
|
|
89
113
|
|
|
90
114
|
// The observer emits before the boundary exists (its constructor
|
|
@@ -103,12 +127,41 @@ function createRegistrationService({
|
|
|
103
127
|
db: identityDb,
|
|
104
128
|
sentinelPath: path.join(dir, 'identity.epoch'),
|
|
105
129
|
journal: entityCloud.journal,
|
|
130
|
+
captureRepo: (repoPath) => prepareRepo(capture(repoPath, { withBundle: true })),
|
|
131
|
+
prepareRepo,
|
|
132
|
+
repoStateAt: (uuid) => importedRepoStates.get(uuid) || null,
|
|
106
133
|
}),
|
|
107
134
|
classify: classifyFile,
|
|
108
135
|
});
|
|
136
|
+
repoStates = boundary.current.repoStates;
|
|
109
137
|
|
|
110
138
|
let closed = false;
|
|
111
139
|
|
|
140
|
+
/** Count only the same eligible shape that registration will see. This is
|
|
141
|
+
* not a second registration walker: it is a bounded routing decision that
|
|
142
|
+
* lets a large tree use one atomic cloud graph replacement instead of
|
|
143
|
+
* thousands of individually compacted mutations. */
|
|
144
|
+
function estimatedRegistrationRecords(targetPath, limit = ATOMIC_REGISTRATION_THRESHOLD) {
|
|
145
|
+
let total = 0;
|
|
146
|
+
const visit = (candidate) => {
|
|
147
|
+
total += 1;
|
|
148
|
+
if (total >= limit) return;
|
|
149
|
+
let stat;
|
|
150
|
+
try { stat = fs.lstatSync(candidate); } catch { return; }
|
|
151
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return;
|
|
152
|
+
let entries;
|
|
153
|
+
try { entries = fs.readdirSync(candidate, { withFileTypes: true }); } catch { return; }
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (total >= limit) return;
|
|
156
|
+
const child = path.join(candidate, entry.name);
|
|
157
|
+
if (!shouldEnroll(child)) continue;
|
|
158
|
+
visit(child);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
visit(targetPath);
|
|
162
|
+
return total;
|
|
163
|
+
}
|
|
164
|
+
|
|
112
165
|
const publicRecord = (record) => ({
|
|
113
166
|
uuid: record.uuid,
|
|
114
167
|
type: record.type,
|
|
@@ -304,14 +357,27 @@ function createRegistrationService({
|
|
|
304
357
|
group.push(record);
|
|
305
358
|
children.set(record.parentUUID, group);
|
|
306
359
|
}
|
|
360
|
+
function repoState(record) {
|
|
361
|
+
if (!record.transportVersion) throw new Error(`cloud repository ${record.uuid} has no Repocard transport head`);
|
|
362
|
+
const state = unpack(entityContent.bytes({ contentHash: record.transportVersion }));
|
|
363
|
+
if (state.stateId !== record.payloadVersion) {
|
|
364
|
+
throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
|
|
365
|
+
}
|
|
366
|
+
return state;
|
|
367
|
+
}
|
|
368
|
+
|
|
307
369
|
for (const record of active) {
|
|
308
370
|
if (['file.text', 'file.binary'].includes(record.type)
|
|
309
371
|
&& !entityContent.complete({ contentHash: record.payloadVersion })) {
|
|
310
372
|
return false;
|
|
311
373
|
}
|
|
374
|
+
if (record.type === 'repo.git'
|
|
375
|
+
&& (!record.transportVersion || !entityContent.complete({ contentHash: record.transportVersion }))) {
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
312
378
|
}
|
|
313
|
-
if (!['workspace', 'file.text', 'file.binary'].includes(root.type)) {
|
|
314
|
-
throw new Error(`cloud root ${root.uuid} is ${root.type}; this materializer supports workspace and file trees only`);
|
|
379
|
+
if (!['workspace', 'repo.git', 'file.text', 'file.binary'].includes(root.type)) {
|
|
380
|
+
throw new Error(`cloud root ${root.uuid} is ${root.type}; this materializer supports workspace, repository, and file trees only`);
|
|
315
381
|
}
|
|
316
382
|
|
|
317
383
|
const parent = fs.realpathSync(job.targetPath);
|
|
@@ -330,6 +396,10 @@ function createRegistrationService({
|
|
|
330
396
|
}
|
|
331
397
|
return;
|
|
332
398
|
}
|
|
399
|
+
if (record.type === 'repo.git') {
|
|
400
|
+
applyRepo(target, repoState(record));
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
333
403
|
if (record.type === 'file.text' || record.type === 'file.binary') {
|
|
334
404
|
fs.writeFileSync(target, entityContent.bytes({ contentHash: record.payloadVersion }), { mode: 0o600, flag: 'wx' });
|
|
335
405
|
return;
|
|
@@ -351,6 +421,14 @@ function createRegistrationService({
|
|
|
351
421
|
write(root, staging);
|
|
352
422
|
fs.renameSync(staging, destination);
|
|
353
423
|
promoted = true;
|
|
424
|
+
// Bind imported repo UUIDs to the verified state before registration's
|
|
425
|
+
// structural walk. The walk then reuses these facts instead of making
|
|
426
|
+
// a second bundle while it arms the local Git follower.
|
|
427
|
+
for (const record of active.filter((candidate) => candidate.type === 'repo.git')) {
|
|
428
|
+
const state = repoState(record);
|
|
429
|
+
importedRepoStates.set(record.uuid, localState(state, record.transportVersion));
|
|
430
|
+
repoTransportHeads.set(state.stateId, record.transportVersion);
|
|
431
|
+
}
|
|
354
432
|
const bound = boundary.current.add(destination, { identityAt, suppressJournal: true });
|
|
355
433
|
if (bound.record.uuid !== root.uuid) {
|
|
356
434
|
throw new Error(`materialized cloud root ${root.uuid} bound to unexpected local UUID ${bound.record.uuid}`);
|
|
@@ -369,11 +447,197 @@ function createRegistrationService({
|
|
|
369
447
|
}
|
|
370
448
|
}
|
|
371
449
|
|
|
450
|
+
/** The local replica of `amalgm add` is Amalgm-managed ground. This is
|
|
451
|
+
* deliberately not a two-way directory merger: if local bytes have drifted
|
|
452
|
+
* from the last authoritative record, the import stops rather than choosing
|
|
453
|
+
* a winner behind the user's back. Otherwise a remote cloud head is
|
|
454
|
+
* projected into the existing binding, with journal suppression so the
|
|
455
|
+
* receiver never echoes its own download back to cloud. */
|
|
456
|
+
function applyLiveCloudTree(job) {
|
|
457
|
+
const tree = entityCloud.catalogTree({ resourceId: job.resourceId, uuid: job.entityUuid });
|
|
458
|
+
const root = tree[0];
|
|
459
|
+
const active = tree.filter((record) => record.status === 'active');
|
|
460
|
+
const unsupported = tree.find((record) => ['link', 'reference'].includes(record.type));
|
|
461
|
+
if (unsupported) {
|
|
462
|
+
throw new Error(`live cloud add for ${unsupported.type} is not available yet; the binding was left unchanged`);
|
|
463
|
+
}
|
|
464
|
+
if (!['workspace', 'repo.git', 'file.text', 'file.binary'].includes(root.type)) {
|
|
465
|
+
throw new Error(`cloud root ${root.uuid} is ${root.type}; this live materializer supports workspace, repository, and file trees only`);
|
|
466
|
+
}
|
|
467
|
+
for (const record of active) {
|
|
468
|
+
if (['file.text', 'file.binary'].includes(record.type)
|
|
469
|
+
&& !entityContent.complete({ contentHash: record.payloadVersion })) return false;
|
|
470
|
+
if (record.type === 'repo.git'
|
|
471
|
+
&& (!record.transportVersion || !entityContent.complete({ contentHash: record.transportVersion }))) return false;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const parent = fs.realpathSync(job.targetPath);
|
|
475
|
+
const destination = path.join(parent, root.name);
|
|
476
|
+
const boundRoot = boundary.current.tree(destination)?.root();
|
|
477
|
+
if (!boundRoot || boundRoot.uuid !== root.uuid) {
|
|
478
|
+
throw new Error(`cloud binding ${root.uuid} no longer owns ${destination}; refusing to overwrite local ground`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const children = new Map();
|
|
482
|
+
for (const record of active) {
|
|
483
|
+
if (record.parentUUID === null) continue;
|
|
484
|
+
const group = children.get(record.parentUUID) || [];
|
|
485
|
+
group.push(record);
|
|
486
|
+
children.set(record.parentUUID, group);
|
|
487
|
+
}
|
|
488
|
+
for (const group of children.values()) group.sort((left, right) => left.name.localeCompare(right.name));
|
|
489
|
+
|
|
490
|
+
function localRecord(uuid) {
|
|
491
|
+
try { return boundary.current.registry.syncRecord(uuid); } catch { return null; }
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function addressOf(uuid) {
|
|
495
|
+
try { return boundary.current.addressOf(uuid); } catch { return null; }
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function fileHash(file) {
|
|
499
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function repoState(record) {
|
|
503
|
+
if (!record.transportVersion) throw new Error(`cloud repository ${record.uuid} has no Repocard transport head`);
|
|
504
|
+
const state = unpack(entityContent.bytes({ contentHash: record.transportVersion }));
|
|
505
|
+
if (state.stateId !== record.payloadVersion) {
|
|
506
|
+
throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
|
|
507
|
+
}
|
|
508
|
+
return state;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Before changing logical identity, prove that every local byte we could
|
|
512
|
+
// overwrite is still the byte named by the old authoritative record.
|
|
513
|
+
// An unsent local edit or an out-of-band edit is a conflict, never an
|
|
514
|
+
// implicit cloud-wins overwrite.
|
|
515
|
+
for (const record of tree) {
|
|
516
|
+
const current = localRecord(record.uuid);
|
|
517
|
+
if (!current || current.version === record.version) continue;
|
|
518
|
+
if (entityCloud.hasUnsettledLocalMutation(record.uuid)) {
|
|
519
|
+
throw new Error(`local change for ${record.uuid} is still waiting for cloud; refusing to overwrite it`);
|
|
520
|
+
}
|
|
521
|
+
if (current.type === 'repo.git') {
|
|
522
|
+
const currentPath = addressOf(record.uuid);
|
|
523
|
+
const observed = currentPath && fs.existsSync(currentPath)
|
|
524
|
+
? capture(currentPath, { withBundle: false })
|
|
525
|
+
: null;
|
|
526
|
+
if (!observed || observed.busy || observed.stateId !== current.payloadVersion) {
|
|
527
|
+
throw new Error(`local repository ${currentPath || record.uuid} changed outside Amalgm; refusing to overwrite it`);
|
|
528
|
+
}
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (!['file.text', 'file.binary'].includes(current.type)) continue;
|
|
532
|
+
const currentPath = addressOf(record.uuid);
|
|
533
|
+
if (!currentPath || !fs.existsSync(currentPath)) {
|
|
534
|
+
throw new Error(`local file for ${record.uuid} is missing; refusing to overwrite an unverified binding`);
|
|
535
|
+
}
|
|
536
|
+
if (fileHash(currentPath) !== current.payloadVersion) {
|
|
537
|
+
throw new Error(`local file ${currentPath} changed outside Amalgm; refusing to overwrite it`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function applyKnownRecords() {
|
|
542
|
+
// Catalog container versions are deliberately regenerated by the
|
|
543
|
+
// authority after child mutations. Applying an unchanged container
|
|
544
|
+
// before its later child would therefore compare an old derived version.
|
|
545
|
+
// Only import records whose stored logical fields actually changed; the
|
|
546
|
+
// local registry derives the same container version after the child lands.
|
|
547
|
+
const depth = new Map([[root.uuid, 0]]);
|
|
548
|
+
for (const record of tree) {
|
|
549
|
+
if (record.parentUUID !== null) depth.set(record.uuid, (depth.get(record.parentUUID) || 0) + 1);
|
|
550
|
+
}
|
|
551
|
+
for (const record of [...tree].sort((left, right) => (depth.get(left.uuid) || 0) - (depth.get(right.uuid) || 0))) {
|
|
552
|
+
const current = localRecord(record.uuid);
|
|
553
|
+
if (current && current.type === record.type && current.parentUUID === record.parentUUID
|
|
554
|
+
&& current.name === record.name && current.status === record.status
|
|
555
|
+
&& current.payloadVersion === record.payloadVersion) continue;
|
|
556
|
+
boundary.current.registry.applyCloudRecord(record);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function atomicWrite(file, bytes) {
|
|
561
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.amalgm-live-${crypto.randomBytes(10).toString('hex')}`);
|
|
562
|
+
try {
|
|
563
|
+
fs.writeFileSync(temporary, bytes, { mode: 0o600, flag: 'wx' });
|
|
564
|
+
fs.renameSync(temporary, file);
|
|
565
|
+
} finally {
|
|
566
|
+
fs.rmSync(temporary, { force: true });
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function removeInactiveChild(parentRecord, name, target) {
|
|
571
|
+
const known = boundary.current.registry.children(parentRecord.uuid)
|
|
572
|
+
.find((candidate) => candidate.name === name);
|
|
573
|
+
if (!known || known.status === 'active') {
|
|
574
|
+
throw new Error(`local item ${target} is not owned by this cloud binding; refusing to remove it`);
|
|
575
|
+
}
|
|
576
|
+
if (['file.text', 'file.binary'].includes(known.type) && fs.existsSync(target)) {
|
|
577
|
+
const previous = localRecord(known.uuid);
|
|
578
|
+
if (previous?.payloadVersion && fileHash(target) !== previous.payloadVersion) {
|
|
579
|
+
throw new Error(`local file ${target} changed outside Amalgm; refusing to remove it`);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function project(record, target) {
|
|
586
|
+
if (record.type === 'workspace' || record.type === 'folder') {
|
|
587
|
+
if (fs.existsSync(target)) {
|
|
588
|
+
if (!fs.statSync(target).isDirectory()) {
|
|
589
|
+
throw new Error(`cloud container ${record.uuid} conflicts with local non-directory ${target}`);
|
|
590
|
+
}
|
|
591
|
+
} else {
|
|
592
|
+
fs.mkdirSync(target, { mode: 0o700 });
|
|
593
|
+
}
|
|
594
|
+
const wanted = new Set((children.get(record.uuid) || []).map((child) => child.name));
|
|
595
|
+
for (const child of children.get(record.uuid) || []) project(child, path.join(target, child.name));
|
|
596
|
+
for (const entry of fs.readdirSync(target)) {
|
|
597
|
+
if (!wanted.has(entry)) removeInactiveChild(record, entry, path.join(target, entry));
|
|
598
|
+
}
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
if (record.type === 'repo.git') {
|
|
602
|
+
applyRepo(target, repoState(record));
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (!['file.text', 'file.binary'].includes(record.type)) {
|
|
606
|
+
throw new Error(`cloud entity ${record.uuid} is ${record.type}; this live materializer cannot write it`);
|
|
607
|
+
}
|
|
608
|
+
if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
|
|
609
|
+
throw new Error(`cloud file ${record.uuid} conflicts with local directory ${target}`);
|
|
610
|
+
}
|
|
611
|
+
const bytes = entityContent.bytes({ contentHash: record.payloadVersion });
|
|
612
|
+
if (fs.existsSync(target) && Buffer.compare(fs.readFileSync(target), bytes) === 0) return;
|
|
613
|
+
atomicWrite(target, bytes);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
boundary.current.registry.withoutJournal(() => {
|
|
617
|
+
for (const record of active.filter((candidate) => candidate.type === 'repo.git')) {
|
|
618
|
+
const state = repoState(record);
|
|
619
|
+
repoStates.put(record.uuid, localState(state, record.transportVersion));
|
|
620
|
+
repoTransportHeads.set(state.stateId, record.transportVersion);
|
|
621
|
+
}
|
|
622
|
+
applyKnownRecords();
|
|
623
|
+
project(root, destination);
|
|
624
|
+
const observerRoot = observer.listRoots().find((entry) => entry.path === destination);
|
|
625
|
+
if (!observerRoot) throw new Error(`cloud binding ${root.uuid} has no local watcher root at ${destination}`);
|
|
626
|
+
observer.scanNow(observerRoot.rootId);
|
|
627
|
+
boundary.current.flushIfDirty();
|
|
628
|
+
});
|
|
629
|
+
return { destination, root };
|
|
630
|
+
}
|
|
631
|
+
|
|
372
632
|
function advanceMaterializations() {
|
|
373
633
|
for (const job of entityCloud.pendingMaterializations()) {
|
|
374
634
|
if (job.state !== 'waiting_for_content') continue;
|
|
375
635
|
try {
|
|
376
|
-
const
|
|
636
|
+
const root = entityCloud.catalogTree({ resourceId: job.resourceId, uuid: job.entityUuid })[0];
|
|
637
|
+
const destination = path.join(fs.realpathSync(job.targetPath), root.name);
|
|
638
|
+
const result = boundary.current.tree(destination)?.root()?.uuid === root.uuid
|
|
639
|
+
? applyLiveCloudTree(job)
|
|
640
|
+
: materializeCloudTree(job);
|
|
377
641
|
if (result) entityCloud.completeMaterialization({ jobId: job.jobId });
|
|
378
642
|
} catch (error) {
|
|
379
643
|
entityCloud.failMaterialization({ jobId: job.jobId, message: error.message });
|
|
@@ -389,12 +653,26 @@ function createRegistrationService({
|
|
|
389
653
|
/** `amalgm register <path>` — register, then begin Watch + Detect. */
|
|
390
654
|
register: async (targetPath) => {
|
|
391
655
|
entityCloud.assertRegistrationReady();
|
|
392
|
-
|
|
656
|
+
if (estimatedRegistrationRecords(targetPath) < ATOMIC_REGISTRATION_THRESHOLD) {
|
|
657
|
+
return boundary.current.add(targetPath);
|
|
658
|
+
}
|
|
659
|
+
const result = boundary.current.registry.withoutJournal(() => boundary.current.add(targetPath));
|
|
660
|
+
entityCloud.replaceSnapshot(boundary.current.registry.syncRecords());
|
|
661
|
+
return result;
|
|
393
662
|
},
|
|
394
|
-
/** `amalgm register <path>...` — the same primitive for each selected root.
|
|
663
|
+
/** `amalgm register <path>...` — the same primitive for each selected root.
|
|
664
|
+
* A large tree has one atomic cloud replacement record: partial graph
|
|
665
|
+
* acknowledgements would make an R2 active head that no receiver may
|
|
666
|
+
* materialize. Small selections retain their ordinary per-entity intent. */
|
|
395
667
|
registerMany: async (targetPaths) => {
|
|
396
668
|
entityCloud.assertRegistrationReady();
|
|
397
|
-
|
|
669
|
+
const needsAtomicReplacement = targetPaths.some((targetPath) => (
|
|
670
|
+
estimatedRegistrationRecords(targetPath) >= ATOMIC_REGISTRATION_THRESHOLD
|
|
671
|
+
));
|
|
672
|
+
if (!needsAtomicReplacement) return boundary.current.addMany(targetPaths);
|
|
673
|
+
const results = boundary.current.registry.withoutJournal(() => boundary.current.addMany(targetPaths));
|
|
674
|
+
entityCloud.replaceSnapshot(boundary.current.registry.syncRecords());
|
|
675
|
+
return results;
|
|
398
676
|
},
|
|
399
677
|
/** `amalgm add <cloud-root-uuid> [parent-directory]` — cloud to local. */
|
|
400
678
|
add: async ({ uuid, targetPath }) => {
|
|
@@ -402,6 +680,27 @@ function createRegistrationService({
|
|
|
402
680
|
advanceMaterializations();
|
|
403
681
|
return entityCloud.materializationStatus(job.jobId);
|
|
404
682
|
},
|
|
683
|
+
/** The inbound realtime rail is part of the add binding. Routes call
|
|
684
|
+
* these composition methods so receiving an authoritative record or one
|
|
685
|
+
* of its immutable content parts immediately advances the local replica;
|
|
686
|
+
* users never run a separate sync command. */
|
|
687
|
+
ingestCloudMutation: ({ envelope, version, committedAt }) => {
|
|
688
|
+
const ingested = entityCloud.ingest({
|
|
689
|
+
registry: boundary.current.registry,
|
|
690
|
+
envelope,
|
|
691
|
+
version,
|
|
692
|
+
committedAt,
|
|
693
|
+
});
|
|
694
|
+
advanceMaterializations();
|
|
695
|
+
return ingested;
|
|
696
|
+
},
|
|
697
|
+
receiveCloudContent: ({ resourceId, contentHash, kind, partIndex, sha256, dataBase64 }) => {
|
|
698
|
+
const received = entityCloud.receiveDownload({
|
|
699
|
+
resourceId, contentHash, kind, partIndex, sha256, dataBase64,
|
|
700
|
+
});
|
|
701
|
+
advanceMaterializations();
|
|
702
|
+
return received;
|
|
703
|
+
},
|
|
405
704
|
advanceMaterializations,
|
|
406
705
|
bootstrapUserHome,
|
|
407
706
|
exactUserHomeCloudRecords,
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
|
|
29
29
|
const fs = require('fs');
|
|
30
30
|
const path = require('path');
|
|
31
|
+
const crypto = require('crypto');
|
|
31
32
|
|
|
32
33
|
function rootType(rootPath, kind) {
|
|
33
34
|
if (kind === 'repo') return 'repo.git';
|
|
@@ -55,7 +56,7 @@ function isRepository(dirPath) {
|
|
|
55
56
|
* directory ground. The caller supplies a root that is already known to the
|
|
56
57
|
* observer but is not yet watched or scanned.
|
|
57
58
|
*/
|
|
58
|
-
function registerTree({ root, registry, memory, shouldEnroll, captureRepo, repoStates, identityAt = null }) {
|
|
59
|
+
function registerTree({ root, registry, memory, shouldEnroll, captureRepo, repoStates, identityAt = null, repoStateAt = null }) {
|
|
59
60
|
if (!root || !registry || !memory || typeof shouldEnroll !== 'function'
|
|
60
61
|
|| typeof captureRepo !== 'function' || !repoStates) {
|
|
61
62
|
throw new Error('registerTree requires { root, registry, memory, shouldEnroll, captureRepo, repoStates }');
|
|
@@ -93,23 +94,42 @@ function registerTree({ root, registry, memory, shouldEnroll, captureRepo, repoS
|
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
/** An imported repository already has a verified Repocard artifact in the
|
|
98
|
+
* local state table before it is bound to Watch. Reuse that exact state;
|
|
99
|
+
* recapturing would waste a second Git bundle and could race the import.
|
|
100
|
+
* Ordinary local registration has no seeded UUID and captures fresh truth. */
|
|
101
|
+
function repoStateFor(identity, repoPath) {
|
|
102
|
+
if (identity.uuid) {
|
|
103
|
+
const imported = repoStateAt?.(identity.uuid);
|
|
104
|
+
if (imported?.stateId === identity.payloadVersion) return imported;
|
|
105
|
+
const saved = repoStates.get(identity.uuid);
|
|
106
|
+
if (saved?.stateId === identity.payloadVersion) return saved;
|
|
107
|
+
}
|
|
108
|
+
return captureInitialRepo(repoPath, captureRepo);
|
|
109
|
+
}
|
|
110
|
+
|
|
96
111
|
return registry.transaction(() => {
|
|
97
|
-
const initialState = root.kind === 'repo' ? captureInitialRepo(root.path, captureRepo) : null;
|
|
98
112
|
const rootIdentity = seeded('', {
|
|
99
113
|
type: rootType(root.path, root.kind), parentUUID: null, name: path.basename(root.path), root: true,
|
|
100
114
|
});
|
|
115
|
+
const initialState = root.kind === 'repo' ? repoStateFor(rootIdentity, root.path) : null;
|
|
116
|
+
// A repo's immutable transport head is already held by the injected
|
|
117
|
+
// cloud-cargo lookup before its entity journal runs. Allocate the UUID,
|
|
118
|
+
// create the FK parent, then persist its bundle-free state in this same
|
|
119
|
+
// transaction. A rollback removes both durable facts together.
|
|
120
|
+
const rootUuid = rootIdentity.uuid || (initialState ? crypto.randomUUID() : undefined);
|
|
101
121
|
const record = registry.enrollRoot({
|
|
102
122
|
type: rootIdentity.type,
|
|
103
123
|
name: path.basename(root.path),
|
|
104
124
|
payloadVersion: initialState?.stateId ?? rootIdentity.payloadVersion ?? null,
|
|
105
|
-
uuid:
|
|
125
|
+
uuid: rootUuid,
|
|
106
126
|
});
|
|
107
127
|
memory.bind(root.rootId, root.rootId, record.uuid);
|
|
108
128
|
if (initialState) repoStates.put(record.uuid, initialState);
|
|
109
129
|
|
|
110
130
|
if (root.kind === 'files' || root.kind === 'repo') {
|
|
111
131
|
registerDirectory(root.path, record.uuid, {
|
|
112
|
-
registry, shouldEnroll, captureRepo, repoStates, identityAt, seeded, relativePath: '',
|
|
132
|
+
registry, shouldEnroll, captureRepo, repoStates, identityAt, repoStateAt, repoStateFor, seeded, relativePath: '',
|
|
113
133
|
});
|
|
114
134
|
}
|
|
115
135
|
return record;
|
|
@@ -128,7 +148,7 @@ function captureInitialRepo(repoPath, captureRepo) {
|
|
|
128
148
|
}
|
|
129
149
|
|
|
130
150
|
function registerDirectory(dirPath, parentUUID, {
|
|
131
|
-
registry, shouldEnroll, captureRepo, repoStates, identityAt, seeded, relativePath,
|
|
151
|
+
registry, shouldEnroll, captureRepo, repoStates, identityAt, repoStateAt, repoStateFor, seeded, relativePath,
|
|
132
152
|
}) {
|
|
133
153
|
let entries;
|
|
134
154
|
try {
|
|
@@ -157,20 +177,21 @@ function registerDirectory(dirPath, parentUUID, {
|
|
|
157
177
|
}
|
|
158
178
|
if (stat.isDirectory()) {
|
|
159
179
|
const type = isRepository(entryPath) ? 'repo.git' : 'folder';
|
|
160
|
-
const state = type === 'repo.git' ? captureInitialRepo(entryPath, captureRepo) : null;
|
|
161
180
|
const identity = seeded(entryRelativePath, {
|
|
162
181
|
type, parentUUID, name: entry.name, root: false,
|
|
163
182
|
});
|
|
183
|
+
const state = type === 'repo.git' ? repoStateFor(identity, entryPath) : null;
|
|
184
|
+
const uuid = identity.uuid || (state ? crypto.randomUUID() : undefined);
|
|
164
185
|
const record = registry.create({
|
|
165
186
|
type: identity.type,
|
|
166
187
|
parentUUID,
|
|
167
188
|
name: entry.name,
|
|
168
189
|
payloadVersion: state?.stateId ?? identity.payloadVersion ?? null,
|
|
169
|
-
uuid
|
|
190
|
+
uuid,
|
|
170
191
|
});
|
|
171
192
|
if (state) repoStates.put(record.uuid, state);
|
|
172
193
|
registerDirectory(entryPath, record.uuid, {
|
|
173
|
-
registry, shouldEnroll, captureRepo, repoStates, identityAt, seeded, relativePath: entryRelativePath,
|
|
194
|
+
registry, shouldEnroll, captureRepo, repoStates, identityAt, repoStateAt, repoStateFor, seeded, relativePath: entryRelativePath,
|
|
174
195
|
});
|
|
175
196
|
continue;
|
|
176
197
|
}
|
|
@@ -240,7 +240,7 @@ async function handleEntityRoutes(ctx) {
|
|
|
240
240
|
const body = await ctx.readJsonBody();
|
|
241
241
|
const service = registrationService();
|
|
242
242
|
try {
|
|
243
|
-
const received = service.
|
|
243
|
+
const received = service.receiveCloudContent({
|
|
244
244
|
resourceId: body?.resourceId,
|
|
245
245
|
contentHash: body?.contentHash,
|
|
246
246
|
kind: body?.kind,
|
|
@@ -248,7 +248,6 @@ async function handleEntityRoutes(ctx) {
|
|
|
248
248
|
sha256: body?.sha256,
|
|
249
249
|
dataBase64: body?.dataBase64,
|
|
250
250
|
});
|
|
251
|
-
service.advanceMaterializations();
|
|
252
251
|
ctx.sendJson(200, { ...received });
|
|
253
252
|
} catch (error) {
|
|
254
253
|
ctx.sendJson(400, { error: error.message });
|
|
@@ -290,12 +289,12 @@ async function handleEntityRoutes(ctx) {
|
|
|
290
289
|
const body = await ctx.readJsonBody();
|
|
291
290
|
const service = registrationService();
|
|
292
291
|
try {
|
|
293
|
-
|
|
294
|
-
registry: service.registration.registry,
|
|
292
|
+
const ingested = service.ingestCloudMutation({
|
|
295
293
|
envelope: body?.envelope,
|
|
296
294
|
version: body?.version,
|
|
297
295
|
committedAt: body?.committedAt,
|
|
298
|
-
})
|
|
296
|
+
});
|
|
297
|
+
ctx.sendJson(200, ingested);
|
|
299
298
|
} catch (error) {
|
|
300
299
|
ctx.sendJson(409, { error: error.message });
|
|
301
300
|
}
|
|
@@ -76,6 +76,56 @@ test('a signed-in registration is refused until the cloud entity resource is act
|
|
|
76
76
|
assert.doesNotThrow(() => cloud.assertRegistrationReady());
|
|
77
77
|
});
|
|
78
78
|
|
|
79
|
+
test('a large registration replaces the complete graph in one cloud operation and a recipient waits for its compacted snapshot', () => {
|
|
80
|
+
const db = new Database(':memory:');
|
|
81
|
+
const cloud = createEntityCloud({ db, userId: 'user-1', deviceId: 'device-1' });
|
|
82
|
+
const registry = createRegistry({ db, ephemeral: true, emit: () => {}, journal: cloud.journal });
|
|
83
|
+
const root = registry.enrollRoot({ type: 'workspace', name: 'large-ground' });
|
|
84
|
+
const bootstrap = cloud.bootstrap(registry);
|
|
85
|
+
cloud.install({
|
|
86
|
+
registry,
|
|
87
|
+
resourceId: bootstrap.resourceId,
|
|
88
|
+
authorityEpoch: 1,
|
|
89
|
+
headVersion: 0,
|
|
90
|
+
snapshotBase64: bootstrap.snapshotBase64,
|
|
91
|
+
snapshotChecksum: bootstrap.snapshotChecksum,
|
|
92
|
+
});
|
|
93
|
+
registry.withoutJournal(() => {
|
|
94
|
+
for (let index = 0; index < 600; index += 1) {
|
|
95
|
+
registry.create({ type: 'folder', parentUUID: root.uuid, name: `folder-${index}` });
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
const replacement = cloud.replaceSnapshot(registry.syncRecords());
|
|
99
|
+
assert.equal(replacement.records, 601);
|
|
100
|
+
assert.equal(db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 1);
|
|
101
|
+
const claim = cloud.claim();
|
|
102
|
+
assert.equal(claim.length, 1);
|
|
103
|
+
assert.equal(claim[0].operationKind, 'entity.snapshot.replace');
|
|
104
|
+
assert.equal(claim[0].operation.snapshot.records.length, 601);
|
|
105
|
+
|
|
106
|
+
const recipientDb = new Database(':memory:');
|
|
107
|
+
const recipient = createEntityCloud({ db: recipientDb, userId: 'user-1', deviceId: 'device-2' });
|
|
108
|
+
const recipientRegistry = createRegistry({ db: recipientDb, ephemeral: true, emit: () => {}, journal: recipient.journal });
|
|
109
|
+
recipient.install({
|
|
110
|
+
registry: recipientRegistry,
|
|
111
|
+
resourceId: bootstrap.resourceId,
|
|
112
|
+
authorityEpoch: 1,
|
|
113
|
+
headVersion: 0,
|
|
114
|
+
snapshotBase64: bootstrap.snapshotBase64,
|
|
115
|
+
snapshotChecksum: bootstrap.snapshotChecksum,
|
|
116
|
+
});
|
|
117
|
+
const inbound = recipient.ingest({
|
|
118
|
+
registry: recipientRegistry,
|
|
119
|
+
envelope: { ...claim[0], operationKind: 'entity.snapshot.replace' },
|
|
120
|
+
version: 1,
|
|
121
|
+
committedAt: '2026-08-05T00:00:00.000Z',
|
|
122
|
+
});
|
|
123
|
+
assert.equal(inbound.snapshotRequired, true);
|
|
124
|
+
assert.equal(recipient.replica().headVersion, 1);
|
|
125
|
+
assert.deepEqual(recipientRegistry.syncRecords(), [], 'the replacement tail never becomes partially bound local identity');
|
|
126
|
+
recipientDb.close();
|
|
127
|
+
});
|
|
128
|
+
|
|
79
129
|
test('only an exact verified bootstrap graph can repair a legacy empty entity resource', () => {
|
|
80
130
|
const db = new Database(':memory:');
|
|
81
131
|
const cloud = createEntityCloud({ db, userId: 'user-1', deviceId: 'device-1' });
|