tether-sync 0.4.0 → 0.6.0

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.
@@ -99,7 +99,7 @@ function call(endpoint, method, params = {}, timeoutMs = 10 * 60 * 1e3) {
99
99
  }
100
100
 
101
101
  // packages/client/src/version.js
102
- var VERSION = "0.4.0";
102
+ var VERSION = "0.6.0";
103
103
 
104
104
  // packages/client/src/update.js
105
105
  import fs2 from "node:fs";
@@ -23,7 +23,7 @@ import {
23
23
  restage,
24
24
  setKeep,
25
25
  tetherHome
26
- } from "./chunk-NMLHD2KU.js";
26
+ } from "./chunk-KG4OTE34.js";
27
27
 
28
28
  // packages/client/src/daemon.js
29
29
  import fs8 from "node:fs";
@@ -45,6 +45,7 @@ var DEFAULTS = {
45
45
  // all | errors | off
46
46
  };
47
47
  var NUMERIC = /* @__PURE__ */ new Set(["tickSeconds", "graceMs"]);
48
+ var MIN_TICK_SECONDS = Number(process.env.TETHER_MIN_TICK_SECONDS) || 10;
48
49
  var Config = class {
49
50
  constructor(file) {
50
51
  this.file = file;
@@ -63,6 +64,7 @@ var Config = class {
63
64
  if (NUMERIC.has(key)) {
64
65
  value = Number(value);
65
66
  if (!Number.isFinite(value) || value <= 0) throw new Error(`${key} must be a positive number`);
67
+ if (key === "tickSeconds" && value < MIN_TICK_SECONDS) throw new Error(`tickSeconds must be at least ${MIN_TICK_SECONDS}`);
66
68
  }
67
69
  if (key === "notifications" && !["all", "errors", "off"].includes(value)) throw new Error("notifications must be all, errors or off");
68
70
  if (key === "scanRoots" && typeof value === "string") value = value.split(/[;,]/).map((s) => s.trim()).filter(Boolean);
@@ -987,6 +989,7 @@ function walkFiles(root) {
987
989
  var UserError = class extends Error {
988
990
  };
989
991
  var ACTIVE = (i) => i.mode !== "orphaned";
992
+ var FULL_CHECK_MS = 60 * 60 * 1e3;
990
993
  var Engine = class {
991
994
  constructor({ db, api, cache, tracker, config, log = () => {
992
995
  }, notify = () => {
@@ -1445,6 +1448,28 @@ var Engine = class {
1445
1448
  this.log(`${inst.path}: ${what}; file kept, no longer tracked`);
1446
1449
  this.notify({ kind: "orphaned", level: "warn", title: "No longer synced", body: `${path4.basename(inst.path)}: ${what}. Your copy stays on disk but Tether no longer keeps it up to date.`, short: path4.basename(inst.path) });
1447
1450
  }
1451
+ /** What a check asks about; a change cursor is only valid for the same question. */
1452
+ checkKey(insts) {
1453
+ return insts.map((i) => `${i.artifactId}@${i.baseVersion}:${i.mode}`).join(",");
1454
+ }
1455
+ /**
1456
+ * The change cursor from the last full check, if it can be reused: same instances, nothing
1457
+ * pending, no live sessions, and a full check at least every FULL_CHECK_MS as a safety net.
1458
+ */
1459
+ reusableCursor(insts) {
1460
+ const f = this.feed;
1461
+ if (!f || this.realtimeActive || Date.now() - f.at > FULL_CHECK_MS) return void 0;
1462
+ return f.key === this.checkKey(insts) ? f.cursor : void 0;
1463
+ }
1464
+ /**
1465
+ * Keeps the server's cursor only when nothing is pending here: an incoming version held back
1466
+ * (editing, unmanaged, waiting for git) must be offered again by a full check once it can apply.
1467
+ */
1468
+ rememberCursor(cursor) {
1469
+ const insts = this.db.all().filter(ACTIVE);
1470
+ const settled = insts.every((i) => i.remoteVersion == null || i.remoteVersion === i.baseVersion);
1471
+ this.feed = cursor !== void 0 && settled ? { cursor, key: this.checkKey(insts), at: Date.now() } : null;
1472
+ }
1448
1473
  /** One batched call for all instances (DESIGN §6.1 step 2). */
1449
1474
  async checkRemote() {
1450
1475
  const insts = this.db.all().filter(ACTIVE);
@@ -1452,10 +1477,15 @@ var Engine = class {
1452
1477
  this.offline = null;
1453
1478
  return;
1454
1479
  }
1455
- let results;
1480
+ let results, cursor;
1456
1481
  try {
1457
- ({ results } = await this.api.call("POST", "/sync/check", { items: insts.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion })) }));
1482
+ const rootOf = (i) => i.folderInstanceId ? this.db.get(i.folderInstanceId)?.artifactId : void 0;
1483
+ const items = insts.map((i) => ({ artifactId: i.artifactId, version: i.baseVersion, root: rootOf(i) }));
1484
+ const body = { items, cursor: this.reusableCursor(insts) };
1485
+ const res = await this.api.call("POST", "/sync/check", body);
1458
1486
  this.offline = null;
1487
+ if (res.unchanged) return;
1488
+ ({ results, cursor } = res);
1459
1489
  } catch (e) {
1460
1490
  if (e instanceof OfflineError || e instanceof ApiError && e.status === 401) {
1461
1491
  this.offline = e.message;
@@ -1468,6 +1498,7 @@ var Engine = class {
1468
1498
  const folders = [];
1469
1499
  insts.forEach((inst, idx) => {
1470
1500
  const r = results[idx];
1501
+ if (!r) return;
1471
1502
  const cur = this.db.get(inst.instanceId);
1472
1503
  if (!cur || cur.mode === "orphaned") return;
1473
1504
  if (r.status === "gone") return this.orphan(cur, r.reason);
@@ -1547,6 +1578,8 @@ var Engine = class {
1547
1578
  this.log(`${folder.path}: new file ${child.relPath}`);
1548
1579
  }
1549
1580
  for (const f of folders) if (!waiting.has(f.inst.instanceId)) this.db.update(f.inst.instanceId, { baseVersion: f.state.version });
1581
+ if (results.some((r) => r && r.status !== "ok")) this.activityAt = Date.now();
1582
+ this.rememberCursor(cursor);
1550
1583
  }
1551
1584
  /** Normal mode: write an incoming version (content and/or layout). */
1552
1585
  applyIncoming(inst, state) {
@@ -1613,6 +1646,22 @@ var Engine = class {
1613
1646
  const local = readLocal(p, inst.eolMode);
1614
1647
  return local?.hash === inst.baseHash;
1615
1648
  }
1649
+ /**
1650
+ * Follow mode, just before a revert: is this content git's own (a git operation that started
1651
+ * after this pass checked, e.g. a cherry-pick between writing the file and moving HEAD)? Asks git
1652
+ * afresh, since the pass's busy check may be stale by now.
1653
+ */
1654
+ gitWroteIt(inst) {
1655
+ if (inst.gitMode !== "follow" || !inst.gitRoot) return false;
1656
+ if (gitBusy(inst.gitDir)) return true;
1657
+ const blob = fileBlob(inst.gitRoot, inst.path);
1658
+ if (!blob) return false;
1659
+ if (blob === indexBlob(inst.gitRoot, inst.path) || blob === blobAt(inst.gitRoot, "HEAD", inst.path)) {
1660
+ this.gitPassDue = true;
1661
+ return true;
1662
+ }
1663
+ return false;
1664
+ }
1616
1665
  /** Normal mode: restore missing files, put moved files back, revert local edits. */
1617
1666
  async enforceLocal() {
1618
1667
  const restores = this.db.all().filter((i) => ACTIVE(i) && i.needsRestore && i.mode === "normal");
@@ -1643,6 +1692,7 @@ var Engine = class {
1643
1692
  continue;
1644
1693
  }
1645
1694
  if (!this.cache.has(cur.baseHash)) continue;
1695
+ if (this.gitWroteIt(cur)) continue;
1646
1696
  this.db.update(cur.instanceId, { ...this.writeInstance(cur, cur.path, cur.baseHash), lastError: null });
1647
1697
  this.log(`${cur.path}: local change reverted (use \`tether edit\` to change it)`);
1648
1698
  const byGit = cur.gitMode === "keep";
@@ -2454,9 +2504,90 @@ $x = New-Object Windows.Data.Xml.Dom.XmlDocument; $x.LoadXml('${xml.replace(/'/g
2454
2504
  }
2455
2505
  };
2456
2506
 
2507
+ // packages/client/src/feed.js
2508
+ var RETRY_MS = 5 * 60 * 1e3;
2509
+ var FeedListener = class {
2510
+ /** onChange(seq) is called with the feed counter on every snapshot from the server. */
2511
+ constructor({ api, log, onChange }) {
2512
+ this.api = api;
2513
+ this.log = log;
2514
+ this.onChange = onChange;
2515
+ this.live = false;
2516
+ this.unsupported = false;
2517
+ this.session = 0;
2518
+ }
2519
+ async start() {
2520
+ if (this.unsupported || this.unsub || this.starting) return;
2521
+ this.starting = true;
2522
+ const session = ++this.session;
2523
+ try {
2524
+ const t = await this.api.call("POST", "/feed/token");
2525
+ const [{ initializeApp }, A, F] = await Promise.all([import("firebase/app"), import("firebase/auth"), import("firebase/firestore")]);
2526
+ if (session !== this.session) return;
2527
+ this.app = initializeApp(t.firebase, `tether-feed-${session}-${Date.now()}`);
2528
+ const auth = A.getAuth(this.app);
2529
+ const db = F.getFirestore(this.app);
2530
+ if (t.emulators?.auth) A.connectAuthEmulator(auth, `http://${t.emulators.auth}`, { disableWarnings: true });
2531
+ if (t.emulators?.firestore) {
2532
+ const [host, port] = t.emulators.firestore.split(":");
2533
+ F.connectFirestoreEmulator(db, host, Number(port));
2534
+ }
2535
+ await A.signInWithCustomToken(auth, t.customToken);
2536
+ if (session !== this.session) return this.dispose();
2537
+ this.unsub = F.onSnapshot(F.doc(db, "feeds", t.uid), { includeMetadataChanges: true }, (snap) => {
2538
+ const wasLive = this.live;
2539
+ this.live = !snap.metadata.fromCache;
2540
+ if (this.live && !wasLive) this.log("push channel connected");
2541
+ if (this.live) this.onChange(snap.get("seq") ?? 0);
2542
+ }, (e) => this.fail(e, session));
2543
+ } catch (e) {
2544
+ this.fail(e, session);
2545
+ } finally {
2546
+ this.starting = false;
2547
+ }
2548
+ }
2549
+ fail(e, session) {
2550
+ if (session !== this.session) return;
2551
+ if (e instanceof ApiError && e.status === 404) {
2552
+ this.unsupported = true;
2553
+ this.stop();
2554
+ return;
2555
+ }
2556
+ this.log(`push channel unavailable (${e.message}); polling until it reconnects`);
2557
+ this.stop();
2558
+ clearTimeout(this.retry);
2559
+ this.retry = setTimeout(() => this.start(), RETRY_MS);
2560
+ this.retry.unref?.();
2561
+ }
2562
+ stop() {
2563
+ this.session++;
2564
+ clearTimeout(this.retry);
2565
+ this.unsub?.();
2566
+ this.unsub = null;
2567
+ if (this.live) this.log("push channel closed");
2568
+ this.live = false;
2569
+ this.dispose();
2570
+ }
2571
+ dispose() {
2572
+ const app = this.app;
2573
+ this.app = null;
2574
+ if (app) import("firebase/app").then(({ deleteApp }) => deleteApp(app)).catch(() => {
2575
+ });
2576
+ }
2577
+ /** After a login, logout or server change: start over (or stay off when signed out). */
2578
+ restart() {
2579
+ this.unsupported = false;
2580
+ this.stop();
2581
+ this.start();
2582
+ }
2583
+ };
2584
+
2457
2585
  // packages/client/src/daemon.js
2458
2586
  import { fileURLToPath } from "node:url";
2459
2587
  var REALTIME_TICK_SECONDS = 3;
2588
+ var IDLE_AFTER_MS = 10 * 60 * 1e3;
2589
+ var IDLE_MAX_SECONDS = 180;
2590
+ var PUSH_TICK_SECONDS = 15 * 60;
2460
2591
  var UPDATE_CHECK_MS = 24 * 60 * 60 * 1e3;
2461
2592
  async function runDaemon() {
2462
2593
  const home = tetherHome();
@@ -2494,23 +2625,54 @@ async function runDaemon() {
2494
2625
  let realtimeTimer = null;
2495
2626
  let stopping = false;
2496
2627
  let latest = null;
2497
- async function runTick(opts) {
2628
+ let remoteAt = 0;
2629
+ async function runTick({ remote } = {}) {
2630
+ remote ??= !feed.live || engine.realtimeActive || Date.now() - remoteAt >= PUSH_TICK_SECONDS * 1e3;
2631
+ if (remote) remoteAt = Date.now();
2498
2632
  try {
2499
- await engine.locked(() => engine.tick(opts));
2633
+ await engine.locked(() => engine.tick({ remote }));
2500
2634
  } catch (e) {
2501
2635
  log(`tick failed: ${e.stack || e.message}`);
2502
2636
  notifier.notify({ kind: "error", level: "error", key: "tick-failed", cooldownMs: 3600 * 1e3, title: "Tether hit an error", body: `${e.message}. Run "tether log" for details.` });
2503
2637
  }
2638
+ if (engine.gitPassDue) {
2639
+ engine.gitPassDue = false;
2640
+ gitPassSoon();
2641
+ }
2504
2642
  refreshWatchers();
2505
2643
  scheduleGrace();
2506
2644
  schedule();
2507
2645
  }
2646
+ let activeAt = Date.now();
2647
+ let timerDue = 0;
2648
+ function interval() {
2649
+ const base = Math.max(MIN_TICK_SECONDS, config.get("tickSeconds"));
2650
+ if (engine.realtimeActive) return Math.min(base, REALTIME_TICK_SECONDS);
2651
+ const idle = Date.now() - Math.max(activeAt, engine.activityAt ?? 0);
2652
+ if (idle < IDLE_AFTER_MS) return base;
2653
+ return Math.max(base, Math.min(IDLE_MAX_SECONDS, base * 2 ** Math.floor(idle / IDLE_AFTER_MS)));
2654
+ }
2508
2655
  function schedule() {
2509
2656
  clearTimeout(timer);
2510
2657
  if (stopping) return;
2511
- const seconds = engine.realtimeActive ? Math.min(config.get("tickSeconds"), REALTIME_TICK_SECONDS) : config.get("tickSeconds");
2658
+ const seconds = interval();
2659
+ timerDue = Date.now() + seconds * 1e3;
2512
2660
  timer = setTimeout(() => runTick(), seconds * 1e3);
2513
2661
  }
2662
+ const feed = new FeedListener({
2663
+ api,
2664
+ log,
2665
+ onChange: (seq) => {
2666
+ if (stopping || engine.feed?.cursor === seq) return;
2667
+ clearTimeout(timer);
2668
+ timerDue = Date.now() + 300;
2669
+ timer = setTimeout(() => runTick({ remote: true }), 300);
2670
+ }
2671
+ });
2672
+ function markActive() {
2673
+ activeAt = Date.now();
2674
+ if (timer && timerDue - activeAt > interval() * 1e3) schedule();
2675
+ }
2514
2676
  function realtimePass(delay = 1e3) {
2515
2677
  if (stopping || !engine.realtimeInstances().length) return;
2516
2678
  clearTimeout(realtimeTimer);
@@ -2587,6 +2749,7 @@ async function runDaemon() {
2587
2749
  if (watchers.has(dir)) continue;
2588
2750
  try {
2589
2751
  const w = kind === "git" ? fs8.watch(dir, { persistent: false }, () => gitPassSoon()) : fs8.watch(dir, { recursive: kind, persistent: false }, () => {
2752
+ markActive();
2590
2753
  quickPass(250);
2591
2754
  realtimePass();
2592
2755
  });
@@ -2631,7 +2794,7 @@ async function runDaemon() {
2631
2794
  return { state, url: `${config.consoleUrl.replace(/\/$/, "")}/cli-login?port=${port}&state=${state}` };
2632
2795
  }
2633
2796
  const L = (fn) => (params) => engine.locked(() => fn(params));
2634
- const soon = () => setTimeout(() => runTick(), 10);
2797
+ const soon = () => setTimeout(() => runTick({ remote: true }), 10);
2635
2798
  const handlers = {
2636
2799
  ping: () => ({ pid: process.pid, version: VERSION, home, tracker: tracker.name, server: config.get("server") }),
2637
2800
  async status(p) {
@@ -2647,11 +2810,13 @@ async function runDaemon() {
2647
2810
  };
2648
2811
  },
2649
2812
  sync: async () => {
2650
- await runTick();
2813
+ await runTick({ remote: true });
2651
2814
  return { offline: engine.offline };
2652
2815
  },
2653
2816
  async "login.password"({ email, password, signup }) {
2654
2817
  const user = await api.passwordLogin({ email, password, signup });
2818
+ engine.feed = null;
2819
+ feed.restart();
2655
2820
  soon();
2656
2821
  return user;
2657
2822
  },
@@ -2661,13 +2826,19 @@ async function runDaemon() {
2661
2826
  if (!p) throw new UserError("no login in progress");
2662
2827
  try {
2663
2828
  const user = await p;
2829
+ engine.feed = null;
2830
+ feed.restart();
2664
2831
  soon();
2665
2832
  return user;
2666
2833
  } finally {
2667
2834
  logins.delete(state);
2668
2835
  }
2669
2836
  },
2670
- logout: async () => api.logout(),
2837
+ logout: async () => {
2838
+ engine.feed = null;
2839
+ feed.stop();
2840
+ return api.logout();
2841
+ },
2671
2842
  whoami: () => api.call("GET", "/me"),
2672
2843
  manage: L((p) => engine.manage(p)),
2673
2844
  add: L((p) => engine.add(p)),
@@ -2727,6 +2898,10 @@ async function runDaemon() {
2727
2898
  "config.get": ({ key }) => key ? { [key]: config.get(key) } : config.values,
2728
2899
  "config.set": ({ key, value }) => {
2729
2900
  config.set(key, value);
2901
+ if (key === "server") {
2902
+ engine.feed = null;
2903
+ feed.restart();
2904
+ }
2730
2905
  if (key === "tickSeconds") schedule();
2731
2906
  return { [key]: config.get(key) };
2732
2907
  },
@@ -2740,6 +2915,7 @@ async function runDaemon() {
2740
2915
  async function handle(method, params) {
2741
2916
  const h = handlers[method];
2742
2917
  if (!h) throw new UserError(`unknown command ${method}`);
2918
+ if (method !== "ping") markActive();
2743
2919
  try {
2744
2920
  const result = await h(params);
2745
2921
  if (CHANGES_WATCHED.has(method)) refreshWatchers();
@@ -2763,6 +2939,7 @@ async function runDaemon() {
2763
2939
  clearInterval(updateTimer);
2764
2940
  clearInterval(repoTimer);
2765
2941
  clearTimeout(gitTimer);
2942
+ feed.stop();
2766
2943
  for (const w of watchers.values()) w.close();
2767
2944
  server.close();
2768
2945
  engine.locked(() => db.close()).finally(() => {
@@ -2782,6 +2959,7 @@ async function runDaemon() {
2782
2959
  const updateTimer = setInterval(checkForUpdate, UPDATE_CHECK_MS);
2783
2960
  setTimeout(checkForUpdate, 5e3);
2784
2961
  await runTick();
2962
+ feed.start();
2785
2963
  }
2786
2964
  export {
2787
2965
  VERSION,
package/bin/tether.js CHANGED
@@ -13,7 +13,7 @@ import {
13
13
  npmInstall,
14
14
  prefixOf,
15
15
  tetherHome
16
- } from "./chunk-NMLHD2KU.js";
16
+ } from "./chunk-KG4OTE34.js";
17
17
 
18
18
  // packages/client/src/cli.js
19
19
  import path2 from "node:path";
package/bin/tetherd.js CHANGED
@@ -4,7 +4,7 @@
4
4
  process.removeAllListeners("warning");
5
5
  var i = process.argv.indexOf("--home");
6
6
  if (i > 0 && process.argv[i + 1]) process.env.TETHER_HOME = process.argv[i + 1];
7
- var { runDaemon } = await import("./daemon-5IFWP5O3.js");
7
+ var { runDaemon } = await import("./daemon-Z2NE3MNG.js");
8
8
  runDaemon().catch((e) => {
9
9
  if (e.code === "EADDRINUSE") {
10
10
  console.error("tetherd is already running");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tether-sync",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Keep files identical to a central source of truth, wherever they live on your machine.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,7 +21,8 @@
21
21
  "dependencies": {
22
22
  "koffi": "^3.3.3",
23
23
  "commander": "^14.0.1",
24
- "diff": "^8.0.2"
24
+ "diff": "^8.0.2",
25
+ "firebase": "^12.19.0"
25
26
  },
26
27
  "keywords": [
27
28
  "sync",