e4e1d24731f9fddd385a64c09d7b9bdc8295ab3d
1094 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e4e1d24731 |
Prevent overlapping workspace cleanup executions (#23522)
## Context The suspended-workspace cleanup is a long-running scheduled job. Under database or cache pressure, BullMQ can consider an execution stalled and start a replacement on another worker while the original execution is still running. Both executions can then enumerate the same suspended workspaces and run destructive cleanup concurrently. This amplifies the initial slowdown: 1. Multiple cleanup transactions target the same workspace data. 2. Transactions wait on each other's locks. 3. Database connections remain occupied while waiting. 4. Other workers and API requests have fewer connections available. There is a second source of unnecessary lock duration in workspace deletion. The deletion transaction currently starts before field metadata is read from the workspace cache. If that lookup is slow, the transaction stays open during an unrelated cache wait. ## What changed ### Prevent overlapping scheduled cleanups - Acquire a non-blocking PostgreSQL advisory lock before listing suspended workspaces. - Skip the execution when another worker already holds the lock. - Keep the lock on one dedicated PostgreSQL session for the full callback. - Release the lock in all normal and error paths. - Discard the database connection if lock acquisition or release has an ambiguous failure, preventing a session that may still own the lock from returning to the pool. - Encapsulate this lifecycle in `PostgresAdvisoryLockService`, exported by `TypeORMModule`, so other coarse-grained jobs can reuse it without handling acquisition and release themselves. ### Shorten the workspace deletion transaction - Read field metadata and build deletion chunks before starting the transaction. - Pass the precomputed chunks into the transactional deletion loop. - Keep the existing deletion order and SQL behavior unchanged. ## Why a PostgreSQL advisory lock The lock needs to coordinate workers running in different pods. A PostgreSQL session advisory lock provides the required behavior: - It is shared across all workers using the same database. - Acquisition is non-blocking, a duplicate execution can exit immediately. - It has no TTL or renewal heartbeat that could expire during the same event-loop stall that caused BullMQ to recover the job. - PostgreSQL automatically releases it when the owning session or process disappears. This is deliberately scoped to `CleanSuspendedWorkspacesJob`. It prevents overlapping scheduled executions, but it is not an exactly-once mechanism or a global mutex around every workspace-deletion entry point. ## Expected impact - Prevent one slow cleanup execution from becoming several concurrent cleanup executions. - Reduce database lock contention and connection-pool pressure during cleanup. - Avoid holding deletion transaction locks while waiting for workspace-cache data. - Reduce cleanup-related API latency bursts without changing normal cleanup semantics. The advisory lock holds one core database connection for the duration of the scheduled cleanup. This is intentional and bounded to the single lock owner. ## Validation - Focused advisory-lock tests cover successful execution, contention, callback failure, and unsafe connection disposal when unlock fails. - Cleanup-job tests cover both the lock-owner and skipped-execution paths. - Workspace-service coverage verifies that field metadata is loaded before the deletion transaction starts. - `yarn nx typecheck twenty-server` - Oxlint, Prettier, and Oxfmt checks on the changed files |
||
|
|
742f76e318 |
[BREAKING-CHANGE] Add NOT_RECORDED call recording status (#23478)
Adds NOT_RECORDED to the CallRecording status select, for meetings where nothing was captured (bot never admitted, meeting not started, nobody joined). First part of twentyhq/core-team-issues#2706, split out so existing workspaces are upgraded before the call-recorder app starts writing the new status. - NOT_RECORDED enum value + standard select option - 2-26 workspace upgrade command adding the option to existing workspaces (idempotent, same option id as the standard definition) App-side classification from Recall sub codes comes in a follow-up PR. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23478?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. --> |
||
|
|
e79424ddb0 |
Stop building the Campaigns navigation menu item (#23493)
Campaigns show up in the sidebar of every workspace, including brand new ones, while the feature is still behind `IS_EMAIL_GROUP_ENABLED`. ## Why it leaked `navigationMenuItem` has no `conditionalAvailabilityExpression` column. Only two entities do: - `page-layout-widget.entity.ts` - `command-menu-item.entity.ts` So there was no flag to attach. #23188 gated every surface that supports gating — the campaign command menu items carry `featureFlags.IS_EMAIL_GROUP_ENABLED` (`standard-command-menu-item.constant.ts:720` and `:735`) and the page layout widgets are gated the same way — but `allMessageCampaigns` was added to `FLAT_NAVIGATION_MENU_ITEM_NAMES`, which builds unconditionally. `messageCampaign` is `isSystem: true`, so the navigation item was the only thing exposing the feature. ## Change - Drop `allMessageCampaigns` from `FLAT_NAVIGATION_MENU_ITEM_NAMES`. Its definition stays in `STANDARD_NAVIGATION_MENU_ITEMS` so the identifier remains reserved and re-enabling is a one-line change. - Add workspace command `1785324390000` to delete the rows from workspaces already provisioned with the item. It collects every matching row rather than looking the identifier up once, because each user workspace gets its own. ## Trade-off Campaigns become unreachable from the sidebar even with the flag on, which restores the pre-#23188 state. The durable fix is adding `conditionalAvailabilityExpression` to `navigationMenuItem` so the item can be gated like the command menu items — a schema change, deliberately not done here. ## Verification Reproduced on a fresh `database:reset` against `main`: two `20202020-b00b-4b0b-8b0b-c0aba11c000b` rows (type `OBJECT`, position 7) for the single seeded workspace, structurally identical to the other nav items. Typecheck, `oxfmt --check src/` and `oxlint --type-aware src/` clean on this branch. The post-fix database check did not complete — local Postgres died mid-reset — so the removal is verified by construction and by the build-list change, not yet by a second reset. --- _Generated by [Claude Code](https://claude.ai/code/session_01NZKeMSUBG3VQPuoy8i5Awe)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23493?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. --> |
||
|
|
0c545bcdeb |
[BREAKING-CHANGE] Centralize system View viewField side effect (#23081)
# Introduction Closes https://github.com/twentyhq/core-team-issues/issues/2669 Part of the `isSystemSideEffect` engine-ownership effort. Until now, a custom object's default **INDEX** table view (`All {objectLabelPlural}`) and its view fields were built imperatively in `ObjectMetadataService` with random `v4()` identifiers, while `twenty-standard` authored its own copies with hardcoded literals. The two never converged, an object rename could drift the view, and nothing marked these rows as engine-owned. This PR makes the metadata side-effect engine the **single owner** of the INDEX view and its view fields, on name-free deterministic identifiers, for custom and standard objects alike. ## Core design - **Name-free deterministic identity.** The INDEX view identifier derives from `object identifier + ViewKey.INDEX` (`getSystemViewUniversalIdentifier`); each view-field identifier derives from `view identifier + field identifier` (`getViewFieldUniversalIdentifier`). An object rename (with a pinned object identifier) keeps the same view, losslessly. - **`isSystemSideEffect: true` is provenance.** Every INDEX view / view field the engine emits is flagged system-owned, so manifest deletion inference never drops it. The flag follows the view: a view field inherits its parent view's flag. - **The engine is the sole owner of the INDEX view.** It always emits it; a caller providing one with the same derived identifier is a genuine conflict surfaced by the engine's reserved-identifier collision, not silently deferred. ## Changes ### Shared (`twenty-shared`) - `getIndexViewUniversalIdentifier` → `getSystemViewUniversalIdentifier`, now taking a `viewKey` (generalizes to any singleton engine-owned view). - Standard field identifiers extracted into a new `STANDARD_OBJECT_FIELDS` constant, so both an object's `fields` and its INDEX view read the same field identifiers. - `buildStandardObjectIndexView` derives the standard INDEX view + view-field identifiers from `STANDARD_OBJECT_FIELDS`, replacing the hardcoded literals in `standard-object.constant.ts`. ### Metadata side-effect engine (custom objects) - **`objectSystemFieldsAndIndexViewOnCreate`** (replaces `objectSystemFieldsOnCreate`): on object creation, provisions the 7 reserved system fields **and** the INDEX view with one view field per displayable system field, all `isSystemSideEffect: true`. - **`fieldIndexViewFieldOnCreate`** (new): on field creation, provisions the field's INDEX view field. Object created in the same batch → visible, positioned before the system view fields; pre-existing object → hidden, appended (preserving the historical `createOneField` behavior). Both branches resolve the INDEX view by its derived identifier (single map access, never a scan). - **`fieldSystemViewFieldsOnDelete`** (new): on field deletion, cascade-deletes every engine-owned view field displaying it. - **`objectSystemSideEffectsOnDelete`** (extended): now also cascade-deletes the object's engine-owned views and their view fields (in addition to system fields, indexes, searchFieldMetadata). Every lookup walks a foreign-key aggregator down from the deleted object, so the work is proportional to what the object owns, never to workspace size. - Object-create and field-create positions are derived from the same caller-input field list, so the INDEX view layout is contiguous with no handler-ordering dependency. - `view` / `viewField` added to the side-effect companion metadata names for `fieldMetadata` and `objectMetadata`. ### Reserved-identifier invariant A caller can never define an entity whose identifier collides with one a system side effect produces: caller inputs are forced `isSystemSideEffect: false` at every entry point (API and app-manifest transpilers), and the engine raises `RESERVED_SYSTEM_UNIVERSAL_IDENTIFIER`, aborting the operation, when a system emission lands on a caller-claimed identifier. Covered by a new engine-level test. ### Caller-side provisioning removed The imperative INDEX view + view-field provisioning is removed from `ObjectMetadataService.createOneObject`. The record-page `FIELDS_WIDGET` view is intentionally left caller-side and deferred to the follow-up (see below). ### `twenty-standard` convergence Standard INDEX views and their view fields converge on the same derived-identifier + `isSystemSideEffect: true` scheme as the engine. `twenty-standard` syncs through the from/to migration path (which never runs the side-effect engine), so it authors this INDEX surface itself, matching what the engine produces for custom objects. ## Rollout Two `2.26.0` workspace commands, running after the `2.25` messageCampaign commands: - `upgrade:2-26:reconcile-index-view-universal-identifier` re-owns the INDEX views of the **twenty-standard and workspace-custom applications** and all their view fields to the derived identifiers with `isSystemSideEffect: true`, in a single per-workspace transaction. Each view field identifier is keyed on the application of the **displayed field** (an app or user column on a standard INDEX view converges too). Soft-deleted views and view fields are skipped: one can coexist with an active successor on the same derivation inputs and both would derive the same identifier. Children reference the view by primary key, so the re-own is lossless. - `upgrade:2-26:demote-and-backfill-application-index-view` handles **manifest-installed applications**, which never had their INDEX view auto-provisioned: every caller-authored INDEX view of another application is demoted to `key: null` (a plain additional view under its manifest identifier), then every application object gets the engine-owned INDEX view and its full view-field layout backfilled through the migration pipeline's legacy path (no side-effect expansion), views committed before view fields across applications since a view field belongs to the application owning its field. Idempotent and retry-safe: engine-owned INDEX views are neither demoted nor re-backfilled, and view creation and view-field creation are gated independently, so a retry after a partial failure still backfills the missing view fields of an already-committed view. Both support `--dry-run` and invalidate the full flat-maps closure (parents aggregate the re-owned identifiers, children resolve them as universal foreign keys, and page-layout widget universal configurations resolve view PKs at cache-build time). The `2.25` `upgrade:2-25:add-message-campaign-name-field` command is adapted to resolve the campaign INDEX view by its INDEX key on the object instead of by universal identifier: it now runs before the reconcile, on workspaces still holding legacy identifiers. ## ⚠️ Breaking change This PR **mutates 187 previously hardcoded universal identifiers** — the standard objects' INDEX views and their view fields (the literals removed from `standard-object.constant.ts`), now derived. - **Handled by the `2.26` commands above** for all existing workspaces. - **The INDEX key is now engine-reserved.** The flat view validator rejects caller-created INDEX views (API and manifest inputs are forced `isSystemSideEffect: false`) and enforces a single non-deleted INDEX view per object; `view.key` is no longer a comparable/updatable property, so no writer can promote or demote a view after creation. `ViewManifest.key` is deprecated and ignored (manifest views are always additional views, so old apps keep syncing and demoted views are not promoted back); the REST/GraphQL create path now rejects `key: INDEX`. In-repo example apps (`hello-world`, `document-generator`) no longer declare it. - **12 declared-but-never-seeded standard INDEX view field identifiers deleted** (the former `preservedViewFields` on `timelineActivity`, `workflowRun` and `workspaceMember`): after the reconcile, no workspace row references them. - **`computeFlatViewFieldsToCreate` now derives view field identifiers** instead of drawing `v4()` ones, which also changes what the committed `1-23` record-page backfill produces going forward (deliberate, documented in-code). - **Record-page views and view fields are not affected** (identifiers unchanged). - **In-repo apps: `twenty-last-contact` updated.** It was the only app declaring explicit INDEX view fields (10 columns across `allPeople` / `allCompanies` / `allOpportunities`) through manifest `viewFields`. Those target identifiers are now engine-owned and derived, so the manifest inputs no longer resolve and install failed with `View not found`. The app now declares only its fields; the engine's `fieldIndexViewFieldOnCreate` provisions the matching INDEX view field automatically. No other app under `packages/twenty-apps` references any of the 187 mutated identifiers, and apps that target standard views point at record-page views (e.g. `real-estate` → `opportunityRecordPageFields`) or their own objects (`twenty-partners`), all unchanged. ### Loss of granularity for app maintainers The engine now owns the INDEX view field of every field a caller adds to an object, so app maintainers lose direct control over those columns. Previously an app could target the engine-owned INDEX view with an explicit manifest `viewField` and set its `position` and `isVisible`. Now `fieldIndexViewFieldOnCreate` appends a **hidden** view field in caller-input order on field creation, so: - Columns an app previously showed at a **dedicated position** and **visible** (e.g. `twenty-last-contact`'s last-contact columns) become **hidden** and **appended in input order** after install. - There is currently **no manifest way to override** the engine-provisioned INDEX view field's position, visibility, or size. This is a deliberate regression accepted for the sake of single-ownership, and app maintainers should expect their INDEX columns to move/hide after upgrading. A follow-up override API will let maintainers reclaim per-field control over the engine-provisioned INDEX view field. ## Testing - Unit specs for each handler: object create (system fields + INDEX view/view fields, override, position offset), field create (same-batch vs existing-object, non-displayable noop, no-INDEX-view noop), field delete, object delete (fields/indexes/searchFieldMetadata/views/view fields cascade, reverse-relation view field on another object). - Engine-level test for the reserved-identifier collision. - `twenty-standard` guard test that its INDEX views/view fields stay on the derived scheme and stay system-owned. - Integration test: full engine provisioning of the INDEX view/view fields on object creation, same view id preserved across an object rename, and cascade delete on object deletion. ## Follow-up The full record-page stack (record-page view, its view fields, view field groups, page layout / tab / widget) is still built imperatively and moves into the engine in https://github.com/twentyhq/core-team-issues/issues/2721. |
||
|
|
5ebcce0a51 |
feat(ai-tool): resolve and default icons in AI metadata tools (#23480)
## Context Objects and fields created through the AI chat / MCP metadata tools almost never get an icon, so they all render with the meaningless `123` fallback icon. Two causes: - The `icon` tool input was described only as `"Icon name"`, so the model had no idea what the value space is and mostly skipped an optional field it couldn't fill confidently. - Any invalid name is silently swapped for `Icon123` by `useIcons.getIcon` on the frontend, so near-misses were indistinguishable from unset. ## What this PR does **Guide the model** (icon names are Tabler names, which LLMs know well): - `icon` / `targetFieldIcon` schema descriptions now state the convention with examples (`IconBuildingSkyscraper`, `IconPaw`, …) and ask for one to always be set - The `metadata-building` skill gains an "Icons" section; the MCP server instructions gain a one-line reminder **Normalize server-side** (new `resolveIconName` util, used by all create/update/batch metadata tool executes incl. `relationCreationPayload.targetFieldIcon`): - Fixes shape mistakes: raw tabler slugs (`"building-skyscraper"`), separators, missing or lowercased `Icon` prefix - Deliberately does NOT validate existence against the full ~4.2k icon registry — an unknown name is harmless since the frontend falls back to its default icon, exactly as for icons stored via the API today - Unusable input (empty/garbage) resolves to nothing: creates fall back to a default, updates keep the existing icon **Fall back sensibly for fields**: - New `FIELD_TYPE_DEFAULT_ICONS` in `twenty-shared/constants` maps every `FieldMetadataType` to a sensible icon (mirroring the settings UI type illustrations), applied when the model provides no usable icon — an AI-created field always gets a meaningful icon - Lives in twenty-shared so the frontend can reuse it later (e.g. as `getIcon`'s custom default for fields) The REST/GraphQL metadata APIs are untouched — this only affects the AI tool layer. ## Test plan - `resolve-icon-name.util.spec.ts` — canonical pass-through, slug/prefix/separator fixes, unknown-name pass-through (FE fallback contract), unusable inputs, icon-key dropping on updates - `FieldTypeDefaultIcons.test.ts` — every field type mapped, all values canonically shaped (values hand-checked against the twenty-ui `ALL_ICONS` registry) - `nx typecheck` twenty-server + twenty-shared, oxlint/oxfmt clean on changed files <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23480?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. --> |
||
|
|
25d20731ac |
Include root cause errors in workspace migration runner exception message (#23416)
closes https://github.com/twentyhq/core-team-issues/issues/2733 ```diff - "message": "Migration action 'create' for 'index' (universalIdentifier: 67c6811e-...) failed" + "message": "Migration action 'create' for 'index' (universalIdentifier: 67c6811e-...) failed: [workspaceSchema] could not create unique index "IDX_UNIQUE_85951922a2..." (pg code: 23505, detail: Key ("externalId")=(DUPLICATED-VALUE) is duplicated.)" ``` ## Problem When a workspace migration action fails, the `EXECUTION_FAILED` exception message only states which action failed: ``` Migration action 'create' for 'index' (universalIdentifier: 9e20a0f6-7a18-51c5-a422-5dc4dbd1d972) failed ``` The underlying errors (`metadata`, `workspaceSchema`, `actionTranspilation`) are attached to the exception instance but dropped by most surfaces: the SDK CLI only prints `errors[0].message`, server logs only log `error.message`, and the REST/GraphQL paths lose the postgres driver details. Debugging a failed app install (like the stale index universalIdentifier case in the issue) requires guessing. ## Change - New `formatWorkspaceMigrationRunnerExecutionErrors` util that builds a compact one-line summary of the underlying execution errors, including the postgres error code and `detail` for `QueryFailedError`, capped at 1500 chars. - The `EXECUTION_FAILED` exception message now appends that summary: ``` Migration action 'create' for 'index' (universalIdentifier: 9e20a0f6-...) failed: [workspaceSchema] relation "IDX_..." already exists (pg code: 42P07) ``` Since every surface (CLI, server logs, Sentry, REST, GraphQL) shows `error.message`, the root cause now propagates everywhere without touching those surfaces. - Since actions run inside a single transaction, when one branch fails with `25P02 current transaction is aborted` (collateral of the other branch's statement aborting the transaction), the summary keeps only the real root cause. A lone 25P02 error is still shown. ## Notes - The SDK's `getSyncErrorRecoveryHint` matching (`/migration action .* failed/`) still works with the suffixed format. - Commit-time failures from `DEFERRABLE INITIALLY DEFERRED` FK constraints still bypass this path (wrapped as `INTERNAL_SERVER_ERROR` with no action attribution) and are left as a follow-up. ## Tests - New spec for the formatter util (labels, pg code/detail, 25P02 demotion, truncation). - Extended `workspace-migration-runner.exception.spec.ts` with a root-cause message assertion. - Updated `format-upgrade-error-for-storage` snapshots (first line now carries the enriched message). --- _Generated by [Claude Code](https://claude.ai/code/session_01Mu8t74X14oYVkLrFBZHs2o)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23416?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. --> |
||
|
|
9509c737e0 |
Replace the onboarding AI chat feature flag with an environment variable (#23439)
Follow-up to #23199. The AI-chat onboarding is an instance-level rollout decision, not a per-workspace experiment, so `IS_ONBOARDING_AI_CHAT_ENABLED` becomes an instance config variable (default `false`, editable from the admin panel) exposed to the frontend through `ClientConfig`. The workspace feature flag is deleted; leftover `featureFlag` rows are inert since the column is plain text. `IS_WORKSPACE_COMPANY_ENRICHMENT_ENABLED` is removed as redundant: the PDL client already skips everything when no API key is set. Enrichment now runs when the AI chat is on and `PEOPLE_DATA_LABS_API_KEY` is configured. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23439?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. --> |
||
|
|
1e58c3073c |
Feat/email composer improvements (#23188)
- Move composer to dedicated page - Add test email option - Auto saved as draft can be revisited from `objects/messageCampaigns` later - Campaign stats component https://github.com/user-attachments/assets/9e523116-e79b-496d-9c9d-3887e0c9213f <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23188?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: Félix Malfait <felix.malfait@gmail.com> |
||
|
|
49e2272fb9 |
fix(workspace-migration): stop leaking workspace ids in delete action payloads (#23377)
Closes https://github.com/twentyhq/core-team-issues/issues/2732 ## Problem Workspace migration delete actions embedded the raw workspace-cache flat entity as their `flatEntity` payload, leaking: - `id`, `workspaceId`, `applicationId` - raw many-to-one join columns (`objectMetadataId`, `relationTargetFieldMetadataId`, ...) - raw FK aggregators (`viewFieldIds`, ...) - raw jsonb properties containing serialized relations (`settings`, `overrides`, `configuration`) Create actions already expose universal identifiers only. The asymmetry made identical migrations non-portable across workspaces (payloads embed random workspace primary keys) and caused snapshot flakiness in integration suites. ## Fix - Add `deleteFlatEntityForeignKeyAggregators` (raw-side counterpart of `deleteUniversalFlatEntityForeignKeyAggregators`, following the `flatEntityForeignKeyAggregator` / `universalFlatEntityForeignKeyAggregator` naming of `ALL_ONE_TO_MANY_METADATA_RELATIONS`). It strips base workspace-scoped properties, every property registered with a `universalProperty` counterpart in `ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME` (covers raw join columns and serialized jsonb, including cases not modeled as many-to-one relations like `labelIdentifierFieldMetadataId`), and raw one-to-many `...Ids` aggregators. Its scope is disjoint from the universal-side util. - Apply it in the delete branch of `WorkspaceEntityMigrationBuilderService` — the single point where delete-action `flatEntity` is attached — so the payload matches its `MetadataUniversalFlatEntity<T>` type at runtime. Universal `...UniversalIdentifiers` aggregators are kept (they are portable), so `BaseUniversalDeleteWorkspaceMigrationAction` needs no type change. - Regenerate the affected `successful-sync-application-workspace-migration` snapshot: the delete payload now only carries universal identifiers. Safe downstream: the runner resolves delete targets via `universalIdentifier` lookups in current maps and metadata events fetch the deleted entity from maps by `entityId`; no consumer reads the stripped properties (only create handlers consume `action.flatEntity`). Note: the `normalizeIdCollections` mitigation flag mentioned in the issue does not exist on `main`, so there was nothing to remove. ## Tests - New snapshot-based unit spec for the strip util (objectMetadata and fieldMetadata shapes, plus input immutability). - Full twenty-server unit suite: 6922 passed. - Integration with live DB: full `metadata/suites/application` (50 suites), object/field/index/agent metadata suites, all 26 `graphql/suites/view` suites, `failing-agent-deletion`, `object-identifier-update-side-effect-on-view-field` — all green, no other snapshot changes. |
||
|
|
710d4da4b1 |
fix(emails): bump @react-email/render to ^2.0.6 to fix empty transactional email bodies (#23323)
## Problem Fixes #23307. Every transactional email (workspace invite, password reset, email verification, etc.) is delivered with an **empty body** — no title, text, or CTA. ## Root cause `twenty-server` pins `@react-email/render` directly at `^1.2.3`: ```jsonc // packages/twenty-server/package.json "@react-email/render": "^1.2.3", ``` In 1.2.3, `render()` reads `renderToReadableStream` **before** the email template's async Suspense boundary (i18n/locale load) has resolved. The result is the Suspense fallback marker instead of the real markup: ```html <!DOCTYPE html ...><!--$!--><template></template><!--/$--> ``` This was fixed upstream in `@react-email/render@2.0.6` (*"await stream.allReady before reading renderToReadableStream output"*). `twenty-emails` already resolves a 2.x render via `react-email@6.5.0`, so the server's direct pin was simply stale — the two were out of sync. ## Fix Bump the direct pin to `^2.0.6` (resolves to `2.1.0`) and regenerate the lockfile. The server's `render()` imports now use the fixed 2.x. > Note: a `1.2.3` entry remains in `yarn.lock` — it is an internal transitive > pin of `@react-email/components@0.5.3`, not the server render path, so it is > expected and harmless. ## Verification Rendering `SendInviteLinkEmail` through the real `render()` (Node 24) now returns full markup (5.7 kB) with no Suspense marker and the resolved invite link + workspace content, instead of the empty fallback. A jest unit test was intentionally not added: `@react-email/render` 2.x uses a dynamic import that jest's CJS runtime rejects ("A dynamic import callback was invoked without --experimental-vm-modules") — which is exactly why the existing email specs mock `render`. The fix was verified with a standalone Node script. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23323?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: Félix Malfait <felix.malfait@gmail.com> |
||
|
|
6cc7ed7570 |
Make solo tabs first-class: derived presentation, native editing, unified widget header (#23109)
## Why
Full-page record tabs (Timeline, Tasks, Notes, Files, Emails, Calendar,
Flow) were encoded by storing a `CANVAS` layout mode. That made them a
separate species: editing one didn't feel native (no drag handles, no
way to add a second widget, the tab couldn't adapt), and the widget
pipeline was full of `layoutMode === CANVAS` branches.
This PR replaces the stored mode with two derived rules and one unified
header grammar:
> **Presentation is derived from content, never stored.**
> A list tab with exactly **one widget** renders it **solo**
(full-bleed, it owns the tab). Anything else is a **stack** of boxed
cards. **Edit mode always shows the stack structure.**
No widget taxonomy, no per-type branches: any lone widget owns its tab.
## What
**Presentation model**
- `getTabPresentation({ widgets, layoutMode, isInEditMode })`: solo iff
a list tab has exactly one widget in view mode; grid tabs (dashboards)
and edit mode are always stacks. The pinned left panel is always a
column (a surface rule, not a widget rule).
- Solo view rendering is identical to the old CANVAS rendering
(container height, internal scroll).
- Stacked widgets in the main tab area get one bounded slot rule
(`max-height` + own scroll) so no widget swallows the tab;
pinned/side-column stacks keep their flowing behavior. This only binds
on user-composed mixed tabs, which could not exist before.
**Native editing (the point of the PR)**
- Every record-page tab is edited through the same vertical-list editor:
drag handle, reorder, remove, add widget. Add a second widget to a
Timeline tab and it becomes a stack; remove back down to one and it's
solo again. Nothing is stored, nothing to migrate.
- Fixes the stuck-drag bug found while testing the preview: widgets
publishing header info republished a fresh object on every render
(activity cards build their action from non-memoized hook returns), and
since the widget chrome reads that state above the widget content, any
tab with an activity card sat in an infinite render loop. The loop
starved React's transition lane, which dnd-kit's drop teardown waits on,
so the drag clone and drop outlines froze on screen after a drop. The
header hook now republishes only on real value changes and routes
onClick through a stable wrapper, so callers need no memoization. The
page-layout drag provider also disables the Feedback drop animation so
clone cleanup is synchronous at drop time.
**Unified widget header API**
- A widget's content can publish header info to its chrome via
`usePublishWidgetHeaderInfo({ count, primaryAction })`: a count rendered
in grey next to the title, and a primary action (icon button with
accessible name) on the right in view mode. Instance-scoped state keyed
by widget id, so third-party widgets (front components) can use the same
seam later; the hook no-ops outside a page layout (stories, previews)
and is safe to call with inline, non-memoized values.
- A solo widget's header only appears when the widget published
something: the tab label already names it, so a bare title row adds
nothing. Timeline/Flow tabs stay exactly as today.
- Emails, Tasks, Notes, Files, Calendar publish their count (query
totals, not loaded-page lengths) and action (Compose, New task, New
note, Add file) and stop rendering internal title rows ("Inbox 12", "All
5"): exactly one header per widget everywhere, same grammar.
`ComposeEmailButton`, `AddTaskButton` and the title/button plumbing in
`NoteList`/`AttachmentList`/`TaskList` are deleted.
**Object-aware tabs**
- The hardcoded `SYSTEM_OBJECT_TABS` title allowlist is gone. A tab
renders based on whether the target object supports its widgets: widgets
that read through a relation (Tasks, Notes, Files, Timeline) require the
relation field to exist and be active, while Emails and Calendar
aggregate through the messaging timeline, so a missing participants
relation is fine (Company) and a deactivated one is an explicit opt-out.
System objects on the shared default layout keep exactly Home +
Timeline, now by derivation instead of hardcoded titles.
**Data cleanup**
- Seeds (frontend defaults, server standard template, `twenty app`
scaffolder, docs) write `VERTICAL_LIST`;
`PageLayoutTabLayoutMode.CANVAS` is `@deprecated`, kept read-only for
layouts persisted before this change (they render correctly through the
derivation; no data migration, by design: an in-place flip can't pass
the widget-position/tab-layoutMode validator atomically, and it isn't
needed).
- Locale catalogs are intentionally untouched: the i18n pipeline
extracts and translates the new header labels on main; they fall back to
their English source until then.
## Deliberate view-mode changes (approved)
- A lone widget of any type now owns its tab full-bleed: lone Fields tab
(mobile/side panel), lone rich-text Note tab, lone chart, and the
message-thread page lose their card box.
- Activity tabs show the unified header (title, grey count, + action)
instead of their internal "Inbox 12"-style rows.
Everything else is pixel-parity, including solo scroll behavior and
dashboards.
## Test plan
- `nx typecheck twenty-front` / `twenty-server`: clean; oxlint/oxfmt on
the changeset: clean
- 239 suites / 1474 tests across page-layout, activities, side-panel
pass, including new tests for `getTabPresentation` (count-based,
edit-mode override) and `usePublishWidgetHeaderInfo` (publish, cleanup
on unmount, no-op outside a widget, referential stability across
re-renders with inline actions, latest-onClick wrapper)
- `getTabsRenderableForTargetObject` tests covering missing vs
deactivated relations, Emails/Calendar without a participants relation,
and non-relation widgets
- Stuck-drag repro verified fixed end to end against a local stack with
an instrumented dnd-kit: before the fix the affected tab committed ~65
renders/second at idle and drops never tore down; after it, idle commits
are flat and every drop cleans up
|
||
|
|
d1c6b8ee72 |
Show relation record labels instead of UUIDs in dashboard charts (#23163)
https://github.com/user-attachments/assets/d012a013-2c90-49a1-a27e-b8e4b684a84f Charts grouped by a relation without a sub-field rendered raw FK UUIDs on axis ticks, legends and tooltips. The server now batch-resolves the grouped record ids to their label identifier through a permission-scoped query and formats every bucket with the record's display name. Unresolvable records (deleted or not readable) render as Unknown and their ids are stripped from the response payload. Same-named records get an ordinal suffix so their buckets don't merge. Covers bar, line and pie, plain and morph relations. ```mermaid flowchart TD A["Dashboard widget load"] --> B["Chart data service<br/>(bar / line / pie)"] B --> C["executeGroupByQuery:<br/>group by relation FK id,<br/>ORDER BY target label identifier,<br/>scoped to source object permissions"] C --> D["filterOutEmptyChartBuckets"] D --> E{"Bare relation axis?<br/>(no sub-field)"} subgraph RL["ChartRelationLabelService.resolveRelationLabels"] direction TB G1["Collect distinct record ids<br/>per target object"] --> G2["Batch SELECT label identifier columns,<br/>scoped to TARGET object permissions"] G2 --> G3["buildRawLabelByRecordId:<br/>display name per record"] G3 --> G4["buildUniqueRelationLabels:<br/>suffix duplicates, Unknown for unresolved"] end E -- No --> H["formatDimensionValue per bucket"] E -- Yes --> G1 G4 --> H H --> I["Strip unresolved ids from<br/>formattedToRawLookup"] I --> J["Chart DTO to frontend"] ``` The chart settings sub-field dropdown gains a Record option to group by the related record itself, and now only offers sub-fields the backend accepts (system fields like a workspace member's updatedBy were selectable but rejected at query time). Chart-data errors are now logged server-side. Also fixes two latent bugs on this path: sorting a bare-relation chart by field threw `Cannot orderBy unknown field: agentId`, and the pie chart truncated slices before sorting. The AI dashboard tool guidance and the seeded dashboards no longer force the sub-field workaround. The group-by query orders buckets by the related record's label identifier at the database level (the engine now accepts ordering by a target field when grouping by its id), so with more than 100 distinct related records the surviving buckets match the label order. |
||
|
|
2c79093b74 |
feat: agent roleUniversalIdentifier for manifest-driven role assignment (#23206)
## Summary - Adds optional `roleUniversalIdentifier` on `AgentManifest` / `defineAgent` so apps can declaratively assign a role to an agent (same config shape as `defaultRoleUniversalIdentifier`). - Wires `agentUniversalIdentifier` as a sync many-to-one FK on `roleTarget`, and emits a deterministic `roleTarget` from the agent during app sync (create / update / delete). - Enables app agents (e.g. Slack assistant) to get a role on install without postInstall hooks or manual admin assignment. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23206?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. --> |
||
|
|
5160415f40 |
fix(workflow): create core mirror rows during workflow prefill (#23204)
## Problem Seeded workflows are inserted during workspace activation (and dev seeding) via raw SQL in `prefill-workflows.util.ts`, which bypasses the ORM entirely. The async dual-write listener that mirrors `workflow` / `workflowVersion` into `core.workflow` / `core."workflowVersion"` never fires for these rows, so every newly activated workspace is born with: - no core mirror rows, and - NULL `coreWorkflowId` / `coreWorkflowVersionId` soft-refs. This is permanent, growing drift. The core-consistency check reports every new workspace as 2 unlinked workflows + versions, and once trigger dispatch reads from core these seeded workflows would silently stop working. ## Change Insert the `core.workflow` and `core."workflowVersion"` mirror rows and stamp the soft-refs inside the same prefill transaction. Field mapping mirrors the dual-write / backfill exactly: - `core."workflowVersion".workflowId` = the workspace workflow id (what the trigger-map cache groups by) - `triggers` = jsonb `[trigger]` - `applicationId` = `workspace.workspaceCustomApplicationId` (throws if missing, same contract as the sync path) - `core.workflow.lastPublishedVersionId` = the workspace version id - one ACTIVE core version per workflow (satisfies the partial unique index) Core ids are deterministic v5 (same helper/namespace as the existing prefill ids) so the existing `.orIgnore()` re-run guard stays idempotent — a re-run hits the PK conflict and is skipped instead of inserting duplicate orphan core rows. The large diff is mostly re-indentation: the step/trigger arrays were extracted to consts so the same objects feed both the workspace and core inserts. The step/field UUID literals are unchanged from main. ## Verification - `nx lint:diff-with-main twenty-server`: clean. - Typecheck (isolated `tsc`): no errors in the changed file (`nx typecheck twenty-server` is blocked by a pre-existing `twenty-shared` build error on main, unrelated). - Runtime: pending a `database:reset` + cross-schema parity query confirming zero drift on a freshly seeded workspace. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23204?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. --> |
||
|
|
8368dd41c4 |
Remove legacy FindAllViews response cache flush from migration runner (#23190)
The pattern-flush (a full Redis keyspace SCAN) was kept for exactly one release after the metadata GraphQL caches were re-keyed on flat-map hashes (#23164), as the only invalidation signal for old pods' version-keyed FindAllViews entries during rolling deploys. With all pods on hash-keyed caches, dependency-hash rotation in the cache key covers both view and metadata changes, so the flush is redundant. flushGraphQLOperation has no remaining callers, so it is deleted from WorkspaceCacheStorageService as well. incrementMetadataVersion stays: it still feeds the X-Schema-Version check. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23190?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. --> |
||
|
|
f5a9adcb76 |
Add post-onboarding AI chat setup behind a feature flag (#23120)
https://github.com/user-attachments/assets/fec7076f-4e46-4c39-84d7-68e4340244ac After finishing onboarding, users now land in a full-screen AI chat that helps them set up their workspace, instead of going straight to their default view. The welcome overlay's title flies into the chat's first message so the handoff reads as one continuous motion: the slide plays alone, the title swaps in place pixel-exactly (a regular-weight clone of the target line is crossfaded in mid-flight to morph the font weight), then the rest of the text fades in. All of it sits behind `IS_ONBOARDING_AI_CHAT_ENABLED` (default off, not registered as a public flag). With the flag off, onboarding behaves exactly as it does today — the welcome overlay still plays and the user lands on their home view. Layout follows the Figma: the nav drawer stays visible and the chat renders in a panel-styled container with an "Onboarding" header, matching the expanded side panel. Also fixes two pre-existing bugs the feature surfaced: - On billing instances the completion redirect raced the lazy `PaymentSuccess` page, which silently skipped the welcome animation on the no-card trial path. The redirect now defers while a checkout is pending, and `PaymentSuccess` always confirms through `useLoadCurrentUser` so freshly served feature flags are respected. - `useDefaultHomePagePath` could conclude its `/settings/profile` empty-workspace fallback from a transiently empty metadata store and strand the user there; it now waits for both object metadata and navigation menu items before deciding. Reviewer notes: - `AgentChatRuntimeEffects` no longer keys off side-panel state, so `modules/ai` stops importing `modules/side-panel`. The two visibility-scoped effects moved into `AiChatTab`. - `/workspace-setup` is deliberately URL-addressable rather than onboarding-only: the collapse control in the header is a general expand/collapse toggle (paired with a new expand button in the side panel top bar), and gating the route would break refresh and browser-back. It is still authenticated-only. - The design's second, LLM-authored paragraph is not implemented — starting an assistant turn with no user message needs server-side work. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23120?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. --> |
||
|
|
0108a34765 |
Rekey metadata caches when flat map hashes change (#23164)
## Context
The `/metadata` GraphQL response cache (`ObjectMetadataItems`,
`FindAllViews`) and the workspace SDL cache were keyed on
`workspace.metadataVersion`, an integer bumped on every object/field
migration. That mechanism is legacy (the migration runner literally
calls it `getLegacyCacheInvalidationPromises`): the data plane already
moved to `WorkspaceCacheService`, which versions each flat entity map
with its own hash minted on invalidation.
Version keying had two concrete costs: the version bump was the only
proactive invalidation for `ObjectMetadataItems`, and keeping
`FindAllViews` fresh required `flushGraphQLOperation`, a full Redis
keyspace SCAN on every relevant migration. It is also the main blocker
for deprecating `metadataVersion` entirely.
This PR re-keys both caches on the flat-map hashes instead.
## What changed
**Response cache** (`use-cached-metadata.ts`): the key is now
`{operation}:{workspaceId}:{combinedDependencyHash}[:{userWorkspaceId}]:{locale}:{queryHash}`.
Each cached operation declares which flat maps its resolvers read
(`metadata-graphql-operations-to-cache.constant.ts`) plus a scope:
`ObjectMetadataItems` stays workspace-shared, `FindAllViews` is per-user
because unlisted-view visibility depends on the caller. When any
declared map changes, its hash rotates and the key rotates with it; no
flush needed. The key is resolved once per request and reused in
`onResponse`, so a rotation mid-request can never cache a response under
a fresher key than the data it was built from. If hash resolution fails,
the request is served uncached (Sentry-captured).
This also fixes three pre-existing key soundness gaps: `FindAllViews`
ignored locale although view names are translated server-side, the query
hash ignored GraphQL variables (`$viewTypes`), and mid-request version
rotation could re-key between request and response.
**SDL cache** (`workspace-graphql-schema-sdl.service.ts`): keyed on the
combined hash of the four maps the schema is generated from, taken from
the same `getOrRecomputeWithHashes` call that returns the data, so key
and content cannot skew. The `metadataVersion` read/seed block there is
gone; the Redis seed moved to `middleware.service.ts` so the
`X-Schema-Version` "refresh the page" check keeps working after the
Redis key's TTL expires.
**`WorkspaceCacheService`**: the internal pipeline now threads `{data,
hashes}` through every stage (local hit, hash validation, Redis fetch,
recompute) and the memoizer stores the pair, so returned hashes are
always consistent with returned data. New public
`getOrRecomputeWithHashes` and `getOrRecomputeCombinedHash`
(hashes-first: one MGET of the small `:hash` keys, full pipeline only
for missing ones, so cold pods never pull map payloads just to build a
key).
**Atomic pair writes** (`cache-storage.service.ts`): `mset` on the Redis
driver now delegates to the store's own `mset` (a MULTI of `SET ... PX`,
or native `MSET`), grouped by TTL. Previously it was a `Promise.all` of
independent SETs, so two concurrent recomputes could interleave and
leave one recompute's `:data` next to the other's `:hash`; with
hash-keyed caches that torn pair could persist a stale response under a
live key. `CoreEntityCacheService` writes through the same method and is
fixed for free.
**Cleanup**: `flush()` lost its `metadataVersion` parameter (always
pattern-flush per key on workspace deletion),
`METADATA_VERSIONED_WORKSPACE_CACHE_KEY` became
`HASH_KEYED_WORKSPACE_CACHE_KEYS` with the `MetadataVersion` key
relocated to `WORKSPACE_CACHE_KEYS` and the dead `ORMEntitySchemas`
entry removed.
## Deliberately unchanged
- `incrementMetadataVersion` and all its callers stay: the version still
feeds the `X-Schema-Version` check and the pinned upgrade commands.
Deprecating the column is a later stage.
- The runner's `FindAllViews` pattern-flush is kept for exactly one
release: view-only migrations never bump `metadataVersion`, so old pods
in a rolling deploy have no other invalidation signal for their
version-keyed entries. It gets deleted next release, which removes the
SCAN entirely.
- Old-shape cache entries are not migrated; they expire via the 7-day
TTL.
## Known limitations (follow-ups, not regressions)
- The plugin reads dependency hashes Redis-fresh while resolvers can
serve up to 10s-old memoized data, so a request landing right after a
migration can cache a pre-rotation response under the new key. Same
shape existed under `metadataVersion`; closing it needs request-scoped
snapshot plumbing.
- Concurrent recomputes are last-writer-wins (lost update). Fencing with
a conditional write is a follow-up.
## Validation
- Unit: response-cache plugin behavior (scope, key stash, serve-uncached
on failure, prototype-name guard), atomic `mset` batching, existing
`WorkspaceCacheService` spec passing unchanged.
- Integration: a new drift-guard spec runs the real
`ObjectMetadataItems`/`FindAllViews` operations with full frontend
selection sets against the in-process app, spies on
`WorkspaceCacheService`, and fails if resolvers read a flat map missing
from the declared dependency lists, so the constant cannot silently
drift.
- Manual against a live server: creating a field rotates the field-map
hash and the very next `ObjectMetadataItems` response contains it (hash
rotation is now its only invalidation path); warm hits are ~5ms; SDL
entries appear under hash-shaped keys via introspection.
## Suggested reading order
1. `workspace-cache.service.ts`, `workspace-cache-key.type.ts`,
`combine-cache-hashes.util.ts` (the `{data, hashes}` pipeline)
2. `use-cached-metadata.ts`,
`metadata-graphql-operations-to-cache.constant.ts`,
`metadata.module-factory.ts` (response cache)
3. `workspace-graphql-schema-sdl.service.ts`,
`workspace-cache-storage.service.ts` (SDL cache and renames)
4. `middleware.service.ts` (metadata version seed relocation)
5. `cache-storage.service.ts` (atomic writes)
6. Tests
|
||
|
|
71a1ff7ac8 |
Cache twenty-client-sdk modules host-side via content-addressed URLs (#22981)
## Context Front component sources are fetched host-side and integrity-verified by the SHA-256 checksum embedded in their URL, cached in Cache Storage — a layer that exists specifically because their download URLs are presigned and rotate. The `twenty-client-sdk` modules (`core` and `metadata`) were re-fetched on every render and could not be cached safely: their URLs carried no checksum and the server exposed no freshness signal. This PR makes the SDK module URLs **content-addressed** and relies on the **browser HTTP cache** for immutability, and it keys the checksums on their real owners: the **application** for `core`, the **instance** for `metadata`. The checksum does double duty: cache invalidation (regeneration changes the checksum → the URL changes → guaranteed cache miss) and a server-side cacheability guard (the server only grants `immutable` when the checksum in the URL matches the authoritative checksum it knows for that module — persisted at generation time for `core`, hashed once at bootstrap for `metadata` — so no per-request hashing of the served bytes). Note this is **not** an end-to-end integrity guarantee: there is no client-side hash verification, and on a fingerprint mismatch the server still serves the current bytes with `no-store` (self-healing for stale URLs) rather than failing. <img width="2412" height="926" alt="image" src="https://github.com/user-attachments/assets/d97935d2-0fdb-4c44-89ac-596b7ca8ca64" /> Closes twentyhq/core-team-issues#2688. ## Routes | Module | URL | Scope | | --- | --- | --- | | `core` | `/rest/sdk-client/{applicationId}/core[/{checksum}]` | Per application (generated bundle) | | `metadata` | `/rest/sdk-client/metadata[/{checksum}]` | **Instance-wide**: no application segment, so every application converges on one URL and the browser downloads the module once per release instead of once per application | The previous application-scoped metadata path (`/rest/sdk-client/{applicationId}/metadata[/{checksum}]`) is **kept for backward compatibility**, new clients just stop generating those URLs. The instance-wide route is declared before the parameterized route so `metadata/{checksum}` is not swallowed as `:applicationId/:moduleName`. ## Caching model | Request | `Cache-Control` | Effect | | --- | --- | --- | | Fingerprinted URL, checksum matches the known module checksum | `immutable` | Cached indefinitely by the browser HTTP cache; a new checksum is a new URL | | Fingerprinted URL, checksum does not match | `no-store` | Current bytes served uncached (self-healing for stale URLs) | | Bare URL (pre-generation fallback, `core` only in practice) | `no-store` | Never cached | - Both responses also set `X-Content-Type-Options: nosniff` and `Content-Type: application/javascript`. - SDK modules are intentionally **not** placed in Cache Storage. That layer stays reserved for the presigned/rotating component-source URLs; SDK modules are served directly and authenticated, so the browser HTTP cache (keyed by the content-addressed URL) is their single cache layer. ## Checksum provenance - **core** — per **application**, persisted on `application.sdkClientCoreChecksum` at generation time and read back from `flatApplicationMaps` (never re-hashed per request). - **metadata** — **instance-wide**, hashed once from the installed `twenty-client-sdk/dist/metadata.mjs` package (warmed at bootstrap, memoized per process) and served straight from that package, so it is fresh from the first request after a release with no archive dependency. ## Server (twenty-server) - Hash `dist/core.mjs` at SDK generation and persist `sdkClientCoreChecksum` via `applicationRepository.update`. Adds the nullable text column to `application.entity.ts` (mirroring `packageJsonChecksum`) plus a fast instance command with up/down; `FlatApplication` picks it up automatically. - New **application-scoped** query `applicationSdkClientChecksums(applicationId: UUID!): SdkClientChecksums` on `ApplicationResolver` (metadata schema, `WorkspaceAuthGuard` + `NoPermissionGuard`). `SdkClientChecksums.core` is **nullable** and stays `null` until the SDK has been generated at least once; `metadata` is **always present** (bootstrap-warmed), so the metadata module is cacheable from the very first render of any app. The query itself returns `null` only for unknown applications. - `SdkClientChecksumsDTO` now lives in the shared `core-modules/sdk-client/dtos/`. `FrontComponentDTO` and the `frontComponent` resolver no longer carry checksums (decoupled from the front-component row). - `sdk-client` controller: instance-wide `metadata[/:checksum]` route (no workspace-cache or application lookup, serves the memoized installed module) + application-scoped `:applicationId/:moduleName[/:checksum]` route (serves `core` from the per-application archive, `metadata` kept for back-compat). Cacheability compares the URL checksum against the **known** checksum — persisted `sdkClientCoreChecksum` for `core`, memoized package hash for `metadata` — instead of hashing the served bytes on every request: `immutable` on match, `no-store` otherwise (bare URL or stale fingerprint), plus `nosniff`. A persisted checksum out of sync with the archive only downgrades to `no-store` until the next regeneration. ## Front (twenty-front) - New metadata query `GetApplicationSdkClientChecksums`, keyed by `applicationId`; removed the `sdkClientChecksums` selection from `FindOneFrontComponent`. - `getSdkClientUrls` builds the two module URLs independently: `/sdk-client/{applicationId}/core/{checksum}` and the **instance-wide** `/sdk-client/metadata/{checksum}` (no application segment → one shared browser cache entry per release across all applications). Each falls back to its bare URL when its checksum is absent — since `core` is nullable, a never-generated app still gets a content-addressed metadata URL and only `core` falls back. The checksum type is sourced from the codegen `SdkClientChecksums` type rather than a hand-maintained duplicate. - `FrontComponentRenderer` is split into a gating outer component (runs `FindOneFrontComponent`, renders nothing while loading) and a content component that receives a guaranteed-non-null `frontComponent`. Following project conventions, the side effects live in dedicated effect components: `FrontComponentLoadErrorSnackBarEffect` (query error → snackbar) and `FrontComponentApplicationTokenPairEffect` (mirrors the query-derived token pair into component state unconditionally, `null` included, so revoked credentials can never be retained or refreshed). The content component fetches checksums via the application-keyed query and **gates the mount of SDK-using components on that query**, so the very first module fetch is always the content-addressed (`immutable`) URL instead of the bare `no-store` one. Non-SDK components skip the query and are never blocked. - **Live invalidation without reload:** SDK regeneration updates the application row, and the server broadcasts an `application` metadata event carrying the new core checksum. `useOnApplicationSdkClientChecksumsUpdated` / `useUpdateSdkClientChecksumsApolloCache` patch the application-keyed checksum query cache (core only; the instance-wide metadata is preserved), so every mounted component of that application picks up the new URL at once. This replaces the previous frontComponent-derived field and closes the earlier "known gap" (a mounted component staying on a session-old checksum until a full reload). The cache-patching callback is memoized (`useCallback`) so the window listener is registered once per application, and the listener is **skipped entirely** for non-SDK components (`useListenToMetadataOperationBrowserEvent` gained a `skip` option) — they register no listener and never refetch a query they don't consume. ## Renderer (twenty-front-component-renderer) - SDK sources are fetched through a dedicated plain authenticated fetch, `fetchJavaScriptModuleSourceText` (Bearer header, `credentials: 'omit'`), instead of the Cache Storage `fetchComponentSource` path; `fetchSdkClientSources` uses it. Execution stays exclusively in the opaque-origin worker via blob URLs; the host only fetches and forwards source strings (no hashing host-side). Staleness self-resolves through the checksum: new checksum → new URL → cache miss. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22981?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. --> |
||
|
|
3ad3e8bd1a |
feat: kanban, calendar and group-by table layouts for dashboard view widgets (#22963)
## Context Dashboard view widgets previously only rendered flat tables. This PR ships the full feature: **Table with group-by**, **Kanban**, and **Calendar** layouts for dashboard view widgets — server API + frontend, end-to-end. (Originally staged as a 4-PR stack — #22966, #22967, #22968 — consolidated here per review.) ## Server / API - **View typing.** Adds `KANBAN_WIDGET` and `CALENDAR_WIDGET` to `ViewType` (following the `TABLE_WIDGET` precedent) so widget-backing views keep their layout in `view.type` while staying excluded from record-index pickers. Shared `getViewLayoutFromViewType()` maps widget types to their base layout; `isWidgetViewType()` centralizes the exclusions that were previously hardcoded per-site. - **Migrations.** Two fast instance commands (**2.23**): `ALTER TYPE core.view_type_enum ADD VALUE` for both values, and a widened `CHK_VIEW_CALENDAR_INTEGRITY` constraint covering `CALENDAR_WIDGET` (entity `@Check` updated for fresh installs). - **Validation.** `FlatViewValidatorService` keys kanban/calendar validation on the mapped layout, so widget views get the same invariants as index views (kanban needs a groupable group-by field; calendar needs a date field + layout). Calendar widget views default to month; a non-month (DAY/WEEK) layout is rejected at the API level **unless** the `IS_CALENDAR_WEEK_VIEW_ENABLED` feature flag is enabled for the workspace — the same flag that gates day/week on index calendars. - **API.** `upsertViewWidget` (LAYOUTS permission) accepts a nested `view` settings input (`type`, `mainGroupByFieldMetadataId`, `shouldHideEmptyGroups`, kanban aggregate/column-width, calendar layout/fields). Routes through the standard update path, so `viewGroups` auto-generate from SELECT options exactly like index views. Only widget view types accepted; only `RECORD_TABLE` widgets can change view settings. - **AI tools.** `create-complete-dashboard` + `create_view` now use/allow the `*_WIDGET` types (previously they created plain `TABLE` views that leak into index pickers). ## Frontend **Settings panel.** The **Source** (object) row comes first, since which layouts are available depends on it. The **Layout** row below is a working dropdown (Table / Kanban / Calendar); layouts the source object can't support are **disabled with a hint** ("Needs a Select field" / "Needs a Date field") rather than hidden. Group-by row (select fields; searchable) with a **Hide empty groups** toggle while grouped; **Date field** row replaces Group by while Calendar is active, and — when the `IS_CALENDAR_WEEK_VIEW_ENABLED` flag is on — a **Calendar view** row (Day / Week / Month) appears beside it; **Limit** row hidden while grouped (only the flat virtualized loader enforces it). Kanban keeps its group-by locked (no `None` option). **Instant edit-mode preview.** Draft snapshots carry `viewGroups`; picking a group-by synthesizes them client-side (`buildDraftViewGroupsForFieldMetadataItem`, mirroring the server's generation), so grouped tables/boards preview immediately before dashboard save. On save, `upsertViewWidget` responses hand back the server-generated groups, which replace the client-generated ones in the persisted snapshot. **Renderers.** `RecordTableWidgetRendererContent` branches on the backing view's layout: `RecordBoardWidget` (wraps the standard `RecordBoardContainer`) and `RecordCalendarWidget` (mounts the existing `RecordCalendar`, which renders month / day / week) inside the same per-widget provider sandbox the table uses. **Read-only semantics.** Two flags with distinct scopes, each documented on its state: - `isRecordBoardViewSettingsReadOnlyComponentState` — locks the board chrome that edits view settings (add group, column reorder/resize/menu, aggregates); **card drag still updates records** under object permissions. - `isRecordCalendarReadOnlyComponentState` — widget calendars are read-only by default (no drag, no add-new, no in-calendar layout switch); cards open the side panel. The one exception, behind `IS_CALENDAR_WEEK_VIEW_ENABLED`: a **live (non edit-mode) day/week** widget calendar allows drag-to-reschedule and record creation under object permissions. Month calendars and edit-mode previews stay read-only. **Calendar state componentization.** The calendar module's three settings move from global atoms to component states keyed on `RecordCalendarComponentInstanceContext` (same pattern as record-board), so several calendar widgets and an index-page calendar can coexist without leaking state. All readers resolve the ambient instance; calendar unit tests updated. **Multi-instance fixes that also fix index pages:** record drag states were written against a different instance than every reader resolves (now use the ambient instance); the board sticky-header DOM id is namespaced per board; dragged board cards portal to `document.body` while dragging so react-grid-layout's transforms can't offset the clone from the pointer. ## Scope (v1) - Widget calendars are month-only and read-only by default. With `IS_CALENDAR_WEEK_VIEW_ENABLED` enabled, day/week layouts become selectable (UI + API) and live day/week widget calendars support drag-to-reschedule and record creation under object permissions. - Widget group-by offers SELECT fields only (server auto-generates groups from options; widgets have no per-record add-group flow). ## Tests - Integration: `upsert-view-widget-view-settings.integration-spec.ts` (9 tests — group auto-creation, invalid type/field rejections, non-month calendar widget rejected while the week/day flag is off and accepted once it's enabled, combined settings+fields call); pre-existing `upsert-view-widget` suite (20) green. - Front: new suites for draft view-group generation and snapshot clone/build utils; calendar suites componentized; full `twenty-front` jest, typecheck, oxlint green; `twenty-server` typecheck + lint green. - Browser-verified end-to-end (real dev server + seeded workspace): configure → live edit-mode preview → save → reload for all three layouts; measured drag with pointer inside the card; index-page calendar re-verified (with the week/day flag enabled). https://claude.ai/code/session_01E5N87kwwZWhDtEQaP72cMf |
||
|
|
e5fc5054cc |
Fix "Go to roles settings" command (#23105)
**Fix the "Go to Roles Settings" command** — it pointed at the non-existent `/settings/roles` route and now navigates to `/settings/members#roles`. **Backfill existing workspaces** — added the `upgrade:2-23:fix-go-to-roles-settings-command-menu-item-path` workspace command, which rewrites the seeded command menu item payload for existing workspaces. It is idempotent and only touches workspaces still holding the legacy path. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23105?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. --> |
||
|
|
1be5a0e54a |
System side effect relations (#22882)
Closes twentyhq/core-team-issues#2667 ## What Default relations to the standard relation objects (`timelineActivities`, `attachments`, `noteTargets`, `taskTargets`) are now fully owned by the **metadata side-effect engine**. Neither the API transpilers nor the SDK manifest builder provision them anymore: any object creation, rename or deletion — regardless of the caller — goes through the same engine handlers. ## Why - Provisioning was duplicated across the API path and the SDK manifest builder, with diverging behavior. - Universal identifiers of relation fields were derived from object **names**, so renaming an object mutated them and forced lossy delete+create cycles on manifest sync. ## How ### Engine-owned lifecycle (side-effect handlers) - `objectSystemRelationsOnCreate`: provisions the 8 forward/reverse relation fields (+ join column indexes) when an object is created. - `objectSystemRelationsOnUpdate`: renames the reverse morph fields (`target<ObjectName>`) when their host object is renamed — a lossless `fieldMetadata.update`. - `objectSystemSideEffectsOnDelete`: cascades deletion of engine-owned fields/indexes when the object is deleted. - The API transpilers and the SDK `buildManifest` no longer inject these fields; `isSystemSideEffect: true` marks engine-owned entities, guarded by a granular property allowlist (only `isActive` is user-editable) and excluded from manifest deletion inference. ### Name-free deterministic universal identifiers New `getSystemRelationFieldUniversalIdentifier({ applicationUniversalIdentifier, objectUniversalIdentifier, relationTargetObjectUniversalIdentifier })` in `twenty-shared`, exported from `twenty-sdk/define`. The identifier is keyed on the two **object** identifiers instead of field names (direction encoded by argument order), so object renames never mutate relation field identifiers. It cannot collide with the name-based `getFieldUniversalIdentifier` derivation (field names cannot contain `:`). ### twenty-standard re-owned All 48 forward/reverse system relation field declarations in `STANDARD_OBJECTS` now pin the derived name-free identifiers (computed inline via the shared util) and carry `isSystemSideEffect: true`, with labels/icons declared explicitly (translated via `msg`). `twenty-standard` is projected as if the engine had generated these fields itself. ### 2.23 upgrade commands - `reconcile-system-relation-field-universal-identifier`: structurally matches existing default relation fields per workspace and backfills the derived universal identifiers, `isSystemSideEffect` flags, and standard labels/icons. - `upgrade-people-data-labs-application`: upgrades installed PDL apps to `1.0.7` right after the backfill to close the desync window (its views reference the re-derived identifiers). ### Misc - `people-data-labs` `1.0.7`: views temporarily pin the new derived identifiers (TODO: import from the next released `twenty-sdk`). - `UpgradeStatusModule` split out of `UpgradeModule` so the application module cluster can consume upgrade status/migration services without importing the versioned command bundles (fixes a require cycle that crashed boot). - Docs: `system-fields.mdx` documents the system relation fields and their resolver; `sync-and-recovery.mdx` plan example no longer shows auto-injected relations. ## Known red CI `people-data-labs (dockerhub-latest)` fails by design until the 2.23 server image is published: the app pins the new identifiers which only exist on a 2.23 server. The `local` leg (server built from this branch) is green. ## System fields are no longer manifest-authorable (accepted regression) The manifest converter no longer derives `isSystem` / `isSystemSideEffect` from field names. Reserved-system-named manifest fields (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy`, `position`, `searchVector`) are now skipped at conversion time when they carry the exact derived universal identifier (keeps manifests built with older SDKs installable), and rejected with `INVALID_INPUT` when they pin any other identifier. System fields are therefore fully engine-canonical: nothing a manifest carries can produce a system-flagged entity anymore. **Accepted regression**: a manifest can no longer influence system field properties at all. Previously a (legacy) re-declaration could shape them at creation — which actually produced broken system fields, e.g. a nullable, non-unique `id` — and could still toggle the allowlisted `isActive` / `universalSettings` afterwards. We consider this acceptable for now: per-app granularity over system fields will be reintroduced later through the **override framework**, which will also settle update semantics by forbidding direct updates over `isSystemSideEffect: true` entities and expressing divergence as overrides. `isSystemSideEffect`-only entities (the default relation fields provisioned by this PR) still have no engine-level update guard (see Follow-up below); that part is unchanged and also lands with the overrides refactor. ## Follow-up `isSystemSideEffect` field update/delete guards intentionally live at the API layer (`sanitize-raw-update-field-input.ts`, `from-delete-field-input-...util.ts`) rather than in the engine-level `FlatFieldMetadataValidatorService`. Moving them into the validator requires threading operation-origin (direct field mutation vs engine cascade) through the migration matrix, otherwise legitimate object rename/delete cascades (which carry `isSystemBuild=false`) would be rejected. Tracked in twentyhq/core-team-issues#2671. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22882?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. --> |
||
|
|
8d84a0b9f3 |
feat(app): allow non-admin developers to claim and list marketplace apps (#22621)
## Context Follow-up to #22609. Lets a non-admin developer claim ownership of a public Twenty app they published to npm, then request a marketplace listing that a server admin reviews. Marketplace state is per-instance for now. ## Claiming - Developer tab gets a **Claim an application** section: look up an unclaimed npm app by package name or universal identifier. - Ownership is proven with GitHub OAuth against the package's npm provenance (trusted publishing): the connected account must own the GitHub account or organization the package was published from. - Errors from the GitHub callback come back as a code and are shown inline with a link to the relevant documentation. - The old one-click claim stays admin-only. - A **Sync catalog** button triggers a catalog refresh instead of waiting for the hourly cron. - Gated behind the `IS_APP_CLAIMING_ENABLED` feature flag. ## Listing requests - Catalog-synced apps are created **unlisted**; a data migration unlists previously auto-listed unclaimed npm apps (owned or vetted rows are left untouched). - Owners request a listing from the Distribution tab (logo + description required); a server admin approves or rejects it from a **Listing requests** section in the Admin Panel. ## Screenshots <img width="1512" height="829" alt="image" src="https://github.com/user-attachments/assets/788d4362-97c4-4e42-810c-ef1f11517bec"/> <img width="1512" height="829" alt="image" src="https://github.com/user-attachments/assets/d6246190-c82a-4f64-87be-3bb668527645"/> <img width="1512" height="828" alt="image" src="https://github.com/user-attachments/assets/21a8dad4-610b-4d1f-8948-b9acab40d373"/> <img width="1512" height="829" alt="image" src="https://github.com/user-attachments/assets/58246130-41f7-451e-ae7f-57bd21d04bb6"/> --------- Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> |
||
|
|
2e671342f5 |
[2/2] Write CREATED at activation, clean stale onboarding workspaces (#22915)
## Context Follow-up to #22904 (merged), which introduced the `CREATED` activation status (schema provisioned, onboarding incomplete — no billing subscription), its enum migration, and the read path. This PR turns the status on and closes the zombie-workspace leak (~60–110 subscription-less ACTIVE workspaces per day since v2 onboarding, 935+ total). ✅ **Deploy gating satisfied**: #22904's slow enum migration shipped with the release deployed to prod on 2026-07-17, so writing `CREATED` is now safe. Rebased on main (clean, no conflicts) — main's #22943/#22955 guarded-transition rework already handles `CREATED` correctly: the webhook suspend switch only suspends `ACTIVE` workspaces, and `reactivateWorkspace` promotes `CREATED`→`ACTIVE`. ## What this PR does 1. **Write path** — `activateWorkspace` sets `CREATED` instead of `ACTIVE` when the workspace has no billing subscription. `hasWorkspaceAnySubscription` returns true when billing is disabled, so self-hosted workspaces keep going straight to `ACTIVE` — no behavior change outside cloud. 2. **Cleanup** — `CREATED` joins `PENDING_CREATION`/`ONGOING_CREATION` in the existing onboarding cleaning flow (cron + `workspace:clean:onboarding` with `--dry-run`): workspaces older than the same seven-day threshold are soft-deleted, then hard-deleted on a later run. **No suspension step and no emails** — an abandoned onboarding is treated as never having completed, exactly like a workspace stuck in creation. A workspace that subscribes before cleanup exits the flow (`CREATED`→`ACTIVE` synchronously via checkout). 3. **Backfill** — slow instance command moving `ACTIVE` workspaces with no `billingSubscription` row, created since v2 onboarding shipped (2026-07-01), to `CREATED`. Gated on `IS_BILLING_ENABLED` so self-hosted instances are untouched. 4. **Resolves #22904's text-cast TODO on the billing activation update** — this PR only deploys after the enum migration, so the `CREATED`→`ACTIVE` promotion is a plain status-scoped update again. The upgrade-path filters (`activationStatusIn`) keep the `::text` cast: upgrade tooling has to run against databases coming from pre-2.22 versions, so its TODO now points at the real removal trigger (dropping pre-2.22 upgrade support). ## Ops note before deploying The backfilled zombies are all older than seven days, so the first cron run after the backfill **soft-deletes them and the next run destroys them (schema and data), with no user-facing communication**. The backfill also catches any post-July-1 cloud workspace that is ACTIVE without a subscription — including intentionally comped/demo/internal ones if any were created since then (verified locally: the seeded demo workspaces matched). **Run the backfill's SELECT as a dry-run against prod and review the list before deploying.** ## CI note ~~`cross-version-upgrade` (and its `ci-server-status-check` aggregate) is red due to a pre-existing regression on main — `Field metadata "coreWorkflowVersionId" is missing in object metadata workflowVersion` on the seed workspaces.~~ Resolved: the rebase picks up main's #22944/#22961 which fixed that regression. ## Verification Server-side (billing-enabled local instance, Stripe test mode) and through the full onboarding UI in both billing modes: - **Billing enabled, UI**: signup → workspace creation → **`activationStatus: CREATED`** in DB mid-onboarding → profile/invite steps work on the CREATED workspace → plan-required page → no-card trial → app loads, workspace **`ACTIVE`** with a `trialing` subscription (exercises #22904's synchronous promotion). - **Billing disabled, UI**: signup → workspace creation → **`ACTIVE` directly**, no plan step anywhere, app loads — self-hosted behavior unchanged. - **Cleanup**: a `CREATED` workspace backdated 8 days is listed by `workspace:clean:onboarding --dry-run`; the real run soft-deletes it silently (no suspension, no email) and the next run hard-deletes it (workspace row and schema gone). - **Backfill**: synthetic `ACTIVE` no-sub workspaces — created 2026-07-05 flips to `CREATED`, created 2026-06-15 stays `ACTIVE`, subscribed workspaces stay `ACTIVE`; billing-disabled short-circuit returns without touching anything. - Lint + typecheck green. |
||
|
|
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. |
||
|
|
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" /> |
||
|
|
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> |
||
|
|
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. --> |
||
|
|
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
```
|
||
|
|
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`). |
||
|
|
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. |
||
|
|
674de0056b |
feat(messaging): message campaign delivery stats + views (#22661)
Re-land of #22452 (reverted in #22627). Rebuilt on fresh main with upgrade commands isolated to 2-20 only; no other version's commands touched. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22661?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. --> |
||
|
|
b733a79821 |
feat(server): support server-scoped files via nullable workspaceId on file table (#22587)
Part of the app settings architecture cleanup (twentyhq/core-team-issues#2456) — PR 1 of the server-level documents plan, reworked after the revert of #22560 (#22579). Same capability, different shape: **no new entity** — server-level documents live in the existing `file` table with a nullable `workspaceId`. ## Problem All file storage is workspace-scoped (`FileEntity.workspaceId NOT NULL`, `{workspaceId}/{app}/…` storage keys). Server-level data like application-registration manifests and tarballs for ownerless catalog registrations has no first-class home, forcing raw-driver bypasses (`DefaultAiCatalogService`, prototype #22556). ## Changes (core storage layer only — no HTTP serving, no GraphQL exposure) **`FileEntity` gains server scope** (mirrors `KeyValuePairEntity`, which already supports both instance-level and per-workspace rows): - `workspaceId` uuid becomes **nullable** — NULL means server-scoped; the entity no longer extends `WorkspaceRelatedEntity` and declares its columns directly - `applicationRegistrationId` nullable FK (`onDelete: CASCADE`) — registration-owned documents follow their registration - ownership checks: `workspaceId IS NOT NULL OR applicationRegistrationId IS NOT NULL` and `workspaceId IS NULL OR applicationRegistrationId IS NULL` — every row has exactly one owner - `IDX_FILE_APPLICATION_REGISTRATION_ID_PATH_UNIQUE` UNIQUE (`applicationRegistrationId`, `path`) — mirrors the workspace unique-constraint pattern; workspace rows are exempt via their NULL `applicationRegistrationId` **New `ServerFileStorageService`** (`file-storage/services/`, exported from the global `FileStorageModule`; `FileStorageService` moved alongside it): - storage keys `server/{fileFolder}/{applicationRegistrationId}/{resourcePath}` — the registration segment is injected by the service itself, so paths cannot collide across registrations; scope-validation util mirroring `validateStoragePathIsWithinWorkspaceOrThrow`; new `ServerFileFolder` enum in twenty-shared - `writeServerFile` (upsert on (`applicationRegistrationId`, `path`) + driver write; throws on failure), `readServerFile`/`readServerFileById` (missing row or bytes surfaces `FILE_NOT_FOUND`), `checkServerFileExists`, `deleteServerFile`/`deleteByServerFileId` (bytes best-effort, row authoritative), `deleteByApplicationRegistrationId` - rows are accessed through a plain repository pinned to `workspaceId: IsNull()` on every query; workspace-file code paths still go through `WorkspaceScopedRepository`, which never sees NULL rows **Null-safety ripples** (workspaceId is now `string | null`): - `WorkspaceScopedEntity` bound widened to `workspaceId: string | null` (the wrapper always filters with a concrete id) - `list-and-delete-orphaned-workspace-entities` now skips `workspaceId IS NULL` rows — previously `NOT EXISTS` would have flagged server rows as orphans and deleted them - `PendingFileCleanupService` sweeps only `workspaceId IS NOT NULL` rows; `application-package-fetcher` pins its tarball lookup to workspace rows (tarball migration to server scope is a follow-up PR) **Migration**: `allow-server-scoped-file` ships as a **2-20 fast instance command** (2.20.0 is current since #22639; re-slotted from 2-19 per review). Command runs are tracked by name, so instances that already executed the 2-20 `standardOverrides` drop command still pick this one up. Its realistic timestamp sorts before that drop command's fabricated `1825000000000`, which the `ci:allow-upgrade-command-timestamp-exception` label covers. ## Next PRs in the plan - PR 2: HTTP serving + token type for server files - PR 3: application-registration manifests stored as versioned server files (rework of draft #22556) - PR 4 (optional): registration tarballs migrate to server scope ## Verification - New spec `server-file-storage.service.spec.ts` (traversal table, upsert conflict semantics, row-before-bytes reads, best-effort byte deletion, registration cascade) + scope-validation util spec; affected suites all green - Typecheck (server + shared), `lint:diff-with-main`, full `oxfmt --check src/` on both packages clean - Fresh `database:reset` on the re-slotted branch: the 2-20 command executes, generator then reports **no schema drift**; both ownership checks and the composite unique verified live (dual-owner insert and duplicate registration+path both rejected) |
||
|
|
435073e9c5 |
Display featured applications in marketplace (#22635)
## After <img width="1060" height="589" alt="image" src="https://github.com/user-attachments/assets/74dfadcf-8698-4404-81c6-b309cc4cbf79" /> <img width="732" alt="image" src="https://github.com/user-attachments/assets/0e1a3644-04bc-4208-aa77-3842d9db9cc8" /> <img width="797" alt="image" src="https://github.com/user-attachments/assets/0456ecce-607a-4705-8a89-c77029bfb6ac" /> - Remove IS_MARKETPLACE_SETTING_TAB_VISIBLE feature flag - add vetted toggle in admin app tab - added people data labs, last contact and call recorder to default vetted applications <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22635?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: prastoin <paul@twenty.com> |
||
|
|
bd8bf89653 |
Revert "feat(messaging): message campaign delivery stats + views" (#22452) (#22627)
Revert "feat(messaging): message campaign delivery stats + views
(#22452)"
This reverts commit
|
||
|
|
5dc9d7ab36 |
fix(server): make all view children reparentable across a workspace migration sync (#22600)
## Summary Uniformizes the workspace migration engine so **every** view child entity — `viewField`, `viewFieldGroup`, `viewGroup`, `viewFilter`, `viewSort`, `viewFilterGroup` — can be reparented from one view to another within a single manifest sync, including when the previous parent view is deleted in the same sync. ### Context When an app manifest deletes a view and reparents its children onto another view in the same sync (e.g. replacing a custom `FIELDS_WIDGET` view with a standard one), the sync failed with a builder validation error `View field to update parent view not found`. Root causes: 1. `viewField`, `viewFieldGroup` and `viewGroup` had `viewId.toCompare: false`, so the diff never detected the parent-view change and never emitted a reparent update (the already-reparentable siblings `viewFilter`/`viewSort`/`viewFilterGroup` had `toCompare: true`). 2. `validateFlatViewFieldGroupUpdate` resolved the *old* parent view (it ignored the update patch), inconsistent with the other view-child validators. 3. Once the builder no longer errors, the runner would fail silently: `view.delete` ran **before** the child reparent updates, and `viewId` is `onDelete: CASCADE`, so the old view's deletion cascade-deleted the children before they could be reparented (silent data loss, since `repository.update` on a missing row is a no-op). ### Changes - **`all-entity-properties-configuration-by-metadata-name.constant.ts`**: set `viewId.toCompare: true` for `viewField`, `viewFieldGroup`, `viewGroup`. Because `viewId` maps to `universalProperty: 'viewUniversalIdentifier'`, the diff compares **only** `viewUniversalIdentifier` (never the raw FK). Snapshot updated accordingly. - **`flat-view-field-group-validator.service.ts`**: merge `flatEntityUpdate` and resolve the **new** parent view, matching the `viewField`/`viewGroup`/`viewSort` validators. - **`compute-ordered-migration-actions.util.ts`**: move `view.delete` to run **after** all view-child create/update actions so a child can be reparented off a view that is being deleted in the same sync. Child `delete → create → update` order is preserved (needed for `viewField`'s partial-unique `(fieldMetadataId, viewId)`). - **New integration test** `successful-manifest-reparent-view-children.integration-spec.ts` covering reparenting of every view child (a) between two persisting views and (b) when the source view is deleted in the same sync. ## Test plan - [x] `nx typecheck twenty-server` - [x] oxlint + oxfmt on changed files - [x] Unit snapshot regenerated: `all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec` - [x] New integration test passes (both scenarios) - [x] Verified the delete-source scenario **fails** on the old action ordering (children cascade-deleted, `Received length: 0`) and **passes** after the reorder — confirming it's a genuine regression guard Made with [Cursor](https://cursor.com) <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22600?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. --> |
||
|
|
2e1117d442 |
feat(messaging): message campaign delivery stats + views (#22452)
<!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22452?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. --> |
||
|
|
6c40c7b91a |
Deterministic system field universal identifier (#22565)
# Introduction Close twentyhq/core-team-issues#2641 Auto-provisioned field metadata used to get its `universalIdentifier` from three unrelated sources: random `v4()` on the server when creating custom objects, hardcoded values in `STANDARD_OBJECTS`, and an ad-hoc `v5` derivation in the SDK manifest build. This PR unifies all of them behind the shared `getFieldUniversalIdentifier` derivation: ``` universalIdentifier = f(applicationUniversalIdentifier, objectUniversalIdentifier, fieldName) ``` ## Ownership model The rollout is built on an explicit split of who owns a field's universal identifier: - **The 8 system fields** (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy`, `position`, `searchVector`) are **server-owned**. Their universal identifiers are always the deterministic derivation, on **every** application (standard, workspace-custom, installed). Clients cannot provide custom values: a temporary check in `validateObjectMetadataSystemFieldsIntegrity` rejects any non-derived system field identifier at migration build time. This check stands in until system fields are generated exclusively server side by the metadata side-effect engine and stripped from client inputs — at which point it becomes structurally impossible to send one. - **`name` is a default field, not a system field**: it is auto-provisioned when absent (server side for custom objects, SDK side for application objects) but authors can define their own. It is only derived where it is guaranteed to be auto-provisioned. In particular, standard objects keep their **historical hardcoded** `name` identifiers: the standard app authors its `name` fields like any installed app would, and moving those identifiers would break every installed application referencing them (e.g. views on `opportunity.name`). - **User-created and author-provided fields** keep random / explicit identifiers, untouched. ## Server - `validateObjectMetadataSystemFieldsIntegrity` now validates, on top of the existing type/`isSystem` checks, that each system field's `universalIdentifier` equals the deterministic derivation. Runs for every object creation going through the migration orchestrator: app sync, custom object creation, standard provisioning - `build-default-flat-field-metadatas-for-custom-object.util.ts` derives the system field identifiers (and the auto-provisioned `name`) with `getFieldUniversalIdentifier` instead of `v4()` - `build-default-relation-flat-field-metadatas-for-custom-object.util.ts` derives both the forward and the reverse default relation field identifiers deterministically - `generateMorphOrRelationFlatFieldMetadataPair` accepts optional `sourceFieldUniversalIdentifier` / `targetFieldUniversalIdentifier` so callers can inject deterministic values; user-created relations still default to `v4()` ## twenty-shared - `STANDARD_OBJECTS` system field identifiers (the 8) are now computed at module load via `buildStandardObjectSystemFields`; `name` and every other identifier keep their hardcoded values - New snapshot test pinning **every** universal identifier of `STANDARD_OBJECTS`: any identifier change now requires an explicit snapshot update and should ship with a coordinated backfill ## SDK (breaking, pre-GA) - `generateDefaultFieldUniversalIdentifier` delegates to `getFieldUniversalIdentifier` and now requires `applicationUniversalIdentifier` - Reverse default relation field identifiers are derived from the field's real coordinates (standard object UID + actual field name, e.g. `targetRocket` on `attachment`) instead of the legacy custom-object UID + synthetic `${fieldName}Inverse` hash input. Field *names* are unchanged - The manifest build threads the application universal identifier through default field injection (two-pass over object configs) - `twenty dev:add` now resolves the application universal identifier upfront and refuses to scaffold anything until `defineApplication` declares one — no more `fill-later` placeholder for the app UID in generated files ## Upgrade A 2.19 **workspace command** backfills existing `fieldMetadata.universalIdentifier` rows to the deterministic derivation. Coverage follows the ownership model: - **The 8 system fields**: taken over for **every application**, whatever value they currently hold. This is both safe and required now that sync rejects non-derived values — leaving a row unconverged would make its application unsyncable - **`name`**: workspace-custom app → always taken over (server-generated, no author to clobber); installed applications → only rows still carrying the legacy SDK derivation are recomputed, author-provided identifiers are never touched; standard app → never touched (hardcoded in `STANDARD_OBJECTS`) - **Default relation fields**: workspace-custom app → forward fields on custom objects and reverse fields on the standard relation objects; installed applications → legacy-derivation probe only All identifiers of a workspace are updated inside a single transaction, then the command flushes the field-metadata-related workspace caches and bumps the metadata version. Stored `applicationRegistration.manifest` snapshots are intentionally **not** rewritten: installs and upgrades always sync from the `manifest.json` inside the resolved package (npm/tarball), the stored column is only used for display/marketplace purposes. ## Breaking behavior for old packages (fail closed) Packages built with an older SDK carry legacy system field identifiers in their tarball `manifest.json`. Installing or upgrading such a package now fails with an explicit `INVALID_SYSTEM_FIELD` validation error ("universal identifier is not deterministic") instead of silently mismatching against the backfilled rows and triggering a destructive delete+create. The remediation is to rebuild the package with the new SDK; the backfill has already converged the installed rows, so the rebuilt manifest syncs cleanly. ## Test plan - [x] `twenty-sdk` unit tests (526 tests) and typecheck - [x] `twenty-shared` unit tests (1635 tests) including the `STANDARD_OBJECTS` snapshot; `name` identifiers verified byte-for-byte identical to `main` - [x] Lint and typecheck clean on all touched packages - [x] Integration: create a custom object and verify system + default relation field identifiers match the deterministic derivation (`create-one-object-metadata-deterministic-field-universal-identifiers`, 13 assertions passing) - [x] Integration: `failing-sync-application-object-system-fields` extended with a non-derived system field identifier case; all identifiers in the spec pinned deterministically so snapshots embedding expected/actual values are stable across runs (verified with a double run) - [x] Integration: all application sync suites pass with the derived system field identifiers now required by the `buildDefaultObjectManifest` test helper (9 suites, 20 tests) - [x] Full test-database reset: standard app provisioning and seeded workspaces pass the new validation - [x] SDK manifest build verified on the postcard example app: all auto-generated default field identifiers match the derivation - [ ] Run `upgrade:2-19:backfill-deterministic-field-universal-identifiers` (dry-run then real) on a seeded workspace and verify identifier convergence with a rebuilt app manifest |
||
|
|
1a60d4eaa3 |
Add MCP setup screen (#22468)
## Summary - Add a first-tab MCP setup experience under MCP & APIs with quick install cards, manual configuration, client logos, and HTTPS gating for Claude install links. - Rename API/Webhooks settings surfaces to MCP & APIs and update related icons, permissions, breadcrumbs, and command menu entries. - Add the Tabler sparkle-2 icon wrapper and MCP setup visual assets. ## Screenshots | Before | After | | --- | --- | |  | <img alt="image" src="https://github.com/user-attachments/assets/a6ae2ae6-322b-4370-b9b6-0a3d73ff7fa7" /> | <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22468?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 <charles@twenty.com> |
||
|
|
43730d7748 |
Centralized side effects devxp basis (#22295)
# Introduction
This PR introduces a centralized, strictly-typed **metadata side-effect
engine** that unifies how system metadata side effects are derived and
applied across both metadata entry points — the **metadata GraphQL API**
and the **application sync / manifest** flow — and migrates the first
side effect end-to-end: **a unique scalar field owns its backing
single-field `UNIQUE` index** (full create / update / delete lifecycle).
## New conventions
- **Engine-owned companions**: metadata flagged `isSystemSideEffect:
true` is owned by the engine. Its deletion is never inferred from
absence in a manifest — it results from PG-level cascade or from a
delete side effect (a side effect always has a cause, its parent
metadata).
- **Reserved deterministic identifiers**: apps cannot declare metadata
reusing an engine-owned deterministic `universalIdentifier`. Doing so
fails validation with `RESERVED_SYSTEM_UNIVERSAL_IDENTIFIER` (until an
explicit override API exists).
- **Record-native operation matrix**: the operation matrix is keyed by
`universalIdentifier` (`AllFlatEntityOperationRecordByMetadataName`)
instead of arrays, making parent resolution and deduplication O(1).
Array-based API callers are transpiled to records at the
validate-build-and-run boundary.
- Twenty-sdk user-facing experience with system fields will only be
related to overrides.
# What this PR does
## 1. Side-effect engine (foundation)
- `MetadataSideEffectEngineService.expandWithSideEffects(...)` takes the
intention-carrying record matrix and returns it expanded with derived
side effects, or a structured failure.
- Handlers are registered via a typed **decorator + registry** pattern
(`MetadataSideEffectHandler({ operation, metadataName, name, description
})`), with runtime duplicate-name detection. Multiple handlers per
(operation, metadataName) are supported.
- Handler contract mirrors the validator pattern:
- receives the trigger flat entity, the live record matrix, and
**strictly-typed related flat entity maps**
(`MetadataFlatEntityAndRelatedFlatEntityMapsForSideEffect<P>`, derived
from declared companion metadata names — no loose
`Partial<AllFlatEntityMaps>` context)
- returns `MetadataSideEffectResult`: `success` (operations record) |
`noop` | `fail` (structured failure)
- **Non-recursion is structural**: triggers are read from the original
caller input, never from the expanded matrix, so a side effect can never
trigger another side effect.
- **Deduplication + collision detection**: side effects are deduped by
`universalIdentifier` per operation; a caller-declared entity colliding
with an engine-owned deterministic identifier is recorded as a
collision.
- **Unified failure channel**: handler failures and reserved-identifier
collisions are merged into the same `OrchestratorFailureReport` contract
as builder validation errors, and the run short-circuits (fail-closed,
nothing is applied).
## 2. First migrated side effect — unique field → backing unique index
Three handlers own the complete lifecycle of the deterministic
single-field `UNIQUE` index backing a unique scalar field:
- **create**: unique scalar field → generate the deterministic backing
index (`fieldUniqueBackingIndexOnCreate`)
- **update**: `isUnique` flips and renames of still-unique fields (the
index name — and therefore its deterministic identifier — derives from
the field name, so a rename drops the stale index and recreates the
deterministic one) (`fieldUniqueBackingIndexOnUpdate`)
- **delete**: cascade-delete the backing index
(`fieldUniqueBackingIndexOnDelete`)
Supporting rules:
- The primary key `id` field never spawns a backing index (uniqueness
comes from the PK constraint) — explicit `isPrimaryKeyFlatFieldMetadata`
guard.
- Parent object resolution is **optimistic-first**: an object created or
updated in the same batch wins over the workspace cache (so e.g.
renaming an object while flipping a field to unique builds the index
from the post-rename object), resolved in O(1) via the record matrix.
- A missing parent object is reported as a structured side-effect
failure, never silently skipped.
## 3. Path convergence — manifest and API share one flow
- The manifest sync now derives a from→to **record matrix** from the
cache and feeds `validateBuildAndRunWorkspaceMigrationFromRecord`, the
same flow the API uses — both paths converge on the engine.
- Manifest-side unique-index generation and API transpiler
system-unique-index handling were removed (declared/composite/relation
indexes stay untouched).
- New `WorkspaceMigrationFlatEntityMapsService` mutualizes
flat-entity-maps computation between the side-effect engine and the
builder: cache keys are derived from the caller metadata names (+
validation- and side-effect-related closures) instead of hardcoded
loads.
- App-scoping and pruning are folded into one shared primitive
(`getSubAllFlatEntityMapsByApplicationIdsOrThrow`): slicing dependency
maps to the involved applications always prunes dangling one-to-many
aggregators — callers can no longer forget it.
- **Behavior change**: an app extending another app's view with a view
field now syncs successfully (cross-app view-field extension), covered
by a dedicated integration test.
## 4. Backfill upgrade command (2.19)
`upgrade:2-19:backfill-system-unique-index-universal-identifier`
rewrites legacy system unique-index `universalIdentifier`s to their
deterministic value so the engine can own pre-existing indexes. The
backfill is **driven from `isUnique: true` fields** (mirroring the
engine ownership predicate — excludes PK / morph / relation fields) and
resolves each field's backing index in O(1).
# Bugs fixed along the way
- `database:reset` seeding failed with
`INDEX_FIELD_INVALID_DEFAULT_VALUE`: the engine derived a backing
`UNIQUE` index for the default `id` primary key. Fixed with the explicit
primary-key guard.
- `isUnique` updates on system-flagged standard fields (e.g.
auto-created `name`) did not trigger the backing-index side effect.
- Manifest sync crashed with "Could not find flat entity with universal
identifier ..." when app-scoped slices left dangling aggregator
references — fixed by centralizing pruning in the shared slice primitive
|
||
|
|
1270054d35 |
feat(ai): dashboard & view building (#22411)
## Why
Building a dashboard through AI chat used to cost ~9 sequential LLM
round-trips
(~160K input tokens for a single request): the agent had to resolve
object/field UUIDs and assemble views through many granular tool calls,
each
step replaying the full cached context.
## What changed
### 1. Reference objects & fields by name (fewer round-trips)
The agent no longer needs to resolve UUIDs before acting.
- `get_object_metadata`: filter by `objectName` (singular/plural) and a
new
`includeFields` flag returning each object's fields (`{id, name, type,
label}`)
inline — object + field IDs in one call.
- `get_field_metadata`: accepts `objectName` as an alternative to
`objectMetadataId`.
- All three dashboard write tools (`create_complete_dashboard`,
`add_dashboard_widget`, `update_dashboard_widget`): accept `objectName`
and
`*FieldName` variants (`aggregateFieldName`,
`primaryAxisGroupByFieldName`,
`secondaryAxisGroupByFieldName`, `groupByFieldName`, ratio `fieldName`),
resolved to UUIDs server-side by `resolveWidgetFieldNamesToIds`. UUID
variants
still win when both are given.
### 2. `upsert_complete_view` — one atomic call to build/reconfigure a
view
- New `upsert_complete_view` tool + `ViewService.upsertCompleteView`:
create or
update a view together with its fields, filters, and sorts.
- Children are **declarative**: a provided array replaces all existing
entries of
that kind, `[]` clears them, omitting leaves them untouched. Fields are
referenced by name or UUID; no child-row IDs needed.
- Runs as a **single workspace migration** (`view` + `viewField` +
`viewFilter` +
`viewSort` in one `validateBuildAndRunWorkspaceMigration` matrice)
instead of
chained per-entity service calls. New
`buildCompleteViewChildrenFlatOperations`
util assembles the child create/delete operations.
- Granular tools (`create_view_filter`, `update_view_sort`, …) are
retained for
surgical single-entry edits.
### 3. Chart filters on dashboard widgets (end-to-end)
- Added `chartFilterSchema` (`recordFilters` + optional
`recordFilterGroups` for
AND/OR logic) to the four chart configs, with field-by-name or -UUID
references
and documented operands/value formats.
- **Relative dates supported** — e.g. `PAST_7_DAY`, `THIS_1_MONTH`,
`NEXT_3_WEEK`,
plus open-ended `IS_IN_PAST` / `IS_IN_FUTURE` / `IS_TODAY`. Filters
route
through the same read pipeline (`computeRecordGqlOperationFilter`) as
view
filters, so they resolve and apply correctly.
- `resolveChartFilterFieldNamesToIds` resolves filter `fieldName` → id
against the
widget object.
### 4. Re-enable AI-assisted dashboards
- Removed the "coming soon" gating (`isActive: false` on the dashboard
skill and
the "not available yet" copy in the MCP server + chat prompts) and
registered
`DashboardToolProvider`.
- Rewrote the dashboard skill prompt: confirmation gate (present a plan,
wait for
confirmation), completion guard (once confirmed, emit the create tool
in-turn —
no "now let me…" preambles), default-and-proceed (pick sensible defaults
for
missing fields instead of stalling), and an intent gate so informational
dashboard questions are answered directly without loading skills.
### 5. Frontend: clearer advanced-filter labels
- `useRecordFilterField` now derives the filter label from field
metadata and
appends the relation target field (e.g. `Company → Name`), so
relation/target
filters — including those set by the AI — display correctly instead of
showing
a stale/blank stored label.
## Fixes
- **`get_object_metadata({ objectName })` crash.**
`ObjectMetadataService.findManyWithinWorkspace`
spread an array-form (`OR`) `where` into a plain object, producing
`{ "0": {...}, "1": {...}, workspaceId }` → `Property "0" was not found
in
"ObjectMetadataEntity"`. Now injects `workspaceId` into each OR clause,
so name
lookups work.
- **Invalid SELECT/MULTI_SELECT filter options silently produced broken
charts/views.**
Chart-configuration validation and the migration-layer
`FlatViewFilterValidator`
now reject filters that reference options that don't exist, with a clear
`Allowed values: …` message at creation time (shared
`getInvalidSelectFilterOptionValues` util + tests).
- **Non-atomic view assembly.** The previous multi-call view build could
leave a
half-built view on failure; `upsert_complete_view` now runs as a single
transaction (one validation pass, one cache recompute, rollback on
error).
- **Blank RECORD_TABLE widgets from UNLISTED views.** Guidance + the
upsert
ownership check steer widget-backing views to `WORKSPACE` visibility; an
UNLISTED view created without an owner renders a blank widget.
- **Extra discovery round-trip removed.** Deleted the skill→tool bundle
mechanism
(`SKILL_TOOL_BUNDLES`, `getBundledToolNamesForSkills`, and the
`load_skills`
schema-loading path) that forced a second `learn_tools` call.
- **Type-safety of widget resolution.** Reworked the widget resolver to
build a
properly typed `WidgetWithMetadataIds` (dedicated input/output types)
instead of
returning an untyped, cast-heavy object.
## Notes
- Backend changes are in `twenty-server`; one small `twenty-front`
change to the
advanced-filter label hook. No entity/schema changes, so no migration.
- Tests added: `getInvalidSelectFilterOptionValues`,
`resolveWidgetFieldNamesToIds`
(incl. filter/relative-date resolution), `update_dashboard_widget`, and
expanded
view-tools factory specs.
- Design decisions: dedicated composite tool over code-interpreter
orchestration
(atomicity + validation + consistency with `create_complete_dashboard` /
`create_complete_workflow`); name-or-UUID but no child-row IDs on
`upsert_complete_view`; name→id resolution kept as stateless utils, not
services.
## Test plan
- [ ] `npx nx run twenty-server:typecheck`
- [ ] `npx nx lint:diff-with-main twenty-server` and `twenty-front`
- [ ] `npx nx test twenty-server` (view tools factory,
`getInvalidSelectFilterOptionValues`,
`resolveWidgetFieldNamesToIds`, `update_dashboard_widget`)
- [ ] AI chat: "Create a dashboard with a chart of deal value by
pipeline stage
and a table of the top 10 open opportunities" → plans, waits for
confirmation, then builds with fewer round-trips
- [ ] AI chat: add a chart widget filtered by a relative date (e.g.
deals created
in `PAST_7_DAY`) and confirm the chart is actually filtered
- [ ] Filter on a non-existent SELECT option is rejected with a clear
error
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22411?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. -->
|
||
|
|
47689e676b |
feat(server): improve traceability of flat-entity map mutation errors (#22396)
## Context
cc @rashad
Twenty applies metadata changes optimistically to in-memory *flat entity
maps* before persisting them. The utils that mutate these maps throw
`FlatEntityMapsException` on invariant violations, which surface in
Sentry (e.g. during `InstallApplication`) as a **hardcoded, generic
message with no identifying data**:
```
GraphQLError: addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists
```
There was no way to know *which* entity collided — making triage
impossible.
## What this does (two layers)
**Layer 1 — leaf utils emit identifiers**
- `FlatEntityMapsException` gains an optional structured `context`
(`universalIdentifier` / `id` / `applicationId` / `metadataName` /
`relatedMetadataName` / `operation`), read by the Sentry driver's
existing `'context' in exception` → `setExtra` channel.
- All **9 leaf throw sites** append their in-scope identifiers to the
message **and** populate `context`.
**Propagation — context survives the re-wraps**
- On the install path the collision throws in the (unwrapped)
`compute()` step, so the raw exception + context reaches app-sync
intact.
- For the run/build-phase paths, the migration runner and
build-orchestrator re-wraps copy only `.message`; they now also
**forward `context`** so structured data survives there too.
**Layer 2 — human installation error**
- `synchronizeFromManifest` catches flat-entity failures, resolves the
offending `universalIdentifier` to a manifest **object/field label**,
and rethrows `ApplicationException(APPLICATION_INSTALLATION_FAILED)`
with a safe, human `userFriendlyMessage`.
- The leaf `userFriendlyMessage` stays `STANDARD_ERROR_MESSAGE` — the
detailed message never leaks to end users.
- `APPLICATION_INSTALLATION_FAILED` surfaces with the dedicated
`ErrorCode.APPLICATION_INSTALLATION_FAILED` GraphQL code (mirroring the
workspace-migration runner formatter), not `INTERNAL_SERVER_ERROR`.
### Result — client-facing GraphQL error envelope
```json
{
"extensions": {
"code": "APPLICATION_INSTALLATION_FAILED",
"subCode": "APPLICATION_INSTALLATION_FAILED",
"userFriendlyMessage": "We couldn't install \"Test Application\". Its Invoice could not be applied to your workspace."
},
"message": "Installing application 'Test Application' failed [object: Invoice]: addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow: flat entity to add already exists (universalIdentifier: ...)",
"name": "GraphQLError"
}
```
## Where the identifier shows up (not just Sentry)
The offending `universalIdentifier` reaches every consumer, not only
Sentry:
- **Sentry (server):** structured `context` extras + the enriched
message (fingerprinted by `code`, so no issue fragmentation).
- **GraphQL response `message`:** un-masked (no `useMaskedErrors`; the
error-handler hook passes `BaseGraphQLError` through as-is), so it
travels over the wire.
- **App-author SDK/CLI terminal:** `twenty-sdk` captures
`errors[0].message`; for this error `formatManifestValidationErrors`
returns `null` (no `extensions.errors`/`summary`), so the orchestrator
falls back to printing the full message, e.g.:
```
✗ Sync failed with error: Installing application 'X' failed [object:
Invoice]: … already exists (universalIdentifier: b1b2c3d4-…)
ℹ Hint: a metadata conflict was detected. Preview the plan with `yarn
twenty dev --once --dry-run`; …
```
The `already exists` / `universalidentifier` substrings also trigger
`getSyncErrorRecoveryHint`, so the author gets an actionable next step.
- **End-user (CRM UI):** only the safe rendered `userFriendlyMessage`
(no UUIDs).
## Design note
`userFriendlyMessage` behaviour of the leaf exceptions is intentionally
unchanged (guardrail). Layer 2 resolves labels for **objects and
fields** (the bulk of metadata); other manifest entity kinds fall back
to an app-name-only human message to avoid brittle manifest-walking —
easy to extend. A future first-class option would be structured
`extensions` (like `METADATA_VALIDATION_FAILED`) + a dedicated SDK
formatter; deferred since the message path already surfaces the detail
in the terminal.
## Tests
- **Unit:** existing through-mutation + runner-exception specs still
pass (they assert on exception **code**, not message). Added a spec for
the enrichment util.
- **Response-format snapshot (verified, green):**
`application-exception-filter.spec.ts` runs the exception filter and
snapshots the exact client-facing GraphQL error envelope shown above.
- **Integration:**
`failing-sync-application-flat-entity-map-conflict.integration-spec.ts`
syncs a manifest whose two objects share a `universalIdentifier`
(collision during manifest map build, before validation) and snapshots
the GraphQL error response via
`expectOneNotInternalServerErrorSnapshot`.
- ⚠️ The integration `.snap` was authored from the identical
deterministic path (verified by the filter unit snapshot) because the
integration suite couldn't be executed in the authoring sandbox. Please
regenerate/confirm with `nx test:integration:with-db-reset` (or `-u`) in
a seeded env.
## Status
Draft — opening for review.
|
||
|
|
7a5896ff5d |
feat(ai) - add delete_workflow tool (#22432)
## Summary - Add a new `delete_workflow` agent tool that soft-deletes a workflow and cleans up its sub-entities (versions, runs, triggers) via `WorkflowCommonWorkspaceService.handleWorkflowSubEntities` - Update the workflow skill system prompt to document the new capability and instruct the agent to always confirm with the user before deleting - Wire `WorkflowCommonModule` / `WorkflowCommonWorkspaceService` into the workflow-tools dependency graph ## Test plan - [x] Unit tests added (`delete-workflow.tool.spec.ts`) covering successful deletion and error handling - [ ] Verify the agent can resolve a workflow by name via `list_workflows` then delete it with `delete_workflow` - [ ] Confirm the agent asks for user confirmation before executing the deletion - [ ] Confirm sub-entities (versions, runs, triggers) are removed after deletion <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22432?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. --> |
||
|
|
5a4ebca226 |
refactor(server): unify the two metadata override mechanisms into one (#22417)
## Unify the two metadata override mechanisms into one Twenty had **two** override mechanisms: - **`standardOverrides`** — a bespoke JSONB column on `objectMetadata`/`fieldMetadata` with typed DTOs and a per-locale `translations` map, resolved by two i18n-aware resolvers. - **`OverridableEntity.overrides`** — a flat, registry-driven JSONB blob on view / view-field / view-field-group / command-menu-item / page-layout-tab / page-layout-widget, resolved by a plain spread. This PR collapses them into **one** concept: a single `overrides` blob, one registry-driven overridable set, one i18n-aware read path, and one write path (`computeMetadataOverridesBlob`, extracted in #22404). Object/field **stay on `SyncableEntity`** (not reparented to `OverridableEntity`) so their `isActive` default stays **FALSE** — this sidesteps the `isActive` default conflict entirely. ### GraphQL breaking change (accepted) The `standardOverrides` field is **removed** with no deprecation alias — `overrides` (a `JSON` scalar) is exposed instead on `Object` and `Field`. Product confirmed negligible external usage; the front-end has no hand-written consumer (only generated types), which are regenerated here. ### Commit structure (reviewable commit-by-commit) 1. **Unified resolver + parity harness** — `resolveEffectiveEntityProperty` is a strict superset of the three legacy resolvers; a corpus parity spec compares it against a *frozen reference* of the old logic across every locale, `isStandardApp` branch and override shape. 2. **Registry-driven** — object/field presentation props tagged `isOverridable` + `translatable`; the overridable/translatable sets are derived from the registry (a test asserts they equal the legacy hardcoded lists). 3. **Rename + swap + delete** — `standardOverrides` → `overrides` across entities, DTOs, flat/universal types, producers, the ~12 resolve/write/create/sync call sites, mocks and specs; the reconciler's two compare entries collapse to one; the three legacy resolvers, both DTOs and the hardcoded constants/types are deleted. 4. **Migration (zero-downtime, two-phase)** — split across two releases so a rolling deploy never drops a column a previous-release pod still `SELECT`s: - **2.19 fast** — add the `overrides` column (schema only). - **2.19 slow** — backfill `overrides` from `standardOverrides` in `runDataMigration` (kept out of the schema transaction so the bulk write doesn't hold the ACCESS EXCLUSIVE lock; skipped on fresh installs, which have no data to copy). - **2.20 fast** — drop the legacy `standardOverrides` column (gated by `TWENTY_NEXT_VERSIONS`, so it stays dormant until the instance reaches 2.20). 5. **Front/client-SDK regen** — regenerated metadata GraphQL types. 6. **Integration specs + i18n** — updated the standard object/field update integration specs + snapshots, and the reworded validator message catalog entry. ### Rolling-deploy safety `standardOverrides` is retained through 2.19 and only dropped in 2.20, mirroring the codebase's deferred-drop convention (`isUIReadOnly`/`isCustom`). During the 2.19 rollout both columns exist, so old and new pods coexist without "column does not exist" errors. The backfill lives in a slow `runDataMigration` (per the `no-data-mutation-in-fast-instance-command` rule) so it doesn't stall reads. ### `isActive` guard The migration never reads or writes `isActive`; the backfill asserts the active-row count is unchanged and aborts otherwise. Verified on a real DB: apply + revert preserves the blob **and** the nested `translations` map, with `isActive` counts identical before/after. ### Verification (local) - `nx typecheck twenty-server` + `nx typecheck twenty-front` — green - `nx lint:diff-with-main twenty-server` (oxlint `--type-aware` + oxfmt) — green - `nx test twenty-server` — green (unit + parity + registry + migration tests) - `nx run twenty-server:test:integration:with-db-reset` — green - `database:reset` applies the 2.19 phases and leaves **both** columns present (2.20 drop stays dormant); backfill + revert round-trip verified on a real DB - Metadata integration suites (standard object/field update, application sync) pass end-to-end against the two-column schema - Metadata GraphQL types regenerated against a booted server; zero `standardOverrides` references remain in application code (only the migration commands + the legacy schema baseline) --------- Co-authored-by: prastoin <paul@twenty.com> |
||
|
|
b8a3399230 |
fix(billing): link billing emails to the workspace subdomain (#22401)
## Problem Billing and workspace-suspension emails hardcoded a `BILLING_SETTINGS_URL` constant pointing at `https://app.twenty.com/settings/billing`. A user in `myworkspace.twenty.com` therefore received a CTA that bounced through the central `app` domain instead of landing on their own workspace. Those cross-subdomain redirects are unreliable, so it's better to link straight to the workspace. The invite, password-reset and email-verification emails already do this correctly by building a workspace-specific URL server-side with `WorkspaceDomainsService.buildWorkspaceURL(...)`; the billing/suspension senders had the `workspace` entity in scope but never used it. ## Fix Build the billing settings URL server-side and pass it into the templates as a `link` prop, mirroring the existing pattern: - **Templates** now take a `link` prop instead of the hardcoded constant: `billing-trial-ending`, `billing-trial-converting`, `billing-subscription-renewing`, `warn-suspended-workspace`. - **`BillingReminderService`** and **`CleanerWorkspaceService`** build `buildWorkspaceURL({ workspace, pathname: getSettingsPath(SettingsPath.Billing) })` and thread it through. - Wired `WorkspaceDomainsModule` into both NestJS modules; deleted the now-unused `billing-settings-url.constant.ts`; updated the reminder unit test. This also fixes **self-hosted** deployments, which previously got the same wrong hardcoded `app.twenty.com` link. ### Intentionally unchanged - `clean-suspended-workspace` keeps its central-domain "start a new workspace" CTA — that workspace is already deleted, so its subdomain no longer resolves. - `password-update-notify` (not a billing email) still uses `getBaseUrl()`; the workspace entity isn't readily loaded there. Can be a follow-up. ## Testing Extended `billing-reminder.service.spec.ts` to assert the workspace-specific `link` is threaded into the email. Note: local `typecheck`/tests could not be run because the sandbox proxy repeatedly dropped `yarn install` mid-fetch; the diff was reviewed line-by-line and import paths verified against the actual `twenty-shared` exports and module wiring. CI will provide the authoritative check. https://claude.ai/code/session_01QsgNd4SWdcRkPFyrnCgj2b --- _Generated by [Claude Code](https://claude.ai/code/session_01QsgNd4SWdcRkPFyrnCgj2b)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22401?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. --> |
||
|
|
2b1417770e |
SearchVector derivation via migration-scoped index (alt to #2622 __warmedUpCache) (#22389)
## What this is close https://github.com/twentyhq/core-team-issues/issues/2622 A **POC / discussion branch** implementing the runner-scoped alternative to the `__warmedUpCache` design in [core-team-issues#2622](https://github.com/twentyhq/core-team-issues/issues/2622). Not for merge as-is — meant to diff against that plan. ## Problem `deriveSearchVectorAsExpressionForTsVectorField` scans the entire `flatSearchFieldMetadataMaps` (`Object.values(...).filter(...)`) once per object created in a migration. On install that's `O(objectsCreated × totalSearchFields)` — the quadratic #2622 targets. Only **one** of the three call sites is actually hot: - `create-object` (runner) — global maps, called per created object → the quadratic - export DDL — maps already built **per object** (O(k)) - `update-field` rebuild — one field, gated on `rebuildSearchVector` ## Approach Instead of a private `__warmedUpCache` side-channel on `FlatEntityMaps<T>` + drain-on-hydration, this keeps the index in the **consumer**: 1. `derive` now takes `targetSearchFieldMetadatas` (already scoped to the tsVector field) instead of scanning the map itself. 2. The runner builds a `Map<tsVectorFieldMetadataId, searchFields[]>` **once per migration**, lazily, and threads it through the action context. Safe because `searchFieldMetadata` creates are ordered before `objectMetadata` creates (`computeOrderedMigrationActions`), so the map is complete on first use. → `O(totalSearchFields)`. 3. `getTargetSearchFieldMetadatasForTsVectorField` (O(total) filter) stays as the fallback for the one-off callers (export, field-update) and when the accessor isn't provided. ## Why this over `__warmedUpCache` - **No `FlatEntityMaps<T>` type widening**, no convention-only privacy, no id/universalIdentifier drain to keep in sync. - **No referential-integrity obligation.** The index only ever contains entities present in the map, so the "search field created-then-deleted before its object hydrates" case (deferred as an edge in #2622) can't put a stale id into an aggregator and crash `derive` via the `-orThrow` lookup. - **One `derive` path**, not "aggregator + direct-filter fallback for export". - Blast radius: ~220 lines, mostly a new util + test. ## Benchmark (micro, isolated function) Median of 7 trials, 10 search fields per object, running the real shipped utils — old = `getTargetSearchFieldMetadatasForTsVectorField` once per object (identical to the old inline scan), new = `buildSearchFieldMetadatasByTsVectorFieldId` once + N lookups (both assert they resolve the same fields): | objects | total search fields | old (scan/obj) | new (index once) | speedup | |--------:|--------------------:|---------------:|-----------------:|--------:| | 50 | 500 | 2.08 ms | 0.06 ms | 33× | | 100 | 1,000 | 9.26 ms | 0.12 ms | 77× | | 200 | 2,000 | 36.2 ms | 0.23 ms | 160× | | 400 | 4,000 | 151 ms | 0.40 ms | 379× | | 800 | 8,000 | 701 ms | 0.92 ms | 766× | Confirms the old path is quadratic (~4× per doubling of object count) and the new path is linear (sub-ms throughout). **Caveats — read these before trusting the speedup:** - This is the **isolated derivation function**, no DB / DDL / inserts. In a real `create-object` action the derive is a small fraction of per-action cost, so the end-to-end win is far smaller than the ratios above. - A default workspace has ~20–30 objects, where the **old** code already costs only ~1–2 ms total across the whole install. The quadratic only becomes material (>50 ms, the runner's slow-action threshold) around **200–400 objects**. - The measurement that should actually gate this — `[install-perf] create:objectMetadata` on a real install against a real DB with a few hundred objects — has **not** been run yet. The micro-benchmark bounds the upside and locates the knee of the curve; it does not prove end-to-end payoff. ## Not done on purpose - **No end-to-end benchmark yet** — step 0 should still be measuring `[install-perf] create:objectMetadata` on a real large install to confirm the quadratic is worth removing at all. - Relies on the ordering invariant (commented at the build site). The fully self-contained variant is to put the object's search fields on `FlatCreateObjectAction` (builder change) — deliberately left out to keep this runner-scoped. ## Checks `nx typecheck twenty-server`, `nx lint:diff-with-main twenty-server`, new util spec + existing `generate-workspace-schema-ddl` spec all green. |
||
|
|
fab0358df5 |
Handle field isNullable update (#22362)
## Context Setting isNullable on a field via the app SDK manifest was silently ignored when re-syncing an existing field. The first sync that creates a field honored isNullable correctly, but any later manifest change to isNullable had no effect, neither on the field metadata nor on the underlying Postgres column. Two compounding gaps caused this: The diff never detected the change. isNullable was configured with toCompare: false, so compareTwoFlatEntity excluded it from the diff and no update action was ever generated. There was no DDL to apply it. Even if detected, the update field action handler only altered name, options, defaultValue, and settings. The column manager had no way to alter a column's NOT NULL constraint. ## Fix - Set isNullable.toCompare: true so manifest changes are detected and persisted to the field metadata (via the existing executeForMetadata path). - Add WorkspaceSchemaColumnManagerService.alterColumnNullable(): emits SET NOT NULL / DROP NOT NULL, with an optional pre-serialized backfill (UPDATE … WHERE col IS NULL) applied only on the nullable → non-nullable transition. - Add handleFieldNullableUpdate() to the update field action handler, dispatched after the defaultValue block so the default is in place before NOT NULL is enforced. It is composite-aware (mirrors the per-sub-column parentIsNullable || !property.isRequired rule used at column creation) and skips relation/morph join columns and TS_VECTOR, which are always nullable by design. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22362?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. --> |