@headband/cli 0.3.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +282 -53
  3. package/dist/index.js +2344 -527
  4. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -37,12 +37,13 @@ __export(index_exports, {
37
37
  parseExpires: () => parseExpires,
38
38
  parsePermissions: () => parsePermissions,
39
39
  run: () => run,
40
- stdinIsInteractive: () => stdinIsInteractive
40
+ stdinIsInteractive: () => stdinIsInteractive,
41
+ syncClientFromFlags: () => syncClientFromFlags
41
42
  });
42
43
  module.exports = __toCommonJS(index_exports);
43
- var import_fs10 = require("fs");
44
44
  var import_fs11 = require("fs");
45
- var import_path9 = require("path");
45
+ var import_fs12 = require("fs");
46
+ var import_path11 = require("path");
46
47
  var import_prompts2 = __toESM(require("prompts"));
47
48
  var import_picocolors3 = __toESM(require("picocolors"));
48
49
 
@@ -58,6 +59,9 @@ var import_path = require("path");
58
59
  function isPublicSearchKey(value) {
59
60
  return /^hb_src_[A-Za-z0-9_-]+$/.test(value.trim());
60
61
  }
62
+ function displayPrefixOf(raw) {
63
+ return raw.slice(0, raw.length === 55 ? 16 : 12);
64
+ }
61
65
  function validatePublicSearchKey(value) {
62
66
  if (!value.trim()) return "Search API key is required";
63
67
  if (value.startsWith("hb_adm_")) {
@@ -292,6 +296,9 @@ var ApiError = class extends CliError {
292
296
  this.details = details;
293
297
  }
294
298
  };
299
+ function restate(error, message) {
300
+ return new ApiError(message, error.status, error.code, error.details);
301
+ }
295
302
  var DEFAULT_CODES = {
296
303
  [EXIT_CODES.generic]: "error",
297
304
  [EXIT_CODES.usage]: "usage_error",
@@ -502,6 +509,25 @@ function resolveAuth(selectors = {}) {
502
509
  }
503
510
 
504
511
  // src/api.ts
512
+ var MAX_RETRY_AFTER_S = 10;
513
+ var RATE_LIMIT_RETRIES = 2;
514
+ function retryAfterSeconds(response) {
515
+ const raw = response.headers.get("retry-after");
516
+ if (raw === null || !/^\d+$/.test(raw.trim())) return null;
517
+ const s = parseInt(raw, 10);
518
+ return s <= MAX_RETRY_AFTER_S ? s : null;
519
+ }
520
+ function errorMessageOf(status, errorBody) {
521
+ const message = errorBody.error || `Request failed with status ${status}`;
522
+ if (status === 429 && typeof errorBody.message === "string" && errorBody.message.trim()) {
523
+ return `${message}. ${errorBody.message.trim()}`;
524
+ }
525
+ if (errorBody.code === "partial_failure" && Array.isArray(errorBody.errors)) {
526
+ return partialFailureSummary({ message, errors: errorBody.errors });
527
+ }
528
+ return message;
529
+ }
530
+ var defaultSleep = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
505
531
  async function readJson(response) {
506
532
  const text = await response.text();
507
533
  if (!text) return {};
@@ -521,13 +547,21 @@ async function apiRequest(baseUrl, path2, init2 = {}) {
521
547
  headers.set("authorization", `Bearer ${init2.apiKey}`);
522
548
  }
523
549
  const base = normalizeUrl(baseUrl);
550
+ const { rateLimitRetries = 0, sleep = defaultSleep, apiKey: _apiKey, ...fetchInit } = init2;
551
+ void _apiKey;
524
552
  let response;
525
553
  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
- });
554
+ for (let attempt = 0; ; attempt++) {
555
+ response = await fetch(`${base}${path2}`, {
556
+ ...fetchInit,
557
+ headers,
558
+ body: init2.body === void 0 || typeof init2.body === "string" ? init2.body : JSON.stringify(init2.body)
559
+ });
560
+ const wait = response.status === 429 ? retryAfterSeconds(response) : null;
561
+ if (wait === null || attempt >= rateLimitRetries) break;
562
+ await response.body?.cancel().catch(() => void 0);
563
+ await sleep(wait * 1e3);
564
+ }
531
565
  } catch (error) {
532
566
  const cause = error?.cause;
533
567
  const detail = cause?.code || cause?.message || (error instanceof Error ? error.message : "");
@@ -540,7 +574,7 @@ async function apiRequest(baseUrl, path2, init2 = {}) {
540
574
  if (!response.ok) {
541
575
  const errorBody = body && typeof body === "object" ? body : {};
542
576
  throw new ApiError(
543
- errorBody.error || `Request failed with status ${response.status}`,
577
+ errorMessageOf(response.status, errorBody),
544
578
  response.status,
545
579
  errorBody.code,
546
580
  body
@@ -549,18 +583,23 @@ async function apiRequest(baseUrl, path2, init2 = {}) {
549
583
  return body;
550
584
  }
551
585
  function createApiClient(url, profile) {
552
- const request = (path2, init2 = {}) => apiRequest(url, path2, { ...init2, apiKey: profile.apiKey });
586
+ const request = (path2, init2 = {}) => apiRequest(url, path2, {
587
+ ...init2,
588
+ apiKey: profile.apiKey,
589
+ headers: profile.headers,
590
+ rateLimitRetries: RATE_LIMIT_RETRIES
591
+ });
553
592
  return {
554
593
  url: normalizeUrl(url),
555
594
  get: (path2) => request(path2, { method: "GET" }),
556
595
  post: (path2, body) => request(path2, { method: "POST", body }),
557
596
  patch: (path2, body) => request(path2, { method: "PATCH", body }),
558
- delete: (path2) => request(path2, { method: "DELETE" })
597
+ delete: (path2, body) => request(path2, body === void 0 ? { method: "DELETE" } : { method: "DELETE", body })
559
598
  };
560
599
  }
561
600
 
562
601
  // src/auth.ts
563
- var defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
602
+ var defaultSleep2 = (ms) => new Promise((resolve2) => setTimeout(resolve2, ms));
564
603
  function openBrowser(url) {
565
604
  try {
566
605
  if (process.platform === "darwin") {
@@ -575,7 +614,7 @@ function openBrowser(url) {
575
614
  }
576
615
  async function runDeviceFlow(url, io, options = {}) {
577
616
  const baseUrl = normalizeUrl(url);
578
- const sleep = options.sleep || defaultSleep;
617
+ const sleep = options.sleep || defaultSleep2;
579
618
  const now = options.now || Date.now;
580
619
  const device = await apiRequest(baseUrl, "/api/cli/device", {
581
620
  method: "POST",
@@ -644,11 +683,564 @@ async function login(url, io, options = {}) {
644
683
  };
645
684
  }
646
685
 
647
- // src/config-load.ts
686
+ // src/branch.ts
687
+ var import_path5 = require("path");
688
+
689
+ // src/output.ts
690
+ var defaultIO = {
691
+ stdout: (message = "") => console.log(message),
692
+ stderr: (message = "") => console.error(message)
693
+ };
694
+ function printJson(io, value) {
695
+ io.stdout(JSON.stringify(value, null, 2));
696
+ }
697
+ function formatTable(rows, columns) {
698
+ if (rows.length === 0) return "No results.";
699
+ const selectedColumns = columns || Object.keys(rows[0]);
700
+ const widths = selectedColumns.map(
701
+ (column) => Math.max(
702
+ column.length,
703
+ ...rows.map(
704
+ (row) => String(row[column] === null || row[column] === void 0 ? "" : row[column]).length
705
+ )
706
+ )
707
+ );
708
+ const renderRow = (values) => values.map((value, index) => value.padEnd(widths[index])).join(" ").trimEnd();
709
+ return [
710
+ renderRow(selectedColumns),
711
+ renderRow(widths.map((width) => "-".repeat(width))),
712
+ ...rows.map(
713
+ (row) => renderRow(
714
+ selectedColumns.map(
715
+ (column) => String(row[column] === null || row[column] === void 0 ? "" : row[column])
716
+ )
717
+ )
718
+ )
719
+ ].join("\n");
720
+ }
721
+ function printTable(io, rows, columns) {
722
+ io.stdout(formatTable(rows, columns));
723
+ }
724
+ function summarizeHit(hit) {
725
+ const result = {};
726
+ for (const key of Object.keys(hit).slice(0, 5)) {
727
+ const value = hit[key];
728
+ result[key] = value && typeof value === "object" ? JSON.stringify(value) : value;
729
+ }
730
+ return result;
731
+ }
732
+
733
+ // src/project-file.ts
648
734
  var import_fs3 = require("fs");
735
+ var import_os3 = require("os");
649
736
  var import_path4 = require("path");
737
+ var PROJECT_DIR = ".headband";
738
+ var PROJECT_FILE = "project.json";
739
+ var PROJECT_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
740
+ var BRANCH_NAME_RE = /^[a-z0-9]([a-z0-9._/-]{0,61}[a-z0-9])?$/;
741
+ var BRANCH_ID_RE = /^br_[a-z0-9]{12}$/;
742
+ var CONTROL_RE = /[\u0000-\u001f\u007f]/;
743
+ var isProjectId = (v) => typeof v === "string" && PROJECT_ID_RE.test(v);
744
+ var isBranchId = (v) => typeof v === "string" && BRANCH_ID_RE.test(v);
745
+ var isBranchName = (v) => typeof v === "string" && BRANCH_NAME_RE.test(v) && !BRANCH_ID_RE.test(v);
746
+ var isOrgId = (v) => typeof v === "string" && v.length > 0 && !CONTROL_RE.test(v);
747
+ function isProjectFile(v) {
748
+ return Boolean(
749
+ v && isOrgId(v.orgId) && isProjectId(v.projectId) && (isBranchName(v.branch) || isBranchId(v.branch))
750
+ );
751
+ }
752
+ function homeDir() {
753
+ return process.env.HOME || process.env.USERPROFILE || (0, import_os3.homedir)();
754
+ }
755
+ function currentUid() {
756
+ return typeof process.getuid === "function" ? process.getuid() : void 0;
757
+ }
758
+ function owns(path2, uid, read = import_fs3.lstatSync) {
759
+ if (uid === void 0) return true;
760
+ try {
761
+ return read(path2).uid === uid;
762
+ } catch {
763
+ return true;
764
+ }
765
+ }
766
+ function projectFileOwned(path2, uid = currentUid()) {
767
+ return owns((0, import_path4.dirname)(path2), uid) && owns(path2, uid) && owns(path2, uid, import_fs3.statSync);
768
+ }
769
+ function foreignOwner(path2, coversCwd) {
770
+ return new CliError(
771
+ 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.`,
772
+ { code: "project_file_foreign_owner" }
773
+ );
774
+ }
775
+ function projectFileSearchDirs(startDir, home = homeDir()) {
776
+ const dirs = [];
777
+ const ceiling = home && (0, import_path4.isAbsolute)(home) ? (0, import_path4.resolve)(home) : null;
778
+ let dir = (0, import_path4.resolve)(startDir);
779
+ for (; ; ) {
780
+ dirs.push(dir);
781
+ if (dir === ceiling) break;
782
+ const parent = (0, import_path4.dirname)(dir);
783
+ if (parent === dir) break;
784
+ dir = parent;
785
+ }
786
+ return dirs;
787
+ }
788
+ function findProjectFile(cwd) {
789
+ for (const dir of projectFileSearchDirs(cwd)) {
790
+ const candidate = (0, import_path4.join)(dir, PROJECT_DIR, PROJECT_FILE);
791
+ if ((0, import_fs3.existsSync)(candidate)) return candidate;
792
+ }
793
+ return null;
794
+ }
795
+ function parseProjectFile(path2) {
796
+ try {
797
+ const v = JSON.parse((0, import_fs3.readFileSync)(path2, "utf-8"));
798
+ return isProjectFile(v) ? { orgId: v.orgId, projectId: v.projectId, branch: v.branch } : null;
799
+ } catch {
800
+ return null;
801
+ }
802
+ }
803
+ function readProjectFile(cwd) {
804
+ const path2 = findProjectFile(cwd);
805
+ return path2 && projectFileOwned(path2) ? parseProjectFile(path2) : null;
806
+ }
807
+ function projectFileTarget(cwd) {
808
+ const found = findProjectFile(cwd);
809
+ if (found && projectFileOwned(found)) return found;
810
+ const own = (0, import_path4.join)((0, import_path4.resolve)(cwd), PROJECT_DIR, PROJECT_FILE);
811
+ if (found === own) throw foreignOwner(found, true);
812
+ if ((0, import_fs3.existsSync)((0, import_path4.dirname)(own)) && !owns((0, import_path4.dirname)(own), currentUid())) {
813
+ throw foreignOwner((0, import_path4.dirname)(own), true);
814
+ }
815
+ return own;
816
+ }
817
+ function writeProjectFile(cwd, f) {
818
+ const file = { orgId: f.orgId, projectId: f.projectId, branch: f.branch };
819
+ if (!isProjectFile(file)) {
820
+ throw new CliError("The branch's org, project or name cannot go in .headband/project.json.");
821
+ }
822
+ const path2 = projectFileTarget(cwd);
823
+ const dir = (0, import_path4.dirname)(path2);
824
+ (0, import_fs3.mkdirSync)(dir, { recursive: true, mode: 448 });
825
+ (0, import_fs3.chmodSync)(dir, 448);
826
+ const temp = (0, import_path4.join)(dir, `.${PROJECT_FILE}.${process.pid}.tmp`);
827
+ try {
828
+ (0, import_fs3.writeFileSync)(temp, `${JSON.stringify(file, null, 2)}
829
+ `, { encoding: "utf-8", mode: 384 });
830
+ (0, import_fs3.chmodSync)(temp, 384);
831
+ (0, import_fs3.renameSync)(temp, path2);
832
+ } catch (error) {
833
+ (0, import_fs3.rmSync)(temp, { force: true });
834
+ throw error;
835
+ }
836
+ return path2;
837
+ }
838
+ function checkoutFor(cwd, loginOrgId) {
839
+ const path2 = findProjectFile(cwd);
840
+ if (!path2) return null;
841
+ if (!projectFileOwned(path2)) throw foreignOwner(path2, (0, import_path4.dirname)((0, import_path4.dirname)(path2)) === (0, import_path4.resolve)(cwd));
842
+ const file = parseProjectFile(path2);
843
+ if (!file) {
844
+ throw new CliError(
845
+ `Can't read ${path2}. Run headband branch checkout <name> again, or delete the file to use the main branch.`,
846
+ { code: "project_file_unreadable" }
847
+ );
848
+ }
849
+ if (loginOrgId && file.orgId !== loginOrgId) {
850
+ throw new CliError(
851
+ `${(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.`,
852
+ { code: "project_file_org_mismatch" }
853
+ );
854
+ }
855
+ return file;
856
+ }
857
+ function stringFlag(flags, name) {
858
+ const value = flags[name];
859
+ return typeof value === "string" ? value : void 0;
860
+ }
861
+ function branchTargetFor(flags, cwd, options = {}) {
862
+ const project = stringFlag(flags, "--project");
863
+ const branch = stringFlag(flags, "--branch");
864
+ if (project && !branch && !options.projectInBody) {
865
+ throw new UsageError(
866
+ "--project needs --branch on this command, which sends no project of its own. Pass --project <id> --branch <name>, or drop --project."
867
+ );
868
+ }
869
+ const file = checkoutFor(cwd, options.loginOrgId);
870
+ if (branch) {
871
+ if (isBranchId(branch)) return { projectId: project ?? null, branch };
872
+ const projectId = project ?? file?.projectId;
873
+ if (!projectId) {
874
+ throw new UsageError(
875
+ "--branch needs a project. Pass --project <id> or run headband branch checkout inside a project directory."
876
+ );
877
+ }
878
+ return { projectId, branch };
879
+ }
880
+ if (!file) return null;
881
+ if (project && project !== file.projectId) return null;
882
+ return { projectId: file.projectId, branch: file.branch };
883
+ }
884
+ function branchHeaderFor(flags, cwd, options = {}) {
885
+ const target = branchTargetFor(flags, cwd, options);
886
+ if (!target) return null;
887
+ return isBranchId(target.branch) || target.projectId === null ? target.branch : `${target.projectId}/${target.branch}`;
888
+ }
889
+
890
+ // src/branch.ts
891
+ var COLUMNS = ["name", "primary", "protected", "expiresAt", "state"];
892
+ var PART_2 = /* @__PURE__ */ new Set(["reset", "rollback", "restore", "set-primary"]);
893
+ var BRANCH_USAGE = [
894
+ "Usage:",
895
+ " headband branch list [--project <id>] [--json]",
896
+ " headband branch create <name> --empty [--parent <name>] [--expires-in <2h|7d>] [--project <id>] [--json]",
897
+ " headband branch delete <name> [--project <id>] [--yes] [--json]",
898
+ " headband branch protect <name> [--project <id>] [--json]",
899
+ " headband branch unprotect <name> [--project <id>] [--json]",
900
+ " headband branch expire <name> (--in <2h|7d> | --at <iso> | --clear) [--project <id>] [--json]",
901
+ " headband branch checkout <name> [--project <id>] [--json]",
902
+ " headband branch create <name> (without --empty, a fork) coming with bucket storage",
903
+ " headband branch reset|rollback|restore|set-primary <name> coming with bucket storage",
904
+ "",
905
+ "The project comes from --project, else the directory's checkout (.headband/project.json).",
906
+ "Durations take s, m, h, d or w: 30m, 2h, 7d.",
907
+ "",
908
+ "The primary branch (main) serves production. It is always protected: it cannot be",
909
+ "unprotected, expired or deleted. A protected branch other than main must be unprotected",
910
+ "before it can be deleted.",
911
+ "",
912
+ "checkout writes .headband/project.json. Every later command in that directory, or in a",
913
+ "directory below it, sends Headband-Branch: <project id>/<branch> with your login key, so it",
914
+ "works on that branch. The nearest .headband/project.json above you applies, as with git.",
915
+ "On those commands, --project <id> --branch <name> picks another branch for one call."
916
+ ].join("\n");
917
+ var DURATION_UNIT_MS = {
918
+ s: 1e3,
919
+ m: 6e4,
920
+ h: 36e5,
921
+ d: 864e5,
922
+ w: 6048e5
923
+ };
924
+ function parseDuration(input) {
925
+ const match = /^(\d+)(s|m|h|d|w)$/.exec(input.trim());
926
+ if (!match) {
927
+ throw new UsageError(
928
+ `Invalid duration "${input}". Use forms like 30m, 2h, 7d (units: s/m/h/d/w).`
929
+ );
930
+ }
931
+ const n = parseInt(match[1], 10);
932
+ if (n <= 0) throw new UsageError(`Duration "${input}" must be greater than zero.`);
933
+ return n * DURATION_UNIT_MS[match[2]];
934
+ }
935
+ function expiresAtFrom(flags, now = Date.now()) {
936
+ const inFlag = stringFlag2(flags, "--in");
937
+ const atFlag = stringFlag2(flags, "--at");
938
+ const clear = flags["--clear"] === true;
939
+ const given = [inFlag !== void 0, atFlag !== void 0, clear].filter(Boolean).length;
940
+ if (given !== 1) {
941
+ throw new UsageError("Pass exactly one of --in <duration>, --at <iso> or --clear.");
942
+ }
943
+ if (clear) return null;
944
+ if (inFlag !== void 0) return new Date(now + parseDuration(inFlag)).toISOString();
945
+ const at = Date.parse(atFlag);
946
+ if (Number.isNaN(at)) {
947
+ throw new UsageError(`Invalid --at "${atFlag}". Use an ISO 8601 time.`);
948
+ }
949
+ if (at <= now) throw new UsageError("--at must be in the future.");
950
+ return new Date(at).toISOString();
951
+ }
952
+ function stringFlag2(flags, name) {
953
+ const value = flags[name];
954
+ return typeof value === "string" ? value : void 0;
955
+ }
956
+ function requiredName(value) {
957
+ if (!value) throw new UsageError("Missing branch name");
958
+ return value;
959
+ }
960
+ function projectFor(flags, checkout, missing = "Pass --project <id> or run headband branch checkout inside a project directory.") {
961
+ const project = stringFlag2(flags, "--project") ?? checkout()?.projectId;
962
+ if (!project) throw new UsageError(missing);
963
+ return project;
964
+ }
965
+ function noLoginForCheckout(auth) {
966
+ const instead = "or pass --project <id> --branch <name> to each command instead.";
967
+ if (auth.source === "env") {
968
+ 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}`;
969
+ }
970
+ if (auth.source === "flag") {
971
+ 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}`;
972
+ }
973
+ return `branch checkout records the org of your login. Run headband login, ${instead}`;
974
+ }
975
+ function restateRefusal(error, action, name) {
976
+ if (!(error instanceof ApiError)) return error;
977
+ if (error.code === "branch_is_primary") {
978
+ 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";
979
+ return restate(error, `${name} is the primary branch. It serves production, ${what}.`);
980
+ }
981
+ if (error.code === "branch_protected") {
982
+ if (action === "protect") {
983
+ return restate(
984
+ error,
985
+ `Branch ${name} has an expiry, and a branch with an expiry cannot be protected. Run headband branch expire ${name} --clear first.`
986
+ );
987
+ }
988
+ if (action === "delete" || action === "expire") {
989
+ return restate(
990
+ error,
991
+ `Branch ${name} is protected. Run headband branch unprotect ${name} first.`
992
+ );
993
+ }
994
+ }
995
+ return error;
996
+ }
997
+ async function runBranch(args, flags, deps) {
998
+ const { io, auth, confirm, cwd } = deps;
999
+ const action = args[0];
1000
+ const json = flags["--json"] === true;
1001
+ if (action && PART_2.has(action)) {
1002
+ throw new UsageError(
1003
+ `headband branch ${action} is coming with bucket storage.`,
1004
+ "not_available"
1005
+ );
1006
+ }
1007
+ const client = createApiClient(auth.url, { apiKey: auth.apiKey });
1008
+ const checkout = () => checkoutFor(cwd, auth.profile?.orgId);
1009
+ const path2 = (name, project) => `/v1/branches/${encodeURIComponent(name)}?project=${encodeURIComponent(project)}`;
1010
+ if (action === "list") {
1011
+ const project = projectFor(flags, checkout);
1012
+ const response = await client.get(
1013
+ `/v1/branches?project=${encodeURIComponent(project)}`
1014
+ );
1015
+ if (json) printJson(io, response);
1016
+ else printTable(io, response.data.branches, COLUMNS);
1017
+ return;
1018
+ }
1019
+ if (action === "create") {
1020
+ const name = requiredName(args[1]);
1021
+ const body = { name, empty: flags["--empty"] === true };
1022
+ const parent = stringFlag2(flags, "--parent");
1023
+ if (parent) body.parent = parent;
1024
+ const expiresIn = stringFlag2(flags, "--expires-in");
1025
+ if (expiresIn) body.expiresAt = new Date(Date.now() + parseDuration(expiresIn)).toISOString();
1026
+ body.projectId = projectFor(flags, checkout);
1027
+ let response;
1028
+ try {
1029
+ response = await client.post("/v1/branches", body);
1030
+ } catch (error) {
1031
+ if (error instanceof ApiError && error.code === "branches_not_open") {
1032
+ throw restate(error, "Branches other than main are not open yet.");
1033
+ }
1034
+ if (error instanceof ApiError && error.code === "fork_not_available") {
1035
+ throw restate(
1036
+ error,
1037
+ "Forking arrives with bucket storage. Pass --empty for an empty branch."
1038
+ );
1039
+ }
1040
+ throw error;
1041
+ }
1042
+ if (json) printJson(io, response);
1043
+ else printTable(io, [response.data], COLUMNS);
1044
+ return;
1045
+ }
1046
+ if (action === "delete") {
1047
+ const name = requiredName(args[1]);
1048
+ const project = projectFor(flags, checkout);
1049
+ if (!await confirm(`Delete branch ${name} with its indexes, keys and engines?`)) {
1050
+ if (json) printJson(io, { cancelled: true });
1051
+ else io.stdout("Cancelled.");
1052
+ return;
1053
+ }
1054
+ const response = await client.delete(path2(name, project)).catch((error) => Promise.reject(restateRefusal(error, action, name)));
1055
+ if (json) printJson(io, response);
1056
+ else io.stdout(`Deleted branch ${name}.`);
1057
+ return;
1058
+ }
1059
+ if (action === "protect" || action === "unprotect") {
1060
+ const name = requiredName(args[1]);
1061
+ const project = projectFor(flags, checkout);
1062
+ const value = action === "protect";
1063
+ const response = await client.patch(path2(name, project), { protected: value }).catch((error) => Promise.reject(restateRefusal(error, action, name)));
1064
+ if (json) printJson(io, response);
1065
+ else io.stdout(value ? `Branch ${name} is protected.` : `Branch ${name} is not protected.`);
1066
+ return;
1067
+ }
1068
+ if (action === "expire") {
1069
+ const name = requiredName(args[1]);
1070
+ const expiresAt = expiresAtFrom(flags);
1071
+ const project = projectFor(flags, checkout);
1072
+ const response = await client.patch(path2(name, project), { expiresAt }).catch((error) => Promise.reject(restateRefusal(error, action, name)));
1073
+ if (json) printJson(io, response);
1074
+ else
1075
+ io.stdout(
1076
+ expiresAt ? `Branch ${name} expires at ${expiresAt}.` : `Branch ${name} no longer expires.`
1077
+ );
1078
+ return;
1079
+ }
1080
+ if (action === "checkout") {
1081
+ const name = requiredName(args[1]);
1082
+ const project = projectFor(flags, () => readProjectFile(cwd), "Pass --project <id>.");
1083
+ const orgId = auth.profile?.orgId;
1084
+ if (!orgId) throw new AuthError(noLoginForCheckout(auth));
1085
+ projectFileTarget(cwd);
1086
+ let branch;
1087
+ try {
1088
+ branch = (await client.get(path2(name, project))).data;
1089
+ } catch (error) {
1090
+ if (error instanceof ApiError && error.status === 404 && error.code !== "project_not_found") {
1091
+ throw new CliError(`No branch '${name}' in project ${project}. See headband branch list.`, {
1092
+ exitCode: EXIT_CODES.notFound,
1093
+ code: error.code ?? "branch_not_found"
1094
+ });
1095
+ }
1096
+ throw error;
1097
+ }
1098
+ if (branch.state === "deleting") {
1099
+ throw new CliError(`Branch '${branch.name}' is being deleted. See headband branch list.`, {
1100
+ exitCode: EXIT_CODES.conflict,
1101
+ code: "branch_deleting"
1102
+ });
1103
+ }
1104
+ const file = { orgId, projectId: branch.projectId, branch: branch.name };
1105
+ const filePath = writeProjectFile(cwd, file);
1106
+ const header = `${file.projectId}/${file.branch}`;
1107
+ if (json) printJson(io, { path: filePath, ...file });
1108
+ else {
1109
+ io.stdout(`Checked out branch ${file.branch} of project ${file.projectId}.`);
1110
+ io.stdout(`Commands under ${(0, import_path5.dirname)((0, import_path5.dirname)(filePath))} now send Headband-Branch: ${header}`);
1111
+ }
1112
+ return;
1113
+ }
1114
+ io.stdout(BRANCH_USAGE);
1115
+ throw new UsageError("Unknown branch command");
1116
+ }
1117
+
1118
+ // src/config-load.ts
1119
+ var import_fs4 = require("fs");
1120
+ var import_path6 = require("path");
650
1121
  var import_url = require("url");
651
1122
  var import_jiti = require("jiti");
1123
+
1124
+ // ../sync/src/config.ts
1125
+ var SETTINGS_ATTRIBUTE_LIST_KEYS = [
1126
+ "searchableAttributes",
1127
+ "filterableAttributes",
1128
+ "sortableAttributes",
1129
+ "displayedAttributes"
1130
+ ];
1131
+ function validateConfig(config, ormSchema) {
1132
+ const errors = [];
1133
+ const seenIndexNames = /* @__PURE__ */ new Set();
1134
+ for (const index of config.indexes) {
1135
+ if (seenIndexNames.has(index.name)) {
1136
+ errors.push({
1137
+ index: index.name,
1138
+ message: `duplicate index name '${index.name}'`
1139
+ });
1140
+ } else {
1141
+ seenIndexNames.add(index.name);
1142
+ }
1143
+ const model = ormSchema.models[index.source];
1144
+ if (!model) {
1145
+ errors.push({
1146
+ index: index.name,
1147
+ message: `unknown model '${index.source}'`
1148
+ });
1149
+ continue;
1150
+ }
1151
+ if (!model.columns[index.primaryKey]) {
1152
+ errors.push({
1153
+ index: index.name,
1154
+ field: index.primaryKey,
1155
+ message: `primaryKey '${index.primaryKey}' is not a column on model '${index.source}'`
1156
+ });
1157
+ }
1158
+ const fieldNames = Object.keys(index.fields);
1159
+ for (const [fieldName, spec] of Object.entries(index.fields)) {
1160
+ if (spec === true) {
1161
+ if (!model.columns[fieldName]) {
1162
+ errors.push({
1163
+ index: index.name,
1164
+ field: fieldName,
1165
+ message: `field '${fieldName}' is not a column on model '${index.source}'`
1166
+ });
1167
+ }
1168
+ continue;
1169
+ }
1170
+ if (typeof spec === "function") {
1171
+ continue;
1172
+ }
1173
+ if (spec.kind === "rel") {
1174
+ const targetModel2 = ormSchema.models[spec.model];
1175
+ if (!targetModel2) {
1176
+ errors.push({
1177
+ index: index.name,
1178
+ field: fieldName,
1179
+ message: `unknown model '${spec.model}'`
1180
+ });
1181
+ continue;
1182
+ }
1183
+ const hasPath2 = model.relations.some(
1184
+ (r) => r.fromModel === index.source && r.toModel === spec.model && r.kind === "one"
1185
+ );
1186
+ if (!hasPath2) {
1187
+ errors.push({
1188
+ index: index.name,
1189
+ field: fieldName,
1190
+ message: `no relation path from '${index.source}' to '${spec.model}' for rel()`
1191
+ });
1192
+ }
1193
+ continue;
1194
+ }
1195
+ const targetModel = ormSchema.models[spec.model];
1196
+ if (!targetModel) {
1197
+ errors.push({
1198
+ index: index.name,
1199
+ field: fieldName,
1200
+ message: `unknown model '${spec.model}'`
1201
+ });
1202
+ continue;
1203
+ }
1204
+ const hasPath = model.relations.some(
1205
+ (r) => r.fromModel === spec.model && r.toModel === index.source && r.kind === "many"
1206
+ );
1207
+ if (!hasPath) {
1208
+ errors.push({
1209
+ index: index.name,
1210
+ field: fieldName,
1211
+ message: `no relation path from '${spec.model}' to '${index.source}' for agg()`
1212
+ });
1213
+ }
1214
+ }
1215
+ if (index.settings) {
1216
+ const allowedAttributes = /* @__PURE__ */ new Set([...fieldNames, index.primaryKey]);
1217
+ for (const key of SETTINGS_ATTRIBUTE_LIST_KEYS) {
1218
+ const list = index.settings[key];
1219
+ if (!list) continue;
1220
+ for (const attribute of list) {
1221
+ if (!allowedAttributes.has(attribute)) {
1222
+ errors.push({
1223
+ index: index.name,
1224
+ field: attribute,
1225
+ message: `settings.${key} references undefined field '${attribute}'`
1226
+ });
1227
+ }
1228
+ }
1229
+ }
1230
+ const distinctAttribute = index.settings.distinctAttribute;
1231
+ if (distinctAttribute && !allowedAttributes.has(distinctAttribute)) {
1232
+ errors.push({
1233
+ index: index.name,
1234
+ field: distinctAttribute,
1235
+ message: `settings.distinctAttribute references undefined field '${distinctAttribute}'`
1236
+ });
1237
+ }
1238
+ }
1239
+ }
1240
+ return errors;
1241
+ }
1242
+
1243
+ // src/config-load.ts
652
1244
  var CONFIG_FILENAMES = [
653
1245
  "headband.config.ts",
654
1246
  "headband.config.mts",
@@ -656,24 +1248,91 @@ var CONFIG_FILENAMES = [
656
1248
  "headband.config.mjs",
657
1249
  "headband.config.cjs"
658
1250
  ];
1251
+ 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
1252
  function findConfigFile(cwd) {
660
1253
  for (const name of CONFIG_FILENAMES) {
661
- const path2 = (0, import_path4.join)(cwd, name);
662
- if ((0, import_fs3.existsSync)(path2)) return path2;
1254
+ const path2 = (0, import_path6.join)(cwd, name);
1255
+ if ((0, import_fs4.existsSync)(path2)) return path2;
663
1256
  }
664
1257
  return void 0;
665
1258
  }
666
1259
  function isSyncConfig(value) {
667
1260
  return typeof value === "object" && value !== null && Array.isArray(value.indexes);
668
1261
  }
1262
+ function isAdapter(value) {
1263
+ if (typeof value !== "object" || value === null) return false;
1264
+ const a = value;
1265
+ return ["introspect", "resolveDocs", "fanOut", "listIds"].every(
1266
+ (name) => typeof a[name] === "function"
1267
+ );
1268
+ }
1269
+ function configShapeProblems(config) {
1270
+ const problems = [];
1271
+ config.indexes.forEach((def, i) => {
1272
+ const label = typeof def?.name === "string" && def.name ? def.name : `indexes[${i}]`;
1273
+ if (typeof def !== "object" || def === null) {
1274
+ problems.push(`${label}: not an index definition`);
1275
+ return;
1276
+ }
1277
+ if (typeof def.name !== "string" || !def.name) problems.push(`${label}: missing "name"`);
1278
+ if (typeof def.source !== "string" || !def.source)
1279
+ problems.push(`${label}: missing "source" (the table the documents come from)`);
1280
+ if (typeof def.primaryKey !== "string" || !def.primaryKey)
1281
+ problems.push(`${label}: missing "primaryKey"`);
1282
+ if (typeof def.fields !== "object" || def.fields === null || Array.isArray(def.fields)) {
1283
+ problems.push(`${label}: "fields" must be an object of field names`);
1284
+ } else {
1285
+ for (const [field, spec] of Object.entries(def.fields)) {
1286
+ const kind = spec?.kind;
1287
+ const ok = spec === true || typeof spec === "function" || typeof spec === "object" && spec !== null && (kind === "rel" || kind === "agg");
1288
+ if (!ok) {
1289
+ problems.push(
1290
+ `${label}.${field}: a field is true, rel(model, field), agg(model, "count") or a function of the row`
1291
+ );
1292
+ }
1293
+ }
1294
+ }
1295
+ if (def.shouldIndex !== void 0 && typeof def.shouldIndex !== "function")
1296
+ problems.push(`${label}: "shouldIndex" must be a function`);
1297
+ });
1298
+ return problems;
1299
+ }
1300
+ function validateLoadedConfig(loaded) {
1301
+ const file = (0, import_path6.basename)(loaded.path);
1302
+ const shape = configShapeProblems(loaded.config);
1303
+ if (shape.length > 0) {
1304
+ throw new CliError(
1305
+ `${file} has ${shape.length} problem(s):
1306
+ ${shape.map((p) => ` ${p}`).join("\n")}`,
1307
+ { code: "invalid_sync_config" }
1308
+ );
1309
+ }
1310
+ if (!loaded.adapter) return;
1311
+ let errors;
1312
+ try {
1313
+ errors = validateConfig(loaded.config, loaded.adapter.introspect());
1314
+ } catch (err) {
1315
+ throw new CliError(
1316
+ `Could not check ${file} against your schema: ${err instanceof Error ? err.message : String(err)}`
1317
+ );
1318
+ }
1319
+ if (errors.length === 0) return;
1320
+ const lines = errors.map((e) => ` ${e.index}${e.field ? `.${e.field}` : ""}: ${e.message}`);
1321
+ throw new CliError(
1322
+ `${file} does not match your database schema (${errors.length} problem(s)):
1323
+ ${lines.join("\n")}`,
1324
+ { code: "invalid_sync_config" }
1325
+ );
1326
+ }
669
1327
  async function loadHeadbandConfig(cwd) {
670
1328
  const path2 = findConfigFile(cwd);
671
1329
  if (!path2) {
672
1330
  throw new CliError(
673
- `No headband.config.ts found in ${cwd}. Run "headband init" or create one that exports a Headband sync config.`
1331
+ `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.)`,
1332
+ { code: "sync_config_missing" }
674
1333
  );
675
1334
  }
676
- const jiti = (0, import_jiti.createJiti)((0, import_url.pathToFileURL)((0, import_path4.join)(cwd, "headband-cli.js")).href, {
1335
+ const jiti = (0, import_jiti.createJiti)((0, import_url.pathToFileURL)((0, import_path6.join)(cwd, "headband-cli.js")).href, {
677
1336
  interopDefault: true,
678
1337
  moduleCache: false,
679
1338
  fsCache: false
@@ -688,19 +1347,36 @@ async function loadHeadbandConfig(cwd) {
688
1347
  }
689
1348
  const candidate = mod.default ?? mod;
690
1349
  if (!isSyncConfig(candidate)) {
691
- throw new CliError(`${path2} does not export a valid Headband sync config (missing "indexes").`);
1350
+ throw new CliError(
1351
+ `${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.`,
1352
+ { code: "invalid_sync_config" }
1353
+ );
1354
+ }
1355
+ const rawAdapter = mod.adapter;
1356
+ if (rawAdapter !== void 0 && !isAdapter(rawAdapter)) {
1357
+ throw new CliError(
1358
+ `${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 }).`,
1359
+ { code: "invalid_sync_config" }
1360
+ );
692
1361
  }
693
- const adapter = mod.adapter;
694
- return { config: candidate, adapter, path: path2 };
1362
+ const loaded = { config: candidate, adapter: rawAdapter, path: path2 };
1363
+ validateLoadedConfig(loaded);
1364
+ return loaded;
1365
+ }
1366
+ function missingAdapterError(command, why) {
1367
+ return new CliError(
1368
+ `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.`,
1369
+ { code: "sync_adapter_missing" }
1370
+ );
695
1371
  }
696
1372
 
697
1373
  // src/docs.ts
698
- var import_path5 = require("path");
699
- var import_fs4 = require("fs");
1374
+ var import_path7 = require("path");
1375
+ var import_fs5 = require("fs");
700
1376
  var DOCS_BATCH_SIZE = 1e3;
701
1377
  function parseDocumentsFile(path2) {
702
- const content = (0, import_fs4.readFileSync)(path2, "utf-8");
703
- const ext = (0, import_path5.extname)(path2).toLowerCase();
1378
+ const content = (0, import_fs5.readFileSync)(path2, "utf-8");
1379
+ const ext = (0, import_path7.extname)(path2).toLowerCase();
704
1380
  if (ext === ".jsonl") {
705
1381
  return content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line));
706
1382
  }
@@ -734,38 +1410,109 @@ async function defaultExportBatchSize(client, index) {
734
1410
  return EXPORT_BATCH_SIZE;
735
1411
  }
736
1412
  }
737
- async function exportDocuments(client, options) {
738
- const { index, format, batchSize, write } = options;
1413
+ async function* walkDocuments(client, options) {
1414
+ const { index, batchSize, fields } = options;
1415
+ let base = `/v1/documents?index=${encodeURIComponent(index)}&limit=${batchSize}`;
1416
+ if (fields?.length) base += `&fields=${encodeURIComponent(fields.join(","))}`;
1417
+ let cursor = "";
739
1418
  let offset = 0;
740
- let exported = 0;
741
- let pending;
742
- if (format === "json") await write("[");
743
1419
  for (; ; ) {
744
1420
  const page = await client.get(
745
- `/v1/documents?index=${encodeURIComponent(index)}&limit=${batchSize}&offset=${offset}`
1421
+ cursor === null ? `${base}&offset=${offset}` : `${base}&cursor=${encodeURIComponent(cursor)}`
746
1422
  );
747
1423
  const results = Array.isArray(page.results) ? page.results : [];
1424
+ const total = typeof page.total === "number" ? page.total : null;
1425
+ yield { results, total };
1426
+ offset += results.length;
1427
+ if (cursor !== null && page.nextCursor !== void 0) {
1428
+ if (!page.nextCursor) return;
1429
+ cursor = page.nextCursor;
1430
+ continue;
1431
+ }
1432
+ cursor = null;
1433
+ if (results.length < batchSize) return;
1434
+ if (total !== null && offset >= total) return;
1435
+ }
1436
+ }
1437
+ async function listRemoteIds(client, index, primaryKey) {
1438
+ const batchSize = await defaultExportBatchSize(client, index);
1439
+ const ids = [];
1440
+ for await (const { results } of walkDocuments(client, {
1441
+ index,
1442
+ batchSize,
1443
+ fields: [primaryKey]
1444
+ })) {
748
1445
  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
- }
1446
+ const id = doc?.[primaryKey];
1447
+ if (id !== void 0 && id !== null) ids.push(String(id));
756
1448
  }
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
1449
  }
763
- if (format === "json") {
1450
+ return ids;
1451
+ }
1452
+ async function exportDocuments(client, options) {
1453
+ const { index, format, batchSize, write } = options;
1454
+ let exported = 0;
1455
+ let pending;
1456
+ const finish = async () => {
1457
+ if (format !== "json") return;
764
1458
  if (pending !== void 0) await write(` ${pending}`);
765
1459
  await write("]");
1460
+ };
1461
+ if (format === "json") await write("[");
1462
+ try {
1463
+ for await (const { results, total } of walkDocuments(client, { index, batchSize })) {
1464
+ for (const doc of results) {
1465
+ const line = JSON.stringify(doc);
1466
+ if (format === "jsonl") {
1467
+ await write(line);
1468
+ } else {
1469
+ if (pending !== void 0) await write(` ${pending},`);
1470
+ pending = line;
1471
+ }
1472
+ }
1473
+ exported += results.length;
1474
+ options.onPage?.(exported, total);
1475
+ }
1476
+ } catch (error) {
1477
+ if (options.finishOnError) await finish();
1478
+ throw new ExportInterruptedError(error, exported);
766
1479
  }
1480
+ await finish();
767
1481
  return exported;
768
1482
  }
1483
+ var ExportInterruptedError = class extends Error {
1484
+ constructor(cause, exported) {
1485
+ super(cause instanceof Error ? cause.message : String(cause));
1486
+ this.cause = cause;
1487
+ this.exported = exported;
1488
+ this.name = "ExportInterruptedError";
1489
+ }
1490
+ };
1491
+ function exportRefusalAdvice(error, index) {
1492
+ if (!(error instanceof ApiError) || error.code !== "feature_not_supported") return null;
1493
+ if (!/primary key/i.test(error.message)) return null;
1494
+ const pk = /add `([^`]+)`/.exec(error.message)?.[1];
1495
+ const key = pk ? `\`${pk}\`` : "the primary key";
1496
+ const missing = ["filterableAttributes", "sortableAttributes", "displayedAttributes"].filter(
1497
+ (name) => error.message.includes(name)
1498
+ );
1499
+ const lists = missing.length ? missing.join(" and ") : "filterableAttributes and sortableAttributes";
1500
+ return [
1501
+ `To export the whole index, declare ${key} on it once:`,
1502
+ ` 1. headband indexes settings get ${index} > settings.json`,
1503
+ ` 2. Add ${key} to ${lists} in settings.json.`,
1504
+ ` 3. headband indexes settings set ${index} --file settings.json`,
1505
+ ` Visor rebuilds the index for this change.`,
1506
+ ` 4. Run headband docs export again.`
1507
+ ].join("\n");
1508
+ }
1509
+ function exportFailure(error, index) {
1510
+ const advice = exportRefusalAdvice(error, index);
1511
+ if (!advice || !(error instanceof ApiError)) return error;
1512
+ return restate(error, `${error.message}
1513
+
1514
+ ${advice}`);
1515
+ }
769
1516
 
770
1517
  // ../../src/lib/density/catalog-constants.ts
771
1518
  function formatMemory(mb) {
@@ -774,6 +1521,7 @@ function formatMemory(mb) {
774
1521
 
775
1522
  // ../../src/lib/density/snippets.ts
776
1523
  var ADMIN_NOTE = "Creating an index and adding a document need an admin key. A search key can only search.";
1524
+ var ENGINE_ONLY_NOTE = "This index lives only on this engine. Headband's dashboard, Search and Analytics do not list it.";
777
1525
  function visorSnippets(endpoint) {
778
1526
  return {
779
1527
  packageName: "built-in fetch",
@@ -786,6 +1534,7 @@ function visorSnippets(endpoint) {
786
1534
  "};",
787
1535
  "",
788
1536
  `// ${ADMIN_NOTE}`,
1537
+ `// ${ENGINE_ONLY_NOTE}`,
789
1538
  "// Creating the index is safe to repeat: an existing index is kept as it is.",
790
1539
  'await fetch(`${base}/v1/indexes/movies`, { method: "PUT", headers, body: JSON.stringify({ primaryKey: "id" }) });',
791
1540
  'await fetch(`${base}/v1/indexes/movies/documents`, { method: "POST", headers, body: JSON.stringify([{ id: 1, title: "The Matrix" }]) });',
@@ -795,6 +1544,7 @@ function visorSnippets(endpoint) {
795
1544
  ].join("\n"),
796
1545
  curl: [
797
1546
  `# ${ADMIN_NOTE}`,
1547
+ `# ${ENGINE_ONLY_NOTE}`,
798
1548
  "# On a second run the index already exists and PUT keeps it. That is fine.",
799
1549
  `curl -X PUT "${endpoint}/v1/indexes/movies" \\`,
800
1550
  ' -H "Authorization: Bearer $HEADBAND_API_KEY" \\',
@@ -830,6 +1580,7 @@ function connectSnippets(engine, endpoint) {
830
1580
  "});",
831
1581
  "",
832
1582
  `// ${ADMIN_NOTE}`,
1583
+ `// ${ENGINE_ONLY_NOTE}`,
833
1584
  'if (!(await client.indices.exists({ index: "movies" }))) {',
834
1585
  ' await client.indices.create({ index: "movies" });',
835
1586
  "}",
@@ -840,6 +1591,7 @@ function connectSnippets(engine, endpoint) {
840
1591
  ].join("\n"),
841
1592
  curl: [
842
1593
  `# ${ADMIN_NOTE}`,
1594
+ `# ${ENGINE_ONLY_NOTE}`,
843
1595
  "# On a second run this answers that the index already exists. That is fine.",
844
1596
  `curl -X PUT "${endpoint}/movies" \\`,
845
1597
  ' -H "Authorization: ApiKey $HEADBAND_API_KEY"',
@@ -877,6 +1629,7 @@ function connectSnippets(engine, endpoint) {
877
1629
  "});",
878
1630
  "",
879
1631
  `// ${ADMIN_NOTE}`,
1632
+ `// ${ENGINE_ONLY_NOTE}`,
880
1633
  "// A 409 means the collection exists from an earlier run.",
881
1634
  "await client.collections().create({",
882
1635
  ' name: "movies",',
@@ -891,6 +1644,7 @@ function connectSnippets(engine, endpoint) {
891
1644
  ].join("\n"),
892
1645
  curl: [
893
1646
  `# ${ADMIN_NOTE}`,
1647
+ `# ${ENGINE_ONLY_NOTE}`,
894
1648
  "# On a second run this answers that the collection already exists. That is fine.",
895
1649
  `curl "${endpoint}/collections" \\`,
896
1650
  ' -H "X-TYPESENSE-API-KEY: $HEADBAND_API_KEY" \\',
@@ -919,6 +1673,7 @@ function connectSnippets(engine, endpoint) {
919
1673
  "});",
920
1674
  "",
921
1675
  `// ${ADMIN_NOTE}`,
1676
+ `// ${ENGINE_ONLY_NOTE}`,
922
1677
  "// waitTask() waits until Meilisearch has applied the change. On a second run",
923
1678
  "// the create task fails because the index exists, which is fine.",
924
1679
  'await client.createIndex("movies", { primaryKey: "id" }).waitTask();',
@@ -929,6 +1684,7 @@ function connectSnippets(engine, endpoint) {
929
1684
  ].join("\n"),
930
1685
  curl: [
931
1686
  `# ${ADMIN_NOTE}`,
1687
+ `# ${ENGINE_ONLY_NOTE}`,
932
1688
  "# On a second run this task fails because the index already exists. That is fine.",
933
1689
  `curl "${endpoint}/indexes" \\`,
934
1690
  ' -H "Authorization: Bearer $HEADBAND_API_KEY" \\',
@@ -951,54 +1707,10 @@ function connectSnippets(engine, endpoint) {
951
1707
  };
952
1708
  }
953
1709
 
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
1710
  // src/instances.ts
999
1711
  var ENGINES = ["meilisearch", "elasticsearch", "typesense", "visor"];
1000
1712
  var CLIENTS = ["node", "curl"];
1001
- var COLUMNS = ["id", "name", "engine", "lane", "memory", "region", "state"];
1713
+ var COLUMNS2 = ["id", "name", "engine", "lane", "memory", "region", "state"];
1002
1714
  var INSTANCES_USAGE = [
1003
1715
  "Usage:",
1004
1716
  " headband instances list [--json]",
@@ -1093,7 +1805,7 @@ async function runInstances(args, flags, io, client, confirm) {
1093
1805
  ...summaryRow(i),
1094
1806
  "wake p95": i.wakeP95Ms === null || i.wakeP95Ms === void 0 ? "" : `${Math.round(i.wakeP95Ms)} ms`
1095
1807
  })),
1096
- [...COLUMNS, "wake p95"]
1808
+ [...COLUMNS2, "wake p95"]
1097
1809
  );
1098
1810
  return;
1099
1811
  }
@@ -1115,7 +1827,7 @@ async function runInstances(args, flags, io, client, confirm) {
1115
1827
  if (idle) body.idleTimeoutS = parsePositiveInt(idle, "--idle-timeout");
1116
1828
  const res = await client.post("/v1/instances", body);
1117
1829
  if (json) return printJson(io, res);
1118
- printTable(io, [summaryRow(res.data)], COLUMNS);
1830
+ printTable(io, [summaryRow(res.data)], COLUMNS2);
1119
1831
  io.stdout(`Endpoint: ${res.data.endpoint}`);
1120
1832
  io.stdout(`Connect a client: headband instances connect ${res.data.id}`);
1121
1833
  return;
@@ -1136,7 +1848,10 @@ async function runInstances(args, flags, io, client, confirm) {
1136
1848
  ["endpoint", i.endpoint],
1137
1849
  ["idle timeout", i.lane === "serverless" ? `${i.idleTimeoutS} s` : "n/a"],
1138
1850
  ["default", i.isDefault ? "yes" : "no"],
1139
- ["last backup", i.lastBackupAt ?? "never"],
1851
+ [
1852
+ "last backup",
1853
+ i.bucketHome ? "not needed (every write is saved to object storage)" : i.lastBackupAt ?? "never"
1854
+ ],
1140
1855
  ["last wake", i.lastWakeAt ?? "never"]
1141
1856
  ];
1142
1857
  const width = Math.max(...lines.map(([k]) => k.length));
@@ -1178,7 +1893,7 @@ async function runInstances(args, flags, io, client, confirm) {
1178
1893
  }
1179
1894
  const res = await client.patch(path2(id), body);
1180
1895
  if (json) return printJson(io, res);
1181
- printTable(io, [summaryRow(res.data)], COLUMNS);
1896
+ printTable(io, [summaryRow(res.data)], COLUMNS2);
1182
1897
  if (res.data.pendingChanges) {
1183
1898
  io.stdout(
1184
1899
  "The engine applies the change in the background. Check it with: headband instances show " + id
@@ -1261,113 +1976,186 @@ async function runInstances(args, flags, io, client, confirm) {
1261
1976
  }
1262
1977
  }
1263
1978
 
1264
- // src/schema.ts
1265
- var import_fs6 = require("fs");
1266
- var import_path7 = require("path");
1267
-
1268
- // ../sync/src/hash.ts
1269
- var import_node_crypto = require("crypto");
1270
- function stableStringify(value) {
1271
- return JSON.stringify(normalize(value));
1979
+ // src/warm.ts
1980
+ var LEVELS = ["auto", "stage", "start"];
1981
+ var COLUMNS3 = ["index", "engine", "state", "reason"];
1982
+ var WARM_USAGE = [
1983
+ "Usage:",
1984
+ " headband warm <index>... [--branch <name>] [--level <auto|stage|start>] [--json]",
1985
+ " headband warm --project <id> [--branch <name>] [--level <auto|stage|start>] [--json]",
1986
+ "",
1987
+ "Wakes sleeping engines ahead of traffic. stage loads an engine's data onto its server; start also starts it.",
1988
+ "auto, the default, starts an engine used in the last 24 hours and stages an older one. Warming is free."
1989
+ ].join("\n");
1990
+ async function runWarm(args, flags, io, client) {
1991
+ const str2 = (name) => typeof flags[name] === "string" ? flags[name] : void 0;
1992
+ const project = str2("--project");
1993
+ const level = str2("--level") ?? "auto";
1994
+ if (!LEVELS.includes(level)) throw new UsageError("--level must be auto, stage or start");
1995
+ const named = args.length > 0;
1996
+ if (named === (project !== void 0))
1997
+ throw new UsageError("Name the indexes to warm, or --project, but not both.\n\n" + WARM_USAGE);
1998
+ const branch = str2("--branch");
1999
+ const on = branch !== void 0 ? { branch } : {};
2000
+ const body = project !== void 0 ? { project, ...on, level } : { indexes: args, ...on, level };
2001
+ const res = await client.post("/v1/warm", body);
2002
+ if (flags["--json"] === true) return printJson(io, res);
2003
+ printTable(
2004
+ io,
2005
+ res.indexes.map((r) => ({
2006
+ index: r.index,
2007
+ engine: r.engine,
2008
+ state: r.state,
2009
+ reason: r.reason ?? ""
2010
+ })),
2011
+ COLUMNS3
2012
+ );
1272
2013
  }
1273
- function normalize(value) {
1274
- if (Array.isArray(value)) {
1275
- return value.map(normalize);
2014
+
2015
+ // src/switch-engine.ts
2016
+ var PHASES = {
2017
+ queued: "waiting to start",
2018
+ copying: "copying documents",
2019
+ draining: "holding writes while in-flight requests finish",
2020
+ catching_up: "copying the last changes and checking every document",
2021
+ cleaning: "switched; removing the old copy",
2022
+ succeeded: "done",
2023
+ failed: "stopped"
2024
+ };
2025
+ function printSettings(io, settings) {
2026
+ if (!settings) return;
2027
+ if (settings.carried.length > 0) io.stdout(`Carried over: ${settings.carried.join(", ")}`);
2028
+ for (const d of settings.drops) {
2029
+ io.stdout(`Not carried: ${d.setting}${d.kept ? " (kept, not applied)" : ""}. ${d.reason}`);
2030
+ }
2031
+ for (const a of settings.added) {
2032
+ const value = Array.isArray(a.value) ? a.value.join(", ") : String(a.value);
2033
+ io.stdout(`Added: ${a.setting} ${value}. ${a.reason}`);
2034
+ }
2035
+ }
2036
+ async function runSwitchEngine(client, opts, io, chatter) {
2037
+ const path2 = `/v1/indexes/${encodeURIComponent(opts.uid)}/engine`;
2038
+ const body = { engine: opts.engine };
2039
+ if (opts.instanceId) body.instanceId = opts.instanceId;
2040
+ if (opts.acceptDrops) body.acceptDrops = true;
2041
+ if (opts.declareKey) body.declareKey = true;
2042
+ if (opts.dryRun) body.dryRun = true;
2043
+ let started;
2044
+ try {
2045
+ started = await client.post(path2, body);
2046
+ } catch (error) {
2047
+ if (error instanceof ApiError && error.code === "settings_not_portable" && !opts.json && error.details && typeof error.details === "object") {
2048
+ printSettings(chatter, error.details.settings);
2049
+ chatter.stdout("Run again with --accept-drops to switch anyway.");
2050
+ }
2051
+ throw error;
1276
2052
  }
1277
- if (value !== null && typeof value === "object") {
1278
- const sortedKeys = Object.keys(value).sort();
1279
- const result = {};
1280
- for (const key of sortedKeys) {
1281
- result[key] = normalize(value[key]);
2053
+ if (opts.dryRun) {
2054
+ if (opts.json) return printJson(io, started);
2055
+ const target = started.target;
2056
+ io.stdout(
2057
+ `${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"}.`
2058
+ );
2059
+ printSettings(io, started.settings);
2060
+ for (const n of started.notes ?? []) io.stdout(n);
2061
+ io.stdout("Dry run: nothing was changed.");
2062
+ return;
2063
+ }
2064
+ let view = started;
2065
+ if (!opts.wait) {
2066
+ if (opts.json) return printJson(io, view);
2067
+ io.stdout(
2068
+ `Switching ${opts.uid} from ${view.fromEngine} to ${view.toEngine} (${view.switchId}). Follow it with: headband indexes switch-engine ${opts.uid} --status`
2069
+ );
2070
+ return;
2071
+ }
2072
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
2073
+ let lastPhase = "";
2074
+ for (; ; ) {
2075
+ if (view.phase !== lastPhase) {
2076
+ lastPhase = view.phase;
2077
+ chatter.stdout(`${opts.uid}: ${PHASES[view.phase] ?? view.phase}`);
1282
2078
  }
1283
- return result;
2079
+ if (view.status === "succeeded" || view.status === "failed") break;
2080
+ await sleep(opts.pollMs ?? 3e3);
2081
+ const status = await client.get(path2);
2082
+ if (!status.switch || status.switch.switchId !== view.switchId) {
2083
+ throw new CliError("The switch is no longer listed for this index.", {
2084
+ exitCode: EXIT_CODES.generic,
2085
+ code: "switch_not_found"
2086
+ });
2087
+ }
2088
+ view = status.switch;
1284
2089
  }
1285
- return value;
1286
- }
1287
- function normalizeFieldSpec(spec) {
1288
- if (typeof spec === "function") {
1289
- return spec.toString();
2090
+ if (opts.json) printJson(io, view);
2091
+ else if (view.status === "succeeded") {
2092
+ io.stdout(
2093
+ `${opts.uid} now runs on ${view.toEngine}${view.documents.verified !== null ? `; ${view.documents.verified} documents verified` : ""}.`
2094
+ );
2095
+ for (const n of view.notes ?? []) io.stdout(n);
1290
2096
  }
1291
- return spec;
1292
- }
1293
- function docShapeHash(def) {
1294
- const normalizedFields = {};
1295
- for (const [name, spec] of Object.entries(def.fields)) {
1296
- normalizedFields[name] = normalizeFieldSpec(spec);
2097
+ if (view.status === "failed") {
2098
+ throw new CliError(view.error?.message ?? "The switch failed.", {
2099
+ exitCode: EXIT_CODES.conflict,
2100
+ code: view.error?.code ?? "switch_failed"
2101
+ });
1297
2102
  }
1298
- const shape = {
1299
- source: def.source,
1300
- primaryKey: def.primaryKey,
1301
- shouldIndex: def.shouldIndex?.toString() ?? null,
1302
- fields: normalizedFields
1303
- };
1304
- return (0, import_node_crypto.createHash)("sha256").update(stableStringify(shape)).digest("hex");
1305
2103
  }
1306
- function settingsHash(def) {
1307
- const settings = def.settings ?? {};
1308
- return (0, import_node_crypto.createHash)("sha256").update(stableStringify(settings)).digest("hex");
2104
+ async function printSwitchStatus(client, uid, io, json) {
2105
+ const status = await client.get(
2106
+ `/v1/indexes/${encodeURIComponent(uid)}/engine`
2107
+ );
2108
+ if (json) return printJson(io, status);
2109
+ io.stdout(`${uid} runs on ${status.engine}.`);
2110
+ const s = status.switch;
2111
+ if (!s) return;
2112
+ io.stdout(
2113
+ `Latest switch ${s.switchId}: ${s.fromEngine} to ${s.toEngine}, ${s.status} (${PHASES[s.phase] ?? s.phase}).`
2114
+ );
2115
+ if (s.error) io.stdout(s.error.message);
1309
2116
  }
1310
2117
 
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 });
2118
+ // ../sync/src/hash.ts
2119
+ var import_node_crypto = require("crypto");
2120
+ function stableStringify(value) {
2121
+ return JSON.stringify(normalize(value));
2122
+ }
2123
+ function normalize(value) {
2124
+ if (Array.isArray(value)) {
2125
+ return value.map(normalize);
1355
2126
  }
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}`);
2127
+ if (value !== null && typeof value === "object") {
2128
+ const sortedKeys = Object.keys(value).sort();
2129
+ const result = {};
2130
+ for (const key of sortedKeys) {
2131
+ result[key] = normalize(value[key]);
1362
2132
  }
1363
- throw new Error(
1364
- `Migration version gap: expected version ${expected}, found ${files[i].version}`
1365
- );
2133
+ return result;
1366
2134
  }
1367
- return files;
2135
+ return value;
1368
2136
  }
1369
- function pendingMigrations(files, appliedVersion) {
1370
- return files.filter((f) => f.version > appliedVersion);
2137
+ function normalizeFieldSpec(spec) {
2138
+ if (typeof spec === "function") {
2139
+ return spec.toString();
2140
+ }
2141
+ return spec;
2142
+ }
2143
+ function docShapeHash(def) {
2144
+ const normalizedFields = {};
2145
+ for (const [name, spec] of Object.entries(def.fields)) {
2146
+ normalizedFields[name] = normalizeFieldSpec(spec);
2147
+ }
2148
+ const shape = {
2149
+ source: def.source,
2150
+ primaryKey: def.primaryKey,
2151
+ shouldIndex: def.shouldIndex?.toString() ?? null,
2152
+ fields: normalizedFields
2153
+ };
2154
+ return (0, import_node_crypto.createHash)("sha256").update(stableStringify(shape)).digest("hex");
2155
+ }
2156
+ function settingsHash(def) {
2157
+ const settings = def.settings ?? {};
2158
+ return (0, import_node_crypto.createHash)("sha256").update(stableStringify(settings)).digest("hex");
1371
2159
  }
1372
2160
 
1373
2161
  // ../sync/src/planner.ts
@@ -1425,11 +2213,78 @@ function planMigration(config, applied, opts) {
1425
2213
  return ops;
1426
2214
  }
1427
2215
 
2216
+ // src/schema.ts
2217
+ var import_fs7 = require("fs");
2218
+ var import_path9 = require("path");
2219
+
2220
+ // ../sync/src/migrations.ts
2221
+ var import_fs6 = require("fs");
2222
+ var import_path8 = require("path");
2223
+ var FILE_NAME_RE = /^(\d{4})_(.+)\.json$/;
2224
+ function sanitizeSlug(slug) {
2225
+ const sanitized = slug.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
2226
+ return sanitized || "migration";
2227
+ }
2228
+ function generateMigrationFile(dir, ops, meta, now = () => /* @__PURE__ */ new Date()) {
2229
+ (0, import_fs6.mkdirSync)(dir, { recursive: true });
2230
+ const existing = readMigrations(dir);
2231
+ const nextVersion = existing.length === 0 ? 1 : existing[existing.length - 1].version + 1;
2232
+ const safeSlug = sanitizeSlug(meta.slug);
2233
+ const fileName = `${String(nextVersion).padStart(4, "0")}_${safeSlug}.json`;
2234
+ const path2 = (0, import_path8.join)(dir, fileName);
2235
+ const file = {
2236
+ version: nextVersion,
2237
+ createdAt: now().toISOString(),
2238
+ parentHashes: meta.parentHashes,
2239
+ ...meta.targetHashes ? { targetHashes: meta.targetHashes } : {},
2240
+ ops
2241
+ };
2242
+ (0, import_fs6.writeFileSync)(path2, JSON.stringify(file, null, 2));
2243
+ return path2;
2244
+ }
2245
+ function readMigrations(dir) {
2246
+ let entries;
2247
+ try {
2248
+ entries = (0, import_fs6.readdirSync)(dir);
2249
+ } catch {
2250
+ return [];
2251
+ }
2252
+ const files = [];
2253
+ for (const entry of entries) {
2254
+ const match = FILE_NAME_RE.exec(entry);
2255
+ if (!match) continue;
2256
+ const path2 = (0, import_path8.join)(dir, entry);
2257
+ const raw = (0, import_fs6.readFileSync)(path2, "utf-8");
2258
+ let parsed;
2259
+ try {
2260
+ parsed = JSON.parse(raw);
2261
+ } catch {
2262
+ throw new Error(`Malformed migration file: ${path2}`);
2263
+ }
2264
+ files.push({ ...parsed, path: path2 });
2265
+ }
2266
+ files.sort((a, b) => a.version - b.version);
2267
+ for (let i = 0; i < files.length; i++) {
2268
+ const expected = i + 1;
2269
+ if (files[i].version === expected) continue;
2270
+ if (files.some((f, idx) => idx !== i && f.version === files[i].version)) {
2271
+ throw new Error(`Duplicate migration version: ${files[i].version}`);
2272
+ }
2273
+ throw new Error(
2274
+ `Migration version gap: expected version ${expected}, found ${files[i].version}`
2275
+ );
2276
+ }
2277
+ return files;
2278
+ }
2279
+ function pendingMigrations(files, appliedVersion) {
2280
+ return files.filter((f) => f.version > appliedVersion);
2281
+ }
2282
+
1428
2283
  // ../sync/src/client.ts
1429
2284
  var BASE_BACKOFF_MS = 500;
1430
2285
  var DEFAULT_MAX_RETRIES = 5;
1431
- function defaultSleep2(ms) {
1432
- return new Promise((resolve) => setTimeout(resolve, ms));
2286
+ function defaultSleep3(ms) {
2287
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1433
2288
  }
1434
2289
  var SchemaCasError = class extends Error {
1435
2290
  constructor(message) {
@@ -1472,7 +2327,7 @@ var HeadbandClient = class {
1472
2327
  this.adminKey = opts.adminKey;
1473
2328
  this.fetchImpl = opts.fetchImpl ?? fetch;
1474
2329
  this.maxRetries = opts.maxRetries ?? DEFAULT_MAX_RETRIES;
1475
- this.sleep = opts.sleep ?? defaultSleep2;
2330
+ this.sleep = opts.sleep ?? defaultSleep3;
1476
2331
  }
1477
2332
  async bulkPush(index, documents, primaryKey) {
1478
2333
  const body = { index, documents };
@@ -1492,9 +2347,14 @@ var HeadbandClient = class {
1492
2347
  );
1493
2348
  return isRecord2(result) && "partialFailure" in result ? { partialFailure: result.partialFailure } : {};
1494
2349
  }
2350
+ /**
2351
+ * `projectId` places the index in that project (its production branch) when the key is
2352
+ * org-wide, such as the CLI's login key. A project key's indexes always land in its project.
2353
+ */
1495
2354
  async createIndex(uid, opts) {
1496
2355
  const body = { uid };
1497
2356
  if (opts?.primaryKey !== void 0) body.primaryKey = opts.primaryKey;
2357
+ if (opts?.projectId !== void 0) body.projectId = opts.projectId;
1498
2358
  await this.request("POST", "/v1/indexes", body);
1499
2359
  }
1500
2360
  async patchSettings(uid, settings) {
@@ -1506,9 +2366,18 @@ var HeadbandClient = class {
1506
2366
  async postSchemaState(state) {
1507
2367
  await this.request("POST", "/v1/schema/state", state, { mapConflictToCasError: true });
1508
2368
  }
2369
+ /** Removes an index's applied schema state (DELETE /v1/schema/state?indexUid=). */
2370
+ async deleteSchemaState(indexUid) {
2371
+ await this.request("DELETE", `/v1/schema/state?indexUid=${encodeURIComponent(indexUid)}`);
2372
+ }
1509
2373
  async patchAlias(index, physicalUid) {
1510
2374
  await this.request("PATCH", "/v1/schema/alias", { indexUid: index, physicalUid });
1511
2375
  }
2376
+ /**
2377
+ * Records a connector run (POST /v1/connectors/runs). The same `externalRunId` updates the
2378
+ * run it created. `status` is one of started, succeeded, failed, skipped, dead_letter. An
2379
+ * org-wide key must name its `projectId`.
2380
+ */
1512
2381
  async reportRun(run2) {
1513
2382
  await this.request("POST", "/v1/connectors/runs", run2);
1514
2383
  }
@@ -1573,22 +2442,35 @@ function printOpsTable(io, ops) {
1573
2442
  printTable(io, opRows(ops), ["op", "index", "detail"]);
1574
2443
  }
1575
2444
  function readAppliedMarker(dir) {
1576
- const path2 = (0, import_path7.join)(dir, APPLIED_MARKER_FILE);
1577
- if (!(0, import_fs6.existsSync)(path2)) return 0;
2445
+ const path2 = (0, import_path9.join)(dir, APPLIED_MARKER_FILE);
2446
+ if (!(0, import_fs7.existsSync)(path2)) return 0;
1578
2447
  try {
1579
- const parsed = JSON.parse((0, import_fs6.readFileSync)(path2, "utf-8"));
2448
+ const parsed = JSON.parse((0, import_fs7.readFileSync)(path2, "utf-8"));
1580
2449
  return typeof parsed.version === "number" ? parsed.version : 0;
1581
2450
  } catch {
1582
2451
  return 0;
1583
2452
  }
1584
2453
  }
1585
2454
  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));
2455
+ (0, import_fs7.mkdirSync)(dir, { recursive: true });
2456
+ (0, import_fs7.writeFileSync)((0, import_path9.join)(dir, APPLIED_MARKER_FILE), JSON.stringify({ version }, null, 2));
1588
2457
  }
1589
- async function planSchemaDiff(deps) {
1590
- const applied = await deps.client.getSchemaState();
1591
- return planMigration(deps.config, applied, { allowDestructive: deps.allowDestructive });
2458
+ function configHashes(config, index) {
2459
+ const def = config.indexes.find((d) => d.name === index);
2460
+ return def ? { schemaHash: docShapeHash(def), settingsHash: settingsHash(def) } : void 0;
2461
+ }
2462
+ function orphanedStates(applied, indexUids) {
2463
+ return applied.filter((a) => !indexUids.has(a.indexUid) && !indexUids.has(a.physicalUid));
2464
+ }
2465
+ function orphanHint(orphans) {
2466
+ const names = orphans.map((o) => o.indexUid).join(", ");
2467
+ 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.`;
2468
+ }
2469
+ function warnOrphans(io, applied, indexUids) {
2470
+ if (!indexUids) return [];
2471
+ const orphans = orphanedStates(applied, indexUids);
2472
+ if (orphans.length > 0) io.stderr(orphanHint(orphans));
2473
+ return orphans;
1592
2474
  }
1593
2475
  async function runSchemaGenerate(deps) {
1594
2476
  const applied = await deps.client.getSchemaState();
@@ -1599,13 +2481,16 @@ async function runSchemaGenerate(deps) {
1599
2481
  }
1600
2482
  const appliedByUid = new Map(applied.map((a) => [a.indexUid, a]));
1601
2483
  const parentHashes = {};
2484
+ const targetHashes = {};
1602
2485
  for (const op of ops) {
1603
2486
  parentHashes[op.index] = appliedByUid.get(op.index)?.schemaHash ?? "";
2487
+ const target = configHashes(deps.config, op.index);
2488
+ if (target) targetHashes[op.index] = target;
1604
2489
  }
1605
2490
  const path2 = generateMigrationFile(
1606
2491
  deps.dir,
1607
2492
  ops,
1608
- { parentHashes, slug: deps.slug ?? "migration" },
2493
+ { parentHashes, slug: deps.slug ?? "migration", targetHashes },
1609
2494
  deps.now
1610
2495
  );
1611
2496
  deps.io.stdout(`Wrote ${path2}`);
@@ -1618,43 +2503,51 @@ function isConflictError(err) {
1618
2503
  }
1619
2504
  async function createIndexIdempotent(ctx, uid, opts) {
1620
2505
  try {
1621
- await ctx.client.createIndex(uid, opts);
2506
+ await ctx.client.createIndex(uid, {
2507
+ ...opts,
2508
+ ...ctx.projectId ? { projectId: ctx.projectId } : {}
2509
+ });
1622
2510
  } catch (err) {
1623
2511
  if (!isConflictError(err)) throw err;
1624
- ctx.io.stdout(`Index "${uid}" already exists \u2014 continuing.`);
2512
+ ctx.io.stdout(`Index "${uid}" already exists. Continuing.`);
2513
+ }
2514
+ }
2515
+ async function patchSettingsBeforeBackfill(ctx, uid, settings) {
2516
+ try {
2517
+ await ctx.client.patchSettings(uid, settings);
2518
+ return false;
2519
+ } catch (err) {
2520
+ if (err?.code === "settings_need_documents") {
2521
+ ctx.io.stdout(
2522
+ `Settings for "${uid}" need its documents first: applying them after the backfill.`
2523
+ );
2524
+ return true;
2525
+ }
2526
+ throw settingsRefused(uid, err);
2527
+ }
2528
+ }
2529
+ async function patchSettingsAfterBackfill(ctx, uid, settings) {
2530
+ try {
2531
+ await ctx.client.patchSettings(uid, settings);
2532
+ } catch (err) {
2533
+ throw settingsRefused(uid, err);
1625
2534
  }
1626
2535
  }
2536
+ function settingsRefused(uid, err) {
2537
+ const message = err instanceof Error ? err.message : String(err);
2538
+ const { status, code } = err ?? {};
2539
+ return new CliError(
2540
+ `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.`,
2541
+ {
2542
+ ...typeof status === "number" ? { exitCode: exitCodeForStatus(status) } : {},
2543
+ ...typeof code === "string" ? { code } : {}
2544
+ }
2545
+ );
2546
+ }
1627
2547
  function postState(client, state) {
1628
2548
  return client.postSchemaState(state);
1629
2549
  }
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);
2550
+ async function backfill(ctx, def, targetUid, abortPoint, onPage) {
1658
2551
  let cursor = null;
1659
2552
  let pushed = 0;
1660
2553
  let excluded = 0;
@@ -1675,23 +2568,64 @@ async function applyReindex(op, ctx, existing, expectedParentHash) {
1675
2568
  toPush.push(doc);
1676
2569
  }
1677
2570
  if (toPush.length > 0) {
1678
- const result = await ctx.client.bulkPush(op.toPhysicalUid, toPush, def.primaryKey);
2571
+ const result = await ctx.client.bulkPush(targetUid, toPush, def.primaryKey);
1679
2572
  if (result?.partialFailure) {
1680
2573
  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".`
2574
+ `Backfill of "${def.name}" stopped: ${partialFailureSummary(result.partialFailure)}. Aborting ${abortPoint}. Fix the rejected documents, then re-run "headband schema apply".`
1682
2575
  );
1683
2576
  }
1684
2577
  pushed += toPush.length;
1685
2578
  }
1686
2579
  cursor = page.nextCursor;
1687
- await writeState("migrating", cursor);
2580
+ if (onPage) await onPage(cursor);
1688
2581
  if (cursor === null || page.ids.length < ctx.backfillBatchSize) break;
1689
2582
  }
1690
2583
  if (pushed + excluded !== total) {
1691
2584
  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.`
2585
+ `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.`
1693
2586
  );
1694
2587
  }
2588
+ ctx.io.stdout(
2589
+ `Backfilled "${def.name}": ${pushed} document(s) pushed${excluded > 0 ? `, ${excluded} row(s) left out by shouldIndex` : ""}.`
2590
+ );
2591
+ return { pushed, excluded };
2592
+ }
2593
+ async function applyReindex(op, ctx, existing, expectedParentHash) {
2594
+ const def = ctx.config.indexes.find((d) => d.name === op.index);
2595
+ if (!def) {
2596
+ throw new CliError(`Cannot reindex "${op.index}": no matching index in the loaded config.`);
2597
+ }
2598
+ await createIndexIdempotent(ctx, op.toPhysicalUid, { primaryKey: def.primaryKey });
2599
+ const settingsAfter = await patchSettingsBeforeBackfill(ctx, op.toPhysicalUid, op.settings);
2600
+ const version = (existing?.version ?? 0) + 1;
2601
+ const schemaHashValue = docShapeHash(def);
2602
+ const settingsHashValue = settingsHash(def);
2603
+ let parentHash = expectedParentHash;
2604
+ const writeState = async (status, cursor) => {
2605
+ await postState(ctx.client, {
2606
+ indexUid: op.index,
2607
+ version,
2608
+ schemaHash: schemaHashValue,
2609
+ settingsHash: settingsHashValue,
2610
+ physicalUid: op.toPhysicalUid,
2611
+ status,
2612
+ // The `/v1/schema/state` route only accepts a string|null
2613
+ // backfillCursor (see route.ts). `Id` can be numeric, so it must be
2614
+ // stringified before crossing the wire.
2615
+ backfillCursor: cursor === null ? null : String(cursor),
2616
+ expectedParentHash: parentHash
2617
+ });
2618
+ parentHash = schemaHashValue;
2619
+ };
2620
+ await writeState("migrating", null);
2621
+ await backfill(
2622
+ ctx,
2623
+ def,
2624
+ op.toPhysicalUid,
2625
+ "before the alias flip",
2626
+ (cursor) => writeState("migrating", cursor)
2627
+ );
2628
+ if (settingsAfter) await patchSettingsAfterBackfill(ctx, op.toPhysicalUid, op.settings);
1695
2629
  await ctx.client.patchAlias(op.index, op.toPhysicalUid);
1696
2630
  await writeState("applied", null);
1697
2631
  return {
@@ -1706,17 +2640,20 @@ async function applyReindex(op, ctx, existing, expectedParentHash) {
1706
2640
  async function applyOp(op, ctx, existing, expectedParentHash) {
1707
2641
  if (op.type === "create-index") {
1708
2642
  const def = ctx.config.indexes.find((d) => d.name === op.index);
1709
- await createIndexIdempotent(
1710
- ctx,
1711
- op.index,
1712
- def?.primaryKey ? { primaryKey: def.primaryKey } : void 0
1713
- );
1714
- await ctx.client.patchSettings(op.index, op.settings);
2643
+ if (!def) {
2644
+ throw new CliError(
2645
+ `Cannot create "${op.index}": no matching index in the loaded config. Regenerate with "headband schema generate".`
2646
+ );
2647
+ }
2648
+ await createIndexIdempotent(ctx, op.index, { primaryKey: def.primaryKey });
2649
+ const settingsAfter = await patchSettingsBeforeBackfill(ctx, op.index, op.settings);
2650
+ await backfill(ctx, def, op.index, "before recording the index as applied");
2651
+ if (settingsAfter) await patchSettingsAfterBackfill(ctx, op.index, op.settings);
1715
2652
  const next = {
1716
2653
  indexUid: op.index,
1717
2654
  version: 1,
1718
- schemaHash: def ? docShapeHash(def) : "",
1719
- settingsHash: def ? settingsHash(def) : "",
2655
+ schemaHash: docShapeHash(def),
2656
+ settingsHash: settingsHash(def),
1720
2657
  physicalUid: op.physicalUid,
1721
2658
  status: "applied"
1722
2659
  };
@@ -1741,31 +2678,59 @@ async function applyOp(op, ctx, existing, expectedParentHash) {
1741
2678
  return applyReindex(op, ctx, existing, expectedParentHash);
1742
2679
  }
1743
2680
  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.`
2681
+ `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
2682
  );
1746
2683
  return void 0;
1747
2684
  }
2685
+ function serverAppliedThrough(files, applied, config) {
2686
+ const byUid = new Map(applied.map((a) => [a.indexUid, a]));
2687
+ const lastFileFor = /* @__PURE__ */ new Map();
2688
+ for (const file of files) {
2689
+ for (const op of file.ops) lastFileFor.set(op.index, file.version);
2690
+ }
2691
+ const through = /* @__PURE__ */ new Map();
2692
+ for (const file of files) {
2693
+ for (const index of new Set(file.ops.map((op) => op.index))) {
2694
+ const state = byUid.get(index);
2695
+ if (!state || state.status !== "applied") continue;
2696
+ const target = file.targetHashes?.[index] ?? (lastFileFor.get(index) === file.version ? configHashes(config, index) : void 0);
2697
+ if (target && target.schemaHash === state.schemaHash && target.settingsHash === state.settingsHash) {
2698
+ through.set(index, file.version);
2699
+ }
2700
+ }
2701
+ }
2702
+ return through;
2703
+ }
1748
2704
  async function runSchemaApply(deps) {
1749
2705
  const files = readMigrations(deps.dir);
1750
2706
  const appliedVersion = readAppliedMarker(deps.dir);
1751
2707
  const pending = pendingMigrations(files, appliedVersion);
1752
2708
  if (pending.length === 0) {
1753
- deps.io.stdout("No pending migrations to apply.");
2709
+ deps.io.stdout(
2710
+ files.length === 0 ? `No migration files in ${deps.dir}. Run "headband schema generate" first.` : "No pending migrations to apply."
2711
+ );
1754
2712
  return { appliedFiles: 0, appliedOps: 0 };
1755
2713
  }
1756
2714
  const applied = await deps.client.getSchemaState();
1757
2715
  const existingByIndex = new Map(applied.map((a) => [a.indexUid, a]));
2716
+ const appliedThrough = serverAppliedThrough(files, applied, deps.config);
1758
2717
  const ctx = {
1759
2718
  config: deps.config,
1760
2719
  adapter: deps.adapter,
1761
2720
  client: deps.client,
1762
2721
  backfillBatchSize: deps.backfillBatchSize ?? DEFAULT_BACKFILL_BATCH_SIZE,
1763
- io: deps.io
2722
+ io: deps.io,
2723
+ projectId: deps.projectId
1764
2724
  };
1765
2725
  let appliedOps = 0;
1766
2726
  let appliedFiles = 0;
1767
2727
  for (const file of pending) {
2728
+ let skipped = 0;
1768
2729
  for (const op of file.ops) {
2730
+ if ((appliedThrough.get(op.index) ?? 0) >= file.version) {
2731
+ skipped += 1;
2732
+ continue;
2733
+ }
1769
2734
  if (op.type === "remove-index" && !deps.allowDestructive) {
1770
2735
  throw new CliError(
1771
2736
  `Migration ${file.version} contains a destructive "remove-index" op for "${op.index}". Re-run with --allow-destructive to proceed.`
@@ -1788,26 +2753,56 @@ async function runSchemaApply(deps) {
1788
2753
  } catch (err) {
1789
2754
  if (err instanceof SchemaCasError) {
1790
2755
  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})`
2756
+ `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
2757
  );
1793
2758
  }
1794
2759
  throw err;
1795
2760
  }
1796
2761
  }
1797
2762
  writeAppliedMarker(deps.dir, file.version);
2763
+ if (skipped === file.ops.length) {
2764
+ deps.io.stdout(
2765
+ `Migration ${file.version}${file.path ? ` (${file.path})` : ""} is already applied on the server. Skipped.`
2766
+ );
2767
+ continue;
2768
+ }
1798
2769
  appliedFiles += 1;
1799
2770
  deps.io.stdout(`Applied migration ${file.version}${file.path ? ` (${file.path})` : ""}.`);
1800
2771
  }
1801
2772
  return { appliedFiles, appliedOps };
1802
2773
  }
2774
+ function serverAppliedVersion(files, applied, config) {
2775
+ const through = serverAppliedThrough(files, applied, config);
2776
+ const known = new Set(applied.map((a) => a.indexUid));
2777
+ let version = 0;
2778
+ for (const file of files) {
2779
+ const done = file.ops.every(
2780
+ (op) => op.type === "remove-index" ? !known.has(op.index) : (through.get(op.index) ?? 0) >= file.version
2781
+ );
2782
+ if (!done) break;
2783
+ version = file.version;
2784
+ }
2785
+ return version;
2786
+ }
1803
2787
  async function runSchemaStatus(deps) {
1804
2788
  const applied = await deps.client.getSchemaState();
1805
2789
  const appliedByUid = new Map(applied.map((a) => [a.indexUid, a]));
2790
+ const orphans = new Set(
2791
+ deps.indexUids ? orphanedStates(applied, deps.indexUids).map((o) => o.indexUid) : []
2792
+ );
1806
2793
  const rows = deps.config.indexes.map((def) => {
1807
2794
  const existing = appliedByUid.get(def.name);
1808
2795
  if (!existing) {
1809
2796
  return { index: def.name, status: "missing", version: 0, drift: "missing" };
1810
2797
  }
2798
+ if (orphans.has(def.name)) {
2799
+ return {
2800
+ index: def.name,
2801
+ status: existing.status,
2802
+ version: existing.version,
2803
+ drift: "index missing"
2804
+ };
2805
+ }
1811
2806
  const schemaDrifted = docShapeHash(def) !== existing.schemaHash;
1812
2807
  const settingsDrifted = settingsHash(def) !== existing.settingsHash;
1813
2808
  return {
@@ -1818,16 +2813,42 @@ async function runSchemaStatus(deps) {
1818
2813
  };
1819
2814
  });
1820
2815
  printTable(deps.io, rows, ["index", "status", "version", "drift"]);
2816
+ if (orphans.size > 0) {
2817
+ deps.io.stdout(orphanHint(applied.filter((a) => orphans.has(a.indexUid))));
2818
+ }
1821
2819
  const files = readMigrations(deps.dir);
1822
- const localVersion = readAppliedMarker(deps.dir);
2820
+ const markerVersion = readAppliedMarker(deps.dir);
1823
2821
  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 };
2822
+ const present = applied.filter((a) => !orphans.has(a.indexUid));
2823
+ const localVersion = serverAppliedVersion(files, present, deps.config);
2824
+ deps.io.stdout(`Local migrations applied on the server: ${localVersion}/${localHead}`);
2825
+ if (markerVersion !== localVersion) {
2826
+ deps.io.stdout(
2827
+ `(${APPLIED_MARKER_FILE} says ${markerVersion}. That file is only a local cache; schema apply reads the server's state.)`
2828
+ );
2829
+ }
2830
+ return { indexes: rows, localApplied: localVersion, localHead, markerApplied: markerVersion };
2831
+ }
2832
+ async function runSchemaForget(deps) {
2833
+ try {
2834
+ await deps.client.deleteSchemaState(deps.index);
2835
+ } catch (err) {
2836
+ const status = err.status;
2837
+ if (status === 404) {
2838
+ throw new CliError(`No schema state is recorded for "${deps.index}".`, {
2839
+ code: "schema_state_not_found"
2840
+ });
2841
+ }
2842
+ throw err;
2843
+ }
2844
+ deps.io.stdout(
2845
+ `Forgot the schema state of "${deps.index}". Run "headband schema generate" and "headband schema apply" to create it again.`
2846
+ );
1826
2847
  }
1827
2848
 
1828
2849
  // src/sync.ts
1829
- var import_fs7 = require("fs");
1830
- var import_path8 = require("path");
2850
+ var import_fs8 = require("fs");
2851
+ var import_path10 = require("path");
1831
2852
  var import_crypto = require("crypto");
1832
2853
  var import_pg = require("pg");
1833
2854
 
@@ -1881,7 +2902,7 @@ function pgOutboxDriver(exec, opts) {
1881
2902
  return rows.map(mapRow);
1882
2903
  },
1883
2904
  async markDone(ids) {
1884
- await exec(`UPDATE ${table} SET status = 'done' WHERE id = ANY($1)`, [ids]);
2905
+ await exec(`DELETE FROM ${table} WHERE id = ANY($1)`, [ids]);
1885
2906
  },
1886
2907
  async deadLetter(id, error) {
1887
2908
  await exec(
@@ -1890,10 +2911,11 @@ function pgOutboxDriver(exec, opts) {
1890
2911
  );
1891
2912
  },
1892
2913
  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`,
2914
+ const { rows } = await exec(
2915
+ `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
2916
  [id, error, maxAttempts]
1896
2917
  );
2918
+ return rows?.[0]?.status === "dead_letter" ? "dead_letter" : "pending";
1897
2919
  },
1898
2920
  async deadLetters(limit) {
1899
2921
  const { rows } = await exec(
@@ -1912,6 +2934,18 @@ function pgOutboxDriver(exec, opts) {
1912
2934
  `SELECT count(*) AS count FROM ${table} WHERE status = 'pending'`
1913
2935
  );
1914
2936
  return Number(rows[0]?.count ?? 0);
2937
+ },
2938
+ async stats() {
2939
+ const { rows } = await exec(
2940
+ `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}`
2941
+ );
2942
+ const row = rows[0] ?? {};
2943
+ const oldest = row.oldest_pending_at;
2944
+ return {
2945
+ pending: Number(row.pending ?? 0),
2946
+ deadLetter: Number(row.dead_letter ?? 0),
2947
+ oldestPendingAt: oldest == null ? null : new Date(oldest)
2948
+ };
1915
2949
  }
1916
2950
  };
1917
2951
  }
@@ -2088,18 +3122,42 @@ function rowOutcomes(rows, resolved, failures) {
2088
3122
  rows.forEach((row, changeIndex) => {
2089
3123
  const rowFailures = failuresByRow.get(changeIndex);
2090
3124
  if (!rowFailures) {
2091
- done.push(row.id);
3125
+ done.push(row);
2092
3126
  return;
2093
3127
  }
2094
3128
  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]);
3129
+ if (permanent) deadLetter.push([row, permanent.message]);
3130
+ else retry.push([row, rowFailures[0].message]);
2097
3131
  });
2098
3132
  return { done, retry, deadLetter };
2099
3133
  }
2100
- function defaultSleep3(ms) {
2101
- return new Promise((resolve) => setTimeout(resolve, ms));
2102
- }
3134
+ function defaultSleep4(ms) {
3135
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
3136
+ }
3137
+ function errorMessage(err) {
3138
+ if (!(err instanceof Error)) return String(err);
3139
+ const cause = err.cause;
3140
+ const reason = cause instanceof Error && cause.message ? cause.message : void 0;
3141
+ if (!reason || err.message.includes(reason)) return err.message;
3142
+ const first = err.message.split("\n")[0];
3143
+ return /^Failed query:/.test(first) ? `${reason} (${first.length > 160 ? `${first.slice(0, 160)}\u2026` : first})` : `${err.message}: ${reason}`;
3144
+ }
3145
+ function isTransientBatchError(err) {
3146
+ const status = err?.status;
3147
+ if (typeof status === "number") return status === 429 || status >= 500;
3148
+ return err instanceof TypeError && /fetch failed|network/i.test(err.message);
3149
+ }
3150
+ function describeRow(row) {
3151
+ const ids = row.docIds.length > 5 ? [...row.docIds.slice(0, 5), "\u2026"] : row.docIds;
3152
+ return `outbox row ${row.id} (${row.model} ${row.op} ${JSON.stringify(ids)})`;
3153
+ }
3154
+ var emptyOutcome = () => ({
3155
+ done: [],
3156
+ retry: [],
3157
+ deadLetter: [],
3158
+ pushed: 0,
3159
+ deleted: 0
3160
+ });
2103
3161
  async function runSyncWorker(opts) {
2104
3162
  const {
2105
3163
  config,
@@ -2113,8 +3171,9 @@ async function runSyncWorker(opts) {
2113
3171
  batchSize = DEFAULT_BATCH_SIZE,
2114
3172
  stopSignal,
2115
3173
  now = Date.now,
2116
- sleep = defaultSleep3,
2117
- onHeartbeat
3174
+ sleep = defaultSleep4,
3175
+ onHeartbeat,
3176
+ log = (message) => console.warn(message)
2118
3177
  } = opts;
2119
3178
  const primaryKeyByIndex = new Map(config.indexes.map((def) => [def.name, def.primaryKey]));
2120
3179
  const report = {
@@ -2122,11 +3181,56 @@ async function runSyncWorker(opts) {
2122
3181
  pushed: 0,
2123
3182
  deleted: 0,
2124
3183
  deadLettered: 0,
3184
+ retried: 0,
2125
3185
  outboxDepth: 0
2126
3186
  };
2127
3187
  const start = now();
2128
3188
  const budgetExceeded = () => budgetMs !== void 0 && now() - start >= budgetMs;
2129
3189
  let consecutiveFailures = 0;
3190
+ let heartbeatWarned = false;
3191
+ const heartbeat = async (final) => {
3192
+ if (!onHeartbeat) return;
3193
+ try {
3194
+ await onHeartbeat({ ...report }, { final });
3195
+ } catch (err) {
3196
+ if (heartbeatWarned) return;
3197
+ heartbeatWarned = true;
3198
+ log(`[headband] could not record this worker run: ${errorMessage(err)}`);
3199
+ }
3200
+ };
3201
+ const backoff = (attempt) => sleep(Math.min(failureDelayMs * 2 ** (attempt - 1), MAX_FAILURE_DELAY_MS));
3202
+ const processBatch = async (batch) => {
3203
+ const changes = batch.map((row) => ({
3204
+ model: row.model,
3205
+ op: row.op,
3206
+ ids: row.docIds,
3207
+ snapshot: row.snapshot
3208
+ }));
3209
+ const resolved = await resolveDirtyDocs(adapter, config, changes);
3210
+ const { failures, pushed, deleted } = await pushResolved(
3211
+ resolved,
3212
+ client,
3213
+ primaryKeyByIndex,
3214
+ backoff
3215
+ );
3216
+ return { ...rowOutcomes(batch, resolved, failures), pushed, deleted };
3217
+ };
3218
+ const processIsolated = async (batch) => {
3219
+ const outcome = emptyOutcome();
3220
+ for (const row of batch) {
3221
+ try {
3222
+ const one = await processBatch([row]);
3223
+ outcome.done.push(...one.done);
3224
+ outcome.retry.push(...one.retry);
3225
+ outcome.deadLetter.push(...one.deadLetter);
3226
+ outcome.pushed += one.pushed;
3227
+ outcome.deleted += one.deleted;
3228
+ } catch (err) {
3229
+ outcome.retry.push([row, errorMessage(err)]);
3230
+ }
3231
+ }
3232
+ return outcome;
3233
+ };
2130
3234
  for (; ; ) {
2131
3235
  if (budgetExceeded()) break;
2132
3236
  if (stopSignal && stopSignal()) break;
@@ -2136,67 +3240,64 @@ async function runSyncWorker(opts) {
2136
3240
  await sleep(idleDelayMs);
2137
3241
  continue;
2138
3242
  }
2139
- const changes = rows.map((row) => ({
2140
- model: row.model,
2141
- op: row.op,
2142
- ids: row.docIds,
2143
- snapshot: row.snapshot
2144
- }));
2145
- 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);
3243
+ let outcome;
3244
+ let wholeBatchFailed = false;
3245
+ try {
3246
+ outcome = await processBatch(rows);
3247
+ } catch (err) {
3248
+ if (rows.length > 1 && !isTransientBatchError(err)) {
3249
+ outcome = await processIsolated(rows);
3250
+ } else {
3251
+ wholeBatchFailed = true;
3252
+ const message = errorMessage(err);
3253
+ outcome = { ...emptyOutcome(), retry: rows.map((row) => [row, message]) };
3254
+ log(
3255
+ `[headband] a batch of ${rows.length} outbox row(s) failed and will be retried: ${message}`
3256
+ );
2160
3257
  }
2161
- for (const [id, message] of outcome.retry) {
2162
- await outbox.markFailed(id, message);
3258
+ }
3259
+ report.pushed += outcome.pushed;
3260
+ report.deleted += outcome.deleted;
3261
+ if (outcome.done.length > 0) await outbox.markDone(outcome.done.map((row) => row.id));
3262
+ report.drained += outcome.done.length;
3263
+ for (const [row, message] of outcome.deadLetter) {
3264
+ if (outbox.deadLetter) {
3265
+ await outbox.deadLetter(row.id, message);
3266
+ report.deadLettered += 1;
3267
+ log(`[headband] ${describeRow(row)} moved to dead letters: ${message}`);
3268
+ } else {
3269
+ const status = await outbox.markFailed(row.id, message);
3270
+ if (status === "dead_letter") report.deadLettered += 1;
3271
+ else report.retried += 1;
3272
+ log(`[headband] ${describeRow(row)} failed: ${message}`);
2163
3273
  }
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)
3274
+ }
3275
+ for (const [row, message] of outcome.retry) {
3276
+ const status = await outbox.markFailed(row.id, message);
3277
+ if (status === "dead_letter") {
3278
+ report.deadLettered += 1;
3279
+ log(
3280
+ `[headband] ${describeRow(row)} moved to dead letters after ${row.attempts + 1} attempt(s): ${message}`
2170
3281
  );
2171
3282
  } 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);
3283
+ report.retried += 1;
3284
+ if (!wholeBatchFailed) {
3285
+ log(
3286
+ `[headband] ${describeRow(row)} failed (attempt ${row.attempts + 1}), will retry: ${message}`
3287
+ );
3288
+ }
2178
3289
  }
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
3290
  }
2184
- if (onHeartbeat) {
2185
- try {
2186
- await onHeartbeat({ ...report });
2187
- } catch (err) {
2188
- console.error("[headband] heartbeat failed:", err);
2189
- }
3291
+ if (outcome.retry.length > 0) {
3292
+ consecutiveFailures += 1;
3293
+ await backoff(consecutiveFailures);
3294
+ } else {
3295
+ consecutiveFailures = 0;
2190
3296
  }
3297
+ await heartbeat(false);
2191
3298
  }
2192
3299
  report.outboxDepth = await outbox.depth();
2193
- if (onHeartbeat) {
2194
- try {
2195
- await onHeartbeat({ ...report });
2196
- } catch (err) {
2197
- console.error("[headband] heartbeat failed:", err);
2198
- }
2199
- }
3300
+ await heartbeat(true);
2200
3301
  return report;
2201
3302
  }
2202
3303
 
@@ -2204,20 +3305,37 @@ async function runSyncWorker(opts) {
2204
3305
  var WORKER_VERSION = "1";
2205
3306
  var DEFAULT_DEAD_LETTER_LIMIT = 1e3;
2206
3307
  var RECONCILE_PAGE_SIZE = 500;
2207
- var OUTBOX_SQL_RELATIVE_PATH = (0, import_path8.join)("headband", "outbox.sql");
3308
+ var OUTBOX_SQL_RELATIVE_PATH = (0, import_path10.join)("headband", "outbox.sql");
2208
3309
  var DOCKERFILE_NAME = "Dockerfile.headband-sync";
2209
- var DOCKERFILE_CONTENT = `FROM node:22-slim
3310
+ var DEFAULT_OUTBOX_TABLE = "headband_outbox";
3311
+ var ORM_SYNC_CONNECTOR_SLUG = "orm-sync";
3312
+ function dockerfileContent(cliVersion) {
3313
+ const pkg = cliVersion && cliVersion !== "dev" ? `@headband/cli@${cliVersion}` : "@headband/cli";
3314
+ return `# Runs the Headband sync worker: drains the outbox table into your Headband indexes.
3315
+ #
3316
+ # It needs, at run time:
3317
+ # DATABASE_URL the Postgres database that holds your tables and the outbox table
3318
+ # HEADBAND_API_KEY an admin key that reaches your indexes (your project's admin key)
3319
+ # HEADBAND_URL optional, defaults to https://headband.dev
3320
+ #
3321
+ # The worker loads headband.config.ts from /app, so npm ci must install what it imports
3322
+ # (@headband/sync, @headband/drizzle, drizzle-orm, pg).
3323
+ FROM node:22-slim
2210
3324
  WORKDIR /app
2211
3325
  COPY . .
2212
3326
  RUN npm ci
2213
- CMD ["npx", "headband", "sync", "worker"]
3327
+ CMD ["npx", "--yes", "${pkg}", "sync", "worker", "--loop"]
2214
3328
  `;
3329
+ }
2215
3330
  var COMPOSE_SNIPPET = `services:
2216
3331
  headband-sync:
2217
3332
  build:
2218
3333
  context: .
2219
3334
  dockerfile: ${DOCKERFILE_NAME}
2220
- env_file: .env
3335
+ environment:
3336
+ - DATABASE_URL
3337
+ - HEADBAND_API_KEY
3338
+ - HEADBAND_URL
2221
3339
  restart: unless-stopped
2222
3340
  `;
2223
3341
  function resolveDatabaseUrl(env = process.env) {
@@ -2229,33 +3347,79 @@ function resolveDatabaseUrl(env = process.env) {
2229
3347
  }
2230
3348
  return url;
2231
3349
  }
3350
+ var UNDEFINED_TABLE = "42P01";
3351
+ function missingOutboxTableError(table) {
3352
+ return new CliError(
3353
+ `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.`,
3354
+ { code: "outbox_table_missing" }
3355
+ );
3356
+ }
2232
3357
  function buildPgOutbox(connectionString, opts) {
3358
+ const table = opts?.table ?? DEFAULT_OUTBOX_TABLE;
2233
3359
  const pool = new import_pg.Pool({ connectionString });
2234
3360
  const exec = async (sqlText, params) => {
2235
- const result = await pool.query(sqlText, params);
2236
- return { rows: result.rows };
3361
+ try {
3362
+ const result = await pool.query(sqlText, params);
3363
+ return { rows: result.rows };
3364
+ } catch (err) {
3365
+ if (err.code === UNDEFINED_TABLE)
3366
+ throw missingOutboxTableError(table);
3367
+ throw err;
3368
+ }
2237
3369
  };
2238
3370
  return { outbox: pgOutboxDriver(exec, opts), close: () => pool.end() };
2239
3371
  }
2240
- function runSyncInit(deps) {
3372
+ async function applyOutboxDDL(connectionString, ddl) {
3373
+ const pool = new import_pg.Pool({ connectionString });
3374
+ try {
3375
+ await pool.query(ddl);
3376
+ } finally {
3377
+ await pool.end();
3378
+ }
3379
+ }
3380
+ async function runSyncInit(deps) {
2241
3381
  const ddl = outboxDDL(deps.table);
3382
+ const table = deps.table ?? DEFAULT_OUTBOX_TABLE;
2242
3383
  deps.io.stdout(ddl);
2243
3384
  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}
3385
+ const path2 = (0, import_path10.join)(deps.cwd ?? process.cwd(), OUTBOX_SQL_RELATIVE_PATH);
3386
+ (0, import_fs8.mkdirSync)((0, import_path10.join)(deps.cwd ?? process.cwd(), "headband"), { recursive: true });
3387
+ (0, import_fs8.writeFileSync)(path2, `${ddl}
2247
3388
  `);
2248
3389
  deps.io.stdout(`Wrote ${path2}`);
2249
3390
  }
3391
+ if (deps.apply) {
3392
+ await deps.apply(ddl);
3393
+ deps.io.stdout(`Created the outbox table "${table}" (if it was missing).`);
3394
+ } else {
3395
+ deps.io.stdout(
3396
+ `
3397
+ Run this SQL once against your database: "headband sync init --apply" does it with DATABASE_URL, or add it to your own migrations.`
3398
+ );
3399
+ }
2250
3400
  return ddl;
2251
3401
  }
2252
3402
  var SHUTDOWN_SIGNALS = ["SIGINT", "SIGTERM"];
3403
+ function explainReportRunError(err) {
3404
+ const code = err?.code;
3405
+ const message = err instanceof Error ? err.message : String(err);
3406
+ if (code === "project_id_required") {
3407
+ return "your key is org-wide, so the run needs a project. Pass --project <id>, or set projectId in headband.config.ts.";
3408
+ }
3409
+ if (code === "invalid_connector_run" && /connectorSlug/.test(message)) {
3410
+ return "this Headband server does not record self-hosted sync runs yet. Syncing is unaffected.";
3411
+ }
3412
+ return message;
3413
+ }
2253
3414
  async function runSyncWorkerCmd(deps) {
2254
3415
  const schemaHashes = Object.fromEntries(
2255
3416
  deps.config.indexes.map((def) => [def.name, docShapeHash(def)])
2256
3417
  );
2257
3418
  const workerVersion = deps.workerVersion ?? WORKER_VERSION;
2258
- let heartbeats = 0;
3419
+ const externalRunId = (0, import_crypto.randomUUID)();
3420
+ const startedAt = (/* @__PURE__ */ new Date()).toISOString();
3421
+ let batches = 0;
3422
+ let last = { drained: 0, pushed: 0, deleted: 0, deadLettered: 0 };
2259
3423
  const proc = deps.process ?? process;
2260
3424
  let stopRequested = false;
2261
3425
  const handleShutdown = () => {
@@ -2280,23 +3444,67 @@ async function runSyncWorkerCmd(deps) {
2280
3444
  now: deps.now,
2281
3445
  sleep: deps.sleep,
2282
3446
  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();
3447
+ log: (message) => deps.io.stderr(message),
3448
+ onHeartbeat: async (r, { final }) => {
3449
+ if (!final) {
3450
+ batches += 1;
3451
+ const drained = r.drained - last.drained;
3452
+ if (drained > 0) {
3453
+ const dead = r.deadLettered - last.deadLettered;
3454
+ deps.io.stderr(
3455
+ `[headband] batch ${batches}: drained ${drained} row(s), pushed ${r.pushed - last.pushed}, deleted ${r.deleted - last.deleted}${dead > 0 ? `, dead-lettered ${dead}` : ""}.`
3456
+ );
3457
+ }
3458
+ last = {
3459
+ drained: r.drained,
3460
+ pushed: r.pushed,
3461
+ deleted: r.deleted,
3462
+ deadLettered: r.deadLettered
3463
+ };
3464
+ if (deps.reconcileEvery && deps.onReconcileTick && batches % deps.reconcileEvery === 0) {
3465
+ try {
3466
+ await deps.onReconcileTick();
3467
+ } catch (err) {
3468
+ deps.io.stderr(
3469
+ `[headband] reconcile failed: ${err instanceof Error ? err.message : String(err)}`
3470
+ );
3471
+ }
3472
+ }
3473
+ }
3474
+ try {
3475
+ await deps.client.reportRun({
3476
+ connectorSlug: ORM_SYNC_CONNECTOR_SLUG,
3477
+ ...deps.projectId ? { projectId: deps.projectId } : {},
3478
+ status: final ? r.deadLettered > 0 ? "dead_letter" : "succeeded" : "started",
3479
+ mode: "self_hosted",
3480
+ command: "headband sync worker",
3481
+ count: r.pushed + r.deleted,
3482
+ externalRunId,
3483
+ startedAt,
3484
+ details: {
3485
+ drained: r.drained,
3486
+ pushed: r.pushed,
3487
+ deleted: r.deleted,
3488
+ deadLettered: r.deadLettered,
3489
+ retried: r.retried,
3490
+ outboxDepth: r.outboxDepth,
3491
+ workerVersion,
3492
+ schemaHashes
3493
+ }
3494
+ });
3495
+ } catch (err) {
3496
+ throw new Error(explainReportRunError(err));
2294
3497
  }
2295
3498
  }
2296
3499
  });
2297
3500
  deps.io.stdout(
2298
- `Drained ${report.drained} row(s): pushed ${report.pushed}, deleted ${report.deleted}, dead-lettered ${report.deadLettered}. Outbox depth: ${report.outboxDepth}.`
3501
+ `Drained ${report.drained} row(s): pushed ${report.pushed}, deleted ${report.deleted}, dead-lettered ${report.deadLettered}, left for retry ${report.retried}. Outbox depth: ${report.outboxDepth}.`
2299
3502
  );
3503
+ if (report.deadLettered > 0) {
3504
+ deps.io.stderr(
3505
+ `${report.deadLettered} row(s) moved to dead letters (reasons above). Fix the cause, then run: headband sync retry-dead-letters`
3506
+ );
3507
+ }
2300
3508
  return report;
2301
3509
  } finally {
2302
3510
  for (const signal of SHUTDOWN_SIGNALS) {
@@ -2304,59 +3512,133 @@ async function runSyncWorkerCmd(deps) {
2304
3512
  }
2305
3513
  }
2306
3514
  }
2307
- async function collectAllIds(adapter, model) {
2308
- const ids = [];
3515
+ async function indexableSourceIds(adapter, def, pageSize) {
3516
+ const ids = /* @__PURE__ */ new Map();
2309
3517
  let cursor = null;
2310
3518
  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;
3519
+ const page = await adapter.listIds(def.source, cursor, pageSize);
3520
+ if (page.ids.length === 0) break;
3521
+ const docs = await adapter.resolveDocs(def.name, page.ids);
3522
+ for (const doc of docs) {
3523
+ const sourceRow = doc.__source ?? doc;
3524
+ if (def.shouldIndex && !def.shouldIndex(sourceRow)) continue;
3525
+ const id = doc[def.primaryKey];
3526
+ if (id !== void 0 && id !== null) ids.set(String(id), id);
3527
+ }
3528
+ if (page.nextCursor === null) break;
2314
3529
  cursor = page.nextCursor;
2315
3530
  }
2316
3531
  return ids;
2317
3532
  }
3533
+ async function pushSourceRows(deps, def, ids, pageSize) {
3534
+ let pushed = 0;
3535
+ for (let offset = 0; offset < ids.length; offset += pageSize) {
3536
+ const docs = await deps.adapter.resolveDocs(def.name, ids.slice(offset, offset + pageSize));
3537
+ const toPush = [];
3538
+ for (const doc of docs) {
3539
+ const sourceRow = doc.__source ?? doc;
3540
+ delete doc.__source;
3541
+ if (def.shouldIndex && !def.shouldIndex(sourceRow)) continue;
3542
+ toPush.push(doc);
3543
+ }
3544
+ if (toPush.length === 0) continue;
3545
+ const result = await deps.client.bulkPush(def.name, toPush, def.primaryKey);
3546
+ if (result?.partialFailure) {
3547
+ throw new CliError(
3548
+ `Repair push for "${def.name}" was incomplete: ${partialFailureSummary(result.partialFailure)}.`
3549
+ );
3550
+ }
3551
+ pushed += toPush.length;
3552
+ }
3553
+ return pushed;
3554
+ }
2318
3555
  async function runSyncReconcile(deps) {
2319
- const ormSchema = deps.adapter.introspect();
3556
+ const pageSize = deps.pageSize ?? RECONCILE_PAGE_SIZE;
2320
3557
  const rows = [];
2321
3558
  for (const def of deps.config.indexes) {
2322
- const ids = await collectAllIds(deps.adapter, def.source);
2323
- const sourceCount = ids.length;
3559
+ const source = await indexableSourceIds(deps.adapter, def, pageSize);
3560
+ let remoteKeys = null;
2324
3561
  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
- );
3562
+ if (deps.listRemoteIds) {
3563
+ try {
3564
+ remoteKeys = new Set((await deps.listRemoteIds(def.name, def.primaryKey)).map(String));
3565
+ remoteCount = remoteKeys.size;
3566
+ } catch (err) {
3567
+ deps.io.stderr(
3568
+ `Could not list the documents of "${def.name}", comparing counts only: ${err instanceof Error ? err.message : String(err)}`
3569
+ );
3570
+ }
2332
3571
  }
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)}.`
3572
+ if (remoteKeys === null) {
3573
+ try {
3574
+ const stats = await deps.getStats(def.name);
3575
+ remoteCount = stats.numberOfDocuments ?? stats.numDocuments ?? -1;
3576
+ } catch (err) {
3577
+ deps.io.stderr(
3578
+ `Could not fetch stats for "${def.name}": ${err instanceof Error ? err.message : String(err)}`
3579
+ );
3580
+ }
3581
+ }
3582
+ const row = {
3583
+ index: def.name,
3584
+ sourceCount: source.size,
3585
+ remoteCount,
3586
+ missing: null,
3587
+ extra: null,
3588
+ drift: remoteCount === -1 ? 0 : Math.abs(source.size - remoteCount),
3589
+ fixed: false
3590
+ };
3591
+ let missingIds = [];
3592
+ let extraIds = [];
3593
+ if (remoteKeys) {
3594
+ const remote = remoteKeys;
3595
+ missingIds = [...source].filter(([key]) => !remote.has(key)).map(([, id]) => id);
3596
+ extraIds = [...remote].filter((key) => !source.has(key));
3597
+ row.missing = missingIds.length;
3598
+ row.extra = extraIds.length;
3599
+ row.drift = missingIds.length + extraIds.length;
3600
+ }
3601
+ if (deps.fix && row.drift !== 0) {
3602
+ if (remoteKeys) {
3603
+ await pushSourceRows(deps, def, missingIds, pageSize);
3604
+ for (let offset = 0; offset < extraIds.length; offset += pageSize) {
3605
+ const result = await deps.client.deleteDocs(
3606
+ def.name,
3607
+ extraIds.slice(offset, offset + pageSize)
2348
3608
  );
3609
+ if (result?.partialFailure) {
3610
+ throw new CliError(
3611
+ `Removing extra documents from "${def.name}" was incomplete: ${partialFailureSummary(result.partialFailure)}.`
3612
+ );
3613
+ }
2349
3614
  }
3615
+ } else {
3616
+ await pushSourceRows(deps, def, [...source.values()], pageSize);
2350
3617
  }
3618
+ row.fixed = true;
2351
3619
  }
3620
+ rows.push(row);
2352
3621
  }
2353
3622
  printTable(
2354
3623
  deps.io,
2355
- rows.map((r) => ({ ...r, remoteCount: r.remoteCount === -1 ? "unknown" : r.remoteCount })),
2356
- ["index", "sourceCount", "remoteCount", "drift", "cursorAware"]
3624
+ rows.map((r) => ({
3625
+ ...r,
3626
+ remoteCount: r.remoteCount === -1 ? "unknown" : r.remoteCount,
3627
+ missing: r.missing ?? "",
3628
+ extra: r.extra ?? "",
3629
+ fixed: r.fixed ? "yes" : ""
3630
+ })),
3631
+ ["index", "sourceCount", "remoteCount", "missing", "extra", "drift", "fixed"]
2357
3632
  );
3633
+ const unfixed = rows.filter((r) => r.drift !== 0 && !r.fixed);
3634
+ if (unfixed.length > 0 && !deps.fix) {
3635
+ deps.io.stderr("Drift found. Run again with --fix to repair it.");
3636
+ }
2358
3637
  return rows;
2359
3638
  }
3639
+ function reconcileHasDrift(rows) {
3640
+ return rows.some((r) => r.remoteCount === -1 || r.drift !== 0 && !r.fixed);
3641
+ }
2360
3642
  async function runSyncRetryDeadLetters(deps) {
2361
3643
  const rows = await deps.outbox.deadLetters(deps.limit ?? DEFAULT_DEAD_LETTER_LIMIT);
2362
3644
  if (rows.length === 0) {
@@ -2367,23 +3649,237 @@ async function runSyncRetryDeadLetters(deps) {
2367
3649
  deps.io.stdout(`Requeued ${rows.length} dead-lettered row(s).`);
2368
3650
  return rows.length;
2369
3651
  }
3652
+ async function runSelfHostedSyncStatus(deps) {
3653
+ const now = deps.now ?? Date.now;
3654
+ const table = deps.config.sync?.outboxTable ?? DEFAULT_OUTBOX_TABLE;
3655
+ const mode = deps.config.sync?.mode ?? "outbox";
3656
+ let outbox = null;
3657
+ if (deps.outbox) {
3658
+ const stats = deps.outbox.stats ? await deps.outbox.stats() : { pending: await deps.outbox.depth(), deadLetter: -1, oldestPendingAt: null };
3659
+ outbox = {
3660
+ ...stats,
3661
+ oldestPendingAgeSeconds: stats.oldestPendingAt ? Math.max(0, Math.round((now() - stats.oldestPendingAt.getTime()) / 1e3)) : null
3662
+ };
3663
+ }
3664
+ const status = {
3665
+ config: deps.configPath,
3666
+ outboxTable: table,
3667
+ mode,
3668
+ indexes: deps.config.indexes.map((def) => def.name),
3669
+ outbox
3670
+ };
3671
+ const { io } = deps;
3672
+ io.stdout(`Self-hosted sync (${deps.configPath}), ${mode} mode`);
3673
+ io.stdout(`Indexes: ${status.indexes.join(", ") || "none"}`);
3674
+ if (!outbox) {
3675
+ io.stdout("Outbox: not checked. Set DATABASE_URL to see its depth and dead letters.");
3676
+ } else {
3677
+ printTable(
3678
+ io,
3679
+ [
3680
+ {
3681
+ table,
3682
+ pending: outbox.pending,
3683
+ deadLetters: outbox.deadLetter === -1 ? "unknown" : outbox.deadLetter,
3684
+ oldestPending: outbox.oldestPendingAgeSeconds === null ? "" : `${outbox.oldestPendingAgeSeconds}s ago`
3685
+ }
3686
+ ],
3687
+ ["table", "pending", "deadLetters", "oldestPending"]
3688
+ );
3689
+ if (outbox.deadLetter > 0) {
3690
+ io.stdout(
3691
+ `${outbox.deadLetter} row(s) are dead letters. Their last error is in the last_error column. Requeue them with: headband sync retry-dead-letters`
3692
+ );
3693
+ }
3694
+ }
3695
+ io.stdout("Managed Sync (replication slots) has its own commands: headband sync source list");
3696
+ return status;
3697
+ }
2370
3698
  function runSyncScaffoldDocker(deps) {
2371
- const path2 = (0, import_path8.join)(deps.cwd, DOCKERFILE_NAME);
2372
- if ((0, import_fs7.existsSync)(path2) && !deps.force) {
3699
+ const path2 = (0, import_path10.join)(deps.cwd, DOCKERFILE_NAME);
3700
+ if ((0, import_fs8.existsSync)(path2) && !deps.force) {
2373
3701
  throw new CliError(`${DOCKERFILE_NAME} already exists at ${path2}. Pass --force to overwrite.`);
2374
3702
  }
2375
- (0, import_fs7.writeFileSync)(path2, DOCKERFILE_CONTENT);
3703
+ (0, import_fs8.writeFileSync)(path2, dockerfileContent(deps.cliVersion));
2376
3704
  deps.io.stdout(`Wrote ${path2}`);
2377
3705
  deps.io.stdout("Add this service to your docker-compose.yml:");
2378
3706
  deps.io.stdout(COMPOSE_SNIPPET);
3707
+ deps.io.stdout(
3708
+ "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."
3709
+ );
2379
3710
  return path2;
2380
3711
  }
2381
3712
 
3713
+ // src/help.ts
3714
+ var CONFIG_NOTE = [
3715
+ "These commands read headband.config.ts in the current directory:",
3716
+ " export default defineConfig({ url, projectId?, indexes: [...], sync? }) (@headband/sync)",
3717
+ " export const adapter = drizzleAdapter({ db, schema, config }) (@headband/drizzle)",
3718
+ "The adapter is needed by apply, worker and reconcile; diff, generate and status run without it.",
3719
+ "When the adapter is there, the config is checked against your tables before anything runs.",
3720
+ "The Headband URL is --url, else HEADBAND_URL, else the config's url, else your login's."
3721
+ ].join("\n");
3722
+ var KEY_NOTE = [
3723
+ "Key: your login (headband login) or HEADBAND_API_KEY. The login key is org-wide, so new",
3724
+ "indexes go to the project named by --project <id> or the config's projectId; without one",
3725
+ "they go to your org's Default project. Your app's own project admin key reaches only",
3726
+ "indexes in its project, so name that project here."
3727
+ ].join("\n");
3728
+ var schemaDiff = [
3729
+ "Usage: headband schema diff [--allow-destructive] [--json]",
3730
+ "",
3731
+ "Compares the indexes in headband.config.ts with the schema state Headband has recorded,",
3732
+ "and prints the operations a migration would run: create-index, patch-settings, reindex,",
3733
+ "and with --allow-destructive, remove-index. Changes nothing. Exits 1 when changes are",
3734
+ "pending, like git diff --exit-code, so CI can fail on an unapplied change."
3735
+ ].join("\n");
3736
+ var schemaGenerate = [
3737
+ "Usage: headband schema generate [--slug <slug>] [--dir <dir>] [--allow-destructive] [--json]",
3738
+ "",
3739
+ "Writes the pending operations to a numbered migration file in --dir",
3740
+ "(default headband/migrations), for review and for git. Changes nothing on the server."
3741
+ ].join("\n");
3742
+ var schemaApply = [
3743
+ "Usage: headband schema apply [--project <id>] [--allow-destructive] [--dir <dir>] [--yes] [--json]",
3744
+ "",
3745
+ "Runs the migration files in --dir (default headband/migrations) that the server does not",
3746
+ "have yet. A new index is created, given its settings, and backfilled from every source row",
3747
+ "(rows shouldIndex rejects are left out). A schema change builds a new index beside the",
3748
+ "live one, backfills it, then switches the name over. Settings changes apply in place.",
3749
+ "",
3750
+ "Applied migrations are recognised from the server's state, so a fresh checkout or CI can",
3751
+ "apply without the local headband/migrations/.applied.json (a cache you may commit or ignore).",
3752
+ "Needs the adapter export and DATABASE_URL (the database your adapter reads). Asks first;",
3753
+ "--yes confirms without a terminal.",
3754
+ "",
3755
+ KEY_NOTE
3756
+ ].join("\n");
3757
+ var schemaStatus = [
3758
+ "Usage: headband schema status [--dir <dir>] [--json]",
3759
+ "",
3760
+ "Per index in headband.config.ts: the applied version and status on the server, and drift",
3761
+ "(schema, settings, none, missing, or index missing when the index was deleted). Also how",
3762
+ "many local migration files are applied."
3763
+ ].join("\n");
3764
+ var schemaForget = [
3765
+ "Usage: headband schema forget <index> [--yes] [--json]",
3766
+ "",
3767
+ "Removes the schema state Headband recorded for <index>, for an index deleted outside",
3768
+ "schema apply (headband indexes delete clears it for you). Does not touch any index. The",
3769
+ "next schema generate plans the index as new."
3770
+ ].join("\n");
3771
+ var syncInit = [
3772
+ "Usage: headband sync init [--table <name>] [--write] [--apply] [--json]",
3773
+ "",
3774
+ "Prints the SQL for the outbox table (default headband_outbox) that outbox mode writes",
3775
+ "changes to. --write saves it to headband/outbox.sql. --apply runs it against DATABASE_URL",
3776
+ "(CREATE TABLE IF NOT EXISTS, safe to re-run). Or add the SQL to your own migrations."
3777
+ ].join("\n");
3778
+ var syncWorker = [
3779
+ "Usage:",
3780
+ " headband sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--project <id>] [--json]",
3781
+ " headband sync worker --scaffold docker [--force] [--json]",
3782
+ "",
3783
+ "Drains the outbox table: resolves each changed row through the adapter and writes the",
3784
+ "documents to Headband. By default it runs until stopped (Ctrl+C finishes the current",
3785
+ "batch), so --loop changes nothing on its own. --budget-ms <ms> is for cron: the worker",
3786
+ "stops claiming after that long, and exits early once the outbox is empty; add --loop to",
3787
+ "keep waiting for changes until the budget ends. Each batch prints one line to stderr",
3788
+ "(rows drained, pushed, deleted), and the run ends with a summary on stdout.",
3789
+ "--reconcile-every <n> runs sync reconcile after",
3790
+ "every n-th batch. A row that keeps failing goes to the dead letters (see sync status and",
3791
+ "sync retry-dead-letters), with its error printed; one bad row never fails its batch.",
3792
+ "Each run is recorded under the connector orm-sync (GET /v1/connectors/runs).",
3793
+ "",
3794
+ "Needs the adapter export, DATABASE_URL (the database with your tables and the outbox), and",
3795
+ "a key that can write documents. A login key records runs only with --project or projectId.",
3796
+ "",
3797
+ "--scaffold docker writes Dockerfile.headband-sync, which runs this worker by its full",
3798
+ "package name, and prints a docker-compose service."
3799
+ ].join("\n");
3800
+ var syncReconcile = [
3801
+ "Usage: headband sync reconcile [--fix] [--json]",
3802
+ "",
3803
+ "Per index, compares the source rows that belong in it (every row shouldIndex keeps) with",
3804
+ "the documents in it, and prints how many documents are missing and how many are extra",
3805
+ "(counts, not ids; --json gives the same counts). --fix pushes the missing ones and deletes",
3806
+ "the extra ones. Exits 1 when drift is left. Documents whose content went",
3807
+ "stale are not detected: a reindex (schema apply) rewrites every document.",
3808
+ "Needs the adapter export and DATABASE_URL."
3809
+ ].join("\n");
3810
+ var syncRetry = [
3811
+ "Usage: headband sync retry-dead-letters [--json]",
3812
+ "",
3813
+ "Moves the outbox rows in dead_letter back to pending, so the next worker run tries them",
3814
+ "again. Fix what failed them first: their error is in the outbox table's last_error column,",
3815
+ "and the worker printed it. Needs DATABASE_URL."
3816
+ ].join("\n");
3817
+ var syncStatus = [
3818
+ "Usage: headband sync status [--json]",
3819
+ "",
3820
+ "Beside a headband.config.ts: the outbox's pending rows, dead letters and oldest pending",
3821
+ "row (needs DATABASE_URL). Elsewhere: managed Sync's sources and synced indexes."
3822
+ ].join("\n");
3823
+ var SUBCOMMAND_HELP = {
3824
+ schema: [
3825
+ "Usage:",
3826
+ " headband schema diff [--allow-destructive] [--json]",
3827
+ " headband schema generate [--slug <slug>] [--dir <dir>] [--allow-destructive] [--json]",
3828
+ " headband schema apply [--project <id>] [--allow-destructive] [--dir <dir>] [--yes] [--json]",
3829
+ " headband schema status [--dir <dir>] [--json]",
3830
+ " headband schema forget <index> [--yes] [--json]",
3831
+ "",
3832
+ "Versioned index schema for self-hosted sync. headband schema <command> --help says more.",
3833
+ "",
3834
+ CONFIG_NOTE
3835
+ ].join("\n"),
3836
+ sync: [
3837
+ "Usage:",
3838
+ " Self-hosted sync with @headband/sync:",
3839
+ " headband sync init [--table <name>] [--write] [--apply] [--json]",
3840
+ " headband sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--project <id>] [--json]",
3841
+ " headband sync worker --scaffold docker [--force] [--json]",
3842
+ " headband sync reconcile [--fix] [--json]",
3843
+ " headband sync retry-dead-letters [--json]",
3844
+ " headband sync status [--json]",
3845
+ "",
3846
+ " Managed Sync (replication slots, early access):",
3847
+ " headband sync source add|list|show|test|setup|setup-sql|set-ca|remove (see headband sync source --help)",
3848
+ " headband sync index create|list|show|update|validate|preview|rebuild|pause|resume|check|remove (see headband sync index --help)",
3849
+ "",
3850
+ "headband sync <command> --help says more.",
3851
+ "",
3852
+ CONFIG_NOTE
3853
+ ].join("\n"),
3854
+ "schema diff": `${schemaDiff}
3855
+
3856
+ ${CONFIG_NOTE}`,
3857
+ "schema generate": `${schemaGenerate}
3858
+
3859
+ ${CONFIG_NOTE}`,
3860
+ "schema apply": `${schemaApply}
3861
+
3862
+ ${CONFIG_NOTE}`,
3863
+ "schema status": `${schemaStatus}
3864
+
3865
+ ${CONFIG_NOTE}`,
3866
+ "schema forget": schemaForget,
3867
+ "sync init": syncInit,
3868
+ "sync worker": `${syncWorker}
3869
+
3870
+ ${CONFIG_NOTE}`,
3871
+ "sync reconcile": `${syncReconcile}
3872
+
3873
+ ${CONFIG_NOTE}`,
3874
+ "sync retry-dead-letters": syncRetry,
3875
+ "sync status": syncStatus
3876
+ };
3877
+
2382
3878
  // src/sync-indexes.ts
2383
- var import_fs9 = require("fs");
3879
+ var import_fs10 = require("fs");
2384
3880
 
2385
3881
  // src/sync-sources.ts
2386
- var import_fs8 = require("fs");
3882
+ var import_fs9 = require("fs");
2387
3883
  var import_pg2 = require("pg");
2388
3884
 
2389
3885
  // ../../src/lib/sync/copy.ts
@@ -2450,7 +3946,7 @@ var SOURCE_USAGE = [
2450
3946
  function readCaFile(deps, file) {
2451
3947
  let text;
2452
3948
  try {
2453
- text = (deps.readFile ?? ((p) => (0, import_fs8.readFileSync)(p, "utf8")))(file);
3949
+ text = (deps.readFile ?? ((p) => (0, import_fs9.readFileSync)(p, "utf8")))(file);
2454
3950
  } catch {
2455
3951
  throw new CliError("Cannot read the --ca-file.");
2456
3952
  }
@@ -2621,10 +4117,11 @@ async function runOwnerSqlWithPg(ownerUrl, sqlText) {
2621
4117
  function friendlySyncError(err) {
2622
4118
  if (err instanceof ApiError) {
2623
4119
  if (err.status === 404 && err.code === "not_found") {
2624
- throw new CliError("Sync is not enabled for this organization yet.");
4120
+ throw restate(err, "Sync is not enabled for this organization yet.");
2625
4121
  }
2626
4122
  if (err.status === 403 && err.code === "permission_denied") {
2627
- throw new CliError(
4123
+ throw restate(
4124
+ err,
2628
4125
  "This CLI key cannot manage Sync. Run headband login again to get a key that can."
2629
4126
  );
2630
4127
  }
@@ -3090,7 +4587,7 @@ var PREVIEW_MAX_IDS = 20;
3090
4587
  var ATTENTION_INDEX = /* @__PURE__ */ new Set(["error", "needs_rebuild", "awaiting_setup", "degraded"]);
3091
4588
  var ATTENTION_SOURCE = /* @__PURE__ */ new Set(["error", "awaiting_setup"]);
3092
4589
  var path = (id, suffix = "") => `/v1/sync/indexes/${encodeURIComponent(id)}${suffix}`;
3093
- function readDefinition(file, readFile = (p) => (0, import_fs9.readFileSync)(p, "utf8")) {
4590
+ function readDefinition(file, readFile = (p) => (0, import_fs10.readFileSync)(p, "utf8")) {
3094
4591
  let text;
3095
4592
  try {
3096
4593
  text = readFile(file);
@@ -3458,6 +4955,7 @@ async function askWithPrompts(message) {
3458
4955
  var ENGINES2 = /* @__PURE__ */ new Set(["meilisearch", "typesense", "elasticsearch", "visor"]);
3459
4956
  var KEY_TYPES = /* @__PURE__ */ new Set(["admin", "search"]);
3460
4957
  var EXPORT_FORMATS = /* @__PURE__ */ new Set(["jsonl", "json"]);
4958
+ var SEARCH_MODES = /* @__PURE__ */ new Set(["instant", "retrieval"]);
3461
4959
  var VALUE_FLAGS = /* @__PURE__ */ new Set([
3462
4960
  "--url",
3463
4961
  "--token",
@@ -3485,6 +4983,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
3485
4983
  "--idle-timeout",
3486
4984
  "--client",
3487
4985
  "--instance",
4986
+ "--instances",
3488
4987
  "--db-url",
3489
4988
  "--owner-url",
3490
4989
  "--tls-mode",
@@ -3502,7 +5001,14 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
3502
5001
  "--format",
3503
5002
  "--batch",
3504
5003
  "--permissions",
3505
- "--expires"
5004
+ "--expires",
5005
+ "--branch",
5006
+ "--parent",
5007
+ "--expires-in",
5008
+ "--in",
5009
+ "--at",
5010
+ "--level",
5011
+ "--mode"
3506
5012
  ]);
3507
5013
  var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
3508
5014
  "--json",
@@ -3519,7 +5025,15 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
3519
5025
  "--rebuild",
3520
5026
  "--drop-index",
3521
5027
  "--none",
3522
- "--no-revoke"
5028
+ "--no-revoke",
5029
+ "--empty",
5030
+ "--clear",
5031
+ "--apply",
5032
+ "--accept-drops",
5033
+ "--declare-key",
5034
+ "--dry-run",
5035
+ "--wait",
5036
+ "--status"
3523
5037
  ]);
3524
5038
  var DEFAULT_MIGRATIONS_DIR = "headband/migrations";
3525
5039
  function parseArgv(argv) {
@@ -3553,7 +5067,7 @@ function parseArgv(argv) {
3553
5067
  }
3554
5068
  return { flags, positional };
3555
5069
  }
3556
- function stringFlag(flags, name) {
5070
+ function stringFlag3(flags, name) {
3557
5071
  const value = flags[name];
3558
5072
  return typeof value === "string" ? value : void 0;
3559
5073
  }
@@ -3561,7 +5075,7 @@ function boolFlag(flags, name) {
3561
5075
  return flags[name] === true;
3562
5076
  }
3563
5077
  function requiredFlag(flags, name) {
3564
- const value = stringFlag(flags, name);
5078
+ const value = stringFlag3(flags, name);
3565
5079
  if (!value) throw new UsageError(`Missing required option: ${name}`);
3566
5080
  return value;
3567
5081
  }
@@ -3570,7 +5084,7 @@ function requiredArg(value, what) {
3570
5084
  return value;
3571
5085
  }
3572
5086
  function parseLimit(flags) {
3573
- const raw = stringFlag(flags, "--limit");
5087
+ const raw = stringFlag3(flags, "--limit");
3574
5088
  if (!raw) return void 0;
3575
5089
  const limit = Number(raw);
3576
5090
  if (!Number.isInteger(limit) || limit <= 0) {
@@ -3596,17 +5110,34 @@ function encode(value) {
3596
5110
  function chatterIO(io, flags) {
3597
5111
  return boolFlag(flags, "--json") ? { stdout: io.stderr, stderr: io.stderr } : io;
3598
5112
  }
3599
- function authFromFlags(flags) {
3600
- return resolveAuth({
3601
- token: stringFlag(flags, "--token"),
3602
- url: stringFlag(flags, "--url"),
3603
- profile: stringFlag(flags, "--profile")
5113
+ function authFromFlags(flags, defaults = {}) {
5114
+ const profile = stringFlag3(flags, "--profile");
5115
+ const url = stringFlag3(flags, "--url") ?? (process.env.HEADBAND_URL || profile || process.env.HEADBAND_PROFILE ? void 0 : defaults.url);
5116
+ return resolveAuth({ token: stringFlag3(flags, "--token"), url, profile });
5117
+ }
5118
+ function branchHeaders(flags, cwd, auth, options = {}) {
5119
+ const branch = branchHeaderFor(flags, cwd, { ...options, loginOrgId: auth.profile?.orgId });
5120
+ return branch ? { "Headband-Branch": branch } : void 0;
5121
+ }
5122
+ function clientFromFlags(flags, cwd, options = {}) {
5123
+ const auth = authFromFlags(flags, { url: options.defaultUrl });
5124
+ const headers = branchHeaders(flags, cwd, auth, options);
5125
+ return createApiClient(auth.url, { apiKey: auth.apiKey, headers });
5126
+ }
5127
+ function syncClientFromFlags(flags, cwd, options = {}) {
5128
+ const auth = authFromFlags(flags, { url: options.defaultUrl });
5129
+ const headers = branchHeaders(flags, cwd, auth, options);
5130
+ const fetchImpl = headers ? (input, init2) => {
5131
+ const merged = new Headers(init2?.headers);
5132
+ for (const [name, value] of Object.entries(headers)) merged.set(name, value);
5133
+ return fetch(input, { ...init2, headers: merged });
5134
+ } : void 0;
5135
+ return new HeadbandClient({
5136
+ url: auth.url,
5137
+ adminKey: auth.apiKey,
5138
+ ...fetchImpl ? { fetchImpl } : {}
3604
5139
  });
3605
5140
  }
3606
- function clientFromFlags(flags) {
3607
- const auth = authFromFlags(flags);
3608
- return createApiClient(auth.url, { apiKey: auth.apiKey });
3609
- }
3610
5141
  function stdinIsInteractive() {
3611
5142
  return process.stdin.isTTY === true;
3612
5143
  }
@@ -3643,16 +5174,24 @@ Commands:
3643
5174
  profiles use <name>
3644
5175
  projects list [--json]
3645
5176
  projects create <name> --engine <meilisearch|typesense|elasticsearch|visor> [--json]
3646
- projects delete <id> [--force] [--yes] [--json]
5177
+ projects delete <id> [--yes] [--json]
5178
+ branch list [--project <id>] [--json]
5179
+ branch create <name> --empty [--parent <name>] [--expires-in <2h|7d>] [--project <id>] [--json]
5180
+ branch delete <name> [--project <id>] [--yes] [--json]
5181
+ branch protect|unprotect <name> [--project <id>] [--json]
5182
+ branch expire <name> (--in <2h|7d> | --at <iso> | --clear) [--project <id>] [--json]
5183
+ branch checkout <name> [--project <id>] [--json]
5184
+ branch reset|rollback|restore|set-primary (coming with bucket storage)
3647
5185
  keys list [--json]
3648
- keys create <name> --type <admin|search> [--project <id>] [--permissions <a,b>] [--expires <30d|ISO date>] [--json]
5186
+ keys create <name> --type <admin|search> [--project <id>] [--branch <name>] [--instances <id,id>] [--permissions <a,b>] [--expires <30d|ISO date>] [--json]
3649
5187
  keys revoke <id> [--yes] [--json]
3650
- indexes list [--json]
5188
+ indexes list [--json] (index works in place of indexes)
3651
5189
  indexes create <uid> [--primary-key <pk>] [--engine <engine>] [--instance <id>] [--json]
3652
5190
  indexes delete <uid> [--yes] [--json]
3653
5191
  indexes settings get <uid> [--json]
3654
5192
  indexes settings set <uid> --file <settings.json> [--json]
3655
5193
  indexes stats <uid> [--json]
5194
+ indexes switch-engine <uid> --engine <engine> [--accept-drops] [--dry-run] [--wait] [--json]
3656
5195
  instances list [--json]
3657
5196
  instances create <engine> [--name <name>] [--lane <lane>] [--memory <size>] [--region <region>] [--idle-timeout <s>] [--json]
3658
5197
  instances update <id> [--name <name>] [--lane <lane>] [--memory <size>] [--idle-timeout <s>] [--json]
@@ -3660,29 +5199,39 @@ Commands:
3660
5199
  instances wakes <id> [--limit <n>] [--json]
3661
5200
  instances delete <id> [--yes] [--json]
3662
5201
  instances connect <id> [--client <node|curl>] [--json]
5202
+ warm <index>... | --project <id> [--branch <name>] [--level auto|stage|start] [--json]
3663
5203
  docs push <file> --index <uid> [--json]
3664
5204
  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]
5205
+ docs delete <docId>... --index <uid> [--json]
5206
+ search <index> [<query>] [--limit N] [--filter <expr>] [--mode <instant|retrieval>] [--json]
5207
+ tasks list [--index <uid>] [--limit <n>] [--json]
5208
+ tasks get <id> [--index <uid>] [--json]
5209
+
5210
+ Self-hosted sync with @headband/sync (reads headband.config.ts; see headband schema --help):
3669
5211
  schema diff [--allow-destructive] [--json]
3670
5212
  schema generate [--slug <slug>] [--dir <dir>] [--allow-destructive] [--json]
3671
- schema apply [--allow-destructive] [--dir <dir>] [--yes] [--json]
5213
+ schema apply [--project <id>] [--allow-destructive] [--dir <dir>] [--yes] [--json]
3672
5214
  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]
5215
+ schema forget <index> [--yes] [--json]
5216
+ sync init [--table <name>] [--write] [--apply] [--json]
5217
+ sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--project <id>] [--json]
3675
5218
  sync worker --scaffold docker [--force] [--json]
3676
5219
  sync reconcile [--fix] [--json]
3677
5220
  sync retry-dead-letters [--json]
5221
+ sync status [--json]
5222
+
5223
+ Managed Sync (replication slots, early access):
3678
5224
  sync source add|list|show|test|setup|setup-sql|set-ca|remove (see headband sync source --help)
3679
5225
  sync index create|list|show|update|validate|preview|rebuild|pause|resume|check|remove (see headband sync index --help)
3680
- sync status [--json]
3681
5226
 
3682
5227
  Global options:
3683
5228
  --token <key> API key for this call. Wins over HEADBAND_API_KEY and stored logins.
3684
5229
  --profile <name> Stored login to use (else HEADBAND_PROFILE, else the current profile)
3685
5230
  --url <url> Headband URL (else HEADBAND_URL, else the profile's, else ${DEFAULT_URL})
5231
+ --branch <name> Work on this branch for one call: sends Headband-Branch: <project>/<name>,
5232
+ with the project from --project <id>, else the checkout's
5233
+ (headband branch checkout). A branch id (br_...) is sent as it is.
5234
+ Without --branch or a checkout, calls reach the primary branches.
3686
5235
  --json Print one JSON document on stdout; errors print one JSON object on stderr
3687
5236
  --yes Answer yes to confirmations (required when stdin is not a terminal)
3688
5237
  --help Show help
@@ -3697,8 +5246,20 @@ function printCommandHelp(command, io) {
3697
5246
  logout: "Usage: headband logout [--profile <name>] [--yes] [--no-revoke] [--json]",
3698
5247
  whoami: "Usage: headband whoami [--json]",
3699
5248
  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]",
5249
+ 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.",
5250
+ branch: BRANCH_USAGE,
5251
+ branches: BRANCH_USAGE,
5252
+ keys: [
5253
+ "Usage:",
5254
+ " headband keys list [--json]",
5255
+ " headband keys create <name> --type <admin|search> [--project <id>] [--branch <name>] [--instances <id,id>] [--permissions <a,b>] [--expires <30d|ISO date>] [--json]",
5256
+ " headband keys revoke <id> [--yes] [--json]",
5257
+ "",
5258
+ "keys create mints a key for one branch: --branch (with --project, else the checkout's project),",
5259
+ "else the checked-out branch, else the --project's primary branch. With none of these,",
5260
+ "--type admin mints an org key, and --type search is refused: a search key needs a branch.",
5261
+ "--instances <id,id> limits the key to those dedicated engines (on a branch: its engines among them)."
5262
+ ].join("\n"),
3702
5263
  indexes: [
3703
5264
  "Usage:",
3704
5265
  " headband indexes list [--json]",
@@ -3707,24 +5268,70 @@ function printCommandHelp(command, io) {
3707
5268
  " headband indexes settings get <uid> [--json]",
3708
5269
  " headband indexes settings set <uid> --file <settings.json> [--json]",
3709
5270
  " headband indexes stats <uid> [--json]",
5271
+ " headband indexes switch-engine <uid> --engine <engine> [--instance <id>] [--accept-drops] [--declare-key] [--dry-run] [--wait] [--json]",
5272
+ " headband indexes switch-engine <uid> --status [--json]",
5273
+ "",
5274
+ "index is an alias: headband index switch-engine <uid> is the same command.",
3710
5275
  "",
3711
5276
  "Options for create:",
3712
5277
  " --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."
5278
+ " The index takes that engine's type; --engine, if given, must match it.",
5279
+ "",
5280
+ "Options for switch-engine (moves the index to another engine; its uid, keys and URLs stay):",
5281
+ " --dry-run Show what would be copied and which settings would not carry over. Changes nothing.",
5282
+ " --accept-drops Switch even though some settings will not apply on the new engine.",
5283
+ " --declare-key Visor over 1000 documents: declare the primary key while it is read (rebuilds it).",
5284
+ " --instance <id> Put the index on that engine of the branch instead of the branch's default.",
5285
+ " --wait Follow the switch until it ends. Writes are held for about a minute at the end.",
5286
+ " --status Show the index's engine and its latest switch."
3714
5287
  ].join("\n"),
3715
5288
  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]"
5289
+ warm: WARM_USAGE,
5290
+ docs: [
5291
+ "Usage:",
5292
+ " headband docs push <file> --index <uid> [--json]",
5293
+ " headband docs export --index <uid> [--out <file>] [--format <jsonl|json>] [--batch <n>] [--json]",
5294
+ " headband docs delete <docId>... --index <uid> [--json]",
5295
+ "",
5296
+ "docs push sends the documents in a .json (an array) or .jsonl file, 1000 at a time.",
5297
+ "",
5298
+ "docs export writes every document of the index:",
5299
+ " without --out JSONL to stdout, one document per line, and nothing else.",
5300
+ " --out <file> to a file. The format follows the extension (.json is one array,",
5301
+ " anything else JSONL) unless --format says otherwise.",
5302
+ " --batch <n> documents per request. Default 1000, or 250 on Typesense.",
5303
+ " If the export stops on an error, the documents written so far are kept in",
5304
+ " <file>.partial, and <file> is not written.",
5305
+ "",
5306
+ "docs delete removes the documents with these ids (one or more) from the index."
5307
+ ].join("\n"),
5308
+ search: [
5309
+ "Usage: headband search <index> [<query>] [--limit N] [--filter <expr>] [--mode <instant|retrieval>] [--json]",
5310
+ "",
5311
+ 'Without a query (or with ""), search browses the index: every document matches, so',
5312
+ "--filter and --limit pick what comes back.",
5313
+ "",
5314
+ "--mode picks Visor's ranking: instant (the default, typo-tolerant, as-you-type) or retrieval",
5315
+ "(BM25F, for longer queries). Indexes on the other engines ignore it."
5316
+ ].join("\n"),
5317
+ tasks: [
5318
+ "Usage:",
5319
+ " headband tasks list [--index <uid>] [--limit <n>] [--json]",
5320
+ " headband tasks get <id> [--index <uid>] [--json]",
5321
+ "",
5322
+ "Tasks live on the engine that holds the index. With the key headband login creates (an",
5323
+ "org key), or on a branch other than main, pass --index so Headband knows which",
5324
+ "engine to ask. A project key of the main branch may leave it out."
5325
+ ].join("\n"),
5326
+ schema: SUBCOMMAND_HELP.schema,
5327
+ sync: SUBCOMMAND_HELP.sync
3721
5328
  };
3722
- io.stdout(help[command] || "");
5329
+ io.stdout(help[command === "index" ? "indexes" : command] || "");
3723
5330
  }
3724
5331
  async function runLogin(flags, io) {
3725
- const url = stringFlag(flags, "--url") || process.env.HEADBAND_URL || DEFAULT_URL;
5332
+ const url = stringFlag3(flags, "--url") || process.env.HEADBAND_URL || DEFAULT_URL;
3726
5333
  const result = await login(url, chatterIO(io, flags), {
3727
- profileName: stringFlag(flags, "--profile")
5334
+ profileName: stringFlag3(flags, "--profile")
3728
5335
  });
3729
5336
  if (boolFlag(flags, "--json")) printJson(io, result);
3730
5337
  }
@@ -3732,8 +5339,8 @@ async function runLogout(flags, io, confirm) {
3732
5339
  const json = boolFlag(flags, "--json");
3733
5340
  const chatter = chatterIO(io, flags);
3734
5341
  const selected = selectProfile({
3735
- url: stringFlag(flags, "--url"),
3736
- profile: stringFlag(flags, "--profile")
5342
+ url: stringFlag3(flags, "--url"),
5343
+ profile: stringFlag3(flags, "--profile")
3737
5344
  });
3738
5345
  if (!selected) {
3739
5346
  throw new AuthError("No stored login to log out of. Run: headband profiles list");
@@ -3775,7 +5382,7 @@ async function runWhoami(flags, io) {
3775
5382
  profile: auth.profileName ?? null,
3776
5383
  orgName: auth.profile?.orgName ?? null,
3777
5384
  orgId: auth.profile?.orgId ?? null,
3778
- keyPrefix: auth.profile?.keyPrefix ?? auth.apiKey.slice(0, 12),
5385
+ keyPrefix: auth.profile?.keyPrefix ?? displayPrefixOf(auth.apiKey),
3779
5386
  keyId: auth.profile?.keyId ?? null,
3780
5387
  valid: true
3781
5388
  };
@@ -3827,9 +5434,9 @@ async function runProfiles(args, flags, io) {
3827
5434
  printCommandHelp("profiles", io);
3828
5435
  throw new UsageError("Unknown profiles command");
3829
5436
  }
3830
- async function runProjects(args, flags, io, confirm) {
5437
+ async function runProjects(args, flags, io, confirm, cwd) {
3831
5438
  const action = args[0];
3832
- const client = clientFromFlags(flags);
5439
+ const client = clientFromFlags(flags, cwd);
3833
5440
  if (action === "list") {
3834
5441
  const response = await client.get(
3835
5442
  "/v1/projects"
@@ -3854,10 +5461,8 @@ async function runProjects(args, flags, io, confirm) {
3854
5461
  }
3855
5462
  if (action === "delete") {
3856
5463
  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" : ""}`);
5464
+ if (!await confirm(`Delete project ${id}?`)) return cancelled(io, flags);
5465
+ const response = await client.delete(`/v1/projects/${encode(id)}`);
3861
5466
  if (boolFlag(flags, "--json")) printJson(io, response);
3862
5467
  else io.stdout(`Deleted project ${id}.`);
3863
5468
  return;
@@ -3903,19 +5508,52 @@ function parsePermissions(raw) {
3903
5508
  }
3904
5509
  return permissions;
3905
5510
  }
3906
- async function runKeys(args, flags, io, confirm) {
5511
+ function keyRow(key) {
5512
+ const branch = key.branch;
5513
+ return { ...key, branch: branch?.name ?? "", engines: enginesColumn(key.instanceIds) };
5514
+ }
5515
+ function enginesColumn(instanceIds) {
5516
+ if (instanceIds === null) return "all";
5517
+ if (instanceIds === void 0) return "unknown";
5518
+ if (Array.isArray(instanceIds) && instanceIds.every((id) => typeof id === "string")) {
5519
+ const printable = instanceIds.filter((id) => id.trim() !== "");
5520
+ return printable.length > 0 ? printable.join(",") : "none";
5521
+ }
5522
+ return "none";
5523
+ }
5524
+ function parseInstanceList(raw) {
5525
+ const ids = [
5526
+ ...new Set(
5527
+ raw.split(",").map((x) => x.trim()).filter(Boolean)
5528
+ )
5529
+ ];
5530
+ if (ids.length === 0) {
5531
+ throw new CliError("--instances needs at least one engine id (see `headband instances list`).");
5532
+ }
5533
+ const bad = ids.find((id) => !INSTANCE_ID_RE.test(id));
5534
+ if (bad) {
5535
+ throw new CliError(
5536
+ `Not an engine id: ${bad}. Engine ids look like iabcdef012345 (see \`headband instances list\`).`
5537
+ );
5538
+ }
5539
+ return ids;
5540
+ }
5541
+ async function runKeys(args, flags, io, confirm, cwd) {
3907
5542
  const action = args[0];
3908
- const client = clientFromFlags(flags);
5543
+ const client = clientFromFlags(flags, cwd, { projectInBody: action === "create" });
3909
5544
  if (action === "list") {
3910
5545
  const response = await client.get("/v1/keys");
3911
5546
  if (boolFlag(flags, "--json")) printJson(io, response);
3912
5547
  else
3913
- printTable(io, response.data.keys, [
5548
+ printTable(io, response.data.keys.map(keyRow), [
3914
5549
  "id",
3915
5550
  "name",
3916
5551
  "type",
3917
5552
  "keyPrefix",
3918
5553
  "projectId",
5554
+ "branch",
5555
+ "engines",
5556
+ "environment",
3919
5557
  "lastUsedAt",
3920
5558
  "expiresAt"
3921
5559
  ]);
@@ -3927,20 +5565,45 @@ async function runKeys(args, flags, io, confirm) {
3927
5565
  name,
3928
5566
  type: validateKeyType(requiredFlag(flags, "--type"))
3929
5567
  };
3930
- const projectId = stringFlag(flags, "--project");
5568
+ const target = branchTargetFor(flags, cwd, { projectInBody: true });
5569
+ const projectId = target?.projectId ?? stringFlag3(flags, "--project");
3931
5570
  if (projectId) body.projectId = projectId;
3932
- const permissions = stringFlag(flags, "--permissions");
5571
+ if (target) body.branch = target.branch;
5572
+ if (stringFlag3(flags, "--instance") !== void 0) {
5573
+ throw new CliError("Unknown option --instance for keys create. Did you mean --instances?");
5574
+ }
5575
+ const instances = stringFlag3(flags, "--instances");
5576
+ if (instances !== void 0) body.instanceIds = parseInstanceList(instances);
5577
+ const permissions = stringFlag3(flags, "--permissions");
3933
5578
  if (permissions) body.permissions = parsePermissions(permissions);
3934
- const expires = stringFlag(flags, "--expires");
5579
+ const expires = stringFlag3(flags, "--expires");
3935
5580
  if (expires) body.expiresAt = parseExpires(expires);
3936
- const response = await client.post("/v1/keys", body);
5581
+ const response = await client.post("/v1/keys", body).catch((error) => {
5582
+ if (error instanceof ApiError && error.code === "branch_required") {
5583
+ throw restate(
5584
+ error,
5585
+ "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."
5586
+ );
5587
+ }
5588
+ throw error;
5589
+ });
5590
+ if (instances !== void 0 && !Array.isArray(response.data.key.instanceIds)) {
5591
+ const keyId = response.data.key.id;
5592
+ throw new CliError(
5593
+ 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>`."
5594
+ );
5595
+ }
3937
5596
  if (boolFlag(flags, "--json")) {
3938
5597
  printJson(io, response);
3939
5598
  return;
3940
5599
  }
3941
5600
  io.stdout(import_picocolors3.default.yellow("Store this API key now. It will not be shown again."));
3942
5601
  io.stdout(response.data.apiKey);
3943
- printTable(io, [response.data.key], ["id", "name", "type", "keyPrefix", "expiresAt"]);
5602
+ printTable(
5603
+ io,
5604
+ [keyRow(response.data.key)],
5605
+ ["id", "name", "type", "environment", "branch", "engines", "keyPrefix", "expiresAt"]
5606
+ );
3944
5607
  return;
3945
5608
  }
3946
5609
  if (action === "revoke") {
@@ -3954,9 +5617,9 @@ async function runKeys(args, flags, io, confirm) {
3954
5617
  printCommandHelp("keys", io);
3955
5618
  throw new UsageError("Unknown keys command");
3956
5619
  }
3957
- async function runIndexes(args, flags, io, confirm) {
5620
+ async function runIndexes(args, flags, io, confirm, cwd) {
3958
5621
  const action = args[0];
3959
- const client = clientFromFlags(flags);
5622
+ const client = clientFromFlags(flags, cwd);
3960
5623
  if (action === "list") {
3961
5624
  const response = await client.get("/v1/indexes");
3962
5625
  if (boolFlag(flags, "--json")) printJson(io, response);
@@ -3976,9 +5639,9 @@ async function runIndexes(args, flags, io, confirm) {
3976
5639
  if (action === "create") {
3977
5640
  const uid = requiredArg(args[1], "index uid");
3978
5641
  const body = { uid };
3979
- const primaryKey = stringFlag(flags, "--primary-key");
3980
- const engine = stringFlag(flags, "--engine");
3981
- const instanceId = stringFlag(flags, "--instance");
5642
+ const primaryKey = stringFlag3(flags, "--primary-key");
5643
+ const engine = stringFlag3(flags, "--engine");
5644
+ const instanceId = stringFlag3(flags, "--instance");
3982
5645
  if (primaryKey) body.primaryKey = primaryKey;
3983
5646
  if (engine) body.engine = validateEngine(engine);
3984
5647
  if (instanceId) body.instanceId = instanceId;
@@ -3997,12 +5660,20 @@ async function runIndexes(args, flags, io, confirm) {
3997
5660
  `On the instance endpoint this index is named ${response.engineIndexUid}. Use that name with the engine's own client.`
3998
5661
  );
3999
5662
  }
5663
+ if (typeof response.projectId === "string") {
5664
+ const where = `Created in project ${response.projectId}${typeof response.branch === "string" ? `, branch ${response.branch}` : ""}.`;
5665
+ const chose = branchTargetFor(flags, cwd) !== null;
5666
+ io.stdout(
5667
+ chose ? where : `${where} To create it elsewhere, pass --project <id> --branch main, or run headband branch checkout in that project's directory.`
5668
+ );
5669
+ }
4000
5670
  return;
4001
5671
  }
4002
5672
  if (action === "delete") {
4003
5673
  const uid = requiredArg(args[1], "index uid");
4004
5674
  if (!await confirm(`Delete index ${uid}?`)) return cancelled(io, flags);
4005
5675
  const response = await client.delete(`/v1/indexes/${encode(uid)}`);
5676
+ await client.delete(`/v1/schema/state?indexUid=${encode(uid)}`).catch(() => void 0);
4006
5677
  if (boolFlag(flags, "--json")) printJson(io, response);
4007
5678
  else io.stdout(`Deleted index ${uid}.`);
4008
5679
  return;
@@ -4017,7 +5688,7 @@ async function runIndexes(args, flags, io, confirm) {
4017
5688
  }
4018
5689
  if (settingsAction === "set") {
4019
5690
  const file = requiredFlag(flags, "--file");
4020
- const settings = JSON.parse((0, import_fs11.readFileSync)(file, "utf-8"));
5691
+ const settings = JSON.parse((0, import_fs12.readFileSync)(file, "utf-8"));
4021
5692
  const response = await client.patch(`/v1/indexes/${encode(uid)}/settings`, settings);
4022
5693
  printJson(io, response);
4023
5694
  return;
@@ -4029,28 +5700,55 @@ async function runIndexes(args, flags, io, confirm) {
4029
5700
  printJson(io, response);
4030
5701
  return;
4031
5702
  }
5703
+ if (action === "switch-engine") {
5704
+ const uid = requiredArg(args[1], "index uid");
5705
+ if (boolFlag(flags, "--status")) {
5706
+ await printSwitchStatus(client, uid, io, boolFlag(flags, "--json"));
5707
+ return;
5708
+ }
5709
+ await runSwitchEngine(
5710
+ client,
5711
+ {
5712
+ uid,
5713
+ engine: validateEngine(requiredFlag(flags, "--engine")),
5714
+ instanceId: stringFlag3(flags, "--instance"),
5715
+ acceptDrops: boolFlag(flags, "--accept-drops"),
5716
+ declareKey: boolFlag(flags, "--declare-key"),
5717
+ dryRun: boolFlag(flags, "--dry-run"),
5718
+ wait: boolFlag(flags, "--wait"),
5719
+ json: boolFlag(flags, "--json")
5720
+ },
5721
+ io,
5722
+ chatterIO(io, flags)
5723
+ );
5724
+ return;
5725
+ }
4032
5726
  printCommandHelp("indexes", io);
4033
5727
  throw new UsageError("Unknown indexes command");
4034
5728
  }
4035
5729
  function exportFormat(flags, out) {
4036
- const raw = stringFlag(flags, "--format") ?? (out?.toLowerCase().endsWith(".json") ? "json" : "jsonl");
5730
+ const raw = stringFlag3(flags, "--format") ?? (out?.toLowerCase().endsWith(".json") ? "json" : "jsonl");
4037
5731
  if (!EXPORT_FORMATS.has(raw)) throw new UsageError("--format must be jsonl or json");
4038
5732
  return raw;
4039
5733
  }
4040
- async function runDocsExport(flags, context) {
5734
+ async function runDocsExport(flags, context, cwd) {
4041
5735
  const { io } = context;
4042
5736
  const index = requiredFlag(flags, "--index");
4043
- const out = stringFlag(flags, "--out");
5737
+ const out = stringFlag3(flags, "--out");
4044
5738
  const format = exportFormat(flags, out);
4045
- const client = clientFromFlags(flags);
5739
+ const client = clientFromFlags(flags, cwd);
4046
5740
  const batchSize = parseIntFlag(flags, "--batch") ?? await defaultExportBatchSize(client, index);
4047
5741
  if (!out) {
4048
5742
  const writeLine = context.writeLine ?? ((line) => io.stdout(line));
4049
- await exportDocuments(client, { index, format, batchSize, write: writeLine });
5743
+ try {
5744
+ await exportDocuments(client, { index, format, batchSize, write: writeLine });
5745
+ } catch (error) {
5746
+ throw exportFailure(error instanceof ExportInterruptedError ? error.cause : error, index);
5747
+ }
4050
5748
  return;
4051
5749
  }
4052
5750
  const partial = `${out}.partial`;
4053
- const fd = (0, import_fs10.openSync)(partial, "w", 384);
5751
+ const fd = (0, import_fs11.openSync)(partial, "w", 384);
4054
5752
  let exported;
4055
5753
  try {
4056
5754
  exported = await exportDocuments(client, {
@@ -4058,32 +5756,40 @@ async function runDocsExport(flags, context) {
4058
5756
  format,
4059
5757
  batchSize,
4060
5758
  write: (line) => {
4061
- (0, import_fs10.writeSync)(fd, `${line}
5759
+ (0, import_fs11.writeSync)(fd, `${line}
4062
5760
  `);
4063
5761
  },
4064
- onPage: (count, total) => io.stderr(`Exported ${count}${total === null ? "" : `/${total}`} documents`)
5762
+ onPage: (count, total) => io.stderr(`Exported ${count}${total === null ? "" : `/${total}`} documents`),
5763
+ finishOnError: true
4065
5764
  });
4066
- (0, import_fs10.closeSync)(fd);
5765
+ (0, import_fs11.closeSync)(fd);
4067
5766
  } catch (error) {
4068
- (0, import_fs10.closeSync)(fd);
4069
- (0, import_fs10.rmSync)(partial, { force: true });
4070
- throw error;
5767
+ (0, import_fs11.closeSync)(fd);
5768
+ const kept = error instanceof ExportInterruptedError ? error.exported : 0;
5769
+ if (kept > 0) {
5770
+ io.stderr(
5771
+ `Kept the ${kept} ${kept === 1 ? "document" : "documents"} exported before the error in ${partial}. ${out} was not written.`
5772
+ );
5773
+ } else {
5774
+ (0, import_fs11.rmSync)(partial, { force: true });
5775
+ }
5776
+ throw exportFailure(error instanceof ExportInterruptedError ? error.cause : error, index);
4071
5777
  }
4072
- (0, import_fs10.renameSync)(partial, out);
5778
+ (0, import_fs11.renameSync)(partial, out);
4073
5779
  if (boolFlag(flags, "--json")) printJson(io, { index, out, format, exported });
4074
5780
  else io.stdout(`Exported ${exported} documents to ${out}.`);
4075
5781
  }
4076
- async function runDocs(args, flags, context) {
5782
+ async function runDocs(args, flags, context, cwd) {
4077
5783
  const { io } = context;
4078
5784
  const action = args[0];
4079
5785
  if (action === "export") {
4080
- await runDocsExport(flags, context);
5786
+ await runDocsExport(flags, context, cwd);
4081
5787
  return;
4082
5788
  }
4083
5789
  if (action === "push") {
4084
5790
  const file = requiredArg(args[1], "documents file");
4085
5791
  const index = requiredFlag(flags, "--index");
4086
- const client = clientFromFlags(flags);
5792
+ const client = clientFromFlags(flags, cwd);
4087
5793
  const documents = parseDocumentsFile(file);
4088
5794
  const count = await pushDocuments(client, index, documents, chatterIO(io, flags));
4089
5795
  if (boolFlag(flags, "--json")) printJson(io, { index, pushed: count });
@@ -4091,30 +5797,38 @@ async function runDocs(args, flags, context) {
4091
5797
  return;
4092
5798
  }
4093
5799
  if (action === "delete") {
4094
- const docId = requiredArg(args[1], "document id");
5800
+ const ids = args.slice(1);
5801
+ requiredArg(ids[0], "document id");
4095
5802
  const index = requiredFlag(flags, "--index");
4096
- const client = clientFromFlags(flags);
4097
- const response = await client.delete(`/v1/documents/${encode(docId)}?index=${encode(index)}`);
5803
+ const client = clientFromFlags(flags, cwd);
5804
+ const response = await client.delete(`/v1/documents?index=${encode(index)}`, { ids });
4098
5805
  if (boolFlag(flags, "--json")) printJson(io, response);
4099
- else io.stdout(`Deleted document ${docId}.`);
5806
+ else
5807
+ io.stdout(
5808
+ ids.length === 1 ? `Deleted document ${ids[0]}.` : `Deleted ${ids.length} documents.`
5809
+ );
4100
5810
  return;
4101
5811
  }
4102
5812
  printCommandHelp("docs", io);
4103
5813
  throw new UsageError("Unknown docs command");
4104
5814
  }
4105
- async function runSearch(args, flags, io) {
5815
+ async function runSearch(args, flags, io, cwd) {
4106
5816
  const index = requiredArg(args[0], "search index");
4107
5817
  const query = args.slice(1).join(" ");
4108
- if (!query) throw new UsageError("Missing search query");
4109
- const client = clientFromFlags(flags);
5818
+ const client = clientFromFlags(flags, cwd);
4110
5819
  const body = {
4111
5820
  index,
4112
5821
  q: query
4113
5822
  };
4114
5823
  const limit = parseLimit(flags);
4115
- const filter = stringFlag(flags, "--filter");
5824
+ const filter = stringFlag3(flags, "--filter");
5825
+ const mode = stringFlag3(flags, "--mode");
5826
+ if (mode !== void 0 && !SEARCH_MODES.has(mode)) {
5827
+ throw new UsageError("--mode must be instant or retrieval");
5828
+ }
4116
5829
  if (limit) body.limit = limit;
4117
5830
  if (filter) body.filter = filter;
5831
+ if (mode) body.mode = mode;
4118
5832
  const response = await client.post("/v1/search", body);
4119
5833
  if (boolFlag(flags, "--json")) {
4120
5834
  printJson(io, response);
@@ -4124,11 +5838,26 @@ async function runSearch(args, flags, io) {
4124
5838
  io.stdout(`${total} hits in ${response.processingTimeMs ?? 0}ms`);
4125
5839
  printTable(io, (response.hits || []).map(summarizeHit));
4126
5840
  }
4127
- async function runTasks(args, flags, io) {
5841
+ function tasksIndexHint(error) {
5842
+ if (error instanceof ApiError && error.status === 400 && (error.code === "indexUids_required" || error.code === "index_required")) {
5843
+ throw new UsageError(
5844
+ "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.",
5845
+ "index_required"
5846
+ );
5847
+ }
5848
+ throw error;
5849
+ }
5850
+ async function runTasks(args, flags, io, cwd) {
4128
5851
  const action = args[0];
4129
- const client = clientFromFlags(flags);
5852
+ const client = clientFromFlags(flags, cwd);
5853
+ const index = stringFlag3(flags, "--index");
4130
5854
  if (action === "list") {
4131
- const response = await client.get("/v1/tasks");
5855
+ const params = new URLSearchParams();
5856
+ if (index) params.set("indexUids", index);
5857
+ const limit = parseLimit(flags);
5858
+ if (limit) params.set("limit", String(limit));
5859
+ const query = params.toString();
5860
+ const response = await client.get(`/v1/tasks${query ? `?${query}` : ""}`).catch(tasksIndexHint);
4132
5861
  if (boolFlag(flags, "--json")) printJson(io, response);
4133
5862
  else
4134
5863
  printTable(io, response.results ?? [], [
@@ -4143,28 +5872,43 @@ async function runTasks(args, flags, io) {
4143
5872
  }
4144
5873
  if (action === "get") {
4145
5874
  const id = requiredArg(args[1], "task id");
4146
- const response = await client.get(`/v1/tasks/${encode(id)}`);
5875
+ const response = await client.get(`/v1/tasks/${encode(id)}${index ? `?index=${encode(index)}` : ""}`).catch(tasksIndexHint);
4147
5876
  printJson(io, response);
4148
5877
  return;
4149
5878
  }
4150
5879
  printCommandHelp("tasks", io);
4151
5880
  throw new UsageError("Unknown tasks command");
4152
5881
  }
4153
- async function runSchema(args, flags, io, confirm) {
5882
+ async function runSchema(args, flags, io, confirm, cwd) {
4154
5883
  const action = args[0];
4155
- if (!["diff", "generate", "apply", "status"].includes(action)) {
5884
+ if (!["diff", "generate", "apply", "status", "forget"].includes(action)) {
4156
5885
  printCommandHelp("schema", io);
4157
5886
  throw new UsageError("Unknown schema command");
4158
5887
  }
4159
5888
  const json = boolFlag(flags, "--json");
4160
5889
  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());
5890
+ const dir = stringFlag3(flags, "--dir") || DEFAULT_MIGRATIONS_DIR;
5891
+ if (action === "forget") {
5892
+ const index = requiredArg(args[1], "index name");
5893
+ const client2 = syncClientFromFlags(flags, cwd);
5894
+ if (!await confirm(`Forget the recorded schema state of "${index}"?`)) {
5895
+ return cancelled(io, flags);
5896
+ }
5897
+ await runSchemaForget({ client: client2, index, io: chatter });
5898
+ if (json) printJson(io, { index, forgotten: true });
5899
+ return;
5900
+ }
5901
+ const { config, adapter } = await loadHeadbandConfig(cwd);
5902
+ const clientOptions = { projectInBody: true, defaultUrl: config.url };
5903
+ const client = syncClientFromFlags(flags, cwd, clientOptions);
5904
+ const apiClient = clientFromFlags(flags, cwd, clientOptions);
5905
+ const projectId = stringFlag3(flags, "--project") ?? config.projectId;
5906
+ const indexUids = () => listIndexUids(apiClient);
4165
5907
  const allowDestructive = boolFlag(flags, "--allow-destructive");
4166
5908
  if (action === "diff") {
4167
- const ops = await planSchemaDiff({ config, client, allowDestructive });
5909
+ const applied = await client.getSchemaState();
5910
+ const ops = planMigration(config, applied, { allowDestructive });
5911
+ warnOrphans(chatter, applied, await indexUids());
4168
5912
  if (json) printJson(io, { inSync: ops.length === 0, ops: opRows(ops) });
4169
5913
  else printOpsTable(io, ops);
4170
5914
  if (ops.length > 0) process.exitCode = 1;
@@ -4175,7 +5919,7 @@ async function runSchema(args, flags, io, confirm) {
4175
5919
  config,
4176
5920
  client,
4177
5921
  dir,
4178
- slug: stringFlag(flags, "--slug"),
5922
+ slug: stringFlag3(flags, "--slug"),
4179
5923
  io: chatter,
4180
5924
  allowDestructive
4181
5925
  });
@@ -4184,9 +5928,7 @@ async function runSchema(args, flags, io, confirm) {
4184
5928
  }
4185
5929
  if (action === "apply") {
4186
5930
  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
- );
5931
+ throw missingAdapterError("schema apply", "it reads your tables to backfill new indexes");
4190
5932
  }
4191
5933
  if (!await confirm("Apply pending schema migrations?")) {
4192
5934
  return cancelled(io, flags);
@@ -4197,20 +5939,46 @@ async function runSchema(args, flags, io, confirm) {
4197
5939
  client,
4198
5940
  dir,
4199
5941
  allowDestructive,
4200
- io: chatter
5942
+ io: chatter,
5943
+ projectId
4201
5944
  });
4202
- if (json) printJson(io, result);
4203
- else
5945
+ if (json) printJson(io, { ...result, projectId: projectId ?? null });
5946
+ else {
4204
5947
  io.stdout(
4205
5948
  `Applied ${result.appliedOps} op(s) across ${result.appliedFiles} migration file(s).`
4206
5949
  );
5950
+ if (result.appliedOps > 0 && !projectId && authFromFlags(flags, { url: config.url }).source === "profile") {
5951
+ io.stdout(
5952
+ "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."
5953
+ );
5954
+ }
5955
+ }
4207
5956
  return;
4208
5957
  }
4209
- const status = await runSchemaStatus({ config, client, dir, io: chatter });
5958
+ const status = await runSchemaStatus({
5959
+ config,
5960
+ client,
5961
+ dir,
5962
+ io: chatter,
5963
+ indexUids: await indexUids()
5964
+ });
4210
5965
  if (json) printJson(io, status);
4211
5966
  }
5967
+ async function listIndexUids(client) {
5968
+ try {
5969
+ const response = await client.get("/v1/indexes");
5970
+ const uids = /* @__PURE__ */ new Set();
5971
+ for (const row of response.results ?? []) {
5972
+ if (row.uid) uids.add(row.uid);
5973
+ if (row.physicalUid) uids.add(row.physicalUid);
5974
+ }
5975
+ return uids;
5976
+ } catch {
5977
+ return void 0;
5978
+ }
5979
+ }
4212
5980
  function parseIntFlag(flags, name) {
4213
- const raw = stringFlag(flags, name);
5981
+ const raw = stringFlag3(flags, name);
4214
5982
  if (!raw) return void 0;
4215
5983
  const value = Number(raw);
4216
5984
  if (!Number.isInteger(value) || value <= 0) {
@@ -4218,14 +5986,14 @@ function parseIntFlag(flags, name) {
4218
5986
  }
4219
5987
  return value;
4220
5988
  }
4221
- async function runSync(args, flags, io, confirm) {
5989
+ async function runSync(args, flags, io, confirm, cwd) {
4222
5990
  const action = args[0];
4223
5991
  const json = boolFlag(flags, "--json");
4224
5992
  const chatter = chatterIO(io, flags);
4225
5993
  if (action === "source") {
4226
5994
  await runSyncSource(args.slice(1), flags, {
4227
5995
  io,
4228
- client: clientFromFlags(flags),
5996
+ client: clientFromFlags(flags, cwd),
4229
5997
  confirm
4230
5998
  });
4231
5999
  return;
@@ -4233,51 +6001,76 @@ async function runSync(args, flags, io, confirm) {
4233
6001
  if (action === "index") {
4234
6002
  await runSyncIndex(args.slice(1), flags, {
4235
6003
  io,
4236
- client: clientFromFlags(flags),
6004
+ client: clientFromFlags(flags, cwd),
4237
6005
  confirm
4238
6006
  });
4239
6007
  return;
4240
6008
  }
4241
6009
  if (action === "status") {
6010
+ if (findConfigFile(cwd)) {
6011
+ const { config, path: path2 } = await loadHeadbandConfig(cwd);
6012
+ const databaseUrl = process.env.DATABASE_URL || process.env.HEADBAND_DATABASE_URL;
6013
+ const pg = databaseUrl ? buildPgOutbox(databaseUrl, { table: config.sync?.outboxTable }) : null;
6014
+ try {
6015
+ const status = await runSelfHostedSyncStatus({
6016
+ config,
6017
+ configPath: path2,
6018
+ outbox: pg?.outbox ?? null,
6019
+ io: chatter
6020
+ });
6021
+ if (json) printJson(io, { selfHosted: status });
6022
+ } finally {
6023
+ await pg?.close();
6024
+ }
6025
+ return;
6026
+ }
4242
6027
  await runSyncStatus(flags, {
4243
6028
  io,
4244
- client: clientFromFlags(flags),
6029
+ client: clientFromFlags(flags, cwd),
4245
6030
  confirm
4246
6031
  });
4247
6032
  return;
4248
6033
  }
4249
6034
  if (action === "init") {
4250
- const cwd = process.cwd();
4251
6035
  const write = boolFlag(flags, "--write");
4252
- const ddl = runSyncInit({
6036
+ const apply = boolFlag(flags, "--apply");
6037
+ const databaseUrl = apply ? resolveDatabaseUrl(process.env) : void 0;
6038
+ const table = stringFlag3(flags, "--table") ?? (findConfigFile(cwd) ? (await loadHeadbandConfig(cwd)).config.sync?.outboxTable : void 0);
6039
+ const ddl = await runSyncInit({
4253
6040
  io: json ? { stdout: () => void 0, stderr: io.stderr } : io,
4254
- table: stringFlag(flags, "--table"),
6041
+ table,
4255
6042
  write,
4256
- cwd
6043
+ cwd,
6044
+ ...databaseUrl ? { apply: (sql) => applyOutboxDDL(databaseUrl, sql) } : {}
4257
6045
  });
4258
- if (json) printJson(io, { ddl, path: write ? (0, import_path9.join)(cwd, OUTBOX_SQL_RELATIVE_PATH) : null });
6046
+ if (json)
6047
+ printJson(io, {
6048
+ ddl,
6049
+ path: write ? (0, import_path11.join)(cwd, OUTBOX_SQL_RELATIVE_PATH) : null,
6050
+ applied: apply
6051
+ });
4259
6052
  return;
4260
6053
  }
4261
6054
  if (action === "worker") {
4262
- const scaffold = stringFlag(flags, "--scaffold");
6055
+ const scaffold = stringFlag3(flags, "--scaffold");
4263
6056
  if (scaffold) {
4264
6057
  if (scaffold !== "docker") throw new UsageError(`Unknown scaffold target: ${scaffold}`);
4265
6058
  const path2 = runSyncScaffoldDocker({
4266
- cwd: process.cwd(),
6059
+ cwd,
4267
6060
  io: chatter,
4268
- force: boolFlag(flags, "--force")
6061
+ force: boolFlag(flags, "--force"),
6062
+ cliVersion: CLI_VERSION
4269
6063
  });
4270
6064
  if (json) printJson(io, { path: path2 });
4271
6065
  return;
4272
6066
  }
4273
- const auth = authFromFlags(flags);
4274
- const client = new HeadbandClient({ url: auth.url, adminKey: auth.apiKey });
4275
- const { config, adapter } = await loadHeadbandConfig(process.cwd());
6067
+ const { config, adapter } = await loadHeadbandConfig(cwd);
4276
6068
  if (!adapter) {
4277
- throw new CliError(
4278
- "headband.config.ts must export an `adapter` (e.g. drizzleAdapter(...)) for sync worker."
4279
- );
6069
+ throw missingAdapterError("sync worker", "it reads your tables to build each document");
4280
6070
  }
6071
+ const clientOptions = { projectInBody: true, defaultUrl: config.url };
6072
+ const client = syncClientFromFlags(flags, cwd, clientOptions);
6073
+ const apiClient = clientFromFlags(flags, cwd, clientOptions);
4281
6074
  const { outbox, close } = buildPgOutbox(resolveDatabaseUrl(process.env), {
4282
6075
  table: config.sync?.outboxTable
4283
6076
  });
@@ -4288,21 +6081,22 @@ async function runSync(args, flags, io, confirm) {
4288
6081
  client,
4289
6082
  outbox,
4290
6083
  io: chatter,
6084
+ projectId: stringFlag3(flags, "--project") ?? config.projectId,
4291
6085
  // 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.
6086
+ // --budget-ms is set) when the caller explicitly passed --loop.
6087
+ // Omitting it here keeps the bare `sync worker` default unchanged.
4294
6088
  loop: boolFlag(flags, "--loop") ? true : void 0,
4295
6089
  budgetMs: parseIntFlag(flags, "--budget-ms"),
4296
6090
  batchSize: parseIntFlag(flags, "--batch-size"),
4297
6091
  reconcileEvery: parseIntFlag(flags, "--reconcile-every"),
4298
6092
  onReconcileTick: async () => {
4299
- const apiClient = clientFromFlags(flags);
4300
6093
  await runSyncReconcile({
4301
6094
  config,
4302
6095
  adapter,
4303
6096
  client,
4304
6097
  io: chatter,
4305
- getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`)
6098
+ getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`),
6099
+ listRemoteIds: (uid, primaryKey) => listRemoteIds(apiClient, uid, primaryKey)
4306
6100
  });
4307
6101
  }
4308
6102
  });
@@ -4313,28 +6107,28 @@ async function runSync(args, flags, io, confirm) {
4313
6107
  return;
4314
6108
  }
4315
6109
  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());
6110
+ const { config, adapter } = await loadHeadbandConfig(cwd);
4320
6111
  if (!adapter) {
4321
- throw new CliError(
4322
- "headband.config.ts must export an `adapter` (e.g. drizzleAdapter(...)) for sync reconcile."
4323
- );
6112
+ throw missingAdapterError("sync reconcile", "it reads your tables to compare them");
4324
6113
  }
6114
+ const clientOptions = { projectInBody: true, defaultUrl: config.url };
6115
+ const client = syncClientFromFlags(flags, cwd, clientOptions);
6116
+ const apiClient = clientFromFlags(flags, cwd, clientOptions);
4325
6117
  const rows = await runSyncReconcile({
4326
6118
  config,
4327
6119
  adapter,
4328
6120
  client,
4329
6121
  io: chatter,
4330
6122
  fix: boolFlag(flags, "--fix"),
4331
- getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`)
6123
+ getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`),
6124
+ listRemoteIds: (uid, primaryKey) => listRemoteIds(apiClient, uid, primaryKey)
4332
6125
  });
4333
- if (json) printJson(io, { indexes: rows });
6126
+ if (json) printJson(io, { indexes: rows, drift: reconcileHasDrift(rows) });
6127
+ if (reconcileHasDrift(rows)) process.exitCode = 1;
4334
6128
  return;
4335
6129
  }
4336
6130
  if (action === "retry-dead-letters") {
4337
- const { config } = await loadHeadbandConfig(process.cwd());
6131
+ const { config } = await loadHeadbandConfig(cwd);
4338
6132
  const { outbox, close } = buildPgOutbox(resolveDatabaseUrl(process.env), {
4339
6133
  table: config.sync?.outboxTable
4340
6134
  });
@@ -4349,7 +6143,7 @@ async function runSync(args, flags, io, confirm) {
4349
6143
  printCommandHelp("sync", io);
4350
6144
  throw new UsageError("Unknown sync command");
4351
6145
  }
4352
- var CLI_VERSION = true ? "0.3.0" : "dev";
6146
+ var CLI_VERSION = true ? "0.6.0" : "dev";
4353
6147
  async function run(argv, context = { io: defaultIO }) {
4354
6148
  if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v" || argv[0] === "version")) {
4355
6149
  context.io.stdout(CLI_VERSION);
@@ -4359,13 +6153,17 @@ async function run(argv, context = { io: defaultIO }) {
4359
6153
  const command = positional[0];
4360
6154
  const args = positional.slice(1);
4361
6155
  if (boolFlag(flags, "--help") || command === "help") {
6156
+ const subcommandHelp = command && args[0] ? SUBCOMMAND_HELP[`${command} ${args[0]}`] : void 0;
4362
6157
  if (command === "sync" && args[0] === "source") context.io.stdout(SOURCE_USAGE);
4363
6158
  else if (command === "sync" && args[0] === "index") context.io.stdout(INDEX_USAGE);
6159
+ else if (subcommandHelp) context.io.stdout(subcommandHelp);
6160
+ else if (command === "help" && args[0]) printCommandHelp(args[0], context.io);
4364
6161
  else if (command && command !== "help") printCommandHelp(command, context.io);
4365
6162
  else printHelp(context.io);
4366
6163
  return;
4367
6164
  }
4368
6165
  const confirm = (message) => confirmOrSkip(message, flags, context.ask);
6166
+ const cwd = context.cwd ?? process.cwd();
4369
6167
  switch (command || "init") {
4370
6168
  case "init":
4371
6169
  if (!boolFlag(flags, "--yes") && !stdinIsInteractive()) {
@@ -4389,31 +6187,39 @@ async function run(argv, context = { io: defaultIO }) {
4389
6187
  await runProfiles(args, flags, context.io);
4390
6188
  return;
4391
6189
  case "projects":
4392
- await runProjects(args, flags, context.io, confirm);
6190
+ await runProjects(args, flags, context.io, confirm, cwd);
6191
+ return;
6192
+ case "branch":
6193
+ case "branches":
6194
+ await runBranch(args, flags, { io: context.io, auth: authFromFlags(flags), confirm, cwd });
4393
6195
  return;
4394
6196
  case "keys":
4395
- await runKeys(args, flags, context.io, confirm);
6197
+ await runKeys(args, flags, context.io, confirm, cwd);
4396
6198
  return;
4397
6199
  case "indexes":
4398
- await runIndexes(args, flags, context.io, confirm);
6200
+ case "index":
6201
+ await runIndexes(args, flags, context.io, confirm, cwd);
4399
6202
  return;
4400
6203
  case "docs":
4401
- await runDocs(args, flags, context);
6204
+ await runDocs(args, flags, context, cwd);
4402
6205
  return;
4403
6206
  case "search":
4404
- await runSearch(args, flags, context.io);
6207
+ await runSearch(args, flags, context.io, cwd);
4405
6208
  return;
4406
6209
  case "tasks":
4407
- await runTasks(args, flags, context.io);
6210
+ await runTasks(args, flags, context.io, cwd);
4408
6211
  return;
4409
6212
  case "schema":
4410
- await runSchema(args, flags, context.io, confirm);
6213
+ await runSchema(args, flags, context.io, confirm, cwd);
4411
6214
  return;
4412
6215
  case "sync":
4413
- await runSync(args, flags, context.io, confirm);
6216
+ await runSync(args, flags, context.io, confirm, cwd);
4414
6217
  return;
4415
6218
  case "instances":
4416
- await runInstances(args, flags, context.io, clientFromFlags(flags), confirm);
6219
+ await runInstances(args, flags, context.io, clientFromFlags(flags, cwd), confirm);
6220
+ return;
6221
+ case "warm":
6222
+ await runWarm(args, flags, context.io, clientFromFlags(flags, cwd));
4417
6223
  return;
4418
6224
  default:
4419
6225
  printHelp(context.io);
@@ -4439,7 +6245,7 @@ async function main(argv, context = { io: defaultIO }) {
4439
6245
  function stdoutLineWriter(line) {
4440
6246
  if (process.stdout.write(`${line}
4441
6247
  `)) return;
4442
- return new Promise((resolve) => process.stdout.once("drain", () => resolve()));
6248
+ return new Promise((resolve2) => process.stdout.once("drain", () => resolve2()));
4443
6249
  }
4444
6250
  function isDirectRun() {
4445
6251
  return typeof require !== "undefined" && typeof module !== "undefined" && require.main === module;
@@ -4450,11 +6256,21 @@ if (isDirectRun()) {
4450
6256
  throw error;
4451
6257
  });
4452
6258
  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();
6259
+ const exitCode = code === EXIT_CODES.ok ? Number(process.exitCode ?? 0) : code;
6260
+ process.exitCode = exitCode;
6261
+ exitAfterFlush(exitCode);
6262
+ setTimeout(() => process.exit(exitCode), 2e3).unref();
4456
6263
  });
4457
6264
  }
6265
+ function exitAfterFlush(code) {
6266
+ let pending = 2;
6267
+ const done = () => {
6268
+ pending -= 1;
6269
+ if (pending === 0) process.exit(code);
6270
+ };
6271
+ process.stdout.write("", done);
6272
+ process.stderr.write("", done);
6273
+ }
4458
6274
  // Annotate the CommonJS export names for ESM import in node:
4459
6275
  0 && (module.exports = {
4460
6276
  CLI_VERSION,
@@ -4463,5 +6279,6 @@ if (isDirectRun()) {
4463
6279
  parseExpires,
4464
6280
  parsePermissions,
4465
6281
  run,
4466
- stdinIsInteractive
6282
+ stdinIsInteractive,
6283
+ syncClientFromFlags
4467
6284
  });