@siloy/hubspot-provisioning 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +203 -2
- package/dist/engine/src/blueprint/associations.d.ts +17 -0
- package/dist/engine/src/blueprint/associations.js +22 -0
- package/dist/engine/src/blueprint/field-types.d.ts +14 -0
- package/dist/engine/src/blueprint/field-types.js +22 -0
- package/dist/engine/src/blueprint/formula.d.ts +25 -0
- package/dist/engine/src/blueprint/formula.js +106 -0
- package/dist/engine/src/blueprint/groups.d.ts +16 -0
- package/dist/engine/src/blueprint/groups.js +30 -0
- package/dist/engine/src/blueprint/index.d.ts +10 -0
- package/dist/engine/src/blueprint/index.js +11 -0
- package/dist/engine/src/blueprint/mapping-targets.d.ts +10 -0
- package/dist/engine/src/blueprint/mapping-targets.js +5 -0
- package/dist/engine/src/blueprint/object-names.d.ts +11 -0
- package/dist/engine/src/blueprint/object-names.js +22 -0
- package/dist/engine/src/blueprint/pipelines.d.ts +40 -0
- package/dist/engine/src/blueprint/pipelines.js +99 -0
- package/dist/engine/src/blueprint/schema.d.ts +422 -0
- package/dist/engine/src/blueprint/schema.js +163 -0
- package/dist/engine/src/blueprint/serialize.d.ts +22 -0
- package/dist/engine/src/blueprint/serialize.js +70 -0
- package/dist/engine/src/blueprint/validate.d.ts +35 -0
- package/dist/engine/src/blueprint/validate.js +385 -0
- package/dist/engine/src/errors.d.ts +40 -0
- package/dist/engine/src/errors.js +73 -0
- package/dist/provisioning/src/api/client.d.ts +36 -0
- package/dist/provisioning/src/api/client.js +186 -0
- package/dist/provisioning/src/api/commit.d.ts +13 -0
- package/dist/provisioning/src/api/commit.js +12 -0
- package/dist/provisioning/src/api/types.d.ts +98 -0
- package/dist/provisioning/src/api/types.js +5 -0
- package/dist/provisioning/src/cli/commands.d.ts +16 -0
- package/dist/provisioning/src/cli/commands.js +373 -0
- package/dist/provisioning/src/cli/hints.d.ts +4 -0
- package/dist/provisioning/src/cli/hints.js +21 -0
- package/dist/provisioning/src/cli/main.d.ts +2 -0
- package/dist/provisioning/src/cli/main.js +24 -0
- package/dist/provisioning/src/cli/render.d.ts +25 -0
- package/dist/provisioning/src/cli/render.js +149 -0
- package/dist/provisioning/src/index.d.ts +6 -0
- package/dist/provisioning/src/index.js +5 -0
- package/dist/provisioning/src/limits.d.ts +2 -0
- package/dist/provisioning/src/limits.js +2 -0
- package/dist/provisioning/src/targets.d.ts +16 -0
- package/dist/provisioning/src/targets.js +27 -0
- package/dist/provisioning/src/validate.d.ts +10 -0
- package/dist/provisioning/src/validate.js +15 -0
- package/dist/provisioning/src/version.d.ts +2 -0
- package/dist/provisioning/src/version.js +17 -0
- package/package.json +38 -4
- package/schema/blueprint.schema.json +420 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type Blueprint, type DraftBlueprint } from './schema.ts';
|
|
2
|
+
import { type BlueprintIssue } from './validate.ts';
|
|
3
|
+
/** Parse a blueprint from YAML or JSON text (JSON is valid YAML). */
|
|
4
|
+
export declare function parseBlueprint(text: string): Blueprint;
|
|
5
|
+
export type DraftYaml = {
|
|
6
|
+
ok: true;
|
|
7
|
+
content: DraftBlueprint;
|
|
8
|
+
issues: BlueprintIssue[];
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
error: string;
|
|
12
|
+
line: number | null;
|
|
13
|
+
issues: BlueprintIssue[];
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* The editor's YAML tab (spec #27 §7.6): parse the text and fit it to the draft schema. `ok` means the
|
|
17
|
+
* forms can render it; issues are listed either way. Never throws.
|
|
18
|
+
*/
|
|
19
|
+
export declare function readDraftYaml(text: string): DraftYaml;
|
|
20
|
+
/** YAML for humans. Values equal to their schema default are omitted; parsing restores them. */
|
|
21
|
+
export declare function serializeBlueprint(bp: Blueprint): string;
|
|
22
|
+
export declare function blueprintJsonSchema(): Record<string, unknown>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { parse, stringify, YAMLParseError } from 'yaml';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { BlueprintError } from '../errors.js';
|
|
4
|
+
import { BlueprintSchema, DraftBlueprintSchema } from './schema.js';
|
|
5
|
+
import { blueprintIssues, validateBlueprint } from './validate.js';
|
|
6
|
+
/** Parse a blueprint from YAML or JSON text (JSON is valid YAML). */
|
|
7
|
+
export function parseBlueprint(text) {
|
|
8
|
+
let raw;
|
|
9
|
+
try {
|
|
10
|
+
raw = parse(text.replace(/^/, ''));
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
throw new BlueprintError([`not valid YAML or JSON: ${error.message}`]);
|
|
14
|
+
}
|
|
15
|
+
return validateBlueprint(raw);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The editor's YAML tab (spec #27 §7.6): parse the text and fit it to the draft schema. `ok` means the
|
|
19
|
+
* forms can render it; issues are listed either way. Never throws.
|
|
20
|
+
*/
|
|
21
|
+
export function readDraftYaml(text) {
|
|
22
|
+
let raw;
|
|
23
|
+
try {
|
|
24
|
+
raw = parse(text.replace(/^\uFEFF/, ''));
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
const line = error instanceof YAMLParseError ? (error.linePos?.[0]?.line ?? null) : null;
|
|
28
|
+
return { ok: false, error: error.message, line, issues: [] };
|
|
29
|
+
}
|
|
30
|
+
const issues = blueprintIssues(raw);
|
|
31
|
+
const draft = DraftBlueprintSchema.safeParse(raw);
|
|
32
|
+
if (draft.success)
|
|
33
|
+
return { ok: true, content: draft.data, issues };
|
|
34
|
+
return { ok: false, error: 'This YAML does not fit the blueprint structure yet; the issues below say where.', line: null, issues };
|
|
35
|
+
}
|
|
36
|
+
/** YAML for humans. Values equal to their schema default are omitted; parsing restores them. */
|
|
37
|
+
export function serializeBlueprint(bp) {
|
|
38
|
+
return stringify(compact(bp), { lineWidth: 0 });
|
|
39
|
+
}
|
|
40
|
+
export function blueprintJsonSchema() {
|
|
41
|
+
return z.toJSONSchema(BlueprintSchema, { io: 'input' });
|
|
42
|
+
}
|
|
43
|
+
function compact(bp) {
|
|
44
|
+
return {
|
|
45
|
+
format: bp.format,
|
|
46
|
+
...(bp.source ? { source: bp.source } : {}),
|
|
47
|
+
objects: bp.objects.map((object) => ({
|
|
48
|
+
objectType: object.objectType,
|
|
49
|
+
...(object.schema ? { schema: object.schema } : {}),
|
|
50
|
+
...(object.labels ? { labels: object.labels } : {}),
|
|
51
|
+
propertyGroups: object.propertyGroups,
|
|
52
|
+
properties: object.properties.map(compactProperty),
|
|
53
|
+
...(object.pipelines.length > 0
|
|
54
|
+
? { pipelines: object.pipelines.map(({ origin, ...pipeline }) => (origin === 'custom' ? pipeline : { origin, ...pipeline })) }
|
|
55
|
+
: {}),
|
|
56
|
+
})),
|
|
57
|
+
associations: bp.associations.map(({ labels, ...pair }) => (labels.length > 0 ? { ...pair, labels } : pair)),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function compactProperty(property) {
|
|
61
|
+
const { origin, hasUniqueValue, options, ...rest } = property;
|
|
62
|
+
return {
|
|
63
|
+
...rest,
|
|
64
|
+
...(origin === 'custom' ? {} : { origin }),
|
|
65
|
+
...(hasUniqueValue ? { hasUniqueValue } : {}),
|
|
66
|
+
...(options.length > 0
|
|
67
|
+
? { options: options.map(({ hidden, ...option }) => (hidden ? { ...option, hidden } : option)) }
|
|
68
|
+
: {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type Blueprint, type BlueprintObject, type Property } from './schema.ts';
|
|
2
|
+
export type IssuePath = Array<string | number>;
|
|
3
|
+
/**
|
|
4
|
+
* One problem in a blueprint. `at: message` is the text BlueprintError has always shown; `path` finds the
|
|
5
|
+
* field by index, so an editor can place it even when two items share a key.
|
|
6
|
+
*/
|
|
7
|
+
export interface BlueprintIssue {
|
|
8
|
+
path: IssuePath;
|
|
9
|
+
at: string;
|
|
10
|
+
message: string;
|
|
11
|
+
}
|
|
12
|
+
export declare function issueText(issue: BlueprintIssue): string;
|
|
13
|
+
export interface ValidateOptions {
|
|
14
|
+
/**
|
|
15
|
+
* Content saved before #107 is read back: allow what the old rules allowed and HubSpot does not, an uppercase
|
|
16
|
+
* group name, so such a version still opens. New content and plans are checked without it.
|
|
17
|
+
*/
|
|
18
|
+
stored?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Structural (Zod) and semantic validation. Throws BlueprintError listing every issue found. */
|
|
21
|
+
export declare function validateBlueprint(input: unknown, options?: ValidateOptions): Blueprint;
|
|
22
|
+
/**
|
|
23
|
+
* Every issue, without throwing. Unlike validateBlueprint it also runs the semantic checks when only
|
|
24
|
+
* string rules fail (an empty label, an odd key), so an editor shows them all at once (spec #27 §4.2).
|
|
25
|
+
*/
|
|
26
|
+
export declare function blueprintIssues(input: unknown): BlueprintIssue[];
|
|
27
|
+
/**
|
|
28
|
+
* Calculated-property rules for one property (spec addendum §2). Also used by ingest to drop what a
|
|
29
|
+
* blueprint cannot hold. Formula references are checked at plan time, against the target too.
|
|
30
|
+
*/
|
|
31
|
+
export declare function calculationIssues(at: string, property: Property): string[];
|
|
32
|
+
/** Semantic option rules for one property. Also used by ingest to drop what a blueprint cannot hold. */
|
|
33
|
+
export declare function optionIssues(at: string, property: Property): string[];
|
|
34
|
+
/** Pipeline and stage rules for one object (spec §2, quirks P1-P2). */
|
|
35
|
+
export declare function pipelineIssues(at: string, object: BlueprintObject): string[];
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { BlueprintError } from '../errors.js';
|
|
2
|
+
import { pairId } from './associations.js';
|
|
3
|
+
import { formulaCycles } from './formula.js';
|
|
4
|
+
import { CUSTOM_OBJECT_NAME_MAX } from './groups.js';
|
|
5
|
+
import { canHavePipelines, isClosedStage, metadataKeyFor } from './pipelines.js';
|
|
6
|
+
import { BlueprintSchema, DraftBlueprintSchema, EQUATION_FIELD_TYPE, EQUATION_TYPES, GROUP_NAME_LOWERCASE, isEquation, isLowercaseGroupName, isStandardObject } from './schema.js';
|
|
7
|
+
const MAX_LABELS_PER_PAIR = 10;
|
|
8
|
+
export function issueText(issue) {
|
|
9
|
+
return `${issue.at}: ${issue.message}`;
|
|
10
|
+
}
|
|
11
|
+
/** Structural (Zod) and semantic validation. Throws BlueprintError listing every issue found. */
|
|
12
|
+
export function validateBlueprint(input, options = {}) {
|
|
13
|
+
const { issues, data } = collect(input, false, options);
|
|
14
|
+
if (issues.length > 0 || data === undefined)
|
|
15
|
+
throw new BlueprintError(issues.map(issueText));
|
|
16
|
+
return data;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Every issue, without throwing. Unlike validateBlueprint it also runs the semantic checks when only
|
|
20
|
+
* string rules fail (an empty label, an odd key), so an editor shows them all at once (spec #27 §4.2).
|
|
21
|
+
*/
|
|
22
|
+
export function blueprintIssues(input) {
|
|
23
|
+
return collect(input, true, {}).issues;
|
|
24
|
+
}
|
|
25
|
+
function collect(input, draftSemantics, options) {
|
|
26
|
+
const issues = preParseIssues(input);
|
|
27
|
+
const result = BlueprintSchema.safeParse(input);
|
|
28
|
+
if (!result.success) {
|
|
29
|
+
for (const zodIssue of result.error.issues) {
|
|
30
|
+
// A lone dataSensitivity key is already explained by preParseIssues; skip Zod's duplicate.
|
|
31
|
+
if (zodIssue.code === 'unrecognized_keys' && sameKeys(zodIssue.keys, ['dataSensitivity']))
|
|
32
|
+
continue;
|
|
33
|
+
// An old flat association too; its unknown keys are the label preParseIssues points at.
|
|
34
|
+
if (zodIssue.code === 'unrecognized_keys' && zodIssue.path.length === 2 && zodIssue.path[0] === 'associations'
|
|
35
|
+
&& isFlatAssociation(input.associations[zodIssue.path[1]]))
|
|
36
|
+
continue;
|
|
37
|
+
const path = zodIssue.path.filter((p) => typeof p !== 'symbol');
|
|
38
|
+
issues.push({ path, at: path.map(String).join('.') || '(root)', message: zodIssue.message });
|
|
39
|
+
}
|
|
40
|
+
if (draftSemantics) {
|
|
41
|
+
const draft = DraftBlueprintSchema.safeParse(input);
|
|
42
|
+
if (draft.success)
|
|
43
|
+
issues.push(...semanticIssues(draft.data, options));
|
|
44
|
+
}
|
|
45
|
+
return { issues };
|
|
46
|
+
}
|
|
47
|
+
issues.push(...semanticIssues(result.data, options));
|
|
48
|
+
return { issues, data: result.data };
|
|
49
|
+
}
|
|
50
|
+
function sameKeys(actual, expected) {
|
|
51
|
+
return actual.length === expected.length && expected.every((key, i) => actual[i] === key);
|
|
52
|
+
}
|
|
53
|
+
function collector() {
|
|
54
|
+
const issues = [];
|
|
55
|
+
return { issues, add: (path, at, message) => { issues.push({ path, at, message }); } };
|
|
56
|
+
}
|
|
57
|
+
/** The format before #30: a label with `key` or `label` directly on the association entry. */
|
|
58
|
+
function isFlatAssociation(association) {
|
|
59
|
+
return association !== null && typeof association === 'object' && ('key' in association || 'label' in association);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* dataSensitivity gets its own message (instead of Zod's "unrecognized key") because the reason
|
|
63
|
+
* matters: the engine reads properties as non_sensitive, so a sensitive one would be invisible to
|
|
64
|
+
* the next run and re-created forever. isClosed too: users expect to set it, but HubSpot derives it.
|
|
65
|
+
* An association in the old flat shape gets a hint where its label now goes.
|
|
66
|
+
* A pure scan: the input is never modified.
|
|
67
|
+
*/
|
|
68
|
+
function preParseIssues(input) {
|
|
69
|
+
const { issues, add } = collector();
|
|
70
|
+
const associations = input?.associations;
|
|
71
|
+
if (Array.isArray(associations)) {
|
|
72
|
+
associations.forEach((association, a) => {
|
|
73
|
+
if (isFlatAssociation(association)) {
|
|
74
|
+
add(['associations', a], `associations.${a}`, 'associations are now grouped per object pair: move this label under `labels` of its pair');
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
const objects = input?.objects;
|
|
79
|
+
if (!Array.isArray(objects))
|
|
80
|
+
return issues;
|
|
81
|
+
objects.forEach((object, i) => {
|
|
82
|
+
const pipelines = object?.pipelines;
|
|
83
|
+
if (Array.isArray(pipelines)) {
|
|
84
|
+
pipelines.forEach((pipeline, j) => {
|
|
85
|
+
const stages = pipeline?.stages;
|
|
86
|
+
if (!Array.isArray(stages))
|
|
87
|
+
return;
|
|
88
|
+
stages.forEach((stage, k) => {
|
|
89
|
+
const metadata = stage?.metadata;
|
|
90
|
+
if (metadata !== null && typeof metadata === 'object' && 'isClosed' in metadata) {
|
|
91
|
+
add(['objects', i, 'pipelines', j, 'stages', k, 'metadata', 'isClosed'], `objects.${i}.pipelines.${j}.stages.${k}.metadata.isClosed`, 'isClosed is derived by HubSpot from probability, ticketState or state; remove it');
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const properties = object?.properties;
|
|
97
|
+
if (!Array.isArray(properties))
|
|
98
|
+
return;
|
|
99
|
+
properties.forEach((property, j) => {
|
|
100
|
+
if (property !== null && typeof property === 'object' && 'dataSensitivity' in property) {
|
|
101
|
+
add(['objects', i, 'properties', j, 'dataSensitivity'], `objects.${i}.properties.${j}.dataSensitivity`, 'not supported — properties are read as non_sensitive, so a sensitive property would be invisible to the next run and re-created forever');
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
return issues;
|
|
106
|
+
}
|
|
107
|
+
function semanticIssues(bp, options) {
|
|
108
|
+
const { issues, add } = collector();
|
|
109
|
+
const objectNames = new Set();
|
|
110
|
+
bp.objects.forEach((object, i) => {
|
|
111
|
+
const at = `objects.${object.objectType}`;
|
|
112
|
+
const path = ['objects', i];
|
|
113
|
+
if (objectNames.has(object.objectType))
|
|
114
|
+
add(path, at, 'declared more than once');
|
|
115
|
+
objectNames.add(object.objectType);
|
|
116
|
+
const standard = isStandardObject(object.objectType);
|
|
117
|
+
if (standard && object.schema)
|
|
118
|
+
add([...path, 'schema'], `${at}.schema`, 'standard objects have no schema block');
|
|
119
|
+
if (!standard && !object.schema) {
|
|
120
|
+
add(path, at, `'${object.objectType}' is not a standard object, so it needs a schema block to be created as a custom object`);
|
|
121
|
+
}
|
|
122
|
+
if (!standard && object.objectType.length > CUSTOM_OBJECT_NAME_MAX) {
|
|
123
|
+
add([...path, 'objectType'], `${at}.objectType`, `at most ${CUSTOM_OBJECT_NAME_MAX} characters; HubSpot's limit for a custom object's name`);
|
|
124
|
+
}
|
|
125
|
+
if (!standard && object.labels)
|
|
126
|
+
add([...path, 'labels'], `${at}.labels`, 'names of a custom object belong in schema.labels');
|
|
127
|
+
if (standard && object.labels) {
|
|
128
|
+
for (const which of ['singular', 'plural']) {
|
|
129
|
+
if (object.labels[which].length > CUSTOM_OBJECT_NAME_MAX) {
|
|
130
|
+
add([...path, 'labels', which], `${at}.labels.${which}`, `at most ${CUSTOM_OBJECT_NAME_MAX} characters; HubSpot's limit for an object's names`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (object.schema) {
|
|
135
|
+
for (const which of ['singular', 'plural']) {
|
|
136
|
+
if (object.schema.labels[which].length > CUSTOM_OBJECT_NAME_MAX) {
|
|
137
|
+
add([...path, 'schema', 'labels', which], `${at}.schema.labels.${which}`, `at most ${CUSTOM_OBJECT_NAME_MAX} characters; HubSpot's limit for a custom object's labels`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const groups = new Set();
|
|
142
|
+
object.propertyGroups.forEach((group, g) => {
|
|
143
|
+
if (groups.has(group.name))
|
|
144
|
+
add([...path, 'propertyGroups', g], `${at}.propertyGroups.${group.name}`, 'declared more than once');
|
|
145
|
+
groups.add(group.name);
|
|
146
|
+
if (!options.stored && !isLowercaseGroupName(group.name)) {
|
|
147
|
+
add([...path, 'propertyGroups', g, 'name'], `${at}.propertyGroups.${group.name}`, GROUP_NAME_LOWERCASE);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
const properties = new Set();
|
|
151
|
+
const equations = new Set();
|
|
152
|
+
object.properties.forEach((property, j) => {
|
|
153
|
+
const pat = `${at}.properties.${property.name}`;
|
|
154
|
+
const ppath = [...path, 'properties', j];
|
|
155
|
+
if (properties.has(property.name))
|
|
156
|
+
add(ppath, pat, 'declared more than once');
|
|
157
|
+
properties.add(property.name);
|
|
158
|
+
if (!groups.has(property.groupName)) {
|
|
159
|
+
add([...ppath, 'groupName'], `${pat}.groupName`, `group '${property.groupName}' is not declared in ${at}.propertyGroups`);
|
|
160
|
+
}
|
|
161
|
+
if (property.origin === 'hubspot' && !standard)
|
|
162
|
+
add([...ppath, 'origin'], `${pat}.origin`, `'hubspot' is only valid on standard objects`);
|
|
163
|
+
issues.push(...optionIssueList(pat, ppath, property));
|
|
164
|
+
issues.push(...calculationIssueList(pat, ppath, property));
|
|
165
|
+
if (isEquation(property))
|
|
166
|
+
equations.add(property.name);
|
|
167
|
+
});
|
|
168
|
+
const cycles = formulaCycles(object.properties);
|
|
169
|
+
object.properties.forEach((property, j) => {
|
|
170
|
+
const cycle = cycles.get(property.name);
|
|
171
|
+
if (!cycle)
|
|
172
|
+
return;
|
|
173
|
+
add(['objects', i, 'properties', j, 'calculationFormula'], `${at}.properties.${property.name}.calculationFormula`, cycle.length === 1
|
|
174
|
+
? 'the formula refers to itself'
|
|
175
|
+
: `the formulas of ${cycle.join(', ')} refer to each other in a cycle, so none of them can be created first; change one of them`);
|
|
176
|
+
});
|
|
177
|
+
issues.push(...pipelineIssueList(at, path, object));
|
|
178
|
+
if (object.schema) {
|
|
179
|
+
const s = object.schema;
|
|
180
|
+
const references = [
|
|
181
|
+
['primaryDisplayProperty', [s.primaryDisplayProperty], false],
|
|
182
|
+
['secondaryDisplayProperties', s.secondaryDisplayProperties, true],
|
|
183
|
+
['requiredProperties', s.requiredProperties, true],
|
|
184
|
+
['searchableProperties', s.searchableProperties, true],
|
|
185
|
+
];
|
|
186
|
+
for (const [field, names, indexed] of references) {
|
|
187
|
+
names.forEach((name, n) => {
|
|
188
|
+
const fpath = indexed ? [...path, 'schema', field, n] : [...path, 'schema', field];
|
|
189
|
+
if (!properties.has(name))
|
|
190
|
+
add(fpath, `${at}.schema.${field}`, `'${name}' is not a property of ${object.objectType}`);
|
|
191
|
+
else if (equations.has(name)) {
|
|
192
|
+
add(fpath, `${at}.schema.${field}`, `'${name}' is an equation; equations are created after the object, so they cannot be part of its schema definition`);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
const pairs = new Set();
|
|
199
|
+
const keys = new Set();
|
|
200
|
+
bp.associations.forEach((association, a) => {
|
|
201
|
+
const { from, to, labels } = association;
|
|
202
|
+
const pair = pairId(from, to);
|
|
203
|
+
const at = `associations.${pair}`;
|
|
204
|
+
const path = ['associations', a];
|
|
205
|
+
if (pairs.has(pair))
|
|
206
|
+
add(path, at, `pair ${pair} declared more than once`);
|
|
207
|
+
pairs.add(pair);
|
|
208
|
+
for (const end of ['from', 'to']) {
|
|
209
|
+
const name = association[end];
|
|
210
|
+
if (!objectNames.has(name) && !isStandardObject(name))
|
|
211
|
+
add([...path, end], `${at}.${end}`, `'${name}' is neither a standard object nor declared in objects`);
|
|
212
|
+
}
|
|
213
|
+
if (labels.length > MAX_LABELS_PER_PAIR) {
|
|
214
|
+
add(path, at, `more than ${MAX_LABELS_PER_PAIR} labels on ${pair}; HubSpot allows at most ${MAX_LABELS_PER_PAIR} per object pair`);
|
|
215
|
+
}
|
|
216
|
+
const self = from === to;
|
|
217
|
+
// HubSpot has one unlabelled typeId on a self-pair, so one direction to limit.
|
|
218
|
+
if (self && association.inverseLimit !== undefined)
|
|
219
|
+
add([...path, 'inverseLimit'], `${at}.inverseLimit`, 'a pair of an object with itself has one limit; use limit');
|
|
220
|
+
if (self && !isStandardObject(from) && labels.length === 0)
|
|
221
|
+
add(path, at, 'a custom object can only be associated with itself through a label; add one');
|
|
222
|
+
labels.forEach((label, l) => {
|
|
223
|
+
const lat = `${at}.labels.${label.key}`;
|
|
224
|
+
const lpath = [...path, 'labels', l];
|
|
225
|
+
if (keys.has(label.key))
|
|
226
|
+
add([...lpath, 'key'], lat, 'key declared more than once');
|
|
227
|
+
keys.add(label.key);
|
|
228
|
+
// A label with one name on a self-pair has a single typeId (L4).
|
|
229
|
+
const oneTypeId = self && label.inverseLabel === undefined;
|
|
230
|
+
if (oneTypeId && label.inverseLimit !== undefined) {
|
|
231
|
+
add([...lpath, 'inverseLimit'], `${lat}.inverseLimit`, 'a label with one name on a pair of an object with itself has one limit; use limit');
|
|
232
|
+
}
|
|
233
|
+
// L4: a label may not exceed the unlabelled limit in its direction; a self-pair's one limit caps both.
|
|
234
|
+
const caps = { limit: association.limit, inverseLimit: self ? association.limit : association.inverseLimit };
|
|
235
|
+
for (const field of oneTypeId ? ['limit'] : ['limit', 'inverseLimit']) {
|
|
236
|
+
const value = label[field];
|
|
237
|
+
const cap = caps[field];
|
|
238
|
+
if (value !== undefined && cap !== undefined && value > cap) {
|
|
239
|
+
add([...lpath, field], `${lat}.${field}`, `higher than the pair's limit (${cap}); HubSpot caps a label at the unlabelled limit in the same direction`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
return issues;
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* Calculated-property rules for one property (spec addendum §2). Also used by ingest to drop what a
|
|
248
|
+
* blueprint cannot hold. Formula references are checked at plan time, against the target too.
|
|
249
|
+
*/
|
|
250
|
+
export function calculationIssues(at, property) {
|
|
251
|
+
return calculationIssueList(at, [], property).map(issueText);
|
|
252
|
+
}
|
|
253
|
+
function calculationIssueList(at, path, property) {
|
|
254
|
+
const { issues, add } = collector();
|
|
255
|
+
const calculated = property.fieldType.startsWith('calculation_');
|
|
256
|
+
if (calculated && !isEquation(property)) {
|
|
257
|
+
add([...path, 'fieldType'], `${at}.fieldType`, `'${property.fieldType}' properties cannot be created through the HubSpot API; only ${EQUATION_FIELD_TYPE} is supported`);
|
|
258
|
+
}
|
|
259
|
+
if (calculated && property.origin === 'hubspot') {
|
|
260
|
+
add([...path, 'origin'], `${at}.origin`, 'HubSpot-defined calculated properties are managed by HubSpot; only custom equations can be copied');
|
|
261
|
+
}
|
|
262
|
+
if (isEquation(property)) {
|
|
263
|
+
if (property.calculationFormula === undefined)
|
|
264
|
+
add([...path, 'calculationFormula'], `${at}.calculationFormula`, `required for fieldType ${EQUATION_FIELD_TYPE}`);
|
|
265
|
+
if (!EQUATION_TYPES.includes(property.type)) {
|
|
266
|
+
add([...path, 'type'], `${at}.type`, 'equations must be number, bool, date or datetime; HubSpot cannot create text or dropdown equations through the API');
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
else if (property.calculationFormula !== undefined) {
|
|
270
|
+
add([...path, 'calculationFormula'], `${at}.calculationFormula`, `only ${EQUATION_FIELD_TYPE} properties have a formula`);
|
|
271
|
+
}
|
|
272
|
+
if (property.type !== 'number') {
|
|
273
|
+
if (property.numberDisplayHint !== undefined)
|
|
274
|
+
add([...path, 'numberDisplayHint'], `${at}.numberDisplayHint`, 'only number properties have a display hint');
|
|
275
|
+
if (property.showCurrencySymbol !== undefined)
|
|
276
|
+
add([...path, 'showCurrencySymbol'], `${at}.showCurrencySymbol`, 'only number properties show a currency symbol');
|
|
277
|
+
}
|
|
278
|
+
return issues;
|
|
279
|
+
}
|
|
280
|
+
/** Semantic option rules for one property. Also used by ingest to drop what a blueprint cannot hold. */
|
|
281
|
+
export function optionIssues(at, property) {
|
|
282
|
+
return optionIssueList(at, [], property).map(issueText);
|
|
283
|
+
}
|
|
284
|
+
function optionIssueList(at, path, property) {
|
|
285
|
+
const { issues, add } = collector();
|
|
286
|
+
if (property.type !== 'enumeration' && property.options.length > 0) {
|
|
287
|
+
add([...path, 'options'], `${at}.options`, property.type === 'bool'
|
|
288
|
+
? 'boolean properties get their fixed true/false options automatically; remove options'
|
|
289
|
+
: 'only enumeration properties have options');
|
|
290
|
+
}
|
|
291
|
+
if (property.externalOptions && property.options.length > 0) {
|
|
292
|
+
add([...path, 'options'], `${at}.options`, `externalOptions properties take their options from ${property.referencedObjectType ?? 'another object'}; remove options`);
|
|
293
|
+
}
|
|
294
|
+
const values = new Set();
|
|
295
|
+
const orders = new Set();
|
|
296
|
+
const labels = new Set();
|
|
297
|
+
property.options.forEach((option, k) => {
|
|
298
|
+
const opath = [...path, 'options', k];
|
|
299
|
+
if (labels.has(option.label))
|
|
300
|
+
add([...opath, 'label'], `${at}.options.${option.value}.label`, `label "${option.label}" is already used by another option`);
|
|
301
|
+
labels.add(option.label);
|
|
302
|
+
if (values.has(option.value))
|
|
303
|
+
add([...opath, 'value'], `${at}.options.${option.value}`, 'value declared more than once');
|
|
304
|
+
values.add(option.value);
|
|
305
|
+
if (option.displayOrder !== undefined) {
|
|
306
|
+
if (orders.has(option.displayOrder)) {
|
|
307
|
+
add([...opath, 'displayOrder'], `${at}.options.${option.value}.displayOrder`, `${option.displayOrder} is already used by another option`);
|
|
308
|
+
}
|
|
309
|
+
orders.add(option.displayOrder);
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
return issues;
|
|
313
|
+
}
|
|
314
|
+
const PROBABILITY = /^\d+(\.\d+)?$/;
|
|
315
|
+
/** Pipeline and stage rules for one object (spec §2, quirks P1-P2). */
|
|
316
|
+
export function pipelineIssues(at, object) {
|
|
317
|
+
return pipelineIssueList(at, [], object).map(issueText);
|
|
318
|
+
}
|
|
319
|
+
function pipelineIssueList(at, path, object) {
|
|
320
|
+
const { issues, add } = collector();
|
|
321
|
+
if (object.pipelines.length === 0)
|
|
322
|
+
return issues;
|
|
323
|
+
if (!canHavePipelines(object.objectType)) {
|
|
324
|
+
add([...path, 'pipelines'], `${at}.pipelines`, 'pipelines are only supported on deals, tickets or custom objects');
|
|
325
|
+
return issues;
|
|
326
|
+
}
|
|
327
|
+
const metadataKey = metadataKeyFor(object.objectType);
|
|
328
|
+
const keys = new Set();
|
|
329
|
+
const labels = new Set();
|
|
330
|
+
let hubspotOrigin = 0;
|
|
331
|
+
object.pipelines.forEach((pipeline, p) => {
|
|
332
|
+
const pat = `${at}.pipelines.${pipeline.key}`;
|
|
333
|
+
const ppath = [...path, 'pipelines', p];
|
|
334
|
+
if (keys.has(pipeline.key))
|
|
335
|
+
add([...ppath, 'key'], pat, `duplicate pipeline key '${pipeline.key}'`);
|
|
336
|
+
keys.add(pipeline.key);
|
|
337
|
+
if (labels.has(pipeline.label))
|
|
338
|
+
add([...ppath, 'label'], `${pat}.label`, `label "${pipeline.label}" is already used by another pipeline`);
|
|
339
|
+
labels.add(pipeline.label);
|
|
340
|
+
if (pipeline.origin === 'hubspot') {
|
|
341
|
+
if (object.objectType !== 'deals' && object.objectType !== 'tickets') {
|
|
342
|
+
add([...ppath, 'origin'], `${pat}.origin`, "'hubspot' is only valid for the default pipeline of deals and tickets (only deals and tickets ship one)");
|
|
343
|
+
}
|
|
344
|
+
else if (++hubspotOrigin === 2) {
|
|
345
|
+
add([...ppath, 'origin'], `${pat}.origin`, "at most one pipeline per object may have origin 'hubspot' (the default pipeline)");
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const stageKeys = new Set();
|
|
349
|
+
const stageLabels = new Set();
|
|
350
|
+
pipeline.stages.forEach((stage, s) => {
|
|
351
|
+
const sat = `${pat}.stages.${stage.key}`;
|
|
352
|
+
const spath = [...ppath, 'stages', s];
|
|
353
|
+
if (stageKeys.has(stage.key))
|
|
354
|
+
add([...spath, 'key'], sat, `duplicate stage key '${stage.key}'`);
|
|
355
|
+
stageKeys.add(stage.key);
|
|
356
|
+
if (stageLabels.has(stage.label))
|
|
357
|
+
add([...spath, 'label'], `${sat}.label`, `label "${stage.label}" is already used by another stage`);
|
|
358
|
+
stageLabels.add(stage.label);
|
|
359
|
+
issues.push(...stageMetadataIssues(`${sat}.metadata`, [...spath, 'metadata'], metadataKey, stage.metadata));
|
|
360
|
+
});
|
|
361
|
+
if (object.objectType === 'tickets' && !pipeline.stages.some((stage) => isClosedStage('tickets', stage.metadata))) {
|
|
362
|
+
add([...ppath, 'stages'], `${pat}.stages`, 'a ticket pipeline needs at least one CLOSED stage');
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
return issues;
|
|
366
|
+
}
|
|
367
|
+
function stageMetadataIssues(at, path, expected, metadata) {
|
|
368
|
+
const { issues, add } = collector();
|
|
369
|
+
const value = metadata[expected];
|
|
370
|
+
if (value === undefined)
|
|
371
|
+
add([...path, expected], `${at}.${expected}`, 'required for stages of this object type');
|
|
372
|
+
for (const key of ['probability', 'ticketState', 'state']) {
|
|
373
|
+
if (key !== expected && metadata[key] !== undefined)
|
|
374
|
+
add([...path, key], `${at}.${key}`, `not valid here; stages of this object type use ${expected}`);
|
|
375
|
+
}
|
|
376
|
+
if (value === undefined)
|
|
377
|
+
return issues;
|
|
378
|
+
if (expected === 'probability' && !PROBABILITY.test(value)) {
|
|
379
|
+
add([...path, 'probability'], `${at}.probability`, `'${value}' must be a non-negative decimal string such as '0.5'`);
|
|
380
|
+
}
|
|
381
|
+
if (expected !== 'probability' && value !== 'OPEN' && value !== 'CLOSED') {
|
|
382
|
+
add([...path, expected], `${at}.${expected}`, `'${value}' must be OPEN or CLOSED`);
|
|
383
|
+
}
|
|
384
|
+
return issues;
|
|
385
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export declare class EngineError extends Error {
|
|
2
|
+
readonly exitCode: 1 | 2;
|
|
3
|
+
constructor(message: string, exitCode: 1 | 2);
|
|
4
|
+
}
|
|
5
|
+
/** The blueprint (a file, an upload, or ingest output) is invalid. Nothing was read or written. */
|
|
6
|
+
export declare class BlueprintError extends EngineError {
|
|
7
|
+
readonly issues: string[];
|
|
8
|
+
constructor(issues: string[]);
|
|
9
|
+
}
|
|
10
|
+
/** Token, portal or state problem, always detected before the first write. */
|
|
11
|
+
export declare class EnvironmentError extends EngineError {
|
|
12
|
+
constructor(message: string);
|
|
13
|
+
}
|
|
14
|
+
/** The target portal changed between reviewing a plan and applying it. Nothing was written. */
|
|
15
|
+
export declare class PlanChangedError extends EngineError {
|
|
16
|
+
constructor(expected: string, actual: string);
|
|
17
|
+
}
|
|
18
|
+
export interface HubSpotApiErrorInit {
|
|
19
|
+
method: string;
|
|
20
|
+
path: string;
|
|
21
|
+
/** 0 means the request never got a response (network failure). */
|
|
22
|
+
status: number;
|
|
23
|
+
/** Already redacted by the HTTP client. */
|
|
24
|
+
body: string;
|
|
25
|
+
}
|
|
26
|
+
export declare class HubSpotApiError extends EngineError {
|
|
27
|
+
readonly method: string;
|
|
28
|
+
readonly path: string;
|
|
29
|
+
readonly status: number;
|
|
30
|
+
readonly body: string;
|
|
31
|
+
/** The summarised body: HubSpot's JSON message, otherwise the body cut to 300 characters. */
|
|
32
|
+
readonly detail: string;
|
|
33
|
+
constructor(init: HubSpotApiErrorInit);
|
|
34
|
+
/** HubSpot's machine-readable error code, when the body carries one. */
|
|
35
|
+
get errorCode(): string | undefined;
|
|
36
|
+
}
|
|
37
|
+
/** A command or selection names something that does not exist or is malformed. Nothing was written. */
|
|
38
|
+
export declare class InputError extends EngineError {
|
|
39
|
+
constructor(message: string);
|
|
40
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export class EngineError extends Error {
|
|
2
|
+
exitCode;
|
|
3
|
+
constructor(message, exitCode) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.name = new.target.name;
|
|
6
|
+
this.exitCode = exitCode;
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
/** The blueprint (a file, an upload, or ingest output) is invalid. Nothing was read or written. */
|
|
10
|
+
export class BlueprintError extends EngineError {
|
|
11
|
+
issues;
|
|
12
|
+
constructor(issues) {
|
|
13
|
+
super(`invalid blueprint:\n - ${issues.join('\n - ')}`, 2);
|
|
14
|
+
this.issues = issues;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** Token, portal or state problem, always detected before the first write. */
|
|
18
|
+
export class EnvironmentError extends EngineError {
|
|
19
|
+
constructor(message) {
|
|
20
|
+
super(message, 2);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** The target portal changed between reviewing a plan and applying it. Nothing was written. */
|
|
24
|
+
export class PlanChangedError extends EngineError {
|
|
25
|
+
constructor(expected, actual) {
|
|
26
|
+
super(`the portal changed since this plan was reviewed (fingerprint ${expected.slice(0, 12)} → ${actual.slice(0, 12)}); review the new plan`, 1);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export class HubSpotApiError extends EngineError {
|
|
30
|
+
method;
|
|
31
|
+
path;
|
|
32
|
+
status;
|
|
33
|
+
body;
|
|
34
|
+
/** The summarised body: HubSpot's JSON message, otherwise the body cut to 300 characters. */
|
|
35
|
+
detail;
|
|
36
|
+
constructor(init) {
|
|
37
|
+
const what = init.status === 0 ? 'network error' : `HTTP ${init.status}`;
|
|
38
|
+
const detail = summarise(init.body);
|
|
39
|
+
super(`${init.method} ${init.path} → ${what}: ${detail}`, 1);
|
|
40
|
+
this.detail = detail;
|
|
41
|
+
this.method = init.method;
|
|
42
|
+
this.path = init.path;
|
|
43
|
+
this.status = init.status;
|
|
44
|
+
this.body = init.body;
|
|
45
|
+
}
|
|
46
|
+
/** HubSpot's machine-readable error code, when the body carries one. */
|
|
47
|
+
get errorCode() {
|
|
48
|
+
const json = parseJsonObject(this.body);
|
|
49
|
+
const code = json?.groupsErrorCode ?? json?.subCategory ?? json?.category;
|
|
50
|
+
return typeof code === 'string' ? code : undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function parseJsonObject(text) {
|
|
54
|
+
try {
|
|
55
|
+
const value = JSON.parse(text);
|
|
56
|
+
return value !== null && typeof value === 'object' ? value : undefined;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function summarise(body) {
|
|
63
|
+
const message = parseJsonObject(body)?.message;
|
|
64
|
+
if (typeof message === 'string')
|
|
65
|
+
return message;
|
|
66
|
+
return body.length > 300 ? `${body.slice(0, 300)}…` : body;
|
|
67
|
+
}
|
|
68
|
+
/** A command or selection names something that does not exist or is malformed. Nothing was written. */
|
|
69
|
+
export class InputError extends EngineError {
|
|
70
|
+
constructor(message) {
|
|
71
|
+
super(message, 2);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { CiRunView, CreateApplyResponse, CreatePlanRequest, CreatePlanResponse, KnownErrorCode } from './types.ts';
|
|
2
|
+
/** The server refused, or answered something unreadable. `status` 0 is a network failure. `message` is the server's text. */
|
|
3
|
+
export declare class CiApiError extends Error {
|
|
4
|
+
readonly status: number;
|
|
5
|
+
readonly code: KnownErrorCode;
|
|
6
|
+
readonly details?: unknown;
|
|
7
|
+
constructor(status: number, code: KnownErrorCode, message: string, details?: unknown);
|
|
8
|
+
}
|
|
9
|
+
export declare class CiTimeoutError extends Error {
|
|
10
|
+
constructor(message: string);
|
|
11
|
+
}
|
|
12
|
+
export interface ClientOptions {
|
|
13
|
+
baseUrl: string;
|
|
14
|
+
key: string;
|
|
15
|
+
/** @internal Test hook. */
|
|
16
|
+
fetch?: typeof fetch;
|
|
17
|
+
/** @internal Test hook. */
|
|
18
|
+
sleep?: (ms: number) => Promise<void>;
|
|
19
|
+
/** @internal Test hook. */
|
|
20
|
+
now?: () => Date;
|
|
21
|
+
/**
|
|
22
|
+
* The command's deadline: a 429 wait that would pass it, or a request still unanswered at it, throws CiTimeoutError.
|
|
23
|
+
* Without one, each request is aborted after 60 s.
|
|
24
|
+
*/
|
|
25
|
+
deadline?: Date;
|
|
26
|
+
}
|
|
27
|
+
/** Client for the three CI endpoints (#105). The key lives in a private field and is scrubbed from everything thrown. */
|
|
28
|
+
export declare class CiClient {
|
|
29
|
+
#private;
|
|
30
|
+
constructor(options: ClientOptions);
|
|
31
|
+
createPlan(body: CreatePlanRequest): Promise<CreatePlanResponse>;
|
|
32
|
+
getRun(runId: string): Promise<CiRunView>;
|
|
33
|
+
createApply(planRunId: string): Promise<CreateApplyResponse>;
|
|
34
|
+
/** Polls getRun every 5 s until status is succeeded/failed; throws CiTimeoutError after `timeoutMs`. */
|
|
35
|
+
waitForRun(runId: string, timeoutMs: number): Promise<CiRunView>;
|
|
36
|
+
}
|