@amalgm/shell 0.1.17 → 0.1.19

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.
@@ -1,11 +1,14 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync, watch, writeFileSync, } from "node:fs";
2
+ import { createReadStream, existsSync, lstatSync, realpathSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync, watch, writeFileSync, } from "node:fs";
3
+ import { open } from "node:fs/promises";
3
4
  import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
5
  import { buildUserHomeManifest, buildUserManifest, liveMachineStateDir, scopedAmalgmDir, shippedUserHomeDeclaration, } from "@amalgm/core/identity";
5
- import { CHUNK_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, convergeUserGround, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, manifestFromChunks, membershipHash, parseSnapshot, privateEntityResourceId, sameRecords, snapshotFromRecords, stableJson, travelingRecords, unpack, userGroundRecords, } from "@amalgm/live";
6
+ import { CHUNK_BYTES, ENTITY_CLOUD_CONTRACT, ENTITY_CLOUD_SCHEMA_VERSION, artifactForRecord, canonicalVersion, checkContentManifest, classifyDirectory, classifyFile, classifyRegisteredRoot, convergeUserGround, createSuspicionScope, createUserGroundEnrollmentPolicy, isRepositoryMetadataEntry, download as downloadArtifact, membershipHash, parseSnapshot, pathIsSuspect, privateEntityResourceId, sameRecords, snapshotFromRecords, stableJson, travelingRecords, upload as uploadArtifact, userGroundRecords, } from "@amalgm/live";
6
7
  import Database from "better-sqlite3";
7
- import { atomicWrite, ensurePrivateDir } from "./filesystem.js";
8
- import { applyRepository, captureRepository, isRepository, } from "./git-repository-host.js";
8
+ import { atomicAssemble, atomicCopy, atomicWrite, ensurePrivateDir } from "./filesystem.js";
9
+ import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile, isRepository, } from "./git-repository-host.js";
10
+ import { inspectGitRegistration, } from "./git-registration-host.js";
11
+ import { projectMaterializedGraph } from "./materialized-graph.js";
9
12
  import { WireClient, WireRequestError } from "./wire-client.js";
10
13
  const PORTABLE_FIELDS = [
11
14
  "uuid", "type", "parentUUID", "name", "status", "version",
@@ -24,6 +27,7 @@ export class UserGroundHost {
24
27
  cloudState = null;
25
28
  converged = false;
26
29
  syncing = null;
30
+ closing = false;
27
31
  port;
28
32
  constructor(options) {
29
33
  this.options = options;
@@ -164,7 +168,7 @@ export class UserGroundHost {
164
168
  workspace: input.workspace,
165
169
  records: input.records,
166
170
  destinationParent: input.destinationParent,
167
- readContent: (contentHash) => this.downloadContent(privateEntityResourceId(identity.userId, sha256Hex), contentHash),
171
+ readContent: (artifact) => this.downloadContent(privateEntityResourceId(identity.userId, sha256Hex), this.cacheDir(identity), artifact),
168
172
  }),
169
173
  coverWorkspace: async () => {
170
174
  this.ensureWatchers(identity);
@@ -178,8 +182,16 @@ export class UserGroundHost {
178
182
  async flush() {
179
183
  if (!this.converged || !this.activeIdentity || !this.cloudState)
180
184
  return;
181
- this.watchDirty = false;
185
+ const identity = this.activeIdentity;
186
+ for (const watcher of this.watchers.values())
187
+ watcher.suspicion.suspectAll();
188
+ this.watchDirty = true;
182
189
  await this.syncNow();
190
+ if (!this.closing)
191
+ this.ensureWatchers(identity);
192
+ await this.flushObservedChanges();
193
+ if (!this.closing)
194
+ this.ensureWatchers(identity);
183
195
  await this.flushObservedChanges();
184
196
  }
185
197
  async syncNow() {
@@ -193,14 +205,20 @@ export class UserGroundHost {
193
205
  await this.syncing;
194
206
  }
195
207
  async flushObservedChanges() {
196
- do {
197
- const dirty = this.watchDirty;
208
+ while (true) {
209
+ const dirty = this.watchDirty
210
+ || [...this.watchers.values()].some((watcher) => watcher.suspicion.size > 0);
198
211
  this.watchDirty = false;
199
- if (dirty)
212
+ if (dirty) {
200
213
  await this.syncNow();
201
- else if (this.syncing)
214
+ continue;
215
+ }
216
+ if (this.syncing) {
202
217
  await this.syncing;
203
- } while (this.watchDirty);
218
+ continue;
219
+ }
220
+ return;
221
+ }
204
222
  }
205
223
  async activateRuntimeTunnel(gatewayPort, runtimeToken) {
206
224
  await this.flush();
@@ -212,12 +230,18 @@ export class UserGroundHost {
212
230
  await this.wire.close();
213
231
  }
214
232
  async close() {
233
+ // Command shutdown is the last catch-up boundary. A filesystem callback
234
+ // may still be queued, so watcher silence cannot be used as evidence yet.
235
+ for (const watcher of this.watchers.values())
236
+ watcher.suspicion.suspectAll();
237
+ this.watchDirty = this.watchers.size > 0 || this.watchDirty;
238
+ this.closing = true;
215
239
  if (this.rescanTimer)
216
240
  clearTimeout(this.rescanTimer);
217
241
  this.rescanTimer = null;
218
242
  await this.flushObservedChanges();
219
243
  for (const watcher of this.watchers.values())
220
- watcher.close();
244
+ watcher.handle.close();
221
245
  this.watchers.clear();
222
246
  if (this.rescanTimer)
223
247
  clearTimeout(this.rescanTimer);
@@ -296,7 +320,7 @@ export class UserGroundHost {
296
320
  database,
297
321
  cacheDir,
298
322
  cloud,
299
- readContent: (contentHash) => this.downloadContent(cloud.resourceId, contentHash),
323
+ readContent: (artifact) => this.downloadContent(cloud.resourceId, cacheDir, artifact),
300
324
  });
301
325
  return localValue(identity, userRoot, database);
302
326
  },
@@ -308,113 +332,158 @@ export class UserGroundHost {
308
332
  }
309
333
  async uploadSnapshotContent(identity, resourceId, records) {
310
334
  const cache = this.cacheDir(identity);
311
- const contentHeads = [...new Set(records
312
- .map((record) => record.type === "repo.git" ? record.transportVersion
313
- : ["file.text", "file.binary", "link"].includes(record.type) ? record.payloadVersion : null)
314
- .filter((head) => typeof head === "string"))];
315
- const small = contentHeads.filter((contentHash) => statSync(join(cache, `${contentHash}.bin`)).size <= CHUNK_BYTES);
316
- const large = contentHeads.filter((contentHash) => statSync(join(cache, `${contentHash}.bin`)).size > CHUNK_BYTES);
317
- const upload = async (contentHash) => {
318
- const bytes = readFileSync(join(cache, `${contentHash}.bin`));
319
- if (sha256Hex(bytes) !== contentHash)
320
- throw new Error(`local content ${contentHash} is corrupt`);
321
- const chunks = [];
322
- for (let offset = 0; offset < bytes.length; offset += CHUNK_BYTES) {
323
- const part = bytes.subarray(offset, Math.min(offset + CHUNK_BYTES, bytes.length));
324
- chunks.push({ sha256: sha256Hex(part), bytes: part.length, data: part });
335
+ const byHash = new Map();
336
+ for (const record of records) {
337
+ const artifact = artifactForRecord(record);
338
+ if (artifact)
339
+ byHash.set(artifact.contentHash, artifact);
340
+ }
341
+ const artifacts = [...byHash.values()];
342
+ const small = artifacts.filter((artifact) => statSync(contentCacheFile(cache, artifact.contentHash)).size <= CHUNK_BYTES);
343
+ const large = artifacts.filter((artifact) => statSync(contentCacheFile(cache, artifact.contentHash)).size > CHUNK_BYTES);
344
+ const transfer = async (artifact) => {
345
+ const file = contentCacheFile(cache, artifact.contentHash);
346
+ const fileBytes = statSync(file).size;
347
+ const cachedManifest = readCachedManifest(cache, artifact.contentHash);
348
+ const sourceFile = { handle: null };
349
+ let manifestPresent = false;
350
+ try {
351
+ const receipt = await uploadArtifact(artifact, {
352
+ bytes: fileBytes,
353
+ ...(cachedManifest ? { manifest: cachedManifest } : {}),
354
+ verify: async (contentHash) => await sha256File(file) === contentHash,
355
+ read: async (offset, bytes) => {
356
+ sourceFile.handle ??= await open(file, "r");
357
+ const buffer = Buffer.allocUnsafe(bytes);
358
+ const { bytesRead } = await sourceFile.handle.read(buffer, 0, bytes, offset);
359
+ return buffer.subarray(0, bytesRead);
360
+ },
361
+ }, {
362
+ sha256Hex,
363
+ missingChunks: async (_candidate, manifest) => {
364
+ const frame = await retryContent(() => this.wire.request({
365
+ type: "private.entity-content.inventory",
366
+ resource_id: resourceId,
367
+ content_hash: artifact.contentHash,
368
+ chunks: manifest.chunks,
369
+ }, ["private.entity-content.inventory-result"]));
370
+ manifestPresent = frame.complete === true;
371
+ if (!Array.isArray(frame.missing)) {
372
+ throw new Error("content authority returned an invalid inventory");
373
+ }
374
+ return frame.missing;
375
+ },
376
+ putChunk: async (_candidate, manifest, index, bytes) => {
377
+ const chunk = manifest.chunks[index];
378
+ if (!chunk)
379
+ throw new Error(`content manifest has no chunk ${index}`);
380
+ await retryContent(() => this.wire.requestBinary({
381
+ type: "private.entity-content.put",
382
+ resource_id: resourceId,
383
+ content_hash: artifact.contentHash,
384
+ kind: "chunk",
385
+ part_index: index,
386
+ part_count: manifest.chunks.length,
387
+ sha256: chunk.sha256,
388
+ bytes: chunk.bytes,
389
+ }, bytes, ["private.entity-content.stored"]));
390
+ },
391
+ putManifest: async (_candidate, manifest) => {
392
+ if (manifestPresent)
393
+ return;
394
+ const bytes = Buffer.from(stableJson(manifest), "utf8");
395
+ await retryContent(() => this.wire.requestBinary({
396
+ type: "private.entity-content.put",
397
+ resource_id: resourceId,
398
+ content_hash: artifact.contentHash,
399
+ kind: "manifest",
400
+ part_index: manifest.chunks.length,
401
+ part_count: manifest.chunks.length,
402
+ sha256: sha256Hex(bytes),
403
+ bytes: bytes.length,
404
+ }, bytes, ["private.entity-content.stored"]));
405
+ },
406
+ });
407
+ atomicWrite(manifestCacheFile(cache, artifact.contentHash), stableJson(receipt.manifest));
325
408
  }
326
- const checked = manifestFromChunks(contentHash, chunks);
327
- if (!checked.ok)
328
- throw new Error(checked.error);
329
- for (let index = 0; index < chunks.length; index += 1) {
330
- const chunk = chunks[index];
331
- await retryContent(() => this.wire.request({
332
- type: "private.entity-content.put",
333
- resource_id: resourceId,
334
- content_hash: contentHash,
335
- kind: "chunk",
336
- part_index: index,
337
- part_count: chunks.length,
338
- sha256: chunk.sha256,
339
- bytes: chunk.bytes,
340
- data_b64: chunk.data.toString("base64"),
341
- }, ["private.entity-content.stored"]));
409
+ finally {
410
+ if (sourceFile.handle)
411
+ await sourceFile.handle.close();
342
412
  }
343
- const manifestBytes = Buffer.from(stableJson(checked.value), "utf8");
344
- await retryContent(() => this.wire.request({
345
- type: "private.entity-content.put",
346
- resource_id: resourceId,
347
- content_hash: contentHash,
348
- kind: "manifest",
349
- part_index: chunks.length,
350
- part_count: chunks.length,
351
- sha256: sha256Hex(manifestBytes),
352
- bytes: manifestBytes.length,
353
- data_b64: manifestBytes.toString("base64"),
354
- }, ["private.entity-content.stored"]));
355
413
  };
356
- await boundedForEach(small, SMALL_CONTENT_UPLOAD_CONCURRENCY, upload);
357
- for (const contentHash of large) {
358
- await upload(contentHash);
414
+ await boundedForEach(small, SMALL_CONTENT_UPLOAD_CONCURRENCY, transfer);
415
+ for (const artifact of large) {
416
+ await transfer(artifact);
359
417
  }
360
418
  }
361
- async downloadContent(resourceId, contentHash) {
362
- const manifestFrame = await retryContent(() => this.wire.request({
363
- type: "private.entity-content.get",
364
- resource_id: resourceId,
365
- content_hash: contentHash,
366
- kind: "manifest",
367
- part_index: 0,
368
- }, ["private.entity-content.data"]));
369
- const manifestBytes = frameBytes(manifestFrame);
370
- const checked = checkContentManifest(JSON.parse(manifestBytes.toString("utf8")), contentHash);
371
- if (!checked.ok)
372
- throw new Error(checked.error);
373
- const parts = [];
374
- for (let index = 0; index < checked.value.chunks.length; index += 1) {
375
- const chunk = checked.value.chunks[index];
376
- const frame = await retryContent(() => this.wire.request({
377
- type: "private.entity-content.get",
378
- resource_id: resourceId,
379
- content_hash: contentHash,
380
- kind: "chunk",
381
- part_index: index,
382
- sha256: chunk.sha256,
383
- }, ["private.entity-content.data"]), true);
384
- const bytes = frameBytes(frame);
385
- if (bytes.length !== chunk.bytes || sha256Hex(bytes) !== chunk.sha256) {
386
- throw new Error(`cloud content chunk ${index} is corrupt`);
387
- }
388
- parts.push(bytes);
389
- }
390
- const bytes = Buffer.concat(parts);
391
- if (bytes.length !== checked.value.bytes || sha256Hex(bytes) !== contentHash) {
392
- throw new Error(`cloud content ${contentHash} is corrupt`);
393
- }
394
- return bytes;
419
+ async downloadContent(resourceId, cacheDir, artifact) {
420
+ const receipt = await downloadArtifact(artifact, {
421
+ sha256Hex,
422
+ getManifest: async () => {
423
+ const frame = await retryContent(() => this.wire.request({
424
+ type: "private.entity-content.get",
425
+ resource_id: resourceId,
426
+ content_hash: artifact.contentHash,
427
+ kind: "manifest",
428
+ part_index: 0,
429
+ }, ["private.entity-content.data"]));
430
+ return JSON.parse(frameBytes(frame).toString("utf8"));
431
+ },
432
+ hasChunk: async (chunk) => {
433
+ try {
434
+ const bytes = readFileSync(chunkCacheFile(cacheDir, chunk.sha256));
435
+ return bytes.length === chunk.bytes && sha256Hex(bytes) === chunk.sha256;
436
+ }
437
+ catch {
438
+ return false;
439
+ }
440
+ },
441
+ getChunk: async (_candidate, manifest, index) => {
442
+ const chunk = manifest.chunks[index];
443
+ if (!chunk)
444
+ throw new Error(`content manifest has no chunk ${index}`);
445
+ const frame = await retryContent(() => this.wire.request({
446
+ type: "private.entity-content.get",
447
+ resource_id: resourceId,
448
+ content_hash: artifact.contentHash,
449
+ kind: "chunk",
450
+ part_index: index,
451
+ sha256: chunk.sha256,
452
+ }, ["private.entity-content.data"]), true);
453
+ return frameBytes(frame);
454
+ },
455
+ putChunk: async (chunk, bytes) => {
456
+ atomicWrite(chunkCacheFile(cacheDir, chunk.sha256), bytes);
457
+ },
458
+ sealArtifact: async (_candidate, manifest) => {
459
+ const file = contentCacheFile(cacheDir, artifact.contentHash);
460
+ const sealed = await atomicAssemble(file, manifest.chunks.map((chunk) => chunkCacheFile(cacheDir, chunk.sha256)));
461
+ if (sealed.bytes !== manifest.bytes || sealed.sha256 !== manifest.contentHash) {
462
+ throw new Error(`cloud content ${artifact.contentHash} is corrupt`);
463
+ }
464
+ atomicWrite(manifestCacheFile(cacheDir, artifact.contentHash), stableJson(manifest));
465
+ return {
466
+ local: { file, bytes: sealed.bytes, contentHash: sealed.sha256 },
467
+ bytes: sealed.bytes,
468
+ contentHash: sealed.sha256,
469
+ };
470
+ },
471
+ });
472
+ return receipt.local;
395
473
  }
396
474
  ensureWatchers(identity) {
475
+ if (this.closing)
476
+ return;
397
477
  const root = this.userRoot(identity);
398
478
  const ignoreFile = join(root, ".amalgmignore");
399
479
  const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
400
- const wanted = new Set();
401
- const visit = (directory, enrollment, base = "") => {
402
- wanted.add(directory);
403
- for (const entry of readdirSync(directory, { withFileTypes: true })) {
404
- const rel = base ? `${base}/${entry.name}` : entry.name;
405
- if (!entry.isDirectory() || entry.isSymbolicLink() || !enrollment(rel))
406
- continue;
407
- const child = join(directory, entry.name);
408
- visit(child, enrollment, rel);
409
- }
410
- };
411
- visit(root, policy);
480
+ const wanted = new Map([[root, policy]]);
412
481
  for (const row of readRows(this.databasePath(identity))) {
413
482
  if (row.parentUUID !== null || row.absolutePath === root)
414
483
  continue;
415
484
  try {
416
485
  if (statSync(row.absolutePath).isDirectory())
417
- visit(row.absolutePath, () => true);
486
+ wanted.set(row.absolutePath, () => true);
418
487
  }
419
488
  catch {
420
489
  // Unresolved ground keeps its rows but cannot hold a watcher handle.
@@ -422,35 +491,62 @@ export class UserGroundHost {
422
491
  }
423
492
  for (const [directory, watcher] of this.watchers) {
424
493
  if (!wanted.has(directory)) {
425
- watcher.close();
494
+ watcher.handle.close();
426
495
  this.watchers.delete(directory);
427
496
  }
428
497
  }
429
- for (const directory of wanted) {
498
+ for (const [directory, enrollment] of wanted) {
430
499
  if (this.watchers.has(directory))
431
500
  continue;
432
- const watcher = watch(directory, (_event, file) => {
433
- if (String(file || "").split(/[\\/]/).includes(".amalgm"))
501
+ const suspicion = createSuspicionScope();
502
+ const handle = watch(directory, { recursive: true }, (_event, file) => {
503
+ const relativePath = String(file || "").split(sep).join("/");
504
+ if (relativePath.split("/").includes(".amalgm"))
434
505
  return;
506
+ if (relativePath && !enrollment(relativePath))
507
+ return;
508
+ const segments = relativePath.split("/");
509
+ const git = segments.indexOf(".git");
510
+ if (!relativePath || relativePath === ".amalgmignore"
511
+ || (git === 0 && segments[1] === "index")) {
512
+ suspicion.suspectAll();
513
+ }
514
+ else if (git > 0 && segments[git + 1] === "index") {
515
+ suspicion.ring(segments.slice(0, git).join("/"));
516
+ }
517
+ else {
518
+ suspicion.ring(relativePath);
519
+ }
435
520
  this.scheduleRescan();
436
521
  });
437
- watcher.on("error", () => {
438
- watcher.close();
522
+ handle.on("error", () => {
523
+ suspicion.suspectAll();
524
+ handle.close();
439
525
  this.watchers.delete(directory);
526
+ this.scheduleRescan();
440
527
  });
441
- this.watchers.set(directory, watcher);
528
+ this.watchers.set(directory, { handle, suspicion });
529
+ // Coverage began after the preceding observation. One catch-up proves
530
+ // the unobserved interval before watcher silence can be trusted.
531
+ suspicion.suspectAll();
532
+ this.scheduleRescan();
442
533
  }
443
534
  }
444
535
  scheduleRescan() {
536
+ if (this.closing)
537
+ return;
445
538
  this.watchDirty = true;
446
539
  if (this.rescanTimer)
447
540
  clearTimeout(this.rescanTimer);
448
541
  this.rescanTimer = setTimeout(() => {
449
542
  this.rescanTimer = null;
450
543
  const identity = this.activeIdentity;
451
- if (!identity)
544
+ if (!identity || this.closing)
452
545
  return;
453
- void this.flushObservedChanges().then(() => this.ensureWatchers(identity)).catch(() => {
546
+ void this.flushObservedChanges().then(() => {
547
+ if (!this.closing)
548
+ this.ensureWatchers(identity);
549
+ }).catch(() => {
454
550
  // The durable outbox remains claimable by the next event or resume.
455
551
  });
456
552
  }, 150);
@@ -461,16 +557,25 @@ export class UserGroundHost {
461
557
  const state = this.cloudState;
462
558
  if (!state)
463
559
  throw new Error("cloud state is unavailable for user-ground Watch");
560
+ const observations = new Map();
561
+ for (const [directory, watcher] of this.watchers) {
562
+ observations.set(directory, watcher.suspicion.snapshot());
563
+ }
464
564
  const localRecords = scanAndRegister({
465
565
  identity,
466
566
  userRoot: this.userRoot(identity),
467
567
  database: this.databasePath(identity),
468
568
  cacheDir: this.cacheDir(identity),
569
+ suspicions: new Map([...observations].map(([directory, snapshot]) => [directory, snapshot.paths])),
469
570
  });
470
571
  const snapshot = snapshotFromRecords(travelingRecords(mergeMaterializedRoots(state.records, localRecords)));
471
572
  const checksum = sha256Hex(stableJson(snapshot));
472
- if (checksum === state.checksum)
573
+ if (checksum === state.checksum) {
574
+ for (const [directory, snapshot] of observations) {
575
+ this.watchers.get(directory)?.suspicion.settle(snapshot);
576
+ }
473
577
  return;
578
+ }
474
579
  const database = initializeDatabase(this.databasePath(identity));
475
580
  try {
476
581
  database.prepare(`
@@ -484,6 +589,9 @@ export class UserGroundHost {
484
589
  database.close();
485
590
  }
486
591
  await this.drainOutbox(identity);
592
+ for (const [directory, snapshot] of observations) {
593
+ this.watchers.get(directory)?.suspicion.settle(snapshot);
594
+ }
487
595
  }
488
596
  async drainOutboxBeforeLookup(identity, resourceId) {
489
597
  const pending = readOutbox(this.databasePath(identity));
@@ -532,6 +640,30 @@ export class UserGroundHost {
532
640
  }
533
641
  }
534
642
  const sha256Hex = (input) => createHash("sha256").update(input).digest("hex");
643
+ async function sha256File(file) {
644
+ const hash = createHash("sha256");
645
+ for await (const chunk of createReadStream(file))
646
+ hash.update(chunk);
647
+ return hash.digest("hex");
648
+ }
649
+ function contentCacheFile(cacheDir, contentHash) {
650
+ return join(cacheDir, `${contentHash}.bin`);
651
+ }
652
+ function manifestCacheFile(cacheDir, contentHash) {
653
+ return join(cacheDir, "manifests", `${contentHash}.json`);
654
+ }
655
+ function chunkCacheFile(cacheDir, chunkHash) {
656
+ return join(cacheDir, "chunks", `${chunkHash}.bin`);
657
+ }
658
+ function readCachedManifest(cacheDir, contentHash) {
659
+ try {
660
+ const checked = checkContentManifest(JSON.parse(readFileSync(manifestCacheFile(cacheDir, contentHash), "utf8")), contentHash);
661
+ return checked.ok ? checked.value : null;
662
+ }
663
+ catch {
664
+ return null;
665
+ }
666
+ }
535
667
  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;
536
668
  function workspaceBindingDir(userRoot, deviceId) {
537
669
  return join(liveMachineStateDir(userRoot, deviceId), "bindings");
@@ -693,10 +825,13 @@ function deleteOutbox(file, mutationId) {
693
825
  database.close();
694
826
  }
695
827
  }
828
+ function portableRecord(row) {
829
+ return Object.fromEntries(PORTABLE_FIELDS.map((field) => [field, row[field]]));
830
+ }
696
831
  function portableRecords(file) {
697
- return readRows(file).map((row) => Object.fromEntries(PORTABLE_FIELDS.map((field) => [field, row[field]])));
832
+ return readRows(file).map(portableRecord);
698
833
  }
699
- function persistRows(file, identity, resourceId, rootUUID, rows) {
834
+ function persistRows(file, identity, resourceId, rootUUID, rows, previousRows = []) {
700
835
  const database = initializeDatabase(file);
701
836
  try {
702
837
  const replaceIdentity = database.prepare(`
@@ -707,18 +842,54 @@ function persistRows(file, identity, resourceId, rootUUID, rows) {
707
842
  user_email = excluded.user_email,
708
843
  device_id = excluded.device_id
709
844
  `);
710
- const insert = database.prepare(`
845
+ const upsert = database.prepare(`
711
846
  INSERT INTO entities(
712
847
  uuid, resource_id, root_uuid, type, parent_uuid, name, status, version, payload_version,
713
848
  transport_version, relative_path, absolute_path, device_number, inode
714
849
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
850
+ ON CONFLICT(uuid) DO UPDATE SET
851
+ resource_id = excluded.resource_id,
852
+ root_uuid = excluded.root_uuid,
853
+ type = excluded.type,
854
+ parent_uuid = excluded.parent_uuid,
855
+ name = excluded.name,
856
+ status = excluded.status,
857
+ version = excluded.version,
858
+ payload_version = excluded.payload_version,
859
+ transport_version = excluded.transport_version,
860
+ relative_path = excluded.relative_path,
861
+ absolute_path = excluded.absolute_path,
862
+ device_number = excluded.device_number,
863
+ inode = excluded.inode
715
864
  `);
865
+ const remove = database.prepare("DELETE FROM entities WHERE uuid = ? AND resource_id = ? AND root_uuid = ?");
866
+ const previousByUuid = new Map(previousRows.map((row) => [row.uuid, row]));
867
+ const currentUuids = new Set(rows.map((row) => row.record.uuid));
868
+ const changed = rows.filter((row) => {
869
+ const old = previousByUuid.get(row.record.uuid);
870
+ return !old
871
+ || old.resourceId !== resourceId
872
+ || old.rootUUID !== rootUUID
873
+ || old.type !== row.record.type
874
+ || old.parentUUID !== row.record.parentUUID
875
+ || old.name !== row.record.name
876
+ || old.status !== row.record.status
877
+ || old.version !== row.record.version
878
+ || old.payloadVersion !== row.record.payloadVersion
879
+ || old.transportVersion !== row.record.transportVersion
880
+ || old.relativePath !== row.relativePath
881
+ || old.absolutePath !== row.absolutePath
882
+ || old.deviceNumber !== row.deviceNumber
883
+ || old.inode !== row.inode;
884
+ });
716
885
  const replace = database.transaction(() => {
717
886
  replaceIdentity.run(identity.userId, identity.userEmail, identity.deviceId);
718
- database.prepare("DELETE FROM entities WHERE resource_id = ? AND root_uuid = ?")
719
- .run(resourceId, rootUUID);
720
- for (const row of rows)
721
- insert.run(row.record.uuid, resourceId, rootUUID, row.record.type, row.record.parentUUID, row.record.name, row.record.status, row.record.version, row.record.payloadVersion, row.record.transportVersion, row.relativePath, row.absolutePath, row.deviceNumber, row.inode);
887
+ for (const old of previousRows) {
888
+ if (!currentUuids.has(old.uuid))
889
+ remove.run(old.uuid, resourceId, rootUUID);
890
+ }
891
+ for (const row of changed)
892
+ upsert.run(row.record.uuid, resourceId, rootUUID, row.record.type, row.record.parentUUID, row.record.name, row.record.status, row.record.version, row.record.payloadVersion, row.record.transportVersion, row.relativePath, row.absolutePath, row.deviceNumber, row.inode);
722
893
  });
723
894
  replace();
724
895
  }
@@ -788,12 +959,17 @@ function slashPath(path) {
788
959
  return path.split(sep).join("/");
789
960
  }
790
961
  function scanAndRegister(input) {
791
- const { identity, userRoot, database, cacheDir } = input;
962
+ const { identity, userRoot, database, cacheDir, suspicions } = input;
792
963
  const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
793
964
  const existing = readRows(database);
794
965
  const ignoreFile = join(userRoot, ".amalgmignore");
795
966
  const policy = createUserGroundEnrollmentPolicy(existsSync(ignoreFile) ? readFileSync(ignoreFile, "utf8") : "");
796
967
  const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
968
+ const suspicionFor = (root) => {
969
+ if (!suspicions || !suspicions.has(root))
970
+ return suspicions ? [] : null;
971
+ return suspicions.get(root);
972
+ };
797
973
  const existingCore = existing.find((row) => row.parentUUID === null && row.absolutePath === userRoot && row.type === "workspace");
798
974
  const coreUUID = existingCore?.uuid || randomUUID();
799
975
  const core = scanRoot({
@@ -807,9 +983,11 @@ function scanAndRegister(input) {
807
983
  cacheDir,
808
984
  policy,
809
985
  bindingDir,
986
+ suspects: suspicionFor(userRoot),
987
+ existingRows: existing.filter((row) => row.rootUUID === coreUUID),
810
988
  });
811
989
  const targetRoots = new Set(core.referenceWorkspaceIds);
812
- for (const row of readRows(database)) {
990
+ for (const row of existing) {
813
991
  if (row.parentUUID === null && row.uuid !== coreUUID)
814
992
  targetRoots.add(row.uuid);
815
993
  }
@@ -827,7 +1005,8 @@ function scanAndRegister(input) {
827
1005
  // so a move can be rescued; absence is never permission to delete it.
828
1006
  continue;
829
1007
  }
830
- const existingRoot = readRows(database).find((row) => row.uuid === workspaceId && row.parentUUID === null);
1008
+ const existingRoot = existing.find((row) => row.uuid === workspaceId && row.parentUUID === null);
1009
+ const existingWorkspaceRows = existing.filter((row) => row.rootUUID === workspaceId);
831
1010
  scanRoot({
832
1011
  identity,
833
1012
  resourceId,
@@ -839,15 +1018,32 @@ function scanAndRegister(input) {
839
1018
  cacheDir,
840
1019
  policy: () => true,
841
1020
  bindingDir,
1021
+ suspects: existingWorkspaceRows.length === 0 ? null : suspicionFor(rootPath),
1022
+ existingRows: existingWorkspaceRows,
842
1023
  });
843
1024
  }
844
1025
  return portableRecords(database);
845
1026
  }
846
1027
  function scanRoot(input) {
847
- const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, seedByPath = new Map(), repositoryHeads = new Map(), persist = true, } = input;
848
- const existingByPath = new Map(readRows(database)
849
- .filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID)
850
- .map((row) => [row.relativePath, row]));
1028
+ const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, } = input;
1029
+ const existingRows = (input.existingRows ?? readRows(database))
1030
+ .filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID);
1031
+ const existingByPath = new Map(existingRows.map((row) => [row.relativePath, row]));
1032
+ if (suspects !== null && suspects.length === 0 && existingRows.length > 0) {
1033
+ return {
1034
+ records: existingRows.map(portableRecord),
1035
+ rows: existingRows.map((row) => ({
1036
+ record: portableRecord(row),
1037
+ relativePath: row.relativePath,
1038
+ absolutePath: row.absolutePath,
1039
+ deviceNumber: row.deviceNumber,
1040
+ inode: row.inode,
1041
+ })),
1042
+ referenceWorkspaceIds: existingRows
1043
+ .filter((row) => row.type === "reference" && row.payloadVersion && UUID.test(row.payloadVersion))
1044
+ .map((row) => row.payloadVersion),
1045
+ };
1046
+ }
851
1047
  const entries = [];
852
1048
  const referenceWorkspaceIds = new Set();
853
1049
  const rootStats = lstatSync(rootPath);
@@ -863,7 +1059,33 @@ function scanRoot(input) {
863
1059
  deviceNumber: rootStats.dev,
864
1060
  inode: rootStats.ino,
865
1061
  });
866
- const visit = (directory, parentUUID, parentType, base = "") => {
1062
+ const touchesSuspicion = (path) => suspects === null
1063
+ || pathIsSuspect(path, suspects)
1064
+ || suspects.some((suspect) => suspect.startsWith(`${path}/`));
1065
+ const repositoryEvidencePaths = (repository) => {
1066
+ if (suspects === null)
1067
+ return null;
1068
+ const prefix = slashPath(relative(rootPath, repository));
1069
+ const paths = [];
1070
+ for (const suspect of suspects) {
1071
+ if (suspect === prefix || (prefix && prefix.startsWith(`${suspect}/`)))
1072
+ return null;
1073
+ const local = prefix
1074
+ ? suspect.startsWith(`${prefix}/`) ? suspect.slice(prefix.length + 1) : null
1075
+ : suspect;
1076
+ if (local === null)
1077
+ continue;
1078
+ if (local === ".git/index" || local.startsWith(".git/index/"))
1079
+ return null;
1080
+ if (local === ".git" || local.startsWith(".git/"))
1081
+ continue;
1082
+ paths.push(local);
1083
+ }
1084
+ return [...new Set(paths)].sort();
1085
+ };
1086
+ const visit = (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
1087
+ ? { root: directory, evidencePaths: repositoryEvidencePaths(directory), evidence: null }
1088
+ : null) => {
867
1089
  const children = readdirSync(directory, { withFileTypes: true })
868
1090
  .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
869
1091
  for (const child of children) {
@@ -875,11 +1097,27 @@ function scanRoot(input) {
875
1097
  const absolutePath = join(directory, child.name);
876
1098
  const stats = lstatSync(absolutePath);
877
1099
  const existing = existingByPath.get(relativePath);
878
- const seeded = seedByPath.get(relativePath);
879
- const uuid = existing?.uuid || seeded?.uuid || randomUUID();
1100
+ const uuid = existing?.uuid || randomUUID();
1101
+ const observe = existing === undefined || touchesSuspicion(relativePath);
1102
+ const repositoryPath = activeRepository
1103
+ ? slashPath(relative(activeRepository.root, absolutePath))
1104
+ : null;
1105
+ if (observe && repositoryPath && activeRepository && !activeRepository.evidence) {
1106
+ activeRepository.evidence = inspectGitRegistration(activeRepository.root, activeRepository.evidencePaths);
1107
+ }
1108
+ const indexed = observe && repositoryPath
1109
+ ? activeRepository?.evidence?.cleanLeaves.get(repositoryPath)
1110
+ : undefined;
880
1111
  let type;
881
1112
  let payloadVersion = null;
882
- if (stats.isSymbolicLink()) {
1113
+ if (!observe && existing) {
1114
+ type = existing.type;
1115
+ payloadVersion = existing.payloadVersion;
1116
+ if (type === "reference" && payloadVersion && UUID.test(payloadVersion)) {
1117
+ referenceWorkspaceIds.add(payloadVersion);
1118
+ }
1119
+ }
1120
+ else if (stats.isSymbolicLink()) {
883
1121
  const referenceId = referenceWorkspaceId(absolutePath, bindingDir);
884
1122
  if (referenceId) {
885
1123
  type = "reference";
@@ -888,28 +1126,36 @@ function scanRoot(input) {
888
1126
  }
889
1127
  else {
890
1128
  type = "link";
891
- const bytes = Buffer.from(readlinkSync(absolutePath), "utf8");
892
- payloadVersion = sha256Hex(bytes);
893
- immutableWrite(join(cacheDir, `${payloadVersion}.bin`), bytes);
1129
+ if (indexed?.type === "link") {
1130
+ payloadVersion = indexed.payloadVersion;
1131
+ }
1132
+ else {
1133
+ const bytes = Buffer.from(readlinkSync(absolutePath), "utf8");
1134
+ payloadVersion = sha256Hex(bytes);
1135
+ if (!activeRepository)
1136
+ immutableWrite(join(cacheDir, `${payloadVersion}.bin`), bytes);
1137
+ }
894
1138
  }
895
1139
  }
896
1140
  else if (stats.isDirectory()) {
897
1141
  type = classifyDirectory({ repository: isRepository(absolutePath) });
898
1142
  }
899
1143
  else if (stats.isFile()) {
900
- const bytes = readFileSync(absolutePath);
901
- type = fileType(bytes);
902
- payloadVersion = sha256Hex(bytes);
903
- immutableWrite(join(cacheDir, `${payloadVersion}.bin`), bytes);
1144
+ if (indexed && indexed.type !== "link") {
1145
+ type = indexed.type;
1146
+ payloadVersion = indexed.payloadVersion;
1147
+ }
1148
+ else {
1149
+ const bytes = readFileSync(absolutePath);
1150
+ type = fileType(bytes);
1151
+ payloadVersion = sha256Hex(bytes);
1152
+ if (!activeRepository)
1153
+ immutableWrite(join(cacheDir, `${payloadVersion}.bin`), bytes);
1154
+ }
904
1155
  }
905
1156
  else {
906
1157
  continue;
907
1158
  }
908
- if (seeded && seeded.type !== type
909
- && !(["file.text", "file.binary"].includes(seeded.type)
910
- && ["file.text", "file.binary"].includes(type))) {
911
- throw new Error(`materialized entity ${relativePath} is ${type}, expected ${seeded.type}`);
912
- }
913
1159
  entries.push({
914
1160
  uuid,
915
1161
  type,
@@ -923,7 +1169,13 @@ function scanRoot(input) {
923
1169
  inode: stats.ino,
924
1170
  });
925
1171
  if (stats.isDirectory() && !stats.isSymbolicLink()) {
926
- visit(absolutePath, uuid, type, relativePath);
1172
+ visit(absolutePath, uuid, type, relativePath, type === "repo.git"
1173
+ ? {
1174
+ root: absolutePath,
1175
+ evidencePaths: repositoryEvidencePaths(absolutePath),
1176
+ evidence: null,
1177
+ }
1178
+ : activeRepository);
927
1179
  }
928
1180
  }
929
1181
  };
@@ -943,42 +1195,50 @@ function scanRoot(input) {
943
1195
  : entry.relativePath,
944
1196
  uuid: entry.uuid,
945
1197
  type: entry.type,
1198
+ payloadVersion: entry.payloadVersion,
946
1199
  }))
947
1200
  .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0);
948
- const seededHead = repositoryHeads.get(repository.uuid);
949
- if (seededHead) {
950
- if (sha256Hex(seededHead.bytes) !== seededHead.transportVersion) {
951
- throw new Error(`repository transport ${repository.uuid} is corrupt`);
952
- }
953
- const state = unpack(Buffer.from(seededHead.bytes).toString("utf8"));
954
- if (state.stateId !== seededHead.stateId
955
- || JSON.stringify(state.identity ?? []) !== JSON.stringify(repoIdentity)) {
956
- throw new Error(`repository transport ${repository.uuid} does not match its materialized identity`);
957
- }
958
- repository.payloadVersion = seededHead.stateId;
959
- repository.transportVersion = seededHead.transportVersion;
960
- immutableWrite(join(cacheDir, `${seededHead.transportVersion}.bin`), seededHead.bytes);
961
- continue;
962
- }
963
1201
  const priorRow = existingByPath.get(repository.relativePath);
964
1202
  let prior = null;
965
1203
  if (priorRow?.type === "repo.git" && priorRow.payloadVersion && priorRow.transportVersion) {
966
1204
  const priorPath = join(cacheDir, `${priorRow.transportVersion}.bin`);
967
1205
  if (existsSync(priorPath)) {
968
- const bytes = readFileSync(priorPath);
969
- if (sha256Hex(bytes) === priorRow.transportVersion) {
1206
+ const layout = inspectRepositoryTransportFile(priorPath);
1207
+ if (layout.stateId === priorRow.payloadVersion) {
970
1208
  prior = {
971
1209
  stateId: priorRow.payloadVersion,
972
1210
  transportVersion: priorRow.transportVersion,
973
- bytes,
1211
+ card: layout.card,
1212
+ identityHash: layout.identityHash,
974
1213
  };
975
1214
  }
976
1215
  }
977
1216
  }
978
- const captured = captureRepository(repository.absolutePath, repoIdentity, prior);
1217
+ const previousRepositories = [...existingByPath.values()]
1218
+ .filter((row) => row.type === "repo.git");
1219
+ const previousOwner = (row) => previousRepositories
1220
+ .filter((candidate) => candidate.uuid !== row.uuid
1221
+ && properDescendantOf(row.relativePath, candidate.relativePath))
1222
+ .sort((left, right) => right.relativePath.length - left.relativePath.length)[0] ?? null;
1223
+ const previousIdentity = prior && priorRow
1224
+ ? [...existingByPath.values()]
1225
+ .filter((row) => previousOwner(row)?.uuid === priorRow.uuid)
1226
+ .map((row) => ({
1227
+ path: priorRow.relativePath
1228
+ ? row.relativePath.slice(priorRow.relativePath.length + 1)
1229
+ : row.relativePath,
1230
+ uuid: row.uuid,
1231
+ type: row.type,
1232
+ payloadVersion: row.payloadVersion,
1233
+ }))
1234
+ .sort((left, right) => left.path < right.path ? -1 : left.path > right.path ? 1 : 0)
1235
+ : null;
1236
+ const captured = captureRepository(repository.absolutePath, repoIdentity, prior, previousIdentity);
979
1237
  repository.payloadVersion = captured.stateId;
980
1238
  repository.transportVersion = captured.transportVersion;
981
- immutableWrite(join(cacheDir, `${captured.transportVersion}.bin`), captured.bytes);
1239
+ if (captured.bytes) {
1240
+ immutableWrite(join(cacheDir, `${captured.transportVersion}.bin`), captured.bytes);
1241
+ }
982
1242
  }
983
1243
  const children = new Map();
984
1244
  for (const entry of entries) {
@@ -1016,8 +1276,7 @@ function scanRoot(input) {
1016
1276
  inode: entry.inode,
1017
1277
  };
1018
1278
  });
1019
- if (persist)
1020
- persistRows(database, identity, resourceId, rootUUID, rows);
1279
+ persistRows(database, identity, resourceId, rootUUID, rows, existingRows);
1021
1280
  return { records: rows.map((row) => row.record), rows, referenceWorkspaceIds: [...referenceWorkspaceIds] };
1022
1281
  }
1023
1282
  function portablePaths(records) {
@@ -1117,19 +1376,10 @@ function installCloudGround(input) {
1117
1376
  bindingDir: workspaceBindingDir(userRoot, identity.deviceId),
1118
1377
  readContent,
1119
1378
  });
1120
- const rows = materialized.repositories.length > 0
1121
- ? captureMaterializedGraph({
1122
- identity,
1123
- resourceId: cloud.resourceId,
1124
- root: roots[0],
1125
- records,
1126
- repositories: materialized.repositories,
1127
- rootPath: userRoot,
1128
- database,
1129
- cacheDir,
1130
- bindingDir: workspaceBindingDir(userRoot, identity.deviceId),
1131
- })
1132
- : materialized.rows;
1379
+ const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
1380
+ record: repository.record,
1381
+ identity: repository.state.identity,
1382
+ })), sha256Hex);
1133
1383
  persistRows(database, identity, cloud.resourceId, roots[0].uuid, rows);
1134
1384
  }
1135
1385
  catch (error) {
@@ -1147,17 +1397,35 @@ async function materializeRecords(input) {
1147
1397
  if (!record.payloadVersion || !record.transportVersion) {
1148
1398
  throw new Error(`cloud repository ${record.uuid} has no state transport`);
1149
1399
  }
1150
- const bytes = await readContent(record.transportVersion);
1151
- if (sha256Hex(bytes) !== record.transportVersion) {
1400
+ const artifact = artifactForRecord(record);
1401
+ if (!artifact)
1402
+ throw new Error(`cloud repository ${record.uuid} has no transfer artifact`);
1403
+ const local = await readContent(artifact);
1404
+ if (local.contentHash !== record.transportVersion) {
1152
1405
  throw new Error(`cloud repository ${record.uuid} transport is corrupt`);
1153
1406
  }
1154
- const state = unpack(bytes.toString("utf8"));
1155
- if (state.stateId !== record.payloadVersion) {
1407
+ const chain = [{ file: local.file, transportVersion: local.contentHash }];
1408
+ const versions = new Set([record.transportVersion]);
1409
+ let state = inspectRepositoryTransportFile(local.file);
1410
+ while (state.parentTransportVersion) {
1411
+ if (versions.has(state.parentTransportVersion)) {
1412
+ throw new Error(`cloud repository ${record.uuid} transport chain has a cycle`);
1413
+ }
1414
+ const parentVersion = state.parentTransportVersion;
1415
+ versions.add(parentVersion);
1416
+ const parent = await readContent({ ...artifact, contentHash: parentVersion });
1417
+ if (parent.contentHash !== parentVersion) {
1418
+ throw new Error(`cloud repository ${record.uuid} parent transport is corrupt`);
1419
+ }
1420
+ chain.unshift({ file: parent.file, transportVersion: parent.contentHash });
1421
+ state = inspectRepositoryTransportFile(parent.file);
1422
+ }
1423
+ const latest = inspectRepositoryTransportFile(local.file);
1424
+ if (latest.stateId !== record.payloadVersion) {
1156
1425
  throw new Error(`cloud repository ${record.uuid} transport does not name its declared state`);
1157
1426
  }
1158
- immutableWrite(join(cacheDir, `${record.transportVersion}.bin`), bytes);
1159
- const applied = applyRepository(destination, bytes);
1160
- repositories.push({ record, state: applied, bytes });
1427
+ const applied = await applyRepositoryFiles(destination, chain);
1428
+ repositories.push({ record, state: applied });
1161
1429
  };
1162
1430
  const paths = portablePaths(records);
1163
1431
  const ordered = [...records].sort((left, right) => {
@@ -1203,13 +1471,15 @@ async function materializeRecords(input) {
1203
1471
  throw new Error(`cloud leaf ${record.uuid} has no content head`);
1204
1472
  if (pathExists(destination))
1205
1473
  throw new Error(`cloud materialization conflicts with ${relativePath}`);
1206
- const bytes = await readContent(record.payloadVersion);
1474
+ const artifact = artifactForRecord(record);
1475
+ if (!artifact)
1476
+ throw new Error(`cloud leaf ${record.uuid} has no transfer artifact`);
1477
+ const local = await readContent(artifact);
1207
1478
  ensurePrivateDir(dirname(destination));
1208
1479
  if (record.type === "link")
1209
- symlinkSync(bytes.toString("utf8"), destination);
1480
+ symlinkSync(readFileSync(local.file, "utf8"), destination);
1210
1481
  else
1211
- atomicWrite(destination, bytes);
1212
- immutableWrite(join(cacheDir, `${record.payloadVersion}.bin`), bytes);
1482
+ atomicCopy(local.file, destination);
1213
1483
  }
1214
1484
  else {
1215
1485
  throw new Error(`materialization for ${record.type} requires its dedicated adapter`);
@@ -1229,50 +1499,6 @@ async function materializeRecords(input) {
1229
1499
  });
1230
1500
  return { rows, repositories };
1231
1501
  }
1232
- function captureMaterializedGraph(input) {
1233
- const { identity, resourceId, root, records, repositories, rootPath, database, cacheDir, bindingDir, } = input;
1234
- if (root.type !== "workspace" && root.type !== "repo.git") {
1235
- throw new Error(`cloud root ${root.uuid} cannot be scanned as a workspace`);
1236
- }
1237
- const paths = portablePaths(records);
1238
- const seedByPath = new Map();
1239
- for (const record of records) {
1240
- const path = paths.get(record.uuid);
1241
- if (path)
1242
- seedByPath.set(path, { uuid: record.uuid, type: record.type });
1243
- }
1244
- const repositoryHeads = new Map();
1245
- for (const repository of repositories) {
1246
- if (!repository.record.transportVersion || !repository.record.payloadVersion) {
1247
- throw new Error(`cloud repository ${repository.record.uuid} has no transport identity`);
1248
- }
1249
- const base = paths.get(repository.record.uuid);
1250
- for (const entry of repository.state.identity ?? []) {
1251
- const path = [base, entry.path].filter(Boolean).join("/");
1252
- seedByPath.set(path, { uuid: entry.uuid, type: entry.type });
1253
- }
1254
- repositoryHeads.set(repository.record.uuid, {
1255
- stateId: repository.record.payloadVersion,
1256
- transportVersion: repository.record.transportVersion,
1257
- bytes: repository.bytes,
1258
- });
1259
- }
1260
- return scanRoot({
1261
- identity,
1262
- resourceId,
1263
- rootUUID: root.uuid,
1264
- rootName: root.name,
1265
- rootPath,
1266
- rootType: root.type,
1267
- database,
1268
- cacheDir,
1269
- policy: () => true,
1270
- bindingDir,
1271
- seedByPath,
1272
- repositoryHeads,
1273
- persist: false,
1274
- }).rows;
1275
- }
1276
1502
  async function installCloudWorkspace(input) {
1277
1503
  const { identity, database, cacheDir, bindingDir, resourceId, workspace, records, destinationParent, readContent, } = input;
1278
1504
  const existing = readRows(database).filter((row) => row.rootUUID === workspace.uuid);
@@ -1306,19 +1532,10 @@ async function installCloudWorkspace(input) {
1306
1532
  const materialized = await materializeRecords({
1307
1533
  records, rootPath: destination, cacheDir, bindingDir, readContent,
1308
1534
  });
1309
- const rows = materialized.repositories.length > 0
1310
- ? captureMaterializedGraph({
1311
- identity,
1312
- resourceId,
1313
- root: workspace,
1314
- records,
1315
- repositories: materialized.repositories,
1316
- rootPath: destination,
1317
- database,
1318
- cacheDir,
1319
- bindingDir,
1320
- })
1321
- : materialized.rows;
1535
+ const rows = projectMaterializedGraph(materialized.rows, materialized.repositories.map((repository) => ({
1536
+ record: repository.record,
1537
+ identity: repository.state.identity,
1538
+ })), sha256Hex);
1322
1539
  symlinkSync(destination, binding, "dir");
1323
1540
  persistRows(database, identity, resourceId, workspace.uuid, rows);
1324
1541
  return {
@@ -1356,7 +1573,10 @@ function readyFromFrame(frame) {
1356
1573
  };
1357
1574
  }
1358
1575
  function frameBytes(frame) {
1359
- const bytes = Buffer.from(String(frame.data_b64 || ""), "base64");
1576
+ if (!(frame.data_bin instanceof Uint8Array)) {
1577
+ throw new Error("wire content response is not a binary frame");
1578
+ }
1579
+ const bytes = Buffer.from(frame.data_bin);
1360
1580
  if (bytes.length !== Number(frame.bytes) || sha256Hex(bytes) !== String(frame.sha256 || "")) {
1361
1581
  throw new Error("wire content bytes do not match their checksum");
1362
1582
  }