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