@rigxyz/tapd 0.3.0 → 0.4.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.
@@ -94,6 +94,17 @@ async function createBinding(opts) {
94
94
  });
95
95
  return expectJson(route, res);
96
96
  }
97
+ async function revokeImportEdge(opts) {
98
+ const fn = opts.fetch ?? fetch;
99
+ const res = await fn(
100
+ joinUrl(opts.baseUrl, `/v1/me/imports/${encodeURIComponent(opts.edgeId)}`),
101
+ { method: "DELETE", headers: { authorization: `Bearer ${opts.accessToken}` } }
102
+ );
103
+ if (res.ok) return { revoked: true, status: res.status };
104
+ const body = await readJson(res);
105
+ const reason = typeof body === "object" && body !== null && "reason" in body ? String(body.reason) : void 0;
106
+ return { revoked: false, status: res.status, ...reason ? { reason } : {} };
107
+ }
97
108
  var RelayClient = class {
98
109
  baseUrl;
99
110
  bindingId;
@@ -148,7 +159,14 @@ var RelayClient = class {
148
159
  );
149
160
  return expectJson(route, res);
150
161
  }
151
- /** PUT bytes to a presigned URL. Throws on non-2xx. */
162
+ /**
163
+ * PUT bytes to a presigned URL. Throws on non-2xx — except 409, which
164
+ * means the content-addressed object is already stored (same hash = same
165
+ * bytes). That's idempotent success, not a conflict: two files with
166
+ * identical content (e.g. several empty files), or an initial upload that
167
+ * overlaps the daemon's first scan, race to PUT the same key. Treating
168
+ * 409 as fatal aborts the whole push and wedges sync at "N pending".
169
+ */
152
170
  async putObjectBytes(uploadUrl, bytes) {
153
171
  const route = "PUT <uploadUrl>";
154
172
  const res = await this.fetch(uploadUrl, {
@@ -156,6 +174,7 @@ var RelayClient = class {
156
174
  body: new Uint8Array(bytes),
157
175
  headers: { "content-length": String(bytes.byteLength) }
158
176
  });
177
+ if (res.status === 409) return;
159
178
  if (!res.ok) {
160
179
  throw new RelayError(route, res.status, await readJson(res));
161
180
  }
@@ -426,6 +445,15 @@ function isAlwaysExcluded(posixPath) {
426
445
  }
427
446
  return false;
428
447
  }
448
+ function isUnderAny(posixPath, prefixes) {
449
+ for (const raw of prefixes) {
450
+ const prefix = raw.replace(/\/+$/, "");
451
+ if (prefix === "") continue;
452
+ if (posixPath === prefix) return true;
453
+ if (posixPath.startsWith(prefix + "/")) return true;
454
+ }
455
+ return false;
456
+ }
429
457
 
430
458
  // src/walker.ts
431
459
  import { readdirSync, statSync } from "fs";
@@ -462,6 +490,7 @@ function walk(rootDir, opts) {
462
490
  const emptyDirs = [];
463
491
  const directoriesScanned = [];
464
492
  const warnings = [];
493
+ const excludePaths = opts.excludePaths ?? [];
465
494
  walkDir(".");
466
495
  return { files, emptyDirs, directoriesScanned, warnings };
467
496
  function walkDir(rel) {
@@ -479,6 +508,10 @@ function walk(rootDir, opts) {
479
508
  const posix = posixOf(childRel);
480
509
  const isDir = ent.isDirectory();
481
510
  if (isAlwaysExcluded(posix)) continue;
511
+ if (excludePaths.length > 0 && isUnderAny(posix, excludePaths)) {
512
+ hasVisible = true;
513
+ continue;
514
+ }
482
515
  if (opts.ignore.ignores(posix + (isDir ? "/" : ""))) continue;
483
516
  if (ent.isSymbolicLink()) {
484
517
  warnings.push({ path: posix, reason: "symlink" });
@@ -537,9 +570,33 @@ async function hashFile(absPath) {
537
570
  import { createHash as createHash2 } from "crypto";
538
571
  import { readFileSync as readFileSync3 } from "fs";
539
572
  import { join as join4 } from "path";
573
+
574
+ // src/pool.ts
575
+ async function mapPool(items, limit, fn) {
576
+ let next = 0;
577
+ let failed = false;
578
+ const worker = async () => {
579
+ while (!failed) {
580
+ const idx = next++;
581
+ if (idx >= items.length) break;
582
+ const item = items[idx];
583
+ try {
584
+ await fn(item);
585
+ } catch (err) {
586
+ failed = true;
587
+ throw err;
588
+ }
589
+ }
590
+ };
591
+ const n = Math.max(1, Math.min(limit, items.length));
592
+ await Promise.all(Array.from({ length: n }, worker));
593
+ }
594
+
595
+ // src/init.ts
540
596
  function sha256OfBuffer(bytes) {
541
597
  return `sha256:${createHash2("sha256").update(bytes).digest("hex")}`;
542
598
  }
599
+ var UPLOAD_CONCURRENCY = 8;
543
600
  var AlreadyInitializedError = class extends Error {
544
601
  constructor(path) {
545
602
  super(`already initialized \u2014 ${path} exists`);
@@ -584,27 +641,27 @@ async function init(opts) {
584
641
  const mutatedDuringScan = /* @__PURE__ */ new Set();
585
642
  let uploaded = 0;
586
643
  let reused = 0;
587
- for (const [hash, f] of uniqueHashes) {
588
- const prep = await client.prepareUpload({ hash, size: f.size });
644
+ await mapPool([...uniqueHashes.values()], UPLOAD_CONCURRENCY, async (f) => {
645
+ const prep = await client.prepareUpload({ hash: f.hash, size: f.size });
589
646
  if (prep.exists) {
590
647
  reused += 1;
591
- continue;
648
+ return;
592
649
  }
593
650
  const bytes = readFileSync3(join4(opts.rootDir, f.path));
594
651
  const actual = sha256OfBuffer(bytes);
595
- if (actual !== hash) {
652
+ if (actual !== f.hash) {
596
653
  mutatedDuringScan.add(f.path);
597
654
  scan.warnings.push({
598
655
  path: f.path,
599
656
  reason: "invalid_path"
600
657
  // closest existing warning kind; semantics are file-mutated-mid-scan
601
658
  });
602
- continue;
659
+ return;
603
660
  }
604
661
  await client.putObjectBytes(prep.uploadUrl, bytes);
605
- await client.completeUpload({ hash, size: f.size });
662
+ await client.completeUpload({ hash: f.hash, size: f.size });
606
663
  uploaded += 1;
607
- }
664
+ });
608
665
  const events = [
609
666
  ...hashed.filter((f) => !mutatedDuringScan.has(f.path)).map((f) => ({
610
667
  op: "write",
@@ -756,20 +813,347 @@ async function join5(opts) {
756
813
  };
757
814
  }
758
815
 
759
- // src/applier.ts
760
- import { createHash as createHash3, randomBytes } from "crypto";
816
+ // src/imports.ts
761
817
  import {
762
818
  existsSync as existsSync3,
763
- lstatSync,
764
819
  mkdirSync as mkdirSync3,
765
820
  readdirSync as readdirSync2,
766
821
  readFileSync as readFileSync4,
822
+ realpathSync,
823
+ statSync as statSync2,
824
+ writeFileSync as writeFileSync2
825
+ } from "fs";
826
+ import { dirname as dirname3, isAbsolute, join as join6, relative } from "path";
827
+ var NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
828
+ function validateImportName(name) {
829
+ if (!name) return "empty_name";
830
+ if (!NAME_RE.test(name)) {
831
+ return "invalid_name (must match ^[a-z0-9][a-z0-9_-]*$ \u2014 lowercase slug, no dots/slashes/..)";
832
+ }
833
+ return null;
834
+ }
835
+ function normalizeImportPath(p) {
836
+ return p.replace(/\\/g, "/").split("/").filter((s) => s !== "" && s !== ".").join("/");
837
+ }
838
+ function validateImportPath(rootDir, importPath) {
839
+ if (!importPath) return "empty_path";
840
+ if (isAbsolute(importPath)) return "absolute_path";
841
+ const norm = normalizeImportPath(importPath);
842
+ if (norm === "" || norm === ".") return "root_path (cannot mount over the rig root)";
843
+ const segs = norm.split("/");
844
+ if (segs.some((s) => s === "..")) return "dotdot_in_path";
845
+ if (segs.some((s) => s === "")) return "empty_segment";
846
+ if (segs[0] === ".rig") return "reserved_rig_path";
847
+ const realRoot = realpathSync(rootDir);
848
+ const abs = join6(realRoot, norm);
849
+ if (!isWithin(realRoot, abs)) return "escapes_root";
850
+ const realMount = realpathNearestExisting(abs);
851
+ if (!isWithin(realRoot, realMount)) return "symlink_escape";
852
+ const fileAncestor = firstExistingFileOnPath(abs);
853
+ if (fileAncestor) {
854
+ return fileAncestor === abs ? "mount_path_is_file" : "ancestor_is_file";
855
+ }
856
+ return null;
857
+ }
858
+ function validateImports(opts) {
859
+ const valid = [];
860
+ const errors = [];
861
+ const seenNames = /* @__PURE__ */ new Set();
862
+ const acceptedPaths = [];
863
+ for (const imp of opts.imports) {
864
+ const reject = (reason) => errors.push({ name: imp.name, path: imp.path, reason });
865
+ const nameErr = validateImportName(imp.name);
866
+ if (nameErr) {
867
+ reject(nameErr);
868
+ continue;
869
+ }
870
+ if (seenNames.has(imp.name)) {
871
+ reject("duplicate_name");
872
+ continue;
873
+ }
874
+ const pathErr = validateImportPath(opts.rootDir, imp.path);
875
+ if (pathErr) {
876
+ reject(pathErr);
877
+ continue;
878
+ }
879
+ const norm = normalizeImportPath(imp.path);
880
+ if (acceptedPaths.some((p) => pathsOverlap(p, norm))) {
881
+ reject("overlaps_other_import");
882
+ continue;
883
+ }
884
+ if (opts.primaryTrackedPaths.some((tp) => tp === norm || tp.startsWith(norm + "/"))) {
885
+ reject("over_primary_content");
886
+ continue;
887
+ }
888
+ seenNames.add(imp.name);
889
+ acceptedPaths.push(norm);
890
+ valid.push({ ...imp, path: norm });
891
+ }
892
+ return { valid, errors };
893
+ }
894
+ function primaryStateDbPath(rootDir) {
895
+ return join6(rootDir, ".rig", "tap", "state.local.db");
896
+ }
897
+ function importStateDbPath(rootDir, name) {
898
+ return join6(rootDir, ".rig", "tap", "imports", name, "state.local.db");
899
+ }
900
+ function importsDir(rootDir) {
901
+ return join6(rootDir, ".rig", "tap", "imports");
902
+ }
903
+ function importMountFile(rootDir, name) {
904
+ return join6(importsDir(rootDir), `${name}.local.json`);
905
+ }
906
+ var MOUNT_FILE_SUFFIX = ".local.json";
907
+ function writeImportMount(rootDir, name, mount) {
908
+ const path = importMountFile(rootDir, name);
909
+ mkdirSync3(dirname3(path), { recursive: true });
910
+ writeFileSync2(path, JSON.stringify(mount, null, 2) + "\n", { mode: 384 });
911
+ }
912
+ function readImportMount(rootDir, name) {
913
+ const file = importMountFile(rootDir, name);
914
+ if (!existsSync3(file)) return { ok: false, reason: "missing_token_file" };
915
+ let parsed;
916
+ try {
917
+ parsed = JSON.parse(readFileSync4(file, "utf8"));
918
+ } catch {
919
+ return { ok: false, reason: "malformed_json" };
920
+ }
921
+ if (typeof parsed.bindingId !== "string" || parsed.bindingId === "") {
922
+ return { ok: false, reason: "missing_bindingId" };
923
+ }
924
+ if (typeof parsed.relayUrl !== "string" || parsed.relayUrl === "") {
925
+ return { ok: false, reason: "missing_relayUrl" };
926
+ }
927
+ if (typeof parsed.token !== "string" || parsed.token === "") {
928
+ return { ok: false, reason: "missing_token" };
929
+ }
930
+ if (typeof parsed.path !== "string" || parsed.path === "") {
931
+ return { ok: false, reason: "missing_path" };
932
+ }
933
+ const ops = Array.isArray(parsed.ops) ? parsed.ops : [];
934
+ if (!ops.includes("read") || !ops.includes("subscribe")) {
935
+ return { ok: false, reason: "insufficient_ops (need read+subscribe)" };
936
+ }
937
+ return {
938
+ ok: true,
939
+ spec: {
940
+ name,
941
+ bindingId: parsed.bindingId,
942
+ relayUrl: parsed.relayUrl,
943
+ token: parsed.token,
944
+ path: parsed.path
945
+ }
946
+ };
947
+ }
948
+ function loadImportMounts(rootDir) {
949
+ const dir = importsDir(rootDir);
950
+ const ready = [];
951
+ const broken = [];
952
+ if (!existsSync3(dir)) return { ready, broken };
953
+ for (const ent of readdirSync2(dir, { withFileTypes: true })) {
954
+ if (!ent.isFile() || !ent.name.endsWith(MOUNT_FILE_SUFFIX)) continue;
955
+ const name = ent.name.slice(0, -MOUNT_FILE_SUFFIX.length);
956
+ const nameErr = validateImportName(name);
957
+ if (nameErr) {
958
+ broken.push({ name, path: "", reason: nameErr });
959
+ continue;
960
+ }
961
+ const res = readImportMount(rootDir, name);
962
+ if (!res.ok) {
963
+ broken.push({ name, path: "", reason: res.reason });
964
+ continue;
965
+ }
966
+ ready.push(res.spec);
967
+ }
968
+ return { ready, broken };
969
+ }
970
+ function isWithin(parent, child) {
971
+ const rel = relative(parent, child);
972
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
973
+ }
974
+ function realpathNearestExisting(abs) {
975
+ let cur = abs;
976
+ while (!existsSync3(cur)) {
977
+ const parent = dirname3(cur);
978
+ if (parent === cur) break;
979
+ cur = parent;
980
+ }
981
+ const realExisting = realpathSync(cur);
982
+ const remainder = relative(cur, abs);
983
+ return remainder ? join6(realExisting, remainder) : realExisting;
984
+ }
985
+ function firstExistingFileOnPath(abs) {
986
+ let cur = abs;
987
+ for (; ; ) {
988
+ if (existsSync3(cur)) {
989
+ return statSync2(cur).isFile() ? cur : null;
990
+ }
991
+ const parent = dirname3(cur);
992
+ if (parent === cur) return null;
993
+ cur = parent;
994
+ }
995
+ }
996
+ function pathsOverlap(a, b) {
997
+ return a === b || a.startsWith(b + "/") || b.startsWith(a + "/");
998
+ }
999
+
1000
+ // src/import-invite.ts
1001
+ import { existsSync as existsSync4, readFileSync as readFileSync5, rmSync } from "fs";
1002
+ import { dirname as dirname4, join as joinPath2 } from "path";
1003
+ var ImportValidationFailed = class extends Error {
1004
+ constructor(reason) {
1005
+ super(`import rejected: ${reason}`);
1006
+ this.reason = reason;
1007
+ this.name = "ImportValidationFailed";
1008
+ }
1009
+ reason;
1010
+ };
1011
+ var ImportTokenScopeError = class extends Error {
1012
+ constructor(ops) {
1013
+ super(
1014
+ ops ? `invite token has ops [${ops.join(", ")}] \u2014 a mirror needs read+subscribe` : `invite can't drive a mirror \u2014 it needs read+subscribe`
1015
+ );
1016
+ this.ops = ops;
1017
+ this.name = "ImportTokenScopeError";
1018
+ }
1019
+ ops;
1020
+ };
1021
+ function isRecord(v) {
1022
+ return typeof v === "object" && v !== null;
1023
+ }
1024
+ function deriveName(canonicalPath) {
1025
+ const segs = canonicalPath.split("/").filter(Boolean);
1026
+ return segs[segs.length - 1] ?? "";
1027
+ }
1028
+ function readPrimaryTrackedPaths(rootDir) {
1029
+ const dbPath = primaryStateDbPath(rootDir);
1030
+ if (!existsSync4(dbPath)) return [];
1031
+ const db = openStateDb(dbPath);
1032
+ try {
1033
+ return db.listPaths();
1034
+ } finally {
1035
+ db.close();
1036
+ }
1037
+ }
1038
+ async function addImport(opts) {
1039
+ const path = normalizeImportPath(opts.path);
1040
+ const name = opts.name && opts.name.length > 0 ? opts.name : deriveName(path);
1041
+ const existing = loadImportMounts(opts.rootDir).ready;
1042
+ const candidate = { name, bindingId: "", relayUrl: "", token: "", path };
1043
+ const { valid, errors } = validateImports({
1044
+ rootDir: opts.rootDir,
1045
+ imports: [...existing, candidate],
1046
+ primaryTrackedPaths: readPrimaryTrackedPaths(opts.rootDir)
1047
+ });
1048
+ if (!valid.some((v) => v.name === name && v.path === path)) {
1049
+ const err = errors.find((e) => e.name === name);
1050
+ throw new ImportValidationFailed(err?.reason ?? "invalid_import");
1051
+ }
1052
+ const primaryBindingId = readBindingConfig(opts.rootDir)?.bindingId;
1053
+ const { baseUrl, secret } = parseInviteUrl(opts.inviteUrl);
1054
+ let accepted;
1055
+ try {
1056
+ accepted = await acceptInvite({
1057
+ baseUrl,
1058
+ secret,
1059
+ accessToken: opts.accessToken ?? null,
1060
+ request: {
1061
+ ...opts.deviceLabel ? { deviceLabel: opts.deviceLabel } : {},
1062
+ importer: { ...primaryBindingId ? { bindingId: primaryBindingId } : {}, path }
1063
+ },
1064
+ fetch: opts.fetch
1065
+ });
1066
+ } catch (err) {
1067
+ if (err instanceof RelayError && isRecord(err.body) && err.body.reason === "import_requires_read_subscribe") {
1068
+ throw new ImportTokenScopeError();
1069
+ }
1070
+ throw err;
1071
+ }
1072
+ const ops = accepted.token.ops;
1073
+ if (!ops.includes("read") || !ops.includes("subscribe")) {
1074
+ throw new ImportTokenScopeError(ops);
1075
+ }
1076
+ writeImportMount(opts.rootDir, name, {
1077
+ bindingId: accepted.bindingId,
1078
+ relayUrl: baseUrl,
1079
+ token: accepted.token.secret,
1080
+ ops,
1081
+ path,
1082
+ ...accepted.importEdgeId ? { edgeId: accepted.importEdgeId } : {}
1083
+ });
1084
+ return {
1085
+ name,
1086
+ path,
1087
+ bindingId: accepted.bindingId,
1088
+ ops,
1089
+ ...accepted.importEdgeId ? { edgeId: accepted.importEdgeId } : {},
1090
+ // Only meaningful when we actually sent a binding to verify.
1091
+ ...primaryBindingId !== void 0 ? { importerBindingVerified: accepted.importerBindingVerified ?? false } : {}
1092
+ };
1093
+ }
1094
+ function readRawMount(rootDir, name) {
1095
+ const file = importMountFile(rootDir, name);
1096
+ if (!existsSync4(file)) return null;
1097
+ try {
1098
+ return JSON.parse(readFileSync5(file, "utf8"));
1099
+ } catch {
1100
+ return null;
1101
+ }
1102
+ }
1103
+ async function removeImport(opts) {
1104
+ const mountPath = importMountFile(opts.rootDir, opts.name);
1105
+ const res = readImportMount(opts.rootDir, opts.name);
1106
+ const raw = readRawMount(opts.rootDir, opts.name);
1107
+ const path = res.ok ? res.spec.path : raw?.path ?? null;
1108
+ let revoke2;
1109
+ if (!raw?.edgeId || !raw.relayUrl) {
1110
+ revoke2 = "no-edge";
1111
+ } else if (!opts.accessToken) {
1112
+ revoke2 = "no-token";
1113
+ } else {
1114
+ try {
1115
+ const r = await revokeImportEdge({
1116
+ baseUrl: raw.relayUrl,
1117
+ edgeId: raw.edgeId,
1118
+ accessToken: opts.accessToken,
1119
+ fetch: opts.fetch
1120
+ });
1121
+ revoke2 = r.revoked ? "revoked" : r.status === 403 ? "not-creator" : r.status === 404 ? "gone" : "offline";
1122
+ } catch {
1123
+ revoke2 = "offline";
1124
+ }
1125
+ }
1126
+ let removed = false;
1127
+ if (existsSync4(mountPath)) {
1128
+ rmSync(mountPath, { force: true });
1129
+ removed = true;
1130
+ }
1131
+ rmSync(dirname4(importStateDbPath(opts.rootDir, opts.name)), {
1132
+ recursive: true,
1133
+ force: true
1134
+ });
1135
+ let purged = false;
1136
+ if (opts.purge && path) {
1137
+ rmSync(joinPath2(opts.rootDir, path), { recursive: true, force: true });
1138
+ purged = true;
1139
+ }
1140
+ return { removed, path, purged, revoke: revoke2 };
1141
+ }
1142
+
1143
+ // src/applier.ts
1144
+ import { createHash as createHash3, randomBytes } from "crypto";
1145
+ import {
1146
+ existsSync as existsSync5,
1147
+ lstatSync,
1148
+ mkdirSync as mkdirSync4,
1149
+ readdirSync as readdirSync3,
1150
+ readFileSync as readFileSync6,
767
1151
  renameSync,
768
1152
  rmdirSync,
769
- rmSync,
770
- writeFileSync as writeFileSync2
1153
+ rmSync as rmSync2,
1154
+ writeFileSync as writeFileSync3
771
1155
  } from "fs";
772
- import { basename, dirname as dirname3, extname, join as join6, sep as sep2 } from "path";
1156
+ import { basename, dirname as dirname5, extname, join as join7, sep as sep2 } from "path";
773
1157
  var STATE_KEY_CONFLICT_COUNT = "conflict_count";
774
1158
  var HashMismatchError = class extends Error {
775
1159
  constructor(path, expectedHash, actualHash) {
@@ -789,10 +1173,10 @@ function sha256Hex(bytes) {
789
1173
  return createHash3("sha256").update(bytes).digest("hex");
790
1174
  }
791
1175
  function sha256OfFile(absPath) {
792
- return `sha256:${sha256Hex(readFileSync4(absPath))}`;
1176
+ return `sha256:${sha256Hex(readFileSync6(absPath))}`;
793
1177
  }
794
1178
  function conflictSidecarPath(path, ev) {
795
- const dir = dirname3(path);
1179
+ const dir = dirname5(path);
796
1180
  const ext = extname(path);
797
1181
  const stem = basename(path, ext);
798
1182
  const actor = ev.actorDeviceId ?? "unknown";
@@ -811,16 +1195,16 @@ function findConflictSidecars(rootDir) {
811
1195
  walk2(".");
812
1196
  return out;
813
1197
  function walk2(rel) {
814
- const abs = rel === "." ? rootDir : join6(rootDir, rel);
1198
+ const abs = rel === "." ? rootDir : join7(rootDir, rel);
815
1199
  let entries;
816
1200
  try {
817
- entries = readdirSync2(abs, { withFileTypes: true });
1201
+ entries = readdirSync3(abs, { withFileTypes: true });
818
1202
  } catch {
819
1203
  return;
820
1204
  }
821
1205
  for (const ent of entries) {
822
1206
  if (ent.isSymbolicLink()) continue;
823
- const childRel = rel === "." ? ent.name : join6(rel, ent.name);
1207
+ const childRel = rel === "." ? ent.name : join7(rel, ent.name);
824
1208
  if (ent.isDirectory()) {
825
1209
  if (SCAN_SKIP_DIRS.has(ent.name)) continue;
826
1210
  walk2(childRel);
@@ -830,7 +1214,7 @@ function findConflictSidecars(rootDir) {
830
1214
  const parsed = parseConflictSidecar(ent.name);
831
1215
  if (!parsed) continue;
832
1216
  const sidecarPosix = sep2 === "/" ? childRel : childRel.replaceAll(sep2, "/");
833
- const dir = dirname3(sidecarPosix);
1217
+ const dir = dirname5(sidecarPosix);
834
1218
  const original = dir === "." ? `${parsed.stem}${parsed.ext}` : `${dir}/${parsed.stem}${parsed.ext}`;
835
1219
  out.push({ path: original, sidecarPath: sidecarPosix });
836
1220
  }
@@ -846,6 +1230,7 @@ function bumpConflictCount(stateDb) {
846
1230
  var STATE_KEY_LAST_APPLY_ERROR = "last_apply_error";
847
1231
  var STATE_KEY_LAST_APPLY_ERROR_AT = "last_apply_error_at";
848
1232
  async function applyOnce(opts) {
1233
+ const mode = opts.mode ?? "primary";
849
1234
  const cursor = opts.stateDb.getCursor();
850
1235
  const since = await opts.client.listChanges({ after: cursor, limit: opts.limit });
851
1236
  let applied = 0;
@@ -858,7 +1243,8 @@ async function applyOnce(opts) {
858
1243
  rootDir: opts.rootDir,
859
1244
  client: opts.client,
860
1245
  stateDb: opts.stateDb,
861
- event: ev
1246
+ event: ev,
1247
+ mode
862
1248
  });
863
1249
  if (wasEcho) echoSkipped += 1;
864
1250
  else applied += 1;
@@ -883,13 +1269,14 @@ async function applyOnce(opts) {
883
1269
  return { applied, echoSkipped, errored, events: since.events, errors };
884
1270
  }
885
1271
  async function applyEvent(opts) {
886
- const { rootDir, client, stateDb, event: ev } = opts;
887
- const abs = join6(rootDir, ev.path);
1272
+ const { rootDir, client, stateDb, event: ev, mode } = opts;
1273
+ const mirror = mode === "mirror";
1274
+ const abs = join7(rootDir, ev.path);
888
1275
  switch (ev.op) {
889
1276
  case "write": {
890
1277
  if (!ev.hash) throw new Error(`write event ${ev.id} missing hash`);
891
1278
  const local = stateDb.getPath(ev.path);
892
- if (local?.lastSeenHash === ev.hash && existsSync3(abs)) {
1279
+ if (local?.lastSeenHash === ev.hash && existsSync5(abs)) {
893
1280
  stateDb.upsertPath({
894
1281
  path: ev.path,
895
1282
  lastAppliedChangeId: ev.id,
@@ -899,12 +1286,15 @@ async function applyEvent(opts) {
899
1286
  });
900
1287
  return true;
901
1288
  }
902
- if (existsSync3(abs) && lstatSync(abs).isDirectory()) {
903
- throw new Error(
904
- `file_vs_dir_collision: ${ev.path} is a local directory; refusing to overwrite with a file`
905
- );
1289
+ if (existsSync5(abs) && lstatSync(abs).isDirectory()) {
1290
+ if (!mirror) {
1291
+ throw new Error(
1292
+ `file_vs_dir_collision: ${ev.path} is a local directory; refusing to overwrite with a file`
1293
+ );
1294
+ }
1295
+ rmSync2(abs, { recursive: true, force: true });
906
1296
  }
907
- const localDirty = existsSync3(abs) && local?.lastSeenHash != null && sha256OfFile(abs) !== local.lastSeenHash;
1297
+ const localDirty = !mirror && existsSync5(abs) && local?.lastSeenHash != null && sha256OfFile(abs) !== local.lastSeenHash;
908
1298
  const dl = await client.downloadUrl(ev.hash);
909
1299
  const bytes = await client.getObjectBytes(dl.downloadUrl);
910
1300
  const actualHash = `sha256:${sha256Hex(bytes)}`;
@@ -913,10 +1303,10 @@ async function applyEvent(opts) {
913
1303
  }
914
1304
  if (localDirty) {
915
1305
  const sidecarPath = conflictSidecarPath(ev.path, ev);
916
- const sidecarAbs = join6(rootDir, sidecarPath);
917
- mkdirSync3(dirname3(sidecarAbs), { recursive: true });
1306
+ const sidecarAbs = join7(rootDir, sidecarPath);
1307
+ mkdirSync4(dirname5(sidecarAbs), { recursive: true });
918
1308
  const tmp2 = `${sidecarAbs}.tap-tmp.${randomBytes(6).toString("hex")}`;
919
- writeFileSync2(tmp2, bytes, { mode: ev.executable ? 493 : 420 });
1309
+ writeFileSync3(tmp2, bytes, { mode: ev.executable ? 493 : 420 });
920
1310
  renameSync(tmp2, sidecarAbs);
921
1311
  stateDb.upsertPath({
922
1312
  path: ev.path,
@@ -928,9 +1318,9 @@ async function applyEvent(opts) {
928
1318
  bumpConflictCount(stateDb);
929
1319
  return false;
930
1320
  }
931
- mkdirSync3(dirname3(abs), { recursive: true });
1321
+ mkdirSync4(dirname5(abs), { recursive: true });
932
1322
  const tmp = `${abs}.tap-tmp.${randomBytes(6).toString("hex")}`;
933
- writeFileSync2(tmp, bytes, { mode: ev.executable ? 493 : 420 });
1323
+ writeFileSync3(tmp, bytes, { mode: ev.executable ? 493 : 420 });
934
1324
  renameSync(tmp, abs);
935
1325
  stateDb.upsertPath({
936
1326
  path: ev.path,
@@ -942,36 +1332,43 @@ async function applyEvent(opts) {
942
1332
  return false;
943
1333
  }
944
1334
  case "delete": {
945
- if (existsSync3(abs)) {
1335
+ if (existsSync5(abs)) {
946
1336
  if (lstatSync(abs).isDirectory()) {
947
- throw new Error(
948
- `delete_vs_dir: ${ev.path} is a local directory; refusing recursive removal`
949
- );
950
- }
951
- const localStateRow = stateDb.getPath(ev.path);
952
- const localBytesHash = sha256OfFile(abs);
953
- const localDirty = localStateRow?.lastSeenHash != null && localBytesHash !== localStateRow.lastSeenHash;
954
- const localUntracked = localStateRow?.lastSeenHash == null;
955
- if (localDirty || localUntracked) {
956
- stateDb.upsertPath({
957
- path: ev.path,
958
- lastAppliedChangeId: ev.id,
959
- // null so scanAndPush's `prev?.lastSeenHash === hash` check
960
- // falls through and the file gets re-submitted as a write.
961
- lastSeenHash: null,
962
- localDirty: true,
963
- lastScanAt: (/* @__PURE__ */ new Date()).toISOString()
964
- });
965
- bumpConflictCount(stateDb);
1337
+ if (!mirror) {
1338
+ throw new Error(
1339
+ `delete_vs_dir: ${ev.path} is a local directory; refusing recursive removal`
1340
+ );
1341
+ }
1342
+ rmSync2(abs, { recursive: true, force: true });
1343
+ stateDb.deletePath(ev.path);
966
1344
  return false;
967
1345
  }
968
- rmSync(abs, { force: true });
1346
+ if (!mirror) {
1347
+ const localStateRow = stateDb.getPath(ev.path);
1348
+ const localBytesHash = sha256OfFile(abs);
1349
+ const localDirty = localStateRow?.lastSeenHash != null && localBytesHash !== localStateRow.lastSeenHash;
1350
+ const localUntracked = localStateRow?.lastSeenHash == null;
1351
+ if (localDirty || localUntracked) {
1352
+ stateDb.upsertPath({
1353
+ path: ev.path,
1354
+ lastAppliedChangeId: ev.id,
1355
+ // null so scanAndPush's `prev?.lastSeenHash === hash` check
1356
+ // falls through and the file gets re-submitted as a write.
1357
+ lastSeenHash: null,
1358
+ localDirty: true,
1359
+ lastScanAt: (/* @__PURE__ */ new Date()).toISOString()
1360
+ });
1361
+ bumpConflictCount(stateDb);
1362
+ return false;
1363
+ }
1364
+ }
1365
+ rmSync2(abs, { force: true });
969
1366
  }
970
1367
  stateDb.deletePath(ev.path);
971
1368
  return false;
972
1369
  }
973
1370
  case "mkdir": {
974
- mkdirSync3(abs, { recursive: true });
1371
+ mkdirSync4(abs, { recursive: true });
975
1372
  stateDb.upsertPath({
976
1373
  path: ev.path,
977
1374
  lastAppliedChangeId: ev.id,
@@ -982,9 +1379,13 @@ async function applyEvent(opts) {
982
1379
  return false;
983
1380
  }
984
1381
  case "rmdir": {
985
- try {
986
- rmdirSync(abs);
987
- } catch {
1382
+ if (mirror) {
1383
+ rmSync2(abs, { recursive: true, force: true });
1384
+ } else {
1385
+ try {
1386
+ rmdirSync(abs);
1387
+ } catch {
1388
+ }
988
1389
  }
989
1390
  stateDb.deletePath(ev.path);
990
1391
  return false;
@@ -1130,19 +1531,120 @@ function asError(err, fallbackName) {
1130
1531
  return e;
1131
1532
  }
1132
1533
 
1534
+ // src/subscription.ts
1535
+ var DEFAULT_POLL_SECONDS = 3;
1536
+ var SSE_APPLY_DEBOUNCE_MS = 50;
1537
+ async function startSubscription(opts) {
1538
+ const { mode } = opts;
1539
+ const stateDb = openStateDb(opts.stateDbPath);
1540
+ const client = new RelayClient({
1541
+ baseUrl: opts.config.relayUrl,
1542
+ bindingId: opts.config.bindingId,
1543
+ token: opts.config.token,
1544
+ fetch: opts.fetch
1545
+ });
1546
+ const pollSeconds = opts.pollSeconds ?? DEFAULT_POLL_SECONDS;
1547
+ let stopped = false;
1548
+ let pollTimer;
1549
+ let sseApplyTimer;
1550
+ let sseSubscriber;
1551
+ let applyInFlight = null;
1552
+ let rerunRequested = false;
1553
+ let lastApplyResult;
1554
+ const triggerApply = () => {
1555
+ if (applyInFlight) {
1556
+ rerunRequested = true;
1557
+ return applyInFlight;
1558
+ }
1559
+ const loop = (async () => {
1560
+ try {
1561
+ do {
1562
+ rerunRequested = false;
1563
+ try {
1564
+ lastApplyResult = await applyOnce({
1565
+ rootDir: opts.rootDir,
1566
+ client,
1567
+ stateDb,
1568
+ mode
1569
+ });
1570
+ } catch (err) {
1571
+ console.error("tapd apply error:", err);
1572
+ }
1573
+ } while (rerunRequested && !stopped);
1574
+ return lastApplyResult;
1575
+ } finally {
1576
+ applyInFlight = null;
1577
+ }
1578
+ })();
1579
+ applyInFlight = loop;
1580
+ return loop;
1581
+ };
1582
+ const schedulePoll = () => {
1583
+ if (stopped) return;
1584
+ pollTimer = setTimeout(() => {
1585
+ triggerApply().finally(() => schedulePoll());
1586
+ }, pollSeconds * 1e3);
1587
+ };
1588
+ const scheduleSseApply = () => {
1589
+ if (stopped) return;
1590
+ if (sseApplyTimer) clearTimeout(sseApplyTimer);
1591
+ sseApplyTimer = setTimeout(() => {
1592
+ sseApplyTimer = void 0;
1593
+ void triggerApply();
1594
+ }, SSE_APPLY_DEBOUNCE_MS);
1595
+ };
1596
+ if (!opts.noSse) {
1597
+ sseSubscriber = subscribe({
1598
+ baseUrl: opts.config.relayUrl,
1599
+ bindingId: opts.config.bindingId,
1600
+ token: opts.config.token,
1601
+ fetch: opts.fetch,
1602
+ heartbeatTimeoutMs: opts.sseHeartbeatTimeoutMs,
1603
+ onChange: () => scheduleSseApply(),
1604
+ onError: (err) => {
1605
+ console.error("tapd sse:", err.message);
1606
+ }
1607
+ });
1608
+ }
1609
+ await triggerApply();
1610
+ schedulePoll();
1611
+ return {
1612
+ async stop() {
1613
+ stopped = true;
1614
+ if (pollTimer) clearTimeout(pollTimer);
1615
+ if (sseApplyTimer) clearTimeout(sseApplyTimer);
1616
+ if (sseSubscriber) await sseSubscriber.stop();
1617
+ await Promise.allSettled([applyInFlight ?? Promise.resolve()]);
1618
+ stateDb.close();
1619
+ },
1620
+ async applyNow() {
1621
+ const result = await triggerApply();
1622
+ return result ?? {
1623
+ applied: 0,
1624
+ echoSkipped: 0,
1625
+ errored: 0,
1626
+ events: [],
1627
+ errors: []
1628
+ };
1629
+ },
1630
+ stateDb
1631
+ };
1632
+ }
1633
+
1133
1634
  // src/uploader.ts
1134
1635
  import { createHash as createHash4 } from "crypto";
1135
- import { readFileSync as readFileSync5 } from "fs";
1136
- import { join as join7 } from "path";
1636
+ import { readFileSync as readFileSync7 } from "fs";
1637
+ import { join as join8 } from "path";
1638
+ var UPLOAD_CONCURRENCY2 = 8;
1137
1639
  function sha256OfBuffer2(bytes) {
1138
1640
  return `sha256:${createHash4("sha256").update(bytes).digest("hex")}`;
1139
1641
  }
1140
1642
  async function computeLocalDiff(opts) {
1141
- const { rootDir, stateDb, ignore: ignore2 } = opts;
1142
- const scan = walk(rootDir, { ignore: ignore2 });
1643
+ const { rootDir, stateDb, ignore: ignore2, excludePaths } = opts;
1644
+ const scan = walk(rootDir, { ignore: ignore2, excludePaths });
1143
1645
  const writes = [];
1144
1646
  for (const f of scan.files) {
1145
- const hash = await hashFile(join7(rootDir, f.path));
1647
+ const hash = await hashFile(join8(rootDir, f.path));
1146
1648
  const prev = stateDb.getPath(f.path);
1147
1649
  if (prev?.lastSeenHash === hash) continue;
1148
1650
  const baseChangeId = prev?.lastAppliedChangeId ?? void 0;
@@ -1205,23 +1707,23 @@ async function scanAndPush(opts) {
1205
1707
  const skipWarnings = [];
1206
1708
  let uploaded = 0;
1207
1709
  let reused = 0;
1208
- for (const [hash, f] of uniqueHashes) {
1209
- const prep = await client.prepareUpload({ hash, size: f.size });
1710
+ await mapPool([...uniqueHashes.values()], UPLOAD_CONCURRENCY2, async (f) => {
1711
+ const prep = await client.prepareUpload({ hash: f.hash, size: f.size });
1210
1712
  if (prep.exists) {
1211
1713
  reused += 1;
1212
- continue;
1714
+ return;
1213
1715
  }
1214
- const bytes = readFileSync5(join7(rootDir, f.path));
1716
+ const bytes = readFileSync7(join8(rootDir, f.path));
1215
1717
  const actual = sha256OfBuffer2(bytes);
1216
- if (actual !== hash) {
1718
+ if (actual !== f.hash) {
1217
1719
  mutatedPaths.add(f.path);
1218
1720
  skipWarnings.push({ path: f.path, reason: `mutated_during_scan:${actual}` });
1219
- continue;
1721
+ return;
1220
1722
  }
1221
1723
  await client.putObjectBytes(prep.uploadUrl, bytes);
1222
- await client.completeUpload({ hash, size: f.size });
1724
+ await client.completeUpload({ hash: f.hash, size: f.size });
1223
1725
  uploaded += 1;
1224
- }
1726
+ });
1225
1727
  const events = [];
1226
1728
  for (const w of diff.writes) {
1227
1729
  if (mutatedPaths.has(w.path)) continue;
@@ -1291,152 +1793,183 @@ function cursorLessThan(a, b) {
1291
1793
  }
1292
1794
 
1293
1795
  // src/start.ts
1294
- import { join as join8 } from "path";
1796
+ import { join as join9 } from "path";
1295
1797
  import chokidar from "chokidar";
1296
- var DEFAULT_POLL_SECONDS = 3;
1798
+ var DEFAULT_POLL_SECONDS2 = 3;
1297
1799
  var DEFAULT_DEBOUNCE_MS = 300;
1800
+ var NOOP_APPLY = {
1801
+ applied: 0,
1802
+ echoSkipped: 0,
1803
+ errored: 0,
1804
+ events: [],
1805
+ errors: []
1806
+ };
1807
+ var NOOP_SCAN = {
1808
+ uploaded: 0,
1809
+ reused: 0,
1810
+ submitted: [],
1811
+ warnings: []
1812
+ };
1298
1813
  async function start(opts) {
1299
1814
  const config = readBindingConfig(opts.rootDir);
1300
- if (!config) {
1815
+ let importSpecs;
1816
+ let loaderBroken = [];
1817
+ if (opts.imports) {
1818
+ importSpecs = opts.imports;
1819
+ } else {
1820
+ const loaded = loadImportMounts(opts.rootDir);
1821
+ importSpecs = loaded.ready;
1822
+ loaderBroken = loaded.broken;
1823
+ }
1824
+ for (const e of loaderBroken) {
1825
+ console.error(`tapd import "${e.name}": skipped \u2014 ${e.reason}`);
1826
+ }
1827
+ if (!config && importSpecs.length === 0) {
1301
1828
  throw new Error(
1302
- `no binding config at ${opts.rootDir}/.rig/tap-binding.local.json \u2014 run \`tapd init\` first`
1829
+ `no binding config at ${opts.rootDir}/.rig/tap-binding.local.json and no imports \u2014 run \`tapd init\` first`
1303
1830
  );
1304
1831
  }
1305
- const stateDb = openStateDb(
1306
- join8(opts.rootDir, ".rig", "tap", "state.local.db")
1307
- );
1308
- const ignore2 = loadIgnore(opts.rootDir);
1309
- const client = new RelayClient({
1310
- baseUrl: config.relayUrl,
1311
- bindingId: config.bindingId,
1312
- token: config.token,
1313
- fetch: opts.fetch
1314
- });
1315
- const pollSeconds = opts.pollSeconds ?? DEFAULT_POLL_SECONDS;
1832
+ const pollSeconds = opts.pollSeconds ?? DEFAULT_POLL_SECONDS2;
1316
1833
  const debounceMs = opts.debounceMs ?? DEFAULT_DEBOUNCE_MS;
1317
1834
  let stopped = false;
1318
- let pollTimer;
1319
- let scanTimer;
1320
- let sseApplyTimer;
1835
+ let primary;
1321
1836
  let watcher;
1837
+ let scanTimer;
1322
1838
  let scanInFlight = Promise.resolve();
1323
- let sseSubscriber;
1324
- let applyInFlight = null;
1325
- let rerunRequested = false;
1326
- let lastApplyResult;
1327
- const triggerApply = () => {
1328
- if (applyInFlight) {
1329
- rerunRequested = true;
1330
- return applyInFlight;
1331
- }
1332
- const loop = (async () => {
1333
- try {
1334
- do {
1335
- rerunRequested = false;
1336
- try {
1337
- lastApplyResult = await applyOnce({
1338
- rootDir: opts.rootDir,
1339
- client,
1340
- stateDb
1341
- });
1342
- } catch (err) {
1343
- console.error("tapd apply error:", err);
1344
- }
1345
- } while (rerunRequested && !stopped);
1346
- return lastApplyResult;
1347
- } finally {
1348
- applyInFlight = null;
1349
- }
1350
- })();
1351
- applyInFlight = loop;
1352
- return loop;
1353
- };
1354
- const schedulePoll = () => {
1355
- if (stopped) return;
1356
- pollTimer = setTimeout(() => {
1357
- triggerApply().finally(() => schedulePoll());
1358
- }, pollSeconds * 1e3);
1359
- };
1360
- const scheduleScan = () => {
1361
- if (stopped) return;
1362
- if (scanTimer) clearTimeout(scanTimer);
1363
- scanTimer = setTimeout(() => {
1364
- scanInFlight = scanAndPush({ rootDir: opts.rootDir, client, stateDb, ignore: ignore2 }).catch((err) => {
1365
- console.error("tapd scan error:", err);
1366
- });
1367
- }, debounceMs);
1368
- };
1369
- const SSE_APPLY_DEBOUNCE_MS = 50;
1370
- const scheduleSseApply = () => {
1371
- if (stopped) return;
1372
- if (sseApplyTimer) clearTimeout(sseApplyTimer);
1373
- sseApplyTimer = setTimeout(() => {
1374
- sseApplyTimer = void 0;
1375
- void triggerApply();
1376
- }, SSE_APPLY_DEBOUNCE_MS);
1377
- };
1378
- if (!opts.noSse) {
1379
- sseSubscriber = subscribe({
1839
+ let writerClient;
1840
+ const ignore2 = loadIgnore(opts.rootDir);
1841
+ let importPaths = [];
1842
+ if (config) {
1843
+ primary = await startSubscription({
1844
+ rootDir: opts.rootDir,
1845
+ config,
1846
+ stateDbPath: primaryStateDbPath(opts.rootDir),
1847
+ mode: "primary",
1848
+ fetch: opts.fetch,
1849
+ pollSeconds,
1850
+ noSse: opts.noSse,
1851
+ sseHeartbeatTimeoutMs: opts.sseHeartbeatTimeoutMs
1852
+ });
1853
+ }
1854
+ const primaryTrackedPaths = primary ? primary.stateDb.listPaths() : [];
1855
+ const { valid: validImports, errors: importErrors } = validateImports({
1856
+ rootDir: opts.rootDir,
1857
+ imports: importSpecs,
1858
+ primaryTrackedPaths
1859
+ });
1860
+ for (const e of importErrors) {
1861
+ console.error(`tapd import "${e.name}" (${e.path}): skipped \u2014 ${e.reason}`);
1862
+ }
1863
+ importPaths = validImports.map((i) => i.path);
1864
+ if (config && primary) {
1865
+ const primaryStateDb = primary.stateDb;
1866
+ writerClient = new RelayClient({
1380
1867
  baseUrl: config.relayUrl,
1381
1868
  bindingId: config.bindingId,
1382
1869
  token: config.token,
1383
- fetch: opts.fetch,
1384
- heartbeatTimeoutMs: opts.sseHeartbeatTimeoutMs,
1385
- onChange: () => scheduleSseApply(),
1386
- onError: (err) => {
1387
- console.error("tapd sse:", err.message);
1870
+ fetch: opts.fetch
1871
+ });
1872
+ const scheduleScan = () => {
1873
+ if (stopped) return;
1874
+ if (scanTimer) clearTimeout(scanTimer);
1875
+ scanTimer = setTimeout(() => {
1876
+ scanInFlight = scanAndPush({
1877
+ rootDir: opts.rootDir,
1878
+ client: writerClient,
1879
+ stateDb: primaryStateDb,
1880
+ ignore: ignore2,
1881
+ excludePaths: importPaths
1882
+ }).catch((err) => {
1883
+ console.error("tapd scan error:", err);
1884
+ });
1885
+ }, debounceMs);
1886
+ };
1887
+ if (!opts.noWatch) {
1888
+ watcher = chokidar.watch(opts.rootDir, {
1889
+ ignoreInitial: true,
1890
+ // Hard-exclude tap metadata AND every import mount point — the
1891
+ // latter so a mirrored subtree never triggers a re-upload into
1892
+ // the primary binding.
1893
+ ignored: (path) => {
1894
+ const rel = path.startsWith(opts.rootDir) ? path.slice(opts.rootDir.length).replace(/^[/\\]/, "") : path;
1895
+ if (isAlwaysExcluded(rel)) return true;
1896
+ if (importPaths.length > 0 && isUnderAny(rel, importPaths)) return true;
1897
+ return false;
1898
+ },
1899
+ awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
1900
+ atomic: true
1901
+ });
1902
+ for (const ev of ["add", "change", "unlink", "addDir", "unlinkDir"]) {
1903
+ watcher.on(ev, () => scheduleScan());
1388
1904
  }
1905
+ }
1906
+ scanInFlight = scanAndPush({
1907
+ rootDir: opts.rootDir,
1908
+ client: writerClient,
1909
+ stateDb: primaryStateDb,
1910
+ ignore: ignore2,
1911
+ excludePaths: importPaths
1912
+ }).catch((err) => {
1913
+ console.error("tapd initial scan error:", err);
1389
1914
  });
1915
+ await scanInFlight;
1390
1916
  }
1391
- if (!opts.noWatch) {
1392
- watcher = chokidar.watch(opts.rootDir, {
1393
- ignoreInitial: true,
1394
- // Ignore tap's own state files at the watcher level too — saves
1395
- // wakeups from sqlite WAL churn.
1396
- ignored: (path) => {
1397
- const rel = path.startsWith(opts.rootDir) ? path.slice(opts.rootDir.length).replace(/^[/\\]/, "") : path;
1398
- if (isAlwaysExcluded(rel)) return true;
1399
- return false;
1400
- },
1401
- // Reasonable defaults for laptop workloads.
1402
- awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 },
1403
- atomic: true
1404
- });
1405
- for (const ev of ["add", "change", "unlink", "addDir", "unlinkDir"]) {
1406
- watcher.on(ev, () => scheduleScan());
1917
+ const imports = [];
1918
+ const mirrorSubs = [];
1919
+ for (const imp of validImports) {
1920
+ try {
1921
+ const sub = await startSubscription({
1922
+ rootDir: join9(opts.rootDir, imp.path),
1923
+ config: { bindingId: imp.bindingId, relayUrl: imp.relayUrl, token: imp.token },
1924
+ stateDbPath: importStateDbPath(opts.rootDir, imp.name),
1925
+ mode: "mirror",
1926
+ fetch: opts.fetch,
1927
+ pollSeconds,
1928
+ noSse: opts.noSse,
1929
+ sseHeartbeatTimeoutMs: opts.sseHeartbeatTimeoutMs
1930
+ });
1931
+ mirrorSubs.push(sub);
1932
+ imports.push({
1933
+ name: imp.name,
1934
+ path: imp.path,
1935
+ applyNow: () => sub.applyNow(),
1936
+ stateDb: sub.stateDb
1937
+ });
1938
+ } catch (err) {
1939
+ const reason = err instanceof Error ? err.message : String(err);
1940
+ console.error(`tapd import "${imp.name}" (${imp.path}): failed to start \u2014 ${reason}`);
1407
1941
  }
1408
1942
  }
1409
- await triggerApply();
1410
- scanInFlight = scanAndPush({ rootDir: opts.rootDir, client, stateDb, ignore: ignore2 }).catch((err) => {
1411
- console.error("tapd initial scan error:", err);
1412
- });
1413
- await scanInFlight;
1414
- schedulePoll();
1943
+ const stateDb = primary?.stateDb ?? imports[0]?.stateDb;
1944
+ if (!stateDb) {
1945
+ throw new Error("tapd start: no primary binding and no import mounted successfully");
1946
+ }
1415
1947
  return {
1416
1948
  async stop() {
1417
1949
  stopped = true;
1418
- if (pollTimer) clearTimeout(pollTimer);
1419
1950
  if (scanTimer) clearTimeout(scanTimer);
1420
- if (sseApplyTimer) clearTimeout(sseApplyTimer);
1421
- if (sseSubscriber) await sseSubscriber.stop();
1422
- await Promise.allSettled([applyInFlight ?? Promise.resolve(), scanInFlight]);
1951
+ await Promise.allSettled([scanInFlight]);
1423
1952
  if (watcher) await watcher.close();
1424
- stateDb.close();
1953
+ const subs = [];
1954
+ if (primary) subs.push(primary.stop());
1955
+ for (const sub of mirrorSubs) subs.push(sub.stop());
1956
+ await Promise.allSettled(subs);
1425
1957
  },
1426
1958
  async applyNow() {
1427
- const result = await triggerApply();
1428
- return result ?? {
1429
- applied: 0,
1430
- echoSkipped: 0,
1431
- errored: 0,
1432
- events: [],
1433
- errors: []
1434
- };
1959
+ return primary ? primary.applyNow() : NOOP_APPLY;
1435
1960
  },
1436
1961
  async scanNow() {
1437
- return scanAndPush({ rootDir: opts.rootDir, client, stateDb, ignore: ignore2 });
1962
+ if (!config || !primary || !writerClient) return NOOP_SCAN;
1963
+ return scanAndPush({
1964
+ rootDir: opts.rootDir,
1965
+ client: writerClient,
1966
+ stateDb: primary.stateDb,
1967
+ ignore: ignore2,
1968
+ excludePaths: importPaths
1969
+ });
1438
1970
  },
1439
- stateDb
1971
+ stateDb,
1972
+ imports
1440
1973
  };
1441
1974
  }
1442
1975
 
@@ -1464,13 +1997,29 @@ export {
1464
1997
  InvalidInviteUrlError,
1465
1998
  parseInviteUrl,
1466
1999
  join5 as join,
2000
+ validateImportName,
2001
+ normalizeImportPath,
2002
+ validateImportPath,
2003
+ validateImports,
2004
+ primaryStateDbPath,
2005
+ importStateDbPath,
2006
+ importsDir,
2007
+ importMountFile,
2008
+ writeImportMount,
2009
+ readImportMount,
2010
+ loadImportMounts,
2011
+ ImportValidationFailed,
2012
+ ImportTokenScopeError,
2013
+ addImport,
2014
+ removeImport,
1467
2015
  STATE_KEY_CONFLICT_COUNT,
1468
2016
  conflictSidecarPath,
1469
2017
  findConflictSidecars,
1470
2018
  applyOnce,
1471
2019
  subscribe,
2020
+ startSubscription,
1472
2021
  computeLocalDiff,
1473
2022
  scanAndPush,
1474
2023
  start
1475
2024
  };
1476
- //# sourceMappingURL=chunk-N3KRCOBG.js.map
2025
+ //# sourceMappingURL=chunk-JBX2ZRMD.js.map