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.
- package/dist/cli.js +517 -10403
- 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/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 +29 -38
- 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 +1 -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,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
|
+
}
|