@wirecat/cli-core 0.589.0 → 0.591.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 (61) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/background/schedules.d.ts +15 -1
  3. package/dist/background/schedules.js +16 -5
  4. package/dist/cli/messenger/flooded.d.ts +1 -1
  5. package/dist/cli/messenger/flooded.js +3 -26
  6. package/dist/cli/messenger/search-command.d.ts +3 -2
  7. package/dist/cli/messenger/search-command.js +7 -5
  8. package/dist/cli/messenger/store-maintenance-command.d.ts +1 -1
  9. package/dist/cli/project-status.js +1 -1
  10. package/dist/cli/schedule/schedule-command.js +23 -12
  11. package/dist/crm/cli/index.d.ts +57 -0
  12. package/dist/crm/cli/index.js +300 -0
  13. package/dist/crm/cli/search-all.d.ts +12 -0
  14. package/dist/crm/cli/search-all.js +63 -0
  15. package/dist/crm/index.d.ts +2 -0
  16. package/dist/crm/index.js +1 -0
  17. package/dist/crm/source.d.ts +43 -0
  18. package/dist/crm/source.js +1 -0
  19. package/dist/crm/sync.d.ts +121 -0
  20. package/dist/crm/sync.js +291 -0
  21. package/dist/crm/testing/index.d.ts +3 -0
  22. package/dist/crm/testing/index.js +3 -0
  23. package/dist/crm/testing/source-contract.d.ts +17 -0
  24. package/dist/crm/testing/source-contract.js +137 -0
  25. package/dist/crm/testing/source.d.ts +33 -0
  26. package/dist/crm/testing/source.js +126 -0
  27. package/dist/crm/testing/sync-contract.d.ts +13 -0
  28. package/dist/crm/testing/sync-contract.js +178 -0
  29. package/dist/domain/crm.d.ts +82 -0
  30. package/dist/domain/crm.js +3 -0
  31. package/dist/entities/deal.d.ts +159 -0
  32. package/dist/entities/deal.js +86 -0
  33. package/dist/entities/index.d.ts +143 -0
  34. package/dist/entities/index.js +5 -0
  35. package/dist/entities/list-results.d.ts +4 -0
  36. package/dist/entities/list-results.js +8 -1
  37. package/dist/errors/reasons.js +5 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.js +1 -0
  40. package/dist/sends/index.d.ts +1 -1
  41. package/dist/sends/index.js +1 -1
  42. package/dist/sends/pace.d.ts +18 -0
  43. package/dist/sends/pace.js +40 -1
  44. package/dist/services/index.d.ts +2 -1
  45. package/dist/services/index.js +2 -1
  46. package/dist/services/project-status/signals/amounts.d.ts +5 -2
  47. package/dist/services/project-status/signals/amounts.js +4 -3
  48. package/dist/services/proposals/index.d.ts +1 -1
  49. package/dist/services/proposals/index.js +1 -1
  50. package/dist/services/proposals/kinds/organization.d.ts +2 -5
  51. package/dist/services/proposals/kinds/organization.js +1 -14
  52. package/dist/store/index.d.ts +2 -1
  53. package/dist/store/index.js +1 -0
  54. package/dist/store/sqlite/crm.d.ts +48 -0
  55. package/dist/store/sqlite/crm.js +318 -0
  56. package/dist/store/sqlite/deals.d.ts +10 -0
  57. package/dist/store/sqlite/deals.js +33 -15
  58. package/dist/store/store.d.ts +3 -0
  59. package/dist/store/store.js +5 -0
  60. package/package.json +13 -1
  61. package/parity.json +16 -0
@@ -0,0 +1,291 @@
1
+ import { CliError } from "@wirecat/cli-base";
2
+ import { runBudget } from "../background/budget.js";
3
+ import { progressLine, progressOf } from "../background/checkpoints.js";
4
+ import { CRM_KINDS, isItemKind } from "../domain/crm.js";
5
+ import { DEAL_KINDS } from "../domain/deals.js";
6
+ import { isCliFailure } from "../errors/failures.js";
7
+ const DAY = 86_400_000;
8
+ /** A row the CRM changed this close to the id pass's start may be newer than the list, by the two clocks' skew. */
9
+ const CLOCK_MARGIN = 60 * 60_000;
10
+ /** An id pass that would mark more than this share of a kind deleted is held: a CRM that lost its rights lists none. */
11
+ const MOST_UNLISTED = 0.5;
12
+ /** Below this many stored rows a pass may empty a kind: a small CRM really may lose half its deals. */
13
+ const GUARDED_FROM = 10;
14
+ const isToken = (value) => value === null || typeof value === "string";
15
+ export const parseCrmCursor = (value) => {
16
+ if (value == null)
17
+ return null;
18
+ try {
19
+ const cursor = JSON.parse(value);
20
+ if (cursor?.v !== 1 || !isToken(cursor.token) || typeof cursor.done !== "number")
21
+ return null;
22
+ const round = cursor.round;
23
+ const pass = cursor.pass;
24
+ if (round != null && (!isToken(round.from) || !isToken(round.page) || typeof round.reset !== "boolean"))
25
+ return null;
26
+ if (pass != null &&
27
+ (!isToken(pass.page) ||
28
+ typeof pass.startedAt !== "number" ||
29
+ !Array.isArray(pass.seen) ||
30
+ pass.seen.some((id) => typeof id !== "string")))
31
+ return null;
32
+ return {
33
+ v: 1,
34
+ token: cursor.token,
35
+ round: round ?? null,
36
+ done: cursor.done,
37
+ updatedAt: typeof cursor.updatedAt === "number" ? cursor.updatedAt : null,
38
+ pass: pass ?? null,
39
+ passAt: typeof cursor.passAt === "number" ? cursor.passAt : 0,
40
+ };
41
+ }
42
+ catch {
43
+ return null;
44
+ }
45
+ };
46
+ export const cursorName = (kind) => `crm:${kind}`;
47
+ const CONTINUE = "run sync again to continue";
48
+ const fresh = () => ({ v: 1, token: null, round: null, done: 0, updatedAt: null, pass: null, passAt: 0 });
49
+ const readPipelines = async (run, kind) => {
50
+ const why = run.budget.take();
51
+ if (why)
52
+ return why;
53
+ const pipelines = await run.source.pipelines(kind);
54
+ const { saved, skipped } = await run.store.crm.savePipelines(run.account, kind, pipelines);
55
+ run.report.pipelines += saved;
56
+ for (const one of skipped)
57
+ run.report.issues.push({ kind, ...one });
58
+ return null;
59
+ };
60
+ /** One page saved; an item whose stage is unknown gets one more chance after the kind's pipelines are read again. */
61
+ const savePage = async (run, kind, page) => {
62
+ const { store, account } = run;
63
+ if (!isItemKind(kind)) {
64
+ const saved = kind === "person"
65
+ ? await store.crm.savePeople(account, page.items)
66
+ : await store.crm.saveOrganizations(account, page.items);
67
+ return { saved: saved.saved, stop: null };
68
+ }
69
+ const items = page.items;
70
+ let { saved, skipped } = await store.crm.saveItems(account, kind, items);
71
+ let stop = null;
72
+ if (skipped.some(({ reason }) => reason === "crm.stage-unknown") && !run.pipelinesReread.has(kind)) {
73
+ run.pipelinesReread.add(kind);
74
+ stop = await readPipelines(run, kind);
75
+ if (!stop) {
76
+ const again = new Set(skipped.filter(({ reason }) => reason === "crm.stage-unknown").map(({ externalId }) => externalId));
77
+ const retried = await store.crm.saveItems(account, kind, items.filter(({ externalId }) => again.has(externalId)));
78
+ saved += retried.saved;
79
+ skipped = [...skipped.filter(({ reason }) => reason !== "crm.stage-unknown"), ...retried.skipped];
80
+ }
81
+ }
82
+ // An item not kept is named, and the round goes on: one bad row must not stop the CRM's copy for good.
83
+ for (const one of skipped)
84
+ run.report.issues.push({ kind, ...one });
85
+ return { saved, stop };
86
+ };
87
+ const syncKind = async (run, kind, options, result) => {
88
+ const { source, store, account, now, budget, signal } = run;
89
+ const name = cursorName(kind);
90
+ let cursor = parseCrmCursor((await store.sync.syncState(account, name))?.value) ?? fresh();
91
+ if (options.rescan)
92
+ cursor = { ...cursor, round: { from: null, page: null, reset: false }, done: 0, pass: null, passAt: 0 };
93
+ let round = cursor.round ?? { from: cursor.token, page: null, reset: false };
94
+ if (cursor.round === null)
95
+ cursor = { ...cursor, done: 0 };
96
+ let listed = false;
97
+ const show = () => Object.assign(result, { round: round.from === null ? "full" : "changes", reset: round.reset });
98
+ show();
99
+ const save = async (patch = {}) => {
100
+ cursor = { ...cursor, round: listed ? null : round, updatedAt: now, ...patch };
101
+ await store.sync.setSyncState(account, name, JSON.stringify(cursor));
102
+ };
103
+ let restarted = false;
104
+ const pages = new Set();
105
+ for (;;) {
106
+ const why = budget.take();
107
+ if (why)
108
+ return why;
109
+ const page = await source.changes(kind, round.from, round.page ?? undefined);
110
+ if (signal?.aborted)
111
+ return "cancelled";
112
+ if ("reset" in page) {
113
+ if (round.page !== null && !restarted) {
114
+ restarted = true;
115
+ round = { ...round, page: null };
116
+ }
117
+ else if (round.from === null)
118
+ throw new CliError("invalid_response", `${source.provider} refused a full list as a dead cursor`);
119
+ else
120
+ round = { from: null, page: null, reset: true };
121
+ cursor = { ...cursor, done: 0 };
122
+ show();
123
+ await save();
124
+ continue;
125
+ }
126
+ const { saved, stop } = await savePage(run, kind, page);
127
+ result.saved += saved;
128
+ cursor = { ...cursor, done: cursor.done + saved };
129
+ if (page.removed?.length)
130
+ result.removed += await store.crm.remove(account, kind, page.removed);
131
+ run.moved();
132
+ if (page.nextPage === undefined) {
133
+ listed = true;
134
+ await save({ token: page.cursor ?? null });
135
+ break;
136
+ }
137
+ if (pages.has(page.nextPage))
138
+ throw new CliError("invalid_response", `${source.provider} returned the same page twice`);
139
+ pages.add(page.nextPage);
140
+ round = { ...round, page: page.nextPage };
141
+ await save();
142
+ if (stop)
143
+ return stop;
144
+ }
145
+ const passes = source.ids && !source.traits.removedInChanges && kind !== "person";
146
+ const due = cursor.pass !== null || now - cursor.passAt >= (options.passEvery ?? DAY);
147
+ if (passes && due) {
148
+ const ids = source.ids;
149
+ let pass = cursor.pass ?? { page: null, startedAt: now, seen: [] };
150
+ for (;;) {
151
+ const why = budget.take();
152
+ if (why) {
153
+ result.pass = "partial";
154
+ await save({ pass });
155
+ return why;
156
+ }
157
+ const page = await ids.call(source, kind, pass.page ?? undefined);
158
+ if (signal?.aborted)
159
+ return "cancelled";
160
+ pass = { ...pass, seen: [...pass.seen, ...page.ids], page: page.nextPage ?? null };
161
+ run.moved();
162
+ if (page.nextPage === undefined)
163
+ break;
164
+ await save({ pass });
165
+ }
166
+ const before = pass.startedAt - CLOCK_MARGIN;
167
+ const stored = await store.crm.count(account, kind);
168
+ const unlisted = await store.crm.removeUnlisted(account, kind, pass.seen, before, { dryRun: true });
169
+ if (!options.rescan && stored >= GUARDED_FROM && unlisted > stored * MOST_UNLISTED) {
170
+ result.pass = "held";
171
+ run.report.issues.push({
172
+ kind,
173
+ reason: "crm.unlisted-held",
174
+ message: `${source.provider} listed ${pass.seen.length} ${kind} ids, so ${unlisted} of the ${stored} stored would be ` +
175
+ "marked deleted; nothing was — check the sign-in's rights, then run sync --rescan to let it through",
176
+ });
177
+ }
178
+ else {
179
+ result.unlisted = await store.crm.removeUnlisted(account, kind, pass.seen, before);
180
+ result.pass = "complete";
181
+ }
182
+ await save({ pass: null, passAt: pass.startedAt });
183
+ }
184
+ result.state = "complete";
185
+ return null;
186
+ };
187
+ /**
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`.
192
+ */
193
+ export const syncCrm = async (source, store, account, options = {}) => {
194
+ const now = options.now ?? Date.now();
195
+ const budget = options.budget ?? runBudget();
196
+ const report = {
197
+ provider: source.provider,
198
+ account: account.account,
199
+ pipelines: 0,
200
+ synced: [],
201
+ issues: [],
202
+ complete: true,
203
+ state: "complete",
204
+ progress: { requests: 0, saved: 0, steps: 0 },
205
+ };
206
+ let steps = 0;
207
+ const run = {
208
+ source,
209
+ store,
210
+ account,
211
+ now,
212
+ budget,
213
+ signal: options.signal,
214
+ report,
215
+ pipelinesReread: new Set(),
216
+ moved: () => {
217
+ steps += 1;
218
+ },
219
+ };
220
+ const kinds = CRM_KINDS.filter((kind) => source.traits.kinds.includes(kind));
221
+ let stopped = null;
222
+ for (const kind of DEAL_KINDS)
223
+ if (!stopped && kinds.includes(kind)) {
224
+ stopped = await readPipelines(run, kind);
225
+ if (options.signal?.aborted)
226
+ stopped = "cancelled";
227
+ }
228
+ for (const kind of kinds) {
229
+ const result = {
230
+ kind,
231
+ round: "changes",
232
+ reset: false,
233
+ state: "partial",
234
+ saved: 0,
235
+ removed: 0,
236
+ unlisted: 0,
237
+ };
238
+ report.synced.push(result);
239
+ if (stopped)
240
+ continue;
241
+ try {
242
+ stopped = await syncKind(run, kind, options, result);
243
+ }
244
+ catch (error) {
245
+ if (options.signal?.aborted) {
246
+ stopped = "cancelled";
247
+ continue;
248
+ }
249
+ // An adapter's own failure says what went wrong; anything else may carry a request URL with a secret in it.
250
+ result.error = isCliFailure(error) ? error.message : `the ${kind} copy could not be synced`;
251
+ }
252
+ }
253
+ report.progress = {
254
+ requests: budget.used,
255
+ saved: report.synced.reduce((sum, one) => sum + one.saved, 0),
256
+ steps,
257
+ };
258
+ report.complete = report.synced.every((one) => one.state === "complete" && one.error === undefined);
259
+ report.state = report.complete && stopped === null ? "complete" : "partial";
260
+ if (stopped)
261
+ report.stopped = stopped;
262
+ if (report.state === "partial") {
263
+ report.complete = false;
264
+ report.hint = CONTINUE;
265
+ }
266
+ return report;
267
+ };
268
+ /** Each kind's sync in the WC-LIFE-14 shape: `cursor_age_ms` growing on a `partial` one means nothing moves it. */
269
+ export const crmStatus = async (store, account, { profile, now, kinds = CRM_KINDS }) => {
270
+ const items = [];
271
+ for (const kind of kinds) {
272
+ const cursor = parseCrmCursor((await store.sync.syncState(account, cursorName(kind)))?.value);
273
+ const progress = progressOf({
274
+ job: `crm ${kind}`,
275
+ profile,
276
+ done: cursor?.done ?? 0,
277
+ complete: cursor !== null && cursor.round === null && cursor.pass === null,
278
+ ...(cursor?.updatedAt == null ? {} : { updatedAt: new Date(cursor.updatedAt).toISOString() }),
279
+ now: new Date(now),
280
+ });
281
+ items.push({
282
+ kind,
283
+ round: cursor?.round ? (cursor.round.from === null ? "full" : "changes") : null,
284
+ id_pass: cursor?.pass ? "partial" : cursor?.passAt ? new Date(cursor.passAt).toISOString() : null,
285
+ stored: await store.crm.count(account, kind),
286
+ ...progress,
287
+ line: progressLine(progress),
288
+ });
289
+ }
290
+ return { items, page: 1, limit: Math.max(1, items.length), hasMore: false };
291
+ };
@@ -0,0 +1,3 @@
1
+ export { exampleDeal, exampleLead, exampleOrganization, examplePerson, type FakeCrmSource, type FakeCrmSourceOptions, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
2
+ export { type CrmSourceFixture, crmSourceContract } from "./source-contract.js";
3
+ export { CRM_TRAIT_COMBINATIONS, type CrmSyncFixture, crmSyncContract } from "./sync-contract.js";
@@ -0,0 +1,3 @@
1
+ export { exampleDeal, exampleLead, exampleOrganization, examplePerson, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
2
+ export { crmSourceContract } from "./source-contract.js";
3
+ export { CRM_TRAIT_COMBINATIONS, crmSyncContract } from "./sync-contract.js";
@@ -0,0 +1,17 @@
1
+ import { type CrmKind } from "../../domain/crm.js";
2
+ import type { ContractCase } from "../../meetings/index.js";
3
+ import type { CrmSource } from "../source.js";
4
+ /**
5
+ * One adapter over recorded or faked responses, fresh for each case. Every kind its traits name must list at least
6
+ * one row, so the checks have something to look at.
7
+ */
8
+ export interface CrmSourceFixture {
9
+ source: CrmSource;
10
+ /** A cursor the CRM refuses; absent skips that case. */
11
+ deadCursor?: {
12
+ kind: CrmKind;
13
+ cursor: string;
14
+ };
15
+ }
16
+ /** What every CRM adapter answers, whatever the CRM: run each case in the adapter's tests. */
17
+ export declare const crmSourceContract: (make: () => CrmSourceFixture | Promise<CrmSourceFixture>) => ContractCase[];
@@ -0,0 +1,137 @@
1
+ import assert from "node:assert/strict";
2
+ import { isItemKind } from "../../domain/crm.js";
3
+ import { DEAL_KINDS, STAGE_OUTCOMES } from "../../domain/deals.js";
4
+ const MAX_PAGES = 1_000;
5
+ const DAY = /^\d{4}-\d{2}-\d{2}$/;
6
+ const DECIMAL = /^\d{1,16}(\.\d{1,6})?$/;
7
+ /** Every page of one kind from `cursor`, checking each page is new and the last one gives a cursor. */
8
+ const listAll = async (source, kind, cursor) => {
9
+ const items = [];
10
+ const pages = new Set();
11
+ let page;
12
+ for (let read = 0; read < MAX_PAGES; read += 1) {
13
+ const answer = await source.changes(kind, cursor, page);
14
+ assert.ok(!("reset" in answer), `${kind}: a full list is never a dead cursor`);
15
+ const listed = answer;
16
+ assert.ok(Array.isArray(listed.items), `${kind}: a page has items`);
17
+ items.push(...listed.items);
18
+ if (listed.nextPage === undefined) {
19
+ assert.ok(listed.cursor === null || typeof listed.cursor === "string", `${kind}: the last page gives a cursor`);
20
+ return { items, cursor: listed.cursor ?? null };
21
+ }
22
+ assert.ok(!pages.has(listed.nextPage), `${kind}: the same page twice`);
23
+ pages.add(listed.nextPage);
24
+ page = listed.nextPage;
25
+ }
26
+ throw new assert.AssertionError({ message: `${kind}: more than ${MAX_PAGES} pages` });
27
+ };
28
+ /** What every CRM adapter answers, whatever the CRM: run each case in the adapter's tests. */
29
+ export const crmSourceContract = (make) => {
30
+ const cases = [
31
+ {
32
+ name: "pipelines: unique ids across both kinds, stages with a known outcome and unique ids",
33
+ async run() {
34
+ const { source } = await make();
35
+ const seen = new Set();
36
+ for (const kind of DEAL_KINDS.filter((one) => source.traits.kinds.includes(one))) {
37
+ const pipelines = await source.pipelines(kind);
38
+ assert.ok(pipelines.length > 0, `${kind}: at least one pipeline`);
39
+ for (const pipeline of pipelines) {
40
+ assert.ok(!seen.has(pipeline.externalId), `pipeline ${pipeline.externalId} is listed twice`);
41
+ seen.add(pipeline.externalId);
42
+ assert.ok(pipeline.name.trim(), `pipeline ${pipeline.externalId} has a name`);
43
+ assert.ok(pipeline.stages.length > 0, `pipeline ${pipeline.externalId} has stages`);
44
+ const stages = new Set(pipeline.stages.map(({ externalId }) => externalId));
45
+ assert.equal(stages.size, pipeline.stages.length, `pipeline ${pipeline.externalId}: stage ids are unique`);
46
+ for (const stage of pipeline.stages) {
47
+ assert.ok(STAGE_OUTCOMES.includes(stage.outcome), `stage ${stage.externalId}'s outcome`);
48
+ assert.ok(Number.isFinite(stage.position), `stage ${stage.externalId}'s position is a number`);
49
+ }
50
+ }
51
+ }
52
+ },
53
+ },
54
+ {
55
+ name: "changes: a full list pages to its end, every item stands in a listed stage, fields in the neutral shape",
56
+ async run() {
57
+ const { source } = await make();
58
+ const stages = new Map();
59
+ for (const kind of DEAL_KINDS.filter((one) => source.traits.kinds.includes(one)))
60
+ stages.set(kind, new Set((await source.pipelines(kind)).flatMap((pipeline) => pipeline.stages.map((stage) => `${pipeline.externalId}\u0000${stage.externalId}`))));
61
+ for (const kind of source.traits.kinds) {
62
+ const { items } = await listAll(source, kind, null);
63
+ assert.ok(items.length > 0, `${kind}: the fixture lists at least one`);
64
+ const ids = new Set();
65
+ for (const raw of items) {
66
+ const item = raw;
67
+ assert.equal(typeof item.externalId, "string", `${kind}: externalId is a string`);
68
+ assert.ok(!ids.has(item.externalId), `${kind} ${item.externalId} is listed twice`);
69
+ ids.add(item.externalId);
70
+ if (!isItemKind(kind))
71
+ continue;
72
+ const one = raw;
73
+ assert.ok(typeof one.title === "string", `${kind} ${one.externalId} has a title`);
74
+ assert.ok(stages.get(kind)?.has(`${one.pipeline}\u0000${one.stage}`), `${kind} ${one.externalId} stands in stage ${one.stage} of pipeline ${one.pipeline}, which is listed`);
75
+ assert.equal(one.amount == null, one.currency == null, `${kind} ${one.externalId}: an amount with its currency`);
76
+ if (one.amount != null)
77
+ assert.match(String(one.amount), DECIMAL, `${kind} ${one.externalId}: a decimal string`);
78
+ for (const day of [one.closeDate, one.closedDate])
79
+ if (day != null)
80
+ assert.match(day, DAY, `${kind} ${one.externalId}: days are YYYY-MM-DD`);
81
+ for (const at of [one.createdAt, one.updatedAt])
82
+ if (at != null)
83
+ assert.ok(Number.isFinite(at) && at > 0, `${kind} ${one.externalId}: times are epoch ms`);
84
+ }
85
+ }
86
+ },
87
+ },
88
+ {
89
+ name: "changes: the cursor a full list gave reads again without failing",
90
+ async run() {
91
+ const { source } = await make();
92
+ for (const kind of source.traits.kinds) {
93
+ const { cursor } = await listAll(source, kind, null);
94
+ if (cursor !== null)
95
+ await listAll(source, kind, cursor);
96
+ }
97
+ },
98
+ },
99
+ {
100
+ name: "ids: a list of every id holds every row a full list gave",
101
+ async run() {
102
+ const { source } = await make();
103
+ if (source.traits.removedInChanges && !source.ids)
104
+ return;
105
+ assert.ok(source.ids, "a CRM that does not name deletions in changes() lists every id");
106
+ const ids = source.ids;
107
+ for (const kind of source.traits.kinds.filter((one) => one !== "person")) {
108
+ const { items } = await listAll(source, kind, null);
109
+ const listed = new Set();
110
+ let page;
111
+ for (let read = 0; read < MAX_PAGES; read += 1) {
112
+ const answer = await ids.call(source, kind, page);
113
+ for (const id of answer.ids)
114
+ listed.add(id);
115
+ if (answer.nextPage === undefined)
116
+ break;
117
+ page = answer.nextPage;
118
+ }
119
+ for (const item of items)
120
+ assert.ok(listed.has(item.externalId), `${kind} ${item.externalId} is missing from ids()`);
121
+ }
122
+ },
123
+ },
124
+ ];
125
+ return [
126
+ ...cases,
127
+ {
128
+ name: "changes: a dead cursor answers reset",
129
+ async run() {
130
+ const { source, deadCursor } = await make();
131
+ if (!deadCursor)
132
+ return;
133
+ assert.deepEqual(await source.changes(deadCursor.kind, deadCursor.cursor), { reset: true });
134
+ },
135
+ },
136
+ ];
137
+ };
@@ -0,0 +1,33 @@
1
+ import type { CrmItem, CrmKind, CrmOrganization, CrmPerson, CrmPipeline, CrmRecords } from "../../domain/crm.js";
2
+ import type { DealKind } from "../../domain/deals.js";
3
+ import type { CrmSource, CrmSourceTraits } from "../source.js";
4
+ export interface FakeCrmSourceOptions {
5
+ traits?: Partial<CrmSourceTraits>;
6
+ /** Rows per page of `changes()` and `ids()`; 2 when absent, so every list has several pages. */
7
+ pageSize?: number;
8
+ }
9
+ /** A CRM held in memory, with a change feed: what the contracts sync against, and what a CLI's tests may use. */
10
+ export interface FakeCrmSource extends CrmSource {
11
+ /** Adds or changes a row; it is in the next `changes()`. */
12
+ put<K extends CrmKind>(kind: K, record: CrmRecords[K]): void;
13
+ /** Deletes a row: named in `removed` when `removedInChanges`, otherwise only missing from `ids()`. */
14
+ delete(kind: CrmKind, externalId: string): void;
15
+ setPipelines(kind: DealKind, pipelines: CrmPipeline[]): void;
16
+ /** The kind's cursors older than its latest change are refused, once, with `{ reset: true }`. */
17
+ expireCursors(kind: CrmKind): void;
18
+ /** Lists no id at all, as a CRM does for a sign-in that lost its rights. */
19
+ hideAll(hidden: boolean): void;
20
+ /** Requests made, by method. */
21
+ readonly calls: {
22
+ pipelines: number;
23
+ changes: number;
24
+ ids: number;
25
+ };
26
+ }
27
+ export declare const SALES_PIPELINE: CrmPipeline;
28
+ export declare const LEAD_PIPELINE: CrmPipeline;
29
+ export declare const fakeCrmSource: (options?: FakeCrmSourceOptions) => FakeCrmSource;
30
+ export declare const exampleDeal: (externalId: string, extra?: Partial<CrmItem>) => CrmItem;
31
+ export declare const exampleLead: (externalId: string, extra?: Partial<CrmItem>) => CrmItem;
32
+ export declare const examplePerson: (externalId: string, extra?: Partial<CrmPerson>) => CrmPerson;
33
+ export declare const exampleOrganization: (externalId: string, extra?: Partial<CrmOrganization>) => CrmOrganization;
@@ -0,0 +1,126 @@
1
+ /** When the fake CRM's changes happened: well before any `now` a contract syncs at. */
2
+ const STAMPED_FROM = Date.UTC(2026, 0, 1);
3
+ export const SALES_PIPELINE = {
4
+ externalId: "deal-1",
5
+ name: "Example sales",
6
+ stages: [
7
+ { externalId: "new", name: "New", position: 1, outcome: "open" },
8
+ { externalId: "quote", name: "Quote sent", position: 2, outcome: "open" },
9
+ { externalId: "won", name: "Won", position: 3, outcome: "won" },
10
+ { externalId: "lost", name: "Lost", position: 4, outcome: "lost" },
11
+ ],
12
+ };
13
+ export const LEAD_PIPELINE = {
14
+ externalId: "lead-1",
15
+ name: "Example leads",
16
+ stages: [
17
+ { externalId: "lead-new", name: "New", position: 1, outcome: "open" },
18
+ { externalId: "lead-good", name: "Converted", position: 2, outcome: "won" },
19
+ { externalId: "lead-junk", name: "Junk", position: 3, outcome: "lost" },
20
+ ],
21
+ };
22
+ export const fakeCrmSource = (options = {}) => {
23
+ const traits = {
24
+ kinds: ["organization", "person", "deal", "lead"],
25
+ removedInChanges: false,
26
+ ...options.traits,
27
+ };
28
+ const size = options.pageSize ?? 2;
29
+ const rows = new Map();
30
+ const pipelines = { deal: [SALES_PIPELINE], lead: [LEAD_PIPELINE] };
31
+ let version = 0;
32
+ const expired = new Map();
33
+ let hidden = false;
34
+ const calls = { pipelines: 0, changes: 0, ids: 0 };
35
+ const of = (kind) => {
36
+ const held = rows.get(kind) ?? new Map();
37
+ rows.set(kind, held);
38
+ return held;
39
+ };
40
+ const window = (list, page) => {
41
+ const at = page === undefined ? 0 : Number(page);
42
+ const next = at + size;
43
+ return { slice: list.slice(at, next), nextPage: next < list.length ? String(next) : undefined };
44
+ };
45
+ return {
46
+ provider: "example-crm",
47
+ traits,
48
+ calls,
49
+ put(kind, record) {
50
+ version += 1;
51
+ // A CRM stamps each change; without one here the store would take its own clock, which an id pass skips.
52
+ const stamped = (kind === "deal" || kind === "lead") && record.updatedAt == null
53
+ ? { ...record, updatedAt: STAMPED_FROM + version * 1000 }
54
+ : record;
55
+ of(kind).set(record.externalId, { record: stamped, version, deleted: false });
56
+ },
57
+ delete(kind, externalId) {
58
+ const held = of(kind).get(externalId);
59
+ if (!held)
60
+ return;
61
+ version += 1;
62
+ of(kind).set(externalId, { ...held, version, deleted: true });
63
+ },
64
+ setPipelines(kind, list) {
65
+ pipelines[kind] = list;
66
+ },
67
+ expireCursors(kind) {
68
+ expired.set(kind, version);
69
+ },
70
+ hideAll(value) {
71
+ hidden = value;
72
+ },
73
+ async pipelines(kind) {
74
+ calls.pipelines += 1;
75
+ return pipelines[kind];
76
+ },
77
+ async changes(kind, cursor, page) {
78
+ calls.changes += 1;
79
+ const since = cursor === null ? null : Number(cursor);
80
+ if (since !== null && since < (expired.get(kind) ?? 0)) {
81
+ expired.delete(kind);
82
+ return { reset: true };
83
+ }
84
+ const changed = [...of(kind).entries()]
85
+ .filter(([, held]) => (since === null ? !held.deleted : held.version > since))
86
+ .filter(([, held]) => !held.deleted || traits.removedInChanges)
87
+ .sort(([, a], [, b]) => a.version - b.version);
88
+ const { slice, nextPage } = window(changed, page);
89
+ return {
90
+ items: slice.filter(([, held]) => !held.deleted).map(([, held]) => held.record),
91
+ ...(traits.removedInChanges ? { removed: slice.filter(([, held]) => held.deleted).map(([id]) => id) } : {}),
92
+ ...(nextPage === undefined ? { cursor: String(version) } : { nextPage }),
93
+ };
94
+ },
95
+ async ids(kind, page) {
96
+ calls.ids += 1;
97
+ const listed = hidden ? [] : [...of(kind).entries()].filter(([, held]) => !held.deleted).map(([id]) => id);
98
+ const { slice, nextPage } = window(listed, page);
99
+ return { ids: slice, ...(nextPage === undefined ? {} : { nextPage }) };
100
+ },
101
+ };
102
+ };
103
+ export const exampleDeal = (externalId, extra = {}) => ({
104
+ externalId,
105
+ title: `Example deal ${externalId}`,
106
+ pipeline: SALES_PIPELINE.externalId,
107
+ stage: "new",
108
+ ...extra,
109
+ });
110
+ export const exampleLead = (externalId, extra = {}) => ({
111
+ externalId,
112
+ title: `Example lead ${externalId}`,
113
+ pipeline: LEAD_PIPELINE.externalId,
114
+ stage: "lead-new",
115
+ ...extra,
116
+ });
117
+ export const examplePerson = (externalId, extra = {}) => ({
118
+ externalId,
119
+ name: `Example person ${externalId}`,
120
+ ...extra,
121
+ });
122
+ export const exampleOrganization = (externalId, extra = {}) => ({
123
+ externalId,
124
+ name: `Example company ${externalId}`,
125
+ ...extra,
126
+ });
@@ -0,0 +1,13 @@
1
+ import type { ContractCase } from "../../meetings/index.js";
2
+ import type { MessageStore } from "../../store/store.js";
3
+ import type { AccountKey } from "../../store/types.js";
4
+ import type { CrmSourceTraits } from "../source.js";
5
+ /** A fresh, empty store and the CRM account to sync into it. */
6
+ export interface CrmSyncFixture {
7
+ store: MessageStore;
8
+ account: AccountKey;
9
+ }
10
+ /** Both ways a CRM may report deletions: the engine must handle each. */
11
+ export declare const CRM_TRAIT_COMBINATIONS: readonly Pick<CrmSourceTraits, "removedInChanges">[];
12
+ /** What the sync engine does over any store, whatever the CRM: core runs it, and a CLI may run it over its store. */
13
+ export declare const crmSyncContract: (make: () => CrmSyncFixture | Promise<CrmSyncFixture>) => ContractCase[];