thinkwork-cli 0.12.16 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,43 @@
1
+ import bcrypt from "bcryptjs";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { hashPassword, quoteIdent } from "../provision-members-db";
5
+
6
+ describe("hashPassword", () => {
7
+ it("emits the $2b$ prefix Twenty's own signup writes", () => {
8
+ const hash = hashPassword("TestPassword1!", 4);
9
+ expect(hash.startsWith("$2b$")).toBe(true);
10
+ expect(hash).toHaveLength(60);
11
+ });
12
+
13
+ it("produces a hash the password verifies against, and nothing else does", () => {
14
+ const hash = hashPassword("TestPassword1!", 4);
15
+ expect(bcrypt.compareSync("TestPassword1!", hash)).toBe(true);
16
+ expect(bcrypt.compareSync("TestPassword1", hash)).toBe(false);
17
+ });
18
+
19
+ it("handles the shell-hostile characters in the shared rep password", () => {
20
+ const hash = hashPassword("Abc123$$", 4);
21
+ expect(bcrypt.compareSync("Abc123$$", hash)).toBe(true);
22
+ });
23
+
24
+ it("refuses passwords past bcrypt's silent 72-byte truncation point", () => {
25
+ expect(() => hashPassword("x".repeat(73), 4)).toThrow(/72-byte/);
26
+ });
27
+ });
28
+
29
+ describe("quoteIdent", () => {
30
+ it("accepts a Twenty workspace data schema", () => {
31
+ expect(quoteIdent("workspace_brl3ypdat40udm5gtn95sozcg")).toBe(
32
+ '"workspace_brl3ypdat40udm5gtn95sozcg"',
33
+ );
34
+ });
35
+
36
+ it("refuses anything that is not a workspace schema", () => {
37
+ expect(() => quoteIdent("core")).toThrow(/Refusing/);
38
+ expect(() =>
39
+ quoteIdent('workspace_x"; drop table core."user"; --'),
40
+ ).toThrow(/Refusing/);
41
+ expect(() => quoteIdent("public")).toThrow(/Refusing/);
42
+ });
43
+ });
@@ -0,0 +1,230 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ applySchemaEnsure,
5
+ FIELD_SPECS,
6
+ PENDING_OBJECT_ID,
7
+ planSchemaEnsure,
8
+ } from "../schema-ensure";
9
+ import { MIGRATION_STAGE_OPTIONS } from "../mappers";
10
+ import type { TwentyClient } from "../twenty-client";
11
+
12
+ const EXISTING_STAGE_OPTIONS = [
13
+ { id: "id-new", label: "New", value: "NEW", color: "red", position: 0 },
14
+ {
15
+ id: "id-cust",
16
+ label: "Customer",
17
+ value: "CUSTOMER",
18
+ color: "yellow",
19
+ position: 1,
20
+ },
21
+ ];
22
+
23
+ interface FieldFixture {
24
+ id: string;
25
+ name: string;
26
+ type: string;
27
+ options: typeof EXISTING_STAGE_OPTIONS | null;
28
+ }
29
+
30
+ function objectsFixture(
31
+ fieldsByObject: Record<string, FieldFixture[]>,
32
+ ): Map<
33
+ string,
34
+ { id: string; nameSingular: string; fields: Map<string, FieldFixture> }
35
+ > {
36
+ const map = new Map<
37
+ string,
38
+ { id: string; nameSingular: string; fields: Map<string, FieldFixture> }
39
+ >();
40
+ for (const [name, fields] of Object.entries(fieldsByObject)) {
41
+ map.set(name, {
42
+ id: `obj-${name}`,
43
+ nameSingular: name,
44
+ fields: new Map(fields.map((field) => [field.name, field])),
45
+ });
46
+ }
47
+ return map;
48
+ }
49
+
50
+ const stageField: FieldFixture = {
51
+ id: "field-stage",
52
+ name: "stage",
53
+ type: "SELECT",
54
+ options: EXISTING_STAGE_OPTIONS,
55
+ };
56
+
57
+ function bareObjects() {
58
+ return objectsFixture({
59
+ company: [],
60
+ person: [],
61
+ opportunity: [stageField],
62
+ note: [],
63
+ attachment: [],
64
+ // The product-line object exists (ensureOpportunityProductObject ran) but
65
+ // has none of its fields yet.
66
+ opportunityProduct: [],
67
+ });
68
+ }
69
+
70
+ /** A workspace that has never seen the product-line object at all. */
71
+ function objectsWithoutProductObject() {
72
+ return objectsFixture({
73
+ company: [],
74
+ person: [],
75
+ opportunity: [stageField],
76
+ note: [],
77
+ attachment: [],
78
+ });
79
+ }
80
+
81
+ function provisionedObjects() {
82
+ const withAll = (object: string): FieldFixture[] =>
83
+ FIELD_SPECS.filter((spec) => spec.object === object).map((spec, index) => ({
84
+ id: `f-${object}-${index}`,
85
+ name: spec.name,
86
+ type: spec.type,
87
+ options: null,
88
+ }));
89
+ const opportunityFields = [
90
+ ...withAll("opportunity"),
91
+ {
92
+ ...stageField,
93
+ options: [
94
+ ...EXISTING_STAGE_OPTIONS,
95
+ ...MIGRATION_STAGE_OPTIONS.map((option, index) => ({
96
+ id: `id-${option.value}`,
97
+ ...option,
98
+ position: EXISTING_STAGE_OPTIONS.length + index,
99
+ })),
100
+ ],
101
+ },
102
+ ];
103
+ return objectsFixture({
104
+ company: withAll("company"),
105
+ person: withAll("person"),
106
+ opportunity: opportunityFields,
107
+ note: withAll("note"),
108
+ attachment: withAll("attachment"),
109
+ opportunityProduct: [
110
+ ...withAll("opportunityProduct"),
111
+ {
112
+ id: "f-op-rel",
113
+ name: "opportunity",
114
+ type: "RELATION",
115
+ options: null,
116
+ },
117
+ ],
118
+ });
119
+ }
120
+
121
+ describe("planSchemaEnsure", () => {
122
+ it("plans every missing field and all stage options on an empty workspace", () => {
123
+ const plan = planSchemaEnsure(bareObjects());
124
+ expect(plan.createFields).toHaveLength(FIELD_SPECS.length);
125
+ expect(plan.stageOptionsToAdd).toEqual(
126
+ MIGRATION_STAGE_OPTIONS.map((option) => option.value),
127
+ );
128
+ // Full-replace semantics (U1): the merged array keeps existing options first.
129
+ expect(
130
+ plan.mergedStageOptions?.slice(0, 2).map((option) => option.value),
131
+ ).toEqual(["NEW", "CUSTOMER"]);
132
+ expect(plan.mergedStageOptions).toHaveLength(
133
+ EXISTING_STAGE_OPTIONS.length + MIGRATION_STAGE_OPTIONS.length,
134
+ );
135
+ });
136
+
137
+ it("is a no-op against already-provisioned metadata (idempotent)", () => {
138
+ const plan = planSchemaEnsure(provisionedObjects());
139
+ expect(plan.createFields).toHaveLength(0);
140
+ expect(plan.stageOptionsToAdd).toEqual([]);
141
+ expect(plan.mergedStageOptions).toBeNull();
142
+ });
143
+
144
+ it("creates only the gap on partial presence", () => {
145
+ const objects = bareObjects();
146
+ objects.get("opportunity")!.fields.set("product", {
147
+ id: "f-product",
148
+ name: "product",
149
+ type: "TEXT",
150
+ options: null,
151
+ });
152
+ const plan = planSchemaEnsure(objects);
153
+ const names = plan.createFields.map(
154
+ (field) => `${field.object}.${field.name}`,
155
+ );
156
+ expect(names).not.toContain("opportunity.product");
157
+ expect(names).toContain("opportunity.quantity");
158
+ });
159
+
160
+ it("aborts when the stage field is missing", () => {
161
+ const objects = objectsFixture({
162
+ company: [],
163
+ person: [],
164
+ opportunity: [],
165
+ note: [],
166
+ attachment: [],
167
+ opportunityProduct: [],
168
+ });
169
+ expect(() => planSchemaEnsure(objects)).toThrow(/stage field/);
170
+ });
171
+
172
+ it("plans product-line fields against a pending object id when it does not exist yet", () => {
173
+ const plan = planSchemaEnsure(objectsWithoutProductObject());
174
+ const productFields = plan.createFields.filter(
175
+ (field) => field.object === "opportunityProduct",
176
+ );
177
+ expect(productFields.length).toBeGreaterThan(0);
178
+ expect(
179
+ productFields.every(
180
+ (field) => field.objectMetadataId === PENDING_OBJECT_ID,
181
+ ),
182
+ ).toBe(true);
183
+ });
184
+ });
185
+
186
+ describe("applySchemaEnsure", () => {
187
+ it("issues createOneField per missing field and one full-replace options write", async () => {
188
+ const requestOnce = vi.fn(async () => ({}));
189
+ const client = { requestOnce } as unknown as TwentyClient;
190
+ const plan = planSchemaEnsure(bareObjects());
191
+ const result = await applySchemaEnsure(client, plan);
192
+ expect(result.createdFields).toHaveLength(FIELD_SPECS.length);
193
+ // one createOneField call per field + one updateOneField for stage options
194
+ expect(requestOnce).toHaveBeenCalledTimes(FIELD_SPECS.length + 1);
195
+ const lastCall = requestOnce.mock.calls.at(-1) as unknown as [
196
+ string,
197
+ string,
198
+ { input: { update: { options: Array<{ value: string }> } } },
199
+ ];
200
+ expect(
201
+ lastCall[2].input.update.options.map((option) => option.value),
202
+ ).toContain("CUSTOMER");
203
+ expect(
204
+ lastCall[2].input.update.options.map((option) => option.value),
205
+ ).toContain("LEAD");
206
+ });
207
+
208
+ it("refuses to create a field against a pending object id", async () => {
209
+ // Guards the ordering bug: fields must be planned only after
210
+ // ensureOpportunityProductObject has created the object.
211
+ const requestOnce = vi.fn(async () => ({}));
212
+ const client = { requestOnce } as unknown as TwentyClient;
213
+ const plan = planSchemaEnsure(objectsWithoutProductObject());
214
+ await expect(applySchemaEnsure(client, plan)).rejects.toThrow(
215
+ /planned against an uncreated object/,
216
+ );
217
+ });
218
+
219
+ it("aborts on the first metadata mutation error", async () => {
220
+ const requestOnce = vi.fn(async () => {
221
+ throw new Error("metadata boom");
222
+ });
223
+ const client = { requestOnce } as unknown as TwentyClient;
224
+ const plan = planSchemaEnsure(bareObjects());
225
+ await expect(applySchemaEnsure(client, plan)).rejects.toThrow(
226
+ /metadata boom/,
227
+ );
228
+ expect(requestOnce).toHaveBeenCalledTimes(1);
229
+ });
230
+ });
@@ -0,0 +1,207 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import {
4
+ BATCH_LIMIT,
5
+ chunk,
6
+ normalizeBaseUrl,
7
+ TwentyClient,
8
+ TwentyGraphqlError,
9
+ } from "../twenty-client";
10
+
11
+ function jsonResponse(body: unknown, status = 200): Response {
12
+ return new Response(JSON.stringify(body), {
13
+ status,
14
+ headers: { "content-type": "application/json" },
15
+ });
16
+ }
17
+
18
+ describe("normalizeBaseUrl", () => {
19
+ it("resolves the endpoint from TWENTY_PUBLIC_URL plus path", () => {
20
+ const client = new TwentyClient({
21
+ baseUrl: "https://crm.tei.thinkwork.ai/",
22
+ authToken: "key",
23
+ });
24
+ expect(client.endpoint("/graphql")).toBe(
25
+ "https://crm.tei.thinkwork.ai/graphql",
26
+ );
27
+ expect(client.endpoint("/metadata")).toBe(
28
+ "https://crm.tei.thinkwork.ai/metadata",
29
+ );
30
+ });
31
+
32
+ it("rejects non-HTTPS URLs", () => {
33
+ expect(() => normalizeBaseUrl("http://crm.tei.thinkwork.ai")).toThrow(
34
+ /HTTPS/,
35
+ );
36
+ });
37
+ });
38
+
39
+ describe("chunk", () => {
40
+ it("splits 130 records into 3 chunks at the batch limit", () => {
41
+ const chunks = chunk(
42
+ Array.from({ length: 130 }, (_, index) => index),
43
+ BATCH_LIMIT,
44
+ );
45
+ expect(chunks.map((c) => c.length)).toEqual([60, 60, 10]);
46
+ });
47
+
48
+ it("rejects a chunk size below 1", () => {
49
+ expect(() => chunk([1], 0)).toThrow(/>= 1/);
50
+ });
51
+ });
52
+
53
+ describe("TwentyClient.requestOnce", () => {
54
+ it("throws with the message when GraphQL returns errors[]", async () => {
55
+ const fetchImpl = vi.fn(async () =>
56
+ jsonResponse({ errors: [{ message: "Field boom is required" }] }),
57
+ );
58
+ const client = new TwentyClient({
59
+ baseUrl: "https://crm.example.com",
60
+ authToken: "key",
61
+ fetchImpl,
62
+ minRequestIntervalMs: 0,
63
+ rateLimitWaitMs: 1,
64
+ });
65
+ await expect(client.requestOnce("/graphql", "query { x }")).rejects.toThrow(
66
+ /Field boom is required/,
67
+ );
68
+ });
69
+
70
+ it("sends the Bearer auth header", async () => {
71
+ const fetchImpl = vi.fn(async () => jsonResponse({ data: { ok: true } }));
72
+ const client = new TwentyClient({
73
+ baseUrl: "https://crm.example.com",
74
+ authToken: "secret-key",
75
+ fetchImpl,
76
+ minRequestIntervalMs: 0,
77
+ });
78
+ await client.requestOnce("/graphql", "query { x }");
79
+ const [, init] = fetchImpl.mock.calls[0] as unknown as [
80
+ string,
81
+ RequestInit,
82
+ ];
83
+ expect((init.headers as Record<string, string>).authorization).toBe(
84
+ "Bearer secret-key",
85
+ );
86
+ });
87
+
88
+ it("flags duplicate-key errors", async () => {
89
+ const fetchImpl = vi.fn(async () =>
90
+ jsonResponse({
91
+ errors: [
92
+ { message: 'duplicate key value violates unique constraint "x"' },
93
+ ],
94
+ }),
95
+ );
96
+ const client = new TwentyClient({
97
+ baseUrl: "https://crm.example.com",
98
+ authToken: "key",
99
+ fetchImpl,
100
+ minRequestIntervalMs: 0,
101
+ rateLimitWaitMs: 1,
102
+ });
103
+ const error = await client
104
+ .requestOnce("/graphql", "mutation { y }")
105
+ .catch((e) => e);
106
+ expect(error).toBeInstanceOf(TwentyGraphqlError);
107
+ expect((error as TwentyGraphqlError).isDuplicateError).toBe(true);
108
+ });
109
+ });
110
+
111
+ describe("TwentyClient.requestWithRetry", () => {
112
+ it("retries on 429 with backoff then succeeds", async () => {
113
+ const fetchImpl = vi
114
+ .fn()
115
+ .mockResolvedValueOnce(new Response("rate limited", { status: 429 }))
116
+ .mockResolvedValueOnce(jsonResponse({ data: { ok: true } }));
117
+ const client = new TwentyClient({
118
+ baseUrl: "https://crm.example.com",
119
+ authToken: "key",
120
+ fetchImpl,
121
+ backoffMs: 1,
122
+ minRequestIntervalMs: 0,
123
+ rateLimitWaitMs: 1,
124
+ });
125
+ const data = await client.requestWithRetry<{ ok: boolean }>(
126
+ "/graphql",
127
+ "query { x }",
128
+ );
129
+ expect(data.ok).toBe(true);
130
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
131
+ });
132
+
133
+ it("does not retry GraphQL validation errors", async () => {
134
+ const fetchImpl = vi.fn(async () =>
135
+ jsonResponse({ errors: [{ message: "bad query" }] }),
136
+ );
137
+ const client = new TwentyClient({
138
+ baseUrl: "https://crm.example.com",
139
+ authToken: "key",
140
+ fetchImpl,
141
+ backoffMs: 1,
142
+ minRequestIntervalMs: 0,
143
+ rateLimitWaitMs: 1,
144
+ });
145
+ await expect(
146
+ client.requestWithRetry("/graphql", "query { x }"),
147
+ ).rejects.toThrow(/bad query/);
148
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
149
+ });
150
+
151
+ it("gives up after maxRetries on persistent 5xx", async () => {
152
+ const fetchImpl = vi.fn(async () => new Response("boom", { status: 502 }));
153
+ const client = new TwentyClient({
154
+ baseUrl: "https://crm.example.com",
155
+ authToken: "key",
156
+ fetchImpl,
157
+ backoffMs: 1,
158
+ maxRetries: 2,
159
+ minRequestIntervalMs: 0,
160
+ rateLimitWaitMs: 1,
161
+ });
162
+ await expect(
163
+ client.requestWithRetry("/graphql", "query { x }"),
164
+ ).rejects.toThrow(/502/);
165
+ expect(fetchImpl).toHaveBeenCalledTimes(3);
166
+ });
167
+ });
168
+
169
+ describe("network errors", () => {
170
+ it("retries socket-level failures on reads", async () => {
171
+ const fetchImpl = vi
172
+ .fn()
173
+ .mockRejectedValueOnce(new TypeError("fetch failed"))
174
+ .mockResolvedValueOnce(jsonResponse({ data: { ok: true } }));
175
+ const client = new TwentyClient({
176
+ baseUrl: "https://crm.example.com",
177
+ authToken: "key",
178
+ fetchImpl,
179
+ backoffMs: 1,
180
+ minRequestIntervalMs: 0,
181
+ rateLimitWaitMs: 1,
182
+ });
183
+ const data = await client.requestWithRetry<{ ok: boolean }>(
184
+ "/graphql",
185
+ "query { x }",
186
+ );
187
+ expect(data.ok).toBe(true);
188
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
189
+ });
190
+
191
+ it("wraps network failures with context on requestOnce", async () => {
192
+ const fetchImpl = vi.fn().mockRejectedValue(new TypeError("fetch failed"));
193
+ const client = new TwentyClient({
194
+ baseUrl: "https://crm.example.com",
195
+ authToken: "key",
196
+ fetchImpl,
197
+ minRequestIntervalMs: 0,
198
+ rateLimitWaitMs: 1,
199
+ });
200
+ const error = await client
201
+ .requestOnce("/graphql", "mutation { y }")
202
+ .catch((e) => e);
203
+ expect(error).toBeInstanceOf(TwentyGraphqlError);
204
+ expect((error as TwentyGraphqlError).isNetworkError).toBe(true);
205
+ expect((error as TwentyGraphqlError).message).toMatch(/network error/);
206
+ });
207
+ });