d1087d5fc7acf101f08641cca7e9102cbca2bf3f
5407 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
e14c32015f |
Make upgrade applications batch size a job parameter defaulting to 5 (#23101)
Makes the batch size used when upgrading applications a parameter instead of a hardcoded constant, defaulting to 5, and lets admins set it from the upgrade confirmation modal. Backend: - `UpgradeApplicationsJobData` gains an optional `batchSize` field, passed through by `UpgradeApplicationsJob` to the service. - `ApplicationUpgradeService.upgradeAllApplications` accepts an optional `batchSize` parameter, defaulting to `UPGRADE_APPLICATIONS_DEFAULT_BATCH_SIZE = 5` (previously a fixed batch size of 20). The value is sanitized to a positive integer to avoid an infinite batching loop. - The `upgradeRegistrationApplications` admin mutation accepts an optional `batchSize: Int` argument and forwards it to the job. Frontend (admin panel): - The "Upgrade existing installations" confirmation modal now includes a "Batch size" number input, defaulting to 5, sent with the mutation. - Updated the admin GraphQL document and generated types. ## Screenshots Upgrade section on the admin app registration page:  Confirmation modal with the new batch size input (defaults to 5):  --------- Co-authored-by: Martin <martin@twenty.com> |
||
|
|
024b379e1a |
Instrument Node runtime and workspace cache metrics (#23107)
## Context High API tail latency can come from either downstream work or the Node.js process itself being unable to schedule work. Existing traces expose database and HTTP spans, but they do not provide a continuous event-loop signal or identify time spent rebuilding individual workspace metadata cache entries. ## What changed - Enable Sentry's built-in Node runtime integration with a 30-second collection interval. - Collect only event-loop delay p99, event-loop delay max, and event-loop utilization. CPU, memory, p50, and uptime metrics remain disabled because existing infrastructure telemetry already covers those areas. - Add a parent span around workspace metadata cache invalidation and recomputation. - Add child spans around cache-provider computation, including the cache key, recomputation strategy, and whether the provider uses local data only. ## Telemetry scope - Cache hits do not create spans. - Cache spans use `onlyIfParent`, so they are recorded only inside an already-sampled trace. - Runtime metrics are three low-cardinality values every 30 seconds per server process. - This does not add Prometheus histograms, per-cache-key metric labels, database pool gauges, or a custom runtime collector. - No cache behavior or invalidation semantics change. This should let us distinguish event-loop stalls from downstream latency, then identify which cache provider contributes to a slow cache rebuild without materially increasing telemetry volume. |
||
|
|
64891c561d |
i18n - translations (#23108)
Created by Github action <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23108?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
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 |
||
|
|
2ef7b7824e |
Upgrade call-recorder, people-data-labs, last-contact and partners apps to twenty-sdk 2.23.0-alpha.1 (#23098)
## What Upgrades the two breaking-change-prone apps to `twenty-sdk` / `twenty-client-sdk` `2.23.0-alpha.1`, and adds the server-side hook that lets the 2.23 upgrade install them: - **people-data-labs** - **partners** Follows up on #22882 (System side effect relations), which re-derived the system relation field universal identifiers name-free and shipped `getSystemRelationFieldUniversalIdentifier` in the SDK. ## How - **people-data-labs**: bump the SDK to `2.23.0-alpha.1`. The enriched views temporarily hardcoded the new system relation identifiers with a TODO because the SDK still embedded the old values; now that the name-free identifiers ship in `2.23`, derive them from `STANDARD_OBJECT_UNIVERSAL_IDENTIFIERS.{company,person}.fields.{noteTargets,taskTargets,attachments,timelineActivities}.universalIdentifier` (identical to the previously pinned values, verified). Engine already pinned `twenty >=2.23.0`; app stays `1.0.7` (manifest unchanged). - **partners**: bump the SDK to `2.23.0-alpha.1`. The partner role references `opportunity.fields.{taskTargets,noteTargets,attachments,timelineActivities}` universal identifiers, which the SDK now resolves to the `2.23` name-free values. Pin `engines.twenty >=2.23.0` and bump the app to `1.3.1`. - **server**: add an opt-in `skipWorkspaceCompatibilityCheck` to the install/upgrade path. The `upgrade-people-data-labs-application` 2.23 command runs mid-upgrade, before the workspace is marked as having completed 2.23, so the workspace-compatibility check would otherwise reject installing `1.0.7` (`engines >=2.23.0`). The server is already on 2.23, so the command passes the flag to install `1.0.7` and close the desync window. Version-progression (downgrade/same-version) checks still run. - **call-recorder** and **last-contact** are intentionally left unchanged (reverted): they don't define custom objects and don't reference the system relation identifiers, so they aren't breaking-change-prone and need no SDK bump. ## Breaking change constraints - **people-data-labs** and **partners** reference system relation identifiers that only exist on a `2.23` server, so both pin `engines.twenty >=2.23.0`. Their `dockerhub-latest` integration leg is red by design until a >=2.23 server image is published (same accepted state as #22882); the `local` leg is green. ## Validation - Regenerated the app lockfiles against the published `2.23.0-alpha.1`. - `people-data-labs` typechecks cleanly against the real `2.23` SDK types. - CI: people-data-labs and partners green on `local`, red on `dockerhub-latest` by design; server/SDK/all other checks green. - Rebased onto latest `main`. |
||
|
|
bc3112a999 |
Fix: allow API key creation without Roles permission (#23102)
## Problem A user with the **API keys & webhooks** permission but **without** the **Roles** setting permission cannot create an API key through the UI. The role selector relies on the `getRoles` query, which is guarded by the `ROLES` permission, so the roles list comes back empty, `SettingsDevelopersRoleSelector` early-returns, and no role can be selected — leaving the form unsavable. <img width="1058" height="408" alt="Screenshot 2026-07-21 at 13 38 34" src="https://github.com/user-attachments/assets/fe97ba78-e116-458d-af10-11c5969c4636" /> ## Fix Expose the assignable roles through the API-key permission scope so users can **pick** a role to assign to an API key without being able to **edit** roles. - **Backend**: add `getApiKeyRoles` query on `ApiKeyResolver` (already guarded by `API_KEYS_AND_WEBHOOKS`), backed by `ApiKeyRoleService.getApiKeyAssignableRoles` which returns roles where `canBeAssignedToApiKeys = true`. - **Frontend**: add a `GetApiKeyRoles` query and use it in the API key create and detail pages instead of `getRoles`. The role selector prop type is narrowed to the fields it actually uses. <img width="1025" height="455" alt="Screenshot 2026-07-21 at 13 45 01" src="https://github.com/user-attachments/assets/f1be8f97-5a30-4afc-9eee-c928f4607471" /> |
||
|
|
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. --> |
||
|
|
26227eff31 |
Add metadata cache tracing and avoid duplicate cache lookups (#23080)
## Summary - Add Sentry tracing around metadata GraphQL cache reads with operation tags and cache-phase attributes. - Record cache hits on the request path so the response hook skips a duplicate lookup after an early return. - Cover the cache-hit, request-miss/response-hit, and allowlist filtering cases with unit tests. Before, even a cache hit performed two Redis reads: ``` onRequest → GET → hit → return cached response onResponse → GET → hit → do nothing ``` GraphQL Yoga still calls onResponse for an early cached response, so that second lookup was redundant in most cases. Now ``` onRequest → GET → hit → mark request in WeakSet → return cached response onResponse → request marked → remove marker → return immediately ``` onResponse cache mechanism is also there to prevent race conditions like: ``` Did another request populate this key while I was executing? yes → keep it no → cache my response ``` |
||
|
|
c32bc5e8ac |
i18n - translations (#23082)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
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. --> |
||
|
|
8a35f78d70 |
i18n - translations (#23076)
Created by Github action --------- Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
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> |
||
|
|
240e185323 |
Show connect emails step to invited users without granting credits (#23058)
Invited users never saw the connect-emails onboarding step, so they could not connect their inbox while onboarding. Now they do, but only the first user (the workspace creator) earns the import-contacts reward for it. The credit is gated on the workspace having a single member, reusing the same "first user" signal the frontend already uses to gate the invite-team step. The connect-account step is still claimed for everyone so invited users' onboarding advances normally. The frontend hides the "free credits" tag and the header counter bump for invited users, so we do not promise credits they will not receive. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23058?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. --> |
||
|
|
baa84bb2e0 |
Add auto-upgrade-in-apps (#23001)
We need to auto upgrade application, lets add this column in application entity, and add an admin button to autoupgrade all applications to latest app registrration version manually <img width="1131" height="372" alt="image" src="https://github.com/user-attachments/assets/4e755abc-38ad-4895-a2e8-d55ee1948ac2" /> <img width="906" height="533" alt="image" src="https://github.com/user-attachments/assets/ce002057-0341-4581-bf9a-66ac2bd84a9b" /> |
||
|
|
d4ac6e752b |
fix(server): stop cross-pod recompute cascade on localDataOnly workspace cache keys (#22980)
## Context Prod investigation (Sentry, last 7 days) traced the current slowness to the per-pod workspace cache. Every recompute of a `localDataOnly` key (`ORMEntityMetadatas`, `flatWorkspaceMemberMaps`) published a fresh `crypto.randomUUID()` as the shared Redis validation hash. Because these keys recover from a hash mismatch by recomputing (their data never enters Redis), one miss on one pod invalidated the local copy on every other pod; each of their recomputes minted yet another hash, re-invalidating everyone else. The fleet never converges. Measured impact in prod: - The `ORMEntityMetadatas` rebuild (full `objectMetadata` + `fieldMetadata` + `application` queries, ~220ms combined, plus `EntityMetadataBuilder.build`) ran **~963k times in 24h** (~11/s), roughly 58h of cumulative Postgres time per day. - The hottest single workspace recomputed its schema metadata 51k times/day (once per 1.7s). - Second-order effects: `POST /metadata` averaged 26.6s (p95 2.3s, so a tail hangs for minutes on pool/event-loop starvation), GraphQL p95 went 846ms (v2.20.0) to 1744ms (v2.21.0), `Query read timeout` on trivial cron queries at 18x baseline. The random hash was correct in the original design (#15962): it is a generation token, and Redis-backed keys recover absorptively by adopting hash+data from Redis. #16287 added `localOnly` keys (EntityMetadata[] is not serializable) whose recovery is generative, which silently broke the invariant later documented in #18649 ("hashes change only on invalidateAndRecompute"). ## What this does - Recovery recomputes now **adopt** the hash already present in Redis instead of minting a new one, and write nothing back. A miss costs one recompute on one pod instead of an unbounded fleet-wide loop. - Minting is reserved for `invalidateAndRecompute` (real metadata changes, propagation semantics unchanged, including the frontend collectionHashes contract) and the bootstrap case where Redis has no hash. - The bootstrap write uses **SET NX** (new `CacheStorageService.setIfAbsent`) instead of a plain overwrite: a slow bootstrap recompute could otherwise land after a concurrent `invalidateAndRecompute` mint and clobber it with a hash of pre-migration data. Under the old code that clobber self-healed via the cascade; with adopt semantics it would pin stale data, so the bootstrap write must lose that race. A losing pod keeps its result locally as provisional and converges on the winning hash at its next revalidation (covered by a dedicated race test). Redis-backed keys are untouched: same fetch-on-mismatch recovery, same mint-and-write on `missingInRedis`. ## Expected effect and how to verify `FieldMetadataEntity`/`ObjectMetadataEntity`/`ApplicationEntity` full-workspace query counts in Sentry should collapse from ~1M/day to the true metadata-change rate, and with them the DB pool pressure behind the `/metadata` latency tail. This also makes local-cache eviction (`MAX_LOCAL_CACHE_ENTRIES`, #22946) cheap: the cap can be tuned purely for RAM. Complementary to, not competing with, the planned Redis pub/sub invalidation: a version token in Redis is still needed for restart catch-up, and this PR gives it sound semantics. --- _Generated by [Claude Code](https://claude.ai/code/session_01T3JUHwXJHPmZDZTrv6YTDi)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22980?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. --> |
||
|
|
6b55a6b51c |
Fix duplicate searchFieldMetadata inserts in the 2.16 backfill upgrade command (#23060)
## Context
A self-hosted instance upgrading from 2.0.3 to v2.22.0 got stuck with
one workspace failing at `2.16.0_BackfillSearchFieldMetadataCommand`:
```
[QueryFailedError] duplicate key value violates unique constraint "IDX_SEARCH_FIELD_METADATA_OBJECT_FIELD_UNIQUE"
Detail: Key ("objectMetadataId", "fieldMetadataId")=(...) already exists.
```
The failure happened on a retry after a previous partial run, and
reproduced even though the command already recomputes
`flatSearchFieldMetadataMaps` before deriving the create-set (#22884).
## Root cause
The idempotency dedupe compares `(objectMetadataId, fieldMetadataId)`
pairs across two differently-fresh caches:
- The **existing rows** side comes from `flatSearchFieldMetadataMaps`,
which is recomputed from the database (real current ids).
- The **candidate** side resolves ids through `flatObjectMetadataMaps` /
`flatFieldMetadataMaps`, which are **not** invalidated. During a
cross-version upgrade these can be stale, since the migration runner
only invalidates the cache keys a migration touched.
When a stale map resolves a candidate to an outdated id, the dedupe key
doesn't match the existing row and the row is re-emitted. The migration
runner then re-resolves the universal identifiers against fresh maps at
execution time and inserts with the real current ids — exactly the pair
already committed by the earlier partial run (each per-application
migration commits independently) — tripping the unique constraint and
failing the upgrade.
## Fix
Two independent layers, either of which would have prevented the
failure:
1. **Consistent snapshot for the build phase**: the command now
invalidates and recomputes all three maps the dedupe depends on
(`flatObjectMetadataMaps`, `flatFieldMetadataMaps`,
`flatSearchFieldMetadataMaps`), so candidate resolution, existing-row
keys, and the runner all see the same database state.
2. **Id-churn-proof dedupe**: every row this command creates carries a
deterministic universal identifier (`getSearchFieldUniversalIdentifier`,
derived from application + field universal identifiers, no database ids
involved) and `(workspaceId, universalIdentifier)` is unique. The build
util now also skips any candidate whose deterministic universal
identifier already exists, catching leftovers from a previous partial
run even if objects/fields were recreated under new ids in between.
Deliberately **not** done: `ON CONFLICT DO NOTHING` in the create action
handler — it is shared by all runtime `searchFieldMetadata` creation,
and swallowing a conflict would leave the flat-entity cache holding an
entity id that differs from the row actually in the database.
## Test
Added a regression test reproducing the failure shape: an existing row
with the same deterministic universal identifier but stale metadata ids
must not be re-emitted by the backfill.
Note: `ReconcileSearchFieldMetadataCommand` (2.20) has the same
stale-cache exposure; hardening it is left to a follow-up.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23060?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. -->
|
||
|
|
98c71d7b3d |
fix(server): recover workflow runs whose queue job was lost - monitoring only (#22995)
## Context A workflow run can get permanently stuck in RUNNING when the queue job executing a step dies without failing (worker crash/restart, lost BullMQ job). The step stays `RUNNING` in the persisted state, nothing ever recomputes the run status, and the run never terminates. If a user clicks Stop, it wedges in STOPPING instead (the stuck-STOPPING sweeper from #22900 then catches it after 1h, but only because of the manual stop). Self-hosters also have no way to tell that a job was lost, or when. ## What this PR does ### Detect runs stuck in RUNNING (monitoring only, no finalization yet) New `handleStuckRunningRunsForWorkspace` in the staled-runs sweeper (same cron/job/CLI wiring as the stuck-STOPPING recovery): - Targets RUNNING runs with `updatedAt` older than 1h (`updatedAt` refreshes on every step-info write, so staleness means zero progress). - Skips any run that still has a job in the queue: new `getInFlightJobs` on the message queue driver (active/waiting/waiting-children/paused/prioritized/delayed), matched by run-id-prefixed job id with a `job.data.workflowRunId` fallback for jobs enqueued before this deploys. - A truly orphaned run (orphaned RUNNING step, lost between two steps, failed branch, or finished-but-never-finalized) is **flagged, not finalized**: warn log + `WorkflowRunStuckRunningDetected` metric + entry in a per-workspace cache. - On every subsequent sweep, flagged runs are re-checked. One that ended or got a new queue job on its own is recorded as `WorkflowRunStuckRunningFalsePositive` (warn log with the status it reached) and unflagged. The cron keeps sweeping a workspace as long as it has flagged runs. This validates the detection before it is allowed to act: if flagged runs never resolve on their own (no false positives) while `Detected` counts real incidents, a follow-up PR can turn the flag into an actual finalization (fail with a clear "job lost" error so Retry works). Runs waiting on PENDING steps (delay, form) are never flagged. ### Make queue jobs traceable to their run All RunWorkflowJob dispatches now set the job id prefix to the workflow run id, so BullMQ job ids become `<workflowRunId>-<uuid>`. Worker logs (`Processing job <id>` / `processed`) and Redis job keys are now greppable by run id. A new opt-in `allowDuplicatedPrefixes` queue option bypasses the one-waiting-job-per-id dedup (which would otherwise drop parallel-branch continuations); existing `id` users keep dedup by default. ### Observability - `stalled` worker event listener: warn log + new `JobStalled` metric — emitted when BullMQ detects a job whose worker stopped renewing its lock (i.e. died mid-job). - `WorkflowRunStuckRunningDetected` / `WorkflowRunStuckRunningFalsePositive` metrics as described above. ## Out of scope (follow-ups) - Actually finalizing flagged runs once monitoring shows no false positives. - Recovering lost *delayed* resume jobs (PENDING delay step whose scheduled job vanished). - Persisting job ids on the run entity — unnecessary given derived ids. ## Testing - 11 unit tests for the monitoring sweeper (flagging, id-prefix + data fallback in-flight guards, pending skip, failed-branch precedence, false-positive tracking, still-stuck retention, error isolation, never-finalizes) plus find-options specs; 613 tests pass across workflow and message-queue modules. - Not covered: end-to-end kill-the-worker scenario against a real queue. |
||
|
|
87729a2822 |
feat(workflow): backfill + dual-write for core workflow entity (#22776)
Workflow-side soft-ref sync — the `coreWorkflowId` mirror of the merged version side (#22821 / #22940 / #22944 / #22961). Rebased onto current main; supersedes the original shared-UUID version of this PR. ## What Gives `core.workflow` a per-workspace copy of each workflow (`name`, `lastPublishedVersionId`), soft-reffed from the workspace record via `coreWorkflowId`, so app-shipped workflows have a core home. Does not touch reads/dispatch (that's Phase B). - **Sync service** (`WorkflowCoreSyncService`): core rows get their own id (`uuidv5(workspaceId:recordId)`), the workspace record links via `coreWorkflowId` (written back after the upsert), and the write-back is **guarded** on the `coreWorkflowId` field being present (skips with a warning otherwise — mirrors #22940). Injected repo renamed `coreWorkflowRepository`. - **Dual-write listener** on the `workflow` object: CREATED/UPDATED/RESTORED upsert, DELETED/DESTROYED delete by `coreWorkflowId`. Always-on; failures routed to Sentry so they never break the user write. - **2-20 backfill** (`backfill-workflow-to-core`): reads via the provided `RunOnWorkspaceArgs.dataSource`, upserts each workspace workflow into core. - **2-22 provisioning** (mirrors #22944/#22961): - `add-workflow-core-soft-ref-field`: adds the `coreWorkflowId` system field on existing workspaces (flat-entity legacy migration). - `backfill-workflow-core-links`: full rebuild — per workspace, in one raw-SQL transaction, wipes all `core.workflow` rows, inserts a fresh own-id row per workflow, and re-links every record. (No trigger-map cache to invalidate on `core.workflow`.) Simpler than the version side: `core.workflow` has no one-active-per-workflow index and no trigger-map cache, and it was never backfilled in prod, so there are no legacy shared-id rows. ## Test Fresh `database:reset` + full sequence (2-20 backfill → 2-22 add-field → 2-22 rebuild link): 4/4 workspace records linked via `coreWorkflowId` (id != coreWorkflowId), links resolve, **0 dangling**, no duplicate core rows, names populated. Typecheck + lint + oxfmt clean. |
||
|
|
c04714b9e0 |
fix(messaging): register and harden webhook subscription renewal cron (#23006)
The renewal cron was never wired into cron:register:all, so Gmail/Calendar/Graph watches were never renewed and went dark ~7 days after connect (the max watch lifetime all three providers allow). Register it, fan renewals out as per-channel queue jobs scoped to active workspaces, retry FAILED channels, and recreate Google Calendar watches before stopping the old one. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23006?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. --> |
||
|
|
70e4d8d36e |
Skip install-apps onboarding step for invited users (#22823)
## What The onboarding "install apps" step was proposed to every new user, including those joining an existing workspace through an invitation link. Now it is only proposed to the user who creates the workspace. ## Why App installation only makes sense for the workspace creator. Invited members should go straight to profile creation. ## How `activateOnboardingForUser` set the `ONBOARDING_INSTALL_APPS_PENDING` flag unconditionally, and that flag is the sole driver of the `APPS_INSTALLATION` status (the frontend just follows the backend status). Gated the setter behind a new `shouldShowInstallAppsStep` flag, mirroring the existing `shouldShowConnectAccountStep` flag: creator path passes `true`, join path (personal invitation, public invite link, SSO into an existing workspace) passes `false`. Backend-only change, no migration needed. Covered by unit tests for both branches. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22823?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. --> |
||
|
|
6e1e98f4ab |
fix(server): shard the server-test unit job to stop the intermittent crash (#23009)
## Problem `server-test` fails intermittently: exit 1 with **no `FAIL` line and no `Test Suites:/Tests:` summary** — the jest run is aborted mid-way, before the reporter's `onRunComplete`. ## Root cause The `test` target runs the entire unit suite (~6,600 tests) in **one in-band jest process on a single runner VM** (`nx.json` sets `maxWorkers: 1` for the `ci` configuration). A few minutes in, that process is killed by an **external `SIGKILL`** — confirmed *not* OOM (~15 GB free at kill time, no cgroup `oom_kill`) and *not* an in-process crash (a Node diagnostic report armed with `--report-on-fatalerror` + `--report-uncaught-exception` writes nothing). The whole-run kill is why it fails intermittently with no summary. `maxWorkers=2` on one VM still dies, so the threshold is **per-VM**, not per-process. ## Fix Shard the unit suite across VMs, the same way `server-integration-test` already does: - A `twenty-server` `test:ci` target runs jest directly (so `--shard` forwards) with `dependsOn: ["^build"]` so the workspace deps are built. - `server-test` becomes a 4-way matrix; each shard runs a quarter of the suite, well under the kill threshold. - `ci-server-status-check` already aggregates `server-test`, so required checks are unchanged. Also provides two mocks a completed run needs but the SIGKILL had been masking in `ApplicationRegistrationService.upsertFromCatalog` unit tests: the `MetricsService` provider and `applicationRegistrationRepository.createQueryBuilder`. |
||
|
|
cdb7e56720 |
chore: sync AI model catalog from models.dev (#23015)
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev). This PR updates pricing, context windows, and model availability based on the latest data. New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically. Deprecated models are detected based on cost-efficiency within the same model family. **Please review before merging** — verify no critical models were incorrectly deprecated. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/23015?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com> |
||
|
|
5bedd5b8cc |
feat(email-group): communications UX, per-record DNS status (#23002)
- Rename Communications label to singular, remove docs-home banner - Provision unsubscribe Cloudflare records at domain creation and surface per-record status badges; skip Cloudflare when not configured - Move sending-domain status into the section header and only show the records table when a record is unverified - Reply to the original recipients when replying to your own message - Fix DNS records table column/badge alignment; emit synthetic records in the log driver for local testing <img width="1496" height="849" alt="Screenshot 2026-07-17 at 7 47 24 PM" src="https://github.com/user-attachments/assets/a5a59adb-2df4-4154-98b2-acf87a8008da" /> |
||
|
|
7e133a4930 |
Converge email recipient fields on existing patterns: shared parser/formatter, search-index members, one display-name rule (#22997)
# Why Follow-up to #22668, addressing @charlesBochet's five post-merge review comments. They all point the same direction: the recipient fields rebuilt things the codebase already had. This PR converges on the existing patterns where that holds up, and answers on the threads where it deliberately does not. # What changed, per comment **Parser duplication ([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064242))**: `parseEmailAddressList` now lives in twenty-shared (addressparser, group flattening, try/catch). The server's `safeParseEmailAddresses` delegates to it, the front wrapper keeps only paste normalization (newlines to commas) and invalid-token preservation for red chips. The `addressparser` dependency moves from twenty-front to twenty-shared. Side effect worth knowing: RFC 5322 group members in inbound To/Cc headers were previously dropped entirely (group entries have no top-level address, so the filter removed them); flattening now imports those participants. Covered by a new regression test. **Formatter duplication ([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064243))**: `formatEmailAddress` (quote only when specials require it) lives in twenty-shared. The composer chips and the server's `formatMessageFromHeader` both delegate to it. The Gmail From header output is byte-identical: the name is mime-encoded first and encoded words never contain characters that trigger quoting. CodeQL then caught that the quoting (ported from the original front util) escaped quotes but not backslashes, letting a crafted name close the quoted string early; escaping now covers both as RFC 5322 quoted-pairs, with a containment test proving a hostile name cannot split into extra recipients on reparse. **Member search divergence ([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064231))**: suggestions now search WorkspaceMember through the search index in the same `useObjectRecordSearchRecords` call as Person (one ranked query), and enrich hits from `currentWorkspaceMembersState`, exactly like `SettingsRoleAssignmentWorkspaceMemberPickerDropdown`. The client-side `filterBySearchQuery` pass is gone. The hook is now what the comment described: the merge of context people, searched people, and members into one ranked list, rendered with the same `SelectableList`/`MenuItemAvatar` primitives the pickers use. Also fixed while in there: searched person ids are sliced to the suggestion limit before hydration, so top-ranked people can no longer be crowded out of the hydration page. **Chip resolution duplication ([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064236))**: the display-name preference is now one rule, `getEmailIdentityDisplayName`, used by both `getDisplayNameFromParticipant` (threads) and the composer chip/menu, so the same address renders identically everywhere. The order is workspace member, then person, then display name, then handle: when an address belongs to both a teammate and a Person record, the internal identity wins (product call from Felix). `BaseChip.maxLabelWidth` is renamed `maxWidth` to match the twenty-ui `Chip` API. `ParticipantChip` itself is not used inside the field: it renders a navigating `RecordChip` when a person is linked, and navigation from the composer destroys the draft (no draft persistence yet), plus the field chips need remove/selected/danger/edit affordances it does not have. **Rebuilding on MultiItemFieldInput ([comment](https://github.com/twentyhq/twenty/pull/22668#discussion_r3553064221))**: answered on the thread rather than in code, deliberately. `MultiItemFieldInput` is a dropdown-panel list editor (vertical rows, one input at a time, bound to record-field contexts and `FieldMetadataType`), and its own TODO says the API should be refactored into a hook before growing. The inline wrapping chip row commits batches (paste), dedupes with a flash, and keeps a persistent inline input with suggestions; layering that through `renderItem`/`renderInput` would strain both components. On the menu overlap: after comparing side by side, the shared surface between `MultiItemFieldMenuItem`'s dropdown and the chip menu is three `MenuItem` rows with different copy, order, and neighbors; `MenuItem` is already the shared primitive, and a config-driven fragment would be indirection without deduplication. If deeper convergence is wanted, the honest path is the existing TODO (extract the multi-item state machine into a hook, rebase both editors on it); that touches the Links/Phones/Emails/Array/Files cell editors and deserves its own PR. # Verification - New twenty-shared suites for the parser and formatter (16 tests), including parse/format round-trips, the encoded-word case, and the backslash-escaping containment case. - Server messaging util specs all pass (70 tests), including new group-flattening regression tests; From-header spec output unchanged. - Front email module suites all pass (59 tests) with the slimmed wrappers. - Typecheck and lint green on twenty-shared, twenty-front, twenty-server; oxfmt clean on all three. - Playwright smoke against the seeded dev stack passes end to end: context suggestions on the Google company, typed search showing people and the workspace member row (now served by the search index), Enter picking the top suggestion, duplicate merge, keyboard delete, chip menu with clipboard copy, Ctrl+Enter committing the buffer then triggering send. |
||
|
|
0d13db1d9c |
fix(twenty-server): re-list marketplace registration when catalog serves an app first installed locally (#22877)
## Problem Fixes #22872. An app first installed from a **local/CLI source** (`yarn twenty dev` / tarball upload) gets its `applicationRegistration` created with `isListed: false` — sensible for a dev app. But when that same app (same `universalIdentifier`) is later **published to the configured app registry**, the marketplace catalog sync's update branch in `upsertFromCatalog` spreads the existing entity and updates name/sourceType/sourcePackage/version/manifest **without ever setting `isListed` back to `true`** (only the create branch does). Result: the app is permanently invisible in the Marketplace tab (`findManyMarketplaceApps` → `findManyListed()`), while `installApplication(universalIdentifier)` still works — a confusing split-brain state with no error anywhere. Reproduced on a self-hosted v2.18.5 with a private Verdaccio registry (details and repro steps in the issue). ## Fix In the `upsertFromCatalog` update branch, re-list the registration **only when its previous source was local** (`TARBALL`/`LOCAL`): ```ts const isRelistedFromLocalSource = existing.sourceType === ApplicationRegistrationSourceType.TARBALL || existing.sourceType === ApplicationRegistrationSourceType.LOCAL; ... isListed: existing.isListed || isRelistedFromLocalSource, ``` This deliberately does **not** blanket-set `isListed: true` on every sync: an operator who delisted a registry-sourced app (via `updateApplicationRegistration`) keeps their decision — the hourly sync won't override it. ## Tests New unit spec `application-registration-upsert-from-catalog.spec.ts`: - re-lists a registration first created by a local install once the catalog serves it; - preserves an operator delisting of a registry-sourced registration; - keeps an already-listed registration listed; - still creates new catalog registrations as listed. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22877?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: Nicolas Chanal <nicolaschanal@MacBook-Pro-de-Nicolas.local> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
716e67a276 |
Copy application source files during build and install (#22991)
## Summary This is a requirement for the 2-way sync feature - Copy logic function and front component source files into the build output during SDK app builds. - Share application file list construction between install and build paths so source and built artifacts stay aligned. - Allow app installs to skip missing optional source files for backward compatibility while still requiring built artifacts. - Extend watcher handling to track source-file uploads and restart when the source set changes. - Update integration coverage to assert built outputs and copied source files for minimal apps. <img width="940" height="165" alt="Screenshot 2026-07-17 at 14 53 40" src="https://github.com/user-attachments/assets/b9306990-5fc0-4866-a390-3bb8c21a88ad" /> <img width="579" height="181" alt="Screenshot 2026-07-17 at 14 53 57" src="https://github.com/user-attachments/assets/af282318-76c3-49a9-b62a-833a0aadc688" /> |
||
|
|
aafee63706 |
Count self-hosted billable seats per distinct user (#22986)
## Context
Self-hosted enterprise pricing is documented as per user ("Pricing is
per user and each user needs a licence"), but seat counts reported to
the enterprise API counted active `userWorkspace` rows. A user belonging
to two workspaces on the same instance was billed as two seats.
## What this does
- Adds `EnterprisePlanService.getBillableSeatCount()`, which counts
`DISTINCT userId` over non-deleted userWorkspaces, keeping the existing
floor of 1.
- Uses it at all four seat-reporting sites: the checkout session
quantity, the seat reports on key activation (`setEnterpriseKey`) and
server-binding release, and the recurring validation cron report.
- Removes the two duplicated private `getActiveUserWorkspaceCount()`
counters and their `UserWorkspaceEntity` repository injections from the
resolver and cron job.
- Adds unit tests for the new method (dedup across workspaces, floor of
1, missing row).
## Intentionally unchanged
- The website `/api/enterprise/seats` and `/checkout` routes apply
whatever quantity the instance reports, so no Stripe-side change is
needed.
- Instance telemetry still reports both `activeUserWorkspaceCount` and
`distinctUserCount`, so the delta stays observable.
- Existing subscriptions need no migration: the next cron seat report
prorates affected instances down automatically.
## Test
- `enterprise-plan.service.spec.ts`: 58 passed (3 new)
- `lint:diff-with-main` and `typecheck` for twenty-server pass
---
_Generated by [Claude
Code](https://claude.ai/code/session_01D58667A9kbMWzSQKHp7KSd)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22986?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. -->
|
||
|
|
f6612e5a85 |
fix(server): stop treating Microsoft Graph 401 as a permanent failure (#22989)
In production we are seeing some accounts being occasionally marked as permanent failure even though when you check with their refresh token it never actually failed this PR stops treating 401 as permanent failure and treats them as Transient error. We already have token refresh stage that runs before the actual import stage, so if it's an actual revoke token error, it should catch it. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22989?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. --> |
||
|
|
39082cf787 |
Only show the install-apps onboarding step to workspace creators (#22990)
New users joining an existing workspace through an invite link were routed through the "Install your first apps" onboarding step, which is meant for workspace creation only. #22347 set `ONBOARDING_INSTALL_APPS_PENDING` inside `activateOnboardingForUser`, which is shared by both sign-up paths. Gate it per path like the connect-account step: `true` on `signUpOnNewWorkspace`, `false` on `signInUpOnExistingWorkspace`. Verified locally: invited users now go straight from create-profile into the workspace, and workspace creators still get the install-apps step. Covered by a unit test on the invite path and integration tests asserting the onboarding status per sign-up path (invite → `PROFILE_CREATION`; workspace creation → `SYNC_EMAIL` then `APPS_INSTALLATION`). |
||
|
|
5e27e04c0a |
Add mostly-empty field hints to data model settings (#22962)
## What
Fields that are empty in almost all records now show a subtle `Mostly
empty` hint next to their name in the object's Fields settings table
(same visual treatment as `Deactivated`), with a tooltip explaining the
signal and a matching **Mostly empty** toggle in the search filter
dropdown. The goal is to nudge admins to clean up and deactivate fields
nobody uses, while keeping the page untouched when the data model is
healthy.
## How
**No table scans.** Emptiness is read from Postgres planner statistics,
so the cost is a catalog lookup regardless of table size:
- `pg_class.reltuples` gates the feature on an approximate row count (≥
100 records; never-analyzed tables mean no hints). Reuses the shared
helper extracted from `ObjectRecordCountService`.
- `pg_stats.null_frac` plus the sampled frequency of the column type's
empty sentinel (`''` for text columns, `'{}'` for arrays, `'{}'`/`'[]'`
for json — matched per physical column type) gives a per-column empty
fraction. A value dominating ≥ 95% of a column is guaranteed to appear
in the most-common-values list, so the approximation is reliable exactly
at the threshold we care about.
**Decision rules** (pure util, unit-tested):
- Flag when every relevant column is ≥ 95% empty and the object has ≥
100 records.
- Skip system fields, the label identifier, relations, booleans, and
actor fields (exhaustive switch — a new `FieldMetadataType` fails to
compile until classified).
- Composite fields must have all their columns empty, with column sets
derived from `compositeTypeDefinitions`; only default-bearing code
columns (`currencyCode`, phone country/calling codes) are excluded so
stamped defaults don't mask emptiness.
- Anything unknown (missing stats, new column since last ANALYZE)
degrades to silence — no hint is ever shown on missing data.
**API:** one `mostlyEmptyFieldMetadataIds(objectMetadataId)` query on
the metadata schema, guarded by the `DATA_MODEL` settings permission,
fetched lazily when the fields page opens.
**UI:** exception-based — no new columns, no persistent controls. The
badge and the filter toggle only materialize when at least one field
qualifies, and disappear once things are cleaned up.
## Test
- Unit tests for the decision util (threshold,
system/label-identifier/inactive exclusion, missing statistics,
composite all-columns rule, links label/secondary data, currency
narrowing, excluded types).
- Catalog SQL validated against Postgres 16 with a table mimicking
Twenty's column shapes (text `''` defaults, enums, arrays, jsonb,
currency pairs), including the type-aware sentinel matching (a text
column full of literal `"{}"` strings does not count as empty).
- End-to-end on a seeded dev instance: 899 companies with a mix of
filled/empty fields — the API returned exactly the five fields predicted
by the raw statistics (`annualRevenue`, `employees`, `introVideo`,
`tagline`, `workPolicy`) and correctly excluded `address` (city 33%
filled), actor/system fields, and the label identifier.
- UI driven with Playwright: badge, tooltip copy, filter toggle, and
filtered table all verified visually.
- `lint:diff-with-main`, `typecheck` (server + front), and all three
`graphql:generate` configurations + SDK metadata client regenerated and
committed.
|
||
|
|
20e74d0553 |
Revert "Remove calendar week view feature flag from public flags" (#22987)
Reverts twentyhq/twenty#22950 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22987?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. --> |
||
|
|
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. |
||
|
|
4a7324c0c8 |
fix(serverless): flush IPC message before exiting local function runner (#22920)
Closes #22925 ## Problem `LocalChildProcessRunnerService.writeBootstrapRunner` generates a child-process runner that returns the function result to the parent over the Node IPC channel: ```js const out = await handlerFn(msg.payload); process.send && process.send({ ok: true, result: out }); process.exit(0); ``` `process.send()` is **asynchronous**. When the serialized payload is larger than the OS pipe buffer (~64 KB on Linux), it can't be written in a single synchronous step, and the `process.exit(0)` on the next line tears the child down before the message is flushed. On the parent side (`runChildWithEnv`), the lost message means the `'message'` handler never fires — only `'exit'` with `code === 0` does, which resolves: ```js resolve({ ok: true, stdout, stderr }); // no `result` ``` `LocalDriver.execute` then returns `data: result ?? null` → **`null`**, so the function's return value is silently discarded while the step is reported as a *success* with an empty result. ## Symptom Larger serverless / workflow **Code** step results intermittently come back empty (`{}` / `null`). It is size- and load-dependent, so it presents as flakiness. A common downstream failure is a workflow **Iterator** fed the now-missing array: ``` Iterator input items must be an array ``` Results smaller than the pipe buffer always flush synchronously and never reproduce it — which is why only larger payloads are affected. ## Root cause `process.exit()` runs before the asynchronous `process.send()` (and the stdout fallback `process.stdout.write()`) has flushed — the classic Node footgun of exiting before pending async writes drain. ## Fix Wait for the `send()` / `write()` flush callback before exiting, on every exit path (success, error, stdout fallback, outer catch): ```js if (process.send) { process.send({ ok: true, result: out }, () => process.exit(0)); } else { process.exit(0); } ``` Behavior-preserving: it never delivers *less* than before; it only closes the window where a large result is dropped. No change to the small-payload happy path. ## Deterministic reproduction Reproduces the dropped IPC message under back-pressure (parent stalls before draining), comparing the current pattern vs the fix: ```js const { spawn } = require('node:child_process'); const fs = require('fs'); const SIZE = 3_000_000; // beyond any pipe buffer const child = (mode) => ` process.on('message', () => { const out = 'z'.repeat(${SIZE}); ${mode === 'fixed' ? 'process.send({ ok: true, result: out }, () => process.exit(0));' : 'process.send({ ok: true, result: out }); process.exit(0);'} });`; const busy = (ms) => { const e = Date.now() + ms; while (Date.now() < e) {} }; function trial(mode) { return new Promise((resolve) => { const f = `/tmp/child_${mode}.cjs`; fs.writeFileSync(f, child(mode)); const c = spawn(process.execPath, [f], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] }); let got = false; c.on('message', (m) => { got = m?.result?.length === SIZE; }); c.on('exit', () => resolve(got)); c.send({ type: 'run' }); busy(30); // stall parent so it doesn't drain the IPC pipe promptly }); } (async () => { for (const mode of ['current', 'fixed']) { let ok = 0; const N = 25; for (let i = 0; i < N; i += 5) { ok += (await Promise.all([...Array(5)].map(() => trial(mode)))).filter(Boolean).length; } console.log(`${mode}: result delivered ${ok}/${N}`); } })(); ``` Output: ``` current: result delivered 0/25 fixed: result delivered 25/25 ``` <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22920?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. --> |
||
|
|
d99f57bc5e |
chore: bump version to 2.23.0 (#22975)
## Summary - Moves current version to previous versions array - Sets TWENTY_CURRENT_VERSION to the new version - Updates TWENTY_NEXT_VERSIONS with the next minor version - Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same version ## Checklist - [ ] Verify version constants are correct - [ ] Verify npm package versions match <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22975?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com> |
||
|
|
6360599943 |
Unify application version gate, registration writes and upgrade paths across sources (#22931)
Continues the source-unification arc after #22921. Three related consolidations, one commit each. ## 1. Single semver version gate (`793f4849`) The "incoming version must move forward" rule was hand-rolled twice: workspace installs (validate semver, reject equal as `APP_ALREADY_INSTALLED`, lower as `CANNOT_DOWNGRADE_APPLICATION`) and tarball deploys (reject `lte` as `VERSION_ALREADY_EXISTS`). `ApplicationVersionValidationService.validateVersionProgression` now owns the comparison rules and messages; new maps in `version-reason-to-exception-code.constant.ts` translate failure reasons to each caller's existing exception codes, so error contracts observed by the frontend/CLI are unchanged. A non-semver current version never blocks, matching both previous behaviors. ## 2. One registration-metadata writer (`13eb5f91`) Tarball upload and marketplace catalog sync wrote registration metadata with their own repository calls, duplicating the gallery-image fileId preservation and variable-schema sync, and bypassing the per-registration lock and transaction that `updateFromManifest` provides. Both now delegate to `updateFromManifest` (new `additionalFields` allowlist for their extra columns: `tarballFileId`, `isListed`, `isVetted`, `ownerWorkspaceId`, `sourcePackage`, `name`), so every manifest-bearing registration write serializes on the same lock and applies the same rules. The shared gallery fileId preservation moved to a `buildRegistrationManifestUpdateFields` util. Tarball uploads can no longer race installs on the registration row. Behavior notes: a tarball re-upload whose manifest lacks `application.displayName` now keeps the existing registration name instead of resetting it to "Unknown App", and a re-upload without a `package.json` version keeps the stored `latestAvailableVersion` instead of nulling it — both strictly less destructive. ## 3. TARBALL upgrades (`b20aaba9`) `upgradeApplication` only supported NPM; TARBALL apps had no update path for installing workspaces. It now accepts TARBALL registrations and re-installs the stored tarball, whose contents define the target version — the install flow already gates same-version and downgrade installs. The settings UI shows the latest-version row and the Upgrade button for both NPM and TARBALL apps via a shared `isUpgradableApplicationSourceType` util. LOCAL (dev-sync updates) and OAUTH_ONLY (no code artifacts) stay rejected with a clearer message. ## Validation - New tests: `validateVersionProgression` matrix in `application-version-validation.service.spec.ts`, `buildRegistrationManifestUpdateFields` gallery-preservation spec - All 27 application suites (148 tests) pass; typecheck and lint green on twenty-server and twenty-front |
||
|
|
52b7aebddf |
fix(server): key connected-account lookup on handle and provider (#22964)
Connect flows (Google, Microsoft, IMAP/SMTP/CalDAV) looked up an existing connectedAccount by `handle` alone, so connecting a second account with the same handle but a different provider overwrote the first instead of inserting a new row (e.g. IMAP inbox clobbering a calendar-only Google account). Fix: add the `provider` discriminator to the lookup. Same provider+handle still updates; a different provider gets its own row. Integration test covers the Google-then-IMAP case. |
||
|
|
f8b7ecf680 |
fix(workflow): rebuild core workflowVersion rows in the 2-22 backfill (#22961)
## Problem
Syncing workflowVersion to core fails with `duplicate key value violates
unique constraint "IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW"`. The
sync's `INSERT ... ON CONFLICT ("id")` only dedupes the primary key — it
can't dedupe `IDX_WORKFLOW_VERSION_ONE_ACTIVE_PER_WORKFLOW`
(`(workspaceId, workflowId) WHERE status='ACTIVE'`). When a leftover
ACTIVE core row exists for a `(workspaceId, workflowId)` with a stale id
(accumulated across the sync's earlier id schemes), inserting the new
active row collides, and the per-id purge misses it.
## Fix
Rewrite the 2-22 `backfill-workflow-version-core-links` command as a
**full rebuild**. Per workspace, in one raw-SQL transaction (core and
workspace schemas are the same database):
1. `DELETE` all `core.workflowVersion` for the workspace — clears every
stale/leftover row.
2. Insert a fresh own-id core row for **every** workspace version.
3. `UPDATE` `coreWorkflowVersionId` on **every** workspace record to its
new core id.
Because it wipes first and re-links all records, leftover ACTIVE rows
can't collide and no record is left pointing at a deleted core row — so
the dual-write's `linked → update` path stays correct afterward.
## Test (local)
Fresh reset, and a reproduced dirty state (leftover ACTIVE core row with
a stale id + a stale link + an unlinked record):
- rebuild runs with no `ONE_ACTIVE` / duplicate-key error,
- leftover wiped, stale link replaced, every record re-linked to a fresh
own-id row,
- 0 dangling/unlinked, no duplicate core rows, exactly one ACTIVE core
version per workflow.
Typecheck + lint + oxfmt clean.
## Note
The 2-20 `backfill-workflow-version-to-core` can still log per-workspace
conflicts on already-dirty instances, but they're non-fatal (the
iterator continues) and this rebuild corrects the end state. The
dual-write (`upsertToCore`) is unchanged and works on the clean data
this produces.
|
||
|
|
fe10975927 |
fix(billing): guard workspace suspend against concurrent soft-delete (#22955)
## Context Follow-up to #22943, addressing the cubic review comments left on that PR (handled in a follow-up as agreed in the thread). In `BillingWebhookSubscriptionService.processStripeEvent`, the suspend/reactivate decision re-reads the workspace and then acts on it. A concurrent soft-delete landing in that window could transition an already soft-deleted workspace to `SUSPENDED`, contrary to the guarded-transition behavior (cubic P2). ## Fix Added `deletedAt: IsNull()` to the `suspendWorkspace` compare-and-swap WHERE clause, matching the guard already present in `reactivateWorkspace`. A concurrent soft-delete now blocks the suspension instead of transitioning a deleted workspace to `SUSPENDED`. ## On the delete guard (cubic P1) Cubic also flagged that the `PENDING_CREATION` hard-delete path could hard-delete a concurrently soft-deleted workspace. On review this is not worth guarding: - A `PENDING_CREATION` workspace has no DB schema and no records (activation is what creates them), so there is no data to lose. - The cleaner already hard-deletes soft-deleted workspaces by design (`cleaner.workspace-service.ts` soft-deletes a pending workspace, then hard-deletes it on a later run), so "hard delete an already soft-deleted workspace" is a supported transition, not corruption. So P1 is intentionally left out to keep `deleteWorkspace` and all its callers unchanged. ## Tests - `suspendWorkspace` update includes `deletedAt IS NULL` and reports whether the guarded update applied. Typecheck, lint, and format pass on the changed files. |
||
|
|
7939cd8684 |
feat(server): app lifecycle metrics (install/uninstall/upgrade + marketplace publish) (#22656)
## What Adds product metrics for the app/marketplace lifecycle so they can be graphed in Grafana. No app-lifecycle metrics existed before; the flows only logged. ### New counters (`MetricsKeys`) - `app-install/succeeded` · `app-install/failed` - `app-upgrade/succeeded` · `app-upgrade/failed` - `app-uninstall/succeeded` · `app-uninstall/failed` - `app-registration/created` (new app published) · `app-registration/version-published` (new version available) All carry `universalIdentifier`, `appName`, `sourceType` attributes (plus `version`, and `errorCode` on failures). ### New gauge - `twenty_app_installed_workspaces_total` — observable gauge emitting the top 100 external apps by installed-workspace count (excludes built-in LOCAL apps). Powers a "most installed apps" leaderboard; combine with the 24h install/uninstall event counters for recent activity. ## Where metrics are emitted - **Install / upgrade** (`ApplicationInstallService.doInstallApplication`): success + failure branches, distinguished by the existing `isVersionUpgrade` flag. - **Uninstall** (`ApplicationInstallResolver.uninstallApplication`): at the resolver, deliberately *not* in the sync service, so rollback-triggered internal uninstalls (fired from the install catch block) don't pollute uninstall counts. - **Publish / new version**: `upsertFromCatalog` (npm marketplace sync), `checkForUpdates` (npm version poll), and the tarball CLI publish path. ### Exactly-once version-published Both the catalog-sync and version-check crons converge `latestAvailableVersion`. Each emission point is **change-guarded** (`stored !== incoming`), so whichever cron observes the change first emits, and the other becomes a no-op. No double counting, no race-dependent misses. ## Pipeline Metrics flow through the existing OTel -> ClickHouse path and can be graphed from the `twenty-product-metrics` dashboard (dashboard changes live in infra-twenty, not this PR). ## Test plan - [x] `nx typecheck twenty-server` - [x] oxlint + oxfmt on changed files - [x] `oauth-discovery.controller.spec` (the one existing spec touching these services) passes - [ ] Reviewer: sanity-check metric names/attributes and cardinality choices (no `workspaceId` attribute, LOCAL apps excluded from the gauge) <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22656?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: martmull <martmull@hotmail.fr> Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com> |
||
|
|
a5108d512f |
Block filters on restricted fields (#22873)
## Summary
- Reject filter conditions that target fields without read access,
including relation traversals
- Add unit and integration coverage for denied field filter access
The issue is an information leak through filtering: a user who cannot
read a field can still infer its values from totalCount
### Manual reproduction
- Create or use a non-admin role.
- Give it read access to People records.
- Disable read access to the Person jobTitle field.
- Assign a test member to that role.
- Authenticate as that member.
Run:
```gql
query People($filter: PersonFilterInput) {
people(filter: $filter, first: 0) {
totalCount
}
}
Variables:
{
"filter": {
"jobTitle": {
"like": "Par%"
}
}
}
```
Ensure at least one Person has a matching jobTitle.
Before the fix, the request succeeds:
```gql
{
"data": {
"people": {
"totalCount": 1
}
}
}
```
The caller can probe restricted values using different filters.
After the fix, it returns a permission error:
```gql
{
"errors": [
{
"message": "Permission denied"
}
]
}
```
The same should happen through a relation filter, for example filtering
Companies by a restricted Person field:
```gql
query Companies($filter: CompanyFilterInput) {
companies(filter: $filter, first: 0) {
totalCount
}
}
{
"filter": {
"people": {
"jobTitle": {
"like": "Par%"
}
}
}
}
```
|
||
|
|
988f8ff900 |
feat(workflow): provision coreWorkflowVersionId on existing workspaces and link them (#22944)
Stacked on the write-back guard hotfix (#22940). Completes the version soft-ref for workspaces that predate the `coreWorkflowVersionId` field. ## Why New standard fields aren't auto-synced to existing workspaces; they're only built at workspace creation or added by an explicit upgrade command. Deployed 2.20/2.21 instances also carry **legacy core rows with `id = record.id`** (the pre-soft-ref shared-UUID model, from the already-run #22663 backfill). The original backfill has already run there and won't re-run (tracking is by command name), so the migration to soft-ref has to be **new appended commands**. ## What (two appended 2-22 workspace commands) 1. `add-workflow-version-core-soft-ref-field` (`1784193206000`): adds the `coreWorkflowVersionId` system field to existing workspaces missing it (flat-entity migration, `add-message-campaign-stat-fields` pattern). Idempotent, dry-run aware, skips workspaces without the `workflowVersion` object. 2. `backfill-workflow-version-core-links` (`1784193207000`): re-runs the sync (`upsertToCore`). For each version, `upsertToCore` **purges the legacy shared-id core row** (`id === record id`) then upserts a deterministic own-id row and writes the link back onto the workspace record. Targeted per-id delete — it does not wipe unrelated core rows. Ordering: both run after the original 2-20 backfill. On instances that run 2-20 fresh (e.g. 2.19 → 2.22) that backfill creates core rows and — via the hotfix guard — skips the write-back until the field exists; command 1 provisions the field; command 2 purges + relinks. On already-migrated 2.21 instances the 2-20 backfill won't re-run, so command 2 is what clears their shared-id rows. The same per-id purge in `upsertToCore` also covers the dual-write path: between the 2.22 deploy and command 2 running, an edited version would otherwise collide with its shared-id row on the one-active-per-workflow index. Scope: version side only. The workflow-side equivalent (`coreWorkflowId`) ships with the workflow-side sync PR; `core.workflow` was never backfilled in prod, so it has no legacy shared-id rows. ## Test - Unit: guard skips write-back when the field is absent; runs it when present. - Happy path (fresh reset): original backfill → add-field no-op → link → 4/4 linked, own ids, 0 duplicates. - Deployed migration (simulated 2.21: shared-id ACTIVE core rows + field removed): add-field re-provisions → link purges the shared-id rows and rebuilds → records linked to own-id rows, 0 duplicates, exactly one ACTIVE core version per workflow (index intact), no collision. - Idempotent re-run: still 4 core rows, links resolve. - Typecheck + lint + oxfmt clean. |
||
|
|
fdb78b35d0 |
Handle scalar select filter values when recomputing view filters (#22930)
## Summary Fixing `Internal Server Error: Unexpected invalid view filter value for filter` Updating a select field’s options failed with an INTERNAL_SERVER_ERROR when the field had an associated IS_NOT_EMPTY view filter. The affected filter stored an empty string as its value: ``` operand: IS_NOT_EMPTY value: "" ``` ### Root cause The option-update side effect treated every associated select filter value as an option array. It attempted to parse the empty string and then rejected the result because it was not an array. However, IS_NOT_EMPTY is a value-less operand, so its empty value is valid and should not participate in option recomputation. ### Fix Skip option-value recomputation for operands that do not expect a value, including IS_NOT_EMPTY. Normalize legacy scalar select-filter values using the same logic as filter validation. Leave subfield filters unchanged. Preserve compatibility with legacy filter-value representations until they are migrated to the canonical JSON format. |
||
|
|
e9bc06a830 |
fix(workflow): skip core version id write-back when the field is missing (#22940)
## What The version soft-ref sync (#22821, on main / the 2.22 line, not yet released) added a **write-back** step: after copying a `workflowVersion` into `core.workflowVersion`, it sets `coreWorkflowVersionId` on the workspace record. On workspaces that predate that field (new standard fields aren't auto-provisioned onto existing workspaces), the write-back throws: `Field metadata for field "coreWorkflowVersionId" is missing in object metadata workflowVersion` (from `formatData`). This breaks the sync wherever it runs on an unprovisioned workspace — the backfill (when it runs under the new code) and the dual-write (on any workflow-version create/update). Observed on staging while upgrading to 2.22: 2 of 70 workspaces. **2.20 itself was fine** — it ran the old shared-UUID sync, which had no write-back. ## Fix Guard the write-back: check the workspace's `workflowVersion` object for the `coreWorkflowVersionId` field (via `workspaceCacheService` flat field maps) and skip it with a warning if absent, instead of throwing. The core row is still upserted; the workspace gets linked later once the field is provisioned. ## Follow-up Provisioning `coreWorkflowVersionId` onto existing workspaces and linking them is #22944 (version side). The workflow-side equivalent follows with the workflow-side sync PR. ## Test Unit test covers both branches: field absent → write-back skipped, no throw, core upsert still runs; field present → write-back runs. Typecheck + lint clean. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22940?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. --> |
||
|
|
5d9d33b513 |
i18n - translations (#22951)
Created by Github action Co-authored-by: github-actions <github-actions@twenty.com> |
||
|
|
2a7e87125f |
Remove calendar week view feature flag from public flags (#22950)
## Summary - Remove `IS_CALENDAR_WEEK_VIEW_ENABLED` from the public feature flag registry - Keep the remaining public feature flags unchanged ## Note Needs more polish before being released in the Lab |
||
|
|
217cf650aa |
Decide workspace destiny from live data and all sub (#22943)
## Context A production customer was stuck on the billing settings page: their workspace was `SUSPENDED` (with `suspendedAt` set) while their subscription was `active` in the database. ## Problem Stripe webhook events can be delivered out of order or processed concurrently ([Stripe explicitly does not guarantee ordering](https://docs.stripe.com/webhooks#events-ordering)), and `BillingWebhookSubscriptionService.processStripeEvent` made its suspend/reactivate decision from stale and incomplete data: - The decision used the **event payload's** subscription status, while the subscription row was upserted from a **live Stripe fetch** — so the two could diverge. Around trial end, Stripe emits `active → past_due` then (once the customer pays) `past_due → active` within a short window. If the stale `past_due` event is processed last, it suspends the workspace while writing an `active` subscription to the DB. Nothing self-heals from that state: the workspace stays suspended while the cleanup cron warns and eventually soft-deletes it. - The decision only looked at the **event's own subscription**, but suspension is a workspace-level decision and a Stripe customer can hold several subscriptions (plan switch, cancel-then-resubscribe). A `customer.subscription.deleted` event for the old subscription is *genuinely* canceled — only the sibling subscription proves the customer is still paying. - The workspace snapshot was read at the top of the handler, before several slow awaits, so a concurrent event could change it mid-flight. ## Fix Every event now converges the workspace to the current Stripe state, regardless of delivery order: - **Live input**: fetch the customer's not-ended subscriptions from Stripe (`subscriptions.list` without a `status` param excludes the unbounded canceled history server-side; an explicit `NOT_ENDED_SUBSCRIPTION_STATUSES` filter additionally drops `incomplete_expired`, which would otherwise block suspension forever since it is neither suspend-worthy nor activating). The event's subscription is taken from that list, or fetched directly by id when absent (deletion events, deleted customers) — same retrieve the code used before. The DB upsert uses this live object, never the payload. - **Workspace-level decision over all live subscriptions**: suspend only when **every** subscription warrants it, reactivate as soon as **one** is activating (`active`/`trialing`), and deliberately do nothing in between — e.g. a `past_due` subscription in its payment-retry grace period blocks suspension without triggering reactivation. - **Guarded transitions (compare-and-swap)**: `WorkspaceService.suspendWorkspace`/`reactivateWorkspace` now apply their UPDATE only when the workspace is still in a state the transition is valid from, and return whether they applied. Repeated suspensions keep the first `suspendedAt` so the cleanup countdown stays anchored to the original suspension date; the deletion-warning cleanup job is only enqueued when a reactivation actually applied. The suspend path re-reads the workspace right before deciding and switches exhaustively on `activationStatus` (`assertUnreachable` in `default`), preserving the previous behavior including suspend-over-reactivate precedence. ## Tests Unit tests cover the incident scenario and its neighbors: a stale `past_due` event after payment reactivates instead of suspending; a live `unpaid` state suspends even when the payload says `active`; a canceled subscription event does not suspend (and reactivates) when a sibling `active`/`trialing` subscription exists; a sibling in `past_due` grace blocks suspension without reactivating; the direct-retrieve fallback handles subscriptions absent from the customer list; the guarded reactivation skips the cleanup job when it did not apply; and soft-deleted workspaces are never transitioned. |
||
|
|
9860871eac |
perf(server): lower workspace local cache cap to 6,000 entries (#22946)
## Context #21954 raised `MAX_LOCAL_CACHE_ENTRIES` from 1,000 to 7,500 to stop the per-pod workspace metadata cache from thrashing against Redis. That worked: the cache node's egress dropped from ~1.1-1.8 TB/day to ~0.5-0.7 TB/day and has stayed there. The memory side of the trade turned out tighter than the sizing assumed. Prod-eu metrics over the past month: - Average server pod working set rose from ~1.9-2.4 GiB (before the rollout on 06/26) to ~3.0-3.4 GiB, with individual pods peaking at 3.5-3.6 GiB, right at the `--max-old-space-size=3500` heap ceiling on 4 GiB pods. - The `workspace-metadata-cache/local-eviction` counter added in #21954 stayed at zero for three weeks, then on 07/15 between 08:30 and 09:30 UTC all 7 server pods hit the 7,500-entry cap within an hour (no deploy that morning; organic growth crossed the threshold during the European morning peak). Since then the fleet evicts continuously (~167k entries/day). - Server pods were terminated with reason OOMKilled on 8 days of the past month (1-2 pods each time). The per-entry payload also grew since the cap was sized: search field metadata backfills, two new per-workspace cache components (`flatSearchFieldMetadataMaps`, `workflowAutomatedTriggerMaps`), and the heap-only `ORMEntityMetadatas` entries. So the byte ceiling implied by 7,500 entries now sits exactly at the heap limit instead of comfortably under it. ## What this does Lower `MAX_LOCAL_CACHE_ENTRIES` 7,500 → 6,000. At the measured ~170-200 KB average entry size this frees roughly 300-500 MB of steady-state heap per pod, restoring headroom under the 3.5 GB old-space limit. Traffic cost should be small: eviction churn at the 7,500 cap only moved cache egress from ~0.55 to ~0.65-0.68 TB/day, so a 20% smaller cap should keep the bulk of the #21954 reduction. The eviction counter gives us the feedback loop; if egress climbs materially we can split the difference, and if pods still run hot the next lever is byte-aware eviction for the few known-heavy keys. |
||
|
|
1b9152d4c5 |
chore: sync AI model catalog from models.dev (#22939)
Automated daily sync of `ai-providers.json` from [models.dev](https://models.dev). This PR updates pricing, context windows, and model availability based on the latest data. New models meeting inclusion criteria (tool calling, pricing data, context limits) are added automatically. Deprecated models are detected based on cost-efficiency within the same model family. **Please review before merging** — verify no critical models were incorrectly deprecated. Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com> |
||
|
|
9c2d87a846 |
Fix role lookup query and align calendar layout behavior (#22928)
## Summary Fixes slow getRoles requests caused by loading several one-to-many role relations in a single TypeORM query. The previous query produced a Cartesian product across role targets, permission flags, object permissions, and field permissions. In production, a workspace with 9 roles expanded into more than 32,000 database rows before TypeORM hydration. This change: - Reads roles and their related permissions from the existing flat-map caches. - Hydrates relations using the same findManyWithRelationsFromCache pattern as ViewService. - Removes the expensive joined query from the getRoles path. - Preserves the existing GraphQL response shape. ## Performance ### Before: 9 roles 32,257 SQL result rows Approximately 10 seconds observed request latency ### After: No Cartesian SQL query Cache-backed lookups and in-memory relation hydration <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22928?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. --> |
||
|
|
67ed2689ce |
Unify manifest apply pipeline between application install and dev sync (#22921)
First step of unifying application behaviors across sources (NPM, TARBALL, LOCAL), following up on #22868. No storage layout changes. ## Problem Marketplace/tarball installs (`ApplicationInstallService`) and CLI dev sync (`ApplicationDevelopmentService`) each implemented the second half of application delivery — metadata sync, SDK client generation, registration refresh — with quietly diverging behavior: - Install decided SDK regeneration on `!isVersionUpgrade` (application row exists), dev sync on `!application.version`. The version-based check is the robust one: a failed first install leaves an application row without a version, and the row-based check then skipped SDK generation on retry unless the schema changed. - Install refreshed the registration manifest (`updateFromManifest`) without syncing its variable schemas, while catalog sync, tarball upload, and dev sync all do. An NPM install that bumped `latestAvailableVersion` with new `serverVariables` left the registration's variable schemas stale until the next catalog cron. - The guards protecting shared registrations from workspace writes (npm-sourced or not owned by the workspace) lived only inside dev sync's `syncRegistrationMetadata`. ## Change New `ApplicationManifestApplyService` (application-manifest module) owns those steps for both flows: - `applyManifestToWorkspace`: `synchronizeFromManifest` + SDK client generation when it's the first successful apply (no persisted version) or the schema changed. Used by install and dev sync. - `refreshRegistrationFromManifest`: guarded registration update + variable schema sync. Callers acting for a workspace (dev sync) pass `onlyIfOwnedByWorkspaceId`, moving the npm/ownership guard into the shared service; installs pass `latestAvailableVersion` + `preventVersionDowngrade` as before. Returns whether anything was written so dependent side effects (registration asset store in dev sync) skip together with it. - `ApplicationRegistrationService.updateFromManifest` now returns whether it wrote, so variable schemas are only synced from manifests that were actually applied — previously a downgraded install would still have synced variables from the older manifest if we had naively added the sync there. Install and dev services lose the duplicated logic (and their direct `SdkClientGenerationService` / variable-service dependencies); hooks and file writes stay where they were. ## Behavior deltas (all deliberate) - NPM/TARBALL installs now sync registration variable schemas when they refresh the registration. - Retrying a failed first install regenerates the SDK client even when the schema diff is empty. ## Follow-ups (agreed plan, separate PRs) Single semver version gate; one registration-metadata writer with lock coverage for dev-sync/tarball; unified upgrade incl. TARBALL; recoverable upgrades + stale file cleanup; tarball storage decoupled from the owner workspace; orphan cleanup cron; faster public-asset serving (ETag/304); parallel install file writes; PREBUILT execution mode for app logic functions. ## Validation - New spec `application-manifest-apply.service.spec.ts` (9 tests: SDK generation matrix, ownership/npm guards, downgrade-skip short-circuits variable sync) - All 26 application suites pass; typecheck, oxlint (type-aware) and oxfmt green on twenty-server --- _Generated by [Claude Code](https://claude.ai/code/session_015L4zvAL9bsk7azYkbhcZSg)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22921?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. --> |