@wirecat/cli-core 0.593.0 → 0.594.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.
@@ -6,7 +6,7 @@ import type { StoreContext } from "./open.js";
6
6
  /** What the store could not keep, and why: an item whose stage is not stored or whose field it refuses, or a pipeline. */
7
7
  export interface CrmSkipped {
8
8
  externalId: string;
9
- reason: "crm.stage-unknown" | "crm.item-invalid" | "crm.pipeline-clash";
9
+ reason: "crm.stage-unknown" | "crm.item-invalid";
10
10
  message: string;
11
11
  }
12
12
  export interface CrmSaved {
@@ -39,6 +39,8 @@ export interface CrmCopies {
39
39
  }): Promise<number>;
40
40
  /** How many of the kind the account holds, not deleted; every contact it ever saved. */
41
41
  count(key: AccountKey, kind: CrmKind): Promise<number>;
42
+ /** The stored copy's `deal:<id>` or `lead:<id>`, deleted or not; `null` when the store holds none. */
43
+ refOf(key: AccountKey, kind: DealKind, externalId: string): Promise<string | null>;
42
44
  }
43
45
  /**
44
46
  * A contact's identity id: identities are unique per provider, and two portals of one CRM both have a contact 5.
@@ -36,24 +36,14 @@ export const crmCopiesOver = (context, people) => {
36
36
  const pipelineOf = (accountId, kind, externalId) => database
37
37
  .prepare("SELECT * FROM pipelines WHERE account_id = ? AND kind = ? AND external_id = ?")
38
38
  .get(accountId, kind, externalId);
39
- // Store version 52 keeps a pipeline's id unique per account, not per kind: a lead pipeline sharing a deal pipeline's
40
- // id cannot be stored until a migration widens that key, so it is skipped and named, not written over the other.
41
- const otherKindHolds = (accountId, kind, externalId) => database
42
- .prepare("SELECT 1 FROM pipelines WHERE account_id = ? AND kind <> ? AND external_id = ?")
43
- .get(accountId, kind, externalId) !== undefined;
39
+ /**
40
+ * One kind's whole list: a pipeline it lacks is marked deleted, one listed again comes back. An empty list marks
41
+ * nothing: a sign-in that lost its CRM rights lists no pipeline at all.
42
+ */
44
43
  const savePipelines = (key, kind, pipelines) => write(database, () => {
45
44
  const accountId = account(key);
46
45
  const at = now();
47
- const skipped = [];
48
46
  for (const pipeline of pipelines) {
49
- if (otherKindHolds(accountId, kind, pipeline.externalId)) {
50
- skipped.push({
51
- externalId: pipeline.externalId,
52
- reason: "crm.pipeline-clash",
53
- message: `${kind} pipeline ${pipeline.externalId} has the id of a stored pipeline of the other kind`,
54
- });
55
- continue;
56
- }
57
47
  const name = textOf(pipeline.name, 80) ?? pipeline.externalId;
58
48
  const held = pipelineOf(accountId, kind, pipeline.externalId);
59
49
  const id = held
@@ -62,13 +52,22 @@ export const crmCopiesOver = (context, people) => {
62
52
  .prepare("INSERT INTO pipelines (account_id, external_id, kind, name, name_folded, created_at, updated_at) " +
63
53
  "VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id")
64
54
  .get(accountId, pipeline.externalId, kind, name, fold(name), at, at)?.id);
65
- if (held && held.name !== name)
55
+ if (held && (held.name !== name || held.deleted_at != null))
66
56
  database
67
- .prepare("UPDATE pipelines SET name = ?, name_folded = ?, updated_at = ? WHERE id = ?")
57
+ .prepare("UPDATE pipelines SET name = ?, name_folded = ?, updated_at = ?, deleted_at = NULL WHERE id = ?")
68
58
  .run(name, fold(name), at, id);
69
59
  saveStages(id, pipeline, at);
70
60
  }
71
- return { saved: pipelines.length - skipped.length, skipped };
61
+ if (pipelines.length === 0)
62
+ return { saved: 0, skipped: [] };
63
+ const listed = new Set(pipelines.map(({ externalId }) => externalId));
64
+ const mark = database.prepare("UPDATE pipelines SET deleted_at = ?, updated_at = ? WHERE id = ?");
65
+ for (const row of database
66
+ .prepare("SELECT id, external_id FROM pipelines WHERE account_id = ? AND kind = ? AND deleted_at IS NULL")
67
+ .all(accountId, kind))
68
+ if (!listed.has(String(row.external_id)))
69
+ mark.run(at, at, Number(row.id));
70
+ return { saved: pipelines.length, skipped: [] };
72
71
  });
73
72
  /**
74
73
  * Stage names are unique in a pipeline, but a CRM may swap two, or give two stages one name: listed stages first
@@ -90,18 +89,23 @@ export const crmCopiesOver = (context, people) => {
90
89
  const listed = new Set(wanted.map(({ externalId }) => externalId));
91
90
  const taken = new Set(wanted.map(({ folded }) => folded));
92
91
  const rename = database.prepare("UPDATE pipeline_stages SET name_folded = ? WHERE id = ?");
92
+ const gone = database.prepare("UPDATE pipeline_stages SET deleted_at = ? WHERE id = ? AND deleted_at IS NULL");
93
93
  for (const row of held) {
94
- if (row.external_id != null && listed.has(String(row.external_id)))
94
+ const kept = row.external_id != null && listed.has(String(row.external_id));
95
+ if (kept)
95
96
  rename.run(`\u0000${row.id}`, Number(row.id));
96
97
  else if (taken.has(String(row.name_folded)))
97
98
  rename.run(`${row.name_folded} (${row.external_id ?? row.id})`, Number(row.id));
99
+ if (!kept)
100
+ gone.run(at, Number(row.id));
98
101
  }
99
102
  const byId = new Map(held.map((row) => [String(row.external_id), row]));
100
103
  for (const stage of wanted) {
101
104
  const row = byId.get(stage.externalId);
102
105
  if (row)
103
106
  database
104
- .prepare("UPDATE pipeline_stages SET name = ?, name_folded = ?, position = ?, outcome = ?, updated_at = ? WHERE id = ?")
107
+ .prepare("UPDATE pipeline_stages SET name = ?, name_folded = ?, position = ?, outcome = ?, updated_at = ?, " +
108
+ "deleted_at = NULL WHERE id = ?")
105
109
  .run(stage.name, stage.folded, stage.position, stage.outcome, at, Number(row.id));
106
110
  else
107
111
  database
@@ -151,7 +155,11 @@ export const crmCopiesOver = (context, people) => {
151
155
  const deal = kind === "lead" && item.deal
152
156
  ? database.prepare("SELECT id FROM deals WHERE account_id = ? AND external_id = ?").get(accountId, item.deal)
153
157
  : undefined;
154
- const metadata = { ...(item.metadata ?? {}), ...(item.url ? { url: item.url } : {}) };
158
+ const metadata = {
159
+ ...(item.metadata ?? {}),
160
+ ...(item.url ? { url: item.url } : {}),
161
+ ...(item.closedReason && status !== "open" ? { closed_reason: textOf(item.closedReason, 2000) } : {}),
162
+ };
155
163
  const values = {
156
164
  title,
157
165
  organization: item.organization ? organizationIdOf(accountId, item.organization) : null,
@@ -314,5 +322,14 @@ export const crmCopiesOver = (context, people) => {
314
322
  .prepare("SELECT count(*) AS n FROM identities WHERE provider = ? AND substr(external_id, 1, ?) = ?")
315
323
  .get(key.provider, key.account.length + 1, `${key.account}/`)?.n)
316
324
  : stored(key, kind).length,
325
+ refOf: async (key, kind, externalId) => {
326
+ const accountId = findAccountPk(context, key);
327
+ const row = accountId === undefined
328
+ ? undefined
329
+ : database
330
+ .prepare(`SELECT id FROM ${TABLES[kind]} WHERE account_id = ? AND external_id = ?`)
331
+ .get(accountId, externalId);
332
+ return row ? `${kind}:${row.id}` : null;
333
+ },
317
334
  };
318
335
  };
@@ -94,9 +94,11 @@ export const dealOperationsOver = (context) => {
94
94
  ? row.account_id == null
95
95
  : row.account_provider === scope.provider && row.account_external_id === scope.account);
96
96
  const kindOf = (kind) => (kind === undefined ? "deal" : oneOf(kind, DEAL_KINDS, "a kind"));
97
- const pipelineRows = (kind, scope) => database
97
+ /** A pipeline the CRM deleted is left out, unless named by its reference: its deals still stand in it. */
98
+ const pipelineRows = (kind, scope, { deleted = false } = {}) => database
98
99
  .prepare("SELECT p.*, a.provider AS account_provider, a.external_id AS account_external_id FROM pipelines p " +
99
- "LEFT JOIN accounts a ON a.id = p.account_id WHERE (? IS NULL OR p.kind = ?) ORDER BY p.name_folded, p.id")
100
+ "LEFT JOIN accounts a ON a.id = p.account_id WHERE (? IS NULL OR p.kind = ?) " +
101
+ `${deleted ? "" : "AND p.deleted_at IS NULL "}ORDER BY p.name_folded, p.id`)
100
102
  .all(kind ?? null, kind ?? null)
101
103
  .filter((row) => inScope(scope, row));
102
104
  const label = (row) => `pipeline:${row.id} (${row.name}${row.account_provider == null ? "" : `, ${row.account_provider}:${row.account_external_id}`})`;
@@ -104,7 +106,7 @@ export const dealOperationsOver = (context) => {
104
106
  const pipelineRow = (reference, kind, scope) => {
105
107
  const named = reference.trim();
106
108
  if (/^pipeline:\d+$/.test(named)) {
107
- const row = pipelineRows(kind, scope).find((one) => `pipeline:${one.id}` === named);
109
+ const row = pipelineRows(kind, scope, { deleted: true }).find((one) => `pipeline:${one.id}` === named);
108
110
  if (!row)
109
111
  throw new CliError("not_found", `no ${kind ?? ""} pipeline ${named}`, { reason: "pipeline.not-found" });
110
112
  return row;
@@ -267,7 +269,7 @@ export const dealOperationsOver = (context) => {
267
269
  });
268
270
  return made;
269
271
  });
270
- return pipelinesOf(pipelineRows(pipelineKind).filter((row) => Number(row.id) === id))[0];
272
+ return pipelinesOf(pipelineRows(pipelineKind, undefined, { deleted: true }).filter((row) => Number(row.id) === id))[0];
271
273
  },
272
274
  pipelines: (filter = {}) => pipelinesOf(pipelineRows(filter.kind === undefined ? undefined : kindOf(filter.kind), filter.account)),
273
275
  pipeline: (reference, options = {}) => {
@@ -54,6 +54,10 @@ export interface MarkedEmail {
54
54
  changedAt: number;
55
55
  /** Sent to many: the bulk mailbox or a List-Id. */
56
56
  bulk: boolean;
57
+ /** Its own List-Id: a real mailing list, as opposed to List-Unsubscribe or Precedence alone. */
58
+ listed: boolean;
59
+ subject: string | null;
60
+ from: string | null;
57
61
  envelope: boolean;
58
62
  /** Stored from its first bytes, not whole yet. */
59
63
  partial: boolean;
@@ -100,7 +100,8 @@ export const writtenTo = (database, addresses) => {
100
100
  };
101
101
  const MARKED = `
102
102
  SELECT e.id, e.external_id, t.external_id AS thread, coalesce(e.received_at, e.sent_at) AS at, e.created_at,
103
- e.updated_at, coalesce(e.draft, 0) AS draft, json_extract(e.metadata, '$.partial') IS NOT NULL AS partial,
103
+ e.updated_at, e.subject, e.from_address, coalesce(e.draft, 0) AS draft,
104
+ coalesce(json_extract(e.headers, '$."list-id"'), json_extract(e.metadata, '$.listId')) IS NOT NULL AS listed, json_extract(e.metadata, '$.partial') IS NOT NULL AS partial,
104
105
  coalesce(json_extract(e.headers, '$."list-id"'), json_extract(e.metadata, '$.listId')) IS NOT NULL
105
106
  OR EXISTS (SELECT 1 FROM email_mailboxes m JOIN mailboxes b ON b.id = m.mailbox_id
106
107
  WHERE m.email_id = e.id AND b.kind = 'bulk') AS bulk,
@@ -117,6 +118,9 @@ const markedOf = (row) => ({
117
118
  storedAt: Number(row.created_at),
118
119
  changedAt: Number(row.updated_at),
119
120
  bulk: Boolean(row.bulk),
121
+ listed: Boolean(row.listed),
122
+ subject: row.subject === null || row.subject === undefined ? null : String(row.subject),
123
+ from: row.from_address === null || row.from_address === undefined ? null : String(row.from_address),
120
124
  envelope: Boolean(row.envelope),
121
125
  partial: Boolean(row.partial),
122
126
  draft: Boolean(row.draft),
@@ -70,6 +70,7 @@ export const MANIFEST = [
70
70
  { name: "20261105144549_version-51-task-links", version: 51, minCompatible: 34 },
71
71
  { name: "20261106010504_version-52-deals", version: 52, minCompatible: 34 },
72
72
  { name: "20261106010507_version-52-deals-rules", version: 52, minCompatible: 34 },
73
+ { name: "20261106165009_version-53-crm-pipelines", version: 53, minCompatible: 34 },
73
74
  ];
74
75
  export const generatedMigrations = (manifest = MANIFEST, generated = GENERATED) => {
75
76
  const migrations = [];
@@ -1033,5 +1033,14 @@ export const GENERATED = [
1033
1033
  "-- The default lead pipeline, so a lead can be recorded before the owner or an agent makes one.\nINSERT INTO pipelines (kind, name, name_folded, created_at, updated_at)\n VALUES ('lead', 'Leads', 'leads', CAST(strftime('%s', 'now') AS INTEGER) * 1000, CAST(strftime('%s', 'now') AS INTEGER) * 1000);",
1034
1034
  "INSERT INTO pipeline_stages (pipeline_id, name, name_folded, position, outcome, created_at, updated_at)\n SELECT p.id, v.name, v.name, v.position, v.outcome, p.created_at, p.created_at FROM pipelines p,\n (SELECT 'new' AS name, 1 AS position, 'open' AS outcome UNION ALL SELECT 'contacted', 2, 'open'\n UNION ALL SELECT 'qualified', 3, 'open' UNION ALL SELECT 'won', 4, 'won' UNION ALL SELECT 'lost', 5, 'lost') v\n WHERE p.kind = 'lead' AND p.name_folded = 'leads' AND p.account_id IS NULL;"
1035
1035
  ]
1036
+ },
1037
+ {
1038
+ "name": "20261106165009_version-53-crm-pipelines",
1039
+ "statements": [
1040
+ "ALTER TABLE `pipeline_stages` ADD `deleted_at` integer;",
1041
+ "ALTER TABLE `pipelines` ADD `deleted_at` integer;",
1042
+ "DROP INDEX IF EXISTS `pipelines_by_account_id_external_id`;",
1043
+ "CREATE UNIQUE INDEX `pipelines_by_account_id_kind_external_id` ON `pipelines` (`account_id`,`kind`,`external_id`) WHERE account_id IS NOT NULL AND external_id IS NOT NULL;"
1044
+ ]
1036
1045
  }
1037
1046
  ];
@@ -15527,6 +15527,22 @@ export declare const pipelines: import("drizzle-orm/sqlite-core").SQLiteTableWit
15527
15527
  identity: undefined;
15528
15528
  generated: undefined;
15529
15529
  }, {}>;
15530
+ deletedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
15531
+ name: string;
15532
+ tableName: "pipelines";
15533
+ dataType: "number int53";
15534
+ data: number;
15535
+ driverParam: number;
15536
+ notNull: false;
15537
+ hasDefault: false;
15538
+ isPrimaryKey: false;
15539
+ isAutoincrement: false;
15540
+ hasRuntimeDefault: false;
15541
+ enumValues: undefined;
15542
+ baseColumn: never;
15543
+ identity: undefined;
15544
+ generated: undefined;
15545
+ }, {}>;
15530
15546
  };
15531
15547
  dialect: 'sqlite';
15532
15548
  isAlias: false;
@@ -15679,6 +15695,22 @@ export declare const pipelineStages: import("drizzle-orm/sqlite-core").SQLiteTab
15679
15695
  identity: undefined;
15680
15696
  generated: undefined;
15681
15697
  }, {}>;
15698
+ deletedAt: import("drizzle-orm/sqlite-core").SQLiteColumn<{
15699
+ name: string;
15700
+ tableName: "pipeline_stages";
15701
+ dataType: "number int53";
15702
+ data: number;
15703
+ driverParam: number;
15704
+ notNull: false;
15705
+ hasDefault: false;
15706
+ isPrimaryKey: false;
15707
+ isAutoincrement: false;
15708
+ hasRuntimeDefault: false;
15709
+ enumValues: undefined;
15710
+ baseColumn: never;
15711
+ identity: undefined;
15712
+ generated: undefined;
15713
+ }, {}>;
15682
15714
  };
15683
15715
  dialect: 'sqlite';
15684
15716
  isAlias: false;
@@ -1545,9 +1545,10 @@ export const pipelines = sqliteTable("pipelines", {
1545
1545
  nameFolded: text("name_folded").notNull(),
1546
1546
  createdAt: integer("created_at").notNull(),
1547
1547
  updatedAt: integer("updated_at").notNull(),
1548
+ deletedAt: integer("deleted_at"),
1548
1549
  }, (table) => [
1549
- uniqueIndex("pipelines_by_account_id_external_id")
1550
- .on(table.accountId, table.externalId)
1550
+ uniqueIndex("pipelines_by_account_id_kind_external_id")
1551
+ .on(table.accountId, table.kind, table.externalId)
1551
1552
  .where(sql `account_id IS NOT NULL AND external_id IS NOT NULL`),
1552
1553
  uniqueIndex("pipelines_by_name_folded_kind").on(table.nameFolded, table.kind).where(sql `account_id IS NULL`),
1553
1554
  ]);
@@ -1563,6 +1564,7 @@ export const pipelineStages = sqliteTable("pipeline_stages", {
1563
1564
  externalId: text("external_id"),
1564
1565
  createdAt: integer("created_at").notNull(),
1565
1566
  updatedAt: integer("updated_at").notNull(),
1567
+ deletedAt: integer("deleted_at"),
1566
1568
  }, (table) => [
1567
1569
  unique().on(table.pipelineId, table.nameFolded),
1568
1570
  uniqueIndex("pipeline_stages_by_pipeline_id_external_id")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wirecat/cli-core",
3
- "version": "0.593.0",
3
+ "version": "0.594.0",
4
4
  "description": "The messenger-neutral half of a messaging CLI: domain model, message locators, rendering, name resolution, the SQLite seam, the command skeleton and shared read commands, the send guard, run records and the message store",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Viacheslav Ptsarev",
@@ -270,7 +270,7 @@
270
270
  "@types/node": "^22.20.5",
271
271
  "@types/pngjs": "6.0.5",
272
272
  "@vitest/coverage-v8": "^5.0.5",
273
- "@wirecat/cli-base": "0.80.0",
273
+ "@wirecat/cli-base": "0.80.2",
274
274
  "@wirecat/cli-core-legacy-tags": "npm:@wirecat/cli-core@0.414.0",
275
275
  "commander": "15.0.0",
276
276
  "cspell": "10.4.0",