@dench.com/cli 3.11.6 → 3.11.7

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.
@@ -215,7 +215,15 @@ const RECIPIENT_ALIASES: Record<string, RecipientField> = {
215
215
  };
216
216
 
217
217
  const MAX_PEOPLE_FILE_RECIPIENTS = 300;
218
- const PEOPLE_BATCH_SIZE = 50;
218
+ /**
219
+ * People per `addEnrollments` call, as the app's Add people sends them
220
+ * (`DIRECT_ENROLL_CHUNK`): a row that matches or creates a Lead is costly,
221
+ * and a 50-person call ran past the mutation limit on staging and was
222
+ * also the long transaction background CRM work kept conflicting with.
223
+ */
224
+ const PEOPLE_BATCH_SIZE = 20;
225
+ /** Rows `add --list` reads at most looking past duplicates (50 pages). */
226
+ const MAX_LIST_ROWS_READ = 5000;
219
227
 
220
228
  function aliasKey(key: string): string {
221
229
  return key.toLowerCase().replace(/[^a-z0-9]/g, "");
@@ -455,9 +463,12 @@ function onlyExclusions(filters: Record<string, unknown>): boolean {
455
463
  }
456
464
 
457
465
  function rangeValue(raw: string, flag: string): { min?: number; max?: number } {
458
- const [minRaw, maxRaw = ""] = raw.includes(":")
459
- ? raw.split(":", 2)
460
- : [raw, ""];
466
+ const parts = raw.split(":");
467
+ // One min:max pair. A third part ("2:10:99", "2::99") was silently
468
+ // dropped, sometimes with the upper bound.
469
+ if (parts.length > 2)
470
+ throw new Error(`${flag} takes one min:max pair, e.g. 2:10, 2: or :10`);
471
+ const [minRaw = "", maxRaw = ""] = parts;
461
472
  const part = (text: string, label: string) => {
462
473
  const trimmed = text.trim();
463
474
  if (!trimmed) return undefined;
@@ -472,6 +483,8 @@ function rangeValue(raw: string, flag: string): { min?: number; max?: number } {
472
483
  const max = part(maxRaw, "max");
473
484
  if (min === undefined && max === undefined)
474
485
  throw new Error(`${flag} needs min:max, e.g. 2:10, 2: or :10`);
486
+ if (min !== undefined && max !== undefined && min > max)
487
+ throw new Error(`${flag} min (${min}) is greater than max (${max})`);
475
488
  return {
476
489
  ...(min !== undefined ? { min } : {}),
477
490
  ...(max !== undefined ? { max } : {}),
@@ -923,17 +936,23 @@ async function addPeopleFile(
923
936
  return addRecipients(ctx, recipients, file, reference, allowDuplicates);
924
937
  }
925
938
 
926
- function columnSummary(columns: CsvColumn[]): string[] {
939
+ function columnSummary(
940
+ columns: CsvColumn[],
941
+ /** Channels switched off with `--map <field>=none`. */
942
+ off: { email?: boolean; linkedin?: boolean } = {},
943
+ ): string[] {
927
944
  const names = (role: CsvColumn["role"]) =>
928
945
  columns
929
946
  .filter((column) => column.role === role)
930
947
  .map((column) => column.label);
931
948
  const parts: string[] = [];
932
949
  const email = names("email");
933
- if (email.length) parts.push(`email = ${email.join(", ")}`);
950
+ if (off.email) parts.push("email off");
951
+ else if (email.length) parts.push(`email = ${email.join(", ")}`);
934
952
  const linkedin = names("linkedin");
935
- if (linkedin.length) parts.push(`LinkedIn = ${linkedin.join(", ")}`);
936
- if (!email.length && !linkedin.length)
953
+ if (off.linkedin) parts.push("LinkedIn off");
954
+ else if (linkedin.length) parts.push(`LinkedIn = ${linkedin.join(", ")}`);
955
+ if (!email.length && !linkedin.length && !off.email && !off.linkedin)
937
956
  parts.push("no email or LinkedIn column");
938
957
  const person = [
939
958
  ...names("full name"),
@@ -1027,67 +1046,99 @@ async function addList(
1027
1046
  throw new Error(
1028
1047
  `"${campaign.name}" is full (${MAX_CAMPAIGN_PEOPLE} people). Nothing was imported.`,
1029
1048
  );
1030
- let wanted: number;
1049
+ let limit: ReturnType<typeof parseLimit>;
1031
1050
  try {
1032
- wanted = Math.min(
1033
- parseLimit(options.limitRaw, {
1034
- defaultValue: MAX_CAMPAIGN_PEOPLE,
1035
- cap: MAX_CAMPAIGN_PEOPLE,
1036
- command: "campaign add --list",
1037
- }).effective,
1038
- room || MAX_CAMPAIGN_PEOPLE,
1039
- );
1051
+ limit = parseLimit(options.limitRaw, {
1052
+ defaultValue: MAX_CAMPAIGN_PEOPLE,
1053
+ cap: MAX_CAMPAIGN_PEOPLE,
1054
+ command: "campaign add --list",
1055
+ });
1040
1056
  } catch (error) {
1041
1057
  throw new Error(error instanceof Error ? error.message : String(error));
1042
1058
  }
1043
- const picked: Recipient[] = [];
1059
+ // How many people to add: the --limit, the campaign cap and its room.
1060
+ const wanted = Math.min(limit.effective, room || MAX_CAMPAIGN_PEOPLE);
1061
+ const clamped = options.limitRaw !== undefined && wanted < limit.requested;
1062
+ if (clamped)
1063
+ notice(
1064
+ limit.clamped
1065
+ ? `--limit ${limit.requested} is above the ${MAX_CAMPAIGN_PEOPLE}-person campaign maximum; adding up to ${wanted}.`
1066
+ : `--limit ${limit.requested} is more than "${campaign.name}" has room for; adding up to ${wanted}.`,
1067
+ );
1068
+ const limitInfo = clamped
1069
+ ? {
1070
+ limitClamped: true,
1071
+ limitRequested: limit.requested,
1072
+ limitEffective: wanted,
1073
+ }
1074
+ : {};
1075
+ // Rows are read a page at a time as they're needed. Adding keeps reading
1076
+ // past people who turn out to be duplicates, so a list whose first rows
1077
+ // are already in the campaign still reaches the eligible rows after them.
1078
+ const queue: Recipient[] = [];
1044
1079
  const sample: Array<Record<string, string>> = [];
1045
1080
  let title = "";
1046
1081
  let rowCount = 0;
1082
+ let rowsRead = 0;
1047
1083
  let cursor: string | undefined;
1048
- do {
1049
- const page = (await invoke(ctx, "lists.rows", {
1050
- artifactId,
1051
- limit: 100,
1052
- ...(cursor ? { cursor } : {}),
1053
- ...(options.enrichment ? { enrichment: options.enrichment } : {}),
1054
- })) as ListRowsPage;
1055
- title = page.title ?? title;
1056
- rowCount = page.rowCount ?? rowCount;
1057
- for (const entry of page.page ?? []) {
1058
- if (picked.length >= wanted) break;
1059
- picked.push(listRowRecipient(entry.row, entry.index));
1060
- if (sample.length < 5) sample.push(listRowSummary(entry.row));
1084
+ let exhausted = false;
1085
+ const readUntil = async (count: number) => {
1086
+ while (
1087
+ queue.length < count &&
1088
+ !exhausted &&
1089
+ rowsRead < MAX_LIST_ROWS_READ
1090
+ ) {
1091
+ const page = (await invoke(ctx, "lists.rows", {
1092
+ artifactId,
1093
+ limit: 100,
1094
+ ...(cursor ? { cursor } : {}),
1095
+ ...(options.enrichment ? { enrichment: options.enrichment } : {}),
1096
+ })) as ListRowsPage;
1097
+ title = page.title ?? title;
1098
+ rowCount = page.rowCount ?? rowCount;
1099
+ for (const entry of page.page ?? []) {
1100
+ rowsRead += 1;
1101
+ queue.push(listRowRecipient(entry.row, entry.index));
1102
+ if (sample.length < 5) sample.push(listRowSummary(entry.row));
1103
+ }
1104
+ exhausted = Boolean(page.isDone || !page.continueCursor);
1105
+ cursor = exhausted ? undefined : (page.continueCursor ?? undefined);
1061
1106
  }
1062
- cursor =
1063
- page.isDone || !page.continueCursor || picked.length >= wanted
1064
- ? undefined
1065
- : page.continueCursor;
1066
- } while (cursor);
1067
- const list = { artifactId, title, rowCount, rows: picked.length };
1068
- if (!picked.length)
1107
+ };
1108
+ await readUntil(options.preview ? wanted : PEOPLE_BATCH_SIZE);
1109
+ if (!queue.length)
1069
1110
  throw new Error(
1070
1111
  `${title ? `"${title}"` : artifactId} has no rows${options.enrichment ? ` with enrichment "${options.enrichment}"` : ""} to add (${rowCount} saved). See dench lists rows ${artifactId}.`,
1071
1112
  );
1072
- if (options.preview)
1113
+ if (options.preview) {
1114
+ const previewed = queue.slice(0, wanted);
1073
1115
  return print(
1074
1116
  ctx,
1075
1117
  {
1076
1118
  sequenceId: campaign._id,
1077
1119
  campaignStatus: campaign.status,
1078
- list,
1120
+ ...limitInfo,
1121
+ list: { artifactId, title, rowCount, rows: previewed.length },
1079
1122
  sample,
1080
1123
  },
1081
- `${plural(picked.length, "row", "rows")} of "${title}" would be added to "${campaign.name}"; nothing was imported.`,
1124
+ `${plural(previewed.length, "row", "rows")} of "${title}" would be added to "${campaign.name}"; nothing was imported. People already in the campaign are skipped, and later rows take their places.`,
1082
1125
  );
1126
+ }
1083
1127
  const totals: BatchCounts & { cappedAtLimit: number | null } = {
1084
1128
  ...batchCounts({}),
1085
1129
  cappedAtLimit: null,
1086
1130
  };
1087
1131
  const perBatch: Array<Record<string, unknown>> = [];
1088
1132
  let missingInfoSample: unknown;
1089
- for (let offset = 0; offset < picked.length; offset += PEOPLE_BATCH_SIZE) {
1090
- const chunk = picked.slice(offset, offset + PEOPLE_BATCH_SIZE);
1133
+ let attempted = 0;
1134
+ const list = () => ({ artifactId, title, rowCount, rows: attempted });
1135
+ while (totals.added < wanted) {
1136
+ const size = Math.min(PEOPLE_BATCH_SIZE, wanted - totals.added);
1137
+ await readUntil(size);
1138
+ const chunk = queue.splice(0, size);
1139
+ if (!chunk.length) break;
1140
+ const offset = attempted;
1141
+ attempted += chunk.length;
1091
1142
  let result: Record<string, unknown>;
1092
1143
  try {
1093
1144
  result = (await invoke(ctx, "campaigns.people.addBatch", {
@@ -1102,7 +1153,8 @@ async function addList(
1102
1153
  sequenceId: campaign._id,
1103
1154
  campaignStatus: campaign.status,
1104
1155
  ...totals,
1105
- list,
1156
+ ...limitInfo,
1157
+ list: list(),
1106
1158
  batches: perBatch,
1107
1159
  failedBatch: { offset, size: chunk.length },
1108
1160
  error: message,
@@ -1141,10 +1193,11 @@ async function addList(
1141
1193
  campaignStatus: campaign.status,
1142
1194
  ...totals,
1143
1195
  ...(missingInfoSample ? { missingInfoSample } : {}),
1196
+ ...limitInfo,
1144
1197
  batches: perBatch,
1145
- list,
1198
+ list: list(),
1146
1199
  },
1147
- `Added ${totals.added} of ${plural(picked.length, "row", "rows")} from "${title}" to "${campaign.name}" (${campaign.status}).`,
1200
+ `Added ${totals.added} of ${plural(attempted, "row", "rows")} from "${title}" to "${campaign.name}" (${campaign.status}).`,
1148
1201
  );
1149
1202
  }
1150
1203
 
@@ -1191,7 +1244,10 @@ async function addCsv(
1191
1244
  hasHeader: result.hasHeader,
1192
1245
  columns,
1193
1246
  };
1194
- const read = columnSummary(result.columns);
1247
+ const read = columnSummary(result.columns, {
1248
+ email: mapping.email === null,
1249
+ linkedin: mapping.linkedin === null,
1250
+ });
1195
1251
  const mergeColumns = result.columns.filter(
1196
1252
  (column) => column.role === "merge value",
1197
1253
  );
@@ -1205,10 +1261,14 @@ async function addCsv(
1205
1261
  );
1206
1262
  if (result.duplicates > 0)
1207
1263
  notice(
1208
- `${plural(result.duplicates, "row repeats", "rows repeat")} an earlier person's email or LinkedIn profile URL and ${result.duplicates === 1 ? "was" : "were"} left out.`,
1264
+ `${plural(result.duplicates, "row repeats", "rows repeat")} an earlier person's email or LinkedIn profile URL; anything new on ${result.duplicates === 1 ? "it" : "them"} was added to that person.`,
1265
+ );
1266
+ if (table.raggedRows)
1267
+ notice(
1268
+ `${plural(table.raggedRows, "row has", "rows have")} more cells than the header row, so the columns may not line up; the extra cells became their own merge values. Check ${file} if that looks wrong.`,
1209
1269
  );
1210
1270
  notice(
1211
- `Read ${file}: ${read.join("; ")}${mergeColumns.length ? `; ${plural(mergeColumns.length, "other column becomes", "other columns become")} merge values (${mergeColumns.map((column) => `{{${column.label}}}`).join(", ")})` : ""}.`,
1271
+ `Read ${file}: ${read.join("; ")}${mergeColumns.length ? `; ${plural(mergeColumns.length, "other column becomes", "other columns become")} merge values (${mergeColumns.map((column) => `{{${column.mergeKey?.replace(/^crm:/, "") ?? column.label}}}`).join(", ")})` : ""}.`,
1212
1272
  );
1213
1273
  if (options.preview)
1214
1274
  return print(
@@ -1399,10 +1459,13 @@ async function draft(ctx: Context) {
1399
1459
  );
1400
1460
  const timezone = assertIanaTimezone(flags.timezone ?? localTimezone());
1401
1461
  schedulePatchFromFlags(flags, { fallbackTimezone: timezone });
1462
+ // The chosen senders go in with the draft, so one that can't be used fails
1463
+ // before anything is created.
1402
1464
  const { sequenceId } = (await invoke(ctx, "campaigns.drafts.create", {
1403
1465
  clientRequestId,
1404
1466
  ...(name ? { name } : {}),
1405
1467
  timezone,
1468
+ ...senderPools,
1406
1469
  })) as { sequenceId: string };
1407
1470
  let campaign = await readCampaign(ctx, sequenceId);
1408
1471
  const schedule = schedulePatchFromFlags(flags, {
@@ -1418,7 +1481,23 @@ async function draft(ctx: Context) {
1418
1481
  const patch = { ...schedule, ...senderPools };
1419
1482
  if (hasScheduleChanges(patch)) {
1420
1483
  if (campaign.status === "draft") {
1421
- await invoke(ctx, "campaigns.update", { sequenceId, ...patch });
1484
+ try {
1485
+ await invoke(ctx, "campaigns.update", { sequenceId, ...patch });
1486
+ } catch (error) {
1487
+ // The draft exists: say which, and that the same --request-id
1488
+ // returns it, so a retry fixes it rather than creating another.
1489
+ const message = backendErrorText(error);
1490
+ print(ctx, {
1491
+ sequenceId,
1492
+ clientRequestId,
1493
+ status: campaign.status,
1494
+ settingsSaved: false,
1495
+ error: message,
1496
+ });
1497
+ throw new Error(
1498
+ `Created draft "${campaign.name}" [${sequenceId}], but its settings weren't saved: ${message}. Re-run with --request-id ${clientRequestId} and corrected flags to update the same draft, or use dench campaign set ${sequenceId}.`,
1499
+ );
1500
+ }
1422
1501
  campaign = await readCampaign(ctx, sequenceId);
1423
1502
  } else {
1424
1503
  notice(
@@ -1636,6 +1715,13 @@ export async function runCampaignAgentCommand(
1636
1715
  input.leadId = shift(ctx.args, "draft lead id");
1637
1716
  if (verb === "save" && !input.clientRequestId)
1638
1717
  input.clientRequestId = randomUUID();
1718
+ // A save from here is deliberate: like the app's "Save and exit" it gets
1719
+ // a screen, so `builder drafts` lists it and it keeps for two weeks.
1720
+ // (Without one the server treats it as a passing autosave: unlisted,
1721
+ // deleted after a day.)
1722
+ if (verb === "save" && input.screen === undefined)
1723
+ input.screen =
1724
+ input.source === "lookalike" || input.lookalike ? "lookalike" : "target";
1639
1725
  finishArgs(ctx.args, "builder");
1640
1726
  return print(ctx, await invoke(ctx, `campaigns.builder.${verb}`, input));
1641
1727
  }