thinkwork-cli 0.12.15 → 0.12.17
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/dist/{api-client-4VT3FWOH.js → api-client-JUBCQQDF.js} +1 -1
- package/dist/{chunk-H7AB42ES.js → chunk-STY44FQO.js} +13 -0
- package/dist/cli.js +1107 -10225
- package/dist/commands/enterprise/templates/deploy-repo/docs/runbook.md +3 -8
- package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +1 -184
- package/dist/drizzle/0050_brain_v0_entity_subtype.sql +2 -2
- package/dist/drizzle/0051_brain_v0_tenant_entity_pages.sql +6 -6
- package/dist/drizzle/0052_brain_v0_external_refs.sql +2 -2
- package/dist/drizzle/0053_brain_v0_unresolved_mentions_subtype.sql +2 -2
- package/dist/drizzle/0145_knowledge_graph_thread_ingest.sql +14 -14
- package/dist/drizzle/0146_knowledge_graph_source_scope.sql +1 -1
- package/dist/drizzle/0157_knowledge_graph_observations_source.sql +2 -2
- package/dist/drizzle/0158_wiki_tenant_scope.sql +1 -1
- package/dist/drizzle/0165_plugin_premium_entitlements.sql +2 -2
- package/dist/drizzle/{0166_company_brain_substrate_contract.sql → 0166_brain_substrate_contract.sql} +9 -9
- package/dist/drizzle/{0167_company_brain_artifact_manifest_runtime.sql → 0167_brain_artifact_manifest_runtime.sql} +5 -5
- package/dist/drizzle/0168_eval_skill_gate.sql +1 -1
- package/dist/drizzle/0183_okf_artifact_manifests.sql +1 -1
- package/dist/drizzle/0197_rcm_context_columns.sql +28 -0
- package/dist/drizzle/0200_eval_results_execution_tier.sql +40 -0
- package/dist/drizzle/0201_brain_dream_runs.sql +72 -0
- package/dist/drizzle/0202_backfill_thread_participants_from_senders.sql +110 -0
- package/dist/drizzle/0203_threads_mode_override.sql +61 -0
- package/dist/drizzle/0204_thread_turns_triggering_message_id.sql +48 -0
- package/dist/drizzle/0205_routines_git_python.sql +185 -0
- package/dist/drizzle/0206_agent_loop_versions_routine_actions.sql +40 -0
- package/dist/drizzle/0207_tenant_credentials_github_repo_kind.sql +52 -0
- package/dist/drizzle/0208_knowledge_graph_neutral_identifiers.sql +253 -0
- package/dist/drizzle/0209_living_artifacts_schema.sql +109 -0
- package/dist/drizzle/0210_think137_automations_additive_schema.sql +113 -0
- package/dist/drizzle/0211_backfill_agent_loop_versions_target_spec.sql +120 -0
- package/dist/drizzle/0212_fold_webhooks_into_automations.sql +312 -0
- package/dist/drizzle/0213_agent_loop_versions_judge_evidence_nullable.sql +63 -0
- package/dist/drizzle/0214_think137_drop_judge_evidence_roi.sql +99 -0
- package/dist/drizzle/0215_agent_loop_versions_goal_worker_policy_nullable.sql +70 -0
- package/dist/drizzle/0216_drop_agent_loop_versions_goal_worker_policy.sql +81 -0
- package/dist/drizzle/0217_agents_capability_folder_dispatch.sql +12 -0
- package/dist/drizzle/0218_document_plates.sql +30 -0
- package/dist/drizzle/0219_document_section_waivers.sql +29 -0
- package/dist/drizzle/0220_artifacts_created_by_user.sql +21 -0
- package/dist/drizzle/0221_artifact_shares.sql +29 -0
- package/dist/drizzle/0221_workflow_interpreter_thin_slice.sql +71 -0
- package/dist/drizzle/0222_compliance_drainer_select_audit_events.sql +21 -0
- package/dist/drizzle/0223_document_conformance_reports.sql +38 -0
- package/dist/drizzle/0224_artifact_refresh_state.sql +11 -0
- package/dist/drizzle/0225_think216_agent_loop_convergence.sql +21 -0
- package/dist/drizzle/0226_drop_n8n_agent_step_runs.sql +6 -0
- package/dist/drizzle/0227_analyst_reader_role.sql +1055 -0
- package/dist/drizzle/0228_tenant_credentials_rds_iam_kind.sql +62 -0
- package/dist/drizzle/0229_analyst_reader_rds_iam_grant.sql +79 -0
- package/dist/drizzle/0230_analyst_rls.sql +1613 -0
- package/dist/drizzle/0231_cost_events_cache_write_enforcement_exempt.sql +11 -0
- package/dist/plugins/catalog/package.json +0 -1
- package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +0 -1
- package/dist/plugins/catalog/src/__tests__/catalog.test.ts +16 -8
- package/dist/plugins/catalog/src/__tests__/contracts.test.ts +56 -0
- package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +0 -2
- package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -5
- package/dist/plugins/catalog/src/contracts.ts +112 -0
- package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -6
- package/dist/plugins/n8n/README.md +4 -36
- package/dist/plugins/n8n/src/deployment/managed-app.ts +0 -17
- package/dist/plugins/n8n/src/manifest.ts +0 -5
- package/dist/plugins/n8n/terraform/n8n/main.tf +0 -26
- package/dist/plugins/n8n/terraform/n8n/outputs.tf +0 -5
- package/dist/plugins/n8n/terraform/n8n/variables.tf +0 -6
- package/dist/plugins/n8n/test/manifest.test.ts +0 -1
- package/dist/plugins/twenty/package.json +5 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +232 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +489 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +199 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +43 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +230 -0
- package/dist/plugins/twenty/scripts/lib/__tests__/twenty-client.test.ts +207 -0
- package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +282 -0
- package/dist/plugins/twenty/scripts/lib/load-attachments.ts +220 -0
- package/dist/plugins/twenty/scripts/lib/load-records.ts +680 -0
- package/dist/plugins/twenty/scripts/lib/mappers.ts +559 -0
- package/dist/plugins/twenty/scripts/lib/members-ensure.ts +381 -0
- package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +191 -0
- package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +425 -0
- package/dist/plugins/twenty/scripts/lib/twenty-client.ts +246 -0
- package/dist/plugins/twenty/scripts/migrate-lastmile.ts +610 -0
- package/dist/plugins/twenty/scripts/provision-twenty-members.ts +214 -0
- package/dist/plugins/twenty/src/manifest.ts +99 -0
- package/dist/plugins/twenty/terraform/twenty/README.md +1 -1
- package/dist/plugins/twenty/test/manifest.test.ts +12 -2
- package/dist/terraform/examples/greenfield/main.tf +90 -380
- package/dist/terraform/examples/greenfield/terraform.tfvars.example +0 -1
- package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
- package/dist/terraform/modules/app/agentcore-pi/main.tf +36 -32
- package/dist/terraform/modules/app/agentcore-pi/variables.tf +23 -15
- package/dist/terraform/modules/app/agentcore-runtime/main.tf +3 -3
- package/dist/terraform/modules/app/customer-domain/main.tf +16 -2
- package/dist/terraform/modules/app/deployment-control-plane/main.tf +5 -0
- package/dist/terraform/modules/app/deployment-control-plane/runner.py +85 -207
- package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +56 -2
- package/dist/terraform/modules/app/hindsight-memory/main.tf +52 -4
- package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +7 -0
- package/dist/terraform/modules/app/lambda-api/handlers.tf +493 -89
- package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +215 -96
- package/dist/terraform/modules/app/lambda-api/main.tf +24 -5
- package/dist/terraform/modules/app/lambda-api/outputs.tf +19 -11
- package/dist/terraform/modules/app/lambda-api/variables.tf +94 -67
- package/dist/terraform/modules/app/ses-email/main.tf +14 -2
- package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/main.tf +493 -0
- package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/outputs.tf +34 -0
- package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/variables.tf +32 -0
- package/dist/terraform/modules/data/aurora-postgres/main.tf +55 -0
- package/dist/terraform/modules/data/aurora-postgres/outputs.tf +15 -0
- package/dist/terraform/modules/data/s3-buckets/main.tf +26 -0
- package/dist/terraform/modules/thinkwork/README.md +0 -1
- package/dist/terraform/modules/thinkwork/main.tf +167 -446
- package/dist/terraform/modules/thinkwork/outputs.tf +2 -142
- package/dist/terraform/modules/thinkwork/variables.tf +55 -309
- package/dist/terraform/schema.graphql +4 -0
- package/dist/workspace-defaults/files/AGENTS.md +45 -31
- package/dist/workspace-defaults/files/CONTEXT.md +48 -4
- package/dist/workspace-defaults/files/GUARDRAILS.md +18 -0
- package/dist/workspace-defaults/files/MEMORY_GUIDE.md +32 -4
- package/dist/workspace-defaults/files/SPACE.md +34 -19
- package/dist/workspace-defaults/files/TOOLS.md +30 -0
- package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +154 -0
- package/dist/workspace-defaults/files/skills/document-composer/references/authoring-rules.md +101 -0
- package/package.json +2 -1
- package/dist/plugins/company-brain/README.md +0 -37
- package/dist/plugins/company-brain/package.json +0 -33
- package/dist/plugins/company-brain/runtime/cognee/Dockerfile +0 -11
- package/dist/plugins/company-brain/smoke/cognee-managed-app-smoke.mjs +0 -458
- package/dist/plugins/company-brain/smoke/cognee-memory-cutover-smoke.mjs +0 -539
- package/dist/plugins/company-brain/smoke/company-brain-context-engine-smoke.mjs +0 -312
- package/dist/plugins/company-brain/smoke/company-brain-operations-smoke.mjs +0 -449
- package/dist/plugins/company-brain/smoke/company-brain-plugin-smoke.mjs +0 -655
- package/dist/plugins/company-brain/smoke/hindsight-memory-isolation-smoke.mjs +0 -474
- package/dist/plugins/company-brain/src/api/cognee-client.ts +0 -896
- package/dist/plugins/company-brain/src/api/cognee-cluster-identity.ts +0 -49
- package/dist/plugins/company-brain/src/api/cognee-memory-scope.ts +0 -84
- package/dist/plugins/company-brain/src/api/context-engine-provider.ts +0 -888
- package/dist/plugins/company-brain/src/api/migration.ts +0 -665
- package/dist/plugins/company-brain/src/deployment/cognee-managed-app.ts +0 -396
- package/dist/plugins/company-brain/src/index.ts +0 -62
- package/dist/plugins/company-brain/src/manifest.ts +0 -112
- package/dist/plugins/company-brain/terraform/cognee/README.md +0 -263
- package/dist/plugins/company-brain/terraform/cognee/main.tf +0 -766
- package/dist/plugins/company-brain/terraform/cognee/outputs.tf +0 -144
- package/dist/plugins/company-brain/terraform/cognee/variables.tf +0 -402
- package/dist/plugins/company-brain/test/api/cognee-client.test.ts +0 -562
- package/dist/plugins/company-brain/test/api/cognee-memory-scope.test.ts +0 -74
- package/dist/plugins/company-brain/test/api/context-engine-provider.test.ts +0 -447
- package/dist/plugins/company-brain/test/api/migration.test.ts +0 -465
- package/dist/plugins/company-brain/test/manifest.test.ts +0 -95
- package/dist/plugins/company-brain/tsconfig.json +0 -8
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotent Twenty schema preparation (plan U3, R1–R3).
|
|
3
|
+
*
|
|
4
|
+
* Creates only missing custom fields via `createOneField` on `/metadata`, and
|
|
5
|
+
* merges the migration's stage options into the opportunity `stage` SELECT.
|
|
6
|
+
* U1 confirmed `updateOneField` FULL-REPLACES the options array, so the merge
|
|
7
|
+
* always writes existing options + additions; existing options (including
|
|
8
|
+
* CUSTOMER, which the ThinkWork workflow triggers on) are preserved verbatim.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { MIGRATION_STAGE_OPTIONS } from "./mappers";
|
|
12
|
+
import type { TwentyClient } from "./twenty-client";
|
|
13
|
+
|
|
14
|
+
/** The whole schema delta in one reviewable place (plan U3 approach note). */
|
|
15
|
+
/**
|
|
16
|
+
* Opportunities carry MULTIPLE product lines (LastMile keeps them in
|
|
17
|
+
* `task.entity_data->'items'`: 816 opportunities, 82 with 2-5 lines each), so
|
|
18
|
+
* product/quantity/amount belong on a child object, not on the opportunity
|
|
19
|
+
* row. This custom object holds one row per line, related many-to-one back to
|
|
20
|
+
* the opportunity. The opportunity's own `product` (category) and native
|
|
21
|
+
* `amount` (deal total) stay where they are.
|
|
22
|
+
*/
|
|
23
|
+
export const OPPORTUNITY_PRODUCT_OBJECT = {
|
|
24
|
+
nameSingular: "opportunityProduct",
|
|
25
|
+
namePlural: "opportunityProducts",
|
|
26
|
+
labelSingular: "Opportunity Product",
|
|
27
|
+
labelPlural: "Opportunity Products",
|
|
28
|
+
icon: "IconPackage",
|
|
29
|
+
/** Field created on `opportunity` pointing back at the lines. */
|
|
30
|
+
targetFieldLabel: "Products",
|
|
31
|
+
} as const;
|
|
32
|
+
|
|
33
|
+
export const FIELD_SPECS: Array<{
|
|
34
|
+
object: string;
|
|
35
|
+
name: string;
|
|
36
|
+
label: string;
|
|
37
|
+
type: "TEXT" | "NUMBER" | "BOOLEAN" | "CURRENCY";
|
|
38
|
+
isUnique?: boolean;
|
|
39
|
+
}> = [
|
|
40
|
+
{
|
|
41
|
+
object: "company",
|
|
42
|
+
name: "sourceId",
|
|
43
|
+
label: "Source ID",
|
|
44
|
+
type: "TEXT",
|
|
45
|
+
isUnique: true,
|
|
46
|
+
},
|
|
47
|
+
{ object: "company", name: "sourceHash", label: "Source Hash", type: "TEXT" },
|
|
48
|
+
{
|
|
49
|
+
object: "person",
|
|
50
|
+
name: "sourceId",
|
|
51
|
+
label: "Source ID",
|
|
52
|
+
type: "TEXT",
|
|
53
|
+
isUnique: true,
|
|
54
|
+
},
|
|
55
|
+
{ object: "person", name: "sourceHash", label: "Source Hash", type: "TEXT" },
|
|
56
|
+
{
|
|
57
|
+
object: "opportunity",
|
|
58
|
+
name: "sourceId",
|
|
59
|
+
label: "Source ID",
|
|
60
|
+
type: "TEXT",
|
|
61
|
+
isUnique: true,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
object: "opportunity",
|
|
65
|
+
name: "sourceHash",
|
|
66
|
+
label: "Source Hash",
|
|
67
|
+
type: "TEXT",
|
|
68
|
+
},
|
|
69
|
+
{ object: "opportunity", name: "product", label: "Product", type: "TEXT" },
|
|
70
|
+
{
|
|
71
|
+
object: "opportunity",
|
|
72
|
+
name: "quantity",
|
|
73
|
+
label: "Quantity",
|
|
74
|
+
type: "NUMBER",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
object: "opportunity",
|
|
78
|
+
name: "isMobil",
|
|
79
|
+
label: "Mobil Product",
|
|
80
|
+
type: "BOOLEAN",
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
object: "note",
|
|
84
|
+
name: "sourceId",
|
|
85
|
+
label: "Source ID",
|
|
86
|
+
type: "TEXT",
|
|
87
|
+
isUnique: true,
|
|
88
|
+
},
|
|
89
|
+
{ object: "note", name: "sourceHash", label: "Source Hash", type: "TEXT" },
|
|
90
|
+
{
|
|
91
|
+
object: "attachment",
|
|
92
|
+
name: "sourceId",
|
|
93
|
+
label: "Source ID",
|
|
94
|
+
type: "TEXT",
|
|
95
|
+
isUnique: true,
|
|
96
|
+
},
|
|
97
|
+
// Product-line object (created first when missing; see ensureOpportunityProductObject).
|
|
98
|
+
{
|
|
99
|
+
object: "opportunityProduct",
|
|
100
|
+
name: "sourceId",
|
|
101
|
+
label: "Source ID",
|
|
102
|
+
type: "TEXT",
|
|
103
|
+
isUnique: true,
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
object: "opportunityProduct",
|
|
107
|
+
name: "sourceHash",
|
|
108
|
+
label: "Source Hash",
|
|
109
|
+
type: "TEXT",
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
object: "opportunityProduct",
|
|
113
|
+
name: "product",
|
|
114
|
+
label: "Product",
|
|
115
|
+
type: "TEXT",
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
object: "opportunityProduct",
|
|
119
|
+
name: "quantity",
|
|
120
|
+
label: "Quantity",
|
|
121
|
+
type: "NUMBER",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
object: "opportunityProduct",
|
|
125
|
+
name: "amount",
|
|
126
|
+
label: "Amount",
|
|
127
|
+
type: "CURRENCY",
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
object: "opportunityProduct",
|
|
131
|
+
name: "isMobil",
|
|
132
|
+
label: "Mobil Product",
|
|
133
|
+
type: "BOOLEAN",
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
object: "opportunityProduct",
|
|
137
|
+
name: "lineNumber",
|
|
138
|
+
label: "Line",
|
|
139
|
+
type: "NUMBER",
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
/** Placeholder id used only in dry-run plans, never sent to Twenty. */
|
|
144
|
+
export const PENDING_OBJECT_ID = "<pending-object-creation>";
|
|
145
|
+
|
|
146
|
+
interface ObjectMetadata {
|
|
147
|
+
id: string;
|
|
148
|
+
nameSingular: string;
|
|
149
|
+
fields: Map<
|
|
150
|
+
string,
|
|
151
|
+
{ id: string; name: string; type: string; options: StageOption[] | null }
|
|
152
|
+
>;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export interface StageOption {
|
|
156
|
+
id?: string;
|
|
157
|
+
label: string;
|
|
158
|
+
value: string;
|
|
159
|
+
color: string;
|
|
160
|
+
position: number;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const OBJECTS_QUERY = `
|
|
164
|
+
query MigrationObjects {
|
|
165
|
+
objects(paging: { first: 1000 }) {
|
|
166
|
+
edges {
|
|
167
|
+
node {
|
|
168
|
+
id
|
|
169
|
+
nameSingular
|
|
170
|
+
fields(paging: { first: 1000 }) {
|
|
171
|
+
edges { node { id name type options } }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
`;
|
|
178
|
+
|
|
179
|
+
const CREATE_FIELD_MUTATION = `
|
|
180
|
+
mutation MigrationCreateField($input: CreateOneFieldMetadataInput!) {
|
|
181
|
+
createOneField(input: $input) { id name }
|
|
182
|
+
}
|
|
183
|
+
`;
|
|
184
|
+
|
|
185
|
+
const UPDATE_FIELD_MUTATION = `
|
|
186
|
+
mutation MigrationUpdateField($input: UpdateOneFieldMetadataInput!) {
|
|
187
|
+
updateOneField(input: $input) { id name options }
|
|
188
|
+
}
|
|
189
|
+
`;
|
|
190
|
+
|
|
191
|
+
const CREATE_OBJECT_MUTATION = `
|
|
192
|
+
mutation MigrationCreateObject($input: CreateOneObjectInput!) {
|
|
193
|
+
createOneObject(input: $input) { id nameSingular }
|
|
194
|
+
}
|
|
195
|
+
`;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Create the opportunityProduct object and its many-to-one relation to
|
|
199
|
+
* opportunity, if absent. Idempotent: returns the existing object's id when it
|
|
200
|
+
* is already there. Must run before the field pass, which addresses the object
|
|
201
|
+
* by name. Verified live: the workspace API key may create objects and
|
|
202
|
+
* RELATION fields via `relationCreationPayload`, and Twenty exposes the FK to
|
|
203
|
+
* the data API as `opportunityId` on the line's create input.
|
|
204
|
+
*/
|
|
205
|
+
export async function ensureOpportunityProductObject(
|
|
206
|
+
client: TwentyClient,
|
|
207
|
+
objects: Map<string, ObjectMetadata>,
|
|
208
|
+
dryRun: boolean,
|
|
209
|
+
): Promise<{ created: boolean; relationCreated: boolean }> {
|
|
210
|
+
const existing = objects.get(OPPORTUNITY_PRODUCT_OBJECT.nameSingular);
|
|
211
|
+
const opportunity = objects.get("opportunity");
|
|
212
|
+
if (!opportunity) throw new Error('Twenty object "opportunity" not found.');
|
|
213
|
+
|
|
214
|
+
if (existing?.fields.has("opportunity")) {
|
|
215
|
+
return { created: false, relationCreated: false };
|
|
216
|
+
}
|
|
217
|
+
if (dryRun) {
|
|
218
|
+
return { created: !existing, relationCreated: true };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let objectId = existing?.id;
|
|
222
|
+
if (!objectId) {
|
|
223
|
+
const created = await client.requestOnce<{
|
|
224
|
+
createOneObject: { id: string };
|
|
225
|
+
}>("/metadata", CREATE_OBJECT_MUTATION, {
|
|
226
|
+
input: {
|
|
227
|
+
object: {
|
|
228
|
+
nameSingular: OPPORTUNITY_PRODUCT_OBJECT.nameSingular,
|
|
229
|
+
namePlural: OPPORTUNITY_PRODUCT_OBJECT.namePlural,
|
|
230
|
+
labelSingular: OPPORTUNITY_PRODUCT_OBJECT.labelSingular,
|
|
231
|
+
labelPlural: OPPORTUNITY_PRODUCT_OBJECT.labelPlural,
|
|
232
|
+
icon: OPPORTUNITY_PRODUCT_OBJECT.icon,
|
|
233
|
+
},
|
|
234
|
+
},
|
|
235
|
+
});
|
|
236
|
+
objectId = created.createOneObject.id;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
await client.requestOnce("/metadata", CREATE_FIELD_MUTATION, {
|
|
240
|
+
input: {
|
|
241
|
+
field: {
|
|
242
|
+
objectMetadataId: objectId,
|
|
243
|
+
name: "opportunity",
|
|
244
|
+
label: "Opportunity",
|
|
245
|
+
type: "RELATION",
|
|
246
|
+
relationCreationPayload: {
|
|
247
|
+
targetObjectMetadataId: opportunity.id,
|
|
248
|
+
targetFieldLabel: OPPORTUNITY_PRODUCT_OBJECT.targetFieldLabel,
|
|
249
|
+
targetFieldIcon: OPPORTUNITY_PRODUCT_OBJECT.icon,
|
|
250
|
+
type: "MANY_TO_ONE",
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
return { created: !existing, relationCreated: true };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function fetchObjectMetadata(
|
|
260
|
+
client: TwentyClient,
|
|
261
|
+
): Promise<Map<string, ObjectMetadata>> {
|
|
262
|
+
const data = await client.requestWithRetry<{
|
|
263
|
+
objects: {
|
|
264
|
+
edges: Array<{
|
|
265
|
+
node: {
|
|
266
|
+
id: string;
|
|
267
|
+
nameSingular: string;
|
|
268
|
+
fields: {
|
|
269
|
+
edges: Array<{
|
|
270
|
+
node: {
|
|
271
|
+
id: string;
|
|
272
|
+
name: string;
|
|
273
|
+
type: string;
|
|
274
|
+
options: StageOption[] | null;
|
|
275
|
+
};
|
|
276
|
+
}>;
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
}>;
|
|
280
|
+
};
|
|
281
|
+
}>("/metadata", OBJECTS_QUERY);
|
|
282
|
+
|
|
283
|
+
const byName = new Map<string, ObjectMetadata>();
|
|
284
|
+
for (const { node } of data.objects.edges) {
|
|
285
|
+
byName.set(node.nameSingular, {
|
|
286
|
+
id: node.id,
|
|
287
|
+
nameSingular: node.nameSingular,
|
|
288
|
+
fields: new Map(
|
|
289
|
+
node.fields.edges.map(({ node: field }) => [field.name, field]),
|
|
290
|
+
),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
return byName;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export interface SchemaEnsurePlan {
|
|
297
|
+
createFields: Array<{
|
|
298
|
+
object: string;
|
|
299
|
+
objectMetadataId: string;
|
|
300
|
+
name: string;
|
|
301
|
+
}>;
|
|
302
|
+
stageOptionsToAdd: string[];
|
|
303
|
+
/** Full merged array to write when stageOptionsToAdd is non-empty. */
|
|
304
|
+
mergedStageOptions: StageOption[] | null;
|
|
305
|
+
stageFieldId: string | null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function planSchemaEnsure(
|
|
309
|
+
objects: Map<string, ObjectMetadata>,
|
|
310
|
+
): SchemaEnsurePlan {
|
|
311
|
+
const createFields: SchemaEnsurePlan["createFields"] = [];
|
|
312
|
+
for (const spec of FIELD_SPECS) {
|
|
313
|
+
const object = objects.get(spec.object);
|
|
314
|
+
if (!object) {
|
|
315
|
+
// ensureOpportunityProductObject creates this object earlier in the same
|
|
316
|
+
// run; in a dry-run against a workspace that lacks it, its fields are
|
|
317
|
+
// planned rather than an error.
|
|
318
|
+
if (spec.object === OPPORTUNITY_PRODUCT_OBJECT.nameSingular) {
|
|
319
|
+
createFields.push({
|
|
320
|
+
object: spec.object,
|
|
321
|
+
objectMetadataId: PENDING_OBJECT_ID,
|
|
322
|
+
name: spec.name,
|
|
323
|
+
});
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
throw new Error(
|
|
327
|
+
`Twenty object "${spec.object}" not found in metadata; aborting.`,
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (!object.fields.has(spec.name)) {
|
|
331
|
+
createFields.push({
|
|
332
|
+
object: spec.object,
|
|
333
|
+
objectMetadataId: object.id,
|
|
334
|
+
name: spec.name,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const opportunity = objects.get("opportunity");
|
|
340
|
+
const stageField = opportunity?.fields.get("stage");
|
|
341
|
+
if (!stageField || !stageField.options) {
|
|
342
|
+
throw new Error(
|
|
343
|
+
"Opportunity stage field (SELECT with options) not found; aborting.",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
const existingValues = new Set(
|
|
347
|
+
stageField.options.map((option) => option.value),
|
|
348
|
+
);
|
|
349
|
+
const additions = MIGRATION_STAGE_OPTIONS.filter(
|
|
350
|
+
(option) => !existingValues.has(option.value),
|
|
351
|
+
);
|
|
352
|
+
let mergedStageOptions: StageOption[] | null = null;
|
|
353
|
+
if (additions.length > 0) {
|
|
354
|
+
const preserved = stageField.options.map((option, index) => ({
|
|
355
|
+
...(option.id ? { id: option.id } : {}),
|
|
356
|
+
label: option.label,
|
|
357
|
+
value: option.value,
|
|
358
|
+
color: option.color,
|
|
359
|
+
position: index,
|
|
360
|
+
}));
|
|
361
|
+
const appended = additions.map((option, index) => ({
|
|
362
|
+
label: option.label,
|
|
363
|
+
value: option.value,
|
|
364
|
+
color: option.color,
|
|
365
|
+
position: preserved.length + index,
|
|
366
|
+
}));
|
|
367
|
+
mergedStageOptions = [...preserved, ...appended];
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
createFields,
|
|
371
|
+
stageOptionsToAdd: additions.map((option) => option.value),
|
|
372
|
+
mergedStageOptions,
|
|
373
|
+
stageFieldId: stageField.id,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
export interface SchemaEnsureResult {
|
|
378
|
+
createdFields: string[];
|
|
379
|
+
addedStageOptions: string[];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export async function applySchemaEnsure(
|
|
383
|
+
client: TwentyClient,
|
|
384
|
+
plan: SchemaEnsurePlan,
|
|
385
|
+
): Promise<SchemaEnsureResult> {
|
|
386
|
+
const createdFields: string[] = [];
|
|
387
|
+
for (const create of plan.createFields) {
|
|
388
|
+
const spec = FIELD_SPECS.find(
|
|
389
|
+
(candidate) =>
|
|
390
|
+
candidate.object === create.object && candidate.name === create.name,
|
|
391
|
+
);
|
|
392
|
+
if (!spec)
|
|
393
|
+
throw new Error(`No field spec for ${create.object}.${create.name}`);
|
|
394
|
+
if (create.objectMetadataId === PENDING_OBJECT_ID) {
|
|
395
|
+
throw new Error(
|
|
396
|
+
`Field ${create.object}.${create.name} was planned against an uncreated object — ` +
|
|
397
|
+
`re-plan after ensureOpportunityProductObject so the real object id is known.`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
// Metadata mutation errors abort the run before any record loading (U3).
|
|
401
|
+
await client.requestOnce("/metadata", CREATE_FIELD_MUTATION, {
|
|
402
|
+
input: {
|
|
403
|
+
field: {
|
|
404
|
+
objectMetadataId: create.objectMetadataId,
|
|
405
|
+
name: spec.name,
|
|
406
|
+
label: spec.label,
|
|
407
|
+
type: spec.type,
|
|
408
|
+
...(spec.isUnique ? { isUnique: true } : {}),
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
});
|
|
412
|
+
createdFields.push(`${create.object}.${create.name}`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (plan.mergedStageOptions && plan.stageFieldId) {
|
|
416
|
+
await client.requestOnce("/metadata", UPDATE_FIELD_MUTATION, {
|
|
417
|
+
input: {
|
|
418
|
+
id: plan.stageFieldId,
|
|
419
|
+
update: { options: plan.mergedStageOptions },
|
|
420
|
+
},
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return { createdFields, addedStageOptions: plan.stageOptionsToAdd };
|
|
425
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL client for Twenty CRM used by the LastMile migration script.
|
|
3
|
+
*
|
|
4
|
+
* Follows the TwentyGraphqlClient shape from scripts/wire-thinkwork-workflow.mjs:
|
|
5
|
+
* Bearer auth, `/graphql` (workspace data) + `/metadata` (field metadata), throws
|
|
6
|
+
* on `body.errors`. Adds the retry/backoff and batching discipline from the
|
|
7
|
+
* migration plan (KTD3): reads may auto-retry; mutations are only retried by
|
|
8
|
+
* callers after re-querying, never blindly.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const BATCH_LIMIT = 60;
|
|
12
|
+
|
|
13
|
+
export type TwentyEndpointPath = "/graphql" | "/metadata";
|
|
14
|
+
|
|
15
|
+
export class TwentyGraphqlError extends Error {
|
|
16
|
+
readonly errors: Array<{
|
|
17
|
+
message: string;
|
|
18
|
+
extensions?: Record<string, unknown>;
|
|
19
|
+
}>;
|
|
20
|
+
readonly httpStatus: number | undefined;
|
|
21
|
+
|
|
22
|
+
constructor(
|
|
23
|
+
message: string,
|
|
24
|
+
options: {
|
|
25
|
+
errors?: Array<{ message: string; extensions?: Record<string, unknown> }>;
|
|
26
|
+
httpStatus?: number;
|
|
27
|
+
} = {},
|
|
28
|
+
) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "TwentyGraphqlError";
|
|
31
|
+
this.errors = options.errors ?? [];
|
|
32
|
+
this.httpStatus = options.httpStatus;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
get isDuplicateError(): boolean {
|
|
36
|
+
return this.errors.some((error) =>
|
|
37
|
+
/duplicate|unique|already exists/i.test(error.message),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Twenty's throttle rejects with a GraphQL error (subCode LIMIT_REACHED),
|
|
42
|
+
* not HTTP 429. The guard fires before the resolver, so a rate-limited
|
|
43
|
+
* request had NO side effect — safe to retry even for mutations. */
|
|
44
|
+
get isRateLimited(): boolean {
|
|
45
|
+
return (
|
|
46
|
+
this.httpStatus === 429 ||
|
|
47
|
+
this.errors.some(
|
|
48
|
+
(error) =>
|
|
49
|
+
(error.extensions as { subCode?: string } | undefined)?.subCode ===
|
|
50
|
+
"LIMIT_REACHED" || /limit reached/i.test(error.message),
|
|
51
|
+
)
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
get isRetryable(): boolean {
|
|
56
|
+
return (
|
|
57
|
+
this.isNetworkError ||
|
|
58
|
+
this.isRateLimited ||
|
|
59
|
+
(this.httpStatus !== undefined && this.httpStatus >= 500)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
isNetworkError = false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface TwentyClientOptions {
|
|
67
|
+
/** e.g. https://crm.tei.thinkwork.ai — no trailing slash. */
|
|
68
|
+
baseUrl: string;
|
|
69
|
+
/** Workspace API key or a user access token; sent as Bearer. */
|
|
70
|
+
authToken: string;
|
|
71
|
+
fetchImpl?: typeof fetch;
|
|
72
|
+
/** Base backoff in ms; test seam. */
|
|
73
|
+
backoffMs?: number;
|
|
74
|
+
maxRetries?: number;
|
|
75
|
+
/** Minimum ms between requests — Twenty allows 100 req/min, so the default
|
|
76
|
+
* paces to ~92/min. 0 disables (tests). */
|
|
77
|
+
minRequestIntervalMs?: number;
|
|
78
|
+
/** Wait before retrying a rate-limited request; test seam. */
|
|
79
|
+
rateLimitWaitMs?: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function normalizeBaseUrl(url: string): string {
|
|
83
|
+
const parsed = new URL(url);
|
|
84
|
+
if (parsed.protocol !== "https:" && parsed.hostname !== "localhost") {
|
|
85
|
+
throw new Error(`Twenty URL must be HTTPS or localhost, got ${url}.`);
|
|
86
|
+
}
|
|
87
|
+
return parsed.toString().replace(/\/$/, "");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function chunk<T>(items: readonly T[], size: number): T[][] {
|
|
91
|
+
if (size < 1) throw new Error(`chunk size must be >= 1, got ${size}`);
|
|
92
|
+
const chunks: T[][] = [];
|
|
93
|
+
for (let index = 0; index < items.length; index += size) {
|
|
94
|
+
chunks.push(items.slice(index, index + size));
|
|
95
|
+
}
|
|
96
|
+
return chunks;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
100
|
+
|
|
101
|
+
export class TwentyClient {
|
|
102
|
+
private readonly baseUrl: string;
|
|
103
|
+
private readonly authToken: string;
|
|
104
|
+
private readonly fetchImpl: typeof fetch;
|
|
105
|
+
private readonly backoffMs: number;
|
|
106
|
+
private readonly maxRetries: number;
|
|
107
|
+
private readonly minRequestIntervalMs: number;
|
|
108
|
+
private readonly rateLimitWaitMs: number;
|
|
109
|
+
private nextSlotAt = 0;
|
|
110
|
+
|
|
111
|
+
constructor(options: TwentyClientOptions) {
|
|
112
|
+
this.baseUrl = normalizeBaseUrl(options.baseUrl);
|
|
113
|
+
this.authToken = options.authToken;
|
|
114
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
115
|
+
this.backoffMs = options.backoffMs ?? 1_000;
|
|
116
|
+
this.maxRetries = options.maxRetries ?? 4;
|
|
117
|
+
this.minRequestIntervalMs = options.minRequestIntervalMs ?? 650;
|
|
118
|
+
this.rateLimitWaitMs = options.rateLimitWaitMs ?? 15_000;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
endpoint(path: TwentyEndpointPath): string {
|
|
122
|
+
return `${this.baseUrl}${path}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Pace requests under Twenty's 100 req/min throttle. */
|
|
126
|
+
private async takeSlot(): Promise<void> {
|
|
127
|
+
if (this.minRequestIntervalMs <= 0) return;
|
|
128
|
+
const now = Date.now();
|
|
129
|
+
const slot = Math.max(this.nextSlotAt, now);
|
|
130
|
+
this.nextSlotAt = slot + this.minRequestIntervalMs;
|
|
131
|
+
if (slot > now) await sleep(slot - now);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Single request with NO blind mutation retries (plan KTD3) — with one
|
|
136
|
+
* carve-out: Twenty's rate-limit guard rejects before the resolver runs, so
|
|
137
|
+
* a LIMIT_REACHED rejection had no side effect and is retried here after a
|
|
138
|
+
* wait, for mutations and reads alike.
|
|
139
|
+
*/
|
|
140
|
+
async requestOnce<T = Record<string, unknown>>(
|
|
141
|
+
path: TwentyEndpointPath,
|
|
142
|
+
query: string,
|
|
143
|
+
variables: Record<string, unknown> = {},
|
|
144
|
+
): Promise<T> {
|
|
145
|
+
let attempt = 0;
|
|
146
|
+
for (;;) {
|
|
147
|
+
try {
|
|
148
|
+
return await this.performRequest<T>(path, query, variables);
|
|
149
|
+
} catch (error) {
|
|
150
|
+
const rateLimited =
|
|
151
|
+
error instanceof TwentyGraphqlError && error.isRateLimited;
|
|
152
|
+
if (!rateLimited || attempt >= this.maxRetries) throw error;
|
|
153
|
+
// The 100-token window refills per minute; wait a meaningful slice.
|
|
154
|
+
await sleep(
|
|
155
|
+
this.rateLimitWaitMs +
|
|
156
|
+
Math.floor(Math.random() * (this.rateLimitWaitMs / 3 + 1)),
|
|
157
|
+
);
|
|
158
|
+
attempt += 1;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async performRequest<T>(
|
|
164
|
+
path: TwentyEndpointPath,
|
|
165
|
+
query: string,
|
|
166
|
+
variables: Record<string, unknown>,
|
|
167
|
+
): Promise<T> {
|
|
168
|
+
await this.takeSlot();
|
|
169
|
+
let response: Response;
|
|
170
|
+
try {
|
|
171
|
+
response = await this.fetchImpl(this.endpoint(path), {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: {
|
|
174
|
+
"content-type": "application/json",
|
|
175
|
+
authorization: `Bearer ${this.authToken}`,
|
|
176
|
+
},
|
|
177
|
+
body: JSON.stringify({ query, variables }),
|
|
178
|
+
});
|
|
179
|
+
} catch (cause) {
|
|
180
|
+
// Socket-level failure (DNS, reset, timeout): no response was received.
|
|
181
|
+
// Reads retry these; mutation callers re-query by sourceId first (KTD3).
|
|
182
|
+
const error = new TwentyGraphqlError(
|
|
183
|
+
`Twenty GraphQL network error: ${cause instanceof Error ? (cause.cause instanceof Error ? cause.cause.message : cause.message) : String(cause)}`,
|
|
184
|
+
);
|
|
185
|
+
error.isNetworkError = true;
|
|
186
|
+
throw error;
|
|
187
|
+
}
|
|
188
|
+
const bodyText = await response.text();
|
|
189
|
+
if (!response.ok) {
|
|
190
|
+
throw new TwentyGraphqlError(
|
|
191
|
+
`Twenty GraphQL HTTP ${response.status}: ${bodyText}`,
|
|
192
|
+
{
|
|
193
|
+
httpStatus: response.status,
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
let body: {
|
|
198
|
+
data?: T;
|
|
199
|
+
errors?: Array<{ message: string; extensions?: Record<string, unknown> }>;
|
|
200
|
+
};
|
|
201
|
+
try {
|
|
202
|
+
body = JSON.parse(bodyText);
|
|
203
|
+
} catch {
|
|
204
|
+
throw new TwentyGraphqlError(
|
|
205
|
+
`Twenty GraphQL returned non-JSON body: ${bodyText.slice(0, 300)}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
if (body.errors?.length) {
|
|
209
|
+
throw new TwentyGraphqlError(
|
|
210
|
+
`Twenty GraphQL errors: ${JSON.stringify(body.errors)}`,
|
|
211
|
+
{
|
|
212
|
+
errors: body.errors,
|
|
213
|
+
},
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (body.data === undefined || body.data === null) {
|
|
217
|
+
throw new TwentyGraphqlError("Twenty GraphQL returned no data.");
|
|
218
|
+
}
|
|
219
|
+
return body.data;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Read request with exponential backoff on 429/5xx. Only use for queries —
|
|
224
|
+
* retrying a mutation blind can double-write (plan KTD3).
|
|
225
|
+
*/
|
|
226
|
+
async requestWithRetry<T = Record<string, unknown>>(
|
|
227
|
+
path: TwentyEndpointPath,
|
|
228
|
+
query: string,
|
|
229
|
+
variables: Record<string, unknown> = {},
|
|
230
|
+
): Promise<T> {
|
|
231
|
+
let attempt = 0;
|
|
232
|
+
for (;;) {
|
|
233
|
+
try {
|
|
234
|
+
return await this.requestOnce<T>(path, query, variables);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
const retryable =
|
|
237
|
+
error instanceof TwentyGraphqlError && error.isRetryable;
|
|
238
|
+
if (!retryable || attempt >= this.maxRetries) throw error;
|
|
239
|
+
const delay =
|
|
240
|
+
this.backoffMs * 2 ** attempt + Math.floor(Math.random() * 250);
|
|
241
|
+
await sleep(delay);
|
|
242
|
+
attempt += 1;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|