@wirecat/cli-core 0.593.0 → 0.595.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 +51 -0
  2. package/dist/background/schedules.d.ts +10 -1
  3. package/dist/background/schedules.js +42 -5
  4. package/dist/cli/index.d.ts +1 -1
  5. package/dist/cli/messenger/store-maintenance-command.js +54 -5
  6. package/dist/cli/schedule/schedule-command.d.ts +4 -2
  7. package/dist/cli/schedule/schedule-command.js +3 -1
  8. package/dist/crm/cli/index.d.ts +6 -0
  9. package/dist/crm/cli/index.js +118 -2
  10. package/dist/crm/index.d.ts +2 -0
  11. package/dist/crm/index.js +2 -0
  12. package/dist/crm/testing/index.d.ts +2 -1
  13. package/dist/crm/testing/index.js +2 -1
  14. package/dist/crm/testing/source-contract.js +2 -2
  15. package/dist/crm/testing/source.d.ts +22 -2
  16. package/dist/crm/testing/source.js +117 -1
  17. package/dist/crm/testing/sync-contract.js +3 -2
  18. package/dist/crm/testing/write-contract.d.ts +21 -0
  19. package/dist/crm/testing/write-contract.js +127 -0
  20. package/dist/crm/writer.d.ts +76 -0
  21. package/dist/crm/writer.js +14 -0
  22. package/dist/crm/writes.d.ts +68 -0
  23. package/dist/crm/writes.js +245 -0
  24. package/dist/domain/crm.d.ts +3 -4
  25. package/dist/entities/index.d.ts +14 -2
  26. package/dist/entities/store-maintenance.d.ts +14 -2
  27. package/dist/entities/store-maintenance.js +19 -6
  28. package/dist/errors/reasons.js +2 -1
  29. package/dist/mail/cli/review.js +11 -4
  30. package/dist/mail/marks.d.ts +2 -0
  31. package/dist/mail/marks.js +8 -6
  32. package/dist/mail/newsletter.d.ts +10 -0
  33. package/dist/mail/newsletter.js +70 -0
  34. package/dist/mail/review.d.ts +1 -0
  35. package/dist/store/migrations.d.ts +11 -2
  36. package/dist/store/migrations.js +32 -2
  37. package/dist/store/sqlite/crm.d.ts +3 -1
  38. package/dist/store/sqlite/crm.js +37 -20
  39. package/dist/store/sqlite/deals.js +6 -4
  40. package/dist/store/sqlite/email-activity.d.ts +4 -0
  41. package/dist/store/sqlite/email-activity.js +5 -1
  42. package/dist/store/sqlite/manifest.d.ts +2 -0
  43. package/dist/store/sqlite/manifest.js +58 -5
  44. package/dist/store/sqlite/migrations.generated.js +9 -0
  45. package/dist/store/sqlite/open-store.js +8 -1
  46. package/dist/store/sqlite/rollback-copy.d.ts +49 -0
  47. package/dist/store/sqlite/rollback-copy.js +290 -0
  48. package/dist/store/sqlite/schema.d.ts +32 -0
  49. package/dist/store/sqlite/schema.js +4 -2
  50. package/dist/store/store.d.ts +1 -0
  51. package/dist/store/store.js +1 -0
  52. package/package.json +2 -2
@@ -0,0 +1,245 @@
1
+ import { CliError, isCliError } from "@wirecat/cli-base";
2
+ import { guardFor } from "../sends/guard.js";
3
+ import { guardedWrite } from "../sends/guarded.js";
4
+ import { keyedGuard } from "../sends/keyed-guard.js";
5
+ import { DEFAULT_PERMISSIONS, levelFor } from "../sends/permissions.js";
6
+ import { newOperationId } from "../sends/send-id.js";
7
+ import { CRM_ASSIGN_OTHERS_KEY, CRM_WRITE_DEFAULTS, } from "./writer.js";
8
+ const PLURAL = { deal: "deals", lead: "leads" };
9
+ const COMMAND = {
10
+ create: "create",
11
+ update: "update",
12
+ move: "move",
13
+ close: "close",
14
+ remove: "delete",
15
+ };
16
+ const keyOf = (kind, verb) => `${PLURAL[kind]}.${COMMAND[verb]}`;
17
+ /** Bitrix24 REST 3.0's `Idempotency-Key` takes 1 to 255 printable ASCII characters; so does every id here. */
18
+ const OPERATION_ID = /^[\x21-\x7e]{1,255}$/u;
19
+ const DECIMAL = /^\d{1,16}(\.\d{1,6})?$/u;
20
+ const CURRENCY = /^[A-Z]{3}$/u;
21
+ const DAY = /^\d{4}-\d{2}-\d{2}$/u;
22
+ const FIELD_CODE = /^[A-Za-z][\w.-]{0,99}$/u;
23
+ const invalid = (message, param) => new CliError("validation_error", message, { reason: "deal.field", ...(param ? { param } : {}) });
24
+ const supported = (writer, kind, verb) => {
25
+ const { kinds, verbs } = writer.capabilities;
26
+ if (!kinds.includes(kind) || !verbs.includes(verb))
27
+ throw new CliError("validation_error", `this CRM does not take a ${COMMAND[verb]} of ${PLURAL[kind]}`, {
28
+ reason: "crm.write-unsupported",
29
+ sent: false,
30
+ });
31
+ };
32
+ const operationOf = (context, given) => {
33
+ if (given === undefined)
34
+ return newOperationId();
35
+ const name = context.names?.id ?? "--operation-id";
36
+ if (!context.writes.writer.capabilities.idempotent)
37
+ throw new CliError("validation_error", `this CRM does not recognise a repeated write, so ${name} would do it a second time; sync and look before ` +
38
+ "writing again", { reason: "crm.write-unsupported", param: name, sent: false });
39
+ if (!OPERATION_ID.test(given))
40
+ throw new CliError("validation_error", `${name} takes 1 to 255 printable characters with no spaces`, {
41
+ param: name,
42
+ });
43
+ return given;
44
+ };
45
+ /** A copy of this profile's account: another account's, or the owner's own, is not written here. */
46
+ const copyOf = async (context, kind, reference) => {
47
+ if (!reference.startsWith(`${kind}:`))
48
+ throw new CliError("validation_error", `name the ${kind} as ${kind}:<id>, as a list shows it`, {
49
+ reason: "ref.malformed",
50
+ });
51
+ const found = await context.store.deals.get(reference);
52
+ const { provider, account } = context.account;
53
+ if (found.account?.provider !== provider || found.account.account !== account || found.externalId === null)
54
+ throw new CliError("not_found", `${reference} is not a copy from this profile's CRM account`);
55
+ return found;
56
+ };
57
+ const pipelineOf = async (context, kind, reference) => {
58
+ if (reference !== undefined) {
59
+ const found = await context.store.deals.pipeline(reference, { kind, account: context.account });
60
+ if (found.externalId === null)
61
+ throw new CliError("not_found", `${reference} is not a pipeline of this CRM account`);
62
+ return found;
63
+ }
64
+ const all = await context.store.deals.pipelines({ kind, account: context.account });
65
+ if (all.length === 1)
66
+ return all[0];
67
+ throw new CliError("validation_error", all.length === 0
68
+ ? `no ${kind} pipeline of this CRM is stored; run sync first`
69
+ : `this CRM has ${all.length} ${kind} pipelines; name one with --pipeline: ${all.map(({ name }) => name).join(", ")}`, { reason: "crm.pipeline-required", param: "--pipeline" });
70
+ };
71
+ /** A stage of `pipeline`, by the CRM's id or by a name only one of its stages has, in any letter case. */
72
+ const stageOf = (pipeline, given) => {
73
+ const byId = pipeline.stages.find((stage) => stage.externalId === given);
74
+ if (byId)
75
+ return byId;
76
+ const folded = given.trim().toLocaleLowerCase();
77
+ const named = pipeline.stages.filter((stage) => stage.name.toLocaleLowerCase() === folded);
78
+ if (named.length === 1)
79
+ return named[0];
80
+ throw new CliError("validation_error", `${pipeline.name} has ${named.length ? "more than one" : "no"} stage ${given}: ` +
81
+ pipeline.stages.map(({ name }) => name).join(", "), { reason: "deal.stage", param: "--stage" });
82
+ };
83
+ const validDay = (text) => {
84
+ const date = new Date(`${text}T00:00:00Z`);
85
+ return DAY.test(text) && !Number.isNaN(date.getTime()) && date.toISOString().startsWith(text);
86
+ };
87
+ /** Typed fields into the port's, resolving the stage in `pipeline`; `currency` is the copy's when a patch keeps it. */
88
+ const fieldsOf = (input, pipeline, currency) => {
89
+ const fields = {};
90
+ if (input.title !== undefined) {
91
+ if (!input.title.trim())
92
+ throw invalid("a title is never empty", "--title");
93
+ fields.title = input.title.trim();
94
+ }
95
+ if (pipeline && input.pipeline !== undefined)
96
+ fields.pipeline = pipeline.externalId;
97
+ const stage = pipeline && input.stage !== undefined ? stageOf(pipeline, input.stage) : undefined;
98
+ if (stage)
99
+ fields.stage = stage.externalId;
100
+ if (input.amount !== undefined || input.currency !== undefined) {
101
+ const code = (input.currency ?? currency ?? "").toUpperCase();
102
+ if (input.amount === undefined)
103
+ throw invalid("--currency goes with --amount", "--amount");
104
+ if (!DECIMAL.test(input.amount))
105
+ throw invalid("--amount is a decimal such as 1500.50", "--amount");
106
+ if (!CURRENCY.test(code))
107
+ throw invalid("--amount needs --currency, an ISO 4217 code such as EUR", "--currency");
108
+ fields.amount = input.amount;
109
+ fields.currency = code;
110
+ }
111
+ if (input.closeDate !== undefined) {
112
+ if (!validDay(input.closeDate))
113
+ throw invalid("--close-date is a day, YYYY-MM-DD", "--close-date");
114
+ fields.closeDate = input.closeDate;
115
+ }
116
+ if (input.owner !== undefined) {
117
+ if (!input.owner.trim())
118
+ throw invalid("--owner is the CRM user's id", "--owner");
119
+ fields.owner = input.owner.trim();
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
+ return { fields, stage };
128
+ };
129
+ /** Every key the write checks: its own, and each action its fields also take (WC-PERM-22). */
130
+ const keysOf = (context, kind, verb, { stage, owner }) => {
131
+ const extra = [];
132
+ if (stage && verb !== "move")
133
+ extra.push(keyOf(kind, "move"));
134
+ if (stage && stage.outcome !== "open")
135
+ extra.push(keyOf(kind, "close"));
136
+ if (owner != null && owner !== context.writes.self)
137
+ extra.push(CRM_ASSIGN_OTHERS_KEY);
138
+ return { key: keyOf(kind, verb), extra };
139
+ };
140
+ /** The profile's guard over the write's own key and the keys its fields imply (`keyedGuard`). */
141
+ const guardOf = (context, extra) => {
142
+ const settings = context.writes.settings();
143
+ const defaults = { ...DEFAULT_PERMISSIONS, ...CRM_WRITE_DEFAULTS, ...settings.permissionDefaults };
144
+ return keyedGuard((ask) => guardFor(context.writes.app, { ...settings, permissionDefaults: defaults }, context.writes.warn ?? (() => { }), context.writes.env, ask), context.ask, (key) => levelFor(settings.permissions, key, defaults).level, extra);
145
+ };
146
+ /** Sends the write once, never again: a lost answer is `outcome_unknown`, with what the owner may do about it. */
147
+ const written = async (context, { key, extra }, operationId, act) => {
148
+ try {
149
+ return await guardedWrite(guardOf(context, extra), { chatId: null, kind: "edit", key, operationId }, act);
150
+ }
151
+ catch (error) {
152
+ if (!isCliError(error) || error.code !== "outcome_unknown")
153
+ throw error;
154
+ const name = context.names?.id ?? "--operation-id";
155
+ throw new CliError("outcome_unknown", context.writes.writer.capabilities.idempotent
156
+ ? `${error.message} — repeat it with ${name} ${operationId}: the CRM answers a repeat from its record ` +
157
+ "instead of doing it again"
158
+ : `${error.message} — sync and look at the CRM before writing again: this CRM would do it a second time`, { ...error.details, operation_id: operationId });
159
+ }
160
+ };
161
+ /**
162
+ * The CRM's answer into the store, as a sync would save it. A failure here names the item: the CRM has made the
163
+ * change, and a repeat would make it again.
164
+ */
165
+ const kept = async (context, kind, item, operationId) => {
166
+ const fail = (why, details = {}) => new CliError("invalid_response", `the CRM wrote ${kind} ${item.externalId}, but the local store did not keep it (${why}); sync to store it — ` +
167
+ "do not write it again", { ...details, operation_id: operationId, external_id: item.externalId, sent: true });
168
+ try {
169
+ let saved = await context.store.crm.saveItems(context.account, kind, [item]);
170
+ if (saved.skipped[0]?.reason === "crm.stage-unknown" && context.source) {
171
+ await context.store.crm.savePipelines(context.account, kind, await context.source().pipelines(kind));
172
+ saved = await context.store.crm.saveItems(context.account, kind, [item]);
173
+ }
174
+ const [skipped] = saved.skipped;
175
+ if (skipped)
176
+ throw fail(skipped.message, { reason: skipped.reason });
177
+ const reference = await context.store.crm.refOf(context.account, kind, item.externalId);
178
+ if (reference === null)
179
+ throw fail("it is not found after the save");
180
+ return await context.store.deals.get(reference);
181
+ }
182
+ catch (error) {
183
+ if (isCliError(error) && error.code === "invalid_response" && error.details.sent === true)
184
+ throw error;
185
+ throw fail(error instanceof Error ? error.message : String(error), isCliError(error) ? error.details : {});
186
+ }
187
+ };
188
+ export const createCrmItem = async (context, kind, input, options = {}) => {
189
+ const { writer } = context.writes;
190
+ supported(writer, kind, "create");
191
+ if (input.title === undefined)
192
+ throw invalid(`a new ${kind} needs --title`, "--title");
193
+ const pipeline = await pipelineOf(context, kind, input.pipeline);
194
+ const { fields, stage } = fieldsOf({ ...input, pipeline: pipeline.ref }, pipeline, null);
195
+ const operationId = operationOf(context, options.operationId);
196
+ const item = await written(context, keysOf(context, kind, "create", { stage, owner: fields.owner }), operationId, () => writer.create(kind, fields, { operationId }));
197
+ return { operationId, item: await kept(context, kind, item, operationId) };
198
+ };
199
+ export const updateCrmItem = async (context, kind, reference, input, options = {}) => {
200
+ const { writer } = context.writes;
201
+ supported(writer, kind, "update");
202
+ const copy = await copyOf(context, kind, reference);
203
+ if (input.pipeline !== undefined && input.stage === undefined)
204
+ throw invalid("a new --pipeline needs the --stage it goes to there", "--stage");
205
+ const pipeline = input.stage === undefined
206
+ ? undefined
207
+ : await pipelineOf(context, kind, input.pipeline === undefined ? copy.pipeline : input.pipeline);
208
+ const { fields, stage } = fieldsOf(input, pipeline, copy.currency);
209
+ if (Object.keys(fields).length === 0)
210
+ throw invalid("nothing to change: name a field");
211
+ const operationId = operationOf(context, options.operationId);
212
+ const item = await written(context, keysOf(context, kind, "update", { stage, owner: fields.owner }), operationId, () => writer.update(kind, copy.externalId, fields, { operationId }));
213
+ return { operationId, item: await kept(context, kind, item, operationId) };
214
+ };
215
+ export const moveCrmItem = async (context, kind, reference, stageName, options = {}) => {
216
+ const { writer } = context.writes;
217
+ supported(writer, kind, "move");
218
+ const copy = await copyOf(context, kind, reference);
219
+ const stage = stageOf(await pipelineOf(context, kind, copy.pipeline), stageName);
220
+ const operationId = operationOf(context, options.operationId);
221
+ const item = await written(context, keysOf(context, kind, "move", { stage }), operationId, () => writer.move(kind, copy.externalId, stage.externalId, { operationId }));
222
+ return { operationId, item: await kept(context, kind, item, operationId) };
223
+ };
224
+ export const closeCrmItem = async (context, kind, reference, outcome, options = {}) => {
225
+ const { writer } = context.writes;
226
+ supported(writer, kind, "close");
227
+ if (outcome !== "won" && outcome !== "lost")
228
+ throw invalid("a close is won or lost");
229
+ const copy = await copyOf(context, kind, reference);
230
+ const reason = options.reason?.trim();
231
+ if (reason !== undefined && (reason === "" || reason.length > 2000))
232
+ throw invalid("--reason is 1 to 2000 characters", "--reason");
233
+ const operationId = operationOf(context, options.operationId);
234
+ const item = await written(context, keysOf(context, kind, "close", {}), operationId, () => writer.close(kind, copy.externalId, outcome, { operationId, ...(reason ? { reason } : {}) }));
235
+ return { operationId, item: await kept(context, kind, item, operationId) };
236
+ };
237
+ export const removeCrmItem = async (context, kind, reference, options = {}) => {
238
+ const { writer } = context.writes;
239
+ supported(writer, kind, "remove");
240
+ const copy = await copyOf(context, kind, reference);
241
+ const operationId = operationOf(context, options.operationId);
242
+ await written(context, keysOf(context, kind, "remove", {}), operationId, () => writer.remove(kind, copy.externalId, { operationId }));
243
+ const removed = await context.store.crm.remove(context.account, kind, [copy.externalId]);
244
+ return { operationId, ref: copy.ref, removed };
245
+ };
@@ -12,10 +12,7 @@ export interface CrmStage {
12
12
  outcome: StageOutcome;
13
13
  }
14
14
  export interface CrmPipeline {
15
- /**
16
- * Unique among the account's pipelines of its kind. Until the store's key is widened, one with the id of a stored
17
- * pipeline of the other kind is skipped and named.
18
- */
15
+ /** Unique among the account's pipelines of its kind; a deal pipeline and a lead pipeline may share one. */
19
16
  externalId: string;
20
17
  name: string;
21
18
  stages: CrmStage[];
@@ -39,6 +36,8 @@ export interface CrmItem {
39
36
  closeDate?: string | null;
40
37
  /** `YYYY-MM-DD`: when it was won or lost. */
41
38
  closedDate?: string | null;
39
+ /** Why it was won or lost, as the CRM keeps it. */
40
+ closedReason?: string | null;
42
41
  /** A company's id. */
43
42
  organization?: string | null;
44
43
  /** Contacts on it, by their id, and the part each plays. */
@@ -9649,12 +9649,24 @@ export declare const ENTITIES: {
9649
9649
  readonly from: v.StringSchema<undefined>;
9650
9650
  readonly bytes: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
9651
9651
  }, undefined>;
9652
- readonly store_restore: v.StrictObjectSchema<{
9652
+ readonly store_restore: v.UnionSchema<[v.StrictObjectSchema<{
9653
9653
  readonly path: v.StringSchema<undefined>;
9654
9654
  readonly restored_from: v.StringSchema<undefined>;
9655
9655
  readonly kept_at: v.NullableSchema<v.StringSchema<undefined>, undefined>;
9656
9656
  readonly schema: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
9657
- }, undefined>;
9657
+ }, undefined>, v.StrictObjectSchema<{
9658
+ readonly path: v.StringSchema<undefined>;
9659
+ readonly restored_from: v.StringSchema<undefined>;
9660
+ readonly rolled_back: v.LiteralSchema<true, undefined>;
9661
+ readonly schema: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
9662
+ readonly was: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
9663
+ readonly tables: v.ArraySchema<v.StrictObjectSchema<{
9664
+ readonly table: v.StringSchema<undefined>;
9665
+ readonly rows: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
9666
+ }, undefined>, undefined>;
9667
+ readonly rebuilt: v.ArraySchema<v.StringSchema<undefined>, undefined>;
9668
+ readonly foreign_key_violations: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
9669
+ }, undefined>], undefined>;
9658
9670
  readonly store_reset: v.UnionSchema<[v.StrictObjectSchema<{
9659
9671
  readonly path: v.StringSchema<undefined>;
9660
9672
  readonly exists: v.LiteralSchema<false, undefined>;
@@ -344,12 +344,24 @@ export declare const storeDecrypt: v.StrictObjectSchema<{
344
344
  readonly from: v.StringSchema<undefined>;
345
345
  readonly bytes: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
346
346
  }, undefined>;
347
- export declare const storeRestore: v.StrictObjectSchema<{
347
+ export declare const storeRestore: v.UnionSchema<[v.StrictObjectSchema<{
348
348
  readonly path: v.StringSchema<undefined>;
349
349
  readonly restored_from: v.StringSchema<undefined>;
350
350
  readonly kept_at: v.NullableSchema<v.StringSchema<undefined>, undefined>;
351
351
  readonly schema: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
352
- }, undefined>;
352
+ }, undefined>, v.StrictObjectSchema<{
353
+ readonly path: v.StringSchema<undefined>;
354
+ readonly restored_from: v.StringSchema<undefined>;
355
+ readonly rolled_back: v.LiteralSchema<true, undefined>;
356
+ readonly schema: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
357
+ readonly was: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
358
+ readonly tables: v.ArraySchema<v.StrictObjectSchema<{
359
+ readonly table: v.StringSchema<undefined>;
360
+ readonly rows: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
361
+ }, undefined>, undefined>;
362
+ readonly rebuilt: v.ArraySchema<v.StringSchema<undefined>, undefined>;
363
+ readonly foreign_key_violations: v.SchemaWithPipe<readonly [v.NumberSchema<undefined>, v.IntegerAction<number, undefined>, v.MinValueAction<number, 0, undefined>]>;
364
+ }, undefined>], undefined>;
353
365
  export declare const storeReset: v.UnionSchema<[v.StrictObjectSchema<{
354
366
  readonly path: v.StringSchema<undefined>;
355
367
  readonly exists: v.LiteralSchema<false, undefined>;
@@ -155,12 +155,25 @@ export const storeBackup = v.strictObject({
155
155
  encrypted: v.optional(v.literal(true)),
156
156
  });
157
157
  export const storeDecrypt = v.strictObject({ path: text, from: text, bytes: count });
158
- export const storeRestore = v.strictObject({
159
- path: text,
160
- restored_from: text,
161
- kept_at: v.nullable(text),
162
- schema: count,
163
- });
158
+ export const storeRestore = v.union([
159
+ v.strictObject({
160
+ path: text,
161
+ restored_from: text,
162
+ kept_at: v.nullable(text),
163
+ schema: count,
164
+ }),
165
+ /** From a rollback copy: its tables put back in place, the store returned to `schema` from `was`. */
166
+ v.strictObject({
167
+ path: text,
168
+ restored_from: text,
169
+ rolled_back: v.literal(true),
170
+ schema: count,
171
+ was: count,
172
+ tables: v.array(v.strictObject({ table: text, rows: count })),
173
+ rebuilt: v.array(text),
174
+ foreign_key_violations: count,
175
+ }),
176
+ ]);
164
177
  export const storeReset = v.union([
165
178
  v.strictObject({ ...absent.entries, reset: v.literal(false) }),
166
179
  v.strictObject({
@@ -213,8 +213,9 @@ const OTHERS = [
213
213
  validation("pipeline.field", "a pipeline needs a name and at least one stage, each named once"),
214
214
  ["pipeline.not-found", "not_found", "no stored pipeline has that name or reference"],
215
215
  validation("crm.stage-unknown", "a CRM item stands in a stage the store does not hold; it was not kept"),
216
- validation("crm.pipeline-clash", "a CRM pipeline has the id of a stored pipeline of the other kind; it was not kept"),
217
216
  validation("crm.item-invalid", "a CRM item has a field the store refuses, such as a currency it does not keep"),
217
+ validation("crm.pipeline-required", "the CRM has more than one pipeline of the kind; the write names one"),
218
+ validation("crm.write-unsupported", "the CRM does not take this write, or does not recognise a repeated one"),
218
219
  validation("crm.unlisted-held", "a list of every id would mark most of a kind deleted; nothing was, until --rescan"),
219
220
  validation("order.duplicate", "the shop's order with this number is already stored; change its status instead"),
220
221
  validation("order.proposed", "the shop's order with this number already waits for the owner in a findings batch"),
@@ -87,10 +87,11 @@ const waitingHours = (value) => value === true ? AGENDA_DEFAULTS.waiting / 3_600
87
87
  const quoted = (value) => `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
88
88
  /** A file name for the mailbox's points: they describe the mailbox, whichever profile reads it. */
89
89
  const pointsName = (mailbox) => encodeURIComponent(mailbox.account);
90
- const markText = (marks, excerptOnly = false) => {
90
+ const markText = (marks, excerptOnly = false, partial = false) => {
91
91
  const said = [
92
92
  marks?.bulk || excerptOnly ? "bulk" : "",
93
93
  excerptOnly ? "excerpt only" : "",
94
+ partial ? "partial: not fully downloaded" : "",
94
95
  marks?.listId ? `list ${marks.listId}` : "",
95
96
  marks?.ruleId ? `rule ${marks.ruleId}` : "",
96
97
  ];
@@ -144,6 +145,7 @@ const reviewAnswer = (found, mailbox, fresh, step) => {
144
145
  ...messageItem(message, mailbox).email,
145
146
  bulk: (message.marks?.bulk ?? false) || message.excerptOnly === true,
146
147
  ...(message.excerptOnly ? { excerpt_only: true } : {}),
148
+ ...(message.partialExcerpt ? { partial: true } : {}),
147
149
  list_id: message.marks?.listId ?? null,
148
150
  rule_id: message.marks?.ruleId ?? null,
149
151
  ...(message.marks
@@ -178,9 +180,14 @@ const holderReview = async (command, mail, holder, query) => {
178
180
  // An agent reads this: earlier mail and signatures stay out of its text; the fingerprint is over the stored body.
179
181
  const emails = [
180
182
  ...read.emails.map((email) => ({ ...email, text: mailOwnText(email.text) })),
181
- ...read.excerpts.map(({ email, excerpt }) => {
183
+ ...read.excerpts.map(({ email, excerpt, partial }) => {
182
184
  const subject = email.email?.subject;
183
- return { ...email, text: subject ? `${subject}\n\n${excerpt}` : excerpt, excerptOnly: true };
185
+ return {
186
+ ...email,
187
+ text: subject ? `${subject}\n\n${excerpt}` : excerpt,
188
+ excerptOnly: true,
189
+ ...(partial ? { partialExcerpt: true } : {}),
190
+ };
184
191
  }),
185
192
  ];
186
193
  const chats = new Map();
@@ -237,7 +244,7 @@ const holderReview = async (command, mail, holder, query) => {
237
244
  ? `${holder}: past ${read.mark.throughId}${emails.length ? `; findings key ${read.findingsKey}` : ""}; acknowledge with --cursor ${read.cursor}${read.more ? "; more after these" : ""}`
238
245
  : `${holder} has no mark yet: it starts from now — acknowledge with --cursor ${read.cursor}`);
239
246
  return emails
240
- .map((hit) => `${hitText(hit, undefined, local)}${markText(marks.get(hit.id), hit.excerptOnly)}\n`)
247
+ .map((hit) => `${hitText(hit, undefined, local)}${markText(marks.get(hit.id), hit.excerptOnly, hit.partialExcerpt === true)}\n`)
241
248
  .join("");
242
249
  });
243
250
  };
@@ -45,6 +45,8 @@ export interface BulkExcerpt {
45
45
  excerpt: string;
46
46
  /** What kept it, or the newsletter signs too few to leave it out. */
47
47
  cues: string[];
48
+ /** Stored from its first bytes, not whole: the excerpt may be all of it that there is. */
49
+ partial: boolean;
48
50
  }
49
51
  export interface MarkRead {
50
52
  /** The emails for the holder, oldest first; bulk, envelopes, drafts, suspicious senders and partial mail left out. */
@@ -1,7 +1,7 @@
1
1
  import { CliError } from "@wirecat/cli-base";
2
2
  import { mailOwnText } from "../conversations/mail-text.js";
3
3
  import { isBusy } from "../store/sqlite/transaction.js";
4
- import { BULK_TRIAGE_DEFAULTS, newsletterSorter } from "./newsletter.js";
4
+ import { BULK_TRIAGE_DEFAULTS, newsletterSorter, transactionalCue, } from "./newsletter.js";
5
5
  /** A holder's name: what `--holder` takes and the mark's key ends with. */
6
6
  export const HOLDER = /^[a-z0-9][a-z0-9-]{0,39}$/;
7
7
  /** At most this many emails are read past the mark in one go; the rest come with the next read. */
@@ -65,13 +65,15 @@ export const marksOf = async (store, mailbox) => {
65
65
  .flatMap(({ key, value, at }) => parsed(key.slice(PREFIX.length), value, at) ?? [])
66
66
  .sort((a, b) => a.holder.localeCompare(b.holder));
67
67
  };
68
+ /** Sent to many and not an order, an invoice or a shipment: those carry List-Unsubscribe too, and are for the holder. */
69
+ const isBulk = (email) => email.bulk && (email.listed || transactionalCue({ subject: email.subject, from: email.from }) === null);
68
70
  /** Why an email is not for the holder, or `held` while it is partial and young, or `read`. */
69
71
  const verdict = (email, now) => {
70
72
  if (email.draft)
71
73
  return "draft";
72
74
  if (email.envelope)
73
75
  return "envelope";
74
- if (email.bulk)
76
+ if (isBulk(email))
75
77
  return "bulk";
76
78
  if (email.demoted)
77
79
  return "suspicious";
@@ -102,14 +104,14 @@ export const readPastMark = async (store, mailbox, holder, options = {}) => {
102
104
  const { wanted, sorted, oldestStoredAt: _, ...read } = await examine(store, mailbox, holder, options);
103
105
  const excerpts = wanted.flatMap((id) => {
104
106
  const one = sorted.get(id);
105
- return one ? [{ email: one.email, excerpt: one.excerpt, cues: one.cues }] : [];
107
+ return one ? [{ email: one.email, excerpt: one.excerpt, cues: one.cues, partial: one.partial }] : [];
106
108
  });
107
109
  const emails = await store.mail.emailHits(wanted.filter((id) => !sorted.has(id)));
108
110
  return { emails, excerpts, ...read };
109
111
  };
110
112
  /**
111
113
  * The batch's bulk emails a holder might read, sorted by `newsletterSorter`, and where the batch ends so it holds at
112
- * most `perRead` excerpts. Drafts, envelopes, suspicious senders and partial mail keep their own verdicts.
114
+ * most `perRead` excerpts. Drafts, envelopes and suspicious senders keep their own verdicts; partial bulk mail is sorted too, and flagged.
113
115
  */
114
116
  const sortBulk = async (store, batch, triage) => {
115
117
  const sorted = new Map();
@@ -118,7 +120,7 @@ const sortBulk = async (store, batch, triage) => {
118
120
  const sort = newsletterSorter(triage.cues);
119
121
  let excerpts = 0;
120
122
  for (const [index, marked] of batch.entries()) {
121
- if (!marked.bulk || marked.draft || marked.envelope || marked.demoted || marked.partial)
123
+ if (!isBulk(marked) || marked.draft || marked.envelope || marked.demoted)
122
124
  continue;
123
125
  const [email] = await store.mail.emailHits([marked.id]);
124
126
  if (!email)
@@ -130,7 +132,7 @@ const sortBulk = async (store, batch, triage) => {
130
132
  return { sorted, end: index };
131
133
  if (one.verdict === "excerpt")
132
134
  excerpts += 1;
133
- sorted.set(marked.id, { ...one, email });
135
+ sorted.set(marked.id, { ...one, email, partial: marked.partial });
134
136
  }
135
137
  return { sorted, end: batch.length };
136
138
  };
@@ -27,6 +27,16 @@ export interface BulkSorted {
27
27
  cues: string[];
28
28
  excerpt: string;
29
29
  }
30
+ /**
31
+ * What makes a bulk email transactional (an order, an invoice, a receipt, a shipment), or `null`. List-Unsubscribe
32
+ * sits on those too, so it proves nothing alone. Subject and sender only, so the store can ask it of every email.
33
+ * An order's number settles it; an amount with its currency or a word of the lists below does not, when the subject
34
+ * also advertises or the sender is a newsletter's ("Order now, save 20%").
35
+ */
36
+ export declare const transactionalCue: ({ subject, from }: {
37
+ subject: string | null;
38
+ from: string | null;
39
+ }) => string | null;
30
40
  /** The first lines of an email's own text, at most `chars`, cut at a word. */
31
41
  export declare const excerptOf: (text: string, chars: number) => string;
32
42
  /**
@@ -130,6 +130,76 @@ const matcherOf = (words) => {
130
130
  const pattern = new RegExp(`(?<![\\p{L}\\p{N}])(?:${list.map(escaped).join("|")})`, "giu");
131
131
  return (text) => [...new Set([...text.toLowerCase().matchAll(pattern)].map(([found]) => found))];
132
132
  };
133
+ /** Words that, in a subject, say "a record of something of yours": a purchase, a payment, a shipment. */
134
+ const TRANSACTIONAL_WORDS = [
135
+ "заказ",
136
+ "счёт",
137
+ "счет",
138
+ "чек",
139
+ "квитанци",
140
+ "оплат",
141
+ "доставк",
142
+ "посылк",
143
+ "отправлен",
144
+ "order",
145
+ "invoice",
146
+ "receipt",
147
+ "payment",
148
+ "shipped",
149
+ "shipping",
150
+ "delivered",
151
+ "delivery",
152
+ "tracking",
153
+ "refund",
154
+ "pedido",
155
+ "factura",
156
+ "recibo",
157
+ "pago",
158
+ "envío",
159
+ "reembolso",
160
+ ];
161
+ /** An amount with its currency, either side: `1 250 ₽`, `49,90 EUR`, `$12.50`. */
162
+ const AMOUNT = /\d[\d\s.,]*\s?(?:₽|€|\$|£|руб|р\.|rub|eur|usd|gbp)(?![\p{L}])|(?:₽|€|\$|£|rub|eur|usd|gbp)\s?\d/iu;
163
+ /** An order number as shops print it, such as Amazon's `406-3547475-4115561`. */
164
+ const ORDER_ID = /(?<!\d)\d{3}-\d{7}-\d{7}(?!\d)/;
165
+ /** Parts of a sender's address that say "orders, billing or shipping". */
166
+ const TRANSACTIONAL_SENDERS = new Set([
167
+ "order",
168
+ "orders",
169
+ "billing",
170
+ "invoice",
171
+ "invoices",
172
+ "receipt",
173
+ "receipts",
174
+ "payment",
175
+ "payments",
176
+ "shipping",
177
+ "tracking",
178
+ ]);
179
+ const transactionalWords = matcherOf(TRANSACTIONAL_WORDS);
180
+ const advertisingWords = matcherOf(NEWSLETTER_WORDS);
181
+ const NEWSLETTER_SENDER_PARTS = new Set(NEWSLETTER_SENDERS.filter((one) => !one.includes(".")));
182
+ /**
183
+ * What makes a bulk email transactional (an order, an invoice, a receipt, a shipment), or `null`. List-Unsubscribe
184
+ * sits on those too, so it proves nothing alone. Subject and sender only, so the store can ask it of every email.
185
+ * An order's number settles it; an amount with its currency or a word of the lists below does not, when the subject
186
+ * also advertises or the sender is a newsletter's ("Order now, save 20%").
187
+ */
188
+ export const transactionalCue = ({ subject, from }) => {
189
+ const title = subject ?? "";
190
+ const number = REFERENCE_NUMBER.exec(title) ?? ORDER_ID.exec(title);
191
+ if (number)
192
+ return number[0];
193
+ const parts = (from ?? "").toLowerCase().split(/[.@_+-]/);
194
+ if (advertisingWords(title).length > 0 || parts.some((part) => NEWSLETTER_SENDER_PARTS.has(part)))
195
+ return null;
196
+ const [word] = transactionalWords(title);
197
+ if (word)
198
+ return word;
199
+ if (AMOUNT.test(title))
200
+ return "an amount";
201
+ return parts.find((part) => TRANSACTIONAL_SENDERS.has(part)) ?? null;
202
+ };
133
203
  /** The first lines of an email's own text, at most `chars`, cut at a word. */
134
204
  export const excerptOf = (text, chars) => {
135
205
  const flat = text
@@ -22,6 +22,7 @@ export interface MailReviewOptions {
22
22
  export type MailHit = StoredHit & {
23
23
  marks?: EmailMarks;
24
24
  excerptOnly?: true;
25
+ partialExcerpt?: true;
25
26
  };
26
27
  export interface MailReview extends Review {
27
28
  chats: (Omit<ReviewChat, "messages"> & {
@@ -1,9 +1,15 @@
1
1
  import type { CacheDatabase } from "./driver.js";
2
+ import { type RollbackCopyOptions } from "./sqlite/rollback-copy.js";
2
3
  export interface Migration {
3
4
  version: number;
4
5
  /** The oldest schema version whose statements still work on a file at this version. */
5
6
  minCompatible: number;
6
7
  statements: string[];
8
+ /**
9
+ * The base tables it changes, by their names before it. Required when it raises `minCompatible`: they are copied
10
+ * aside before it runs, so `store restore` can take the store back to the version older tools still open.
11
+ */
12
+ tables?: string[];
7
13
  /** Runs before the statements, inside the same transaction. */
8
14
  before?: (database: CacheDatabase, now: () => number) => void;
9
15
  }
@@ -12,7 +18,8 @@ export interface Migration {
12
18
  * schema. **A migration that has reached anyone's file is never edited.** After it: forward-only and additive —
13
19
  * no `DROP` and no rebuild of a base table, a new column is nullable or has a default, and every `INSERT` in the
14
20
  * store names its columns — so an older `tg` keeps writing to a file a newer `max` has migrated. Only a breaking
15
- * change raises `minCompatible`, and that is a major version of this package. The search indexes are derived and
21
+ * change raises `minCompatible`, and that is a major version of this package; it names the base tables it changes
22
+ * (`tables`), which are copied aside before it runs (`sqlite/rollback-copy.ts`). The search indexes are derived and
16
23
  * may be dropped and rebuilt. Every version after the first gets a `version-N.test.ts` that opens a file at N-1.
17
24
  *
18
25
  * Times are epoch milliseconds: an integer sorts and ranges without a format to agree on.
@@ -24,11 +31,13 @@ export declare const MIGRATIONS: Migration[];
24
31
  * that fails names `store reset` when the app has one: without it the owner is stuck until a build that can migrate
25
32
  * the file. An app without that command (drive) passes no `command`, and the advice names none.
26
33
  */
27
- export declare const migrate: (database: CacheDatabase, { migrations, now, command, version, ownTable, }?: {
34
+ export declare const migrate: (database: CacheDatabase, { migrations, now, command, version, ownTable, rollback, }?: {
28
35
  migrations?: Migration[];
29
36
  now?: () => number;
30
37
  command?: string;
31
38
  /** The CLI's own version: with `command`, it is recorded so a later upgrade can name who it locks out. */
32
39
  version?: string;
33
40
  ownTable?: string | null;
41
+ /** A store on disk: before a migration that raises `minCompatible`, a copy of the tables it changes. */
42
+ rollback?: RollbackCopyOptions;
34
43
  }) => string | undefined;