@wirecat/cli-core 0.595.0 → 0.597.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.
Files changed (52) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/dist/background/deliver.d.ts +7 -1
  3. package/dist/background/deliver.js +66 -10
  4. package/dist/background/telegram-html.d.ts +2 -0
  5. package/dist/background/telegram-html.js +66 -0
  6. package/dist/calendar/mcp/index.d.ts +12 -12
  7. package/dist/cli/index.d.ts +1 -1
  8. package/dist/cli/permission-keys.d.ts +10 -2
  9. package/dist/cli/preview.d.ts +3 -0
  10. package/dist/cli/preview.js +9 -5
  11. package/dist/crm/cli/index.js +71 -14
  12. package/dist/crm/index.d.ts +1 -1
  13. package/dist/crm/index.js +1 -1
  14. package/dist/crm/source.d.ts +6 -1
  15. package/dist/crm/sync.d.ts +5 -4
  16. package/dist/crm/sync.js +22 -4
  17. package/dist/crm/testing/index.d.ts +1 -1
  18. package/dist/crm/testing/index.js +1 -1
  19. package/dist/crm/testing/source-contract.js +28 -0
  20. package/dist/crm/testing/source.d.ts +8 -1
  21. package/dist/crm/testing/source.js +53 -9
  22. package/dist/crm/testing/sync-contract.js +14 -3
  23. package/dist/crm/writer.d.ts +11 -2
  24. package/dist/crm/writer.js +31 -0
  25. package/dist/crm/writes.d.ts +12 -2
  26. package/dist/crm/writes.js +93 -8
  27. package/dist/domain/crm.d.ts +29 -1
  28. package/dist/entities/deal.d.ts +16 -0
  29. package/dist/entities/deal.js +8 -0
  30. package/dist/entities/index.d.ts +12 -0
  31. package/dist/errors/reasons.js +4 -0
  32. package/dist/index.d.ts +1 -1
  33. package/dist/meetings/mcp/index.d.ts +15 -15
  34. package/dist/store/driver.d.ts +2 -0
  35. package/dist/store/driver.js +2 -0
  36. package/dist/store/search-index.js +6 -1
  37. package/dist/store/sqlite/accounts.js +1 -0
  38. package/dist/store/sqlite/crm.d.ts +5 -1
  39. package/dist/store/sqlite/crm.js +52 -0
  40. package/dist/store/sqlite/deals.d.ts +5 -0
  41. package/dist/store/sqlite/deals.js +13 -0
  42. package/dist/store/sqlite/manifest.js +1 -0
  43. package/dist/store/sqlite/migrations.generated.js +6 -0
  44. package/dist/store/sqlite/note-index.d.ts +1 -1
  45. package/dist/store/sqlite/note-index.js +3 -0
  46. package/dist/store/sqlite/open-store.d.ts +2 -2
  47. package/dist/store/sqlite/open-store.js +29 -6
  48. package/dist/store/sqlite/read-compatibility.d.ts +2 -0
  49. package/dist/store/sqlite/read-compatibility.js +7 -3
  50. package/dist/store/sqlite/schema.d.ts +217 -0
  51. package/dist/store/sqlite/schema.js +18 -0
  52. package/package.json +7 -1
@@ -1,4 +1,4 @@
1
1
  export type { CrmChanges, CrmIdPage, CrmPage, CrmSource, CrmSourceTraits } from "./source.js";
2
2
  export { type CrmCursor, type CrmSyncIssue, type CrmSyncOptions, type CrmSyncReport, type CrmSyncResult, crmStatus, cursorName, parseCrmCursor, syncCrm, } from "./sync.js";
3
- export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, type CrmDraft, type CrmFields, type CrmVerb, type CrmWriteCapabilities, type CrmWriteOptions, type CrmWriter, } from "./writer.js";
3
+ export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, type CrmDraft, type CrmFields, type CrmVerb, type CrmWriteCapabilities, type CrmWriteOptions, type CrmWriter, crmAlsoChecks, } from "./writer.js";
4
4
  export { type CrmFieldsInput, type CrmWriteContext, type CrmWrites, type CrmWritten, closeCrmItem, createCrmItem, moveCrmItem, removeCrmItem, updateCrmItem, } from "./writes.js";
package/dist/crm/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  export { crmStatus, cursorName, parseCrmCursor, syncCrm, } from "./sync.js";
2
- export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, } from "./writer.js";
2
+ export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, crmAlsoChecks, } from "./writer.js";
3
3
  export { closeCrmItem, createCrmItem, moveCrmItem, removeCrmItem, updateCrmItem, } from "./writes.js";
@@ -1,4 +1,4 @@
1
- import type { CrmKind, CrmPipeline, CrmRecords } from "../domain/crm.js";
1
+ import type { CrmField, CrmKind, CrmPipeline, CrmRecords } from "../domain/crm.js";
2
2
  import type { DealKind } from "../domain/deals.js";
3
3
  export interface CrmSourceTraits {
4
4
  /** What this CRM has: one without leads leaves `lead` out. Contacts and companies come before the items on them. */
@@ -38,6 +38,11 @@ export interface CrmSource {
38
38
  * reads by its own modified time, with an overlap, and gives that time back as the cursor.
39
39
  */
40
40
  changes<K extends CrmKind>(kind: K, cursor: string | null, page?: string): Promise<CrmChanges<CrmRecords[K]>>;
41
+ /**
42
+ * The CRM's own fields on one kind, in its order: what the codes in a row's `metadata` mean. Read every sync; a CRM
43
+ * without custom fields leaves it out, and one that has none on a kind answers `[]`.
44
+ */
45
+ fields?(kind: CrmKind): Promise<CrmField[]>;
41
46
  /** Every id the CRM holds of the kind, a page at a time: how deletions are found when `removedInChanges` is false. */
42
47
  ids?(kind: CrmKind, page?: string): Promise<CrmIdPage>;
43
48
  }
@@ -87,10 +87,11 @@ export interface CrmSyncReport {
87
87
  hint?: string;
88
88
  }
89
89
  /**
90
- * Brings one CRM account's copy up to date: both kinds' pipelines, then contacts, companies, deals and leads, each
91
- * from its cursor or listed in full, a page at a time, each page saved with the round's place in the cursor, so a
92
- * run the budget stops, or one that fails, goes on from there. Deletions come from `changes()`, or from a list of
93
- * every id that marks only after its last page arrived, and never more than half of a kind without `--rescan`.
90
+ * Brings one CRM account's copy up to date: both kinds' pipelines and each kind's fields, then contacts, companies,
91
+ * deals and leads, each from its cursor or listed in full, a page at a time, each page saved with the round's place in
92
+ * the cursor, so a run the budget stops, or one that fails, goes on from there. Deletions come from `changes()`, or
93
+ * from a list of every id that marks only after its last page arrived, and never more than half of a kind without
94
+ * `--rescan`.
94
95
  */
95
96
  export declare const syncCrm: (source: CrmSource, store: MessageStore, account: AccountKey, options?: CrmSyncOptions) => Promise<CrmSyncReport>;
96
97
  /** Each kind's sync in the WC-LIFE-14 shape: `cursor_age_ms` growing on a `partial` one means nothing moves it. */
package/dist/crm/sync.js CHANGED
@@ -57,6 +57,16 @@ const readPipelines = async (run, kind) => {
57
57
  run.report.issues.push({ kind, ...one });
58
58
  return null;
59
59
  };
60
+ const readFields = async (run, kind) => {
61
+ const why = run.budget.take();
62
+ if (why)
63
+ return why;
64
+ const fields = await run.source.fields.call(run.source, kind);
65
+ const { skipped } = await run.store.crm.saveFields(run.account, kind, fields);
66
+ for (const one of skipped)
67
+ run.report.issues.push({ kind, ...one });
68
+ return null;
69
+ };
60
70
  /** One page saved; an item whose stage is unknown gets one more chance after the kind's pipelines are read again. */
61
71
  const savePage = async (run, kind, page) => {
62
72
  const { store, account } = run;
@@ -185,10 +195,11 @@ const syncKind = async (run, kind, options, result) => {
185
195
  return null;
186
196
  };
187
197
  /**
188
- * Brings one CRM account's copy up to date: both kinds' pipelines, then contacts, companies, deals and leads, each
189
- * from its cursor or listed in full, a page at a time, each page saved with the round's place in the cursor, so a
190
- * run the budget stops, or one that fails, goes on from there. Deletions come from `changes()`, or from a list of
191
- * every id that marks only after its last page arrived, and never more than half of a kind without `--rescan`.
198
+ * Brings one CRM account's copy up to date: both kinds' pipelines and each kind's fields, then contacts, companies,
199
+ * deals and leads, each from its cursor or listed in full, a page at a time, each page saved with the round's place in
200
+ * the cursor, so a run the budget stops, or one that fails, goes on from there. Deletions come from `changes()`, or
201
+ * from a list of every id that marks only after its last page arrived, and never more than half of a kind without
202
+ * `--rescan`.
192
203
  */
193
204
  export const syncCrm = async (source, store, account, options = {}) => {
194
205
  const now = options.now ?? Date.now();
@@ -225,6 +236,13 @@ export const syncCrm = async (source, store, account, options = {}) => {
225
236
  if (options.signal?.aborted)
226
237
  stopped = "cancelled";
227
238
  }
239
+ if (run.source.fields)
240
+ for (const kind of kinds)
241
+ if (!stopped) {
242
+ stopped = await readFields(run, kind);
243
+ if (options.signal?.aborted)
244
+ stopped = "cancelled";
245
+ }
228
246
  for (const kind of kinds) {
229
247
  const result = {
230
248
  kind,
@@ -1,4 +1,4 @@
1
- export { exampleDeal, exampleLead, exampleOrganization, examplePerson, FAKE_CRM_ALIASES, type FakeCrmSource, type FakeCrmSourceOptions, type FakeCrmWrite, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
1
+ export { exampleDeal, exampleLead, exampleOrganization, examplePerson, FAKE_CRM_ALIASES, type FakeCrmSource, type FakeCrmSourceOptions, type FakeCrmWrite, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, SOURCE_FIELD, } from "./source.js";
2
2
  export { type CrmSourceFixture, crmSourceContract } from "./source-contract.js";
3
3
  export { CRM_TRAIT_COMBINATIONS, type CrmSyncFixture, crmSyncContract } from "./sync-contract.js";
4
4
  export { type CrmWriteFixture, crmWriteContract } from "./write-contract.js";
@@ -1,4 +1,4 @@
1
- export { exampleDeal, exampleLead, exampleOrganization, examplePerson, FAKE_CRM_ALIASES, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
1
+ export { exampleDeal, exampleLead, exampleOrganization, examplePerson, FAKE_CRM_ALIASES, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, SOURCE_FIELD, } from "./source.js";
2
2
  export { crmSourceContract } from "./source-contract.js";
3
3
  export { CRM_TRAIT_COMBINATIONS, crmSyncContract } from "./sync-contract.js";
4
4
  export { crmWriteContract } from "./write-contract.js";
@@ -51,6 +51,34 @@ export const crmSourceContract = (make) => {
51
51
  }
52
52
  },
53
53
  },
54
+ {
55
+ name: "fields: unique codes in each kind, a type, yes-or-no flags, and a list field's options with unique ids",
56
+ async run() {
57
+ const { source } = await make();
58
+ if (!source.fields)
59
+ return;
60
+ for (const kind of source.traits.kinds) {
61
+ const fields = await source.fields(kind);
62
+ assert.ok(Array.isArray(fields), `${kind}: fields() answers a list`);
63
+ const codes = new Set();
64
+ for (const field of fields) {
65
+ assert.ok(typeof field.code === "string" && field.code.trim(), `${kind}: a field has a code`);
66
+ assert.ok(!codes.has(field.code), `${kind} field ${field.code} is listed twice`);
67
+ codes.add(field.code);
68
+ assert.ok(field.label === null || typeof field.label === "string", `${field.code}: a label or null`);
69
+ assert.ok(typeof field.type === "string" && field.type.trim(), `${field.code} has a type`);
70
+ assert.equal(typeof field.multiple, "boolean", `${field.code}: multiple is true or false`);
71
+ assert.equal(typeof field.filterable, "boolean", `${field.code}: filterable is true or false`);
72
+ if (field.options === undefined)
73
+ continue;
74
+ const ids = new Set(field.options.map(({ id }) => id));
75
+ assert.equal(ids.size, field.options.length, `${field.code}: option ids are unique`);
76
+ for (const option of field.options)
77
+ assert.ok(typeof option.id === "string" && typeof option.label === "string", `${field.code}'s options`);
78
+ }
79
+ }
80
+ },
81
+ },
54
82
  {
55
83
  name: "changes: a full list pages to its end, every item stands in a listed stage, fields in the neutral shape",
56
84
  async run() {
@@ -1,4 +1,4 @@
1
- import type { CrmItem, CrmKind, CrmOrganization, CrmPerson, CrmPipeline, CrmRecords } from "../../domain/crm.js";
1
+ import type { CrmField, CrmItem, CrmKind, CrmOrganization, CrmPerson, CrmPipeline, CrmRecords } from "../../domain/crm.js";
2
2
  import type { DealKind } from "../../domain/deals.js";
3
3
  import type { CrmSource, CrmSourceTraits } from "../source.js";
4
4
  import { type CrmVerb, type CrmWriteCapabilities, type CrmWriter } from "../writer.js";
@@ -27,15 +27,20 @@ export interface FakeCrmSource extends CrmSource, CrmWriter {
27
27
  /** Deletes a row: named in `removed` when `removedInChanges`, otherwise only missing from `ids()`. */
28
28
  delete(kind: CrmKind, externalId: string): void;
29
29
  setPipelines(kind: DealKind, pipelines: CrmPipeline[]): void;
30
+ /** The kind's own fields from the next `fields()` on: deals have `SOURCE_FIELD` and the rest none until set. */
31
+ setFields(kind: CrmKind, fields: CrmField[]): void;
30
32
  /** The kind's cursors older than its latest change are refused, once, with `{ reset: true }`. */
31
33
  expireCursors(kind: CrmKind): void;
32
34
  /** Lists no id at all, as a CRM does for a sign-in that lost its rights. */
33
35
  hideAll(hidden: boolean): void;
34
36
  /** The next write is carried out and its answer lost: it throws `outcome_unknown`. */
35
37
  loseNextAnswer(): void;
38
+ /** The next write is carried out and its item cannot be read back: it throws with `sent: true`. */
39
+ failNextReadBack(): void;
36
40
  /** Requests made, by method. */
37
41
  readonly calls: {
38
42
  pipelines: number;
43
+ fields: number;
39
44
  changes: number;
40
45
  ids: number;
41
46
  };
@@ -45,6 +50,8 @@ export interface FakeCrmSource extends CrmSource, CrmWriter {
45
50
  /** Custom codes the fake refuses: each is a neutral field under another name. */
46
51
  export declare const FAKE_CRM_ALIASES: string[];
47
52
  export declare const SALES_PIPELINE: CrmPipeline;
53
+ /** A deal's list field, as a CRM lists one: what the fake's deals have. */
54
+ export declare const SOURCE_FIELD: CrmField;
48
55
  export declare const LEAD_PIPELINE: CrmPipeline;
49
56
  export declare const fakeCrmSource: (options?: FakeCrmSourceOptions) => FakeCrmSource;
50
57
  export declare const exampleDeal: (externalId: string, extra?: Partial<CrmItem>) => CrmItem;
@@ -14,6 +14,18 @@ export const SALES_PIPELINE = {
14
14
  { externalId: "lost", name: "Lost", position: 4, outcome: "lost" },
15
15
  ],
16
16
  };
17
+ /** A deal's list field, as a CRM lists one: what the fake's deals have. */
18
+ export const SOURCE_FIELD = {
19
+ code: "UF_SOURCE",
20
+ label: "Source",
21
+ type: "enumeration",
22
+ multiple: false,
23
+ filterable: true,
24
+ options: [
25
+ { id: "1", label: "Website" },
26
+ { id: "2", label: "Fair" },
27
+ ],
28
+ };
17
29
  export const LEAD_PIPELINE = {
18
30
  externalId: "lead-1",
19
31
  name: "Example leads",
@@ -32,10 +44,11 @@ export const fakeCrmSource = (options = {}) => {
32
44
  const size = options.pageSize ?? 2;
33
45
  const rows = new Map();
34
46
  const pipelines = { deal: [SALES_PIPELINE], lead: [LEAD_PIPELINE] };
47
+ const fields = { deal: [SOURCE_FIELD], lead: [], person: [], organization: [] };
35
48
  let version = 0;
36
49
  const expired = new Map();
37
50
  let hidden = false;
38
- const calls = { pipelines: 0, changes: 0, ids: 0 };
51
+ const calls = { pipelines: 0, fields: 0, changes: 0, ids: 0 };
39
52
  const capabilities = {
40
53
  kinds: ["deal", "lead"],
41
54
  verbs: CRM_VERBS,
@@ -46,6 +59,7 @@ export const fakeCrmSource = (options = {}) => {
46
59
  const answered = new Map();
47
60
  let made = 0;
48
61
  let lose = false;
62
+ let unread = false;
49
63
  const of = (kind) => {
50
64
  const held = rows.get(kind) ?? new Map();
51
65
  rows.set(kind, held);
@@ -69,18 +83,32 @@ export const fakeCrmSource = (options = {}) => {
69
83
  throw new CliError("validation_error", `the CRM has no stage ${stageId} there`);
70
84
  return { pipeline: pipeline.externalId, stage: stage.externalId, outcome: stage.outcome };
71
85
  };
72
- const applied = (item, fields) => {
73
- const { custom, owner, pipeline, stage, ...named } = fields;
74
- const metadata = {
75
- ...item.metadata,
76
- ...(owner === undefined ? {} : { owner }),
77
- ...(custom ? { custom: { ...item.metadata?.custom, ...custom } } : {}),
86
+ /** A custom value as a CRM answers it: by code, with the field's label and a list option's label. */
87
+ const customValue = (kind, code, value) => {
88
+ const field = fields[kind].find((one) => one.code === code);
89
+ const label = (id) => field?.options?.find((option) => option.id === id)?.label ?? id;
90
+ if (!field?.options)
91
+ return { label: field?.label ?? null, value };
92
+ return {
93
+ label: field.label,
94
+ value: Array.isArray(value) ? value.map(label) : label(value),
95
+ option_id: value,
78
96
  };
97
+ };
98
+ const applied = (kind, item, given) => {
99
+ const { custom, owner, pipeline, stage, ...named } = given;
100
+ const metadata = { ...item.metadata };
101
+ for (const [code, value] of Object.entries(custom ?? {}))
102
+ if (value === null)
103
+ delete metadata[code];
104
+ else
105
+ metadata[code] = customValue(kind, code, value);
79
106
  return {
80
107
  ...item,
81
108
  ...Object.fromEntries(Object.entries(named).filter(([, value]) => value !== undefined)),
82
109
  ...(pipeline === undefined ? {} : { pipeline }),
83
110
  ...(stage === undefined ? {} : { stage }),
111
+ ...(owner === undefined ? {} : { assignee: owner }),
84
112
  metadata,
85
113
  updatedAt: null,
86
114
  };
@@ -102,6 +130,12 @@ export const fakeCrmSource = (options = {}) => {
102
130
  lose = false;
103
131
  throw new CliError("outcome_unknown", "the CRM did not answer");
104
132
  }
133
+ if (unread) {
134
+ unread = false;
135
+ throw new CliError("provider_error", `the CRM made the ${verb} but its ${kind} could not be read back`, {
136
+ sent: true,
137
+ });
138
+ }
105
139
  return done;
106
140
  };
107
141
  const saved = (kind, item) => {
@@ -117,13 +151,16 @@ export const fakeCrmSource = (options = {}) => {
117
151
  loseNextAnswer() {
118
152
  lose = true;
119
153
  },
154
+ failNextReadBack() {
155
+ unread = true;
156
+ },
120
157
  async create(kind, draft, { operationId }) {
121
158
  refuseAliases(draft);
122
159
  return write("create", kind, null, operationId, () => {
123
160
  const { pipeline, stage } = placed(kind, draft.pipeline, draft.stage);
124
161
  made += 1;
125
162
  const blank = { externalId: `${kind}-new-${made}`, title: draft.title, pipeline, stage };
126
- return saved(kind, applied(blank, { ...draft, pipeline, stage }));
163
+ return saved(kind, applied(kind, blank, { ...draft, pipeline, stage }));
127
164
  });
128
165
  },
129
166
  async update(kind, externalId, patch, { operationId }) {
@@ -131,7 +168,7 @@ export const fakeCrmSource = (options = {}) => {
131
168
  return write("update", kind, externalId, operationId, () => {
132
169
  const item = held(kind, externalId);
133
170
  const at = patch.stage === undefined ? {} : placed(kind, patch.pipeline ?? item.pipeline, patch.stage);
134
- return saved(kind, applied(item, { ...patch, ...at }));
171
+ return saved(kind, applied(kind, item, { ...patch, ...at }));
135
172
  });
136
173
  },
137
174
  async move(kind, externalId, stage, { operationId }) {
@@ -179,6 +216,13 @@ export const fakeCrmSource = (options = {}) => {
179
216
  setPipelines(kind, list) {
180
217
  pipelines[kind] = list;
181
218
  },
219
+ setFields(kind, list) {
220
+ fields[kind] = list;
221
+ },
222
+ async fields(kind) {
223
+ calls.fields += 1;
224
+ return fields[kind];
225
+ },
182
226
  expireCursors(kind) {
183
227
  expired.set(kind, version);
184
228
  },
@@ -1,7 +1,7 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { runBudget } from "../../background/budget.js";
3
3
  import { syncCrm } from "../sync.js";
4
- import { exampleDeal, exampleLead, exampleOrganization, examplePerson, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
4
+ import { exampleDeal, exampleLead, exampleOrganization, examplePerson, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, SOURCE_FIELD, } from "./source.js";
5
5
  /** Both ways a CRM may report deletions: the engine must handle each. */
6
6
  export const CRM_TRAIT_COMBINATIONS = [
7
7
  { removedInChanges: false },
@@ -68,7 +68,7 @@ export const crmSyncContract = (make) => CRM_TRAIT_COMBINATIONS.flatMap((traits)
68
68
  await sync();
69
69
  source.delete("deal", "d1");
70
70
  now += DAY;
71
- const stopped = await sync({ budget: runBudget({ requests: 7 }) });
71
+ const stopped = await sync({ budget: runBudget({ requests: 11 }) });
72
72
  assert.equal(stopped.state, "partial");
73
73
  assert.equal(stopped.hint, "run sync again to continue");
74
74
  assert.deepEqual(await deals(), ["d1", "d2", "d3", "d4", "d5"]);
@@ -90,6 +90,17 @@ export const crmSyncContract = (make) => CRM_TRAIT_COMBINATIONS.flatMap((traits)
90
90
  await sync({ rescan: true });
91
91
  assert.deepEqual(await deals(), []);
92
92
  }),
93
+ test("stores each kind's own fields, and marks deleted one the CRM no longer lists", async ({ source, sync, store, account, }) => {
94
+ const note = { code: "UF_NOTE", label: "Note", type: "string", multiple: true, filterable: false };
95
+ source.setFields("person", [note]);
96
+ await sync();
97
+ assert.deepEqual(await store.crm.fields(account, "deal"), [SOURCE_FIELD]);
98
+ assert.deepEqual(await store.crm.fields(account, "person"), [note]);
99
+ source.setFields("deal", []);
100
+ await sync();
101
+ assert.deepEqual(await store.crm.fields(account, "deal"), []);
102
+ assert.deepEqual(await store.crm.fields(account, "person"), [note]);
103
+ }),
93
104
  test("a dead cursor starts a full round, marked reset", async ({ source, sync, deals }) => {
94
105
  seeded(source);
95
106
  await sync();
@@ -103,7 +114,7 @@ export const crmSyncContract = (make) => CRM_TRAIT_COMBINATIONS.flatMap((traits)
103
114
  }),
104
115
  test("a run its budget stops saves its pages and the next goes on from the page it stopped at", async ({ source, sync, deals, }) => {
105
116
  seeded(source, 9);
106
- const stopped = await sync({ budget: runBudget({ requests: 6 }) });
117
+ const stopped = await sync({ budget: runBudget({ requests: 10 }) });
107
118
  assert.equal(stopped.state, "partial");
108
119
  assert.equal(stopped.stopped, "requests");
109
120
  const kept = (await deals()).length;
@@ -1,3 +1,4 @@
1
+ import type { ConditionalKey } from "../cli/permission-keys.js";
1
2
  import type { CrmItem } from "../domain/crm.js";
2
3
  import type { DealKind, DealRole } from "../domain/deals.js";
3
4
  import type { Level, PermissionKey } from "../sends/permissions.js";
@@ -35,7 +36,8 @@ export interface CrmFields {
35
36
  }[];
36
37
  /** The CRM user it is assigned to: a user's id, never a contact's. */
37
38
  owner?: string | null;
38
- custom?: Record<string, string | null>;
39
+ /** A list field's option by its id; a multiple field's values as a list; `null` clears it. */
40
+ custom?: Record<string, string | string[] | null>;
39
41
  }
40
42
  export type CrmDraft = CrmFields & {
41
43
  title: string;
@@ -47,7 +49,8 @@ export interface CrmWriteOptions {
47
49
  /**
48
50
  * Writes to one CRM account. Each answers the whole item as the CRM holds it after the write, every field and not
49
51
  * only the changed ones: the store replaces its copy with it. None of them retries: a write whose answer was lost
50
- * throws `outcome_unknown`.
52
+ * throws `outcome_unknown`. A write the CRM made whose item could then not be read back throws with `sent: true` in
53
+ * its details: it is journaled as sent, and the read's failure is answered on its own.
51
54
  */
52
55
  export interface CrmWriter {
53
56
  readonly provider: string;
@@ -69,6 +72,12 @@ export interface CrmWriter {
69
72
  export declare const CRM_ASSIGN_OTHERS_KEY: PermissionKey;
70
73
  /** The keys the CRM writes check beyond their command paths, for a CLI's `commandPermissions({ extra })`. */
71
74
  export declare const CRM_WRITE_KEYS: readonly PermissionKey[];
75
+ /**
76
+ * Every key a CRM write command may check beyond its own path, for `commandPermissions({ alsoChecks })`, so its
77
+ * `--dry-run` names each: a stage implies the move key and a won or lost one the close key, an owner other than the
78
+ * sign-in's `crm.assign-others`, and `also` the CLI's own (the same `CrmWrites.alsoChecks` the real run checks).
79
+ */
80
+ export declare const crmAlsoChecks: (also?: (kind: DealKind, verb: CrmVerb) => readonly PermissionKey[]) => (path: readonly string[]) => readonly (PermissionKey | ConditionalKey)[];
72
81
  /**
73
82
  * Built in, and only ever tightening (WC-PERM-7): an ordinary change goes ahead, a deletion asks, and a record is not
74
83
  * given to somebody else until the owner allows it.
@@ -3,6 +3,37 @@ export const CRM_VERBS = ["create", "update", "move", "close", "remove"];
3
3
  export const CRM_ASSIGN_OTHERS_KEY = "crm.assign-others";
4
4
  /** The keys the CRM writes check beyond their command paths, for a CLI's `commandPermissions({ extra })`. */
5
5
  export const CRM_WRITE_KEYS = [CRM_ASSIGN_OTHERS_KEY];
6
+ const PLURALS = { deals: "deal", leads: "lead" };
7
+ const VERB_OF = {
8
+ create: "create",
9
+ update: "update",
10
+ move: "move",
11
+ close: "close",
12
+ delete: "remove",
13
+ };
14
+ /**
15
+ * Every key a CRM write command may check beyond its own path, for `commandPermissions({ alsoChecks })`, so its
16
+ * `--dry-run` names each: a stage implies the move key and a won or lost one the close key, an owner other than the
17
+ * sign-in's `crm.assign-others`, and `also` the CLI's own (the same `CrmWrites.alsoChecks` the real run checks).
18
+ */
19
+ export const crmAlsoChecks = (also) => (path) => {
20
+ const [noun, command] = path;
21
+ const kind = noun === undefined ? undefined : PLURALS[noun];
22
+ const verb = command === undefined ? undefined : VERB_OF[command];
23
+ if (path.length !== 2 || kind === undefined || verb === undefined)
24
+ return [];
25
+ const close = { key: `${noun}.close`, when: "a won or lost stage" };
26
+ const implied = verb === "create" || verb === "update"
27
+ ? [
28
+ { key: `${noun}.move`, when: "--stage" },
29
+ close,
30
+ { key: CRM_ASSIGN_OTHERS_KEY, when: "--owner other than the sign-in's user" },
31
+ ]
32
+ : verb === "move"
33
+ ? [close]
34
+ : [];
35
+ return [...implied, ...(also?.(kind, verb) ?? [])];
36
+ };
6
37
  /**
7
38
  * Built in, and only ever tightening (WC-PERM-7): an ordinary change goes ahead, a deletion asks, and a record is not
8
39
  * given to somebody else until the owner allows it.
@@ -2,11 +2,12 @@ import type { DealKind } from "../domain/deals.js";
2
2
  import type { AppIdentity } from "../ports/app.js";
3
3
  import type { Warn } from "../runs/events.js";
4
4
  import { type Asker, type GuardSettings } from "../sends/guard.js";
5
+ import { type PermissionKey } from "../sends/permissions.js";
5
6
  import type { Deal } from "../store/sqlite/deals.js";
6
7
  import type { MessageStore } from "../store/store.js";
7
8
  import type { AccountKey } from "../store/types.js";
8
9
  import type { CrmSource } from "./source.js";
9
- import { type CrmWriter } from "./writer.js";
10
+ import { type CrmVerb, type CrmWriter } from "./writer.js";
10
11
  /** What a CLI hands the CRM writes for one profile. */
11
12
  export interface CrmWrites {
12
13
  writer: CrmWriter;
@@ -17,6 +18,11 @@ export interface CrmWrites {
17
18
  self?: string;
18
19
  env?: NodeJS.ProcessEnv;
19
20
  warn?: Warn;
21
+ /**
22
+ * The CLI's own keys a write also checks, such as Bitrix24's `api.crm.deal.update`; pass the same function to
23
+ * `crmAlsoChecks` so `--dry-run` names them.
24
+ */
25
+ alsoChecks?: (kind: DealKind, verb: CrmVerb) => readonly PermissionKey[];
20
26
  }
21
27
  export interface CrmWriteContext {
22
28
  store: MessageStore;
@@ -40,7 +46,11 @@ export interface CrmFieldsInput {
40
46
  currency?: string;
41
47
  closeDate?: string;
42
48
  owner?: string;
43
- custom?: Record<string, string | null>;
49
+ /**
50
+ * `--field` pairs as typed, in order: a field by its code or whole label, a list field's option by its id or label,
51
+ * repeated for each value of a multiple field; an empty value clears it.
52
+ */
53
+ custom?: readonly (readonly [name: string, value: string])[];
44
54
  }
45
55
  export interface CrmWritten {
46
56
  operationId: string;
@@ -118,14 +118,80 @@ const fieldsOf = (input, pipeline, currency) => {
118
118
  throw invalid("--owner is the CRM user's id", "--owner");
119
119
  fields.owner = input.owner.trim();
120
120
  }
121
- if (input.custom !== undefined && Object.keys(input.custom).length) {
122
- for (const code of Object.keys(input.custom))
123
- if (!FIELD_CODE.test(code))
124
- throw invalid(`${code} is not a field code`, "--field");
125
- fields.custom = input.custom;
126
- }
127
121
  return { fields, stage };
128
122
  };
123
+ /** Case, spaces and underscores aside: `lead_source` and `Lead source` are one name, as bitrix24-cli folds them. */
124
+ const folded = (name) => name
125
+ .trim()
126
+ .toLocaleLowerCase()
127
+ .replace(/[\s_]+/gu, " ");
128
+ const named = ({ code, label }) => ({ code, label });
129
+ /** A field by its code or its whole label; two with that label are refused with both, never guessed. */
130
+ const fieldNamed = (fields, name) => {
131
+ const want = folded(name);
132
+ const byCode = fields.find((field) => folded(field.code) === want);
133
+ if (byCode)
134
+ return byCode;
135
+ const labelled = fields.filter((field) => field.label !== null && folded(field.label) === want);
136
+ if (labelled.length === 1)
137
+ return labelled[0];
138
+ if (labelled.length > 1)
139
+ throw new CliError("validation_error", `${labelled.length} custom fields are labelled ${name}; name one by its code`, {
140
+ reason: "field.ambiguous",
141
+ param: "--field",
142
+ candidates: labelled.map(named),
143
+ });
144
+ const close = fields.filter((field) => [field.code, field.label ?? ""].some((one) => {
145
+ const other = folded(one);
146
+ return other !== "" && (other.includes(want) || want.includes(other));
147
+ }));
148
+ throw new CliError("validation_error", `no custom field is named ${name}${close.length ? "; close ones are in candidates" : ""} — \`fields list\` lists them`, { reason: "field.unknown", param: "--field", candidates: close.map(named) });
149
+ };
150
+ const optionOf = (field, value) => {
151
+ if (!field.options)
152
+ return value;
153
+ const found = field.options.find((option) => option.id === value.trim()) ??
154
+ field.options.find((option) => folded(option.label) === folded(value));
155
+ if (!found)
156
+ throw new CliError("validation_error", `${field.label ?? field.code} has no option ${value}`, {
157
+ reason: "field.unknown-option",
158
+ param: "--field",
159
+ field: field.code,
160
+ allowed: field.options,
161
+ });
162
+ return found.id;
163
+ };
164
+ /**
165
+ * `--field` pairs by the synced field definitions: a label to its code, an option to its id, a multiple field's
166
+ * repeats into a list. A CRM whose definitions are not stored takes codes as typed.
167
+ */
168
+ const customOf = async (context, kind, pairs) => {
169
+ if (!pairs?.length)
170
+ return undefined;
171
+ const fields = await context.store.crm.fields(context.account, kind);
172
+ const values = new Map();
173
+ for (const [name, value] of pairs) {
174
+ const field = fields.length ? fieldNamed(fields, name) : null;
175
+ const code = field?.code ?? name.trim();
176
+ if (!field && !FIELD_CODE.test(code))
177
+ throw invalid(`${name} is not a field code`, "--field");
178
+ const held = values.get(code) ?? { field, all: [] };
179
+ held.all.push(value === "" ? null : field ? optionOf(field, value) : value);
180
+ values.set(code, held);
181
+ }
182
+ return Object.fromEntries([...values].map(([code, { field, all }]) => {
183
+ const given = all.filter((one) => one !== null);
184
+ if (field?.multiple)
185
+ return [code, given.length ? given : null];
186
+ if (all.length > 1)
187
+ throw new CliError("validation_error", `${field?.label ?? code} holds one value; give --field once`, {
188
+ reason: "deal.field",
189
+ param: "--field",
190
+ field: code,
191
+ });
192
+ return [code, all[0] ?? null];
193
+ }));
194
+ };
129
195
  /** Every key the write checks: its own, and each action its fields also take (WC-PERM-22). */
130
196
  const keysOf = (context, kind, verb, { stage, owner }) => {
131
197
  const extra = [];
@@ -135,7 +201,7 @@ const keysOf = (context, kind, verb, { stage, owner }) => {
135
201
  extra.push(keyOf(kind, "close"));
136
202
  if (owner != null && owner !== context.writes.self)
137
203
  extra.push(CRM_ASSIGN_OTHERS_KEY);
138
- return { key: keyOf(kind, verb), extra };
204
+ return { key: keyOf(kind, verb), extra: [...extra, ...(context.writes.alsoChecks?.(kind, verb) ?? [])] };
139
205
  };
140
206
  /** The profile's guard over the write's own key and the keys its fields imply (`keyedGuard`). */
141
207
  const guardOf = (context, extra) => {
@@ -146,7 +212,20 @@ const guardOf = (context, extra) => {
146
212
  /** Sends the write once, never again: a lost answer is `outcome_unknown`, with what the owner may do about it. */
147
213
  const written = async (context, { key, extra }, operationId, act) => {
148
214
  try {
149
- return await guardedWrite(guardOf(context, extra), { chatId: null, kind: "edit", key, operationId }, act);
215
+ // A write the CRM made and could not read back is sent: its journal line says so, and the read's failure follows.
216
+ const done = await guardedWrite(guardOf(context, extra), { chatId: null, kind: "edit", key, operationId }, async () => {
217
+ try {
218
+ return { item: await act(), unread: null };
219
+ }
220
+ catch (error) {
221
+ if (isCliError(error) && error.details.sent === true)
222
+ return { item: null, unread: error };
223
+ throw error;
224
+ }
225
+ });
226
+ if (done.unread)
227
+ throw new CliError(done.unread.code, `${done.unread.message} — the CRM made the change; sync to store it, do not write it again`, { ...done.unread.details, operation_id: operationId });
228
+ return done.item;
150
229
  }
151
230
  catch (error) {
152
231
  if (!isCliError(error) || error.code !== "outcome_unknown")
@@ -192,6 +271,9 @@ export const createCrmItem = async (context, kind, input, options = {}) => {
192
271
  throw invalid(`a new ${kind} needs --title`, "--title");
193
272
  const pipeline = await pipelineOf(context, kind, input.pipeline);
194
273
  const { fields, stage } = fieldsOf({ ...input, pipeline: pipeline.ref }, pipeline, null);
274
+ const custom = await customOf(context, kind, input.custom);
275
+ if (custom)
276
+ fields.custom = custom;
195
277
  const operationId = operationOf(context, options.operationId);
196
278
  const item = await written(context, keysOf(context, kind, "create", { stage, owner: fields.owner }), operationId, () => writer.create(kind, fields, { operationId }));
197
279
  return { operationId, item: await kept(context, kind, item, operationId) };
@@ -206,6 +288,9 @@ export const updateCrmItem = async (context, kind, reference, input, options = {
206
288
  ? undefined
207
289
  : await pipelineOf(context, kind, input.pipeline === undefined ? copy.pipeline : input.pipeline);
208
290
  const { fields, stage } = fieldsOf(input, pipeline, copy.currency);
291
+ const custom = await customOf(context, kind, input.custom);
292
+ if (custom)
293
+ fields.custom = custom;
209
294
  if (Object.keys(fields).length === 0)
210
295
  throw invalid("nothing to change: name a field");
211
296
  const operationId = operationOf(context, options.operationId);