@dench.com/cli 2.8.3 → 2.8.5-staging.175

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/crm.ts CHANGED
@@ -35,7 +35,9 @@ import {
35
35
  getFlag,
36
36
  getFlagWithAliases,
37
37
  hasFlag,
38
+ type ParsedLimit,
38
39
  parseJson as parseJsonRaw,
40
+ parseLimit as parseLimitRaw,
39
41
  shift as shiftRaw,
40
42
  } from "./lib/cli-args";
41
43
  import {
@@ -57,6 +59,7 @@ import {
57
59
  enrichCompany,
58
60
  enrichPerson,
59
61
  } from "./lib/enrichment-gateway";
62
+ import { closestMatch, levenshteinDistance } from "./lib/levenshtein";
60
63
 
61
64
  type JsonRecord = Record<string, unknown>;
62
65
 
@@ -175,9 +178,18 @@ function withCrmEntryDeepLinks(
175
178
  objectName,
176
179
  entryId,
177
180
  });
181
+ const record = value as JsonRecord;
178
182
  return {
179
- ...(value as JsonRecord),
183
+ ...record,
184
+ // Alias `id` ↔ `entryId`: server responses expose `entryId`, but
185
+ // agents overwhelmingly `jq .id` (the shape `entries list` returns).
186
+ // A null there caused retry-loops that created duplicate rows.
187
+ ...(typeof record.id === "string" && record.id.trim()
188
+ ? {}
189
+ : { id: entryId }),
180
190
  href: links.href,
191
+ // `url` stays conditional on host+workspaceSlug being known — an
192
+ // absolute link with a guessed origin is worse than none.
181
193
  ...(links.url ? { url: links.url } : {}),
182
194
  };
183
195
  }
@@ -221,6 +233,71 @@ function shift(args: string[], expected: string): string {
221
233
  }
222
234
  }
223
235
 
236
+ /**
237
+ * shift() for name-bearing positionals (object/field/group names). A
238
+ * value starting with `--` is always a mis-ordered flag, never a real
239
+ * name — `dench crm fields create lead --name "Fit Score"` used to
240
+ * create a literal field named "--name" in production CRMs. Fail fast
241
+ * with the correct usage instead of writing the flag as data.
242
+ */
243
+ function shiftName(args: string[], expected: string, usage: string): string {
244
+ const value = shift(args, expected);
245
+ if (value.startsWith("--")) {
246
+ const label = expected.charAt(0).toUpperCase() + expected.slice(1);
247
+ throw new CrmCliError(
248
+ `${label} cannot start with "--". The name is positional: ${usage}`,
249
+ );
250
+ }
251
+ return value;
252
+ }
253
+
254
+ /**
255
+ * Parse `--limit` against a server-side cap and make any clamping LOUD.
256
+ * The server clamps silently, which historically let agents dedupe /
257
+ * count over a subset believing it was the full dataset. The warning
258
+ * always goes to stderr so `--json` stdout stays valid JSON; callers
259
+ * whose output is a JSON object should also surface `limitClamped`.
260
+ */
261
+ function resolveLimitFlag(
262
+ args: string[],
263
+ opts: {
264
+ defaultValue: number;
265
+ cap: number;
266
+ command: string;
267
+ fullDatasetHint?: string;
268
+ },
269
+ ): ParsedLimit {
270
+ const raw = getFlag(args, "--limit");
271
+ let parsed: ParsedLimit;
272
+ try {
273
+ parsed = parseLimitRaw(raw, opts);
274
+ } catch (error) {
275
+ if (error instanceof CliArgError) throw new CrmCliError(error.message);
276
+ throw error;
277
+ }
278
+ if (parsed.clamped) {
279
+ process.stderr.write(
280
+ `warning: --limit ${parsed.requested} exceeds the ${opts.cap}-row cap for ` +
281
+ `${opts.command}; returned rows are the newest ${parsed.effective}. ` +
282
+ `${opts.fullDatasetHint ?? "Use 'dench crm export' for full datasets."}\n`,
283
+ );
284
+ }
285
+ return parsed;
286
+ }
287
+
288
+ /**
289
+ * Reject curl-style `--data @rows.json`. The CLI never reads files off
290
+ * an `@` prefix — the literal string "@rows.json" would be parsed as
291
+ * (invalid) JSON, or worse, silently written as a value.
292
+ */
293
+ function rejectAtFileValue(raw: string | undefined, flag: string): void {
294
+ if (raw !== undefined && raw.trimStart().startsWith("@")) {
295
+ throw new CrmCliError(
296
+ `${flag}: "@file" syntax is not supported. Use --file <path>.`,
297
+ );
298
+ }
299
+ }
300
+
224
301
  function parseJson(value: string | undefined): unknown {
225
302
  try {
226
303
  return parseJsonRaw(value);
@@ -281,6 +358,7 @@ async function readJsonArrayInput(opts: {
281
358
  `${opts.command} requires ${opts.inlineFlag} '<json-array>' or --file <path>`,
282
359
  );
283
360
  }
361
+ rejectAtFileValue(opts.inlineValue, opts.inlineFlag);
284
362
 
285
363
  let raw: string;
286
364
  if (filePath) {
@@ -306,6 +384,71 @@ function parseCreateManyRows(items: unknown[]): JsonRecord[] {
306
384
  );
307
385
  }
308
386
 
387
+ /**
388
+ * Stable per-row dedupe key derived from a named column. Values are
389
+ * normalized (String → trim → lowercase) so `" Jane@X.com"` and
390
+ * `"jane@x.com"` collapse onto the same key. Throws when a row has no
391
+ * usable value — a blank dedupe cell would silently merge unrelated rows.
392
+ */
393
+ function dedupeKeyFromColumn(opts: {
394
+ prefix: string;
395
+ column: string;
396
+ row: JsonRecord;
397
+ rowIndex: number; // 0-based
398
+ command: string;
399
+ }): string {
400
+ const raw = opts.row[opts.column];
401
+ const normalized =
402
+ raw === null || raw === undefined ? "" : String(raw).trim().toLowerCase();
403
+ if (!normalized) {
404
+ throw new CrmCliError(
405
+ `${opts.command}: row ${opts.rowIndex + 1} has no value for --dedupe-key column "${opts.column}"`,
406
+ );
407
+ }
408
+ return `${opts.prefix}:${normalized}`;
409
+ }
410
+
411
+ /**
412
+ * Deterministic JSON stringification (recursively sorted object keys).
413
+ * Used to derive content-hash dedupe keys for `crm import`: the same
414
+ * source row must map to the same key on every run — the previous
415
+ * implementation keyed on `Date.now()`, which made every re-run of an
416
+ * import duplicate all rows (a 42-row CSV became 84 rows).
417
+ */
418
+ export function stableJsonStringify(value: unknown): string {
419
+ if (Array.isArray(value)) {
420
+ return `[${value.map(stableJsonStringify).join(",")}]`;
421
+ }
422
+ if (value && typeof value === "object") {
423
+ const record = value as JsonRecord;
424
+ const entries = Object.keys(record)
425
+ .sort()
426
+ .map(
427
+ (key) => `${JSON.stringify(key)}:${stableJsonStringify(record[key])}`,
428
+ );
429
+ return `{${entries.join(",")}}`;
430
+ }
431
+ return JSON.stringify(value) ?? "null";
432
+ }
433
+
434
+ /** Count batch.apply result statuses (created / already_existed / updated). */
435
+ function countResultStatuses(results: unknown[]): {
436
+ created: number;
437
+ alreadyExisted: number;
438
+ updated: number;
439
+ } {
440
+ let created = 0;
441
+ let alreadyExisted = 0;
442
+ let updated = 0;
443
+ for (const result of results) {
444
+ const status = isJsonRecord(result) ? result.status : undefined;
445
+ if (status === "already_existed") alreadyExisted += 1;
446
+ else if (status === "updated") updated += 1;
447
+ else created += 1;
448
+ }
449
+ return { created, alreadyExisted, updated };
450
+ }
451
+
309
452
  function parseUpdateManyRows(items: unknown[]): Array<{
310
453
  id: string;
311
454
  data: JsonRecord;
@@ -356,9 +499,29 @@ function parseCellSetManyRows(items: unknown[]): Array<{
356
499
  }
357
500
 
358
501
  type CrmBatchOp =
359
- | { type: "entries.create"; objectName: string; data: JsonRecord }
502
+ | {
503
+ type: "entries.create";
504
+ objectName: string;
505
+ data: JsonRecord;
506
+ /** Stable per-row dedupe key (server upserts on conflict). */
507
+ seedKey?: string;
508
+ /** 1-based source row for server-side error messages. */
509
+ rowIndex?: number;
510
+ }
360
511
  | { type: "entries.update"; entryId: string; data: JsonRecord };
361
512
 
513
+ type CrmOnConflictPolicy = "skip" | "update" | "error";
514
+
515
+ function parseOnConflictFlag(raw: string | undefined): CrmOnConflictPolicy {
516
+ const value = raw ?? "skip";
517
+ if (value === "skip" || value === "update" || value === "error") {
518
+ return value;
519
+ }
520
+ throw new CrmCliError(
521
+ `--on-conflict must be skip, update, or error (got "${raw}")`,
522
+ );
523
+ }
524
+
362
525
  function chunkArray<T>(items: T[], size: number): T[][] {
363
526
  const chunks: T[][] = [];
364
527
  for (let index = 0; index < items.length; index += size) {
@@ -381,6 +544,7 @@ async function applyBatchOpsInChunks(
381
544
  ops: CrmBatchOp[],
382
545
  idempotencyKey: string | undefined,
383
546
  allowUnknown = false,
547
+ onConflict?: CrmOnConflictPolicy,
384
548
  ): Promise<{ chunks: number; results: unknown[] }> {
385
549
  const chunks = chunkArray(ops, CRM_BATCH_CHUNK_SIZE);
386
550
  const results: unknown[] = [];
@@ -391,6 +555,7 @@ async function applyBatchOpsInChunks(
391
555
  ...runContextArgs(ctx),
392
556
  ...(chunkKey ? { idempotencyKey: chunkKey } : {}),
393
557
  ...(allowUnknown ? { allowUnknown: true } : {}),
558
+ ...(onConflict ? { onConflict } : {}),
394
559
  })) as { results?: unknown[] } | null | undefined;
395
560
  if (Array.isArray(batchResult?.results)) {
396
561
  results.push(...batchResult.results);
@@ -428,22 +593,8 @@ async function fetchFieldNames(
428
593
  }
429
594
  }
430
595
 
431
- function levenshteinDistance(a: string, b: string): number {
432
- if (a === b) return 0;
433
- if (a.length === 0) return b.length;
434
- if (b.length === 0) return a.length;
435
- let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
436
- let curr = new Array<number>(b.length + 1);
437
- for (let i = 1; i <= a.length; i += 1) {
438
- curr[0] = i;
439
- for (let j = 1; j <= b.length; j += 1) {
440
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
441
- curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
442
- }
443
- [prev, curr] = [curr, prev];
444
- }
445
- return prev[b.length];
446
- }
596
+ // levenshteinDistance now lives in cli/lib/levenshtein.ts (shared with
597
+ // the `dench apps` dispatcher's "did you mean" suggestions).
447
598
 
448
599
  function suggestKnownField(name: string, validNames: string[]): string | null {
449
600
  const lower = name.toLowerCase();
@@ -719,6 +870,7 @@ const api = {
719
870
  },
720
871
  entries: {
721
872
  list: makeFunctionReference<"query">("functions/crm/entries:list"),
873
+ listPage: makeFunctionReference<"query">("functions/crm/entries:listPage"),
722
874
  get: makeFunctionReference<"query">("functions/crm/entries:get"),
723
875
  create: makeFunctionReference<"mutation">("functions/crm/entries:create"),
724
876
  update: makeFunctionReference<"mutation">("functions/crm/entries:update"),
@@ -874,11 +1026,48 @@ export async function runCrmCommand(opts: {
874
1026
  // on. Delegates to the shared implementation in cli/members.ts
875
1027
  // so payload formatting + flag handling stay in lockstep.
876
1028
  return await runCrmMembersCommand(ctx);
877
- default:
878
- throw new CrmCliError(`Unknown crm subcommand: ${subcommand}`);
1029
+ default: {
1030
+ const suggestion = closestMatch(subcommand, CRM_SUBCOMMANDS);
1031
+ throw new CrmCliError(
1032
+ `Unknown crm subcommand: ${subcommand}` +
1033
+ `${suggestion ? `. Did you mean "${suggestion}"?` : ""}` +
1034
+ " Run `dench crm help` for the full list.",
1035
+ );
1036
+ }
879
1037
  }
880
1038
  }
881
1039
 
1040
+ /** Every verb the `runCrmCommand` switch accepts (for "did you mean"). */
1041
+ const CRM_SUBCOMMANDS = [
1042
+ "objects",
1043
+ "views",
1044
+ "sidebar",
1045
+ "fields",
1046
+ "entries",
1047
+ "history",
1048
+ "cells",
1049
+ "query",
1050
+ "sql",
1051
+ "aggregate",
1052
+ "search",
1053
+ "statuses",
1054
+ "batch",
1055
+ "import",
1056
+ "export",
1057
+ "transaction",
1058
+ "people",
1059
+ "companies",
1060
+ "enrich",
1061
+ "reports",
1062
+ "docs",
1063
+ "documents",
1064
+ "actions",
1065
+ "automations",
1066
+ "templates",
1067
+ "members",
1068
+ "help",
1069
+ ] as const;
1070
+
882
1071
  /**
883
1072
  * `dench crm automations …` — deterministic CRM rules (installed by
884
1073
  * templates, e.g. "qualified prospect → opportunity"). These fire inside
@@ -1026,7 +1215,11 @@ async function runSidebarCommand(ctx: CrmCliContext): Promise<void> {
1026
1215
  const groupVerb = ctx.args.shift();
1027
1216
  switch (groupVerb) {
1028
1217
  case "create": {
1029
- const name = shift(ctx.args, "group name");
1218
+ const name = shiftName(
1219
+ ctx.args,
1220
+ "group name",
1221
+ 'dench crm sidebar group create "<name>"',
1222
+ );
1030
1223
  assertNoUnknownFlags(ctx.args, "crm sidebar group create");
1031
1224
  out(ctx, await callMutation(ctx, api.sidebar.createGroup, { name }));
1032
1225
  return;
@@ -1105,7 +1298,11 @@ async function runObjectsCommand(ctx: CrmCliContext): Promise<void> {
1105
1298
  return;
1106
1299
  }
1107
1300
  case "create": {
1108
- const name = shift(ctx.args, "object name");
1301
+ const name = shiftName(
1302
+ ctx.args,
1303
+ "object name",
1304
+ 'dench crm objects create "<name>" [--description …] [--icon …]',
1305
+ );
1109
1306
  const description = getFlag(ctx.args, "--description");
1110
1307
  const defaultView = getFlag(ctx.args, "--default-view") as any;
1111
1308
  const icon = getFlag(ctx.args, "--icon");
@@ -1147,7 +1344,11 @@ async function runObjectsCommand(ctx: CrmCliContext): Promise<void> {
1147
1344
  }
1148
1345
  case "rename": {
1149
1346
  const from = shift(ctx.args, "current name");
1150
- const to = shift(ctx.args, "new name");
1347
+ const to = shiftName(
1348
+ ctx.args,
1349
+ "new name",
1350
+ 'dench crm objects rename <from> "<to>"',
1351
+ );
1151
1352
  assertNoUnknownFlags(ctx.args, "crm objects rename");
1152
1353
  out(ctx, await callMutation(ctx, api.objects.rename, { from, to }));
1153
1354
  return;
@@ -1630,7 +1831,13 @@ async function runViewsCommand(ctx: CrmCliContext): Promise<void> {
1630
1831
  case "rename": {
1631
1832
  const objectName = shift(ctx.args, "object name");
1632
1833
  const viewName = shift(ctx.args, "view name");
1633
- const newName = getFlag(ctx.args, "--to") ?? shift(ctx.args, "new name");
1834
+ const newName =
1835
+ getFlag(ctx.args, "--to") ??
1836
+ shiftName(
1837
+ ctx.args,
1838
+ "new name",
1839
+ 'dench crm views rename <object> "<view>" "<new name>"',
1840
+ );
1634
1841
  assertNoUnknownFlags(ctx.args, "crm views rename");
1635
1842
  if (!newName.trim()) {
1636
1843
  throw new CrmCliError(
@@ -1709,7 +1916,11 @@ async function runFieldsCommand(ctx: CrmCliContext): Promise<void> {
1709
1916
  }
1710
1917
  case "create": {
1711
1918
  const objectName = shift(ctx.args, "object name");
1712
- const name = shift(ctx.args, "field name");
1919
+ const name = shiftName(
1920
+ ctx.args,
1921
+ "field name",
1922
+ 'dench crm fields create <object> "<Name>" --type <type>',
1923
+ );
1713
1924
  const type = (getFlag(ctx.args, "--type") ?? "text") as any;
1714
1925
  const required = hasFlag(ctx.args, "--required");
1715
1926
  const description = getFlag(ctx.args, "--description");
@@ -1919,14 +2130,20 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1919
2130
  switch (verb) {
1920
2131
  case "list": {
1921
2132
  const objectName = shift(ctx.args, "object name");
1922
- const limit = parseInt(getFlag(ctx.args, "--limit") ?? "100", 10);
2133
+ const limit = resolveLimitFlag(ctx.args, {
2134
+ defaultValue: 100,
2135
+ cap: 1000,
2136
+ command: "crm entries list",
2137
+ });
1923
2138
  const withMeta = hasFlag(ctx.args, "--with-meta");
1924
2139
  assertNoUnknownFlags(ctx.args, "crm entries list");
1925
2140
  const entries = await callQuery(ctx, api.entries.list, {
1926
2141
  objectName,
1927
- limit,
2142
+ limit: limit.effective,
1928
2143
  });
1929
2144
  if (!withMeta) {
2145
+ // Bare-array output: the clamp warning already went to stderr
2146
+ // (stdout must stay a valid JSON array in --json mode).
1930
2147
  out(ctx, entries);
1931
2148
  return;
1932
2149
  }
@@ -1942,7 +2159,15 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1942
2159
  objectName,
1943
2160
  totalCount,
1944
2161
  returnedCount,
1945
- limit,
2162
+ limit: limit.effective,
2163
+ ...(limit.clamped
2164
+ ? {
2165
+ limitClamped: {
2166
+ requested: limit.requested,
2167
+ effective: limit.effective,
2168
+ },
2169
+ }
2170
+ : {}),
1946
2171
  hasMore: totalCount > returnedCount,
1947
2172
  entries,
1948
2173
  });
@@ -1968,6 +2193,11 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1968
2193
  case "create": {
1969
2194
  const objectName = shift(ctx.args, "object name");
1970
2195
  const dataRaw = getDataFlag(ctx.args);
2196
+ // Optional idempotency: the server upserts on (object, key), so a
2197
+ // retried create (agent saw a null `.id`, ran the command again)
2198
+ // returns the existing row with `alreadyExisted: true` instead of
2199
+ // duplicating it.
2200
+ const idempotencyKey = getFlag(ctx.args, "--idempotency-key");
1971
2201
  const allowUnknown = hasFlag(ctx.args, "--allow-unknown");
1972
2202
  assertNoUnknownFlags(ctx.args, "crm entries create");
1973
2203
  // Refuse silent blank-row creation. The agent that triggered this
@@ -1980,12 +2210,18 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1980
2210
  'dench crm entries create requires --data \'{"Field":"Value",...}\' (alias: --fields)',
1981
2211
  );
1982
2212
  }
2213
+ rejectAtFileValue(dataRaw, "--data");
1983
2214
  const data = (parseJson(dataRaw) as JsonRecord | undefined) ?? {};
1984
2215
  if (!data || Object.keys(data).length === 0) {
1985
2216
  throw new CrmCliError(
1986
2217
  'dench crm entries create: --data must include at least one field. Pass --data \'{"Name":"…"}\' or use cells set after creation.',
1987
2218
  );
1988
2219
  }
2220
+ if (idempotencyKey !== undefined && !idempotencyKey.trim()) {
2221
+ throw new CrmCliError(
2222
+ "dench crm entries create: --idempotency-key must be a non-empty string",
2223
+ );
2224
+ }
1989
2225
  // Single-row writes rely on the server's schema validation (which
1990
2226
  // returns the same "did you mean …" hint the CLI would). No local
1991
2227
  // pre-fetch — that would add a round-trip to every create.
@@ -1999,6 +2235,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
1999
2235
  data,
2000
2236
  ...runContextArgs(ctx),
2001
2237
  ...(allowUnknown ? { allowUnknown: true } : {}),
2238
+ ...(idempotencyKey ? { seedKey: idempotencyKey.trim() } : {}),
2002
2239
  }),
2003
2240
  ),
2004
2241
  );
@@ -2008,6 +2245,10 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
2008
2245
  const objectName = shift(ctx.args, "object name");
2009
2246
  const dataRaw = getDataFlag(ctx.args);
2010
2247
  const idempotencyKey = getFlag(ctx.args, "--idempotency-key");
2248
+ // Per-row idempotency: derive a stable seedKey from this column so
2249
+ // re-running the same create-many cannot duplicate rows. Rows whose
2250
+ // key already exists are skipped (`status: "already_existed"`).
2251
+ const dedupeColumn = getFlag(ctx.args, "--dedupe-key");
2011
2252
  const allowUnknown = hasFlag(ctx.args, "--allow-unknown");
2012
2253
  const rows = parseCreateManyRows(
2013
2254
  await readJsonArrayInput({
@@ -2018,23 +2259,43 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
2018
2259
  }),
2019
2260
  );
2020
2261
  await assertKnownDataKeys(ctx, objectName, rows, allowUnknown);
2262
+ const seedKeys = dedupeColumn
2263
+ ? rows.map((row, index) =>
2264
+ dedupeKeyFromColumn({
2265
+ prefix: `many:${objectName}:${dedupeColumn}`,
2266
+ column: dedupeColumn,
2267
+ row,
2268
+ rowIndex: index,
2269
+ command: "crm entries create-many",
2270
+ }),
2271
+ )
2272
+ : null;
2021
2273
  const { chunks, results } = await applyBatchOpsInChunks(
2022
2274
  ctx,
2023
- rows.map((data) => ({
2275
+ rows.map((data, index) => ({
2024
2276
  type: "entries.create",
2025
2277
  objectName,
2026
2278
  data,
2279
+ ...(seedKeys
2280
+ ? { seedKey: seedKeys[index], rowIndex: index + 1 }
2281
+ : {}),
2027
2282
  })),
2028
2283
  idempotencyKey,
2029
2284
  allowUnknown,
2285
+ seedKeys ? "skip" : undefined,
2030
2286
  );
2031
2287
  // Only claim per-row links when we actually got a result per row. A
2032
2288
  // silently-empty `results` is indistinguishable from "this object has
2033
2289
  // no links" and sends the agent right back to inventing URLs — the
2034
2290
  // exact failure this command's `url`/`href` output exists to prevent.
2035
2291
  const linked = results.length;
2292
+ const statusCounts =
2293
+ seedKeys && results.length > 0 ? countResultStatuses(results) : null;
2036
2294
  out(ctx, {
2037
- created: rows.length,
2295
+ created: statusCounts ? statusCounts.created : rows.length,
2296
+ ...(statusCounts
2297
+ ? { alreadyExisted: statusCounts.alreadyExisted }
2298
+ : {}),
2038
2299
  chunks,
2039
2300
  ...(linked > 0
2040
2301
  ? {
@@ -2076,6 +2337,7 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
2076
2337
  'dench crm entries update requires --data \'{"Field":"Value",...}\' (alias: --fields)',
2077
2338
  );
2078
2339
  }
2340
+ rejectAtFileValue(dataRaw, "--data");
2079
2341
  const data = (parseJson(dataRaw) as JsonRecord | undefined) ?? {};
2080
2342
  if (!data || Object.keys(data).length === 0) {
2081
2343
  throw new CrmCliError(
@@ -2144,17 +2406,36 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
2144
2406
  }
2145
2407
  case "bulk-delete":
2146
2408
  case "bulkDelete": {
2147
- const _objectName = shift(ctx.args, "object name");
2409
+ const objectName = shift(ctx.args, "object name");
2148
2410
  const idsCsv = getFlag(ctx.args, "--ids") ?? "";
2149
2411
  assertNoUnknownFlags(ctx.args, "crm entries bulk-delete");
2150
- const entryIds = idsCsv.split(",").filter(Boolean);
2151
- out(
2412
+ const entryIds = idsCsv
2413
+ .split(",")
2414
+ .map((id) => id.trim())
2415
+ .filter(Boolean);
2416
+ if (entryIds.length === 0) {
2417
+ throw new CrmCliError(
2418
+ "dench crm entries bulk-delete requires --ids id1,id2,…",
2419
+ );
2420
+ }
2421
+ // Automatic undo file: snapshot the target rows to JSONL BEFORE
2422
+ // deleting. A bulk-delete driven by a buggy dedupe script is only
2423
+ // recoverable if the pre-delete state exists somewhere — this file
2424
+ // is that somewhere. Snapshot failure aborts the delete.
2425
+ const snapshot = await snapshotEntriesBeforeBulkDelete(
2152
2426
  ctx,
2153
- await callMutation(ctx, api.entries.bulkDelete, {
2154
- entryIds: entryIds as any,
2155
- ...runContextArgs(ctx),
2156
- }),
2427
+ objectName,
2428
+ entryIds,
2157
2429
  );
2430
+ const result = await callMutation(ctx, api.entries.bulkDelete, {
2431
+ entryIds: entryIds as any,
2432
+ ...runContextArgs(ctx),
2433
+ });
2434
+ out(ctx, {
2435
+ ...(isJsonRecord(result) ? result : { result }),
2436
+ snapshotPath: snapshot.path,
2437
+ snapshotRows: snapshot.rows,
2438
+ });
2158
2439
  return;
2159
2440
  }
2160
2441
  default:
@@ -2162,6 +2443,56 @@ async function runEntriesCommand(ctx: CrmCliContext): Promise<void> {
2162
2443
  }
2163
2444
  }
2164
2445
 
2446
+ /**
2447
+ * Fetch every target row and write it to a JSONL snapshot under the OS
2448
+ * temp dir ($TMPDIR or /tmp). This is an automatic undo file, not an
2449
+ * interactive gate: the delete proceeds after a successful snapshot and
2450
+ * ABORTS if the snapshot cannot be captured or written.
2451
+ */
2452
+ async function snapshotEntriesBeforeBulkDelete(
2453
+ ctx: CrmCliContext,
2454
+ objectName: string,
2455
+ entryIds: string[],
2456
+ ): Promise<{ path: string; rows: number }> {
2457
+ const lines: string[] = [];
2458
+ try {
2459
+ for (const entryId of entryIds) {
2460
+ const entry = await callQuery(ctx, api.entries.get, {
2461
+ objectName,
2462
+ entryId: entryId as any,
2463
+ });
2464
+ lines.push(JSON.stringify(entry));
2465
+ }
2466
+ } catch (error) {
2467
+ throw new CrmCliError(
2468
+ "bulk-delete aborted: could not snapshot the target rows before " +
2469
+ `deleting (${error instanceof Error ? error.message : String(error)}). ` +
2470
+ "No entries were deleted.",
2471
+ );
2472
+ }
2473
+ const os = await import("node:os");
2474
+ const path = await import("node:path");
2475
+ const fs = await import("node:fs/promises");
2476
+ const snapshotPath = path.join(
2477
+ os.tmpdir(),
2478
+ `dench-bulk-delete-${objectName.replace(/[^A-Za-z0-9._-]+/g, "_")}-${Date.now()}.jsonl`,
2479
+ );
2480
+ try {
2481
+ await fs.writeFile(
2482
+ snapshotPath,
2483
+ lines.length > 0 ? `${lines.join("\n")}\n` : "",
2484
+ "utf-8",
2485
+ );
2486
+ } catch (error) {
2487
+ throw new CrmCliError(
2488
+ `bulk-delete aborted: failed to write snapshot file ${snapshotPath} ` +
2489
+ `(${error instanceof Error ? error.message : String(error)}). ` +
2490
+ "No entries were deleted.",
2491
+ );
2492
+ }
2493
+ return { path: snapshotPath, rows: lines.length };
2494
+ }
2495
+
2165
2496
  async function runHistoryCommand(ctx: CrmCliContext): Promise<void> {
2166
2497
  const objectName = shift(ctx.args, "object name");
2167
2498
  const entryId = shift(ctx.args, "entry id");
@@ -2280,12 +2611,19 @@ async function runQueryCommand(ctx: CrmCliContext): Promise<void> {
2280
2611
  | undefined;
2281
2612
  const select = getFlag(ctx.args, "--select")?.split(",").filter(Boolean);
2282
2613
  const sort = getFlag(ctx.args, "--sort");
2283
- const limit = parseInt(getFlag(ctx.args, "--limit") ?? "100", 10);
2614
+ // The server clamps at 1000 silently; surface the clamp on stderr so
2615
+ // callers never mistake a truncated page for the full dataset. Output
2616
+ // here is a bare JSON array, so stderr is the only safe channel.
2617
+ const limit = resolveLimitFlag(ctx.args, {
2618
+ defaultValue: 100,
2619
+ cap: 1000,
2620
+ command: "crm query",
2621
+ });
2284
2622
  assertNoUnknownFlags(ctx.args, "crm query");
2285
2623
  // Convex's wire serializer rejects $-prefixed object keys, so we
2286
2624
  // route the DSL through the dslJson string field whenever the filter
2287
2625
  // has any operator-shaped clause.
2288
- const dsl = { filter, select, sort, limit };
2626
+ const dsl = { filter, select, sort, limit: limit.effective };
2289
2627
  const hasDollarKey = filter ? containsDollarKey(filter) : false;
2290
2628
  out(
2291
2629
  ctx,
@@ -2324,14 +2662,27 @@ async function runAggregateCommand(ctx: CrmCliContext): Promise<void> {
2324
2662
 
2325
2663
  async function runSearchCommand(ctx: CrmCliContext): Promise<void> {
2326
2664
  const objectName = getFlag(ctx.args, "--object");
2327
- const limit = parseInt(getFlag(ctx.args, "--limit") ?? "50", 10);
2665
+ // Search caps at 100 server-side. Warn on stderr when clamped (the
2666
+ // output is a bare JSON array, so stdout must stay untouched).
2667
+ const limit = resolveLimitFlag(ctx.args, {
2668
+ defaultValue: 50,
2669
+ cap: 100,
2670
+ command: "crm search",
2671
+ });
2328
2672
  // Drain known flags before joining the rest into the search text so a
2329
2673
  // typo like `--objct` surfaces instead of being concatenated into the
2330
2674
  // query string.
2331
2675
  assertNoUnknownFlags(ctx.args, "crm search");
2332
2676
  const text = ctx.args.join(" ").trim();
2333
2677
  if (!text) throw new CrmCliError("Search text required");
2334
- out(ctx, await callQuery(ctx, api.query.search, { text, objectName, limit }));
2678
+ out(
2679
+ ctx,
2680
+ await callQuery(ctx, api.query.search, {
2681
+ text,
2682
+ objectName,
2683
+ limit: limit.effective,
2684
+ }),
2685
+ );
2335
2686
  }
2336
2687
 
2337
2688
  async function runStatusesCommand(ctx: CrmCliContext): Promise<void> {
@@ -2681,15 +3032,44 @@ async function runTransactionCommand(ctx: CrmCliContext): Promise<void> {
2681
3032
 
2682
3033
  // ── Import / export ──────────────────────────────────────────────────────
2683
3034
 
3035
+ /**
3036
+ * Compute the stable per-row dedupe key for `crm import`.
3037
+ *
3038
+ * Default: content hash — `import:<object>:<sha256(stable JSON of row)>`.
3039
+ * With `--dedupe-key <column>`: `import:<object>:<column>:<normalized
3040
+ * value>` (trim + lowercase).
3041
+ *
3042
+ * Both are deterministic across runs — re-running the same import file
3043
+ * hits the same keys and the server's on-conflict policy applies instead
3044
+ * of duplicating every row (the old key embedded `Date.now()`, which
3045
+ * turned a re-run of a 42-row CSV into 84 rows).
3046
+ */
3047
+ export function importSeedKeyForRow(opts: {
3048
+ objectName: string;
3049
+ row: Record<string, unknown>;
3050
+ rowIndex: number; // 0-based
3051
+ dedupeColumn?: string;
3052
+ sha256: (input: string) => string;
3053
+ }): string {
3054
+ if (opts.dedupeColumn) {
3055
+ return dedupeKeyFromColumn({
3056
+ prefix: `import:${opts.objectName}:${opts.dedupeColumn}`,
3057
+ column: opts.dedupeColumn,
3058
+ row: opts.row,
3059
+ rowIndex: opts.rowIndex,
3060
+ command: "crm import",
3061
+ });
3062
+ }
3063
+ return `import:${opts.objectName}:${opts.sha256(stableJsonStringify(opts.row))}`;
3064
+ }
3065
+
2684
3066
  async function runImportCommand(ctx: CrmCliContext): Promise<void> {
2685
3067
  const objectName = shift(ctx.args, "object name");
2686
3068
  const csvPath = getFlag(ctx.args, "--csv");
2687
3069
  const jsonlPath = getFlag(ctx.args, "--jsonl");
2688
3070
  const mapJson = getFlag(ctx.args, "--map");
2689
- const onConflict = (getFlag(ctx.args, "--on-conflict") ?? "skip") as
2690
- | "skip"
2691
- | "update"
2692
- | "error";
3071
+ const dedupeColumn = getFlag(ctx.args, "--dedupe-key");
3072
+ const onConflict = parseOnConflictFlag(getFlag(ctx.args, "--on-conflict"));
2693
3073
  assertNoUnknownFlags(ctx.args, "crm import");
2694
3074
  if (!csvPath && !jsonlPath) {
2695
3075
  throw new CrmCliError(
@@ -2712,41 +3092,98 @@ async function runImportCommand(ctx: CrmCliContext): Promise<void> {
2712
3092
  rows.push(remapKeys(parsed, colMap));
2713
3093
  }
2714
3094
  }
2715
- // Chunk into 100-op batches and call batch.apply repeatedly.
2716
- let imported = 0;
2717
- for (let i = 0; i < rows.length; i += 100) {
2718
- const chunk = rows.slice(i, i + 100);
2719
- const ops = chunk.map((data) => ({
2720
- type: "entries.create" as const,
3095
+ const { createHash } = await import("node:crypto");
3096
+ const sha256 = (input: string) =>
3097
+ createHash("sha256").update(input).digest("hex");
3098
+ const ops: CrmBatchOp[] = rows.map((data, index) => ({
3099
+ type: "entries.create",
3100
+ objectName,
3101
+ data,
3102
+ seedKey: importSeedKeyForRow({
2721
3103
  objectName,
2722
- data,
2723
- }));
2724
- const result = (await callMutation(ctx, api.batch.apply, {
2725
- ops: ops as any,
2726
- idempotencyKey: `import:${objectName}:${Date.now()}:${i}`,
2727
- } as any)) as { count: number };
2728
- imported += result.count;
2729
- }
2730
- out(ctx, { imported, onConflict });
3104
+ row: data,
3105
+ rowIndex: index,
3106
+ dedupeColumn,
3107
+ sha256,
3108
+ }),
3109
+ rowIndex: index + 1,
3110
+ }));
3111
+ // batch.apply enforces the on-conflict policy per row against the
3112
+ // (objectId, seedKey) index; chunking is handled by the shared helper.
3113
+ const { results } = await applyBatchOpsInChunks(
3114
+ ctx,
3115
+ ops,
3116
+ undefined,
3117
+ false,
3118
+ onConflict,
3119
+ );
3120
+ const counts = countResultStatuses(results);
3121
+ out(ctx, {
3122
+ objectName,
3123
+ sourceRowsParsed: rows.length,
3124
+ imported: counts.created,
3125
+ skipped: counts.alreadyExisted,
3126
+ updated: counts.updated,
3127
+ errors: 0,
3128
+ onConflict,
3129
+ ...(dedupeColumn ? { dedupeKey: dedupeColumn } : {}),
3130
+ });
2731
3131
  }
2732
3132
 
2733
3133
  async function runExportCommand(ctx: CrmCliContext): Promise<void> {
2734
3134
  const objectName = shift(ctx.args, "object name");
2735
3135
  const format = (getFlag(ctx.args, "--format") ?? "jsonl") as "csv" | "jsonl";
2736
- const limit = parseInt(getFlag(ctx.args, "--limit") ?? "10000", 10);
3136
+ const limit = resolveLimitFlag(ctx.args, {
3137
+ defaultValue: 10000,
3138
+ cap: Number.MAX_SAFE_INTEGER,
3139
+ command: "crm export",
3140
+ }).effective;
2737
3141
  assertNoUnknownFlags(ctx.args, "crm export");
2738
- // Page through entries.list in 1000-row chunks.
3142
+ // Page through entries.listPage in 1000-row chunks using a real
3143
+ // creation-time cursor. The previous loop called `entries.list` with
3144
+ // no cursor at all, so every iteration re-fetched the newest 1000 rows
3145
+ // — a 10,000-row export was the same 1,000 rows ten times, and the
3146
+ // dedupe script that consumed it deleted ~8,000 real accounts.
2739
3147
  const all: any[] = [];
2740
- let cursor = 0;
3148
+ let cursor: string | null = null;
2741
3149
  while (all.length < limit) {
2742
- const page = (await callQuery(ctx, api.entries.list, {
3150
+ const result = (await callQuery(ctx, api.entries.listPage, {
2743
3151
  objectName,
2744
3152
  limit: Math.min(1000, limit - all.length),
2745
- })) as any[];
2746
- if (!page || page.length === 0) break;
3153
+ ...(cursor !== null ? { cursor } : {}),
3154
+ })) as {
3155
+ page?: unknown[];
3156
+ isDone?: boolean;
3157
+ continueCursor?: string | null;
3158
+ truncated?: boolean;
3159
+ } | null;
3160
+ const page = Array.isArray(result?.page) ? result.page : [];
2747
3161
  all.push(...page);
2748
- cursor += page.length;
2749
- if (page.length < 1000) break;
3162
+ if (result?.truncated) {
3163
+ process.stderr.write(
3164
+ `warning: crm export of "${objectName}" is capped at ${page.length} rows ` +
3165
+ "(personal objects load single-shot); the export may be incomplete.\n",
3166
+ );
3167
+ }
3168
+ if (!result || result.isDone !== false) break;
3169
+ const next = result.continueCursor ?? null;
3170
+ if (!next || next === cursor) break; // defensive: never loop in place
3171
+ cursor = next;
3172
+ }
3173
+ // Invariant guard: one export must never contain the same entry twice.
3174
+ // Duplicates mean pagination is broken and the file is unsafe to feed
3175
+ // into any dedupe/migration script — fail hard rather than emit it.
3176
+ const ids = all.map((entry) => String(entry?.id ?? ""));
3177
+ const uniqueIds = new Set(ids).size;
3178
+ if (uniqueIds !== all.length) {
3179
+ process.stderr.write(
3180
+ `error: crm export produced duplicate rows (${all.length} rows, ` +
3181
+ `${uniqueIds} unique entry ids). Pagination invariant violated — ` +
3182
+ "no output was written.\n",
3183
+ );
3184
+ throw new CrmCliError(
3185
+ `crm export: duplicate entry ids detected (${all.length} rows, ${uniqueIds} unique). Export aborted.`,
3186
+ );
2750
3187
  }
2751
3188
  if (format === "csv") {
2752
3189
  const fields = Object.keys(all[0]?.fields ?? {});
@@ -2766,21 +3203,34 @@ async function runExportCommand(ctx: CrmCliContext): Promise<void> {
2766
3203
  );
2767
3204
  }
2768
3205
  }
3206
+ // Verification trailer (stderr — stdout is the data stream): row count
3207
+ // and unique-id count so callers can assert integrity in scripts.
3208
+ process.stderr.write(
3209
+ `crm export ${objectName}: exportedCount=${all.length} uniqueIds=${uniqueIds}\n`,
3210
+ );
2769
3211
  }
2770
3212
 
2771
3213
  export function parseCsv(
2772
3214
  text: string,
2773
3215
  colMap: Record<string, string>,
2774
3216
  ): Record<string, unknown>[] {
2775
- // Tiny CSV parser: comma separator, double-quote escaping. Doesn't
2776
- // handle multi-line quoted cells (rare for CRM imports). Callers with
2777
- // pathological CSVs should pre-clean via `csvkit` or similar.
3217
+ // Full-stream CSV parser: comma separator, double-quote escaping,
3218
+ // QUOTED CELLS SPANNING MULTIPLE LINES, and CRLF endings. The previous
3219
+ // implementation split on newlines before parsing quotes, so any CSV
3220
+ // with a multiline Notes cell was silently corrupted (a 217-row file
3221
+ // collapsed to 42 parsed rows). Header names are trimmed (" Email" →
3222
+ // "Email"); both the raw and the trimmed header resolve via colMap.
3223
+ const records = parseCsvRecords(text).filter((cells) =>
3224
+ cells.some((cell) => cell.trim().length > 0),
3225
+ );
3226
+ if (records.length === 0) return [];
3227
+ const header = records[0].map((col) => {
3228
+ const trimmed = col.trim();
3229
+ return colMap[trimmed] ?? colMap[col] ?? trimmed;
3230
+ });
2778
3231
  const rows: Record<string, unknown>[] = [];
2779
- const lines = text.split(/\r?\n/).filter((line) => line.trim().length > 0);
2780
- if (lines.length === 0) return rows;
2781
- const header = parseCsvLine(lines[0]).map((col) => colMap[col] ?? col);
2782
- for (let i = 1; i < lines.length; i++) {
2783
- const cols = parseCsvLine(lines[i]);
3232
+ for (let i = 1; i < records.length; i++) {
3233
+ const cols = records[i];
2784
3234
  const row: Record<string, unknown> = {};
2785
3235
  for (let j = 0; j < header.length; j++) {
2786
3236
  row[header[j]] = cols[j] ?? null;
@@ -2790,6 +3240,61 @@ export function parseCsv(
2790
3240
  return rows;
2791
3241
  }
2792
3242
 
3243
+ /**
3244
+ * Split raw CSV text into records of cells, walking the text as ONE
3245
+ * character stream (never line-splitting first) so quoted cells can
3246
+ * contain literal newlines. Quote semantics match `parseCsvLine`: a
3247
+ * quote only opens a quoted section at the start of a cell, and `""`
3248
+ * inside quotes is an escaped quote.
3249
+ */
3250
+ export function parseCsvRecords(text: string): string[][] {
3251
+ const records: string[][] = [];
3252
+ let record: string[] = [];
3253
+ let buf = "";
3254
+ let inQuotes = false;
3255
+ let sawAny = false; // any char consumed into the current record
3256
+ for (let i = 0; i < text.length; i++) {
3257
+ const ch = text[i];
3258
+ if (inQuotes) {
3259
+ if (ch === '"' && text[i + 1] === '"') {
3260
+ buf += '"';
3261
+ i++;
3262
+ } else if (ch === '"') {
3263
+ inQuotes = false;
3264
+ } else {
3265
+ // Includes \n / \r — this is what makes multiline cells work.
3266
+ buf += ch;
3267
+ }
3268
+ continue;
3269
+ }
3270
+ if (ch === ",") {
3271
+ record.push(buf);
3272
+ buf = "";
3273
+ sawAny = true;
3274
+ } else if (ch === '"' && buf === "") {
3275
+ inQuotes = true;
3276
+ sawAny = true;
3277
+ } else if (ch === "\n" || ch === "\r") {
3278
+ if (ch === "\r" && text[i + 1] === "\n") i++;
3279
+ record.push(buf);
3280
+ records.push(record);
3281
+ record = [];
3282
+ buf = "";
3283
+ sawAny = false;
3284
+ } else {
3285
+ buf += ch;
3286
+ sawAny = true;
3287
+ }
3288
+ }
3289
+ // Trailing record without a final newline (or an unterminated quote —
3290
+ // best-effort: keep what was read).
3291
+ if (sawAny || buf.length > 0 || record.length > 0) {
3292
+ record.push(buf);
3293
+ records.push(record);
3294
+ }
3295
+ return records;
3296
+ }
3297
+
2793
3298
  export function parseCsvLine(line: string): string[] {
2794
3299
  const out: string[] = [];
2795
3300
  let buf = "";
@@ -3721,6 +4226,9 @@ Fields (columns):
3721
4226
  dench crm fields enum-add <object> <field> <value> [--color HEX] [--position N]
3722
4227
  Appends <value> to the field's enumValues. --color is a hex like
3723
4228
  "#22c55e"; --position is a 0-based insertion index (defaults to append).
4229
+ Without --color the option is auto-colored: semantic keyword match
4230
+ (Done/Won → green, Working → blue, At Risk/Pending → yellow,
4231
+ Churned/Lost → red, Todo/New → gray), else a rotating palette.
3724
4232
  Idempotent (case-insensitive): re-adding "won" when "Won" exists does
3725
4233
  NOT create a twin option — the response's "value" echoes the canonical
3726
4234
  casing; a given --color still updates the existing option's color.
@@ -3728,16 +4236,19 @@ Fields (columns):
3728
4236
  kanban column (positioned to match), so you do NOT need a separate
3729
4237
  "statuses set" call.
3730
4238
  dench crm fields enum-remove <object> <field> <value>
3731
- Removes the value from the option list. Historical entry rows that held
3732
- <value> keep the literal string (the badge falls back to the default
3733
- tint), matching Notion's "remove option, keep history" semantics. A
3734
- matching kanban column is removed too.
4239
+ Removes the value from the option list AND unsets it on every entry
4240
+ that held it (complete for indexed enum fields — the default). A
4241
+ matching kanban column is removed too. Use enum-rename first if the
4242
+ entries should migrate to another option instead of losing the value.
3735
4243
  dench crm fields enum-rename <object> <field> <oldValue> <newValue>
3736
4244
  Renames the option AND migrates every entry that referenced the old
3737
4245
  value (complete for indexed enum fields — the default — via the
3738
- projection index) AND renames matching crmStatuses. NOTE: saved views
3739
- whose filters pin the OLD label are not rewritten; update those view
3740
- filters after a rename or they silently go empty.
4246
+ projection index) AND renames matching crmStatuses. When <newValue>
4247
+ matches an EXISTING option case-insensitively, the rename MERGES onto
4248
+ that option's canonical casing (entries migrate, the duplicate is
4249
+ dropped) — the canonical way to dedupe two same-meaning options.
4250
+ NOTE: saved views whose filters pin the OLD label are not rewritten;
4251
+ update those view filters after a rename or they silently go empty.
3741
4252
  dench crm fields enum-set-color <object> <field> <value> <hex>
3742
4253
  Update the color (e.g. "#3b82f6") for an existing enum option.
3743
4254
 
@@ -3745,12 +4256,19 @@ Entries (rows):
3745
4256
  dench crm entries list <object> [--limit N] [--with-meta] [--json]
3746
4257
  dench crm entries get <object> <entryId>
3747
4258
  dench crm history <object> <entryId> [--include-system] [--limit N] [--cursor CURSOR] [--json]
3748
- dench crm entries create <object> --data '{"Field":"Value",...}' (alias: --fields) [--allow-unknown]
3749
- dench crm entries create-many <object> --data '[{"Field":"Value",...}]' [--file rows.json] [--idempotency-key <key>] [--allow-unknown]
4259
+ dench crm entries create <object> --data '{"Field":"Value",...}' (alias: --fields) [--allow-unknown] [--idempotency-key <key>]
4260
+ --idempotency-key upserts on (object, key): a retried create returns
4261
+ the existing row with alreadyExisted: true instead of duplicating it.
4262
+ dench crm entries create-many <object> --data '[{"Field":"Value",...}]' [--file rows.json] [--idempotency-key <key>] [--dedupe-key <column>] [--allow-unknown]
4263
+ --dedupe-key <column> makes each row idempotent by that column's
4264
+ value; rows that already exist are skipped (status: already_existed).
3750
4265
  dench crm entries update <object> <entryId> --data '{...}' (alias: --fields) [--allow-unknown]
3751
4266
  dench crm entries update-many <object> --updates '[{"id":"...","data":{...}}]' [--file updates.json] [--idempotency-key <key>] [--allow-unknown]
3752
4267
  dench crm entries delete <object> <entryId>
3753
4268
  dench crm entries bulk-delete <object> --ids id1,id2,id3
4269
+ Automatically snapshots the target rows to a JSONL undo file in the
4270
+ temp dir first (snapshotPath/snapshotRows in the output); the delete
4271
+ aborts if the snapshot cannot be written.
3754
4272
  Note: --data is REQUIRED on create/update and must include >=1 field.
3755
4273
  An empty --data '{}' is rejected — use 'crm cells set' for
3756
4274
  single-field tweaks instead of creating a placeholder blank row.
@@ -3814,9 +4332,14 @@ Batch:
3814
4332
  and calls a batch surface in chunks.
3815
4333
 
3816
4334
  Import / export:
3817
- dench crm import <object> --csv leads.csv [--map '{"Email":"Email Address"}'] [--on-conflict skip|update|error]
4335
+ dench crm import <object> --csv leads.csv [--map '{"Email":"Email Address"}'] [--on-conflict skip|update|error] [--dedupe-key <column>]
3818
4336
  dench crm import <object> --jsonl rows.jsonl
4337
+ Imports are idempotent: every row gets a stable dedupe key (content
4338
+ hash, or --dedupe-key <column>), so re-running the same file skips /
4339
+ updates / errors per --on-conflict instead of duplicating rows.
3819
4340
  dench crm export <object> --format csv|jsonl [--limit N]
4341
+ Pages with a real cursor and verifies no duplicate ids before
4342
+ emitting; a summary (exportedCount/uniqueIds) is printed to stderr.
3820
4343
 
3821
4344
  Transactions (multi-step, all-or-nothing):
3822
4345
  TXN=$(dench crm transaction begin)