@wirecat/cli-core 0.596.0 → 0.598.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 +79 -0
- package/dist/background/deliver.d.ts +7 -1
- package/dist/background/deliver.js +66 -10
- package/dist/background/telegram-html.d.ts +2 -0
- package/dist/background/telegram-html.js +66 -0
- package/dist/calendar/cli/index.js +4 -11
- package/dist/calendar/mcp/index.d.ts +12 -12
- package/dist/cli/context.d.ts +12 -0
- package/dist/cli/context.js +17 -3
- package/dist/cli/contract-templates.js +1 -0
- package/dist/cli/early-refusal.js +7 -5
- package/dist/cli/index.d.ts +2 -2
- package/dist/cli/index.js +1 -1
- package/dist/cli/permission-keys.d.ts +10 -2
- package/dist/cli/preview.d.ts +3 -0
- package/dist/cli/preview.js +9 -5
- package/dist/cli/program.js +3 -0
- package/dist/crm/cli/index.js +76 -17
- package/dist/crm/index.d.ts +1 -1
- package/dist/crm/index.js +1 -1
- package/dist/crm/source.d.ts +6 -1
- package/dist/crm/sync.d.ts +5 -4
- package/dist/crm/sync.js +22 -4
- package/dist/crm/testing/index.d.ts +1 -1
- package/dist/crm/testing/index.js +1 -1
- package/dist/crm/testing/source-contract.js +28 -0
- package/dist/crm/testing/source.d.ts +8 -1
- package/dist/crm/testing/source.js +53 -9
- package/dist/crm/testing/sync-contract.js +14 -3
- package/dist/crm/writer.d.ts +11 -2
- package/dist/crm/writer.js +31 -0
- package/dist/crm/writes.d.ts +12 -2
- package/dist/crm/writes.js +93 -8
- package/dist/domain/crm.d.ts +29 -1
- package/dist/entities/deal.d.ts +16 -0
- package/dist/entities/deal.js +8 -0
- package/dist/entities/index.d.ts +12 -0
- package/dist/errors/reasons.js +4 -0
- package/dist/index.d.ts +1 -1
- package/dist/kit/index.d.ts +1 -0
- package/dist/kit/index.js +1 -0
- package/dist/kit/run-scope.d.ts +34 -0
- package/dist/kit/run-scope.js +112 -0
- package/dist/mcp/app-server.js +7 -2
- package/dist/meetings/cli/index.d.ts +5 -0
- package/dist/meetings/cli/index.js +4 -3
- package/dist/meetings/mcp/index.d.ts +15 -15
- package/dist/store/driver.d.ts +2 -0
- package/dist/store/driver.js +2 -0
- package/dist/store/search-index.js +6 -1
- package/dist/store/sqlite/accounts.js +1 -0
- package/dist/store/sqlite/crm.d.ts +5 -1
- package/dist/store/sqlite/crm.js +52 -0
- package/dist/store/sqlite/deals.d.ts +5 -0
- package/dist/store/sqlite/deals.js +13 -0
- package/dist/store/sqlite/manifest.js +1 -0
- package/dist/store/sqlite/migrations.generated.js +6 -0
- package/dist/store/sqlite/note-index.d.ts +1 -1
- package/dist/store/sqlite/note-index.js +3 -0
- package/dist/store/sqlite/schema.d.ts +217 -0
- package/dist/store/sqlite/schema.js +18 -0
- package/package.json +2 -2
package/dist/cli/preview.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export declare const preview: (command: Command, settings: Pick<Settings, "permi
|
|
|
13
13
|
also?: {
|
|
14
14
|
key: PermissionKey;
|
|
15
15
|
level: string;
|
|
16
|
+
when?: string;
|
|
16
17
|
}[];
|
|
17
18
|
} | undefined;
|
|
18
19
|
validation: {
|
|
@@ -49,6 +50,7 @@ export declare const previewBody: (command: Command, permission: {
|
|
|
49
50
|
also?: {
|
|
50
51
|
key: PermissionKey;
|
|
51
52
|
level: string;
|
|
53
|
+
when?: string;
|
|
52
54
|
}[];
|
|
53
55
|
} | undefined, messenger: boolean) => {
|
|
54
56
|
preview: boolean;
|
|
@@ -59,6 +61,7 @@ export declare const previewBody: (command: Command, permission: {
|
|
|
59
61
|
also?: {
|
|
60
62
|
key: PermissionKey;
|
|
61
63
|
level: string;
|
|
64
|
+
when?: string;
|
|
62
65
|
}[];
|
|
63
66
|
} | undefined;
|
|
64
67
|
validation: {
|
package/dist/cli/preview.js
CHANGED
|
@@ -23,11 +23,15 @@ export const preview = (command, settings, tool, keys, messenger = true) => {
|
|
|
23
23
|
(!declared && key !== null && key !== undefined && WRITE_KEYS.includes(key));
|
|
24
24
|
if (level === "deny" || (writes && level === "readonly"))
|
|
25
25
|
throw new CliError("permission_error", `this profile does not permit ${path.join(" ")}`, { permission: key });
|
|
26
|
-
const also = (declared?.alsoChecks?.(path) ?? []).map((one) =>
|
|
27
|
-
key: one
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
const also = (declared?.alsoChecks?.(path) ?? []).map((one) => {
|
|
27
|
+
const key = typeof one === "string" ? one : one.key;
|
|
28
|
+
return {
|
|
29
|
+
key,
|
|
30
|
+
level: levelFor(permissions, key, declared?.defaults).level,
|
|
31
|
+
...(typeof one === "string" ? {} : { when: one.when }),
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
const stopped = also.find((one) => one.when === undefined && (one.level === "deny" || (writes && one.level === "readonly")));
|
|
31
35
|
if (stopped)
|
|
32
36
|
throw new CliError("permission_error", `this profile does not permit ${stopped.key}`, { permission: stopped.key });
|
|
33
37
|
return previewBody(command, { key: key ?? null, level, ...(also.length ? { also } : {}) }, messenger);
|
package/dist/cli/program.js
CHANGED
|
@@ -228,6 +228,9 @@ const runOnce = async (argv, definition, options) => {
|
|
|
228
228
|
commandSignal: control.signal,
|
|
229
229
|
trackCloseable: control.trackCloseable,
|
|
230
230
|
app: definition.app,
|
|
231
|
+
// `settingsFor`'s, which every CLI passes; the definition's type is looser only so a test can stub it.
|
|
232
|
+
resolveSettings: (definition.configuration ?? settingsFor(definition.app))
|
|
233
|
+
.resolveSettings,
|
|
231
234
|
...(definition.rows ? { rows: definition.rows } : {}),
|
|
232
235
|
platform: options.platform ?? process.platform,
|
|
233
236
|
exit,
|
package/dist/crm/cli/index.js
CHANGED
|
@@ -3,11 +3,11 @@ import { annotate, metaOf, sharedCommand } from "@wirecat/cli-base/commands";
|
|
|
3
3
|
import { Argument, InvalidArgumentError } from "commander";
|
|
4
4
|
import * as v from "valibot";
|
|
5
5
|
import { answerEntity } from "../../cli/answer-entity.js";
|
|
6
|
-
import { environmentOf, outputFor, runBudgetOf } from "../../cli/context.js";
|
|
6
|
+
import { environmentOf, inCommandRun, outputFor, runBudgetOf } from "../../cli/context.js";
|
|
7
7
|
import { honoursDryRun } from "../../cli/dry-run-guard.js";
|
|
8
8
|
import { FETCH, LOCAL_READ, NO_EFFECTS, REMOTE_WRITE } from "../../cli/effects.js";
|
|
9
|
-
import { itemOf, listOf, renderAnswer, snakeKeys } from "../../cli/envelope.js";
|
|
10
|
-
import { queryTerms, termField, withQuery } from "../../cli/list-query.js";
|
|
9
|
+
import { itemOf, listOf, renderAnswer, snakeKeys, snakeKeysKeeping } from "../../cli/envelope.js";
|
|
10
|
+
import { queryTerms, termField, unfilteredQuery, withQuery } from "../../cli/list-query.js";
|
|
11
11
|
import { terminalAsker } from "../../cli/messenger/ask.js";
|
|
12
12
|
import { lineOf } from "../../cli/steps.js";
|
|
13
13
|
import { CRM_KINDS } from "../../domain/crm.js";
|
|
@@ -41,7 +41,15 @@ export const STAGES_QUERY = {
|
|
|
41
41
|
],
|
|
42
42
|
};
|
|
43
43
|
/** A CRM's users typed these; a list item names its own, as `events list` does. */
|
|
44
|
-
const DEAL_UNTRUSTED = [
|
|
44
|
+
const DEAL_UNTRUSTED = [
|
|
45
|
+
"title",
|
|
46
|
+
"summary",
|
|
47
|
+
"stage",
|
|
48
|
+
"pipeline_name",
|
|
49
|
+
"organization.name",
|
|
50
|
+
"people.name",
|
|
51
|
+
"provider_metadata",
|
|
52
|
+
];
|
|
45
53
|
const PIPELINE_UNTRUSTED = ["name", "stages.name"];
|
|
46
54
|
const STAGE_UNTRUSTED = ["name", "pipeline_name"];
|
|
47
55
|
const crmSync = v.strictObject({
|
|
@@ -92,18 +100,36 @@ const crmWrite = v.strictObject({
|
|
|
92
100
|
lead: v.optional(dealEntity),
|
|
93
101
|
untrusted: v.array(text),
|
|
94
102
|
});
|
|
103
|
+
/** b24's `fields list` answer, so a CRM CLI's own command can go; `untrusted` names what the CRM's users typed. */
|
|
104
|
+
const crmField = v.strictObject({
|
|
105
|
+
code: v.string(),
|
|
106
|
+
label: v.nullable(v.string()),
|
|
107
|
+
type: v.string(),
|
|
108
|
+
multiple: v.boolean(),
|
|
109
|
+
filterable: v.boolean(),
|
|
110
|
+
options: v.optional(v.array(v.strictObject({ id: v.string(), label: v.string() }))),
|
|
111
|
+
untrusted: v.array(text),
|
|
112
|
+
});
|
|
113
|
+
const FIELDS_QUERY = unfilteredQuery("crm_field", crmField);
|
|
114
|
+
const FIELD_UNTRUSTED = ["label", "options.label"];
|
|
115
|
+
/** The words `fields list` takes: its sibling commands' nouns. */
|
|
116
|
+
const FIELD_KINDS = {
|
|
117
|
+
deals: "deal",
|
|
118
|
+
leads: "lead",
|
|
119
|
+
people: "person",
|
|
120
|
+
organizations: "organization",
|
|
121
|
+
};
|
|
95
122
|
const crmRemoval = v.strictObject({ operation_id: text, ref: text, removed: count });
|
|
96
123
|
const collect = (value, previous = []) => {
|
|
97
124
|
if (value.indexOf("=") < 1)
|
|
98
|
-
throw new InvalidArgumentError("expected
|
|
125
|
+
throw new InvalidArgumentError("expected key=value");
|
|
99
126
|
return [...previous, value];
|
|
100
127
|
};
|
|
101
|
-
/** `--field
|
|
102
|
-
const
|
|
128
|
+
/** `--field` as typed, split at the first `=`. */
|
|
129
|
+
const pairsOf = (typed) => typed.map((pair) => {
|
|
103
130
|
const at = pair.indexOf("=");
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
}));
|
|
131
|
+
return [pair.slice(0, at), pair.slice(at + 1)];
|
|
132
|
+
});
|
|
107
133
|
const integer = (most) => (value) => {
|
|
108
134
|
if (!/^[1-9]\d*$/.test(value) || Number(value) > most)
|
|
109
135
|
throw new InvalidArgumentError(`expected 1 to ${most}`);
|
|
@@ -111,10 +137,12 @@ const integer = (most) => (value) => {
|
|
|
111
137
|
};
|
|
112
138
|
/** The words below the program, as `page.next` and `sync`'s next step run it again. */
|
|
113
139
|
const pathOf = (command) => command.parent?.parent ? [...pathOf(command.parent), command.name()] : [command.name()];
|
|
114
|
-
|
|
140
|
+
/** A CRM's own field codes are its keys, never ours to rename. */
|
|
141
|
+
const dealKeys = snakeKeysKeeping(["providerMetadata"]);
|
|
142
|
+
const marked = (items, untrusted) => items.map((item) => ({ ...dealKeys(item), untrusted }));
|
|
115
143
|
const addCrmCommandsDefinition = (program, options) => {
|
|
116
144
|
const now = () => options.now?.() ?? Date.now();
|
|
117
|
-
const within =
|
|
145
|
+
const within = (command, work) => inCommandRun(command, async () => {
|
|
118
146
|
const run = await options.open(command, { readOnly: metaOf(command).effects?.local !== "write" });
|
|
119
147
|
let body;
|
|
120
148
|
try {
|
|
@@ -124,7 +152,7 @@ const addCrmCommandsDefinition = (program, options) => {
|
|
|
124
152
|
await run.close?.();
|
|
125
153
|
}
|
|
126
154
|
renderAnswer(command, outputFor(command), body);
|
|
127
|
-
};
|
|
155
|
+
});
|
|
128
156
|
const example = (command, ...lines) => annotate(command, { examples: lines.map((line) => `${options.app.command} work ${line}`) });
|
|
129
157
|
example(answerEntity(annotate(program.command("sync"), { effects: FETCH, offline: false, cooperative: true, partial: true }), "crm_sync", crmSync).description("bring the CRM's copy in the local store up to date: pipelines, companies, contacts, deals and leads that " +
|
|
130
158
|
"changed since the last sync, or everything with --rescan; a run stops before its deadline or --max-pages " +
|
|
@@ -237,7 +265,7 @@ const addCrmCommandsDefinition = (program, options) => {
|
|
|
237
265
|
.argument("<ref>", `${kind}:<id>, as a list shows it`), `${plural} show ${kind}:12`).action(async (reference, _opts, command) => {
|
|
238
266
|
await within(command, async (run) => {
|
|
239
267
|
const found = await ownCopy(run, kind, reference);
|
|
240
|
-
return itemOf({ ...
|
|
268
|
+
return itemOf({ ...dealKeys(found), untrusted: DEAL_UNTRUSTED });
|
|
241
269
|
});
|
|
242
270
|
});
|
|
243
271
|
addWrites(group, kind);
|
|
@@ -272,7 +300,7 @@ const addCrmCommandsDefinition = (program, options) => {
|
|
|
272
300
|
};
|
|
273
301
|
const answered = (operationId, item) => itemOf({
|
|
274
302
|
operation_id: operationId,
|
|
275
|
-
[kind]: { ...
|
|
303
|
+
[kind]: { ...dealKeys(item), untrusted: DEAL_UNTRUSTED },
|
|
276
304
|
untrusted: DEAL_UNTRUSTED.map((field) => `${kind}.${field}`),
|
|
277
305
|
});
|
|
278
306
|
const write = (name, verb, description) => {
|
|
@@ -292,12 +320,14 @@ const addCrmCommandsDefinition = (program, options) => {
|
|
|
292
320
|
.option("--currency <code>", "an ISO 4217 code, such as EUR")
|
|
293
321
|
.option("--close-date <date>", "the day it is expected to close, YYYY-MM-DD")
|
|
294
322
|
.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
|
|
323
|
+
.option("--field <key=value>", "one of the CRM's own fields, by its code or label as `fields list` prints them, and its value; a list " +
|
|
324
|
+
"field takes its option's label or id; repeat it for more fields, or for each value of a multiple field; " +
|
|
325
|
+
"an empty value clears it", collect,
|
|
296
326
|
// The empty default marks the flag as repeatable, so an agent's tool takes a list.
|
|
297
327
|
[]);
|
|
298
328
|
const inputOf = ({ field, operationId: _id, ...rest }) => ({
|
|
299
329
|
...Object.fromEntries(Object.entries(rest).filter(([, value]) => value !== undefined)),
|
|
300
|
-
...(field?.length ? { custom:
|
|
330
|
+
...(field?.length ? { custom: pairsOf(field) } : {}),
|
|
301
331
|
});
|
|
302
332
|
const idOf = (opts) => opts.operationId === undefined ? {} : { operationId: opts.operationId };
|
|
303
333
|
const run = (command, work) => within(command, (opened) => work(contextOf(opened, command)));
|
|
@@ -408,6 +438,35 @@ const addCrmCommandsDefinition = (program, options) => {
|
|
|
408
438
|
});
|
|
409
439
|
});
|
|
410
440
|
});
|
|
441
|
+
const fieldKinds = Object.keys(FIELD_KINDS).filter((word) => word !== "leads" || options.leads !== false);
|
|
442
|
+
const fields = annotate(program.command("fields"), { effects: NO_EFFECTS }).description("the CRM's own fields on deals, leads, people and organizations, as last synced");
|
|
443
|
+
example(withQuery(answerEntity(annotate(fields.command("list"), { effects: LOCAL_READ }), "crm_field", crmField)
|
|
444
|
+
.description("the CRM's custom fields of one kind, in its order, with a list field's options")
|
|
445
|
+
.argument("<kind>", fieldKinds.join(", "))
|
|
446
|
+
.option("--limit <n>", `fields per page, 1–${MOST}`, integer(MOST), 50)
|
|
447
|
+
// No default: the run's settings refuse --all beside a --page, given or defaulted.
|
|
448
|
+
.option("--page <n>", "which page, starting at 1", integer(1_000_000))
|
|
449
|
+
.option("--all", "every field, no paging"), FIELDS_QUERY), "fields list deals", "fields list people --json").action(async (kind, words, opts, command) => {
|
|
450
|
+
if (!fieldKinds.includes(kind))
|
|
451
|
+
throw new CliError("validation_error", `fields are listed for ${fieldKinds.join(", ")}; got ${kind}`, {
|
|
452
|
+
reason: "field.unknown-kind",
|
|
453
|
+
allowed: fieldKinds,
|
|
454
|
+
});
|
|
455
|
+
queryTerms(words, FIELDS_QUERY, "fields list");
|
|
456
|
+
await within(command, async (run) => {
|
|
457
|
+
const found = await run.store.crm.fields(run.account, FIELD_KINDS[kind]);
|
|
458
|
+
const page = opts.page ?? 1;
|
|
459
|
+
const offset = opts.all ? 0 : (page - 1) * opts.limit;
|
|
460
|
+
const end = opts.all ? found.length : offset + opts.limit;
|
|
461
|
+
return listOf(pathOf(command).join(" "), {
|
|
462
|
+
items: found.slice(offset, end).map((field) => ({ ...field, untrusted: FIELD_UNTRUSTED })),
|
|
463
|
+
limit: opts.all ? Math.max(1, found.length) : opts.limit,
|
|
464
|
+
page,
|
|
465
|
+
has_more: found.length > end,
|
|
466
|
+
...(words.length ? { args: { query: words } } : {}),
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
});
|
|
411
470
|
const search = program.commands.find((one) => one.name() === "search") ??
|
|
412
471
|
annotate(program.command("search"), { effects: NO_EFFECTS }).description("search what the local store holds");
|
|
413
472
|
addCrmSearchAll(search, { within, app: options.app, example });
|
package/dist/crm/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
export type { CrmChanges, CrmIdPage, CrmPage, CrmSource, CrmSourceTraits } from "./source.js";
|
|
2
2
|
export { type CrmCursor, type CrmSyncIssue, type CrmSyncOptions, type CrmSyncReport, type CrmSyncResult, crmStatus, cursorName, parseCrmCursor, syncCrm, } from "./sync.js";
|
|
3
|
-
export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, type CrmDraft, type CrmFields, type CrmVerb, type CrmWriteCapabilities, type CrmWriteOptions, type CrmWriter, } from "./writer.js";
|
|
3
|
+
export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, type CrmDraft, type CrmFields, type CrmVerb, type CrmWriteCapabilities, type CrmWriteOptions, type CrmWriter, crmAlsoChecks, } from "./writer.js";
|
|
4
4
|
export { type CrmFieldsInput, type CrmWriteContext, type CrmWrites, type CrmWritten, closeCrmItem, createCrmItem, moveCrmItem, removeCrmItem, updateCrmItem, } from "./writes.js";
|
package/dist/crm/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
export { crmStatus, cursorName, parseCrmCursor, syncCrm, } from "./sync.js";
|
|
2
|
-
export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, } from "./writer.js";
|
|
2
|
+
export { CRM_ASSIGN_OTHERS_KEY, CRM_VERBS, CRM_WRITE_DEFAULTS, CRM_WRITE_KEYS, crmAlsoChecks, } from "./writer.js";
|
|
3
3
|
export { closeCrmItem, createCrmItem, moveCrmItem, removeCrmItem, updateCrmItem, } from "./writes.js";
|
package/dist/crm/source.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CrmKind, CrmPipeline, CrmRecords } from "../domain/crm.js";
|
|
1
|
+
import type { CrmField, CrmKind, CrmPipeline, CrmRecords } from "../domain/crm.js";
|
|
2
2
|
import type { DealKind } from "../domain/deals.js";
|
|
3
3
|
export interface CrmSourceTraits {
|
|
4
4
|
/** What this CRM has: one without leads leaves `lead` out. Contacts and companies come before the items on them. */
|
|
@@ -38,6 +38,11 @@ export interface CrmSource {
|
|
|
38
38
|
* reads by its own modified time, with an overlap, and gives that time back as the cursor.
|
|
39
39
|
*/
|
|
40
40
|
changes<K extends CrmKind>(kind: K, cursor: string | null, page?: string): Promise<CrmChanges<CrmRecords[K]>>;
|
|
41
|
+
/**
|
|
42
|
+
* The CRM's own fields on one kind, in its order: what the codes in a row's `metadata` mean. Read every sync; a CRM
|
|
43
|
+
* without custom fields leaves it out, and one that has none on a kind answers `[]`.
|
|
44
|
+
*/
|
|
45
|
+
fields?(kind: CrmKind): Promise<CrmField[]>;
|
|
41
46
|
/** Every id the CRM holds of the kind, a page at a time: how deletions are found when `removedInChanges` is false. */
|
|
42
47
|
ids?(kind: CrmKind, page?: string): Promise<CrmIdPage>;
|
|
43
48
|
}
|
package/dist/crm/sync.d.ts
CHANGED
|
@@ -87,10 +87,11 @@ export interface CrmSyncReport {
|
|
|
87
87
|
hint?: string;
|
|
88
88
|
}
|
|
89
89
|
/**
|
|
90
|
-
* Brings one CRM account's copy up to date: both kinds' pipelines, then contacts, companies,
|
|
91
|
-
* from its cursor or listed in full, a page at a time, each page saved with the round's place in
|
|
92
|
-
* run the budget stops, or one that fails, goes on from there. Deletions come from `changes()`, or
|
|
93
|
-
* every id that marks only after its last page arrived, and never more than half of a kind without
|
|
90
|
+
* Brings one CRM account's copy up to date: both kinds' pipelines and each kind's fields, then contacts, companies,
|
|
91
|
+
* deals and leads, each from its cursor or listed in full, a page at a time, each page saved with the round's place in
|
|
92
|
+
* the cursor, so a run the budget stops, or one that fails, goes on from there. Deletions come from `changes()`, or
|
|
93
|
+
* from a list of every id that marks only after its last page arrived, and never more than half of a kind without
|
|
94
|
+
* `--rescan`.
|
|
94
95
|
*/
|
|
95
96
|
export declare const syncCrm: (source: CrmSource, store: MessageStore, account: AccountKey, options?: CrmSyncOptions) => Promise<CrmSyncReport>;
|
|
96
97
|
/** Each kind's sync in the WC-LIFE-14 shape: `cursor_age_ms` growing on a `partial` one means nothing moves it. */
|
package/dist/crm/sync.js
CHANGED
|
@@ -57,6 +57,16 @@ const readPipelines = async (run, kind) => {
|
|
|
57
57
|
run.report.issues.push({ kind, ...one });
|
|
58
58
|
return null;
|
|
59
59
|
};
|
|
60
|
+
const readFields = async (run, kind) => {
|
|
61
|
+
const why = run.budget.take();
|
|
62
|
+
if (why)
|
|
63
|
+
return why;
|
|
64
|
+
const fields = await run.source.fields.call(run.source, kind);
|
|
65
|
+
const { skipped } = await run.store.crm.saveFields(run.account, kind, fields);
|
|
66
|
+
for (const one of skipped)
|
|
67
|
+
run.report.issues.push({ kind, ...one });
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
60
70
|
/** One page saved; an item whose stage is unknown gets one more chance after the kind's pipelines are read again. */
|
|
61
71
|
const savePage = async (run, kind, page) => {
|
|
62
72
|
const { store, account } = run;
|
|
@@ -185,10 +195,11 @@ const syncKind = async (run, kind, options, result) => {
|
|
|
185
195
|
return null;
|
|
186
196
|
};
|
|
187
197
|
/**
|
|
188
|
-
* Brings one CRM account's copy up to date: both kinds' pipelines, then contacts, companies,
|
|
189
|
-
* from its cursor or listed in full, a page at a time, each page saved with the round's place in
|
|
190
|
-
* run the budget stops, or one that fails, goes on from there. Deletions come from `changes()`, or
|
|
191
|
-
* every id that marks only after its last page arrived, and never more than half of a kind without
|
|
198
|
+
* Brings one CRM account's copy up to date: both kinds' pipelines and each kind's fields, then contacts, companies,
|
|
199
|
+
* deals and leads, each from its cursor or listed in full, a page at a time, each page saved with the round's place in
|
|
200
|
+
* the cursor, so a run the budget stops, or one that fails, goes on from there. Deletions come from `changes()`, or
|
|
201
|
+
* from a list of every id that marks only after its last page arrived, and never more than half of a kind without
|
|
202
|
+
* `--rescan`.
|
|
192
203
|
*/
|
|
193
204
|
export const syncCrm = async (source, store, account, options = {}) => {
|
|
194
205
|
const now = options.now ?? Date.now();
|
|
@@ -225,6 +236,13 @@ export const syncCrm = async (source, store, account, options = {}) => {
|
|
|
225
236
|
if (options.signal?.aborted)
|
|
226
237
|
stopped = "cancelled";
|
|
227
238
|
}
|
|
239
|
+
if (run.source.fields)
|
|
240
|
+
for (const kind of kinds)
|
|
241
|
+
if (!stopped) {
|
|
242
|
+
stopped = await readFields(run, kind);
|
|
243
|
+
if (options.signal?.aborted)
|
|
244
|
+
stopped = "cancelled";
|
|
245
|
+
}
|
|
228
246
|
for (const kind of kinds) {
|
|
229
247
|
const result = {
|
|
230
248
|
kind,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { exampleDeal, exampleLead, exampleOrganization, examplePerson, FAKE_CRM_ALIASES, type FakeCrmSource, type FakeCrmSourceOptions, type FakeCrmWrite, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
|
|
1
|
+
export { exampleDeal, exampleLead, exampleOrganization, examplePerson, FAKE_CRM_ALIASES, type FakeCrmSource, type FakeCrmSourceOptions, type FakeCrmWrite, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, SOURCE_FIELD, } from "./source.js";
|
|
2
2
|
export { type CrmSourceFixture, crmSourceContract } from "./source-contract.js";
|
|
3
3
|
export { CRM_TRAIT_COMBINATIONS, type CrmSyncFixture, crmSyncContract } from "./sync-contract.js";
|
|
4
4
|
export { type CrmWriteFixture, crmWriteContract } from "./write-contract.js";
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { exampleDeal, exampleLead, exampleOrganization, examplePerson, FAKE_CRM_ALIASES, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
|
|
1
|
+
export { exampleDeal, exampleLead, exampleOrganization, examplePerson, FAKE_CRM_ALIASES, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, SOURCE_FIELD, } from "./source.js";
|
|
2
2
|
export { crmSourceContract } from "./source-contract.js";
|
|
3
3
|
export { CRM_TRAIT_COMBINATIONS, crmSyncContract } from "./sync-contract.js";
|
|
4
4
|
export { crmWriteContract } from "./write-contract.js";
|
|
@@ -51,6 +51,34 @@ export const crmSourceContract = (make) => {
|
|
|
51
51
|
}
|
|
52
52
|
},
|
|
53
53
|
},
|
|
54
|
+
{
|
|
55
|
+
name: "fields: unique codes in each kind, a type, yes-or-no flags, and a list field's options with unique ids",
|
|
56
|
+
async run() {
|
|
57
|
+
const { source } = await make();
|
|
58
|
+
if (!source.fields)
|
|
59
|
+
return;
|
|
60
|
+
for (const kind of source.traits.kinds) {
|
|
61
|
+
const fields = await source.fields(kind);
|
|
62
|
+
assert.ok(Array.isArray(fields), `${kind}: fields() answers a list`);
|
|
63
|
+
const codes = new Set();
|
|
64
|
+
for (const field of fields) {
|
|
65
|
+
assert.ok(typeof field.code === "string" && field.code.trim(), `${kind}: a field has a code`);
|
|
66
|
+
assert.ok(!codes.has(field.code), `${kind} field ${field.code} is listed twice`);
|
|
67
|
+
codes.add(field.code);
|
|
68
|
+
assert.ok(field.label === null || typeof field.label === "string", `${field.code}: a label or null`);
|
|
69
|
+
assert.ok(typeof field.type === "string" && field.type.trim(), `${field.code} has a type`);
|
|
70
|
+
assert.equal(typeof field.multiple, "boolean", `${field.code}: multiple is true or false`);
|
|
71
|
+
assert.equal(typeof field.filterable, "boolean", `${field.code}: filterable is true or false`);
|
|
72
|
+
if (field.options === undefined)
|
|
73
|
+
continue;
|
|
74
|
+
const ids = new Set(field.options.map(({ id }) => id));
|
|
75
|
+
assert.equal(ids.size, field.options.length, `${field.code}: option ids are unique`);
|
|
76
|
+
for (const option of field.options)
|
|
77
|
+
assert.ok(typeof option.id === "string" && typeof option.label === "string", `${field.code}'s options`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
},
|
|
54
82
|
{
|
|
55
83
|
name: "changes: a full list pages to its end, every item stands in a listed stage, fields in the neutral shape",
|
|
56
84
|
async run() {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { CrmItem, CrmKind, CrmOrganization, CrmPerson, CrmPipeline, CrmRecords } from "../../domain/crm.js";
|
|
1
|
+
import type { CrmField, CrmItem, CrmKind, CrmOrganization, CrmPerson, CrmPipeline, CrmRecords } from "../../domain/crm.js";
|
|
2
2
|
import type { DealKind } from "../../domain/deals.js";
|
|
3
3
|
import type { CrmSource, CrmSourceTraits } from "../source.js";
|
|
4
4
|
import { type CrmVerb, type CrmWriteCapabilities, type CrmWriter } from "../writer.js";
|
|
@@ -27,15 +27,20 @@ export interface FakeCrmSource extends CrmSource, CrmWriter {
|
|
|
27
27
|
/** Deletes a row: named in `removed` when `removedInChanges`, otherwise only missing from `ids()`. */
|
|
28
28
|
delete(kind: CrmKind, externalId: string): void;
|
|
29
29
|
setPipelines(kind: DealKind, pipelines: CrmPipeline[]): void;
|
|
30
|
+
/** The kind's own fields from the next `fields()` on: deals have `SOURCE_FIELD` and the rest none until set. */
|
|
31
|
+
setFields(kind: CrmKind, fields: CrmField[]): void;
|
|
30
32
|
/** The kind's cursors older than its latest change are refused, once, with `{ reset: true }`. */
|
|
31
33
|
expireCursors(kind: CrmKind): void;
|
|
32
34
|
/** Lists no id at all, as a CRM does for a sign-in that lost its rights. */
|
|
33
35
|
hideAll(hidden: boolean): void;
|
|
34
36
|
/** The next write is carried out and its answer lost: it throws `outcome_unknown`. */
|
|
35
37
|
loseNextAnswer(): void;
|
|
38
|
+
/** The next write is carried out and its item cannot be read back: it throws with `sent: true`. */
|
|
39
|
+
failNextReadBack(): void;
|
|
36
40
|
/** Requests made, by method. */
|
|
37
41
|
readonly calls: {
|
|
38
42
|
pipelines: number;
|
|
43
|
+
fields: number;
|
|
39
44
|
changes: number;
|
|
40
45
|
ids: number;
|
|
41
46
|
};
|
|
@@ -45,6 +50,8 @@ export interface FakeCrmSource extends CrmSource, CrmWriter {
|
|
|
45
50
|
/** Custom codes the fake refuses: each is a neutral field under another name. */
|
|
46
51
|
export declare const FAKE_CRM_ALIASES: string[];
|
|
47
52
|
export declare const SALES_PIPELINE: CrmPipeline;
|
|
53
|
+
/** A deal's list field, as a CRM lists one: what the fake's deals have. */
|
|
54
|
+
export declare const SOURCE_FIELD: CrmField;
|
|
48
55
|
export declare const LEAD_PIPELINE: CrmPipeline;
|
|
49
56
|
export declare const fakeCrmSource: (options?: FakeCrmSourceOptions) => FakeCrmSource;
|
|
50
57
|
export declare const exampleDeal: (externalId: string, extra?: Partial<CrmItem>) => CrmItem;
|
|
@@ -14,6 +14,18 @@ export const SALES_PIPELINE = {
|
|
|
14
14
|
{ externalId: "lost", name: "Lost", position: 4, outcome: "lost" },
|
|
15
15
|
],
|
|
16
16
|
};
|
|
17
|
+
/** A deal's list field, as a CRM lists one: what the fake's deals have. */
|
|
18
|
+
export const SOURCE_FIELD = {
|
|
19
|
+
code: "UF_SOURCE",
|
|
20
|
+
label: "Source",
|
|
21
|
+
type: "enumeration",
|
|
22
|
+
multiple: false,
|
|
23
|
+
filterable: true,
|
|
24
|
+
options: [
|
|
25
|
+
{ id: "1", label: "Website" },
|
|
26
|
+
{ id: "2", label: "Fair" },
|
|
27
|
+
],
|
|
28
|
+
};
|
|
17
29
|
export const LEAD_PIPELINE = {
|
|
18
30
|
externalId: "lead-1",
|
|
19
31
|
name: "Example leads",
|
|
@@ -32,10 +44,11 @@ export const fakeCrmSource = (options = {}) => {
|
|
|
32
44
|
const size = options.pageSize ?? 2;
|
|
33
45
|
const rows = new Map();
|
|
34
46
|
const pipelines = { deal: [SALES_PIPELINE], lead: [LEAD_PIPELINE] };
|
|
47
|
+
const fields = { deal: [SOURCE_FIELD], lead: [], person: [], organization: [] };
|
|
35
48
|
let version = 0;
|
|
36
49
|
const expired = new Map();
|
|
37
50
|
let hidden = false;
|
|
38
|
-
const calls = { pipelines: 0, changes: 0, ids: 0 };
|
|
51
|
+
const calls = { pipelines: 0, fields: 0, changes: 0, ids: 0 };
|
|
39
52
|
const capabilities = {
|
|
40
53
|
kinds: ["deal", "lead"],
|
|
41
54
|
verbs: CRM_VERBS,
|
|
@@ -46,6 +59,7 @@ export const fakeCrmSource = (options = {}) => {
|
|
|
46
59
|
const answered = new Map();
|
|
47
60
|
let made = 0;
|
|
48
61
|
let lose = false;
|
|
62
|
+
let unread = false;
|
|
49
63
|
const of = (kind) => {
|
|
50
64
|
const held = rows.get(kind) ?? new Map();
|
|
51
65
|
rows.set(kind, held);
|
|
@@ -69,18 +83,32 @@ export const fakeCrmSource = (options = {}) => {
|
|
|
69
83
|
throw new CliError("validation_error", `the CRM has no stage ${stageId} there`);
|
|
70
84
|
return { pipeline: pipeline.externalId, stage: stage.externalId, outcome: stage.outcome };
|
|
71
85
|
};
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
86
|
+
/** A custom value as a CRM answers it: by code, with the field's label and a list option's label. */
|
|
87
|
+
const customValue = (kind, code, value) => {
|
|
88
|
+
const field = fields[kind].find((one) => one.code === code);
|
|
89
|
+
const label = (id) => field?.options?.find((option) => option.id === id)?.label ?? id;
|
|
90
|
+
if (!field?.options)
|
|
91
|
+
return { label: field?.label ?? null, value };
|
|
92
|
+
return {
|
|
93
|
+
label: field.label,
|
|
94
|
+
value: Array.isArray(value) ? value.map(label) : label(value),
|
|
95
|
+
option_id: value,
|
|
78
96
|
};
|
|
97
|
+
};
|
|
98
|
+
const applied = (kind, item, given) => {
|
|
99
|
+
const { custom, owner, pipeline, stage, ...named } = given;
|
|
100
|
+
const metadata = { ...item.metadata };
|
|
101
|
+
for (const [code, value] of Object.entries(custom ?? {}))
|
|
102
|
+
if (value === null)
|
|
103
|
+
delete metadata[code];
|
|
104
|
+
else
|
|
105
|
+
metadata[code] = customValue(kind, code, value);
|
|
79
106
|
return {
|
|
80
107
|
...item,
|
|
81
108
|
...Object.fromEntries(Object.entries(named).filter(([, value]) => value !== undefined)),
|
|
82
109
|
...(pipeline === undefined ? {} : { pipeline }),
|
|
83
110
|
...(stage === undefined ? {} : { stage }),
|
|
111
|
+
...(owner === undefined ? {} : { assignee: owner }),
|
|
84
112
|
metadata,
|
|
85
113
|
updatedAt: null,
|
|
86
114
|
};
|
|
@@ -102,6 +130,12 @@ export const fakeCrmSource = (options = {}) => {
|
|
|
102
130
|
lose = false;
|
|
103
131
|
throw new CliError("outcome_unknown", "the CRM did not answer");
|
|
104
132
|
}
|
|
133
|
+
if (unread) {
|
|
134
|
+
unread = false;
|
|
135
|
+
throw new CliError("provider_error", `the CRM made the ${verb} but its ${kind} could not be read back`, {
|
|
136
|
+
sent: true,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
105
139
|
return done;
|
|
106
140
|
};
|
|
107
141
|
const saved = (kind, item) => {
|
|
@@ -117,13 +151,16 @@ export const fakeCrmSource = (options = {}) => {
|
|
|
117
151
|
loseNextAnswer() {
|
|
118
152
|
lose = true;
|
|
119
153
|
},
|
|
154
|
+
failNextReadBack() {
|
|
155
|
+
unread = true;
|
|
156
|
+
},
|
|
120
157
|
async create(kind, draft, { operationId }) {
|
|
121
158
|
refuseAliases(draft);
|
|
122
159
|
return write("create", kind, null, operationId, () => {
|
|
123
160
|
const { pipeline, stage } = placed(kind, draft.pipeline, draft.stage);
|
|
124
161
|
made += 1;
|
|
125
162
|
const blank = { externalId: `${kind}-new-${made}`, title: draft.title, pipeline, stage };
|
|
126
|
-
return saved(kind, applied(blank, { ...draft, pipeline, stage }));
|
|
163
|
+
return saved(kind, applied(kind, blank, { ...draft, pipeline, stage }));
|
|
127
164
|
});
|
|
128
165
|
},
|
|
129
166
|
async update(kind, externalId, patch, { operationId }) {
|
|
@@ -131,7 +168,7 @@ export const fakeCrmSource = (options = {}) => {
|
|
|
131
168
|
return write("update", kind, externalId, operationId, () => {
|
|
132
169
|
const item = held(kind, externalId);
|
|
133
170
|
const at = patch.stage === undefined ? {} : placed(kind, patch.pipeline ?? item.pipeline, patch.stage);
|
|
134
|
-
return saved(kind, applied(item, { ...patch, ...at }));
|
|
171
|
+
return saved(kind, applied(kind, item, { ...patch, ...at }));
|
|
135
172
|
});
|
|
136
173
|
},
|
|
137
174
|
async move(kind, externalId, stage, { operationId }) {
|
|
@@ -179,6 +216,13 @@ export const fakeCrmSource = (options = {}) => {
|
|
|
179
216
|
setPipelines(kind, list) {
|
|
180
217
|
pipelines[kind] = list;
|
|
181
218
|
},
|
|
219
|
+
setFields(kind, list) {
|
|
220
|
+
fields[kind] = list;
|
|
221
|
+
},
|
|
222
|
+
async fields(kind) {
|
|
223
|
+
calls.fields += 1;
|
|
224
|
+
return fields[kind];
|
|
225
|
+
},
|
|
182
226
|
expireCursors(kind) {
|
|
183
227
|
expired.set(kind, version);
|
|
184
228
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { runBudget } from "../../background/budget.js";
|
|
3
3
|
import { syncCrm } from "../sync.js";
|
|
4
|
-
import { exampleDeal, exampleLead, exampleOrganization, examplePerson, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, } from "./source.js";
|
|
4
|
+
import { exampleDeal, exampleLead, exampleOrganization, examplePerson, fakeCrmSource, LEAD_PIPELINE, SALES_PIPELINE, SOURCE_FIELD, } from "./source.js";
|
|
5
5
|
/** Both ways a CRM may report deletions: the engine must handle each. */
|
|
6
6
|
export const CRM_TRAIT_COMBINATIONS = [
|
|
7
7
|
{ removedInChanges: false },
|
|
@@ -68,7 +68,7 @@ export const crmSyncContract = (make) => CRM_TRAIT_COMBINATIONS.flatMap((traits)
|
|
|
68
68
|
await sync();
|
|
69
69
|
source.delete("deal", "d1");
|
|
70
70
|
now += DAY;
|
|
71
|
-
const stopped = await sync({ budget: runBudget({ requests:
|
|
71
|
+
const stopped = await sync({ budget: runBudget({ requests: 11 }) });
|
|
72
72
|
assert.equal(stopped.state, "partial");
|
|
73
73
|
assert.equal(stopped.hint, "run sync again to continue");
|
|
74
74
|
assert.deepEqual(await deals(), ["d1", "d2", "d3", "d4", "d5"]);
|
|
@@ -90,6 +90,17 @@ export const crmSyncContract = (make) => CRM_TRAIT_COMBINATIONS.flatMap((traits)
|
|
|
90
90
|
await sync({ rescan: true });
|
|
91
91
|
assert.deepEqual(await deals(), []);
|
|
92
92
|
}),
|
|
93
|
+
test("stores each kind's own fields, and marks deleted one the CRM no longer lists", async ({ source, sync, store, account, }) => {
|
|
94
|
+
const note = { code: "UF_NOTE", label: "Note", type: "string", multiple: true, filterable: false };
|
|
95
|
+
source.setFields("person", [note]);
|
|
96
|
+
await sync();
|
|
97
|
+
assert.deepEqual(await store.crm.fields(account, "deal"), [SOURCE_FIELD]);
|
|
98
|
+
assert.deepEqual(await store.crm.fields(account, "person"), [note]);
|
|
99
|
+
source.setFields("deal", []);
|
|
100
|
+
await sync();
|
|
101
|
+
assert.deepEqual(await store.crm.fields(account, "deal"), []);
|
|
102
|
+
assert.deepEqual(await store.crm.fields(account, "person"), [note]);
|
|
103
|
+
}),
|
|
93
104
|
test("a dead cursor starts a full round, marked reset", async ({ source, sync, deals }) => {
|
|
94
105
|
seeded(source);
|
|
95
106
|
await sync();
|
|
@@ -103,7 +114,7 @@ export const crmSyncContract = (make) => CRM_TRAIT_COMBINATIONS.flatMap((traits)
|
|
|
103
114
|
}),
|
|
104
115
|
test("a run its budget stops saves its pages and the next goes on from the page it stopped at", async ({ source, sync, deals, }) => {
|
|
105
116
|
seeded(source, 9);
|
|
106
|
-
const stopped = await sync({ budget: runBudget({ requests:
|
|
117
|
+
const stopped = await sync({ budget: runBudget({ requests: 10 }) });
|
|
107
118
|
assert.equal(stopped.state, "partial");
|
|
108
119
|
assert.equal(stopped.stopped, "requests");
|
|
109
120
|
const kept = (await deals()).length;
|
package/dist/crm/writer.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ConditionalKey } from "../cli/permission-keys.js";
|
|
1
2
|
import type { CrmItem } from "../domain/crm.js";
|
|
2
3
|
import type { DealKind, DealRole } from "../domain/deals.js";
|
|
3
4
|
import type { Level, PermissionKey } from "../sends/permissions.js";
|
|
@@ -35,7 +36,8 @@ export interface CrmFields {
|
|
|
35
36
|
}[];
|
|
36
37
|
/** The CRM user it is assigned to: a user's id, never a contact's. */
|
|
37
38
|
owner?: string | null;
|
|
38
|
-
|
|
39
|
+
/** A list field's option by its id; a multiple field's values as a list; `null` clears it. */
|
|
40
|
+
custom?: Record<string, string | string[] | null>;
|
|
39
41
|
}
|
|
40
42
|
export type CrmDraft = CrmFields & {
|
|
41
43
|
title: string;
|
|
@@ -47,7 +49,8 @@ export interface CrmWriteOptions {
|
|
|
47
49
|
/**
|
|
48
50
|
* Writes to one CRM account. Each answers the whole item as the CRM holds it after the write, every field and not
|
|
49
51
|
* only the changed ones: the store replaces its copy with it. None of them retries: a write whose answer was lost
|
|
50
|
-
* throws `outcome_unknown`.
|
|
52
|
+
* throws `outcome_unknown`. A write the CRM made whose item could then not be read back throws with `sent: true` in
|
|
53
|
+
* its details: it is journaled as sent, and the read's failure is answered on its own.
|
|
51
54
|
*/
|
|
52
55
|
export interface CrmWriter {
|
|
53
56
|
readonly provider: string;
|
|
@@ -69,6 +72,12 @@ export interface CrmWriter {
|
|
|
69
72
|
export declare const CRM_ASSIGN_OTHERS_KEY: PermissionKey;
|
|
70
73
|
/** The keys the CRM writes check beyond their command paths, for a CLI's `commandPermissions({ extra })`. */
|
|
71
74
|
export declare const CRM_WRITE_KEYS: readonly PermissionKey[];
|
|
75
|
+
/**
|
|
76
|
+
* Every key a CRM write command may check beyond its own path, for `commandPermissions({ alsoChecks })`, so its
|
|
77
|
+
* `--dry-run` names each: a stage implies the move key and a won or lost one the close key, an owner other than the
|
|
78
|
+
* sign-in's `crm.assign-others`, and `also` the CLI's own (the same `CrmWrites.alsoChecks` the real run checks).
|
|
79
|
+
*/
|
|
80
|
+
export declare const crmAlsoChecks: (also?: (kind: DealKind, verb: CrmVerb) => readonly PermissionKey[]) => (path: readonly string[]) => readonly (PermissionKey | ConditionalKey)[];
|
|
72
81
|
/**
|
|
73
82
|
* Built in, and only ever tightening (WC-PERM-7): an ordinary change goes ahead, a deletion asks, and a record is not
|
|
74
83
|
* given to somebody else until the owner allows it.
|