mainflow 0.32.0 → 0.32.1-rc.1

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,4 +1,4 @@
1
- import { $ as ACCESS_HEADERS, F as renumberFiles, I as renumberPath, L as renumberText, N as applyOffline, P as renumberRequest, Q as nodeStorage, R as scopeOfPath, X as nowMinute, ct as DATA_DIR, dt as splitFrontMatter, et as ROUTES, lt as WriteError, nt as comparePaths, rt as requestTime, st as ConflictError, tt as SOCKET_MAX_AGE_CLOSE, ut as contentHash, z as applyOperation } from "./host-CdB_vDY_.js";
1
+ import { $ as ACCESS_HEADERS, F as renumberFiles, I as renumberPath, L as renumberText, N as applyOffline, P as renumberRequest, Q as nodeStorage, R as scopeOfPath, X as nowMinute, at as requestTime, dt as DATA_DIR, et as MAX_FILE_BYTES, ft as WriteError, it as exceedsFileLimit, mt as splitFrontMatter, nt as SOCKET_MAX_AGE_CLOSE, ot as textBytes, pt as contentHash, rt as comparePaths, tt as ROUTES, ut as ConflictError, z as applyOperation } from "./host-C7GKIjSc.js";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import { accessSync, appendFileSync, chmodSync, closeSync, constants, copyFileSync, existsSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
4
4
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
@@ -12,6 +12,7 @@ import { Agent as Agent$1, request as request$1 } from "node:https";
12
12
  import { EventEmitter } from "node:events";
13
13
  import { appendFile, mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
14
14
  import { diff3Merge } from "node-diff3";
15
+ import ignore from "ignore";
15
16
  //#region ../sync/dist/paths.js
16
17
  /** The sync client's own directory, inside local/ so it is neither synced nor committed. */
17
18
  const SYNC_DIR = "local/sync";
@@ -373,7 +374,8 @@ var ServerApi = class {
373
374
  * text the client holds of each file, so the server sends only the files that differ, and names the ones of `have` it
374
375
  * does not hold as removed; each batch is sent only the part of `have` in its own range. A path of `have` that no
375
376
  * batch mentions is a file the server holds with that text. The batches are in order, each with its seq; one whose seq
376
- * is below the batch before, as when the server's storage was replaced meanwhile, starts the snapshot over.
377
+ * is below the batch before, as when the server's storage was replaced meanwhile, starts the snapshot over. A path of
378
+ * `have` the server never held a file at comes as `unknown` rather than `removed`, from a server that tells them apart.
377
379
  * `listOnly` asks for the files under those prefixes as `listed`, without their text. A part of the hub alone is taken
378
380
  * from `after` on, and ends with the batch that covers `through`; only the paths of that range are told of.
379
381
  */
@@ -390,6 +392,7 @@ var ServerApi = class {
390
392
  const { seq, files, next } = await this.batch({
391
393
  protocol: 3,
392
394
  have: range,
395
+ unknown: true,
393
396
  ...after === void 0 ? {} : { after },
394
397
  ...options.listOnly?.length ? { listOnly: options.listOnly } : {}
395
398
  });
@@ -561,6 +564,135 @@ function mergeThreeWay(current, base, incoming) {
561
564
  };
562
565
  }
563
566
  //#endregion
567
+ //#region ../sync/dist/scope.js
568
+ /** The name of a file of ignore rules. */
569
+ const GITIGNORE = ".gitignore";
570
+ const decoder$1 = new TextDecoder("utf-8", { fatal: true });
571
+ /** The text of the file at `file`, or null when there is none or it cannot be read. */
572
+ function readText(file) {
573
+ try {
574
+ return readFileSync(file, "utf8");
575
+ } catch {
576
+ return null;
577
+ }
578
+ }
579
+ /** The hub's ignore rules, by the directory of each .gitignore, as last read. */
580
+ var SyncScope = class {
581
+ /** Each .gitignore's text and rules by its directory, relative to .mainflow/ with `/`, '' for .mainflow/ itself */
582
+ rules = /* @__PURE__ */ new Map();
583
+ /** Whether each path, a directory's ending in `/`, is ignored under the rules as they are */
584
+ decided = /* @__PURE__ */ new Map();
585
+ changes = 0;
586
+ /** How many times the rules changed: a caller that noted it tells whether they changed since. */
587
+ get generation() {
588
+ return this.changes;
589
+ }
590
+ /** The text of `dir`'s .gitignore as the rules have it, null for none. */
591
+ text(dir) {
592
+ return this.rules.get(dir)?.text ?? null;
593
+ }
594
+ /** Take `text` as the .gitignore of `dir`, null for none; true when that changes the rules. */
595
+ set(dir, text) {
596
+ if ((this.rules.get(dir)?.text ?? null) === text) return false;
597
+ if (text === null) this.rules.delete(dir);
598
+ else this.rules.set(dir, {
599
+ text,
600
+ matcher: ignore().add(text)
601
+ });
602
+ this.decided.clear();
603
+ this.changes++;
604
+ return true;
605
+ }
606
+ /** Forget the rules of every directory but `dirs`, as when a scan of the whole hub no longer finds the others. */
607
+ keepOnly(dirs) {
608
+ for (const dir of this.rules.keys()) if (!dirs.has(dir)) this.set(dir, null);
609
+ }
610
+ /** Whether the file at `path`, relative to .mainflow/, is synced: by the sync client's own rules, and not ignored. */
611
+ synced(path) {
612
+ return isSynced(path) && !this.ignored(path, false);
613
+ }
614
+ /** Whether a scan walks into the directory at `path`: one that may hold synced files, and not ignored. */
615
+ walks(path) {
616
+ return mayHoldSynced(path) && !this.ignored(path, true);
617
+ }
618
+ /** Whether git leaves the file or directory (`dir`) at `path` out: it, or a directory above it, is ignored. */
619
+ ignored(path, dir) {
620
+ if (path === "local" || path.startsWith("local/")) return false;
621
+ const parts = path.split("/");
622
+ if (parts.some((part) => part === "" || part === "." || part === "..")) return true;
623
+ const key = dir ? `${path}/` : path;
624
+ const known = this.decided.get(key);
625
+ if (known !== void 0) return known;
626
+ let out = false;
627
+ for (let n = 1; n <= parts.length && !out; n++) out = this.matches(parts, n, n < parts.length || dir);
628
+ this.decided.set(key, out);
629
+ return out;
630
+ }
631
+ /**
632
+ * Whether the rules ignore the first `n` parts of a path, a directory's when `dir`: each .gitignore above it in turn,
633
+ * from .mainflow/ down, the last one with a pattern that matches deciding.
634
+ */
635
+ matches(parts, n, dir) {
636
+ if (parts[n - 1] === ".git") return true;
637
+ let out = false;
638
+ for (let depth = 0; depth < n; depth++) {
639
+ const rules = this.rules.get(parts.slice(0, depth).join("/"));
640
+ if (!rules) continue;
641
+ const test = rules.matcher.test(`${parts.slice(depth, n).join("/")}${dir ? "/" : ""}`);
642
+ if (test.ignored) out = true;
643
+ else if (test.unignored) out = false;
644
+ }
645
+ return out;
646
+ }
647
+ /**
648
+ * Read every .gitignore under `data`, the hub's .mainflow/, again, and return the synced files there; it walks only into
649
+ * the directories the rules leave in, as git does. Synchronous, for a start and for `mainflow check`.
650
+ */
651
+ load(data) {
652
+ const files = [];
653
+ const dirs = /* @__PURE__ */ new Set();
654
+ const visit = (dir) => {
655
+ let entries;
656
+ try {
657
+ entries = readdirSync(join(data, dir), { withFileTypes: true });
658
+ } catch {
659
+ return;
660
+ }
661
+ dirs.add(dir);
662
+ this.set(dir, entries.some((entry) => entry.name === ".gitignore" && entry.isFile()) ? readText(join(data, dir, GITIGNORE)) : null);
663
+ for (const entry of entries) {
664
+ const path = dir ? `${dir}/${entry.name}` : entry.name;
665
+ if (entry.isDirectory()) {
666
+ if (this.walks(path)) visit(path);
667
+ } else if (entry.isFile() && this.synced(path)) files.push(path);
668
+ }
669
+ };
670
+ visit("");
671
+ this.keepOnly(dirs);
672
+ return files;
673
+ }
674
+ };
675
+ /**
676
+ * The synced files of the hub at `root` larger than the sync server keeps (MAX_FILE_BYTES): UTF-8 text the hub's
677
+ * .gitignore files leave in. The sync client leaves them out of sync; `mainflow check` warns of them.
678
+ */
679
+ function oversizedFiles(root) {
680
+ const data = join(root, DATA_DIR);
681
+ const out = [];
682
+ for (const path of new SyncScope().load(data)) {
683
+ const file = join(data, path);
684
+ try {
685
+ if (statSync(file).size <= 19e5) continue;
686
+ const bytes = textBytes(decoder$1.decode(readFileSync(file)));
687
+ if (bytes > 19e5) out.push({
688
+ path,
689
+ bytes
690
+ });
691
+ } catch {}
692
+ }
693
+ return out;
694
+ }
695
+ //#endregion
564
696
  //#region ../sync/dist/state.js
565
697
  /** Whether `entry` waits for the server: not one it refused. */
566
698
  const waiting = (entry) => entry.refused === void 0;
@@ -1094,14 +1226,17 @@ function patchRequest(entry) {
1094
1226
  text
1095
1227
  };
1096
1228
  }
1097
- /** Whether the server holds any file, by a snapshot taken with `held` named: one it sent, or one of `held` it did not name removed. */
1229
+ /**
1230
+ * Whether the server holds any file, by a snapshot taken with `held` named: one it sent, or one of `held` it did not name
1231
+ * removed or unknown.
1232
+ */
1098
1233
  function holdsFiles(parts, held) {
1099
- const removed = /* @__PURE__ */ new Set();
1234
+ const missing = /* @__PURE__ */ new Set();
1100
1235
  for (const part of parts) for (const event of part.files) {
1101
1236
  if (event.kind === "file" || event.kind === "listed") return true;
1102
- if (event.kind === "removed") removed.add(event.path);
1237
+ if (event.kind === "removed" || event.kind === "unknown") missing.add(event.path);
1103
1238
  }
1104
- return [...held.keys()].some((path) => !removed.has(path));
1239
+ return [...held.keys()].some((path) => !missing.has(path));
1105
1240
  }
1106
1241
  var SyncClient = class extends EventEmitter {
1107
1242
  opts;
@@ -1141,6 +1276,22 @@ var SyncClient = class extends EventEmitter {
1141
1276
  copied = false;
1142
1277
  /** The server has no copy of the hub, which this environment, downloading on demand, does not upload (snapshotNow) */
1143
1278
  uploadWanted = false;
1279
+ /** Why the server refused the upload of this environment's copy, as last reported (snapshotNow); null once it holds the hub */
1280
+ uploadRefusal = null;
1281
+ /** The hub's .gitignore files, which leave a file out of sync as they leave it out of git (src/scope.ts) */
1282
+ scope = new SyncScope();
1283
+ /**
1284
+ * The generation of the rules (SyncScope.generation) the last snapshot was asked for under. A file the rules left out
1285
+ * then was not taken in, whatever the server sent, nor a broadcast of it since; rules changed after it may let such a
1286
+ * file in, so the snapshot is taken again (walk), and a file not taken in from the server waits for it (scopeStale).
1287
+ */
1288
+ scopeTaken = -1;
1289
+ /**
1290
+ * The paths the server last named `unknown`: it holds no file there and never removed one. A file here at such a path
1291
+ * is new to the server, whatever known.json says, as when another environment uploaded the hub to a new server
1292
+ * without it; it is sent as new, never removed here. Only `removed` removes a file here.
1293
+ */
1294
+ unheld = /* @__PURE__ */ new Set();
1144
1295
  /** The hub's seq of each path's version in `cloud`: a version from the server with a seq no higher is not newer */
1145
1296
  fileSeq = /* @__PURE__ */ new Map();
1146
1297
  /** The hub's seq up to which the client has taken in every change: the last snapshot and the broadcasts after it */
@@ -1151,7 +1302,8 @@ var SyncClient = class extends EventEmitter {
1151
1302
  inflight = /* @__PURE__ */ new Map();
1152
1303
  chains = /* @__PURE__ */ new Map();
1153
1304
  work = /* @__PURE__ */ new Set();
1154
- binaries = /* @__PURE__ */ new Set();
1305
+ /** The files left out of sync and reported, by kind, path and content hash, so each is reported once */
1306
+ skipped = /* @__PURE__ */ new Set();
1155
1307
  /**
1156
1308
  * The files only on the server, by the hash of the server's text: artifacts of closed mainlines this client never
1157
1309
  * had on disk and has not downloaded (onDemand). remote.json keeps them.
@@ -1241,6 +1393,20 @@ var SyncClient = class extends EventEmitter {
1241
1393
  get online() {
1242
1394
  return this.connected && !this.stopped;
1243
1395
  }
1396
+ /**
1397
+ * Whether the file at `path` is synced: by the sync client's own rules (src/paths.ts) and the hub's .gitignore files as
1398
+ * last read (src/scope.ts). Anything else is left as it is here, whatever the server holds.
1399
+ */
1400
+ syncs(path) {
1401
+ return this.scope.synced(path);
1402
+ }
1403
+ /**
1404
+ * Whether the hub's .gitignore files changed since the last snapshot was asked for: a file they let in since may be on
1405
+ * the server although the client took nothing of it in.
1406
+ */
1407
+ get scopeStale() {
1408
+ return this.scope.generation !== this.scopeTaken;
1409
+ }
1244
1410
  /** Writes waiting in the outbox for the server: not the ones it refused, which wait for nothing (src/state.ts). */
1245
1411
  get queued() {
1246
1412
  return this.queue.filter(waiting).length;
@@ -1289,6 +1455,7 @@ var SyncClient = class extends EventEmitter {
1289
1455
  this.info("", `atomic exchange unavailable, writing with a rename: ${swapper.reason}`);
1290
1456
  }
1291
1457
  }
1458
+ this.scope.load(this.paths.data);
1292
1459
  await this.restoreKnown();
1293
1460
  this.restoreRemote();
1294
1461
  await this.whenFree(() => this.syncQueue());
@@ -1424,7 +1591,7 @@ var SyncClient = class extends EventEmitter {
1424
1591
  this.connected = true;
1425
1592
  this.rejoining = false;
1426
1593
  this.current = false;
1427
- if (!this.uploadWanted) this.backoff = 500;
1594
+ if (!this.uploadWanted && this.uploadRefusal === null) this.backoff = 500;
1428
1595
  this.lastPong = Date.now();
1429
1596
  clearInterval(this.pingTimer);
1430
1597
  this.pingTimer = setInterval(() => this.ping(ws), 15e3);
@@ -1562,10 +1729,10 @@ var SyncClient = class extends EventEmitter {
1562
1729
  */
1563
1730
  receive(event, seq) {
1564
1731
  if (event.kind === "dir") {
1565
- if (event.path.split("/")[0] !== "local" || event.path === "local/sessions") this.track(mkdir(join(this.paths.data, event.path), { recursive: true }));
1732
+ if ((event.path.split("/")[0] !== "local" || event.path === "local/sessions") && !this.scope.ignored(event.path, true)) this.track(mkdir(join(this.paths.data, event.path), { recursive: true }));
1566
1733
  return;
1567
1734
  }
1568
- if (!isSynced(event.path)) return;
1735
+ if (!this.syncs(event.path)) return;
1569
1736
  const text = event.kind === "file" ? event.text : null;
1570
1737
  const held = this.fileSeq.get(event.path) ?? -1;
1571
1738
  if (seq <= held) {
@@ -1573,6 +1740,8 @@ var SyncClient = class extends EventEmitter {
1573
1740
  return;
1574
1741
  }
1575
1742
  this.fileSeq.set(event.path, seq);
1743
+ if (event.kind === "unknown") this.unheld.add(event.path);
1744
+ else this.unheld.delete(event.path);
1576
1745
  if (this.remote.has(event.path) && !this.awaiting.has(event.path) && (text === null || this.staysRemote(event.path))) {
1577
1746
  this.trace(`remote ${event.path} ${short(text)} seq ${seq}`);
1578
1747
  if (text === null) this.remote.delete(event.path);
@@ -1630,9 +1799,10 @@ var SyncClient = class extends EventEmitter {
1630
1799
  * reconciled: the server broadcasts a change after storing it, so a snapshot asked for later holds it. One that
1631
1800
  * still does not, and brought nothing newer, is asked for again after a pause; after STALLED_SNAPSHOTS of them the
1632
1801
  * server is taken to have lost those changes, as when its storage was replaced: the versions the client holds from
1633
- * after the gap are forgotten and the snapshot is taken once more, as it is. The connection has its snapshot once
1634
- * one ends while it is open (I1); one that fails leaves it without, and the client reconnects to take a fresh one,
1635
- * rather than send on the snapshot of an earlier connection.
1802
+ * after the gap are forgotten and the snapshot is taken once more, as it is. So is it when the hub's .gitignore files
1803
+ * changed after it was asked for (scopeStale): a file they let in since was not taken in. The connection has its
1804
+ * snapshot once one ends while it is open (I1); one that fails leaves it without, and the client reconnects to take a
1805
+ * fresh one, rather than send on the snapshot of an earlier connection.
1636
1806
  */
1637
1807
  resync() {
1638
1808
  if (this.closing) return Promise.resolve();
@@ -1661,7 +1831,7 @@ var SyncClient = class extends EventEmitter {
1661
1831
  this.log(`missed the hub's changes ${missing} while taking the snapshot; reloading it`);
1662
1832
  if (stalled > 0) await sleep$1(250 * 2 ** (stalled - 1));
1663
1833
  this.resyncAgain = true;
1664
- } while (this.resyncAgain && !this.closing);
1834
+ } while ((this.resyncAgain || this.scopeStale) && !this.closing);
1665
1835
  this.current = this.connected && !this.closing;
1666
1836
  } catch (error) {
1667
1837
  this.current = false;
@@ -1680,8 +1850,14 @@ var SyncClient = class extends EventEmitter {
1680
1850
  * it comes as a broadcast, and the first batch's seq is the one the whole snapshot is as of. A file the server sent,
1681
1851
  * or named removed, is taken at its batch's seq; a file it left out holds what the client named, as of the first
1682
1852
  * batch's seq; a file neither named nor sent is not on the server. A broadcast arriving meanwhile may be older or
1683
- * newer than either; each file keeps whichever version has the higher seq. A server without the hub first gets the
1684
- * local one.
1853
+ * newer than either; each file keeps whichever version has the higher seq. A path the server never held a file at
1854
+ * (`unknown`) is one it has no file at: a file here, even one never synced or one known.json names, is sent as new and
1855
+ * never removed (reconcile, unheld). What the
1856
+ * client does not sync (syncs), as a file the hub's .gitignore leaves out, is not taken in, whatever the server sends.
1857
+ *
1858
+ * A server without the hub first gets the local one. When it refuses the upload and still holds no file, the snapshot
1859
+ * fails without taking anything in: an empty hub would tell a copy without known.json to remove every file it holds.
1860
+ * The writer is told why once, and the next connection tries again.
1685
1861
  *
1686
1862
  * With onDemand, the artifacts of mainlines not known to be open come `listed` (listOnly), and the files only on the
1687
1863
  * server are named by their hash, so one that has not changed is not mentioned. A listed file this client has no
@@ -1698,14 +1874,20 @@ var SyncClient = class extends EventEmitter {
1698
1874
  let held;
1699
1875
  let have;
1700
1876
  let before;
1701
- for (let upload = 0;; upload++) {
1877
+ /** Whether the hub was uploaded, and why the server refused the upload; asked again, the snapshot may hold the hub */
1878
+ let uploaded = false;
1879
+ let refusal = null;
1880
+ /** The rules' generation the snapshot is asked for under: what it holds is taken in as they leave it in */
1881
+ let asked;
1882
+ for (;;) {
1702
1883
  before = this.hubSeq;
1703
1884
  held = await this.holdings();
1704
1885
  for (const path of this.remote.keys()) if (held.has(path) || this.expected.get(path) !== void 0) this.remote.delete(path);
1705
1886
  have = new Map([...held].map(([path, text]) => [path, this.hashOf(path, text)]));
1706
1887
  for (const [path, hash] of this.remote) have.set(path, hash);
1888
+ asked = this.scope.generation;
1707
1889
  parts = await this.api.snapshot(have, { listOnly: this.listOnly(this.copied && !this.synced) });
1708
- if (upload > 0 || holdsFiles(parts, have)) break;
1890
+ if (uploaded || holdsFiles(parts, have)) break;
1709
1891
  if (this.opts.settings.onDemand === true) {
1710
1892
  if (!this.uploadWanted) {
1711
1893
  this.uploadWanted = true;
@@ -1717,10 +1899,18 @@ var SyncClient = class extends EventEmitter {
1717
1899
  }
1718
1900
  throw new Error("the sync server has no copy of the hub, and this environment downloads on demand, so it waits for one that keeps every file to upload it");
1719
1901
  }
1720
- if (!await this.uploadIfEmpty()) break;
1902
+ if (refusal !== null) {
1903
+ this.refusedUpload(refusal);
1904
+ throw new Error(`the sync server has no copy of the hub and refused its upload (${refusal}); nothing was taken in, and the next connection tries again`);
1905
+ }
1906
+ const answer = await this.uploadIfEmpty();
1907
+ if (answer === null) break;
1908
+ if (answer.ok) uploaded = true;
1909
+ else refusal = answer.message;
1721
1910
  }
1722
- if (this.uploadWanted) {
1911
+ if (this.uploadWanted || this.uploadRefusal !== null) {
1723
1912
  this.uploadWanted = false;
1913
+ this.uploadRefusal = null;
1724
1914
  this.backoff = 500;
1725
1915
  }
1726
1916
  if (this.closing) return;
@@ -1738,6 +1928,7 @@ var SyncClient = class extends EventEmitter {
1738
1928
  this.snapshotAnswers.clear();
1739
1929
  const fromCopy = this.copied && !this.synced;
1740
1930
  for (const part of parts) for (const event of part.files) {
1931
+ if (event.kind !== "dir" && !this.syncs(event.path)) continue;
1741
1932
  if (event.kind === "listed") {
1742
1933
  named.add(event.path);
1743
1934
  if (this.mayStay(event.path, held)) this.keepRemote(event.path, event.hash, part.seq);
@@ -1772,10 +1963,11 @@ var SyncClient = class extends EventEmitter {
1772
1963
  text
1773
1964
  }, seq);
1774
1965
  }
1775
- for (const path of /* @__PURE__ */ new Set([...this.cloud.keys(), ...this.expected.keys()])) if (!present.has(path) && !named.has(path) && isSynced(path)) this.receive({
1966
+ for (const path of /* @__PURE__ */ new Set([...this.cloud.keys(), ...this.expected.keys()])) if (!present.has(path) && !named.has(path) && this.syncs(path)) this.receive({
1776
1967
  kind: "removed",
1777
1968
  path
1778
1969
  }, seq);
1970
+ this.scopeTaken = asked;
1779
1971
  this.log(`took the snapshot at seq ${seq} in ${parts.length} batch${parts.length === 1 ? "" : "es"}: ${sent} files sent, ${present.size - sent} already held here${this.remote.size > 0 ? `, ${this.remote.size} only on the server` : ""}`);
1780
1972
  this.takenUpTo(seq);
1781
1973
  this.synced = true;
@@ -1789,16 +1981,17 @@ var SyncClient = class extends EventEmitter {
1789
1981
  * The text the client holds of each synced file, which a snapshot request names by hash so the server leaves out what
1790
1982
  * is the same (SnapshotRequest.have): the server's version this run last took in, else the version known.json names,
1791
1983
  * else the file on disk, as in a hub cloned with git that never synced here. Only a text the client has is named,
1792
- * since a file the server leaves out holds the text named for it.
1984
+ * since a file the server leaves out holds the text named for it; a file larger than the server keeps is not synced,
1985
+ * so it is not named either.
1793
1986
  */
1794
1987
  async holdings() {
1795
1988
  const held = /* @__PURE__ */ new Map();
1796
- for (const [path, text] of this.cloud) if (typeof text === "string" && isSynced(path)) held.set(path, text);
1797
- for (const [path, disk] of this.confirmed) if (!held.has(path) && disk?.text !== void 0 && isSynced(path)) held.set(path, disk.text);
1989
+ for (const [path, text] of this.cloud) if (typeof text === "string" && this.syncs(path)) held.set(path, text);
1990
+ for (const [path, disk] of this.confirmed) if (!held.has(path) && disk?.text !== void 0 && this.syncs(path)) held.set(path, disk.text);
1798
1991
  for (const path of await this.walk()) {
1799
1992
  if (held.has(path)) continue;
1800
1993
  const local = await this.readLocal(path);
1801
- if (local.kind === "text") held.set(path, local.text);
1994
+ if (local.kind === "text" && !exceedsFileLimit(local.text)) held.set(path, local.text);
1802
1995
  }
1803
1996
  return held;
1804
1997
  }
@@ -1814,9 +2007,11 @@ var SyncClient = class extends EventEmitter {
1814
2007
  return hash;
1815
2008
  }
1816
2009
  /**
1817
- * A server without the hub gets the local one; true when it was uploaded and a reload follows. The local files
1818
- * already hold what the outbox's operations did, which the server does when they are sent, so a file an operation
1819
- * changed is uploaded as it was before the first one that did (d017).
2010
+ * A server without the hub gets the local one: what the server answered, or null when there is nothing here to upload.
2011
+ * The local files already hold what the outbox's operations did, which the server does when they are sent, so a file
2012
+ * an operation changed is uploaded as it was before the first one that did (d017). A file the client does not sync,
2013
+ * or larger than the server keeps, stays out, so the server takes the rest. A refusal before the hub, such as a 400,
2014
+ * is answered as a refusal; the server out of reach throws (Unreachable).
1820
2015
  */
1821
2016
  async uploadIfEmpty() {
1822
2017
  const files = {};
@@ -1826,23 +2021,46 @@ var SyncClient = class extends EventEmitter {
1826
2021
  for (const [path, before] of Object.entries(entry.before ?? {})) if (!outbox.has(path)) outbox.set(path, before);
1827
2022
  }
1828
2023
  for (const path of /* @__PURE__ */ new Set([...await this.walk(), ...outbox.keys()])) {
2024
+ if (!this.syncs(path)) continue;
1829
2025
  if (outbox.has(path)) {
1830
2026
  const before = outbox.get(path);
1831
- if (typeof before === "string") files[path] = before;
2027
+ if (typeof before === "string" && !exceedsFileLimit(before)) files[path] = before;
1832
2028
  continue;
1833
2029
  }
1834
2030
  const local = await this.readLocal(path);
1835
- if (local.kind === "text") files[path] = local.text;
2031
+ if (local.kind === "text" && !exceedsFileLimit(local.text)) files[path] = local.text;
1836
2032
  }
1837
- if (Object.keys(files).length === 0) return false;
2033
+ if (Object.keys(files).length === 0) return null;
1838
2034
  const dirs = await this.walkDirs();
1839
- const result = await this.api.upload(files, dirs);
2035
+ let result;
2036
+ try {
2037
+ result = await this.api.upload(files, dirs);
2038
+ } catch (error) {
2039
+ if (!(error instanceof ServerError)) throw error;
2040
+ result = {
2041
+ ok: false,
2042
+ message: error.message
2043
+ };
2044
+ }
1840
2045
  if (!result.ok) {
1841
2046
  this.log(`upload refused: ${result.message}`);
1842
- return false;
2047
+ return {
2048
+ ok: false,
2049
+ message: result.message
2050
+ };
1843
2051
  }
1844
2052
  this.log(`uploaded the hub: ${result.files} files`);
1845
- return true;
2053
+ return { ok: true };
2054
+ }
2055
+ /** Tell the writer, once for each reason, that the server has no copy of the hub and refused this one (snapshotNow). */
2056
+ refusedUpload(message) {
2057
+ if (this.uploadRefusal === message) return;
2058
+ this.uploadRefusal = message;
2059
+ this.problem({
2060
+ kind: "upload-refused",
2061
+ path: "",
2062
+ message: `the sync server has no copy of hub ${this.opts.settings.hub} and refused the upload of this environment's copy: ${message}. Nothing was taken in, removed or sent here; fix what the refusal names, and the next connection uploads the hub again`
2063
+ });
1846
2064
  }
1847
2065
  /** Reconcile `path` after whatever earlier work on it has finished; nothing once stopping. */
1848
2066
  schedule(path) {
@@ -1893,6 +2111,7 @@ var SyncClient = class extends EventEmitter {
1893
2111
  * each write an entry of its own; the server's version waits while the outbox has a write about the file (I4).
1894
2112
  */
1895
2113
  async reconcile(path) {
2114
+ if (!this.syncs(path)) return;
1896
2115
  if (this.awaiting.has(path)) return;
1897
2116
  if (this.remote.has(path)) return this.reconcileRemote(path);
1898
2117
  const local = await this.readLocal(path);
@@ -1902,6 +2121,7 @@ var SyncClient = class extends EventEmitter {
1902
2121
  if (again.kind !== "binary" || again.hash !== local.hash) return this.schedule(path);
1903
2122
  return this.skipBinary(path, local.hash);
1904
2123
  }
2124
+ if (local.kind === "text" && exceedsFileLimit(local.text)) return this.skipLarge(path, local.text);
1905
2125
  const seen = this.expected.get(path);
1906
2126
  if (seen === void 0 ? local.kind !== "absent" : !this.matches(seen, local)) {
1907
2127
  await this.whenFree(() => this.syncQueue());
@@ -1910,15 +2130,16 @@ var SyncClient = class extends EventEmitter {
1910
2130
  const after = this.expected.get(path);
1911
2131
  if (after !== seen && after !== void 0 && this.matches(after, local)) return this.schedule(path);
1912
2132
  }
1913
- const target = this.cloud.has(path) ? this.cloud.get(path) : this.synced ? null : void 0;
2133
+ const target = this.cloud.has(path) ? this.cloud.get(path) : this.synced && !this.scopeStale ? null : void 0;
1914
2134
  const disk = this.expected.get(path);
2135
+ const unheld = target === null && this.unheld.has(path);
1915
2136
  if (disk === void 0) {
1916
2137
  if (local.kind === "absent") {
1917
2138
  if (typeof target === "string") await this.replace(path, target);
1918
2139
  return;
1919
2140
  }
1920
2141
  if (target === local.text) return this.adopt(path, local.text);
1921
- if (!this.synced) return;
2142
+ if (!this.synced || target === void 0) return;
1922
2143
  const answer = this.snapshotAnswers.get(path);
1923
2144
  if (answer?.text === local.text && !this.pending(path)) {
1924
2145
  this.snapshotAnswers.delete(path);
@@ -1949,7 +2170,7 @@ var SyncClient = class extends EventEmitter {
1949
2170
  const again = await this.readLocal(path);
1950
2171
  if (again.kind !== "text" || again.text !== "") return this.schedule(path);
1951
2172
  }
1952
- return this.queueLocal(path, local.text);
2173
+ return this.queueLocal(path, local.text, unheld ? { start: null } : {});
1953
2174
  }
1954
2175
  if (target === null || disk === null) {
1955
2176
  this.forget(path);
@@ -1968,6 +2189,7 @@ var SyncClient = class extends EventEmitter {
1968
2189
  return this.release(path);
1969
2190
  }
1970
2191
  if (this.pending(path)) return;
2192
+ if (unheld && local.kind === "text") return this.queueUnheld(path, local.text);
1971
2193
  await this.replace(path, target);
1972
2194
  }
1973
2195
  /**
@@ -2039,15 +2261,25 @@ var SyncClient = class extends EventEmitter {
2039
2261
  this.history.set(path, list);
2040
2262
  }
2041
2263
  skipBinary(path, hash) {
2042
- const key = `${path}:${hash}`;
2043
- if (this.binaries.has(key)) return;
2044
- this.binaries.add(key);
2264
+ const key = `binary:${path}:${hash}`;
2265
+ if (this.skipped.has(key)) return;
2266
+ this.skipped.add(key);
2045
2267
  this.problem({
2046
2268
  kind: "skipped-binary",
2047
2269
  path,
2048
2270
  message: `${path} is not UTF-8 text, so it is not synced and not overwritten; other environments do not see it`
2049
2271
  });
2050
2272
  }
2273
+ skipLarge(path, text) {
2274
+ const key = `large:${path}:${contentHash(text)}`;
2275
+ if (this.skipped.has(key)) return;
2276
+ this.skipped.add(key);
2277
+ this.problem({
2278
+ kind: "skipped-large",
2279
+ path,
2280
+ message: `${path} is larger than the ${MAX_FILE_BYTES} bytes the sync server keeps of a file, so it is not synced and not overwritten; other environments do not see it. Split it, or add it to .mainflow/.gitignore`
2281
+ });
2282
+ }
2051
2283
  /**
2052
2284
  * Whether mainline `dir` is open, closed (done or dropped) or of a status the client does not know, by its
2053
2285
  * mainline.md: the server's text the client took in, else the file here.
@@ -2157,7 +2389,7 @@ var SyncClient = class extends EventEmitter {
2157
2389
  * and `have` does not name it. Returns, besides, the files of other paths listed that may not stay on the server.
2158
2390
  */
2159
2391
  async fetchNow(targets) {
2160
- const sorted = [...new Set(targets)].filter(isSynced).sort(comparePaths);
2392
+ const sorted = [...new Set(targets)].filter((path) => this.syncs(path)).sort(comparePaths);
2161
2393
  const again = /* @__PURE__ */ new Set();
2162
2394
  if (sorted.length === 0 || this.closing) return {
2163
2395
  fetched: [],
@@ -2169,7 +2401,7 @@ var SyncClient = class extends EventEmitter {
2169
2401
  let done = false;
2170
2402
  try {
2171
2403
  const have = /* @__PURE__ */ new Map();
2172
- for (const [path, text] of this.cloud) if (typeof text === "string" && !want.has(path) && isSynced(path)) have.set(path, this.hashOf(path, text));
2404
+ for (const [path, text] of this.cloud) if (typeof text === "string" && !want.has(path) && this.syncs(path)) have.set(path, this.hashOf(path, text));
2173
2405
  for (const [path, hash] of this.remote) if (!want.has(path)) have.set(path, hash);
2174
2406
  const listOnly = this.listOnly(false)?.filter((prefix) => !sorted.some((path) => path.startsWith(prefix)));
2175
2407
  const first = sorted[0];
@@ -2192,6 +2424,7 @@ var SyncClient = class extends EventEmitter {
2192
2424
  continue;
2193
2425
  }
2194
2426
  mentioned.add(event.path);
2427
+ if (!this.syncs(event.path)) continue;
2195
2428
  if (event.kind === "listed") {
2196
2429
  if (want.has(event.path)) continue;
2197
2430
  if (this.mayStay(event.path)) this.keepRemote(event.path, event.hash, part.seq);
@@ -2307,7 +2540,7 @@ var SyncClient = class extends EventEmitter {
2307
2540
  await this.fetchPaths([path]).catch((error) => this.log(`${path}: could not download it: ${error.message}`));
2308
2541
  }
2309
2542
  restoreRemote() {
2310
- for (const [path, hash] of Object.entries(readRemote(this.root))) if (isSynced(path) && artifactOf(path) !== null && this.expected.get(path) === void 0) this.remote.set(path, hash);
2543
+ for (const [path, hash] of Object.entries(readRemote(this.root))) if (this.syncs(path) && artifactOf(path) !== null && this.expected.get(path) === void 0) this.remote.set(path, hash);
2311
2544
  }
2312
2545
  saveRemote() {
2313
2546
  if (this.remoteTimer) return;
@@ -2628,6 +2861,24 @@ var SyncClient = class extends EventEmitter {
2628
2861
  this.trace(`local ${path} ${short(text)} changed before it was queued; reconciling it again`);
2629
2862
  this.schedule(path);
2630
2863
  }
2864
+ /**
2865
+ * Queue `path`, which holds `text` as the client left it there, as a new file: the server never held a file at it
2866
+ * (`unheld`). Under the write lock that queues it, the file must still hold it and the server still have no text of
2867
+ * it; otherwise it is reconciled again.
2868
+ */
2869
+ async queueUnheld(path, text) {
2870
+ if (await this.enqueue(() => {
2871
+ if (this.expected.get(path)?.hash !== contentHash(text) || !this.onDisk(path, text) || !this.unheld.has(path) || typeof this.cloud.get(path) === "string") return null;
2872
+ return {
2873
+ kind: "patch",
2874
+ path,
2875
+ text,
2876
+ base: null
2877
+ };
2878
+ }) !== null) return;
2879
+ this.trace(`new ${path} changed before it was queued; reconciling it again`);
2880
+ this.schedule(path);
2881
+ }
2631
2882
  /** What the response to queued edit `entry` means for the writer (layer 2), then wait for the broadcast it caused. */
2632
2883
  async settleEdit(entry, response, context, sent) {
2633
2884
  const { path } = entry;
@@ -3397,7 +3648,7 @@ var SyncClient = class extends EventEmitter {
3397
3648
  ...nodeStorage(this.root),
3398
3649
  lock: (fn) => fn()
3399
3650
  };
3400
- const seen = new Map([...new Set(paths)].filter(isSynced).map((path) => [path, this.expected.get(path)]));
3651
+ const seen = new Map([...new Set(paths)].filter((path) => this.syncs(path)).map((path) => [path, this.expected.get(path)]));
3401
3652
  const changes = renumberFiles(storage, seen.keys(), renumbered);
3402
3653
  const unknown = /* @__PURE__ */ new Set();
3403
3654
  for (const [path, disk] of seen) {
@@ -3450,7 +3701,7 @@ var SyncClient = class extends EventEmitter {
3450
3701
  */
3451
3702
  async flushEdit(edit, timeoutMs = 2e4) {
3452
3703
  const { path } = edit;
3453
- if (this.caughtUp && isSynced(path) && edit.old !== "") await this.withChain(path, () => this.replayEdit(edit));
3704
+ if (this.caughtUp && this.syncs(path) && edit.old !== "") await this.withChain(path, () => this.replayEdit(edit));
3454
3705
  return this.flush(timeoutMs);
3455
3706
  }
3456
3707
  /**
@@ -3513,28 +3764,61 @@ var SyncClient = class extends EventEmitter {
3513
3764
  }]);
3514
3765
  if ("files" in response && response.files.some((f) => f.path === path)) await this.delivery(path, response.seq);
3515
3766
  }
3516
- async walk(dir = "") {
3767
+ /**
3768
+ * The synced files here, by path. Each directory's .gitignore is read again on the way, before what the directory
3769
+ * holds, so a change to one takes effect from the next scan on; a directory the rules leave out is not walked into.
3770
+ * Rules changed since the last snapshot was asked for take it again: a file they let in that is on the server, as
3771
+ * another environment sent it while the rules here left it out, comes then, not only on the next connection. A walk
3772
+ * of a snapshot being taken leaves that to it (resync).
3773
+ */
3774
+ async walk() {
3517
3775
  const out = [];
3518
- let entries;
3519
- try {
3520
- entries = await readdir(join(this.paths.data, dir), { withFileTypes: true });
3521
- } catch {
3522
- return out;
3523
- }
3524
- for (const entry of entries) {
3525
- const path = dir ? `${dir}/${entry.name}` : entry.name;
3526
- if (entry.isDirectory()) {
3527
- if (mayHoldSynced(path)) out.push(...await this.walk(path));
3528
- } else if (entry.isFile() && isSynced(path)) out.push(path);
3776
+ const dirs = /* @__PURE__ */ new Set();
3777
+ const visit = async (dir) => {
3778
+ let entries;
3779
+ try {
3780
+ entries = await readdir(join(this.paths.data, dir), { withFileTypes: true });
3781
+ } catch {
3782
+ return;
3783
+ }
3784
+ dirs.add(dir);
3785
+ this.scope.set(dir, entries.some((entry) => entry.name === ".gitignore" && entry.isFile()) ? await this.readRules(dir) : null);
3786
+ for (const entry of entries) {
3787
+ const path = dir ? `${dir}/${entry.name}` : entry.name;
3788
+ if (entry.isDirectory()) {
3789
+ if (this.scope.walks(path)) await visit(path);
3790
+ } else if (entry.isFile() && this.syncs(path)) out.push(path);
3791
+ }
3792
+ };
3793
+ await visit("");
3794
+ this.scope.keepOnly(dirs);
3795
+ if (this.scopeStale && this.synced && this.connected && this.resyncing === null && !this.closing) {
3796
+ this.log("the hub's .gitignore changed; reloading the snapshot for the files it lets in");
3797
+ this.track(this.resync().catch((error) => this.log(`could not reload the snapshot: ${error.message}`)));
3529
3798
  }
3530
3799
  return out;
3531
3800
  }
3801
+ /**
3802
+ * The text of the .gitignore in `dir`, which is there, as walk takes it. A text other than the rules have may be one
3803
+ * being written, as `cat >` empties a file before it fills it, and a rule missing for a moment would let its files in,
3804
+ * to be sent: it is read again after EMPTY_RECHECK_MS and taken only when it reads the same, empty too. Until then, and
3805
+ * while the file cannot be read, the rules stay as they were; the next walk reads it again.
3806
+ */
3807
+ async readRules(dir) {
3808
+ const held = this.scope.text(dir);
3809
+ const read = () => readFile(join(this.paths.data, dir, GITIGNORE), "utf8").catch(() => null);
3810
+ const text = await read();
3811
+ if (text === null || text === held) return held;
3812
+ await sleep$1(EMPTY_RECHECK_MS);
3813
+ return await read() === text ? text : held;
3814
+ }
3815
+ /** The directories an upload holds: every one outside local/ that the hub's .gitignore files leave in. */
3532
3816
  async walkDirs(dir = "") {
3533
3817
  const out = [];
3534
3818
  const entries = await readdir(join(this.paths.data, dir), { withFileTypes: true }).catch(() => []);
3535
3819
  for (const entry of entries) {
3536
3820
  const path = dir ? `${dir}/${entry.name}` : entry.name;
3537
- if (!entry.isDirectory() || path === "local" || path.startsWith("local/")) continue;
3821
+ if (!entry.isDirectory() || path === "local" || path.startsWith("local/") || this.scope.ignored(path, true)) continue;
3538
3822
  out.push(path, ...await this.walkDirs(path));
3539
3823
  }
3540
3824
  return out;
@@ -3557,7 +3841,7 @@ var SyncClient = class extends EventEmitter {
3557
3841
  if (!baseline) this.schedule(path);
3558
3842
  }
3559
3843
  if (baseline) return;
3560
- for (const [path, disk] of this.expected) if (disk !== null && !seen.has(path)) {
3844
+ for (const [path, disk] of this.expected) if (disk !== null && !seen.has(path) && this.syncs(path)) {
3561
3845
  this.signatures.delete(path);
3562
3846
  this.schedule(path);
3563
3847
  }
@@ -3578,12 +3862,13 @@ var SyncClient = class extends EventEmitter {
3578
3862
  }, SETTLE_MS));
3579
3863
  return;
3580
3864
  }
3581
- if (!isSynced(path)) return;
3865
+ const rules = path === ".gitignore" || path.endsWith(`/.gitignore`);
3866
+ if (!rules && !this.syncs(path)) return;
3582
3867
  this.trace(`watched ${path}`);
3583
3868
  clearTimeout(timers.get(path));
3584
3869
  timers.set(path, setTimeout(() => {
3585
3870
  timers.delete(path);
3586
- this.track(this.whenSettled(path).then(() => this.schedule(path)));
3871
+ this.track(this.whenSettled(path).then(() => rules ? this.scan(true) : this.schedule(path)));
3587
3872
  }, SETTLE_MS));
3588
3873
  };
3589
3874
  try {
@@ -4208,4 +4493,4 @@ function hubWriter(root, env = process.env, options = {}) {
4208
4493
  };
4209
4494
  }
4210
4495
  //#endregion
4211
- export { syncSettings as C, syncPaths as D, isSynced as E, hubName as S, SYNC_DIR as T, readRemote as _, remoteMode as a, ENV as b, planFetch as c, DaemonRunning as d, DaemonUnavailable as f, silentHint as g, readDaemon as h, queuedCount as i, planFlush as l, probeDaemon as m, hubWriter as n, fetchDenial as o, callDaemon as p, writtenLines as r, hookAnswer as s, CONFIRM_MS as t, startDaemon as u, ServerApi as v, SETTINGS_FILE as w, earlierToken as x, ServerError as y };
4496
+ export { hubName as C, isSynced as D, SYNC_DIR as E, syncPaths as O, earlierToken as S, SETTINGS_FILE as T, readRemote as _, remoteMode as a, ServerError as b, planFetch as c, DaemonRunning as d, DaemonUnavailable as f, silentHint as g, readDaemon as h, queuedCount as i, planFlush as l, probeDaemon as m, hubWriter as n, fetchDenial as o, callDaemon as p, writtenLines as r, hookAnswer as s, CONFIRM_MS as t, startDaemon as u, oversizedFiles as v, syncSettings as w, ENV as x, ServerApi as y };