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.
Files changed (152) hide show
  1. package/dist/{api-client-4VT3FWOH.js → api-client-JUBCQQDF.js} +1 -1
  2. package/dist/{chunk-H7AB42ES.js → chunk-STY44FQO.js} +13 -0
  3. package/dist/cli.js +1107 -10225
  4. package/dist/commands/enterprise/templates/deploy-repo/docs/runbook.md +3 -8
  5. package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +1 -184
  6. package/dist/drizzle/0050_brain_v0_entity_subtype.sql +2 -2
  7. package/dist/drizzle/0051_brain_v0_tenant_entity_pages.sql +6 -6
  8. package/dist/drizzle/0052_brain_v0_external_refs.sql +2 -2
  9. package/dist/drizzle/0053_brain_v0_unresolved_mentions_subtype.sql +2 -2
  10. package/dist/drizzle/0145_knowledge_graph_thread_ingest.sql +14 -14
  11. package/dist/drizzle/0146_knowledge_graph_source_scope.sql +1 -1
  12. package/dist/drizzle/0157_knowledge_graph_observations_source.sql +2 -2
  13. package/dist/drizzle/0158_wiki_tenant_scope.sql +1 -1
  14. package/dist/drizzle/0165_plugin_premium_entitlements.sql +2 -2
  15. package/dist/drizzle/{0166_company_brain_substrate_contract.sql → 0166_brain_substrate_contract.sql} +9 -9
  16. package/dist/drizzle/{0167_company_brain_artifact_manifest_runtime.sql → 0167_brain_artifact_manifest_runtime.sql} +5 -5
  17. package/dist/drizzle/0168_eval_skill_gate.sql +1 -1
  18. package/dist/drizzle/0183_okf_artifact_manifests.sql +1 -1
  19. package/dist/drizzle/0197_rcm_context_columns.sql +28 -0
  20. package/dist/drizzle/0200_eval_results_execution_tier.sql +40 -0
  21. package/dist/drizzle/0201_brain_dream_runs.sql +72 -0
  22. package/dist/drizzle/0202_backfill_thread_participants_from_senders.sql +110 -0
  23. package/dist/drizzle/0203_threads_mode_override.sql +61 -0
  24. package/dist/drizzle/0204_thread_turns_triggering_message_id.sql +48 -0
  25. package/dist/drizzle/0205_routines_git_python.sql +185 -0
  26. package/dist/drizzle/0206_agent_loop_versions_routine_actions.sql +40 -0
  27. package/dist/drizzle/0207_tenant_credentials_github_repo_kind.sql +52 -0
  28. package/dist/drizzle/0208_knowledge_graph_neutral_identifiers.sql +253 -0
  29. package/dist/drizzle/0209_living_artifacts_schema.sql +109 -0
  30. package/dist/drizzle/0210_think137_automations_additive_schema.sql +113 -0
  31. package/dist/drizzle/0211_backfill_agent_loop_versions_target_spec.sql +120 -0
  32. package/dist/drizzle/0212_fold_webhooks_into_automations.sql +312 -0
  33. package/dist/drizzle/0213_agent_loop_versions_judge_evidence_nullable.sql +63 -0
  34. package/dist/drizzle/0214_think137_drop_judge_evidence_roi.sql +99 -0
  35. package/dist/drizzle/0215_agent_loop_versions_goal_worker_policy_nullable.sql +70 -0
  36. package/dist/drizzle/0216_drop_agent_loop_versions_goal_worker_policy.sql +81 -0
  37. package/dist/drizzle/0217_agents_capability_folder_dispatch.sql +12 -0
  38. package/dist/drizzle/0218_document_plates.sql +30 -0
  39. package/dist/drizzle/0219_document_section_waivers.sql +29 -0
  40. package/dist/drizzle/0220_artifacts_created_by_user.sql +21 -0
  41. package/dist/drizzle/0221_artifact_shares.sql +29 -0
  42. package/dist/drizzle/0221_workflow_interpreter_thin_slice.sql +71 -0
  43. package/dist/drizzle/0222_compliance_drainer_select_audit_events.sql +21 -0
  44. package/dist/drizzle/0223_document_conformance_reports.sql +38 -0
  45. package/dist/drizzle/0224_artifact_refresh_state.sql +11 -0
  46. package/dist/drizzle/0225_think216_agent_loop_convergence.sql +21 -0
  47. package/dist/drizzle/0226_drop_n8n_agent_step_runs.sql +6 -0
  48. package/dist/drizzle/0227_analyst_reader_role.sql +1055 -0
  49. package/dist/drizzle/0228_tenant_credentials_rds_iam_kind.sql +62 -0
  50. package/dist/drizzle/0229_analyst_reader_rds_iam_grant.sql +79 -0
  51. package/dist/drizzle/0230_analyst_rls.sql +1613 -0
  52. package/dist/drizzle/0231_cost_events_cache_write_enforcement_exempt.sql +11 -0
  53. package/dist/plugins/catalog/package.json +0 -1
  54. package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +0 -1
  55. package/dist/plugins/catalog/src/__tests__/catalog.test.ts +16 -8
  56. package/dist/plugins/catalog/src/__tests__/contracts.test.ts +56 -0
  57. package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +0 -2
  58. package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -5
  59. package/dist/plugins/catalog/src/contracts.ts +112 -0
  60. package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -6
  61. package/dist/plugins/n8n/README.md +4 -36
  62. package/dist/plugins/n8n/src/deployment/managed-app.ts +0 -17
  63. package/dist/plugins/n8n/src/manifest.ts +0 -5
  64. package/dist/plugins/n8n/terraform/n8n/main.tf +0 -26
  65. package/dist/plugins/n8n/terraform/n8n/outputs.tf +0 -5
  66. package/dist/plugins/n8n/terraform/n8n/variables.tf +0 -6
  67. package/dist/plugins/n8n/test/manifest.test.ts +0 -1
  68. package/dist/plugins/twenty/package.json +5 -0
  69. package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +232 -0
  70. package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +489 -0
  71. package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +199 -0
  72. package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +43 -0
  73. package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +230 -0
  74. package/dist/plugins/twenty/scripts/lib/__tests__/twenty-client.test.ts +207 -0
  75. package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +282 -0
  76. package/dist/plugins/twenty/scripts/lib/load-attachments.ts +220 -0
  77. package/dist/plugins/twenty/scripts/lib/load-records.ts +680 -0
  78. package/dist/plugins/twenty/scripts/lib/mappers.ts +559 -0
  79. package/dist/plugins/twenty/scripts/lib/members-ensure.ts +381 -0
  80. package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +191 -0
  81. package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +425 -0
  82. package/dist/plugins/twenty/scripts/lib/twenty-client.ts +246 -0
  83. package/dist/plugins/twenty/scripts/migrate-lastmile.ts +610 -0
  84. package/dist/plugins/twenty/scripts/provision-twenty-members.ts +214 -0
  85. package/dist/plugins/twenty/src/manifest.ts +99 -0
  86. package/dist/plugins/twenty/terraform/twenty/README.md +1 -1
  87. package/dist/plugins/twenty/test/manifest.test.ts +12 -2
  88. package/dist/terraform/examples/greenfield/main.tf +90 -380
  89. package/dist/terraform/examples/greenfield/terraform.tfvars.example +0 -1
  90. package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
  91. package/dist/terraform/modules/app/agentcore-pi/main.tf +36 -32
  92. package/dist/terraform/modules/app/agentcore-pi/variables.tf +23 -15
  93. package/dist/terraform/modules/app/agentcore-runtime/main.tf +3 -3
  94. package/dist/terraform/modules/app/customer-domain/main.tf +16 -2
  95. package/dist/terraform/modules/app/deployment-control-plane/main.tf +5 -0
  96. package/dist/terraform/modules/app/deployment-control-plane/runner.py +85 -207
  97. package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +56 -2
  98. package/dist/terraform/modules/app/hindsight-memory/main.tf +52 -4
  99. package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +7 -0
  100. package/dist/terraform/modules/app/lambda-api/handlers.tf +493 -89
  101. package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +215 -96
  102. package/dist/terraform/modules/app/lambda-api/main.tf +24 -5
  103. package/dist/terraform/modules/app/lambda-api/outputs.tf +19 -11
  104. package/dist/terraform/modules/app/lambda-api/variables.tf +94 -67
  105. package/dist/terraform/modules/app/ses-email/main.tf +14 -2
  106. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/main.tf +493 -0
  107. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/outputs.tf +34 -0
  108. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/variables.tf +32 -0
  109. package/dist/terraform/modules/data/aurora-postgres/main.tf +55 -0
  110. package/dist/terraform/modules/data/aurora-postgres/outputs.tf +15 -0
  111. package/dist/terraform/modules/data/s3-buckets/main.tf +26 -0
  112. package/dist/terraform/modules/thinkwork/README.md +0 -1
  113. package/dist/terraform/modules/thinkwork/main.tf +167 -446
  114. package/dist/terraform/modules/thinkwork/outputs.tf +2 -142
  115. package/dist/terraform/modules/thinkwork/variables.tf +55 -309
  116. package/dist/terraform/schema.graphql +4 -0
  117. package/dist/workspace-defaults/files/AGENTS.md +45 -31
  118. package/dist/workspace-defaults/files/CONTEXT.md +48 -4
  119. package/dist/workspace-defaults/files/GUARDRAILS.md +18 -0
  120. package/dist/workspace-defaults/files/MEMORY_GUIDE.md +32 -4
  121. package/dist/workspace-defaults/files/SPACE.md +34 -19
  122. package/dist/workspace-defaults/files/TOOLS.md +30 -0
  123. package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +154 -0
  124. package/dist/workspace-defaults/files/skills/document-composer/references/authoring-rules.md +101 -0
  125. package/package.json +2 -1
  126. package/dist/plugins/company-brain/README.md +0 -37
  127. package/dist/plugins/company-brain/package.json +0 -33
  128. package/dist/plugins/company-brain/runtime/cognee/Dockerfile +0 -11
  129. package/dist/plugins/company-brain/smoke/cognee-managed-app-smoke.mjs +0 -458
  130. package/dist/plugins/company-brain/smoke/cognee-memory-cutover-smoke.mjs +0 -539
  131. package/dist/plugins/company-brain/smoke/company-brain-context-engine-smoke.mjs +0 -312
  132. package/dist/plugins/company-brain/smoke/company-brain-operations-smoke.mjs +0 -449
  133. package/dist/plugins/company-brain/smoke/company-brain-plugin-smoke.mjs +0 -655
  134. package/dist/plugins/company-brain/smoke/hindsight-memory-isolation-smoke.mjs +0 -474
  135. package/dist/plugins/company-brain/src/api/cognee-client.ts +0 -896
  136. package/dist/plugins/company-brain/src/api/cognee-cluster-identity.ts +0 -49
  137. package/dist/plugins/company-brain/src/api/cognee-memory-scope.ts +0 -84
  138. package/dist/plugins/company-brain/src/api/context-engine-provider.ts +0 -888
  139. package/dist/plugins/company-brain/src/api/migration.ts +0 -665
  140. package/dist/plugins/company-brain/src/deployment/cognee-managed-app.ts +0 -396
  141. package/dist/plugins/company-brain/src/index.ts +0 -62
  142. package/dist/plugins/company-brain/src/manifest.ts +0 -112
  143. package/dist/plugins/company-brain/terraform/cognee/README.md +0 -263
  144. package/dist/plugins/company-brain/terraform/cognee/main.tf +0 -766
  145. package/dist/plugins/company-brain/terraform/cognee/outputs.tf +0 -144
  146. package/dist/plugins/company-brain/terraform/cognee/variables.tf +0 -402
  147. package/dist/plugins/company-brain/test/api/cognee-client.test.ts +0 -562
  148. package/dist/plugins/company-brain/test/api/cognee-memory-scope.test.ts +0 -74
  149. package/dist/plugins/company-brain/test/api/context-engine-provider.test.ts +0 -447
  150. package/dist/plugins/company-brain/test/api/migration.test.ts +0 -465
  151. package/dist/plugins/company-brain/test/manifest.test.ts +0 -95
  152. 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
+ }