All files / src vfs-node.ts

97.16% Statements 652/671
91.37% Branches 371/406
100% Functions 47/47
97.16% Lines 652/671

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 945 946 947 948 949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 969 970 971 972 973 974 975 976 977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 11371x 1x 1x 1x 1x 1x   1x 1x                     1x                                                                                 1x                                               1x 74478x 74478x                         521x 521x 521x 521x 521x 521x 2563x 521x 2563x 515x 515x 515x 515x 515x 515x 515x 6x 6x                                 1x 1x 355x 355x   355x   355x 355x 355x   1x 355x 355x 355x 355x 355x 355x 355x 355x     1x 355x 355x 355x 355x 355x 1x 1x 1x 355x 355x   1x 50x 50x     1x 1338x 1338x       1x 3123x 3123x     1x 160x 160x     1x 93x 93x     1x 73x 73x           1x 1x 1x 1x 1x 1x 1x 1x                 1x 43396x 43396x     1x 3x 3x 3x                                       1x 5x 5x 5x 5x 2x 2x 2x 2x 2x   3x 3x     3x 5x   3x 3x 3x 3x 3x 3x 3x   3x 5x 6x 6x 6x 6x 6x     6x 6x         6x     1x 1x 3x 5x             1x 2732x 2732x 2732x 2732x       1x 615x 615x   1x 642x 642x   1x 526x 526x     1x 82x 82x                       1x 5x 5x   1x 5x 5x           1x 1x 1x   1x 70x 70x             1x 81x 81x 81x 81x 81x 81x                       1x 218x 19x 19x 6x 6x   218x               1x 2729x 2729x 2729x   2729x 2729x 2729x 2729x 2729x 2729x   2729x 2729x 2729x 2729x     2729x 2729x 20763x 20763x 20763x 8474x 7758x 8066x 8x 716x 708x 708x 8474x 20763x 20763x                           2729x 2729x 2729x 2729x 2729x       2729x 2729x 185x 132x 185x 132x 185x       2729x 2729x 81x 81x 81x 81x 2729x   2729x 2729x 2729x   2729x 20763x 20763x 20763x 20763x         20747x 20747x     20747x     20747x 20747x 20763x   20763x 20763x   20763x 20763x   20763x 20763x 20763x 20763x 20763x 20763x 20763x 20763x 20763x 20763x 20763x 20763x 19912x 20763x 20763x 20692x 20763x   20763x 938x 938x 938x 938x 938x 20763x   20763x 921x 921x 921x 921x 921x 921x 921x 921x 921x 921x 921x 921x 921x 921x 20763x                 2729x 33x 33x 33x 33x       2729x 24526x 24272x 4363x 4363x 4363x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x 119x   2729x 2729x               1x 2707x 2707x 2707x 2707x 2181x       2181x 2181x   526x 526x 526x 526x 561x 526x 526x 2707x                           1x 1980x 1980x 1980x 1980x 1980x 1980x 17358x 6084x 17358x 5065x 17358x 17348x 17358x 967x 967x 964x 964x 964x     1980x 1980x                     1x 529x 529x 529x 529x             1x 529x 529x 315x 315x 479x 479x 1107x 1107x 315x 315x 315x 529x                         1x 1460x 1460x 1460x   1460x 1460x 1460x 1460x   1460x 13916x 13916x 13916x 2440x 2440x 2440x     13916x               13916x 13916x 107x 107x 13916x 6902x 6902x 6902x           13916x 13916x         1460x 1460x 12775x       4204x 4196x 4196x       1460x 1460x 1460x 1460x 1460x 1460x 1460x 572x 572x 50x 50x 50x 50x             1460x 1460x 1460x 97x 97x 97x       1460x 1460x 107x 1x 1x 1x 1x 1x 1460x 107x 106x 106x 106x 106x       1460x 1460x 101x 101x 101x     1460x 1x 1x 1x   1460x   1460x 572x 572x 572x 53x 53x 53x 519x 516x   1460x 1460x                 1x 522x 522x 522x 522x 6x 6x 10x 6x 6x 1x 1x 1x 522x 513x 513x 513x 511x 511x 515x 522x   1x 103x       103x 103x           1x 1454x     1454x 13910x 13910x 13910x 13910x       13910x 13910x         13910x 13910x 12743x 10678x 10570x 10443x 13910x 10437x 10437x 13901x 1022x 1022x 1022x 1x 1x 1x 1022x 13910x 13910x 1454x 1454x 1454x 1454x 1454x         1x 8x 8x 8x 8x 8x 8x                             1x 3x 3x 3x 3x 1x 1x       1x 1x 3x       1x 3x                                               1x 5x 5x 5x 1x 1x 4x 4x 4x 4x 4x 4x 5x                 1x 44x 44x 44x 44x 38x 11x 11x 11x 11x 35x 38x 38x 38x 38x 38x 38x 38x 38x 38x 38x 38x 38x 38x 44x 44x                 1x 8x 8x 8x 7x         7x 8x     1x 1x 1x 8x 2x 1x 2x 6x         8x 6x 8x           1x 3x 3x 3x 3x 3x 6x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x 3x   1x 12x 12x 1x   546x 546x 546x 546x 546x 546x 546x 2x 2x 2x 2x         2182x 2182x 2182x 2182x 21803x 21803x 21803x 21803x 21803x 21803x 21803x         21803x   2182x 2182x  
import { MAX_TEXT_MERGE } from './diff3.js';
import { randomId, sha256, sha256Stream } from './hash.js';
import { History } from './history.js';
import { makeRow } from './log.js';
import { Sha256 } from './sha256.js';
import { CONTROL_DIR, VFSStore } from './store.js';
import type { VFSStoreOptions } from './store.js';
import { STREAM_THRESHOLD, canStream, pump, readRange, readStream, writeStream } from './stream.js';
import { extensionOf } from './vfs-file.js';
import type {
  ByteRange,
  Hash,
  LogRow,
  PendingConflict,
  VFSAdapter,
  VFSEntry,
  VFSFile,
  VFSStat,
} from './types.js';
import { walk } from './walk.js';
 
export interface VFSNodeOptions {
  /** Stable peer id. Generated and persisted in `.vfs/vfs.json` if omitted. */
  id?: string;
  /** Return true to keep a path out of sync entirely. */
  ignore?: (path: string) => boolean;
  /**
   * Bytes this node keeps on disk. Everything else: the entry travels, the
   * content does not — the tree is complete, the folder is not. Absent means
   * materialise everything, which is what a node without a policy has always
   * done.
   *
   * ```ts
   * VFSNode.open(fs, { materialize: (entry) => entry.size < 10_000_000 });
   * ```
   *
   * The engine stores nothing about this and nothing about it travels, so the
   * policy can be anything the app knows and can change between two calls. Two
   * consequences worth knowing before relying on it:
   *
   * - It governs what **arrives**, never what is already here. Turning it
   *   `false` for content this node holds does not free the bytes; that is
   *   {@link VFSNode.dematerialize}, which is deliberate and verified.
   * - It is the steady state. {@link VFSNode.materialize} fetches against the
   *   policy, and a policy that still wants the entry will fetch it back on the
   *   next sync.
   */
  materialize?: (entry: VFSEntry) => boolean;
  /** Injectable clock, mostly for tests. */
  now?: () => number;
  /**
   * Files at or above this many bytes are hashed and moved as streams instead
   * of being held whole. Defaults to {@link STREAM_THRESHOLD} (4 MiB). Only
   * takes effect on adapters that implement the streaming methods.
   */
  streamThreshold?: number;
  /** Active log segment size that triggers a rotation. */
  rotateAt?: number;
}
 
const TEMP_DIR = `${CONTROL_DIR}/tmp`;
 
/** Bytes for a hash, however the holder prefers to hand them over. */
export interface ContentHandle {
  size: number;
  read(): Promise<Uint8Array>;
  stream(): Promise<ReadableStream<Uint8Array>>;
}
 
/** Where `apply()` gets content it does not already have on disk. */
export interface ContentSource {
  open(hash: Hash, entry: VFSEntry): Promise<ContentHandle | null>;
}
 
/**
 * Whether this node has the file on disk. `mtime` is deleted on every adopt and
 * only ever re-set from a real `stat()`, so its presence means exactly that —
 * the invariant reconciliation leans on to tell a deletion from content that
 * was never here.
 *
 * One name for it because three places have to agree: `scan()` decides whether
 * absence is evidence, `apply()` and `planChanges()` decide whether a policy
 * that declines an entry is allowed to leave its bytes behind at a stale hash.
 */
export function materialised(entry: VFSEntry | undefined): boolean {
  return !!entry && !entry.deleted && entry.mtime !== undefined;
}
 
/**
 * Bytes for `hash` from whatever path `node` still holds them at.
 *
 * Every candidate path, not just the first: one of them missing from disk does
 * not mean the peer cannot serve the content. Duplicate content is ordinary,
 * and an entry can legitimately have no file behind it — bytes that never
 * travelled, or that this peer has not materialised.
 *
 * Disk is the ground truth here, not policy. A peer serves whatever it happens
 * to hold, whether or not its own predicate would have chosen to keep it.
 */
export async function holds(
  node: VFSNode,
  entries: VFSEntry[],
  hash: Hash,
): Promise<ContentHandle | null> {
  for (const entry of entries) {
    if (entry.deleted || entry.kind !== 'file' || entry.hash !== hash || entry.held) continue;
    const stat = await node.stat(entry.path);
    if (!stat || stat.kind !== 'file') continue;
    const path = entry.path;
    return {
      size: stat.size,
      read: () => node.read(path),
      stream: () => node.readStream(path),
    };
  }
  return null;
}
 
export interface ScanResult {
  entries: VFSEntry[];
  /** One row per operation the scan discovered. Empty when nothing changed. */
  rows: LogRow[];
  batch: string;
}
 
/**
 * One participant in the mesh: an adapter (the working folder) plus its `.vfs`
 * control folder. A node only ever knows the peers it syncs with directly.
 *
 * In v2 there is no object store and no commit graph. The working file *is* the
 * content, `vfs.json` is the mirror of the tree, and `commits` is an
 * append-only log of operations whose union across peers is idempotent.
 */
export class VFSNode {
  readonly adapter: VFSAdapter;
  readonly store: VFSStore;
  readonly peerId: string;
  /** Size from which content takes the streaming path. See {@link VFSNodeOptions}. */
  readonly streamThreshold: number;
 
  private readonly ignore: ((path: string) => boolean) | undefined;
  private readonly policy: ((entry: VFSEntry) => boolean) | undefined;
  private readonly now: () => number;
 
  private constructor(adapter: VFSAdapter, id: string, options: VFSNodeOptions, store: VFSStore) {
    this.adapter = adapter;
    this.store = store;
    this.peerId = id;
    this.ignore = options.ignore;
    this.policy = options.materialize;
    this.now = options.now ?? (() => Date.now());
    this.streamThreshold = options.streamThreshold ?? STREAM_THRESHOLD;
  }
 
  /** Opens (creating `.vfs/` if needed) the folder behind `adapter`. */
  static async open(adapter: VFSAdapter, options: VFSNodeOptions = {}): Promise<VFSNode> {
    const storeOptions: VFSStoreOptions = { ...(options.now ? { now: options.now } : {}) };
    if (options.rotateAt !== undefined) storeOptions.rotateAt = options.rotateAt;
    const store = new VFSStore(adapter, CONTROL_DIR, storeOptions);
    const file = await store.init(options.id ? { peerId: options.id } : {});
    if (options.id && file.peerId !== options.id) {
      file.peerId = options.id;
      await store.write(file);
    }
    return new VFSNode(adapter, file.peerId, options, store);
  }
 
  get name(): string {
    return this.adapter.name;
  }
 
  /** True when this content should go through the streaming path. */
  private streams(size: number): boolean {
    return size >= this.streamThreshold && canStream(this.adapter);
  }
 
  // ------------------------------------------------------------- the mirror
 
  file(): Promise<VFSFile> {
    return this.store.read();
  }
 
  /** The tree as recorded — no disk access. See §6: paint from here, reconcile apart. */
  async entries(): Promise<VFSEntry[]> {
    return (await this.store.read()).entries;
  }
 
  /** Live entries only, tombstones dropped. */
  async live(): Promise<VFSEntry[]> {
    return (await this.entries()).filter((entry) => !entry.deleted);
  }
 
  /** Digest of the live entries — one comparison answers "anything to sync?". */
  async state(): Promise<Hash> {
    return (await this.store.read()).state;
  }
 
  /**
   * The ancestry this node can answer from without paying for an archive: the
   * entries themselves, the active log segment and its cumulative snapshot.
   */
  async history(): Promise<History> {
    const file = await this.store.read();
    return History.from([
      file.entries,
      await this.store.logRows(),
      await this.store.readSnapshot(file),
    ]);
  }
 
  /**
   * True when this node's policy keeps this entry's bytes on disk. No policy
   * means everything, which is the behaviour a node has always had.
   *
   * Public because `planChanges()` has to ask the destination node the same
   * question `apply()` asks itself, and the two must not answer differently.
   */
  wants(entry: VFSEntry): boolean {
    return this.policy ? this.policy(entry) : true;
  }
 
  /** True when this path's extension is on the store's text list (§4). */
  async isText(path: string): Promise<boolean> {
    const extension = extensionOf(path);
    return extension !== '' && (await this.store.read()).text.includes(extension);
  }
 
  // ------------------------------------------------------ external changes
 
  /**
   * What has changed under this root since the last look, from the backend's
   * change feed — one request instead of a listing per folder.
   *
   * `null` means the backend has no feed, or its token expired, and the caller
   * has to fall back to a full walk (`commit()`). That fallback is not an error
   * path: it is the way this has always worked, and the feed is an optimisation
   * on top of it.
   *
   * Two filters make the answer usable. The feed is **account-wide** on the
   * backend that motivates it, so changes are attributed to entries by their
   * `native` id — which is what that field is for — and anything that cannot be
   * attributed is dropped until the next walk. And **our own writes come back in
   * the feed**, so a change whose size still matches what the mirror records is
   * discarded; without that, every write this node makes looks external.
   */
  async externalChanges(): Promise<string[] | null> {
    if (!this.adapter.changes) return null;
    const file = await this.store.read();
    const token = file.local.driveChangeToken;
    if (token === undefined) {
      const started = await this.adapter.changes(null);
      file.local.driveChangeToken = started.token;
      await this.store.write(file);
      return null; // no baseline yet — this walk is the baseline
    }
 
    const feed = await this.adapter.changes(token);
    file.local.driveChangeToken = feed.token;
    // The token is persisted when the cycle closes, not per page: on Drive every
    // write of `vfs.json` is a full re-upload.
    await this.store.write(file);
    if (feed.reset) return null;
 
    const byNative = new Map<string, VFSEntry>();
    const byPath = new Map<string, VFSEntry>();
    for (const entry of file.entries) {
      if (entry.deleted) continue;
      if (entry.native) byNative.set(entry.native, entry);
      byPath.set(entry.path, entry);
    }
 
    const out = new Set<string>();
    for (const change of feed.changes) {
      const known = byNative.get(change.native) ?? (change.path ? byPath.get(change.path) : undefined);
      const path = known?.path ?? change.path;
      if (!path) continue;
      if (this.ignore?.(path)) continue;
      if (path === CONTROL_DIR || path.startsWith(`${CONTROL_DIR}/`)) continue;
      // A change we cannot attribute is a file in a folder we have never
      // resolved: honestly out of reach until the next walk.
      if (!known && !change.path) continue;
      if (change.removed) {
        if (known) out.add(path);
        continue;
      }
      // Ours, echoed back: the mirror already describes exactly this.
      if (known && change.stat && change.stat.size === known.size && known.mtime === change.stat.mtime) {
        continue;
      }
      out.add(path);
    }
    return [...out].sort();
  }
 
  /**
   * Hybrid logical clock: never behind anything already in the store, so two
   * peers that have met once have their dates ordered against each other and a
   * lagging clock cannot "lose against the past".
   */
  private stamp(file: VFSFile): number {
    let highest = 0;
    for (const entry of file.entries) if (entry.updated > highest) highest = entry.updated;
    return Math.max(this.now(), highest + 1);
  }
 
  // ------------------------------------------------------- working folder
 
  read(path: string): Promise<Uint8Array> {
    return this.adapter.read(path);
  }
 
  write(path: string, data: Uint8Array): Promise<void> {
    return this.adapter.write(path, data);
  }
 
  stat(path: string): Promise<VFSStat | null> {
    return this.adapter.stat(path);
  }
 
  /** Creates an empty folder. v2 syncs those, which v1 could not. */
  async mkdir(path: string): Promise<void> {
    await this.adapter.mkdir?.(path);
  }
 
  /**
   * Reads `[start, end)` of a file without pulling in the rest — enough to
   * parse a header or a trailer out of a file far too big to load.
   *
   * ```ts
   * const header = await node.readRange('track.mp3', { end: 10 });   // ID3v2
   * const { size } = (await node.stat('track.mp3'))!;
   * const tail = await node.readRange('track.mp3', { start: size - 128 });
   * ```
   */
  readRange(path: string, range?: ByteRange): Promise<Uint8Array> {
    return readRange(this.adapter, path, range);
  }
 
  readStream(path: string, range?: ByteRange): Promise<ReadableStream<Uint8Array>> {
    return readStream(this.adapter, path, range);
  }
 
  /**
   * Replaces a file from a stream, without ever holding it whole. `commit()`
   * afterwards to record it — the write itself does not.
   */
  writeStream(path: string): Promise<WritableStream<Uint8Array>> {
    return writeStream(this.adapter, path);
  }
 
  delete(path: string): Promise<void> {
    return this.adapter.delete(path);
  }
 
  /**
   * Renames through the node rather than the adapter so the intent is recorded.
   * The hash heuristic in `scan()` is only a fallback for renames that happened
   * outside the VFS (the user moving a file in Finder, say).
   */
  async rename(from: string, to: string): Promise<void> {
    await this.adapter.rename(from, to);
    const file = await this.store.read();
    const pending = file.local.pendingRenames ?? (file.local.pendingRenames = []);
    pending.push({ from, to });
    await this.store.write(file);
  }
 
  // ----------------------------------------------------------------- scan
 
  /**
   * Whether the current rule keeps this path out of the walk.
   *
   * The walk prunes at the directory and never descends, so a rule covering
   * `.cache` also excludes `.cache/x` even when the predicate says nothing
   * about the child. Testing the leaf alone would read those children as
   * vanished, which is the very thing this exists to prevent.
   */
  private excluded(path: string): boolean {
    if (!this.ignore) return false;
    if (this.ignore(path)) return true;
    for (let cut = path.lastIndexOf('/'); cut > 0; cut = path.lastIndexOf('/', cut - 1)) {
      if (this.ignore(path.slice(0, cut))) return true;
    }
    return false;
  }
 
  /**
   * Reconciles the working folder into entries, and reports what changed as log
   * rows. Files whose `mtime`+`size` still match what was recorded are not
   * re-read — with catalogues of hundred-megabyte ROMs that filter stops being
   * an optimisation and becomes the difference between a sync and a full read.
   */
  async scan(): Promise<ScanResult> {
    const file = await this.store.read();
    const at = this.stamp(file);
    const batch = randomId();
 
    const prev = file.entries;
    const live = prev.filter((entry) => !entry.deleted);
    const byPath = new Map(live.map((entry) => [entry.path, entry]));
    const byNative = new Map(
      live.filter((entry) => entry.native).map((entry) => [entry.native as string, entry]),
    );
 
    const walked = await walk(this.adapter, {
      directories: true,
      ...(this.ignore ? { ignore: this.ignore } : {}),
    });
 
    // 1. content hashes, through the mtime+size filter
    const seen: Array<{ path: string; stat: VFSStat; hash: Hash | null; prior?: VFSEntry }> = [];
    for (const item of walked) {
      const known = byPath.get(item.path);
      let hash: Hash | null = null;
      if (item.stat.kind === 'file') {
        if (known && known.mtime === item.stat.mtime && known.size === item.stat.size && known.hash) {
          hash = known.hash;
        } else if (this.streams(item.stat.size)) {
          hash = await sha256Stream(await readStream(this.adapter, item.path));
        } else {
          hash = await sha256(await this.adapter.read(item.path));
        }
      }
      seen.push({ path: item.path, stat: item.stat, hash });
    }
 
    // 2. what disappeared — the pool a move outside the VFS is matched against.
    //
    //    Absence from the walk is not by itself evidence of deletion. There are
    //    three reasons a path does not come back, and only the first is a
    //    delete: the user removed it, the current rule filters it, or this node
    //    never materialised the bytes. `mtime` separates them — it is deleted
    //    on every adopt and only ever re-set from a real `stat()`, so it means
    //    "this node has seen the file on disk", and it is absent exactly on the
    //    entries whose bytes deliberately never travelled.
    //
    //    Erring here is asymmetric: a missed deletion is picked up on the next
    //    pass, an invented one destroys the file on every other peer.
    const alive = new Set(seen.map((item) => item.path));
    const absent = live.filter((entry) => !alive.has(entry.path));
    const preserved = absent.filter((entry) => this.excluded(entry.path) || entry.mtime === undefined);
    const kept = new Set(preserved.map((entry) => entry.uuid));
    const vanished = absent.filter((entry) => !kept.has(entry.uuid));
    // Preserved entries stay out of the pool below on purpose: still in it, a
    // new file with the same content elsewhere would read as a move of one of
    // them and would carry the entry away from the path it is holding.
    const vanishedByHash = new Map<Hash, VFSEntry[]>();
    for (const entry of vanished) {
      if (!entry.hash) continue;
      const bucket = vanishedByHash.get(entry.hash);
      if (bucket) bucket.push(entry);
      else vanishedByHash.set(entry.hash, [entry]);
    }
    // Renames recorded through `node.rename()`, collapsed to where each file
    // *started*. A chain has to collapse: a swap goes a -> tmp -> b -> a, and
    // reading one hop at a time pins each identity to the wrong file.
    const origin = new Map<string, string>();
    for (const rename of file.local.pendingRenames ?? []) {
      const source = origin.get(rename.from) ?? rename.from;
      origin.delete(rename.from);
      if (source !== rename.to) origin.set(rename.to, source);
    }
    const movedAway = new Set(origin.values());
 
    const used = new Set<string>();
    const entries: VFSEntry[] = [];
    const rows: Array<Omit<LogRow, 'op'>> = [];
 
    for (const item of seen) {
      const native = this.adapter.fileId ? await this.adapter.fileId(item.path) : null;
      let prior: VFSEntry | undefined;
      if (native) prior = byNative.get(native);
      if (!prior) {
        // 1. A rename recorded through node.rename() is the strongest signal
        //    there is, so it outranks path continuity — in a swap (a -> b,
        //    b -> a) both paths still exist and the path would pin each
        //    identity to the wrong file.
        const from = origin.get(item.path);
        if (from) prior = byPath.get(from);
        // 2. Same path as last time, unless this path was itself renamed away:
        //    whatever sits here now would be a different file reusing the name.
        if (!prior && !movedAway.has(item.path)) prior = byPath.get(item.path);
        // 3. Same content under a path we no longer see: a move made outside
        //    the VFS, e.g. the user dragging the file in Finder.
        if (!prior && item.hash) prior = takeVanished(vanishedByHash, item.hash, used);
      }
      if (prior && used.has(prior.uuid)) prior = undefined;
 
      const uuid = prior?.uuid ?? randomId();
      used.add(uuid);
 
      const moved = prior !== undefined && prior.path !== item.path;
      const changed = prior === undefined || prior.hash !== item.hash || prior.kind !== item.stat.kind;
 
      const entry: VFSEntry = {
        uuid,
        kind: item.stat.kind,
        path: item.path,
        hash: item.hash,
        size: item.stat.kind === 'file' ? item.stat.size : 0,
        created: prior?.created ?? at,
        updated: changed || moved ? at : (prior?.updated ?? at),
        peerId: changed || moved ? this.peerId : (prior?.peerId ?? this.peerId),
        mtime: item.stat.mtime,
      };
      if (changed) entry.prev = prior?.hash ?? null;
      else if (prior?.prev !== undefined) entry.prev = prior.prev;
      if (!changed && prior?.prev2) entry.prev2 = prior.prev2;
      if (moved && prior) entry.prevPath = prior.path;
      else if (!moved && prior?.prevPath) entry.prevPath = prior.prevPath;
      if (native) entry.native = native;
      // A file the user edited by hand is no longer somebody's conflict copy.
      if (prior?.conflictOf && !changed) {
        entry.conflictOf = prior.conflictOf;
        if (prior.reason) entry.reason = prior.reason;
        if (prior.base) entry.base = prior.base;
        if (prior.held) entry.held = prior.held;
      }
      entries.push(entry);
 
      if (changed || moved) {
        rows.push({
          batch,
          at,
          peerId: this.peerId,
          uuid,
          type: changed ? 'write' : 'rename',
          kind: entry.kind,
          path: entry.path,
          hash: entry.hash,
          size: entry.size,
          ...(changed ? { prev: prior?.hash ?? null } : {}),
          ...(moved && prior ? { prevPath: prior.path } : {}),
        });
      }
    }
 
    // 3. entries the walk did not return and that are not deletions, carried
    //    over verbatim. Not `updated`, not `peer`: re-stamping would let an
    //    entry nobody touched win a tiebreak by date it has not won. Marking
    //    them `used` is what keeps the loop below from tombstoning them, and
    //    the claim above from handing their uuid to another file.
    //
    //    Preserving is not an operation, so it emits no log rows.
    for (const entry of preserved) {
      if (used.has(entry.uuid)) continue; // already claimed above as a rename
      used.add(entry.uuid);
      entries.push(entry);
    }
 
    // 4. tombstones for everything previously known that is neither live nor
    //    accounted for as a rename target
    for (const entry of prev) {
      if (used.has(entry.uuid)) continue;
      if (entry.deleted) {
        entries.push(entry); // history a fresh peer still has to learn
        continue;
      }
      entries.push({
        uuid: entry.uuid,
        kind: entry.kind,
        path: entry.path,
        hash: null,
        size: 0,
        created: entry.created,
        updated: at,
        peerId: this.peerId,
        deleted: true,
        prev: entry.hash,
      });
      rows.push({
        batch,
        at,
        peerId: this.peerId,
        uuid: entry.uuid,
        type: 'delete',
        kind: entry.kind,
        path: entry.path,
        hash: null,
        prev: entry.hash,
      });
    }
 
    return { entries, rows: await Promise.all(rows.map(makeRow)), batch };
  }
 
  /**
   * Scans, appends what changed to the log and writes `vfs.json`.
   *
   * Returns the batch id, or `null` when nothing moved — a quiet sync loop must
   * not grow the log.
   */
  async commit(): Promise<string | null> {
    const { entries, rows, batch } = await this.scan();
    const file = await this.store.read();
    const unchanged = rows.length === 0 && sameEntries(file.entries, entries);
    if (unchanged) {
      if (file.local.pendingRenames?.length) {
        file.local.pendingRenames = [];
        await this.store.write(file);
      }
      return null;
    }
 
    await this.keepText(entries, file);
    file.entries = entries;
    file.local.pendingRenames = [];
    file.local.verifiedAt = this.now();
    if (rows.length > 0) await this.store.append(rows, file);
    await this.settle(file);
    return batch;
  }
 
  /**
   * Keeps a copy of every text version this node records, under `base/<hash>`.
   *
   * A three-way merge needs base + A + B. A and B are free — the working file
   * *is* the content — and this is where the base comes from. It has to be
   * written when a version is *recorded*, not when it is about to be lost: by
   * the time a scan notices the user edited `gamelist.xml`, the previous bytes
   * are already gone.
   *
   * Local by construction: it never travels and no peer reads it. Missing a
   * base degrades the merge to LWW plus a copy, which costs nothing but a file.
   */
  private async keepText(entries: VFSEntry[], file: VFSFile): Promise<void> {
    const text = file.text;
    if (text.length === 0) return;
    const before = new Map(file.entries.map((entry) => [entry.uuid, entry.hash]));
    const keep = new Set<Hash>();
    let wrote = 0;
    for (const entry of entries) {
      if (entry.deleted || entry.kind !== 'file' || !entry.hash) continue;
      const extension = extensionOf(entry.path);
      if (extension === '' || !text.includes(extension)) continue;
      keep.add(entry.hash);
      if (entry.prev) keep.add(entry.prev);
      if (entry.prev2) keep.add(entry.prev2);
      if (entry.size > MAX_TEXT_MERGE || before.get(entry.uuid) === entry.hash) continue;
      const data = await this.adapter.read(entry.path).catch(() => null);
      if (!data) continue;
      await this.store.putBase(entry.hash, data);
      wrote++;
    }
    // Retention only needs revisiting when something was added — listing
    // `base/` on every quiet commit would be a round trip for nothing.
    if (wrote > 0) await this.store.pruneBase(keep);
  }
 
  /**
   * Rotates when the segment has outgrown its budget, prunes what the rotation
   * has made safe to prune, then writes.
   *
   * The order is the invariant of §3 and §4: photograph first, prune after. The
   * cumulative snapshot holds the last known state of every uuid that has ever
   * existed, so a tombstone dropped from `vfs.json` afterwards can still be
   * proved to a peer that shows up with the file still alive.
   */
  private async settle(file: VFSFile): Promise<void> {
    if (await this.store.shouldRotate(file)) await this.store.rotate(file);
    file.entries = await this.pruneTombstones(file);
    await this.store.write(file);
  }
 
  /**
   * Drops tombstones every known peer has already seen, but only those the log
   * or the snapshot can still vouch for. What is dropped here is recoverable;
   * what is not is the difference between a delete and a resurrection.
   */
  private async pruneTombstones(file: VFSFile): Promise<VFSEntry[]> {
    const marks = Object.values(file.peers);
    if (marks.length === 0) return file.entries;
    const floor = Math.min(...marks.map((mark) => mark.lastSync));
    const provable = new Set<string>();
    for (const row of await this.store.logRows()) if (row.type === 'delete') provable.add(row.uuid);
    for (const entry of await this.store.readSnapshot(file)) {
      if (entry.deleted) provable.add(entry.uuid);
    }
    return file.entries.filter(
      (entry) => !entry.deleted || entry.updated >= floor || !provable.has(entry.uuid),
    );
  }
 
  // ---------------------------------------------------------------- apply
 
  /**
   * Makes the working folder match `target`, pulling whatever content is
   * missing from `source`.
   *
   * Everything that arrives is re-hashed against the hash `vfs.json` declares.
   * v1 got that for free — `putObjectStreamAt` verified on the way to the
   * content address — and dropping content addressing means paying for it
   * explicitly. Without it a truncated upload lands as "the newest version".
   */
  async apply(target: VFSEntry[], source: ContentSource): Promise<void> {
    const file = await this.store.read();
    const current = new Map(file.entries.map((entry) => [entry.uuid, entry]));
    const livePaths = new Set(file.entries.filter((e) => !e.deleted).map((e) => e.path));
 
    const renames: Array<{ from: string; to: string; uuid: string }> = [];
    const writes: VFSEntry[] = [];
    const mkdirs: VFSEntry[] = [];
    const deletes: Array<{ path: string; kind: string }> = [];
 
    for (const entry of target) {
      const before = current.get(entry.uuid);
      const wasLive = before && !before.deleted;
      if (entry.deleted) {
        if (wasLive) deletes.push({ path: before.path, kind: before.kind });
        continue;
      }
      // Content the far peer chose to keep to itself (§4): the entry travels,
      // the bytes do not, and the explorer paints it as remote.
      if (entry.held && entry.held !== this.peerId) continue;
      // Content this node's policy declines. The `materialised` half is not
      // optional: a policy that turns false for something already on disk must
      // not skip the write, or the file would sit at the old hash while the
      // tree records the new one — and `scan()`'s mtime filter, seeing an
      // untouched file, would never look at it again. Nothing would ever
      // disagree with itself about it. The predicate governs what arrives;
      // releasing what is already here is `dematerialize()`.
      if (!this.wants(entry) && !materialised(before)) continue;
      if (wasLive && before.path !== entry.path) {
        renames.push({ from: before.path, to: entry.path, uuid: entry.uuid });
      }
      if (entry.kind === 'directory') {
        if (!wasLive) mkdirs.push(entry);
        continue;
      }
      // `!materialised` is the refill: an entry this node wants but has no
      // bytes for is a write, even though nothing about it changed. Without it
      // the policy is only ever a filter — pinning a folder that nobody has
      // edited since would download nothing, and there would be two unrelated
      // ways to ask for the same content.
      if (!wasLive || before.hash !== entry.hash || !materialised(before)) writes.push(entry);
    }
 
    // Content this node already holds, from *before* anything moved. A conflict
    // copy is the peer's own current file, and a duplicate is a file that is
    // still sitting somewhere else — neither has to come over the wire.
    const local = new Map<Hash, { path: string; size: number }>();
    for (const entry of current.values()) {
      if (entry.deleted || entry.kind !== 'file' || !entry.hash || local.has(entry.hash)) continue;
      // An entry is not a file. One with no bytes behind it would send the copy
      // below reading a path that holds nothing — the same confusion `chain()`
      // had on the serving side, here on the receiving one.
      if (!materialised(entry)) continue;
      local.set(entry.hash, { path: entry.path, size: entry.size });
    }
 
    // Any of those paths that this apply is about to disturb has to be parked
    // first — otherwise the "copy" would be of whatever landed on top.
    const disturbed = new Set<string>([
      ...deletes.map((item) => item.path),
      ...renames.flatMap((item) => [item.from, item.to]),
      ...writes.map((entry) => entry.path),
    ]);
    const parkedContent = new Map<Hash, string>();
    for (const entry of writes) {
      const held = entry.hash ? local.get(entry.hash) : undefined;
      if (!held || !disturbed.has(held.path) || parkedContent.has(entry.hash as Hash)) continue;
      const temp = `${TEMP_DIR}/${entry.hash}`;
      await this.copy(held.path, temp, held.size);
      parkedContent.set(entry.hash as Hash, temp);
    }
 
    // Doomed files go first: a rename may be waiting for its destination to be
    // freed. Doomed *directories* wait until the renames have run — deleting
    // one while a file is still on its way out takes the file with it, and the
    // loss then travels as an ordinary delete. `roms` removed before
    // `roms/game.bin -> moved/0/game.bin` is exactly that.
    const doomedFiles = deletes.filter((item) => item.kind !== 'directory');
    const doomedDirs = deletes.filter((item) => item.kind === 'directory');
    for (const doomed of doomedFiles) {
      await this.adapter.delete(doomed.path);
      livePaths.delete(doomed.path);
    }
 
    // A rename whose destination is still occupied (a swap, a chain, or a
    // folder that is about to go) has to step through a scratch path first.
    const parked: Array<{ temp: string; to: string }> = [];
    for (const rename of renames) {
      if (!livePaths.has(rename.to)) continue;
      const temp = `${TEMP_DIR}/${rename.uuid}`;
      await this.adapter.rename(rename.from, temp);
      livePaths.delete(rename.from);
      parked.push({ temp, to: rename.to });
    }
    for (const rename of renames) {
      if (parked.some((item) => item.to === rename.to)) continue;
      await this.adapter.rename(rename.from, rename.to);
      livePaths.delete(rename.from);
      livePaths.add(rename.to);
    }
 
    // Deepest first, so a directory is only removed once it is empty — and now
    // that whatever was leaving it has left.
    doomedDirs.sort((x, y) => y.path.length - x.path.length);
    for (const doomed of doomedDirs) {
      await this.adapter.delete(doomed.path);
      livePaths.delete(doomed.path);
    }
 
    // Last, so a rename onto a path a delete had to free lands on empty ground.
    for (const item of parked) {
      await this.adapter.rename(item.temp, item.to);
      livePaths.add(item.to);
    }
 
    for (const entry of mkdirs) await this.adapter.mkdir?.(entry.path);
 
    for (const entry of writes) {
      const hash = entry.hash as Hash;
      const staged = parkedContent.get(hash) ?? local.get(hash)?.path;
      if (staged !== undefined && staged !== entry.path) {
        await this.copy(staged, entry.path, entry.size);
        continue;
      }
      await this.fetchContent(entry, source);
    }
 
    for (const temp of parkedContent.values()) await this.adapter.delete(temp).catch(() => undefined);
  }
 
  /**
   * Writes `entry`'s content from `source`, verified against the hash the tree
   * declares. False when no holder could serve it.
   *
   * One implementation, because `materialize()` needs exactly this and a second
   * copy is a second place for the two to drift on the check that matters.
   */
  private async fetchContent(entry: VFSEntry, source: ContentSource): Promise<boolean> {
    const hash = entry.hash as Hash;
    const handle = await source.open(hash, entry);
    if (!handle) return false;
    if (this.streams(handle.size)) {
      const hasher = new Sha256();
      await pump(await handle.stream(), await writeStream(this.adapter, entry.path), (chunk) =>
        hasher.update(chunk),
      );
      if (hasher.digest() !== hash) {
        await this.adapter.delete(entry.path);
        throw new Error(`${entry.path} arrived as ${hasher.digest()} in ${this.adapter.name}`);
      }
    } else {
      const data = await handle.read();
      const actual = await sha256(data);
      if (actual !== hash) throw new Error(`${entry.path} arrived as ${actual} in ${this.adapter.name}`);
      await this.adapter.write(entry.path, data);
    }
    return true;
  }
 
  private async copy(from: string, to: string, size: number): Promise<void> {
    if (this.streams(size)) {
      await pump(await readStream(this.adapter, from), await writeStream(this.adapter, to));
      return;
    }
    await this.adapter.write(to, await this.adapter.read(from));
  }
 
  /**
   * Adopts `target` as the recorded tree, re-stamping the two per-node fields —
   * the backend id and the disk `mtime` the fast filter compares against.
   */
  async adopt(target: VFSEntry[], file?: VFSFile): Promise<VFSFile> {
    const held = file ?? (await this.store.read());
    const current = new Map(held.entries.map((entry) => [entry.uuid, entry]));
    const entries: VFSEntry[] = [];
    for (const entry of target) {
      const next: VFSEntry = { ...entry };
      delete next.native;
      delete next.mtime;
      const before = current.get(entry.uuid);
      // Whether this node has the file on disk once the apply is done, which is
      // the same question `apply()` asked: the policy decides what arrives, and
      // bytes already here are kept current whatever it says.
      const onDisk =
        !(entry.held && entry.held !== this.peerId) && (this.wants(entry) || materialised(before));
      // Nothing this apply touched: the two node-local fields still describe
      // the file on disk, so carry them over. On Drive re-statting an untouched
      // entry is a round trip per file, which for a catalogue is the whole cost
      // — and an entry with no bytes here is exactly the one not to pay it for.
      if (
        before &&
        !entry.deleted &&
        before.path === entry.path &&
        before.hash === entry.hash &&
        materialised(before)
      ) {
        if (before.mtime !== undefined) next.mtime = before.mtime;
        if (before.native) next.native = before.native;
      } else if (!entry.deleted && onDisk) {
        const stat = await this.adapter.stat(entry.path);
        if (stat) next.mtime = stat.mtime;
        if (this.adapter.fileId) {
          const native = await this.adapter.fileId(entry.path);
          if (native) next.native = native;
        }
      }
      entries.push(next);
    }
    await this.keepText(entries, held);
    held.entries = entries;
    held.local.verifiedAt = this.now();
    return held;
  }
 
  // ------------------------------------------------------ materialisation
 
  /** The live file entry at `path`, or an error naming what is wrong with it. */
  private async fileEntry(path: string): Promise<{ file: VFSFile; entry: VFSEntry }> {
    const file = await this.store.read();
    const entry = file.entries.find((item) => !item.deleted && item.path === path);
    if (!entry) throw new Error(`no live entry at ${path}`);
    if (entry.kind !== 'file' || !entry.hash) throw new Error(`${path} is not a file`);
    return { file, entry };
  }
 
  /**
   * Fetches the bytes for a path this node has the entry for but not the
   * content — declined by its own policy, or kept by the peer that made it (§4).
   *
   * Verified against the declared hash, exactly as a sync would be. Once the
   * bytes are on disk the entry is ordinary again, and the `mtime` stamped here
   * is what makes it so: reconciliation stops treating it as content that was
   * never here.
   *
   * It fetches *against* the policy, not through it — the caller is overriding
   * a standing decision. A policy that still declines the entry leaves it alone
   * from here on; one that wants it would have fetched it on the next sync.
   */
  async materialize(path: string, from: VFSNode): Promise<void> {
    const { file, entry } = await this.fileEntry(path);
    const available = await from.live();
    const source: ContentSource = { open: (hash) => holds(from, available, hash) };
    if (!(await this.fetchContent(entry, source))) {
      throw new Error(`${from.name} cannot serve ${path}`);
    }
    // Stamped here rather than through `commit()`: the bytes match the hash the
    // entry already declares, so a walk would emit no row and would cost the
    // full listing the mtime filter exists to avoid.
    const stat = await this.adapter.stat(path);
    if (stat) entry.mtime = stat.mtime;
    if (this.adapter.fileId) {
      const native = await this.adapter.fileId(path);
      if (native) entry.native = native;
    }
    await this.store.write(file);
  }
 
  /**
   * Releases the bytes for a path and keeps the entry. Nothing about it travels
   * and no log row is written: which content a node stores is a local storage
   * decision, not an operation on the mesh. `state` does not change either —
   * `mtime` is outside the digest.
   *
   * `from` has to be able to serve the content first. Dematerialising the last
   * copy would leave the entry live across the mesh with the bytes nowhere, and
   * the library cannot see that on its own, because nothing about
   * materialisation travels. It is a sanity check and not a guarantee — the
   * peer could lose the bytes a moment later — but the case it catches is the
   * realistic one. If no peer holds it, what is wanted is a deletion, and a
   * deletion says so with a tombstone.
   *
   * **The order of the two writes is not a preference.** The record that this
   * node no longer holds the bytes lands *before* the bytes go. Interrupted the
   * other way round, the next `scan()` would find the file gone with an `mtime`
   * still saying it had been seen here, and would tombstone it on every peer —
   * the deletion-by-inference this engine exists to not do. Interrupted this
   * way it heals: the scan finds the file, the hash is unchanged, and it
   * re-stamps the `mtime` with no row and no re-dating.
   */
  async dematerialize(path: string, from: VFSNode): Promise<void> {
    const { file, entry } = await this.fileEntry(path);
    const available = await from.live();
    if (!(await holds(from, available, entry.hash as Hash))) {
      throw new Error(`${from.name} cannot serve ${path}: releasing it here would leave no copy`);
    }
    if (materialised(entry)) {
      delete entry.mtime;
      delete entry.native;
      await this.store.write(file);
    }
    await this.adapter.delete(path).catch(() => undefined);
  }
 
  // ------------------------------------------------------------ conflicts
 
  /**
   * Conflicts waiting for a person. No network and no sync: the pending state
   * *is* the conflict copy, which is an ordinary entry in the file that is read
   * anyway, so "are there conflicts?" is "any entry with `conflictOf`?".
   */
  async conflicts(): Promise<PendingConflict[]> {
    const entries = await this.entries();
    const byUuid = new Map(entries.map((entry) => [entry.uuid, entry]));
    const out: PendingConflict[] = [];
    for (const entry of entries) {
      if (!entry.conflictOf || entry.deleted) continue;
      const disputed = byUuid.get(entry.conflictOf);
      out.push({
        uuid: entry.uuid,
        of: entry.conflictOf,
        reason: entry.reason ?? 'binary',
        path: disputed?.path ?? entry.path,
        copyPath: entry.path,
        peerId: entry.peerId,
        ...(entry.held ? { held: entry.held } : {}),
        ...(entry.base ? { base: entry.base } : {}),
        mine: {
          hash: disputed?.hash ?? null,
          size: disputed?.size ?? 0,
          updated: disputed?.updated ?? 0,
        },
        theirs: { hash: entry.hash, size: entry.size, updated: entry.updated },
      });
    }
    return out;
  }
 
  /**
   * Settles one pending conflict: writes the winner and deletes the copy, in a
   * single batch. Two operations the engine already knows how to do, so the
   * resolution propagates like any other write — and two people resolving the
   * same thing on different peers is an ordinary write conflict, decided by the
   * ordinary rules. There is no state machine.
   */
  async resolve(uuid: string, choice: 'mine' | 'theirs' | Uint8Array): Promise<void> {
    const file = await this.store.read();
    const copy = file.entries.find((entry) => entry.uuid === uuid && entry.conflictOf);
    if (!copy) throw new Error(`no pending conflict ${uuid}`);
    const disputed = file.entries.find((entry) => entry.uuid === copy.conflictOf);
 
    // Whether the bytes of the losing version are actually here. Two ways they
    // are not: the copy was too big to travel (§4) and stayed on the peer that
    // made it, or this node's policy declined to materialise it.
    const here = materialised(copy);
    if (choice === 'theirs' && !here) {
      // Say so, rather than failing on a read of a file that was never going to
      // be here.
      const why = copy.held && copy.held !== this.peerId ? `held on ${copy.held}` : 'not materialised here';
      throw new Error(`the losing version of ${copy.path} is ${why}`);
    }
    if (choice !== 'mine' && disputed) {
      const data = choice instanceof Uint8Array ? choice : await this.adapter.read(copy.path);
      await this.adapter.write(disputed.path, data);
    }
    await this.adapter.delete(copy.path).catch(() => undefined);
    // A copy whose bytes are not here has no file to remove, and the scan reads
    // absence as evidence only for content this node actually held. Deleting it
    // therefore has to be said, not shown — otherwise the copy is immortal:
    // nothing on disk to remove, and nothing for reconciliation to notice.
    if (!here) await this.retire(copy.uuid);
    await this.commit();
  }
 
  /**
   * Tombstones an entry outright, for the deletions `scan()` cannot see: an
   * entry with no file behind it looks the same before and after.
   */
  private async retire(uuid: string): Promise<void> {
    const file = await this.store.read();
    const doomed = file.entries.find((entry) => entry.uuid === uuid);
    if (!doomed || doomed.deleted) return;
    const at = this.stamp(file);
    file.entries = file.entries.map((entry) =>
      entry.uuid === uuid
        ? {
            uuid,
            kind: doomed.kind,
            path: doomed.path,
            hash: null,
            size: 0,
            created: doomed.created,
            updated: at,
            peerId: this.peerId,
            deleted: true,
            prev: doomed.hash,
          }
        : entry,
    );
    const row = await makeRow({
      batch: randomId(),
      at,
      peerId: this.peerId,
      uuid,
      type: 'delete',
      kind: doomed.kind,
      path: doomed.path,
      hash: null,
      prev: doomed.hash,
    });
    await this.store.append([row], file);
    await this.settle(file);
  }
 
  async baseOf(hash: Hash): Promise<Uint8Array | null> {
    return this.store.getBase(hash);
  }
}
 
function takeVanished(
  byHash: Map<Hash, VFSEntry[]>,
  hash: Hash,
  used: Set<string>,
): VFSEntry | undefined {
  const bucket = byHash.get(hash);
  if (!bucket) return undefined;
  while (bucket.length > 0) {
    const candidate = bucket.shift() as VFSEntry;
    if (!used.has(candidate.uuid)) return candidate;
  }
  return undefined;
}
 
/** Cheap equality over what a commit would actually change. */
function sameEntries(left: VFSEntry[], right: VFSEntry[]): boolean {
  if (left.length !== right.length) return false;
  const byUuid = new Map(left.map((entry) => [entry.uuid, entry]));
  return right.every((entry) => {
    const held = byUuid.get(entry.uuid);
    return (
      held !== undefined &&
      held.path === entry.path &&
      held.hash === entry.hash &&
      held.size === entry.size &&
      !!held.deleted === !!entry.deleted &&
      // Not the `mtime` value — a touched file with the same content is not a
      // change worth a write. Whether there is an `mtime` at all is: it is the
      // record of whether this node holds the bytes, and losing that record
      // makes reconciliation read the file as content that was never here.
      materialised(held) === materialised(entry)
    );
  });
}