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 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. */
@@ -50,7 +50,10 @@ function runtimeBranchForLabel(label, env = process.env) {
50
50
  }
51
51
 
52
52
  function npmTagForRuntimeLabel(label) {
53
- return label === 'preview' ? 'canary' : 'latest';
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "amalgm",
3
- "version": "0.1.249",
3
+ "version": "0.1.251",
4
4
  "description": "Amalgm local computer runtime: login, MCP, chat, events, previews, and tunnels.",
5
5
  "license": "UNLICENSED",
6
6
  "private": false,
@@ -14,6 +14,8 @@ const crypto = require('crypto');
14
14
  const ENTITY_CLOUD_CONTRACT = 'amalgm-entity-registry@1';
15
15
  const ENTITY_CLOUD_SCHEMA_VERSION = 1;
16
16
  const BOOTSTRAP_RECONCILIATION_STATE = 'reconciling-bootstrap';
17
+ const SNAPSHOT_REPLACE_OPERATION = 'entity.snapshot.replace';
18
+ const SNAPSHOT_REPLACE_MAX_BYTES = 8 * 1024 * 1024;
17
19
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
18
20
  const TYPES = new Set([
19
21
  'workspace', 'folder', 'repo.git', 'file.text', 'file.binary', 'link', 'reference',
@@ -57,12 +59,21 @@ function assertRecord(value) {
57
59
  || typeof value.name !== 'string' || !value.name || value.name.includes('/') || value.name.includes('\0')
58
60
  || !['active', 'trashed', 'deleted'].includes(value.status)
59
61
  || typeof value.version !== 'string' || !/^[0-9a-f]{64}$/.test(value.version)
60
- || (value.payloadVersion !== null && typeof value.payloadVersion !== 'string')) {
62
+ || (value.payloadVersion !== null && typeof value.payloadVersion !== 'string')
63
+ || (value.transportVersion !== undefined && value.transportVersion !== null
64
+ && (typeof value.transportVersion !== 'string' || !CONTENT_HEAD.test(value.transportVersion)))) {
61
65
  throw new Error('cloud entity snapshot has an invalid record');
62
66
  }
63
67
  if (value.parentUUID === null && !ROOT_TYPES.has(value.type)) {
64
68
  throw new Error(`cloud entity ${value.uuid} is not a valid tree root`);
65
69
  }
70
+ const transportVersion = value.transportVersion ?? null;
71
+ if (value.type === 'repo.git' && transportVersion !== null && !CONTENT_HEAD.test(transportVersion)) {
72
+ throw new Error('cloud repository transport head is invalid');
73
+ }
74
+ if (value.type !== 'repo.git' && transportVersion !== null) {
75
+ throw new Error('only repository entities may declare a transport head');
76
+ }
66
77
  return {
67
78
  uuid: value.uuid.toLowerCase(),
68
79
  type: value.type,
@@ -71,6 +82,7 @@ function assertRecord(value) {
71
82
  status: value.status,
72
83
  version: value.version,
73
84
  payloadVersion: value.payloadVersion,
85
+ transportVersion,
74
86
  };
75
87
  }
76
88
 
@@ -106,26 +118,48 @@ function parseSnapshot(bytes) {
106
118
  * no watcher imports: a cloud record may be known on this machine long before
107
119
  * `amalgm add <uuid>` chooses and validates local ground for it.
108
120
  */
109
- function createEntityCloud({ db, userId, deviceId, content = null }) {
121
+ function createEntityCloud({ db, userId, deviceId, content = null, repoStates = null, repoTransportFor = null }) {
110
122
  if (!db) throw new Error('createEntityCloud requires an identity database');
111
123
  const ownerUserId = typeof userId === 'string' && userId ? userId : null;
112
124
  const localDeviceId = typeof deviceId === 'string' && deviceId ? deviceId : null;
113
125
 
126
+ function cloudRecord(record) {
127
+ const normalized = assertRecord(record);
128
+ if (normalized.type !== 'repo.git' || normalized.transportVersion !== null) return normalized;
129
+ const supplied = repoTransportFor?.(normalized) || null;
130
+ const state = repoStates?.get(normalized.uuid);
131
+ const transportVersion = supplied || state?.transportVersion || null;
132
+ if ((state && state.stateId !== normalized.payloadVersion) || !CONTENT_HEAD.test(String(transportVersion || ''))) {
133
+ throw new Error(`repository ${normalized.uuid} has no immutable Repocard transport for state ${normalized.payloadVersion || 'none'}`);
134
+ }
135
+ return { ...normalized, transportVersion };
136
+ }
137
+
114
138
  function contentHead(record) {
115
- return ['file.text', 'file.binary'].includes(record?.type)
116
- && typeof record?.payloadVersion === 'string'
117
- && CONTENT_HEAD.test(record.payloadVersion);
139
+ if (['file.text', 'file.binary'].includes(record?.type)
140
+ && typeof record?.payloadVersion === 'string' && CONTENT_HEAD.test(record.payloadVersion)) {
141
+ return record.payloadVersion;
142
+ }
143
+ if (record?.type === 'repo.git' && typeof record?.transportVersion === 'string'
144
+ && CONTENT_HEAD.test(record.transportVersion)) return record.transportVersion;
145
+ return null;
146
+ }
147
+
148
+ function cloudSnapshot(records) {
149
+ return snapshotFromRecords(records.map(cloudRecord));
118
150
  }
119
151
 
120
- function queueContent(record, resourceId, { queue = false } = {}) {
121
- if (!contentHead(record)) return;
152
+ function queueContent(input, resourceId, { queue = false } = {}) {
153
+ const record = cloudRecord(input);
154
+ const head = contentHead(record);
155
+ if (!head) return;
122
156
  if (!content || typeof content.has !== 'function') {
123
157
  throw new Error('file entity content is unavailable: registration must capture bytes before it records their cloud head');
124
158
  }
125
- if (!content.has({ contentHash: record.payloadVersion })) {
159
+ if (!content.has({ contentHash: head })) {
126
160
  throw new Error('file entity bytes were not captured before its cloud head committed');
127
161
  }
128
- if (queue) content.ensureUpload({ resourceId, contentHash: record.payloadVersion });
162
+ if (queue) content.ensureUpload({ resourceId, contentHash: head });
129
163
  }
130
164
 
131
165
  function replica() {
@@ -301,15 +335,16 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
301
335
  // The SQLite mutation is the durable delivery intent. Its post-commit
302
336
  // content drain creates the filesystem upload cursor; doing that here
303
337
  // would let a rolled-back identity write upload unreachable bytes.
304
- queueContent(record, current.resourceId);
338
+ const cloud = cloudRecord(record);
339
+ queueContent(cloud, current.resourceId);
305
340
  upsertCatalogRecord({
306
341
  resourceId: current.resourceId,
307
342
  authorityEpoch: current.authorityEpoch,
308
343
  headVersion: current.headVersion,
309
- record,
344
+ record: cloud,
310
345
  });
311
346
  const mutationId = `ent_${crypto.randomBytes(18).toString('base64url')}`;
312
- const operation = { kind: 'entity.upsert', record: assertRecord(record) };
347
+ const operation = { kind: 'entity.upsert', record: cloud };
313
348
  const createdAt = now();
314
349
  db.prepare(`
315
350
  INSERT INTO entity_cloud_mutations (
@@ -319,6 +354,45 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
319
354
  `).run(current.resourceId, mutationId, stableJson(operation), createdAt, createdAt);
320
355
  }
321
356
 
357
+ /** Publish one complete logical graph as one atomic cloud operation. A
358
+ * large registration must never expose a half-created parent/child graph
359
+ * between individual acknowledgements. Ordinary watcher edits remain
360
+ * single-record operations. */
361
+ function replaceSnapshot(records) {
362
+ const current = replica();
363
+ if (!current || current.state !== 'active') {
364
+ throw new Error('entity cloud registry is not ready yet; wait for the signed-in runtime to finish its first cloud handshake');
365
+ }
366
+ const unsettled = db.prepare(`
367
+ SELECT COUNT(*) AS count FROM entity_cloud_mutations
368
+ WHERE resource_id = ? AND mutation_state <> 'synced'
369
+ `).get(current.resourceId);
370
+ if (Number(unsettled.count) !== 0) {
371
+ throw new Error('entity cloud registration is already uploading another change; wait for it before registering this large tree');
372
+ }
373
+ const snapshot = cloudSnapshot(records);
374
+ const operation = { kind: SNAPSHOT_REPLACE_OPERATION, snapshot };
375
+ const bytes = Buffer.byteLength(stableJson(operation), 'utf8');
376
+ if (bytes > SNAPSHOT_REPLACE_MAX_BYTES) {
377
+ throw new Error(`registered entity graph is ${bytes} bytes; the atomic cloud registration limit is ${SNAPSHOT_REPLACE_MAX_BYTES} bytes`);
378
+ }
379
+ for (const record of snapshot.records) queueContent(record, current.resourceId, { queue: true });
380
+ const createdAt = now();
381
+ db.prepare(`
382
+ INSERT INTO entity_cloud_mutations (
383
+ resource_id, mutation_id, operation_json, mutation_state,
384
+ created_at, updated_at
385
+ ) VALUES (?, ?, ?, 'pending', ?, ?)
386
+ `).run(
387
+ current.resourceId,
388
+ `ent_${crypto.randomBytes(18).toString('base64url')}`,
389
+ stableJson(operation),
390
+ createdAt,
391
+ createdAt,
392
+ );
393
+ return { records: snapshot.records.length, bytes };
394
+ }
395
+
322
396
  function enqueueBootstrapReconciliation(records, current) {
323
397
  const pending = db.prepare('SELECT COUNT(*) AS count FROM entity_cloud_mutations WHERE resource_id = ? AND mutation_state <> \'synced\'')
324
398
  .get(current.resourceId);
@@ -327,7 +401,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
327
401
  }
328
402
  const insert = db.prepare('INSERT INTO entity_cloud_mutations (resource_id, mutation_id, operation_json, mutation_state, created_at, updated_at) VALUES (?, ?, ?, \'pending\', ?, ?)');
329
403
  const createdAt = now();
330
- for (const record of snapshotFromRecords(records).records) {
404
+ for (const record of cloudSnapshot(records).records) {
331
405
  queueContent(record, current.resourceId, { queue: true });
332
406
  insert.run(
333
407
  current.resourceId,
@@ -357,7 +431,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
357
431
  if (existing?.state === 'active') {
358
432
  throw new Error('entity cloud registry already exists locally; cloud lookup must not create it again');
359
433
  }
360
- const snapshot = snapshotFromRecords(registry.syncRecords());
434
+ const snapshot = cloudSnapshot(registry.syncRecords());
361
435
  const cutoff = db.prepare('SELECT COALESCE(MAX(sequence), 0) AS sequence FROM entity_cloud_mutations').get().sequence;
362
436
  saveReplica({
363
437
  resourceId: currentIdentity.resourceId,
@@ -397,7 +471,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
397
471
  || !Number.isInteger(Number(headVersion)) || Number(headVersion) < 0) {
398
472
  throw new Error('entity cloud registry authority is invalid');
399
473
  }
400
- const local = snapshotFromRecords(registry.syncRecords());
474
+ const local = cloudSnapshot(registry.syncRecords());
401
475
  const current = replica();
402
476
  const bootstrapping = current?.state === 'bootstrapping';
403
477
  const reconciling = current?.state === BOOTSTRAP_RECONCILIATION_STATE;
@@ -526,7 +600,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
526
600
  if (typeof targetPath !== 'string' || !targetPath) throw new Error('materialization requires a destination path');
527
601
  const tree = catalogTree({ resourceId: current.resourceId, uuid });
528
602
  if (tree[0].status !== 'active') throw new Error(`cloud entity ${uuid} is not active`);
529
- const unsupported = tree.find((record) => ['repo.git', 'link', 'reference'].includes(record.type));
603
+ const unsupported = tree.find((record) => ['link', 'reference'].includes(record.type));
530
604
  if (unsupported) {
531
605
  throw new Error(`cloud entity ${unsupported.uuid} is ${unsupported.type}; this first materializer supports ordinary workspace and file trees only`);
532
606
  }
@@ -563,13 +637,14 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
563
637
  if (job.resourceId !== current.resourceId) continue;
564
638
  const tree = catalogTree({ resourceId: job.resourceId, uuid: job.entityUuid });
565
639
  for (const record of tree) {
566
- if (!['file.text', 'file.binary'].includes(record.type) || record.status !== 'active') continue;
567
- if (!contentHead(record)) throw new Error(`cloud file ${record.uuid} has no immutable content head`);
568
- const part = content.missingPart({ contentHash: record.payloadVersion });
640
+ if (record.status !== 'active') continue;
641
+ const head = contentHead(record);
642
+ if (!head) continue;
643
+ const part = content.missingPart({ contentHash: head });
569
644
  if (!part) continue;
570
645
  return {
571
646
  resourceId: job.resourceId,
572
- contentHash: record.payloadVersion,
647
+ contentHash: head,
573
648
  kind: part.kind,
574
649
  partIndex: part.partIndex,
575
650
  sha256: part.sha256,
@@ -613,6 +688,47 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
613
688
  return materialization(db.prepare('SELECT * FROM entity_materializations WHERE job_id = ?').get(jobId));
614
689
  }
615
690
 
691
+ /** A completed add is a live binding, not a one-time download. A remote
692
+ * record beneath that binding moves the durable job back to its content
693
+ * gate. The service owns the later filesystem application; this module
694
+ * owns only the durable intent to do it. */
695
+ function refreshMaterializations({ resourceId, record, version }) {
696
+ const refreshed = [];
697
+ for (const job of db.prepare(`
698
+ SELECT * FROM entity_materializations
699
+ WHERE resource_id = ? AND state = 'complete'
700
+ ORDER BY created_at ASC
701
+ `).all(resourceId)) {
702
+ const tree = catalogTree({ resourceId, uuid: job.entity_uuid });
703
+ if (!tree.some((candidate) => candidate.uuid === record.uuid)) continue;
704
+ db.prepare(`
705
+ UPDATE entity_materializations
706
+ SET head_version = ?, state = 'waiting_for_content', error = NULL, updated_at = ?
707
+ WHERE job_id = ?
708
+ `).run(Number(version), now(), job.job_id);
709
+ refreshed.push(job.job_id);
710
+ }
711
+ return refreshed;
712
+ }
713
+
714
+ function hasUnsettledLocalMutation(uuid) {
715
+ const rows = db.prepare(`
716
+ SELECT operation_json FROM entity_cloud_mutations
717
+ WHERE resource_id = ? AND mutation_state <> 'synced'
718
+ ORDER BY sequence ASC
719
+ `).all(replica()?.resourceId || '');
720
+ return rows.some((row) => {
721
+ try {
722
+ const operation = JSON.parse(row.operation_json);
723
+ return operation?.record?.uuid === uuid
724
+ || operation?.snapshot?.records?.some((record) => record?.uuid === uuid) === true;
725
+ } catch {
726
+ // A corrupt local outbox must never authorize an overwrite.
727
+ return true;
728
+ }
729
+ });
730
+ }
731
+
616
732
  function claim({ limit = 16, leaseMs = 30_000 } = {}) {
617
733
  const current = replica();
618
734
  if (!current || !['active', BOOTSTRAP_RECONCILIATION_STATE].includes(current.state)) return [];
@@ -641,8 +757,13 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
641
757
  // The logical mutation is intentionally held behind the immutable
642
758
  // content manifest. A cloud graph never advances to a file head
643
759
  // whose bytes have not reached private R2 storage.
644
- if (contentHead(operation?.record)
645
- && (!content || !content.uploaded({ resourceId: current.resourceId, contentHash: operation.record.payloadVersion }))) {
760
+ const records = operation?.kind === SNAPSHOT_REPLACE_OPERATION
761
+ ? operation?.snapshot?.records
762
+ : [operation?.record];
763
+ if ((records || []).some((record) => {
764
+ const head = contentHead(record);
765
+ return head && (!content || !content.uploaded({ resourceId: current.resourceId, contentHash: head }));
766
+ })) {
646
767
  return [];
647
768
  }
648
769
  claimRow.run(current.headVersion, leaseUntil, started, row.sequence);
@@ -654,7 +775,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
654
775
  baseVersion: current.headVersion,
655
776
  contract: ENTITY_CLOUD_CONTRACT,
656
777
  schemaVersion: ENTITY_CLOUD_SCHEMA_VERSION,
657
- operationKind: 'entity.upsert',
778
+ operationKind: operation.kind,
658
779
  operation,
659
780
  }];
660
781
  });
@@ -673,8 +794,11 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
673
794
  ORDER BY sequence ASC
674
795
  `).all(current.resourceId);
675
796
  for (const row of rows) {
676
- const record = JSON.parse(row.operation_json)?.record;
677
- queueContent(record, current.resourceId, { queue: true });
797
+ const operation = JSON.parse(row.operation_json);
798
+ const records = operation?.kind === SNAPSHOT_REPLACE_OPERATION
799
+ ? operation?.snapshot?.records
800
+ : [operation?.record];
801
+ for (const record of records || []) queueContent(record, current.resourceId, { queue: true });
678
802
  }
679
803
  return content.claim({ resourceId: current.resourceId, leaseMs });
680
804
  }
@@ -760,9 +884,18 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
760
884
  }
761
885
  if (envelope?.resourceId !== current.resourceId
762
886
  || envelope?.contract !== ENTITY_CLOUD_CONTRACT
763
- || Number(envelope?.schemaVersion) !== ENTITY_CLOUD_SCHEMA_VERSION
764
- || envelope?.operationKind !== 'entity.upsert'
765
- || envelope?.operation?.kind !== 'entity.upsert') {
887
+ || Number(envelope?.schemaVersion) !== ENTITY_CLOUD_SCHEMA_VERSION) {
888
+ throw new Error('cloud entity mutation does not belong to this registry');
889
+ }
890
+ if (envelope?.operationKind === SNAPSHOT_REPLACE_OPERATION
891
+ && envelope?.operation?.kind === SNAPSHOT_REPLACE_OPERATION) {
892
+ // The complete replacement is imported only from the active snapshot
893
+ // the gateway makes before acknowledgement. Consuming its tail would
894
+ // reintroduce the very partial-graph state this operation prevents.
895
+ if (Number(version) > current.headVersion) saveReplica({ ...current, headVersion: Number(version) });
896
+ return { duplicate: true, version: Number(version), snapshotRequired: true, materializations: [] };
897
+ }
898
+ if (envelope?.operationKind !== 'entity.upsert' || envelope?.operation?.kind !== 'entity.upsert') {
766
899
  throw new Error('cloud entity mutation does not belong to this registry');
767
900
  }
768
901
  const record = assertRecord(envelope.operation.record);
@@ -781,9 +914,13 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
781
914
  }
782
915
  // Remote mutations update the cloud-only catalog. They never create a
783
916
  // local watcher; `amalgm add` alone turns catalog identity into ground.
917
+ // A pre-existing local record is normally a conflict, except for a
918
+ // completed add binding with no unsent local edit. That binding will
919
+ // apply the authoritative cloud record through its own watcher-safe
920
+ // materializer below; it must not be stranded at its first snapshot.
784
921
  let local;
785
922
  try { local = registry.syncRecord(record.uuid); } catch { local = null; }
786
- if (local && !sameRecord(local, record)) {
923
+ if (local && !sameRecord(local, record) && hasUnsettledLocalMutation(record.uuid)) {
787
924
  throw new Error('remote entity mutation conflicts with this device; refresh the compacted cloud head before applying it locally');
788
925
  }
789
926
  upsertCatalogRecord({
@@ -793,12 +930,17 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
793
930
  record,
794
931
  });
795
932
  if (Number(version) > current.headVersion) saveReplica({ ...current, headVersion: Number(version) });
796
- return { duplicate: true, version: Number(version) };
933
+ return {
934
+ duplicate: true,
935
+ version: Number(version),
936
+ materializations: refreshMaterializations({ resourceId: current.resourceId, record, version }),
937
+ };
797
938
  }
798
939
 
799
940
  return {
800
941
  identity,
801
942
  journal,
943
+ replaceSnapshot,
802
944
  assertRegistrationReady,
803
945
  bootstrap,
804
946
  install,
@@ -816,6 +958,7 @@ function createEntityCloud({ db, userId, deviceId, content = null }) {
816
958
  completeMaterialization,
817
959
  failMaterialization,
818
960
  materializationStatus,
961
+ hasUnsettledLocalMutation,
819
962
  catalogTree,
820
963
  replica,
821
964
  };
@@ -168,6 +168,34 @@ function createEntityContent({ dir }) {
168
168
  return installed;
169
169
  }
170
170
 
171
+ /** Capture immutable bytes that do not originate as a standalone file.
172
+ * Repocard transport is the first caller: its Git bundle plus checkpoint
173
+ * are one verified payload, while identity.db stores only the resulting
174
+ * content head. The supplied bytes must already equal that head; this is
175
+ * the same content-addressed rule as captureFile, not a second storage
176
+ * format. */
177
+ function captureBytes({ bytes, contentHash = null }) {
178
+ const fullBytes = Buffer.from(bytes || []);
179
+ const head = contentHash || digest(fullBytes);
180
+ assertHash(head);
181
+ if (digest(fullBytes) !== head) throw new Error('entity content bytes do not match their declared head');
182
+ ensureDirs();
183
+ const existing = manifest(head);
184
+ if (existing) return existing;
185
+ const chunks = [];
186
+ for (let offset = 0; offset < fullBytes.length; offset += CHUNK_BYTES) {
187
+ const part = fullBytes.subarray(offset, Math.min(offset + CHUNK_BYTES, fullBytes.length));
188
+ const checksum = digest(part);
189
+ writeChunk(checksum, part);
190
+ chunks.push({ sha256: checksum, bytes: part.length });
191
+ }
192
+ const captured = { contract: CONTENT_CONTRACT, contentHash: head, bytes: fullBytes.length, chunks };
193
+ writeJson(manifestFile(head), captured);
194
+ const installed = manifest(head);
195
+ if (!installed) throw new Error('captured entity content bytes did not persist');
196
+ return installed;
197
+ }
198
+
171
199
  function initialUpload(resourceId, contentHash) {
172
200
  return {
173
201
  contract: CONTENT_CONTRACT,
@@ -414,6 +442,7 @@ function createEntityContent({ dir }) {
414
442
 
415
443
  return {
416
444
  captureFile,
445
+ captureBytes,
417
446
  ensureUpload,
418
447
  queueRecords,
419
448
  claim,
@@ -84,6 +84,11 @@ function createRegistration(options) {
84
84
  }
85
85
 
86
86
  const opened = openIdentity();
87
+ const captureRepo = opened.captureRepo || ((repoPath) => capture(repoPath, { withBundle: false }));
88
+ const repoStateAt = opened.repoStateAt ?? null;
89
+ if (repoStateAt !== null && typeof repoStateAt !== 'function') {
90
+ throw new Error('openIdentity.repoStateAt must be a function when supplied');
91
+ }
87
92
  const registry = createRegistry({
88
93
  db: opened.db,
89
94
  sentinelPath: opened.sentinelPath,
@@ -92,7 +97,7 @@ function createRegistration(options) {
92
97
  journal: opened.journal ?? null,
93
98
  });
94
99
  const memory = createEvidenceMemory(opened.db);
95
- const repoStates = createRepoStates(opened.db);
100
+ const repoStates = opened.repoStates || createRepoStates(opened.db);
96
101
  const lenses = new Map(); // rootId (permanent witness) -> evidence lens
97
102
  let reported = []; // the latest edge pass's refusals
98
103
  let edgesDirty = false;
@@ -102,6 +107,7 @@ function createRegistration(options) {
102
107
  observer,
103
108
  registry,
104
109
  repoStates,
110
+ prepareRepo: opened.prepareRepo ?? null,
105
111
  recordForRoot: repoRecordForRoot,
106
112
  onSettled: reconcileSettledRepo,
107
113
  });
@@ -237,8 +243,9 @@ function createRegistration(options) {
237
243
  registry,
238
244
  memory,
239
245
  shouldEnroll: observer.shouldEnroll,
240
- captureRepo: (repoPath) => capture(repoPath, { withBundle: false }),
246
+ captureRepo,
241
247
  repoStates,
248
+ repoStateAt,
242
249
  });
243
250
  } catch (error) {
244
251
  // An observer root is only coverage until its one structural
@@ -520,8 +527,9 @@ function createRegistration(options) {
520
527
  registry,
521
528
  memory,
522
529
  shouldEnroll: observer.shouldEnroll,
523
- captureRepo: (repoPath) => capture(repoPath, { withBundle: false }),
530
+ captureRepo,
524
531
  repoStates,
532
+ repoStateAt,
525
533
  identityAt,
526
534
  });
527
535
  if (suppressJournal) registry.withoutJournal(register);
@@ -744,6 +752,11 @@ function createRegistration(options) {
744
752
 
745
753
  /** The latest edge pass's refusals — visible, never silent. */
746
754
  reported: () => [...reported],
755
+ addressOf,
756
+ // Composition owns the one database handle; exposing this narrow state
757
+ // store lets the cloud adapter name verified repository cargo without
758
+ // opening a competing registry over the same SQLite file.
759
+ repoStates,
747
760
  repoState: (uuid) => repoStates.get(uuid),
748
761
  tree: treeAt,
749
762
  trees: () => new Map(treeRoots()
@@ -25,7 +25,7 @@
25
25
 
26
26
  const { createFollower, createCaptureWorker } = require('../repocard');
27
27
 
28
- function createRepoFollowers({ observer, registry, repoStates, recordForRoot, onSettled }) {
28
+ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, onSettled, prepareRepo = null }) {
29
29
  if (!observer || !registry || !repoStates || typeof recordForRoot !== 'function'
30
30
  || typeof onSettled !== 'function') {
31
31
  throw new Error('createRepoFollowers requires { observer, registry, repoStates, recordForRoot, onSettled }');
@@ -71,7 +71,13 @@ function createRepoFollowers({ observer, registry, repoStates, recordForRoot, on
71
71
  const follower = createFollower({
72
72
  repoPath: root.path,
73
73
  last: baseline && { cardId: baseline.cardId, checkpointId: baseline.checkpointId },
74
- captureFn: (repoPath) => worker.capture(repoPath, { withBundle: false }),
74
+ // The service can turn a full capture into immutable cloud cargo
75
+ // before the one stateId attestation commits. Local-only callers
76
+ // retain the lightweight bundle-free capture used historically.
77
+ captureFn: async (repoPath) => {
78
+ const captured = await worker.capture(repoPath, { withBundle: typeof prepareRepo === 'function' });
79
+ return typeof prepareRepo === 'function' && !captured?.busy ? prepareRepo(captured) : captured;
80
+ },
75
81
  emit: (next) => {
76
82
  // ONE settled Card + Checkpoint state is ONE registry transition on
77
83
  // the repo entity. Raw filesystem events never write Git state.
@@ -25,17 +25,22 @@ function decodeBytes(encoded) {
25
25
  return Buffer.from(encoded || '', 'base64');
26
26
  }
27
27
 
28
- function pack(state) {
28
+ /** The durable local state deliberately omits a Git bundle. Bundles are
29
+ * transport cargo: keeping them in identity.db would turn a small registry
30
+ * into a second, unbounded object store. The immutable content store owns
31
+ * the bundle bytes; SQLite retains only the content head that names them. */
32
+ function pack(state, { includeBundle = true, transportVersion = state.transportVersion ?? null } = {}) {
29
33
  return JSON.stringify({
30
34
  stateId: state.stateId,
31
35
  cardId: state.cardId,
32
36
  checkpointId: state.checkpointId,
37
+ transportVersion,
33
38
  card: {
34
39
  ...state.card,
35
- bundle: state.card.bundle && {
40
+ bundle: includeBundle && state.card.bundle ? {
36
41
  ...state.card.bundle,
37
42
  bytes: encodeBytes(state.card.bundle.bytes),
38
- },
43
+ } : null,
39
44
  },
40
45
  checkpoint: {
41
46
  ...state.checkpoint,
@@ -53,6 +58,7 @@ function unpack(text) {
53
58
  const state = JSON.parse(text);
54
59
  return {
55
60
  ...state,
61
+ transportVersion: typeof state.transportVersion === 'string' ? state.transportVersion : null,
56
62
  card: {
57
63
  ...state.card,
58
64
  bundle: state.card.bundle && {
@@ -72,6 +78,22 @@ function unpack(text) {
72
78
  };
73
79
  }
74
80
 
81
+ /** A Repocard transport document contains its bundle and checkpoint bytes,
82
+ * but never names itself: its SHA-256 is the immutable cloud content head.
83
+ * The semantic `stateId` remains on the repo entity, so bundle packing can
84
+ * vary without changing repository identity. */
85
+ function transportBytes(state) {
86
+ return Buffer.from(pack(state, { includeBundle: true, transportVersion: null }), 'utf8');
87
+ }
88
+
89
+ function localState(state, transportVersion) {
90
+ return {
91
+ ...state,
92
+ transportVersion,
93
+ card: { ...state.card, bundle: null },
94
+ };
95
+ }
96
+
75
97
  function createRepoStates(db) {
76
98
  if (!db) throw new Error('createRepoStates requires the identity database');
77
99
  const read = db.prepare('SELECT state_json FROM repo_states WHERE entity_uuid = ?');
@@ -106,4 +128,4 @@ function createRepoStates(db) {
106
128
  };
107
129
  }
108
130
 
109
- module.exports = { createRepoStates };
131
+ module.exports = { createRepoStates, transportBytes, localState, unpack };