amalgm 0.1.260 → 0.1.261

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.260",
3
+ "version": "0.1.261",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -124,7 +124,15 @@ function parseSnapshot(bytes) {
124
124
  * no watcher imports: a cloud record may be known on this machine long before
125
125
  * `amalgm add <uuid>` chooses and validates local ground for it.
126
126
  */
127
- function createEntityCloud({ db, userId, deviceId, content = null, repoStates = null, repoTransportFor = null }) {
127
+ function createEntityCloud({
128
+ db,
129
+ userId,
130
+ deviceId,
131
+ content = null,
132
+ repoStates = null,
133
+ repoTransportFor = null,
134
+ recordAt = null,
135
+ }) {
128
136
  if (!db) throw new Error('createEntityCloud requires an identity database');
129
137
  const ownerUserId = typeof userId === 'string' && userId ? userId : null;
130
138
  const localDeviceId = typeof deviceId === 'string' && deviceId ? deviceId : null;
@@ -155,17 +163,62 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
155
163
  return snapshotFromRecords(records.map(cloudRecord));
156
164
  }
157
165
 
158
- function queueContent(input, resourceId, { queue = false } = {}) {
159
- const record = cloudRecord(input);
160
- const head = contentHead(record);
161
- if (!head) return;
162
- if (!content || typeof content.has !== 'function') {
163
- throw new Error('file entity content is unavailable: registration must capture bytes before it records their cloud head');
166
+ /**
167
+ * Select bytes by the root representation that reconstructs them. A
168
+ * repository's Repocard transport contains its worktree, so child file rows
169
+ * retain their UUID/version facts but never create duplicate cloud cargo.
170
+ * Ordinary workspace files still carry their own content, and a nested
171
+ * repository carries its own transport.
172
+ */
173
+ function contentHeads(records, { fallbackRecordAt = recordAt } = {}) {
174
+ const normalized = (records || []).map(cloudRecord);
175
+ const supplied = new Map(normalized.map((record) => [record.uuid, record]));
176
+ const lookup = (uuid) => {
177
+ const known = supplied.get(uuid);
178
+ if (known) return known;
179
+ const fallback = typeof fallbackRecordAt === 'function' ? fallbackRecordAt(uuid) : null;
180
+ return fallback ? cloudRecord(fallback) : null;
181
+ };
182
+
183
+ function enclosedByRepository(record) {
184
+ const seen = new Set([record.uuid]);
185
+ let parentUUID = record.parentUUID;
186
+ while (parentUUID !== null) {
187
+ if (seen.has(parentUUID)) throw new Error(`cloud entity ${record.uuid} has a cyclic parent chain`);
188
+ seen.add(parentUUID);
189
+ const parent = lookup(parentUUID);
190
+ // Missing ancestry retains content rather than risking an ordinary
191
+ // file whose only bytes were silently skipped.
192
+ if (!parent) return false;
193
+ if (parent.type === 'repo.git') return true;
194
+ parentUUID = parent.parentUUID;
195
+ }
196
+ return false;
197
+ }
198
+
199
+ const heads = [];
200
+ const seen = new Set();
201
+ for (const record of normalized) {
202
+ const contentHash = contentHead(record);
203
+ if (!contentHash) continue;
204
+ if (['file.text', 'file.binary'].includes(record.type) && enclosedByRepository(record)) continue;
205
+ if (seen.has(contentHash)) continue;
206
+ seen.add(contentHash);
207
+ heads.push({ record, contentHash });
164
208
  }
165
- if (!content.has({ contentHash: head })) {
166
- throw new Error('file entity bytes were not captured before its cloud head committed');
209
+ return heads;
210
+ }
211
+
212
+ function queueContentHeads(records, resourceId, { queue = false, fallbackRecordAt = recordAt } = {}) {
213
+ for (const { contentHash } of contentHeads(records, { fallbackRecordAt })) {
214
+ if (!content || typeof content.has !== 'function') {
215
+ throw new Error('file entity content is unavailable: registration must capture bytes before it records their cloud head');
216
+ }
217
+ if (!content.has({ contentHash })) {
218
+ throw new Error('file entity bytes were not captured before its cloud head committed');
219
+ }
220
+ if (queue) content.ensureUpload({ resourceId, contentHash });
167
221
  }
168
- if (queue) content.ensureUpload({ resourceId, contentHash: head });
169
222
  }
170
223
 
171
224
  function replica() {
@@ -383,7 +436,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
383
436
  // content drain creates the filesystem upload cursor; doing that here
384
437
  // would let a rolled-back identity write upload unreachable bytes.
385
438
  const cloud = cloudRecord(record);
386
- queueContent(cloud, current.resourceId);
439
+ queueContentHeads([cloud], current.resourceId);
387
440
  upsertCatalogRecord({
388
441
  resourceId: current.resourceId,
389
442
  authorityEpoch: current.authorityEpoch,
@@ -423,7 +476,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
423
476
  if (bytes > SNAPSHOT_REPLACE_MAX_BYTES) {
424
477
  throw new Error(`registered entity graph is ${bytes} bytes; the atomic cloud registration limit is ${SNAPSHOT_REPLACE_MAX_BYTES} bytes`);
425
478
  }
426
- for (const record of snapshot.records) queueContent(record, current.resourceId, { queue: true });
479
+ queueContentHeads(snapshot.records, current.resourceId, { queue: true });
427
480
  const createdAt = now();
428
481
  db.prepare(`
429
482
  INSERT INTO entity_cloud_mutations (
@@ -448,8 +501,9 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
448
501
  }
449
502
  const insert = db.prepare('INSERT INTO entity_cloud_mutations (resource_id, mutation_id, operation_json, mutation_state, created_at, updated_at) VALUES (?, ?, ?, \'pending\', ?, ?)');
450
503
  const createdAt = now();
451
- for (const record of cloudSnapshot(records).records) {
452
- queueContent(record, current.resourceId, { queue: true });
504
+ const snapshot = cloudSnapshot(records);
505
+ queueContentHeads(snapshot.records, current.resourceId, { queue: true });
506
+ for (const record of snapshot.records) {
453
507
  insert.run(
454
508
  current.resourceId,
455
509
  'ent_' + crypto.randomBytes(18).toString('base64url'),
@@ -632,7 +686,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
632
686
  // upload. A joining device must NOT queue remote bytes as uploads; it
633
687
  // downloads them later through a materialization job instead.
634
688
  if (bootstrapping) {
635
- for (const record of snapshot.records) queueContent(record, resourceId, { queue: true });
689
+ queueContentHeads(snapshot.records, resourceId, { queue: true });
636
690
  }
637
691
  replaceCatalog({
638
692
  resourceId,
@@ -706,15 +760,13 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
706
760
  for (const job of pendingMaterializations().filter((entry) => entry.state === 'waiting_for_content')) {
707
761
  if (job.resourceId !== current.resourceId) continue;
708
762
  const tree = catalogTree({ resourceId: job.resourceId, uuid: job.entityUuid });
709
- for (const record of tree) {
710
- if (record.status !== 'active') continue;
711
- const head = contentHead(record);
712
- if (!head) continue;
713
- const part = content.missingPart({ contentHash: head });
763
+ const active = tree.filter((record) => record.status === 'active');
764
+ for (const { contentHash } of contentHeads(active)) {
765
+ const part = content.missingPart({ contentHash });
714
766
  if (!part) continue;
715
767
  return {
716
768
  resourceId: job.resourceId,
717
- contentHash: head,
769
+ contentHash,
718
770
  kind: part.kind,
719
771
  partIndex: part.partIndex,
720
772
  sha256: part.sha256,
@@ -882,10 +934,9 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
882
934
  const records = operation?.kind === SNAPSHOT_REPLACE_OPERATION
883
935
  ? operation?.snapshot?.records
884
936
  : [operation?.record];
885
- if ((records || []).some((record) => {
886
- const head = contentHead(record);
887
- return head && (!content || !content.uploaded({ resourceId: current.resourceId, contentHash: head }));
888
- })) {
937
+ if (contentHeads(records).some(({ contentHash }) => (
938
+ !content || !content.uploaded({ resourceId: current.resourceId, contentHash })
939
+ ))) {
889
940
  return [];
890
941
  }
891
942
  claimRow.run(current.headVersion, leaseUntil, started, row.sequence);
@@ -923,7 +974,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
923
974
  const records = operation?.kind === SNAPSHOT_REPLACE_OPERATION
924
975
  ? operation?.snapshot?.records
925
976
  : [operation?.record];
926
- for (const record of records || []) queueContent(record, current.resourceId, { queue: true });
977
+ queueContentHeads(records || [], current.resourceId, { queue: true });
927
978
  contentQueuedSequences.add(row.sequence);
928
979
  }
929
980
  return content.claim({ resourceId: current.resourceId, leaseMs });
@@ -1136,6 +1187,7 @@ function createEntityCloud({ db, userId, deviceId, content = null, repoStates =
1136
1187
  status,
1137
1188
  hasUnsettledLocalMutation,
1138
1189
  catalogTree,
1190
+ contentHeads,
1139
1191
  replica,
1140
1192
  };
1141
1193
  }
@@ -106,6 +106,10 @@ function createRegistrationService({
106
106
  return localState(captured, artifact.contentHash);
107
107
  }
108
108
 
109
+ // Entity-cloud needs only logical ancestry to choose a content plan. It
110
+ // never owns a watcher or address; this facade is available after the
111
+ // registration boundary is constructed below.
112
+ const boundary = { current: null };
109
113
  const entityCloud = createEntityCloud({
110
114
  db: identityDb,
111
115
  userId: cloudUserId,
@@ -113,11 +117,13 @@ function createRegistrationService({
113
117
  content: entityContent,
114
118
  repoStates: repoStateLookup,
115
119
  repoTransportFor: (record) => repoTransportHeads.get(record.payloadVersion) || null,
120
+ recordAt: (uuid) => {
121
+ try { return boundary.current?.registry.syncRecord(uuid) || null; } catch { return null; }
122
+ },
116
123
  });
117
124
 
118
125
  // The observer emits before the boundary exists (its constructor
119
126
  // scans), so the wire reads through a box that the boundary fills.
120
- const boundary = { current: null };
121
127
  const observer = createObserver({
122
128
  db: evidenceDb,
123
129
  emit: (event) => boundary.current?.onEvent(event),
@@ -474,15 +480,8 @@ function createRegistrationService({
474
480
  return state;
475
481
  }
476
482
 
477
- for (const record of active) {
478
- if (['file.text', 'file.binary'].includes(record.type)
479
- && !entityContent.complete({ contentHash: record.payloadVersion })) {
480
- return false;
481
- }
482
- if (record.type === 'repo.git'
483
- && (!record.transportVersion || !entityContent.complete({ contentHash: record.transportVersion }))) {
484
- return false;
485
- }
483
+ for (const { contentHash } of entityCloud.contentHeads(active)) {
484
+ if (!entityContent.complete({ contentHash })) return false;
486
485
  }
487
486
  if (!['workspace', 'repo.git', 'file.text', 'file.binary', 'link'].includes(root.type)) {
488
487
  throw new Error(`cloud root ${root.uuid} is ${root.type}; this materializer supports workspace, repository, file, and link trees only`);
@@ -579,11 +578,8 @@ function createRegistrationService({
579
578
  if (!['workspace', 'repo.git', 'file.text', 'file.binary', 'link'].includes(root.type)) {
580
579
  throw new Error(`cloud root ${root.uuid} is ${root.type}; this live materializer supports workspace, repository, file, and link trees only`);
581
580
  }
582
- for (const record of active) {
583
- if (['file.text', 'file.binary'].includes(record.type)
584
- && !entityContent.complete({ contentHash: record.payloadVersion })) return false;
585
- if (record.type === 'repo.git'
586
- && (!record.transportVersion || !entityContent.complete({ contentHash: record.transportVersion }))) return false;
581
+ for (const { contentHash } of entityCloud.contentHeads(active)) {
582
+ if (!entityContent.complete({ contentHash })) return false;
587
583
  }
588
584
 
589
585
  const parent = fs.realpathSync(job.targetPath);
@@ -369,20 +369,35 @@ test('add recreates an actual Git repository from verified Repocard cargo, then
369
369
  snapshotChecksum: sha256(snapshotBytes),
370
370
  });
371
371
 
372
- const heads = new Set(records.flatMap((record) => {
373
- if (['file.text', 'file.binary'].includes(record.type) && record.payloadVersion) return [record.payloadVersion];
374
- if (record.type === 'repo.git' && record.transportVersion) return [record.transportVersion];
375
- return [];
376
- }));
377
- for (const head of heads) {
378
- const parts = uploadedParts(source.entityContent, bootstrap.resourceId, head);
379
- for (const part of [...parts.filter((entry) => entry.kind === 'manifest'), ...parts.filter((entry) => entry.kind === 'chunk')]) {
380
- target.receiveCloudContent({
381
- resourceId: bootstrap.resourceId, contentHash: head, kind: part.kind,
382
- partIndex: part.kind === 'manifest' ? 0 : part.partIndex,
383
- sha256: part.sha256, dataBase64: part.dataBase64,
384
- });
385
- }
372
+ const planned = source.entityCloud.contentHeads(records);
373
+ assert.deepEqual(
374
+ planned.map(({ contentHash }) => contentHash),
375
+ [repoCloudRecord.transportVersion],
376
+ 'a repository register uploads its one Repocard transport, never duplicate child file bytes',
377
+ );
378
+ const uploaded = [];
379
+ while (true) {
380
+ const part = source.entityCloud.claimContent();
381
+ if (!part) break;
382
+ uploaded.push(part);
383
+ source.entityCloud.acknowledgeContent({
384
+ resourceId: bootstrap.resourceId,
385
+ contentHash: part.contentHash,
386
+ kind: part.kind,
387
+ partIndex: part.partIndex,
388
+ });
389
+ }
390
+ assert.deepEqual(
391
+ [...new Set(uploaded.map((part) => part.contentHash))],
392
+ [repoCloudRecord.transportVersion],
393
+ 'the source content pump sends only the repository transport',
394
+ );
395
+ for (const part of [...uploaded.filter((entry) => entry.kind === 'manifest'), ...uploaded.filter((entry) => entry.kind === 'chunk')]) {
396
+ target.receiveCloudContent({
397
+ resourceId: bootstrap.resourceId, contentHash: part.contentHash, kind: part.kind,
398
+ partIndex: part.kind === 'manifest' ? 0 : part.partIndex,
399
+ sha256: part.sha256, dataBase64: part.dataBase64,
400
+ });
386
401
  }
387
402
 
388
403
  const job = await target.add({ uuid: registered.record.uuid, targetPath: targetParent });