thinkwork-cli 0.12.16 → 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 (149) hide show
  1. package/dist/cli.js +517 -10403
  2. package/dist/commands/enterprise/templates/deploy-repo/docs/runbook.md +3 -8
  3. package/dist/commands/enterprise/templates/deploy-repo/terraform/main.tf +1 -184
  4. package/dist/drizzle/0050_brain_v0_entity_subtype.sql +2 -2
  5. package/dist/drizzle/0051_brain_v0_tenant_entity_pages.sql +6 -6
  6. package/dist/drizzle/0052_brain_v0_external_refs.sql +2 -2
  7. package/dist/drizzle/0053_brain_v0_unresolved_mentions_subtype.sql +2 -2
  8. package/dist/drizzle/0145_knowledge_graph_thread_ingest.sql +14 -14
  9. package/dist/drizzle/0146_knowledge_graph_source_scope.sql +1 -1
  10. package/dist/drizzle/0157_knowledge_graph_observations_source.sql +2 -2
  11. package/dist/drizzle/0158_wiki_tenant_scope.sql +1 -1
  12. package/dist/drizzle/0165_plugin_premium_entitlements.sql +2 -2
  13. package/dist/drizzle/{0166_company_brain_substrate_contract.sql → 0166_brain_substrate_contract.sql} +9 -9
  14. package/dist/drizzle/{0167_company_brain_artifact_manifest_runtime.sql → 0167_brain_artifact_manifest_runtime.sql} +5 -5
  15. package/dist/drizzle/0168_eval_skill_gate.sql +1 -1
  16. package/dist/drizzle/0183_okf_artifact_manifests.sql +1 -1
  17. package/dist/drizzle/0200_eval_results_execution_tier.sql +40 -0
  18. package/dist/drizzle/0201_brain_dream_runs.sql +72 -0
  19. package/dist/drizzle/0202_backfill_thread_participants_from_senders.sql +110 -0
  20. package/dist/drizzle/0203_threads_mode_override.sql +61 -0
  21. package/dist/drizzle/0204_thread_turns_triggering_message_id.sql +48 -0
  22. package/dist/drizzle/0205_routines_git_python.sql +185 -0
  23. package/dist/drizzle/0206_agent_loop_versions_routine_actions.sql +40 -0
  24. package/dist/drizzle/0207_tenant_credentials_github_repo_kind.sql +52 -0
  25. package/dist/drizzle/0208_knowledge_graph_neutral_identifiers.sql +253 -0
  26. package/dist/drizzle/0209_living_artifacts_schema.sql +109 -0
  27. package/dist/drizzle/0210_think137_automations_additive_schema.sql +113 -0
  28. package/dist/drizzle/0211_backfill_agent_loop_versions_target_spec.sql +120 -0
  29. package/dist/drizzle/0212_fold_webhooks_into_automations.sql +312 -0
  30. package/dist/drizzle/0213_agent_loop_versions_judge_evidence_nullable.sql +63 -0
  31. package/dist/drizzle/0214_think137_drop_judge_evidence_roi.sql +99 -0
  32. package/dist/drizzle/0215_agent_loop_versions_goal_worker_policy_nullable.sql +70 -0
  33. package/dist/drizzle/0216_drop_agent_loop_versions_goal_worker_policy.sql +81 -0
  34. package/dist/drizzle/0217_agents_capability_folder_dispatch.sql +12 -0
  35. package/dist/drizzle/0218_document_plates.sql +30 -0
  36. package/dist/drizzle/0219_document_section_waivers.sql +29 -0
  37. package/dist/drizzle/0220_artifacts_created_by_user.sql +21 -0
  38. package/dist/drizzle/0221_artifact_shares.sql +29 -0
  39. package/dist/drizzle/0221_workflow_interpreter_thin_slice.sql +71 -0
  40. package/dist/drizzle/0222_compliance_drainer_select_audit_events.sql +21 -0
  41. package/dist/drizzle/0223_document_conformance_reports.sql +38 -0
  42. package/dist/drizzle/0224_artifact_refresh_state.sql +11 -0
  43. package/dist/drizzle/0225_think216_agent_loop_convergence.sql +21 -0
  44. package/dist/drizzle/0226_drop_n8n_agent_step_runs.sql +6 -0
  45. package/dist/drizzle/0227_analyst_reader_role.sql +1055 -0
  46. package/dist/drizzle/0228_tenant_credentials_rds_iam_kind.sql +62 -0
  47. package/dist/drizzle/0229_analyst_reader_rds_iam_grant.sql +79 -0
  48. package/dist/drizzle/0230_analyst_rls.sql +1613 -0
  49. package/dist/drizzle/0231_cost_events_cache_write_enforcement_exempt.sql +11 -0
  50. package/dist/plugins/catalog/package.json +0 -1
  51. package/dist/plugins/catalog/src/__tests__/build-catalog.test.ts +0 -1
  52. package/dist/plugins/catalog/src/__tests__/catalog.test.ts +16 -8
  53. package/dist/plugins/catalog/src/__tests__/contracts.test.ts +56 -0
  54. package/dist/plugins/catalog/src/__tests__/plugin-package.test.ts +0 -2
  55. package/dist/plugins/catalog/src/__tests__/plugin-registry.test.ts +0 -5
  56. package/dist/plugins/catalog/src/contracts.ts +112 -0
  57. package/dist/plugins/catalog/src/registry/generated-first-party.ts +0 -6
  58. package/dist/plugins/n8n/README.md +4 -36
  59. package/dist/plugins/n8n/src/deployment/managed-app.ts +0 -17
  60. package/dist/plugins/n8n/src/manifest.ts +0 -5
  61. package/dist/plugins/n8n/terraform/n8n/main.tf +0 -26
  62. package/dist/plugins/n8n/terraform/n8n/outputs.tf +0 -5
  63. package/dist/plugins/n8n/terraform/n8n/variables.tf +0 -6
  64. package/dist/plugins/n8n/test/manifest.test.ts +0 -1
  65. package/dist/plugins/twenty/package.json +5 -0
  66. package/dist/plugins/twenty/scripts/lib/__tests__/load-records.test.ts +232 -0
  67. package/dist/plugins/twenty/scripts/lib/__tests__/mappers.test.ts +489 -0
  68. package/dist/plugins/twenty/scripts/lib/__tests__/members-ensure.test.ts +199 -0
  69. package/dist/plugins/twenty/scripts/lib/__tests__/provision-members-db.test.ts +43 -0
  70. package/dist/plugins/twenty/scripts/lib/__tests__/schema-ensure.test.ts +230 -0
  71. package/dist/plugins/twenty/scripts/lib/__tests__/twenty-client.test.ts +207 -0
  72. package/dist/plugins/twenty/scripts/lib/lastmile-reader.ts +282 -0
  73. package/dist/plugins/twenty/scripts/lib/load-attachments.ts +220 -0
  74. package/dist/plugins/twenty/scripts/lib/load-records.ts +680 -0
  75. package/dist/plugins/twenty/scripts/lib/mappers.ts +559 -0
  76. package/dist/plugins/twenty/scripts/lib/members-ensure.ts +381 -0
  77. package/dist/plugins/twenty/scripts/lib/provision-members-db.ts +191 -0
  78. package/dist/plugins/twenty/scripts/lib/schema-ensure.ts +425 -0
  79. package/dist/plugins/twenty/scripts/lib/twenty-client.ts +246 -0
  80. package/dist/plugins/twenty/scripts/migrate-lastmile.ts +610 -0
  81. package/dist/plugins/twenty/scripts/provision-twenty-members.ts +214 -0
  82. package/dist/plugins/twenty/src/manifest.ts +99 -0
  83. package/dist/plugins/twenty/terraform/twenty/README.md +1 -1
  84. package/dist/plugins/twenty/test/manifest.test.ts +12 -2
  85. package/dist/terraform/examples/greenfield/main.tf +90 -380
  86. package/dist/terraform/examples/greenfield/terraform.tfvars.example +0 -1
  87. package/dist/terraform/modules/app/agentcore-pi/README.md +1 -3
  88. package/dist/terraform/modules/app/agentcore-pi/main.tf +29 -38
  89. package/dist/terraform/modules/app/agentcore-pi/variables.tf +23 -15
  90. package/dist/terraform/modules/app/agentcore-runtime/main.tf +3 -3
  91. package/dist/terraform/modules/app/customer-domain/main.tf +16 -2
  92. package/dist/terraform/modules/app/deployment-control-plane/main.tf +5 -0
  93. package/dist/terraform/modules/app/deployment-control-plane/runner.py +85 -207
  94. package/dist/terraform/modules/app/deployment-control-plane/test_runner_bundle.py +56 -2
  95. package/dist/terraform/modules/app/hindsight-memory/main.tf +52 -4
  96. package/dist/terraform/modules/app/lambda-api/eval-fanout.tf +7 -0
  97. package/dist/terraform/modules/app/lambda-api/handlers.tf +493 -89
  98. package/dist/terraform/modules/app/lambda-api/iam-grouped.tf +215 -96
  99. package/dist/terraform/modules/app/lambda-api/main.tf +24 -5
  100. package/dist/terraform/modules/app/lambda-api/outputs.tf +19 -11
  101. package/dist/terraform/modules/app/lambda-api/variables.tf +94 -67
  102. package/dist/terraform/modules/app/ses-email/main.tf +14 -2
  103. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/main.tf +493 -0
  104. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/outputs.tf +34 -0
  105. package/dist/terraform/modules/app/workflow-interpreter-stepfunctions/variables.tf +32 -0
  106. package/dist/terraform/modules/data/aurora-postgres/main.tf +55 -0
  107. package/dist/terraform/modules/data/aurora-postgres/outputs.tf +15 -0
  108. package/dist/terraform/modules/data/s3-buckets/main.tf +26 -0
  109. package/dist/terraform/modules/thinkwork/README.md +0 -1
  110. package/dist/terraform/modules/thinkwork/main.tf +167 -446
  111. package/dist/terraform/modules/thinkwork/outputs.tf +2 -142
  112. package/dist/terraform/modules/thinkwork/variables.tf +55 -309
  113. package/dist/terraform/schema.graphql +4 -0
  114. package/dist/workspace-defaults/files/AGENTS.md +45 -31
  115. package/dist/workspace-defaults/files/CONTEXT.md +48 -4
  116. package/dist/workspace-defaults/files/GUARDRAILS.md +18 -0
  117. package/dist/workspace-defaults/files/MEMORY_GUIDE.md +32 -4
  118. package/dist/workspace-defaults/files/SPACE.md +34 -19
  119. package/dist/workspace-defaults/files/TOOLS.md +30 -0
  120. package/dist/workspace-defaults/files/skills/document-composer/SKILL.md +154 -0
  121. package/dist/workspace-defaults/files/skills/document-composer/references/authoring-rules.md +101 -0
  122. package/package.json +1 -1
  123. package/dist/plugins/company-brain/README.md +0 -37
  124. package/dist/plugins/company-brain/package.json +0 -33
  125. package/dist/plugins/company-brain/runtime/cognee/Dockerfile +0 -11
  126. package/dist/plugins/company-brain/smoke/cognee-managed-app-smoke.mjs +0 -458
  127. package/dist/plugins/company-brain/smoke/cognee-memory-cutover-smoke.mjs +0 -539
  128. package/dist/plugins/company-brain/smoke/company-brain-context-engine-smoke.mjs +0 -312
  129. package/dist/plugins/company-brain/smoke/company-brain-operations-smoke.mjs +0 -449
  130. package/dist/plugins/company-brain/smoke/company-brain-plugin-smoke.mjs +0 -655
  131. package/dist/plugins/company-brain/smoke/hindsight-memory-isolation-smoke.mjs +0 -474
  132. package/dist/plugins/company-brain/src/api/cognee-client.ts +0 -896
  133. package/dist/plugins/company-brain/src/api/cognee-cluster-identity.ts +0 -49
  134. package/dist/plugins/company-brain/src/api/cognee-memory-scope.ts +0 -84
  135. package/dist/plugins/company-brain/src/api/context-engine-provider.ts +0 -888
  136. package/dist/plugins/company-brain/src/api/migration.ts +0 -665
  137. package/dist/plugins/company-brain/src/deployment/cognee-managed-app.ts +0 -396
  138. package/dist/plugins/company-brain/src/index.ts +0 -62
  139. package/dist/plugins/company-brain/src/manifest.ts +0 -112
  140. package/dist/plugins/company-brain/terraform/cognee/README.md +0 -263
  141. package/dist/plugins/company-brain/terraform/cognee/main.tf +0 -766
  142. package/dist/plugins/company-brain/terraform/cognee/outputs.tf +0 -144
  143. package/dist/plugins/company-brain/terraform/cognee/variables.tf +0 -402
  144. package/dist/plugins/company-brain/test/api/cognee-client.test.ts +0 -562
  145. package/dist/plugins/company-brain/test/api/cognee-memory-scope.test.ts +0 -74
  146. package/dist/plugins/company-brain/test/api/context-engine-provider.test.ts +0 -447
  147. package/dist/plugins/company-brain/test/api/migration.test.ts +0 -465
  148. package/dist/plugins/company-brain/test/manifest.test.ts +0 -95
  149. package/dist/plugins/company-brain/tsconfig.json +0 -8
@@ -0,0 +1,680 @@
1
+ /**
2
+ * Upsert-by-sourceId record loading, deletion mirroring, rollback, and the
3
+ * parity report (plan U5–U7, KTD3/KTD6/KTD7).
4
+ *
5
+ * Idempotency is query-then-branch on the unique `sourceId` custom field —
6
+ * Twenty has no public upsert. Lookups include soft-deleted records (a miss on
7
+ * a soft-deleted twin would create a live duplicate): missing → create; found
8
+ * active → sourceHash diff, update or skip; found soft-deleted with a live
9
+ * source row → restore + update. Two live records sharing a sourceId abort.
10
+ */
11
+
12
+ import { contentHash } from "./mappers";
13
+ import type {
14
+ MappedNote,
15
+ MappedOpportunityProduct,
16
+ MappedRecord,
17
+ } from "./mappers";
18
+ import {
19
+ BATCH_LIMIT,
20
+ chunk,
21
+ TwentyClient,
22
+ TwentyGraphqlError,
23
+ } from "./twenty-client";
24
+
25
+ const QUERY_PAGE = 200;
26
+
27
+ /** True when Twenty rejects a filter because the sourceId custom field does
28
+ * not exist yet (virgin workspace before schema-ensure applies — the dry-run
29
+ * plans the field but cannot create it). No field ⇒ no record carries it. */
30
+ export function isMissingSourceIdFieldError(error: unknown): boolean {
31
+ return (
32
+ error instanceof TwentyGraphqlError &&
33
+ error.errors.some((entry) =>
34
+ /doesn't have any .?sourceId.? field/i.test(entry.message),
35
+ )
36
+ );
37
+ }
38
+
39
+ export interface EntityShape {
40
+ /** e.g. "company" */
41
+ singular: string;
42
+ /** e.g. "companies" */
43
+ plural: string;
44
+ /** Capitalized singular, e.g. "Company" — for mutation names. */
45
+ capSingular: string;
46
+ /** Capitalized plural, e.g. "Companies". */
47
+ capPlural: string;
48
+ }
49
+
50
+ export const COMPANY: EntityShape = {
51
+ singular: "company",
52
+ plural: "companies",
53
+ capSingular: "Company",
54
+ capPlural: "Companies",
55
+ };
56
+ export const PERSON: EntityShape = {
57
+ singular: "person",
58
+ plural: "people",
59
+ capSingular: "Person",
60
+ capPlural: "People",
61
+ };
62
+ export const OPPORTUNITY: EntityShape = {
63
+ singular: "opportunity",
64
+ plural: "opportunities",
65
+ capSingular: "Opportunity",
66
+ capPlural: "Opportunities",
67
+ };
68
+ export const OPPORTUNITY_PRODUCT: EntityShape = {
69
+ singular: "opportunityProduct",
70
+ plural: "opportunityProducts",
71
+ capSingular: "OpportunityProduct",
72
+ capPlural: "OpportunityProducts",
73
+ };
74
+ export const NOTE: EntityShape = {
75
+ singular: "note",
76
+ plural: "notes",
77
+ capSingular: "Note",
78
+ capPlural: "Notes",
79
+ };
80
+
81
+ export interface ExistingRecord {
82
+ id: string;
83
+ sourceId: string;
84
+ sourceHash: string | null;
85
+ deletedAt: string | null;
86
+ }
87
+
88
+ export interface EntityCounters {
89
+ sourceTotal: number;
90
+ created: number;
91
+ updated: number;
92
+ restored: number;
93
+ skipped: number;
94
+ deleted: number;
95
+ failed: number;
96
+ plannedMutations: string[];
97
+ gaps: string[];
98
+ warnings: string[];
99
+ }
100
+
101
+ export function emptyCounters(): EntityCounters {
102
+ return {
103
+ sourceTotal: 0,
104
+ created: 0,
105
+ updated: 0,
106
+ restored: 0,
107
+ skipped: 0,
108
+ deleted: 0,
109
+ failed: 0,
110
+ plannedMutations: [],
111
+ gaps: [],
112
+ warnings: [],
113
+ };
114
+ }
115
+
116
+ /**
117
+ * Fetch existing Twenty records for the given sourceIds — active AND
118
+ * soft-deleted (two filtered passes; Twenty's default queries exclude deleted
119
+ * rows). Aborts when two live records share a sourceId.
120
+ */
121
+ export async function fetchExistingBySourceIds(
122
+ client: TwentyClient,
123
+ entity: EntityShape,
124
+ ids: readonly string[],
125
+ ): Promise<Map<string, ExistingRecord>> {
126
+ const found = new Map<string, ExistingRecord>();
127
+ const query = `
128
+ query MigrationExisting${entity.capPlural}($filter: ${entity.capSingular}FilterInput) {
129
+ ${entity.plural}(filter: $filter, first: ${QUERY_PAGE}) {
130
+ edges { node { id sourceId sourceHash deletedAt } }
131
+ }
132
+ }
133
+ `;
134
+ for (const page of chunk(ids, QUERY_PAGE)) {
135
+ for (const filter of [
136
+ { sourceId: { in: [...page] } },
137
+ { sourceId: { in: [...page] }, deletedAt: { is: "NOT_NULL" } },
138
+ ]) {
139
+ let data: {
140
+ [plural: string]: { edges: Array<{ node: ExistingRecord }> };
141
+ };
142
+ try {
143
+ data = await client.requestWithRetry<{
144
+ [plural: string]: { edges: Array<{ node: ExistingRecord }> };
145
+ }>("/graphql", query, { filter });
146
+ } catch (error) {
147
+ if (isMissingSourceIdFieldError(error)) return found;
148
+ throw error;
149
+ }
150
+ for (const { node } of data[entity.plural].edges) {
151
+ const existing = found.get(node.sourceId);
152
+ if (existing && existing.id !== node.id) {
153
+ if (!existing.deletedAt && !node.deletedAt) {
154
+ throw new Error(
155
+ `Two live ${entity.plural} share sourceId ${node.sourceId} (${existing.id}, ${node.id}) — aborting; reconcile manually.`,
156
+ );
157
+ }
158
+ // Prefer the live record over a soft-deleted twin.
159
+ if (existing.deletedAt && !node.deletedAt)
160
+ found.set(node.sourceId, node);
161
+ continue;
162
+ }
163
+ found.set(node.sourceId, node);
164
+ }
165
+ }
166
+ }
167
+ return found;
168
+ }
169
+
170
+ export interface UpsertOptions {
171
+ client: TwentyClient;
172
+ entity: EntityShape;
173
+ mapped: MappedRecord[];
174
+ dryRun: boolean;
175
+ counters: EntityCounters;
176
+ /** Extra fields ignored when creating (e.g. none today); test seam. */
177
+ onCreated?: (sourceId: string, twentyId: string) => void;
178
+ }
179
+
180
+ /**
181
+ * Core upsert pass. Returns sourceId → Twenty id for every record that exists
182
+ * after the pass (created, updated, restored, or skipped), so later phases can
183
+ * resolve relations.
184
+ */
185
+ export async function upsertRecords(
186
+ options: UpsertOptions,
187
+ ): Promise<Map<string, string>> {
188
+ const { client, entity, mapped, dryRun, counters } = options;
189
+ counters.sourceTotal += mapped.length;
190
+ for (const record of mapped) counters.warnings.push(...record.warnings);
191
+
192
+ const existing = await fetchExistingBySourceIds(
193
+ client,
194
+ entity,
195
+ mapped.map((record) => record.sourceId),
196
+ );
197
+
198
+ const idBySourceId = new Map<string, string>();
199
+ const toCreate: MappedRecord[] = [];
200
+ const toUpdate: Array<{
201
+ record: MappedRecord;
202
+ twentyId: string;
203
+ restore: boolean;
204
+ }> = [];
205
+
206
+ for (const record of mapped) {
207
+ const hash = contentHash(record.input);
208
+ const current = existing.get(record.sourceId);
209
+ if (!current) {
210
+ toCreate.push(record);
211
+ continue;
212
+ }
213
+ idBySourceId.set(record.sourceId, current.id);
214
+ if (current.deletedAt) {
215
+ toUpdate.push({ record, twentyId: current.id, restore: true });
216
+ } else if (current.sourceHash !== hash) {
217
+ toUpdate.push({ record, twentyId: current.id, restore: false });
218
+ } else {
219
+ counters.skipped += 1;
220
+ }
221
+ }
222
+
223
+ if (dryRun) {
224
+ for (const record of toCreate) {
225
+ counters.created += 1;
226
+ counters.plannedMutations.push(
227
+ `create ${entity.singular} ${record.sourceId}`,
228
+ );
229
+ }
230
+ for (const { record, restore } of toUpdate) {
231
+ if (restore) {
232
+ counters.restored += 1;
233
+ counters.plannedMutations.push(
234
+ `restore+update ${entity.singular} ${record.sourceId}`,
235
+ );
236
+ } else {
237
+ counters.updated += 1;
238
+ counters.plannedMutations.push(
239
+ `update ${entity.singular} ${record.sourceId}`,
240
+ );
241
+ }
242
+ }
243
+ return idBySourceId;
244
+ }
245
+
246
+ // Creates — batched ≤60; on batch failure fall back per-record to isolate.
247
+ const createMutation = `
248
+ mutation MigrationCreate${entity.capPlural}($data: [${entity.capSingular}CreateInput!]!) {
249
+ create${entity.capPlural}(data: $data) { id sourceId }
250
+ }
251
+ `;
252
+ for (const batch of chunk(toCreate, BATCH_LIMIT)) {
253
+ const data = batch.map((record) => ({
254
+ ...record.input,
255
+ sourceHash: contentHash(record.input),
256
+ }));
257
+ try {
258
+ const result = await client.requestOnce<{
259
+ [key: string]: Array<{ id: string; sourceId: string }>;
260
+ }>("/graphql", createMutation, { data });
261
+ for (const node of result[`create${entity.capPlural}`]) {
262
+ idBySourceId.set(node.sourceId, node.id);
263
+ options.onCreated?.(node.sourceId, node.id);
264
+ counters.created += 1;
265
+ }
266
+ } catch (error) {
267
+ // Ambiguous batch failure: re-query by sourceId before any re-attempt
268
+ // (KTD3 — never blind-retry a create), then create the true misses
269
+ // one-by-one so a single bad row can't sink the batch.
270
+ const requeried = await fetchExistingBySourceIds(
271
+ client,
272
+ entity,
273
+ batch.map((record) => record.sourceId),
274
+ );
275
+ for (const record of batch) {
276
+ const current = requeried.get(record.sourceId);
277
+ if (current) {
278
+ idBySourceId.set(record.sourceId, current.id);
279
+ counters.created += 1;
280
+ continue;
281
+ }
282
+ try {
283
+ const single = await client.requestOnce<{
284
+ [key: string]: Array<{ id: string; sourceId: string }>;
285
+ }>("/graphql", createMutation, {
286
+ data: [{ ...record.input, sourceHash: contentHash(record.input) }],
287
+ });
288
+ const node = single[`create${entity.capPlural}`][0];
289
+ idBySourceId.set(node.sourceId, node.id);
290
+ counters.created += 1;
291
+ } catch (singleError) {
292
+ counters.failed += 1;
293
+ counters.gaps.push(
294
+ `create ${entity.singular} ${record.sourceId} failed: ${
295
+ singleError instanceof Error
296
+ ? singleError.message.slice(0, 300)
297
+ : String(singleError)
298
+ }`,
299
+ );
300
+ }
301
+ }
302
+ void error;
303
+ }
304
+ }
305
+
306
+ // Restores then updates — per record.
307
+ const restoreMutation = `
308
+ mutation MigrationRestore${entity.capSingular}($id: UUID!) {
309
+ restore${entity.capSingular}(id: $id) { id }
310
+ }
311
+ `;
312
+ const updateMutation = `
313
+ mutation MigrationUpdate${entity.capSingular}($id: UUID!, $data: ${entity.capSingular}UpdateInput!) {
314
+ update${entity.capSingular}(id: $id, data: $data) { id }
315
+ }
316
+ `;
317
+ for (const { record, twentyId, restore } of toUpdate) {
318
+ try {
319
+ if (restore) {
320
+ await client.requestOnce("/graphql", restoreMutation, { id: twentyId });
321
+ }
322
+ await client.requestOnce("/graphql", updateMutation, {
323
+ id: twentyId,
324
+ data: { ...record.input, sourceHash: contentHash(record.input) },
325
+ });
326
+ if (restore) counters.restored += 1;
327
+ else counters.updated += 1;
328
+ } catch (error) {
329
+ counters.failed += 1;
330
+ counters.gaps.push(
331
+ `${restore ? "restore" : "update"} ${entity.singular} ${record.sourceId} failed: ${
332
+ error instanceof Error ? error.message.slice(0, 300) : String(error)
333
+ }`,
334
+ );
335
+ }
336
+ }
337
+
338
+ return idBySourceId;
339
+ }
340
+
341
+ /**
342
+ * Deletion mirror (R13, KTD7): soft-delete Twenty records whose sourceId no
343
+ * longer exists live in LastMile. LastMile's CRM tables have no dead-mark
344
+ * columns (2026-07-09 schema read), so "dead" = absent from the current source
345
+ * id set — an id-set diff. Records without a sourceId are never candidates.
346
+ */
347
+ export async function mirrorDeletions(options: {
348
+ client: TwentyClient;
349
+ entity: EntityShape;
350
+ /** Every sourceId currently live in LastMile for this entity. */
351
+ liveSourceIds: ReadonlySet<string>;
352
+ /** Namespace prefixes owned by the migration for this entity, e.g. ["lead:", "opportunity:"]. */
353
+ ownedPrefixes: readonly string[];
354
+ dryRun: boolean;
355
+ counters: EntityCounters;
356
+ }): Promise<void> {
357
+ const { client, entity, liveSourceIds, ownedPrefixes, dryRun, counters } =
358
+ options;
359
+ const query = `
360
+ query MigrationAll${entity.capPlural}($filter: ${entity.capSingular}FilterInput, $after: String) {
361
+ ${entity.plural}(filter: $filter, first: ${QUERY_PAGE}, after: $after) {
362
+ edges { node { id sourceId } cursor }
363
+ pageInfo { hasNextPage endCursor }
364
+ }
365
+ }
366
+ `;
367
+ const staleIds: Array<{ id: string; sourceId: string }> = [];
368
+ let after: string | null = null;
369
+ for (;;) {
370
+ let data: {
371
+ [plural: string]: {
372
+ edges: Array<{ node: { id: string; sourceId: string | null } }>;
373
+ pageInfo: { hasNextPage: boolean; endCursor: string | null };
374
+ };
375
+ };
376
+ try {
377
+ data = await client.requestWithRetry("/graphql", query, {
378
+ filter: { sourceId: { is: "NOT_NULL" } },
379
+ ...(after ? { after } : {}),
380
+ });
381
+ } catch (error) {
382
+ if (isMissingSourceIdFieldError(error)) return;
383
+ throw error;
384
+ }
385
+ for (const { node } of data[entity.plural].edges) {
386
+ if (!node.sourceId) continue;
387
+ if (!ownedPrefixes.some((prefix) => node.sourceId!.startsWith(prefix)))
388
+ continue;
389
+ if (!liveSourceIds.has(node.sourceId)) {
390
+ staleIds.push({ id: node.id, sourceId: node.sourceId });
391
+ }
392
+ }
393
+ if (!data[entity.plural].pageInfo.hasNextPage) break;
394
+ after = data[entity.plural].pageInfo.endCursor;
395
+ }
396
+
397
+ if (dryRun) {
398
+ for (const stale of staleIds) {
399
+ counters.deleted += 1;
400
+ counters.plannedMutations.push(
401
+ `soft-delete ${entity.singular} ${stale.sourceId}`,
402
+ );
403
+ }
404
+ return;
405
+ }
406
+
407
+ const deleteMutation = `
408
+ mutation MigrationDelete${entity.capPlural}($filter: ${entity.capSingular}FilterInput!) {
409
+ delete${entity.capPlural}(filter: $filter) { id }
410
+ }
411
+ `;
412
+ for (const batch of chunk(staleIds, BATCH_LIMIT)) {
413
+ try {
414
+ await client.requestOnce("/graphql", deleteMutation, {
415
+ filter: { id: { in: batch.map((stale) => stale.id) } },
416
+ });
417
+ counters.deleted += batch.length;
418
+ } catch (error) {
419
+ counters.failed += batch.length;
420
+ counters.gaps.push(
421
+ `soft-delete batch (${batch.length} ${entity.plural}) failed: ${
422
+ error instanceof Error ? error.message.slice(0, 300) : String(error)
423
+ }`,
424
+ );
425
+ }
426
+ }
427
+ }
428
+
429
+ /**
430
+ * Rollback mode (Risks & Rollback): soft-delete EVERY record whose sourceId
431
+ * the migration owns, per entity — reversible via Twenty's restore. Dry-run
432
+ * lists the exact set first (rollback rehearsal gate).
433
+ */
434
+ export async function rollbackEntity(options: {
435
+ client: TwentyClient;
436
+ entity: EntityShape;
437
+ ownedPrefixes: readonly string[];
438
+ dryRun: boolean;
439
+ counters: EntityCounters;
440
+ }): Promise<void> {
441
+ await mirrorDeletions({
442
+ client: options.client,
443
+ entity: options.entity,
444
+ liveSourceIds: new Set<string>(),
445
+ ownedPrefixes: options.ownedPrefixes,
446
+ dryRun: options.dryRun,
447
+ counters: options.counters,
448
+ });
449
+ }
450
+
451
+ // --- Notes (Note + NoteTarget, KTD6/U6) ----------------------------------
452
+
453
+ export async function upsertNotes(options: {
454
+ client: TwentyClient;
455
+ notes: MappedNote[];
456
+ /** target sourceId → Twenty id, for opportunities and companies. */
457
+ targetIdBySourceId: ReadonlyMap<string, string>;
458
+ dryRun: boolean;
459
+ counters: EntityCounters;
460
+ }): Promise<void> {
461
+ const { client, notes, targetIdBySourceId, dryRun, counters } = options;
462
+ const liveNotes = notes.filter((note) => !note.isDeleted);
463
+ counters.sourceTotal += liveNotes.length;
464
+
465
+ const resolvable = liveNotes.filter((note) => {
466
+ if (targetIdBySourceId.has(note.targetSourceId)) return true;
467
+ counters.gaps.push(
468
+ `note ${note.sourceId}: target ${note.targetSourceId} not migrated`,
469
+ );
470
+ counters.skipped += 1;
471
+ return false;
472
+ });
473
+
474
+ const mapped: MappedRecord[] = resolvable.map((note) => ({
475
+ sourceId: note.sourceId,
476
+ input: {
477
+ title: note.title,
478
+ bodyV2: { markdown: note.bodyMarkdown },
479
+ sourceId: note.sourceId,
480
+ },
481
+ warnings: [],
482
+ }));
483
+
484
+ const noteCounters = emptyCounters();
485
+ const noteIdBySourceId = await upsertRecords({
486
+ client,
487
+ entity: NOTE,
488
+ mapped,
489
+ dryRun,
490
+ counters: noteCounters,
491
+ });
492
+ counters.created += noteCounters.created;
493
+ counters.updated += noteCounters.updated;
494
+ counters.restored += noteCounters.restored;
495
+ counters.skipped += noteCounters.skipped;
496
+ counters.failed += noteCounters.failed;
497
+ counters.plannedMutations.push(...noteCounters.plannedMutations);
498
+ counters.gaps.push(...noteCounters.gaps);
499
+
500
+ // NoteTarget is ensured UNCONDITIONALLY — including for hash-skipped notes —
501
+ // so a crash between the Note write and the NoteTarget write heals on
502
+ // re-run (plan U6).
503
+ for (const note of resolvable) {
504
+ if (dryRun) continue; // note creates already planned; target ensure follows them
505
+ const noteId = noteIdBySourceId.get(note.sourceId);
506
+ const targetId = targetIdBySourceId.get(note.targetSourceId);
507
+ if (!noteId || !targetId) continue;
508
+ try {
509
+ const existing = await client.requestWithRetry<{
510
+ noteTargets: { edges: Array<{ node: { id: string } }> };
511
+ }>(
512
+ "/graphql",
513
+ `query MigrationNoteTargets($filter: NoteTargetFilterInput) {
514
+ noteTargets(filter: $filter, first: 1) { edges { node { id } } }
515
+ }`,
516
+ { filter: { noteId: { eq: noteId } } },
517
+ );
518
+ if (existing.noteTargets.edges.length > 0) continue;
519
+ const targetField =
520
+ note.targetKind === "company"
521
+ ? "targetCompanyId"
522
+ : "targetOpportunityId";
523
+ await client.requestOnce(
524
+ "/graphql",
525
+ `mutation MigrationCreateNoteTargets($data: [NoteTargetCreateInput!]!) {
526
+ createNoteTargets(data: $data) { id }
527
+ }`,
528
+ { data: [{ noteId, [targetField]: targetId }] },
529
+ );
530
+ } catch (error) {
531
+ counters.failed += 1;
532
+ counters.gaps.push(
533
+ `noteTarget for ${note.sourceId} failed: ${
534
+ error instanceof Error ? error.message.slice(0, 300) : String(error)
535
+ }`,
536
+ );
537
+ }
538
+ }
539
+
540
+ // Deleted comments mirror to deleted notes.
541
+ const deletedNoteIds = notes
542
+ .filter((note) => note.isDeleted)
543
+ .map((note) => note.sourceId);
544
+ if (deletedNoteIds.length > 0) {
545
+ const existing = await fetchExistingBySourceIds(
546
+ client,
547
+ NOTE,
548
+ deletedNoteIds,
549
+ );
550
+ const toDelete = [...existing.values()].filter(
551
+ (record) => !record.deletedAt,
552
+ );
553
+ if (dryRun) {
554
+ for (const record of toDelete) {
555
+ counters.deleted += 1;
556
+ counters.plannedMutations.push(`soft-delete note ${record.sourceId}`);
557
+ }
558
+ } else {
559
+ for (const batch of chunk(toDelete, BATCH_LIMIT)) {
560
+ try {
561
+ await client.requestOnce(
562
+ "/graphql",
563
+ `mutation MigrationDeleteNotes($filter: NoteFilterInput!) {
564
+ deleteNotes(filter: $filter) { id }
565
+ }`,
566
+ { filter: { id: { in: batch.map((record) => record.id) } } },
567
+ );
568
+ counters.deleted += batch.length;
569
+ } catch (error) {
570
+ counters.failed += batch.length;
571
+ counters.gaps.push(
572
+ `soft-delete notes batch failed: ${
573
+ error instanceof Error
574
+ ? error.message.slice(0, 300)
575
+ : String(error)
576
+ }`,
577
+ );
578
+ }
579
+ }
580
+ }
581
+ }
582
+ }
583
+
584
+ /**
585
+ * Product lines on opportunities (multiple products per opportunity). Lines
586
+ * whose opportunity did not migrate are skipped and reported; the rest upsert
587
+ * by their `opportunity_item:<oppId>#<index>` sourceId, so re-runs update a
588
+ * line in place rather than duplicating it.
589
+ */
590
+ export async function upsertOpportunityProducts(options: {
591
+ client: TwentyClient;
592
+ products: MappedOpportunityProduct[];
593
+ /** opportunity sourceId -> Twenty opportunity id. */
594
+ opportunityIdBySourceId: ReadonlyMap<string, string>;
595
+ dryRun: boolean;
596
+ counters: EntityCounters;
597
+ }): Promise<void> {
598
+ const { client, products, opportunityIdBySourceId, dryRun, counters } =
599
+ options;
600
+
601
+ const resolvable: MappedRecord[] = [];
602
+ for (const product of products) {
603
+ const opportunityId = opportunityIdBySourceId.get(
604
+ product.opportunitySourceId,
605
+ );
606
+ if (!opportunityId) {
607
+ counters.sourceTotal += 1;
608
+ counters.skipped += 1;
609
+ counters.gaps.push(
610
+ `product line ${product.sourceId}: opportunity ${product.opportunitySourceId} not migrated`,
611
+ );
612
+ continue;
613
+ }
614
+ resolvable.push({
615
+ sourceId: product.sourceId,
616
+ input: { ...product.input, opportunityId },
617
+ warnings: product.warnings,
618
+ });
619
+ }
620
+
621
+ await upsertRecords({
622
+ client,
623
+ entity: OPPORTUNITY_PRODUCT,
624
+ mapped: resolvable,
625
+ dryRun,
626
+ counters,
627
+ });
628
+ }
629
+
630
+ // --- Consistency invariants (U7) ------------------------------------------
631
+
632
+ export interface InvariantViolation {
633
+ invariant: string;
634
+ detail: string;
635
+ }
636
+
637
+ export async function checkNoteTargetPairing(
638
+ client: TwentyClient,
639
+ ): Promise<InvariantViolation[]> {
640
+ const violations: InvariantViolation[] = [];
641
+ let after: string | null = null;
642
+ for (;;) {
643
+ const data: {
644
+ notes: {
645
+ edges: Array<{
646
+ node: {
647
+ id: string;
648
+ sourceId: string | null;
649
+ noteTargets: { edges: Array<{ node: { id: string } }> };
650
+ };
651
+ }>;
652
+ pageInfo: { hasNextPage: boolean; endCursor: string | null };
653
+ };
654
+ } = await client.requestWithRetry(
655
+ "/graphql",
656
+ `query MigrationNotePairing($filter: NoteFilterInput, $after: String) {
657
+ notes(filter: $filter, first: ${QUERY_PAGE}, after: $after) {
658
+ edges {
659
+ node { id sourceId noteTargets { edges { node { id } } } }
660
+ cursor
661
+ }
662
+ pageInfo { hasNextPage endCursor }
663
+ }
664
+ }`,
665
+ { filter: { sourceId: { is: "NOT_NULL" } }, ...(after ? { after } : {}) },
666
+ );
667
+ for (const { node } of data.notes.edges) {
668
+ const targetCount = node.noteTargets.edges.length;
669
+ if (targetCount !== 1) {
670
+ violations.push({
671
+ invariant: "note-has-one-target",
672
+ detail: `note ${node.sourceId ?? node.id} has ${targetCount} targets`,
673
+ });
674
+ }
675
+ }
676
+ if (!data.notes.pageInfo.hasNextPage) break;
677
+ after = data.notes.pageInfo.endCursor;
678
+ }
679
+ return violations;
680
+ }