2dcf53619f568d397eae272fcd20dcbe2a06bf10
5135 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
dd9763a7a0 |
Allow workflow run control mutations to be called with API key auth (#22924)
## Context A customer wants to control workflow runs from an external system / their own automation calling the API. Today the workflow mutations are gated by `UserAuthGuard`, which requires an interactive logged-in user (`request.user`). API-key requests set `request.workspace` but never `request.user`, so they can't call them. ## Change `UserAuthGuard` was applied at the class level on `WorkflowTriggerResolver`, blanketing all five mutations even though only `runWorkflowVersion` actually consumes the user (it looks up the workspace member to stamp `createdBy`). This drops `UserAuthGuard` from the class and keeps it only on `runWorkflowVersion`. As a result, these become callable with API-key auth: - `stopWorkflowRun` - `retryWorkflowRun` - `activateWorkflowVersion` - `deactivateWorkflowVersion` None of these ever referenced the user, so no logic depends on it. `runWorkflowVersion` stays user-only because it needs a workspace member to attribute `createdBy`. Permissioning is unchanged: `SettingsPermissionGuard(WORKFLOWS)` stays at the class level and already resolves the permission for API keys via `apiKeyId`, so a key still needs the WORKFLOWS permission. ## Notes / open questions - No actor is recorded for these operations today (they only change run/version state), so exposing them to API keys doesn't drop any audit that existed. Attributing API-key-initiated actions would be a follow-up. - If there's a deliberate product stance that workflow control should stay user-only, this is a policy change worth confirming. |
||
|
|
f4b2968a74 |
fix(server): finalize workflow runs stuck in STOPPING (#22900)
## Context A workflow run only reaches STOPPED via the in-flight worker execution: `stopWorkflowRun` just flips a RUNNING run to `STOPPING` (records intent), and the `STOPPING -> STOPPED` transition is done later inside `computeWorkflowRunStatus`, which only runs while a worker is executing the run's steps. If no worker is executing the run at that point, nothing ever finalizes it: - the worker that owned the run crashed / was killed mid-step (e.g. under heavy load), or - a step legitimately sits in RUNNING awaiting an external event that never arrives (the user stopped it). Only `ENQUEUED` runs had a staleness sweep, so `STOPPING` (and `RUNNING`) had no recovery path and would stay stuck indefinitely. This has been observed in production (~150 runs stuck in `STOPPING` after manual stops during a migration). ## Change Extend the existing staled-runs machinery to also finalize runs left in `STOPPING`: - New `stuck-stopping-runs-threshold` (1h) + `getStuckStoppingRunsFindOptions` matching `status = STOPPING AND updatedAt < now - 1h`. `updatedAt` is a TypeORM update-date column, so it reliably marks when the run entered `STOPPING`, and 1h stays above any legitimate in-flight step. - `handleStuckStoppingRunsForWorkspace` finalizes each match to `STOPPED` via `endWorkflowRun`, so `endedAt`, step infos and the `WorkflowRunStopped` metric stay consistent. It pages the backlog with keyset pagination on `(createdAt, id)`, so a page whose finalizations all fail can't stay at the front of the query and starve later runs (failed ones are retried on the next sweep). - Wired into the same cron (`WorkflowHandleStaledRunsCronJob`, every 10 min), per-workspace job, and the manual `workflow:handle-staled-runs` command — so ops can also clear an existing backlog immediately. The staled-ENQUEUED and stuck-STOPPING handlers run independently (`Promise.allSettled` in the job, separate try/catch in the command), so a failure in one doesn't block the other. Stop remains manual and unchanged; this only guarantees a stopped run eventually reaches `STOPPED`. ## Notes / scope - No schema change (reuses `updatedAt`), so no migration. - `RUNNING` runs orphaned by a worker crash have the same missing-net problem; left out of scope here (this covers the user-triggered STOPPING case). - The new detection query scans `status`/`updatedAt` like the existing ENQUEUED sweep; at very high `workflowRun` volumes an index on `(status, updatedAt)` would help — same pre-existing consideration as the ENQUEUED path. ## Tests Unit tests for `handleStuckStoppingRunsForWorkspace`: no-op when none, finalizes each match to STOPPED, pages through a multi-page backlog, and advances past a fully-failed page instead of starving later runs. Plus a unit test for the `(createdAt, id)` keyset condition in `getStuckStoppingRunsFindOptions`. Full suite green, lint + typecheck clean on changed files. Manually verified on a real instance (Postgres): seeded a `STOPPING` run aged 2h and ran `workflow:handle-staled-runs` -> transitioned to `STOPPED` with `endedAt` set; a freshly-`STOPPING` run (updatedAt now) was correctly left untouched by the 1h threshold. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22900?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
541c67d222 |
i18n - translations (#22923)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
f67eb60c57 |
feat(workflow): soft-ref core workflow/version (backfill + dual-write) (#22821)
Replaces the shared-UUID model (core row reuses the workspace record id) with a **soft-ref**: the workspace `workflow`/`workflowVersion` records carry a nullable `coreWorkflowId`/`coreWorkflowVersionId` pointing to their **own-id** core rows. This removes the assumption that workspace record ids are globally unique - which is false, since prefilled/seeded workflows share ids across workspaces. Supersedes #22776. ## In this PR **Soft-ref columns (foundation):** - **twenty-shared** `STANDARD_OBJECTS`: `workflowVersion.coreWorkflowVersionId` + `workflow.coreWorkflowId` (+ snapshot test). - **compute utils**: both as system, nullable UUID fields. - **entity classes**: the bare fields. **Version soft-ref sync:** - Core `workflowVersion` rows get their own id, derived deterministically from `workspaceId + record id` (uuidv5). Deterministic so the upsert is idempotent: a failed write-back re-derives the same id and self-heals instead of orphaning rows or colliding on the one-active-per-workflow index. - Sync = find-or-create keyed on the workspace record's `coreWorkflowVersionId`, then write the core id back onto the workspace record. - Migrating over pre-soft-ref data: purges any core row whose id equals the workspace record id before recreating, so old shared-UUID rows aren't orphaned. - Version dual-write listener reworked: delete is keyed by the core id read off `before.coreWorkflowVersionId`. Verified on a fresh `database:reset` (columns materialize, backfill produces deterministic own-id rows linked back, idempotent re-run), a simulated old shared-UUID state (stale rows purged, records re-linked), and a simulated write-back failure (retry re-links to the same id, no orphan, active-version index intact). ## Next steps (follow-up work, not in this PR) 1. Workflow-side soft-ref sync mirroring the version side (service, module, dual-write listener, backfill command). 2. Workspace command to add the two columns to existing workspaces. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22821?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
8e03921372 |
Add CREATED workspace activation status (read path + enum migration) (#22904)
## Context Since v2 onboarding (#22303), workspaces are activated **before** the billing plan step (now the last onboarding step). Users abandoning at the plan step leave ACTIVE workspaces with a Stripe customer but no subscription (~60–110/day on cloud, 935+ so far), and no cleanup mechanism ever touches them: billing webhooks never fire (no subscription), the suspended-workspaces cron only handles SUSPENDED, the onboarding cron only handles PENDING_CREATION/ONGOING_CREATION. Target lifecycle (across two PRs): `PENDING_CREATION → ONGOING_CREATION → CREATED → ACTIVE → SUSPENDED → deleted`. **`CREATED`** = the workspace schema is provisioned but onboarding is not complete — no billing subscription yet. It is **not** considered active: | Concern | CREATED behavior | |---|---| | Sign-in / invited teammates joining | allowed (invite-team step precedes the plan step) | | Member + metadata loading (app shell) | allowed (user must finish onboarding) | | Permissions | real permission checks (no PENDING-style bypass) | | Version upgrades / workspace migrations | **included** (schema must not drift) | | Messaging/calendar/workflow/etc. crons | **excluded** — no background processing until a plan is chosen | | PLAN_REQUIRED onboarding lock | unchanged (still derived from subscription existence) | ## What this PR does (read path only) The enum addition ships as a **slow** instance command, which can run after deploy — so nothing in this PR ever **writes** `CREATED`. The write path (setting it at activation, the cleanup sweep, the backfill of the existing zombie cohort) is a follow-up PR that ships once this migration has run everywhere. - **twenty-shared**: `CREATED` enum value; `PROVISIONED_WORKSPACE_ACTIVATION_STATUSES` + `isWorkspaceProvisioned` ("schema exists": CREATED | ACTIVE | SUSPENDED), replacing `isWorkspaceActiveOrSuspended` — all call sites (server member loading, access-token workspace-member lookup, front metadata-store gates) meant "has schema/members". - **Slow instance command** (2.22.0): swaps `core.workspace_activationStatus_enum` using the rename→recreate→alter-column idiom. The CHECK constraints on `core.workspace` embed casts to the enum type and would break the swap — the command captures them from `pg_constraint`, drops them, swaps the type, and restores them. - **Pre-migration-safe queries**: Postgres rejects `IN ('CREATED', ...)` when the enum value does not exist yet — even for reads, and the instance-command runner itself queries provisioned workspaces before migrating (a fresh database could never initialize). All provisioned-status filters go through a new `activationStatusIn` util comparing on `"activationStatus"::text`, valid before and after the migration. - **Upgrade path**: workspace iterator, command runner, upgrade-status and workspace-version services iterate CREATED workspaces. Since they now cover more than ACTIVE/SUSPENDED, the stale names were renamed to `ProvisionedWorkspaceCommandRunner`, `hasProvisionedWorkspaces`, `getProvisionedWorkspaceIds`, `loadProvisionedWorkspaces` (the mechanical import rename in old version-command dirs is why this PR carries the `ci:allow-previous-version-upgrade-mutation` label). - **Sign-in**: `throwIfWorkspaceIsNotReadyForSignInUp` accepts CREATED so invited members can join during onboarding (join authorization itself is unchanged — enforced upstream in `checkAccessForSignIn`); `activateWorkspace` idempotent-retry accepts CREATED as a terminal state. - **Transitions out of CREATED** (only write ACTIVE — safe to ship now, dead until the write path lands): the Stripe webhook reactivation branch also promotes CREATED, and `syncSubscriptionToDatabase` promotes synchronously; both gated on `WORKSPACE_ACTIVATING_SUBSCRIPTION_STATUSES` (Active/Trialing — extracted from `shouldReactivateWorkspace`, behavior-preserving) so an `incomplete` subscription created by the payment-intent flow before payment never promotes the workspace. - Deliberately untouched: all background crons, permission guards, JWT strategy, PLAN_REQUIRED logic, admin panel (renders the raw status string). ## Follow-up PR (after this migration has run) 1. `activateWorkspace` sets `hasWorkspaceAnySubscription ? ACTIVE : CREATED` (billing disabled → always ACTIVE, self-hosted unchanged). 2. Cleanup: suspend CREATED workspaces older than N days (config var), handing them to the existing suspended pipeline (warn → soft-delete → destroy). 3. Backfill: cloud-only slow command moving ACTIVE workspaces with no billingSubscription row (created since Jul 1) to CREATED. ## Verification - Migration exercised against a real database via the command class: up → down → up; `enum_range` and `pg_get_constraintdef` checked after each step (constraints restored against the new type, `DEFAULT 'INACTIVE'` preserved). - Pre-migration safety exercised for real: with the migration rolled back (enum without CREATED), `run-instance-commands` — the exact fresh-database CI path that failed before the `::text` fix — completes cleanly. - End-to-end with a workspace manually set to CREATED and the branch server+front running: sign-in issues tokens, `currentUser` loads workspaceMember(s), the full app loads with no console errors; GraphQL returns `activationStatus: CREATED`. - Workspace creation ran end-to-end locally in **both billing modes** on this branch: - billing disabled: signup → workspace creation → ACTIVE immediately → onboarding completes with no plan step → app loads (unchanged behavior); - billing enabled (Stripe test mode): signup creates the Stripe customer eagerly → activation ends ACTIVE → subscription-less workspace is pinned to the plan-required page → no-card trial checkout creates a `trialing` subscription via `createDirectSubscription`/`syncSubscriptionToDatabase` → app loads. - `twenty-shared` unit tests, server specs on touched services, `lint:diff-with-main` and `typecheck` for shared/server/front all green; full CI green. |
||
|
|
36a14478ae |
i18n - translations (#22916)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
25bd2897a3 |
Add weekly layout to record calendar (#22819)
## Summary - Add a week layout to record calendar views and persist the selected layout. - Render `DATE` calendars as an all-day week and `DATE_TIME` calendars as an hourly week. - Add an optional end date field across calendar configuration, metadata, persistence, and complete-view upserts. - Use configured end values for ranged and multi-day events, with a one-hour fallback when a `DATE_TIME` end is absent or invalid. - Keep calendar cards consistent with the existing compact view, including checkbox selection and whole-card record opening. - Gate the weekly layout and end-date behavior behind the public Labs `IS_CALENDAR_WEEK_VIEW_ENABLED` workspace feature flag. ## Week interactions - Show overlapping timed events side by side and cap the visible records at two per day. - Display start and end times on timed cards, enforce a readable 30-minute minimum height, and keep today’s text contrast stronger. - Drag timed events between days and times with 30-minute snapping while preserving their duration, including zero-duration events. - Show a create button when hovering a 30-minute slot; keyboard users can focus a day, move the slot with the arrow keys, and reach the same contextual action. - Initialize new records with the selected slot time and a compatible writable end value one hour later. - Show the workspace time zone and current-time indicator in timed weeks; date-only weeks keep the all-day section without an hourly grid. ## Configuration and data loading - Only allow end fields that match the start field type, and prevent selecting the same field for both boundaries. - Load records whose ranges overlap the visible period so month and week layouts display the same relevant records. - Resolve and persist calendar end fields when updating existing views through `upsert_complete_view`. - Fall back to Month and ignore the configured end field while the flag is disabled, without overwriting either persisted setting, so re-enabling restores the previous configuration. - Expose the flag in Labs and keep it default-off for workspaces without a stored value; enable it in the development seeder. <img width="1285" height="808" alt="Screenshot 2026-07-15 at 15 50 17" src="https://github.com/user-attachments/assets/b7e3f7f1-ca77-492f-8cce-cca186ebca0b" /> |
||
|
|
0dbae2eda3 |
Address #22827 review comments and converge application file endpoints (#22868)
Follow-up to #22827, addressing the review comments left around merge time and applying the endpoint convergence discussed afterwards. ## Review comments from #22827 - **Swallowed error in dev sync asset read** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571513265)): the swallow is intentional (a missing public asset must not fail the whole dev sync) but it now logs a warning with the asset path and error, and the registration keeps its previously stored file for that path instead of losing it. - **`isAbsoluteUrl` location** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571524234)): moved to `twenty-shared/utils/url`. The server, and now also `twenty-sdk`'s `normalize-application-assets`, use the shared util. - **Soft delete vs file cleanup** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571589558)): per review, deleting a registration is now a hard delete. Stored assets (bytes + rows) are deleted with it, dependent rows are removed by their existing FK cascades, and installed applications keep working with their registration link nulled. No soft-delete/cron mechanism. - **Asset cap too generous** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571595745)): lowered to 10MB per review and documented in the publishing and public-assets docs pages. - **One missing image retriggers a full asset sync** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571646243)): `storeRegistrationAssets` now takes `skipAlreadyStoredPaths`; the catalog sync passes it when the package version is unchanged, so only assets missing a stored file are fetched instead of re-downloading everything. - **`existing.logo` already contains the new logo** ([comment](https://github.com/twentyhq/twenty/pull/22827#discussion_r3571667847)): correct, `updateFromManifest` runs first, so the previous "keep fileId when the path did not change" guard compared the new logo against itself. The fileId preservation is now keyed on the stored server file for the exact path (files are unique per `(applicationRegistrationId, path)`): a changed logo path no longer inherits the old file's id, and a transient download failure on an unchanged path still keeps the working file. This also removed the fileId-preservation bookkeeping from `storeRegistrationAssets`. ## Endpoint convergence - **Path-addressed public route for registration assets**: `GET /file/server/application-registration/:fileId` is replaced by `GET /files/application-registrations/:registrationId/*path`, mirroring the manifest's public-folder paths and leaving room for a future `:version` segment. Assets stay addressable by stable ids server-side; the fileId now only marks a path as stored. No URL is ever persisted (all are built at query time), and the old route never shipped in a release, so there is nothing to migrate. - **`Application.logoUrl` resolved server-side**: new `ResolveField` on the `Application` type builds the `/public-assets/...` display URL (or passes absolute URLs through). `useApplicationChipData` now reads it from `currentWorkspace.installedApplications`, and the frontend `buildApplicationLogoUrl` util is deleted, so clients no longer construct file URLs themselves. ## Validation - Unit: `file.controller.spec` (route renamed, traversal case added), `server-file-storage.service.spec` (`findServerFile`, `deleteByApplicationRegistrationId`), `application-registration-asset-url.service.spec` (new URL shape, url-encoding), new `isAbsoluteUrl` test; all application/file suites pass. - Live against a local server: new route serves tarball and rehosted npm assets with `public, max-age=3600` (nested paths included), 404s on missing files, unknown registrations, traversal attempts, and the removed old route; `findManyApplicationRegistrations` returns path-addressed URLs for stored assets, CDN fallback for npm, absolute passthrough; `installedApplications.logoUrl` resolves the public-assets URL and stays null for logo-less apps. Registration hard delete verified against the DB: file rows cascade, application rows keep a nulled registration link. - Typecheck + lint on twenty-server, twenty-front, twenty-shared, twenty-sdk; metadata codegen and client-sdk regenerated. |
||
|
|
f4ff234db8 |
feat: make record avatar/icon resolution data-driven via a configurable image identifier field (#22644)
## Summary Today the avatar/icon shown for a record is hardcoded per object — Company pulls a favicon from its domain link, Person uses `avatarUrl`, etc. This PR replaces that hardcoding with a generic, data-driven abstraction based on a configurable **image identifier field** on each object's metadata (mirroring the existing **label identifier** concept). An object's image identifier can point to: - a **`FILES`** field → the uploaded image is used directly (rounded avatar), or - a **`LINKS`** field → a favicon is derived from the primary URL via the Twenty icons service (squared avatar), gated by `ALLOW_REQUESTS_TO_TWENTY_ICONS`. This lets any object type (Opportunity, a custom "Listing", etc.) define its own avatar/icon without code changes, and makes the field configurable/overridable for standard objects. ## ❓ Open question: also allow `TEXT` → direct image URL? Right now the image identifier is restricted to `FILES` (uploaded file) and `LINKS` (favicon). We deliberately left out `TEXT` → **direct image URL** (e.g. an imported/synced photo URL stored in a text field). There's precedent for it — Person's avatar was originally a `TEXT` `avatarUrl`, and WorkspaceMember still is — and it's unambiguous (a `TEXT` field has no favicon-vs-image ambiguity, and selecting it as the image identifier is itself the declaration of intent). It's a small, clean extension: - add `TEXT` to the allowed image-identifier types, - add an explicit `TEXT → raw URL` case - `getAvatarType`: `TEXT → rounded`. Caveats: it relies on admin assertion that the text values are image URLs (no data-level guarantee), and external image URLs load third-party content in the browser (IP-leak/hotlinking, same as favicons — a proxy/cache would be the more robust long-term answer). ### ✅ Resolution Decision: **we will not support `TEXT` as an image identifier.** Image identifiers stay restricted to `FILES` and `LINKS`, and any other type fails closed (returns no avatar) on both the frontend and backend. Instead, the legacy items that still rely on a `TEXT` avatar — Person's deprecated `avatarUrl` and WorkspaceMember's `avatarUrl` — will be migrated to `FILE` fields in a follow-up PR. Until then, WorkspaceMember remains an exception (its `avatarUrl` still resolves through the existing CorePicture path), and legacy Person `avatarUrl` values that haven't been migrated will show initials placeholders. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22644?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
a28c3a905a |
Route pre-2.19 upgrade commands through a legacy validate-build path (#22884)
## Problem Since the centralized metadata side-effect engine landed in v2.19, `WorkspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigrationFromRecord` runs `metadataSideEffectEngineService.expandWithSideEffects(...)` before building. As a result every historical upgrade command (`upgrade-version-command/1-21/*` … `2-18/*`), authored before the engine existed, now flows through it. Their operation matrix is no longer applied literally: the engine injects/cascades companions (system fields, `searchVector` field + GIN index, `searchFieldMetadata` rows, unique backing indexes) and can hard-fail on reserved-identifier collisions (`RESERVED_SYSTEM_UNIVERSAL_IDENTIFIER`). Two hazards for already-shipped commands: 1. **Collision → hard failure**: a command declaring a companion the engine now owns collides with the engine's deterministic `universalIdentifier`. 2. **Silent drift**: on object/field create/delete the engine adds/cascades companions the command author never intended, so workspaces upgraded now differ structurally from those upgraded incrementally before 2.19. Suspected real-world impact: a self-hosted user upgrading v2.6.1 → v2.21.0 hit `duplicate key value violates unique constraint "IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE"` in `upgrade:2-16:backfill-search-field-metadata`, because object-creating commands now cascade and pre-create the deterministic `searchFieldMetadata` rows the standalone backfill then re-inserts. ## Changes - `workspace-migration-validate-build-and-run-service.ts`: extract the shared compute-and-run tail into a private method, and add `validateBuildAndRunLegacyWorkspaceMigration` (marked `@deprecated`) that skips `expandWithSideEffects` and applies the matrix literally. The existing side-effect entry points are unchanged (the live API and application manifests depend on them). - Repoint **all** pre-2.19 upgrade command call sites (1-21 … 2-18, including `2-10 sync-call-recording-standard-objects`) to the legacy method. Only the four `2-20/*` commands (target version ≥ 2.19) remain on the side-effect path. - `2-16 backfill-search-field-metadata`: recompute `flatSearchFieldMetadataMaps` from the database before building the existing-rows dedupe set. The migration runner only invalidates the flat-maps keys a migration touched, so during a cross-version upgrade earlier commands can leave this map stale; a stale map breaks the dedupe and re-inserts rows, tripping `IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE`. This is the direct fix for the reported failure. - Export `FlatEntityMapsBundle` so the shared tail can be typed. - Document the side-effect vs legacy path and the selection rule in `packages/twenty-server/docs/UPGRADE_COMMANDS.md`. Selection rule: target version **< 2.19** → legacy path; **≥ 2.19** → side-effect path (default). No exceptions. ## Known gap / merge ordering The static twenty-standard definition declares all of `callRecording`'s fields (including the `searchVector` system field) but **not** its `searchVector` GIN index — every other searchable standard object declares its GIN index statically. On the legacy path, workspaces upgrading through `2-10 sync-call-recording-standard-objects` therefore create the `searchVector` column unindexed (`searchFieldMetadata` rows are created later in the same pipeline by the 2-16 backfill). The static GIN index declaration plus a backfill for already-upgraded workspaces land in a follow-up (twentyhq/core-team-issues#2672), which must ship in the same release as this PR. ## Out of scope (separate follow-ups) - `UpgradeMigrationService.getLastAttemptedInstanceCommand()` ordering. - callRecording `searchVector` GIN index static declaration + backfill (twentyhq/core-team-issues#2672, same-release dependency, see above). ## Test plan - `nx typecheck twenty-server` passes. - `nx lint:diff-with-main twenty-server` (oxlint + oxfmt) clean on changed files. - 2-20 command specs (which exercise the unchanged side-effect path) pass. --------- Co-authored-by: twenty <noreply@twenty.com> |
||
|
|
14dacd8d35 |
[Slow db query] Resolve applicationId from cache in FileStorageService (#22870)
## Context Sentry flagged a recurring slow DB query (TWENTY-SERVER-HZJ): `SELECT ... FROM core.application WHERE workspaceId = $1 AND universalIdentifier = $2 AND deletedAt IS NULL LIMIT 1`, emitted on every file write under `POST /graphql` (record avatar/file fields) and `POST /metadata`. `FileStorageService` re-resolved the owning application row from `core.application` by `(workspaceId, universalIdentifier)` on every file write, uncached and synchronously in the request path. The row was only used to recover `application.id`. The workspace cache already exposes this mapping via `flatApplicationMaps.idByUniversalIdentifier`. Closes twentyhq/core-team-issues#2668. ## Changes - Injected `WorkspaceCacheService` into `FileStorageService` in place of the `ApplicationEntity` repository. - Added `resolveApplicationIdOrThrow`: resolves `applicationId` from `flatApplicationMaps.idByUniversalIdentifier` on the normal (already-committed) path, throwing `FileStorageException(FILE_NOT_FOUND)` on a cache miss. When a `queryRunner` is provided (application-creating transactions, where the freshly created row is not yet in cache), it keeps the DB read through `queryRunner.manager` so it can see uncommitted rows. - Added `resolveApplicationUniversalIdentifierOrThrow` for the by-id lookup in `deleteByFileId`, resolved from `flatApplicationMaps.byId`. - Applied the cache path to `writeFile`, `createPendingFile`, `deleteFile`, `deleteFolder`, and `deleteByFileId`. Only `writeFile` carries a `queryRunner`; the others never do. - Updated `FileStorageModule` to import `WorkspaceCacheModule` and drop the now-unused `ApplicationEntity` repository registration. No migration needed: a partial unique composite index on `(universalIdentifier, workspaceId) WHERE deletedAt IS NULL AND universalIdentifier IS NOT NULL` already exists on `ApplicationEntity` and covers the query. ## Tests Extended `file-storage.service.spec.ts`: - cache hit resolves `applicationId` without a DB call, - cache miss throws `FILE_NOT_FOUND`, - the `queryRunner` path still reads from the DB and skips the cache. All 95 file-storage unit tests pass; typecheck, oxlint, and oxfmt are clean on the touched files. --- _Generated by [Claude Code](https://claude.ai/code/session_018GUrJ26xvZpjtrGGev9jsk)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22870?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
58fcb3cb0f |
drop nestjs-query IDField from standalone DTOs/entities (#22881)
## What Replaces `@IDField(() => UUIDScalarType)` from `@ptc-org/nestjs-query-graphql` with the native `@Field(() => UUIDScalarType)` (`@nestjs/graphql`) across 50 DTOs and entities that are **not** wired to a nestjs-query auto-resolver. This continues the incremental migration off `@ptc-org/nestjs-query`. ## Why These 50 types only used `IDField` to type their `id` column as a UUID scalar. Since none of them are attached to a `NestjsQueryGraphQLModule.forFeature` resolver, `IDField` carries no extra behavior here — it's a plain field decorator. Co-authored-by: Abdul Rahman <abdulrahmancodes@users.noreply.github.com> |
||
|
|
9f75506896 |
feat(workflow-tools): add get_logic_function_source tool (#22835)
## What / why
The workflow agent tools let an AI **write** a CODE step's logic
function (`update_logic_function_source`) and **list** logic functions
(`list_logic_function_tools`), but there is no tool to **read** an
existing function's source.
That's fine for greenfield generation — the agent already has in context
whatever code it just wrote. But it's a real gap when editing a function
the agent did **not** author: to safely modify an existing CODE step it
has to see the current source first, and today the only ways to get it
are the frontend (`getLogicFunctionSourceCode` query) or the DB. So the
agent is forced to either guess or ask a human to paste the code.
This adds a small read tool that closes the loop, mirroring the existing
`update_logic_function_source` tool.
## How
- New `get_logic_function_source` tool that calls the existing
`LogicFunctionFromSourceService.getSourceCode({ id, workspaceId })` —
the same service method backing the `getLogicFunctionSourceCode` GraphQL
resolver the frontend already uses. No new service logic.
- Registered in `workflow-tool.workspace-service.ts` alongside
`update_logic_function_source` (the dependency
`logicFunctionFromSourceService` is already injected).
- Unit test covering the success and error paths, matching the `get-*`
tool test convention.
## Notes
- Read-only, additive; no schema or API changes.
- Naturally pairs with `update_logic_function_source`: read → edit →
write.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22835?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
94192a2164 |
Resolve application registration logo and gallery image urls at query time (#22827)
## Context
Application registration logo and gallery image urls were baked into the
stored manifest and display columns at write time, with each source flow
doing it differently: npm catalog sync baked CDN urls, local dev sync
baked `public-assets` urls, and tarball uploads left raw manifest paths
that never displayed in the UI. The entity also carried a `logoUrl`
getter computed field.
This moves url generation to query time, the same way the workspace logo
works.
## What changed
**Read side**
- `ApplicationRegistrationAssetUrlService` builds display urls when
queried: stored files are served by fileId, absolute urls pass through
untouched, and not-yet-rehosted npm assets fall back to the registry CDN
from `sourcePackage@latestAvailableVersion`.
- The `logoUrl` getter on `ApplicationRegistrationEntity` is replaced by
`logoUrl` and `galleryImages` `@ResolveField`s on the metadata resolver,
the admin panel resolver, and a new resolver for
`ApplicationRegistrationSummary` (used by
`Application.applicationRegistration`).
- The marketplace detail/card DTOs and the public OAuth authorize DTO
(`findApplicationRegistrationByClientId`) go through the same url
builder.
- New public route `GET /file/application-registration/:id` streams
registration server files (these are instance-global marketplace assets,
also shown on the public OAuth authorize page).
`ServerFileStorageService.readServerFileById` now returns the mime type
alongside the stream.
**Write side**
- New `logoFileId` column on `applicationRegistration` (2.21 fast
instance command, constraint names match TypeORM naming), complementing
the fileIds already stored in the `galleryImages` jsonb.
- `ApplicationRegistrationAssetService` copies the manifest logo and
gallery images into instance-global server file storage, so all three
sources behave the same:
- **TARBALL**: from the uploaded package (previously only gallery images
were stored, never the logo).
- **LOCAL**: dev sync reads the already-uploaded public assets from
workspace storage (the CLI uploads files before syncing).
- **NPM**: catalog sync downloads the assets from the registry CDN.
Downloads are skipped when the package version is unchanged and the
files are already stored; failed or pending downloads fall back to CDN
urls at query time.
- Write-time url rewriting is removed
(`ManifestAssetUrlResolverService`, `resolveManifestAssetUrls`);
manifests now keep raw asset paths. Existing rows with baked absolute
urls keep working through the absolute-url passthrough, so no backfill
is needed.
- `updateFromManifest` and `upsertFromCatalog` preserve stored gallery
fileIds for unchanged paths, so installs and the hourly catalog sync no
longer clobber them.
## How it was verified
Against a local Postgres/Redis with the server running:
- Fresh database init runs the new instance command; column and FK/UQ
constraint names match TypeORM's generated names, and the CI
pending-migration check produces no diff.
- `findManyApplicationRegistrations { logoUrl galleryImages }` returns
fileId-served urls for a TARBALL registration (absolute urls passed
through), and null/[] for a LOCAL registration without assets.
- Ran `marketplace:catalog-sync` against the real npm registry: 14
packages synced, logos and gallery images rehosted from unpkg with
fileIds set; a second run re-downloaded nothing (version-unchanged
skip); `findMarketplaceAppDetail` for `twenty-linear` returns
fileId-served urls for the logo and all four gallery images.
- `GET /file/application-registration/:id` serves stored files with the
right content type (png and svg verified), 404s on unknown ids, and the
token-guarded generic `/file/:folder/:id` route still returns 403
without a token.
- Unit tests for the url builder and the assets-stored check; server
unit test suites for the application module pass; typecheck and lint
clean.
|
||
|
|
2b0b62235e |
fix: validation link for access-domains deeplinks to Invite tab (#22845)
The "validate domain" link sent in the email when adding an Access Domain wasn't working because the link didn't deep link to the Invite tab URLs are now built to include the that hash property to deep link to the target tab. Before: ``` https://example.com/settings/members?wtdId=<id>&validationToken=<token> ``` After: ``` https://example.com/settings/members?wtdId=<id>&validationToken=<token>#invite ``` <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22845?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
ca437d374f |
chore: bump version to 2.22.0 (#22867)
## Summary - Moves current version to previous versions array - Sets TWENTY_CURRENT_VERSION to the new version - Updates TWENTY_NEXT_VERSIONS with the next minor version - Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same version ## Checklist - [ ] Verify version constants are correct - [ ] Verify npm package versions match <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22867?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com> |
||
|
|
de75be16e2 |
harden(server): backfill and enforce workspace.databaseSchema invariant with a check constraint (#22855)
## What & why `core.workspace.databaseSchema` is meant to be set for every workspace past the creation phase. It only started being written at creation time in 2.x (dual-write since 2026-03-28, direct write since 2026-04-10); older workspaces relied on the `1-21 backfill-datasource-to-workspace` instance command, which never effectively ran on some instances. On affected rows the column could be left `NULL`. A null value on a post-creation workspace is a real integrity problem — several paths trust the column: - **REST API**: `hydrateRestRequest` throws `No data sources found` for authenticated requests. - **GraphQL API**: `getOrComputeSchemaSDL` returns `null`, so `WorkspaceSchemaFactory` hands back an empty schema. - **GraphQL introspection** (direct execution) returns `null`. This PR makes the invariant impossible to silently violate, and repairs any instance still lagging. ### On the original "No data source, skipping" logs This investigation started from `BackfillActorSourceEnumValuesCommand` logging `No data source for workspace <id>, skipping` at high volume. **That symptom is not explained by this change, and this PR is not a fix for it.** Findings: - The workspace iterator only processes `ACTIVE` + `SUSPENDED` workspaces, and on the affected instance all of those already have `databaseSchema` set (only `PENDING_CREATION` rows are null, and those are never iterated). - `getGlobalWorkspaceDataSource()` never resolves to `undefined` (it returns a value or throws), so a defined-schema workspace should never hit the skip branch. - The upgrade-aware repository proxy was investigated as a possible cause (it can short-circuit `findOne` to `null` for entities marked unavailable during an upgrade) and **exonerated**: `WorkspaceEntity` and its `databaseSchema` column carry no `@WasIntroducedInUpgrade`/`@WasRemovedInUpgrade` decorators, so `resolveEntityShapeAtUpgradeCursor` always reports the entity available and the column visible at every cursor. In other words, current code should emit zero such skips for that instance's data, so the root cause of the observed logs remains undetermined and is tracked separately. See twentyhq/core-team-issues#2666. ## Changes - **Check constraint `workspace_requires_database_schema`** (the core of this PR): enforces `databaseSchema IS NOT NULL` for any workspace past creation (`activationStatus NOT IN ('PENDING_CREATION', 'ONGOING_CREATION')`). Declared on `WorkspaceEntity` and applied in the slow instance command's `up()`. Safe against the creation flow: `databaseSchema` is written in `WorkspaceManagerService.init` (right after schema creation) long before a workspace becomes `ACTIVE`. - **Defensive backfill** (`2-21` slow instance command): repopulates `databaseSchema` where it is `NULL`/empty, deriving the schema name deterministically from the workspace id (`getWorkspaceSchemaName`) and only setting it for workspaces whose schema actually exists in `information_schema.schemata` (so `PENDING_CREATION` rows without a provisioned schema are left untouched, and stay exempt via the constraint). No-op on instances already backfilled. - `runDataMigration` runs before `up()`, so the backfill repairs legacy rows before the constraint is enforced. Keeping both in the same slow command (rather than a standalone fast command) guarantees the constraint is never added ahead of the repair. - `checkSchemaExists` gets an explicit `: Promise<boolean>` return type. ## Notes - Backfill + constraint live in a **slow** instance command, so they only apply on upgrades run with `--include-slow`. - The constraint is added **`NOT VALID`**: the backfill repairs every workspace whose Postgres schema exists, but some legacy active/suspended workspaces (e.g. carried over from very old versions, as reproduced by the cross-version upgrade from v1.22) have a null `databaseSchema` with no schema to point at and are unrepairable. `NOT VALID` enforces the invariant on all future inserts/updates without failing the upgrade on that pre-existing corruption. - No production request path was changed — the iterator and `checkSchemaExists` keep trusting the (now backfilled + constrained) column. ## Test plan - [ ] Run `database:migrate:prod --include-slow` on an instance with null `databaseSchema` rows; verify rows whose schema exists get backfilled and `PENDING_CREATION` rows are left null. - [ ] Verify the `workspace_requires_database_schema` constraint exists on `core.workspace` and rejects nulling `databaseSchema` on an active workspace. - [ ] Verify a fresh workspace creation still succeeds (constraint does not fight the `PENDING_CREATION` → `ACTIVE` transition). |
||
|
|
9f5d17d1f0 |
Add receipt metrics and logs to connected account sync webhooks (#22853)
Webhook deliveries from Google and Microsoft were invisible at the app level: successful notifications produced no logs and no metrics, so webhook-triggered syncs could not be told apart from cron polling. Add two counters, connected-account-sync-webhook/received/messaging and /received/calendar, mirroring the sync-job metric umbrellas, and log a line whenever a notification triggers a sync. Unmatched subscriptions keep their existing warn logs. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22853?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
1b168ac1f7 |
fix(server): gate workspaceDiscoverability behind upgrade decorator (#22818)
## Context Needs to be patched on 2.20, will craft a 2.19 equivalent with fallback asap ( be it won't be merged unlike this one ) Fixes #22662. Follow-up to #22423, which introduced `workspaceDiscoverability`. A clean 2.18.x to 2.19 upgrade breaks on login with: ``` QueryFailedError: column workspaceDiscoverability does not exist ``` `workspaceDiscoverability` was added to `WorkspaceEntity` (in #22423) as a plain, always-selected, non-nullable column, so any workspace query (including the auth-path `findAvailableWorkspacesByEmail` lookup) fails as soon as the ORM selects it, before the `2.19` upgrade command that creates the column has run. Because the Docker entrypoint is fail-open, the API starts even if the upgrade is delayed, and users hit this on their first login. ## Changes - Add `@WasIntroducedInUpgrade` to `workspaceDiscoverability`, referencing the existing `2.19.0` fast instance command that creates the column. The upgrade-aware ORM then skips the column until the command has actually added it, keeping login working during the upgrade. - Keep the GraphQL `@Field` non-nullable and add a `workspaceDiscoverability` `@ResolveField` that falls back to `WorkspaceDiscoverability.PUBLIC` while the column is hidden, so the resolver never returns `null` for the non-nullable field during the upgrade window. This mirrors the existing pattern already applied to `FileEntity.status` and `FileEntity.applicationRegistrationId`, and the resolver-default pattern already used for `fastModel` / `smartModel` / `logo`. ## Cherry-pick This fix needs to be cherry-picked onto both the **2.19** and **2.20** release branches, since affected instances are upgrading into those versions. ## Test - `validate-upgrade-aware-entity-decorators` and `resolve-entity-shape-at-upgrade-cursor` unit tests pass (the referenced upgrade command name resolves correctly). - `upgrade-aware-repository.proxy` and `upgrade-aware-entity-metadata.adapter` specs pass. - `typecheck` and lint pass for `twenty-server` and `twenty-front`. - Regenerating the GraphQL schemas produces no diff (the field stays non-nullable). |
||
|
|
7381038452 |
Paginate admin panel app registrations list (#22734)
## Context The `findAllApplicationRegistrations` query on the admin panel Apps page (`/settings/admin-panel#apps`) loaded every application registration at once, with search and filtering done client-side. ## Changes **Server** - `findAllApplicationRegistrations` now takes `limit` / `offset` / `searchTerm` / `isPreInstalledOnly` args and returns a `PaginatedApplicationRegistrations` object (`registrations`, `totalCount`, `hasMore`), following the same pattern as `getQueueJobs`. - `ApplicationRegistrationService.findAll` uses `findAndCount` with `take`/`skip`, and moves the search (name, source package, universal identifier via `ILIKE`) and the pre-installed filter into the SQL query, mirroring how `getInstalledWorkspacesGlobal` filters installed workspaces. **Frontend** - `SettingsAdminApps` passes the page, the debounced search term (300ms, like the installed workspaces table), and the pre-installed toggle as query variables instead of filtering client-side. - Adds a Previous / Next pagination footer (25 per page) matching the queue jobs table, shown only when there is more than one page. - The "unconfigured first" ordering is kept within each page (`isConfigured` is a dataloader-resolved field, so it can't be sorted in SQL). ## Notes - Regenerated `generated-admin/graphql.ts` follows in a subsequent commit. --- _Generated by [Claude Code](https://claude.ai/code/session_015erumgPozkbNA3zPeKrrFW)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22734?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Weiko <corentin@twenty.com> |
||
|
|
652adc3c03 |
fix(server): backfill isSystemSideEffect on system fields provisioned before 2.15 (#22850)
## Context The `isSystemSideEffect` column was introduced in **2.15** via a fast instance command that added it with `DEFAULT false`. That stamped `false` onto every pre-existing `fieldMetadata` row — including the 8 engine-owned system fields (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy`, `position`, `searchVector`) of every object provisioned before 2.15, regardless of the creation path (API metadata **and** manifest sync). The per-workspace backfill that should have re-flagged those existing rows was explicitly deferred as out of scope in #21673 ("PR 2") and never shipped for `fieldMetadata`. Because `isSystemSideEffect` is configured with `toCompare: false`, no later sync ever repaired the stale value either. Since **2.20** the SDK no longer declares system fields in manifests. On an up-to-date instance, `twenty plan` against an unchanged app therefore diffs those stale-`false` system fields as **missing from the manifest**, and they fall through the `isSystemSideEffectFlatEntity` exclusion in `buildAllFlatEntityOperationRecordByMetadataNameFromFromTo`. Deletion inference then emits them as deletes, which the validator rejects: ``` Sync failed with 144 errors fieldMetadata: 144 errors 1..144. FIELD_MUTATION_NOT_ALLOWED: System fields cannot be deleted ``` (144 = 8 system fields × 18 custom objects, as reported on a production 2.20 instance.) ## What this PR does Adds a **2.21 workspace command** (`upgrade:2-21:backfill-system-field-is-system-side-effect`) that iterates active/suspended workspaces and flags the 8 system fields as `isSystemSideEffect: true`. - **Resolution by deterministic universal identifier**: for each object × reserved system field name it recomputes `getFieldUniversalIdentifier(applicationUID, objectUID, name)` and looks the row up in the flat maps. This is safe (and preferable to matching by `name`) because the 2.19 backfill already took over system field UIDs for every application, so an author-declared field reusing a reserved name keeps its own identifier and is never touched. An extra `isSystem` guard warn-and-skips any mismatch. - **All applications** are covered (installed apps, workspace custom app, twenty-standard): the stale flag is a function of *when* a row was provisioned, not *how*. Installed/custom apps are the acute `twenty plan` delete trap; twenty-standard has no trap today but flagging is a zero-diff no-op (`toCompare: false`) and a prerequisite for the end-state ownership invariant. - **`name` is intentionally excluded**: the 2.20 slow instance command deliberately flipped it to `false` (caller-provided default, not engine-owned); re-flagging it would undo that migration. - Supports `--dry-run`, updates only the collected rows, and invalidates the `flatFieldMetadataMaps` workspace cache after the write (a raw repository update does not invalidate it). ## Related - Resolves the pre-2.15 regression tail of twentyhq/core-team-issues#2635 - Follow-up to twentyhq/core-team-issues#2642 (system field side-effect engine migration) <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22850?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Weiko <corentin@twenty.com> |
||
|
|
bbe9886274 |
chore: sync AI model catalog from models.dev (#22842)
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev). This PR updates pricing, context windows, and model availability based on the latest data. New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically. Deprecated models are detected based on cost-efficiency within the same model family. **Please review before merging** — verify no critical models were incorrectly deprecated. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22842?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com> |
||
|
|
983f03adbe |
i18n - translations (#22833)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
b0dc637dbd |
Throw proper error on duplicate emailing domain (#22790)
Adding an emailing domain that already exists blew up with a raw QueryFailedError and the client just saw a generic "An error occurred". The unique index on domain is global, so the workspace-scoped existence check never caught rows owned by another workspace. Now the check is unscoped and throws an EmailingDomainException mapped to CONFLICT with a proper user-facing message, in both the createEmailingDomain mutation and the email group channel flow. Also dropped the hardcoded catch-all snackbar on the new channel page so server messages actually reach the user. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22790?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
cb2e325c4b |
fix(workflow): make prefilled workflow ids unique per workspace (#22800)
## Problem \`prefillWorkflows\` (run for every workspace on \`activateWorkspace\`) inserts workflows and versions with **hardcoded ids** (\`QUICK_LEAD_WORKFLOW_ID = 8b213cac...\`, etc.). So every workspace carries the same workflow/version record ids. Within a workspace schema that's harmless, but it means workspace record ids are **not unique across workspaces**, which: - breaks the workflowVersion backfill on the shared core table (surfaced as the \`IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW\` duplicate-key error, since multiple workspaces claim the same active \`workflowId\`), and - collides on \`core.workflow\`/\`core.workflowVersion\` PKs once workflows migrate to core (the core row reuses the workspace record id), causing cross-workspace clobbering. ## Fix Derive the prefill ids **deterministically per workspace**: \`getWorkflowPrefillIds(workspaceId)\` returns \`v5(label:workspaceId, namespace)\` for each of the workflow/version/trigger ids. Deterministic (stable across the idempotent \`orIgnore\` re-runs) but unique per workspace. The command-menu-item prefill uses the same helper so its \`workflowVersionId\` reference stays consistent. Only affects **new** workspaces; existing workspaces keep their current ids (prefill is skipped on re-activation). ## Test Reset seeds two workspaces; both now get a Quick Lead workflow with a **distinct** v5-derived id (not the old \`8b213cac\`), and internal references stay consistent (\`version.workflowId == workflow.id\`, \`lastPublishedVersionId == version.id\`). Typecheck + lint clean. Companion to #22795 (which scopes the active index to workspace). Together they fix the backfill duplicate-id failures. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22800?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
ab9e6f30b8 |
chore: bump version to 2.21.0 (#22820)
## Summary - Moves current version to previous versions array - Sets TWENTY_CURRENT_VERSION to the new version - Updates TWENTY_NEXT_VERSIONS with the next minor version - Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same version ## Checklist - [ ] Verify version constants are correct - [ ] Verify npm package versions match <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22820?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com> |
||
|
|
666ceb41d5 |
Fix SDK plan on non installed apps (#22805)
# Context `yarn twenty plan` fails when the app has never been installed in the target workspace: ``` Sync failed with error: Application "f5ce204f-..." is not installed in workspace "10f39a9d-...". Install it first. Hint: run `yarn twenty dev --once` to register the app in this workspace, then retry. ``` This forces developers to apply before they can plan, which defeats the purpose of `plan`. Planning a not-yet-installed app is well defined: the from-state is empty, so the plan is simply "create everything". ## Why it failed The dry-run sync required the application row to exist in two places: 1. `ApplicationSyncService.synchronizeFromManifest` threw `APP_NOT_INSTALLED` when the app row was missing, because the dry-run needs an owner `FlatApplication` to anchor the from → to metadata diff. 2. `WorkspaceMigrationFlatEntityMapsService.computeAllInvolvedApplicationIds` threw when the owner app id was absent from `flatApplicationMaps`, even though the only hard dependency of a build is the twenty standard application. `apply` never hit this because it registers the app row as a side effect before syncing (and even swallows this exact error on its pre-apply plan). # What this PR does Keeps `plan` strictly read-only, no registration or app row is created: - **`application-sync.service.ts`**: on dry-run, resolve the owner to the installed application when it exists (unchanged behavior), otherwise build a virtual, non-persisted `FlatApplication` from the manifest. Its freshly generated id matches no existing metadata, so the from-state slice resolves to empty and every manifest entity shows up as a create. - **`workspace-migration-flat-entity-maps.service.ts`**: relax the guard so only the twenty standard application is required. A missing owner app just contributes an empty from-slice instead of throwing. Installed apps take the exact same path as before (`applicationId` defined → identical behavior). |
||
|
|
60f5964c64 |
Run front components in a sandboxed opaque-origin iframe (#22588)
Front components run untrusted third-party React in a Web Worker. That
worker previously shared the host origin, so it could reach
origin-scoped storage (the metadata-store IndexedDB, the
`twenty-sign-out` BroadcastChannel), cookies, and same-origin resources.
This runs the worker inside a `sandbox="allow-scripts"` (no
`allow-same-origin`) iframe, giving it an opaque origin where the
browser denies localStorage, cookies, IndexedDB, and BroadcastChannel
outright. The worker is kept inside the iframe (rather than a bare
iframe) so untrusted code always runs off the main thread; the
remote-dom render path is unchanged.
- **Transport:** host ↔ iframe ↔ worker over a re-transferred
`MessagePort` (`ThreadMessagePort`); a small bootstrap script is inlined
into the iframe via `srcdoc` (bundled at build time by a prebuild step)
and relays the port to the worker it spawns. Messages across the
boundary use a typed discriminated union with a single parse/guard.
- **Network:** under the opaque origin, direct fetches to the Twenty API
would be `Origin: null`, so the component source and SDK modules are
fetched through an allowlisted, credential-omitting `hostFetch` bridge
and blobbed inside the worker. The allowlist is single-sourced on the
host (http(s) origins only) and carried in the render context. The
bridge is mandatory (rendering fails closed if it is missing), refuses
redirects except for GET/HEAD to the known file-storage URLs, and caps
response body size.
- **SDK loading:** SDK client modules now load inside the worker through
the bridge, replacing the host-side SDK-blob state/effect/provider with
a pure `getSdkClientUrls` URL builder.
- **Isolation tests:** a unit test locks the sandbox attribute
(`allow-scripts`, never `allow-same-origin`); a browser test asserts the
worker actually gets an opaque origin with storage denied, probing
cookies by writing one rather than reading an empty jar.
Also adds a "List Companies" seed front component that queries workspace
data via the SDK client (exercising the bridge end-to-end),
single-sources the command-menu confirmation-modal result event name and
detail type in `twenty-shared` (previously a hand-synced duplicate), and
decomposes the renderer (bridge, sandbox, worker orchestration) into
small single-purpose utils with unit tests.
## How it works
```mermaid
sequenceDiagram
autonumber
participant Host as Host window (twenty-front · host origin)
participant Frame as Sandboxed iframe (allow-scripts · opaque origin)
participant Worker as Worker (untrusted component · opaque origin)
participant API as Twenty API (host origin)
rect rgb(238,242,248)
Note over Host,Worker: 1 — Boot handshake
Host->>Frame: create iframe sandbox="allow-scripts", srcdoc = inlined bootstrap script
Host->>Host: MessageChannel + ThreadMessagePort(port1)<br/>exports = host API + hostFetch
Frame-->>Host: READY
Host->>Frame: INIT + transfer port2
Frame->>Worker: spawn inlined Worker + re-transfer port2
Worker->>Worker: ThreadMessagePort(port)<br/>exports = render / updateContext
Note over Host,Worker: Port now entangles Host ↔ Worker directly
end
rect rgb(246,240,248)
Note over Host,Worker: 2 — Render
Host->>Worker: render(connection, { componentUrl, sdkClientUrls, hostFetchOrigins, token })
Worker->>Worker: override globalThis.fetch<br/>(Twenty origins → hostFetch)
end
rect rgb(248,244,238)
Note over Worker,API: 3 — Network via hostFetch bridge (opaque Origin:null cannot reach the API directly)
Worker->>Host: hostFetch(componentUrl, Bearer)
Host->>Host: origin allowlist + credentials:'omit'
Host->>API: fetch(componentUrl)
API-->>Host: source
Host-->>Worker: { status, headers, body }
Worker->>Host: hostFetch(sdkClientUrls.core / .metadata)
Host-->>Worker: SDK module sources
Worker->>Worker: blob each source in its own opaque origin → import() → run untrusted React
end
rect rgb(238,248,242)
Note over Worker,Host: 4 — Render mirror
Worker->>Host: remote-dom mutations (RemoteConnection)
Host->>Host: RemoteReceiver → RemoteRootRenderer → host DOM
end
Note over Worker: Opaque origin ⇒ browser denies localStorage,<br/>cookies, IndexedDB, BroadcastChannel
```
|
||
|
|
c1b62334b7 |
fix(workflow): scope one-active-per-workflow index to workspace (#22795)
## Problem
The Phase 0 core index \`IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW\`
is on \`(workflowId) WHERE status='ACTIVE'\`, with **no
\`workspaceId\`**. But \`core.workflowVersion\` is a shared multi-tenant
table, so this enforces "one active version per workflowId **globally
across all workspaces**" instead of per workspace.
The version backfill fails on staging with:
\`\`\`
duplicate key value violates unique constraint
"IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW"
Detail: Key ("workflowId")=(8b213cac-...) already exists.
\`\`\`
across several different workspaces that share the same workflowId
(seeded/cloned data): workspace A's active version claims the
workflowId, and every other workspace's insert collides. Every other
index on this table includes \`workspaceId\`; this one dropped it when
copied from the per-tenant workspace entity.
## Fix
Index becomes \`(workspaceId, workflowId) WHERE status='ACTIVE'\` — one
active version per workflow **per workspace**, matching the table's
multi-tenant design and the intended invariant. New 2-20 fast instance
command drops and recreates the index (Phase 0's command is
merged/append-only).
## Test
Reset + reproduce the exact scenario against the fixed index:
- two workspaces with the same workflowId, both ACTIVE → **insert
succeeds** (previously collided)
- a second ACTIVE version for the same workflow within one workspace →
**still blocked** (invariant preserved)
Zero \`migrate:generate\` drift, typecheck + lint clean. After this
deploys, re-run \`upgrade:2-20:backfill-workflow-version-to-core\`.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22795?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
ffdda50afc |
remove nestjs-query auto-resolver from index-metadata (#22775)
## Summary
Removes the `NestjsQueryGraphQLModule.forFeature` block from
`IndexMetadataModule`, continuing the incremental migration off
`@ptc-org/nestjs-query`.
- Drops the dead auto-generated read surface: `index` and
`indexMetadatas` queries, the `IndexConnection` /
`IndexObjectMetadataConnection` types, and the `Index.objectMetadata`
field. No client consumes these — the frontend reads indexes via
`ObjectMetadata.indexMetadatas`.
- Keeps `IndexMetadataDTO` nestjs-query-compatible (`@Authorize`,
`@FilterableField`, `@QueryOptions`, `@IDField`) because
`ObjectMetadataDTO` still references it via
`@CursorConnection('indexMetadatas')` until object-metadata is migrated.
- Hand-written `createOneIndex` / `deleteOneIndex` mutations and the
`indexFieldMetadataList` resolve-field are unchanged.
- Deletes the now-obsolete `index-metadatas` integration test and
regenerates the GraphQL schema artifacts (frontend + client-sdk).
## Breaking change
This is an intentional GraphQL schema breaking change
(`api-breaking-changes` CI will flag it)
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22775?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
d23511ea5e |
remove nestjs-query from user and workspace resolvers (#22766)
## What Migrates the `user` and `workspace` core modules off `@ptc-org/nestjs-query`. Both used `NestjsQueryGraphQLModule` only as scaffolding — all CRUD was disabled and the real GraphQL API is already served by the hand-written `UserResolver` / `WorkspaceResolver`. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22766?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Weiko <corentin@twenty.com> |
||
|
|
4ab36a630b |
remove nestjs-query from app-token and drop unused createOneAppToken mutation (#22765)
## What Migrates `app-token` off `@ptc-org/nestjs-query` and removes the auto-generated `createOneAppToken` mutation, which was unused dead API surface. ## Why The `createOneAppToken` mutation was reachable only from the schema — no frontend query, SDK caller, or test used it. It was also non-functional (its input couldn't set the token `value`), a leftover from nestjs-query's default `create.one` being left enabled. Real app tokens (refresh, password-reset, email-verification, invitation, OAuth, enterprise) are all created directly via the repository in ~14 services, none of which touched this mutation. ## Notes - ⚠️ This removes `AppToken`, `createOneAppToken`, `CreateAppTokenInput`, and `CreateOneAppTokenInput` from the `/metadata` schema, so the **api-breaking-changes check will flag it** <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22765?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: Weiko <corentin@twenty.com> |
||
|
|
9e20e2222a |
Fix front-component serving on Safari, kill stale presigned caching, and cache built bundles client-side (#22672)
## Context Built front-component bundles are served via `GET /rest/front-components/:id/:cacheKey`. On S3-backed storage (Twenty Cloud) the endpoint used to 302-redirect the worker's authenticated fetch to a presigned S3 URL. That redirect caused two bugs, and fixing it removed the caching the redirect was accidentally providing — so this PR also adds a proper client-side cache. Closes twentyhq/core-team-issues#2653. ### Bug 1 — Safari 403 (Authorization header forwarded across redirect) The renderer worker fetches the bundle with `Authorization: Bearer`. The controller answered with a 302 to a presigned S3 URL. Per the Fetch spec, browsers must strip `Authorization` on a cross-origin redirect. Chrome/Firefox do, but Safari/WebKit forwards it, so S3 receives both a query-string signature and an `Authorization` header and rejects with `InvalidArgument: Only one auth mechanism allowed`. Result: front components never load in Safari on S3-backed storage. ### Bug 2 — 302 cached publicly (browser-independent) The redirect branch set no `Cache-Control`, so a CDN could cache it far beyond the presigned URL's TTL (`STORAGE_S3_PRESIGNED_URL_EXPIRES_IN`, 900s). Consequences: any client re-served the cached 302 after 15 min hits an expired signature (403, also affects Chrome), and the cached redirect containing a live presigned URL is served to unauthenticated requests (short-lived auth bypass). ### Regression this introduces — warm-load caching lost Marking the handoff `no-store` (Bug 2 fix) is correct, but it means the built bundle is no longer cached anywhere on the S3 path. The browser HTTP cache cannot compensate: the presigned URL that actually returns the bytes carries a fresh `X-Amz-Date`/`X-Amz-Signature` on every request, so each download is a brand-new cache key and never hits. Net effect without mitigation: every worker mount re-downloads the full bundle. ## What changed - **Front components return a 200 JSON body instead of a 302.** The controller now responds `200 { url }` with `Cache-Control: private, no-store`. The worker parses the JSON and issues a separate header-less `fetch(url)` to S3. No redirect means the `Authorization` header is never forwarded, making it browser-independent, and the handoff carrying the presigned URL is never cached. The stream path (local storage) is unchanged. - **Client-side bundle cache in the renderer (restores warm loads).** `fetchComponentSource` wraps the fetch chain in a `CacheStorage` layer keyed by the **content-addressed** `/front-components/:id/:checksum.js` URL. A hit returns the stored bundle and skips **both** the `no-store` handoff to Twenty and the S3 download — restoring cross-session warm loads without ever persisting a presigned credential. Because `CacheStorage` is writable by any same-origin code (including the untrusted component code this cache feeds), cached content is verified against the sha-256 checksum embedded in the URL on every read, and evicted on mismatch. Caching degrades to a plain fetch where `CacheStorage` or WebCrypto is unavailable. - **sha-256 checksums for built front components.** The SDK build and workspace prefill now fingerprint built front-component bundles with sha-256 (WebCrypto has no md5), enabling the integrity check above. Other file folders keep md5. Legacy md5-fingerprinted URLs (32-hex) simply bypass the cache — already-synced components keep working and start benefiting from caching on their next build/sync. - **WebKit e2e coverage.** Added a `webkit` project to the postcard example's Playwright config mirroring `chrome` (shared setup + storageState), plus iframe/worker diagnostics logging so front-component failures surface in the test log. `TZ` is pinned to `Europe/Paris` because WebKit on Linux ignores Playwright's `timezoneId` emulation and rejects the runner's legacy `CET` alias, which crashed the record page before the component could render. ### Why we hand off to S3 instead of streaming through Twenty On S3-backed storage we deliberately **do not** proxy/stream the bundle bytes through the API. The controller returns the presigned URL and the worker fetches the content directly from S3, for two reasons: - **Server CPU/bandwidth.** Streaming every bundle on every cold load would put the API server on the hot path for all front-component content. Handing off to S3 keeps that load off the server. - **Domain isolation.** Front-component content is fetched from the object-storage domain (e.g. `s3.domain.com`), a different origin than the API and the front app. Serving untrusted/app-authored bundle content from a separate domain than `twenty.com` keeps it off the app's origin. The stream path is kept only as the local-storage fallback (no S3/presign available), where these concerns don't apply. ## Examples ### The JSON handoff (S3 path) ```http GET /rest/front-components/d3b07384-.../a1b2c3d4.js HTTP/1.1 Host: twenty.com Authorization: Bearer <worker-token> ``` ```http HTTP/1.1 200 OK Content-Type: application/json Cache-Control: private, no-store {"url":"https://s3.domain.com/bucket/.../checkout-widget.mjs?X-Amz-Date=20260709T091500Z&X-Amz-Expires=900&...&X-Amz-Signature=AAAA1111..."} ``` The worker then fetches that presigned URL **without** headers (the Safari fix) and gets the bundle bytes. ### Why the browser HTTP cache can't reuse it | | Load 1 (09:15) | Load 2 (09:30) | Same key? | |---|---|---|---| | Twenty handoff URL | `.../a1b2c3d4.js` | `.../a1b2c3d4.js` | ✅ but response is `no-store` | | Presigned `X-Amz-Signature` | `AAAA1111...` | `ZZZZ9999...` | ❌ | | Effective S3 URL (the HTTP cache key) | `...&X-Amz-Signature=AAAA1111...` | `...&X-Amz-Signature=ZZZZ9999...` | ❌ new key → miss | ### What the CacheStorage layer stores ``` key = https://twenty.com/rest/front-components/d3b07384-.../a1b2c3d4.js (stable, chosen by us) value = <bundle JS bytes> (NOT the presigned URL) ``` Keying by the stable logical URL (not the volatile URL the bytes arrived from) is the one thing the native HTTP cache can't express. The presigned URL is used once and discarded. ### Invalidation No TTL and no explicit delete — invalidation is by key change. A rebuild changes the checksum → changes the URL → guaranteed miss on the new key. The old entry is orphaned and reclaimed by normal browser eviction (quota/LRU; Safari ITP after 7 idle days). Global invalidation lever: bump the cache name suffix (`front-component-source-v1`). ## Deploy note — front/server release window Old frontend bundles (already-open tabs) hitting the new server receive the JSON handoff where they expect raw JS and fail to render until the tab is reloaded. The other direction is safe: the new worker against an old server follows the 302 transparently (the content-type check falls through to `response.text()`). Accepted as a short deploy-window trade-off. ## Follow-ups (not in this PR) - The client-side cache is a bridge for the `no-store` presigned handoff. If built components are later served from a stable, non-signed, public-by-URL path (they are already content-addressed by checksum, so `immutable` is safe), the browser + CDN cache natively and this custom layer can be removed. - `GET /file/:fileFolder/:id` presigned 302s still carry no `Cache-Control`. An explicit policy there (bounded `private, max-age` below the presigned TTL) was prototyped in this PR and deliberately dropped to keep the scope on front components — the file path authenticates via a query-param token (part of any cache key), so its exposure differs and deserves its own PR. ## Non-goals Per the issue, file serving keeps its query-param token + 302 model. Native browser loads (`<img>`, downloads) cannot do a two-step fetch and already work on Safari. The public-asset redirect is left untouched since its caching is intentional. ## Test plan - Renderer: `fetchComponentSource.spec.ts` covers cache miss + write, verified cache hit (no network), poisoned-entry eviction, checksum-mismatch (never cached), non-fingerprinted and legacy-md5 URL bypass, and the no-`CacheStorage` / no-WebCrypto fallbacks. `fetchComponentSourceFromNetwork.spec.ts` covers the direct JS response, the JSON handoff follow-through (header-less presigned fetch), and error mapping. - e2e: the postcard front-component spec now runs on both Chromium and WebKit against prod-parity storage (S3 + Lambda). - `oxlint` + `oxfmt` clean; typecheck passes on changed packages. ### Reproduction proof — Safari was always broken (e2e probe) We ran the prod-parity postcard e2e suite (S3 storage + Lambda) with WebKit against **`main` without this fix**, via a throwaway probe PR: twentyhq/twenty#22717. Result — [ci-privileged run 29015624468](https://github.com/twentyhq/ci-privileged/actions/runs/29015624468): ``` 1 failed [webkit] › card-front-component.spec.ts:61 › renders the postcard name and status badge in the record preview 2 passed (1.4m) ``` `[webkit]` times out waiting for `getByTestId('postcard-card')` to become visible (*element(s) not found*) while the Chromium run of the same spec passes. This confirms the front component **never rendered in Safari** on S3-backed storage prior to this PR — it is a genuine, browser-specific bug, not a flake. The fix in this PR is expected to turn that same `[webkit]` assertion green. Note: running the WebKit tests in CI requires the WebKit browser binary and its system dependencies in the e2e job (now installed via `npx playwright install --with-deps chromium webkit`). |
||
|
|
1c8b8970fd | Allow CLI dev mode on catalog-synced apps without mutating the shared registration (#22756) | ||
|
|
f8d3555fe7 |
i18n - translations (#22779)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22779?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
09496a0f98 |
i18n - translations (#22745)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22745?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: github-actions <github-actions@twenty.com> Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
f547da4ee9 |
Gate connected-account webhook subscriptions behind config (#22761)
Now gated behind IS_CONNECTED_ACCOUNT_WEBHOOK_SUBSCRIPTION_ENABLED (default false). Merge + deploy twentyhq/twenty-infra#780 first. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22761?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: Charles Bochet <charlesBochet@users.noreply.github.com> |
||
|
|
a360f9bdec |
migrate 10 modules off NestjsQueryTypeOrmModule wiring (#22763)
## Summary Continues the incremental removal of `@ptc-org/nestjs-query`. Migrates ten modules from `NestjsQueryTypeOrmModule.forFeature` to the standard `TypeOrmModule.forFeature`. These modules only used `nestjs-query` for repository registration — none register `NestjsQueryGraphQLModule` / auto-generated resolvers — so this is a pure module-wiring swap with no behavior or schema change. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22763?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Weiko <corentin@twenty.com> |
||
|
|
23cae2040a |
Improve application asset management (#22564)
App manifests could point the logo and screenshots at either external
URLs or public folder paths, and that was handled inconsistently across
install, sync and the marketplace.
This makes assets always bundled files:
- Manifests now use `logo` and `galleryImages` (a `string[]` of public
folder paths) instead of `logoUrl` and `screenshots`. The old fields
still work but are deprecated. Gallery order comes from the array index.
Normalization (deprecated-field migration, and warning about + ignoring
external URLs) happens in `defineApplication`, so the warnings surface
at define time.
- Logo is stored as a File record (`logoFileId`).
- The registration gallery is configured via a `settings` jsonb column
on `applicationRegistration` (`{ galleryImages: string[] }`) — populated
from the manifest, read by the marketplace detail (falling back to the
legacy `screenshots` column, then the manifest). No dedicated gallery
table.
- The marketplace detail DTO and front now use `galleryImages`.
Verified against a local Postgres: the fast instance commands run with
no pending-migration diff, the schema is correct, and the server boots.
Typecheck, lint, codegen and the application unit tests pass.
Not included yet: rehosting assets into storage for npm catalog and
tarball registrations, versioned cache busting on the serving route, and
a backfill for existing installs.
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22564?utm_source=github"
rel="nofollow noreferrer noopener" target="_blank">``<img alt="Review
in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg">``</a>
|
||
|
|
b327ab09a7 |
feat(workflow): add universalIdentifier + applicationId to core workflowVersion (#22747)
## workflowVersion core: syncable columns Adds `universalIdentifier` + `applicationId` (nullable) to `core.workflowVersion`, plus the FK to `core.application` and the `(workspaceId, universalIdentifier)` unique index, via a 2.20 add-columns fast command gated with `@WasIntroducedInUpgrade`. Nullable for now: the already-merged Phase A backfill (#22663) inserts version rows without these columns, so `applicationId` can't be NOT NULL yet. Flipping to NOT NULL + `extends SyncableEntity` comes once they're populated (backfill + dual-write follow-ups). Schema captured and verified via `migrate:generate` (zero drift). Independent of the core-workflow PR, but both add 2.20 upgrade commands, so this one (ts `…480`) must merge **after** the core-workflow PR (ts `…479`), or it gets re-timestamped on rebase. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22747?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
25d7758049 |
chore: sync AI model catalog from models.dev (#22770)
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev). This PR updates pricing, context windows, and model availability based on the latest data. New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically. Deprecated models are detected based on cost-efficiency within the same model family. **Please review before merging** — verify no critical models were incorrectly deprecated. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22770?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com> |
||
|
|
6210389221 |
feat(workflow): add core workflow entity (syncable) + create-table (#22746)
## Core `workflow` entity (syncable) Part of the app-workflows work. Adds a core `WorkflowEntity extends SyncableEntity` (`name`, `lastPublishedVersionId`, plus `universalIdentifier`/`applicationId`/workspace from the base class) and its 2.20 create-table fast command, gated with `@WasIntroducedInUpgrade`. Schema was captured and verified via `migrate:generate` (zero drift, FK/index hashes correct), then run against a live DB. Backfill (populate from workspace `workflow` records) and the dual-write listener land in follow-ups. Independent of the version-syncable-columns PR, but note: both add 2.20 upgrade commands, so this one (ts `…479`) must merge **before** the version PR (ts `…480`) to satisfy the append-only guard. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22746?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
9c405384f3 |
Only propose configured apps during onboarding install step (#22712)
## What - Onboarding "Install your first apps" now proposes only apps that are actually installable: it intersects the onboarding list with `findManyMarketplaceApps`, which the backend already filters to listed + configured apps (all required server variables set). - If none are available, the step auto-skips. If the marketplace query fails, it shows an intentional fallback (heading + Skip) instead of silently skipping or rendering an empty install card. - `findManyMarketplaceApps` now accepts `universalIdentifiers`, so onboarding fetches and configuration-checks only its own apps instead of the entire catalog. ## Why Previously the step rendered all hardcoded apps regardless of configuration, only borrowing logos from the marketplace, so a user could be offered an app the admin never configured. This centralizes onboarding availability on the marketplace's existing logic and keeps the query bounded as the marketplace grows. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22712?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
e9a030f762 |
fix(server): allow relabelling onto a field introduced in the same manifest sync (#22727)
## Context Closes twentyhq/core-team-issues#2655. `computeOrderedMigrationActions` runs `objectMetadata.update` **before** `fieldMetadata.create`. In a single manifest sync that both introduces a new field and relabels the object's `labelIdentifierFieldMetadataUniversalIdentifier` onto that field, the object update handler resolved the label identifier's universal identifier against the persisted `flatFieldMetadataMaps` only. Since the field's `fieldMetadata.create` runs later in the same migration, the field isn't in the maps yet and the sync failed with `ENTITY_NOT_FOUND`. The API metadata path is unaffected because create-field and update-object are separate requests (separate transactions), so the field is already persisted by the time the object update resolves. ## What this does `update-object-action-handler.service.ts` now resolves `labelIdentifierFieldMetadataId` and `imageIdentifierFieldMetadataId` against the deterministically preallocated field ids first, then falls back to the persisted flat maps for fields that already exist. The preallocated ids (`preallocatedIdByUniversalIdentifierByMetadataName`) are built from every create action before the migration loop starts (`buildPreallocatedIdByUniversalIdentifierFromActions`) and are the same ids `create-field-action-handler` persists the fields with. This is the same "preallocated-first, then flat maps" resolution that `resolveUniversalRelationIdentifiersToIds` already uses for modeled many-to-one relations, so no ordering change or new machinery is needed, and the single sync stays one atomic transaction. This does not touch the underlying action ordering or the hand-rolled label/image identifier handling flagged by the `#2172` TODO; generalizing those into the relation config remains the follow-up. ## Test Adds `relabel-onto-new-field-manifest-sync.integration-spec.ts` (used as the TDD reproduction, now green): - introducing a field and relabelling onto it in a single sync succeeds, and the object's `labelIdentifierFieldMetadataId` points at the new field; - the split path (introduce in one sync, relabel in the next) still succeeds and exposes the enriched `labelIdentifierFieldMetadataId` through the metadata API. Both cases pass against the fix. `oxlint`, `oxfmt`, and `typecheck` are clean. --- _Generated by [Claude Code](https://claude.ai/code/session_01KJewXMuWYUyrX3YJh2JBDE)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22727?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |
||
|
|
60fd322b49 |
Centralize system field side effects + search field metadata (#22594)
## Introduction Closes twentyhq/core-team-issues#2635 and twentyhq/core-team-issues#2642 and twentyhq/core-team-issues#2589 Object system fields (`searchVector` + its GIN index + `searchFieldMetadata`, the reserved system fields, default relations) were provisioned through several scattered, path-specific code paths. As a result the **app-manifest sync path** authored objects with an empty/`NULL` `searchVector` and **zero `searchFieldMetadata`**, so app-owned objects shipped a broken generated search column (see #22657). The generation logic also lived partly in imperative services rather than in the metadata side-effect engine, and relied on non-deterministic (`v4`) universal identifiers that `twenty apply` could not converge, destroying manually backfilled rows. This PR centralizes every object-creation system side effect into the **metadata side-effect engine**, extends the engine to keep search metadata consistent on field delete and object relabel, makes the standard app's search identifiers deterministic, and ships upgrade commands to reconcile existing workspaces. ## What changed ### Side effects moved into the metadata side-effect engine New dedicated, self-contained handlers — so every write path (API and app manifest) gets identical results, and side effects never trigger other side effects. **Object create / delete** (`handlers/object-metadata`) * **`objectSystemFieldsOnCreate`** — generates the 7 reserved system fields (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy`, `position`). * **`objectSearchVectorOnCreate`** — provisions the full-text search surface as one unit: the `searchVector` `TS_VECTOR` field, its backing GIN index, and the `searchFieldMetadata` row (for searchable objects whose label identifier is a searchable field) that keeps `searchVector` populated instead of `NULL`. * **`objectSystemSideEffectsOnDelete`** — tears the above down on object deletion. **Search-metadata consistency on relabel / field delete** (new — these are what close the manifest-path gaps) * **`objectSearchVectorOnUpdate`** (`handlers/object-metadata`) — when a searchable object is relabeled onto a new searchable field, provisions the `searchFieldMetadata` row that indexes it. Relabeling is **additive**: existing rows (e.g. the provisioned `name` row) are preserved, so the previous label identifier stays searchable. Mirrors the API update path so a manifest re-sync that changes the label identifier reaches search parity. No-ops for junction objects (`id` label identifier) and non-searchable field types. * **`fieldSearchFieldMetadataOnDelete`** (`handlers/field-metadata`) — when a field is deleted, cascade-deletes every `searchFieldMetadata` row that indexes it. `searchFieldMetadata` is excluded from manifest deletion inference, so this explicit cascade is what covers **both the API and manifest paths** (the object-scoped DB cascade only fires on object deletion). Uses the `searchFieldMetadataUniversalIdentifiers` aggregator on the flat field for an O(k) lookup instead of scanning all rows. The **default `name` field and default relations are now caller-provided default fields** (SDK autocomplete on the manifest path, input transpiler on the API path) rather than system side effects — removing duplicate name generation, the imperative `build-default-*-for-custom-object` utilities, and the ad-hoc system-field integrity validator. ### Deterministic identifiers for the standard app The twenty-standard search GIN index and `searchFieldMetadata` now derive deterministic universal identifiers (`getIndexUniversalIdentifier` / `getSearchFieldUniversalIdentifier`) instead of `v4`, so `twenty apply` converges instead of recreating. ### Upgrade commands (`2-20`) to reconcile existing workspaces **Instance commands** (run once per instance; ordered fast → slow → workspace): 1. **`AddIsSystemSideEffectToSearchFieldMetadata`** (fast) — adds the `isSystemSideEffect` column to `core.searchFieldMetadata`. Defaults to `true`, which also correctly backfills every existing row since `searchFieldMetadata` is always system-derived (never user-authored). 2. **`BackfillNameFieldIsSystemSideEffect`** (slow) — re-flags existing `name` fields from `isSystemSideEffect: true` → `false`, since the default `name` field is now a caller-provided default like any other user-owned field (it was provisioned as `true` in 2.15 → 2.19). This is a pure data backfill, so the bulk `UPDATE` lives in `runDataMigration()` rather than `up()` — keeping it out of the fast schema transaction avoids holding an `ACCESS EXCLUSIVE` lock that could stall reads during the deploy. Slow instance commands still run before every workspace command of the version, so the fresh value is in place before the search-reconcile workspace commands recompute the `fieldMetadata` flat-entity cache. Scoping by name alone is safe (no engine-owned field is named `name`); `down()` is best-effort (pre-2.15 `false` rows are indistinguishable from flipped ones). **Workspace commands** (idempotent, dry-run supported): 1. **`reconcile-search-vector-gin-index-universal-identifier`** — re-owns every searchVector GIN index UID to its deterministic value (all applications), then backfills the missing GIN index for installed-app objects. 2. **`reconcile-search-field-metadata`** — re-owns every `searchFieldMetadata` UID (all applications), then backfills the missing rows for installed-app searchable objects. 3. **`rebuild-installed-app-search-vectors`** — rebuilds the `searchVector` column of every installed-app `TS_VECTOR` field, once the index and rows exist. Design notes: * **Re-own is global** (twenty-standard, workspace-custom, installed) — a UID convergence keyed on each row's own application. * **Backfill is installed-app only** — standard/custom objects already have these rows via the manifest funnel. * Re-own runs **before** backfill and is transaction-guarded; a failure aborts that workspace to avoid a unique-identifier collision. ## Tests * Integration: app manifest sync now asserts system fields + searchable objects (searchVector, GIN index, searchFieldMetadata) are created; a new relabel suite drives three manifest syncs and asserts records stay searchable through the old + new label identifiers and lose searchability when a field is removed; removed the obsolete system-fields-integrity suite/snapshots. * Unit: per-handler side-effect specs (including the new `objectSearchVectorOnUpdate` and `fieldSearchFieldMetadataOnDelete` handlers), and per-util specs for the re-own / backfill operation builders and the GIN-index classifier. ## Upgrade / migration notes * Existing workspaces converge on the next upgrade run via the `2-20` instance + workspace commands (idempotent, dry-run supported). * Backfill and rebuild go through the workspace-migration runner (automatic cache invalidation); the re-own step invalidates only the affected flat-entity maps directly. * The cross-version upgrade CI now flushes the cache before running the upgrade, so the new version recomputes every flat-entity map from the database instead of reading blobs the old version serialized in an older shape. ## Follow-up * `object-metadata.service.ts` still carries a `TODO: remove once default view fields move to the metadata side effect engine` — default view fields are the next candidate to move into the engine. * A single manifest sync cannot yet both create a field and relabel the object onto it, because `objectMetadata.update` is ordered before `fieldMetadata.create` in the migration runner. Tracked in twentyhq/core-team-issues#2655; to be fixed in a follow-up. |
||
|
|
f06ba08b16 |
Fix onboarding locale reverting to English after signup (#22675)
During onboarding the UI reverted to English after email verification. The chosen locale was never stored on the user, so the workspace member seeded from it inherited `en`, and after workspace activation `loadCurrentUser` reactivated `en` and flipped the whole UI to English regardless of the browser language. Two changes: **Backend (the actual fix):** the `signUp` resolver received `signUpInput.locale` but only used it for the verification email, creating the user without it (so it defaulted to `en`). It now passes the locale into `signUpWithoutWorkspace`, and the SSO create-a-workspace path forwards `locale` as well. The user, and the workspace member created from it at activation, now keep the chosen locale, so the post-activation reactivation no longer forces English. **Frontend:** carry the active locale across the workspace subdomain redirect (in `useBuildSearchParamsFromUrlSyncedStates`) so the freshly loaded subdomain renders `/verify` in the right language before the current user loads, instead of briefly falling back to the browser default. Minor, only affects the case where the chosen locale differs from the browser language. |
||
|
|
7475b5f16f |
perf(upsert) - tighten createMany upsert candidate lookup to avoid broad OR scans (#22721)
## Summary
Fixes a performance-correctness bug in the createMany upsert path where
the
existing-record lookup built an overly broad WHERE clause, causing full
table
scans and multi-second latency on bulk upserts.
Reported via Sentry: a 100-record create*(upsert: true) request on
_sdWorkspace
completed with HTTP 200 but took ~14s. The candidate-lookup SELECT alone
took
~6.7s because it fetched a huge superset of rows before matching in
memory.
## Root cause
buildWhereConditions created one IN(...) per conflicting column across
the whole
batch, and findExistingRecords OR-ed them together:
WHERE "cbCustomerId" IN ($1..$100) OR "environment" IN ($101..$200)
For a composite unique index (cbCustomerId, environment), this is
semantically
wrong: it OR's the columns instead of matching them as a tuple. Because
environment is low-cardinality (prod/staging/dev/test), the second IN
alone
matched almost the entire table, forcing a Seq Scan and shipping the
whole
result set to the app for in-memory filtering.
## Fix
buildWhereConditions now generates targeted lookup conditions:
- Single-column unique keys collapse into one `column IN
(distinctValues)`
condition (instead of N OR-ed equalities).
- Composite unique keys produce one `(colA = ? AND colB = ?)` condition
per
input record — the columns are ANDed as a tuple, and separate unique
indexes
are still OR-ed together.
- Conditions are deduplicated (robust JSON-based key, no separator
collisions)
to avoid redundant OR branches.
- Values are now typed as string | number | boolean instead of being
implicitly
coerced to string.
## Benchmark
Reproduced the incident with a table mirroring _sdWorkspace:
high-cardinality
cbCustomerId + low-cardinality environment (4 values), composite unique
index on
(cbCustomerId, environment), 100-record upsert batch. EXPLAIN (ANALYZE,
BUFFERS)
on a warm 500k-row / 392 MB table:
Metric | OLD (colA IN OR colB IN) | NEW (targeted) | Improvement
----------------------|--------------------------|----------------|------------
Scan type | Seq Scan (full table) | Index Scan | index vs full scan
Rows returned to app | 500,000 | 100 | ~5,000x fewer
Buffers touched | ~45,455 (~355 MB) | 400 (~3.2 MB) | ~110x fewer
Execution time | 224 ms | 11.9 ms | ~19x faster
Data shipped to app | ~322 MB | ~64 KB | ~5,000x less
The cache-independent facts are the proof: the old query returned the
entire
table for a 100-record batch (the "overly broad record set" from the
report),
while the new query returns exactly the matching rows via the composite
index.
## Test plan
- [x] Unit tests for buildWhereConditions (single-column IN batching,
nested
paths, composite AND tuples, dedup incl. separator-collision safety,
numeric values, mixed single-column + composite OR) — 22 passing across
build-where-conditions, get-matching-record-id, get-value-from-path.
- [x] Upsert integration suites pass (upsert +
composite-unique-index-upsert,
10 tests).
- [x] EXPLAIN ANALYZE benchmark confirms Index Scan and bounded row
counts.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22721?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
6897fff632 |
Rebuild email composer recipient fields as a structured chip input with person resolution and autocomplete (#22668)
# Why
The To/Cc/Bcc fields reused `FormMultiTextFieldInput`, the workflow
Tiptap tag editor, with recipients stored as a comma-separated string.
That caused every reported issue: duplicates were allowed, the field was
locked to one 32px line with a hidden horizontal scrollbar, chips did
nothing on click, `First Last <email>` could not even be typed (space
committed a tag) and was rejected by the backend when pasted, chips
could not be edited, invalid addresses only failed server-side after
pressing Send, and there was no autocomplete at all.
## The model
A recipient is `{ address, displayName? }`. Person and workspace member
are never stored in composer state; they are resolved live from the
address at render time, mirroring how `MatchParticipantService` links
`messageParticipant.handle` to `personId`/`workspaceMemberId` on the
receive side. Entities appear at the edges (autocomplete in, chip
display out); state, dedupe, validation, and send operate on addresses
only. The send path is unchanged: `SendEmailInput.to/cc/bcc` stay
comma-separated bare addresses.
# What changed
New module `activities/emails/recipients/` (the workflow editor is
untouched; its other consumers are unaffected):
- **`EmailRecipientsFieldInput`**: wrapping chip rows (up to ~3 lines,
then scroll), commit on Enter/Tab/comma/semicolon/blur, space commits
only when the buffer is already a valid email, paste parses RFC 5322
lists (names, quoted commas, semicolons, newlines), case-insensitive
dedupe with a flash on the existing chip, invalid addresses become red
chips that disable Send, double-click or keyboard editing in place with
Escape revert, Backspace select-then-delete, arrow-key chip navigation,
Ctrl/Cmd+Enter commits a pending buffer or sends when the buffer is
empty.
- **Person resolution**: chips resolve against People
(`emails.primaryEmail`, case-insensitive) and workspace members,
rendering avatar + name when known and degrading to a plain address chip
otherwise.
- **Chip menu**: person/member header, Copy email, Edit, Remove, and Add
as person for unknown addresses (creates the Person; the chip upgrades
in place).
- **Autocomplete**: blends context people (company you are composing
from, or the company behind a person/opportunity), ranked people search,
workspace members with a Team member badge, and a literal "Use this
email" row ranked first when the typed buffer is a valid address.
Suggestions exclude addresses already present in any field. Enter picks
the highlighted or top row.
- **Prefill**: replies and drafts preserve participant display names
(`getEmailDraftPrefillFromMessage`, `useReplyContext`).
- `useEmailComposerState` holds `EmailRecipient[]` per field and blocks
send on invalid recipients; the recipient-limit warning is surfaced
again in the composer.
- The Send Email engine command passes the record context so context
suggestions work from the record page action.
- `EmailsFilter` was missing from the shared `LeafFilter` union, so
nothing could filter on `emails.primaryEmail`; added (additive).
- New dependency `addressparser@1.0.1` in twenty-front, the same package
and version the server already uses to parse inbound mail headers, so
both sides parse identically. Tiny, dependency-free, browser-safe.
# Decisions and tradeoffs
- Person resolution matches on `emails.primaryEmail` only,
case-insensitively via per-address `ilike` filters (no `%` wildcards,
`%_\` escaped). `additionalEmails` is a JSONB array and not cleanly
filterable through the GraphQL filter API today; the server-side matcher
checks additional emails too, so a chip may show as a plain address even
though the send still links to the person via participant matching.
- Chip flash-on-duplicate replays its CSS animation by remounting the
chip subtree (nonce in the React key), chosen over animation-restart
hacks; the remount is invisible.
- Keyboard chip selection keeps DOM focus on the input and tracks a
virtual `selectedChipIndex` (`aria-activedescendant`) instead of roving
focus across chips: one focus point, no focus juggling, standard
combobox listbox pattern.
- `flushSync` (precedent: `Dropdown.tsx`) focuses and places the caret
after entering chip-edit mode; the alternative was a useEffect on
editing state.
- Suggestion rows `preventDefault` on mousedown so picking a suggestion
never blurs the input (blur would first commit the half-typed buffer as
a junk chip).
- Cmd/Ctrl+Enter inside a recipient field: with a non-empty buffer it
commits the buffer only; with an empty buffer it sends via an `onSubmit`
prop wired to `handleSend`. Not commit+send in one stroke: `handleSend`
holds a same-render closure over composer state, so sending in the same
event would read the pre-commit recipients. E2E also showed the side
panel's own ctrl+Enter hotkey never fires while any form field is
focused (focus-stack scoping, applies to the old composer too), which is
why the field triggers the submit itself.
- Enter with suggestions open picks the highlighted (or top) suggestion,
Gmail-style. When the typed buffer is itself a valid email, the literal
row is ranked first so Enter keeps meaning "add what I typed".
- Suggestions are disabled while editing a chip (the edit buffer holds
`Name <email>` text, a poor search query).
- Dedupe blocks within a field; across fields typed duplicates are
allowed (sometimes intentional), but suggestions exclude addresses
already present in any of To/Cc/Bcc.
- Chip menu actions never navigate: navigating the side panel (or main
view) unmounts the composer and silently destroys the draft, since
composer state is component-local with no draft persistence. "Add as
person" creates the record and shows a snackbar while the chip upgrades
in place; the person header row is informational. "Open person"
navigation should come back once drafts survive navigation.
- The reply composer gets no context record: its widget target record is
the message thread, not a person/company, and replies already prefill
participants.
- If two people share a primary email, the last fetched match wins for
chip display (no ambiguity UI).
- "Add as person" splits the display name on the first space for
firstName/lastName, the same heuristic the contact-creation manager uses
server-side.
# Deferred
- Display names on the wire (`Name <email>` in outbound headers): needs
`SendEmailInput` / `EmailComposerService.validateEmails` changes
server-side.
- Drag chips between To/Cc/Bcc; collapse-on-blur to one line with a "+N
others" summary.
- Frequency/recency ranking of suggestions from `messageParticipant`
aggregates.
- "Open person" from the chip menu, pending draft persistence across
navigation.
# Verification
Unit tests cover the parser, formatter round-trip, merge/dedupe, and the
field state machine (commit, dedupe flash, edit, cancel, keyboard
selection). Typecheck, lint, and the email module suites pass, plus the
shared and side-panel suites.
Every flow was also driven end to end with Playwright against seeded
data: prefill resolution, context and typed suggestions, keyboard
navigation and picks, dedupe flash, RFC 5322 paste, invalid chips gating
Send, wrapping, in-place editing, chip menus, clipboard copy, Add as
person with live chip upgrade, Cc/Bcc exclusions, and the Ctrl+Enter
send path (the mutation reached the server; it failed only on the seeded
account's missing refresh token, expected outside a real provider
connection).
Screenshots of each verified behavior:
https://claude.ai/code/artifact/1743f05d-422e-43d0-bbea-a34a0470c180
---
_Generated by [Claude
Code](https://claude.ai/code/session_0199wDARiw48GqVTpgWzbXWw)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22668?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
|
||
|
|
a0cf4cc9e1 |
i18n - translations (#22714)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22714?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
a51c37dae5 |
feat(ai) - add light AI chat turn instrumentation (metrics + Sentry correlation) (#22692)
## Summary Adds minimal server-side observability for AI chat turns: lifecycle counters to measure success/failure rates, Sentry scope tags to correlate API and worker traces, and per-LLM-call telemetry metadata for turn/stream correlation. - Add turn lifecycle metrics: `ai-chat/turn-started`, `ai-chat/turn-completed`, `ai-chat/turn-failed` (with `failure_phase` and `error_code` attributes) - Emit counters at key points: job start, clean completion, execution failures, enqueue failures, interrupted streams, and empty completions - Tag Sentry scope with `streamId`, `turnId`, `threadId`, and `workspaceId` at API entry points (`sendChatMessage`, `retryChatMessage`, `answerAgentChatQuestion`) and worker entry (`StreamAgentChatJob`) - Enrich LLM `experimental_telemetry` metadata with stream/turn/thread/workspace IDs - Return `turnId` from streaming service methods so resolvers can tag the scope - Remove granular tool-learned/skill-loaded metrics in favor of the turn-level counters ## Test plan - [ ] Send a chat message and verify `ai-chat/turn-started` and `ai-chat/turn-completed` increment - [ ] Trigger a stream failure (e.g. interrupted/dead stream) and verify `ai-chat/turn-failed` with correct `failure_phase` - [ ] Retry a failed turn and confirm a new `turn-started` is emitted for the retry attempt - [ ] Answer an `ask_questions` prompt and confirm Sentry tags include `streamId` and `turnId` - [ ] Check Sentry spans for LLM calls include `streamId`, `turnId`, `threadId`, `workspaceId` in telemetry metadata - [ ] Run unit tests: - `npx jest packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/jobs/__tests__/stream-agent-chat.job.spec.ts` - `npx jest packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/agent-chat-streaming.service.claim.spec.ts` - `npx jest packages/twenty-server/src/engine/metadata-modules/ai/ai-chat/services/__tests__/agent-chat-streaming.service.retry.spec.ts` <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22692?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> |