amalgm 0.1.249 → 0.1.250
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 +50 -2
- package/runtime/scripts/amalgm-mcp/registration/index.js +1 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +178 -1
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +4 -5
- package/runtime/scripts/amalgm-mcp/tests/entity-materialization.test.js +105 -0
package/lib/cli.js
CHANGED
|
@@ -1456,6 +1456,8 @@ async function register(positionals) {
|
|
|
1456
1456
|
const targets = (positionals.length > 0 ? positionals : [process.cwd()])
|
|
1457
1457
|
.map((target) => path.resolve(target));
|
|
1458
1458
|
|
|
1459
|
+
console.log(`Registering ${targets.length === 1 ? targets[0] : `${targets.length} locations`}…`);
|
|
1460
|
+
|
|
1459
1461
|
// A resolved port is not a running runtime — the manifest supplies a
|
|
1460
1462
|
// default whether or not anything listens — so both "no port for this
|
|
1461
1463
|
// scope" and "nothing answered" mean the same thing to the user.
|
|
@@ -1487,6 +1489,8 @@ async function register(positionals) {
|
|
|
1487
1489
|
const target = targets[index];
|
|
1488
1490
|
console.log(`Registered ${rootPath}`);
|
|
1489
1491
|
console.log(` ${record.type} ${record.uuid}`);
|
|
1492
|
+
console.log(' Watching: on');
|
|
1493
|
+
console.log(' Cloud: queued');
|
|
1490
1494
|
if (rootPath !== target) console.log(` (${target} is already covered by this tree)`);
|
|
1491
1495
|
}
|
|
1492
1496
|
}
|
|
@@ -1504,6 +1508,7 @@ async function add(positionals) {
|
|
|
1504
1508
|
throw new Error('amalgm add requires a cloud entity UUID');
|
|
1505
1509
|
}
|
|
1506
1510
|
const targetPath = path.resolve(positionals[1] || process.cwd());
|
|
1511
|
+
console.log(`Adding ${uuid} to ${targetPath}…`);
|
|
1507
1512
|
const port = servicePorts().find(([name]) => name === 'amalgm-mcp')?.[1];
|
|
1508
1513
|
let answer = null;
|
|
1509
1514
|
if (port) {
|
|
@@ -1539,6 +1544,9 @@ async function add(positionals) {
|
|
|
1539
1544
|
return;
|
|
1540
1545
|
}
|
|
1541
1546
|
let job = answer.payload;
|
|
1547
|
+
if (job?.state === 'waiting_for_content' || job?.state === 'materializing') {
|
|
1548
|
+
console.log(' Downloading cloud content…');
|
|
1549
|
+
}
|
|
1542
1550
|
const deadline = Date.now() + 120_000;
|
|
1543
1551
|
while (job?.state === 'waiting_for_content' || job?.state === 'materializing') {
|
|
1544
1552
|
if (Date.now() > deadline) {
|
|
@@ -1555,6 +1563,8 @@ async function add(positionals) {
|
|
|
1555
1563
|
return;
|
|
1556
1564
|
}
|
|
1557
1565
|
console.log(`Added ${job.entityUuid} to ${job.targetPath}`);
|
|
1566
|
+
console.log(' Watching: on');
|
|
1567
|
+
console.log(' Cloud: live');
|
|
1558
1568
|
}
|
|
1559
1569
|
|
|
1560
1570
|
/** POST JSON to a local runtime service, carrying the runtime token. */
|
package/lib/runtime-identity.js
CHANGED
|
@@ -50,7 +50,10 @@ function runtimeBranchForLabel(label, env = process.env) {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
function npmTagForRuntimeLabel(label) {
|
|
53
|
-
|
|
53
|
+
// Releases are promoted deliberately to `main`; `latest` is retained as
|
|
54
|
+
// the public compatibility channel and may therefore lag the runtime we
|
|
55
|
+
// boot. A main installation must update from the channel it actually runs.
|
|
56
|
+
return label === 'preview' ? 'canary' : 'main';
|
|
54
57
|
}
|
|
55
58
|
|
|
56
59
|
function sanitizeScopeSegment(value, fallback = 'local') {
|
package/package.json
CHANGED
|
@@ -613,6 +613,45 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
613
613
|
return materialization(db.prepare('SELECT * FROM entity_materializations WHERE job_id = ?').get(jobId));
|
|
614
614
|
}
|
|
615
615
|
|
|
616
|
+
/** A completed add is a live binding, not a one-time download. A remote
|
|
617
|
+
* record beneath that binding moves the durable job back to its content
|
|
618
|
+
* gate. The service owns the later filesystem application; this module
|
|
619
|
+
* owns only the durable intent to do it. */
|
|
620
|
+
function refreshMaterializations({ resourceId, record, version }) {
|
|
621
|
+
const refreshed = [];
|
|
622
|
+
for (const job of db.prepare(`
|
|
623
|
+
SELECT * FROM entity_materializations
|
|
624
|
+
WHERE resource_id = ? AND state = 'complete'
|
|
625
|
+
ORDER BY created_at ASC
|
|
626
|
+
`).all(resourceId)) {
|
|
627
|
+
const tree = catalogTree({ resourceId, uuid: job.entity_uuid });
|
|
628
|
+
if (!tree.some((candidate) => candidate.uuid === record.uuid)) continue;
|
|
629
|
+
db.prepare(`
|
|
630
|
+
UPDATE entity_materializations
|
|
631
|
+
SET head_version = ?, state = 'waiting_for_content', error = NULL, updated_at = ?
|
|
632
|
+
WHERE job_id = ?
|
|
633
|
+
`).run(Number(version), now(), job.job_id);
|
|
634
|
+
refreshed.push(job.job_id);
|
|
635
|
+
}
|
|
636
|
+
return refreshed;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function hasUnsettledLocalMutation(uuid) {
|
|
640
|
+
const rows = db.prepare(`
|
|
641
|
+
SELECT operation_json FROM entity_cloud_mutations
|
|
642
|
+
WHERE resource_id = ? AND mutation_state <> 'synced'
|
|
643
|
+
ORDER BY sequence ASC
|
|
644
|
+
`).all(replica()?.resourceId || '');
|
|
645
|
+
return rows.some((row) => {
|
|
646
|
+
try {
|
|
647
|
+
return JSON.parse(row.operation_json)?.record?.uuid === uuid;
|
|
648
|
+
} catch {
|
|
649
|
+
// A corrupt local outbox must never authorize an overwrite.
|
|
650
|
+
return true;
|
|
651
|
+
}
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
|
|
616
655
|
function claim({ limit = 16, leaseMs = 30_000 } = {}) {
|
|
617
656
|
const current = replica();
|
|
618
657
|
if (!current || !['active', BOOTSTRAP_RECONCILIATION_STATE].includes(current.state)) return [];
|
|
@@ -781,9 +820,13 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
781
820
|
}
|
|
782
821
|
// Remote mutations update the cloud-only catalog. They never create a
|
|
783
822
|
// local watcher; `amalgm add` alone turns catalog identity into ground.
|
|
823
|
+
// A pre-existing local record is normally a conflict, except for a
|
|
824
|
+
// completed add binding with no unsent local edit. That binding will
|
|
825
|
+
// apply the authoritative cloud record through its own watcher-safe
|
|
826
|
+
// materializer below; it must not be stranded at its first snapshot.
|
|
784
827
|
let local;
|
|
785
828
|
try { local = registry.syncRecord(record.uuid); } catch { local = null; }
|
|
786
|
-
if (local && !sameRecord(local, record)) {
|
|
829
|
+
if (local && !sameRecord(local, record) && hasUnsettledLocalMutation(record.uuid)) {
|
|
787
830
|
throw new Error('remote entity mutation conflicts with this device; refresh the compacted cloud head before applying it locally');
|
|
788
831
|
}
|
|
789
832
|
upsertCatalogRecord({
|
|
@@ -793,7 +836,11 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
793
836
|
record,
|
|
794
837
|
});
|
|
795
838
|
if (Number(version) > current.headVersion) saveReplica({ ...current, headVersion: Number(version) });
|
|
796
|
-
return {
|
|
839
|
+
return {
|
|
840
|
+
duplicate: true,
|
|
841
|
+
version: Number(version),
|
|
842
|
+
materializations: refreshMaterializations({ resourceId: current.resourceId, record, version }),
|
|
843
|
+
};
|
|
797
844
|
}
|
|
798
845
|
|
|
799
846
|
return {
|
|
@@ -816,6 +863,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
|
|
|
816
863
|
completeMaterialization,
|
|
817
864
|
failMaterialization,
|
|
818
865
|
materializationStatus,
|
|
866
|
+
hasUnsettledLocalMutation,
|
|
819
867
|
catalogTree,
|
|
820
868
|
replica,
|
|
821
869
|
};
|
|
@@ -744,6 +744,7 @@ function createRegistration(options) {
|
|
|
744
744
|
|
|
745
745
|
/** The latest edge pass's refusals — visible, never silent. */
|
|
746
746
|
reported: () => [...reported],
|
|
747
|
+
addressOf,
|
|
747
748
|
repoState: (uuid) => repoStates.get(uuid),
|
|
748
749
|
tree: treeAt,
|
|
749
750
|
trees: () => new Map(treeRoots()
|
|
@@ -369,11 +369,167 @@ function createRegistrationService({
|
|
|
369
369
|
}
|
|
370
370
|
}
|
|
371
371
|
|
|
372
|
+
/** The local replica of `amalgm add` is Amalgm-managed ground. This is
|
|
373
|
+
* deliberately not a two-way directory merger: if local bytes have drifted
|
|
374
|
+
* from the last authoritative record, the import stops rather than choosing
|
|
375
|
+
* a winner behind the user's back. Otherwise a remote cloud head is
|
|
376
|
+
* projected into the existing binding, with journal suppression so the
|
|
377
|
+
* receiver never echoes its own download back to cloud. */
|
|
378
|
+
function applyLiveCloudTree(job) {
|
|
379
|
+
const tree = entityCloud.catalogTree({ resourceId: job.resourceId, uuid: job.entityUuid });
|
|
380
|
+
const root = tree[0];
|
|
381
|
+
const active = tree.filter((record) => record.status === 'active');
|
|
382
|
+
const unsupported = tree.find((record) => ['repo.git', 'link', 'reference'].includes(record.type));
|
|
383
|
+
if (unsupported) {
|
|
384
|
+
throw new Error(`live cloud add for ${unsupported.type} is not available yet; the binding was left unchanged`);
|
|
385
|
+
}
|
|
386
|
+
if (!['workspace', 'file.text', 'file.binary'].includes(root.type)) {
|
|
387
|
+
throw new Error(`cloud root ${root.uuid} is ${root.type}; this live materializer supports workspace and file trees only`);
|
|
388
|
+
}
|
|
389
|
+
for (const record of active) {
|
|
390
|
+
if (['file.text', 'file.binary'].includes(record.type)
|
|
391
|
+
&& !entityContent.complete({ contentHash: record.payloadVersion })) return false;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const parent = fs.realpathSync(job.targetPath);
|
|
395
|
+
const destination = path.join(parent, root.name);
|
|
396
|
+
const boundRoot = boundary.current.tree(destination)?.root();
|
|
397
|
+
if (!boundRoot || boundRoot.uuid !== root.uuid) {
|
|
398
|
+
throw new Error(`cloud binding ${root.uuid} no longer owns ${destination}; refusing to overwrite local ground`);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const children = new Map();
|
|
402
|
+
for (const record of active) {
|
|
403
|
+
if (record.parentUUID === null) continue;
|
|
404
|
+
const group = children.get(record.parentUUID) || [];
|
|
405
|
+
group.push(record);
|
|
406
|
+
children.set(record.parentUUID, group);
|
|
407
|
+
}
|
|
408
|
+
for (const group of children.values()) group.sort((left, right) => left.name.localeCompare(right.name));
|
|
409
|
+
|
|
410
|
+
function localRecord(uuid) {
|
|
411
|
+
try { return boundary.current.registry.syncRecord(uuid); } catch { return null; }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function addressOf(uuid) {
|
|
415
|
+
try { return boundary.current.addressOf(uuid); } catch { return null; }
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function fileHash(file) {
|
|
419
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Before changing logical identity, prove that every local byte we could
|
|
423
|
+
// overwrite is still the byte named by the old authoritative record.
|
|
424
|
+
// An unsent local edit or an out-of-band edit is a conflict, never an
|
|
425
|
+
// implicit cloud-wins overwrite.
|
|
426
|
+
for (const record of tree) {
|
|
427
|
+
const current = localRecord(record.uuid);
|
|
428
|
+
if (!current || current.version === record.version) continue;
|
|
429
|
+
if (entityCloud.hasUnsettledLocalMutation(record.uuid)) {
|
|
430
|
+
throw new Error(`local change for ${record.uuid} is still waiting for cloud; refusing to overwrite it`);
|
|
431
|
+
}
|
|
432
|
+
if (!['file.text', 'file.binary'].includes(current.type)) continue;
|
|
433
|
+
const currentPath = addressOf(record.uuid);
|
|
434
|
+
if (!currentPath || !fs.existsSync(currentPath)) {
|
|
435
|
+
throw new Error(`local file for ${record.uuid} is missing; refusing to overwrite an unverified binding`);
|
|
436
|
+
}
|
|
437
|
+
if (fileHash(currentPath) !== current.payloadVersion) {
|
|
438
|
+
throw new Error(`local file ${currentPath} changed outside Amalgm; refusing to overwrite it`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function applyKnownRecords() {
|
|
443
|
+
// Catalog container versions are deliberately regenerated by the
|
|
444
|
+
// authority after child mutations. Applying an unchanged container
|
|
445
|
+
// before its later child would therefore compare an old derived version.
|
|
446
|
+
// Only import records whose stored logical fields actually changed; the
|
|
447
|
+
// local registry derives the same container version after the child lands.
|
|
448
|
+
const depth = new Map([[root.uuid, 0]]);
|
|
449
|
+
for (const record of tree) {
|
|
450
|
+
if (record.parentUUID !== null) depth.set(record.uuid, (depth.get(record.parentUUID) || 0) + 1);
|
|
451
|
+
}
|
|
452
|
+
for (const record of [...tree].sort((left, right) => (depth.get(left.uuid) || 0) - (depth.get(right.uuid) || 0))) {
|
|
453
|
+
const current = localRecord(record.uuid);
|
|
454
|
+
if (current && current.type === record.type && current.parentUUID === record.parentUUID
|
|
455
|
+
&& current.name === record.name && current.status === record.status
|
|
456
|
+
&& current.payloadVersion === record.payloadVersion) continue;
|
|
457
|
+
boundary.current.registry.applyCloudRecord(record);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function atomicWrite(file, bytes) {
|
|
462
|
+
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.amalgm-live-${crypto.randomBytes(10).toString('hex')}`);
|
|
463
|
+
try {
|
|
464
|
+
fs.writeFileSync(temporary, bytes, { mode: 0o600, flag: 'wx' });
|
|
465
|
+
fs.renameSync(temporary, file);
|
|
466
|
+
} finally {
|
|
467
|
+
fs.rmSync(temporary, { force: true });
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function removeInactiveChild(parentRecord, name, target) {
|
|
472
|
+
const known = boundary.current.registry.children(parentRecord.uuid)
|
|
473
|
+
.find((candidate) => candidate.name === name);
|
|
474
|
+
if (!known || known.status === 'active') {
|
|
475
|
+
throw new Error(`local item ${target} is not owned by this cloud binding; refusing to remove it`);
|
|
476
|
+
}
|
|
477
|
+
if (['file.text', 'file.binary'].includes(known.type) && fs.existsSync(target)) {
|
|
478
|
+
const previous = localRecord(known.uuid);
|
|
479
|
+
if (previous?.payloadVersion && fileHash(target) !== previous.payloadVersion) {
|
|
480
|
+
throw new Error(`local file ${target} changed outside Amalgm; refusing to remove it`);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function project(record, target) {
|
|
487
|
+
if (record.type === 'workspace' || record.type === 'folder') {
|
|
488
|
+
if (fs.existsSync(target)) {
|
|
489
|
+
if (!fs.statSync(target).isDirectory()) {
|
|
490
|
+
throw new Error(`cloud container ${record.uuid} conflicts with local non-directory ${target}`);
|
|
491
|
+
}
|
|
492
|
+
} else {
|
|
493
|
+
fs.mkdirSync(target, { mode: 0o700 });
|
|
494
|
+
}
|
|
495
|
+
const wanted = new Set((children.get(record.uuid) || []).map((child) => child.name));
|
|
496
|
+
for (const child of children.get(record.uuid) || []) project(child, path.join(target, child.name));
|
|
497
|
+
for (const entry of fs.readdirSync(target)) {
|
|
498
|
+
if (!wanted.has(entry)) removeInactiveChild(record, entry, path.join(target, entry));
|
|
499
|
+
}
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
if (!['file.text', 'file.binary'].includes(record.type)) {
|
|
503
|
+
throw new Error(`cloud entity ${record.uuid} is ${record.type}; this live materializer cannot write it`);
|
|
504
|
+
}
|
|
505
|
+
if (fs.existsSync(target) && fs.statSync(target).isDirectory()) {
|
|
506
|
+
throw new Error(`cloud file ${record.uuid} conflicts with local directory ${target}`);
|
|
507
|
+
}
|
|
508
|
+
const bytes = entityContent.bytes({ contentHash: record.payloadVersion });
|
|
509
|
+
if (fs.existsSync(target) && Buffer.compare(fs.readFileSync(target), bytes) === 0) return;
|
|
510
|
+
atomicWrite(target, bytes);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
boundary.current.registry.withoutJournal(() => {
|
|
514
|
+
applyKnownRecords();
|
|
515
|
+
project(root, destination);
|
|
516
|
+
const observerRoot = observer.listRoots().find((entry) => entry.path === destination);
|
|
517
|
+
if (!observerRoot) throw new Error(`cloud binding ${root.uuid} has no local watcher root at ${destination}`);
|
|
518
|
+
observer.scanNow(observerRoot.rootId);
|
|
519
|
+
boundary.current.flushIfDirty();
|
|
520
|
+
});
|
|
521
|
+
return { destination, root };
|
|
522
|
+
}
|
|
523
|
+
|
|
372
524
|
function advanceMaterializations() {
|
|
373
525
|
for (const job of entityCloud.pendingMaterializations()) {
|
|
374
526
|
if (job.state !== 'waiting_for_content') continue;
|
|
375
527
|
try {
|
|
376
|
-
const
|
|
528
|
+
const root = entityCloud.catalogTree({ resourceId: job.resourceId, uuid: job.entityUuid })[0];
|
|
529
|
+
const destination = path.join(fs.realpathSync(job.targetPath), root.name);
|
|
530
|
+
const result = boundary.current.tree(destination)?.root()?.uuid === root.uuid
|
|
531
|
+
? applyLiveCloudTree(job)
|
|
532
|
+
: materializeCloudTree(job);
|
|
377
533
|
if (result) entityCloud.completeMaterialization({ jobId: job.jobId });
|
|
378
534
|
} catch (error) {
|
|
379
535
|
entityCloud.failMaterialization({ jobId: job.jobId, message: error.message });
|
|
@@ -402,6 +558,27 @@ function createRegistrationService({
|
|
|
402
558
|
advanceMaterializations();
|
|
403
559
|
return entityCloud.materializationStatus(job.jobId);
|
|
404
560
|
},
|
|
561
|
+
/** The inbound realtime rail is part of the add binding. Routes call
|
|
562
|
+
* these composition methods so receiving an authoritative record or one
|
|
563
|
+
* of its immutable content parts immediately advances the local replica;
|
|
564
|
+
* users never run a separate sync command. */
|
|
565
|
+
ingestCloudMutation: ({ envelope, version, committedAt }) => {
|
|
566
|
+
const ingested = entityCloud.ingest({
|
|
567
|
+
registry: boundary.current.registry,
|
|
568
|
+
envelope,
|
|
569
|
+
version,
|
|
570
|
+
committedAt,
|
|
571
|
+
});
|
|
572
|
+
advanceMaterializations();
|
|
573
|
+
return ingested;
|
|
574
|
+
},
|
|
575
|
+
receiveCloudContent: ({ resourceId, contentHash, kind, partIndex, sha256, dataBase64 }) => {
|
|
576
|
+
const received = entityCloud.receiveDownload({
|
|
577
|
+
resourceId, contentHash, kind, partIndex, sha256, dataBase64,
|
|
578
|
+
});
|
|
579
|
+
advanceMaterializations();
|
|
580
|
+
return received;
|
|
581
|
+
},
|
|
405
582
|
advanceMaterializations,
|
|
406
583
|
bootstrapUserHome,
|
|
407
584
|
exactUserHomeCloudRecords,
|
|
@@ -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
|
}
|
|
@@ -116,3 +116,108 @@ test('add materializes one compacted cloud head, preserves its UUIDs, and starts
|
|
|
116
116
|
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
117
117
|
}
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
test('an added ordinary workspace stays live: a later cloud file head replaces local bytes without producing an echo mutation', async () => {
|
|
121
|
+
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-add-live-materialization-'));
|
|
122
|
+
const sourceDir = path.join(rootDir, 'source');
|
|
123
|
+
const targetState = path.join(rootDir, 'target-state');
|
|
124
|
+
const targetParent = path.join(rootDir, 'checkout');
|
|
125
|
+
fs.mkdirSync(sourceDir, { recursive: true });
|
|
126
|
+
fs.mkdirSync(targetParent, { recursive: true });
|
|
127
|
+
|
|
128
|
+
const firstBytes = Buffer.from('first authoritative content\n');
|
|
129
|
+
const firstHash = sha256(firstBytes);
|
|
130
|
+
fs.writeFileSync(path.join(sourceDir, 'readme.md'), firstBytes);
|
|
131
|
+
const sourceDb = new Database(':memory:');
|
|
132
|
+
const sourceContent = createEntityContent({ dir: sourceDir });
|
|
133
|
+
const sourceCloud = createEntityCloud({
|
|
134
|
+
db: sourceDb, userId: 'user-1', deviceId: 'source-device', content: sourceContent,
|
|
135
|
+
});
|
|
136
|
+
const sourceRegistry = createRegistry({ db: sourceDb, ephemeral: true, emit: () => {}, journal: sourceCloud.journal });
|
|
137
|
+
const root = sourceRegistry.enrollRoot({ type: 'workspace', name: 'project' });
|
|
138
|
+
const file = sourceRegistry.create({
|
|
139
|
+
type: 'file.text', parentUUID: root.uuid, name: 'readme.md', payloadVersion: firstHash,
|
|
140
|
+
});
|
|
141
|
+
sourceContent.captureFile({ filePath: path.join(sourceDir, 'readme.md'), contentHash: firstHash });
|
|
142
|
+
const initialParts = uploadedParts(sourceContent, sourceCloud.identity().resourceId, firstHash);
|
|
143
|
+
const snapshot = snapshotFromRecords(sourceRegistry.syncRecords());
|
|
144
|
+
const snapshotBytes = Buffer.from(JSON.stringify(snapshot), 'utf8');
|
|
145
|
+
|
|
146
|
+
const target = createRegistrationService({
|
|
147
|
+
dir: targetState,
|
|
148
|
+
homeDir: path.join(rootDir, 'target-home'),
|
|
149
|
+
cloudUserId: 'user-1',
|
|
150
|
+
cloudDeviceId: 'target-device',
|
|
151
|
+
});
|
|
152
|
+
try {
|
|
153
|
+
target.entityCloud.install({
|
|
154
|
+
registry: target.registration.registry,
|
|
155
|
+
resourceId: sourceCloud.identity().resourceId,
|
|
156
|
+
authorityEpoch: 7,
|
|
157
|
+
headVersion: 41,
|
|
158
|
+
snapshotBase64: snapshotBytes.toString('base64'),
|
|
159
|
+
snapshotChecksum: sha256(snapshotBytes),
|
|
160
|
+
});
|
|
161
|
+
for (const part of [...initialParts.filter((part) => part.kind === 'manifest'), ...initialParts.filter((part) => part.kind === 'chunk')]) {
|
|
162
|
+
target.entityCloud.receiveDownload({
|
|
163
|
+
resourceId: sourceCloud.identity().resourceId,
|
|
164
|
+
contentHash: firstHash,
|
|
165
|
+
kind: part.kind,
|
|
166
|
+
partIndex: part.kind === 'manifest' ? 0 : part.partIndex,
|
|
167
|
+
sha256: part.sha256,
|
|
168
|
+
dataBase64: part.dataBase64,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
assert.equal((await target.add({ uuid: root.uuid, targetPath: targetParent })).state, 'complete');
|
|
172
|
+
|
|
173
|
+
const secondBytes = Buffer.from('second authoritative content\n');
|
|
174
|
+
const secondHash = sha256(secondBytes);
|
|
175
|
+
fs.writeFileSync(path.join(sourceDir, 'readme.md'), secondBytes);
|
|
176
|
+
sourceContent.captureFile({ filePath: path.join(sourceDir, 'readme.md'), contentHash: secondHash });
|
|
177
|
+
const secondParts = uploadedParts(sourceContent, sourceCloud.identity().resourceId, secondHash);
|
|
178
|
+
const changed = sourceRegistry.attest(file.uuid, { type: 'file.text', payloadVersion: secondHash });
|
|
179
|
+
const remoteRecord = sourceRegistry.syncRecord(changed.uuid);
|
|
180
|
+
|
|
181
|
+
const ingested = target.ingestCloudMutation({
|
|
182
|
+
envelope: {
|
|
183
|
+
resourceId: sourceCloud.identity().resourceId,
|
|
184
|
+
mutationId: 'ent_remote_file_change',
|
|
185
|
+
contract: 'amalgm-entity-registry@1',
|
|
186
|
+
schemaVersion: 1,
|
|
187
|
+
operationKind: 'entity.upsert',
|
|
188
|
+
operation: { kind: 'entity.upsert', record: remoteRecord },
|
|
189
|
+
},
|
|
190
|
+
version: 42,
|
|
191
|
+
committedAt: '2026-08-05T00:00:00.000Z',
|
|
192
|
+
});
|
|
193
|
+
assert.equal(ingested.materializations.length, 1, 'the completed add became a live pending application');
|
|
194
|
+
assert.equal(fs.readFileSync(path.join(targetParent, 'project', 'readme.md'), 'utf8'), firstBytes.toString(),
|
|
195
|
+
'the destination waits for verified immutable bytes');
|
|
196
|
+
|
|
197
|
+
for (const part of [...secondParts.filter((part) => part.kind === 'manifest'), ...secondParts.filter((part) => part.kind === 'chunk')]) {
|
|
198
|
+
target.receiveCloudContent({
|
|
199
|
+
resourceId: sourceCloud.identity().resourceId,
|
|
200
|
+
contentHash: secondHash,
|
|
201
|
+
kind: part.kind,
|
|
202
|
+
partIndex: part.kind === 'manifest' ? 0 : part.partIndex,
|
|
203
|
+
sha256: part.sha256,
|
|
204
|
+
dataBase64: part.dataBase64,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
assert.deepEqual(fs.readFileSync(path.join(targetParent, 'project', 'readme.md')), secondBytes);
|
|
208
|
+
assert.equal(target.registration.registry.syncRecord(file.uuid).payloadVersion, secondHash);
|
|
209
|
+
assert.equal(target.observer.status().find((entry) => entry.path === fs.realpathSync(path.join(targetParent, 'project')))?.watching, true);
|
|
210
|
+
|
|
211
|
+
const targetDb = new Database(path.join(targetState, 'identity.db'), { readonly: true });
|
|
212
|
+
try {
|
|
213
|
+
assert.equal(targetDb.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 0,
|
|
214
|
+
'the receiving watcher did not echo the imported cloud edit as a new local mutation');
|
|
215
|
+
} finally {
|
|
216
|
+
targetDb.close();
|
|
217
|
+
}
|
|
218
|
+
} finally {
|
|
219
|
+
target.close();
|
|
220
|
+
sourceDb.close();
|
|
221
|
+
fs.rmSync(rootDir, { recursive: true, force: true });
|
|
222
|
+
}
|
|
223
|
+
});
|