@datarails-ai/dr-cli 1.6.93 → 1.6.95

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/dist/cli.js CHANGED
@@ -8,7 +8,7 @@ import { fileURLToPath as fileURLToPath3 } from "url";
8
8
  // package.json
9
9
  var package_default = {
10
10
  name: "@datarails-ai/dr-cli",
11
- version: "1.6.93",
11
+ version: "1.6.95",
12
12
  description: "Datarails CLI",
13
13
  type: "module",
14
14
  engines: {
@@ -67,7 +67,7 @@ var package_default = {
67
67
  };
68
68
 
69
69
  // src/buildInfo.ts
70
- var BUILD_TIME = "2026-10-01T13:51:17.711Z";
70
+ var BUILD_TIME = "2026-10-02T15:08:38.411Z";
71
71
  function formatBuildTime(iso) {
72
72
  if (iso === "") return "";
73
73
  const d = new Date(iso);
@@ -4853,13 +4853,13 @@ function formatExpLine(claims) {
4853
4853
  } else {
4854
4854
  return String(raw);
4855
4855
  }
4856
- const stamp = formatUtc(unix);
4856
+ const stamp2 = formatUtc(unix);
4857
4857
  const deltaMs = unix * 1e3 - Date.now();
4858
4858
  const roundedMs = roundToMinuteMs(deltaMs);
4859
4859
  if (roundedMs < 0) {
4860
- return `${stamp} (expired ${formatGoDuration(-roundedMs)} ago)`;
4860
+ return `${stamp2} (expired ${formatGoDuration(-roundedMs)} ago)`;
4861
4861
  }
4862
- return `${stamp} (in ${formatGoDuration(roundedMs)})`;
4862
+ return `${stamp2} (in ${formatGoDuration(roundedMs)})`;
4863
4863
  }
4864
4864
  function formatClaimValue(key, v) {
4865
4865
  if (key === "exp" || key === "iat" || key === "nbf") {
@@ -19305,9 +19305,9 @@ var PERIOD_LABEL_RE = /^(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*[
19305
19305
  function findPeriodLabelRefs(formula) {
19306
19306
  const bad = [];
19307
19307
  for (const m of formula.matchAll(BRACKET_REF_RE)) {
19308
- const ref = (m[1] ?? "").trim();
19309
- if (PERIOD_LABEL_RE.test(ref)) {
19310
- bad.push(ref);
19308
+ const ref2 = (m[1] ?? "").trim();
19309
+ if (PERIOD_LABEL_RE.test(ref2)) {
19310
+ bad.push(ref2);
19311
19311
  }
19312
19312
  }
19313
19313
  return bad;
@@ -20374,7 +20374,7 @@ function toWireFormula(input) {
20374
20374
  }
20375
20375
  const wire = padded.replace(STASH_RE, (_m, i) => stash[Number(i)]);
20376
20376
  const hasMetricOperand = (wire.match(/'[^']*'/g) ?? []).some(
20377
- (ref) => !INTERNAL_FORMULA_TOKENS.includes(ref.slice(1, -1))
20377
+ (ref2) => !INTERNAL_FORMULA_TOKENS.includes(ref2.slice(1, -1))
20378
20378
  );
20379
20379
  if (!hasMetricOperand) {
20380
20380
  throw new Error(`--formula must reference at least one metric, e.g. [Revenue]`);
@@ -25544,8 +25544,8 @@ async function fetchTemplate(client, id, signal) {
25544
25544
  throw err;
25545
25545
  }
25546
25546
  }
25547
- function resolveFieldRef(template, ref) {
25548
- const trimmed = ref.trim();
25547
+ function resolveFieldRef(template, ref2) {
25548
+ const trimmed = ref2.trim();
25549
25549
  if (/^\d+$/.test(trimmed)) {
25550
25550
  const id = Number(trimmed);
25551
25551
  const byId = template.fields.find((f) => f.id === id);
@@ -36423,6 +36423,249 @@ function matchesAny(buf, sigs) {
36423
36423
  return sigs.some((sig) => startsWith(buf, sig));
36424
36424
  }
36425
36425
 
36426
+ // src/commands/_dataQueryWait.ts
36427
+ var DQ_WAIT_POLL_INTERVAL_MS = 5e3;
36428
+ var DQ_WAIT_POLL_MAX_INTERVAL_MS = 3e4;
36429
+ var DQ_WAIT_DEFAULT_TIMEOUT_SEC = 1800;
36430
+ var DQ_WAIT_TIMEOUT_MAX_SEC = 86400;
36431
+ var DQ_WAIT_SETUP_OVERHEAD_MS = 6e4;
36432
+ function dataQuerySyncCallTimeoutMs(wait, timeoutSec) {
36433
+ if (!wait) return void 0;
36434
+ return timeoutSec * 1e3 + DQ_WAIT_SETUP_OVERHEAD_MS;
36435
+ }
36436
+ function parseDataQueryWaitTimeout(raw) {
36437
+ const n = Number(raw ?? String(DQ_WAIT_DEFAULT_TIMEOUT_SEC));
36438
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
36439
+ throw new Error(`--timeout must be a positive integer number of seconds (got "${raw}")`);
36440
+ }
36441
+ if (n > DQ_WAIT_TIMEOUT_MAX_SEC) {
36442
+ throw new Error(
36443
+ `--timeout must be at most ${DQ_WAIT_TIMEOUT_MAX_SEC} seconds (24 hours; got "${raw}") \u2014 for longer runs, re-check later with 'dr filebox versions <id>' instead of waiting.`
36444
+ );
36445
+ }
36446
+ return n;
36447
+ }
36448
+ function asRecord(v) {
36449
+ return typeof v === "object" && v !== null && !Array.isArray(v) ? v : {};
36450
+ }
36451
+ function stamp(v) {
36452
+ return JSON.stringify(v ?? null);
36453
+ }
36454
+ function belongsTo(v, period) {
36455
+ return period.tags.every(
36456
+ (want) => (v.tags ?? []).some((t) => t.id === want.id && want.inPeriod(t.value))
36457
+ );
36458
+ }
36459
+ function maxId(rows) {
36460
+ return rows.reduce((m, v) => Math.max(m, v.id), 0);
36461
+ }
36462
+ function ref(v) {
36463
+ return { id: v.id, version: v.version };
36464
+ }
36465
+ function buildDataQuerySyncBaseline(periods, versions, dataQueryRaw) {
36466
+ const dataQuery = asRecord(dataQueryRaw);
36467
+ return {
36468
+ periods: periods.map((p) => {
36469
+ const rows = versions.filter((v) => belongsTo(v, p));
36470
+ return {
36471
+ maxId: maxId(rows),
36472
+ lastSyncById: new Map(rows.map((v) => [v.id, stamp(v.last_sync)]))
36473
+ };
36474
+ }),
36475
+ maxVersionId: maxId(versions),
36476
+ updateTime: stamp(dataQuery["update_time"])
36477
+ };
36478
+ }
36479
+ function classifyDataQuerySyncProgress(periods, baseline, versions, dataQueryRaw) {
36480
+ const dataQuery = asRecord(dataQueryRaw);
36481
+ const out = { periods: [], unmatchedNewVersions: [] };
36482
+ periods.forEach((p, i) => {
36483
+ const base = baseline.periods[i];
36484
+ const rows = versions.filter((v) => belongsTo(v, p));
36485
+ const created = rows.filter((v) => v.id > base.maxId);
36486
+ if (created.length > 0) {
36487
+ const newest = created.reduce((a, b) => b.id > a.id ? b : a);
36488
+ out.periods.push({ period: p.label, result: "new_version", version: ref(newest) });
36489
+ return;
36490
+ }
36491
+ const confirmed = rows.find(
36492
+ (v) => base.lastSyncById.has(v.id) && stamp(v.last_sync) !== base.lastSyncById.get(v.id)
36493
+ );
36494
+ if (confirmed !== void 0) {
36495
+ out.periods.push({ period: p.label, result: "no_change", version: ref(confirmed) });
36496
+ return;
36497
+ }
36498
+ out.periods.push({ period: p.label, result: "pending" });
36499
+ });
36500
+ out.unmatchedNewVersions = versions.filter((v) => v.id > baseline.maxVersionId && !periods.some((p) => belongsTo(v, p))).map((v) => v.id);
36501
+ if (dataQuery["status"] === "ERROR" && stamp(dataQuery["update_time"]) !== baseline.updateTime) {
36502
+ const msg = dataQuery["last_sync_error"];
36503
+ out.error = typeof msg === "string" && msg !== "" ? msg : "sync failed (no error text)";
36504
+ }
36505
+ return out;
36506
+ }
36507
+ async function captureDataQuerySyncBaseline(client, fileboxId, periods, signal) {
36508
+ try {
36509
+ const [versions, dataQuery] = await Promise.all([
36510
+ listVersions(client, fileboxId, {}, { signal }),
36511
+ getDataQueryRaw(client, fileboxId, { signal })
36512
+ ]);
36513
+ return buildDataQuerySyncBaseline(periods, versions, dataQuery);
36514
+ } catch (err) {
36515
+ const msg = err instanceof Error ? err.message : String(err);
36516
+ throw new Error(
36517
+ `--wait: could not read filebox ${fileboxId}'s versions before syncing (${msg}); the sync was NOT triggered`
36518
+ );
36519
+ }
36520
+ }
36521
+ var defaultSleep8 = (ms, signal) => new Promise((resolve3, reject) => {
36522
+ if (signal?.aborted) {
36523
+ reject(signal.reason ?? new Error("aborted"));
36524
+ return;
36525
+ }
36526
+ const onAbort = () => {
36527
+ clearTimeout(t);
36528
+ reject(signal?.reason ?? new Error("aborted"));
36529
+ };
36530
+ const t = setTimeout(() => {
36531
+ signal?.removeEventListener("abort", onAbort);
36532
+ resolve3();
36533
+ }, ms);
36534
+ signal?.addEventListener("abort", onAbort, { once: true });
36535
+ });
36536
+ async function waitForDataQuerySync(cfg) {
36537
+ let interval = cfg.pollIntervalMs ?? DQ_WAIT_POLL_INTERVAL_MS;
36538
+ const maxInterval = cfg.maxPollIntervalMs ?? DQ_WAIT_POLL_MAX_INTERVAL_MS;
36539
+ const sleep4 = cfg.sleep ?? defaultSleep8;
36540
+ const now = cfg.now ?? Date.now;
36541
+ const start = now();
36542
+ const deadline = start + cfg.timeoutSec * 1e3;
36543
+ if (!cfg.asJson) {
36544
+ const n = cfg.periods.length;
36545
+ cfg.out.write(
36546
+ `Waiting for the sync of ${n} period${plural(n)} on filebox ${cfg.fileboxId} (timeout ${cfg.timeoutSec}s)\u2026
36547
+ `
36548
+ );
36549
+ }
36550
+ let progress;
36551
+ let outcome;
36552
+ for (; ; ) {
36553
+ try {
36554
+ const [versions, dataQuery] = await Promise.all([
36555
+ listVersions(cfg.client, cfg.fileboxId, {}, { signal: cfg.signal }),
36556
+ getDataQueryRaw(cfg.client, cfg.fileboxId, { signal: cfg.signal })
36557
+ ]);
36558
+ progress = classifyDataQuerySyncProgress(cfg.periods, cfg.baseline, versions, dataQuery);
36559
+ } catch (err) {
36560
+ if (cfg.signal.aborted) {
36561
+ outcome = "cancelled";
36562
+ break;
36563
+ }
36564
+ const msg = err instanceof Error ? err.message : String(err);
36565
+ cfg.errOut.write(`warning: sync status poll failed (${msg}); retrying
36566
+ `);
36567
+ }
36568
+ if (progress?.error !== void 0) {
36569
+ outcome = "failed";
36570
+ break;
36571
+ }
36572
+ if (progress !== void 0 && progress.periods.every((p) => p.result !== "pending")) {
36573
+ outcome = "completed";
36574
+ break;
36575
+ }
36576
+ if (now() >= deadline) {
36577
+ outcome = "timed_out";
36578
+ break;
36579
+ }
36580
+ try {
36581
+ await sleep4(interval, cfg.signal);
36582
+ } catch {
36583
+ outcome = "cancelled";
36584
+ break;
36585
+ }
36586
+ interval = Math.min(interval * 1.5, maxInterval);
36587
+ }
36588
+ const final = progress ?? {
36589
+ periods: cfg.periods.map((p) => ({ period: p.label, result: "pending" })),
36590
+ unmatchedNewVersions: []
36591
+ };
36592
+ const waitedSec = Math.round((now() - start) / 1e3);
36593
+ renderSummary2(cfg, final, outcome, waitedSec);
36594
+ if (outcome !== "completed") {
36595
+ throw new Error(failureMessage(cfg.fileboxId, final, outcome, waitedSec));
36596
+ }
36597
+ }
36598
+ function renderSummary2(cfg, progress, outcome, waitedSec) {
36599
+ if (cfg.asJson) {
36600
+ writeJSONIndented(cfg.out, {
36601
+ filebox_id: cfg.fileboxId,
36602
+ outcome,
36603
+ periods: progress.periods.map((p) => ({
36604
+ period: p.period,
36605
+ result: p.result,
36606
+ version_id: p.version?.id ?? null,
36607
+ version: p.version?.version ?? null
36608
+ })),
36609
+ unmatched_new_versions: progress.unmatchedNewVersions,
36610
+ error: progress.error ?? null,
36611
+ waited_seconds: waitedSec
36612
+ });
36613
+ return;
36614
+ }
36615
+ const count = (r) => progress.periods.filter((p) => p.result === r).length;
36616
+ const parts = [`${count("new_version")} new version${plural(count("new_version"))}`];
36617
+ parts.push(`${count("no_change")} unchanged`);
36618
+ if (count("pending") > 0) parts.push(`${count("pending")} unfinished`);
36619
+ const headline = {
36620
+ completed: "Sync finished",
36621
+ failed: "Sync failed",
36622
+ timed_out: `Sync did not finish within ${waitedSec}s`,
36623
+ cancelled: "Sync wait cancelled"
36624
+ }[outcome];
36625
+ cfg.out.write(`${headline} for filebox ${cfg.fileboxId}: ${parts.join(", ")}.
36626
+ `);
36627
+ if (progress.error !== void 0) {
36628
+ cfg.out.write(` error: ${progress.error}
36629
+ `);
36630
+ }
36631
+ for (const p of progress.periods) {
36632
+ if (p.result === "new_version") {
36633
+ cfg.out.write(` ${p.period}: new version #${p.version.id} (v${p.version.version})
36634
+ `);
36635
+ } else if (p.result === "no_change") {
36636
+ cfg.out.write(
36637
+ ` ${p.period}: unchanged \u2014 identical to version #${p.version.id} (v${p.version.version}), so no new version was written
36638
+ `
36639
+ );
36640
+ } else if (outcome === "timed_out") {
36641
+ cfg.out.write(
36642
+ ` ${p.period}: no new or re-confirmed version yet \u2014 still running, or the sync returned no rows (check 'dr filebox data-query get ${cfg.fileboxId}')
36643
+ `
36644
+ );
36645
+ } else {
36646
+ cfg.out.write(` ${p.period}: not finished
36647
+ `);
36648
+ }
36649
+ }
36650
+ for (const id of progress.unmatchedNewVersions) {
36651
+ cfg.out.write(
36652
+ ` note: new version #${id} matches none of the requested periods \u2014 check its date tag with 'dr filebox versions ${cfg.fileboxId}'
36653
+ `
36654
+ );
36655
+ }
36656
+ }
36657
+ function failureMessage(fileboxId, progress, outcome, waitedSec) {
36658
+ const pending = progress.periods.filter((p) => p.result === "pending").map((p) => p.period);
36659
+ const pendingList = pending.join(", ") || "(none)";
36660
+ if (outcome === "failed") {
36661
+ return `data-query sync on filebox ${fileboxId} failed: ${progress.error}`;
36662
+ }
36663
+ if (outcome === "cancelled") {
36664
+ return `data-query sync wait cancelled after ${waitedSec}s with ${pending.length} period(s) unfinished: ${pendingList}. The sync continues server-side; re-check with 'dr filebox versions ${fileboxId}' instead of re-triggering.`;
36665
+ }
36666
+ return `data-query sync on filebox ${fileboxId} did not finish within ${waitedSec}s \u2014 ${pending.length} period(s) unfinished: ${pendingList}. Increase --timeout, or re-check with 'dr filebox versions ${fileboxId}'; do not blindly re-trigger.`;
36667
+ }
36668
+
36426
36669
  // src/commands/filebox.ts
36427
36670
  async function fileToBlob(filePath) {
36428
36671
  const buf = await fsPromises.readFile(filePath);
@@ -36732,7 +36975,7 @@ ${JSON.stringify(result)}
36732
36975
  `
36733
36976
  );
36734
36977
  }
36735
- function asRecord(v) {
36978
+ function asRecord2(v) {
36736
36979
  return v !== null && typeof v === "object" ? v : void 0;
36737
36980
  }
36738
36981
  function numOf(r, k) {
@@ -36742,9 +36985,9 @@ function strOf(r, k) {
36742
36985
  return r !== void 0 && typeof r[k] === "string" ? r[k] : void 0;
36743
36986
  }
36744
36987
  function extractUploadedVersionMeta(result) {
36745
- const r = asRecord(result);
36746
- const dv = asRecord(r?.["document_version"]);
36747
- const docObj = asRecord(r?.["document"]);
36988
+ const r = asRecord2(result);
36989
+ const dv = asRecord2(r?.["document_version"]);
36990
+ const docObj = asRecord2(r?.["document"]);
36748
36991
  return {
36749
36992
  id: numOf(r, "id") ?? numOf(dv, "id") ?? numOf(docObj, "latest_version_id") ?? 0,
36750
36993
  version: numOf(r, "version") ?? numOf(dv, "version") ?? 0,
@@ -38106,6 +38349,10 @@ async function runFileboxDataQueryGet(cfg) {
38106
38349
  `);
38107
38350
  cfg.out.write(` Last sync: ${orElse(renderCreateTime(q.last_sync), "never")}
38108
38351
  `);
38352
+ if (q.last_general_sync !== void 0) {
38353
+ cfg.out.write(` Last start: ${orElse(renderCreateTime(q.last_general_sync), "never")}
38354
+ `);
38355
+ }
38109
38356
  if (q.last_sync_error !== null && q.last_sync_error !== void 0 && q.last_sync_error !== "") {
38110
38357
  cfg.out.write(` Last error: ${truncateField2(q.last_sync_error, 100)}
38111
38358
  `);
@@ -38300,6 +38547,12 @@ function isoMondayOfUTC(d) {
38300
38547
  monday.setUTCDate(d.getUTCDate() - isoDow);
38301
38548
  return monday;
38302
38549
  }
38550
+ function samePeriodAs(subType, stored, resolvedValue) {
38551
+ if (typeof stored !== "number" || !Number.isFinite(stored)) return false;
38552
+ const d = new Date(stored * 1e3);
38553
+ const parsed2 = { year: d.getUTCFullYear(), month0: d.getUTCMonth(), day: d.getUTCDate() };
38554
+ return resolveDateForSubType(subType, parsed2).value === resolvedValue;
38555
+ }
38303
38556
  function resolveDateForSubType(subType, parsed2) {
38304
38557
  const { year, month0, day } = parsed2;
38305
38558
  switch ((subType ?? "").toLowerCase()) {
@@ -38347,9 +38600,15 @@ async function runFileboxDataQuerySync(cfg) {
38347
38600
  const trimmed = t.trim();
38348
38601
  if (trimmed !== "") rawTags.push(trimmed);
38349
38602
  }
38603
+ if (cfg.wait !== void 0 && rawTags.length > 0) {
38604
+ throw new Error(
38605
+ "--wait cannot be combined with --tag; use --date to name the periods to wait for"
38606
+ );
38607
+ }
38350
38608
  const parsedDates = cfg.dateArgs.map(parseCalendarDate);
38351
38609
  const dateTargets = [];
38352
38610
  const periodLabels = [];
38611
+ const waitPeriods = [];
38353
38612
  if (parsedDates.length > 0) {
38354
38613
  const details = await getDetails(cfg.client, cfg.id, {
38355
38614
  signal: cfg.signal
@@ -38372,15 +38631,27 @@ async function runFileboxDataQuerySync(cfg) {
38372
38631
  }
38373
38632
  for (const parsed2 of parsedDates) {
38374
38633
  const resolved = dateTags.map((tag) => {
38375
- const { value, label } = resolveDateForSubType(tagSubType(tag), parsed2);
38376
- return { id: tag.id, name: tag.name, value, label };
38634
+ const { value, label: label2 } = resolveDateForSubType(tagSubType(tag), parsed2);
38635
+ return { id: tag.id, name: tag.name, value, label: label2 };
38377
38636
  });
38378
38637
  dateTargets.push(
38379
38638
  JSON.stringify(resolved.map(({ id, name, value }) => ({ id, name, value })))
38380
38639
  );
38381
- periodLabels.push(resolved.map((r) => r.label).join(" + "));
38640
+ const label = resolved.map((r) => r.label).join(" + ");
38641
+ periodLabels.push(label);
38642
+ waitPeriods.push({
38643
+ label,
38644
+ tags: dateTags.map((tag, i) => ({
38645
+ id: tag.id,
38646
+ inPeriod: (stored) => samePeriodAs(tagSubType(tag), stored, resolved[i].value)
38647
+ }))
38648
+ });
38382
38649
  }
38383
38650
  }
38651
+ if (waitPeriods.length === 0) {
38652
+ waitPeriods.push({ label: "latest sync", tags: [] });
38653
+ }
38654
+ const baseline = cfg.wait === void 0 ? void 0 : await captureDataQuerySyncBaseline(cfg.client, cfg.id, waitPeriods, cfg.signal);
38384
38655
  const multipleTags = [...rawTags, ...dateTargets];
38385
38656
  const body = {};
38386
38657
  if (multipleTags.length > 0) {
@@ -38401,10 +38672,33 @@ async function runFileboxDataQuerySync(cfg) {
38401
38672
  summary.push(`tags=${rawTags.join(",")}`);
38402
38673
  }
38403
38674
  const tagInfo = summary.length > 0 ? ` (${summary.join("; ")})` : "";
38404
- cfg.out.write(`Sync queued for filebox ${cfg.id}${tagInfo} \u2014 server: ${detail}
38675
+ if (cfg.wait === void 0 || baseline === void 0) {
38676
+ cfg.out.write(`Sync queued for filebox ${cfg.id}${tagInfo} \u2014 server: ${detail}
38405
38677
  `);
38406
- cfg.out.write(`Check progress with: dr filebox data-query get ${cfg.id}
38678
+ cfg.out.write(
38679
+ `Check progress with: dr filebox data-query get ${cfg.id} (or re-run with --wait)
38680
+ `
38681
+ );
38682
+ return;
38683
+ }
38684
+ const progressOut = cfg.wait.asJson ? cfg.wait.err ?? process.stderr : cfg.out;
38685
+ progressOut.write(`Sync queued for filebox ${cfg.id}${tagInfo} \u2014 server: ${detail}
38407
38686
  `);
38687
+ await waitForDataQuerySync({
38688
+ client: cfg.client,
38689
+ fileboxId: cfg.id,
38690
+ periods: waitPeriods,
38691
+ baseline,
38692
+ timeoutSec: cfg.wait.timeoutSec,
38693
+ asJson: cfg.wait.asJson,
38694
+ out: cfg.out,
38695
+ errOut: cfg.wait.err ?? process.stderr,
38696
+ signal: cfg.signal,
38697
+ pollIntervalMs: cfg.wait.pollIntervalMs,
38698
+ maxPollIntervalMs: cfg.wait.maxPollIntervalMs,
38699
+ sleep: cfg.wait.sleep,
38700
+ now: cfg.wait.now
38701
+ });
38408
38702
  }
38409
38703
  async function runFileboxDownload(cfg) {
38410
38704
  if (cfg.fileboxId <= 0) {
@@ -39651,22 +39945,39 @@ function registerFileboxCommand(root) {
39651
39945
  `Advanced: raw server-shape tag JSON, e.g. '[{"id":6,"name":"Month","value":1782907200}]'. Prefer --date \u2014 this is an unvalidated escape hatch for tag dimensions --date doesn't cover (e.g. LIST tags). Repeatable.`,
39652
39946
  collectStrings,
39653
39947
  []
39654
- ).action(async (idStr, opts) => {
39655
- const id = parseFileboxIdArg(idStr);
39656
- const setup = await setupCall(opts.server, opts.account);
39657
- try {
39658
- await runFileboxDataQuerySync({
39659
- client: setup.client,
39660
- id,
39661
- tags: opts.tag ?? [],
39662
- dateArgs: opts.date ?? [],
39663
- signal: setup.signal,
39664
- out: process.stdout
39948
+ ).option(
39949
+ "--wait",
39950
+ "After queueing, wait until every --date period has finished, and say whether each wrote a new version or was unchanged (identical output writes no version). Exits non-zero on a failed sync or a timeout. Not combinable with --tag.",
39951
+ false
39952
+ ).option(
39953
+ "--timeout <seconds>",
39954
+ `With --wait: how long to wait (default ${DQ_WAIT_DEFAULT_TIMEOUT_SEC}, max 86400)`
39955
+ ).option("--json", "With --wait: print the result as one JSON object", false).action(
39956
+ async (idStr, opts) => {
39957
+ const id = parseFileboxIdArg(idStr);
39958
+ const wait = opts.wait === true;
39959
+ if (!wait && (opts.timeout !== void 0 || opts.json === true)) {
39960
+ throw new Error("--timeout and --json only apply with --wait");
39961
+ }
39962
+ const timeoutSec = parseDataQueryWaitTimeout(opts.timeout);
39963
+ const setup = await setupCall(opts.server, opts.account, {
39964
+ timeoutMs: dataQuerySyncCallTimeoutMs(wait, timeoutSec)
39665
39965
  });
39666
- } finally {
39667
- setup.cleanup();
39966
+ try {
39967
+ await runFileboxDataQuerySync({
39968
+ client: setup.client,
39969
+ id,
39970
+ tags: opts.tag ?? [],
39971
+ dateArgs: opts.date ?? [],
39972
+ signal: setup.signal,
39973
+ out: process.stdout,
39974
+ wait: wait ? { timeoutSec, asJson: opts.json === true } : void 0
39975
+ });
39976
+ } finally {
39977
+ setup.cleanup();
39978
+ }
39668
39979
  }
39669
- });
39980
+ );
39670
39981
  addServerFlag(dqSyncCmd);
39671
39982
  addAccountFlag(dqSyncCmd);
39672
39983
  registerFileboxAdminCommands(fb, setupCall, addServerFlag, addAccountFlag);
@@ -42915,6 +43226,31 @@ async function runReconciliationAccountsSettings(cfg) {
42915
43226
  }
42916
43227
  renderAccountsSettings(cfg.out, items);
42917
43228
  }
43229
+ var SETTINGS_BATCH_MAX_BYTES = 256 * 1024;
43230
+ function settingsKey(s) {
43231
+ return JSON.stringify([s.entity ?? "", s.account_group ?? "", s.name]);
43232
+ }
43233
+ function settingsFieldChanges(have, want) {
43234
+ const fields = ["frequency", "threshold_from", "threshold_to"];
43235
+ return fields.filter((f) => have[f] !== want[f]).map((f) => `${f} ${String(have[f])} \u2192 ${String(want[f])}`);
43236
+ }
43237
+ function chunkByJsonBytes(rows, maxBytes) {
43238
+ const chunks = [];
43239
+ let current = [];
43240
+ let size = 2;
43241
+ for (const row of rows) {
43242
+ const rowBytes = Buffer.byteLength(JSON.stringify(row));
43243
+ if (current.length > 0 && size + rowBytes + 1 > maxBytes) {
43244
+ chunks.push(current);
43245
+ current = [];
43246
+ size = 2;
43247
+ }
43248
+ size += current.length === 0 ? rowBytes : rowBytes + 1;
43249
+ current.push(row);
43250
+ }
43251
+ if (current.length > 0) chunks.push(current);
43252
+ return chunks;
43253
+ }
42918
43254
  function settingsActionId(settings) {
42919
43255
  return createHash8("sha256").update(JSON.stringify(settings)).digest("hex").slice(0, 12);
42920
43256
  }
@@ -42926,6 +43262,32 @@ async function runReconciliationAccountsUpdateSettings(cfg) {
42926
43262
  };
42927
43263
  await preflightConfirmToken(action, cfg.token);
42928
43264
  const n = cfg.settings.length;
43265
+ const current = await getAccountSettings(cfg.client, {}, { signal: cfg.signal });
43266
+ const currentByKey = new Map(current.map((s) => [settingsKey(s), s]));
43267
+ const changed = [];
43268
+ const preview = [];
43269
+ for (const want of cfg.settings) {
43270
+ const have = currentByKey.get(settingsKey(want));
43271
+ let what;
43272
+ if (have === void 0) {
43273
+ what = `new (frequency ${want.frequency})`;
43274
+ } else {
43275
+ const diffs = settingsFieldChanges(have, want);
43276
+ if (diffs.length === 0) continue;
43277
+ what = diffs.join(", ");
43278
+ }
43279
+ changed.push(want);
43280
+ if (preview.length < 5) {
43281
+ preview.push(` ${want.entity ?? ""} / ${want.account_group ?? ""} / ${want.name}: ${what}`);
43282
+ }
43283
+ }
43284
+ const unchanged = n - changed.length;
43285
+ if (changed.length === 0) {
43286
+ cfg.out.write(`Nothing to change: all ${n} row${plural(n)} already match the tenant.
43287
+ `);
43288
+ return;
43289
+ }
43290
+ const chunks = chunkByJsonBytes(changed, cfg.maxBatchBytes ?? SETTINGS_BATCH_MAX_BYTES);
42929
43291
  await twoStepConfirm({
42930
43292
  out: cfg.out,
42931
43293
  action,
@@ -42933,19 +43295,42 @@ async function runReconciliationAccountsUpdateSettings(cfg) {
42933
43295
  server: cfg.serverFlag,
42934
43296
  confirmCmd: cfg.confirmCmd,
42935
43297
  describe: async () => {
42936
- const names = cfg.settings.map((s) => s.name);
42937
- const shown = names.slice(0, 5).join(", ");
42938
- return [
42939
- `rows: ${n}`,
42940
- `accounts: ${shown}${names.length > 5 ? `, \u2026 (+${names.length - 5} more)` : ""}`
43298
+ const byFrequency = /* @__PURE__ */ new Map();
43299
+ for (const s of changed) {
43300
+ byFrequency.set(s.frequency, (byFrequency.get(s.frequency) ?? 0) + 1);
43301
+ }
43302
+ const lines = [
43303
+ `rows: ${n}`,
43304
+ `to change: ${changed.length} (${chunks.length} PATCH request${plural(chunks.length)})`,
43305
+ `unchanged: ${unchanged} (already match the tenant \u2014 skipped)`,
43306
+ `by frequency: ${[...byFrequency].map(([f, c]) => `${f} ${c}`).join(", ")}`,
43307
+ "changes:",
43308
+ ...preview
42941
43309
  ];
43310
+ if (changed.length > preview.length) {
43311
+ lines.push(` \u2026 (+${changed.length - preview.length} more)`);
43312
+ }
43313
+ return lines;
42942
43314
  },
42943
43315
  execute: async () => {
42944
- await updateAccountSettings(cfg.client, cfg.settings, {
42945
- signal: cfg.signal
42946
- });
42947
- cfg.out.write(`Updated ${n} account setting${n === 1 ? "" : "s"}.
42948
- `);
43316
+ extendForBatch(cfg.extendTimeout, chunks.length);
43317
+ let applied = 0;
43318
+ for (const chunk of chunks) {
43319
+ try {
43320
+ await updateAccountSettings(cfg.client, chunk, { signal: cfg.signal });
43321
+ } catch (err) {
43322
+ const msg = err instanceof Error ? err.message : String(err);
43323
+ throw new Error(
43324
+ `PATCH failed after ${applied} of ${changed.length} rows were written (rows ${applied + 1}\u2013${applied + chunk.length} may be partially applied): ${msg}
43325
+ Re-run the same command without --confirm-token: rows that landed plan as unchanged, so the new plan covers only the rest.`
43326
+ );
43327
+ }
43328
+ applied += chunk.length;
43329
+ }
43330
+ cfg.out.write(
43331
+ `Updated ${applied} account setting${plural(applied)} (${unchanged} unchanged).
43332
+ `
43333
+ );
42949
43334
  }
42950
43335
  });
42951
43336
  }
@@ -43384,10 +43769,29 @@ verify the date matches the accounts' reporting period (try \`dr reconciliation
43384
43769
  account_id: id,
43385
43770
  members: applyAssignments(byId.get(id), changesByAccount.get(id))
43386
43771
  }));
43387
- extendForBatch(cfg.extendTimeout, requests);
43772
+ extendForBatch(cfg.extendTimeout, requests * 2 - 1);
43388
43773
  let applied = 0;
43389
43774
  for (let i = 0; i < items.length; i += chunkSize) {
43390
43775
  const chunk = items.slice(i, i + chunkSize);
43776
+ if (i > 0) {
43777
+ let drift;
43778
+ try {
43779
+ drift = await chunkDrift(cfg, byId, chunk);
43780
+ } catch (err) {
43781
+ const msg = err instanceof Error ? err.message : String(err);
43782
+ throw new Error(
43783
+ `re-read failed after ${applied} of ${items.length} account${plural(items.length)} were written; nothing more was sent: ${msg}
43784
+ Re-run the same command without --confirm-token: rows that landed plan as unchanged, so the new plan covers only the rest.`
43785
+ );
43786
+ }
43787
+ if (drift.length > 0) {
43788
+ throw new Error(
43789
+ `halted after ${applied} of ${items.length} account${plural(items.length)} were written; nothing more was sent:
43790
+ ` + drift.join("\n ") + `
43791
+ The tenant changed since the plan. Re-run the same command without --confirm-token: rows that landed plan as unchanged. If accounts are gone, the trial balance was rebuilt and their ids changed \u2014 wait for it to settle and rebuild the file from \`dr reconciliation accounts list\`.`
43792
+ );
43793
+ }
43794
+ }
43391
43795
  try {
43392
43796
  await updateAccountMembers(cfg.client, chunk, { signal: cfg.signal });
43393
43797
  } catch (err) {
@@ -43411,6 +43815,28 @@ Re-run the same command without --confirm-token: rows that landed plan as unchan
43411
43815
  }
43412
43816
  });
43413
43817
  }
43818
+ async function chunkDrift(cfg, planned, chunk) {
43819
+ const now = await getAccounts(cfg.client, { date: cfg.date }, { signal: cfg.signal });
43820
+ const nowById = new Map(now.map((a) => [a.account_id, a]));
43821
+ const drift = [];
43822
+ for (const { account_id: id } of chunk) {
43823
+ const before = planned.get(id);
43824
+ const after = nowById.get(id);
43825
+ const label = `${before.account_name} (${id})`;
43826
+ if (after === void 0) {
43827
+ drift.push(`${label}: no longer at --date`);
43828
+ } else if (!sameMemberSlots(before.members, after.members)) {
43829
+ drift.push(`${label}: member slots changed since the plan`);
43830
+ }
43831
+ }
43832
+ return drift;
43833
+ }
43834
+ function sameMemberSlots(a, b) {
43835
+ const shape = (ms) => JSON.stringify(
43836
+ ms.map((m) => [m.type, m.user_id, m.due_date, m.is_sign_off, m.sign_off_timestamp]).sort()
43837
+ );
43838
+ return shape(a) === shape(b);
43839
+ }
43414
43840
  function applyAssignments(account, changes) {
43415
43841
  const byType = new Map(changes.map((c) => [c.row.type, c]));
43416
43842
  const assigned = (type, dueDate) => {
@@ -43734,39 +44160,42 @@ function wireReconciliation(program) {
43734
44160
  ).requiredOption(
43735
44161
  "--file <path>",
43736
44162
  "JSON file with the array of settings (output of `dr reconciliation accounts settings --json`)"
43737
- ).option(
44163
+ ).option("--timeout <seconds>", BATCH_TIMEOUT_FLAG_HELP).option(
43738
44164
  "--confirm-token <token>",
43739
44165
  "Token from a prior plan run \u2014 required to actually update",
43740
44166
  ""
43741
- ).action(async (opts) => {
43742
- const filePath = opts.file ?? "";
43743
- const raw = await fs18.readFile(filePath, "utf8");
43744
- const settings = parseReconciliationSettingsFile(raw);
43745
- await preflightConfirmToken(
43746
- {
43747
- resource: "recon-settings",
43748
- id: settingsActionId(settings),
43749
- verb: "update"
43750
- },
43751
- opts.confirmToken
43752
- );
43753
- const serverKey = resolveServer(opts.server);
43754
- const confirmCmd = `dr reconciliation accounts update-settings --file ${JSON.stringify(filePath)} --server ${serverKey}`;
43755
- const setup = await setupCall(opts.server, opts.account);
43756
- try {
43757
- await runReconciliationAccountsUpdateSettings({
43758
- client: setup.client,
43759
- settings,
43760
- token: opts.confirmToken,
43761
- confirmCmd,
43762
- serverFlag: serverKey,
43763
- signal: setup.signal,
43764
- out: process.stdout
43765
- });
43766
- } finally {
43767
- setup.cleanup();
44167
+ ).action(
44168
+ async (opts) => {
44169
+ const filePath = opts.file ?? "";
44170
+ const raw = await fs18.readFile(filePath, "utf8");
44171
+ const settings = parseReconciliationSettingsFile(raw);
44172
+ await preflightConfirmToken(
44173
+ {
44174
+ resource: "recon-settings",
44175
+ id: settingsActionId(settings),
44176
+ verb: "update"
44177
+ },
44178
+ opts.confirmToken
44179
+ );
44180
+ const serverKey = resolveServer(opts.server);
44181
+ const confirmCmd = `dr reconciliation accounts update-settings --file ${JSON.stringify(filePath)}` + (opts.timeout !== void 0 ? ` --timeout ${opts.timeout}` : "") + ` --server ${serverKey}`;
44182
+ const setup = await setupCall(opts.server, opts.account, batchSetupOptions(opts.timeout));
44183
+ try {
44184
+ await runReconciliationAccountsUpdateSettings({
44185
+ client: setup.client,
44186
+ extendTimeout: setup.extendTimeout,
44187
+ settings,
44188
+ token: opts.confirmToken,
44189
+ confirmCmd,
44190
+ serverFlag: serverKey,
44191
+ signal: setup.signal,
44192
+ out: process.stdout
44193
+ });
44194
+ } finally {
44195
+ setup.cleanup();
44196
+ }
43768
44197
  }
43769
- });
44198
+ );
43770
44199
  addServerFlag(updateSettingsCmd);
43771
44200
  addAccountFlag(updateSettingsCmd);
43772
44201
  const setAssigneeCmd = accounts.command("set-assignee").summary(
@@ -58473,7 +58902,7 @@ var EXTRACT_POLL_TIMEOUT_MS = 10 * 60 * 1e3;
58473
58902
  function isTerminalAiStatus(status2) {
58474
58903
  return status2 === "PROCESSED" || status2 === "FAILED";
58475
58904
  }
58476
- async function defaultSleep8(ms, signal) {
58905
+ async function defaultSleep9(ms, signal) {
58477
58906
  if (signal?.aborted === true) return;
58478
58907
  await new Promise((resolve3) => {
58479
58908
  const timer = setTimeout(resolve3, ms);
@@ -58490,7 +58919,7 @@ async function defaultSleep8(ms, signal) {
58490
58919
  async function pollExtractionStatus(client, contractFileIds, options = {}) {
58491
58920
  const interval = options.pollIntervalMs ?? EXTRACT_POLL_INTERVAL_MS;
58492
58921
  const timeout = options.timeoutMs ?? EXTRACT_POLL_TIMEOUT_MS;
58493
- const sleep4 = options.sleep ?? defaultSleep8;
58922
+ const sleep4 = options.sleep ?? defaultSleep9;
58494
58923
  const now = options.now ?? Date.now;
58495
58924
  const resolved = /* @__PURE__ */ new Map();
58496
58925
  if (contractFileIds.length === 0) return { resolved, pending: [] };
@@ -60269,15 +60698,15 @@ async function extractAsync(cfg, parts) {
60269
60698
  return { results, sourceFiles, outcomes: ordered2, abortedMidRun };
60270
60699
  }
60271
60700
  function describeUnresolvedExtraction(contractFileId, lastError, commandAborted) {
60272
- const ref = `contract_file_id ${contractFileId}`;
60701
+ const ref2 = `contract_file_id ${contractFileId}`;
60273
60702
  if (lastError !== void 0 && !isAbortError(lastError)) {
60274
60703
  const msg = lastError instanceof Error ? lastError.message : String(lastError);
60275
- return `extraction status could not be read (${ref}): ${msg}. The upload succeeded \u2014 re-check the status rather than re-uploading.`;
60704
+ return `extraction status could not be read (${ref2}): ${msg}. The upload succeeded \u2014 re-check the status rather than re-uploading.`;
60276
60705
  }
60277
60706
  if (commandAborted || lastError !== void 0 && isAbortError(lastError)) {
60278
- return `the command budget expired before extraction finished (${ref}). Raise --timeout, then re-check the status rather than re-uploading.`;
60707
+ return `the command budget expired before extraction finished (${ref2}). Raise --timeout, then re-check the status rather than re-uploading.`;
60279
60708
  }
60280
- return `still extracting when --poll-timeout expired (${ref}). Raise --poll-timeout, then re-check rather than re-uploading.`;
60709
+ return `still extracting when --poll-timeout expired (${ref2}). Raise --poll-timeout, then re-check rather than re-uploading.`;
60281
60710
  }
60282
60711
  async function extractSync(cfg, parts) {
60283
60712
  const allResults = [];
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "cliVersion": "1.6.93",
3
+ "cliVersion": "1.6.95",
4
4
  "_contract": "Resolve a command path through `aliases` (canonical prefix + alias segment -> canonical path) before any lookup. A path listed in `pendingCommands`, or whose first segment is a key of `pending`, is UNDECIDED, not unrestricted: fail closed or skip it, and never read a path's absence from `commands` as 'no requirement'. An entry carrying `denied` is UNAVAILABLE to any AGENT-SCOPED credential — one whose `scopes` claim carries at least one `resource:action` entry; a claim that is absent, empty, capability-only or wholly unparseable is UNRESTRICTED, and this mark does not apply to it — whatever else that entry says: it is not a ceiling and it is not always-available, its `denied.reason` says why and `denied.until` names the ticket that lifts it, and the absence of `requires` on it must NEVER be read as 'no requirement'. Only an entry in `commands` with an empty `requires` means always-available.",
5
5
  "commands": {
6
6
  "login": {
@@ -60,12 +60,18 @@ parameter fails in `connect()` before the vendor is called.
60
60
  synthetic. Per-customer values (columns, category or custom-field names) live only in thin files.
61
61
  Before committing, grep the lib diff for the customer's name, domain and org id.
62
62
  - **Reuse before adding.** Use the SDK kit first (`from dr_integrations_sdk import kit`: logger, query
63
- vars, HTTP client, CSV writer, paging, auth, dates), then `_core` helpers and an existing connection
63
+ vars, HTTP client, CSV writer, paging, auth, dates; on kit 1.1 also `config.is_unlimited`,
64
+ `http.json`, `http.same_origin`, `paging.gather_or_cancel`, `runtime.Deadline` and
65
+ `CsvWriter(block_bytes=)`), then `_core` helpers and an existing connection
64
66
  that fits; never a second copy. Name modules after what they do, never after who uses them. Hand the
65
67
  user a list of lib improvements you noticed (duplicated helpers, code that belongs in `_core`, a
66
- hand-written copy of a kit helper), done in their own PR. The kit comes from the runner image at
67
- runtime, not from `.sdk-ref`: publish a kit-based lib connector only once each hook's first log line
68
- in that datacenter reads `INFO:dr.sdk: hook=… kit=<version>`.
68
+ hand-written copy of a kit helper), done in their own PR.
69
+ - **Every new lib is built on the kit.** A new `lib/<vendor>/` module or thin file that defines its own
70
+ logger, query-vars merge, redaction, CSV block helper, date parser or retry loop is wrong; use the
71
+ kit call. The kit is live in every production ring, so that is never a reason to copy one. It comes
72
+ from the runner image at runtime, not from `.sdk-ref`: on a dev or test environment, check that each
73
+ hook's first log line reads `INFO:dr.sdk: hook=… kit=<version>` before publishing there, and if it
74
+ does not, get that environment's runner image updated rather than dropping the kit.
69
75
  - **Never change what another org lands.** A change to a module other orgs import must be proven
70
76
  output-neutral: their tests pass, and for text-only edits, each module's AST with docstrings stripped
71
77
  equals `origin/main`'s. A lib merge cuts new versions of every connector that bundles it; none moves
@@ -77,7 +83,9 @@ parameter fails in `connect()` before the vendor is called.
77
83
  the header-once, one-block-per-page writer and takes `csv.writer` dialect kwargs
78
84
  (`lineterminator="\n"`, `quoting=`), but writes each value as `csv.writer` does (`str()`, `None` as an
79
85
  empty cell), so the engine still pre-formats every cell the way the landed file shows (`true`, not
80
- `True`).
86
+ `True`). Kit 1.1's `CsvWriter(cell=kit.csv.cell)` is a generic formatter, not a parity one: use it
87
+ only when its output matches the landed file byte for byte, else pass the engine's own function as
88
+ `cell=`.
81
89
  - **Credentials resolve at runtime from the datasource** (`get_credentials`), never from code, tests
82
90
  or logs (`tech/security.md` §4). Check a migrated credential's shape without printing it; a stored
83
91
  header value may carry its scheme (`Basic <base64>`).
@@ -187,28 +187,45 @@ Those carry org-specific config, may lag the current SDK/runtime contract, and c
187
187
  drift — the samples are the single maintained source of truth. At most read an existing one to sanity-check
188
188
  one behavior; never use it as the starting point to copy from.
189
189
 
190
- **Shared helpers come from the SDK's connector kit — never copy them, from a sample or anywhere else.**
191
- A template gives you the structure; the logger, config, retry, CSV and paging code comes from
190
+ **Every new connector is built on the SDK's connector kit. Shared helpers come from the kit — never
191
+ copy them, from a sample or anywhere else.** This is required, not a preference: a new connection or
192
+ dataquery that defines its own `_get_logger`, `_query_vars`, `_safe_snippet`, `_csv_bytes` /
193
+ `_csv_block`, `_parse_date` or a `for attempt in range` retry loop is wrong; replace it with the kit
194
+ call below before it is reviewed. A template gives you the structure; the logger, config, retry, CSV
195
+ and paging code comes from
192
196
  `from dr_integrations_sdk import kit` (import the package, not `kit.csv`/`kit.logging` by name — they
193
- would shadow the stdlib). `sample_http_vendor` uses it; `sample_streaming_fanout` and `lib/sample_vendor`
194
- still carry hand-written `_get_logger` / `_query_vars` / retry helpers, so swap those for the kit when you
195
- copy them. Reference: dr-connectors-runner `docs/connector-kit.md`.
197
+ would shadow the stdlib). Every template is built on it (`sample_http_vendor`, `sample_streaming_fanout`,
198
+ `lib/sample_vendor`), so a copy starts with no hand-written helpers. Reference: dr-connectors-runner
199
+ `docs/connector-kit.md`.
196
200
 
197
201
  | Need | Kit |
198
202
  |---|---|
199
203
  | a logger whose lines survive the sandbox | `kit.logging.get_logger("<vendor>__<dq>")` |
200
204
  | the merged config channels, required variables | `kit.config.query_vars(req)`, `kit.config.require(vars, *names, context=)` (raises `PermanentError` naming each missing one) |
205
+ | *(1.1)* a cap variable that may mean "no cap" | `kit.config.is_unlimited(value)`: `None`, `""`, `0` and the string `"0"` all mean unlimited |
201
206
  | vendor text in a log line or an error | `kit.redact.safe_snippet` / `log_safe` (redacts credentials); `one_line` for ids and cursors |
202
207
  | HTTP with retry and 4xx classification | `kit.http.client(...)`, an `httpx.AsyncClient` (every httpx kwarg passes through): retries 429 (honouring `Retry-After`), 500/502/503/504 and transport errors; 401/403 raise `AuthError`, any other 4xx or an unfollowed redirect `PermanentError`; a 429/5xx still failing after the last attempt raises `httpx.HTTPStatusError` (runtime retry). `raise_for_status=False` hands 4xx back to you |
208
+ | *(1.1)* a JSON body you must not misread as an empty page | `kit.http.json(response, expect=list)`: `PermanentError` with a redacted snippet when the body is not JSON or not that type |
209
+ | *(1.1)* a vendor next link fetched with the credential | `kit.paging.cursor_pages(page, same_origin=base_url)` refuses a link to another scheme, host or port before fetching it; `kit.http.same_origin(url, base)` / `require_same_origin` for links you follow yourself; *(1.2)* `kit.http.client(base_url=..., same_origin_only=True)` refuses every request off that origin, redirect hops included |
203
210
  | the CSV header once, one block per page | `kit.csv.CsvWriter(fields).blocks(req, pages)`; `.rows` counts rows written |
211
+ | *(1.1)* many small pages | `CsvWriter(fields, block_bytes=kit.csv.BLOCK_BYTES)` coalesces them into ~4 MB blocks, far under Azure's 50,000-block limit |
212
+ | *(1.1)* typed values as CSV cells | `CsvWriter(fields, cell=kit.csv.cell)`: bool `true`/`false`, `Decimal` without exponent, tz-aware datetime as naive UTC, bytes hex, dict/list compact JSON. Not on a Workato migration: there the cell shape comes from the landed file (see *create-connector-lib*) |
204
213
  | paging that fails loudly | `kit.paging.cursor_pages` / `offset_pages`; `kit.paging.waves(items, worker, concurrency=)` |
214
+ | *(1.1)* a handful of concurrent vendor calls | `kit.paging.gather_or_cancel(awaitables)`: cancels the siblings on the first failure and re-raises it unwrapped. Never `asyncio.TaskGroup`: its `ExceptionGroup` turns a `PermanentError` into 5 retries |
215
+ | *(1.1)* the hook's time budget | `deadline = kit.runtime.Deadline("<vendor> <dq>")` in `fetch`: *(1.2)* it counts from the runner's own start of the hook and stops 60 s short of the timeout the runner kills at, watching CPU time too. `deadline.check(progress)` per page, `await deadline.sleep(s, progress)` for poll waits, and `kit.http.client(deadline=deadline)` for retry waits. Each raises `PermanentError` naming the progress |
205
216
  | credentials and tokens | `kit.auth.credential(creds, *keys, vendor=)`, `client_credentials` / `password_grant` → `Token.header()`, `basic_header`, `bearer_header` |
206
217
  | a date window | `kit.dates.date_window(vars, start_keys=, end_keys=, required=)`, `DateWindow.chunks(days)`, `parse_date` |
207
218
 
208
- **Publish a kit connector only where the kit is live.** At runtime the kit comes from the **runner
209
- image**, not from `.sdk-ref` (that pins only what CI tests against). An image with the kit prints
210
- `INFO:dr.sdk: hook=… correlation_id=… kit=<version>` as each hook's first log line; until that line
211
- shows in the target datacenter, a connector that imports `kit` fails at import there.
219
+ **Kit 1.2 is live in every production ring** (runner image `connectors-runner-base` v1.35.0, since
220
+ 2026-10-02), so every row above is usable there and the kit is never a reason to fall back to copied
221
+ helpers. The *(1.1)* / *(1.2)* marks say which kit version a row needs, for an environment whose runner
222
+ image is older.
223
+ At runtime the kit comes from the **runner image**, not from `.sdk-ref` (that pins only what CI tests
224
+ against). An image with the kit prints
225
+ `INFO:dr.sdk: hook=… correlation_id=… kit=<version>` as each hook's first log line. On a dev or test
226
+ environment, check for that line before publishing there; if it is missing, a connector that imports
227
+ `kit` fails at import, so get that environment's runner image updated — do not rewrite the connector
228
+ without the kit.
212
229
 
213
230
  **Batching & output — the non-negotiable pattern (get this right FIRST, before writing fetch).**
214
231
 
@@ -302,9 +319,10 @@ Two more sandbox ceilings worth knowing, both undocumented elsewhere:
302
319
  ThreadPoolExecutor **never reaps its threads** — so an unbounded pool can claim ~256 MB of the
303
320
  512 MB and never give it back. That is why a brief concurrency spike used to poison every *later*
304
321
  phase of a run with `RuntimeError: can't start new thread` and SSL allocation failures long after
305
- the spike finished. Cap it once at the top of the fetch:
306
- `asyncio.get_running_loop().set_default_executor(ThreadPoolExecutor(max_workers=4))`.
307
- - **`RLIMIT_CPU = 60s`** sits right next to the memory limit in `sandbox_entry.py`. Exceeding it
322
+ the spike finished. **The runner now caps it at 8 threads** before every hook
323
+ (`sandbox_entry.py` `SANDBOX_EXECUTOR_THREADS`, DR-53651), so a connector no longer installs its own
324
+ `set_default_executor(...)`; remove one when you touch the file.
325
+ - **`RLIMIT_CPU = 2700s`** sits right next to the memory limit in `sandbox_entry.py`. Exceeding it
308
326
  SIGKILLs the hook (runner reports `sandbox exited -24`) with no `MemoryError` to explain it. Async
309
327
  I/O waiting is free, but TLS handshakes, gzip and JSON parsing are not — log
310
328
  `time.process_time()` alongside wall clock so you can see how close you are.
@@ -864,8 +882,8 @@ unmerged connector has no version to ingest, bind, or run.
864
882
  | Confused about publish vs ingest | CI publishes to JFrog on merge (cuts the git tag `vN`); the CLI ingests a published version into a DC | `dr connectors publish <path/to/dataqueries/<dq>.py> --server DEV --account <customer-user-email> --json` reads the newest git tag (`git fetch --tags` + max `vN`, or `--tag vN`) — **no local compose** — then the control-plane **pulls that tag from JFrog** (no upload) and registers it (needs `NODE_EXTRA_CA_CERTS`); returns `{name, version: "vN", digest}`. |
865
883
  | Run `Failed`, `connect` completion `verdict: ok` but `fetch` completion `verdict: retry`, error `'async for' requires an object with __aiter__ method, got function` | Passed the async-generator **function** to `write_output` — `write_output(req, gen)` instead of **calling** it `write_output(req, gen())`. `write_output` does `async for chunk in <arg>`, which needs an async **iterator**, not a function. (The connect gate passing but fetch dying instantly is the tell.) | Call the generator: `write_output(req, gen())`. The golden template already shows `write_output(req, rows())` / `write_output(req, blocks())` — match it exactly; the missing `()` is easy to drop. |
866
884
  | Vendor **4xx** (e.g. `402 Payment Required`, `401`, `403`, bad-symbol `404`) makes the `fetch` completion `verdict: retry` and the run **retries forever**; the HTTP status shows ONLY in the completion store, never in the Inngest run `output` (which is `null`). | A bare `r.raise_for_status()` raises `httpx.HTTPStatusError` for **every** status ≥ 400, and the runtime classifies that as **transient → retry**. But a plan/auth/bad-request 4xx will never succeed on retry. | Use `kit.http.client` (skill §1): 401/403 raise `AuthError` and any other 4xx `PermanentError`, each naming the status and a redacted body; 429 and 5xx are retried, then left to the runtime's retry (genuinely transient). Concrete case: FMP's free tier answers **402** when `limit` exceeds its historical allowance (dev key caps statements at the last **5** annual records) — a small `limit` or a paid key fixes that one; the PermanentError stops the infinite loop for the general case. The golden `sample_http_vendor` template is built on this client. |
867
- | Connector works perfectly in local/dev testing, then in production `fetch` dies with `MemoryError`, `MemoryError: Unable to allocate output buffer`, an SSL/crypto allocation failure, or `RuntimeError: can't start new thread` — often within a second of "fetch start", and deterministically on every one of the 5 attempts | The sandbox caps **virtual address space at `RLIMIT_AS` = 512 MB** (`dr-connectors-runner/src/runner/sandbox_entry.py`), and the runner pod's limit is also 512Mi. The guard is `if sys.platform != "win32"`, so **the ceiling does not exist on a Windows dev box** — a buffering connector cannot fail locally. Two causes, usually together: (a) the fetch accumulates the result set (a `rows` list, `asyncio.gather` over every parent, a full CSV string + its `.encode()` copy) — journal_lines measured **378 MB live to emit 67 MB**, 243 MB of it row dicts at 846 B/row; (b) asyncio's **default** DNS ThreadPoolExecutor grew to `min(32, node_cores + 4)` threads × 8 MB stack ≈ **256 MB of address space that is never reclaimed**, which is why a brief concurrency spike poisons every later phase of the run. | Stream: do the fetching **inside** the generator passed to `write_output`, replace gather-over-everything with a bounded worker pool feeding a bounded `asyncio.Queue`, format rows to CSV bytes immediately (never hold dicts), and re-batch to ~4 MB blocks. Cap the executor once: `asyncio.get_running_loop().set_default_executor(ThreadPoolExecutor(max_workers=4))`. Full rules in §1 *Memory*; working template `samples/sample_streaming_fanout/`. Reproduce locally in `python:3.12-slim` under the same rlimits — never trust a Windows run for this. |
868
- | Hook dies with no traceback and no `MemoryError`; runner reports `sandbox exited -24` | **`RLIMIT_CPU = 60s`** (`sandbox_entry.py`, right beside the memory limit) — 60 seconds of **CPU** time, then SIGXCPU/SIGKILL. Async I/O waiting is free, but TLS handshakes, gzip and JSON parsing across thousands of requests are not. | Cut CPU per row (fewer/larger vendor pages, `onlyData` where the vendor supports it, no per-row dict churn) and log `time.process_time()` next to wall clock so proximity to the cap is visible before it kills a run. |
885
+ | Connector works perfectly in local/dev testing, then in production `fetch` dies with `MemoryError`, `MemoryError: Unable to allocate output buffer`, an SSL/crypto allocation failure, or `RuntimeError: can't start new thread` — often within a second of "fetch start", and deterministically on every one of the 5 attempts | The sandbox caps **virtual address space at `RLIMIT_AS` = 512 MB** (`dr-connectors-runner/src/runner/sandbox_entry.py`), and the runner pod's limit is also 512Mi. The guard is `if sys.platform != "win32"`, so **the ceiling does not exist on a Windows dev box** — a buffering connector cannot fail locally. Two causes, usually together: (a) the fetch accumulates the result set (a `rows` list, `asyncio.gather` over every parent, a full CSV string + its `.encode()` copy) — journal_lines measured **378 MB live to emit 67 MB**, 243 MB of it row dicts at 846 B/row; (b) asyncio's **default** DNS ThreadPoolExecutor grew to `min(32, node_cores + 4)` threads × 8 MB stack ≈ **256 MB of address space that is never reclaimed**, which is why a brief concurrency spike poisons every later phase of the run. | Stream: do the fetching **inside** the generator passed to `write_output`, replace gather-over-everything with a bounded worker pool feeding a bounded `asyncio.Queue`, format rows to CSV bytes immediately (never hold dicts), and re-batch to ~4 MB blocks (`kit.csv.CsvWriter(..., block_bytes=kit.csv.BLOCK_BYTES)` on kit 1.1). The executor cap (b) is now set by the runner (8 threads); a connector installs none. Full rules in §1 *Memory*; working template `samples/sample_streaming_fanout/`. Reproduce locally in `python:3.12-slim` under the same rlimits — never trust a Windows run for this. |
886
+ | Hook dies with no traceback and no `MemoryError`; runner reports `sandbox exited -24` | **`RLIMIT_CPU = 2700s`** (`sandbox_entry.py`, right beside the memory limit) — 2700 seconds of **CPU** time, summed over all threads, then SIGXCPU/SIGKILL. Async I/O waiting is free, but TLS handshakes, gzip and JSON parsing across thousands of requests are not. | Cut CPU per row (fewer/larger vendor pages, `onlyData` where the vendor supports it, no per-row dict churn) and log `time.process_time()` next to wall clock so proximity to the cap is visible before it kills a run. |
869
887
  | A sync `Completed` and the file **looks** complete, but the customer's totals are short | A page-limit "safety cap" (`_LINE_PAGE_LIMIT`, `_HEADER_PAGE_LIMIT`, …) tripped and the code **logged a warning and returned what it had**, so a partial result landed as if whole. Same failure class as a missing offset follow-up: journal_lines silently dropped **113,171 rows** of one customer's month this way. | Caps must `raise PermanentError`, never warn-and-return — a failed sync is recoverable, a silently partial landing gets reconciled against and believed. Audit every `log.warning` that is followed by `return`. |
870
888
 
871
889
  ## Env quick reference (dev germany)
@@ -611,7 +611,10 @@ network traffic or hand-edits JSON. Full recipes in the relevant reference file.
611
611
  date to whatever granularity the filebox's own DATE tag actually uses —
612
612
  no need to know if it's yearly/quarterly/monthly/daily/weekly. `--tag` is
613
613
  an unvalidated escape hatch that forwards straight to the server; a
614
- malformed value 500s with no error body. See reference/filebox-documents.md.
614
+ malformed value 500s with no error body. To know when it finished, add
615
+ `--wait` — never poll `status` or the version count: an unchanged result
616
+ writes no new version and still reads `SYNCED`. See
617
+ reference/filebox-documents.md.
615
618
  - **"User has no table permissions" is almost always a misread.** To see which
616
619
  tables a user can access, use `dr users permissions get <id>` — never the raw
617
620
  `GET /api/it/users/<id>` object's `permissions` field. That field is Django
@@ -169,8 +169,12 @@ set-version-include` (below).
169
169
  only allow-listed keys are accepted (currently `is_bank_integration`); unknown
170
170
  keys are rejected client-side (the server silently accepts dead keys).
171
171
  - `filebox data-query get|set|clear|sync <fb_id>` — read/write/delete/poke the
172
- Workato data-query. `get` shows `sync_interval` (cron) + `last_sync`; `set
173
- --payload-file` PUTs a create/update body; `clear` is two-step.
172
+ Workato data-query. `get` shows `sync_interval` (cron), `last_sync` and
173
+ `Last start` (when the latest sync started); `set --payload-file` PUTs a
174
+ create/update body; `clear` is two-step. `last_sync` doubles as the
175
+ auto-sync debounce, so it can read minutes in the future. Older servers do
176
+ not return either timestamp: `last_sync` then reads `never` and `Last start`
177
+ is omitted.
174
178
  - `filebox data-query sync <fb_id> [--date YYYY-MM-DD]` — trigger an async
175
179
  sync, optionally scoped to specific period(s). `--date` is repeatable and
176
180
  takes one plain calendar date — the CLI reads the filebox's own DATE
@@ -195,6 +199,32 @@ set-version-include` (below).
195
199
  constant — varies per tenant) and the epoch just needs to fall within the
196
200
  target period (the server normalizes it). Prefer `--date`; reach for raw
197
201
  `--tag` only when it doesn't cover your case.
202
+ - **Waiting for a sync: `filebox data-query sync <fb_id> --date … --wait`.**
203
+ Never poll `status` or the version count yourself — both lie:
204
+ - With `sync_only_changes` (the default), a sync whose output is identical
205
+ to the stored latest version for that period writes **no new version**.
206
+ `status` still goes to `SYNCED`, and the version count never moves. This
207
+ is not an error.
208
+ - Right after a sync is queued, `status` can still show the previous run's
209
+ value (or `-`) until the sync starts. A loop that waits for "not
210
+ SYNCING" exits at once.
211
+ - A multi-period sync flips `status` to `SYNCED` after each period, so
212
+ "status is SYNCED" can mean one period of several.
213
+ - A premature exit followed by `filebox download` silently returns the
214
+ **previous** version.
215
+
216
+ `--wait` snapshots each `--date` period's versions before triggering, then
217
+ polls until every period either gained a new version (**new version**) or
218
+ had its latest version re-confirmed — the server bumps that version's
219
+ `last_sync` instead of writing one (**unchanged**). It exits non-zero when
220
+ the data-query goes to `ERROR` (with the server's `last_sync_error`) or on
221
+ `--timeout <seconds>` (default 1800). `--json` prints one object:
222
+ `{outcome, periods: [{period, result: new_version|no_change|pending,
223
+ version_id, version}], unmatched_new_versions, error, waited_seconds}`.
224
+ Without `--date`, it waits for the first period to finish. It cannot be
225
+ combined with `--tag`. One blind spot: a sync that returns **no rows**
226
+ writes no version and re-confirms none, so it ends in a timeout whose
227
+ message says so — check `dr filebox data-query get <fb_id>`.
198
228
  - `filebox data-query set-sync-interval <fb_id> --cron "<expr>"` — change how
199
229
  often the data-query polls (Workato-backed **and** connector-platform /
200
230
  `DatarailsConnector` fileboxes both use this cron). **Use this for sync
@@ -22,7 +22,14 @@ Account assignment / sign-off (all take the bank-account UUID via
22
22
 
23
23
  - `reconciliation accounts update-settings --file <p>` — admin-only PATCH.
24
24
  Two-step confirm; the token binds to a hash of the file's rows, so editing
25
- the file between plan and confirm invalidates it.
25
+ the file between plan and confirm invalidates it. Rows are matched to the
26
+ tenant by `(entity, account_group, name)`; only rows that differ are sent,
27
+ in PATCHes of at most 256 KB (one whole-tenant PATCH near 1 MB returns
28
+ HTTP 500). The plan reports rows to change, rows skipped as already
29
+ matching, and the changes by target frequency (`Exclude` is how an account
30
+ is excluded from reconciliation). If a PATCH fails, the error says how many
31
+ rows landed; re-run the same file without a token and the new plan covers
32
+ only the rest.
26
33
  - `reconciliation accounts set-assignee <account_uuid> --date <YYYY-MM-DD>
27
34
  --type Preparer|Reviewer --user-id <n> [--user-name <s> --user-email <s>]` —
28
35
  resets any prior sign-off; name/email are auto-fetched if not supplied.
@@ -38,7 +45,12 @@ Account assignment / sign-off (all take the bank-account UUID via
38
45
  resets. Every account is checked before anything is written, and the
39
46
  write goes out in PATCHes of 100 accounts. If one fails, the error says
40
47
  how many accounts landed; re-run the same file without a token and the
41
- new plan covers only the rest. Do not script a loop of single
48
+ new plan covers only the rest. Before each PATCH after the first, the
49
+ accounts are re-read; if any account in the next chunk is gone (a
50
+ trial-balance rebuild re-mints account ids) or its member slots changed
51
+ since the plan, the run halts, says how many accounts landed, and names
52
+ the drifted accounts. Gone accounts mean the file must be rebuilt from
53
+ `accounts list` once the rebuild settles. Do not script a loop of single
42
54
  `set-assignee` calls for this.
43
55
  - `reconciliation accounts clear-assignee <account_uuid> --date <YYYY-MM-DD>
44
56
  --type Preparer|Reviewer` — unassigns (preserves due_date, resets sign-off).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@datarails-ai/dr-cli",
3
- "version": "1.6.93",
3
+ "version": "1.6.95",
4
4
  "description": "Datarails CLI",
5
5
  "type": "module",
6
6
  "engines": {