amalgm 0.1.252 → 0.1.254

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.252",
3
+ "version": "0.1.254",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -600,14 +600,14 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
600
600
  if (typeof targetPath !== 'string' || !targetPath) throw new Error('materialization requires a destination path');
601
601
  const tree = catalogTree({ resourceId: current.resourceId, uuid });
602
602
  if (tree[0].status !== 'active') throw new Error(`cloud entity ${uuid} is not active`);
603
- const unsupported = tree.find((record) => ['link', 'reference'].includes(record.type));
603
+ const unsupported = tree.find((record) => record.type === 'reference');
604
604
  if (unsupported) {
605
- throw new Error(`cloud entity ${unsupported.uuid} is ${unsupported.type}; this first materializer supports ordinary workspace and file trees only`);
605
+ throw new Error(`cloud entity ${unsupported.uuid} is a managed reference; reference projection is not materialized by amalgm add yet`);
606
606
  }
607
607
  const prior = db.prepare(`
608
608
  SELECT * FROM entity_materializations
609
609
  WHERE resource_id = ? AND entity_uuid = ? AND target_path = ?
610
- AND state IN ('waiting_for_content', 'materializing', 'complete')
610
+ AND state IN ('waiting_for_content', 'materializing')
611
611
  ORDER BY created_at DESC LIMIT 1
612
612
  `).get(current.resourceId, tree[0].uuid, targetPath);
613
613
  if (prior) return materialization(prior);
@@ -505,8 +505,12 @@ function createRegistration(options) {
505
505
  }
506
506
  const covering = treeRootCovering(ground);
507
507
  if (covering) {
508
- ensureTrees();
509
- tryFollow();
508
+ // This address is already registered ground. Its prior Register →
509
+ // Watch transition remains the proof of its identity; repeating a
510
+ // harmless "ensure the safe home exists" request must not rescan every
511
+ // edge and repository in every tree. Doorbells own later convergence.
512
+ // We still prove the one property this caller may rely on now: that
513
+ // the existing watcher is live (and retry its own failed birth once).
510
514
  requireLiveWatchers(covering.path);
511
515
  return { rootPath: covering.path, record: rootRecordOf(covering.rootId) };
512
516
  }
@@ -546,11 +550,21 @@ function createRegistration(options) {
546
550
  // Start's first census must run before a parent lens is allowed to
547
551
  // conclude that a pre-registered nested repo is absent: unobserved is
548
552
  // not absent. Only then do evidence lenses reconcile that census.
549
- observer.start(root.rootId);
550
- ensureTrees();
551
- reconcileInitialRepoWorktrees(root.path);
552
- flushIfDirty();
553
- requireLiveWatchers(root.path);
553
+ //
554
+ // An imported cloud tree is different only in authority, not in
555
+ // mechanics: its first census supplies local observer evidence but must
556
+ // never turn the authoritative snapshot back into a fresh outbound
557
+ // mutation. Keep the whole first Watch/Detect transition inside the
558
+ // existing journal boundary used for the seeded structural bind.
559
+ const startWatch = () => {
560
+ observer.start(root.rootId);
561
+ ensureTrees();
562
+ reconcileInitialRepoWorktrees(root.path);
563
+ flushIfDirty();
564
+ requireLiveWatchers(root.path);
565
+ };
566
+ if (suppressJournal) registry.withoutJournal(startWatch);
567
+ else startWatch();
554
568
  return { rootPath: root.path, record: rootRecordOf(root.rootId) };
555
569
  }
556
570
 
@@ -376,15 +376,18 @@ function createRegistrationService({
376
376
  return false;
377
377
  }
378
378
  }
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`);
379
+ if (!['workspace', 'repo.git', 'file.text', 'file.binary', 'link'].includes(root.type)) {
380
+ throw new Error(`cloud root ${root.uuid} is ${root.type}; this materializer supports workspace, repository, file, and link trees only`);
381
381
  }
382
382
 
383
383
  const parent = fs.realpathSync(job.targetPath);
384
384
  const parentStat = fs.statSync(parent);
385
385
  if (!parentStat.isDirectory()) throw new Error(`add destination is not a directory: ${parent}`);
386
386
  const destination = path.join(parent, root.name);
387
- if (fs.existsSync(destination)) throw new Error(`refusing to add ${root.name}: destination already exists at ${destination}`);
387
+ const destinationExists = (() => {
388
+ try { fs.lstatSync(destination); return true; } catch { return false; }
389
+ })();
390
+ if (destinationExists) throw new Error(`refusing to add ${root.name}: destination already exists at ${destination}`);
388
391
  const staging = path.join(parent, `.${root.name}.amalgm-materializing-${crypto.randomBytes(10).toString('hex')}`);
389
392
  let promoted = false;
390
393
 
@@ -404,6 +407,10 @@ function createRegistrationService({
404
407
  fs.writeFileSync(target, entityContent.bytes({ contentHash: record.payloadVersion }), { mode: 0o600, flag: 'wx' });
405
408
  return;
406
409
  }
410
+ if (record.type === 'link') {
411
+ fs.symlinkSync(record.payloadVersion, target);
412
+ return;
413
+ }
407
414
  throw new Error(`cloud entity ${record.uuid} is ${record.type}; this materializer cannot write it yet`);
408
415
  }
409
416
 
@@ -457,12 +464,12 @@ function createRegistrationService({
457
464
  const tree = entityCloud.catalogTree({ resourceId: job.resourceId, uuid: job.entityUuid });
458
465
  const root = tree[0];
459
466
  const active = tree.filter((record) => record.status === 'active');
460
- const unsupported = tree.find((record) => ['link', 'reference'].includes(record.type));
467
+ const unsupported = tree.find((record) => record.type === 'reference');
461
468
  if (unsupported) {
462
469
  throw new Error(`live cloud add for ${unsupported.type} is not available yet; the binding was left unchanged`);
463
470
  }
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`);
471
+ if (!['workspace', 'repo.git', 'file.text', 'file.binary', 'link'].includes(root.type)) {
472
+ throw new Error(`cloud root ${root.uuid} is ${root.type}; this live materializer supports workspace, repository, file, and link trees only`);
466
473
  }
467
474
  for (const record of active) {
468
475
  if (['file.text', 'file.binary'].includes(record.type)
@@ -512,9 +519,11 @@ function createRegistrationService({
512
519
  // overwrite is still the byte named by the old authoritative record.
513
520
  // An unsent local edit or an out-of-band edit is a conflict, never an
514
521
  // implicit cloud-wins overwrite.
522
+ const locallyBound = new Set();
515
523
  for (const record of tree) {
516
524
  const current = localRecord(record.uuid);
517
525
  if (!current || current.version === record.version) continue;
526
+ locallyBound.add(record.uuid);
518
527
  if (entityCloud.hasUnsettledLocalMutation(record.uuid)) {
519
528
  throw new Error(`local change for ${record.uuid} is still waiting for cloud; refusing to overwrite it`);
520
529
  }
@@ -528,6 +537,15 @@ function createRegistrationService({
528
537
  }
529
538
  continue;
530
539
  }
540
+ if (current.type === 'link') {
541
+ const currentPath = addressOf(record.uuid);
542
+ let stat = null;
543
+ try { stat = currentPath ? fs.lstatSync(currentPath) : null; } catch {}
544
+ if (!stat || !stat.isSymbolicLink() || fs.readlinkSync(currentPath) !== current.payloadVersion) {
545
+ throw new Error(`local link ${currentPath || record.uuid} changed outside Amalgm; refusing to overwrite it`);
546
+ }
547
+ continue;
548
+ }
531
549
  if (!['file.text', 'file.binary'].includes(current.type)) continue;
532
550
  const currentPath = addressOf(record.uuid);
533
551
  if (!currentPath || !fs.existsSync(currentPath)) {
@@ -567,6 +585,16 @@ function createRegistrationService({
567
585
  }
568
586
  }
569
587
 
588
+ function atomicLink(file, target) {
589
+ const temporary = path.join(path.dirname(file), `.${path.basename(file)}.amalgm-live-${crypto.randomBytes(10).toString('hex')}`);
590
+ try {
591
+ fs.symlinkSync(target, temporary);
592
+ fs.renameSync(temporary, file);
593
+ } finally {
594
+ fs.rmSync(temporary, { force: true });
595
+ }
596
+ }
597
+
570
598
  function removeInactiveChild(parentRecord, name, target) {
571
599
  const known = boundary.current.registry.children(parentRecord.uuid)
572
600
  .find((candidate) => candidate.name === name);
@@ -579,6 +607,14 @@ function createRegistrationService({
579
607
  throw new Error(`local file ${target} changed outside Amalgm; refusing to remove it`);
580
608
  }
581
609
  }
610
+ if (known.type === 'link') {
611
+ const previous = localRecord(known.uuid);
612
+ let stat = null;
613
+ try { stat = fs.lstatSync(target); } catch {}
614
+ if (!stat || !stat.isSymbolicLink() || fs.readlinkSync(target) !== previous?.payloadVersion) {
615
+ throw new Error(`local link ${target} changed outside Amalgm; refusing to remove it`);
616
+ }
617
+ }
582
618
  fs.rmSync(target, { recursive: true, force: true });
583
619
  }
584
620
 
@@ -602,6 +638,26 @@ function createRegistrationService({
602
638
  applyRepo(target, repoState(record));
603
639
  return;
604
640
  }
641
+ if (record.type === 'link') {
642
+ let present = false;
643
+ try { fs.lstatSync(target); present = true; } catch {}
644
+ if (present) {
645
+ const stat = fs.lstatSync(target);
646
+ if (stat.isSymbolicLink() && fs.readlinkSync(target) === record.payloadVersion) return;
647
+ // This address was just proven to be the old authoritative UUID
648
+ // above. The registry now holds the new link head, so replace the
649
+ // literal projection atomically rather than treating its old target
650
+ // as unrelated ground. A brand-new cloud link retains the refusal:
651
+ // it may never overwrite an existing local address.
652
+ if (locallyBound.has(record.uuid)) {
653
+ atomicLink(target, record.payloadVersion);
654
+ return;
655
+ }
656
+ throw new Error(`cloud link ${record.uuid} conflicts with local ground ${target}`);
657
+ }
658
+ atomicLink(target, record.payloadVersion);
659
+ return;
660
+ }
605
661
  if (!['file.text', 'file.binary'].includes(record.type)) {
606
662
  throw new Error(`cloud entity ${record.uuid} is ${record.type}; this live materializer cannot write it`);
607
663
  }
@@ -172,7 +172,16 @@ function registerDirectory(dirPath, parentUUID, {
172
172
  const identity = seeded(entryRelativePath, {
173
173
  type: 'link', parentUUID, name: entry.name, root: false,
174
174
  });
175
- registry.create({ type: identity.type, parentUUID, name: entry.name, uuid: identity.uuid });
175
+ registry.create({
176
+ type: identity.type,
177
+ parentUUID,
178
+ name: entry.name,
179
+ // A cloud materializer has already verified the literal link text.
180
+ // Preserve it through the structural bind; normal local registration
181
+ // still leaves this null until Watch observes the filesystem.
182
+ payloadVersion: identity.payloadVersion ?? null,
183
+ uuid: identity.uuid,
184
+ });
176
185
  continue;
177
186
  }
178
187
  if (stat.isDirectory()) {
@@ -183,6 +183,18 @@ function createRegistry(options) {
183
183
  let bellPending = false;
184
184
  const deferred = []; // bells born inside an open transaction: they ring at commit, or a rollback silences them
185
185
  let journalSuppression = 0;
186
+ // A container's head is derived from its active direct membership. A
187
+ // reconciliation may need that answer for many descendants, but it is
188
+ // unchanged until a child enters, leaves, moves, or is renamed. Cache the
189
+ // derivation here, at the registry boundary, and invalidate it with the
190
+ // membership transition itself.
191
+ const membershipHashes = new Map();
192
+
193
+ function invalidateMembership(...uuids) {
194
+ for (const uuid of uuids) {
195
+ if (uuid !== null && uuid !== undefined) membershipHashes.delete(uuid);
196
+ }
197
+ }
186
198
 
187
199
  function ring(event) {
188
200
  try {
@@ -273,8 +285,12 @@ function createRegistry(options) {
273
285
 
274
286
  /** Container payload: the sorted active direct membership (childUUID, name). */
275
287
  function membershipHash(uuid) {
288
+ const cached = membershipHashes.get(uuid);
289
+ if (cached) return cached;
276
290
  const pairs = store.activeChildrenOf(uuid).map((child) => [child.uuid, child.name]);
277
- return crypto.createHash('sha256').update(JSON.stringify(pairs)).digest('hex');
291
+ const hash = crypto.createHash('sha256').update(JSON.stringify(pairs)).digest('hex');
292
+ membershipHashes.set(uuid, hash);
293
+ return hash;
278
294
  }
279
295
 
280
296
  function payloadFor(row) {
@@ -361,6 +377,9 @@ function createRegistry(options) {
361
377
  const previous = record(row);
362
378
  store.update(row.uuid, { ...row, ...changes });
363
379
  const nextRow = mustGet(row.uuid);
380
+ if (changes.status !== undefined && changes.status !== row.status) {
381
+ invalidateMembership(row.parentUuid);
382
+ }
364
383
  const next = record(nextRow);
365
384
  journalRecord(nextRow, row);
366
385
  publish({ type: 'entity.committed', record: next, previous });
@@ -451,6 +470,9 @@ function createRegistry(options) {
451
470
  const adoptedWorkspace = row.parentUuid === null && row.type === 'workspace';
452
471
  return { uuid, parentUuid: parentUUID, name, ...(adoptedWorkspace ? { type: 'folder' } : {}) };
453
472
  }));
473
+ for (const { uuid, parentUUID } of moves) {
474
+ invalidateMembership(rows.get(uuid).parentUuid, parentUUID);
475
+ }
454
476
  return moves.map(({ uuid }, index) => {
455
477
  const nextRow = mustGet(uuid);
456
478
  const next = record(nextRow);
@@ -536,6 +558,7 @@ function createRegistry(options) {
536
558
  status: 'active', payloadVersion,
537
559
  };
538
560
  store.insert(row);
561
+ invalidateMembership(parentUUID);
539
562
  const next = record(row);
540
563
  journalRecord(row, null);
541
564
  publish({ type: 'entity.committed', record: next, previous: null });
@@ -639,17 +662,21 @@ function createRegistry(options) {
639
662
  if (!previous && incoming.parentUuid !== null && !store.byUuid(incoming.parentUuid)) {
640
663
  throw new Error(`cloud entity ${incoming.uuid} names missing parent ${incoming.parentUuid}`);
641
664
  }
665
+ const previousRecord = previous ? record(previous) : null;
642
666
  if (previous) {
643
667
  store.update(incoming.uuid, incoming);
644
668
  } else {
645
669
  store.insert(incoming);
646
670
  }
647
671
  const current = mustGet(incoming.uuid);
672
+ if (!previous || previous.parentUuid !== current.parentUuid
673
+ || previous.name !== current.name || previous.status !== current.status) {
674
+ invalidateMembership(previous?.parentUuid, current.parentUuid);
675
+ }
648
676
  const synced = syncRecord(current);
649
677
  if (synced.version !== incoming.expectedVersion) {
650
678
  throw new Error(`cloud entity ${incoming.uuid} version does not match its logical record`);
651
679
  }
652
- const previousRecord = previous ? record(previous) : null;
653
680
  if (!previous || !sameLogicalRecord(previous, current)) {
654
681
  publish({ type: 'entity.committed', record: record(current), previous: previousRecord });
655
682
  }
@@ -221,6 +221,11 @@ test('a compacted remote snapshot enters the cloud catalog, not locally watched
221
221
  assert.deepEqual(targetRegistry.syncRecords(), [], 'a remote graph does not pretend to be watched local ground');
222
222
  const job = targetCloud.startMaterialization({ uuid: root.uuid, targetPath: targetDir });
223
223
  assert.equal(job.state, 'waiting_for_content');
224
+ targetCloud.completeMaterialization({ jobId: job.jobId });
225
+ const retry = targetCloud.startMaterialization({ uuid: root.uuid, targetPath: targetDir });
226
+ assert.notEqual(retry.jobId, job.jobId,
227
+ 'a completed job is historical evidence, not permission to claim a destination still exists');
228
+ assert.equal(retry.state, 'waiting_for_content');
224
229
  assert.deepEqual(targetCloud.catalogTree({ resourceId, uuid: root.uuid }).map((record) => record.uuid), [root.uuid, file.uuid]);
225
230
  assert.deepEqual(targetCloud.claimDownload(), {
226
231
  resourceId,
@@ -229,6 +229,95 @@ test('an added ordinary workspace stays live: a later cloud file head replaces l
229
229
  }
230
230
  });
231
231
 
232
+ test('add preserves a symlink UUID and later retargets the same literal link without an echo', async () => {
233
+ const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-add-link-materialization-'));
234
+ const sourceDir = path.join(rootDir, 'source');
235
+ const targetState = path.join(rootDir, 'target-state');
236
+ const targetParent = path.join(rootDir, 'checkout');
237
+ fs.mkdirSync(sourceDir, { recursive: true });
238
+ fs.mkdirSync(targetParent, { recursive: true });
239
+ const firstBytes = Buffer.from('first target\n');
240
+ const secondBytes = Buffer.from('second target\n');
241
+ const firstHash = sha256(firstBytes);
242
+ const secondHash = sha256(secondBytes);
243
+ fs.writeFileSync(path.join(sourceDir, 'first.md'), firstBytes);
244
+ fs.writeFileSync(path.join(sourceDir, 'second.md'), secondBytes);
245
+ fs.symlinkSync('first.md', path.join(sourceDir, 'current.md'));
246
+
247
+ const sourceDb = new Database(':memory:');
248
+ const sourceContent = createEntityContent({ dir: sourceDir });
249
+ const sourceCloud = createEntityCloud({
250
+ db: sourceDb, userId: 'user-1', deviceId: 'source-device', content: sourceContent,
251
+ });
252
+ const sourceRegistry = createRegistry({ db: sourceDb, ephemeral: true, emit: () => {}, journal: sourceCloud.journal });
253
+ const root = sourceRegistry.enrollRoot({ type: 'workspace', name: 'project' });
254
+ sourceRegistry.create({ type: 'file.text', parentUUID: root.uuid, name: 'first.md', payloadVersion: firstHash });
255
+ sourceRegistry.create({ type: 'file.text', parentUUID: root.uuid, name: 'second.md', payloadVersion: secondHash });
256
+ const link = sourceRegistry.create({ type: 'link', parentUUID: root.uuid, name: 'current.md', payloadVersion: 'first.md' });
257
+ sourceContent.captureFile({ filePath: path.join(sourceDir, 'first.md'), contentHash: firstHash });
258
+ sourceContent.captureFile({ filePath: path.join(sourceDir, 'second.md'), contentHash: secondHash });
259
+ const initialParts = [
260
+ ...uploadedParts(sourceContent, sourceCloud.identity().resourceId, firstHash),
261
+ ...uploadedParts(sourceContent, sourceCloud.identity().resourceId, secondHash),
262
+ ];
263
+ const snapshot = snapshotFromRecords(sourceRegistry.syncRecords());
264
+ const snapshotBytes = Buffer.from(JSON.stringify(snapshot), 'utf8');
265
+ const target = createRegistrationService({
266
+ dir: targetState, homeDir: path.join(rootDir, 'target-home'),
267
+ cloudUserId: 'user-1', cloudDeviceId: 'target-device',
268
+ });
269
+ try {
270
+ target.entityCloud.install({
271
+ registry: target.registration.registry, resourceId: sourceCloud.identity().resourceId,
272
+ authorityEpoch: 7, headVersion: 41, snapshotBase64: snapshotBytes.toString('base64'),
273
+ snapshotChecksum: sha256(snapshotBytes),
274
+ });
275
+ for (const part of [...initialParts.filter((part) => part.kind === 'manifest'), ...initialParts.filter((part) => part.kind === 'chunk')]) {
276
+ target.receiveCloudContent({
277
+ resourceId: sourceCloud.identity().resourceId, contentHash: part.contentHash, kind: part.kind,
278
+ partIndex: part.kind === 'manifest' ? 0 : part.partIndex, sha256: part.sha256, dataBase64: part.dataBase64,
279
+ });
280
+ }
281
+ const job = await target.add({ uuid: root.uuid, targetPath: targetParent });
282
+ assert.equal(job.state, 'complete', job.error);
283
+ const destination = path.join(targetParent, 'project');
284
+ assert.equal(fs.readlinkSync(path.join(destination, 'current.md')), 'first.md');
285
+ assert.equal(target.registration.registry.syncRecord(link.uuid).uuid, link.uuid);
286
+ assert.equal(target.observer.status().find((entry) => entry.path === fs.realpathSync(destination))?.watching, true);
287
+ const afterAddDb = new Database(path.join(targetState, 'identity.db'), { readonly: true });
288
+ try {
289
+ assert.equal(afterAddDb.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 0,
290
+ 'the first authoritative link binding does not create an outbound mutation');
291
+ } finally {
292
+ afterAddDb.close();
293
+ }
294
+
295
+ const changed = sourceRegistry.attest(link.uuid, { type: 'link', payloadVersion: 'second.md' });
296
+ const ingested = target.ingestCloudMutation({
297
+ envelope: {
298
+ resourceId: sourceCloud.identity().resourceId, mutationId: 'ent_remote_link_change',
299
+ contract: 'amalgm-entity-registry@1', schemaVersion: 1,
300
+ operationKind: 'entity.upsert', operation: { kind: 'entity.upsert', record: sourceRegistry.syncRecord(changed.uuid) },
301
+ },
302
+ version: 42, committedAt: '2026-08-05T00:00:00.000Z',
303
+ });
304
+ assert.equal(ingested.materializations.length, 1);
305
+ assert.equal(fs.readlinkSync(path.join(destination, 'current.md')), 'second.md');
306
+ assert.equal(target.registration.registry.syncRecord(link.uuid).payloadVersion, 'second.md');
307
+ const targetDb = new Database(path.join(targetState, 'identity.db'), { readonly: true });
308
+ try {
309
+ assert.equal(targetDb.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations').get().count, 0,
310
+ 'the receiving link watcher did not echo the authoritative retarget upstream');
311
+ } finally {
312
+ targetDb.close();
313
+ }
314
+ } finally {
315
+ target.close();
316
+ sourceDb.close();
317
+ fs.rmSync(rootDir, { recursive: true, force: true });
318
+ }
319
+ });
320
+
232
321
  test('add recreates an actual Git repository from verified Repocard cargo, then arms its local watcher', async () => {
233
322
  const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-add-repo-materialization-'));
234
323
  const sourceState = path.join(rootDir, 'source-state');