@headband/cli 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +14 -6
  2. package/dist/index.js +401 -30
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -528,8 +528,11 @@ headband schema forget <index> [--yes] [--json]
528
528
  - `apply` runs the migration files the server does not have yet, in order. A
529
529
  new index is created and backfilled from every source row (rows
530
530
  `shouldIndex` rejects are left out). A schema change builds a new index
531
- beside the live one, backfills it and switches the name over. Removing an
532
- index needs `--allow-destructive`. `--json` prints
531
+ beside the live one, backfills it and switches the name over. Rows your app
532
+ writes during the backfill still go to the live index, so `apply` then
533
+ compares every document of the new index with its source row and writes the
534
+ ones that differ, once before the switch and once after it (CLI 0.6.1).
535
+ Removing an index needs `--allow-destructive`. `--json` prints
533
536
  `{appliedFiles, appliedOps, projectId}`.
534
537
  - `apply` recognises migrations the server already has from its schema state,
535
538
  so a fresh checkout or CI applies without `headband/migrations/.applied.json`.
@@ -600,7 +603,7 @@ changes to an outbox table, and the worker pushes them to Headband. The
600
603
  headband sync init [--table <name>] [--write] [--apply] [--json]
601
604
  headband sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--project <id>] [--json]
602
605
  headband sync worker --scaffold docker [--force] [--json]
603
- headband sync reconcile [--fix] [--json]
606
+ headband sync reconcile [--content] [--fix] [--json]
604
607
  headband sync retry-dead-letters [--json]
605
608
  headband sync status [--json]
606
609
  ```
@@ -617,7 +620,9 @@ headband sync status [--json]
617
620
  `--json` sends progress to stderr and prints the report when the worker
618
621
  stops. A row that fails is printed with its model, ids and error; a batch
619
622
  that fails is retried row by row, so one bad row never fails the others.
620
- Rows that keep failing move to the dead letters. Each invocation is recorded
623
+ Rows that keep failing move to the dead letters. A batch that fails because
624
+ Headband or the network is unavailable goes back to the queue without
625
+ counting an attempt, so an outage never fills the dead letters (CLI 0.6.1). Each invocation is recorded
621
626
  as one run under the connector `orm-sync` (`GET /v1/connectors/runs`); with
622
627
  the org-wide login key that needs `--project <id>` or the config's
623
628
  `projectId`. `--reconcile-every <n>` runs `reconcile` after every n-th batch.
@@ -626,8 +631,11 @@ headband sync status [--json]
626
631
  service for it. The container needs `DATABASE_URL` and `HEADBAND_API_KEY`.
627
632
  - `reconcile` compares the source rows that belong in each index (every row
628
633
  `shouldIndex` keeps) with the documents in it, and reports how many are
629
- missing and how many are extra (counts, not ids). `--fix` pushes the missing
630
- and deletes the extra. It exits
634
+ missing and how many are extra (counts, not ids). `--content` also compares
635
+ every document with its freshly resolved source row and counts the stale
636
+ ones; it reads the whole index, so it takes about as long as a backfill (CLI
637
+ 0.6.1). `--fix` pushes the missing (and with `--content` the stale) and
638
+ deletes the extra. It exits
631
639
  `1` when drift is left. `--json` prints `{indexes, drift}`. The index's ids
632
640
  are read by cursor, so a Visor index past 1000 documents is compared id by
633
641
  id too.
package/dist/index.js CHANGED
@@ -35,6 +35,7 @@ __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,
@@ -1202,7 +1203,7 @@ function validateConfig(config, ormSchema) {
1202
1203
  continue;
1203
1204
  }
1204
1205
  const hasPath = model.relations.some(
1205
- (r) => r.fromModel === spec.model && r.toModel === index.source && r.kind === "many"
1206
+ (r) => r.fromModel === index.source && r.toModel === spec.model && r.kind === "many"
1206
1207
  );
1207
1208
  if (!hasPath) {
1208
1209
  errors.push({
@@ -1488,6 +1489,67 @@ var ExportInterruptedError = class extends Error {
1488
1489
  this.name = "ExportInterruptedError";
1489
1490
  }
1490
1491
  };
1492
+ function refusedWalk(error) {
1493
+ if (!(error instanceof ApiError) || error.code !== "feature_not_supported") return null;
1494
+ if (!/primary key/i.test(error.message)) return null;
1495
+ const pk = /add `([^`]+)`/.exec(error.message)?.[1] ?? null;
1496
+ const named = ["filterableAttributes", "sortableAttributes", "displayedAttributes"].filter(
1497
+ (name) => error.message.includes(name)
1498
+ );
1499
+ return { pk, lists: named.length ? named : ["filterableAttributes", "sortableAttributes"] };
1500
+ }
1501
+ var DECLARE_WAIT_MS = 15 * 6e4;
1502
+ var DECLARE_POLL_MS = 3e3;
1503
+ async function declareExportKey(client, index, io, opts = {}) {
1504
+ const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
1505
+ const now = opts.now ?? Date.now;
1506
+ const probe = () => client.get(`/v1/documents?index=${encodeURIComponent(index)}&limit=1&cursor=`);
1507
+ let refused;
1508
+ try {
1509
+ await probe();
1510
+ return false;
1511
+ } catch (error) {
1512
+ refused = refusedWalk(error);
1513
+ if (!refused) throw error;
1514
+ if (!refused.pk) throw error;
1515
+ }
1516
+ const { pk, lists } = refused;
1517
+ const path2 = `/v1/indexes/${encodeURIComponent(index)}/settings`;
1518
+ const current = await client.get(path2);
1519
+ const patch = {};
1520
+ for (const name of lists) {
1521
+ const list = Array.isArray(current?.[name]) ? current[name] : [];
1522
+ if (list.includes("*") || list.includes(pk)) continue;
1523
+ patch[name] = [...list, pk];
1524
+ }
1525
+ io.stderr(
1526
+ `Declaring \`${pk}\` in ${Object.keys(patch).join(" and ") || lists.join(" and ")} on ${index}, so the export can read past 1000 documents. Visor rebuilds the index for this change, which can take minutes on a large one.`
1527
+ );
1528
+ if (Object.keys(patch).length > 0) {
1529
+ try {
1530
+ await client.patch(path2, patch);
1531
+ } catch (error) {
1532
+ if (!(error instanceof ApiError) || error.status < 500) throw error;
1533
+ }
1534
+ }
1535
+ const deadline = now() + (opts.waitMs ?? DECLARE_WAIT_MS);
1536
+ for (; ; ) {
1537
+ try {
1538
+ await probe();
1539
+ io.stderr(`\`${pk}\` is declared. Exporting.`);
1540
+ return true;
1541
+ } catch (error) {
1542
+ const waiting = refusedWalk(error) || error instanceof ApiError && error.status >= 500;
1543
+ if (!waiting) throw error;
1544
+ }
1545
+ if (now() >= deadline) {
1546
+ throw new CliError(
1547
+ `\`${pk}\` is still not declared on ${index} after ${Math.round((opts.waitMs ?? DECLARE_WAIT_MS) / 6e4)} minutes. Visor may still be rebuilding it: run headband docs export again later.`
1548
+ );
1549
+ }
1550
+ await sleep(DECLARE_POLL_MS);
1551
+ }
1552
+ }
1491
1553
  function exportRefusalAdvice(error, index) {
1492
1554
  if (!(error instanceof ApiError) || error.code !== "feature_not_supported") return null;
1493
1555
  if (!/primary key/i.test(error.message)) return null;
@@ -1498,7 +1560,8 @@ function exportRefusalAdvice(error, index) {
1498
1560
  );
1499
1561
  const lists = missing.length ? missing.join(" and ") : "filterableAttributes and sortableAttributes";
1500
1562
  return [
1501
- `To export the whole index, declare ${key} on it once:`,
1563
+ `To export the whole index, run it again with --declare-key: it declares ${key} on the index`,
1564
+ `once (Visor rebuilds the index for it), waits for it, and exports. Or declare it yourself:`,
1502
1565
  ` 1. headband indexes settings get ${index} > settings.json`,
1503
1566
  ` 2. Add ${key} to ${lists} in settings.json.`,
1504
1567
  ` 3. headband indexes settings set ${index} --file settings.json`,
@@ -1514,6 +1577,41 @@ function exportFailure(error, index) {
1514
1577
  ${advice}`);
1515
1578
  }
1516
1579
 
1580
+ // src/content-drift.ts
1581
+ async function readRemoteDocuments(client, index, primaryKey) {
1582
+ const batchSize = await defaultExportBatchSize(client, index);
1583
+ const docs = /* @__PURE__ */ new Map();
1584
+ for await (const { results } of walkDocuments(client, { index, batchSize })) {
1585
+ for (const doc of results) {
1586
+ const id = doc?.[primaryKey];
1587
+ if (id !== void 0 && id !== null) docs.set(String(id), doc);
1588
+ }
1589
+ }
1590
+ return docs;
1591
+ }
1592
+ function wire(value) {
1593
+ return value === void 0 ? null : JSON.parse(JSON.stringify(value));
1594
+ }
1595
+ function canonical(value) {
1596
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
1597
+ if (value !== null && typeof value === "object") {
1598
+ const entries = Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical(value[k])}`);
1599
+ return `{${entries.join(",")}}`;
1600
+ }
1601
+ return JSON.stringify(value ?? null);
1602
+ }
1603
+ function documentsMatch(expected, remote, primaryKey) {
1604
+ if (!remote) return false;
1605
+ for (const [key, raw] of Object.entries(expected)) {
1606
+ if (key === primaryKey) {
1607
+ if (String(raw) !== String(remote[key])) return false;
1608
+ continue;
1609
+ }
1610
+ if (canonical(wire(raw)) !== canonical(wire(remote[key]))) return false;
1611
+ }
1612
+ return true;
1613
+ }
1614
+
1517
1615
  // ../../src/lib/density/catalog-constants.ts
1518
1616
  function formatMemory(mb) {
1519
1617
  return mb >= 1024 ? `${mb / 1024} GB` : `${mb} MB`;
@@ -2357,6 +2455,14 @@ var HeadbandClient = class {
2357
2455
  if (opts?.projectId !== void 0) body.projectId = opts.projectId;
2358
2456
  await this.request("POST", "/v1/indexes", body);
2359
2457
  }
2458
+ /** The index (GET /v1/indexes/{uid}): its engine among the rest. */
2459
+ async getIndex(uid) {
2460
+ return this.request("GET", `/v1/indexes/${encodeURIComponent(uid)}`);
2461
+ }
2462
+ /** Deletes an index (DELETE /v1/indexes/{uid}): `schema apply` removes a reindex it could not set up. */
2463
+ async deleteIndex(uid) {
2464
+ await this.request("DELETE", `/v1/indexes/${encodeURIComponent(uid)}`);
2465
+ }
2360
2466
  async patchSettings(uid, settings) {
2361
2467
  await this.request("PATCH", `/v1/indexes/${uid}/settings`, settings);
2362
2468
  }
@@ -2512,7 +2618,36 @@ async function createIndexIdempotent(ctx, uid, opts) {
2512
2618
  ctx.io.stdout(`Index "${uid}" already exists. Continuing.`);
2513
2619
  }
2514
2620
  }
2621
+ var ES_EVERY_FIELD = [
2622
+ "searchableAttributes",
2623
+ "filterableAttributes",
2624
+ "sortableAttributes"
2625
+ ];
2626
+ async function indexEngine(ctx, uid) {
2627
+ try {
2628
+ return (await ctx.client.getIndex(uid)).engine ?? null;
2629
+ } catch {
2630
+ return null;
2631
+ }
2632
+ }
2633
+ function settingsForEngine(ctx, uid, engine, settings) {
2634
+ if (engine !== "elasticsearch") return settings;
2635
+ const out = { ...settings };
2636
+ const left = [];
2637
+ for (const key of ES_EVERY_FIELD) {
2638
+ if (out[key] === void 0) continue;
2639
+ delete out[key];
2640
+ left.push(key);
2641
+ }
2642
+ if (left.length > 0) {
2643
+ ctx.io.stdout(
2644
+ `"${uid}" is on Elasticsearch, which searches, filters and sorts every field without these lists, so they are not sent: ${left.join(", ")}.`
2645
+ );
2646
+ }
2647
+ return out;
2648
+ }
2515
2649
  async function patchSettingsBeforeBackfill(ctx, uid, settings) {
2650
+ if (Object.keys(settings).length === 0) return false;
2516
2651
  try {
2517
2652
  await ctx.client.patchSettings(uid, settings);
2518
2653
  return false;
@@ -2536,13 +2671,14 @@ async function patchSettingsAfterBackfill(ctx, uid, settings) {
2536
2671
  function settingsRefused(uid, err) {
2537
2672
  const message = err instanceof Error ? err.message : String(err);
2538
2673
  const { status, code } = err ?? {};
2539
- return new CliError(
2674
+ const refused = new CliError(
2540
2675
  `Index "${uid}" exists, but its settings were refused: ${message.replace(/\.?$/, ".")} Change the index's settings in headband.config.ts, then re-run "headband schema apply": it picks up the existing index.`,
2541
2676
  {
2542
2677
  ...typeof status === "number" ? { exitCode: exitCodeForStatus(status) } : {},
2543
2678
  ...typeof code === "string" ? { code } : {}
2544
2679
  }
2545
2680
  );
2681
+ return Object.assign(refused, { refusal: message });
2546
2682
  }
2547
2683
  function postState(client, state) {
2548
2684
  return client.postSchemaState(state);
@@ -2590,13 +2726,83 @@ async function backfill(ctx, def, targetUid, abortPoint, onPage) {
2590
2726
  );
2591
2727
  return { pushed, excluded };
2592
2728
  }
2729
+ async function catchUp(ctx, def, targetUid, when) {
2730
+ if (!ctx.readRemoteDocuments) return;
2731
+ const remote = await ctx.readRemoteDocuments(targetUid, def.primaryKey);
2732
+ const keep = /* @__PURE__ */ new Set();
2733
+ let pushed = 0;
2734
+ let cursor = null;
2735
+ for (; ; ) {
2736
+ const page = await ctx.adapter.listIds(def.source, cursor, ctx.backfillBatchSize);
2737
+ if (page.ids.length === 0) break;
2738
+ const docs = await ctx.adapter.resolveDocs(def.name, page.ids);
2739
+ const toPush = [];
2740
+ for (const doc of docs) {
2741
+ const sourceRow = doc.__source ?? doc;
2742
+ delete doc.__source;
2743
+ if (def.shouldIndex && !def.shouldIndex(sourceRow)) continue;
2744
+ const key = String(doc[def.primaryKey]);
2745
+ keep.add(key);
2746
+ if (!documentsMatch(doc, remote.get(key), def.primaryKey)) toPush.push(doc);
2747
+ }
2748
+ if (toPush.length > 0) {
2749
+ const result = await ctx.client.bulkPush(targetUid, toPush, def.primaryKey);
2750
+ if (result?.partialFailure) {
2751
+ throw new CliError(
2752
+ `Catching up "${def.name}" ${when} stopped: ${partialFailureSummary(result.partialFailure)}. Re-run "headband schema apply" once resolved.`
2753
+ );
2754
+ }
2755
+ pushed += toPush.length;
2756
+ }
2757
+ cursor = page.nextCursor;
2758
+ if (cursor === null || page.ids.length < ctx.backfillBatchSize) break;
2759
+ }
2760
+ const extra = [...remote.keys()].filter((key) => !keep.has(key));
2761
+ for (let offset = 0; offset < extra.length; offset += ctx.backfillBatchSize) {
2762
+ const result = await ctx.client.deleteDocs(
2763
+ targetUid,
2764
+ extra.slice(offset, offset + ctx.backfillBatchSize)
2765
+ );
2766
+ if (result?.partialFailure) {
2767
+ throw new CliError(
2768
+ `Catching up "${def.name}" ${when} stopped: ${partialFailureSummary(result.partialFailure)}. Re-run "headband schema apply" once resolved.`
2769
+ );
2770
+ }
2771
+ }
2772
+ ctx.io.stdout(
2773
+ `Caught up "${def.name}" ${when}: ${pushed} changed or new document(s) written, ${extra.length} removed.`
2774
+ );
2775
+ }
2776
+ async function abandonReindex(ctx, index, physical, err) {
2777
+ let removed;
2778
+ try {
2779
+ await ctx.client.deleteIndex(physical);
2780
+ removed = `The new index "${physical}" was removed`;
2781
+ } catch (deleteErr) {
2782
+ const why = deleteErr instanceof Error ? deleteErr.message : String(deleteErr);
2783
+ removed = `The new index "${physical}" could not be removed (${why}): remove it with "headband indexes delete ${physical}"`;
2784
+ }
2785
+ const reason = err?.refusal ?? (err instanceof Error ? err.message : String(err));
2786
+ return new CliError(
2787
+ `The reindex of "${index}" stopped: the new index's settings were refused: ${String(reason).replace(/\.?$/, ".")} ${removed}, and "${index}" is unchanged. Change the index's settings in headband.config.ts, then re-run "headband schema apply".`,
2788
+ {
2789
+ ...err instanceof CliError ? { exitCode: err.exitCode, code: err.code } : {}
2790
+ }
2791
+ );
2792
+ }
2593
2793
  async function applyReindex(op, ctx, existing, expectedParentHash) {
2594
2794
  const def = ctx.config.indexes.find((d) => d.name === op.index);
2595
2795
  if (!def) {
2596
2796
  throw new CliError(`Cannot reindex "${op.index}": no matching index in the loaded config.`);
2597
2797
  }
2798
+ const settings = settingsForEngine(ctx, op.index, await indexEngine(ctx, op.index), op.settings);
2598
2799
  await createIndexIdempotent(ctx, op.toPhysicalUid, { primaryKey: def.primaryKey });
2599
- const settingsAfter = await patchSettingsBeforeBackfill(ctx, op.toPhysicalUid, op.settings);
2800
+ let settingsAfter;
2801
+ try {
2802
+ settingsAfter = await patchSettingsBeforeBackfill(ctx, op.toPhysicalUid, settings);
2803
+ } catch (err) {
2804
+ throw await abandonReindex(ctx, op.index, op.toPhysicalUid, err);
2805
+ }
2600
2806
  const version = (existing?.version ?? 0) + 1;
2601
2807
  const schemaHashValue = docShapeHash(def);
2602
2808
  const settingsHashValue = settingsHash(def);
@@ -2625,8 +2831,10 @@ async function applyReindex(op, ctx, existing, expectedParentHash) {
2625
2831
  "before the alias flip",
2626
2832
  (cursor) => writeState("migrating", cursor)
2627
2833
  );
2628
- if (settingsAfter) await patchSettingsAfterBackfill(ctx, op.toPhysicalUid, op.settings);
2834
+ if (settingsAfter) await patchSettingsAfterBackfill(ctx, op.toPhysicalUid, settings);
2835
+ await catchUp(ctx, def, op.toPhysicalUid, "before the alias flip");
2629
2836
  await ctx.client.patchAlias(op.index, op.toPhysicalUid);
2837
+ await catchUp(ctx, def, op.index, "after the alias flip");
2630
2838
  await writeState("applied", null);
2631
2839
  return {
2632
2840
  indexUid: op.index,
@@ -2646,9 +2854,15 @@ async function applyOp(op, ctx, existing, expectedParentHash) {
2646
2854
  );
2647
2855
  }
2648
2856
  await createIndexIdempotent(ctx, op.index, { primaryKey: def.primaryKey });
2649
- const settingsAfter = await patchSettingsBeforeBackfill(ctx, op.index, op.settings);
2857
+ const settings = settingsForEngine(
2858
+ ctx,
2859
+ op.index,
2860
+ await indexEngine(ctx, op.index),
2861
+ op.settings
2862
+ );
2863
+ const settingsAfter = await patchSettingsBeforeBackfill(ctx, op.index, settings);
2650
2864
  await backfill(ctx, def, op.index, "before recording the index as applied");
2651
- if (settingsAfter) await patchSettingsAfterBackfill(ctx, op.index, op.settings);
2865
+ if (settingsAfter) await patchSettingsAfterBackfill(ctx, op.index, settings);
2652
2866
  const next = {
2653
2867
  indexUid: op.index,
2654
2868
  version: 1,
@@ -2664,7 +2878,13 @@ async function applyOp(op, ctx, existing, expectedParentHash) {
2664
2878
  if (!existing) {
2665
2879
  throw new CliError(`Cannot patch settings for "${op.index}": no applied schema state found.`);
2666
2880
  }
2667
- await ctx.client.patchSettings(op.index, op.settings);
2881
+ const settings = settingsForEngine(
2882
+ ctx,
2883
+ op.index,
2884
+ await indexEngine(ctx, op.index),
2885
+ op.settings
2886
+ );
2887
+ if (Object.keys(settings).length > 0) await ctx.client.patchSettings(op.index, settings);
2668
2888
  const def = ctx.config.indexes.find((d) => d.name === op.index);
2669
2889
  const next = {
2670
2890
  ...existing,
@@ -2720,7 +2940,8 @@ async function runSchemaApply(deps) {
2720
2940
  client: deps.client,
2721
2941
  backfillBatchSize: deps.backfillBatchSize ?? DEFAULT_BACKFILL_BATCH_SIZE,
2722
2942
  io: deps.io,
2723
- projectId: deps.projectId
2943
+ projectId: deps.projectId,
2944
+ readRemoteDocuments: deps.readRemoteDocuments
2724
2945
  };
2725
2946
  let appliedOps = 0;
2726
2947
  let appliedFiles = 0;
@@ -2917,6 +3138,12 @@ function pgOutboxDriver(exec, opts) {
2917
3138
  );
2918
3139
  return rows?.[0]?.status === "dead_letter" ? "dead_letter" : "pending";
2919
3140
  },
3141
+ async release(ids, error) {
3142
+ await exec(
3143
+ `UPDATE ${table} SET last_error = $2, claimed_at = NULL WHERE id = ANY($1) AND status = 'pending'`,
3144
+ [ids, error]
3145
+ );
3146
+ },
2920
3147
  async deadLetters(limit) {
2921
3148
  const { rows } = await exec(
2922
3149
  `SELECT * FROM ${table} WHERE status = 'dead_letter' ORDER BY id LIMIT $1`,
@@ -3154,6 +3381,7 @@ function describeRow(row) {
3154
3381
  var emptyOutcome = () => ({
3155
3382
  done: [],
3156
3383
  retry: [],
3384
+ release: [],
3157
3385
  deadLetter: [],
3158
3386
  pushed: 0,
3159
3387
  deleted: 0
@@ -3213,7 +3441,7 @@ async function runSyncWorker(opts) {
3213
3441
  primaryKeyByIndex,
3214
3442
  backoff
3215
3443
  );
3216
- return { ...rowOutcomes(batch, resolved, failures), pushed, deleted };
3444
+ return { ...rowOutcomes(batch, resolved, failures), release: [], pushed, deleted };
3217
3445
  };
3218
3446
  const processIsolated = async (batch) => {
3219
3447
  const outcome = emptyOutcome();
@@ -3226,7 +3454,10 @@ async function runSyncWorker(opts) {
3226
3454
  outcome.pushed += one.pushed;
3227
3455
  outcome.deleted += one.deleted;
3228
3456
  } catch (err) {
3229
- outcome.retry.push([row, errorMessage(err)]);
3457
+ (isTransientBatchError(err) ? outcome.release : outcome.retry).push([
3458
+ row,
3459
+ errorMessage(err)
3460
+ ]);
3230
3461
  }
3231
3462
  }
3232
3463
  return outcome;
@@ -3245,12 +3476,14 @@ async function runSyncWorker(opts) {
3245
3476
  try {
3246
3477
  outcome = await processBatch(rows);
3247
3478
  } catch (err) {
3248
- if (rows.length > 1 && !isTransientBatchError(err)) {
3479
+ const transient = isTransientBatchError(err);
3480
+ if (rows.length > 1 && !transient) {
3249
3481
  outcome = await processIsolated(rows);
3250
3482
  } else {
3251
3483
  wholeBatchFailed = true;
3252
3484
  const message = errorMessage(err);
3253
- outcome = { ...emptyOutcome(), retry: rows.map((row) => [row, message]) };
3485
+ const failed = rows.map((row) => [row, message]);
3486
+ outcome = transient ? { ...emptyOutcome(), release: failed } : { ...emptyOutcome(), retry: failed };
3254
3487
  log(
3255
3488
  `[headband] a batch of ${rows.length} outbox row(s) failed and will be retried: ${message}`
3256
3489
  );
@@ -3272,6 +3505,17 @@ async function runSyncWorker(opts) {
3272
3505
  log(`[headband] ${describeRow(row)} failed: ${message}`);
3273
3506
  }
3274
3507
  }
3508
+ if (outcome.release.length > 0) {
3509
+ if (outbox.release) {
3510
+ await outbox.release(
3511
+ outcome.release.map(([row]) => row.id),
3512
+ outcome.release[0][1]
3513
+ );
3514
+ report.retried += outcome.release.length;
3515
+ } else {
3516
+ outcome.retry.push(...outcome.release);
3517
+ }
3518
+ }
3275
3519
  for (const [row, message] of outcome.retry) {
3276
3520
  const status = await outbox.markFailed(row.id, message);
3277
3521
  if (status === "dead_letter") {
@@ -3288,7 +3532,7 @@ async function runSyncWorker(opts) {
3288
3532
  }
3289
3533
  }
3290
3534
  }
3291
- if (outcome.retry.length > 0) {
3535
+ if (outcome.retry.length > 0 || outcome.release.length > 0) {
3292
3536
  consecutiveFailures += 1;
3293
3537
  await backoff(consecutiveFailures);
3294
3538
  } else {
@@ -3512,6 +3756,66 @@ async function runSyncWorkerCmd(deps) {
3512
3756
  }
3513
3757
  }
3514
3758
  }
3759
+ async function reconcileContent(deps, def, pageSize) {
3760
+ const remote = await deps.readRemoteDocuments(def.name, def.primaryKey);
3761
+ const keep = /* @__PURE__ */ new Set();
3762
+ let missing = 0;
3763
+ let stale = 0;
3764
+ let cursor = null;
3765
+ for (; ; ) {
3766
+ const page = await deps.adapter.listIds(def.source, cursor, pageSize);
3767
+ if (page.ids.length === 0) break;
3768
+ const docs = await deps.adapter.resolveDocs(def.name, page.ids);
3769
+ const toPush = [];
3770
+ for (const doc of docs) {
3771
+ const sourceRow = doc.__source ?? doc;
3772
+ delete doc.__source;
3773
+ if (def.shouldIndex && !def.shouldIndex(sourceRow)) continue;
3774
+ const key = String(doc[def.primaryKey]);
3775
+ keep.add(key);
3776
+ const indexed = remote.get(key);
3777
+ if (!indexed) missing += 1;
3778
+ else if (!documentsMatch(doc, indexed, def.primaryKey)) stale += 1;
3779
+ else continue;
3780
+ toPush.push(doc);
3781
+ }
3782
+ if (deps.fix && toPush.length > 0) {
3783
+ const result = await deps.client.bulkPush(def.name, toPush, def.primaryKey);
3784
+ if (result?.partialFailure) {
3785
+ throw new CliError(
3786
+ `Repair push for "${def.name}" was incomplete: ${partialFailureSummary(result.partialFailure)}.`
3787
+ );
3788
+ }
3789
+ }
3790
+ if (page.nextCursor === null) break;
3791
+ cursor = page.nextCursor;
3792
+ }
3793
+ const extraIds = [...remote.keys()].filter((key) => !keep.has(key));
3794
+ if (deps.fix) {
3795
+ for (let offset = 0; offset < extraIds.length; offset += pageSize) {
3796
+ const result = await deps.client.deleteDocs(
3797
+ def.name,
3798
+ extraIds.slice(offset, offset + pageSize)
3799
+ );
3800
+ if (result?.partialFailure) {
3801
+ throw new CliError(
3802
+ `Removing extra documents from "${def.name}" was incomplete: ${partialFailureSummary(result.partialFailure)}.`
3803
+ );
3804
+ }
3805
+ }
3806
+ }
3807
+ const drift = missing + extraIds.length + stale;
3808
+ return {
3809
+ index: def.name,
3810
+ sourceCount: keep.size,
3811
+ remoteCount: remote.size,
3812
+ missing,
3813
+ extra: extraIds.length,
3814
+ stale,
3815
+ drift,
3816
+ fixed: Boolean(deps.fix) && drift !== 0
3817
+ };
3818
+ }
3515
3819
  async function indexableSourceIds(adapter, def, pageSize) {
3516
3820
  const ids = /* @__PURE__ */ new Map();
3517
3821
  let cursor = null;
@@ -3556,6 +3860,17 @@ async function runSyncReconcile(deps) {
3556
3860
  const pageSize = deps.pageSize ?? RECONCILE_PAGE_SIZE;
3557
3861
  const rows = [];
3558
3862
  for (const def of deps.config.indexes) {
3863
+ if (deps.content && deps.readRemoteDocuments) {
3864
+ try {
3865
+ rows.push(await reconcileContent(deps, def, pageSize));
3866
+ continue;
3867
+ } catch (err) {
3868
+ if (err instanceof CliError) throw err;
3869
+ deps.io.stderr(
3870
+ `Could not read the documents of "${def.name}", comparing ids only: ${err instanceof Error ? err.message : String(err)}`
3871
+ );
3872
+ }
3873
+ }
3559
3874
  const source = await indexableSourceIds(deps.adapter, def, pageSize);
3560
3875
  let remoteKeys = null;
3561
3876
  let remoteCount = -1;
@@ -3619,6 +3934,7 @@ async function runSyncReconcile(deps) {
3619
3934
  }
3620
3935
  rows.push(row);
3621
3936
  }
3937
+ const contentCompared = rows.some((r) => r.stale !== void 0 && r.stale !== null);
3622
3938
  printTable(
3623
3939
  deps.io,
3624
3940
  rows.map((r) => ({
@@ -3626,9 +3942,10 @@ async function runSyncReconcile(deps) {
3626
3942
  remoteCount: r.remoteCount === -1 ? "unknown" : r.remoteCount,
3627
3943
  missing: r.missing ?? "",
3628
3944
  extra: r.extra ?? "",
3945
+ stale: r.stale ?? "",
3629
3946
  fixed: r.fixed ? "yes" : ""
3630
3947
  })),
3631
- ["index", "sourceCount", "remoteCount", "missing", "extra", "drift", "fixed"]
3948
+ contentCompared ? ["index", "sourceCount", "remoteCount", "missing", "extra", "stale", "drift", "fixed"] : ["index", "sourceCount", "remoteCount", "missing", "extra", "drift", "fixed"]
3632
3949
  );
3633
3950
  const unfixed = rows.filter((r) => r.drift !== 0 && !r.fixed);
3634
3951
  if (unfixed.length > 0 && !deps.fix) {
@@ -3798,13 +4115,14 @@ var syncWorker = [
3798
4115
  "package name, and prints a docker-compose service."
3799
4116
  ].join("\n");
3800
4117
  var syncReconcile = [
3801
- "Usage: headband sync reconcile [--fix] [--json]",
4118
+ "Usage: headband sync reconcile [--content] [--fix] [--json]",
3802
4119
  "",
3803
4120
  "Per index, compares the source rows that belong in it (every row shouldIndex keeps) with",
3804
4121
  "the documents in it, and prints how many documents are missing and how many are extra",
3805
4122
  "(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.",
4123
+ "the extra ones. Exits 1 when drift is left. --content also compares every document with its",
4124
+ "freshly resolved source row and counts the stale ones; with --fix it rewrites them. It reads",
4125
+ "the whole index, so it takes about as long as a backfill.",
3808
4126
  "Needs the adapter export and DATABASE_URL."
3809
4127
  ].join("\n");
3810
4128
  var syncRetry = [
@@ -3839,7 +4157,7 @@ var SUBCOMMAND_HELP = {
3839
4157
  " headband sync init [--table <name>] [--write] [--apply] [--json]",
3840
4158
  " headband sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--project <id>] [--json]",
3841
4159
  " headband sync worker --scaffold docker [--force] [--json]",
3842
- " headband sync reconcile [--fix] [--json]",
4160
+ " headband sync reconcile [--content] [--fix] [--json]",
3843
4161
  " headband sync retry-dead-letters [--json]",
3844
4162
  " headband sync status [--json]",
3845
4163
  "",
@@ -4984,6 +5302,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
4984
5302
  "--client",
4985
5303
  "--instance",
4986
5304
  "--instances",
5305
+ "--indexes",
4987
5306
  "--db-url",
4988
5307
  "--owner-url",
4989
5308
  "--tls-mode",
@@ -5020,6 +5339,7 @@ var BOOLEAN_FLAGS = /* @__PURE__ */ new Set([
5020
5339
  "--write",
5021
5340
  "--force",
5022
5341
  "--fix",
5342
+ "--content",
5023
5343
  "--loop",
5024
5344
  "--accept-broad-role",
5025
5345
  "--rebuild",
@@ -5183,7 +5503,7 @@ Commands:
5183
5503
  branch checkout <name> [--project <id>] [--json]
5184
5504
  branch reset|rollback|restore|set-primary (coming with bucket storage)
5185
5505
  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]
5506
+ keys create <name> --type <admin|search> [--project <id>] [--branch <name>] [--instances <id,id>] [--indexes <uid,uid>] [--permissions <a,b>] [--expires <30d|ISO date>] [--json]
5187
5507
  keys revoke <id> [--yes] [--json]
5188
5508
  indexes list [--json] (index works in place of indexes)
5189
5509
  indexes create <uid> [--primary-key <pk>] [--engine <engine>] [--instance <id>] [--json]
@@ -5201,7 +5521,7 @@ Commands:
5201
5521
  instances connect <id> [--client <node|curl>] [--json]
5202
5522
  warm <index>... | --project <id> [--branch <name>] [--level auto|stage|start] [--json]
5203
5523
  docs push <file> --index <uid> [--json]
5204
- docs export --index <uid> [--out <file>] [--format <jsonl|json>] [--batch <n>]
5524
+ docs export --index <uid> [--out <file>] [--format <jsonl|json>] [--batch <n>] [--declare-key]
5205
5525
  docs delete <docId>... --index <uid> [--json]
5206
5526
  search <index> [<query>] [--limit N] [--filter <expr>] [--mode <instant|retrieval>] [--json]
5207
5527
  tasks list [--index <uid>] [--limit <n>] [--json]
@@ -5216,7 +5536,7 @@ Commands:
5216
5536
  sync init [--table <name>] [--write] [--apply] [--json]
5217
5537
  sync worker [--loop] [--budget-ms <ms>] [--batch-size <n>] [--reconcile-every <n>] [--project <id>] [--json]
5218
5538
  sync worker --scaffold docker [--force] [--json]
5219
- sync reconcile [--fix] [--json]
5539
+ sync reconcile [--content] [--fix] [--json]
5220
5540
  sync retry-dead-letters [--json]
5221
5541
  sync status [--json]
5222
5542
 
@@ -5252,13 +5572,14 @@ function printCommandHelp(command, io) {
5252
5572
  keys: [
5253
5573
  "Usage:",
5254
5574
  " 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]",
5575
+ " headband keys create <name> --type <admin|search> [--project <id>] [--branch <name>] [--instances <id,id>] [--indexes <uid,uid>] [--permissions <a,b>] [--expires <30d|ISO date>] [--json]",
5256
5576
  " headband keys revoke <id> [--yes] [--json]",
5257
5577
  "",
5258
5578
  "keys create mints a key for one branch: --branch (with --project, else the checkout's project),",
5259
5579
  "else the checked-out branch, else the --project's primary branch. With none of these,",
5260
5580
  "--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)."
5581
+ "--instances <id,id> limits the key to those dedicated engines (on a branch: its engines among them).",
5582
+ "--indexes <uid,uid> limits a search key to those indexes; any other index answers 404."
5262
5583
  ].join("\n"),
5263
5584
  indexes: [
5264
5585
  "Usage:",
@@ -5290,7 +5611,7 @@ function printCommandHelp(command, io) {
5290
5611
  docs: [
5291
5612
  "Usage:",
5292
5613
  " headband docs push <file> --index <uid> [--json]",
5293
- " headband docs export --index <uid> [--out <file>] [--format <jsonl|json>] [--batch <n>] [--json]",
5614
+ " headband docs export --index <uid> [--out <file>] [--format <jsonl|json>] [--batch <n>] [--declare-key] [--json]",
5294
5615
  " headband docs delete <docId>... --index <uid> [--json]",
5295
5616
  "",
5296
5617
  "docs push sends the documents in a .json (an array) or .jsonl file, 1000 at a time.",
@@ -5300,6 +5621,9 @@ function printCommandHelp(command, io) {
5300
5621
  " --out <file> to a file. The format follows the extension (.json is one array,",
5301
5622
  " anything else JSONL) unless --format says otherwise.",
5302
5623
  " --batch <n> documents per request. Default 1000, or 250 on Typesense.",
5624
+ " --declare-key a Visor index past 1000 documents whose primary key is not",
5625
+ " filterable and sortable (made before 2026-10-21): declare the",
5626
+ " key on it first (Visor rebuilds the index), wait, then export.",
5303
5627
  " If the export stops on an error, the documents written so far are kept in",
5304
5628
  " <file>.partial, and <file> is not written.",
5305
5629
  "",
@@ -5510,7 +5834,12 @@ function parsePermissions(raw) {
5510
5834
  }
5511
5835
  function keyRow(key) {
5512
5836
  const branch = key.branch;
5513
- return { ...key, branch: branch?.name ?? "", engines: enginesColumn(key.instanceIds) };
5837
+ return {
5838
+ ...key,
5839
+ branch: branch?.name ?? "",
5840
+ engines: enginesColumn(key.instanceIds),
5841
+ indexes: enginesColumn(key.indexUids)
5842
+ };
5514
5843
  }
5515
5844
  function enginesColumn(instanceIds) {
5516
5845
  if (instanceIds === null) return "all";
@@ -5521,6 +5850,21 @@ function enginesColumn(instanceIds) {
5521
5850
  }
5522
5851
  return "none";
5523
5852
  }
5853
+ function parseIndexList(raw) {
5854
+ const uids = [
5855
+ ...new Set(
5856
+ raw.split(",").map((x) => x.trim()).filter(Boolean)
5857
+ )
5858
+ ];
5859
+ if (uids.length === 0) throw new CliError("--indexes needs at least one index uid.");
5860
+ const bad = uids.find((uid) => !/^[A-Za-z0-9_-]{1,64}$/.test(uid));
5861
+ if (bad) {
5862
+ throw new CliError(
5863
+ `Not an index uid: ${bad}. Index uids are letters, numbers, hyphens and underscores.`
5864
+ );
5865
+ }
5866
+ return uids;
5867
+ }
5524
5868
  function parseInstanceList(raw) {
5525
5869
  const ids = [
5526
5870
  ...new Set(
@@ -5553,6 +5897,7 @@ async function runKeys(args, flags, io, confirm, cwd) {
5553
5897
  "projectId",
5554
5898
  "branch",
5555
5899
  "engines",
5900
+ "indexes",
5556
5901
  "environment",
5557
5902
  "lastUsedAt",
5558
5903
  "expiresAt"
@@ -5574,6 +5919,11 @@ async function runKeys(args, flags, io, confirm, cwd) {
5574
5919
  }
5575
5920
  const instances = stringFlag3(flags, "--instances");
5576
5921
  if (instances !== void 0) body.instanceIds = parseInstanceList(instances);
5922
+ if (stringFlag3(flags, "--index") !== void 0) {
5923
+ throw new CliError("Unknown option --index for keys create. Did you mean --indexes?");
5924
+ }
5925
+ const indexes = stringFlag3(flags, "--indexes");
5926
+ if (indexes !== void 0) body.indexUids = parseIndexList(indexes);
5577
5927
  const permissions = stringFlag3(flags, "--permissions");
5578
5928
  if (permissions) body.permissions = parsePermissions(permissions);
5579
5929
  const expires = stringFlag3(flags, "--expires");
@@ -5593,6 +5943,12 @@ async function runKeys(args, flags, io, confirm, cwd) {
5593
5943
  typeof keyId === "string" && keyId ? `This API did not limit the new key to the chosen engines, so key ${keyId} reaches wider than asked. Revoke it now: headband keys revoke ${keyId}` : "This API did not limit the new key to the chosen engines, so it reaches wider than asked. Find it with `headband keys list` and revoke it with `headband keys revoke <id>`."
5594
5944
  );
5595
5945
  }
5946
+ if (indexes !== void 0 && !Array.isArray(response.data.key.indexUids)) {
5947
+ const keyId = response.data.key.id;
5948
+ throw new CliError(
5949
+ typeof keyId === "string" && keyId ? `This API did not limit the new key to the chosen indexes, so key ${keyId} reaches every index of its branch. Revoke it now: headband keys revoke ${keyId}` : "This API did not limit the new key to the chosen indexes, so it reaches every index of its branch. Find it with `headband keys list` and revoke it with `headband keys revoke <id>`."
5950
+ );
5951
+ }
5596
5952
  if (boolFlag(flags, "--json")) {
5597
5953
  printJson(io, response);
5598
5954
  return;
@@ -5602,7 +5958,17 @@ async function runKeys(args, flags, io, confirm, cwd) {
5602
5958
  printTable(
5603
5959
  io,
5604
5960
  [keyRow(response.data.key)],
5605
- ["id", "name", "type", "environment", "branch", "engines", "keyPrefix", "expiresAt"]
5961
+ [
5962
+ "id",
5963
+ "name",
5964
+ "type",
5965
+ "environment",
5966
+ "branch",
5967
+ "engines",
5968
+ "indexes",
5969
+ "keyPrefix",
5970
+ "expiresAt"
5971
+ ]
5606
5972
  );
5607
5973
  return;
5608
5974
  }
@@ -5738,6 +6104,7 @@ async function runDocsExport(flags, context, cwd) {
5738
6104
  const format = exportFormat(flags, out);
5739
6105
  const client = clientFromFlags(flags, cwd);
5740
6106
  const batchSize = parseIntFlag(flags, "--batch") ?? await defaultExportBatchSize(client, index);
6107
+ if (boolFlag(flags, "--declare-key")) await declareExportKey(client, index, io);
5741
6108
  if (!out) {
5742
6109
  const writeLine = context.writeLine ?? ((line) => io.stdout(line));
5743
6110
  try {
@@ -5940,7 +6307,8 @@ async function runSchema(args, flags, io, confirm, cwd) {
5940
6307
  dir,
5941
6308
  allowDestructive,
5942
6309
  io: chatter,
5943
- projectId
6310
+ projectId,
6311
+ readRemoteDocuments: (uid, primaryKey) => readRemoteDocuments(apiClient, uid, primaryKey)
5944
6312
  });
5945
6313
  if (json) printJson(io, { ...result, projectId: projectId ?? null });
5946
6314
  else {
@@ -6120,8 +6488,10 @@ async function runSync(args, flags, io, confirm, cwd) {
6120
6488
  client,
6121
6489
  io: chatter,
6122
6490
  fix: boolFlag(flags, "--fix"),
6491
+ content: boolFlag(flags, "--content"),
6123
6492
  getStats: (uid) => apiClient.get(`/v1/indexes/${encode(uid)}/stats`),
6124
- listRemoteIds: (uid, primaryKey) => listRemoteIds(apiClient, uid, primaryKey)
6493
+ listRemoteIds: (uid, primaryKey) => listRemoteIds(apiClient, uid, primaryKey),
6494
+ readRemoteDocuments: (uid, primaryKey) => readRemoteDocuments(apiClient, uid, primaryKey)
6125
6495
  });
6126
6496
  if (json) printJson(io, { indexes: rows, drift: reconcileHasDrift(rows) });
6127
6497
  if (reconcileHasDrift(rows)) process.exitCode = 1;
@@ -6143,7 +6513,7 @@ async function runSync(args, flags, io, confirm, cwd) {
6143
6513
  printCommandHelp("sync", io);
6144
6514
  throw new UsageError("Unknown sync command");
6145
6515
  }
6146
- var CLI_VERSION = true ? "0.6.0" : "dev";
6516
+ var CLI_VERSION = true ? "0.6.1" : "dev";
6147
6517
  async function run(argv, context = { io: defaultIO }) {
6148
6518
  if (argv.length === 1 && (argv[0] === "--version" || argv[0] === "-v" || argv[0] === "version")) {
6149
6519
  context.io.stdout(CLI_VERSION);
@@ -6277,6 +6647,7 @@ function exitAfterFlush(code) {
6277
6647
  main,
6278
6648
  parseArgv,
6279
6649
  parseExpires,
6650
+ parseIndexList,
6280
6651
  parsePermissions,
6281
6652
  run,
6282
6653
  stdinIsInteractive,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@headband/cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "CLI tool for setting up Headband search in any project",
5
5
  "bin": {
6
6
  "headband": "dist/index.js"