conduyt 1.69.0 → 1.71.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -32,8 +32,9 @@ The saved config lives at `~/.conduyt/config.json` and is written owner-only (`c
32
32
  ```bash
33
33
  conduyt whoami # show the authenticated user
34
34
  conduyt contacts list --limit 25 # list contacts (supports --cursor, --query)
35
- conduyt contacts get <id> # fetch one contact (carries `revision` and `hasOutboundSms`, a proven send only)
35
+ conduyt contacts get <id> # fetch one contact (carries `revision`, `hasOutboundSms` and `hasOutboundEmail`: a proven send only)
36
36
  conduyt contacts update <id> --first Ann --expected-revision <n> # PATCH; a contact changed since revision n is refused: 409 contact_changed, exit 1, code and current revision printed
37
+ conduyt contacts merge <primaryId> <secondaryId...> [--confirm [--yes]] # DESTRUCTIVE: a dry run by default (prints the impact); --confirm shows it, asks y/N, then merges (the losers are deleted); --yes skips the question; moved counts 409 and 503 merge_contacts_busy / merge_budget_exhausted + Retry-After exit 1, never retried
37
38
  conduyt contacts collaborators add <contactId> <userId> [--expected-revision <n>] # POST /contacts/{id}/collaborators; prints { added, revision }
38
39
  conduyt contacts collaborators remove <contactId> <userId> [--expected-revision <n>] # DELETE with a body; prints { removed, revision }; 404 when not a collaborator
39
40
  conduyt chat channels # GET /chat/channels: your channels; also CREATES your auto-channel memberships the first time
@@ -78,7 +79,7 @@ conduyt whatsapp connect <senderSid> # connect a sender (verified with Twilio
78
79
  conduyt whatsapp templates # approved / pending / rejected templates with their {{n}} placeholders
79
80
  conduyt dialer voice-drop --contact <id> [--recording "Missed you"] [--if-human play] # a call that plays a Voicemail Drop recording to a machine
80
81
  conduyt messages send-sms --contact <id> --transport whatsapp --whatsapp-template HX… --whatsapp-variables '{"1":"Alex"}' --body "…"
81
- conduyt messages check --channel sms --body "…" [--context first|conversation] [--provider twilio] [--clean] [--body-file draft.txt] # grade a DRAFT before sending (score, grade, flagged words with safer alternatives, Unicode code-point offsets); --provider twilio (texts only) words the notices in Twilio's wording, --clean adds the cleaner's proposal; educates, never blocks, nothing stored or sent; 20 per minute; 400 / 403 / 413 / 429 exit 1 with the code
82
+ conduyt messages check --channel sms --body "…" [--context first|conversation] [--provider twilio] [--clean] [--body-file draft.txt] # grade a DRAFT before sending (score, grade, flagged words with safer alternatives, Unicode code-point offsets); --provider twilio (texts only) words the notices in Twilio's wording, --clean adds the cleaner's proposal; prints `englishDictionary` (`loaded` / `not_loaded`: the final grade is only meaningful when `loaded`) and `engine.version` (2.2.1); educates, never blocks, nothing stored or sent; 20 per minute; 400 / 403 / 413 / 429 exit 1 with the code
82
83
  conduyt messages minimum-grade # the admin's Message check minimum grade to send (settings.messageCheck.minimumGrade): enabled, sms / email minimum (A-F, 0-100 or null), adminOverride; groundwork, stored but not enforced yet; owner / admin only (anyone else sees visible: false)
83
84
  conduyt messages set-minimum-grade [--sms A|B|C|D|F|0-100|none] [--email ...] [--admin-override on|off] [--enabled on|off] # sends the whole block with the settings revision it read; --enabled on is refused 400 minimum_grade_not_available_yet; 403 forbidden for a non-owner / non-admin; all exit 1 with the code
84
85
  conduyt sms providers options # the workspace's active SMS providers, redacted: [{ id, name, color }] (needs email_templates.view or settings.view)
@@ -131,6 +132,8 @@ out and named in `_omitted`. Every HTTP refusal takes this path (JSON calls, tex
131
132
  Error: Conduyt API 503: Contact merge in progress [Retry-After: 5s] {"code":"merge_in_progress"}
132
133
  ```
133
134
 
135
+ The contact merge route (`POST /contacts/merge`, command `contacts merge`) answers a retryable 503 with `Retry-After` and the code `merge_contacts_busy` (a contact stayed busy for the whole lock budget) or `merge_budget_exhausted` (the request's time budget ran out), nothing changed. A command that reaches it prints the refusal in this grammar, exits 1 and does not retry; run it again after the stated seconds.
136
+
134
137
  ## License
135
138
 
136
139
  MIT
package/dist/client.js CHANGED
@@ -83,6 +83,58 @@ export function renderErrorDetails(body) {
83
83
  out._omitted = omitted;
84
84
  return JSON.stringify(out);
85
85
  }
86
+ const RA_MAX_SECONDS = 2_147_483_647; // delay-seconds is unbounded 1*DIGIT: a longer value saturates here instead of becoming null
87
+ const RA_MONTHS = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
88
+ const RA_MON = "Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec";
89
+ const RA_IMF = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (\\d{2}) (${RA_MON}) (\\d{4}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`);
90
+ const RA_850 = new RegExp(`^(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (\\d{2})-(${RA_MON})-(\\d{2}) (\\d{2}):(\\d{2}):(\\d{2}) GMT$`);
91
+ const RA_ASC = new RegExp(`^(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (${RA_MON}) ([ \\d]\\d) (\\d{2}):(\\d{2}):(\\d{2}) (\\d{4})$`);
92
+ /** A UTC timestamp for the parts, or NaN when any part is out of range (31 Feb, 25:00, ...). */
93
+ function raUtc(y, mo, d, h, mi, s) {
94
+ if (y < 1900 || h > 23 || mi > 59 || s > 60)
95
+ return NaN; // RFC 5322: years below 1900 are not dates
96
+ const t = Date.UTC(y, mo, d, h, mi, Math.min(s, 59));
97
+ const back = new Date(t);
98
+ return back.getUTCFullYear() === y && back.getUTCMonth() === mo && back.getUTCDate() === d ? t : NaN;
99
+ }
100
+ /**
101
+ * The ONE Retry-After parser. RFC 9110: delay-seconds (digits; a value too long for a number saturates at 2^31-1) or an
102
+ * HTTP-date in one of the three §5.6.7 forms, parsed explicitly and always as UTC (never the host timezone): IMF-fixdate,
103
+ * RFC 850 (two-digit year: more than 50 years ahead of `nowMs` means the previous century) and asctime. A date becomes the
104
+ * remaining seconds, floored at 0. Absent, blank or anything else (ISO timestamps, other zones, "-3") is null.
105
+ */
106
+ export function parseRetryAfter(raw, nowMs = Date.now()) {
107
+ if (raw === null || raw === undefined)
108
+ return null;
109
+ const v = raw.trim();
110
+ if (v === "")
111
+ return null;
112
+ if (/^\d+(\.\d+)?$/.test(v)) {
113
+ const n = Number(v);
114
+ return Number.isNaN(n) ? null : Math.min(n, RA_MAX_SECONDS);
115
+ }
116
+ let t = NaN;
117
+ let m;
118
+ if ((m = RA_IMF.exec(v)))
119
+ t = raUtc(Number(m[3]), RA_MONTHS[m[2]], Number(m[1]), Number(m[4]), Number(m[5]), Number(m[6]));
120
+ else if ((m = RA_850.exec(v))) {
121
+ const century = Math.floor(new Date(nowMs).getUTCFullYear() / 100) * 100;
122
+ const mo = RA_MONTHS[m[2]], d = Number(m[1]), h = Number(m[4]), mi = Number(m[5]), sec = Number(m[6]);
123
+ t = raUtc(century + Number(m[3]), mo, d, h, mi, sec);
124
+ // RFC 9110: a timestamp more than 50 years in the future (the same instant 50 calendar years on, UTC) is the previous century
125
+ // (the day is clamped to the month's last day: 29 Feb + 50 years is 28 Feb, never 1 Mar)
126
+ const n = new Date(nowMs);
127
+ const ly = n.getUTCFullYear() + 50, lm = n.getUTCMonth();
128
+ const limit = Date.UTC(ly, lm, Math.min(n.getUTCDate(), new Date(Date.UTC(ly, lm + 1, 0)).getUTCDate()), n.getUTCHours(), n.getUTCMinutes(), n.getUTCSeconds(), n.getUTCMilliseconds());
129
+ if (!Number.isNaN(t) && t > limit)
130
+ t = raUtc(century - 100 + Number(m[3]), mo, d, h, mi, sec);
131
+ }
132
+ else if ((m = RA_ASC.exec(v)))
133
+ t = raUtc(Number(m[6]), RA_MONTHS[m[1]], Number(m[2].trim()), Number(m[3]), Number(m[4]), Number(m[5]));
134
+ if (Number.isNaN(t))
135
+ return null;
136
+ return Math.max(0, Math.ceil((t - nowMs) / 1000));
137
+ }
86
138
  /**
87
139
  * The ONE place an HTTP refusal becomes an Error (request, postText and stream all use it). Grammar (stable):
88
140
  * `Conduyt API <status>: <message>[ [Retry-After: <N>s]][ <details>]`; <details> is ONE JSON object and the FINAL token
@@ -90,9 +142,8 @@ export function renderErrorDetails(body) {
90
142
  */
91
143
  export function formatHttpError(res, text) {
92
144
  const json = text ? safeParse(text) : null;
93
- const ra = res.headers.get("retry-after");
94
- const raN = ra === null || ra.trim() === "" ? NaN : Number(ra);
95
- const raTag = Number.isFinite(raN) && raN >= 0 ? ` [Retry-After: ${raN}s]` : "";
145
+ const raN = parseRetryAfter(res.headers.get("retry-after"));
146
+ const raTag = raN !== null ? ` [Retry-After: ${raN}s]` : "";
96
147
  const obj = json && typeof json === "object" && !Array.isArray(json) ? json : null;
97
148
  if (obj && typeof obj.error === "string" && obj.error) {
98
149
  const extras = renderErrorDetails(obj);
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { Command, CommanderError } from "commander";
7
7
  import { ConduytClient, buildQuery } from "./client.js";
8
8
  import { minimumGradeOf, minimumGradePatch, parseGradeFlag } from "./message-check-minimum.js";
9
9
  import { splitList, stageRulePatch, validateCardFields, validateRuleChange } from "./board-settings.js";
10
- import { ARCHIVE_PIPELINE_TIMEOUT_MS, MAILCHIMP_ADMIN_TIMEOUT_MS, STATUS_METADATA_IMPORT_TIMEOUT_MS, PIPELINE_COLORS, boolFlag, intFlag, isoDate, lifecycleStage as lifecycleStageArg, MAX_PIPELINE_ROT_DAYS, MIN_PIPELINE_ROT_DAYS, pipelineColor, settingsRevision, slug as slugArg, uuid as uuidOf, uuidList, validateImportItems, validateStatusMetadataBody } from "./pipeline-settings.js";
10
+ import { ARCHIVE_PIPELINE_TIMEOUT_MS, MERGE_TIMEOUT_MS, MAILCHIMP_ADMIN_TIMEOUT_MS, STATUS_METADATA_IMPORT_TIMEOUT_MS, PIPELINE_COLORS, boolFlag, intFlag, isoDate, lifecycleStage as lifecycleStageArg, MAX_PIPELINE_ROT_DAYS, MIN_PIPELINE_ROT_DAYS, pipelineColor, settingsRevision, slug as slugArg, uuid as uuidOf, uuidList, validateImportItems, validateStatusMetadataBody } from "./pipeline-settings.js";
11
11
  import { saveConfig, resolveConfig, configPath } from "./config.js";
12
12
  import { print, fail, unwrap } from "./output.js";
13
13
  import { callsPage, historyPage } from "./dialer-pages.js";
@@ -74,6 +74,7 @@ program
74
74
  .description("Show the authenticated user")
75
75
  .action(run(async (client) => client.get("/api/v1/users/me")));
76
76
  // ---- contacts ----
77
+ const LOSER_ORDER_NOTE = "losers are merged in the order given: for each field the survivor lacks, the first loser in this order with a value wins; reorder to change precedence; the dry run's filledFields shows which fields will be filled";
77
78
  const contacts = program.command("contacts").description("Manage contacts");
78
79
  contacts
79
80
  .command("list")
@@ -86,7 +87,7 @@ contacts
86
87
  .action(run(async (client, opts) => client.get(`/api/v1/contacts${buildQuery({ limit: opts.limit, cursor: opts.cursor, query: opts.query, master_status: opts.masterStatus, exclude_master_status: opts.excludeMasterStatus })}`)));
87
88
  contacts
88
89
  .command("get <id>")
89
- .description("Get a single contact by id (includes `revision`: pass it as --expected-revision on `contacts update` / `contacts collaborators`; `hasOutboundSms` is true when the workspace has ever PROVABLY texted the contact: a proven send only, not a draft, a queued row or a pre-send failure; use it to pick `messages check --context`: false = first, true = conversation)")
90
+ .description("Get a single contact by id (includes `revision`: pass it as --expected-revision on `contacts update` / `contacts collaborators`; `hasOutboundSms` is true when the workspace has ever PROVABLY texted the contact: a proven send only, not a draft, a queued row or a pre-send failure; use it to pick `messages check --context`: false = first, true = conversation; `hasOutboundEmail` is the same for email: true when at least one outbound email to the contact was proven sent, so a genuine \"Re:\" reply is a conversation and not flagged)")
90
91
  .action(run(async (client, id) => client.get(`/api/v1/contacts/${encodeURIComponent(id)}`)));
91
92
  contacts
92
93
  .command("create")
@@ -213,6 +214,66 @@ contacts
213
214
  assertUuid(id, "contact id");
214
215
  return client.del(`/api/v1/contacts/${encodeURIComponent(id)}`);
215
216
  }));
217
+ contacts
218
+ .command("merge <primaryId> <secondaryIds...>")
219
+ .description("DESTRUCTIVE merge of duplicate contacts (POST /contacts/merge; owner or admin): <primaryId> survives and receives the records of every <secondaryIds> contact, which are then DELETED (no undelete). By default a DRY RUN: nothing changes, it prints the impact { primary, secondaries, counts, totalRecords, guardCounts, filledFields, identifierOverflow, blockers }. --confirm repeats the dry run, shows the impact (the records that move, and separately the guard counts: chat messages, quick notes, identifiers, outbox rows, which are not in the records total), asks y/N and then merges, sending the values that dry run returned (expectedCounts, expectedGuardCounts, expectedIdentifiers). LOSER ORDER: losers are merged in the order given: for each field the survivor lacks, the first loser in this order with a value wins; reorder to change precedence; the dry run's filledFields shows which fields will be filled. WHAT IS PINNED: the server refuses (409) if the record counts, guard counts or identifier plan moved; other field edits made after this preview (a loser's name, company, source, address) are not detected; --yes skips the question (scripts; needs --confirm). A refusal exits 1 with the status, the API's message and its code and is NEVER retried: 409 \"Merge impact changed\" (preview again), 409 queued work, 400 / 404, and 503 merge_contacts_busy / merge_budget_exhausted with `[Retry-After: <N>s]` (nothing changed: wait that long, then run again).")
220
+ .option("--confirm", "perform the merge (after the dry run's impact and a y/N question) instead of only previewing")
221
+ .option("--yes", "skip the y/N question (non-interactive; needs --confirm)")
222
+ .action(run(async (client, primaryId, secondaryIds, opts) => {
223
+ assertUuid(primaryId, "primaryId");
224
+ for (const id of secondaryIds)
225
+ assertUuid(id, "secondaryIds");
226
+ if (opts.yes && !opts.confirm)
227
+ throw new Error("--yes only skips the question of --confirm: add --confirm to merge, or drop --yes to preview.");
228
+ const preview = unwrap(await client.post("/api/v1/contacts/merge", { primaryId, secondaryIds, dryRun: true }, { timeoutMs: MERGE_TIMEOUT_MS }));
229
+ if (!opts.confirm) {
230
+ process.stderr.write("Dry run only: nothing was merged. Re-run with --confirm to merge.\n");
231
+ return preview;
232
+ }
233
+ const name = (c) => {
234
+ const r = (c ?? {});
235
+ return `${[r.firstName, r.lastName].filter(Boolean).join(" ") || "(no name)"} <${r.email ?? r.phone ?? r.id}> ${r.id}`;
236
+ };
237
+ const guard = (preview.guardCounts ?? {});
238
+ const guardTotal = Object.values(guard).reduce((n, v) => n + (typeof v === "number" ? v : 0), 0);
239
+ const lines = [
240
+ `Merge into: ${name(preview.primary)}`,
241
+ ...secondaryIds.map((id, i) => {
242
+ const row = (preview.secondaries ?? []).find((c) => String(c.id).toLowerCase() === id.toLowerCase());
243
+ return ` DELETE (fill priority ${i + 1}): ${row ? name(row) : id}`;
244
+ }),
245
+ `Order: ${LOSER_ORDER_NOTE}`,
246
+ `Records moving to the survivor: ${preview.totalRecords ?? "?"} ${JSON.stringify(preview.counts ?? {})}`,
247
+ `Guard counts (separate from the records above, not in that total): ${guardTotal} ${JSON.stringify(guard)}`,
248
+ `Fields the survivor gains: ${JSON.stringify(preview.filledFields ?? [])}`,
249
+ `Identifiers: ${JSON.stringify(preview.identifierOverflow ?? {})}`,
250
+ ];
251
+ const blockers = preview.blockers?.outbox ?? [];
252
+ if (blockers.length > 0)
253
+ lines.push(`Queued work that will block the merge: ${JSON.stringify(blockers)}`);
254
+ lines.push("The server refuses if the record counts, guard counts or identifier plan moved; other field edits made after this preview are not detected.");
255
+ process.stderr.write(lines.join("\n") + "\n");
256
+ if (!opts.yes) {
257
+ const { createInterface } = await import("node:readline/promises");
258
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
259
+ // EOF with no answer (a closed stdin) counts as "no"
260
+ const answer = await new Promise((resolve) => {
261
+ rl.once("close", () => resolve(""));
262
+ rl.question("Merge these contacts? The losers are deleted. [y/N] ").then(resolve, () => resolve(""));
263
+ });
264
+ rl.close();
265
+ if (!/^y(es)?$/i.test(answer.trim()))
266
+ throw new Error("Cancelled: nothing was merged.");
267
+ }
268
+ return client.post("/api/v1/contacts/merge", {
269
+ primaryId,
270
+ secondaryIds,
271
+ confirmImpact: true,
272
+ expectedCounts: preview.counts,
273
+ expectedGuardCounts: preview.guardCounts,
274
+ ...(preview.identifierOverflow ? { expectedIdentifiers: preview.identifierOverflow } : {}),
275
+ }, { timeoutMs: MERGE_TIMEOUT_MS });
276
+ }));
216
277
  // ---- deals ----
217
278
  contacts
218
279
  .command("delete-matching")
@@ -1852,7 +1913,7 @@ messages
1852
1913
  }));
1853
1914
  messages
1854
1915
  .command("check")
1855
- .description("Grade a DRAFT text or email before sending (POST /messages/check): the same check the contact composers run as the rep types. Prints the route's data (the standard envelope is unwrapped): { score 0-100, grade A-F, verdict, vclass, hits, subjectHits, checks, sms (a text's chars / segments / unicode / perSeg; null for an email), context, notices (SMS; informational), breakdown (the score's bands, offsets in code points), unicodeCulprits (SMS; the characters that force Unicode), cleaned (only with --clean), engine { version, updatedAt } } (engine 2.1.2 today; any further field is additive). Each hit: { start, end, word, level (r likely blocked, a risky, y caution, n heads up), label, cat, why, alts (safer wording; some are advice rather than a swap), restricted (prohibited in US business texting: no rewording makes it deliverable), rule }. start / end are Unicode CODE-POINT offsets into the submitted text (the body for hits, the subject for subjectHits), end exclusive; an emoji counts as one, so [...text].slice(start, end) in JavaScript or text[start:end] in Python is the word (not a UTF-16 index). The check EDUCATES and never blocks: nothing is stored and nothing is sent; merge tokens such as {{contact.firstName}} are never flagged. --context template (default) grades a reusable message, first the first text to a contact, conversation a reply in an existing thread (the next engine release skips the opt-out and business-name checks for first / conversation; today every context scores the same); `contacts get <id>` carries hasOutboundSms to choose. --provider twilio (a text only) words the notices in Twilio's own wording, as the composers show them; an email with a provider, or any other value, is a 400 invalid_body (`sms providers options` lists the workspace's providers; only twilio is a check wording today). --clean adds `cleaned` { text, subject (email), changes, skipped } or null: the cleaner's PROPOSAL, never stored or applied. Limits: body 10,000 characters, subject 300, request 48 KB; 20 checks per minute per account. Every refusal exits 1 with the status, the API's message and its code, sent once and never retried: 400 invalid_body, 403 forbidden (the key's member may view neither contacts nor email templates, or the key's scopes do not reach the route: needs the contacts, emails, sms or messages scope, a read-tier key too), 413 message_too_long / body_too_large, 429 rate_limited (Retry-After shown in the error text)")
1916
+ .description("Grade a DRAFT text or email before sending (POST /messages/check): the same check the contact composers run as the rep types. Prints the route's data (the standard envelope is unwrapped): { score 0-100, grade A-F, verdict, vclass, hits, subjectHits, checks, sms (a text's chars / segments / unicode / perSeg; null for an email), context, notices (SMS; informational), breakdown (the score's bands, offsets in code points), unicodeCulprits (SMS; the characters that force Unicode), cleaned (only with --clean), englishDictionary ('loaded' | 'not_loaded', engine 2.2.1+), engine { version, updatedAt } } (engine 2.2.1 today; any further field is additive). The final grade is only meaningful when englishDictionary is 'loaded' (the server embeds the dictionary, so it is): a result with any other value, or without the field, is provisional (the 'no recognizable English words' check did not run): treat it as unavailable, never as a grade. Each hit: { start, end, word, level (r likely blocked, a risky, y caution, n heads up), label, cat, why, alts (safer wording; some are advice rather than a swap), restricted (prohibited in US business texting: no rewording makes it deliverable), rule }. start / end are Unicode CODE-POINT offsets into the submitted text (the body for hits, the subject for subjectHits), end exclusive; an emoji counts as one, so [...text].slice(start, end) in JavaScript or text[start:end] in Python is the word (not a UTF-16 index). The check EDUCATES and never blocks: nothing is stored and nothing is sent; merge tokens such as {{contact.firstName}} are never flagged. --context template (default) grades a reusable message, first the first text to a contact, conversation a reply in an existing thread (engine 2.2.1: 'conversation' skips the SMS opt-out line and who-is-texting checks and the email fake \"Re:\" / \"Fwd:\" subject check, because a reply in an existing thread legitimately lacks them; 'template' and 'first' run all of them); `contacts get <id>` carries hasOutboundSms (hasOutboundEmail for an email) to choose. --provider twilio (a text only) words the notices in Twilio's own wording, as the composers show them; an email with a provider, or any other value, is a 400 invalid_body (`sms providers options` lists the workspace's providers; only twilio is a check wording today). --clean adds `cleaned` { text, subject (email), changes, skipped } or null: the cleaner's PROPOSAL, never stored or applied. Limits: body 10,000 characters, subject 300, request 48 KB; 20 checks per minute per account. Every refusal exits 1 with the status, the API's message and its code, sent once and never retried: 400 invalid_body, 403 forbidden (the key's member may view neither contacts nor email templates, or the key's scopes do not reach the route: needs the contacts, emails, sms or messages scope, a read-tier key too), 413 message_too_long / body_too_large, 429 rate_limited (Retry-After shown in the error text)")
1856
1917
  .option("--channel <channel>", "sms | email (required)")
1857
1918
  .option("--subject <text>", "email only (300 characters at most); refused on a text")
1858
1919
  .option("--body <text>", "the draft text (10,000 characters at most)")
@@ -8,6 +8,8 @@ export const LIFECYCLE_STAGES = ["pre_sale", "sale", "underwriting", "active", "
8
8
  export const MAX_STATUS_METADATA_IMPORT = 1000;
9
9
  /** per-call deadlines above the server budgets (archive tx 60 s; Mailchimp admin 60 s; import tx 20 s plus locks) */
10
10
  export const ARCHIVE_PIPELINE_TIMEOUT_MS = 75_000;
11
+ /** the contact merge route's own budget is 45 s: both merge calls (dry run and confirm) get 75 s so the client never gives up while the server commits */
12
+ export const MERGE_TIMEOUT_MS = 75_000;
11
13
  export const MAILCHIMP_ADMIN_TIMEOUT_MS = 60_000;
12
14
  export const STATUS_METADATA_IMPORT_TIMEOUT_MS = 60_000;
13
15
  /** the exact fields an import item takes; anything else (a misspelled isTerminal, say) would be dropped by the API and stored as defaults */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "conduyt",
3
- "version": "1.69.0",
3
+ "version": "1.71.0",
4
4
  "description": "Command-line interface for Conduyt CRM — manage contacts, deals, pipelines, and run insight queries from your terminal.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",