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