@headband/cli 0.3.0 → 0.6.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +290 -53
  3. package/dist/index.js +2700 -512
  4. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -35,14 +35,16 @@ __export(index_exports, {
35
35
  main: () => main,
36
36
  parseArgv: () => parseArgv,
37
37
  parseExpires: () => parseExpires,
38
+ parseIndexList: () => parseIndexList,
38
39
  parsePermissions: () => parsePermissions,
39
40
  run: () => run,
40
- stdinIsInteractive: () => stdinIsInteractive
41
+ stdinIsInteractive: () => stdinIsInteractive,
42
+ syncClientFromFlags: () => syncClientFromFlags
41
43
  });
42
44
  module.exports = __toCommonJS(index_exports);
43
- var import_fs10 = require("fs");
44
45
  var import_fs11 = require("fs");
45
- var import_path9 = require("path");
46
+ var import_fs12 = require("fs");
47
+ var import_path11 = require("path");
46
48
  var import_prompts2 = __toESM(require("prompts"));
47
49
  var import_picocolors3 = __toESM(require("picocolors"));
48
50
 
@@ -58,6 +60,9 @@ var import_path = require("path");
58
60
  function isPublicSearchKey(value) {
59
61
  return /^hb_src_[A-Za-z0-9_-]+$/.test(value.trim());
60
62
  }
63
+ function displayPrefixOf(raw) {
64
+ return raw.slice(0, raw.length === 55 ? 16 : 12);
65
+ }
61
66
  function validatePublicSearchKey(value) {
62
67
  if (!value.trim()) return "Search API key is required";
63
68
  if (value.startsWith("hb_adm_")) {
@@ -292,6 +297,9 @@ var ApiError = class extends CliError {
292
297
  this.details = details;
293
298
  }
294
299
  };
300
+ function restate(error, message) {
301
+ return new ApiError(message, error.status, error.code, error.details);
302
+ }
295
303
  var DEFAULT_CODES = {
296
304
  [EXIT_CODES.generic]: "error",
297
305
  [EXIT_CODES.usage]: "usage_error",
@@ -502,6 +510,25 @@ function resolveAuth(selectors = {}) {
502
510
  }
503
511
 
504
512
  // src/api.ts
513
+ var MAX_RETRY_AFTER_S = 10;
514
+ var RATE_LIMIT_RETRIES = 2;
515
+ function retryAfterSeconds(response) {
516
+ const raw = response.headers.get("retry-after");
517
+ if (raw === null || !/^\d+$/.test(raw.trim())) return null;
518
+ const s = parseInt(raw, 10);
519
+ return s <= MAX_RETRY_AFTER_S ? s : null;
520
+ }
521
+ function errorMessageOf(status, errorBody) {
522
+ const message = errorBody.error || `Request failed with status ${status}`;
523
+ if (status === 429 && typeof errorBody.message === "string" && errorBody.message.trim()) {
524
+ return `${message}. ${errorBody.message.trim()}`;
525
+ }
526
+ if (errorBody.code === "partial_failure" && Array.isArray(errorBody.errors)) {
527
+ return partialFailureSummary({ message, errors: errorBody.errors });
528
+ }
529
+ return message;
530
+ }
531
+ var defaultSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
505
532
  async function readJson(response) {
506
533
  const text = await response.text();
507
534
  if (!text) return {};
@@ -521,13 +548,21 @@ async function apiRequest(baseUrl, path2, init2 = {}) {
521
548
  headers.set("authorization", `Bearer ${init2.apiKey}`);
522
549
  }
523
550
  const base = normalizeUrl(baseUrl);
551
+ const { rateLimitRetries = 0, sleep = defaultSleep, apiKey: _apiKey, ...fetchInit } = init2;
552
+ void _apiKey;
524
553
  let response;
525
554
  try {
526
- response = await fetch(`${base}${path2}`, {
527
- ...init2,
528
- headers,
529
- body: init2.body === void 0 || typeof init2.body === "string" ? init2.body : JSON.stringify(init2.body)
530
- });
555
+ for (let attempt = 0; ; attempt++) {
556
+ response = await fetch(`${base}${path2}`, {
557
+ ...fetchInit,
558
+ headers,
559
+ body: init2.body === void 0 || typeof init2.body === "string" ? init2.body : JSON.stringify(init2.body)
560
+ });
561
+ const wait = response.status === 429 ? retryAfterSeconds(response) : null;
562
+ if (wait === null || attempt >= rateLimitRetries) break;
563
+ await response.body?.cancel().catch(() => void 0);
564
+ await sleep(wait * 1e3);
565
+ }
531
566
  } catch (error) {
532
567
  const cause = error?.cause;
533
568
  const detail = cause?.code || cause?.message || (error instanceof Error ? error.message : "");
@@ -540,7 +575,7 @@ async function apiRequest(baseUrl, path2, init2 = {}) {
540
575
  if (!response.ok) {
541
576
  const errorBody = body && typeof body === "object" ? body : {};
542
577
  throw new ApiError(
543
- errorBody.error || `Request failed with status ${response.status}`,
578
+ errorMessageOf(response.status, errorBody),
544
579
  response.status,
545
580
  errorBody.code,
546
581
  body
@@ -549,18 +584,23 @@ async function apiRequest(baseUrl, path2, init2 = {}) {
549
584
  return body;
550
585
  }
551
586
  function createApiClient(url, profile) {
552
- const request = (path2, init2 = {}) => apiRequest(url, path2, { ...init2, apiKey: profile.apiKey });
587
+ const request = (path2, init2 = {}) => apiRequest(url, path2, {
588
+ ...init2,
589
+ apiKey: profile.apiKey,
590
+ headers: profile.headers,
591
+ rateLimitRetries: RATE_LIMIT_RETRIES
592
+ });
553
593
  return {
554
594
  url: normalizeUrl(url),
555
595
  get: (path2) => request(path2, { method: "GET" }),
556
596
  post: (path2, body) => request(path2, { method: "POST", body }),
557
597
  patch: (path2, body) => request(path2, { method: "PATCH", body }),
558
- delete: (path2) => request(path2, { method: "DELETE" })
598
+ delete: (path2, body) => request(path2, body === void 0 ? { method: "DELETE" } : { method: "DELETE", body })
559
599
  };
560
600
  }
561
601
 
562
602
  // src/auth.ts
563
- var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
603
+ var defaultSleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
564
604
  function openBrowser(url) {
565
605
  try {
566
606
  if (process.platform === "darwin") {
@@ -575,7 +615,7 @@ function openBrowser(url) {
575
615
  }
576
616
  async function runDeviceFlow(url, io, options = {}) {
577
617
  const baseUrl = normalizeUrl(url);
578
- const sleep = options.sleep || defaultSleep;
618
+ const sleep = options.sleep || defaultSleep2;
579
619
  const now = options.now || Date.now;
580
620
  const device = await apiRequest(baseUrl, "/api/cli/device", {
581
621
  method: "POST",
@@ -644,11 +684,564 @@ async function login(url, io, options = {}) {
644
684
  };
645
685
  }
646
686
 
647
- // src/config-load.ts
687
+ // src/branch.ts
688
+ var import_path5 = require("path");
689
+
690
+ // src/output.ts
691
+ var defaultIO = {
692
+ stdout: (message = "") => console.log(message),
693
+ stderr: (message = "") => console.error(message)
694
+ };
695
+ function printJson(io, value) {
696
+ io.stdout(JSON.stringify(value, null, 2));
697
+ }
698
+ function formatTable(rows, columns) {
699
+ if (rows.length === 0) return "No results.";
700
+ const selectedColumns = columns || Object.keys(rows[0]);
701
+ const widths = selectedColumns.map(
702
+ (column) => Math.max(
703
+ column.length,
704
+ ...rows.map(
705
+ (row) => String(row[column] === null || row[column] === void 0 ? "" : row[column]).length
706
+ )
707
+ )
708
+ );
709
+ const renderRow = (values) => values.map((value, index) => value.padEnd(widths[index])).join(" ").trimEnd();
710
+ return [
711
+ renderRow(selectedColumns),
712
+ renderRow(widths.map((width) => "-".repeat(width))),
713
+ ...rows.map(
714
+ (row) => renderRow(
715
+ selectedColumns.map(
716
+ (column) => String(row[column] === null || row[column] === void 0 ? "" : row[column])
717
+ )
718
+ )
719
+ )
720
+ ].join("\n");
721
+ }
722
+ function printTable(io, rows, columns) {
723
+ io.stdout(formatTable(rows, columns));
724
+ }
725
+ function summarizeHit(hit) {
726
+ const result = {};
727
+ for (const key of Object.keys(hit).slice(0, 5)) {
728
+ const value = hit[key];
729
+ result[key] = value && typeof value === "object" ? JSON.stringify(value) : value;
730
+ }
731
+ return result;
732
+ }
733
+
734
+ // src/project-file.ts
648
735
  var import_fs3 = require("fs");
736
+ var import_os3 = require("os");
649
737
  var import_path4 = require("path");
738
+ var PROJECT_DIR = ".headband";
739
+ var PROJECT_FILE = "project.json";
740
+ var PROJECT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
741
+ var BRANCH_NAME_RE = /^[a-z0-9]([a-z0-9._/-]{0,61}[a-z0-9])?$/;
742
+ var BRANCH_ID_RE = /^br_[a-z0-9]{12}$/;
743
+ var CONTROL_RE = /[\u0000-\u001f\u007f]/;
744
+ var isProjectId = (v) => typeof v === "string" && PROJECT_ID_RE.test(v);
745
+ var isBranchId = (v) => typeof v === "string" && BRANCH_ID_RE.test(v);
746
+ var isBranchName = (v) => typeof v === "string" && BRANCH_NAME_RE.test(v) && !BRANCH_ID_RE.test(v);
747
+ var isOrgId = (v) => typeof v === "string" && v.length > 0 && !CONTROL_RE.test(v);
748
+ function isProjectFile(v) {
749
+ return Boolean(
750
+ v && isOrgId(v.orgId) && isProjectId(v.projectId) && (isBranchName(v.branch) || isBranchId(v.branch))
751
+ );
752
+ }
753
+ function homeDir() {
754
+ return process.env.HOME || process.env.USERPROFILE || (0, import_os3.homedir)();
755
+ }
756
+ function currentUid() {
757
+ return typeof process.getuid === "function" ? process.getuid() : void 0;
758
+ }
759
+ function owns(path2, uid, read = import_fs3.lstatSync) {
760
+ if (uid === void 0) return true;
761
+ try {
762
+ return read(path2).uid === uid;
763
+ } catch {
764
+ return true;
765
+ }
766
+ }
767
+ function projectFileOwned(path2, uid = currentUid()) {
768
+ return owns((0, import_path4.dirname)(path2), uid) && owns(path2, uid) && owns(path2, uid, import_fs3.statSync);
769
+ }
770
+ function foreignOwner(path2, coversCwd) {
771
+ return new CliError(
772
+ coversCwd ? `${path2} belongs to another user. Remove it to use your own checkout here.` : `${path2} belongs to another user. Remove it, or run headband branch checkout <name> in this directory to use your own.`,
773
+ { code: "project_file_foreign_owner" }
774
+ );
775
+ }
776
+ function projectFileSearchDirs(startDir, home = homeDir()) {
777
+ const dirs = [];
778
+ const ceiling = home && (0, import_path4.isAbsolute)(home) ? (0, import_path4.resolve)(home) : null;
779
+ let dir = (0, import_path4.resolve)(startDir);
780
+ for (; ; ) {
781
+ dirs.push(dir);
782
+ if (dir === ceiling) break;
783
+ const parent = (0, import_path4.dirname)(dir);
784
+ if (parent === dir) break;
785
+ dir = parent;
786
+ }
787
+ return dirs;
788
+ }
789
+ function findProjectFile(cwd) {
790
+ for (const dir of projectFileSearchDirs(cwd)) {
791
+ const candidate = (0, import_path4.join)(dir, PROJECT_DIR, PROJECT_FILE);
792
+ if ((0, import_fs3.existsSync)(candidate)) return candidate;
793
+ }
794
+ return null;
795
+ }
796
+ function parseProjectFile(path2) {
797
+ try {
798
+ const v = JSON.parse((0, import_fs3.readFileSync)(path2, "utf-8"));
799
+ return isProjectFile(v) ? { orgId: v.orgId, projectId: v.projectId, branch: v.branch } : null;
800
+ } catch {
801
+ return null;
802
+ }
803
+ }
804
+ function readProjectFile(cwd) {
805
+ const path2 = findProjectFile(cwd);
806
+ return path2 && projectFileOwned(path2) ? parseProjectFile(path2) : null;
807
+ }
808
+ function projectFileTarget(cwd) {
809
+ const found = findProjectFile(cwd);
810
+ if (found && projectFileOwned(found)) return found;
811
+ const own = (0, import_path4.join)((0, import_path4.resolve)(cwd), PROJECT_DIR, PROJECT_FILE);
812
+ if (found === own) throw foreignOwner(found, true);
813
+ if ((0, import_fs3.existsSync)((0, import_path4.dirname)(own)) && !owns((0, import_path4.dirname)(own), currentUid())) {
814
+ throw foreignOwner((0, import_path4.dirname)(own), true);
815
+ }
816
+ return own;
817
+ }
818
+ function writeProjectFile(cwd, f) {
819
+ const file = { orgId: f.orgId, projectId: f.projectId, branch: f.branch };
820
+ if (!isProjectFile(file)) {
821
+ throw new CliError("The branch's org, project or name cannot go in .headband/project.json.");
822
+ }
823
+ const path2 = projectFileTarget(cwd);
824
+ const dir = (0, import_path4.dirname)(path2);
825
+ (0, import_fs3.mkdirSync)(dir, { recursive: true, mode: 448 });
826
+ (0, import_fs3.chmodSync)(dir, 448);
827
+ const temp = (0, import_path4.join)(dir, `.${PROJECT_FILE}.${process.pid}.tmp`);
828
+ try {
829
+ (0, import_fs3.writeFileSync)(temp, `${JSON.stringify(file, null, 2)}
830
+ `, { encoding: "utf-8", mode: 384 });
831
+ (0, import_fs3.chmodSync)(temp, 384);
832
+ (0, import_fs3.renameSync)(temp, path2);
833
+ } catch (error) {
834
+ (0, import_fs3.rmSync)(temp, { force: true });
835
+ throw error;
836
+ }
837
+ return path2;
838
+ }
839
+ function checkoutFor(cwd, loginOrgId) {
840
+ const path2 = findProjectFile(cwd);
841
+ if (!path2) return null;
842
+ if (!projectFileOwned(path2)) throw foreignOwner(path2, (0, import_path4.dirname)((0, import_path4.dirname)(path2)) === (0, import_path4.resolve)(cwd));
843
+ const file = parseProjectFile(path2);
844
+ if (!file) {
845
+ throw new CliError(
846
+ `Can't read ${path2}. Run headband branch checkout <name> again, or delete the file to use the main branch.`,
847
+ { code: "project_file_unreadable" }
848
+ );
849
+ }
850
+ if (loginOrgId && file.orgId !== loginOrgId) {
851
+ throw new CliError(
852
+ `${(0, import_path4.dirname)((0, import_path4.dirname)(path2))} is checked out for org ${file.orgId}, but you are logged in to ${loginOrgId}. Run headband branch checkout <name> again, or log in to that org.`,
853
+ { code: "project_file_org_mismatch" }
854
+ );
855
+ }
856
+ return file;
857
+ }
858
+ function stringFlag(flags, name) {
859
+ const value = flags[name];
860
+ return typeof value === "string" ? value : void 0;
861
+ }
862
+ function branchTargetFor(flags, cwd, options = {}) {
863
+ const project = stringFlag(flags, "--project");
864
+ const branch = stringFlag(flags, "--branch");
865
+ if (project && !branch && !options.projectInBody) {
866
+ throw new UsageError(
867
+ "--project needs --branch on this command, which sends no project of its own. Pass --project <id> --branch <name>, or drop --project."
868
+ );
869
+ }
870
+ const file = checkoutFor(cwd, options.loginOrgId);
871
+ if (branch) {
872
+ if (isBranchId(branch)) return { projectId: project ?? null, branch };
873
+ const projectId = project ?? file?.projectId;
874
+ if (!projectId) {
875
+ throw new UsageError(
876
+ "--branch needs a project. Pass --project <id> or run headband branch checkout inside a project directory."
877
+ );
878
+ }
879
+ return { projectId, branch };
880
+ }
881
+ if (!file) return null;
882
+ if (project && project !== file.projectId) return null;
883
+ return { projectId: file.projectId, branch: file.branch };
884
+ }
885
+ function branchHeaderFor(flags, cwd, options = {}) {
886
+ const target = branchTargetFor(flags, cwd, options);
887
+ if (!target) return null;
888
+ return isBranchId(target.branch) || target.projectId === null ? target.branch : `${target.projectId}/${target.branch}`;
889
+ }
890
+
891
+ // src/branch.ts
892
+ var COLUMNS = ["name", "primary", "protected", "expiresAt", "state"];
893
+ var PART_2 = /* @__PURE__ */ new Set(["reset", "rollback", "restore", "set-primary"]);
894
+ var BRANCH_USAGE = [
895
+ "Usage:",
896
+ " headband branch list [--project <id>] [--json]",
897
+ " headband branch create <name> --empty [--parent <name>] [--expires-in <2h|7d>] [--project <id>] [--json]",
898
+ " headband branch delete <name> [--project <id>] [--yes] [--json]",
899
+ " headband branch protect <name> [--project <id>] [--json]",
900
+ " headband branch unprotect <name> [--project <id>] [--json]",
901
+ " headband branch expire <name> (--in <2h|7d> | --at <iso> | --clear) [--project <id>] [--json]",
902
+ " headband branch checkout <name> [--project <id>] [--json]",
903
+ " headband branch create <name> (without --empty, a fork) coming with bucket storage",
904
+ " headband branch reset|rollback|restore|set-primary <name> coming with bucket storage",
905
+ "",
906
+ "The project comes from --project, else the directory's checkout (.headband/project.json).",
907
+ "Durations take s, m, h, d or w: 30m, 2h, 7d.",
908
+ "",
909
+ "The primary branch (main) serves production. It is always protected: it cannot be",
910
+ "unprotected, expired or deleted. A protected branch other than main must be unprotected",
911
+ "before it can be deleted.",
912
+ "",
913
+ "checkout writes .headband/project.json. Every later command in that directory, or in a",
914
+ "directory below it, sends Headband-Branch: <project id>/<branch> with your login key, so it",
915
+ "works on that branch. The nearest .headband/project.json above you applies, as with git.",
916
+ "On those commands, --project <id> --branch <name> picks another branch for one call."
917
+ ].join("\n");
918
+ var DURATION_UNIT_MS = {
919
+ s: 1e3,
920
+ m: 6e4,
921
+ h: 36e5,
922
+ d: 864e5,
923
+ w: 6048e5
924
+ };
925
+ function parseDuration(input) {
926
+ const match = /^(\d+)(s|m|h|d|w)$/.exec(input.trim());
927
+ if (!match) {
928
+ throw new UsageError(
929
+ `Invalid duration "${input}". Use forms like 30m, 2h, 7d (units: s/m/h/d/w).`
930
+ );
931
+ }
932
+ const n = parseInt(match[1], 10);
933
+ if (n <= 0) throw new UsageError(`Duration "${input}" must be greater than zero.`);
934
+ return n * DURATION_UNIT_MS[match[2]];
935
+ }
936
+ function expiresAtFrom(flags, now = Date.now()) {
937
+ const inFlag = stringFlag2(flags, "--in");
938
+ const atFlag = stringFlag2(flags, "--at");
939
+ const clear = flags["--clear"] === true;
940
+ const given = [inFlag !== void 0, atFlag !== void 0, clear].filter(Boolean).length;
941
+ if (given !== 1) {
942
+ throw new UsageError("Pass exactly one of --in <duration>, --at <iso> or --clear.");
943
+ }
944
+ if (clear) return null;
945
+ if (inFlag !== void 0) return new Date(now + parseDuration(inFlag)).toISOString();
946
+ const at = Date.parse(atFlag);
947
+ if (Number.isNaN(at)) {
948
+ throw new UsageError(`Invalid --at "${atFlag}". Use an ISO 8601 time.`);
949
+ }
950
+ if (at <= now) throw new UsageError("--at must be in the future.");
951
+ return new Date(at).toISOString();
952
+ }
953
+ function stringFlag2(flags, name) {
954
+ const value = flags[name];
955
+ return typeof value === "string" ? value : void 0;
956
+ }
957
+ function requiredName(value) {
958
+ if (!value) throw new UsageError("Missing branch name");
959
+ return value;
960
+ }
961
+ function projectFor(flags, checkout, missing = "Pass --project <id> or run headband branch checkout inside a project directory.") {
962
+ const project = stringFlag2(flags, "--project") ?? checkout()?.projectId;
963
+ if (!project) throw new UsageError(missing);
964
+ return project;
965
+ }
966
+ function noLoginForCheckout(auth) {
967
+ const instead = "or pass --project <id> --branch <name> to each command instead.";
968
+ if (auth.source === "env") {
969
+ return `branch checkout records the org of your login, and HEADBAND_API_KEY takes the place of a stored login. Unset HEADBAND_API_KEY and use headband login, ${instead}`;
970
+ }
971
+ if (auth.source === "flag") {
972
+ return `branch checkout records the org of your login, and --token takes the place of a stored login. Drop --token and use headband login, ${instead}`;
973
+ }
974
+ return `branch checkout records the org of your login. Run headband login, ${instead}`;
975
+ }
976
+ function restateRefusal(error, action, name) {
977
+ if (!(error instanceof ApiError)) return error;
978
+ if (error.code === "branch_is_primary") {
979
+ const what = action === "unprotect" ? "so it is always protected and cannot be unprotected" : action === "expire" ? "so it never expires" : action === "delete" ? "so it cannot be deleted" : "so it is always protected";
980
+ return restate(error, `${name} is the primary branch. It serves production, ${what}.`);
981
+ }
982
+ if (error.code === "branch_protected") {
983
+ if (action === "protect") {
984
+ return restate(
985
+ error,
986
+ `Branch ${name} has an expiry, and a branch with an expiry cannot be protected. Run headband branch expire ${name} --clear first.`
987
+ );
988
+ }
989
+ if (action === "delete" || action === "expire") {
990
+ return restate(
991
+ error,
992
+ `Branch ${name} is protected. Run headband branch unprotect ${name} first.`
993
+ );
994
+ }
995
+ }
996
+ return error;
997
+ }
998
+ async function runBranch(args, flags, deps) {
999
+ const { io, auth, confirm, cwd } = deps;
1000
+ const action = args[0];
1001
+ const json = flags["--json"] === true;
1002
+ if (action && PART_2.has(action)) {
1003
+ throw new UsageError(
1004
+ `headband branch ${action} is coming with bucket storage.`,
1005
+ "not_available"
1006
+ );
1007
+ }
1008
+ const client = createApiClient(auth.url, { apiKey: auth.apiKey });
1009
+ const checkout = () => checkoutFor(cwd, auth.profile?.orgId);
1010
+ const path2 = (name, project) => `/v1/branches/${encodeURIComponent(name)}?project=${encodeURIComponent(project)}`;
1011
+ if (action === "list") {
1012
+ const project = projectFor(flags, checkout);
1013
+ const response = await client.get(
1014
+ `/v1/branches?project=${encodeURIComponent(project)}`
1015
+ );
1016
+ if (json) printJson(io, response);
1017
+ else printTable(io, response.data.branches, COLUMNS);
1018
+ return;
1019
+ }
1020
+ if (action === "create") {
1021
+ const name = requiredName(args[1]);
1022
+ const body = { name, empty: flags["--empty"] === true };
1023
+ const parent = stringFlag2(flags, "--parent");
1024
+ if (parent) body.parent = parent;
1025
+ const expiresIn = stringFlag2(flags, "--expires-in");
1026
+ if (expiresIn) body.expiresAt = new Date(Date.now() + parseDuration(expiresIn)).toISOString();
1027
+ body.projectId = projectFor(flags, checkout);
1028
+ let response;
1029
+ try {
1030
+ response = await client.post("/v1/branches", body);
1031
+ } catch (error) {
1032
+ if (error instanceof ApiError && error.code === "branches_not_open") {
1033
+ throw restate(error, "Branches other than main are not open yet.");
1034
+ }
1035
+ if (error instanceof ApiError && error.code === "fork_not_available") {
1036
+ throw restate(
1037
+ error,
1038
+ "Forking arrives with bucket storage. Pass --empty for an empty branch."
1039
+ );
1040
+ }
1041
+ throw error;
1042
+ }
1043
+ if (json) printJson(io, response);
1044
+ else printTable(io, [response.data], COLUMNS);
1045
+ return;
1046
+ }
1047
+ if (action === "delete") {
1048
+ const name = requiredName(args[1]);
1049
+ const project = projectFor(flags, checkout);
1050
+ if (!await confirm(`Delete branch ${name} with its indexes, keys and engines?`)) {
1051
+ if (json) printJson(io, { cancelled: true });
1052
+ else io.stdout("Cancelled.");
1053
+ return;
1054
+ }
1055
+ const response = await client.delete(path2(name, project)).catch((error) => Promise.reject(restateRefusal(error, action, name)));
1056
+ if (json) printJson(io, response);
1057
+ else io.stdout(`Deleted branch ${name}.`);
1058
+ return;
1059
+ }
1060
+ if (action === "protect" || action === "unprotect") {
1061
+ const name = requiredName(args[1]);
1062
+ const project = projectFor(flags, checkout);
1063
+ const value = action === "protect";
1064
+ const response = await client.patch(path2(name, project), { protected: value }).catch((error) => Promise.reject(restateRefusal(error, action, name)));
1065
+ if (json) printJson(io, response);
1066
+ else io.stdout(value ? `Branch ${name} is protected.` : `Branch ${name} is not protected.`);
1067
+ return;
1068
+ }
1069
+ if (action === "expire") {
1070
+ const name = requiredName(args[1]);
1071
+ const expiresAt = expiresAtFrom(flags);
1072
+ const project = projectFor(flags, checkout);
1073
+ const response = await client.patch(path2(name, project), { expiresAt }).catch((error) => Promise.reject(restateRefusal(error, action, name)));
1074
+ if (json) printJson(io, response);
1075
+ else
1076
+ io.stdout(
1077
+ expiresAt ? `Branch ${name} expires at ${expiresAt}.` : `Branch ${name} no longer expires.`
1078
+ );
1079
+ return;
1080
+ }
1081
+ if (action === "checkout") {
1082
+ const name = requiredName(args[1]);
1083
+ const project = projectFor(flags, () => readProjectFile(cwd), "Pass --project <id>.");
1084
+ const orgId = auth.profile?.orgId;
1085
+ if (!orgId) throw new AuthError(noLoginForCheckout(auth));
1086
+ projectFileTarget(cwd);
1087
+ let branch;
1088
+ try {
1089
+ branch = (await client.get(path2(name, project))).data;
1090
+ } catch (error) {
1091
+ if (error instanceof ApiError && error.status === 404 && error.code !== "project_not_found") {
1092
+ throw new CliError(`No branch '${name}' in project ${project}. See headband branch list.`, {
1093
+ exitCode: EXIT_CODES.notFound,
1094
+ code: error.code ?? "branch_not_found"
1095
+ });
1096
+ }
1097
+ throw error;
1098
+ }
1099
+ if (branch.state === "deleting") {
1100
+ throw new CliError(`Branch '${branch.name}' is being deleted. See headband branch list.`, {
1101
+ exitCode: EXIT_CODES.conflict,
1102
+ code: "branch_deleting"
1103
+ });
1104
+ }
1105
+ const file = { orgId, projectId: branch.projectId, branch: branch.name };
1106
+ const filePath = writeProjectFile(cwd, file);
1107
+ const header = `${file.projectId}/${file.branch}`;
1108
+ if (json) printJson(io, { path: filePath, ...file });
1109
+ else {
1110
+ io.stdout(`Checked out branch ${file.branch} of project ${file.projectId}.`);
1111
+ io.stdout(`Commands under ${(0, import_path5.dirname)((0, import_path5.dirname)(filePath))} now send Headband-Branch: ${header}`);
1112
+ }
1113
+ return;
1114
+ }
1115
+ io.stdout(BRANCH_USAGE);
1116
+ throw new UsageError("Unknown branch command");
1117
+ }
1118
+
1119
+ // src/config-load.ts
1120
+ var import_fs4 = require("fs");
1121
+ var import_path6 = require("path");
650
1122
  var import_url = require("url");
651
1123
  var import_jiti = require("jiti");
1124
+
1125
+ // ../sync/src/config.ts
1126
+ var SETTINGS_ATTRIBUTE_LIST_KEYS = [
1127
+ "searchableAttributes",
1128
+ "filterableAttributes",
1129
+ "sortableAttributes",
1130
+ "displayedAttributes"
1131
+ ];
1132
+ function validateConfig(config, ormSchema) {
1133
+ const errors = [];
1134
+ const seenIndexNames = /* @__PURE__ */ new Set();
1135
+ for (const index of config.indexes) {
1136
+ if (seenIndexNames.has(index.name)) {
1137
+ errors.push({
1138
+ index: index.name,
1139
+ message: `duplicate index name '${index.name}'`
1140
+ });
1141
+ } else {
1142
+ seenIndexNames.add(index.name);
1143
+ }
1144
+ const model = ormSchema.models[index.source];
1145
+ if (!model) {
1146
+ errors.push({
1147
+ index: index.name,
1148
+ message: `unknown model '${index.source}'`
1149
+ });
1150
+ continue;
1151
+ }
1152
+ if (!model.columns[index.primaryKey]) {
1153
+ errors.push({
1154
+ index: index.name,
1155
+ field: index.primaryKey,
1156
+ message: `primaryKey '${index.primaryKey}' is not a column on model '${index.source}'`
1157
+ });
1158
+ }
1159
+ const fieldNames = Object.keys(index.fields);
1160
+ for (const [fieldName, spec] of Object.entries(index.fields)) {
1161
+ if (spec === true) {
1162
+ if (!model.columns[fieldName]) {
1163
+ errors.push({
1164
+ index: index.name,
1165
+ field: fieldName,
1166
+ message: `field '${fieldName}' is not a column on model '${index.source}'`
1167
+ });
1168
+ }
1169
+ continue;
1170
+ }
1171
+ if (typeof spec === "function") {
1172
+ continue;
1173
+ }
1174
+ if (spec.kind === "rel") {
1175
+ const targetModel2 = ormSchema.models[spec.model];
1176
+ if (!targetModel2) {
1177
+ errors.push({
1178
+ index: index.name,
1179
+ field: fieldName,
1180
+ message: `unknown model '${spec.model}'`
1181
+ });
1182
+ continue;
1183
+ }
1184
+ const hasPath2 = model.relations.some(
1185
+ (r) => r.fromModel === index.source && r.toModel === spec.model && r.kind === "one"
1186
+ );
1187
+ if (!hasPath2) {
1188
+ errors.push({
1189
+ index: index.name,
1190
+ field: fieldName,
1191
+ message: `no relation path from '${index.source}' to '${spec.model}' for rel()`
1192
+ });
1193
+ }
1194
+ continue;
1195
+ }
1196
+ const targetModel = ormSchema.models[spec.model];
1197
+ if (!targetModel) {
1198
+ errors.push({
1199
+ index: index.name,
1200
+ field: fieldName,
1201
+ message: `unknown model '${spec.model}'`
1202
+ });
1203
+ continue;
1204
+ }
1205
+ const hasPath = model.relations.some(
1206
+ (r) => r.fromModel === index.source && r.toModel === spec.model && r.kind === "many"
1207
+ );
1208
+ if (!hasPath) {
1209
+ errors.push({
1210
+ index: index.name,
1211
+ field: fieldName,
1212
+ message: `no relation path from '${spec.model}' to '${index.source}' for agg()`
1213
+ });
1214
+ }
1215
+ }
1216
+ if (index.settings) {
1217
+ const allowedAttributes = /* @__PURE__ */ new Set([...fieldNames, index.primaryKey]);
1218
+ for (const key of SETTINGS_ATTRIBUTE_LIST_KEYS) {
1219
+ const list = index.settings[key];
1220
+ if (!list) continue;
1221
+ for (const attribute of list) {
1222
+ if (!allowedAttributes.has(attribute)) {
1223
+ errors.push({
1224
+ index: index.name,
1225
+ field: attribute,
1226
+ message: `settings.${key} references undefined field '${attribute}'`
1227
+ });
1228
+ }
1229
+ }
1230
+ }
1231
+ const distinctAttribute = index.settings.distinctAttribute;
1232
+ if (distinctAttribute && !allowedAttributes.has(distinctAttribute)) {
1233
+ errors.push({
1234
+ index: index.name,
1235
+ field: distinctAttribute,
1236
+ message: `settings.distinctAttribute references undefined field '${distinctAttribute}'`
1237
+ });
1238
+ }
1239
+ }
1240
+ }
1241
+ return errors;
1242
+ }
1243
+
1244
+ // src/config-load.ts
652
1245
  var CONFIG_FILENAMES = [
653
1246
  "headband.config.ts",
654
1247
  "headband.config.mts",
@@ -656,24 +1249,91 @@ var CONFIG_FILENAMES = [
656
1249
  "headband.config.mjs",
657
1250
  "headband.config.cjs"
658
1251
  ];
1252
+ var SYNC_CONFIG_SHAPE = 'a headband.config.ts whose default export is defineConfig({ indexes: [...] }) from @headband/sync, plus a named "adapter" export such as drizzleAdapter({ db, schema, config }) from @headband/drizzle';
659
1253
  function findConfigFile(cwd) {
660
1254
  for (const name of CONFIG_FILENAMES) {
661
- const path2 = (0, import_path4.join)(cwd, name);
662
- if ((0, import_fs3.existsSync)(path2)) return path2;
1255
+ const path2 = (0, import_path6.join)(cwd, name);
1256
+ if ((0, import_fs4.existsSync)(path2)) return path2;
663
1257
  }
664
1258
  return void 0;
665
1259
  }
666
1260
  function isSyncConfig(value) {
667
1261
  return typeof value === "object" && value !== null && Array.isArray(value.indexes);
668
1262
  }
1263
+ function isAdapter(value) {
1264
+ if (typeof value !== "object" || value === null) return false;
1265
+ const a = value;
1266
+ return ["introspect", "resolveDocs", "fanOut", "listIds"].every(
1267
+ (name) => typeof a[name] === "function"
1268
+ );
1269
+ }
1270
+ function configShapeProblems(config) {
1271
+ const problems = [];
1272
+ config.indexes.forEach((def, i) => {
1273
+ const label = typeof def?.name === "string" && def.name ? def.name : `indexes[${i}]`;
1274
+ if (typeof def !== "object" || def === null) {
1275
+ problems.push(`${label}: not an index definition`);
1276
+ return;
1277
+ }
1278
+ if (typeof def.name !== "string" || !def.name) problems.push(`${label}: missing "name"`);
1279
+ if (typeof def.source !== "string" || !def.source)
1280
+ problems.push(`${label}: missing "source" (the table the documents come from)`);
1281
+ if (typeof def.primaryKey !== "string" || !def.primaryKey)
1282
+ problems.push(`${label}: missing "primaryKey"`);
1283
+ if (typeof def.fields !== "object" || def.fields === null || Array.isArray(def.fields)) {
1284
+ problems.push(`${label}: "fields" must be an object of field names`);
1285
+ } else {
1286
+ for (const [field, spec] of Object.entries(def.fields)) {
1287
+ const kind = spec?.kind;
1288
+ const ok = spec === true || typeof spec === "function" || typeof spec === "object" && spec !== null && (kind === "rel" || kind === "agg");
1289
+ if (!ok) {
1290
+ problems.push(
1291
+ `${label}.${field}: a field is true, rel(model, field), agg(model, "count") or a function of the row`
1292
+ );
1293
+ }
1294
+ }
1295
+ }
1296
+ if (def.shouldIndex !== void 0 && typeof def.shouldIndex !== "function")
1297
+ problems.push(`${label}: "shouldIndex" must be a function`);
1298
+ });
1299
+ return problems;
1300
+ }
1301
+ function validateLoadedConfig(loaded) {
1302
+ const file = (0, import_path6.basename)(loaded.path);
1303
+ const shape = configShapeProblems(loaded.config);
1304
+ if (shape.length > 0) {
1305
+ throw new CliError(
1306
+ `${file} has ${shape.length} problem(s):
1307
+ ${shape.map((p) => ` ${p}`).join("\n")}`,
1308
+ { code: "invalid_sync_config" }
1309
+ );
1310
+ }
1311
+ if (!loaded.adapter) return;
1312
+ let errors;
1313
+ try {
1314
+ errors = validateConfig(loaded.config, loaded.adapter.introspect());
1315
+ } catch (err) {
1316
+ throw new CliError(
1317
+ `Could not check ${file} against your schema: ${err instanceof Error ? err.message : String(err)}`
1318
+ );
1319
+ }
1320
+ if (errors.length === 0) return;
1321
+ const lines = errors.map((e) => ` ${e.index}${e.field ? `.${e.field}` : ""}: ${e.message}`);
1322
+ throw new CliError(
1323
+ `${file} does not match your database schema (${errors.length} problem(s)):
1324
+ ${lines.join("\n")}`,
1325
+ { code: "invalid_sync_config" }
1326
+ );
1327
+ }
669
1328
  async function loadHeadbandConfig(cwd) {
670
1329
  const path2 = findConfigFile(cwd);
671
1330
  if (!path2) {
672
1331
  throw new CliError(
673
- `No headband.config.ts found in ${cwd}. Run "headband init" or create one that exports a Headband sync config.`
1332
+ `No headband.config.ts found in ${cwd}. The schema and sync commands need ${SYNC_CONFIG_SHAPE}. See the @headband/drizzle README. (headband init writes a search UI config, not a sync config.)`,
1333
+ { code: "sync_config_missing" }
674
1334
  );
675
1335
  }
676
- const jiti = (0, import_jiti.createJiti)((0, import_url.pathToFileURL)((0, import_path4.join)(cwd, "headband-cli.js")).href, {
1336
+ const jiti = (0, import_jiti.createJiti)((0, import_url.pathToFileURL)((0, import_path6.join)(cwd, "headband-cli.js")).href, {
677
1337
  interopDefault: true,
678
1338
  moduleCache: false,
679
1339
  fsCache: false
@@ -688,19 +1348,36 @@ async function loadHeadbandConfig(cwd) {
688
1348
  }
689
1349
  const candidate = mod.default ?? mod;
690
1350
  if (!isSyncConfig(candidate)) {
691
- throw new CliError(`${path2} does not export a valid Headband sync config (missing "indexes").`);
1351
+ throw new CliError(
1352
+ `${path2} is not a sync config: its default export has no "indexes" array. The schema and sync commands need ${SYNC_CONFIG_SHAPE}. If this file came from "headband init", it is the search UI config; keep the sync config in its own directory or merge "indexes" into it.`,
1353
+ { code: "invalid_sync_config" }
1354
+ );
692
1355
  }
693
- const adapter = mod.adapter;
694
- return { config: candidate, adapter, path: path2 };
1356
+ const rawAdapter = mod.adapter;
1357
+ if (rawAdapter !== void 0 && !isAdapter(rawAdapter)) {
1358
+ throw new CliError(
1359
+ `${path2} exports "adapter", but it is not an ORM adapter (it needs introspect, resolveDocs, fanOut and listIds). Export the result of drizzleAdapter({ db, schema, config }).`,
1360
+ { code: "invalid_sync_config" }
1361
+ );
1362
+ }
1363
+ const loaded = { config: candidate, adapter: rawAdapter, path: path2 };
1364
+ validateLoadedConfig(loaded);
1365
+ return loaded;
695
1366
  }
696
-
1367
+ function missingAdapterError(command, why) {
1368
+ return new CliError(
1369
+ `headband.config.ts must export an "adapter" for ${command}: ${why}. Add: export const adapter = drizzleAdapter({ db, schema, config }) (from @headband/drizzle), in headband.config.ts itself.`,
1370
+ { code: "sync_adapter_missing" }
1371
+ );
1372
+ }
1373
+
697
1374
  // src/docs.ts
698
- var import_path5 = require("path");
699
- var import_fs4 = require("fs");
1375
+ var import_path7 = require("path");
1376
+ var import_fs5 = require("fs");
700
1377
  var DOCS_BATCH_SIZE = 1e3;
701
1378
  function parseDocumentsFile(path2) {
702
- const content = (0, import_fs4.readFileSync)(path2, "utf-8");
703
- const ext = (0, import_path5.extname)(path2).toLowerCase();
1379
+ const content = (0, import_fs5.readFileSync)(path2, "utf-8");
1380
+ const ext = (0, import_path7.extname)(path2).toLowerCase();
704
1381
  if (ext === ".jsonl") {
705
1382
  return content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line));
706
1383
  }
@@ -734,38 +1411,206 @@ async function defaultExportBatchSize(client, index) {
734
1411
  return EXPORT_BATCH_SIZE;
735
1412
  }
736
1413
  }
737
- async function exportDocuments(client, options) {
738
- const { index, format, batchSize, write } = options;
1414
+ async function* walkDocuments(client, options) {
1415
+ const { index, batchSize, fields } = options;
1416
+ let base = `/v1/documents?index=${encodeURIComponent(index)}&limit=${batchSize}`;
1417
+ if (fields?.length) base += `&fields=${encodeURIComponent(fields.join(","))}`;
1418
+ let cursor = "";
739
1419
  let offset = 0;
740
- let exported = 0;
741
- let pending;
742
- if (format === "json") await write("[");
743
1420
  for (; ; ) {
744
1421
  const page = await client.get(
745
- `/v1/documents?index=${encodeURIComponent(index)}&limit=${batchSize}&offset=${offset}`
1422
+ cursor === null ? `${base}&offset=${offset}` : `${base}&cursor=${encodeURIComponent(cursor)}`
746
1423
  );
747
1424
  const results = Array.isArray(page.results) ? page.results : [];
1425
+ const total = typeof page.total === "number" ? page.total : null;
1426
+ yield { results, total };
1427
+ offset += results.length;
1428
+ if (cursor !== null && page.nextCursor !== void 0) {
1429
+ if (!page.nextCursor) return;
1430
+ cursor = page.nextCursor;
1431
+ continue;
1432
+ }
1433
+ cursor = null;
1434
+ if (results.length < batchSize) return;
1435
+ if (total !== null && offset >= total) return;
1436
+ }
1437
+ }
1438
+ async function listRemoteIds(client, index, primaryKey) {
1439
+ const batchSize = await defaultExportBatchSize(client, index);
1440
+ const ids = [];
1441
+ for await (const { results } of walkDocuments(client, {
1442
+ index,
1443
+ batchSize,
1444
+ fields: [primaryKey]
1445
+ })) {
748
1446
  for (const doc of results) {
749
- const line = JSON.stringify(doc);
750
- if (format === "jsonl") {
751
- await write(line);
752
- } else {
753
- if (pending !== void 0) await write(` ${pending},`);
754
- pending = line;
755
- }
1447
+ const id = doc?.[primaryKey];
1448
+ if (id !== void 0 && id !== null) ids.push(String(id));
756
1449
  }
757
- exported += results.length;
758
- offset += results.length;
759
- options.onPage?.(exported, typeof page.total === "number" ? page.total : null);
760
- if (results.length < batchSize) break;
761
- if (typeof page.total === "number" && offset >= page.total) break;
762
1450
  }
763
- if (format === "json") {
1451
+ return ids;
1452
+ }
1453
+ async function exportDocuments(client, options) {
1454
+ const { index, format, batchSize, write } = options;
1455
+ let exported = 0;
1456
+ let pending;
1457
+ const finish = async () => {
1458
+ if (format !== "json") return;
764
1459
  if (pending !== void 0) await write(` ${pending}`);
765
1460
  await write("]");
1461
+ };
1462
+ if (format === "json") await write("[");
1463
+ try {
1464
+ for await (const { results, total } of walkDocuments(client, { index, batchSize })) {
1465
+ for (const doc of results) {
1466
+ const line = JSON.stringify(doc);
1467
+ if (format === "jsonl") {
1468
+ await write(line);
1469
+ } else {
1470
+ if (pending !== void 0) await write(` ${pending},`);
1471
+ pending = line;
1472
+ }
1473
+ }
1474
+ exported += results.length;
1475
+ options.onPage?.(exported, total);
1476
+ }
1477
+ } catch (error) {
1478
+ if (options.finishOnError) await finish();
1479
+ throw new ExportInterruptedError(error, exported);
766
1480
  }
1481
+ await finish();
767
1482
  return exported;
768
1483
  }
1484
+ var ExportInterruptedError = class extends Error {
1485
+ constructor(cause, exported) {
1486
+ super(cause instanceof Error ? cause.message : String(cause));
1487
+ this.cause = cause;
1488
+ this.exported = exported;
1489
+ this.name = "ExportInterruptedError";
1490
+ }
1491
+ };
1492
+ function refusedWalk(error) {
1493
+ if (!(error instanceof ApiError) || error.code !== "feature_not_supported") return null;
1494
+ if (!/primary key/i.test(error.message)) return null;
1495
+ const pk = /add `([^`]+)`/.exec(error.message)?.[1] ?? null;
1496
+ const named = ["filterableAttributes", "sortableAttributes", "displayedAttributes"].filter(
1497
+ (name) => error.message.includes(name)
1498
+ );
1499
+ return { pk, lists: named.length ? named : ["filterableAttributes", "sortableAttributes"] };
1500
+ }
1501
+ var DECLARE_WAIT_MS = 15 * 6e4;
1502
+ var DECLARE_POLL_MS = 3e3;
1503
+ async function declareExportKey(client, index, io, opts = {}) {
1504
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1505
+ const now = opts.now ?? Date.now;
1506
+ const probe = () => client.get(`/v1/documents?index=${encodeURIComponent(index)}&limit=1&cursor=`);
1507
+ let refused;
1508
+ try {
1509
+ await probe();
1510
+ return false;
1511
+ } catch (error) {
1512
+ refused = refusedWalk(error);
1513
+ if (!refused) throw error;
1514
+ if (!refused.pk) throw error;
1515
+ }
1516
+ const { pk, lists } = refused;
1517
+ const path2 = `/v1/indexes/${encodeURIComponent(index)}/settings`;
1518
+ const current = await client.get(path2);
1519
+ const patch = {};
1520
+ for (const name of lists) {
1521
+ const list = Array.isArray(current?.[name]) ? current[name] : [];
1522
+ if (list.includes("*") || list.includes(pk)) continue;
1523
+ patch[name] = [...list, pk];
1524
+ }
1525
+ io.stderr(
1526
+ `Declaring \`${pk}\` in ${Object.keys(patch).join(" and ") || lists.join(" and ")} on ${index}, so the export can read past 1000 documents. Visor rebuilds the index for this change, which can take minutes on a large one.`
1527
+ );
1528
+ if (Object.keys(patch).length > 0) {
1529
+ try {
1530
+ await client.patch(path2, patch);
1531
+ } catch (error) {
1532
+ if (!(error instanceof ApiError) || error.status < 500) throw error;
1533
+ }
1534
+ }
1535
+ const deadline = now() + (opts.waitMs ?? DECLARE_WAIT_MS);
1536
+ for (; ; ) {
1537
+ try {
1538
+ await probe();
1539
+ io.stderr(`\`${pk}\` is declared. Exporting.`);
1540
+ return true;
1541
+ } catch (error) {
1542
+ const waiting = refusedWalk(error) || error instanceof ApiError && error.status >= 500;
1543
+ if (!waiting) throw error;
1544
+ }
1545
+ if (now() >= deadline) {
1546
+ throw new CliError(
1547
+ `\`${pk}\` is still not declared on ${index} after ${Math.round((opts.waitMs ?? DECLARE_WAIT_MS) / 6e4)} minutes. Visor may still be rebuilding it: run headband docs export again later.`
1548
+ );
1549
+ }
1550
+ await sleep(DECLARE_POLL_MS);
1551
+ }
1552
+ }
1553
+ function exportRefusalAdvice(error, index) {
1554
+ if (!(error instanceof ApiError) || error.code !== "feature_not_supported") return null;
1555
+ if (!/primary key/i.test(error.message)) return null;
1556
+ const pk = /add `([^`]+)`/.exec(error.message)?.[1];
1557
+ const key = pk ? `\`${pk}\`` : "the primary key";
1558
+ const missing = ["filterableAttributes", "sortableAttributes", "displayedAttributes"].filter(
1559
+ (name) => error.message.includes(name)
1560
+ );
1561
+ const lists = missing.length ? missing.join(" and ") : "filterableAttributes and sortableAttributes";
1562
+ return [
1563
+ `To export the whole index, run it again with --declare-key: it declares ${key} on the index`,
1564
+ `once (Visor rebuilds the index for it), waits for it, and exports. Or declare it yourself:`,
1565
+ ` 1. headband indexes settings get ${index} > settings.json`,
1566
+ ` 2. Add ${key} to ${lists} in settings.json.`,
1567
+ ` 3. headband indexes settings set ${index} --file settings.json`,
1568
+ ` Visor rebuilds the index for this change.`,
1569
+ ` 4. Run headband docs export again.`
1570
+ ].join("\n");
1571
+ }
1572
+ function exportFailure(error, index) {
1573
+ const advice = exportRefusalAdvice(error, index);
1574
+ if (!advice || !(error instanceof ApiError)) return error;
1575
+ return restate(error, `${error.message}
1576
+
1577
+ ${advice}`);
1578
+ }
1579
+
1580
+ // src/content-drift.ts
1581
+ async function readRemoteDocuments(client, index, primaryKey) {
1582
+ const batchSize = await defaultExportBatchSize(client, index);
1583
+ const docs = /* @__PURE__ */ new Map();
1584
+ for await (const { results } of walkDocuments(client, { index, batchSize })) {
1585
+ for (const doc of results) {
1586
+ const id = doc?.[primaryKey];
1587
+ if (id !== void 0 && id !== null) docs.set(String(id), doc);
1588
+ }
1589
+ }
1590
+ return docs;
1591
+ }
1592
+ function wire(value) {
1593
+ return value === void 0 ? null : JSON.parse(JSON.stringify(value));
1594
+ }
1595
+ function canonical(value) {
1596
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
1597
+ if (value !== null && typeof value === "object") {
1598
+ const entries = Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`);
1599
+ return `{${entries.join(",")}}`;
1600
+ }
1601
+ return JSON.stringify(value ?? null);
1602
+ }
1603
+ function documentsMatch(expected, remote, primaryKey) {
1604
+ if (!remote) return false;
1605
+ for (const [key, raw] of Object.entries(expected)) {
1606
+ if (key === primaryKey) {
1607
+ if (String(raw) !== String(remote[key])) return false;
1608
+ continue;
1609
+ }
1610
+ if (canonical(wire(raw)) !== canonical(wire(remote[key]))) return false;
1611
+ }
1612
+ return true;
1613
+ }
769
1614
 
770
1615
  // ../../src/lib/density/catalog-constants.ts
771
1616
  function formatMemory(mb) {
@@ -774,6 +1619,7 @@ function formatMemory(mb) {
774
1619
 
775
1620
  // ../../src/lib/density/snippets.ts
776
1621
  var ADMIN_NOTE = "Creating an index and adding a document need an admin key. A search key can only search.";
1622
+ var ENGINE_ONLY_NOTE = "This index lives only on this engine. Headband's dashboard, Search and Analytics do not list it.";
777
1623
  function visorSnippets(endpoint) {
778
1624
  return {
779
1625
  packageName: "built-in fetch",
@@ -786,6 +1632,7 @@ function visorSnippets(endpoint) {
786
1632
  "};",
787
1633
  "",
788
1634
  `// ${ADMIN_NOTE}`,
1635
+ `// ${ENGINE_ONLY_NOTE}`,
789
1636
  "// Creating the index is safe to repeat: an existing index is kept as it is.",
790
1637
  'await fetch(`${base}/v1/indexes/movies`, { method: "PUT", headers, body: JSON.stringify({ primaryKey: "id" }) });',
791
1638
  'await fetch(`${base}/v1/indexes/movies/documents`, { method: "POST", headers, body: JSON.stringify([{ id: 1, title: "The Matrix" }]) });',
@@ -795,6 +1642,7 @@ function visorSnippets(endpoint) {
795
1642
  ].join("\n"),
796
1643
  curl: [
797
1644
  `# ${ADMIN_NOTE}`,
1645
+ `# ${ENGINE_ONLY_NOTE}`,
798
1646
  "# On a second run the index already exists and PUT keeps it. That is fine.",
799
1647
  `curl -X PUT "${endpoint}/v1/indexes/movies" \\`,
800
1648
  ' -H "Authorization: Bearer $HEADBAND_API_KEY" \\',
@@ -830,6 +1678,7 @@ function connectSnippets(engine, endpoint) {
830
1678
  "});",
831
1679
  "",
832
1680
  `// ${ADMIN_NOTE}`,
1681
+ `// ${ENGINE_ONLY_NOTE}`,
833
1682
  'if (!(await client.indices.exists({ index: "movies" }))) {',
834
1683
  ' await client.indices.create({ index: "movies" });',
835
1684
  "}",
@@ -840,6 +1689,7 @@ function connectSnippets(engine, endpoint) {
840
1689
  ].join("\n"),
841
1690
  curl: [
842
1691
  `# ${ADMIN_NOTE}`,
1692
+ `# ${ENGINE_ONLY_NOTE}`,
843
1693
  "# On a second run this answers that the index already exists. That is fine.",
844
1694
  `curl -X PUT "${endpoint}/movies" \\`,
845
1695
  ' -H "Authorization: ApiKey $HEADBAND_API_KEY"',
@@ -877,6 +1727,7 @@ function connectSnippets(engine, endpoint) {
877
1727
  "});",
878
1728
  "",
879
1729
  `// ${ADMIN_NOTE}`,
1730
+ `// ${ENGINE_ONLY_NOTE}`,
880
1731
  "// A 409 means the collection exists from an earlier run.",
881
1732
  "await client.collections().create({",
882
1733
  ' name: "movies",',
@@ -891,6 +1742,7 @@ function connectSnippets(engine, endpoint) {
891
1742
  ].join("\n"),
892
1743
  curl: [
893
1744
  `# ${ADMIN_NOTE}`,
1745
+ `# ${ENGINE_ONLY_NOTE}`,
894
1746
  "# On a second run this answers that the collection already exists. That is fine.",
895
1747
  `curl "${endpoint}/collections" \\`,
896
1748
  ' -H "X-TYPESENSE-API-KEY: $HEADBAND_API_KEY" \\',
@@ -919,6 +1771,7 @@ function connectSnippets(engine, endpoint) {
919
1771
  "});",
920
1772
  "",
921
1773
  `// ${ADMIN_NOTE}`,
1774
+ `// ${ENGINE_ONLY_NOTE}`,
922
1775
  "// waitTask() waits until Meilisearch has applied the change. On a second run",
923
1776
  "// the create task fails because the index exists, which is fine.",
924
1777
  'await client.createIndex("movies", { primaryKey: "id" }).waitTask();',
@@ -929,6 +1782,7 @@ function connectSnippets(engine, endpoint) {
929
1782
  ].join("\n"),
930
1783
  curl: [
931
1784
  `# ${ADMIN_NOTE}`,
1785
+ `# ${ENGINE_ONLY_NOTE}`,
932
1786
  "# On a second run this task fails because the index already exists. That is fine.",
933
1787
  `curl "${endpoint}/indexes" \\`,
934
1788
  ' -H "Authorization: Bearer $HEADBAND_API_KEY" \\',
@@ -951,54 +1805,10 @@ function connectSnippets(engine, endpoint) {
951
1805
  };
952
1806
  }
953
1807
 
954
- // src/output.ts
955
- var defaultIO = {
956
- stdout: (message = "") => console.log(message),
957
- stderr: (message = "") => console.error(message)
958
- };
959
- function printJson(io, value) {
960
- io.stdout(JSON.stringify(value, null, 2));
961
- }
962
- function formatTable(rows, columns) {
963
- if (rows.length === 0) return "No results.";
964
- const selectedColumns = columns || Object.keys(rows[0]);
965
- const widths = selectedColumns.map(
966
- (column) => Math.max(
967
- column.length,
968
- ...rows.map(
969
- (row) => String(row[column] === null || row[column] === void 0 ? "" : row[column]).length
970
- )
971
- )
972
- );
973
- const renderRow = (values) => values.map((value, index) => value.padEnd(widths[index])).join(" ").trimEnd();
974
- return [
975
- renderRow(selectedColumns),
976
- renderRow(widths.map((width) => "-".repeat(width))),
977
- ...rows.map(
978
- (row) => renderRow(
979
- selectedColumns.map(
980
- (column) => String(row[column] === null || row[column] === void 0 ? "" : row[column])
981
- )
982
- )
983
- )
984
- ].join("\n");
985
- }
986
- function printTable(io, rows, columns) {
987
- io.stdout(formatTable(rows, columns));
988
- }
989
- function summarizeHit(hit) {
990
- const result = {};
991
- for (const key of Object.keys(hit).slice(0, 5)) {
992
- const value = hit[key];
993
- result[key] = value && typeof value === "object" ? JSON.stringify(value) : value;
994
- }
995
- return result;
996
- }
997
-
998
1808
  // src/instances.ts
999
1809
  var ENGINES = ["meilisearch", "elasticsearch", "typesense", "visor"];
1000
1810
  var CLIENTS = ["node", "curl"];
1001
- var COLUMNS = ["id", "name", "engine", "lane", "memory", "region", "state"];
1811
+ var COLUMNS2 = ["id", "name", "engine", "lane", "memory", "region", "state"];
1002
1812
  var INSTANCES_USAGE = [
1003
1813
  "Usage:",
1004
1814
  " headband instances list [--json]",
@@ -1093,7 +1903,7 @@ async function runInstances(args, flags, io, client, confirm) {
1093
1903
  ...summaryRow(i),
1094
1904
  "wake p95": i.wakeP95Ms === null || i.wakeP95Ms === void 0 ? "" : `${Math.round(i.wakeP95Ms)} ms`
1095
1905
  })),
1096
- [...COLUMNS, "wake p95"]
1906
+ [...COLUMNS2, "wake p95"]
1097
1907
  );
1098
1908
  return;
1099
1909
  }
@@ -1115,7 +1925,7 @@ async function runInstances(args, flags, io, client, confirm) {
1115
1925
  if (idle) body.idleTimeoutS = parsePositiveInt(idle, "--idle-timeout");
1116
1926
  const res = await client.post("/v1/instances", body);
1117
1927
  if (json) return printJson(io, res);
1118
- printTable(io, [summaryRow(res.data)], COLUMNS);
1928
+ printTable(io, [summaryRow(res.data)], COLUMNS2);
1119
1929
  io.stdout(`Endpoint: ${res.data.endpoint}`);
1120
1930
  io.stdout(`Connect a client: headband instances connect ${res.data.id}`);
1121
1931
  return;
@@ -1136,7 +1946,10 @@ async function runInstances(args, flags, io, client, confirm) {
1136
1946
  ["endpoint", i.endpoint],
1137
1947
  ["idle timeout", i.lane === "serverless" ? `${i.idleTimeoutS} s` : "n/a"],
1138
1948
  ["default", i.isDefault ? "yes" : "no"],
1139
- ["last backup", i.lastBackupAt ?? "never"],
1949
+ [
1950
+ "last backup",
1951
+ i.bucketHome ? "not needed (every write is saved to object storage)" : i.lastBackupAt ?? "never"
1952
+ ],
1140
1953
  ["last wake", i.lastWakeAt ?? "never"]
1141
1954
  ];
1142
1955
  const width = Math.max(...lines.map(([k]) => k.length));
@@ -1178,7 +1991,7 @@ async function runInstances(args, flags, io, client, confirm) {
1178
1991
  }
1179
1992
  const res = await client.patch(path2(id), body);
1180
1993
  if (json) return printJson(io, res);
1181
- printTable(io, [summaryRow(res.data)], COLUMNS);
1994
+ printTable(io, [summaryRow(res.data)], COLUMNS2);
1182
1995
  if (res.data.pendingChanges) {
1183
1996
  io.stdout(
1184
1997
  "The engine applies the change in the background. Check it with: headband instances show " + id
@@ -1261,9 +2074,144 @@ async function runInstances(args, flags, io, client, confirm) {
1261
2074
  }
1262
2075
  }
1263
2076
 
1264
- // src/schema.ts
1265
- var import_fs6 = require("fs");
1266
- var import_path7 = require("path");
2077
+ // src/warm.ts
2078
+ var LEVELS = ["auto", "stage", "start"];
2079
+ var COLUMNS3 = ["index", "engine", "state", "reason"];
2080
+ var WARM_USAGE = [
2081
+ "Usage:",
2082
+ " headband warm <index>... [--branch <name>] [--level <auto|stage|start>] [--json]",
2083
+ " headband warm --project <id> [--branch <name>] [--level <auto|stage|start>] [--json]",
2084
+ "",
2085
+ "Wakes sleeping engines ahead of traffic. stage loads an engine's data onto its server; start also starts it.",
2086
+ "auto, the default, starts an engine used in the last 24 hours and stages an older one. Warming is free."
2087
+ ].join("\n");
2088
+ async function runWarm(args, flags, io, client) {
2089
+ const str2 = (name) => typeof flags[name] === "string" ? flags[name] : void 0;
2090
+ const project = str2("--project");
2091
+ const level = str2("--level") ?? "auto";
2092
+ if (!LEVELS.includes(level)) throw new UsageError("--level must be auto, stage or start");
2093
+ const named = args.length > 0;
2094
+ if (named === (project !== void 0))
2095
+ throw new UsageError("Name the indexes to warm, or --project, but not both.\n\n" + WARM_USAGE);
2096
+ const branch = str2("--branch");
2097
+ const on = branch !== void 0 ? { branch } : {};
2098
+ const body = project !== void 0 ? { project, ...on, level } : { indexes: args, ...on, level };
2099
+ const res = await client.post("/v1/warm", body);
2100
+ if (flags["--json"] === true) return printJson(io, res);
2101
+ printTable(
2102
+ io,
2103
+ res.indexes.map((r) => ({
2104
+ index: r.index,
2105
+ engine: r.engine,
2106
+ state: r.state,
2107
+ reason: r.reason ?? ""
2108
+ })),
2109
+ COLUMNS3
2110
+ );
2111
+ }
2112
+
2113
+ // src/switch-engine.ts
2114
+ var PHASES = {
2115
+ queued: "waiting to start",
2116
+ copying: "copying documents",
2117
+ draining: "holding writes while in-flight requests finish",
2118
+ catching_up: "copying the last changes and checking every document",
2119
+ cleaning: "switched; removing the old copy",
2120
+ succeeded: "done",
2121
+ failed: "stopped"
2122
+ };
2123
+ function printSettings(io, settings) {
2124
+ if (!settings) return;
2125
+ if (settings.carried.length > 0) io.stdout(`Carried over: ${settings.carried.join(", ")}`);
2126
+ for (const d of settings.drops) {
2127
+ io.stdout(`Not carried: ${d.setting}${d.kept ? " (kept, not applied)" : ""}. ${d.reason}`);
2128
+ }
2129
+ for (const a of settings.added) {
2130
+ const value = Array.isArray(a.value) ? a.value.join(", ") : String(a.value);
2131
+ io.stdout(`Added: ${a.setting} ${value}. ${a.reason}`);
2132
+ }
2133
+ }
2134
+ async function runSwitchEngine(client, opts, io, chatter) {
2135
+ const path2 = `/v1/indexes/${encodeURIComponent(opts.uid)}/engine`;
2136
+ const body = { engine: opts.engine };
2137
+ if (opts.instanceId) body.instanceId = opts.instanceId;
2138
+ if (opts.acceptDrops) body.acceptDrops = true;
2139
+ if (opts.declareKey) body.declareKey = true;
2140
+ if (opts.dryRun) body.dryRun = true;
2141
+ let started;
2142
+ try {
2143
+ started = await client.post(path2, body);
2144
+ } catch (error) {
2145
+ if (error instanceof ApiError && error.code === "settings_not_portable" && !opts.json && error.details && typeof error.details === "object") {
2146
+ printSettings(chatter, error.details.settings);
2147
+ chatter.stdout("Run again with --accept-drops to switch anyway.");
2148
+ }
2149
+ throw error;
2150
+ }
2151
+ if (opts.dryRun) {
2152
+ if (opts.json) return printJson(io, started);
2153
+ const target = started.target;
2154
+ io.stdout(
2155
+ `${opts.uid}: ${String(started.documents)} documents from ${String(started.fromEngine)} to ${String(started.toEngine)}, on ${target.willCreate ? "a new engine of the branch" : target.instanceId ? `engine ${target.instanceId}` : "the shared engine"}.`
2156
+ );
2157
+ printSettings(io, started.settings);
2158
+ for (const n of started.notes ?? []) io.stdout(n);
2159
+ io.stdout("Dry run: nothing was changed.");
2160
+ return;
2161
+ }
2162
+ let view = started;
2163
+ if (!opts.wait) {
2164
+ if (opts.json) return printJson(io, view);
2165
+ io.stdout(
2166
+ `Switching ${opts.uid} from ${view.fromEngine} to ${view.toEngine} (${view.switchId}). Follow it with: headband indexes switch-engine ${opts.uid} --status`
2167
+ );
2168
+ return;
2169
+ }
2170
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
2171
+ let lastPhase = "";
2172
+ for (; ; ) {
2173
+ if (view.phase !== lastPhase) {
2174
+ lastPhase = view.phase;
2175
+ chatter.stdout(`${opts.uid}: ${PHASES[view.phase] ?? view.phase}`);
2176
+ }
2177
+ if (view.status === "succeeded" || view.status === "failed") break;
2178
+ await sleep(opts.pollMs ?? 3e3);
2179
+ const status = await client.get(path2);
2180
+ if (!status.switch || status.switch.switchId !== view.switchId) {
2181
+ throw new CliError("The switch is no longer listed for this index.", {
2182
+ exitCode: EXIT_CODES.generic,
2183
+ code: "switch_not_found"
2184
+ });
2185
+ }
2186
+ view = status.switch;
2187
+ }
2188
+ if (opts.json) printJson(io, view);
2189
+ else if (view.status === "succeeded") {
2190
+ io.stdout(
2191
+ `${opts.uid} now runs on ${view.toEngine}${view.documents.verified !== null ? `; ${view.documents.verified} documents verified` : ""}.`
2192
+ );
2193
+ for (const n of view.notes ?? []) io.stdout(n);
2194
+ }
2195
+ if (view.status === "failed") {
2196
+ throw new CliError(view.error?.message ?? "The switch failed.", {
2197
+ exitCode: EXIT_CODES.conflict,
2198
+ code: view.error?.code ?? "switch_failed"
2199
+ });
2200
+ }
2201
+ }
2202
+ async function printSwitchStatus(client, uid, io, json) {
2203
+ const status = await client.get(
2204
+ `/v1/indexes/${encodeURIComponent(uid)}/engine`
2205
+ );
2206
+ if (json) return printJson(io, status);
2207
+ io.stdout(`${uid} runs on ${status.engine}.`);
2208
+ const s = status.switch;
2209
+ if (!s) return;
2210
+ io.stdout(
2211
+ `Latest switch ${s.switchId}: ${s.fromEngine} to ${s.toEngine}, ${s.status} (${PHASES[s.phase] ?? s.phase}).`
2212
+ );
2213
+ if (s.error) io.stdout(s.error.message);
2214
+ }
1267
2215
 
1268
2216
  // ../sync/src/hash.ts
1269
2217
  var import_node_crypto = require("crypto");
@@ -1308,68 +2256,6 @@ function settingsHash(def) {
1308
2256
  return (0, import_node_crypto.createHash)("sha256").update(stableStringify(settings)).digest("hex");
1309
2257
  }
1310
2258
 
1311
- // ../sync/src/migrations.ts
1312
- var import_fs5 = require("fs");
1313
- var import_path6 = require("path");
1314
- var FILE_NAME_RE = /^(\d{4})_(.+)\.json$/;
1315
- function sanitizeSlug(slug) {
1316
- const sanitized = slug.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
1317
- return sanitized || "migration";
1318
- }
1319
- function generateMigrationFile(dir, ops, meta, now = () => /* @__PURE__ */ new Date()) {
1320
- (0, import_fs5.mkdirSync)(dir, { recursive: true });
1321
- const existing = readMigrations(dir);
1322
- const nextVersion = existing.length === 0 ? 1 : existing[existing.length - 1].version + 1;
1323
- const safeSlug = sanitizeSlug(meta.slug);
1324
- const fileName = `${String(nextVersion).padStart(4, "0")}_${safeSlug}.json`;
1325
- const path2 = (0, import_path6.join)(dir, fileName);
1326
- const file = {
1327
- version: nextVersion,
1328
- createdAt: now().toISOString(),
1329
- parentHashes: meta.parentHashes,
1330
- ops
1331
- };
1332
- (0, import_fs5.writeFileSync)(path2, JSON.stringify(file, null, 2));
1333
- return path2;
1334
- }
1335
- function readMigrations(dir) {
1336
- let entries;
1337
- try {
1338
- entries = (0, import_fs5.readdirSync)(dir);
1339
- } catch {
1340
- return [];
1341
- }
1342
- const files = [];
1343
- for (const entry of entries) {
1344
- const match = FILE_NAME_RE.exec(entry);
1345
- if (!match) continue;
1346
- const path2 = (0, import_path6.join)(dir, entry);
1347
- const raw = (0, import_fs5.readFileSync)(path2, "utf-8");
1348
- let parsed;
1349
- try {
1350
- parsed = JSON.parse(raw);
1351
- } catch {
1352
- throw new Error(`Malformed migration file: ${path2}`);
1353
- }
1354
- files.push({ ...parsed, path: path2 });
1355
- }
1356
- files.sort((a, b) => a.version - b.version);
1357
- for (let i = 0; i < files.length; i++) {
1358
- const expected = i + 1;
1359
- if (files[i].version === expected) continue;
1360
- if (files.some((f, idx) => idx !== i && f.version === files[i].version)) {
1361
- throw new Error(`Duplicate migration version: ${files[i].version}`);
1362
- }
1363
- throw new Error(
1364
- `Migration version gap: expected version ${expected}, found ${files[i].version}`
1365
- );
1366
- }
1367
- return files;
1368
- }
1369
- function pendingMigrations(files, appliedVersion) {
1370
- return files.filter((f) => f.version > appliedVersion);
1371
- }
1372
-
1373
2259
  // ../sync/src/planner.ts
1374
2260
  function planMigration(config, applied, opts) {
1375
2261
  const ops = [];
@@ -1425,18 +2311,85 @@ function planMigration(config, applied, opts) {
1425
2311
  return ops;
1426
2312
  }
1427
2313
 
1428
- // ../sync/src/client.ts
1429
- var BASE_BACKOFF_MS = 500;
1430
- var DEFAULT_MAX_RETRIES = 5;
1431
- function defaultSleep2(ms) {
1432
- return new Promise((resolve) => setTimeout(resolve, ms));
2314
+ // src/schema.ts
2315
+ var import_fs7 = require("fs");
2316
+ var import_path9 = require("path");
2317
+
2318
+ // ../sync/src/migrations.ts
2319
+ var import_fs6 = require("fs");
2320
+ var import_path8 = require("path");
2321
+ var FILE_NAME_RE = /^(\d{4})_(.+)\.json$/;
2322
+ function sanitizeSlug(slug) {
2323
+ const sanitized = slug.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
2324
+ return sanitized || "migration";
1433
2325
  }
1434
- var SchemaCasError = class extends Error {
1435
- constructor(message) {
1436
- super(message);
1437
- this.name = "SchemaCasError";
2326
+ function generateMigrationFile(dir, ops, meta, now = () => /* @__PURE__ */ new Date()) {
2327
+ (0, import_fs6.mkdirSync)(dir, { recursive: true });
2328
+ const existing = readMigrations(dir);
2329
+ const nextVersion = existing.length === 0 ? 1 : existing[existing.length - 1].version + 1;
2330
+ const safeSlug = sanitizeSlug(meta.slug);
2331
+ const fileName = `${String(nextVersion).padStart(4, "0")}_${safeSlug}.json`;
2332
+ const path2 = (0, import_path8.join)(dir, fileName);
2333
+ const file = {
2334
+ version: nextVersion,
2335
+ createdAt: now().toISOString(),
2336
+ parentHashes: meta.parentHashes,
2337
+ ...meta.targetHashes ? { targetHashes: meta.targetHashes } : {},
2338
+ ops
2339
+ };
2340
+ (0, import_fs6.writeFileSync)(path2, JSON.stringify(file, null, 2));
2341
+ return path2;
2342
+ }
2343
+ function readMigrations(dir) {
2344
+ let entries;
2345
+ try {
2346
+ entries = (0, import_fs6.readdirSync)(dir);
2347
+ } catch {
2348
+ return [];
1438
2349
  }
1439
- };
2350
+ const files = [];
2351
+ for (const entry of entries) {
2352
+ const match = FILE_NAME_RE.exec(entry);
2353
+ if (!match) continue;
2354
+ const path2 = (0, import_path8.join)(dir, entry);
2355
+ const raw = (0, import_fs6.readFileSync)(path2, "utf-8");
2356
+ let parsed;
2357
+ try {
2358
+ parsed = JSON.parse(raw);
2359
+ } catch {
2360
+ throw new Error(`Malformed migration file: ${path2}`);
2361
+ }
2362
+ files.push({ ...parsed, path: path2 });
2363
+ }
2364
+ files.sort((a, b) => a.version - b.version);
2365
+ for (let i = 0; i < files.length; i++) {
2366
+ const expected = i + 1;
2367
+ if (files[i].version === expected) continue;
2368
+ if (files.some((f, idx) => idx !== i && f.version === files[i].version)) {
2369
+ throw new Error(`Duplicate migration version: ${files[i].version}`);
2370
+ }
2371
+ throw new Error(
2372
+ `Migration version gap: expected version ${expected}, found ${files[i].version}`
2373
+ );
2374
+ }
2375
+ return files;
2376
+ }
2377
+ function pendingMigrations(files, appliedVersion) {
2378
+ return files.filter((f) => f.version > appliedVersion);
2379
+ }
2380
+
2381
+ // ../sync/src/client.ts
2382
+ var BASE_BACKOFF_MS = 500;
2383
+ var DEFAULT_MAX_RETRIES = 5;
2384
+ function defaultSleep3(ms) {
2385
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
2386
+ }
2387
+ var SchemaCasError = class extends Error {
2388
+ constructor(message) {
2389
+ super(message);
2390
+ this.name = "SchemaCasError";
2391
+ }
2392
+ };
1440
2393
  var HttpError = class extends Error {
1441
2394
  constructor(message, status, code) {
1442
2395
  super(message);
@@ -1472,7 +2425,7 @@ var HeadbandClient = class {
1472
2425
  this.adminKey = opts.adminKey;
1473
2426
  this.fetchImpl = opts.fetchImpl ?? fetch;
1474
2427
  this.maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES;
1475
- this.sleep = opts.sleep ?? defaultSleep2;
2428
+ this.sleep = opts.sleep ?? defaultSleep3;
1476
2429
  }
1477
2430
  async bulkPush(index, documents, primaryKey) {
1478
2431
  const body = { index, documents };
@@ -1492,11 +2445,24 @@ var HeadbandClient = class {
1492
2445
  );
1493
2446
  return isRecord2(result) && "partialFailure" in result ? { partialFailure: result.partialFailure } : {};
1494
2447
  }
2448
+ /**
2449
+ * `projectId` places the index in that project (its production branch) when the key is
2450
+ * org-wide, such as the CLI's login key. A project key's indexes always land in its project.
2451
+ */
1495
2452
  async createIndex(uid, opts) {
1496
2453
  const body = { uid };
1497
2454
  if (opts?.primaryKey !== void 0) body.primaryKey = opts.primaryKey;
2455
+ if (opts?.projectId !== void 0) body.projectId = opts.projectId;
1498
2456
  await this.request("POST", "/v1/indexes", body);
1499
2457
  }
2458
+ /** The index (GET /v1/indexes/{uid}): its engine among the rest. */
2459
+ async getIndex(uid) {
2460
+ return this.request("GET", `/v1/indexes/${encodeURIComponent(uid)}`);
2461
+ }
2462
+ /** Deletes an index (DELETE /v1/indexes/{uid}): `schema apply` removes a reindex it could not set up. */
2463
+ async deleteIndex(uid) {
2464
+ await this.request("DELETE", `/v1/indexes/${encodeURIComponent(uid)}`);
2465
+ }
1500
2466
  async patchSettings(uid, settings) {
1501
2467
  await this.request("PATCH", `/v1/indexes/${uid}/settings`, settings);
1502
2468
  }
@@ -1506,9 +2472,18 @@ var HeadbandClient = class {
1506
2472
  async postSchemaState(state) {
1507
2473
  await this.request("POST", "/v1/schema/state", state, { mapConflictToCasError: true });
1508
2474
  }
2475
+ /** Removes an index's applied schema state (DELETE /v1/schema/state?indexUid=). */
2476
+ async deleteSchemaState(indexUid) {
2477
+ await this.request("DELETE", `/v1/schema/state?indexUid=${encodeURIComponent(indexUid)}`);
2478
+ }
1509
2479
  async patchAlias(index, physicalUid) {
1510
2480
  await this.request("PATCH", "/v1/schema/alias", { indexUid: index, physicalUid });
1511
2481
  }
2482
+ /**
2483
+ * Records a connector run (POST /v1/connectors/runs). The same `externalRunId` updates the
2484
+ * run it created. `status` is one of started, succeeded, failed, skipped, dead_letter. An
2485
+ * org-wide key must name its `projectId`.
2486
+ */
1512
2487
  async reportRun(run2) {
1513
2488
  await this.request("POST", "/v1/connectors/runs", run2);
1514
2489
  }
@@ -1573,22 +2548,35 @@ function printOpsTable(io, ops) {
1573
2548
  printTable(io, opRows(ops), ["op", "index", "detail"]);
1574
2549
  }
1575
2550
  function readAppliedMarker(dir) {
1576
- const path2 = (0, import_path7.join)(dir, APPLIED_MARKER_FILE);
1577
- if (!(0, import_fs6.existsSync)(path2)) return 0;
2551
+ const path2 = (0, import_path9.join)(dir, APPLIED_MARKER_FILE);
2552
+ if (!(0, import_fs7.existsSync)(path2)) return 0;
1578
2553
  try {
1579
- const parsed = JSON.parse((0, import_fs6.readFileSync)(path2, "utf-8"));
2554
+ const parsed = JSON.parse((0, import_fs7.readFileSync)(path2, "utf-8"));
1580
2555
  return typeof parsed.version === "number" ? parsed.version : 0;
1581
2556
  } catch {
1582
2557
  return 0;
1583
2558
  }
1584
2559
  }
1585
2560
  function writeAppliedMarker(dir, version) {
1586
- (0, import_fs6.mkdirSync)(dir, { recursive: true });
1587
- (0, import_fs6.writeFileSync)((0, import_path7.join)(dir, APPLIED_MARKER_FILE), JSON.stringify({ version }, null, 2));
2561
+ (0, import_fs7.mkdirSync)(dir, { recursive: true });
2562
+ (0, import_fs7.writeFileSync)((0, import_path9.join)(dir, APPLIED_MARKER_FILE), JSON.stringify({ version }, null, 2));
1588
2563
  }
1589
- async function planSchemaDiff(deps) {
1590
- const applied = await deps.client.getSchemaState();
1591
- return planMigration(deps.config, applied, { allowDestructive: deps.allowDestructive });
2564
+ function configHashes(config, index) {
2565
+ const def = config.indexes.find((d) => d.name === index);
2566
+ return def ? { schemaHash: docShapeHash(def), settingsHash: settingsHash(def) } : void 0;
2567
+ }
2568
+ function orphanedStates(applied, indexUids) {
2569
+ return applied.filter((a) => !indexUids.has(a.indexUid) && !indexUids.has(a.physicalUid));
2570
+ }
2571
+ function orphanHint(orphans) {
2572
+ const names = orphans.map((o) => o.indexUid).join(", ");
2573
+ return `Schema state is recorded for ${names}, but no such index exists any more. Clear it with "headband schema forget <index>", then run "headband schema generate" and "headband schema apply" to create the index again.`;
2574
+ }
2575
+ function warnOrphans(io, applied, indexUids) {
2576
+ if (!indexUids) return [];
2577
+ const orphans = orphanedStates(applied, indexUids);
2578
+ if (orphans.length > 0) io.stderr(orphanHint(orphans));
2579
+ return orphans;
1592
2580
  }
1593
2581
  async function runSchemaGenerate(deps) {
1594
2582
  const applied = await deps.client.getSchemaState();
@@ -1599,13 +2587,16 @@ async function runSchemaGenerate(deps) {
1599
2587
  }
1600
2588
  const appliedByUid = new Map(applied.map((a) => [a.indexUid, a]));
1601
2589
  const parentHashes = {};
2590
+ const targetHashes = {};
1602
2591
  for (const op of ops) {
1603
2592
  parentHashes[op.index] = appliedByUid.get(op.index)?.schemaHash ?? "";
2593
+ const target = configHashes(deps.config, op.index);
2594
+ if (target) targetHashes[op.index] = target;
1604
2595
  }
1605
2596
  const path2 = generateMigrationFile(
1606
2597
  deps.dir,
1607
2598
  ops,
1608
- { parentHashes, slug: deps.slug ?? "migration" },
2599
+ { parentHashes, slug: deps.slug ?? "migration", targetHashes },
1609
2600
  deps.now
1610
2601
  );
1611
2602
  deps.io.stdout(`Wrote ${path2}`);
@@ -1618,43 +2609,81 @@ function isConflictError(err) {
1618
2609
  }
1619
2610
  async function createIndexIdempotent(ctx, uid, opts) {
1620
2611
  try {
1621
- await ctx.client.createIndex(uid, opts);
2612
+ await ctx.client.createIndex(uid, {
2613
+ ...opts,
2614
+ ...ctx.projectId ? { projectId: ctx.projectId } : {}
2615
+ });
1622
2616
  } catch (err) {
1623
2617
  if (!isConflictError(err)) throw err;
1624
- ctx.io.stdout(`Index "${uid}" already exists \u2014 continuing.`);
2618
+ ctx.io.stdout(`Index "${uid}" already exists. Continuing.`);
2619
+ }
2620
+ }
2621
+ var ES_EVERY_FIELD = [
2622
+ "searchableAttributes",
2623
+ "filterableAttributes",
2624
+ "sortableAttributes"
2625
+ ];
2626
+ async function indexEngine(ctx, uid) {
2627
+ try {
2628
+ return (await ctx.client.getIndex(uid)).engine ?? null;
2629
+ } catch {
2630
+ return null;
2631
+ }
2632
+ }
2633
+ function settingsForEngine(ctx, uid, engine, settings) {
2634
+ if (engine !== "elasticsearch") return settings;
2635
+ const out = { ...settings };
2636
+ const left = [];
2637
+ for (const key of ES_EVERY_FIELD) {
2638
+ if (out[key] === void 0) continue;
2639
+ delete out[key];
2640
+ left.push(key);
2641
+ }
2642
+ if (left.length > 0) {
2643
+ ctx.io.stdout(
2644
+ `"${uid}" is on Elasticsearch, which searches, filters and sorts every field without these lists, so they are not sent: ${left.join(", ")}.`
2645
+ );
2646
+ }
2647
+ return out;
2648
+ }
2649
+ async function patchSettingsBeforeBackfill(ctx, uid, settings) {
2650
+ if (Object.keys(settings).length === 0) return false;
2651
+ try {
2652
+ await ctx.client.patchSettings(uid, settings);
2653
+ return false;
2654
+ } catch (err) {
2655
+ if (err?.code === "settings_need_documents") {
2656
+ ctx.io.stdout(
2657
+ `Settings for "${uid}" need its documents first: applying them after the backfill.`
2658
+ );
2659
+ return true;
2660
+ }
2661
+ throw settingsRefused(uid, err);
1625
2662
  }
1626
2663
  }
2664
+ async function patchSettingsAfterBackfill(ctx, uid, settings) {
2665
+ try {
2666
+ await ctx.client.patchSettings(uid, settings);
2667
+ } catch (err) {
2668
+ throw settingsRefused(uid, err);
2669
+ }
2670
+ }
2671
+ function settingsRefused(uid, err) {
2672
+ const message = err instanceof Error ? err.message : String(err);
2673
+ const { status, code } = err ?? {};
2674
+ const refused = new CliError(
2675
+ `Index "${uid}" exists, but its settings were refused: ${message.replace(/\.?$/, ".")} Change the index's settings in headband.config.ts, then re-run "headband schema apply": it picks up the existing index.`,
2676
+ {
2677
+ ...typeof status === "number" ? { exitCode: exitCodeForStatus(status) } : {},
2678
+ ...typeof code === "string" ? { code } : {}
2679
+ }
2680
+ );
2681
+ return Object.assign(refused, { refusal: message });
2682
+ }
1627
2683
  function postState(client, state) {
1628
2684
  return client.postSchemaState(state);
1629
2685
  }
1630
- async function applyReindex(op, ctx, existing, expectedParentHash) {
1631
- const def = ctx.config.indexes.find((d) => d.name === op.index);
1632
- if (!def) {
1633
- throw new CliError(`Cannot reindex "${op.index}": no matching index in the loaded config.`);
1634
- }
1635
- await createIndexIdempotent(ctx, op.toPhysicalUid, { primaryKey: def.primaryKey });
1636
- await ctx.client.patchSettings(op.toPhysicalUid, op.settings);
1637
- const version = (existing?.version ?? 0) + 1;
1638
- const schemaHashValue = docShapeHash(def);
1639
- const settingsHashValue = settingsHash(def);
1640
- let parentHash = expectedParentHash;
1641
- const writeState = async (status, cursor2) => {
1642
- await postState(ctx.client, {
1643
- indexUid: op.index,
1644
- version,
1645
- schemaHash: schemaHashValue,
1646
- settingsHash: settingsHashValue,
1647
- physicalUid: op.toPhysicalUid,
1648
- status,
1649
- // The `/v1/schema/state` route only accepts a string|null
1650
- // backfillCursor (see route.ts) — `Id` can be numeric, so it must be
1651
- // stringified before crossing the wire.
1652
- backfillCursor: cursor2 === null ? null : String(cursor2),
1653
- expectedParentHash: parentHash
1654
- });
1655
- parentHash = schemaHashValue;
1656
- };
1657
- await writeState("migrating", null);
2686
+ async function backfill(ctx, def, targetUid, abortPoint, onPage) {
1658
2687
  let cursor = null;
1659
2688
  let pushed = 0;
1660
2689
  let excluded = 0;
@@ -1675,24 +2704,137 @@ async function applyReindex(op, ctx, existing, expectedParentHash) {
1675
2704
  toPush.push(doc);
1676
2705
  }
1677
2706
  if (toPush.length > 0) {
1678
- const result = await ctx.client.bulkPush(op.toPhysicalUid, toPush, def.primaryKey);
2707
+ const result = await ctx.client.bulkPush(targetUid, toPush, def.primaryKey);
1679
2708
  if (result?.partialFailure) {
1680
2709
  throw new CliError(
1681
- `Backfill of "${op.index}" stopped: ${partialFailureSummary(result.partialFailure)}. Aborting before the alias flip. Fix the rejected documents, then re-run "headband schema apply".`
2710
+ `Backfill of "${def.name}" stopped: ${partialFailureSummary(result.partialFailure)}. Aborting ${abortPoint}. Fix the rejected documents, then re-run "headband schema apply".`
1682
2711
  );
1683
2712
  }
1684
2713
  pushed += toPush.length;
1685
2714
  }
1686
2715
  cursor = page.nextCursor;
1687
- await writeState("migrating", cursor);
2716
+ if (onPage) await onPage(cursor);
1688
2717
  if (cursor === null || page.ids.length < ctx.backfillBatchSize) break;
1689
2718
  }
1690
2719
  if (pushed + excluded !== total) {
1691
2720
  throw new CliError(
1692
- `Backfill count mismatch for "${op.index}": listed ${total} source row(s) but accounted for ${pushed + excluded} (${pushed} pushed, ${excluded} excluded by shouldIndex). Aborting before the alias flip \u2014 re-run "headband schema apply" once resolved.`
2721
+ `Backfill count mismatch for "${def.name}": listed ${total} source row(s) but accounted for ${pushed + excluded} (${pushed} pushed, ${excluded} excluded by shouldIndex). Aborting ${abortPoint}. Re-run "headband schema apply" once resolved.`
2722
+ );
2723
+ }
2724
+ ctx.io.stdout(
2725
+ `Backfilled "${def.name}": ${pushed} document(s) pushed${excluded > 0 ? `, ${excluded} row(s) left out by shouldIndex` : ""}.`
2726
+ );
2727
+ return { pushed, excluded };
2728
+ }
2729
+ async function catchUp(ctx, def, targetUid, when) {
2730
+ if (!ctx.readRemoteDocuments) return;
2731
+ const remote = await ctx.readRemoteDocuments(targetUid, def.primaryKey);
2732
+ const keep = /* @__PURE__ */ new Set();
2733
+ let pushed = 0;
2734
+ let cursor = null;
2735
+ for (; ; ) {
2736
+ const page = await ctx.adapter.listIds(def.source, cursor, ctx.backfillBatchSize);
2737
+ if (page.ids.length === 0) break;
2738
+ const docs = await ctx.adapter.resolveDocs(def.name, page.ids);
2739
+ const toPush = [];
2740
+ for (const doc of docs) {
2741
+ const sourceRow = doc.__source ?? doc;
2742
+ delete doc.__source;
2743
+ if (def.shouldIndex && !def.shouldIndex(sourceRow)) continue;
2744
+ const key = String(doc[def.primaryKey]);
2745
+ keep.add(key);
2746
+ if (!documentsMatch(doc, remote.get(key), def.primaryKey)) toPush.push(doc);
2747
+ }
2748
+ if (toPush.length > 0) {
2749
+ const result = await ctx.client.bulkPush(targetUid, toPush, def.primaryKey);
2750
+ if (result?.partialFailure) {
2751
+ throw new CliError(
2752
+ `Catching up "${def.name}" ${when} stopped: ${partialFailureSummary(result.partialFailure)}. Re-run "headband schema apply" once resolved.`
2753
+ );
2754
+ }
2755
+ pushed += toPush.length;
2756
+ }
2757
+ cursor = page.nextCursor;
2758
+ if (cursor === null || page.ids.length < ctx.backfillBatchSize) break;
2759
+ }
2760
+ const extra = [...remote.keys()].filter((key) => !keep.has(key));
2761
+ for (let offset = 0; offset < extra.length; offset += ctx.backfillBatchSize) {
2762
+ const result = await ctx.client.deleteDocs(
2763
+ targetUid,
2764
+ extra.slice(offset, offset + ctx.backfillBatchSize)
1693
2765
  );
2766
+ if (result?.partialFailure) {
2767
+ throw new CliError(
2768
+ `Catching up "${def.name}" ${when} stopped: ${partialFailureSummary(result.partialFailure)}. Re-run "headband schema apply" once resolved.`
2769
+ );
2770
+ }
2771
+ }
2772
+ ctx.io.stdout(
2773
+ `Caught up "${def.name}" ${when}: ${pushed} changed or new document(s) written, ${extra.length} removed.`
2774
+ );
2775
+ }
2776
+ async function abandonReindex(ctx, index, physical, err) {
2777
+ let removed;
2778
+ try {
2779
+ await ctx.client.deleteIndex(physical);
2780
+ removed = `The new index "${physical}" was removed`;
2781
+ } catch (deleteErr) {
2782
+ const why = deleteErr instanceof Error ? deleteErr.message : String(deleteErr);
2783
+ removed = `The new index "${physical}" could not be removed (${why}): remove it with "headband indexes delete ${physical}"`;
2784
+ }
2785
+ const reason = err?.refusal ?? (err instanceof Error ? err.message : String(err));
2786
+ return new CliError(
2787
+ `The reindex of "${index}" stopped: the new index's settings were refused: ${String(reason).replace(/\.?$/, ".")} ${removed}, and "${index}" is unchanged. Change the index's settings in headband.config.ts, then re-run "headband schema apply".`,
2788
+ {
2789
+ ...err instanceof CliError ? { exitCode: err.exitCode, code: err.code } : {}
2790
+ }
2791
+ );
2792
+ }
2793
+ async function applyReindex(op, ctx, existing, expectedParentHash) {
2794
+ const def = ctx.config.indexes.find((d) => d.name === op.index);
2795
+ if (!def) {
2796
+ throw new CliError(`Cannot reindex "${op.index}": no matching index in the loaded config.`);
2797
+ }
2798
+ const settings = settingsForEngine(ctx, op.index, await indexEngine(ctx, op.index), op.settings);
2799
+ await createIndexIdempotent(ctx, op.toPhysicalUid, { primaryKey: def.primaryKey });
2800
+ let settingsAfter;
2801
+ try {
2802
+ settingsAfter = await patchSettingsBeforeBackfill(ctx, op.toPhysicalUid, settings);
2803
+ } catch (err) {
2804
+ throw await abandonReindex(ctx, op.index, op.toPhysicalUid, err);
1694
2805
  }
2806
+ const version = (existing?.version ?? 0) + 1;
2807
+ const schemaHashValue = docShapeHash(def);
2808
+ const settingsHashValue = settingsHash(def);
2809
+ let parentHash = expectedParentHash;
2810
+ const writeState = async (status, cursor) => {
2811
+ await postState(ctx.client, {
2812
+ indexUid: op.index,
2813
+ version,
2814
+ schemaHash: schemaHashValue,
2815
+ settingsHash: settingsHashValue,
2816
+ physicalUid: op.toPhysicalUid,
2817
+ status,
2818
+ // The `/v1/schema/state` route only accepts a string|null
2819
+ // backfillCursor (see route.ts). `Id` can be numeric, so it must be
2820
+ // stringified before crossing the wire.
2821
+ backfillCursor: cursor === null ? null : String(cursor),
2822
+ expectedParentHash: parentHash
2823
+ });
2824
+ parentHash = schemaHashValue;
2825
+ };
2826
+ await writeState("migrating", null);
2827
+ await backfill(
2828
+ ctx,
2829
+ def,
2830
+ op.toPhysicalUid,
2831
+ "before the alias flip",
2832
+ (cursor) => writeState("migrating", cursor)
2833
+ );
2834
+ if (settingsAfter) await patchSettingsAfterBackfill(ctx, op.toPhysicalUid, settings);
2835
+ await catchUp(ctx, def, op.toPhysicalUid, "before the alias flip");
1695
2836
  await ctx.client.patchAlias(op.index, op.toPhysicalUid);
2837
+ await catchUp(ctx, def, op.index, "after the alias flip");
1696
2838
  await writeState("applied", null);
1697
2839
  return {
1698
2840
  indexUid: op.index,
@@ -1706,17 +2848,26 @@ async function applyReindex(op, ctx, existing, expectedParentHash) {
1706
2848
  async function applyOp(op, ctx, existing, expectedParentHash) {
1707
2849
  if (op.type === "create-index") {
1708
2850
  const def = ctx.config.indexes.find((d) => d.name === op.index);
1709
- await createIndexIdempotent(
2851
+ if (!def) {
2852
+ throw new CliError(
2853
+ `Cannot create "${op.index}": no matching index in the loaded config. Regenerate with "headband schema generate".`
2854
+ );
2855
+ }
2856
+ await createIndexIdempotent(ctx, op.index, { primaryKey: def.primaryKey });
2857
+ const settings = settingsForEngine(
1710
2858
  ctx,
1711
2859
  op.index,
1712
- def?.primaryKey ? { primaryKey: def.primaryKey } : void 0
2860
+ await indexEngine(ctx, op.index),
2861
+ op.settings
1713
2862
  );
1714
- await ctx.client.patchSettings(op.index, op.settings);
2863
+ const settingsAfter = await patchSettingsBeforeBackfill(ctx, op.index, settings);
2864
+ await backfill(ctx, def, op.index, "before recording the index as applied");
2865
+ if (settingsAfter) await patchSettingsAfterBackfill(ctx, op.index, settings);
1715
2866
  const next = {
1716
2867
  indexUid: op.index,
1717
2868
  version: 1,
1718
- schemaHash: def ? docShapeHash(def) : "",
1719
- settingsHash: def ? settingsHash(def) : "",
2869
+ schemaHash: docShapeHash(def),
2870
+ settingsHash: settingsHash(def),
1720
2871
  physicalUid: op.physicalUid,
1721
2872
  status: "applied"
1722
2873
  };
@@ -1727,7 +2878,13 @@ async function applyOp(op, ctx, existing, expectedParentHash) {
1727
2878
  if (!existing) {
1728
2879
  throw new CliError(`Cannot patch settings for "${op.index}": no applied schema state found.`);
1729
2880
  }
1730
- await ctx.client.patchSettings(op.index, op.settings);
2881
+ const settings = settingsForEngine(
2882
+ ctx,
2883
+ op.index,
2884
+ await indexEngine(ctx, op.index),
2885
+ op.settings
2886
+ );
2887
+ if (Object.keys(settings).length > 0) await ctx.client.patchSettings(op.index, settings);
1731
2888
  const def = ctx.config.indexes.find((d) => d.name === op.index);
1732
2889
  const next = {
1733
2890
  ...existing,
@@ -1741,31 +2898,60 @@ async function applyOp(op, ctx, existing, expectedParentHash) {
1741
2898
  return applyReindex(op, ctx, existing, expectedParentHash);
1742
2899
  }
1743
2900
  ctx.io.stdout(
1744
- `Skipping engine deletion for index "${op.index}" \u2014 not yet supported by the API. Remove it manually with "headband indexes delete ${op.index}" if needed.`
2901
+ `Skipping engine deletion for index "${op.index}": not yet supported by the API. Remove it manually with "headband indexes delete ${op.index}" if needed.`
1745
2902
  );
1746
2903
  return void 0;
1747
2904
  }
2905
+ function serverAppliedThrough(files, applied, config) {
2906
+ const byUid = new Map(applied.map((a) => [a.indexUid, a]));
2907
+ const lastFileFor = /* @__PURE__ */ new Map();
2908
+ for (const file of files) {
2909
+ for (const op of file.ops) lastFileFor.set(op.index, file.version);
2910
+ }
2911
+ const through = /* @__PURE__ */ new Map();
2912
+ for (const file of files) {
2913
+ for (const index of new Set(file.ops.map((op) => op.index))) {
2914
+ const state = byUid.get(index);
2915
+ if (!state || state.status !== "applied") continue;
2916
+ const target = file.targetHashes?.[index] ?? (lastFileFor.get(index) === file.version ? configHashes(config, index) : void 0);
2917
+ if (target && target.schemaHash === state.schemaHash && target.settingsHash === state.settingsHash) {
2918
+ through.set(index, file.version);
2919
+ }
2920
+ }
2921
+ }
2922
+ return through;
2923
+ }
1748
2924
  async function runSchemaApply(deps) {
1749
2925
  const files = readMigrations(deps.dir);
1750
2926
  const appliedVersion = readAppliedMarker(deps.dir);
1751
2927
  const pending = pendingMigrations(files, appliedVersion);
1752
2928
  if (pending.length === 0) {
1753
- deps.io.stdout("No pending migrations to apply.");
2929
+ deps.io.stdout(
2930
+ files.length === 0 ? `No migration files in ${deps.dir}. Run "headband schema generate" first.` : "No pending migrations to apply."
2931
+ );
1754
2932
  return { appliedFiles: 0, appliedOps: 0 };
1755
2933
  }
1756
2934
  const applied = await deps.client.getSchemaState();
1757
2935
  const existingByIndex = new Map(applied.map((a) => [a.indexUid, a]));
2936
+ const appliedThrough = serverAppliedThrough(files, applied, deps.config);
1758
2937
  const ctx = {
1759
2938
  config: deps.config,
1760
2939
  adapter: deps.adapter,
1761
2940
  client: deps.client,
1762
2941
  backfillBatchSize: deps.backfillBatchSize ?? DEFAULT_BACKFILL_BATCH_SIZE,
1763
- io: deps.io
2942
+ io: deps.io,
2943
+ projectId: deps.projectId,
2944
+ readRemoteDocuments: deps.readRemoteDocuments
1764
2945
  };
1765
2946
  let appliedOps = 0;
1766
2947
  let appliedFiles = 0;
1767
2948
  for (const file of pending) {
2949
+ let skipped = 0;
1768
2950
  for (const op of file.ops) {
2951
+ if ((appliedThrough.get(op.index) ?? 0) >= file.version) {
2952
+ skipped += 1;
2953
+ continue;
2954
+ }
1769
2955
  if (op.type === "remove-index" && !deps.allowDestructive) {
1770
2956
  throw new CliError(
1771
2957
  `Migration ${file.version} contains a destructive "remove-index" op for "${op.index}". Re-run with --allow-destructive to proceed.`
@@ -1788,26 +2974,56 @@ async function runSchemaApply(deps) {
1788
2974
  } catch (err) {
1789
2975
  if (err instanceof SchemaCasError) {
1790
2976
  throw new CliError(
1791
- `Schema state for "${op.index}" was changed by another machine since this migration was generated \u2014 re-run "headband schema diff" and regenerate before retrying. (${err.message})`
2977
+ `Schema state for "${op.index}" was changed by another machine since this migration was generated. Re-run "headband schema diff" and regenerate before retrying. (${err.message})`
1792
2978
  );
1793
2979
  }
1794
2980
  throw err;
1795
2981
  }
1796
2982
  }
1797
2983
  writeAppliedMarker(deps.dir, file.version);
2984
+ if (skipped === file.ops.length) {
2985
+ deps.io.stdout(
2986
+ `Migration ${file.version}${file.path ? ` (${file.path})` : ""} is already applied on the server. Skipped.`
2987
+ );
2988
+ continue;
2989
+ }
1798
2990
  appliedFiles += 1;
1799
2991
  deps.io.stdout(`Applied migration ${file.version}${file.path ? ` (${file.path})` : ""}.`);
1800
2992
  }
1801
2993
  return { appliedFiles, appliedOps };
1802
2994
  }
2995
+ function serverAppliedVersion(files, applied, config) {
2996
+ const through = serverAppliedThrough(files, applied, config);
2997
+ const known = new Set(applied.map((a) => a.indexUid));
2998
+ let version = 0;
2999
+ for (const file of files) {
3000
+ const done = file.ops.every(
3001
+ (op) => op.type === "remove-index" ? !known.has(op.index) : (through.get(op.index) ?? 0) >= file.version
3002
+ );
3003
+ if (!done) break;
3004
+ version = file.version;
3005
+ }
3006
+ return version;
3007
+ }
1803
3008
  async function runSchemaStatus(deps) {
1804
3009
  const applied = await deps.client.getSchemaState();
1805
3010
  const appliedByUid = new Map(applied.map((a) => [a.indexUid, a]));
3011
+ const orphans = new Set(
3012
+ deps.indexUids ? orphanedStates(applied, deps.indexUids).map((o) => o.indexUid) : []
3013
+ );
1806
3014
  const rows = deps.config.indexes.map((def) => {
1807
3015
  const existing = appliedByUid.get(def.name);
1808
3016
  if (!existing) {
1809
3017
  return { index: def.name, status: "missing", version: 0, drift: "missing" };
1810
3018
  }
3019
+ if (orphans.has(def.name)) {
3020
+ return {
3021
+ index: def.name,
3022
+ status: existing.status,
3023
+ version: existing.version,
3024
+ drift: "index missing"
3025
+ };
3026
+ }
1811
3027
  const schemaDrifted = docShapeHash(def) !== existing.schemaHash;
1812
3028
  const settingsDrifted = settingsHash(def) !== existing.settingsHash;
1813
3029
  return {
@@ -1818,16 +3034,42 @@ async function runSchemaStatus(deps) {
1818
3034
  };
1819
3035
  });
1820
3036
  printTable(deps.io, rows, ["index", "status", "version", "drift"]);
3037
+ if (orphans.size > 0) {
3038
+ deps.io.stdout(orphanHint(applied.filter((a) => orphans.has(a.indexUid))));
3039
+ }
1821
3040
  const files = readMigrations(deps.dir);
1822
- const localVersion = readAppliedMarker(deps.dir);
3041
+ const markerVersion = readAppliedMarker(deps.dir);
1823
3042
  const localHead = files.length > 0 ? files[files.length - 1].version : 0;
1824
- deps.io.stdout(`Local migrations applied: ${localVersion}/${localHead}`);
1825
- return { indexes: rows, localApplied: localVersion, localHead };
3043
+ const present = applied.filter((a) => !orphans.has(a.indexUid));
3044
+ const localVersion = serverAppliedVersion(files, present, deps.config);
3045
+ deps.io.stdout(`Local migrations applied on the server: ${localVersion}/${localHead}`);
3046
+ if (markerVersion !== localVersion) {
3047
+ deps.io.stdout(
3048
+ `(${APPLIED_MARKER_FILE} says ${markerVersion}. That file is only a local cache; schema apply reads the server's state.)`
3049
+ );
3050
+ }
3051
+ return { indexes: rows, localApplied: localVersion, localHead, markerApplied: markerVersion };
3052
+ }
3053
+ async function runSchemaForget(deps) {
3054
+ try {
3055
+ await deps.client.deleteSchemaState(deps.index);
3056
+ } catch (err) {
3057
+ const status = err.status;
3058
+ if (status === 404) {
3059
+ throw new CliError(`No schema state is recorded for "${deps.index}".`, {
3060
+ code: "schema_state_not_found"
3061
+ });
3062
+ }
3063
+ throw err;
3064
+ }
3065
+ deps.io.stdout(
3066
+ `Forgot the schema state of "${deps.index}". Run "headband schema generate" and "headband schema apply" to create it again.`
3067
+ );
1826
3068
  }
1827
3069
 
1828
3070
  // src/sync.ts
1829
- var import_fs7 = require("fs");
1830
- var import_path8 = require("path");
3071
+ var import_fs8 = require("fs");
3072
+ var import_path10 = require("path");
1831
3073
  var import_crypto = require("crypto");
1832
3074
  var import_pg = require("pg");
1833
3075
 
@@ -1881,7 +3123,7 @@ function pgOutboxDriver(exec, opts) {
1881
3123
  return rows.map(mapRow);
1882
3124
  },
1883
3125
  async markDone(ids) {
1884
- await exec(`UPDATE ${table} SET status = 'done' WHERE id = ANY($1)`, [ids]);
3126
+ await exec(`DELETE FROM ${table} WHERE id = ANY($1)`, [ids]);
1885
3127
  },
1886
3128
  async deadLetter(id, error) {
1887
3129
  await exec(
@@ -1890,10 +3132,17 @@ function pgOutboxDriver(exec, opts) {
1890
3132
  );
1891
3133
  },
1892
3134
  async markFailed(id, error) {
1893
- await exec(
1894
- `UPDATE ${table} SET attempts = attempts + 1, last_error = $2, status = CASE WHEN attempts + 1 >= $3 THEN 'dead_letter' ELSE 'pending' END, claimed_at = NULL WHERE id = $1`,
3135
+ const { rows } = await exec(
3136
+ `UPDATE ${table} SET attempts = attempts + 1, last_error = $2, status = CASE WHEN attempts + 1 >= $3 THEN 'dead_letter' ELSE 'pending' END, claimed_at = NULL WHERE id = $1 RETURNING status`,
1895
3137
  [id, error, maxAttempts]
1896
3138
  );
3139
+ return rows?.[0]?.status === "dead_letter" ? "dead_letter" : "pending";
3140
+ },
3141
+ async release(ids, error) {
3142
+ await exec(
3143
+ `UPDATE ${table} SET last_error = $2, claimed_at = NULL WHERE id = ANY($1) AND status = 'pending'`,
3144
+ [ids, error]
3145
+ );
1897
3146
  },
1898
3147
  async deadLetters(limit) {
1899
3148
  const { rows } = await exec(
@@ -1912,6 +3161,18 @@ function pgOutboxDriver(exec, opts) {
1912
3161
  `SELECT count(*) AS count FROM ${table} WHERE status = 'pending'`
1913
3162
  );
1914
3163
  return Number(rows[0]?.count ?? 0);
3164
+ },
3165
+ async stats() {
3166
+ const { rows } = await exec(
3167
+ `SELECT count(*) FILTER (WHERE status = 'pending') AS pending, count(*) FILTER (WHERE status = 'dead_letter') AS dead_letter, min(created_at) FILTER (WHERE status = 'pending') AS oldest_pending_at FROM ${table}`
3168
+ );
3169
+ const row = rows[0] ?? {};
3170
+ const oldest = row.oldest_pending_at;
3171
+ return {
3172
+ pending: Number(row.pending ?? 0),
3173
+ deadLetter: Number(row.dead_letter ?? 0),
3174
+ oldestPendingAt: oldest == null ? null : new Date(oldest)
3175
+ };
1915
3176
  }
1916
3177
  };
1917
3178
  }
@@ -2088,18 +3349,43 @@ function rowOutcomes(rows, resolved, failures) {
2088
3349
  rows.forEach((row, changeIndex) => {
2089
3350
  const rowFailures = failuresByRow.get(changeIndex);
2090
3351
  if (!rowFailures) {
2091
- done.push(row.id);
3352
+ done.push(row);
2092
3353
  return;
2093
3354
  }
2094
3355
  const permanent = rowFailures.find((f) => !f.retryable);
2095
- if (permanent) deadLetter.push([row.id, permanent.message]);
2096
- else retry.push([row.id, rowFailures[0].message]);
3356
+ if (permanent) deadLetter.push([row, permanent.message]);
3357
+ else retry.push([row, rowFailures[0].message]);
2097
3358
  });
2098
3359
  return { done, retry, deadLetter };
2099
3360
  }
2100
- function defaultSleep3(ms) {
2101
- return new Promise((resolve) => setTimeout(resolve, ms));
2102
- }
3361
+ function defaultSleep4(ms) {
3362
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
3363
+ }
3364
+ function errorMessage(err) {
3365
+ if (!(err instanceof Error)) return String(err);
3366
+ const cause = err.cause;
3367
+ const reason = cause instanceof Error && cause.message ? cause.message : void 0;
3368
+ if (!reason || err.message.includes(reason)) return err.message;
3369
+ const first = err.message.split("\n")[0];
3370
+ return /^Failed query:/.test(first) ? `${reason} (${first.length > 160 ? `${first.slice(0, 160)}\u2026` : first})` : `${err.message}: ${reason}`;
3371
+ }
3372
+ function isTransientBatchError(err) {
3373
+ const status = err?.status;
3374
+ if (typeof status === "number") return status === 429 || status >= 500;
3375
+ return err instanceof TypeError && /fetch failed|network/i.test(err.message);
3376
+ }
3377
+ function describeRow(row) {
3378
+ const ids = row.docIds.length > 5 ? [...row.docIds.slice(0, 5), "\u2026"] : row.docIds;
3379
+ return `outbox row ${row.id} (${row.model} ${row.op} ${JSON.stringify(ids)})`;
3380
+ }
3381
+ var emptyOutcome = () => ({
3382
+ done: [],
3383
+ retry: [],
3384
+ release: [],
3385
+ deadLetter: [],
3386
+ pushed: 0,
3387
+ deleted: 0
3388
+ });
2103
3389
  async function runSyncWorker(opts) {
2104
3390
  const {
2105
3391
  config,
@@ -2113,8 +3399,9 @@ async function runSyncWorker(opts) {
2113
3399
  batchSize = DEFAULT_BATCH_SIZE,
2114
3400
  stopSignal,
2115
3401
  now = Date.now,
2116
- sleep = defaultSleep3,
2117
- onHeartbeat
3402
+ sleep = defaultSleep4,
3403
+ onHeartbeat,
3404
+ log = (message) => console.warn(message)
2118
3405
  } = opts;
2119
3406
  const primaryKeyByIndex = new Map(config.indexes.map((def) => [def.name, def.primaryKey]));
2120
3407
  const report = {
@@ -2122,11 +3409,59 @@ async function runSyncWorker(opts) {
2122
3409
  pushed: 0,
2123
3410
  deleted: 0,
2124
3411
  deadLettered: 0,
3412
+ retried: 0,
2125
3413
  outboxDepth: 0
2126
3414
  };
2127
3415
  const start = now();
2128
3416
  const budgetExceeded = () => budgetMs !== void 0 && now() - start >= budgetMs;
2129
3417
  let consecutiveFailures = 0;
3418
+ let heartbeatWarned = false;
3419
+ const heartbeat = async (final) => {
3420
+ if (!onHeartbeat) return;
3421
+ try {
3422
+ await onHeartbeat({ ...report }, { final });
3423
+ } catch (err) {
3424
+ if (heartbeatWarned) return;
3425
+ heartbeatWarned = true;
3426
+ log(`[headband] could not record this worker run: ${errorMessage(err)}`);
3427
+ }
3428
+ };
3429
+ const backoff = (attempt) => sleep(Math.min(failureDelayMs * 2 ** (attempt - 1), MAX_FAILURE_DELAY_MS));
3430
+ const processBatch = async (batch) => {
3431
+ const changes = batch.map((row) => ({
3432
+ model: row.model,
3433
+ op: row.op,
3434
+ ids: row.docIds,
3435
+ snapshot: row.snapshot
3436
+ }));
3437
+ const resolved = await resolveDirtyDocs(adapter, config, changes);
3438
+ const { failures, pushed, deleted } = await pushResolved(
3439
+ resolved,
3440
+ client,
3441
+ primaryKeyByIndex,
3442
+ backoff
3443
+ );
3444
+ return { ...rowOutcomes(batch, resolved, failures), release: [], pushed, deleted };
3445
+ };
3446
+ const processIsolated = async (batch) => {
3447
+ const outcome = emptyOutcome();
3448
+ for (const row of batch) {
3449
+ try {
3450
+ const one = await processBatch([row]);
3451
+ outcome.done.push(...one.done);
3452
+ outcome.retry.push(...one.retry);
3453
+ outcome.deadLetter.push(...one.deadLetter);
3454
+ outcome.pushed += one.pushed;
3455
+ outcome.deleted += one.deleted;
3456
+ } catch (err) {
3457
+ (isTransientBatchError(err) ? outcome.release : outcome.retry).push([
3458
+ row,
3459
+ errorMessage(err)
3460
+ ]);
3461
+ }
3462
+ }
3463
+ return outcome;
3464
+ };
2130
3465
  for (; ; ) {
2131
3466
  if (budgetExceeded()) break;
2132
3467
  if (stopSignal && stopSignal()) break;
@@ -2136,67 +3471,77 @@ async function runSyncWorker(opts) {
2136
3471
  await sleep(idleDelayMs);
2137
3472
  continue;
2138
3473
  }
2139
- const changes = rows.map((row) => ({
2140
- model: row.model,
2141
- op: row.op,
2142
- ids: row.docIds,
2143
- snapshot: row.snapshot
2144
- }));
3474
+ let outcome;
3475
+ let wholeBatchFailed = false;
2145
3476
  try {
2146
- const resolved = await resolveDirtyDocs(adapter, config, changes);
2147
- const { failures, pushed, deleted } = await pushResolved(
2148
- resolved,
2149
- client,
2150
- primaryKeyByIndex,
2151
- (attempt) => sleep(Math.min(failureDelayMs * 2 ** (attempt - 1), MAX_FAILURE_DELAY_MS))
2152
- );
2153
- report.pushed += pushed;
2154
- report.deleted += deleted;
2155
- const outcome = rowOutcomes(rows, resolved, failures);
2156
- if (outcome.done.length > 0) await outbox.markDone(outcome.done);
2157
- for (const [id, message] of outcome.deadLetter) {
2158
- if (outbox.deadLetter) await outbox.deadLetter(id, message);
2159
- else await outbox.markFailed(id, message);
3477
+ outcome = await processBatch(rows);
3478
+ } catch (err) {
3479
+ const transient = isTransientBatchError(err);
3480
+ if (rows.length > 1 && !transient) {
3481
+ outcome = await processIsolated(rows);
3482
+ } else {
3483
+ wholeBatchFailed = true;
3484
+ const message = errorMessage(err);
3485
+ const failed = rows.map((row) => [row, message]);
3486
+ outcome = transient ? { ...emptyOutcome(), release: failed } : { ...emptyOutcome(), retry: failed };
3487
+ log(
3488
+ `[headband] a batch of ${rows.length} outbox row(s) failed and will be retried: ${message}`
3489
+ );
2160
3490
  }
2161
- for (const [id, message] of outcome.retry) {
2162
- await outbox.markFailed(id, message);
3491
+ }
3492
+ report.pushed += outcome.pushed;
3493
+ report.deleted += outcome.deleted;
3494
+ if (outcome.done.length > 0) await outbox.markDone(outcome.done.map((row) => row.id));
3495
+ report.drained += outcome.done.length;
3496
+ for (const [row, message] of outcome.deadLetter) {
3497
+ if (outbox.deadLetter) {
3498
+ await outbox.deadLetter(row.id, message);
3499
+ report.deadLettered += 1;
3500
+ log(`[headband] ${describeRow(row)} moved to dead letters: ${message}`);
3501
+ } else {
3502
+ const status = await outbox.markFailed(row.id, message);
3503
+ if (status === "dead_letter") report.deadLettered += 1;
3504
+ else report.retried += 1;
3505
+ log(`[headband] ${describeRow(row)} failed: ${message}`);
2163
3506
  }
2164
- report.drained += outcome.done.length;
2165
- report.deadLettered += outcome.deadLetter.length + outcome.retry.length;
2166
- if (outcome.retry.length > 0) {
2167
- consecutiveFailures += 1;
2168
- await sleep(
2169
- Math.min(failureDelayMs * 2 ** (consecutiveFailures - 1), MAX_FAILURE_DELAY_MS)
3507
+ }
3508
+ if (outcome.release.length > 0) {
3509
+ if (outbox.release) {
3510
+ await outbox.release(
3511
+ outcome.release.map(([row]) => row.id),
3512
+ outcome.release[0][1]
2170
3513
  );
3514
+ report.retried += outcome.release.length;
2171
3515
  } else {
2172
- consecutiveFailures = 0;
2173
- }
2174
- } catch (err) {
2175
- const message = err instanceof Error ? err.message : String(err);
2176
- for (const row of rows) {
2177
- await outbox.markFailed(row.id, message);
3516
+ outcome.retry.push(...outcome.release);
2178
3517
  }
2179
- report.deadLettered += rows.length;
2180
- consecutiveFailures += 1;
2181
- const delay = Math.min(failureDelayMs * 2 ** (consecutiveFailures - 1), MAX_FAILURE_DELAY_MS);
2182
- await sleep(delay);
2183
3518
  }
2184
- if (onHeartbeat) {
2185
- try {
2186
- await onHeartbeat({ ...report });
2187
- } catch (err) {
2188
- console.error("[headband] heartbeat failed:", err);
3519
+ for (const [row, message] of outcome.retry) {
3520
+ const status = await outbox.markFailed(row.id, message);
3521
+ if (status === "dead_letter") {
3522
+ report.deadLettered += 1;
3523
+ log(
3524
+ `[headband] ${describeRow(row)} moved to dead letters after ${row.attempts + 1} attempt(s): ${message}`
3525
+ );
3526
+ } else {
3527
+ report.retried += 1;
3528
+ if (!wholeBatchFailed) {
3529
+ log(
3530
+ `[headband] ${describeRow(row)} failed (attempt ${row.attempts + 1}), will retry: ${message}`
3531
+ );
3532
+ }
2189
3533
  }
2190
3534
  }
2191
- }
2192
- report.outboxDepth = await outbox.depth();
2193
- if (onHeartbeat) {
2194
- try {
2195
- await onHeartbeat({ ...report });
2196
- } catch (err) {
2197
- console.error("[headband] heartbeat failed:", err);
3535
+ if (outcome.retry.length > 0 || outcome.release.length > 0) {
3536
+ consecutiveFailures += 1;
3537
+ await backoff(consecutiveFailures);
3538
+ } else {
3539
+ consecutiveFailures = 0;
2198
3540
  }
3541
+ await heartbeat(false);
2199
3542
  }
3543
+ report.outboxDepth = await outbox.depth();
3544
+ await heartbeat(true);
2200
3545
  return report;
2201
3546
  }
2202
3547
 
@@ -2204,20 +3549,37 @@ async function runSyncWorker(opts) {
2204
3549
  var WORKER_VERSION = "1";
2205
3550
  var DEFAULT_DEAD_LETTER_LIMIT = 1e3;
2206
3551
  var RECONCILE_PAGE_SIZE = 500;
2207
- var OUTBOX_SQL_RELATIVE_PATH = (0, import_path8.join)("headband", "outbox.sql");
3552
+ var OUTBOX_SQL_RELATIVE_PATH = (0, import_path10.join)("headband", "outbox.sql");
2208
3553
  var DOCKERFILE_NAME = "Dockerfile.headband-sync";
2209
- var DOCKERFILE_CONTENT = `FROM node:22-slim
3554
+ var DEFAULT_OUTBOX_TABLE = "headband_outbox";
3555
+ var ORM_SYNC_CONNECTOR_SLUG = "orm-sync";
3556
+ function dockerfileContent(cliVersion) {
3557
+ const pkg = cliVersion && cliVersion !== "dev" ? `@headband/cli@${cliVersion}` : "@headband/cli";
3558
+ return `# Runs the Headband sync worker: drains the outbox table into your Headband indexes.
3559
+ #
3560
+ # It needs, at run time:
3561
+ # DATABASE_URL the Postgres database that holds your tables and the outbox table
3562
+ # HEADBAND_API_KEY an admin key that reaches your indexes (your project's admin key)
3563
+ # HEADBAND_URL optional, defaults to https://headband.dev
3564
+ #
3565
+ # The worker loads headband.config.ts from /app, so npm ci must install what it imports
3566
+ # (@headband/sync, @headband/drizzle, drizzle-orm, pg).
3567
+ FROM node:22-slim
2210
3568
  WORKDIR /app
2211
3569
  COPY . .
2212
3570
  RUN npm ci
2213
- CMD ["npx", "headband", "sync", "worker"]
3571
+ CMD ["npx", "--yes", "${pkg}", "sync", "worker", "--loop"]
2214
3572
  `;
3573
+ }
2215
3574
  var COMPOSE_SNIPPET = `services:
2216
3575
  headband-sync:
2217
3576
  build:
2218
3577
  context: .
2219
3578
  dockerfile: ${DOCKERFILE_NAME}
2220
- env_file: .env
3579
+ environment:
3580
+ - DATABASE_URL
3581
+ - HEADBAND_API_KEY
3582
+ - HEADBAND_URL
2221
3583
  restart: unless-stopped
2222
3584
  `;
2223
3585
  function resolveDatabaseUrl(env = process.env) {
@@ -2229,33 +3591,79 @@ function resolveDatabaseUrl(env = process.env) {
2229
3591
  }
2230
3592
  return url;
2231
3593
  }
3594
+ var UNDEFINED_TABLE = "42P01";
3595
+ function missingOutboxTableError(table) {
3596
+ return new CliError(
3597
+ `The outbox table "${table}" does not exist in the database DATABASE_URL points at. Create it once with "headband sync init --apply", or add the SQL that "headband sync init" prints to your own migrations.`,
3598
+ { code: "outbox_table_missing" }
3599
+ );
3600
+ }
2232
3601
  function buildPgOutbox(connectionString, opts) {
3602
+ const table = opts?.table ?? DEFAULT_OUTBOX_TABLE;
2233
3603
  const pool = new import_pg.Pool({ connectionString });
2234
3604
  const exec = async (sqlText, params) => {
2235
- const result = await pool.query(sqlText, params);
2236
- return { rows: result.rows };
3605
+ try {
3606
+ const result = await pool.query(sqlText, params);
3607
+ return { rows: result.rows };
3608
+ } catch (err) {
3609
+ if (err.code === UNDEFINED_TABLE)
3610
+ throw missingOutboxTableError(table);
3611
+ throw err;
3612
+ }
2237
3613
  };
2238
3614
  return { outbox: pgOutboxDriver(exec, opts), close: () => pool.end() };
2239
3615
  }
2240
- function runSyncInit(deps) {
3616
+ async function applyOutboxDDL(connectionString, ddl) {
3617
+ const pool = new import_pg.Pool({ connectionString });
3618
+ try {
3619
+ await pool.query(ddl);
3620
+ } finally {
3621
+ await pool.end();
3622
+ }
3623
+ }
3624
+ async function runSyncInit(deps) {
2241
3625
  const ddl = outboxDDL(deps.table);
3626
+ const table = deps.table ?? DEFAULT_OUTBOX_TABLE;
2242
3627
  deps.io.stdout(ddl);
2243
3628
  if (deps.write) {
2244
- const path2 = (0, import_path8.join)(deps.cwd ?? process.cwd(), OUTBOX_SQL_RELATIVE_PATH);
2245
- (0, import_fs7.mkdirSync)((0, import_path8.join)(deps.cwd ?? process.cwd(), "headband"), { recursive: true });
2246
- (0, import_fs7.writeFileSync)(path2, `${ddl}
3629
+ const path2 = (0, import_path10.join)(deps.cwd ?? process.cwd(), OUTBOX_SQL_RELATIVE_PATH);
3630
+ (0, import_fs8.mkdirSync)((0, import_path10.join)(deps.cwd ?? process.cwd(), "headband"), { recursive: true });
3631
+ (0, import_fs8.writeFileSync)(path2, `${ddl}
2247
3632
  `);
2248
3633
  deps.io.stdout(`Wrote ${path2}`);
2249
3634
  }
3635
+ if (deps.apply) {
3636
+ await deps.apply(ddl);
3637
+ deps.io.stdout(`Created the outbox table "${table}" (if it was missing).`);
3638
+ } else {
3639
+ deps.io.stdout(
3640
+ `
3641
+ Run this SQL once against your database: "headband sync init --apply" does it with DATABASE_URL, or add it to your own migrations.`
3642
+ );
3643
+ }
2250
3644
  return ddl;
2251
3645
  }
2252
3646
  var SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM"];
3647
+ function explainReportRunError(err) {
3648
+ const code = err?.code;
3649
+ const message = err instanceof Error ? err.message : String(err);
3650
+ if (code === "project_id_required") {
3651
+ return "your key is org-wide, so the run needs a project. Pass --project <id>, or set projectId in headband.config.ts.";
3652
+ }
3653
+ if (code === "invalid_connector_run" && /connectorSlug/.test(message)) {
3654
+ return "this Headband server does not record self-hosted sync runs yet. Syncing is unaffected.";
3655
+ }
3656
+ return message;
3657
+ }
2253
3658
  async function runSyncWorkerCmd(deps) {
2254
3659
  const schemaHashes = Object.fromEntries(
2255
3660
  deps.config.indexes.map((def) => [def.name, docShapeHash(def)])
2256
3661
  );
2257
3662
  const workerVersion = deps.workerVersion ?? WORKER_VERSION;
2258
- let heartbeats = 0;
3663
+ const externalRunId = (0, import_crypto.randomUUID)();
3664
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3665
+ let batches = 0;
3666
+ let last = { drained: 0, pushed: 0, deleted: 0, deadLettered: 0 };
2259
3667
  const proc = deps.process ?? process;
2260
3668
  let stopRequested = false;
2261
3669
  const handleShutdown = () => {
@@ -2280,83 +3688,274 @@ async function runSyncWorkerCmd(deps) {
2280
3688
  now: deps.now,
2281
3689
  sleep: deps.sleep,
2282
3690
  stopSignal: () => stopRequested,
2283
- onHeartbeat: async (r) => {
2284
- heartbeats += 1;
2285
- await deps.client.reportRun({
2286
- connectorSlug: "orm-sync",
2287
- status: r.deadLettered > 0 ? "error" : "success",
2288
- count: r.pushed + r.deleted,
2289
- externalRunId: (0, import_crypto.randomUUID)(),
2290
- details: { outboxDepth: r.outboxDepth, workerVersion, schemaHashes }
2291
- });
2292
- if (deps.reconcileEvery && deps.onReconcileTick && heartbeats % deps.reconcileEvery === 0) {
2293
- await deps.onReconcileTick();
3691
+ log: (message) => deps.io.stderr(message),
3692
+ onHeartbeat: async (r, { final }) => {
3693
+ if (!final) {
3694
+ batches += 1;
3695
+ const drained = r.drained - last.drained;
3696
+ if (drained > 0) {
3697
+ const dead = r.deadLettered - last.deadLettered;
3698
+ deps.io.stderr(
3699
+ `[headband] batch ${batches}: drained ${drained} row(s), pushed ${r.pushed - last.pushed}, deleted ${r.deleted - last.deleted}${dead > 0 ? `, dead-lettered ${dead}` : ""}.`
3700
+ );
3701
+ }
3702
+ last = {
3703
+ drained: r.drained,
3704
+ pushed: r.pushed,
3705
+ deleted: r.deleted,
3706
+ deadLettered: r.deadLettered
3707
+ };
3708
+ if (deps.reconcileEvery && deps.onReconcileTick && batches % deps.reconcileEvery === 0) {
3709
+ try {
3710
+ await deps.onReconcileTick();
3711
+ } catch (err) {
3712
+ deps.io.stderr(
3713
+ `[headband] reconcile failed: ${err instanceof Error ? err.message : String(err)}`
3714
+ );
3715
+ }
3716
+ }
2294
3717
  }
3718
+ try {
3719
+ await deps.client.reportRun({
3720
+ connectorSlug: ORM_SYNC_CONNECTOR_SLUG,
3721
+ ...deps.projectId ? { projectId: deps.projectId } : {},
3722
+ status: final ? r.deadLettered > 0 ? "dead_letter" : "succeeded" : "started",
3723
+ mode: "self_hosted",
3724
+ command: "headband sync worker",
3725
+ count: r.pushed + r.deleted,
3726
+ externalRunId,
3727
+ startedAt,
3728
+ details: {
3729
+ drained: r.drained,
3730
+ pushed: r.pushed,
3731
+ deleted: r.deleted,
3732
+ deadLettered: r.deadLettered,
3733
+ retried: r.retried,
3734
+ outboxDepth: r.outboxDepth,
3735
+ workerVersion,
3736
+ schemaHashes
3737
+ }
3738
+ });
3739
+ } catch (err) {
3740
+ throw new Error(explainReportRunError(err));
3741
+ }
3742
+ }
3743
+ });
3744
+ deps.io.stdout(
3745
+ `Drained ${report.drained} row(s): pushed ${report.pushed}, deleted ${report.deleted}, dead-lettered ${report.deadLettered}, left for retry ${report.retried}. Outbox depth: ${report.outboxDepth}.`
3746
+ );
3747
+ if (report.deadLettered > 0) {
3748
+ deps.io.stderr(
3749
+ `${report.deadLettered} row(s) moved to dead letters (reasons above). Fix the cause, then run: headband sync retry-dead-letters`
3750
+ );
3751
+ }
3752
+ return report;
3753
+ } finally {
3754
+ for (const signal of SHUTDOWN_SIGNALS) {
3755
+ proc.removeListener(signal, handleShutdown);
3756
+ }
3757
+ }
3758
+ }
3759
+ async function reconcileContent(deps, def, pageSize) {
3760
+ const remote = await deps.readRemoteDocuments(def.name, def.primaryKey);
3761
+ const keep = /* @__PURE__ */ new Set();
3762
+ let missing = 0;
3763
+ let stale = 0;
3764
+ let cursor = null;
3765
+ for (; ; ) {
3766
+ const page = await deps.adapter.listIds(def.source, cursor, pageSize);
3767
+ if (page.ids.length === 0) break;
3768
+ const docs = await deps.adapter.resolveDocs(def.name, page.ids);
3769
+ const toPush = [];
3770
+ for (const doc of docs) {
3771
+ const sourceRow = doc.__source ?? doc;
3772
+ delete doc.__source;
3773
+ if (def.shouldIndex && !def.shouldIndex(sourceRow)) continue;
3774
+ const key = String(doc[def.primaryKey]);
3775
+ keep.add(key);
3776
+ const indexed = remote.get(key);
3777
+ if (!indexed) missing += 1;
3778
+ else if (!documentsMatch(doc, indexed, def.primaryKey)) stale += 1;
3779
+ else continue;
3780
+ toPush.push(doc);
3781
+ }
3782
+ if (deps.fix && toPush.length > 0) {
3783
+ const result = await deps.client.bulkPush(def.name, toPush, def.primaryKey);
3784
+ if (result?.partialFailure) {
3785
+ throw new CliError(
3786
+ `Repair push for "${def.name}" was incomplete: ${partialFailureSummary(result.partialFailure)}.`
3787
+ );
3788
+ }
3789
+ }
3790
+ if (page.nextCursor === null) break;
3791
+ cursor = page.nextCursor;
3792
+ }
3793
+ const extraIds = [...remote.keys()].filter((key) => !keep.has(key));
3794
+ if (deps.fix) {
3795
+ for (let offset = 0; offset < extraIds.length; offset += pageSize) {
3796
+ const result = await deps.client.deleteDocs(
3797
+ def.name,
3798
+ extraIds.slice(offset, offset + pageSize)
3799
+ );
3800
+ if (result?.partialFailure) {
3801
+ throw new CliError(
3802
+ `Removing extra documents from "${def.name}" was incomplete: ${partialFailureSummary(result.partialFailure)}.`
3803
+ );
2295
3804
  }
2296
- });
2297
- deps.io.stdout(
2298
- `Drained ${report.drained} row(s): pushed ${report.pushed}, deleted ${report.deleted}, dead-lettered ${report.deadLettered}. Outbox depth: ${report.outboxDepth}.`
2299
- );
2300
- return report;
2301
- } finally {
2302
- for (const signal of SHUTDOWN_SIGNALS) {
2303
- proc.removeListener(signal, handleShutdown);
2304
3805
  }
2305
3806
  }
3807
+ const drift = missing + extraIds.length + stale;
3808
+ return {
3809
+ index: def.name,
3810
+ sourceCount: keep.size,
3811
+ remoteCount: remote.size,
3812
+ missing,
3813
+ extra: extraIds.length,
3814
+ stale,
3815
+ drift,
3816
+ fixed: Boolean(deps.fix) && drift !== 0
3817
+ };
2306
3818
  }
2307
- async function collectAllIds(adapter, model) {
2308
- const ids = [];
3819
+ async function indexableSourceIds(adapter, def, pageSize) {
3820
+ const ids = /* @__PURE__ */ new Map();
2309
3821
  let cursor = null;
2310
3822
  for (; ; ) {
2311
- const page = await adapter.listIds(model, cursor, RECONCILE_PAGE_SIZE);
2312
- ids.push(...page.ids);
2313
- if (page.nextCursor === null || page.ids.length === 0) break;
3823
+ const page = await adapter.listIds(def.source, cursor, pageSize);
3824
+ if (page.ids.length === 0) break;
3825
+ const docs = await adapter.resolveDocs(def.name, page.ids);
3826
+ for (const doc of docs) {
3827
+ const sourceRow = doc.__source ?? doc;
3828
+ if (def.shouldIndex && !def.shouldIndex(sourceRow)) continue;
3829
+ const id = doc[def.primaryKey];
3830
+ if (id !== void 0 && id !== null) ids.set(String(id), id);
3831
+ }
3832
+ if (page.nextCursor === null) break;
2314
3833
  cursor = page.nextCursor;
2315
3834
  }
2316
3835
  return ids;
2317
3836
  }
3837
+ async function pushSourceRows(deps, def, ids, pageSize) {
3838
+ let pushed = 0;
3839
+ for (let offset = 0; offset < ids.length; offset += pageSize) {
3840
+ const docs = await deps.adapter.resolveDocs(def.name, ids.slice(offset, offset + pageSize));
3841
+ const toPush = [];
3842
+ for (const doc of docs) {
3843
+ const sourceRow = doc.__source ?? doc;
3844
+ delete doc.__source;
3845
+ if (def.shouldIndex && !def.shouldIndex(sourceRow)) continue;
3846
+ toPush.push(doc);
3847
+ }
3848
+ if (toPush.length === 0) continue;
3849
+ const result = await deps.client.bulkPush(def.name, toPush, def.primaryKey);
3850
+ if (result?.partialFailure) {
3851
+ throw new CliError(
3852
+ `Repair push for "${def.name}" was incomplete: ${partialFailureSummary(result.partialFailure)}.`
3853
+ );
3854
+ }
3855
+ pushed += toPush.length;
3856
+ }
3857
+ return pushed;
3858
+ }
2318
3859
  async function runSyncReconcile(deps) {
2319
- const ormSchema = deps.adapter.introspect();
3860
+ const pageSize = deps.pageSize ?? RECONCILE_PAGE_SIZE;
2320
3861
  const rows = [];
2321
3862
  for (const def of deps.config.indexes) {
2322
- const ids = await collectAllIds(deps.adapter, def.source);
2323
- const sourceCount = ids.length;
3863
+ if (deps.content && deps.readRemoteDocuments) {
3864
+ try {
3865
+ rows.push(await reconcileContent(deps, def, pageSize));
3866
+ continue;
3867
+ } catch (err) {
3868
+ if (err instanceof CliError) throw err;
3869
+ deps.io.stderr(
3870
+ `Could not read the documents of "${def.name}", comparing ids only: ${err instanceof Error ? err.message : String(err)}`
3871
+ );
3872
+ }
3873
+ }
3874
+ const source = await indexableSourceIds(deps.adapter, def, pageSize);
3875
+ let remoteKeys = null;
2324
3876
  let remoteCount = -1;
2325
- try {
2326
- const stats = await deps.getStats(def.name);
2327
- remoteCount = stats.numDocuments ?? 0;
2328
- } catch (err) {
2329
- deps.io.stderr(
2330
- `Could not fetch stats for "${def.name}": ${err instanceof Error ? err.message : String(err)}`
2331
- );
3877
+ if (deps.listRemoteIds) {
3878
+ try {
3879
+ remoteKeys = new Set((await deps.listRemoteIds(def.name, def.primaryKey)).map(String));
3880
+ remoteCount = remoteKeys.size;
3881
+ } catch (err) {
3882
+ deps.io.stderr(
3883
+ `Could not list the documents of "${def.name}", comparing counts only: ${err instanceof Error ? err.message : String(err)}`
3884
+ );
3885
+ }
2332
3886
  }
2333
- const cursorAware = Boolean(ormSchema.models[def.source]?.columns?.updatedAt);
2334
- const drift = remoteCount === -1 ? 0 : sourceCount - remoteCount;
2335
- rows.push({ index: def.name, sourceCount, remoteCount, drift, cursorAware });
2336
- if (deps.fix && drift !== 0) {
2337
- const docs = await deps.adapter.resolveDocs(def.name, ids);
2338
- const toPush = docs.filter((doc) => {
2339
- const sourceRow = doc.__source ?? doc;
2340
- return !def.shouldIndex || def.shouldIndex(sourceRow);
2341
- });
2342
- for (const doc of toPush) delete doc.__source;
2343
- if (toPush.length > 0) {
2344
- const result = await deps.client.bulkPush(def.name, toPush, def.primaryKey);
2345
- if (result?.partialFailure) {
2346
- throw new CliError(
2347
- `Repair push for "${def.name}" was incomplete: ${partialFailureSummary(result.partialFailure)}.`
3887
+ if (remoteKeys === null) {
3888
+ try {
3889
+ const stats = await deps.getStats(def.name);
3890
+ remoteCount = stats.numberOfDocuments ?? stats.numDocuments ?? -1;
3891
+ } catch (err) {
3892
+ deps.io.stderr(
3893
+ `Could not fetch stats for "${def.name}": ${err instanceof Error ? err.message : String(err)}`
3894
+ );
3895
+ }
3896
+ }
3897
+ const row = {
3898
+ index: def.name,
3899
+ sourceCount: source.size,
3900
+ remoteCount,
3901
+ missing: null,
3902
+ extra: null,
3903
+ drift: remoteCount === -1 ? 0 : Math.abs(source.size - remoteCount),
3904
+ fixed: false
3905
+ };
3906
+ let missingIds = [];
3907
+ let extraIds = [];
3908
+ if (remoteKeys) {
3909
+ const remote = remoteKeys;
3910
+ missingIds = [...source].filter(([key]) => !remote.has(key)).map(([, id]) => id);
3911
+ extraIds = [...remote].filter((key) => !source.has(key));
3912
+ row.missing = missingIds.length;
3913
+ row.extra = extraIds.length;
3914
+ row.drift = missingIds.length + extraIds.length;
3915
+ }
3916
+ if (deps.fix && row.drift !== 0) {
3917
+ if (remoteKeys) {
3918
+ await pushSourceRows(deps, def, missingIds, pageSize);
3919
+ for (let offset = 0; offset < extraIds.length; offset += pageSize) {
3920
+ const result = await deps.client.deleteDocs(
3921
+ def.name,
3922
+ extraIds.slice(offset, offset + pageSize)
2348
3923
  );
3924
+ if (result?.partialFailure) {
3925
+ throw new CliError(
3926
+ `Removing extra documents from "${def.name}" was incomplete: ${partialFailureSummary(result.partialFailure)}.`
3927
+ );
3928
+ }
2349
3929
  }
3930
+ } else {
3931
+ await pushSourceRows(deps, def, [...source.values()], pageSize);
2350
3932
  }
3933
+ row.fixed = true;
2351
3934
  }
3935
+ rows.push(row);
2352
3936
  }
3937
+ const contentCompared = rows.some((r) => r.stale !== void 0 && r.stale !== null);
2353
3938
  printTable(
2354
3939
  deps.io,
2355
- rows.map((r) => ({ ...r, remoteCount: r.remoteCount === -1 ? "unknown" : r.remoteCount })),
2356
- ["index", "sourceCount", "remoteCount", "drift", "cursorAware"]
3940
+ rows.map((r) => ({
3941
+ ...r,
3942
+ remoteCount: r.remoteCount === -1 ? "unknown" : r.remoteCount,
3943
+ missing: r.missing ?? "",
3944
+ extra: r.extra ?? "",
3945
+ stale: r.stale ?? "",
3946
+ fixed: r.fixed ? "yes" : ""
3947
+ })),
3948
+ contentCompared ? ["index", "sourceCount", "remoteCount", "missing", "extra", "stale", "drift", "fixed"] : ["index", "sourceCount", "remoteCount", "missing", "extra", "drift", "fixed"]
2357
3949
  );
3950
+ const unfixed = rows.filter((r) => r.drift !== 0 && !r.fixed);
3951
+ if (unfixed.length > 0 && !deps.fix) {
3952
+ deps.io.stderr("Drift found. Run again with --fix to repair it.");
3953
+ }
2358
3954
  return rows;
2359
3955
  }
3956
+ function reconcileHasDrift(rows) {
3957
+ return rows.some((r) => r.remoteCount === -1 || r.drift !== 0 && !r.fixed);
3958
+ }
2360
3959
  async function runSyncRetryDeadLetters(deps) {
2361
3960
  const rows = await deps.outbox.deadLetters(deps.limit ?? DEFAULT_DEAD_LETTER_LIMIT);
2362
3961
  if (rows.length === 0) {
@@ -2367,23 +3966,238 @@ async function runSyncRetryDeadLetters(deps) {
2367
3966
  deps.io.stdout(`Requeued ${rows.length} dead-lettered row(s).`);
2368
3967
  return rows.length;
2369
3968
  }
3969
+ async function runSelfHostedSyncStatus(deps) {
3970
+ const now = deps.now ?? Date.now;
3971
+ const table = deps.config.sync?.outboxTable ?? DEFAULT_OUTBOX_TABLE;
3972
+ const mode = deps.config.sync?.mode ?? "outbox";
3973
+ let outbox = null;
3974
+ if (deps.outbox) {
3975
+ const stats = deps.outbox.stats ? await deps.outbox.stats() : { pending: await deps.outbox.depth(), deadLetter: -1, oldestPendingAt: null };
3976
+ outbox = {
3977
+ ...stats,
3978
+ oldestPendingAgeSeconds: stats.oldestPendingAt ? Math.max(0, Math.round((now() - stats.oldestPendingAt.getTime()) / 1e3)) : null
3979
+ };
3980
+ }
3981
+ const status = {
3982
+ config: deps.configPath,
3983
+ outboxTable: table,
3984
+ mode,
3985
+ indexes: deps.config.indexes.map((def) => def.name),
3986
+ outbox
3987
+ };
3988
+ const { io } = deps;
3989
+ io.stdout(`Self-hosted sync (${deps.configPath}), ${mode} mode`);
3990
+ io.stdout(`Indexes: ${status.indexes.join(", ") || "none"}`);
3991
+ if (!outbox) {
3992
+ io.stdout("Outbox: not checked. Set DATABASE_URL to see its depth and dead letters.");
3993
+ } else {
3994
+ printTable(
3995
+ io,
3996
+ [
3997
+ {
3998
+ table,
3999
+ pending: outbox.pending,
4000
+ deadLetters: outbox.deadLetter === -1 ? "unknown" : outbox.deadLetter,
4001
+ oldestPending: outbox.oldestPendingAgeSeconds === null ? "" : `${outbox.oldestPendingAgeSeconds}s ago`
4002
+ }
4003
+ ],
4004
+ ["table", "pending", "deadLetters", "oldestPending"]
4005
+ );
4006
+ if (outbox.deadLetter > 0) {
4007
+ io.stdout(
4008
+ `${outbox.deadLetter} row(s) are dead letters. Their last error is in the last_error column. Requeue them with: headband sync retry-dead-letters`
4009
+ );
4010
+ }
4011
+ }
4012
+ io.stdout("Managed Sync (replication slots) has its own commands: headband sync source list");
4013
+ return status;
4014
+ }
2370
4015
  function runSyncScaffoldDocker(deps) {
2371
- const path2 = (0, import_path8.join)(deps.cwd, DOCKERFILE_NAME);
2372
- if ((0, import_fs7.existsSync)(path2) && !deps.force) {
4016
+ const path2 = (0, import_path10.join)(deps.cwd, DOCKERFILE_NAME);
4017
+ if ((0, import_fs8.existsSync)(path2) && !deps.force) {
2373
4018
  throw new CliError(`${DOCKERFILE_NAME} already exists at ${path2}. Pass --force to overwrite.`);
2374
4019
  }
2375
- (0, import_fs7.writeFileSync)(path2, DOCKERFILE_CONTENT);
4020
+ (0, import_fs8.writeFileSync)(path2, dockerfileContent(deps.cliVersion));
2376
4021
  deps.io.stdout(`Wrote ${path2}`);
2377
4022
  deps.io.stdout("Add this service to your docker-compose.yml:");
2378
4023
  deps.io.stdout(COMPOSE_SNIPPET);
4024
+ deps.io.stdout(
4025
+ "Set DATABASE_URL (the database with your tables and the outbox) and HEADBAND_API_KEY (an admin key that reaches your indexes) where the container runs."
4026
+ );
2379
4027
  return path2;
2380
4028
  }
2381
4029
 
4030
+ // src/help.ts
4031
+ var CONFIG_NOTE = [
4032
+ "These commands read headband.config.ts in the current directory:",
4033
+ " export default defineConfig({ url, projectId?, indexes: [...], sync? }) (@headband/sync)",
4034
+ " export const adapter = drizzleAdapter({ db, schema, config }) (@headband/drizzle)",
4035
+ "The adapter is needed by apply, worker and reconcile; diff, generate and status run without it.",
4036
+ "When the adapter is there, the config is checked against your tables before anything runs.",
4037
+ "The Headband URL is --url, else HEADBAND_URL, else the config's url, else your login's."
4038
+ ].join("\n");
4039
+ var KEY_NOTE = [
4040
+ "Key: your login (headband login) or HEADBAND_API_KEY. The login key is org-wide, so new",
4041
+ "indexes go to the project named by --project <id> or the config's projectId; without one",
4042
+ "they go to your org's Default project. Your app's own project admin key reaches only",
4043
+ "indexes in its project, so name that project here."
4044
+ ].join("\n");
4045
+ var schemaDiff = [
4046
+ "Usage: headband schema diff [--allow-destructive] [--json]",
4047
+ "",
4048
+ "Compares the indexes in headband.config.ts with the schema state Headband has recorded,",
4049
+ "and prints the operations a migration would run: create-index, patch-settings, reindex,",
4050
+ "and with --allow-destructive, remove-index. Changes nothing. Exits 1 when changes are",
4051
+ "pending, like git diff --exit-code, so CI can fail on an unapplied change."
4052
+ ].join("\n");
4053
+ var schemaGenerate = [
4054
+ "Usage: headband schema generate [--slug <slug>] [--dir <dir>] [--allow-destructive] [--json]",
4055
+ "",
4056
+ "Writes the pending operations to a numbered migration file in --dir",
4057
+ "(default headband/migrations), for review and for git. Changes nothing on the server."
4058
+ ].join("\n");
4059
+ var schemaApply = [
4060
+ "Usage: headband schema apply [--project <id>] [--allow-destructive] [--dir <dir>] [--yes] [--json]",
4061
+ "",
4062
+ "Runs the migration files in --dir (default headband/migrations) that the server does not",
4063
+ "have yet. A new index is created, given its settings, and backfilled from every source row",
4064
+ "(rows shouldIndex rejects are left out). A schema change builds a new index beside the",
4065
+ "live one, backfills it, then switches the name over. Settings changes apply in place.",
4066
+ "",
4067
+ "Applied migrations are recognised from the server's state, so a fresh checkout or CI can",
4068
+ "apply without the local headband/migrations/.applied.json (a cache you may commit or ignore).",
4069
+ "Needs the adapter export and DATABASE_URL (the database your adapter reads). Asks first;",
4070
+ "--yes confirms without a terminal.",
4071
+ "",
4072
+ KEY_NOTE
4073
+ ].join("\n");
4074
+ var schemaStatus = [
4075
+ "Usage: headband schema status [--dir <dir>] [--json]",
4076
+ "",
4077
+ "Per index in headband.config.ts: the applied version and status on the server, and drift",
4078
+ "(schema, settings, none, missing, or index missing when the index was deleted). Also how",
4079
+ "many local migration files are applied."
4080
+ ].join("\n");
4081
+ var schemaForget = [
4082
+ "Usage: headband schema forget <index> [--yes] [--json]",
4083
+ "",
4084
+ "Removes the schema state Headband recorded for <index>, for an index deleted outside",
4085
+ "schema apply (headband indexes delete clears it for you). Does not touch any index. The",
4086
+ "next schema generate plans the index as new."
4087
+ ].join("\n");
4088
+ var syncInit = [
4089
+ "Usage: headband sync init [--table <name>] [--write] [--apply] [--json]",
4090
+ "",
4091
+ "Prints the SQL for the outbox table (default headband_outbox) that outbox mode writes",
4092
+ "changes to. --write saves it to headband/outbox.sql. --apply runs it against DATABASE_URL",
4093
+ "(CREATE TABLE IF NOT EXISTS, safe to re-run). Or add the SQL to your own migrations."
4094
+ ].join("\n");
4095
+ var syncWorker = [
4096
+ "Usage:",
4097
+ " headband sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--project <id>] [--json]",
4098
+ " headband sync worker --scaffold docker [--force] [--json]",
4099
+ "",
4100
+ "Drains the outbox table: resolves each changed row through the adapter and writes the",
4101
+ "documents to Headband. By default it runs until stopped (Ctrl+C finishes the current",
4102
+ "batch), so --loop changes nothing on its own. --budget-ms <ms> is for cron: the worker",
4103
+ "stops claiming after that long, and exits early once the outbox is empty; add --loop to",
4104
+ "keep waiting for changes until the budget ends. Each batch prints one line to stderr",
4105
+ "(rows drained, pushed, deleted), and the run ends with a summary on stdout.",
4106
+ "--reconcile-every <n> runs sync reconcile after",
4107
+ "every n-th batch. A row that keeps failing goes to the dead letters (see sync status and",
4108
+ "sync retry-dead-letters), with its error printed; one bad row never fails its batch.",
4109
+ "Each run is recorded under the connector orm-sync (GET /v1/connectors/runs).",
4110
+ "",
4111
+ "Needs the adapter export, DATABASE_URL (the database with your tables and the outbox), and",
4112
+ "a key that can write documents. A login key records runs only with --project or projectId.",
4113
+ "",
4114
+ "--scaffold docker writes Dockerfile.headband-sync, which runs this worker by its full",
4115
+ "package name, and prints a docker-compose service."
4116
+ ].join("\n");
4117
+ var syncReconcile = [
4118
+ "Usage: headband sync reconcile [--content] [--fix] [--json]",
4119
+ "",
4120
+ "Per index, compares the source rows that belong in it (every row shouldIndex keeps) with",
4121
+ "the documents in it, and prints how many documents are missing and how many are extra",
4122
+ "(counts, not ids; --json gives the same counts). --fix pushes the missing ones and deletes",
4123
+ "the extra ones. Exits 1 when drift is left. --content also compares every document with its",
4124
+ "freshly resolved source row and counts the stale ones; with --fix it rewrites them. It reads",
4125
+ "the whole index, so it takes about as long as a backfill.",
4126
+ "Needs the adapter export and DATABASE_URL."
4127
+ ].join("\n");
4128
+ var syncRetry = [
4129
+ "Usage: headband sync retry-dead-letters [--json]",
4130
+ "",
4131
+ "Moves the outbox rows in dead_letter back to pending, so the next worker run tries them",
4132
+ "again. Fix what failed them first: their error is in the outbox table's last_error column,",
4133
+ "and the worker printed it. Needs DATABASE_URL."
4134
+ ].join("\n");
4135
+ var syncStatus = [
4136
+ "Usage: headband sync status [--json]",
4137
+ "",
4138
+ "Beside a headband.config.ts: the outbox's pending rows, dead letters and oldest pending",
4139
+ "row (needs DATABASE_URL). Elsewhere: managed Sync's sources and synced indexes."
4140
+ ].join("\n");
4141
+ var SUBCOMMAND_HELP = {
4142
+ schema: [
4143
+ "Usage:",
4144
+ " headband schema diff [--allow-destructive] [--json]",
4145
+ " headband schema generate [--slug <slug>] [--dir <dir>] [--allow-destructive] [--json]",
4146
+ " headband schema apply [--project <id>] [--allow-destructive] [--dir <dir>] [--yes] [--json]",
4147
+ " headband schema status [--dir <dir>] [--json]",
4148
+ " headband schema forget <index> [--yes] [--json]",
4149
+ "",
4150
+ "Versioned index schema for self-hosted sync. headband schema <command> --help says more.",
4151
+ "",
4152
+ CONFIG_NOTE
4153
+ ].join("\n"),
4154
+ sync: [
4155
+ "Usage:",
4156
+ " Self-hosted sync with @headband/sync:",
4157
+ " headband sync init [--table <name>] [--write] [--apply] [--json]",
4158
+ " headband sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--project <id>] [--json]",
4159
+ " headband sync worker --scaffold docker [--force] [--json]",
4160
+ " headband sync reconcile [--content] [--fix] [--json]",
4161
+ " headband sync retry-dead-letters [--json]",
4162
+ " headband sync status [--json]",
4163
+ "",
4164
+ " Managed Sync (replication slots, early access):",
4165
+ " headband sync source add|list|show|test|setup|setup-sql|set-ca|remove (see headband sync source --help)",
4166
+ " headband sync index create|list|show|update|validate|preview|rebuild|pause|resume|check|remove (see headband sync index --help)",
4167
+ "",
4168
+ "headband sync <command> --help says more.",
4169
+ "",
4170
+ CONFIG_NOTE
4171
+ ].join("\n"),
4172
+ "schema diff": `${schemaDiff}
4173
+
4174
+ ${CONFIG_NOTE}`,
4175
+ "schema generate": `${schemaGenerate}
4176
+
4177
+ ${CONFIG_NOTE}`,
4178
+ "schema apply": `${schemaApply}
4179
+
4180
+ ${CONFIG_NOTE}`,
4181
+ "schema status": `${schemaStatus}
4182
+
4183
+ ${CONFIG_NOTE}`,
4184
+ "schema forget": schemaForget,
4185
+ "sync init": syncInit,
4186
+ "sync worker": `${syncWorker}
4187
+
4188
+ ${CONFIG_NOTE}`,
4189
+ "sync reconcile": `${syncReconcile}
4190
+
4191
+ ${CONFIG_NOTE}`,
4192
+ "sync retry-dead-letters": syncRetry,
4193
+ "sync status": syncStatus
4194
+ };
4195
+
2382
4196
  // src/sync-indexes.ts
2383
- var import_fs9 = require("fs");
4197
+ var import_fs10 = require("fs");
2384
4198
 
2385
4199
  // src/sync-sources.ts
2386
- var import_fs8 = require("fs");
4200
+ var import_fs9 = require("fs");
2387
4201
  var import_pg2 = require("pg");
2388
4202
 
2389
4203
  // ../../src/lib/sync/copy.ts
@@ -2450,7 +4264,7 @@ var SOURCE_USAGE = [
2450
4264
  function readCaFile(deps, file) {
2451
4265
  let text;
2452
4266
  try {
2453
- text = (deps.readFile ?? ((p) => (0, import_fs8.readFileSync)(p, "utf8")))(file);
4267
+ text = (deps.readFile ?? ((p) => (0, import_fs9.readFileSync)(p, "utf8")))(file);
2454
4268
  } catch {
2455
4269
  throw new CliError("Cannot read the --ca-file.");
2456
4270
  }
@@ -2621,10 +4435,11 @@ async function runOwnerSqlWithPg(ownerUrl, sqlText) {
2621
4435
  function friendlySyncError(err) {
2622
4436
  if (err instanceof ApiError) {
2623
4437
  if (err.status === 404 && err.code === "not_found") {
2624
- throw new CliError("Sync is not enabled for this organization yet.");
4438
+ throw restate(err, "Sync is not enabled for this organization yet.");
2625
4439
  }
2626
4440
  if (err.status === 403 && err.code === "permission_denied") {
2627
- throw new CliError(
4441
+ throw restate(
4442
+ err,
2628
4443
  "This CLI key cannot manage Sync. Run headband login again to get a key that can."
2629
4444
  );
2630
4445
  }
@@ -3090,7 +4905,7 @@ var PREVIEW_MAX_IDS = 20;
3090
4905
  var ATTENTION_INDEX = /* @__PURE__ */ new Set(["error", "needs_rebuild", "awaiting_setup", "degraded"]);
3091
4906
  var ATTENTION_SOURCE = /* @__PURE__ */ new Set(["error", "awaiting_setup"]);
3092
4907
  var path = (id, suffix = "") => `/v1/sync/indexes/${encodeURIComponent(id)}${suffix}`;
3093
- function readDefinition(file, readFile = (p) => (0, import_fs9.readFileSync)(p, "utf8")) {
4908
+ function readDefinition(file, readFile = (p) => (0, import_fs10.readFileSync)(p, "utf8")) {
3094
4909
  let text;
3095
4910
  try {
3096
4911
  text = readFile(file);
@@ -3458,6 +5273,7 @@ async function askWithPrompts(message) {
3458
5273
  var ENGINES2 = /* @__PURE__ */ new Set(["meilisearch", "typesense", "elasticsearch", "visor"]);
3459
5274
  var KEY_TYPES = /* @__PURE__ */ new Set(["admin", "search"]);
3460
5275
  var EXPORT_FORMATS = /* @__PURE__ */ new Set(["jsonl", "json"]);
5276
+ var SEARCH_MODES = /* @__PURE__ */ new Set(["instant", "retrieval"]);
3461
5277
  var VALUE_FLAGS = /* @__PURE__ */ new Set([
3462
5278
  "--url",
3463
5279
  "--token",
@@ -3485,6 +5301,8 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
3485
5301
  "--idle-timeout",
3486
5302
  "--client",
3487
5303
  "--instance",
5304
+ "--instances",
5305
+ "--indexes",
3488
5306
  "--db-url",
3489
5307
  "--owner-url",
3490
5308
  "--tls-mode",
@@ -3502,7 +5320,14 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
3502
5320
  "--format",
3503
5321
  "--batch",
3504
5322
  "--permissions",
3505
- "--expires"
5323
+ "--expires",
5324
+ "--branch",
5325
+ "--parent",
5326
+ "--expires-in",
5327
+ "--in",
5328
+ "--at",
5329
+ "--level",
5330
+ "--mode"
3506
5331
  ]);
3507
5332
  var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
3508
5333
  "--json",
@@ -3514,12 +5339,21 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
3514
5339
  "--write",
3515
5340
  "--force",
3516
5341
  "--fix",
5342
+ "--content",
3517
5343
  "--loop",
3518
5344
  "--accept-broad-role",
3519
5345
  "--rebuild",
3520
5346
  "--drop-index",
3521
5347
  "--none",
3522
- "--no-revoke"
5348
+ "--no-revoke",
5349
+ "--empty",
5350
+ "--clear",
5351
+ "--apply",
5352
+ "--accept-drops",
5353
+ "--declare-key",
5354
+ "--dry-run",
5355
+ "--wait",
5356
+ "--status"
3523
5357
  ]);
3524
5358
  var DEFAULT_MIGRATIONS_DIR = "headband/migrations";
3525
5359
  function parseArgv(argv) {
@@ -3553,7 +5387,7 @@ function parseArgv(argv) {
3553
5387
  }
3554
5388
  return { flags, positional };
3555
5389
  }
3556
- function stringFlag(flags, name) {
5390
+ function stringFlag3(flags, name) {
3557
5391
  const value = flags[name];
3558
5392
  return typeof value === "string" ? value : void 0;
3559
5393
  }
@@ -3561,7 +5395,7 @@ function boolFlag(flags, name) {
3561
5395
  return flags[name] === true;
3562
5396
  }
3563
5397
  function requiredFlag(flags, name) {
3564
- const value = stringFlag(flags, name);
5398
+ const value = stringFlag3(flags, name);
3565
5399
  if (!value) throw new UsageError(`Missing required option: ${name}`);
3566
5400
  return value;
3567
5401
  }
@@ -3570,7 +5404,7 @@ function requiredArg(value, what) {
3570
5404
  return value;
3571
5405
  }
3572
5406
  function parseLimit(flags) {
3573
- const raw = stringFlag(flags, "--limit");
5407
+ const raw = stringFlag3(flags, "--limit");
3574
5408
  if (!raw) return void 0;
3575
5409
  const limit = Number(raw);
3576
5410
  if (!Number.isInteger(limit) || limit <= 0) {
@@ -3596,17 +5430,34 @@ function encode(value) {
3596
5430
  function chatterIO(io, flags) {
3597
5431
  return boolFlag(flags, "--json") ? { stdout: io.stderr, stderr: io.stderr } : io;
3598
5432
  }
3599
- function authFromFlags(flags) {
3600
- return resolveAuth({
3601
- token: stringFlag(flags, "--token"),
3602
- url: stringFlag(flags, "--url"),
3603
- profile: stringFlag(flags, "--profile")
5433
+ function authFromFlags(flags, defaults = {}) {
5434
+ const profile = stringFlag3(flags, "--profile");
5435
+ const url = stringFlag3(flags, "--url") ?? (process.env.HEADBAND_URL || profile || process.env.HEADBAND_PROFILE ? void 0 : defaults.url);
5436
+ return resolveAuth({ token: stringFlag3(flags, "--token"), url, profile });
5437
+ }
5438
+ function branchHeaders(flags, cwd, auth, options = {}) {
5439
+ const branch = branchHeaderFor(flags, cwd, { ...options, loginOrgId: auth.profile?.orgId });
5440
+ return branch ? { "Headband-Branch": branch } : void 0;
5441
+ }
5442
+ function clientFromFlags(flags, cwd, options = {}) {
5443
+ const auth = authFromFlags(flags, { url: options.defaultUrl });
5444
+ const headers = branchHeaders(flags, cwd, auth, options);
5445
+ return createApiClient(auth.url, { apiKey: auth.apiKey, headers });
5446
+ }
5447
+ function syncClientFromFlags(flags, cwd, options = {}) {
5448
+ const auth = authFromFlags(flags, { url: options.defaultUrl });
5449
+ const headers = branchHeaders(flags, cwd, auth, options);
5450
+ const fetchImpl = headers ? (input, init2) => {
5451
+ const merged = new Headers(init2?.headers);
5452
+ for (const [name, value] of Object.entries(headers)) merged.set(name, value);
5453
+ return fetch(input, { ...init2, headers: merged });
5454
+ } : void 0;
5455
+ return new HeadbandClient({
5456
+ url: auth.url,
5457
+ adminKey: auth.apiKey,
5458
+ ...fetchImpl ? { fetchImpl } : {}
3604
5459
  });
3605
5460
  }
3606
- function clientFromFlags(flags) {
3607
- const auth = authFromFlags(flags);
3608
- return createApiClient(auth.url, { apiKey: auth.apiKey });
3609
- }
3610
5461
  function stdinIsInteractive() {
3611
5462
  return process.stdin.isTTY === true;
3612
5463
  }
@@ -3643,16 +5494,24 @@ Commands:
3643
5494
  profiles use <name>
3644
5495
  projects list [--json]
3645
5496
  projects create <name> --engine <meilisearch|typesense|elasticsearch|visor> [--json]
3646
- projects delete <id> [--force] [--yes] [--json]
5497
+ projects delete <id> [--yes] [--json]
5498
+ branch list [--project <id>] [--json]
5499
+ branch create <name> --empty [--parent <name>] [--expires-in <2h|7d>] [--project <id>] [--json]
5500
+ branch delete <name> [--project <id>] [--yes] [--json]
5501
+ branch protect|unprotect <name> [--project <id>] [--json]
5502
+ branch expire <name> (--in <2h|7d> | --at <iso> | --clear) [--project <id>] [--json]
5503
+ branch checkout <name> [--project <id>] [--json]
5504
+ branch reset|rollback|restore|set-primary (coming with bucket storage)
3647
5505
  keys list [--json]
3648
- keys create <name> --type <admin|search> [--project <id>] [--permissions <a,b>] [--expires <30d|ISO date>] [--json]
5506
+ keys create <name> --type <admin|search> [--project <id>] [--branch <name>] [--instances <id,id>] [--indexes <uid,uid>] [--permissions <a,b>] [--expires <30d|ISO date>] [--json]
3649
5507
  keys revoke <id> [--yes] [--json]
3650
- indexes list [--json]
5508
+ indexes list [--json] (index works in place of indexes)
3651
5509
  indexes create <uid> [--primary-key <pk>] [--engine <engine>] [--instance <id>] [--json]
3652
5510
  indexes delete <uid> [--yes] [--json]
3653
5511
  indexes settings get <uid> [--json]
3654
5512
  indexes settings set <uid> --file <settings.json> [--json]
3655
5513
  indexes stats <uid> [--json]
5514
+ indexes switch-engine <uid> --engine <engine> [--accept-drops] [--dry-run] [--wait] [--json]
3656
5515
  instances list [--json]
3657
5516
  instances create <engine> [--name <name>] [--lane <lane>] [--memory <size>] [--region <region>] [--idle-timeout <s>] [--json]
3658
5517
  instances update <id> [--name <name>] [--lane <lane>] [--memory <size>] [--idle-timeout <s>] [--json]
@@ -3660,29 +5519,39 @@ Commands:
3660
5519
  instances wakes <id> [--limit <n>] [--json]
3661
5520
  instances delete <id> [--yes] [--json]
3662
5521
  instances connect <id> [--client <node|curl>] [--json]
5522
+ warm <index>... | --project <id> [--branch <name>] [--level auto|stage|start] [--json]
3663
5523
  docs push <file> --index <uid> [--json]
3664
- docs export --index <uid> [--out <file>] [--format <jsonl|json>] [--batch <n>]
3665
- docs delete <docId> --index <uid> [--json]
3666
- search <index> <query> [--limit N] [--filter <expr>] [--json]
3667
- tasks list [--json]
3668
- tasks get <id> [--json]
5524
+ docs export --index <uid> [--out <file>] [--format <jsonl|json>] [--batch <n>] [--declare-key]
5525
+ docs delete <docId>... --index <uid> [--json]
5526
+ search <index> [<query>] [--limit N] [--filter <expr>] [--mode <instant|retrieval>] [--json]
5527
+ tasks list [--index <uid>] [--limit <n>] [--json]
5528
+ tasks get <id> [--index <uid>] [--json]
5529
+
5530
+ Self-hosted sync with @headband/sync (reads headband.config.ts; see headband schema --help):
3669
5531
  schema diff [--allow-destructive] [--json]
3670
5532
  schema generate [--slug <slug>] [--dir <dir>] [--allow-destructive] [--json]
3671
- schema apply [--allow-destructive] [--dir <dir>] [--yes] [--json]
5533
+ schema apply [--project <id>] [--allow-destructive] [--dir <dir>] [--yes] [--json]
3672
5534
  schema status [--dir <dir>] [--json]
3673
- sync init [--table <name>] [--write] [--json]
3674
- sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--json]
5535
+ schema forget <index> [--yes] [--json]
5536
+ sync init [--table <name>] [--write] [--apply] [--json]
5537
+ sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--project <id>] [--json]
3675
5538
  sync worker --scaffold docker [--force] [--json]
3676
- sync reconcile [--fix] [--json]
5539
+ sync reconcile [--content] [--fix] [--json]
3677
5540
  sync retry-dead-letters [--json]
5541
+ sync status [--json]
5542
+
5543
+ Managed Sync (replication slots, early access):
3678
5544
  sync source add|list|show|test|setup|setup-sql|set-ca|remove (see headband sync source --help)
3679
5545
  sync index create|list|show|update|validate|preview|rebuild|pause|resume|check|remove (see headband sync index --help)
3680
- sync status [--json]
3681
5546
 
3682
5547
  Global options:
3683
5548
  --token <key> API key for this call. Wins over HEADBAND_API_KEY and stored logins.
3684
5549
  --profile <name> Stored login to use (else HEADBAND_PROFILE, else the current profile)
3685
5550
  --url <url> Headband URL (else HEADBAND_URL, else the profile's, else ${DEFAULT_URL})
5551
+ --branch <name> Work on this branch for one call: sends Headband-Branch: <project>/<name>,
5552
+ with the project from --project <id>, else the checkout's
5553
+ (headband branch checkout). A branch id (br_...) is sent as it is.
5554
+ Without --branch or a checkout, calls reach the primary branches.
3686
5555
  --json Print one JSON document on stdout; errors print one JSON object on stderr
3687
5556
  --yes Answer yes to confirmations (required when stdin is not a terminal)
3688
5557
  --help Show help
@@ -3697,8 +5566,21 @@ function printCommandHelp(command, io) {
3697
5566
  logout: "Usage: headband logout [--profile <name>] [--yes] [--no-revoke] [--json]",
3698
5567
  whoami: "Usage: headband whoami [--json]",
3699
5568
  profiles: "Usage:\n headband profiles list [--json]\n headband profiles use <name>",
3700
- projects: "Usage:\n headband projects list [--json]\n headband projects create <name> --engine <meilisearch|typesense|elasticsearch|visor> [--json]\n headband projects delete <id> [--force] [--yes] [--json]\n\n delete refuses a project that still has indexes; --force deletes its indexes with it.",
3701
- keys: "Usage:\n headband keys list [--json]\n headband keys create <name> --type <admin|search> [--project <id>] [--permissions <a,b>] [--expires <30d|ISO date>] [--json]\n headband keys revoke <id> [--yes] [--json]",
5569
+ projects: "Usage:\n headband projects list [--json]\n headband projects create <name> --engine <meilisearch|typesense|elasticsearch|visor> [--json]\n headband projects delete <id> [--yes] [--json]\n\n delete refuses a project that still has indexes. Delete its indexes first.",
5570
+ branch: BRANCH_USAGE,
5571
+ branches: BRANCH_USAGE,
5572
+ keys: [
5573
+ "Usage:",
5574
+ " headband keys list [--json]",
5575
+ " headband keys create <name> --type <admin|search> [--project <id>] [--branch <name>] [--instances <id,id>] [--indexes <uid,uid>] [--permissions <a,b>] [--expires <30d|ISO date>] [--json]",
5576
+ " headband keys revoke <id> [--yes] [--json]",
5577
+ "",
5578
+ "keys create mints a key for one branch: --branch (with --project, else the checkout's project),",
5579
+ "else the checked-out branch, else the --project's primary branch. With none of these,",
5580
+ "--type admin mints an org key, and --type search is refused: a search key needs a branch.",
5581
+ "--instances <id,id> limits the key to those dedicated engines (on a branch: its engines among them).",
5582
+ "--indexes <uid,uid> limits a search key to those indexes; any other index answers 404."
5583
+ ].join("\n"),
3702
5584
  indexes: [
3703
5585
  "Usage:",
3704
5586
  " headband indexes list [--json]",
@@ -3707,24 +5589,73 @@ function printCommandHelp(command, io) {
3707
5589
  " headband indexes settings get <uid> [--json]",
3708
5590
  " headband indexes settings set <uid> --file <settings.json> [--json]",
3709
5591
  " headband indexes stats <uid> [--json]",
5592
+ " headband indexes switch-engine <uid> --engine <engine> [--instance <id>] [--accept-drops] [--declare-key] [--dry-run] [--wait] [--json]",
5593
+ " headband indexes switch-engine <uid> --status [--json]",
5594
+ "",
5595
+ "index is an alias: headband index switch-engine <uid> is the same command.",
3710
5596
  "",
3711
5597
  "Options for create:",
3712
5598
  " --instance <id> Create the index on one of your dedicated engines (ids from headband instances list).",
3713
- " The index takes that engine's type; --engine, if given, must match it."
5599
+ " The index takes that engine's type; --engine, if given, must match it.",
5600
+ "",
5601
+ "Options for switch-engine (moves the index to another engine; its uid, keys and URLs stay):",
5602
+ " --dry-run Show what would be copied and which settings would not carry over. Changes nothing.",
5603
+ " --accept-drops Switch even though some settings will not apply on the new engine.",
5604
+ " --declare-key Visor over 1000 documents: declare the primary key while it is read (rebuilds it).",
5605
+ " --instance <id> Put the index on that engine of the branch instead of the branch's default.",
5606
+ " --wait Follow the switch until it ends. Writes are held for about a minute at the end.",
5607
+ " --status Show the index's engine and its latest switch."
3714
5608
  ].join("\n"),
3715
5609
  instances: INSTANCES_USAGE,
3716
- docs: "Usage:\n headband docs push <file> --index <uid> [--json]\n headband docs export --index <uid> [--out <file>] [--format <jsonl|json>] [--batch <n>] [--json]\n headband docs delete <docId> --index <uid> [--json]",
3717
- search: "Usage: headband search <index> <query> [--limit N] [--filter <expr>] [--json]",
3718
- tasks: "Usage:\n headband tasks list [--json]\n headband tasks get <id> [--json]",
3719
- schema: "Usage:\n headband schema diff [--allow-destructive] [--json]\n headband schema generate [--slug <slug>] [--dir <dir>] [--allow-destructive] [--json]\n headband schema apply [--allow-destructive] [--dir <dir>] [--yes] [--json]\n headband schema status [--dir <dir>] [--json]",
3720
- sync: "Usage:\n headband sync init [--table <name>] [--write] [--json]\n headband sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--json]\n headband sync worker --scaffold docker [--force] [--json]\n headband sync reconcile [--fix] [--json]\n headband sync retry-dead-letters [--json]\n headband sync source add|list|show|test|setup|setup-sql|set-ca|remove (see headband sync source --help)\n headband sync index create|list|show|update|validate|preview|rebuild|pause|resume|check|remove (see headband sync index --help)\n headband sync status [--json]"
5610
+ warm: WARM_USAGE,
5611
+ docs: [
5612
+ "Usage:",
5613
+ " headband docs push <file> --index <uid> [--json]",
5614
+ " headband docs export --index <uid> [--out <file>] [--format <jsonl|json>] [--batch <n>] [--declare-key] [--json]",
5615
+ " headband docs delete <docId>... --index <uid> [--json]",
5616
+ "",
5617
+ "docs push sends the documents in a .json (an array) or .jsonl file, 1000 at a time.",
5618
+ "",
5619
+ "docs export writes every document of the index:",
5620
+ " without --out JSONL to stdout, one document per line, and nothing else.",
5621
+ " --out <file> to a file. The format follows the extension (.json is one array,",
5622
+ " anything else JSONL) unless --format says otherwise.",
5623
+ " --batch <n> documents per request. Default 1000, or 250 on Typesense.",
5624
+ " --declare-key a Visor index past 1000 documents whose primary key is not",
5625
+ " filterable and sortable (made before 2026-10-21): declare the",
5626
+ " key on it first (Visor rebuilds the index), wait, then export.",
5627
+ " If the export stops on an error, the documents written so far are kept in",
5628
+ " <file>.partial, and <file> is not written.",
5629
+ "",
5630
+ "docs delete removes the documents with these ids (one or more) from the index."
5631
+ ].join("\n"),
5632
+ search: [
5633
+ "Usage: headband search <index> [<query>] [--limit N] [--filter <expr>] [--mode <instant|retrieval>] [--json]",
5634
+ "",
5635
+ 'Without a query (or with ""), search browses the index: every document matches, so',
5636
+ "--filter and --limit pick what comes back.",
5637
+ "",
5638
+ "--mode picks Visor's ranking: instant (the default, typo-tolerant, as-you-type) or retrieval",
5639
+ "(BM25F, for longer queries). Indexes on the other engines ignore it."
5640
+ ].join("\n"),
5641
+ tasks: [
5642
+ "Usage:",
5643
+ " headband tasks list [--index <uid>] [--limit <n>] [--json]",
5644
+ " headband tasks get <id> [--index <uid>] [--json]",
5645
+ "",
5646
+ "Tasks live on the engine that holds the index. With the key headband login creates (an",
5647
+ "org key), or on a branch other than main, pass --index so Headband knows which",
5648
+ "engine to ask. A project key of the main branch may leave it out."
5649
+ ].join("\n"),
5650
+ schema: SUBCOMMAND_HELP.schema,
5651
+ sync: SUBCOMMAND_HELP.sync
3721
5652
  };
3722
- io.stdout(help[command] || "");
5653
+ io.stdout(help[command === "index" ? "indexes" : command] || "");
3723
5654
  }
3724
5655
  async function runLogin(flags, io) {
3725
- const url = stringFlag(flags, "--url") || process.env.HEADBAND_URL || DEFAULT_URL;
5656
+ const url = stringFlag3(flags, "--url") || process.env.HEADBAND_URL || DEFAULT_URL;
3726
5657
  const result = await login(url, chatterIO(io, flags), {
3727
- profileName: stringFlag(flags, "--profile")
5658
+ profileName: stringFlag3(flags, "--profile")
3728
5659
  });
3729
5660
  if (boolFlag(flags, "--json")) printJson(io, result);
3730
5661
  }
@@ -3732,8 +5663,8 @@ async function runLogout(flags, io, confirm) {
3732
5663
  const json = boolFlag(flags, "--json");
3733
5664
  const chatter = chatterIO(io, flags);
3734
5665
  const selected = selectProfile({
3735
- url: stringFlag(flags, "--url"),
3736
- profile: stringFlag(flags, "--profile")
5666
+ url: stringFlag3(flags, "--url"),
5667
+ profile: stringFlag3(flags, "--profile")
3737
5668
  });
3738
5669
  if (!selected) {
3739
5670
  throw new AuthError("No stored login to log out of. Run: headband profiles list");
@@ -3775,7 +5706,7 @@ async function runWhoami(flags, io) {
3775
5706
  profile: auth.profileName ?? null,
3776
5707
  orgName: auth.profile?.orgName ?? null,
3777
5708
  orgId: auth.profile?.orgId ?? null,
3778
- keyPrefix: auth.profile?.keyPrefix ?? auth.apiKey.slice(0, 12),
5709
+ keyPrefix: auth.profile?.keyPrefix ?? displayPrefixOf(auth.apiKey),
3779
5710
  keyId: auth.profile?.keyId ?? null,
3780
5711
  valid: true
3781
5712
  };
@@ -3827,9 +5758,9 @@ async function runProfiles(args, flags, io) {
3827
5758
  printCommandHelp("profiles", io);
3828
5759
  throw new UsageError("Unknown profiles command");
3829
5760
  }
3830
- async function runProjects(args, flags, io, confirm) {
5761
+ async function runProjects(args, flags, io, confirm, cwd) {
3831
5762
  const action = args[0];
3832
- const client = clientFromFlags(flags);
5763
+ const client = clientFromFlags(flags, cwd);
3833
5764
  if (action === "list") {
3834
5765
  const response = await client.get(
3835
5766
  "/v1/projects"
@@ -3854,10 +5785,8 @@ async function runProjects(args, flags, io, confirm) {
3854
5785
  }
3855
5786
  if (action === "delete") {
3856
5787
  const id = requiredArg(args[1], "project id");
3857
- const force = boolFlag(flags, "--force");
3858
- const question = force ? `Delete project ${id} and every index in it?` : `Delete project ${id}?`;
3859
- if (!await confirm(question)) return cancelled(io, flags);
3860
- const response = await client.delete(`/v1/projects/${encode(id)}${force ? "?force=true" : ""}`);
5788
+ if (!await confirm(`Delete project ${id}?`)) return cancelled(io, flags);
5789
+ const response = await client.delete(`/v1/projects/${encode(id)}`);
3861
5790
  if (boolFlag(flags, "--json")) printJson(io, response);
3862
5791
  else io.stdout(`Deleted project ${id}.`);
3863
5792
  return;
@@ -3903,19 +5832,73 @@ function parsePermissions(raw) {
3903
5832
  }
3904
5833
  return permissions;
3905
5834
  }
3906
- async function runKeys(args, flags, io, confirm) {
5835
+ function keyRow(key) {
5836
+ const branch = key.branch;
5837
+ return {
5838
+ ...key,
5839
+ branch: branch?.name ?? "",
5840
+ engines: enginesColumn(key.instanceIds),
5841
+ indexes: enginesColumn(key.indexUids)
5842
+ };
5843
+ }
5844
+ function enginesColumn(instanceIds) {
5845
+ if (instanceIds === null) return "all";
5846
+ if (instanceIds === void 0) return "unknown";
5847
+ if (Array.isArray(instanceIds) && instanceIds.every((id) => typeof id === "string")) {
5848
+ const printable = instanceIds.filter((id) => id.trim() !== "");
5849
+ return printable.length > 0 ? printable.join(",") : "none";
5850
+ }
5851
+ return "none";
5852
+ }
5853
+ function parseIndexList(raw) {
5854
+ const uids = [
5855
+ ...new Set(
5856
+ raw.split(",").map((x) => x.trim()).filter(Boolean)
5857
+ )
5858
+ ];
5859
+ if (uids.length === 0) throw new CliError("--indexes needs at least one index uid.");
5860
+ const bad = uids.find((uid) => !/^[A-Za-z0-9_-]{1,64}$/.test(uid));
5861
+ if (bad) {
5862
+ throw new CliError(
5863
+ `Not an index uid: ${bad}. Index uids are letters, numbers, hyphens and underscores.`
5864
+ );
5865
+ }
5866
+ return uids;
5867
+ }
5868
+ function parseInstanceList(raw) {
5869
+ const ids = [
5870
+ ...new Set(
5871
+ raw.split(",").map((x) => x.trim()).filter(Boolean)
5872
+ )
5873
+ ];
5874
+ if (ids.length === 0) {
5875
+ throw new CliError("--instances needs at least one engine id (see `headband instances list`).");
5876
+ }
5877
+ const bad = ids.find((id) => !INSTANCE_ID_RE.test(id));
5878
+ if (bad) {
5879
+ throw new CliError(
5880
+ `Not an engine id: ${bad}. Engine ids look like iabcdef012345 (see \`headband instances list\`).`
5881
+ );
5882
+ }
5883
+ return ids;
5884
+ }
5885
+ async function runKeys(args, flags, io, confirm, cwd) {
3907
5886
  const action = args[0];
3908
- const client = clientFromFlags(flags);
5887
+ const client = clientFromFlags(flags, cwd, { projectInBody: action === "create" });
3909
5888
  if (action === "list") {
3910
5889
  const response = await client.get("/v1/keys");
3911
5890
  if (boolFlag(flags, "--json")) printJson(io, response);
3912
5891
  else
3913
- printTable(io, response.data.keys, [
5892
+ printTable(io, response.data.keys.map(keyRow), [
3914
5893
  "id",
3915
5894
  "name",
3916
5895
  "type",
3917
5896
  "keyPrefix",
3918
5897
  "projectId",
5898
+ "branch",
5899
+ "engines",
5900
+ "indexes",
5901
+ "environment",
3919
5902
  "lastUsedAt",
3920
5903
  "expiresAt"
3921
5904
  ]);
@@ -3927,20 +5910,66 @@ async function runKeys(args, flags, io, confirm) {
3927
5910
  name,
3928
5911
  type: validateKeyType(requiredFlag(flags, "--type"))
3929
5912
  };
3930
- const projectId = stringFlag(flags, "--project");
5913
+ const target = branchTargetFor(flags, cwd, { projectInBody: true });
5914
+ const projectId = target?.projectId ?? stringFlag3(flags, "--project");
3931
5915
  if (projectId) body.projectId = projectId;
3932
- const permissions = stringFlag(flags, "--permissions");
5916
+ if (target) body.branch = target.branch;
5917
+ if (stringFlag3(flags, "--instance") !== void 0) {
5918
+ throw new CliError("Unknown option --instance for keys create. Did you mean --instances?");
5919
+ }
5920
+ const instances = stringFlag3(flags, "--instances");
5921
+ if (instances !== void 0) body.instanceIds = parseInstanceList(instances);
5922
+ if (stringFlag3(flags, "--index") !== void 0) {
5923
+ throw new CliError("Unknown option --index for keys create. Did you mean --indexes?");
5924
+ }
5925
+ const indexes = stringFlag3(flags, "--indexes");
5926
+ if (indexes !== void 0) body.indexUids = parseIndexList(indexes);
5927
+ const permissions = stringFlag3(flags, "--permissions");
3933
5928
  if (permissions) body.permissions = parsePermissions(permissions);
3934
- const expires = stringFlag(flags, "--expires");
5929
+ const expires = stringFlag3(flags, "--expires");
3935
5930
  if (expires) body.expiresAt = parseExpires(expires);
3936
- const response = await client.post("/v1/keys", body);
5931
+ const response = await client.post("/v1/keys", body).catch((error) => {
5932
+ if (error instanceof ApiError && error.code === "branch_required") {
5933
+ throw restate(
5934
+ error,
5935
+ "A search key needs a branch, because only --type admin makes an org key. Pass --project <id> for that project's main branch, add --branch <name> for another branch, or run headband branch checkout first."
5936
+ );
5937
+ }
5938
+ throw error;
5939
+ });
5940
+ if (instances !== void 0 && !Array.isArray(response.data.key.instanceIds)) {
5941
+ const keyId = response.data.key.id;
5942
+ throw new CliError(
5943
+ typeof keyId === "string" && keyId ? `This API did not limit the new key to the chosen engines, so key ${keyId} reaches wider than asked. Revoke it now: headband keys revoke ${keyId}` : "This API did not limit the new key to the chosen engines, so it reaches wider than asked. Find it with `headband keys list` and revoke it with `headband keys revoke <id>`."
5944
+ );
5945
+ }
5946
+ if (indexes !== void 0 && !Array.isArray(response.data.key.indexUids)) {
5947
+ const keyId = response.data.key.id;
5948
+ throw new CliError(
5949
+ typeof keyId === "string" && keyId ? `This API did not limit the new key to the chosen indexes, so key ${keyId} reaches every index of its branch. Revoke it now: headband keys revoke ${keyId}` : "This API did not limit the new key to the chosen indexes, so it reaches every index of its branch. Find it with `headband keys list` and revoke it with `headband keys revoke <id>`."
5950
+ );
5951
+ }
3937
5952
  if (boolFlag(flags, "--json")) {
3938
5953
  printJson(io, response);
3939
5954
  return;
3940
5955
  }
3941
5956
  io.stdout(import_picocolors3.default.yellow("Store this API key now. It will not be shown again."));
3942
5957
  io.stdout(response.data.apiKey);
3943
- printTable(io, [response.data.key], ["id", "name", "type", "keyPrefix", "expiresAt"]);
5958
+ printTable(
5959
+ io,
5960
+ [keyRow(response.data.key)],
5961
+ [
5962
+ "id",
5963
+ "name",
5964
+ "type",
5965
+ "environment",
5966
+ "branch",
5967
+ "engines",
5968
+ "indexes",
5969
+ "keyPrefix",
5970
+ "expiresAt"
5971
+ ]
5972
+ );
3944
5973
  return;
3945
5974
  }
3946
5975
  if (action === "revoke") {
@@ -3954,9 +5983,9 @@ async function runKeys(args, flags, io, confirm) {
3954
5983
  printCommandHelp("keys", io);
3955
5984
  throw new UsageError("Unknown keys command");
3956
5985
  }
3957
- async function runIndexes(args, flags, io, confirm) {
5986
+ async function runIndexes(args, flags, io, confirm, cwd) {
3958
5987
  const action = args[0];
3959
- const client = clientFromFlags(flags);
5988
+ const client = clientFromFlags(flags, cwd);
3960
5989
  if (action === "list") {
3961
5990
  const response = await client.get("/v1/indexes");
3962
5991
  if (boolFlag(flags, "--json")) printJson(io, response);
@@ -3976,9 +6005,9 @@ async function runIndexes(args, flags, io, confirm) {
3976
6005
  if (action === "create") {
3977
6006
  const uid = requiredArg(args[1], "index uid");
3978
6007
  const body = { uid };
3979
- const primaryKey = stringFlag(flags, "--primary-key");
3980
- const engine = stringFlag(flags, "--engine");
3981
- const instanceId = stringFlag(flags, "--instance");
6008
+ const primaryKey = stringFlag3(flags, "--primary-key");
6009
+ const engine = stringFlag3(flags, "--engine");
6010
+ const instanceId = stringFlag3(flags, "--instance");
3982
6011
  if (primaryKey) body.primaryKey = primaryKey;
3983
6012
  if (engine) body.engine = validateEngine(engine);
3984
6013
  if (instanceId) body.instanceId = instanceId;
@@ -3997,12 +6026,20 @@ async function runIndexes(args, flags, io, confirm) {
3997
6026
  `On the instance endpoint this index is named ${response.engineIndexUid}. Use that name with the engine's own client.`
3998
6027
  );
3999
6028
  }
6029
+ if (typeof response.projectId === "string") {
6030
+ const where = `Created in project ${response.projectId}${typeof response.branch === "string" ? `, branch ${response.branch}` : ""}.`;
6031
+ const chose = branchTargetFor(flags, cwd) !== null;
6032
+ io.stdout(
6033
+ chose ? where : `${where} To create it elsewhere, pass --project <id> --branch main, or run headband branch checkout in that project's directory.`
6034
+ );
6035
+ }
4000
6036
  return;
4001
6037
  }
4002
6038
  if (action === "delete") {
4003
6039
  const uid = requiredArg(args[1], "index uid");
4004
6040
  if (!await confirm(`Delete index ${uid}?`)) return cancelled(io, flags);
4005
6041
  const response = await client.delete(`/v1/indexes/${encode(uid)}`);
6042
+ await client.delete(`/v1/schema/state?indexUid=${encode(uid)}`).catch(() => void 0);
4006
6043
  if (boolFlag(flags, "--json")) printJson(io, response);
4007
6044
  else io.stdout(`Deleted index ${uid}.`);
4008
6045
  return;
@@ -4017,7 +6054,7 @@ async function runIndexes(args, flags, io, confirm) {
4017
6054
  }
4018
6055
  if (settingsAction === "set") {
4019
6056
  const file = requiredFlag(flags, "--file");
4020
- const settings = JSON.parse((0, import_fs11.readFileSync)(file, "utf-8"));
6057
+ const settings = JSON.parse((0, import_fs12.readFileSync)(file, "utf-8"));
4021
6058
  const response = await client.patch(`/v1/indexes/${encode(uid)}/settings`, settings);
4022
6059
  printJson(io, response);
4023
6060
  return;
@@ -4029,28 +6066,56 @@ async function runIndexes(args, flags, io, confirm) {
4029
6066
  printJson(io, response);
4030
6067
  return;
4031
6068
  }
6069
+ if (action === "switch-engine") {
6070
+ const uid = requiredArg(args[1], "index uid");
6071
+ if (boolFlag(flags, "--status")) {
6072
+ await printSwitchStatus(client, uid, io, boolFlag(flags, "--json"));
6073
+ return;
6074
+ }
6075
+ await runSwitchEngine(
6076
+ client,
6077
+ {
6078
+ uid,
6079
+ engine: validateEngine(requiredFlag(flags, "--engine")),
6080
+ instanceId: stringFlag3(flags, "--instance"),
6081
+ acceptDrops: boolFlag(flags, "--accept-drops"),
6082
+ declareKey: boolFlag(flags, "--declare-key"),
6083
+ dryRun: boolFlag(flags, "--dry-run"),
6084
+ wait: boolFlag(flags, "--wait"),
6085
+ json: boolFlag(flags, "--json")
6086
+ },
6087
+ io,
6088
+ chatterIO(io, flags)
6089
+ );
6090
+ return;
6091
+ }
4032
6092
  printCommandHelp("indexes", io);
4033
6093
  throw new UsageError("Unknown indexes command");
4034
6094
  }
4035
6095
  function exportFormat(flags, out) {
4036
- const raw = stringFlag(flags, "--format") ?? (out?.toLowerCase().endsWith(".json") ? "json" : "jsonl");
6096
+ const raw = stringFlag3(flags, "--format") ?? (out?.toLowerCase().endsWith(".json") ? "json" : "jsonl");
4037
6097
  if (!EXPORT_FORMATS.has(raw)) throw new UsageError("--format must be jsonl or json");
4038
6098
  return raw;
4039
6099
  }
4040
- async function runDocsExport(flags, context) {
6100
+ async function runDocsExport(flags, context, cwd) {
4041
6101
  const { io } = context;
4042
6102
  const index = requiredFlag(flags, "--index");
4043
- const out = stringFlag(flags, "--out");
6103
+ const out = stringFlag3(flags, "--out");
4044
6104
  const format = exportFormat(flags, out);
4045
- const client = clientFromFlags(flags);
6105
+ const client = clientFromFlags(flags, cwd);
4046
6106
  const batchSize = parseIntFlag(flags, "--batch") ?? await defaultExportBatchSize(client, index);
6107
+ if (boolFlag(flags, "--declare-key")) await declareExportKey(client, index, io);
4047
6108
  if (!out) {
4048
6109
  const writeLine = context.writeLine ?? ((line) => io.stdout(line));
4049
- await exportDocuments(client, { index, format, batchSize, write: writeLine });
6110
+ try {
6111
+ await exportDocuments(client, { index, format, batchSize, write: writeLine });
6112
+ } catch (error) {
6113
+ throw exportFailure(error instanceof ExportInterruptedError ? error.cause : error, index);
6114
+ }
4050
6115
  return;
4051
6116
  }
4052
6117
  const partial = `${out}.partial`;
4053
- const fd = (0, import_fs10.openSync)(partial, "w", 384);
6118
+ const fd = (0, import_fs11.openSync)(partial, "w", 384);
4054
6119
  let exported;
4055
6120
  try {
4056
6121
  exported = await exportDocuments(client, {
@@ -4058,32 +6123,40 @@ async function runDocsExport(flags, context) {
4058
6123
  format,
4059
6124
  batchSize,
4060
6125
  write: (line) => {
4061
- (0, import_fs10.writeSync)(fd, `${line}
6126
+ (0, import_fs11.writeSync)(fd, `${line}
4062
6127
  `);
4063
6128
  },
4064
- onPage: (count, total) => io.stderr(`Exported ${count}${total === null ? "" : `/${total}`} documents`)
6129
+ onPage: (count, total) => io.stderr(`Exported ${count}${total === null ? "" : `/${total}`} documents`),
6130
+ finishOnError: true
4065
6131
  });
4066
- (0, import_fs10.closeSync)(fd);
6132
+ (0, import_fs11.closeSync)(fd);
4067
6133
  } catch (error) {
4068
- (0, import_fs10.closeSync)(fd);
4069
- (0, import_fs10.rmSync)(partial, { force: true });
4070
- throw error;
6134
+ (0, import_fs11.closeSync)(fd);
6135
+ const kept = error instanceof ExportInterruptedError ? error.exported : 0;
6136
+ if (kept > 0) {
6137
+ io.stderr(
6138
+ `Kept the ${kept} ${kept === 1 ? "document" : "documents"} exported before the error in ${partial}. ${out} was not written.`
6139
+ );
6140
+ } else {
6141
+ (0, import_fs11.rmSync)(partial, { force: true });
6142
+ }
6143
+ throw exportFailure(error instanceof ExportInterruptedError ? error.cause : error, index);
4071
6144
  }
4072
- (0, import_fs10.renameSync)(partial, out);
6145
+ (0, import_fs11.renameSync)(partial, out);
4073
6146
  if (boolFlag(flags, "--json")) printJson(io, { index, out, format, exported });
4074
6147
  else io.stdout(`Exported ${exported} documents to ${out}.`);
4075
6148
  }
4076
- async function runDocs(args, flags, context) {
6149
+ async function runDocs(args, flags, context, cwd) {
4077
6150
  const { io } = context;
4078
6151
  const action = args[0];
4079
6152
  if (action === "export") {
4080
- await runDocsExport(flags, context);
6153
+ await runDocsExport(flags, context, cwd);
4081
6154
  return;
4082
6155
  }
4083
6156
  if (action === "push") {
4084
6157
  const file = requiredArg(args[1], "documents file");
4085
6158
  const index = requiredFlag(flags, "--index");
4086
- const client = clientFromFlags(flags);
6159
+ const client = clientFromFlags(flags, cwd);
4087
6160
  const documents = parseDocumentsFile(file);
4088
6161
  const count = await pushDocuments(client, index, documents, chatterIO(io, flags));
4089
6162
  if (boolFlag(flags, "--json")) printJson(io, { index, pushed: count });
@@ -4091,30 +6164,38 @@ async function runDocs(args, flags, context) {
4091
6164
  return;
4092
6165
  }
4093
6166
  if (action === "delete") {
4094
- const docId = requiredArg(args[1], "document id");
6167
+ const ids = args.slice(1);
6168
+ requiredArg(ids[0], "document id");
4095
6169
  const index = requiredFlag(flags, "--index");
4096
- const client = clientFromFlags(flags);
4097
- const response = await client.delete(`/v1/documents/${encode(docId)}?index=${encode(index)}`);
6170
+ const client = clientFromFlags(flags, cwd);
6171
+ const response = await client.delete(`/v1/documents?index=${encode(index)}`, { ids });
4098
6172
  if (boolFlag(flags, "--json")) printJson(io, response);
4099
- else io.stdout(`Deleted document ${docId}.`);
6173
+ else
6174
+ io.stdout(
6175
+ ids.length === 1 ? `Deleted document ${ids[0]}.` : `Deleted ${ids.length} documents.`
6176
+ );
4100
6177
  return;
4101
6178
  }
4102
6179
  printCommandHelp("docs", io);
4103
6180
  throw new UsageError("Unknown docs command");
4104
6181
  }
4105
- async function runSearch(args, flags, io) {
6182
+ async function runSearch(args, flags, io, cwd) {
4106
6183
  const index = requiredArg(args[0], "search index");
4107
6184
  const query = args.slice(1).join(" ");
4108
- if (!query) throw new UsageError("Missing search query");
4109
- const client = clientFromFlags(flags);
6185
+ const client = clientFromFlags(flags, cwd);
4110
6186
  const body = {
4111
6187
  index,
4112
6188
  q: query
4113
6189
  };
4114
6190
  const limit = parseLimit(flags);
4115
- const filter = stringFlag(flags, "--filter");
6191
+ const filter = stringFlag3(flags, "--filter");
6192
+ const mode = stringFlag3(flags, "--mode");
6193
+ if (mode !== void 0 && !SEARCH_MODES.has(mode)) {
6194
+ throw new UsageError("--mode must be instant or retrieval");
6195
+ }
4116
6196
  if (limit) body.limit = limit;
4117
6197
  if (filter) body.filter = filter;
6198
+ if (mode) body.mode = mode;
4118
6199
  const response = await client.post("/v1/search", body);
4119
6200
  if (boolFlag(flags, "--json")) {
4120
6201
  printJson(io, response);
@@ -4124,11 +6205,26 @@ async function runSearch(args, flags, io) {
4124
6205
  io.stdout(`${total} hits in ${response.processingTimeMs ?? 0}ms`);
4125
6206
  printTable(io, (response.hits || []).map(summarizeHit));
4126
6207
  }
4127
- async function runTasks(args, flags, io) {
6208
+ function tasksIndexHint(error) {
6209
+ if (error instanceof ApiError && error.status === 400 && (error.code === "indexUids_required" || error.code === "index_required")) {
6210
+ throw new UsageError(
6211
+ "Pass --index <uid>: tasks live on the engine that holds the index, and this key (an org key, or a branch other than main) does not name one.",
6212
+ "index_required"
6213
+ );
6214
+ }
6215
+ throw error;
6216
+ }
6217
+ async function runTasks(args, flags, io, cwd) {
4128
6218
  const action = args[0];
4129
- const client = clientFromFlags(flags);
6219
+ const client = clientFromFlags(flags, cwd);
6220
+ const index = stringFlag3(flags, "--index");
4130
6221
  if (action === "list") {
4131
- const response = await client.get("/v1/tasks");
6222
+ const params = new URLSearchParams();
6223
+ if (index) params.set("indexUids", index);
6224
+ const limit = parseLimit(flags);
6225
+ if (limit) params.set("limit", String(limit));
6226
+ const query = params.toString();
6227
+ const response = await client.get(`/v1/tasks${query ? `?${query}` : ""}`).catch(tasksIndexHint);
4132
6228
  if (boolFlag(flags, "--json")) printJson(io, response);
4133
6229
  else
4134
6230
  printTable(io, response.results ?? [], [
@@ -4143,28 +6239,43 @@ async function runTasks(args, flags, io) {
4143
6239
  }
4144
6240
  if (action === "get") {
4145
6241
  const id = requiredArg(args[1], "task id");
4146
- const response = await client.get(`/v1/tasks/${encode(id)}`);
6242
+ const response = await client.get(`/v1/tasks/${encode(id)}${index ? `?index=${encode(index)}` : ""}`).catch(tasksIndexHint);
4147
6243
  printJson(io, response);
4148
6244
  return;
4149
6245
  }
4150
6246
  printCommandHelp("tasks", io);
4151
6247
  throw new UsageError("Unknown tasks command");
4152
6248
  }
4153
- async function runSchema(args, flags, io, confirm) {
6249
+ async function runSchema(args, flags, io, confirm, cwd) {
4154
6250
  const action = args[0];
4155
- if (!["diff", "generate", "apply", "status"].includes(action)) {
6251
+ if (!["diff", "generate", "apply", "status", "forget"].includes(action)) {
4156
6252
  printCommandHelp("schema", io);
4157
6253
  throw new UsageError("Unknown schema command");
4158
6254
  }
4159
6255
  const json = boolFlag(flags, "--json");
4160
6256
  const chatter = chatterIO(io, flags);
4161
- const dir = stringFlag(flags, "--dir") || DEFAULT_MIGRATIONS_DIR;
4162
- const auth = authFromFlags(flags);
4163
- const client = new HeadbandClient({ url: auth.url, adminKey: auth.apiKey });
4164
- const { config, adapter } = await loadHeadbandConfig(process.cwd());
6257
+ const dir = stringFlag3(flags, "--dir") || DEFAULT_MIGRATIONS_DIR;
6258
+ if (action === "forget") {
6259
+ const index = requiredArg(args[1], "index name");
6260
+ const client2 = syncClientFromFlags(flags, cwd);
6261
+ if (!await confirm(`Forget the recorded schema state of "${index}"?`)) {
6262
+ return cancelled(io, flags);
6263
+ }
6264
+ await runSchemaForget({ client: client2, index, io: chatter });
6265
+ if (json) printJson(io, { index, forgotten: true });
6266
+ return;
6267
+ }
6268
+ const { config, adapter } = await loadHeadbandConfig(cwd);
6269
+ const clientOptions = { projectInBody: true, defaultUrl: config.url };
6270
+ const client = syncClientFromFlags(flags, cwd, clientOptions);
6271
+ const apiClient = clientFromFlags(flags, cwd, clientOptions);
6272
+ const projectId = stringFlag3(flags, "--project") ?? config.projectId;
6273
+ const indexUids = () => listIndexUids(apiClient);
4165
6274
  const allowDestructive = boolFlag(flags, "--allow-destructive");
4166
6275
  if (action === "diff") {
4167
- const ops = await planSchemaDiff({ config, client, allowDestructive });
6276
+ const applied = await client.getSchemaState();
6277
+ const ops = planMigration(config, applied, { allowDestructive });
6278
+ warnOrphans(chatter, applied, await indexUids());
4168
6279
  if (json) printJson(io, { inSync: ops.length === 0, ops: opRows(ops) });
4169
6280
  else printOpsTable(io, ops);
4170
6281
  if (ops.length > 0) process.exitCode = 1;
@@ -4175,7 +6286,7 @@ async function runSchema(args, flags, io, confirm) {
4175
6286
  config,
4176
6287
  client,
4177
6288
  dir,
4178
- slug: stringFlag(flags, "--slug"),
6289
+ slug: stringFlag3(flags, "--slug"),
4179
6290
  io: chatter,
4180
6291
  allowDestructive
4181
6292
  });
@@ -4184,9 +6295,7 @@ async function runSchema(args, flags, io, confirm) {
4184
6295
  }
4185
6296
  if (action === "apply") {
4186
6297
  if (!adapter) {
4187
- throw new CliError(
4188
- "headband.config.ts must export an `adapter` (e.g. drizzleAdapter(...)) for schema apply \u2014 it's needed to backfill documents during a reindex."
4189
- );
6298
+ throw missingAdapterError("schema apply", "it reads your tables to backfill new indexes");
4190
6299
  }
4191
6300
  if (!await confirm("Apply pending schema migrations?")) {
4192
6301
  return cancelled(io, flags);
@@ -4197,20 +6306,47 @@ async function runSchema(args, flags, io, confirm) {
4197
6306
  client,
4198
6307
  dir,
4199
6308
  allowDestructive,
4200
- io: chatter
6309
+ io: chatter,
6310
+ projectId,
6311
+ readRemoteDocuments: (uid, primaryKey) => readRemoteDocuments(apiClient, uid, primaryKey)
4201
6312
  });
4202
- if (json) printJson(io, result);
4203
- else
6313
+ if (json) printJson(io, { ...result, projectId: projectId ?? null });
6314
+ else {
4204
6315
  io.stdout(
4205
6316
  `Applied ${result.appliedOps} op(s) across ${result.appliedFiles} migration file(s).`
4206
6317
  );
6318
+ if (result.appliedOps > 0 && !projectId && authFromFlags(flags, { url: config.url }).source === "profile") {
6319
+ io.stdout(
6320
+ "New indexes went to your org's Default project, because no project was named. A project admin key (what your app uses) reaches only its own project's indexes: name it with --project <id> or projectId in headband.config.ts."
6321
+ );
6322
+ }
6323
+ }
4207
6324
  return;
4208
6325
  }
4209
- const status = await runSchemaStatus({ config, client, dir, io: chatter });
6326
+ const status = await runSchemaStatus({
6327
+ config,
6328
+ client,
6329
+ dir,
6330
+ io: chatter,
6331
+ indexUids: await indexUids()
6332
+ });
4210
6333
  if (json) printJson(io, status);
4211
6334
  }
6335
+ async function listIndexUids(client) {
6336
+ try {
6337
+ const response = await client.get("/v1/indexes");
6338
+ const uids = /* @__PURE__ */ new Set();
6339
+ for (const row of response.results ?? []) {
6340
+ if (row.uid) uids.add(row.uid);
6341
+ if (row.physicalUid) uids.add(row.physicalUid);
6342
+ }
6343
+ return uids;
6344
+ } catch {
6345
+ return void 0;
6346
+ }
6347
+ }
4212
6348
  function parseIntFlag(flags, name) {
4213
- const raw = stringFlag(flags, name);
6349
+ const raw = stringFlag3(flags, name);
4214
6350
  if (!raw) return void 0;
4215
6351
  const value = Number(raw);
4216
6352
  if (!Number.isInteger(value) || value <= 0) {
@@ -4218,14 +6354,14 @@ function parseIntFlag(flags, name) {
4218
6354
  }
4219
6355
  return value;
4220
6356
  }
4221
- async function runSync(args, flags, io, confirm) {
6357
+ async function runSync(args, flags, io, confirm, cwd) {
4222
6358
  const action = args[0];
4223
6359
  const json = boolFlag(flags, "--json");
4224
6360
  const chatter = chatterIO(io, flags);
4225
6361
  if (action === "source") {
4226
6362
  await runSyncSource(args.slice(1), flags, {
4227
6363
  io,
4228
- client: clientFromFlags(flags),
6364
+ client: clientFromFlags(flags, cwd),
4229
6365
  confirm
4230
6366
  });
4231
6367
  return;
@@ -4233,51 +6369,76 @@ async function runSync(args, flags, io, confirm) {
4233
6369
  if (action === "index") {
4234
6370
  await runSyncIndex(args.slice(1), flags, {
4235
6371
  io,
4236
- client: clientFromFlags(flags),
6372
+ client: clientFromFlags(flags, cwd),
4237
6373
  confirm
4238
6374
  });
4239
6375
  return;
4240
6376
  }
4241
6377
  if (action === "status") {
6378
+ if (findConfigFile(cwd)) {
6379
+ const { config, path: path2 } = await loadHeadbandConfig(cwd);
6380
+ const databaseUrl = process.env.DATABASE_URL || process.env.HEADBAND_DATABASE_URL;
6381
+ const pg = databaseUrl ? buildPgOutbox(databaseUrl, { table: config.sync?.outboxTable }) : null;
6382
+ try {
6383
+ const status = await runSelfHostedSyncStatus({
6384
+ config,
6385
+ configPath: path2,
6386
+ outbox: pg?.outbox ?? null,
6387
+ io: chatter
6388
+ });
6389
+ if (json) printJson(io, { selfHosted: status });
6390
+ } finally {
6391
+ await pg?.close();
6392
+ }
6393
+ return;
6394
+ }
4242
6395
  await runSyncStatus(flags, {
4243
6396
  io,
4244
- client: clientFromFlags(flags),
6397
+ client: clientFromFlags(flags, cwd),
4245
6398
  confirm
4246
6399
  });
4247
6400
  return;
4248
6401
  }
4249
6402
  if (action === "init") {
4250
- const cwd = process.cwd();
4251
6403
  const write = boolFlag(flags, "--write");
4252
- const ddl = runSyncInit({
6404
+ const apply = boolFlag(flags, "--apply");
6405
+ const databaseUrl = apply ? resolveDatabaseUrl(process.env) : void 0;
6406
+ const table = stringFlag3(flags, "--table") ?? (findConfigFile(cwd) ? (await loadHeadbandConfig(cwd)).config.sync?.outboxTable : void 0);
6407
+ const ddl = await runSyncInit({
4253
6408
  io: json ? { stdout: () => void 0, stderr: io.stderr } : io,
4254
- table: stringFlag(flags, "--table"),
6409
+ table,
4255
6410
  write,
4256
- cwd
6411
+ cwd,
6412
+ ...databaseUrl ? { apply: (sql) => applyOutboxDDL(databaseUrl, sql) } : {}
4257
6413
  });
4258
- if (json) printJson(io, { ddl, path: write ? (0, import_path9.join)(cwd, OUTBOX_SQL_RELATIVE_PATH) : null });
6414
+ if (json)
6415
+ printJson(io, {
6416
+ ddl,
6417
+ path: write ? (0, import_path11.join)(cwd, OUTBOX_SQL_RELATIVE_PATH) : null,
6418
+ applied: apply
6419
+ });
4259
6420
  return;
4260
6421
  }
4261
6422
  if (action === "worker") {
4262
- const scaffold = stringFlag(flags, "--scaffold");
6423
+ const scaffold = stringFlag3(flags, "--scaffold");
4263
6424
  if (scaffold) {
4264
6425
  if (scaffold !== "docker") throw new UsageError(`Unknown scaffold target: ${scaffold}`);
4265
6426
  const path2 = runSyncScaffoldDocker({
4266
- cwd: process.cwd(),
6427
+ cwd,
4267
6428
  io: chatter,
4268
- force: boolFlag(flags, "--force")
6429
+ force: boolFlag(flags, "--force"),
6430
+ cliVersion: CLI_VERSION
4269
6431
  });
4270
6432
  if (json) printJson(io, { path: path2 });
4271
6433
  return;
4272
6434
  }
4273
- const auth = authFromFlags(flags);
4274
- const client = new HeadbandClient({ url: auth.url, adminKey: auth.apiKey });
4275
- const { config, adapter } = await loadHeadbandConfig(process.cwd());
6435
+ const { config, adapter } = await loadHeadbandConfig(cwd);
4276
6436
  if (!adapter) {
4277
- throw new CliError(
4278
- "headband.config.ts must export an `adapter` (e.g. drizzleAdapter(...)) for sync worker."
4279
- );
6437
+ throw missingAdapterError("sync worker", "it reads your tables to build each document");
4280
6438
  }
6439
+ const clientOptions = { projectInBody: true, defaultUrl: config.url };
6440
+ const client = syncClientFromFlags(flags, cwd, clientOptions);
6441
+ const apiClient = clientFromFlags(flags, cwd, clientOptions);
4281
6442
  const { outbox, close } = buildPgOutbox(resolveDatabaseUrl(process.env), {
4282
6443
  table: config.sync?.outboxTable
4283
6444
  });
@@ -4288,21 +6449,22 @@ async function runSync(args, flags, io, confirm) {
4288
6449
  client,
4289
6450
  outbox,
4290
6451
  io: chatter,
6452
+ projectId: stringFlag3(flags, "--project") ?? config.projectId,
4291
6453
  // Only override runSyncWorkerCmd's own default (loop unless
4292
- // --budget-ms is set) when the caller explicitly passed --loop —
4293
- // omitting it here keeps the bare `sync worker` default unchanged.
6454
+ // --budget-ms is set) when the caller explicitly passed --loop.
6455
+ // Omitting it here keeps the bare `sync worker` default unchanged.
4294
6456
  loop: boolFlag(flags, "--loop") ? true : void 0,
4295
6457
  budgetMs: parseIntFlag(flags, "--budget-ms"),
4296
6458
  batchSize: parseIntFlag(flags, "--batch-size"),
4297
6459
  reconcileEvery: parseIntFlag(flags, "--reconcile-every"),
4298
6460
  onReconcileTick: async () => {
4299
- const apiClient = clientFromFlags(flags);
4300
6461
  await runSyncReconcile({
4301
6462
  config,
4302
6463
  adapter,
4303
6464
  client,
4304
6465
  io: chatter,
4305
- getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`)
6466
+ getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`),
6467
+ listRemoteIds: (uid, primaryKey) => listRemoteIds(apiClient, uid, primaryKey)
4306
6468
  });
4307
6469
  }
4308
6470
  });
@@ -4313,28 +6475,30 @@ async function runSync(args, flags, io, confirm) {
4313
6475
  return;
4314
6476
  }
4315
6477
  if (action === "reconcile") {
4316
- const auth = authFromFlags(flags);
4317
- const client = new HeadbandClient({ url: auth.url, adminKey: auth.apiKey });
4318
- const apiClient = createApiClient(auth.url, auth);
4319
- const { config, adapter } = await loadHeadbandConfig(process.cwd());
6478
+ const { config, adapter } = await loadHeadbandConfig(cwd);
4320
6479
  if (!adapter) {
4321
- throw new CliError(
4322
- "headband.config.ts must export an `adapter` (e.g. drizzleAdapter(...)) for sync reconcile."
4323
- );
6480
+ throw missingAdapterError("sync reconcile", "it reads your tables to compare them");
4324
6481
  }
6482
+ const clientOptions = { projectInBody: true, defaultUrl: config.url };
6483
+ const client = syncClientFromFlags(flags, cwd, clientOptions);
6484
+ const apiClient = clientFromFlags(flags, cwd, clientOptions);
4325
6485
  const rows = await runSyncReconcile({
4326
6486
  config,
4327
6487
  adapter,
4328
6488
  client,
4329
6489
  io: chatter,
4330
6490
  fix: boolFlag(flags, "--fix"),
4331
- getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`)
6491
+ content: boolFlag(flags, "--content"),
6492
+ getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`),
6493
+ listRemoteIds: (uid, primaryKey) => listRemoteIds(apiClient, uid, primaryKey),
6494
+ readRemoteDocuments: (uid, primaryKey) => readRemoteDocuments(apiClient, uid, primaryKey)
4332
6495
  });
4333
- if (json) printJson(io, { indexes: rows });
6496
+ if (json) printJson(io, { indexes: rows, drift: reconcileHasDrift(rows) });
6497
+ if (reconcileHasDrift(rows)) process.exitCode = 1;
4334
6498
  return;
4335
6499
  }
4336
6500
  if (action === "retry-dead-letters") {
4337
- const { config } = await loadHeadbandConfig(process.cwd());
6501
+ const { config } = await loadHeadbandConfig(cwd);
4338
6502
  const { outbox, close } = buildPgOutbox(resolveDatabaseUrl(process.env), {
4339
6503
  table: config.sync?.outboxTable
4340
6504
  });
@@ -4349,7 +6513,7 @@ async function runSync(args, flags, io, confirm) {
4349
6513
  printCommandHelp("sync", io);
4350
6514
  throw new UsageError("Unknown sync command");
4351
6515
  }
4352
- var CLI_VERSION = true ? "0.3.0" : "dev";
6516
+ var CLI_VERSION = true ? "0.6.1" : "dev";
4353
6517
  async function run(argv, context = { io: defaultIO }) {
4354
6518
  if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v" || argv[0] === "version")) {
4355
6519
  context.io.stdout(CLI_VERSION);
@@ -4359,13 +6523,17 @@ async function run(argv, context = { io: defaultIO }) {
4359
6523
  const command = positional[0];
4360
6524
  const args = positional.slice(1);
4361
6525
  if (boolFlag(flags, "--help") || command === "help") {
6526
+ const subcommandHelp = command && args[0] ? SUBCOMMAND_HELP[`${command} ${args[0]}`] : void 0;
4362
6527
  if (command === "sync" && args[0] === "source") context.io.stdout(SOURCE_USAGE);
4363
6528
  else if (command === "sync" && args[0] === "index") context.io.stdout(INDEX_USAGE);
6529
+ else if (subcommandHelp) context.io.stdout(subcommandHelp);
6530
+ else if (command === "help" && args[0]) printCommandHelp(args[0], context.io);
4364
6531
  else if (command && command !== "help") printCommandHelp(command, context.io);
4365
6532
  else printHelp(context.io);
4366
6533
  return;
4367
6534
  }
4368
6535
  const confirm = (message) => confirmOrSkip(message, flags, context.ask);
6536
+ const cwd = context.cwd ?? process.cwd();
4369
6537
  switch (command || "init") {
4370
6538
  case "init":
4371
6539
  if (!boolFlag(flags, "--yes") && !stdinIsInteractive()) {
@@ -4389,31 +6557,39 @@ async function run(argv, context = { io: defaultIO }) {
4389
6557
  await runProfiles(args, flags, context.io);
4390
6558
  return;
4391
6559
  case "projects":
4392
- await runProjects(args, flags, context.io, confirm);
6560
+ await runProjects(args, flags, context.io, confirm, cwd);
6561
+ return;
6562
+ case "branch":
6563
+ case "branches":
6564
+ await runBranch(args, flags, { io: context.io, auth: authFromFlags(flags), confirm, cwd });
4393
6565
  return;
4394
6566
  case "keys":
4395
- await runKeys(args, flags, context.io, confirm);
6567
+ await runKeys(args, flags, context.io, confirm, cwd);
4396
6568
  return;
4397
6569
  case "indexes":
4398
- await runIndexes(args, flags, context.io, confirm);
6570
+ case "index":
6571
+ await runIndexes(args, flags, context.io, confirm, cwd);
4399
6572
  return;
4400
6573
  case "docs":
4401
- await runDocs(args, flags, context);
6574
+ await runDocs(args, flags, context, cwd);
4402
6575
  return;
4403
6576
  case "search":
4404
- await runSearch(args, flags, context.io);
6577
+ await runSearch(args, flags, context.io, cwd);
4405
6578
  return;
4406
6579
  case "tasks":
4407
- await runTasks(args, flags, context.io);
6580
+ await runTasks(args, flags, context.io, cwd);
4408
6581
  return;
4409
6582
  case "schema":
4410
- await runSchema(args, flags, context.io, confirm);
6583
+ await runSchema(args, flags, context.io, confirm, cwd);
4411
6584
  return;
4412
6585
  case "sync":
4413
- await runSync(args, flags, context.io, confirm);
6586
+ await runSync(args, flags, context.io, confirm, cwd);
4414
6587
  return;
4415
6588
  case "instances":
4416
- await runInstances(args, flags, context.io, clientFromFlags(flags), confirm);
6589
+ await runInstances(args, flags, context.io, clientFromFlags(flags, cwd), confirm);
6590
+ return;
6591
+ case "warm":
6592
+ await runWarm(args, flags, context.io, clientFromFlags(flags, cwd));
4417
6593
  return;
4418
6594
  default:
4419
6595
  printHelp(context.io);
@@ -4439,7 +6615,7 @@ async function main(argv, context = { io: defaultIO }) {
4439
6615
  function stdoutLineWriter(line) {
4440
6616
  if (process.stdout.write(`${line}
4441
6617
  `)) return;
4442
- return new Promise((resolve) => process.stdout.once("drain", () => resolve()));
6618
+ return new Promise((resolve2) => process.stdout.once("drain", () => resolve2()));
4443
6619
  }
4444
6620
  function isDirectRun() {
4445
6621
  return typeof require !== "undefined" && typeof module !== "undefined" && require.main === module;
@@ -4450,18 +6626,30 @@ if (isDirectRun()) {
4450
6626
  throw error;
4451
6627
  });
4452
6628
  void main(process.argv.slice(2), { io: defaultIO, writeLine: stdoutLineWriter }).then((code) => {
4453
- if (code === EXIT_CODES.ok) return;
4454
- process.exitCode = code;
4455
- setTimeout(() => process.exit(code), 2e3).unref();
6629
+ const exitCode = code === EXIT_CODES.ok ? Number(process.exitCode ?? 0) : code;
6630
+ process.exitCode = exitCode;
6631
+ exitAfterFlush(exitCode);
6632
+ setTimeout(() => process.exit(exitCode), 2e3).unref();
4456
6633
  });
4457
6634
  }
6635
+ function exitAfterFlush(code) {
6636
+ let pending = 2;
6637
+ const done = () => {
6638
+ pending -= 1;
6639
+ if (pending === 0) process.exit(code);
6640
+ };
6641
+ process.stdout.write("", done);
6642
+ process.stderr.write("", done);
6643
+ }
4458
6644
  // Annotate the CommonJS export names for ESM import in node:
4459
6645
  0 && (module.exports = {
4460
6646
  CLI_VERSION,
4461
6647
  main,
4462
6648
  parseArgv,
4463
6649
  parseExpires,
6650
+ parseIndexList,
4464
6651
  parsePermissions,
4465
6652
  run,
4466
- stdinIsInteractive
6653
+ stdinIsInteractive,
6654
+ syncClientFromFlags
4467
6655
  });