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