@headband/cli 0.6.0 → 0.7.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/README.md +36 -16
- package/dist/index.js +588 -91
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -35,14 +35,15 @@ __export(index_exports, {
|
|
|
35
35
|
main: () => main,
|
|
36
36
|
parseArgv: () => parseArgv,
|
|
37
37
|
parseExpires: () => parseExpires,
|
|
38
|
+
parseIndexList: () => parseIndexList,
|
|
38
39
|
parsePermissions: () => parsePermissions,
|
|
39
40
|
run: () => run,
|
|
40
41
|
stdinIsInteractive: () => stdinIsInteractive,
|
|
41
42
|
syncClientFromFlags: () => syncClientFromFlags
|
|
42
43
|
});
|
|
43
44
|
module.exports = __toCommonJS(index_exports);
|
|
44
|
-
var import_fs11 = require("fs");
|
|
45
45
|
var import_fs12 = require("fs");
|
|
46
|
+
var import_fs13 = require("fs");
|
|
46
47
|
var import_path11 = require("path");
|
|
47
48
|
var import_prompts2 = __toESM(require("prompts"));
|
|
48
49
|
var import_picocolors3 = __toESM(require("picocolors"));
|
|
@@ -527,7 +528,7 @@ function errorMessageOf(status, errorBody) {
|
|
|
527
528
|
}
|
|
528
529
|
return message;
|
|
529
530
|
}
|
|
530
|
-
var defaultSleep = (ms) => new Promise((
|
|
531
|
+
var defaultSleep = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
531
532
|
async function readJson(response) {
|
|
532
533
|
const text = await response.text();
|
|
533
534
|
if (!text) return {};
|
|
@@ -547,7 +548,7 @@ async function apiRequest(baseUrl, path2, init2 = {}) {
|
|
|
547
548
|
headers.set("authorization", `Bearer ${init2.apiKey}`);
|
|
548
549
|
}
|
|
549
550
|
const base = normalizeUrl(baseUrl);
|
|
550
|
-
const { rateLimitRetries = 0, sleep = defaultSleep, apiKey: _apiKey, ...fetchInit } = init2;
|
|
551
|
+
const { rateLimitRetries = 0, sleep: sleep2 = defaultSleep, apiKey: _apiKey, ...fetchInit } = init2;
|
|
551
552
|
void _apiKey;
|
|
552
553
|
let response;
|
|
553
554
|
try {
|
|
@@ -560,7 +561,7 @@ async function apiRequest(baseUrl, path2, init2 = {}) {
|
|
|
560
561
|
const wait = response.status === 429 ? retryAfterSeconds(response) : null;
|
|
561
562
|
if (wait === null || attempt >= rateLimitRetries) break;
|
|
562
563
|
await response.body?.cancel().catch(() => void 0);
|
|
563
|
-
await
|
|
564
|
+
await sleep2(wait * 1e3);
|
|
564
565
|
}
|
|
565
566
|
} catch (error) {
|
|
566
567
|
const cause = error?.cause;
|
|
@@ -599,7 +600,7 @@ function createApiClient(url, profile) {
|
|
|
599
600
|
}
|
|
600
601
|
|
|
601
602
|
// src/auth.ts
|
|
602
|
-
var defaultSleep2 = (ms) => new Promise((
|
|
603
|
+
var defaultSleep2 = (ms) => new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
603
604
|
function openBrowser(url) {
|
|
604
605
|
try {
|
|
605
606
|
if (process.platform === "darwin") {
|
|
@@ -614,7 +615,7 @@ function openBrowser(url) {
|
|
|
614
615
|
}
|
|
615
616
|
async function runDeviceFlow(url, io, options = {}) {
|
|
616
617
|
const baseUrl = normalizeUrl(url);
|
|
617
|
-
const
|
|
618
|
+
const sleep2 = options.sleep || defaultSleep2;
|
|
618
619
|
const now = options.now || Date.now;
|
|
619
620
|
const device = await apiRequest(baseUrl, "/api/cli/device", {
|
|
620
621
|
method: "POST",
|
|
@@ -629,7 +630,7 @@ async function runDeviceFlow(url, io, options = {}) {
|
|
|
629
630
|
let interval = device.data.interval;
|
|
630
631
|
const deadline = now() + device.data.expiresIn * 1e3;
|
|
631
632
|
while (now() < deadline) {
|
|
632
|
-
await
|
|
633
|
+
await sleep2(interval * 1e3);
|
|
633
634
|
try {
|
|
634
635
|
const token = await apiRequest(baseUrl, "/api/cli/token", {
|
|
635
636
|
method: "POST",
|
|
@@ -684,6 +685,7 @@ async function login(url, io, options = {}) {
|
|
|
684
685
|
}
|
|
685
686
|
|
|
686
687
|
// src/branch.ts
|
|
688
|
+
var import_fs4 = require("fs");
|
|
687
689
|
var import_path5 = require("path");
|
|
688
690
|
|
|
689
691
|
// src/output.ts
|
|
@@ -893,14 +895,22 @@ var PART_2 = /* @__PURE__ */ new Set(["reset", "rollback", "restore", "set-prima
|
|
|
893
895
|
var BRANCH_USAGE = [
|
|
894
896
|
"Usage:",
|
|
895
897
|
" headband branch list [--project <id>] [--json]",
|
|
896
|
-
" headband branch create <name> --empty [--parent <name>] [--expires-in <2h|7d>] [--project <id>] [--json]",
|
|
898
|
+
" headband branch create <name> [--empty] [--parent <name>] [--expires-in <2h|7d>] [--project <id>] [--json]",
|
|
899
|
+
" headband branch ensure <name> [--from <parent>] [--expires-in <7d>] [--env-out <file>] [--project <id>] [--json]",
|
|
897
900
|
" headband branch delete <name> [--project <id>] [--yes] [--json]",
|
|
898
901
|
" headband branch protect <name> [--project <id>] [--json]",
|
|
899
902
|
" headband branch unprotect <name> [--project <id>] [--json]",
|
|
900
903
|
" headband branch expire <name> (--in <2h|7d> | --at <iso> | --clear) [--project <id>] [--json]",
|
|
901
904
|
" headband branch checkout <name> [--project <id>] [--json]",
|
|
902
|
-
" headband branch
|
|
903
|
-
"
|
|
905
|
+
" headband branch reset|rollback|restore|set-primary <name> not available yet",
|
|
906
|
+
"",
|
|
907
|
+
"create without --empty forks the parent (default: main): its indexes, documents and settings,",
|
|
908
|
+
"copy-on-write. --empty makes a branch with no indexes.",
|
|
909
|
+
"",
|
|
910
|
+
"ensure is for preview deploys: it forks the branch unless it exists, pushes its expiry out",
|
|
911
|
+
"(--expires-in, default 7d), mints a search key and an admin key for it, and writes HEADBAND_*",
|
|
912
|
+
"variables to --env-out (or prints them). The name is made branch-safe first: feature/Foo_Bar",
|
|
913
|
+
"becomes feature/foo_bar. It refuses the primary branch.",
|
|
904
914
|
"",
|
|
905
915
|
"The project comes from --project, else the directory's checkout (.headband/project.json).",
|
|
906
916
|
"Durations take s, m, h, d or w: 30m, 2h, 7d.",
|
|
@@ -999,10 +1009,7 @@ async function runBranch(args, flags, deps) {
|
|
|
999
1009
|
const action = args[0];
|
|
1000
1010
|
const json = flags["--json"] === true;
|
|
1001
1011
|
if (action && PART_2.has(action)) {
|
|
1002
|
-
throw new UsageError(
|
|
1003
|
-
`headband branch ${action} is coming with bucket storage.`,
|
|
1004
|
-
"not_available"
|
|
1005
|
-
);
|
|
1012
|
+
throw new UsageError(`headband branch ${action} is not available yet.`, "not_available");
|
|
1006
1013
|
}
|
|
1007
1014
|
const client = createApiClient(auth.url, { apiKey: auth.apiKey });
|
|
1008
1015
|
const checkout = () => checkoutFor(cwd, auth.profile?.orgId);
|
|
@@ -1031,18 +1038,35 @@ async function runBranch(args, flags, deps) {
|
|
|
1031
1038
|
if (error instanceof ApiError && error.code === "branches_not_open") {
|
|
1032
1039
|
throw restate(error, "Branches other than main are not open yet.");
|
|
1033
1040
|
}
|
|
1034
|
-
|
|
1035
|
-
throw restate(
|
|
1036
|
-
error,
|
|
1037
|
-
"Forking arrives with bucket storage. Pass --empty for an empty branch."
|
|
1038
|
-
);
|
|
1039
|
-
}
|
|
1040
|
-
throw error;
|
|
1041
|
+
throw restateCreate(error);
|
|
1041
1042
|
}
|
|
1042
1043
|
if (json) printJson(io, response);
|
|
1043
1044
|
else printTable(io, [response.data], COLUMNS);
|
|
1044
1045
|
return;
|
|
1045
1046
|
}
|
|
1047
|
+
if (action === "ensure") {
|
|
1048
|
+
const result = await ensureBranch(client, {
|
|
1049
|
+
name: branchNameFrom(requiredName(args[1])),
|
|
1050
|
+
project: projectFor(flags, checkout),
|
|
1051
|
+
from: stringFlag2(flags, "--from"),
|
|
1052
|
+
expiresInMs: parseDuration(stringFlag2(flags, "--expires-in") ?? "7d"),
|
|
1053
|
+
url: auth.url
|
|
1054
|
+
});
|
|
1055
|
+
const envOut = stringFlag2(flags, "--env-out");
|
|
1056
|
+
if (envOut && envOut !== "-") writeEnvFile((0, import_path5.resolve)(cwd, envOut), result.env);
|
|
1057
|
+
if (json) {
|
|
1058
|
+
printJson(io, { branch: result.branch, created: result.created, env: result.env });
|
|
1059
|
+
} else if (envOut && envOut !== "-") {
|
|
1060
|
+
const engines = result.branch.instances?.length ?? 0;
|
|
1061
|
+
io.stdout(
|
|
1062
|
+
`${result.created ? "Forked" : "Reused"} branch ${result.branch.name} (${engines} engine${engines === 1 ? "" : "s"}), expires ${result.branch.expiresAt}.`
|
|
1063
|
+
);
|
|
1064
|
+
io.stdout(`Wrote ${Object.keys(result.env).length} HEADBAND_* variables to ${envOut}.`);
|
|
1065
|
+
} else {
|
|
1066
|
+
io.stdout(dotenv(result.env));
|
|
1067
|
+
}
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1046
1070
|
if (action === "delete") {
|
|
1047
1071
|
const name = requiredName(args[1]);
|
|
1048
1072
|
const project = projectFor(flags, checkout);
|
|
@@ -1114,9 +1138,109 @@ async function runBranch(args, flags, deps) {
|
|
|
1114
1138
|
io.stdout(BRANCH_USAGE);
|
|
1115
1139
|
throw new UsageError("Unknown branch command");
|
|
1116
1140
|
}
|
|
1141
|
+
function restateCreate(error) {
|
|
1142
|
+
if (error instanceof ApiError && error.code === "branches_not_open") {
|
|
1143
|
+
return restate(error, "Branches other than main are not open yet.");
|
|
1144
|
+
}
|
|
1145
|
+
if (error instanceof ApiError && error.code === "fork_not_available") {
|
|
1146
|
+
return restate(error, "Forking is not open yet. Pass --empty for an empty branch.");
|
|
1147
|
+
}
|
|
1148
|
+
return error;
|
|
1149
|
+
}
|
|
1150
|
+
function branchNameFrom(ref) {
|
|
1151
|
+
const name = ref.trim().toLowerCase().replace(/[^a-z0-9._/-]+/g, "-").slice(0, 63).replace(/^[^a-z0-9]+/, "").replace(/[^a-z0-9]+$/, "");
|
|
1152
|
+
if (!name) throw new UsageError(`"${ref}" has no letters or digits to make a branch name from.`);
|
|
1153
|
+
return name;
|
|
1154
|
+
}
|
|
1155
|
+
var ENSURE_WAIT_MS = 6e4;
|
|
1156
|
+
var ENSURE_POLL_MS = 2e3;
|
|
1157
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1158
|
+
var engineVar = (engine) => `HEADBAND_${engine.toUpperCase().replace(/[^A-Z0-9]/g, "_")}_URL`;
|
|
1159
|
+
async function ensureBranch(client, o, now = Date.now) {
|
|
1160
|
+
var _a;
|
|
1161
|
+
const path2 = `/v1/branches/${encodeURIComponent(o.name)}?project=${encodeURIComponent(o.project)}`;
|
|
1162
|
+
const read = async () => {
|
|
1163
|
+
try {
|
|
1164
|
+
return (await client.get(path2)).data;
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
if (error instanceof ApiError && error.status === 404 && error.code === "branch_not_found") {
|
|
1167
|
+
return null;
|
|
1168
|
+
}
|
|
1169
|
+
throw error;
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
1172
|
+
const expiresAt = () => new Date(now() + o.expiresInMs).toISOString();
|
|
1173
|
+
let branch = await read();
|
|
1174
|
+
let created = false;
|
|
1175
|
+
if (!branch) {
|
|
1176
|
+
const body = {
|
|
1177
|
+
name: o.name,
|
|
1178
|
+
projectId: o.project,
|
|
1179
|
+
expiresAt: expiresAt()
|
|
1180
|
+
};
|
|
1181
|
+
if (o.from) body.parent = o.from;
|
|
1182
|
+
try {
|
|
1183
|
+
branch = (await client.post("/v1/branches", body)).data;
|
|
1184
|
+
created = true;
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
if (!(error instanceof ApiError && error.code === "branch_exists"))
|
|
1187
|
+
throw restateCreate(error);
|
|
1188
|
+
branch = await read();
|
|
1189
|
+
if (!branch) throw error;
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
if (branch.primary) {
|
|
1193
|
+
throw new UsageError(
|
|
1194
|
+
`${branch.name} is the primary branch. ensure is for preview branches; run it only on preview builds.`
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
for (const until = now() + ENSURE_WAIT_MS; branch.state === "creating" && now() < until; ) {
|
|
1198
|
+
await sleep(ENSURE_POLL_MS);
|
|
1199
|
+
branch = await read() ?? branch;
|
|
1200
|
+
}
|
|
1201
|
+
if (branch.state !== "ready") {
|
|
1202
|
+
throw new CliError(`Branch ${branch.name} is ${branch.state}, not ready. Try again shortly.`, {
|
|
1203
|
+
exitCode: EXIT_CODES.conflict,
|
|
1204
|
+
code: "branch_not_ready"
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
if (!created && !branch.protected) {
|
|
1208
|
+
branch = {
|
|
1209
|
+
...branch,
|
|
1210
|
+
...(await client.patch(path2, { expiresAt: expiresAt() })).data
|
|
1211
|
+
};
|
|
1212
|
+
}
|
|
1213
|
+
if (!branch.instances)
|
|
1214
|
+
branch = { ...branch, ...(await client.get(path2)).data };
|
|
1215
|
+
const mint = async (type) => (await client.post("/v1/keys", {
|
|
1216
|
+
name: `preview ${branch.name} ${type}`,
|
|
1217
|
+
type,
|
|
1218
|
+
projectId: o.project,
|
|
1219
|
+
branch: branch.id
|
|
1220
|
+
})).data.apiKey;
|
|
1221
|
+
const env = {
|
|
1222
|
+
HEADBAND_URL: o.url,
|
|
1223
|
+
HEADBAND_PROJECT_ID: o.project,
|
|
1224
|
+
HEADBAND_BRANCH: branch.name,
|
|
1225
|
+
HEADBAND_BRANCH_ID: branch.id,
|
|
1226
|
+
HEADBAND_SEARCH_KEY: await mint("search"),
|
|
1227
|
+
HEADBAND_ADMIN_KEY: await mint("admin")
|
|
1228
|
+
};
|
|
1229
|
+
for (const i of branch.instances ?? []) env[_a = engineVar(i.engine)] ?? (env[_a] = i.endpoint);
|
|
1230
|
+
return { branch, created, env };
|
|
1231
|
+
}
|
|
1232
|
+
var quote = (v) => /^[A-Za-z0-9_./:@+-]*$/.test(v) ? v : JSON.stringify(v);
|
|
1233
|
+
function dotenv(env) {
|
|
1234
|
+
return Object.entries(env).map(([k, v]) => `${k}=${quote(v)}`).join("\n");
|
|
1235
|
+
}
|
|
1236
|
+
function writeEnvFile(file, env) {
|
|
1237
|
+
const kept = (0, import_fs4.existsSync)(file) ? (0, import_fs4.readFileSync)(file, "utf8").split("\n").filter((line) => line.trim() !== "" && !/^\s*(export\s+)?HEADBAND_[A-Z0-9_]*=/.test(line)) : [];
|
|
1238
|
+
(0, import_fs4.writeFileSync)(file, [...kept, dotenv(env)].join("\n") + "\n", { mode: 384 });
|
|
1239
|
+
(0, import_fs4.chmodSync)(file, 384);
|
|
1240
|
+
}
|
|
1117
1241
|
|
|
1118
1242
|
// src/config-load.ts
|
|
1119
|
-
var
|
|
1243
|
+
var import_fs5 = require("fs");
|
|
1120
1244
|
var import_path6 = require("path");
|
|
1121
1245
|
var import_url = require("url");
|
|
1122
1246
|
var import_jiti = require("jiti");
|
|
@@ -1202,7 +1326,7 @@ function validateConfig(config, ormSchema) {
|
|
|
1202
1326
|
continue;
|
|
1203
1327
|
}
|
|
1204
1328
|
const hasPath = model.relations.some(
|
|
1205
|
-
(r) => r.fromModel ===
|
|
1329
|
+
(r) => r.fromModel === index.source && r.toModel === spec.model && r.kind === "many"
|
|
1206
1330
|
);
|
|
1207
1331
|
if (!hasPath) {
|
|
1208
1332
|
errors.push({
|
|
@@ -1252,7 +1376,7 @@ var SYNC_CONFIG_SHAPE = 'a headband.config.ts whose default export is defineConf
|
|
|
1252
1376
|
function findConfigFile(cwd) {
|
|
1253
1377
|
for (const name of CONFIG_FILENAMES) {
|
|
1254
1378
|
const path2 = (0, import_path6.join)(cwd, name);
|
|
1255
|
-
if ((0,
|
|
1379
|
+
if ((0, import_fs5.existsSync)(path2)) return path2;
|
|
1256
1380
|
}
|
|
1257
1381
|
return void 0;
|
|
1258
1382
|
}
|
|
@@ -1372,10 +1496,10 @@ function missingAdapterError(command, why) {
|
|
|
1372
1496
|
|
|
1373
1497
|
// src/docs.ts
|
|
1374
1498
|
var import_path7 = require("path");
|
|
1375
|
-
var
|
|
1499
|
+
var import_fs6 = require("fs");
|
|
1376
1500
|
var DOCS_BATCH_SIZE = 1e3;
|
|
1377
1501
|
function parseDocumentsFile(path2) {
|
|
1378
|
-
const content = (0,
|
|
1502
|
+
const content = (0, import_fs6.readFileSync)(path2, "utf-8");
|
|
1379
1503
|
const ext = (0, import_path7.extname)(path2).toLowerCase();
|
|
1380
1504
|
if (ext === ".jsonl") {
|
|
1381
1505
|
return content.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).map((line) => JSON.parse(line));
|
|
@@ -1488,6 +1612,67 @@ var ExportInterruptedError = class extends Error {
|
|
|
1488
1612
|
this.name = "ExportInterruptedError";
|
|
1489
1613
|
}
|
|
1490
1614
|
};
|
|
1615
|
+
function refusedWalk(error) {
|
|
1616
|
+
if (!(error instanceof ApiError) || error.code !== "feature_not_supported") return null;
|
|
1617
|
+
if (!/primary key/i.test(error.message)) return null;
|
|
1618
|
+
const pk = /add `([^`]+)`/.exec(error.message)?.[1] ?? null;
|
|
1619
|
+
const named = ["filterableAttributes", "sortableAttributes", "displayedAttributes"].filter(
|
|
1620
|
+
(name) => error.message.includes(name)
|
|
1621
|
+
);
|
|
1622
|
+
return { pk, lists: named.length ? named : ["filterableAttributes", "sortableAttributes"] };
|
|
1623
|
+
}
|
|
1624
|
+
var DECLARE_WAIT_MS = 15 * 6e4;
|
|
1625
|
+
var DECLARE_POLL_MS = 3e3;
|
|
1626
|
+
async function declareExportKey(client, index, io, opts = {}) {
|
|
1627
|
+
const sleep2 = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
1628
|
+
const now = opts.now ?? Date.now;
|
|
1629
|
+
const probe = () => client.get(`/v1/documents?index=${encodeURIComponent(index)}&limit=1&cursor=`);
|
|
1630
|
+
let refused;
|
|
1631
|
+
try {
|
|
1632
|
+
await probe();
|
|
1633
|
+
return false;
|
|
1634
|
+
} catch (error) {
|
|
1635
|
+
refused = refusedWalk(error);
|
|
1636
|
+
if (!refused) throw error;
|
|
1637
|
+
if (!refused.pk) throw error;
|
|
1638
|
+
}
|
|
1639
|
+
const { pk, lists } = refused;
|
|
1640
|
+
const path2 = `/v1/indexes/${encodeURIComponent(index)}/settings`;
|
|
1641
|
+
const current = await client.get(path2);
|
|
1642
|
+
const patch = {};
|
|
1643
|
+
for (const name of lists) {
|
|
1644
|
+
const list = Array.isArray(current?.[name]) ? current[name] : [];
|
|
1645
|
+
if (list.includes("*") || list.includes(pk)) continue;
|
|
1646
|
+
patch[name] = [...list, pk];
|
|
1647
|
+
}
|
|
1648
|
+
io.stderr(
|
|
1649
|
+
`Declaring \`${pk}\` in ${Object.keys(patch).join(" and ") || lists.join(" and ")} on ${index}, so the export can read past 1000 documents. Visor rebuilds the index for this change, which can take minutes on a large one.`
|
|
1650
|
+
);
|
|
1651
|
+
if (Object.keys(patch).length > 0) {
|
|
1652
|
+
try {
|
|
1653
|
+
await client.patch(path2, patch);
|
|
1654
|
+
} catch (error) {
|
|
1655
|
+
if (!(error instanceof ApiError) || error.status < 500) throw error;
|
|
1656
|
+
}
|
|
1657
|
+
}
|
|
1658
|
+
const deadline = now() + (opts.waitMs ?? DECLARE_WAIT_MS);
|
|
1659
|
+
for (; ; ) {
|
|
1660
|
+
try {
|
|
1661
|
+
await probe();
|
|
1662
|
+
io.stderr(`\`${pk}\` is declared. Exporting.`);
|
|
1663
|
+
return true;
|
|
1664
|
+
} catch (error) {
|
|
1665
|
+
const waiting = refusedWalk(error) || error instanceof ApiError && error.status >= 500;
|
|
1666
|
+
if (!waiting) throw error;
|
|
1667
|
+
}
|
|
1668
|
+
if (now() >= deadline) {
|
|
1669
|
+
throw new CliError(
|
|
1670
|
+
`\`${pk}\` is still not declared on ${index} after ${Math.round((opts.waitMs ?? DECLARE_WAIT_MS) / 6e4)} minutes. Visor may still be rebuilding it: run headband docs export again later.`
|
|
1671
|
+
);
|
|
1672
|
+
}
|
|
1673
|
+
await sleep2(DECLARE_POLL_MS);
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1491
1676
|
function exportRefusalAdvice(error, index) {
|
|
1492
1677
|
if (!(error instanceof ApiError) || error.code !== "feature_not_supported") return null;
|
|
1493
1678
|
if (!/primary key/i.test(error.message)) return null;
|
|
@@ -1498,7 +1683,8 @@ function exportRefusalAdvice(error, index) {
|
|
|
1498
1683
|
);
|
|
1499
1684
|
const lists = missing.length ? missing.join(" and ") : "filterableAttributes and sortableAttributes";
|
|
1500
1685
|
return [
|
|
1501
|
-
`To export the whole index, declare ${key} on
|
|
1686
|
+
`To export the whole index, run it again with --declare-key: it declares ${key} on the index`,
|
|
1687
|
+
`once (Visor rebuilds the index for it), waits for it, and exports. Or declare it yourself:`,
|
|
1502
1688
|
` 1. headband indexes settings get ${index} > settings.json`,
|
|
1503
1689
|
` 2. Add ${key} to ${lists} in settings.json.`,
|
|
1504
1690
|
` 3. headband indexes settings set ${index} --file settings.json`,
|
|
@@ -1514,6 +1700,41 @@ function exportFailure(error, index) {
|
|
|
1514
1700
|
${advice}`);
|
|
1515
1701
|
}
|
|
1516
1702
|
|
|
1703
|
+
// src/content-drift.ts
|
|
1704
|
+
async function readRemoteDocuments(client, index, primaryKey) {
|
|
1705
|
+
const batchSize = await defaultExportBatchSize(client, index);
|
|
1706
|
+
const docs = /* @__PURE__ */ new Map();
|
|
1707
|
+
for await (const { results } of walkDocuments(client, { index, batchSize })) {
|
|
1708
|
+
for (const doc of results) {
|
|
1709
|
+
const id = doc?.[primaryKey];
|
|
1710
|
+
if (id !== void 0 && id !== null) docs.set(String(id), doc);
|
|
1711
|
+
}
|
|
1712
|
+
}
|
|
1713
|
+
return docs;
|
|
1714
|
+
}
|
|
1715
|
+
function wire(value) {
|
|
1716
|
+
return value === void 0 ? null : JSON.parse(JSON.stringify(value));
|
|
1717
|
+
}
|
|
1718
|
+
function canonical(value) {
|
|
1719
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
1720
|
+
if (value !== null && typeof value === "object") {
|
|
1721
|
+
const entries = Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`);
|
|
1722
|
+
return `{${entries.join(",")}}`;
|
|
1723
|
+
}
|
|
1724
|
+
return JSON.stringify(value ?? null);
|
|
1725
|
+
}
|
|
1726
|
+
function documentsMatch(expected, remote, primaryKey) {
|
|
1727
|
+
if (!remote) return false;
|
|
1728
|
+
for (const [key, raw] of Object.entries(expected)) {
|
|
1729
|
+
if (key === primaryKey) {
|
|
1730
|
+
if (String(raw) !== String(remote[key])) return false;
|
|
1731
|
+
continue;
|
|
1732
|
+
}
|
|
1733
|
+
if (canonical(wire(raw)) !== canonical(wire(remote[key]))) return false;
|
|
1734
|
+
}
|
|
1735
|
+
return true;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1517
1738
|
// ../../src/lib/density/catalog-constants.ts
|
|
1518
1739
|
function formatMemory(mb) {
|
|
1519
1740
|
return mb >= 1024 ? `${mb / 1024} GB` : `${mb} MB`;
|
|
@@ -2069,7 +2290,7 @@ async function runSwitchEngine(client, opts, io, chatter) {
|
|
|
2069
2290
|
);
|
|
2070
2291
|
return;
|
|
2071
2292
|
}
|
|
2072
|
-
const
|
|
2293
|
+
const sleep2 = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
2073
2294
|
let lastPhase = "";
|
|
2074
2295
|
for (; ; ) {
|
|
2075
2296
|
if (view.phase !== lastPhase) {
|
|
@@ -2077,7 +2298,7 @@ async function runSwitchEngine(client, opts, io, chatter) {
|
|
|
2077
2298
|
chatter.stdout(`${opts.uid}: ${PHASES[view.phase] ?? view.phase}`);
|
|
2078
2299
|
}
|
|
2079
2300
|
if (view.status === "succeeded" || view.status === "failed") break;
|
|
2080
|
-
await
|
|
2301
|
+
await sleep2(opts.pollMs ?? 3e3);
|
|
2081
2302
|
const status = await client.get(path2);
|
|
2082
2303
|
if (!status.switch || status.switch.switchId !== view.switchId) {
|
|
2083
2304
|
throw new CliError("The switch is no longer listed for this index.", {
|
|
@@ -2214,11 +2435,11 @@ function planMigration(config, applied, opts) {
|
|
|
2214
2435
|
}
|
|
2215
2436
|
|
|
2216
2437
|
// src/schema.ts
|
|
2217
|
-
var
|
|
2438
|
+
var import_fs8 = require("fs");
|
|
2218
2439
|
var import_path9 = require("path");
|
|
2219
2440
|
|
|
2220
2441
|
// ../sync/src/migrations.ts
|
|
2221
|
-
var
|
|
2442
|
+
var import_fs7 = require("fs");
|
|
2222
2443
|
var import_path8 = require("path");
|
|
2223
2444
|
var FILE_NAME_RE = /^(\d{4})_(.+)\.json$/;
|
|
2224
2445
|
function sanitizeSlug(slug) {
|
|
@@ -2226,7 +2447,7 @@ function sanitizeSlug(slug) {
|
|
|
2226
2447
|
return sanitized || "migration";
|
|
2227
2448
|
}
|
|
2228
2449
|
function generateMigrationFile(dir, ops, meta, now = () => /* @__PURE__ */ new Date()) {
|
|
2229
|
-
(0,
|
|
2450
|
+
(0, import_fs7.mkdirSync)(dir, { recursive: true });
|
|
2230
2451
|
const existing = readMigrations(dir);
|
|
2231
2452
|
const nextVersion = existing.length === 0 ? 1 : existing[existing.length - 1].version + 1;
|
|
2232
2453
|
const safeSlug = sanitizeSlug(meta.slug);
|
|
@@ -2239,13 +2460,13 @@ function generateMigrationFile(dir, ops, meta, now = () => /* @__PURE__ */ new D
|
|
|
2239
2460
|
...meta.targetHashes ? { targetHashes: meta.targetHashes } : {},
|
|
2240
2461
|
ops
|
|
2241
2462
|
};
|
|
2242
|
-
(0,
|
|
2463
|
+
(0, import_fs7.writeFileSync)(path2, JSON.stringify(file, null, 2));
|
|
2243
2464
|
return path2;
|
|
2244
2465
|
}
|
|
2245
2466
|
function readMigrations(dir) {
|
|
2246
2467
|
let entries;
|
|
2247
2468
|
try {
|
|
2248
|
-
entries = (0,
|
|
2469
|
+
entries = (0, import_fs7.readdirSync)(dir);
|
|
2249
2470
|
} catch {
|
|
2250
2471
|
return [];
|
|
2251
2472
|
}
|
|
@@ -2254,7 +2475,7 @@ function readMigrations(dir) {
|
|
|
2254
2475
|
const match = FILE_NAME_RE.exec(entry);
|
|
2255
2476
|
if (!match) continue;
|
|
2256
2477
|
const path2 = (0, import_path8.join)(dir, entry);
|
|
2257
|
-
const raw = (0,
|
|
2478
|
+
const raw = (0, import_fs7.readFileSync)(path2, "utf-8");
|
|
2258
2479
|
let parsed;
|
|
2259
2480
|
try {
|
|
2260
2481
|
parsed = JSON.parse(raw);
|
|
@@ -2284,7 +2505,7 @@ function pendingMigrations(files, appliedVersion) {
|
|
|
2284
2505
|
var BASE_BACKOFF_MS = 500;
|
|
2285
2506
|
var DEFAULT_MAX_RETRIES = 5;
|
|
2286
2507
|
function defaultSleep3(ms) {
|
|
2287
|
-
return new Promise((
|
|
2508
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
2288
2509
|
}
|
|
2289
2510
|
var SchemaCasError = class extends Error {
|
|
2290
2511
|
constructor(message) {
|
|
@@ -2357,6 +2578,14 @@ var HeadbandClient = class {
|
|
|
2357
2578
|
if (opts?.projectId !== void 0) body.projectId = opts.projectId;
|
|
2358
2579
|
await this.request("POST", "/v1/indexes", body);
|
|
2359
2580
|
}
|
|
2581
|
+
/** The index (GET /v1/indexes/{uid}): its engine among the rest. */
|
|
2582
|
+
async getIndex(uid) {
|
|
2583
|
+
return this.request("GET", `/v1/indexes/${encodeURIComponent(uid)}`);
|
|
2584
|
+
}
|
|
2585
|
+
/** Deletes an index (DELETE /v1/indexes/{uid}): `schema apply` removes a reindex it could not set up. */
|
|
2586
|
+
async deleteIndex(uid) {
|
|
2587
|
+
await this.request("DELETE", `/v1/indexes/${encodeURIComponent(uid)}`);
|
|
2588
|
+
}
|
|
2360
2589
|
async patchSettings(uid, settings) {
|
|
2361
2590
|
await this.request("PATCH", `/v1/indexes/${uid}/settings`, settings);
|
|
2362
2591
|
}
|
|
@@ -2443,17 +2672,17 @@ function printOpsTable(io, ops) {
|
|
|
2443
2672
|
}
|
|
2444
2673
|
function readAppliedMarker(dir) {
|
|
2445
2674
|
const path2 = (0, import_path9.join)(dir, APPLIED_MARKER_FILE);
|
|
2446
|
-
if (!(0,
|
|
2675
|
+
if (!(0, import_fs8.existsSync)(path2)) return 0;
|
|
2447
2676
|
try {
|
|
2448
|
-
const parsed = JSON.parse((0,
|
|
2677
|
+
const parsed = JSON.parse((0, import_fs8.readFileSync)(path2, "utf-8"));
|
|
2449
2678
|
return typeof parsed.version === "number" ? parsed.version : 0;
|
|
2450
2679
|
} catch {
|
|
2451
2680
|
return 0;
|
|
2452
2681
|
}
|
|
2453
2682
|
}
|
|
2454
2683
|
function writeAppliedMarker(dir, version) {
|
|
2455
|
-
(0,
|
|
2456
|
-
(0,
|
|
2684
|
+
(0, import_fs8.mkdirSync)(dir, { recursive: true });
|
|
2685
|
+
(0, import_fs8.writeFileSync)((0, import_path9.join)(dir, APPLIED_MARKER_FILE), JSON.stringify({ version }, null, 2));
|
|
2457
2686
|
}
|
|
2458
2687
|
function configHashes(config, index) {
|
|
2459
2688
|
const def = config.indexes.find((d) => d.name === index);
|
|
@@ -2512,7 +2741,36 @@ async function createIndexIdempotent(ctx, uid, opts) {
|
|
|
2512
2741
|
ctx.io.stdout(`Index "${uid}" already exists. Continuing.`);
|
|
2513
2742
|
}
|
|
2514
2743
|
}
|
|
2744
|
+
var ES_EVERY_FIELD = [
|
|
2745
|
+
"searchableAttributes",
|
|
2746
|
+
"filterableAttributes",
|
|
2747
|
+
"sortableAttributes"
|
|
2748
|
+
];
|
|
2749
|
+
async function indexEngine(ctx, uid) {
|
|
2750
|
+
try {
|
|
2751
|
+
return (await ctx.client.getIndex(uid)).engine ?? null;
|
|
2752
|
+
} catch {
|
|
2753
|
+
return null;
|
|
2754
|
+
}
|
|
2755
|
+
}
|
|
2756
|
+
function settingsForEngine(ctx, uid, engine, settings) {
|
|
2757
|
+
if (engine !== "elasticsearch") return settings;
|
|
2758
|
+
const out = { ...settings };
|
|
2759
|
+
const left = [];
|
|
2760
|
+
for (const key of ES_EVERY_FIELD) {
|
|
2761
|
+
if (out[key] === void 0) continue;
|
|
2762
|
+
delete out[key];
|
|
2763
|
+
left.push(key);
|
|
2764
|
+
}
|
|
2765
|
+
if (left.length > 0) {
|
|
2766
|
+
ctx.io.stdout(
|
|
2767
|
+
`"${uid}" is on Elasticsearch, which searches, filters and sorts every field without these lists, so they are not sent: ${left.join(", ")}.`
|
|
2768
|
+
);
|
|
2769
|
+
}
|
|
2770
|
+
return out;
|
|
2771
|
+
}
|
|
2515
2772
|
async function patchSettingsBeforeBackfill(ctx, uid, settings) {
|
|
2773
|
+
if (Object.keys(settings).length === 0) return false;
|
|
2516
2774
|
try {
|
|
2517
2775
|
await ctx.client.patchSettings(uid, settings);
|
|
2518
2776
|
return false;
|
|
@@ -2536,13 +2794,14 @@ async function patchSettingsAfterBackfill(ctx, uid, settings) {
|
|
|
2536
2794
|
function settingsRefused(uid, err) {
|
|
2537
2795
|
const message = err instanceof Error ? err.message : String(err);
|
|
2538
2796
|
const { status, code } = err ?? {};
|
|
2539
|
-
|
|
2797
|
+
const refused = new CliError(
|
|
2540
2798
|
`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
2799
|
{
|
|
2542
2800
|
...typeof status === "number" ? { exitCode: exitCodeForStatus(status) } : {},
|
|
2543
2801
|
...typeof code === "string" ? { code } : {}
|
|
2544
2802
|
}
|
|
2545
2803
|
);
|
|
2804
|
+
return Object.assign(refused, { refusal: message });
|
|
2546
2805
|
}
|
|
2547
2806
|
function postState(client, state) {
|
|
2548
2807
|
return client.postSchemaState(state);
|
|
@@ -2590,13 +2849,83 @@ async function backfill(ctx, def, targetUid, abortPoint, onPage) {
|
|
|
2590
2849
|
);
|
|
2591
2850
|
return { pushed, excluded };
|
|
2592
2851
|
}
|
|
2852
|
+
async function catchUp(ctx, def, targetUid, when) {
|
|
2853
|
+
if (!ctx.readRemoteDocuments) return;
|
|
2854
|
+
const remote = await ctx.readRemoteDocuments(targetUid, def.primaryKey);
|
|
2855
|
+
const keep = /* @__PURE__ */ new Set();
|
|
2856
|
+
let pushed = 0;
|
|
2857
|
+
let cursor = null;
|
|
2858
|
+
for (; ; ) {
|
|
2859
|
+
const page = await ctx.adapter.listIds(def.source, cursor, ctx.backfillBatchSize);
|
|
2860
|
+
if (page.ids.length === 0) break;
|
|
2861
|
+
const docs = await ctx.adapter.resolveDocs(def.name, page.ids);
|
|
2862
|
+
const toPush = [];
|
|
2863
|
+
for (const doc of docs) {
|
|
2864
|
+
const sourceRow = doc.__source ?? doc;
|
|
2865
|
+
delete doc.__source;
|
|
2866
|
+
if (def.shouldIndex && !def.shouldIndex(sourceRow)) continue;
|
|
2867
|
+
const key = String(doc[def.primaryKey]);
|
|
2868
|
+
keep.add(key);
|
|
2869
|
+
if (!documentsMatch(doc, remote.get(key), def.primaryKey)) toPush.push(doc);
|
|
2870
|
+
}
|
|
2871
|
+
if (toPush.length > 0) {
|
|
2872
|
+
const result = await ctx.client.bulkPush(targetUid, toPush, def.primaryKey);
|
|
2873
|
+
if (result?.partialFailure) {
|
|
2874
|
+
throw new CliError(
|
|
2875
|
+
`Catching up "${def.name}" ${when} stopped: ${partialFailureSummary(result.partialFailure)}. Re-run "headband schema apply" once resolved.`
|
|
2876
|
+
);
|
|
2877
|
+
}
|
|
2878
|
+
pushed += toPush.length;
|
|
2879
|
+
}
|
|
2880
|
+
cursor = page.nextCursor;
|
|
2881
|
+
if (cursor === null || page.ids.length < ctx.backfillBatchSize) break;
|
|
2882
|
+
}
|
|
2883
|
+
const extra = [...remote.keys()].filter((key) => !keep.has(key));
|
|
2884
|
+
for (let offset = 0; offset < extra.length; offset += ctx.backfillBatchSize) {
|
|
2885
|
+
const result = await ctx.client.deleteDocs(
|
|
2886
|
+
targetUid,
|
|
2887
|
+
extra.slice(offset, offset + ctx.backfillBatchSize)
|
|
2888
|
+
);
|
|
2889
|
+
if (result?.partialFailure) {
|
|
2890
|
+
throw new CliError(
|
|
2891
|
+
`Catching up "${def.name}" ${when} stopped: ${partialFailureSummary(result.partialFailure)}. Re-run "headband schema apply" once resolved.`
|
|
2892
|
+
);
|
|
2893
|
+
}
|
|
2894
|
+
}
|
|
2895
|
+
ctx.io.stdout(
|
|
2896
|
+
`Caught up "${def.name}" ${when}: ${pushed} changed or new document(s) written, ${extra.length} removed.`
|
|
2897
|
+
);
|
|
2898
|
+
}
|
|
2899
|
+
async function abandonReindex(ctx, index, physical, err) {
|
|
2900
|
+
let removed;
|
|
2901
|
+
try {
|
|
2902
|
+
await ctx.client.deleteIndex(physical);
|
|
2903
|
+
removed = `The new index "${physical}" was removed`;
|
|
2904
|
+
} catch (deleteErr) {
|
|
2905
|
+
const why = deleteErr instanceof Error ? deleteErr.message : String(deleteErr);
|
|
2906
|
+
removed = `The new index "${physical}" could not be removed (${why}): remove it with "headband indexes delete ${physical}"`;
|
|
2907
|
+
}
|
|
2908
|
+
const reason = err?.refusal ?? (err instanceof Error ? err.message : String(err));
|
|
2909
|
+
return new CliError(
|
|
2910
|
+
`The reindex of "${index}" stopped: the new index's settings were refused: ${String(reason).replace(/\.?$/, ".")} ${removed}, and "${index}" is unchanged. Change the index's settings in headband.config.ts, then re-run "headband schema apply".`,
|
|
2911
|
+
{
|
|
2912
|
+
...err instanceof CliError ? { exitCode: err.exitCode, code: err.code } : {}
|
|
2913
|
+
}
|
|
2914
|
+
);
|
|
2915
|
+
}
|
|
2593
2916
|
async function applyReindex(op, ctx, existing, expectedParentHash) {
|
|
2594
2917
|
const def = ctx.config.indexes.find((d) => d.name === op.index);
|
|
2595
2918
|
if (!def) {
|
|
2596
2919
|
throw new CliError(`Cannot reindex "${op.index}": no matching index in the loaded config.`);
|
|
2597
2920
|
}
|
|
2921
|
+
const settings = settingsForEngine(ctx, op.index, await indexEngine(ctx, op.index), op.settings);
|
|
2598
2922
|
await createIndexIdempotent(ctx, op.toPhysicalUid, { primaryKey: def.primaryKey });
|
|
2599
|
-
|
|
2923
|
+
let settingsAfter;
|
|
2924
|
+
try {
|
|
2925
|
+
settingsAfter = await patchSettingsBeforeBackfill(ctx, op.toPhysicalUid, settings);
|
|
2926
|
+
} catch (err) {
|
|
2927
|
+
throw await abandonReindex(ctx, op.index, op.toPhysicalUid, err);
|
|
2928
|
+
}
|
|
2600
2929
|
const version = (existing?.version ?? 0) + 1;
|
|
2601
2930
|
const schemaHashValue = docShapeHash(def);
|
|
2602
2931
|
const settingsHashValue = settingsHash(def);
|
|
@@ -2625,8 +2954,10 @@ async function applyReindex(op, ctx, existing, expectedParentHash) {
|
|
|
2625
2954
|
"before the alias flip",
|
|
2626
2955
|
(cursor) => writeState("migrating", cursor)
|
|
2627
2956
|
);
|
|
2628
|
-
if (settingsAfter) await patchSettingsAfterBackfill(ctx, op.toPhysicalUid,
|
|
2957
|
+
if (settingsAfter) await patchSettingsAfterBackfill(ctx, op.toPhysicalUid, settings);
|
|
2958
|
+
await catchUp(ctx, def, op.toPhysicalUid, "before the alias flip");
|
|
2629
2959
|
await ctx.client.patchAlias(op.index, op.toPhysicalUid);
|
|
2960
|
+
await catchUp(ctx, def, op.index, "after the alias flip");
|
|
2630
2961
|
await writeState("applied", null);
|
|
2631
2962
|
return {
|
|
2632
2963
|
indexUid: op.index,
|
|
@@ -2646,9 +2977,15 @@ async function applyOp(op, ctx, existing, expectedParentHash) {
|
|
|
2646
2977
|
);
|
|
2647
2978
|
}
|
|
2648
2979
|
await createIndexIdempotent(ctx, op.index, { primaryKey: def.primaryKey });
|
|
2649
|
-
const
|
|
2980
|
+
const settings = settingsForEngine(
|
|
2981
|
+
ctx,
|
|
2982
|
+
op.index,
|
|
2983
|
+
await indexEngine(ctx, op.index),
|
|
2984
|
+
op.settings
|
|
2985
|
+
);
|
|
2986
|
+
const settingsAfter = await patchSettingsBeforeBackfill(ctx, op.index, settings);
|
|
2650
2987
|
await backfill(ctx, def, op.index, "before recording the index as applied");
|
|
2651
|
-
if (settingsAfter) await patchSettingsAfterBackfill(ctx, op.index,
|
|
2988
|
+
if (settingsAfter) await patchSettingsAfterBackfill(ctx, op.index, settings);
|
|
2652
2989
|
const next = {
|
|
2653
2990
|
indexUid: op.index,
|
|
2654
2991
|
version: 1,
|
|
@@ -2664,7 +3001,13 @@ async function applyOp(op, ctx, existing, expectedParentHash) {
|
|
|
2664
3001
|
if (!existing) {
|
|
2665
3002
|
throw new CliError(`Cannot patch settings for "${op.index}": no applied schema state found.`);
|
|
2666
3003
|
}
|
|
2667
|
-
|
|
3004
|
+
const settings = settingsForEngine(
|
|
3005
|
+
ctx,
|
|
3006
|
+
op.index,
|
|
3007
|
+
await indexEngine(ctx, op.index),
|
|
3008
|
+
op.settings
|
|
3009
|
+
);
|
|
3010
|
+
if (Object.keys(settings).length > 0) await ctx.client.patchSettings(op.index, settings);
|
|
2668
3011
|
const def = ctx.config.indexes.find((d) => d.name === op.index);
|
|
2669
3012
|
const next = {
|
|
2670
3013
|
...existing,
|
|
@@ -2720,7 +3063,8 @@ async function runSchemaApply(deps) {
|
|
|
2720
3063
|
client: deps.client,
|
|
2721
3064
|
backfillBatchSize: deps.backfillBatchSize ?? DEFAULT_BACKFILL_BATCH_SIZE,
|
|
2722
3065
|
io: deps.io,
|
|
2723
|
-
projectId: deps.projectId
|
|
3066
|
+
projectId: deps.projectId,
|
|
3067
|
+
readRemoteDocuments: deps.readRemoteDocuments
|
|
2724
3068
|
};
|
|
2725
3069
|
let appliedOps = 0;
|
|
2726
3070
|
let appliedFiles = 0;
|
|
@@ -2847,7 +3191,7 @@ async function runSchemaForget(deps) {
|
|
|
2847
3191
|
}
|
|
2848
3192
|
|
|
2849
3193
|
// src/sync.ts
|
|
2850
|
-
var
|
|
3194
|
+
var import_fs9 = require("fs");
|
|
2851
3195
|
var import_path10 = require("path");
|
|
2852
3196
|
var import_crypto = require("crypto");
|
|
2853
3197
|
var import_pg = require("pg");
|
|
@@ -2917,6 +3261,12 @@ function pgOutboxDriver(exec, opts) {
|
|
|
2917
3261
|
);
|
|
2918
3262
|
return rows?.[0]?.status === "dead_letter" ? "dead_letter" : "pending";
|
|
2919
3263
|
},
|
|
3264
|
+
async release(ids, error) {
|
|
3265
|
+
await exec(
|
|
3266
|
+
`UPDATE ${table} SET last_error = $2, claimed_at = NULL WHERE id = ANY($1) AND status = 'pending'`,
|
|
3267
|
+
[ids, error]
|
|
3268
|
+
);
|
|
3269
|
+
},
|
|
2920
3270
|
async deadLetters(limit) {
|
|
2921
3271
|
const { rows } = await exec(
|
|
2922
3272
|
`SELECT * FROM ${table} WHERE status = 'dead_letter' ORDER BY id LIMIT $1`,
|
|
@@ -3132,7 +3482,7 @@ function rowOutcomes(rows, resolved, failures) {
|
|
|
3132
3482
|
return { done, retry, deadLetter };
|
|
3133
3483
|
}
|
|
3134
3484
|
function defaultSleep4(ms) {
|
|
3135
|
-
return new Promise((
|
|
3485
|
+
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
3136
3486
|
}
|
|
3137
3487
|
function errorMessage(err) {
|
|
3138
3488
|
if (!(err instanceof Error)) return String(err);
|
|
@@ -3154,6 +3504,7 @@ function describeRow(row) {
|
|
|
3154
3504
|
var emptyOutcome = () => ({
|
|
3155
3505
|
done: [],
|
|
3156
3506
|
retry: [],
|
|
3507
|
+
release: [],
|
|
3157
3508
|
deadLetter: [],
|
|
3158
3509
|
pushed: 0,
|
|
3159
3510
|
deleted: 0
|
|
@@ -3171,7 +3522,7 @@ async function runSyncWorker(opts) {
|
|
|
3171
3522
|
batchSize = DEFAULT_BATCH_SIZE,
|
|
3172
3523
|
stopSignal,
|
|
3173
3524
|
now = Date.now,
|
|
3174
|
-
sleep = defaultSleep4,
|
|
3525
|
+
sleep: sleep2 = defaultSleep4,
|
|
3175
3526
|
onHeartbeat,
|
|
3176
3527
|
log = (message) => console.warn(message)
|
|
3177
3528
|
} = opts;
|
|
@@ -3198,7 +3549,7 @@ async function runSyncWorker(opts) {
|
|
|
3198
3549
|
log(`[headband] could not record this worker run: ${errorMessage(err)}`);
|
|
3199
3550
|
}
|
|
3200
3551
|
};
|
|
3201
|
-
const backoff = (attempt) =>
|
|
3552
|
+
const backoff = (attempt) => sleep2(Math.min(failureDelayMs * 2 ** (attempt - 1), MAX_FAILURE_DELAY_MS));
|
|
3202
3553
|
const processBatch = async (batch) => {
|
|
3203
3554
|
const changes = batch.map((row) => ({
|
|
3204
3555
|
model: row.model,
|
|
@@ -3213,7 +3564,7 @@ async function runSyncWorker(opts) {
|
|
|
3213
3564
|
primaryKeyByIndex,
|
|
3214
3565
|
backoff
|
|
3215
3566
|
);
|
|
3216
|
-
return { ...rowOutcomes(batch, resolved, failures), pushed, deleted };
|
|
3567
|
+
return { ...rowOutcomes(batch, resolved, failures), release: [], pushed, deleted };
|
|
3217
3568
|
};
|
|
3218
3569
|
const processIsolated = async (batch) => {
|
|
3219
3570
|
const outcome = emptyOutcome();
|
|
@@ -3226,7 +3577,10 @@ async function runSyncWorker(opts) {
|
|
|
3226
3577
|
outcome.pushed += one.pushed;
|
|
3227
3578
|
outcome.deleted += one.deleted;
|
|
3228
3579
|
} catch (err) {
|
|
3229
|
-
outcome.retry.push([
|
|
3580
|
+
(isTransientBatchError(err) ? outcome.release : outcome.retry).push([
|
|
3581
|
+
row,
|
|
3582
|
+
errorMessage(err)
|
|
3583
|
+
]);
|
|
3230
3584
|
}
|
|
3231
3585
|
}
|
|
3232
3586
|
return outcome;
|
|
@@ -3237,7 +3591,7 @@ async function runSyncWorker(opts) {
|
|
|
3237
3591
|
const rows = await outbox.claim(batchSize);
|
|
3238
3592
|
if (rows.length === 0) {
|
|
3239
3593
|
if (!loop) break;
|
|
3240
|
-
await
|
|
3594
|
+
await sleep2(idleDelayMs);
|
|
3241
3595
|
continue;
|
|
3242
3596
|
}
|
|
3243
3597
|
let outcome;
|
|
@@ -3245,12 +3599,14 @@ async function runSyncWorker(opts) {
|
|
|
3245
3599
|
try {
|
|
3246
3600
|
outcome = await processBatch(rows);
|
|
3247
3601
|
} catch (err) {
|
|
3248
|
-
|
|
3602
|
+
const transient = isTransientBatchError(err);
|
|
3603
|
+
if (rows.length > 1 && !transient) {
|
|
3249
3604
|
outcome = await processIsolated(rows);
|
|
3250
3605
|
} else {
|
|
3251
3606
|
wholeBatchFailed = true;
|
|
3252
3607
|
const message = errorMessage(err);
|
|
3253
|
-
|
|
3608
|
+
const failed = rows.map((row) => [row, message]);
|
|
3609
|
+
outcome = transient ? { ...emptyOutcome(), release: failed } : { ...emptyOutcome(), retry: failed };
|
|
3254
3610
|
log(
|
|
3255
3611
|
`[headband] a batch of ${rows.length} outbox row(s) failed and will be retried: ${message}`
|
|
3256
3612
|
);
|
|
@@ -3272,6 +3628,17 @@ async function runSyncWorker(opts) {
|
|
|
3272
3628
|
log(`[headband] ${describeRow(row)} failed: ${message}`);
|
|
3273
3629
|
}
|
|
3274
3630
|
}
|
|
3631
|
+
if (outcome.release.length > 0) {
|
|
3632
|
+
if (outbox.release) {
|
|
3633
|
+
await outbox.release(
|
|
3634
|
+
outcome.release.map(([row]) => row.id),
|
|
3635
|
+
outcome.release[0][1]
|
|
3636
|
+
);
|
|
3637
|
+
report.retried += outcome.release.length;
|
|
3638
|
+
} else {
|
|
3639
|
+
outcome.retry.push(...outcome.release);
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3275
3642
|
for (const [row, message] of outcome.retry) {
|
|
3276
3643
|
const status = await outbox.markFailed(row.id, message);
|
|
3277
3644
|
if (status === "dead_letter") {
|
|
@@ -3288,7 +3655,7 @@ async function runSyncWorker(opts) {
|
|
|
3288
3655
|
}
|
|
3289
3656
|
}
|
|
3290
3657
|
}
|
|
3291
|
-
if (outcome.retry.length > 0) {
|
|
3658
|
+
if (outcome.retry.length > 0 || outcome.release.length > 0) {
|
|
3292
3659
|
consecutiveFailures += 1;
|
|
3293
3660
|
await backoff(consecutiveFailures);
|
|
3294
3661
|
} else {
|
|
@@ -3383,8 +3750,8 @@ async function runSyncInit(deps) {
|
|
|
3383
3750
|
deps.io.stdout(ddl);
|
|
3384
3751
|
if (deps.write) {
|
|
3385
3752
|
const path2 = (0, import_path10.join)(deps.cwd ?? process.cwd(), OUTBOX_SQL_RELATIVE_PATH);
|
|
3386
|
-
(0,
|
|
3387
|
-
(0,
|
|
3753
|
+
(0, import_fs9.mkdirSync)((0, import_path10.join)(deps.cwd ?? process.cwd(), "headband"), { recursive: true });
|
|
3754
|
+
(0, import_fs9.writeFileSync)(path2, `${ddl}
|
|
3388
3755
|
`);
|
|
3389
3756
|
deps.io.stdout(`Wrote ${path2}`);
|
|
3390
3757
|
}
|
|
@@ -3512,6 +3879,66 @@ async function runSyncWorkerCmd(deps) {
|
|
|
3512
3879
|
}
|
|
3513
3880
|
}
|
|
3514
3881
|
}
|
|
3882
|
+
async function reconcileContent(deps, def, pageSize) {
|
|
3883
|
+
const remote = await deps.readRemoteDocuments(def.name, def.primaryKey);
|
|
3884
|
+
const keep = /* @__PURE__ */ new Set();
|
|
3885
|
+
let missing = 0;
|
|
3886
|
+
let stale = 0;
|
|
3887
|
+
let cursor = null;
|
|
3888
|
+
for (; ; ) {
|
|
3889
|
+
const page = await deps.adapter.listIds(def.source, cursor, pageSize);
|
|
3890
|
+
if (page.ids.length === 0) break;
|
|
3891
|
+
const docs = await deps.adapter.resolveDocs(def.name, page.ids);
|
|
3892
|
+
const toPush = [];
|
|
3893
|
+
for (const doc of docs) {
|
|
3894
|
+
const sourceRow = doc.__source ?? doc;
|
|
3895
|
+
delete doc.__source;
|
|
3896
|
+
if (def.shouldIndex && !def.shouldIndex(sourceRow)) continue;
|
|
3897
|
+
const key = String(doc[def.primaryKey]);
|
|
3898
|
+
keep.add(key);
|
|
3899
|
+
const indexed = remote.get(key);
|
|
3900
|
+
if (!indexed) missing += 1;
|
|
3901
|
+
else if (!documentsMatch(doc, indexed, def.primaryKey)) stale += 1;
|
|
3902
|
+
else continue;
|
|
3903
|
+
toPush.push(doc);
|
|
3904
|
+
}
|
|
3905
|
+
if (deps.fix && toPush.length > 0) {
|
|
3906
|
+
const result = await deps.client.bulkPush(def.name, toPush, def.primaryKey);
|
|
3907
|
+
if (result?.partialFailure) {
|
|
3908
|
+
throw new CliError(
|
|
3909
|
+
`Repair push for "${def.name}" was incomplete: ${partialFailureSummary(result.partialFailure)}.`
|
|
3910
|
+
);
|
|
3911
|
+
}
|
|
3912
|
+
}
|
|
3913
|
+
if (page.nextCursor === null) break;
|
|
3914
|
+
cursor = page.nextCursor;
|
|
3915
|
+
}
|
|
3916
|
+
const extraIds = [...remote.keys()].filter((key) => !keep.has(key));
|
|
3917
|
+
if (deps.fix) {
|
|
3918
|
+
for (let offset = 0; offset < extraIds.length; offset += pageSize) {
|
|
3919
|
+
const result = await deps.client.deleteDocs(
|
|
3920
|
+
def.name,
|
|
3921
|
+
extraIds.slice(offset, offset + pageSize)
|
|
3922
|
+
);
|
|
3923
|
+
if (result?.partialFailure) {
|
|
3924
|
+
throw new CliError(
|
|
3925
|
+
`Removing extra documents from "${def.name}" was incomplete: ${partialFailureSummary(result.partialFailure)}.`
|
|
3926
|
+
);
|
|
3927
|
+
}
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
const drift = missing + extraIds.length + stale;
|
|
3931
|
+
return {
|
|
3932
|
+
index: def.name,
|
|
3933
|
+
sourceCount: keep.size,
|
|
3934
|
+
remoteCount: remote.size,
|
|
3935
|
+
missing,
|
|
3936
|
+
extra: extraIds.length,
|
|
3937
|
+
stale,
|
|
3938
|
+
drift,
|
|
3939
|
+
fixed: Boolean(deps.fix) && drift !== 0
|
|
3940
|
+
};
|
|
3941
|
+
}
|
|
3515
3942
|
async function indexableSourceIds(adapter, def, pageSize) {
|
|
3516
3943
|
const ids = /* @__PURE__ */ new Map();
|
|
3517
3944
|
let cursor = null;
|
|
@@ -3556,6 +3983,17 @@ async function runSyncReconcile(deps) {
|
|
|
3556
3983
|
const pageSize = deps.pageSize ?? RECONCILE_PAGE_SIZE;
|
|
3557
3984
|
const rows = [];
|
|
3558
3985
|
for (const def of deps.config.indexes) {
|
|
3986
|
+
if (deps.content && deps.readRemoteDocuments) {
|
|
3987
|
+
try {
|
|
3988
|
+
rows.push(await reconcileContent(deps, def, pageSize));
|
|
3989
|
+
continue;
|
|
3990
|
+
} catch (err) {
|
|
3991
|
+
if (err instanceof CliError) throw err;
|
|
3992
|
+
deps.io.stderr(
|
|
3993
|
+
`Could not read the documents of "${def.name}", comparing ids only: ${err instanceof Error ? err.message : String(err)}`
|
|
3994
|
+
);
|
|
3995
|
+
}
|
|
3996
|
+
}
|
|
3559
3997
|
const source = await indexableSourceIds(deps.adapter, def, pageSize);
|
|
3560
3998
|
let remoteKeys = null;
|
|
3561
3999
|
let remoteCount = -1;
|
|
@@ -3619,6 +4057,7 @@ async function runSyncReconcile(deps) {
|
|
|
3619
4057
|
}
|
|
3620
4058
|
rows.push(row);
|
|
3621
4059
|
}
|
|
4060
|
+
const contentCompared = rows.some((r) => r.stale !== void 0 && r.stale !== null);
|
|
3622
4061
|
printTable(
|
|
3623
4062
|
deps.io,
|
|
3624
4063
|
rows.map((r) => ({
|
|
@@ -3626,9 +4065,10 @@ async function runSyncReconcile(deps) {
|
|
|
3626
4065
|
remoteCount: r.remoteCount === -1 ? "unknown" : r.remoteCount,
|
|
3627
4066
|
missing: r.missing ?? "",
|
|
3628
4067
|
extra: r.extra ?? "",
|
|
4068
|
+
stale: r.stale ?? "",
|
|
3629
4069
|
fixed: r.fixed ? "yes" : ""
|
|
3630
4070
|
})),
|
|
3631
|
-
["index", "sourceCount", "remoteCount", "missing", "extra", "drift", "fixed"]
|
|
4071
|
+
contentCompared ? ["index", "sourceCount", "remoteCount", "missing", "extra", "stale", "drift", "fixed"] : ["index", "sourceCount", "remoteCount", "missing", "extra", "drift", "fixed"]
|
|
3632
4072
|
);
|
|
3633
4073
|
const unfixed = rows.filter((r) => r.drift !== 0 && !r.fixed);
|
|
3634
4074
|
if (unfixed.length > 0 && !deps.fix) {
|
|
@@ -3697,10 +4137,10 @@ async function runSelfHostedSyncStatus(deps) {
|
|
|
3697
4137
|
}
|
|
3698
4138
|
function runSyncScaffoldDocker(deps) {
|
|
3699
4139
|
const path2 = (0, import_path10.join)(deps.cwd, DOCKERFILE_NAME);
|
|
3700
|
-
if ((0,
|
|
4140
|
+
if ((0, import_fs9.existsSync)(path2) && !deps.force) {
|
|
3701
4141
|
throw new CliError(`${DOCKERFILE_NAME} already exists at ${path2}. Pass --force to overwrite.`);
|
|
3702
4142
|
}
|
|
3703
|
-
(0,
|
|
4143
|
+
(0, import_fs9.writeFileSync)(path2, dockerfileContent(deps.cliVersion));
|
|
3704
4144
|
deps.io.stdout(`Wrote ${path2}`);
|
|
3705
4145
|
deps.io.stdout("Add this service to your docker-compose.yml:");
|
|
3706
4146
|
deps.io.stdout(COMPOSE_SNIPPET);
|
|
@@ -3798,13 +4238,14 @@ var syncWorker = [
|
|
|
3798
4238
|
"package name, and prints a docker-compose service."
|
|
3799
4239
|
].join("\n");
|
|
3800
4240
|
var syncReconcile = [
|
|
3801
|
-
"Usage: headband sync reconcile [--fix] [--json]",
|
|
4241
|
+
"Usage: headband sync reconcile [--content] [--fix] [--json]",
|
|
3802
4242
|
"",
|
|
3803
4243
|
"Per index, compares the source rows that belong in it (every row shouldIndex keeps) with",
|
|
3804
4244
|
"the documents in it, and prints how many documents are missing and how many are extra",
|
|
3805
4245
|
"(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.
|
|
3807
|
-
"
|
|
4246
|
+
"the extra ones. Exits 1 when drift is left. --content also compares every document with its",
|
|
4247
|
+
"freshly resolved source row and counts the stale ones; with --fix it rewrites them. It reads",
|
|
4248
|
+
"the whole index, so it takes about as long as a backfill.",
|
|
3808
4249
|
"Needs the adapter export and DATABASE_URL."
|
|
3809
4250
|
].join("\n");
|
|
3810
4251
|
var syncRetry = [
|
|
@@ -3839,7 +4280,7 @@ var SUBCOMMAND_HELP = {
|
|
|
3839
4280
|
" headband sync init [--table <name>] [--write] [--apply] [--json]",
|
|
3840
4281
|
" headband sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--project <id>] [--json]",
|
|
3841
4282
|
" headband sync worker --scaffold docker [--force] [--json]",
|
|
3842
|
-
" headband sync reconcile [--fix] [--json]",
|
|
4283
|
+
" headband sync reconcile [--content] [--fix] [--json]",
|
|
3843
4284
|
" headband sync retry-dead-letters [--json]",
|
|
3844
4285
|
" headband sync status [--json]",
|
|
3845
4286
|
"",
|
|
@@ -3876,10 +4317,10 @@ ${CONFIG_NOTE}`,
|
|
|
3876
4317
|
};
|
|
3877
4318
|
|
|
3878
4319
|
// src/sync-indexes.ts
|
|
3879
|
-
var
|
|
4320
|
+
var import_fs11 = require("fs");
|
|
3880
4321
|
|
|
3881
4322
|
// src/sync-sources.ts
|
|
3882
|
-
var
|
|
4323
|
+
var import_fs10 = require("fs");
|
|
3883
4324
|
var import_pg2 = require("pg");
|
|
3884
4325
|
|
|
3885
4326
|
// ../../src/lib/sync/copy.ts
|
|
@@ -3946,7 +4387,7 @@ var SOURCE_USAGE = [
|
|
|
3946
4387
|
function readCaFile(deps, file) {
|
|
3947
4388
|
let text;
|
|
3948
4389
|
try {
|
|
3949
|
-
text = (deps.readFile ?? ((p) => (0,
|
|
4390
|
+
text = (deps.readFile ?? ((p) => (0, import_fs10.readFileSync)(p, "utf8")))(file);
|
|
3950
4391
|
} catch {
|
|
3951
4392
|
throw new CliError("Cannot read the --ca-file.");
|
|
3952
4393
|
}
|
|
@@ -4587,7 +5028,7 @@ var PREVIEW_MAX_IDS = 20;
|
|
|
4587
5028
|
var ATTENTION_INDEX = /* @__PURE__ */ new Set(["error", "needs_rebuild", "awaiting_setup", "degraded"]);
|
|
4588
5029
|
var ATTENTION_SOURCE = /* @__PURE__ */ new Set(["error", "awaiting_setup"]);
|
|
4589
5030
|
var path = (id, suffix = "") => `/v1/sync/indexes/${encodeURIComponent(id)}${suffix}`;
|
|
4590
|
-
function readDefinition(file, readFile = (p) => (0,
|
|
5031
|
+
function readDefinition(file, readFile = (p) => (0, import_fs11.readFileSync)(p, "utf8")) {
|
|
4591
5032
|
let text;
|
|
4592
5033
|
try {
|
|
4593
5034
|
text = readFile(file);
|
|
@@ -4984,6 +5425,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
|
4984
5425
|
"--client",
|
|
4985
5426
|
"--instance",
|
|
4986
5427
|
"--instances",
|
|
5428
|
+
"--indexes",
|
|
4987
5429
|
"--db-url",
|
|
4988
5430
|
"--owner-url",
|
|
4989
5431
|
"--tls-mode",
|
|
@@ -5005,6 +5447,8 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
|
|
|
5005
5447
|
"--branch",
|
|
5006
5448
|
"--parent",
|
|
5007
5449
|
"--expires-in",
|
|
5450
|
+
"--env-out",
|
|
5451
|
+
"--from",
|
|
5008
5452
|
"--in",
|
|
5009
5453
|
"--at",
|
|
5010
5454
|
"--level",
|
|
@@ -5020,6 +5464,7 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
|
|
|
5020
5464
|
"--write",
|
|
5021
5465
|
"--force",
|
|
5022
5466
|
"--fix",
|
|
5467
|
+
"--content",
|
|
5023
5468
|
"--loop",
|
|
5024
5469
|
"--accept-broad-role",
|
|
5025
5470
|
"--rebuild",
|
|
@@ -5176,14 +5621,15 @@ Commands:
|
|
|
5176
5621
|
projects create <name> --engine <meilisearch|typesense|elasticsearch|visor> [--json]
|
|
5177
5622
|
projects delete <id> [--yes] [--json]
|
|
5178
5623
|
branch list [--project <id>] [--json]
|
|
5179
|
-
branch create <name> --empty [--parent <name>] [--expires-in <2h|7d>] [--project <id>] [--json]
|
|
5624
|
+
branch create <name> [--empty] [--parent <name>] [--expires-in <2h|7d>] [--project <id>] [--json]
|
|
5625
|
+
branch ensure <name> [--from <parent>] [--expires-in <7d>] [--env-out <file>] [--project <id>] [--json]
|
|
5180
5626
|
branch delete <name> [--project <id>] [--yes] [--json]
|
|
5181
5627
|
branch protect|unprotect <name> [--project <id>] [--json]
|
|
5182
5628
|
branch expire <name> (--in <2h|7d> | --at <iso> | --clear) [--project <id>] [--json]
|
|
5183
5629
|
branch checkout <name> [--project <id>] [--json]
|
|
5184
|
-
branch reset|rollback|restore|set-primary (
|
|
5630
|
+
branch reset|rollback|restore|set-primary (not available yet)
|
|
5185
5631
|
keys list [--json]
|
|
5186
|
-
keys create <name> --type <admin|search> [--project <id>] [--branch <name>] [--instances <id,id>] [--permissions <a,b>] [--expires <30d|ISO date>] [--json]
|
|
5632
|
+
keys create <name> --type <admin|search> [--project <id>] [--branch <name>] [--instances <id,id>] [--indexes <uid,uid>] [--permissions <a,b>] [--expires <30d|ISO date>] [--json]
|
|
5187
5633
|
keys revoke <id> [--yes] [--json]
|
|
5188
5634
|
indexes list [--json] (index works in place of indexes)
|
|
5189
5635
|
indexes create <uid> [--primary-key <pk>] [--engine <engine>] [--instance <id>] [--json]
|
|
@@ -5201,7 +5647,7 @@ Commands:
|
|
|
5201
5647
|
instances connect <id> [--client <node|curl>] [--json]
|
|
5202
5648
|
warm <index>... | --project <id> [--branch <name>] [--level auto|stage|start] [--json]
|
|
5203
5649
|
docs push <file> --index <uid> [--json]
|
|
5204
|
-
docs export --index <uid> [--out <file>] [--format <jsonl|json>] [--batch <n>]
|
|
5650
|
+
docs export --index <uid> [--out <file>] [--format <jsonl|json>] [--batch <n>] [--declare-key]
|
|
5205
5651
|
docs delete <docId>... --index <uid> [--json]
|
|
5206
5652
|
search <index> [<query>] [--limit N] [--filter <expr>] [--mode <instant|retrieval>] [--json]
|
|
5207
5653
|
tasks list [--index <uid>] [--limit <n>] [--json]
|
|
@@ -5216,7 +5662,7 @@ Commands:
|
|
|
5216
5662
|
sync init [--table <name>] [--write] [--apply] [--json]
|
|
5217
5663
|
sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--project <id>] [--json]
|
|
5218
5664
|
sync worker --scaffold docker [--force] [--json]
|
|
5219
|
-
sync reconcile [--fix] [--json]
|
|
5665
|
+
sync reconcile [--content] [--fix] [--json]
|
|
5220
5666
|
sync retry-dead-letters [--json]
|
|
5221
5667
|
sync status [--json]
|
|
5222
5668
|
|
|
@@ -5252,13 +5698,14 @@ function printCommandHelp(command, io) {
|
|
|
5252
5698
|
keys: [
|
|
5253
5699
|
"Usage:",
|
|
5254
5700
|
" 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]",
|
|
5701
|
+
" headband keys create <name> --type <admin|search> [--project <id>] [--branch <name>] [--instances <id,id>] [--indexes <uid,uid>] [--permissions <a,b>] [--expires <30d|ISO date>] [--json]",
|
|
5256
5702
|
" headband keys revoke <id> [--yes] [--json]",
|
|
5257
5703
|
"",
|
|
5258
5704
|
"keys create mints a key for one branch: --branch (with --project, else the checkout's project),",
|
|
5259
5705
|
"else the checked-out branch, else the --project's primary branch. With none of these,",
|
|
5260
5706
|
"--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)."
|
|
5707
|
+
"--instances <id,id> limits the key to those dedicated engines (on a branch: its engines among them).",
|
|
5708
|
+
"--indexes <uid,uid> limits a search key to those indexes; any other index answers 404."
|
|
5262
5709
|
].join("\n"),
|
|
5263
5710
|
indexes: [
|
|
5264
5711
|
"Usage:",
|
|
@@ -5290,7 +5737,7 @@ function printCommandHelp(command, io) {
|
|
|
5290
5737
|
docs: [
|
|
5291
5738
|
"Usage:",
|
|
5292
5739
|
" headband docs push <file> --index <uid> [--json]",
|
|
5293
|
-
" headband docs export --index <uid> [--out <file>] [--format <jsonl|json>] [--batch <n>] [--json]",
|
|
5740
|
+
" headband docs export --index <uid> [--out <file>] [--format <jsonl|json>] [--batch <n>] [--declare-key] [--json]",
|
|
5294
5741
|
" headband docs delete <docId>... --index <uid> [--json]",
|
|
5295
5742
|
"",
|
|
5296
5743
|
"docs push sends the documents in a .json (an array) or .jsonl file, 1000 at a time.",
|
|
@@ -5300,6 +5747,9 @@ function printCommandHelp(command, io) {
|
|
|
5300
5747
|
" --out <file> to a file. The format follows the extension (.json is one array,",
|
|
5301
5748
|
" anything else JSONL) unless --format says otherwise.",
|
|
5302
5749
|
" --batch <n> documents per request. Default 1000, or 250 on Typesense.",
|
|
5750
|
+
" --declare-key a Visor index past 1000 documents whose primary key is not",
|
|
5751
|
+
" filterable and sortable (made before 2026-10-21): declare the",
|
|
5752
|
+
" key on it first (Visor rebuilds the index), wait, then export.",
|
|
5303
5753
|
" If the export stops on an error, the documents written so far are kept in",
|
|
5304
5754
|
" <file>.partial, and <file> is not written.",
|
|
5305
5755
|
"",
|
|
@@ -5510,7 +5960,12 @@ function parsePermissions(raw) {
|
|
|
5510
5960
|
}
|
|
5511
5961
|
function keyRow(key) {
|
|
5512
5962
|
const branch = key.branch;
|
|
5513
|
-
return {
|
|
5963
|
+
return {
|
|
5964
|
+
...key,
|
|
5965
|
+
branch: branch?.name ?? "",
|
|
5966
|
+
engines: enginesColumn(key.instanceIds),
|
|
5967
|
+
indexes: enginesColumn(key.indexUids)
|
|
5968
|
+
};
|
|
5514
5969
|
}
|
|
5515
5970
|
function enginesColumn(instanceIds) {
|
|
5516
5971
|
if (instanceIds === null) return "all";
|
|
@@ -5521,6 +5976,21 @@ function enginesColumn(instanceIds) {
|
|
|
5521
5976
|
}
|
|
5522
5977
|
return "none";
|
|
5523
5978
|
}
|
|
5979
|
+
function parseIndexList(raw) {
|
|
5980
|
+
const uids = [
|
|
5981
|
+
...new Set(
|
|
5982
|
+
raw.split(",").map((x) => x.trim()).filter(Boolean)
|
|
5983
|
+
)
|
|
5984
|
+
];
|
|
5985
|
+
if (uids.length === 0) throw new CliError("--indexes needs at least one index uid.");
|
|
5986
|
+
const bad = uids.find((uid) => !/^[A-Za-z0-9_-]{1,64}$/.test(uid));
|
|
5987
|
+
if (bad) {
|
|
5988
|
+
throw new CliError(
|
|
5989
|
+
`Not an index uid: ${bad}. Index uids are letters, numbers, hyphens and underscores.`
|
|
5990
|
+
);
|
|
5991
|
+
}
|
|
5992
|
+
return uids;
|
|
5993
|
+
}
|
|
5524
5994
|
function parseInstanceList(raw) {
|
|
5525
5995
|
const ids = [
|
|
5526
5996
|
...new Set(
|
|
@@ -5553,6 +6023,7 @@ async function runKeys(args, flags, io, confirm, cwd) {
|
|
|
5553
6023
|
"projectId",
|
|
5554
6024
|
"branch",
|
|
5555
6025
|
"engines",
|
|
6026
|
+
"indexes",
|
|
5556
6027
|
"environment",
|
|
5557
6028
|
"lastUsedAt",
|
|
5558
6029
|
"expiresAt"
|
|
@@ -5574,6 +6045,11 @@ async function runKeys(args, flags, io, confirm, cwd) {
|
|
|
5574
6045
|
}
|
|
5575
6046
|
const instances = stringFlag3(flags, "--instances");
|
|
5576
6047
|
if (instances !== void 0) body.instanceIds = parseInstanceList(instances);
|
|
6048
|
+
if (stringFlag3(flags, "--index") !== void 0) {
|
|
6049
|
+
throw new CliError("Unknown option --index for keys create. Did you mean --indexes?");
|
|
6050
|
+
}
|
|
6051
|
+
const indexes = stringFlag3(flags, "--indexes");
|
|
6052
|
+
if (indexes !== void 0) body.indexUids = parseIndexList(indexes);
|
|
5577
6053
|
const permissions = stringFlag3(flags, "--permissions");
|
|
5578
6054
|
if (permissions) body.permissions = parsePermissions(permissions);
|
|
5579
6055
|
const expires = stringFlag3(flags, "--expires");
|
|
@@ -5593,6 +6069,12 @@ async function runKeys(args, flags, io, confirm, cwd) {
|
|
|
5593
6069
|
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
6070
|
);
|
|
5595
6071
|
}
|
|
6072
|
+
if (indexes !== void 0 && !Array.isArray(response.data.key.indexUids)) {
|
|
6073
|
+
const keyId = response.data.key.id;
|
|
6074
|
+
throw new CliError(
|
|
6075
|
+
typeof keyId === "string" && keyId ? `This API did not limit the new key to the chosen indexes, so key ${keyId} reaches every index of its branch. Revoke it now: headband keys revoke ${keyId}` : "This API did not limit the new key to the chosen indexes, so it reaches every index of its branch. Find it with `headband keys list` and revoke it with `headband keys revoke <id>`."
|
|
6076
|
+
);
|
|
6077
|
+
}
|
|
5596
6078
|
if (boolFlag(flags, "--json")) {
|
|
5597
6079
|
printJson(io, response);
|
|
5598
6080
|
return;
|
|
@@ -5602,7 +6084,17 @@ async function runKeys(args, flags, io, confirm, cwd) {
|
|
|
5602
6084
|
printTable(
|
|
5603
6085
|
io,
|
|
5604
6086
|
[keyRow(response.data.key)],
|
|
5605
|
-
[
|
|
6087
|
+
[
|
|
6088
|
+
"id",
|
|
6089
|
+
"name",
|
|
6090
|
+
"type",
|
|
6091
|
+
"environment",
|
|
6092
|
+
"branch",
|
|
6093
|
+
"engines",
|
|
6094
|
+
"indexes",
|
|
6095
|
+
"keyPrefix",
|
|
6096
|
+
"expiresAt"
|
|
6097
|
+
]
|
|
5606
6098
|
);
|
|
5607
6099
|
return;
|
|
5608
6100
|
}
|
|
@@ -5688,7 +6180,7 @@ async function runIndexes(args, flags, io, confirm, cwd) {
|
|
|
5688
6180
|
}
|
|
5689
6181
|
if (settingsAction === "set") {
|
|
5690
6182
|
const file = requiredFlag(flags, "--file");
|
|
5691
|
-
const settings = JSON.parse((0,
|
|
6183
|
+
const settings = JSON.parse((0, import_fs13.readFileSync)(file, "utf-8"));
|
|
5692
6184
|
const response = await client.patch(`/v1/indexes/${encode(uid)}/settings`, settings);
|
|
5693
6185
|
printJson(io, response);
|
|
5694
6186
|
return;
|
|
@@ -5738,6 +6230,7 @@ async function runDocsExport(flags, context, cwd) {
|
|
|
5738
6230
|
const format = exportFormat(flags, out);
|
|
5739
6231
|
const client = clientFromFlags(flags, cwd);
|
|
5740
6232
|
const batchSize = parseIntFlag(flags, "--batch") ?? await defaultExportBatchSize(client, index);
|
|
6233
|
+
if (boolFlag(flags, "--declare-key")) await declareExportKey(client, index, io);
|
|
5741
6234
|
if (!out) {
|
|
5742
6235
|
const writeLine = context.writeLine ?? ((line) => io.stdout(line));
|
|
5743
6236
|
try {
|
|
@@ -5748,7 +6241,7 @@ async function runDocsExport(flags, context, cwd) {
|
|
|
5748
6241
|
return;
|
|
5749
6242
|
}
|
|
5750
6243
|
const partial = `${out}.partial`;
|
|
5751
|
-
const fd = (0,
|
|
6244
|
+
const fd = (0, import_fs12.openSync)(partial, "w", 384);
|
|
5752
6245
|
let exported;
|
|
5753
6246
|
try {
|
|
5754
6247
|
exported = await exportDocuments(client, {
|
|
@@ -5756,26 +6249,26 @@ async function runDocsExport(flags, context, cwd) {
|
|
|
5756
6249
|
format,
|
|
5757
6250
|
batchSize,
|
|
5758
6251
|
write: (line) => {
|
|
5759
|
-
(0,
|
|
6252
|
+
(0, import_fs12.writeSync)(fd, `${line}
|
|
5760
6253
|
`);
|
|
5761
6254
|
},
|
|
5762
6255
|
onPage: (count, total) => io.stderr(`Exported ${count}${total === null ? "" : `/${total}`} documents`),
|
|
5763
6256
|
finishOnError: true
|
|
5764
6257
|
});
|
|
5765
|
-
(0,
|
|
6258
|
+
(0, import_fs12.closeSync)(fd);
|
|
5766
6259
|
} catch (error) {
|
|
5767
|
-
(0,
|
|
6260
|
+
(0, import_fs12.closeSync)(fd);
|
|
5768
6261
|
const kept = error instanceof ExportInterruptedError ? error.exported : 0;
|
|
5769
6262
|
if (kept > 0) {
|
|
5770
6263
|
io.stderr(
|
|
5771
6264
|
`Kept the ${kept} ${kept === 1 ? "document" : "documents"} exported before the error in ${partial}. ${out} was not written.`
|
|
5772
6265
|
);
|
|
5773
6266
|
} else {
|
|
5774
|
-
(0,
|
|
6267
|
+
(0, import_fs12.rmSync)(partial, { force: true });
|
|
5775
6268
|
}
|
|
5776
6269
|
throw exportFailure(error instanceof ExportInterruptedError ? error.cause : error, index);
|
|
5777
6270
|
}
|
|
5778
|
-
(0,
|
|
6271
|
+
(0, import_fs12.renameSync)(partial, out);
|
|
5779
6272
|
if (boolFlag(flags, "--json")) printJson(io, { index, out, format, exported });
|
|
5780
6273
|
else io.stdout(`Exported ${exported} documents to ${out}.`);
|
|
5781
6274
|
}
|
|
@@ -5940,7 +6433,8 @@ async function runSchema(args, flags, io, confirm, cwd) {
|
|
|
5940
6433
|
dir,
|
|
5941
6434
|
allowDestructive,
|
|
5942
6435
|
io: chatter,
|
|
5943
|
-
projectId
|
|
6436
|
+
projectId,
|
|
6437
|
+
readRemoteDocuments: (uid, primaryKey) => readRemoteDocuments(apiClient, uid, primaryKey)
|
|
5944
6438
|
});
|
|
5945
6439
|
if (json) printJson(io, { ...result, projectId: projectId ?? null });
|
|
5946
6440
|
else {
|
|
@@ -6120,8 +6614,10 @@ async function runSync(args, flags, io, confirm, cwd) {
|
|
|
6120
6614
|
client,
|
|
6121
6615
|
io: chatter,
|
|
6122
6616
|
fix: boolFlag(flags, "--fix"),
|
|
6617
|
+
content: boolFlag(flags, "--content"),
|
|
6123
6618
|
getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`),
|
|
6124
|
-
listRemoteIds: (uid, primaryKey) => listRemoteIds(apiClient, uid, primaryKey)
|
|
6619
|
+
listRemoteIds: (uid, primaryKey) => listRemoteIds(apiClient, uid, primaryKey),
|
|
6620
|
+
readRemoteDocuments: (uid, primaryKey) => readRemoteDocuments(apiClient, uid, primaryKey)
|
|
6125
6621
|
});
|
|
6126
6622
|
if (json) printJson(io, { indexes: rows, drift: reconcileHasDrift(rows) });
|
|
6127
6623
|
if (reconcileHasDrift(rows)) process.exitCode = 1;
|
|
@@ -6143,7 +6639,7 @@ async function runSync(args, flags, io, confirm, cwd) {
|
|
|
6143
6639
|
printCommandHelp("sync", io);
|
|
6144
6640
|
throw new UsageError("Unknown sync command");
|
|
6145
6641
|
}
|
|
6146
|
-
var CLI_VERSION = true ? "0.
|
|
6642
|
+
var CLI_VERSION = true ? "0.7.0" : "dev";
|
|
6147
6643
|
async function run(argv, context = { io: defaultIO }) {
|
|
6148
6644
|
if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v" || argv[0] === "version")) {
|
|
6149
6645
|
context.io.stdout(CLI_VERSION);
|
|
@@ -6245,7 +6741,7 @@ async function main(argv, context = { io: defaultIO }) {
|
|
|
6245
6741
|
function stdoutLineWriter(line) {
|
|
6246
6742
|
if (process.stdout.write(`${line}
|
|
6247
6743
|
`)) return;
|
|
6248
|
-
return new Promise((
|
|
6744
|
+
return new Promise((resolve3) => process.stdout.once("drain", () => resolve3()));
|
|
6249
6745
|
}
|
|
6250
6746
|
function isDirectRun() {
|
|
6251
6747
|
return typeof require !== "undefined" && typeof module !== "undefined" && require.main === module;
|
|
@@ -6277,6 +6773,7 @@ function exitAfterFlush(code) {
|
|
|
6277
6773
|
main,
|
|
6278
6774
|
parseArgv,
|
|
6279
6775
|
parseExpires,
|
|
6776
|
+
parseIndexList,
|
|
6280
6777
|
parsePermissions,
|
|
6281
6778
|
run,
|
|
6282
6779
|
stdinIsInteractive,
|