@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.
- package/CHANGELOG.md +51 -0
- package/dist/background/schedules.d.ts +10 -1
- package/dist/background/schedules.js +42 -5
- package/dist/cli/index.d.ts +1 -1
- package/dist/cli/messenger/store-maintenance-command.js +54 -5
- package/dist/cli/schedule/schedule-command.d.ts +4 -2
- package/dist/cli/schedule/schedule-command.js +3 -1
- package/dist/crm/cli/index.d.ts +6 -0
- package/dist/crm/cli/index.js +118 -2
- package/dist/crm/index.d.ts +2 -0
- package/dist/crm/index.js +2 -0
- package/dist/crm/testing/index.d.ts +2 -1
- package/dist/crm/testing/index.js +2 -1
- package/dist/crm/testing/source-contract.js +2 -2
- package/dist/crm/testing/source.d.ts +22 -2
- package/dist/crm/testing/source.js +117 -1
- package/dist/crm/testing/sync-contract.js +3 -2
- package/dist/crm/testing/write-contract.d.ts +21 -0
- package/dist/crm/testing/write-contract.js +127 -0
- package/dist/crm/writer.d.ts +76 -0
- package/dist/crm/writer.js +14 -0
- package/dist/crm/writes.d.ts +68 -0
- package/dist/crm/writes.js +245 -0
- package/dist/domain/crm.d.ts +3 -4
- package/dist/entities/index.d.ts +14 -2
- package/dist/entities/store-maintenance.d.ts +14 -2
- package/dist/entities/store-maintenance.js +19 -6
- package/dist/errors/reasons.js +2 -1
- package/dist/mail/cli/review.js +11 -4
- package/dist/mail/marks.d.ts +2 -0
- package/dist/mail/marks.js +8 -6
- package/dist/mail/newsletter.d.ts +10 -0
- package/dist/mail/newsletter.js +70 -0
- package/dist/mail/review.d.ts +1 -0
- package/dist/store/migrations.d.ts +11 -2
- package/dist/store/migrations.js +32 -2
- package/dist/store/sqlite/crm.d.ts +3 -1
- package/dist/store/sqlite/crm.js +37 -20
- package/dist/store/sqlite/deals.js +6 -4
- package/dist/store/sqlite/email-activity.d.ts +4 -0
- package/dist/store/sqlite/email-activity.js +5 -1
- package/dist/store/sqlite/manifest.d.ts +2 -0
- package/dist/store/sqlite/manifest.js +58 -5
- package/dist/store/sqlite/migrations.generated.js +9 -0
- package/dist/store/sqlite/open-store.js +8 -1
- package/dist/store/sqlite/rollback-copy.d.ts +49 -0
- package/dist/store/sqlite/rollback-copy.js +290 -0
- package/dist/store/sqlite/schema.d.ts +32 -0
- package/dist/store/sqlite/schema.js +4 -2
- package/dist/store/store.d.ts +1 -0
- package/dist/store/store.js +1 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,57 @@ shape of this file.
|
|
|
7
7
|
|
|
8
8
|
## Unreleased
|
|
9
9
|
|
|
10
|
+
## 0.595.0 — 06.11.2026
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **A rollback copy before a breaking store upgrade.** Before a migration that raises `min_compatible` (after which
|
|
15
|
+
older tools refuse the store), the base tables it names are copied into `backups/` beside the store, as
|
|
16
|
+
`<store>-rollback-schema-<version>-<time>.db`, with the schema as it was. Search indexes, vectors and other derived
|
|
17
|
+
tables are never copied. Without the free space for it, the upgrade runs anyway and warns. `store restore <copy>`
|
|
18
|
+
puts the tables back and returns the store to that version, so the older tool opens it again; its `store rebuild`
|
|
19
|
+
then refreshes the search indexes. The copy is deleted on the first open a day after the upgrade succeeded.
|
|
20
|
+
- **`Migration.tables`** and the manifest's `tables`: required on a migration that raises `minCompatible`, the base
|
|
21
|
+
tables it changes by their names before it. `generatedMigrations` refuses a raising one without it, or one that
|
|
22
|
+
names a derived table.
|
|
23
|
+
|
|
24
|
+
## 0.594.0 — 06.11.2026
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **A CRM write port, `CrmWriter`** (`@wirecat/cli-core/crm`), beside `CrmSource`: `create`, `update`, `move`,
|
|
29
|
+
`close` and `remove` of deals and leads, each sent once with its `operationId` and answering the whole item as the
|
|
30
|
+
CRM holds it. `capabilities` names the kinds and verbs it takes and whether it recognises a repeated operation id
|
|
31
|
+
(Bitrix24's `Idempotency-Key`). Fields use `CrmItem`'s names, plus `owner` (a CRM user's id) and `custom` (the CRM's
|
|
32
|
+
own fields by code). An adapter refuses a custom code that is a neutral field under another name, such as a stage
|
|
33
|
+
or an owner. `CRM_WRITE_DEFAULTS` and `CRM_WRITE_KEYS` go into a CLI's `commandPermissions`.
|
|
34
|
+
- **`deals` and `leads` `create`, `update`, `move`, `close` and `delete`** in `addCrmCommands`, mounted by its new
|
|
35
|
+
`writes` option (the writer's capabilities) and run through `CrmRun.writes`. Each goes through the write guard and
|
|
36
|
+
the send journal, honours `--dry-run`, and is exposed over MCP as a write. A write is never sent twice: a lost
|
|
37
|
+
answer is `outcome_unknown`, and `--operation-id` is offered only by a CRM that recognises a repeat. A stage in
|
|
38
|
+
`create` or `update` also checks the `move` key, and a won or lost stage the `close` key. An owner other than the
|
|
39
|
+
sign-in's own user (`CrmWrites.self`) checks `crm.assign-others`, denied by default. `delete` asks by default. The
|
|
40
|
+
item the CRM answers is stored as a sync would store it, and the answer carries it with its `operation_id`.
|
|
41
|
+
`--field <key=value>` sets a custom field; an empty value clears it.
|
|
42
|
+
- **`crmWriteContract` and a write side on `fakeCrmSource`** (`@wirecat/cli-core/crm/testing`). The contract checks
|
|
43
|
+
that an update of one field answers the whole item, that a custom code naming a neutral field is refused before
|
|
44
|
+
anything is sent, and that a lost answer ends after one request.
|
|
45
|
+
- **`CrmItem.closedReason`** is stored as the deal's or lead's `closed_reason`, and `store.crm.refOf` finds the stored
|
|
46
|
+
copy of a CRM id.
|
|
47
|
+
- **A CRM's deal and lead pipelines may share an id (store version 53).** The unique key of a CRM account's pipelines
|
|
48
|
+
is now its account, kind and id, so `savePipelines` stores a lead pipeline whose id a deal pipeline already has,
|
|
49
|
+
and `crmSourceContract` checks pipeline ids are unique within each kind only.
|
|
50
|
+
- **Pipelines and stages the CRM no longer lists are marked deleted (store version 53),** never removed: deals may still
|
|
51
|
+
stand in them. `savePipelines` takes a kind's whole list, marks what it lacks and brings back what it lists again; an
|
|
52
|
+
empty list marks nothing. A deleted pipeline is left out of `pipelines()` and of a lookup by name, and still found by
|
|
53
|
+
its `pipeline:<id>`. A deleted stage is only marked. The migration adds columns and swaps one index; `minCompatible`
|
|
54
|
+
stays 34.
|
|
55
|
+
|
|
56
|
+
### Removed
|
|
57
|
+
|
|
58
|
+
- **The `crm.pipeline-clash` skip reason.** `savePipelines` no longer skips a pipeline for sharing the other kind's id,
|
|
59
|
+
so `CrmSkipped.reason` is `crm.stage-unknown` or `crm.item-invalid`.
|
|
60
|
+
|
|
10
61
|
## 0.593.0 — 06.11.2026
|
|
11
62
|
|
|
12
63
|
### Added
|
|
@@ -49,6 +49,14 @@ export interface DaySpend {
|
|
|
49
49
|
export declare const localDay: (at: Date) => string;
|
|
50
50
|
/** What the schedule's runs cost today; nothing before its first priced run of the day. */
|
|
51
51
|
export declare const spentToday: (file: string, now?: Date) => number;
|
|
52
|
+
/**
|
|
53
|
+
* What an agent is given on its stdin before its first turn: the host's own reads of what the run is about, so
|
|
54
|
+
* the agent spends no model turns fetching them. Returns `undefined` when the schedule has none. `run` runs one
|
|
55
|
+
* command of this CLI under the run's variables and answers its stdout.
|
|
56
|
+
*/
|
|
57
|
+
export type AgentInput = (schedule: Schedule, run: (argv: string[]) => Promise<string>) => Promise<string | undefined>;
|
|
58
|
+
/** The most an agent's input may hold; a longer one is cut, so a mailbox in flood cannot grow every turn's context. */
|
|
59
|
+
export declare const AGENT_INPUT_MAX_CHARS = 60000;
|
|
52
60
|
/** Another CLI a scheduled agent may run by name, as the host declares it: `memo`'s agent runs `mail`. */
|
|
53
61
|
export type AgentCli = Pick<AppIdentity, "command" | "appName" | "envPrefix">;
|
|
54
62
|
export interface LastRun {
|
|
@@ -164,7 +172,7 @@ export declare const CREDENTIALS_UNREACHABLE = "credentials.unreachable";
|
|
|
164
172
|
* keyring, so a run that fails for that reason is tried again, a minute then up to 15 minutes apart, for up to
|
|
165
173
|
* 12 hours. A schedule that repeats every few minutes is left to its next tick; a run by hand fails at once.
|
|
166
174
|
*/
|
|
167
|
-
export declare const runSchedule: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv, file: string, { now, timer, signal, killAfterMs, untilUnlocked, wait, agentRuns, }?: {
|
|
175
|
+
export declare const runSchedule: (app: AppIdentity, system: ServerSystem, env: NodeJS.ProcessEnv, file: string, { now, timer, signal, killAfterMs, untilUnlocked, wait, agentRuns, agentInput, }?: {
|
|
168
176
|
now?: () => Date;
|
|
169
177
|
timer?: Timer;
|
|
170
178
|
signal?: AbortSignal;
|
|
@@ -173,4 +181,5 @@ export declare const runSchedule: (app: AppIdentity, system: ServerSystem, env:
|
|
|
173
181
|
wait?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
174
182
|
/** The other CLIs an agent may run; each runs under its own read-only lock and knows it runs from a schedule. */
|
|
175
183
|
agentRuns?: readonly AgentCli[];
|
|
184
|
+
agentInput?: AgentInput;
|
|
176
185
|
}) => Promise<LastRun>;
|
|
@@ -53,6 +53,11 @@ const reportOf = (stdout) => {
|
|
|
53
53
|
return { text: stdout };
|
|
54
54
|
}
|
|
55
55
|
};
|
|
56
|
+
/** The most an agent's input may hold; a longer one is cut, so a mailbox in flood cannot grow every turn's context. */
|
|
57
|
+
export const AGENT_INPUT_MAX_CHARS = 60_000;
|
|
58
|
+
const capped = (input) => input.length <= AGENT_INPUT_MAX_CHARS
|
|
59
|
+
? input
|
|
60
|
+
: `${input.slice(0, AGENT_INPUT_MAX_CHARS)}\n\n[input cut at ${AGENT_INPUT_MAX_CHARS} characters; the rest waits for the next run]\n`;
|
|
56
61
|
/** Why a timer left a run out, as an id a translation can key on; `why` stays the English sentence. */
|
|
57
62
|
export const SKIP_REASONS = {
|
|
58
63
|
costDayCap: "cost.day-cap",
|
|
@@ -426,7 +431,7 @@ const pause = (ms, signal) => new Promise((resolve) => {
|
|
|
426
431
|
* keyring, so a run that fails for that reason is tried again, a minute then up to 15 minutes apart, for up to
|
|
427
432
|
* 12 hours. A schedule that repeats every few minutes is left to its next tick; a run by hand fails at once.
|
|
428
433
|
*/
|
|
429
|
-
export const runSchedule = async (app, system, env, file, { now = () => new Date(), timer, signal, killAfterMs = KILL_AFTER_MS, untilUnlocked = false, wait = pause, agentRuns = [], } = {}) => {
|
|
434
|
+
export const runSchedule = async (app, system, env, file, { now = () => new Date(), timer, signal, killAfterMs = KILL_AFTER_MS, untilUnlocked = false, wait = pause, agentRuns = [], agentInput, } = {}) => {
|
|
430
435
|
const schedule = readSchedule(file);
|
|
431
436
|
const folder = join(file, "..");
|
|
432
437
|
const runs = join(folder, "runs");
|
|
@@ -434,12 +439,24 @@ export const runSchedule = async (app, system, env, file, { now = () => new Date
|
|
|
434
439
|
const agent = schedule.kind === "agent";
|
|
435
440
|
const retries = untilUnlocked && !("every" in schedule.when);
|
|
436
441
|
const firstAt = now().getTime();
|
|
437
|
-
let last = await attemptSchedule(app, system, env, schedule, folder, {
|
|
442
|
+
let last = await attemptSchedule(app, system, env, schedule, folder, {
|
|
443
|
+
now,
|
|
444
|
+
signal,
|
|
445
|
+
killAfterMs,
|
|
446
|
+
agentRuns,
|
|
447
|
+
agentInput,
|
|
448
|
+
});
|
|
438
449
|
for (let delay = LOCKED_FIRST_WAIT_MS; retries && lockedOut(last) && !signal?.aborted && now().getTime() - firstAt + delay <= LOCKED_GIVE_UP_MS; delay = Math.min(delay * 2, LOCKED_LONGEST_WAIT_MS)) {
|
|
439
450
|
await wait(delay, signal);
|
|
440
451
|
if (signal?.aborted)
|
|
441
452
|
break;
|
|
442
|
-
last = await attemptSchedule(app, system, env, schedule, folder, {
|
|
453
|
+
last = await attemptSchedule(app, system, env, schedule, folder, {
|
|
454
|
+
now,
|
|
455
|
+
signal,
|
|
456
|
+
killAfterMs,
|
|
457
|
+
agentRuns,
|
|
458
|
+
agentInput,
|
|
459
|
+
});
|
|
443
460
|
}
|
|
444
461
|
const { exitCode, output } = last;
|
|
445
462
|
const errors = agent ? output.replace(/\.md$/, ".log") : output;
|
|
@@ -493,8 +510,24 @@ export const runSchedule = async (app, system, env, file, { now = () => new Date
|
|
|
493
510
|
}
|
|
494
511
|
return last;
|
|
495
512
|
};
|
|
513
|
+
/** The agent's input, or none: a host read that fails leaves the agent to fetch what it needs itself. */
|
|
514
|
+
const inputOf = async (agentInput, system, env, schedule, log) => {
|
|
515
|
+
try {
|
|
516
|
+
const input = await agentInput(schedule, async (argv) => {
|
|
517
|
+
const ran = await system.run([...system.entry, ...argv], env);
|
|
518
|
+
if (ran.code !== 0)
|
|
519
|
+
throw new Error(`${argv.slice(0, 3).join(" ")} exited ${ran.code}`);
|
|
520
|
+
return ran.stdout;
|
|
521
|
+
});
|
|
522
|
+
return input === undefined ? undefined : capped(input);
|
|
523
|
+
}
|
|
524
|
+
catch (error) {
|
|
525
|
+
log.warn({ event: "schedule.input-failed", schedule: schedule.name, reason: String(error) });
|
|
526
|
+
return undefined;
|
|
527
|
+
}
|
|
528
|
+
};
|
|
496
529
|
/** One run of the schedule's command or agent: its output in `runs/`, its result in `last.json`. */
|
|
497
|
-
const attemptSchedule = async (app, system, env, schedule, folder, { now, signal, killAfterMs, agentRuns, }) => {
|
|
530
|
+
const attemptSchedule = async (app, system, env, schedule, folder, { now, signal, killAfterMs, agentRuns, agentInput, }) => {
|
|
498
531
|
const runs = join(folder, "runs");
|
|
499
532
|
const stamp = now()
|
|
500
533
|
.toISOString()
|
|
@@ -526,9 +559,13 @@ const attemptSchedule = async (app, system, env, schedule, folder, { now, signal
|
|
|
526
559
|
const facts = { schedule: schedule.name, kind: schedule.kind };
|
|
527
560
|
const began = now().getTime();
|
|
528
561
|
log.info({ event: "schedule.start", ...facts });
|
|
562
|
+
const input = agent && agentInput ? await inputOf(agentInput, system, childEnv, schedule, log) : undefined;
|
|
529
563
|
let costUsd;
|
|
530
564
|
const exitCode = await new Promise((resolve) => {
|
|
531
|
-
const child = spawn(program, args, { env: childEnv, cwd: folder, stdio: ["
|
|
565
|
+
const child = spawn(program, args, { env: childEnv, cwd: folder, stdio: ["pipe", "pipe", "pipe"] });
|
|
566
|
+
// An agent that stops reading its stdin may close it first; that is no failure of the run.
|
|
567
|
+
child.stdin?.on("error", () => { });
|
|
568
|
+
child.stdin?.end(input);
|
|
532
569
|
const out = [];
|
|
533
570
|
const err = [];
|
|
534
571
|
child.stdout.on("data", (chunk) => out.push(chunk));
|
package/dist/cli/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { AI_ENTRIES, AI_SETTING_KEYS, type AISettings, resolveAISettings } from "../analysis/settings.js";
|
|
2
|
-
export type { AgentCli } from "../background/schedules.js";
|
|
2
|
+
export type { AgentCli, AgentInput } from "../background/schedules.js";
|
|
3
3
|
export { parseDuration } from "../domain/duration.js";
|
|
4
4
|
export { openOf, PROFILE_PLACEHOLDER, type ProfilesFor, type RefOpen, type RefSummary, refLabel, } from "../domain/ref-summaries.js";
|
|
5
5
|
export { botTemplate } from "../entities/list-results.js";
|
|
@@ -12,7 +12,7 @@ import { migrate } from "../../store/migrations.js";
|
|
|
12
12
|
import { openCache } from "../../store/open.js";
|
|
13
13
|
import { storePath } from "../../store/path.js";
|
|
14
14
|
import { deleteCopy, repairStore } from "../../store/repair.js";
|
|
15
|
-
import { drainNoteIndex, dropAllUnusedVectors, fillSearchIndex, fillStems, involvementStoreOver, noteIndexState, openSqlite, openStore, orphanPointers, pendingNormalization, resetAttachmentWords, resetNoteIndex, resetSearchIndex, resetStems, searchIndexState, stemmerCache, stemsState, vectorIndexCounts, } from "../../store/store.js";
|
|
15
|
+
import { drainNoteIndex, dropAllUnusedVectors, fillSearchIndex, fillStems, involvementStoreOver, noteIndexState, openSqlite, openStore, orphanPointers, pendingNormalization, resetAttachmentWords, resetNoteIndex, resetSearchIndex, resetStems, rollBack, searchIndexState, stemmerCache, stemsState, vectorIndexCounts, } from "../../store/store.js";
|
|
16
16
|
import { environmentOf, outputFor } from "../context.js";
|
|
17
17
|
import { destroysNothing, LOCAL_READ, LOCAL_WRITE } from "../effects.js";
|
|
18
18
|
import { itemOf, renderAnswer, snakeKeys } from "../envelope.js";
|
|
@@ -362,7 +362,11 @@ const migrateCommand = (messenger) => annotate(new Command("migrate"), { output:
|
|
|
362
362
|
}
|
|
363
363
|
const answer = await reading(path, (database) => {
|
|
364
364
|
const from = schemaOf(database).version;
|
|
365
|
-
migrate(database, {
|
|
365
|
+
migrate(database, {
|
|
366
|
+
command: messenger.app.command,
|
|
367
|
+
version: messenger.app.version,
|
|
368
|
+
rollback: { store: path, warn: (line) => renderer.warn(line) },
|
|
369
|
+
});
|
|
366
370
|
const { normalized, indexed, terms } = buildWordIndex(database, (note) => renderer.note(note));
|
|
367
371
|
const stemmed = buildStems(database, (note) => renderer.note(note));
|
|
368
372
|
const notes = buildNoteIndex(database, (note) => renderer.note(note));
|
|
@@ -533,9 +537,11 @@ const decryptCommand = () => annotate(new Command("decrypt"), { output: { entity
|
|
|
533
537
|
* `serve` runs: that opens the store on its first write, so until then it holds nothing to see.
|
|
534
538
|
*/
|
|
535
539
|
const restoreCommand = (messenger) => annotate(new Command("restore"), { output: { entity: "store_restore" }, effects: LOCAL_WRITE })
|
|
536
|
-
.summary("restore a backup
|
|
537
|
-
.description("put a backup in place of the store; the store it replaces is kept beside it, never deleted"
|
|
538
|
-
|
|
540
|
+
.summary("restore a backup and keep the current store, or roll back a breaking upgrade")
|
|
541
|
+
.description("put a backup in place of the store; the store it replaces is kept beside it, never deleted. Given a rollback " +
|
|
542
|
+
"copy (backups/<store>-rollback-schema-<version>-<time>.db, kept for a day before an upgrade older tools " +
|
|
543
|
+
"cannot open), put its tables back in the store and return it to that version instead")
|
|
544
|
+
.argument("<file>", "a file `store backup` wrote, or a rollback copy; one written with --encrypt asks for its password")
|
|
539
545
|
.action(async function (file) {
|
|
540
546
|
const path = storePath(environmentOf(this).env ?? process.env);
|
|
541
547
|
const given = resolve(file);
|
|
@@ -560,6 +566,8 @@ const restoreFrom = async (command, messenger, backup, given) => {
|
|
|
560
566
|
if (existsSync(path) && realpathSync(backup) === realpathSync(path)) {
|
|
561
567
|
throw new CliError("validation_error", `${backup} is the store itself`);
|
|
562
568
|
}
|
|
569
|
+
if (await isRollbackCopy(backup))
|
|
570
|
+
return rollBackFrom(command, messenger, backup);
|
|
563
571
|
const schema = await backupSchema(backup);
|
|
564
572
|
const stamp = stampNow();
|
|
565
573
|
const kept = existsSync(path) ? `${path}.before-restore-${stamp}` : null;
|
|
@@ -588,6 +596,47 @@ const restoreFrom = async (command, messenger, backup, given) => {
|
|
|
588
596
|
renderer.note(`the backup is behind this build; the next command migrates it — \`${lineOf(command, "store migrate")}\` now`);
|
|
589
597
|
}
|
|
590
598
|
};
|
|
599
|
+
const isRollbackCopy = async (file) => {
|
|
600
|
+
try {
|
|
601
|
+
return await readingOnly(file, (database) => database.prepare("SELECT 1 FROM sqlite_master WHERE name = 'rollback_meta'").get() !== undefined);
|
|
602
|
+
}
|
|
603
|
+
catch {
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
};
|
|
607
|
+
/** In place, in one transaction: the copy holds only the tables the upgrade changed, never a whole store. */
|
|
608
|
+
const rollBackFrom = async (command, messenger, copy) => {
|
|
609
|
+
const { renderer } = outputFor(command);
|
|
610
|
+
const path = storePath(environmentOf(command).env ?? process.env);
|
|
611
|
+
if (!existsSync(path))
|
|
612
|
+
throw new CliError("not_found", `no store at ${path} to roll back`);
|
|
613
|
+
const waitMs = environmentOf(command).storeLockWaitMs;
|
|
614
|
+
await quiesce(path, messenger, "restore", (message) => renderer.warn(message), waitMs);
|
|
615
|
+
const { database } = await openSqlite(path);
|
|
616
|
+
let rolled;
|
|
617
|
+
try {
|
|
618
|
+
rolled = rollBack(database, copy);
|
|
619
|
+
}
|
|
620
|
+
finally {
|
|
621
|
+
database.close();
|
|
622
|
+
}
|
|
623
|
+
result(command, {
|
|
624
|
+
path,
|
|
625
|
+
restoredFrom: copy,
|
|
626
|
+
rolledBack: true,
|
|
627
|
+
schema: rolled.from,
|
|
628
|
+
was: rolled.was,
|
|
629
|
+
tables: rolled.restored,
|
|
630
|
+
rebuilt: rolled.rebuilt,
|
|
631
|
+
foreignKeyViolations: rolled.foreignKeyViolations,
|
|
632
|
+
});
|
|
633
|
+
const taken = new Date(rolled.createdAt).toISOString();
|
|
634
|
+
renderer.note(`the store is back at schema ${rolled.from}; ${rolled.tables.length > 0 ? rolled.tables.join(", ") : "no table"} ` +
|
|
635
|
+
`hold what they held at ${taken}, and what was written to them since is not kept`);
|
|
636
|
+
renderer.note("open it with the older tool and run its `store rebuild`: the search indexes are behind the tables");
|
|
637
|
+
renderer.note(`a tool that speaks schema ${rolled.to} or later migrates it again on its next run`);
|
|
638
|
+
renderer.note("restart every serve and mcp of either CLI that was running, so they read the restored store");
|
|
639
|
+
};
|
|
591
640
|
const backupSchema = async (backup) => {
|
|
592
641
|
let read;
|
|
593
642
|
try {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
|
-
import { type AgentCli, type Schedule } from "../../background/schedules.js";
|
|
2
|
+
import { type AgentCli, type AgentInput, type Schedule } from "../../background/schedules.js";
|
|
3
3
|
import { type MailWaiting } from "../../mail/marks.js";
|
|
4
4
|
import { type AppIdentity } from "../../ports/app.js";
|
|
5
5
|
import { type AnswerOutput } from "../envelope.js";
|
|
@@ -19,6 +19,8 @@ export interface ScheduleHost {
|
|
|
19
19
|
* then runs only once enough of it waits, or the oldest has waited long enough.
|
|
20
20
|
*/
|
|
21
21
|
newMail?: (command: Command, holder: string) => Promise<MailWaiting>;
|
|
22
|
+
/** What an agent is given on its stdin before its first turn; see `AgentInput`. */
|
|
23
|
+
agentInput?: AgentInput;
|
|
22
24
|
/** The config file's settings for agent schedules; a schedule's own flags win, the defaults fill the rest. */
|
|
23
25
|
limits?: (command: Command) => ScheduleLimits;
|
|
24
26
|
/**
|
|
@@ -47,5 +49,5 @@ export declare const scheduleLimitsOf: (config: {
|
|
|
47
49
|
* (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
|
|
48
50
|
* the rest, so what runs and with which variables is the same on all three.
|
|
49
51
|
*/
|
|
50
|
-
export declare const scheduleCommandFor: ({ app, context: contextOf, newMail, limits: configured, agentRuns, }: ScheduleHost) => Command;
|
|
52
|
+
export declare const scheduleCommandFor: ({ app, context: contextOf, newMail, agentInput, limits: configured, agentRuns, }: ScheduleHost) => Command;
|
|
51
53
|
export {};
|
|
@@ -157,7 +157,7 @@ const runCapOf = (argv) => {
|
|
|
157
157
|
* (macOS) or Task Scheduler (Windows). Every timer runs `schedule exec <file>`; the schedule file holds
|
|
158
158
|
* the rest, so what runs and with which variables is the same on all three.
|
|
159
159
|
*/
|
|
160
|
-
export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: configured, agentRuns = [], }) => {
|
|
160
|
+
export const scheduleCommandFor = ({ app, context: contextOf, newMail, agentInput, limits: configured, agentRuns = [], }) => {
|
|
161
161
|
const agentClis = [app, ...agentRuns];
|
|
162
162
|
const command = new Command("schedule").description(`run a ${app.command} command or an agent later or on a timer (systemd, launchd, Task Scheduler); an agent cannot send`);
|
|
163
163
|
const prepare = (self, resolving = {}) => {
|
|
@@ -427,6 +427,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
|
|
|
427
427
|
const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, scheduleFile(state, scheduleName(name)), {
|
|
428
428
|
timer: timer(),
|
|
429
429
|
agentRuns,
|
|
430
|
+
...(agentInput ? { agentInput } : {}),
|
|
430
431
|
...(signal ? { signal } : {}),
|
|
431
432
|
}));
|
|
432
433
|
say(snakeKeys({ name, ...last }), [`${name}: exit ${last.exitCode} — ${last.output}`]);
|
|
@@ -462,6 +463,7 @@ export const scheduleCommandFor = ({ app, context: contextOf, newMail, limits: c
|
|
|
462
463
|
const last = await untilChildEnds(this, (signal) => runSchedule(app, system, context.env, file, {
|
|
463
464
|
timer: timer(),
|
|
464
465
|
agentRuns,
|
|
466
|
+
...(agentInput ? { agentInput } : {}),
|
|
465
467
|
untilUnlocked: true,
|
|
466
468
|
...(signal ? { signal } : {}),
|
|
467
469
|
}));
|
package/dist/crm/cli/index.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ import type { AppIdentity } from "../../ports/app.js";
|
|
|
4
4
|
import type { MessageStore } from "../../store/store.js";
|
|
5
5
|
import type { AccountKey } from "../../store/types.js";
|
|
6
6
|
import type { CrmSource } from "../source.js";
|
|
7
|
+
import type { CrmWriteCapabilities } from "../writer.js";
|
|
8
|
+
import { type CrmWrites } from "../writes.js";
|
|
7
9
|
/**
|
|
8
10
|
* The selected profile's CRM account, opened by the app for one command and closed once its answer is built, before
|
|
9
11
|
* anything is printed (WC-LIFE-4).
|
|
@@ -18,6 +20,8 @@ export interface CrmRun {
|
|
|
18
20
|
source: () => CrmSource;
|
|
19
21
|
/** A profile lock pins the process (WC-ACCT-5): `search all` then reads only this account's deals. */
|
|
20
22
|
locked?: boolean;
|
|
23
|
+
/** The CRM's writer and the profile's levels, built on first use; needed by every write command. */
|
|
24
|
+
writes?: () => CrmWrites;
|
|
21
25
|
env?: NodeJS.ProcessEnv;
|
|
22
26
|
close?: () => Promise<void>;
|
|
23
27
|
}
|
|
@@ -30,6 +34,8 @@ export interface CrmCommandOptions {
|
|
|
30
34
|
}) => Promise<CrmRun>;
|
|
31
35
|
/** The CRM has leads: mounts `leads list|show`. True when absent. */
|
|
32
36
|
leads?: boolean;
|
|
37
|
+
/** The writer's capabilities: mounts `create`, `update`, `move`, `close` and `delete` of each kind it writes. */
|
|
38
|
+
writes?: CrmWriteCapabilities;
|
|
33
39
|
now?: () => number;
|
|
34
40
|
}
|
|
35
41
|
/** What `deals list` and `leads list` narrow by, the same in memo and every CRM CLI (WC-OUT-11). */
|
package/dist/crm/cli/index.js
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { CliError } from "@wirecat/cli-base";
|
|
2
2
|
import { annotate, metaOf, sharedCommand } from "@wirecat/cli-base/commands";
|
|
3
|
-
import { InvalidArgumentError } from "commander";
|
|
3
|
+
import { Argument, InvalidArgumentError } from "commander";
|
|
4
4
|
import * as v from "valibot";
|
|
5
5
|
import { answerEntity } from "../../cli/answer-entity.js";
|
|
6
6
|
import { environmentOf, outputFor, runBudgetOf } from "../../cli/context.js";
|
|
7
7
|
import { honoursDryRun } from "../../cli/dry-run-guard.js";
|
|
8
|
-
import { FETCH, LOCAL_READ, NO_EFFECTS } from "../../cli/effects.js";
|
|
8
|
+
import { FETCH, LOCAL_READ, NO_EFFECTS, REMOTE_WRITE } from "../../cli/effects.js";
|
|
9
9
|
import { itemOf, listOf, renderAnswer, snakeKeys } from "../../cli/envelope.js";
|
|
10
10
|
import { queryTerms, termField, withQuery } from "../../cli/list-query.js";
|
|
11
|
+
import { terminalAsker } from "../../cli/messenger/ask.js";
|
|
11
12
|
import { lineOf } from "../../cli/steps.js";
|
|
12
13
|
import { CRM_KINDS } from "../../domain/crm.js";
|
|
13
14
|
import { DEAL_KINDS, DEAL_STATUSES } from "../../domain/deals.js";
|
|
15
|
+
import { deal as dealEntity } from "../../entities/deal.js";
|
|
14
16
|
import { count, text } from "../../entities/fields.js";
|
|
15
17
|
import { crmStatus, syncCrm } from "../sync.js";
|
|
18
|
+
import { closeCrmItem, createCrmItem, moveCrmItem, removeCrmItem, updateCrmItem, } from "../writes.js";
|
|
16
19
|
import { addCrmSearchAll } from "./search-all.js";
|
|
17
20
|
const MOST = 500;
|
|
18
21
|
const DEAL_FIELDS = [
|
|
@@ -83,6 +86,24 @@ const crmSyncProgress = v.strictObject({
|
|
|
83
86
|
cursor_age_ms: v.nullable(count),
|
|
84
87
|
line: text,
|
|
85
88
|
});
|
|
89
|
+
const crmWrite = v.strictObject({
|
|
90
|
+
operation_id: text,
|
|
91
|
+
deal: v.optional(dealEntity),
|
|
92
|
+
lead: v.optional(dealEntity),
|
|
93
|
+
untrusted: v.array(text),
|
|
94
|
+
});
|
|
95
|
+
const crmRemoval = v.strictObject({ operation_id: text, ref: text, removed: count });
|
|
96
|
+
const collect = (value, previous = []) => {
|
|
97
|
+
if (value.indexOf("=") < 1)
|
|
98
|
+
throw new InvalidArgumentError("expected code=value");
|
|
99
|
+
return [...previous, value];
|
|
100
|
+
};
|
|
101
|
+
/** `--field code=value` pairs; an empty value clears the field. */
|
|
102
|
+
const customOf = (pairs) => Object.fromEntries(pairs.map((pair) => {
|
|
103
|
+
const at = pair.indexOf("=");
|
|
104
|
+
const given = pair.slice(at + 1);
|
|
105
|
+
return [pair.slice(0, at), given === "" ? null : given];
|
|
106
|
+
}));
|
|
86
107
|
const integer = (most) => (value) => {
|
|
87
108
|
if (!/^[1-9]\d*$/.test(value) || Number(value) > most)
|
|
88
109
|
throw new InvalidArgumentError(`expected 1 to ${most}`);
|
|
@@ -219,6 +240,7 @@ const addCrmCommandsDefinition = (program, options) => {
|
|
|
219
240
|
return itemOf({ ...snakeKeys(found), untrusted: DEAL_UNTRUSTED });
|
|
220
241
|
});
|
|
221
242
|
});
|
|
243
|
+
addWrites(group, kind);
|
|
222
244
|
};
|
|
223
245
|
/** A row of this profile's account: another account's copy, or the owner's own, is not found here. */
|
|
224
246
|
const ownCopy = async (run, kind, reference) => {
|
|
@@ -231,6 +253,100 @@ const addCrmCommandsDefinition = (program, options) => {
|
|
|
231
253
|
throw new CliError("not_found", `${reference} is not a copy from this profile's CRM account`);
|
|
232
254
|
return found;
|
|
233
255
|
};
|
|
256
|
+
const writes = options.writes;
|
|
257
|
+
const addWrites = (group, kind) => {
|
|
258
|
+
if (!writes?.kinds.includes(kind))
|
|
259
|
+
return;
|
|
260
|
+
const plural = `${kind}s`;
|
|
261
|
+
const contextOf = (run, command) => {
|
|
262
|
+
if (!run.writes)
|
|
263
|
+
throw new CliError("configuration_error", "this profile cannot write to its CRM");
|
|
264
|
+
return {
|
|
265
|
+
store: run.store,
|
|
266
|
+
account: run.account,
|
|
267
|
+
writes: run.writes(),
|
|
268
|
+
ask: terminalAsker(command),
|
|
269
|
+
source: run.source,
|
|
270
|
+
names: { id: "--operation-id" },
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
const answered = (operationId, item) => itemOf({
|
|
274
|
+
operation_id: operationId,
|
|
275
|
+
[kind]: { ...snakeKeys(item), untrusted: DEAL_UNTRUSTED },
|
|
276
|
+
untrusted: DEAL_UNTRUSTED.map((field) => `${kind}.${field}`),
|
|
277
|
+
});
|
|
278
|
+
const write = (name, verb, description) => {
|
|
279
|
+
if (!writes.verbs.includes(verb))
|
|
280
|
+
return undefined;
|
|
281
|
+
const command = answerEntity(annotate(group.command(name), { effects: REMOTE_WRITE, offline: false }), verb === "remove" ? "crm_removal" : "crm_write", verb === "remove" ? crmRemoval : crmWrite).description(description);
|
|
282
|
+
if (writes.idempotent)
|
|
283
|
+
command.option("--operation-id <id>", "the operation_id a write that ended in outcome_unknown named: the CRM answers the repeat from its record");
|
|
284
|
+
return command;
|
|
285
|
+
};
|
|
286
|
+
const reference = (command) => command.argument("<ref>", `${kind}:<id>, as a list shows it`);
|
|
287
|
+
const fields = (command) => command
|
|
288
|
+
.option("--title <title>", `the ${kind}'s title`)
|
|
289
|
+
.option("--pipeline <pipeline>", "pipeline:<id>, or a name only one of the CRM's pipelines has")
|
|
290
|
+
.option("--stage <stage>", "a stage of its pipeline, by the CRM's id or name; checks the move key too, and the close key for a won or lost stage")
|
|
291
|
+
.option("--amount <decimal>", "the expected sum, such as 1500.50, with --currency")
|
|
292
|
+
.option("--currency <code>", "an ISO 4217 code, such as EUR")
|
|
293
|
+
.option("--close-date <date>", "the day it is expected to close, YYYY-MM-DD")
|
|
294
|
+
.option("--owner <user>", "the CRM user it is assigned to; anyone but the sign-in's own user checks crm.assign-others")
|
|
295
|
+
.option("--field <key=value>", "one of the CRM's own fields, by its code; repeat for more, empty clears it", collect,
|
|
296
|
+
// The empty default marks the flag as repeatable, so an agent's tool takes a list.
|
|
297
|
+
[]);
|
|
298
|
+
const inputOf = ({ field, operationId: _id, ...rest }) => ({
|
|
299
|
+
...Object.fromEntries(Object.entries(rest).filter(([, value]) => value !== undefined)),
|
|
300
|
+
...(field?.length ? { custom: customOf(field) } : {}),
|
|
301
|
+
});
|
|
302
|
+
const idOf = (opts) => opts.operationId === undefined ? {} : { operationId: opts.operationId };
|
|
303
|
+
const run = (command, work) => within(command, (opened) => work(contextOf(opened, command)));
|
|
304
|
+
const create = write("create", "create", `make a ${kind} in the CRM, then store the CRM's copy of it`);
|
|
305
|
+
if (create)
|
|
306
|
+
example(fields(create), `${plural} create --title "Example kitchen" --amount 15000 --currency EUR`, `${plural} create --title "Example office" --pipeline Sales --stage "Quote sent" --field UF_CRM_SOURCE=web`).action(async (opts, command) => {
|
|
307
|
+
await run(command, async (context) => {
|
|
308
|
+
const done = await createCrmItem(context, kind, inputOf(opts), idOf(opts));
|
|
309
|
+
return answered(done.operationId, done.item);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
const update = write("update", "update", `change a ${kind} in the CRM; what is left out keeps its value; the CRM's copy is stored again`);
|
|
313
|
+
if (update)
|
|
314
|
+
example(fields(reference(update)), `${plural} update ${kind}:12 --amount 18000`, `${plural} update ${kind}:12 --field UF_CRM_SOURCE=`).action(async (ref, opts, command) => {
|
|
315
|
+
await run(command, async (context) => {
|
|
316
|
+
const done = await updateCrmItem(context, kind, ref, inputOf(opts), idOf(opts));
|
|
317
|
+
return answered(done.operationId, done.item);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
const move = write("move", "move", `move a ${kind} to another stage of its pipeline; a won or lost stage checks the close key too`);
|
|
321
|
+
if (move)
|
|
322
|
+
example(reference(move).argument("<stage>", "the stage, by the CRM's id or a name only one stage of its pipeline has"), `${plural} move ${kind}:12 "Quote sent"`).action(async (ref, stage, opts, command) => {
|
|
323
|
+
await run(command, async (context) => {
|
|
324
|
+
const done = await moveCrmItem(context, kind, ref, stage, idOf(opts));
|
|
325
|
+
return answered(done.operationId, done.item);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
const close = write("close", "close", `mark a ${kind} won or lost, the CRM's own way`);
|
|
329
|
+
if (close)
|
|
330
|
+
example(reference(close)
|
|
331
|
+
.addArgument(new Argument("<outcome>", "how it ended").choices(["won", "lost"]))
|
|
332
|
+
.option("--reason <text>", "why, in a few words; kept with it"), `${plural} close ${kind}:12 won`, `${plural} close ${kind}:12 lost --reason "Example: chose another supplier"`).action(async (ref, outcome, opts, command) => {
|
|
333
|
+
await run(command, async (context) => {
|
|
334
|
+
const done = await closeCrmItem(context, kind, ref, outcome, {
|
|
335
|
+
...idOf(opts),
|
|
336
|
+
...(opts.reason === undefined ? {} : { reason: opts.reason }),
|
|
337
|
+
});
|
|
338
|
+
return answered(done.operationId, done.item);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
const remove = write("delete", "remove", `delete a ${kind} in the CRM; asks first unless allowed`);
|
|
342
|
+
if (remove)
|
|
343
|
+
example(reference(remove), `${plural} delete ${kind}:12`).action(async (ref, opts, command) => {
|
|
344
|
+
await run(command, async (context) => {
|
|
345
|
+
const done = await removeCrmItem(context, kind, ref, idOf(opts));
|
|
346
|
+
return itemOf({ operation_id: done.operationId, ref: done.ref, removed: done.removed });
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
};
|
|
234
350
|
items("deal");
|
|
235
351
|
if (options.leads !== false)
|
|
236
352
|
items("lead");
|
package/dist/crm/index.d.ts
CHANGED
|
@@ -1,2 +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";
|
|
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 +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";
|
|
3
|
+
export { closeCrmItem, createCrmItem, moveCrmItem, removeCrmItem, updateCrmItem, } from "./writes.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { exampleDeal, exampleLead, exampleOrganization, examplePerson, type FakeCrmSource, type FakeCrmSourceOptions, 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, } 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
|
+
export { type CrmWriteFixture, crmWriteContract } from "./write-contract.js";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
-
export { exampleDeal, exampleLead, exampleOrganization, examplePerson, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
|
|
1
|
+
export { exampleDeal, exampleLead, exampleOrganization, examplePerson, FAKE_CRM_ALIASES, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
|
|
2
2
|
export { crmSourceContract } from "./source-contract.js";
|
|
3
3
|
export { CRM_TRAIT_COMBINATIONS, crmSyncContract } from "./sync-contract.js";
|
|
4
|
+
export { crmWriteContract } from "./write-contract.js";
|
|
@@ -29,11 +29,11 @@ const listAll = async (source, kind, cursor) => {
|
|
|
29
29
|
export const crmSourceContract = (make) => {
|
|
30
30
|
const cases = [
|
|
31
31
|
{
|
|
32
|
-
name: "pipelines: unique ids
|
|
32
|
+
name: "pipelines: unique ids in each kind, stages with a known outcome and unique ids",
|
|
33
33
|
async run() {
|
|
34
34
|
const { source } = await make();
|
|
35
|
-
const seen = new Set();
|
|
36
35
|
for (const kind of DEAL_KINDS.filter((one) => source.traits.kinds.includes(one))) {
|
|
36
|
+
const seen = new Set();
|
|
37
37
|
const pipelines = await source.pipelines(kind);
|
|
38
38
|
assert.ok(pipelines.length > 0, `${kind}: at least one pipeline`);
|
|
39
39
|
for (const pipeline of pipelines) {
|
|
@@ -1,13 +1,27 @@
|
|
|
1
1
|
import type { 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
|
+
import { type CrmVerb, type CrmWriteCapabilities, type CrmWriter } from "../writer.js";
|
|
4
5
|
export interface FakeCrmSourceOptions {
|
|
5
6
|
traits?: Partial<CrmSourceTraits>;
|
|
6
7
|
/** Rows per page of `changes()` and `ids()`; 2 when absent, so every list has several pages. */
|
|
7
8
|
pageSize?: number;
|
|
9
|
+
/** What the write side takes: every verb on both kinds, not idempotent, when absent. */
|
|
10
|
+
capabilities?: Partial<CrmWriteCapabilities>;
|
|
8
11
|
}
|
|
9
|
-
/**
|
|
10
|
-
export interface
|
|
12
|
+
/** One write the fake CRM was sent, in order. */
|
|
13
|
+
export interface FakeCrmWrite {
|
|
14
|
+
verb: CrmVerb;
|
|
15
|
+
kind: DealKind;
|
|
16
|
+
externalId: string | null;
|
|
17
|
+
operationId: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A CRM held in memory, with a change feed and a write side: what the contracts sync and write against, and what a
|
|
21
|
+
* CLI's tests may use. Custom field codes `stage`, `pipeline`, `owner` and `status` are refused, as an adapter refuses
|
|
22
|
+
* a custom code that names one of the neutral fields.
|
|
23
|
+
*/
|
|
24
|
+
export interface FakeCrmSource extends CrmSource, CrmWriter {
|
|
11
25
|
/** Adds or changes a row; it is in the next `changes()`. */
|
|
12
26
|
put<K extends CrmKind>(kind: K, record: CrmRecords[K]): void;
|
|
13
27
|
/** Deletes a row: named in `removed` when `removedInChanges`, otherwise only missing from `ids()`. */
|
|
@@ -17,13 +31,19 @@ export interface FakeCrmSource extends CrmSource {
|
|
|
17
31
|
expireCursors(kind: CrmKind): void;
|
|
18
32
|
/** Lists no id at all, as a CRM does for a sign-in that lost its rights. */
|
|
19
33
|
hideAll(hidden: boolean): void;
|
|
34
|
+
/** The next write is carried out and its answer lost: it throws `outcome_unknown`. */
|
|
35
|
+
loseNextAnswer(): void;
|
|
20
36
|
/** Requests made, by method. */
|
|
21
37
|
readonly calls: {
|
|
22
38
|
pipelines: number;
|
|
23
39
|
changes: number;
|
|
24
40
|
ids: number;
|
|
25
41
|
};
|
|
42
|
+
/** Writes sent, refused ones included. */
|
|
43
|
+
readonly writes: FakeCrmWrite[];
|
|
26
44
|
}
|
|
45
|
+
/** Custom codes the fake refuses: each is a neutral field under another name. */
|
|
46
|
+
export declare const FAKE_CRM_ALIASES: string[];
|
|
27
47
|
export declare const SALES_PIPELINE: CrmPipeline;
|
|
28
48
|
export declare const LEAD_PIPELINE: CrmPipeline;
|
|
29
49
|
export declare const fakeCrmSource: (options?: FakeCrmSourceOptions) => FakeCrmSource;
|