2de60d7ea1ce9bb179df99225fea02fdbdfac134
4999 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2de60d7ea1 |
chore(server): temporary diagnostic logging for empty verification email body (#21628)
## What Adds **temporary** diagnostic logging to `EmailVerificationService.sendVerificationEmail` so we can capture the real error behind the empty verification email body in deployed environments. ## Why Verification emails are delivered with an **empty body** (subject is fine). The body is `<!DOCTYPE html …><!--$!--><template></template><!--/$-->` — an **errored React Suspense boundary**. `@react-email/render`'s `render()` wraps the email in `<Suspense>` and streams via `renderToReadableStream` **without an `onError` handler**, so any throw during SSR is swallowed into the errored boundary and the body ships empty. In production React also strips the error text from the markup, so the cause is invisible. This could **not** be reproduced locally on `main` (renders fine in dev, in the production-focused `yarn workspaces focus --production` install layout, and on the React 18 + react-email 6 dep set), so we need the error from a deployed environment. ## What it logs When the rendered html is empty or contains `<!--$!-->`, it logs (prefix `EMAIL_VERIFICATION_RENDER_DEBUG`): - locale, trigger, html length, and the first 400 chars of the html; - the **real error + stack**, obtained by re-rendering synchronously with `renderToStaticMarkup` (which re-throws instead of swallowing). No behavior change on the happy path — the block only runs when rendering already failed. ## How to use Deploy, trigger a verification email (sign up / resend), then: ``` grep -i "EMAIL_VERIFICATION_RENDER_DEBUG" <twenty-server logs> ``` ## Revert Remove this block once the root cause is identified. |
||
|
|
206120677b |
chore: bump version to 2.15.0 (#21624)
## 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/21624?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> |
||
|
|
fdab89ae02 |
Move twenty-client-sdk to dev dep (#21611)
# Introduction The `twenty-client-sdk` is always provided and injected at runtime by the twenty-server instance Which mean that even if in your app locally you're using twenty-client-sdk `1.0` installing this app on twenty instance `2.0` will result in injecting another `twenty-client-sdk` That's the expected behavior and tradeof The twenty-app devdep should only be used to guide local devxp following typesafety and so on A user can still locally generated its own twenty-client-sdk and publish it if necessary <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21611?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. --> |
||
|
|
97871131a1 |
fix(server): mitigate integration-test OOM flakiness (#21588)
## Problem `server-integration-test` shards have been failing intermittently across unrelated PRs with a distinctive signature: the shard exits code 1 with **no jest assertion failure, no `Test Suites:` summary, and no V8 `JavaScript heap out of memory` error** — the process just dies mid-run. Failures hit random shards and clear on re-run (e.g. an unrelated branch failed shard 6 once, then passed 3× on identical code), while the `merge_group` gate stays green. ### Root cause Each shard runs a **single in-band jest process** that boots one shared NestJS app (`globalSetup` → `app.listen`) and holds it for the entire shard, driving heavy metadata migrations + cache rebuilds in that one process. `NODE_OPTIONS=--max-old-space-size=12288` let V8 grow to 12 GB — *above* the `ubuntu-latest` runner's available RAM (16 GB, shared with Postgres/Redis/ClickHouse). V8 therefore deferred aggressive GC and grew past physical memory, so the **OS OOM-killer killed the process before V8 hit its own ceiling** — which is why there's no heap error and no jest summary, just a silent exit. ## Changes (CI/test-only — prod runtime untouched) - **Lower the integration jest heap cap `12288` → `6144`** so V8 self-limits below physical headroom instead of being OS-killed. Counterintuitively safer: a real leak now surfaces as a *visible* heap error naming the test, rather than a silent death. (`database:reset` keeps 12288 — it runs alone, before jest.) - **Add `--logHeapUsage`** to the integration jest runs to expose the per-file heap trend for confirming/pinpointing the growth. - **Split integration tests across 16 shards (was 10)** to lower the peak working set per shard. - **Make perf logging a first-class `LoggerService` tool** (per @prastoin's review): add `LoggerService.perf()` and unify the existing `time()`/`timeEnd()` helpers into `perfTime()`/`perfTimeEnd()` (now routed through the driver), all gated by a new `PERF_LOG_ENABLED` config var. It **defaults on** so real environments keep emitting the install-perf logs, and `.env.test` sets it `false` to mute the per-action flood in integration tests. The `application-manifest` and `validate-build` services were moved from the built-in `Logger` to `LoggerService` to use it. ## Notes - `--max-old-space-size` lives only in the `test:integration` nx target; it is **not** the prod server heap setting, so prod is unaffected. - This is mitigation. If `--logHeapUsage` shows monotonic growth across files, there's a real accumulation in the long-lived app (retained flat-maps / metadata cache) worth a follow-up heap-snapshot fix. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21588?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. --> |
||
|
|
2f6a267b68 |
chore(server): remove stray comment in flat-entity-maps spec (#21599)
Follow-up to #21585: removes an explanatory comment in the test that slipped through before merge. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21599?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. --> |
||
|
|
8a866dba54 |
Add call recording schema and meeting bot scaffold (#21584)
## Summary - add 2.13 upgrade commands for call recording request status and dropping CalendarEvent recordingPreference - remove the recording preference from the core CalendarEvent standard object - add a scaffold-generated twenty-meeting-bot app with logo and the CalendarEvent meetingBotPreference field ## Tests - yarn install - yarn lint - yarn twenty dev:typecheck - git diff --check <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21584?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. --> |
||
|
|
4be76e3fd1 |
Support morph relations in workflow record nodes (#21403)
## Support morph (polymorphic) relations in workflow record nodes
Morph relations (e.g. a polymorphic `Owner` on `Pet` targeting `Person`
or `Company`) were not selectable in the workflow **Create / Update /
Upsert Record** nodes. This PR adds full support for setting them.
### What changed
**Frontend**
- `shouldDisplayFormField`: allow `MORPH_RELATION` (many-to-one) so
morph fields appear in record forms.
- New `FormMorphRelationToOneFieldInput`: a polymorphic record picker
across the morph's target objects, storing a self-describing value `{
targetObjectMetadataId, id }`.
- Wired the morph branch into `FormFieldInput`.
**Backend**
- New `formatWorkflowRecordMorphRelationFields` util: resolves the form
value (stored under the base field name, e.g. `owner`) into the correct
per-target join column (`ownerCompanyId`), nulling siblings to keep
exactly one target referenced.
- Wired into the create / update / upsert workflow actions (update also
expands `fieldsToUpdate` to the concrete join columns).
### Permissions handling
- The picker's search is scoped to only the morph targets the user can
read (`canReadObjectRecords`), so it no longer breaks when a target
object is inaccessible.
- If an existing value points to an object the user can't read, the
field shows the reused **"Not shared"** lock display instead of an empty
field, while remaining editable when other targets are readable.
### Notes
- No data schema / migration changes — reuses the existing per-target
morph columns and stores the selection in the existing workflow step
JSON settings.
<img width="607" height="717" alt="Screenshot 2026-06-10 at 14 57 40"
src="https://github.com/user-attachments/assets/496442a1-04a5-40f8-8b56-b28e38b00d5a"
/>
Also handles the case where the selected record is not readable
<img width="596" height="737" alt="image"
src="https://github.com/user-attachments/assets/c5ffb94e-3838-4db5-853e-f8e490331f23"
/>
|
||
|
|
02d6e2d76f |
perf(server): avoid O(n²) when building flat entity maps (#21585)
## Problem
After 2.13, server CPU stepped up and stayed up. Sentry profiling of
`POST /metadata` pins it on
`addFlatEntityToFlatEntityMapsThroughMutationOrThrow` — ~26% self-time
plus a long tail, turning metadata-write requests into multi-second
(~18s observed) operations.
The hot stack is:
```
WorkspaceMigrationValidateBuildAndRunService.computeAllRelatedFlatEntityMaps
└ getSubFlatEntityMapsByApplicationIdsOrThrow
└ addFlatEntityToFlatEntityMapsThroughMutationOrThrow
```
Every metadata migration rebuilds the twenty-standard application's flat
sub-maps — thousands of entities, across every involved metadata type —
through this util.
## Root cause
`addFlatEntityToFlatEntityMapsThroughMutationOrThrow` maintains
`universalIdentifiersByApplicationId` and deduped each insert with
`Array.includes`:
```ts
if (!existingUniversalIdentifiers.includes(flatEntity.universalIdentifier)) {
existingUniversalIdentifiers.push(flatEntity.universalIdentifier);
}
```
That scan is O(n) per insert, so building a map for an application with
`n` entities is **O(n²)**. The twenty-standard application groups
thousands of standard entities under one `applicationId`, so its sub-map
rebuild dominates.
The dedup is also redundant: the function throws `ENTITY_ALREADY_EXISTS`
at the top if the `universalIdentifier` is already in
`byUniversalIdentifier`, and every id pushed to the per-application list
is also written there. So reaching the push guarantees the id is new —
the `.includes()` is always `false`.
## Fix
Drop the scan and push directly → map building is **O(n)**. Behavior is
unchanged (the early throw already enforces uniqueness).
## Test
Adds a unit spec covering indexing, the no-`applicationId` case, the
duplicate throw, and a 20k-entity build that completes instantly (guards
against re-introducing the quadratic).
## Follow-up (separate PR)
This is the bleed-stopper. The deeper issue is that
`computeAllInvolvedApplicationIds` pulls the **entire** twenty-standard
application into the dependency set of every migration and rebuilds
those sub-maps per request instead of caching them. Worth scoping the
dependency set to referenced entities (or caching the standard-app
sub-maps), which I'll raise separately.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21585?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. -->
|
||
|
|
0a99f784eb |
fix(front): dedupe morph relation fields in view field pickers (#21580)
## Issue Reported in quality-feedbacks: **"Issues with morph relation view field"** — a morph relation column added to a view **disappears after refresh** (and can be added several times). ## Root cause — the SSE metadata sync A morph relation is stored as **one `fieldMetadata` row per target object**, all sharing a `morphId`. Collapsing those rows into the single field that represents the relation is a **read-time projection** in the server's `objects.fieldsList` resolver — it is *not* a storage invariant, and the rows are never merged. The frontend metadata store is kept in sync with the raw rows **one row at a time over SSE** (`MetadataStoreSSEEffect`): every metadata change broadcasts a single created/updated record that's pushed straight into the store. Creating a morph relation creates N rows (one per target), so **N `create` events arrive and N raw sub-fields land in the store — bypassing the `fieldsList` projection entirely.** The view-field pickers read straight from that store, so they saw the morph relation **once per target**. Each could be added as a column referencing a different sub-field id; after a refresh the view reloads from the projected (deduped) data, the non-survivor columns no longer resolve, and they disappear. ## Fix & architecture note Because the store deliberately mirrors raw rows (that's what the SSE sync maintains), the fix applies the **same read-time projection on the client** — deduping morph rows by `morphId` in `useActiveFieldMetadataItems` — rather than filtering rows at each insert path (SSE, optimistic create, …). This matches how the backend already models morph fields and is robust regardless of which path delivered the rows. The survivor-selection rule (which sub-field id represents the relation) now lives in `twenty-shared` (`pickMorphGroupSurvivor`) so client and server can't drift. |
||
|
|
c848ac34fd |
Fix default view widget visibility (#21590)
Tim logged on the left, Phil on the right, Tim created view widget ## Before <img width="1512" height="938" alt="image" src="https://github.com/user-attachments/assets/7f993f41-a244-42db-b56a-b17c15fb3409" /> ## After Fix Tim created a second view widget, Phil can see it <img width="1512" height="982" alt="image" src="https://github.com/user-attachments/assets/3908a70d-538b-4adf-95df-7373a2f6e269" /> ## After slow migration Phil can see first widget <img width="1512" height="982" alt="image" src="https://github.com/user-attachments/assets/6b30429e-1acf-4119-ba32-26db3155975e" /> <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21590?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Weiko <corentin@twenty.com> |
||
|
|
5f59ae20bf |
chore: bump version to 2.14.0 (#21593)
## 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/21593?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> |
||
|
|
ebababcda1 |
chore(deps): bump @ai-sdk/amazon-bedrock from 4.0.97 to 4.0.117 (#21569)
Bumps [@ai-sdk/amazon-bedrock](https://github.com/vercel/ai/tree/HEAD/packages/amazon-bedrock) from 4.0.97 to 4.0.117. <details> <summary>Changelog</summary> <p><em>Sourced from <a href="https://github.com/vercel/ai/blob/@ai-sdk/amazon-bedrock@4.0.117/packages/amazon-bedrock/CHANGELOG.md">@ai-sdk/amazon-bedrock's changelog</a>.</em></p> <blockquote> <h2>4.0.117</h2> <h3>Patch Changes</h3> <ul> <li>Updated dependencies [bfa5864]</li> <li>Updated dependencies [f42aa79] <ul> <li><code>@ai-sdk/provider-utils</code><a href="https://github.com/4"><code>@4</code></a>.0.29</li> <li><code>@ai-sdk/anthropic</code><a href="https://github.com/3"><code>@3</code></a>.0.84</li> <li><code>@ai-sdk/openai</code><a href="https://github.com/3"><code>@3</code></a>.0.71</li> </ul> </li> </ul> <h2>4.0.116</h2> <h3>Patch Changes</h3> <ul> <li>f0b5c16: fix(provider/amazon-bedrock): detect Cohere embedding models behind cross-region inference profile ids</li> <li>Updated dependencies [942f2f8] <ul> <li><code>@ai-sdk/provider-utils</code><a href="https://github.com/4"><code>@4</code></a>.0.28</li> <li><code>@ai-sdk/anthropic</code><a href="https://github.com/3"><code>@3</code></a>.0.83</li> <li><code>@ai-sdk/openai</code><a href="https://github.com/3"><code>@3</code></a>.0.70</li> </ul> </li> </ul> <h2>4.0.115</h2> <h3>Patch Changes</h3> <ul> <li>c97ede5: fix(provider/amazon-bedrock): extract Cohere embedding token usage from response header</li> </ul> <h2>4.0.114</h2> <h3>Patch Changes</h3> <ul> <li>2a91a17: feat(provider/anthropic): add support for <code>claude-fable-5</code> and the <code>fallbacks</code> API parameter</li> <li>Updated dependencies [9a55f6d]</li> <li>Updated dependencies [2a91a17] <ul> <li><code>@ai-sdk/openai</code><a href="https://github.com/3"><code>@3</code></a>.0.69</li> <li><code>@ai-sdk/anthropic</code><a href="https://github.com/3"><code>@3</code></a>.0.82</li> </ul> </li> </ul> <h2>4.0.113</h2> <h3>Patch Changes</h3> <ul> <li>Updated dependencies [c65c952] <ul> <li><code>@ai-sdk/openai</code><a href="https://github.com/3"><code>@3</code></a>.0.68</li> </ul> </li> </ul> <h2>4.0.112</h2> <h3>Patch Changes</h3> <ul> <li>53b002d: added bedrock mantle provider</li> </ul> <h2>4.0.111</h2> <!-- raw HTML omitted --> </blockquote> <p>... (truncated)</p> </details> <details> <summary>Commits</summary> <ul> <li><a href="https://github.com/vercel/ai/commit/bae9babb22e195e74a9a0c0e26a5e52c8ba8e7f2"><code>bae9bab</code></a> Version Packages (<a href="https://github.com/vercel/ai/tree/HEAD/packages/amazon-bedrock/issues/16026">#16026</a>)</li> <li><a href="https://github.com/vercel/ai/commit/9ef2c3cfadfc4a469e9eec6a6e8a0ac0fc80a1e5"><code>9ef2c3c</code></a> Version Packages (<a href="https://github.com/vercel/ai/tree/HEAD/packages/amazon-bedrock/issues/15998">#15998</a>)</li> <li><a href="https://github.com/vercel/ai/commit/f0b5c16ce5f17a7c9cc91ce0ae8f292920594e91"><code>f0b5c16</code></a> Backport: fix(provider/amazon-bedrock): detect Cohere embedding models behind...</li> <li><a href="https://github.com/vercel/ai/commit/dca8c38b09acba1a5eebf354b532833ab055413a"><code>dca8c38</code></a> Version Packages (<a href="https://github.com/vercel/ai/tree/HEAD/packages/amazon-bedrock/issues/15992">#15992</a>)</li> <li><a href="https://github.com/vercel/ai/commit/c97ede5cbbbc0aaca0137ed41c7fd6f5fedd23b6"><code>c97ede5</code></a> Backport: fix(provider/amazon-bedrock): extract Cohere embedding token usage ...</li> <li><a href="https://github.com/vercel/ai/commit/f6e588173713842794c619f9554a4b341c6e97f5"><code>f6e5881</code></a> Version Packages (<a href="https://github.com/vercel/ai/tree/HEAD/packages/amazon-bedrock/issues/15902">#15902</a>)</li> <li><a href="https://github.com/vercel/ai/commit/2a91a17e0b885968814110fe3581d1ea0fd589ae"><code>2a91a17</code></a> backport: feat(provider/anthropic): add support for <code>claude-fable-5</code> and the ...</li> <li><a href="https://github.com/vercel/ai/commit/de852ab79aac88345c8a9ae54003fb206e1a64b4"><code>de852ab</code></a> Version Packages (<a href="https://github.com/vercel/ai/tree/HEAD/packages/amazon-bedrock/issues/15821">#15821</a>)</li> <li><a href="https://github.com/vercel/ai/commit/879395199bac3796e6c34b43f6aa43ca5d682940"><code>8793951</code></a> Version Packages (<a href="https://github.com/vercel/ai/tree/HEAD/packages/amazon-bedrock/issues/15755">#15755</a>)</li> <li><a href="https://github.com/vercel/ai/commit/53b002d2d0701235026b41e0fa11aa1a41c90b8b"><code>53b002d</code></a> Backport: feat (provider/amazon-bedrock): add bedrock mantle provider (<a href="https://github.com/vercel/ai/tree/HEAD/packages/amazon-bedrock/issues/14246">#14246</a>...</li> <li>Additional commits viewable in <a href="https://github.com/vercel/ai/commits/@ai-sdk/amazon-bedrock@4.0.117/packages/amazon-bedrock">compare view</a></li> </ul> </details> <br /> [](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) --- <details> <summary>Dependabot commands and options</summary> <br /> You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show <dependency name> ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) </details> <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21569?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
fb4608e437 |
chore(deps): upgrade Tier-1 deps (googleapis 173, gaxios 7, express 5, jsdom 29, date-fns 4, stripe 20) (#21570)
## What Security-driven upgrade of the biggest-drift Tier-1 dependencies (staying on latest = staying patched). Bundled because they share the lockfile and the googleapis/gaxios pair must move together. | Package | From | To | Gap | |---|---|---|---| | googleapis | 105.0.0 | **173.0.0** | 68 majors | | gaxios | 5.1.3 | **7.1.5** | 2 majors | | express | 4.22.2 | **5.2.1** | 1 major | | jsdom | 26.1.0 | **29.1.1** | 3 majors | | date-fns | 2.30.0 | **4.4.0** | 2 majors | | date-fns-tz | 2.0.0 | **3.2.0** | 1 major | | stripe | 19.3.1 | **20.4.1** | 1 major | `yarn npm audit` reports **0 high/critical** advisories before and after. ## Code changes - **gaxios v7** — `GaxiosError.code` is now `string | number` (guard the calendar network-error check by `typeof`); `GaxiosError` config/response use `URL` + `Headers`; and crucially the v7 constructor drops `response.data` unless `bodyUsed` is set — updated the synthetic gmail error mocks accordingly (production gaxios sets it, so real error parsing is unaffected). - **google-auth-library / gaxios dedup** — `googleapis-common@8.0.2` exact-pins `google-auth-library@10.5.0` + `gaxios@7.1.3` while `googleapis` pulls `^10.2.0`; the two copies made `OAuth2Client`/`GaxiosError` type-identities diverge across every gmail/calendar service. Added two singleton `resolutions` (documented inline in root `package.json`). - **express 5** — no source changes. `@nestjs/platform-express@11.1.24` already resolves `express@5.2.1` internally; the old `4.22.2` pin was the override. - **jsdom 29** — no source changes, but it now pulls ESM-only transitive deps (`@csstools/*` `.mjs`, `parse5`, `entities`, `tough-cookie`, `@exodus/bytes`). Extended the server jest `transformIgnorePatterns` allowlist and added `.mjs` to the transform/extensions so jest can load jsdom. - **stripe 20** — `Subscription` gained a required `customer_account` field; added to mocks. No runtime changes. - **date-fns v4** — `Locale` is no longer ambient (import explicitly in 5 files); per-locale entrypoints dropped the typed `default` export (the locale loader now reads the single named export); fixed the default locale import in `formatTimeZoneLabel`. ## Tests - Full suites green locally: **twenty-server 5709 passed**, **twenty-front 4937 passed**, twenty-ui / twenty-ui-deprecated green; typecheck + builds (swc + vite) + lint all pass. - Added regression tests for the two runtime behaviors these upgrades touch and that had no coverage: - `getDateFnsLocale` — named-export locale resolution (date-fns v4). - `sanitizeFile` — jsdom 29 + DOMPurify still strips `<script>`/event handlers from uploaded SVGs (security guard). ## Deliberately deferred (not in this PR) - **stripe → 21/22**: stripe **21** bundles a runtime `Decimal` type for money fields **and** jumps the pinned API version to `2026-03-25.dahlia` (changes webhook/billing payload behavior) — too risky to fold into a deps bump on billing code. stripe **22** additionally drops the node10-resolvable `types` entry, which would force a repo-wide `moduleResolution` change. Capped at the latest clean **20.x**. - **openid-client → 6**: v6 is a full functional rewrite and its passport strategy manages the OAuth `state` internally, but our SSO flow uses `state` to carry `identityProviderId` across the shared `/auth/oidc/callback`. That needs an auth-flow redesign (session-carried provider id) on Enterprise SSO code with no integration harness — it deserves its own focused PR rather than riding along here. ## Tier-1 source Originated from a dependency-drift audit; remaining Tier-1 items (date-fns done here) plus Tier-2/3 follow-ups tracked separately. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21570?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. --> |
||
|
|
a84a4c1ab7 |
fix(server): load integration jest config transpile-only; drop tsx pin (#21563)
## Context Follow-up to [#21559](https://github.com/twentyhq/twenty/pull/21559) (the esbuild 0.28.1 security bump). That PR had to pin `tsx` to `4.21.0` to avoid a CI-only `server-integration-test` failure. This removes the need for that pin by fixing the root cause. ## Root cause The integration-test command boots jest with `NODE_OPTIONS="--import tsx/esm"`, while jest *also* compiles `jest-integration.config.ts` with **ts-node, type-checking on**. Two TypeScript transformers run over the same file: - tsx's loader transpiles `node-environment.interface.ts` via esbuild, downleveling the enum to `var NodeEnvironment = (…)(NodeEnvironment || {})`. - jest's ts-node then *type-checks that downleveled output* and rejects it with `TS7022: 'NodeEnvironment' … referenced directly or indirectly in its own initializer`. It's not a real type error and not esbuild's fault — esbuild's output is valid JS, just not valid TS to re-type-check. It only surfaced once `tsx` resolved to `4.22.x` (whose loader feeds that output into ts-node), which is why #21559 pinned tsx to 4.21.0. Verified in isolation: ts-node type-checking esbuild's downleveled enum → `TS7022`; the same under `transpileOnly`/`TS_NODE_TRANSPILE_ONLY=true` → clean. ## Fix Run the integration jest config **transpile-only** (`TS_NODE_TRANSPILE_ONLY=true` on the `test:integration` target, base + `with-db-reset`). The config file doesn't need type-checking at boot, and jest's ts-node now emits JS without re-type-checking esbuild's output — eliminating the whole class of tsx/esbuild-downleveling sensitivity. With the collision gone, drop the workaround from the root `package.json`: - removed the `tsx: 4.21.0` resolution - removed the `tsx/esbuild: 0.28.1` resolution `tsx`'s `^4.x` ranges now resolve to **4.22.4**, which pins esbuild `~0.28.0` → **0.28.1** on its own, so esbuild stays 0.28.1 across the lockfile with no resolution. The `//resolutions` doc block is updated accordingly. ## Verification - `yarn install` clean; lockfile has only esbuild 0.28.1; tsx resolves to 4.22.4. - `jest --config ./jest-integration.config.ts --listTests` with tsx 4.22.4 + `TS_NODE_TRANSPILE_ONLY=true` loads the config and lists all 420 suites. - CI `server-integration-test` is the real validator (the failure was CI-only). <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21563?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. --> |
||
|
|
25b0e4d81c |
fix: #19173 correct labels and icons for custom object default relations (#19224)
**### Problem**
When creating a custom Data Model object, the auto-generated Note and
Task relations had incorrect labels ("Note Targets", "Task Targets") and
a wrong hardcoded icon (IconBuildingSkyscraper).
Expected behavior is to use user-friendly labels ("Notes", "Tasks") and
proper icons, consistent with standard objects like Company and Person.
**Root causes:**
* `icon` in `createFieldInput` was hardcoded to
`'IconBuildingSkyscraper'`
* `label` was derived from `targetFlatObjectMetadata.labelPlural`, which
returns system labels (e.g., "Note Targets") instead of display labels
---
**Fix**
* Added `sourceFieldOverridesByRelationObjectNameSingular` map to define
correct labels and icons for all default relation types
* Ensures consistency with standard objects
Mappings:
* noteTarget: "Note Targets" → "Notes", IconBuildingSkyscraper →
IconNotes
* taskTarget: "Task Targets" → "Tasks", IconBuildingSkyscraper →
IconCheckbox
* attachment: "Attachments" → "Attachments", IconBuildingSkyscraper →
IconFileImport
* timelineActivity: "Timeline Activities" → "Timeline Activities",
IconBuildingSkyscraper → IconTimelineEvent
* favorite: "Favorites" → "Favorites", IconBuildingSkyscraper →
IconHeart
* Added type safety using:
`satisfies Record<(typeof
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS)[number], ...>`
This ensures new default relations must be explicitly defined
* Renamed variable:
`icon` → `targetFieldIcon`
for better clarity (it is only used for the target field)
---
**Limitations**
* Applies only to newly created custom objects
* Existing objects will keep incorrect labels/icons
* Requires a separate data migration to fix existing data
---
**Testing**
1. Go to Settings → Data Model
2. Create a new custom object
3. Verify:
* Labels show "Notes" and "Tasks" (not "Note Targets"/"Task Targets")
* Icons match those used in standard objects (e.g., Company, Person)
---
## Update (reworked while merging main)
The original approach was reworked:
- The label/icon mapping now lives in a shared
`STANDARD_RELATION_FIELD_PROPERTIES_BY_RELATION_OBJECT` constant
(`msg`-based, so labels stay translatable), used as the single source of
truth. Dropped the unused `favorite` entry.
- Standard objects now reference that same constant explicitly at each
call site (uniformization) instead of duplicating the values. Objects
that intentionally differ keep their explicit overrides: note/task →
`Relations`, person/workspaceMember → `Events`, workflow attachments →
`IconFileUpload`.
- Fixed an unrelated typo found along the way: Company's
`timelineActivities` icon was `IconIconTimelineEvent`.
- For the history (supersedes the "Limitations" above): added a `2.9.0`
workspace upgrade command
`upgrade:2-9:fix-standard-relation-field-labels-icons` that re-syncs
**standard** objects' default relation labels/icons against the source
of truth. It deliberately leaves **custom** objects untouched — their
relation fields are user-editable and must not be overwritten by an
upgrade.
## Testing / Verification
Verified locally end-to-end:
**New custom objects**
- Created a custom object via the Data Model UI and via the metadata API
— its note/task/attachment/timeline relations now show `Notes` / `Tasks`
/ `Attachments` / `Timeline Activities` with the correct icons instead
of `Note Targets` + `IconBuildingSkyscraper`.
**Standard uniformization (value-preserving)**
- Re-seeded a workspace on this branch and inspected all 25
default-relation field definitions across the 10 standard objects: every
canonical value is unchanged, every intentional variant (Relations /
Events / IconFileUpload) is preserved, and the only diff vs `main` is
the Company `IconIconTimelineEvent` → `IconTimelineEvent` fix.
**Upgrade command (existing workspaces)**
- Simulated a real upgrade: seeded a workspace on `main` (Company icon
typo present), created a custom object via the metadata API (it came out
with the old buggy labels, as expected on `main`), then switched to this
branch and ran the command.
- Confirmed via both the metadata API and direct DB inspection:
Company's standard `timelineActivities` icon healed to
`IconTimelineEvent`, while the custom object's relations were left
untouched.
- Idempotent: re-running reports "already up to date".
**CI**: typecheck, lint, server unit tests, and all server
integration-test shards green.
---------
Co-authored-by: Manish Kumar <manishkumar@Mac.lan>
Co-authored-by: Charles Bochet <charles@twenty.com>
|
||
|
|
0baa333809 |
feat(lint): forbid data mutations in fast instance command up() (#21547)
## Why Fast instance commands run in the ArgoCD **PreSync** hook, before the new pods roll. A bulk `UPDATE`/`INSERT`/`DELETE` held in the **same transaction** as an `ADD COLUMN`/`ALTER` keeps an `ACCESS EXCLUSIVE` lock on the table for the whole write, blocking every read of it. That is what froze prod during the 2.13 `isUIReadOnly → isUIEditable` rename — a bulk `UPDATE "fieldMetadata"` inside the same `up()` transaction as the `ADD COLUMN`s → read timeouts → failed PreSync → aborted sync. @charlesBochet already caught this exact pattern by hand on #21527 ("data migration => make a slow instance command :)"). This turns that manual review into something CI enforces. ## What New oxlint rule **`twenty/no-data-mutation-in-fast-instance-command`**: - Flags statement-leading `UPDATE`/`INSERT`/`DELETE`/`MERGE` passed to `.query(...)` **inside `up()`** of a `*-instance-command-fast-*` file. - Allows: schema DDL (`ALTER`/`CREATE`/`DROP`); `ON DELETE CASCADE` / a column named `updatedAt` (not statement-leading, so never matched); rollback DML in `down()`; and data migrations in **slow** commands' `runDataMigration()`. - The error message points the author straight at the slow-command pattern. Enabled as `error` in `twenty-server`. ## Grandfathering Scoping to `up()` means **only one** existing file violates the rule: the already-shipped 2.13 rename command. It's recorded complete in cloud and must not be rewritten, so it's grandfathered with a documented file-level `oxlint-disable` (the comment makes clear it's an exception, not a precedent). The four other fast commands that contain DML keep theirs in `down()` and are correctly unaffected. ## Tests - 9 RuleTester cases — valid: DDL, FK cascade, `updatedAt`, `down()` DML, slow-command DML, non-upgrade files; invalid: `UPDATE`/`INSERT`/`DELETE` in `up()`. - Verified end-to-end with oxlint: a throwaway violating file → 1 error; all 141 upgrade-command files → 0 errors; full oxlint-rules suite 225/225; typecheck clean. Part of the v2.13 deploy post-mortem follow-ups. https://claude.ai/code/session_013Az1etaGyxWRRVhgjhPWeB --- _Generated by [Claude Code](https://claude.ai/code/session_013Az1etaGyxWRRVhgjhPWeB)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21547?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: Claude <noreply@anthropic.com> |
||
|
|
7c0136b97b |
feat(deps): migrate frontend to React 19 (#21531)
## What Migrates the frontend stack from **React 18.3 → 19.2**. The website, sdk, companion and emails packages were already on React 19; this brings the remaining holdouts (`twenty-front`, `twenty-ui`, `twenty-ui-deprecated`, `twenty-front-component-renderer`) and `twenty-server`'s email rendering onto 19, and pins a single React version repo-wide. ## Why React 18.x is now the legacy line. Staying current keeps us on the patched/maintained branch and unblocks downstream library majors (react-router 7, mantine 9, etc.) that require React 19 peers. ## Dependency bumps (required by React 19 peers / removed APIs) | Package | From | To | Reason | |---|---|---|---| | react / react-dom | 18.3.1 | 19.2.3 | core | | @hello-pangea/dnd | 16 | 18 | peer `^18 \|\| ^19` | | react-datepicker | 6 | 9 | v<7 used removed `findDOMNode`; drops `@types/react-datepicker` | | react-data-grid | beta.13 | beta.59 | peer `^19.2`; new render API | | graphiql (+ @graphiql/react, plugin-explorer) | 3 / 0.23 / 1 | 5 / 0.37 / 5.1 | peer `^18 \|\| ^19` | | react-helmet-async | 1.3 | **@dr.pogodin/react-helmet** 3.2 | upstream caps peer at `^18`; drop-in React 19 fork | A `resolutions` pin enforces a single React (19.2.3) + `@types/react` (19.2.14) across the monorepo to avoid duplicate copies / type-identity splits. Versions are the aged lockfile patches (clears the `npmMinimalAgeGate`). ## Code changes - **Global `JSX` shim** (`react-jsx-global.d.ts` per package): React 19 moved the `JSX` namespace under `React.JSX`; several deps' published types (notably `@linaria/react`'s `styled.d.ts`, which types every `styled.x` via `keyof JSX.IntrinsicElements`) still reference the global namespace. Without the shim, every styled component degrades to `any` props. - **Ref nullability**: `useRef<T>(null)` now returns `RefObject<T | null>`; widened consumer prop/hook ref types accordingly (incl. the shared `useListenClickOutside`). - **react-datepicker v9**: `onChange`/`onSelect` accept `Date | null`, `calendarStartDay` typing, `ReactDatePickerProps`→`DatePickerProps`, relaxed the dynamic `selectsMultiple` discriminated union. - **react-data-grid beta.59**: `formatter`→`renderCell`, `editor`→`renderEditCell`, `headerRenderer`→`renderHeaderCell`, `components`→`renderers`, `onRowClick`→`onCellClick`, object-shaped `useRowSelection`, Set-based selection. - **dnd style cast**: `@radix-ui/react-popper` augments `CSSProperties` with a `--radix-*` index signature that dnd's closed `DraggingStyle` doesn't satisfy → cast at the spread. ## Status / testing - ✅ `typecheck` green: twenty-front, twenty-ui, twenty-ui-deprecated, twenty-front-component-renderer, twenty-server - ⏳ build / lint / unit tests / storybook+argos / runtime smoke-test in progress Draft until local + CI verification completes. Notable behavior to QA manually: spreadsheet import (data-grid), date pickers, drag-and-drop boards/lists, GraphQL playground, page titles/favicon. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21531?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. --> |
||
|
|
9901fa93d9 |
fix(server): write 2.13 UI capability flags directly, bypassing validation (#21543)
## Context The `v1.22 → v2.13.x` cross-version upgrade test (twenty-infra) still fails *after* #21537. This is the **same root cause from a deeper layer**, and the fix here ends the class. ## What's actually happening The 2.13 `SyncStandardUiCapabilityFlags` workspace command heals drifted `isUIEditable`/`isUICreatable` on standard metadata by running a **bulk update through `validateBuildAndRunWorkspaceMigration`** — the validation pipeline meant for *user-initiated* metadata edits. That pipeline has multiple "you may not mutate property X on entity Y" guards, and `isSystemBuild: true` only bypasses **some** of them: | guard | gated on `isSystemBuild`? | |---|---| | system-**field** allow-list (`flat-field-metadata-validator.ts:83`) | ✅ bypassed | | system-**object** guard (`flat-object-metadata-validator.ts:63`) | ✅ bypassed | | **relation-field** allow-list (`flat-field-metadata-validator.ts:143`) | ❌ not gated | So the healing command fails on exactly the workspaces that have real drift (the genuinely cross-version-upgraded ones). #21537 patched the relation allow-list by adding `isUIEditable` to it — one guard — and the build then failed on the next. From this run's logs: `Upgrade summary: 42 workspace(s) succeeded, 2 workspace(s) failed` (the 2 drifted workspaces; the build returns `status=fail`, the per-workspace error detail isn't surfaced in logs). **Root cause:** a trusted system flag-backfill should not run through the user-mutation validation layer at all. ## Fix (direct metadata write) `isUIEditable`/`isUICreatable` are UI-affordance columns on `core.fieldMetadata`/`core.objectMetadata` — changing them needs **no workspace-schema migration**. The command now writes them **directly** to those tables (mirroring the 2.13 slow backfill's raw `UPDATE core."objectMetadata"`) and invalidates the flat-metadata cache, bypassing the validation pipeline entirely. Drift detection is unchanged. This removes the whole class of guard rejections instead of patching guards one at a time. ## Verification - `nx typecheck twenty-server` ✅, `oxlint --type-aware` ✅ (the file is intentionally oxfmt-ignored via `**/upgrade-version-command/**`). - ⚠️ I could **not** run a live cross-version repro from the dev container (no Docker/Postgres available here). The fix categorically can't hit the previous failure (the validation pipeline is gone), but the definitive runtime gate is the twenty-infra `cross-version-upgrade` job against a new image. Quick local repro to confirm: on a reset dev DB, flip `isUIEditable` on a standard relation field (e.g. an `activityTargets` `target*` field) so it drifts, run `yarn command:prod upgrade:2-13:sync-standard-ui-capability-flags -w <workspaceId>`, and confirm it completes (pre-fix it threw on the relation field). --- _Generated by [Claude Code](https://claude.ai/code/session_013Az1etaGyxWRRVhgjhPWeB)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21543?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: Claude <noreply@anthropic.com> |
||
|
|
09f0c9e29a |
fix(address): coerce addressLat/addressLng to numbers in ORM result formatting (#21542)
## Fixes #21390 Saved addresses render as the **"Empty"** placeholder in the record detail / side panel when `addressLat`/`addressLng` are populated (e.g. after picking a Google autocomplete suggestion). The list view shows the address correctly. ## Root cause `addressLat`/`addressLng` are `NUMERIC` composite subfields, stored as Postgres `numeric` columns — which the `pg` driver returns as **strings** to preserve precision. The ORM result formatter already normalizes this for Currency, but not for Address. In `packages/twenty-server/src/engine/twenty-orm/utils/format-result.util.ts`, `formatCompositeFieldValue` had a case for `CURRENCY.amountMicros` (`parseInt`) but **no case for `ADDRESS`**, so coordinates were passed through as raw strings. This only breaks the **record detail**, not the list view, because: - The **standard GraphQL (Yoga) path** masks it — the `BigFloat` scalar's `serialize()` runs `parseFloat()` and quietly turns the string into a number on the wire. - The **direct-execution path** formats results itself and bypasses scalar serialization, so the string reaches the frontend. There, `addressFieldValueSchema` validates lat/lng with `z.number()` → `isFieldAddressValue` returns `false` → `isFieldValueEmpty` returns `true` → `RecordInlineCellDisplayMode` renders the placeholder. The table cell renders the value directly with no empty-check, so the list view is unaffected. ## Why it surfaced now The `z.number()` constraint on lat/lng is old ("latent since the address guard was introduced"). The trigger was **#19254 (2026-04-03) "Remove direct execution feature flag"**, which made direct execution always-on for workspace queries — the same PR added string→number coercion for aggregates but not for composite subfields. **#21033** (the PR the issue blames) only made `addressStreet1` nullable; it didn't touch lat/lng, but by fixing the overlapping null-street1 case it isolated and exposed this one. ## Fix Add the `ADDRESS` case to `formatCompositeFieldValue`, mirroring Currency. Coordinates are fractional, so `parseFloat` is used (Currency uses `parseInt` because micros are integers). This is the exact operation the `BigFloat` scalar already performs, so there is no behavior change on the standard path — it just makes direct execution consistent, and lat/lng are now numbers everywhere (matching `FieldAddressValue`). No frontend change is needed. ## Scope check — similar bugs in other field types/composites This bug class = a transforming scalar `serialize` that direct execution doesn't replicate. The only scalar that changes a pg-returned type for a real field is `BigFloat` (`NUMERIC` → number). The only `NUMERIC` fields are the two composite subfields: - `CURRENCY.amountMicros` — already handled ✅ - `ADDRESS.addressLat` / `addressLng` — fixed here ✅ Standalone `NUMERIC` is not user-creatable (it's in `SettingsExcludedFieldType`). Other scalars were checked and don't diverge: `Date.serialize` is identity; `NUMBER`/`POSITION` are stored as `float8` and returned as numbers (and `NUMBER` is already coerced in direct execution); `DATE_TIME` resolves to the same ISO string via both paths. So `ADDRESS` was the last gap. ## Tests - New `format-result.util.spec.ts`: `addressLat`/`addressLng` strings parse to numbers, already-number coordinates pass through, numeric-looking text subfields (e.g. `addressPostcode: "10001"`) are **not** coerced, and the existing Currency `amountMicros` coercion still holds. - `npx jest format-result.util.spec` → 4 passed - `npx nx lint:diff-with-main twenty-server` → 0 warnings, 0 errors - `npx nx typecheck twenty-server` → pass ## Follow-ups (not in this PR) - The cross-path parity integration test (#18972) doesn't cover a record with address coordinates — worth adding so this class can't regress. - `formatAddressDisplay` falls back to `ALLOWED_ADDRESS_SUBFIELDS` (which includes lat/lng) when a field has no `subFields` configured, unlike `getEnabledAddressSubFields` (which falls back to the text-only `DEFAULT_VISIBLE_ADDRESS_SUBFIELDS`). Harmless now that coordinates are numbers (filtered by `isNonEmptyString`), but a latent inconsistency. https://claude.ai/code/session_011pf9KQn4UDZGr4V4k8rRHh --- _Generated by [Claude Code](https://claude.ai/code/session_011pf9KQn4UDZGr4V4k8rRHh)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21542?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: Claude <noreply@anthropic.com> |
||
|
|
5d0a4b8db4 |
fix(twenty-front): keep paging record board columns past the second page (#21348)
Fixes #21355 ## Problem On a Record Board (Kanban) view, columns that contain more than 20 records stop loading at exactly 20. The initial query loads the first page, one automatic fetch brings the column to 20, and then the loading placeholder at the bottom of the column **spins forever** — scrolling all the way down triggers no further requests. Every column is permanently capped at `2 * RECORD_BOARD_QUERY_PAGE_SIZE` (20) records. ### Steps to reproduce 1. Open any object in board view, grouped by a field where at least one group has > 20 matching records. 2. Wait for the board to load — the first 20 cards in the large column appear. 3. Scroll that column to the bottom. **Expected:** more cards load as you approach the bottom, until the column is exhausted. **Actual:** the placeholder stays forever; no additional group-by request is fired. ## Root cause An **edge-triggered consumer reading a level signal that is stuck high.** The fetch-more trigger (`RecordBoardFetchMoreInViewTriggerComponent`) is an `IntersectionObserver` sentinel that writes its `inView` state into the board-level `recordBoardShouldFetchMoreComponentState`. Its `rootMargin` is: ```ts const rootMargin = `${estimatedCardHeight * RECORD_BOARD_QUERY_PAGE_SIZE * 2}px`; ``` With `estimatedCardHeight ≈ 130px` and `RECORD_BOARD_QUERY_PAGE_SIZE = 10`, that's ~2600px — roughly two pages, i.e. as tall as the entire already-loaded board. So the sentinel reports `inView = true` across the whole loaded board, and the boolean **latches `true` after the first auto-fetch and never toggles back**. The consumer in `RecordBoardQueryEffect` only reacts to the **false→true edge** of that boolean, and `triggerRecordBoardFetchMore` is a stable `useCallback`. Once the boolean is stuck `true` and the dependency array stops changing, the effect never re-runs — so it fetches exactly once. The signal is *level* ("the bottom is in view, keep loading") but it's consumed as an *edge* ("the bottom just appeared, load once"), and the oversized `rootMargin` guarantees the level is permanently high so the single edge never repeats. The large `rootMargin` is intentional prefetch buffering and is not the bug; the consumer simply needs to keep paging while the signal is high. ## Fix Make the consumer **re-arm** the trigger after every page that actually returned records: 1. `useTriggerRecordBoardFetchMore` now returns a `boolean` — `true` only once at least one column received records this round, `false` on every early-exit / empty result. 2. `RecordBoardQueryEffect` resets `recordBoardShouldFetchMoreComponentState` to `false` after a **productive** fetch. The sentinel is still inside the inflated `rootMargin`, so the observer immediately re-asserts `true`, which re-runs the effect and fetches the next page. The loop terminates naturally and never spins: - **Buffer filled** — enough cards load that the sentinel finally leaves the `rootMargin` → observer reports `false` → loop stops. As the user scrolls, it re-arms (normal infinite scroll). - **Columns exhausted** — `triggerRecordBoardFetchMore` returns `false` (per-column `shouldFetchMore` flags already get set `false` when a page returns `< PAGE_SIZE`), so the boolean is not reset and no further fetch fires — no busy-loop on a fully-loaded board. The existing `recordBoardIsFetchingMore` re-entrancy guard prevents any overlapping/double fetch during the round-trip. ## Test - `npx nx typecheck twenty-front` → passes - `npx nx lint twenty-front` (oxlint --type-aware + oxfmt) → 0 warnings, 0 errors, formatting clean - Manually verified on a board with columns of 38 and 74 records: pre-fix both froze at 20; post-fix they page to completion on scroll, and a fully-loaded board issues no extra requests. ## Notes / alternatives considered - **Shrinking `rootMargin`** would mask the bug for tall boards but defeat the intended prefetch buffering and reintroduce it whenever the buffer is smaller than the loaded content. The level/edge mismatch is the real defect. - **Moving the loop into the trigger component** was rejected — it only knows `inView`, not whether a fetch was productive or whether columns are exhausted, so self-looping there would increase coupling. The query effect is the right owner of fetch orchestration. --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: Félix Malfait <felix@twenty.com> Co-authored-by: Félix Malfait <felix.malfait@gmail.com> |
||
|
|
c05f01f2a3 |
fix(server): repair 2.13 isUIReadOnly→isUIEditable rename fallout (#21504) (#21537)
## Context Follow-up to #21504 ("Rename isUIReadOnly to isUIEditable, add isUICreatable…"), which surfaced two issues: 1. **`column FieldMetadataEntity.isUIReadOnly does not exist`** on twenty-main.com. 2. **`cross-version-upgrade` CI failure** — `SyncStandardUiCapabilityFlags` aborts the v1.22 → 2.13 upgrade. ## Fix 1 — don't drop `isUIReadOnly` in the 2.13 rename command The 2.13 fast instance command physically dropped `isUIReadOnly` from `core."fieldMetadata"` and `core."objectMetadata"`. But migrations run in an ArgoCD **PreSync** hook **before** the new pods roll out (`charts/prod-eu/apps/twenty-server` migration Job is `hook: PreSync`, sync-wave `2`; the api/worker Deployments are sync-wave `10`). So the **previous** release's pods keep serving and still `SELECT isUIReadOnly`, throwing `column ... does not exist` from the moment the column is dropped until the rollout finishes. This keeps the column (already hidden from the app via `@WasRemovedInUpgrade` on both entities) and **defers the physical drop** to a later release. Since 2.13 hasn't shipped to self-hosters yet, the committed command is amended in place. Both tables handled; `isUICreatable` (new, additive column) is unaffected. The eventual physical drop + GraphQL-compat removals are tracked in twentyhq/core-team-issues#2542. ## Fix 2 — allow `isUIEditable` updates on relation field metadata `SyncStandardUiCapabilityFlags` re-syncs `isUIEditable` on standard fields, including morph/relation fields (the activityTargets `target*` relations). The flat-field-metadata validator only permits a fixed property allow-list on relation fields, which omitted `isUIEditable`, so the command failed with `FIELD_MUTATION_NOT_ALLOWED` and aborted the upgrade (leaving workspaces in a FAILED state). `isUIEditable` is a per-field UI-affordance flag that applies to relation fields too, so it's added to the relation-field updatable properties (a constant used **only** by that validator — no diff-engine side effects). ## Coherence notes - Object-level is covered: the drop is deferred on **both** tables, and `ObjectMetadataEntity` has the identical decorators. - `isUICreatable` needs no change: object-only and additive (no drop → no rolling-deploy hazard), and never reaches the field relation allow-list. - The object-metadata validator has no relation allow-list, so there's no object-level analog to change. ## Verification - `nx typecheck twenty-server` ✅ (the `satisfies` guard holds — `isUIEditable` is a `toCompare` property of `fieldMetadata`) - `oxlint --type-aware` + `oxfmt --check` ✅ on changed files - Fix 2's path is exercised end-to-end by the `cross-version-upgrade` CI that originally caught it. --------- Co-authored-by: Claude <noreply@anthropic.com> |
||
|
|
fefb6cdb94 |
feat(page-layout): add number format option to aggregate chart widget (#21521)
## Context Closes #21522 Large values in the dashboard **Number** widget are always abbreviated (e.g. `1300090` → `1.3m`) with no way to display the full number. Following a discussion with the core team who were interested in this feature (https://discordapp.com/channels/1130383047699738754/1509604545381142649) , this adds a **Format** option in the **Style** section of the Number (aggregate chart) widget, letting users choose between **Short** (abbreviated, current behavior) and **Full** (complete number with thousand separators). Only the displayed value of the Number widget is affected — axes, labels and tooltips of other chart types are intentionally left untouched. ## What's inside **Server** - New `ChartNumberFormat` GraphQL enum (`SHORT` / `FULL`), following the `AxisNameDisplay` pattern - The existing — and previously unused — `format` field on `AggregateChartConfigurationDTO` is now typed with this enum and validated with `@IsEnum` - The dashboard AI tool schema (`widget.schema.ts`) accepts the new `format` option - Regenerated GraphQL types and the `twenty-client-sdk` metadata client to reflect the enum **Front** - New **Format** setting in the Style section of the Number widget settings, with a Short/Full selection dropdown (same pattern as the Axis name setting) - `transformAggregateRawValueIntoAggregateDisplayValue` takes an optional `numberFormat`: - `FULL` → full number via `formatNumber` (currency values keep up to 2 decimals) - `SHORT` → abbreviated via `formatToShortNumber` - not set → behavior unchanged (currency short, number full), so existing widgets and the record table/board footers render exactly as before ## Screenshots | Full UI Look | <img width="1917" height="955" alt="Twenty_Showcas_FullShort" src="https://github.com/user-attachments/assets/05d05779-395d-4e1a-8ff0-964f6fbef182" /> | Menu UI Look | <img width="291" height="308" alt="Screenshot_2" src="https://github.com/user-attachments/assets/82b5a1de-32fe-46ec-a9b8-add11ab4c6cd" /> ## Tests - Extended `transformAggregateRawValueIntoAggregateDisplayValue` unit tests with SHORT/FULL cases for currency and number fields - Updated the page-layout-widget creation/update integration tests and snapshots to use `ChartNumberFormat.SHORT` <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21521?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
5d892bdfd0 |
[WIP] Feat/marketing emails (#21173)
Marketing/campaign emails on top of the emailing-domain (SES) feature:
send a broadcast to a hand-picked list, with per-customer-domain
unsubscribe links and opt-out-only **unsubscribe topics**.
## Model
Standard objects (workspace schema, flat-metadata):
- `messageCampaign` — a campaign send (subject, body template, from
address, status, list, optional unsubscribe topic).
- `messageList` + `messageListMember` — the hand-picked audience (person
↔ list join). A campaign's recipients are its list's members; everyone
is sendable unless suppressed.
Core entities (`core` schema, workspace-scoped — readable by the public
unsubscribe flow without a workspace context):
- `unsubscribeTopic` — an opt-out-only category (name, description,
visibility). There is no opt-in subscription state.
- `messageSuppression` — the single consent store: a row with
`unsubscribeTopicId` NULL is a global block; a row with an
`unsubscribeTopicId` and reason `UNSUBSCRIBE` is a per-topic opt-out.
Two partial unique indexes dedupe global vs per-topic rows (Postgres
treats NULLs as distinct).
- `emailingDomain` — the workspace's SES sending domain,
auto-provisioned when an email channel is added (and cleaned up when its
last channel is removed), with verification status + DNS records.
Campaign messages reuse the existing `message` / `messageThread` /
`messageParticipant` model — one outbound `message` per recipient with a
`deliveryStatus` state machine.
## Sending
- `sendMessageCampaign` resolves the audience **under the caller's
permissions**, creates the campaign, and enqueues a single fan-out job
(the request never materializes per-recipient rows or jobs).
- The fan-out job materializes one QUEUED message per recipient
(deterministic ids → idempotent re-runs, reconciles crash-orphaned rows)
and fans out per-recipient send jobs carrying **only ids**.
- Each send job renders per-recipient `{{variable}}` merge fields and
sends via `EmailingDomainSenderService`, which applies suppression
(global + per-topic) and the unsubscribe footer/headers. Suppressed
recipients are recorded `SKIPPED`.
- The campaign finalizes `SENT`, or `SENT_WITH_ERRORS` if any recipient
terminally failed.
- `previewMessageCampaignAudience` returns a pre-send breakdown (total /
without-email / duplicate / globally-unsubscribed / topic-unsubscribed /
sendable), shown as a hint under the composer pickers.
## Unsubscribe
- Encrypted (AES-256-GCM) token carrying workspaceId, address, optional
`unsubscribeTopicId`, `issuedAt`, and a `preview` flag.
- One-click POST (RFC 8058) + `mailto:` — topic-scoped when the token
carries a topic, global otherwise.
- Preferences page: a checkbox per visible topic (checked = still
receiving); submitting creates per-topic opt-outs for unchecked topics
and lifts re-checked ones (UNSUBSCRIBE only — never
`BOUNCE`/`COMPLAINT`, never a global block).
- A **Preview** action in settings opens the live page via a
preview-claim token; opt-out POSTs are no-ops for preview tokens, so
previewing never mutates state.
- SES webhooks: inbound unsubscribe + outbound bounce/complaint →
suppression (race-safe against at-least-once delivery, with reason
escalation that never downgrades).
- Per-customer unsubscribe hostname (Cloudflare DNS); sends are gated on
it being active, except in LOG/demo mode.
## Architecture
Campaign orchestration, suppression, the sender, the unsubscribe
controller, and the SES webhook handlers live in `src/modules/emailing`
+ `src/modules/messaging-webhooks` (the workspace-feature layer).
`core-modules/emailing-domain` keeps the SES driver, domain
provisioning, the `unsubscribeTopic` / `messageSuppression` core
entities, and the unsubscribe token/hostname plumbing. Domain creation
is validated (`CreateEmailingDomainInput` — domain-format regex,
lowercased) before any value reaches SES or the unsubscribe hostname.
## Frontend
- Campaign composer side panel (from / list / unsubscribe topic /
subject / body) with a live audience-preview hint.
- Email settings: email channels each showing their auto-provisioned
sending domain in a single section (status + DNS records + a "Check
verification" action), plus an **Unsubscribe Topics** section to
create/manage topics and preview the recipient page. A demo-mode banner
is shown when the LOG driver is active.
---------
Co-authored-by: Félix Malfait <felix@twenty.com>
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
|
||
|
|
76f69efb43 |
Keep synced messages and events when removing a workspace member (#21443)
## Context Removing a workspace member deletes their connected accounts, which cascades into deleting every message and calendar event those accounts synced. For a CRM, losing the email history of departed teammates is a big deal. ## What this does Connected accounts are now kept and reassigned instead of deleted when a member is removed: - Ownership moves to the acting user (whoever removed the member). When members remove themselves (leave workspace, account deletion), it falls back to the oldest admin. - OAuth tokens are revoked, credentials wiped, message/calendar channels get `isSyncEnabled = false`, and the account is stamped with a new `archivedAt` column (fast instance command included). - Synced messages, threads and calendar events stay in the workspace. Channel visibility settings keep applying as before, since channels and associations survive. - The reassigned account appears in the new owner's Settings → Accounts, where it can still be deleted (with its data) like any other account. The transfer happens synchronously during removal, while the member's userWorkspace row still exists. This also removes `DeleteWorkspaceMemberConnectedAccountsCleanupJob` and its listener: the async job had to reconstruct the account-owner link from rows the removal flow had just deleted, which was race-prone (see 2181fb541e). Archived accounts are excluded from the workflow send-email default account resolution, and both removal confirmation modals now mention what happens to synced data. --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> |
||
|
|
f63f053444 |
CommandMenuItem overridable entity (#21486)
## Context Second PR of the overridable-entities track (after #21436 for views): command menu items become overridable so that edits on non-owned items are stored as overrides instead of mutating the row, and deletion/deactivation becomes reversible. ## What this does - `CommandMenuItemEntity` now extends `OverridableEntity<CommandMenuItemOverrides>` (adds `isActive` + `overrides`). All editable properties are overridable for now (to discuss). - **Update**: mutations on a command item not owned by the caller (standard items) are written into `overrides`; reads merge them in the DTO. The command palette edit mode (pin, reorder, shortLabel) now preserves standard values, "Reset label to default" gains true post-save semantics. - **Delete**: protected items are deactivated (`isActive = false`) instead of deleted; custom items still hard-delete. - **Object deactivate/enable toggle**: now flips `isActive` on the command item (merged into the main migration call) instead of delete/recreate; a create-if-missing fallback covers legacy deactivated objects. - **Front**: inactive command items are filtered out of the palette selector. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21486?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. --> --------- Co-authored-by: Charles Bochet <charles@twenty.com> |
||
|
|
036d9a2bcf |
feat: gate record creation on isUICreatable only, decoupled from isSystem (#21527)
## Context The generic "create a record" UI affordance previously required `!isSystem`, conflating two distinct concerns: **"hidden from Data Model"** and **"not user-creatable"**. This blocked legitimately creatable system objects (e.g. marketing message lists kept `isSystem: true` only to stay out of the Data Model). This PR makes creatability depend on `isUICreatable` alone, so visibility (`isSystem`) and creatability (`isUICreatable`) become independent. ## Changes - **Front gate** (`canCreateRecordsForObjectMetadataItem.ts`): drop the `!isSystem` clause — creatability is now `isUICreatable && !readOnly`. Updated comment + unit test. - **Command menu** (`standard-command-menu-item.constant.ts`): drop the matching `not objectMetadataItem.isSystem` clause from the `createNewRecord` availability expression so the "Create new X" command mirrors the front gate. Existing workspaces get this via the already-present `SyncCreateRecordCommandAvailabilityExpressionCommand`, which re-syncs from the live definition. - **Standard object audit** (`create-standard-flat-object-metadata.util.ts`): the 15 sync/system-created standard objects that relied on `!isSystem` to stay non-creatable now set `isUICreatable: false` (attachment, blocklist, calendar*/message*/note/task targets, message, messageThread, messageParticipant, timelineActivity, callRecording, workflowAutomatedTrigger, …). `workflowRun`/`workflowVersion`/`workspaceMember` were already `false`. Non-system objects (company, person, note, opportunity, dashboard, task, workflow) are untouched. - **Backfill**: new fast instance command (`2-13-…-1781277480000-backfill-non-ui-creatable-standard-system-objects.ts`) runs `UPDATE core.objectMetadata SET isUICreatable = false` for those standard system objects (symmetric `down`), registered in `instance-commands.constant.ts`. `isSystem` and Data-Model visibility logic are unchanged. ## Verification - Front gate unit test (7 pass), standard-application suite incl. callRecording (10 pass) - `oxlint` clean on all changed files - `twenty-server` and `twenty-front` typecheck green 🤖 https://claude.ai/code/session_01TF4kjD56hHjP31wkHPxPv3 --- _Generated by [Claude Code](https://claude.ai/code/session_01TF4kjD56hHjP31wkHPxPv3)_ <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21527?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: Claude <noreply@anthropic.com> |
||
|
|
d2fbc165b6 |
fix(messaging): emit channel and account deletion events from core metadata services (#21491)
/closes #21425 <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21491?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. --> |
||
|
|
869680a5a1 |
fix(deps): esbuild ^0.28.1 floors + vite 7→8 (rolldown) upgrade (#21517)
## What this does Resolves the remaining esbuild security alerts on packages we own, and upgrades the repo to **Vite 8** (which drops esbuild entirely in favour of rolldown/oxc). ### 1. esbuild → `^0.28.1` (security) - Raised the declared `esbuild` floor in `twenty-sdk` and the logic-function common-layer (both were `^0.25.0`, which can only resolve to a vulnerable version). These are our packages, so this is just declaring the patched version — clears Dependabot **#1467** and **#1468**. ### 2. Vite 7 → 8 - Bumped `vite` to `^8` in the 5 packages that declare it, and `@vitejs/plugin-react-swc` to `^4.3.1` (the only plugin that needed a bump for Vite 8; everything else already supports it). - `twenty-front` keeps esbuild minification, so esbuild is now an explicit (patched) devDependency there — Vite 8 no longer ships it. ### Two Vite-8 fallout fixes (bundler internals changed) - **Storybook tests:** added React to `optimizeDeps.include` so Vite's dep optimizer doesn't re-bundle React mid-run and break in-flight imports in browser-mode tests. - **`hex-rgb`:** it's ESM-only and broke rolldown's CJS interop (a default import resolved to the wrong thing under jest). Replaced its one use with a tiny inline hex→rgb parse and dropped the dependency. ## Verified Vite resolves to a single `8.0.16` with no esbuild in its tree. Builds pass on Vite 8/rolldown: `twenty-front` production build, the SDKs, and Storybook; the previously-failing front and storybook test jobs now pass; `yarn install --immutable` is clean. ## Note This doesn't close root alert **#1469** — esbuild is still pulled by other third-party tools (storybook, tsx, lingui, zapier, etc.) that haven't shipped a patched release. The vulnerable code path (esbuild's dev server) isn't used here, so that one is best dismissed as not-affected. |
||
|
|
050f7dcf85 |
fix(server): allow ordering operators on SELECT and RATING fields (#21506)
## Problem
On a record **show page**, prev/next navigation fails with toasts like:
```
Invalid filter : Operator "lt" is not valid for this "severity" SELECT field
```
…but only when the record is reached via in-app navigation from an index
view sorted by a SELECT field (a hard reload is clean).
## Root cause
The record show page paginates with **keyset (cursor) pagination**.
`useRecordShowPagePagination` builds `before`/`after` filters via
`computeCursorArgFilter`, which emits `{ field: { gt|lt: value } }` for
**every** field in the parent view's `orderBy`, regardless of type.
When a view is sorted by a **SELECT** field (e.g. the *All Bugs* view
sorts by `severity`), the keyset filter becomes `{ severity: { gt:
"HIGH" } }` / `{ severity: { lt: "HIGH" } }`. SELECT/RATING fields use
`ENUM_FILTER_OPERATORS` (`eq, neq, in, containsAny, is, isEmptyArray`) —
no ordering operators — so filter validation rejects them, breaking both
the neighbour queries and the rank-in-view ("X of Y") count query. A
hard reload has no parent-view `orderBy`, so neighbour queries are
skipped → no error.
This isn't a technical limitation: the column is comparable, `ORDER BY
severity` works, and the backend already emits the same comparison for
its own cursor path (`buildCursorWhereCondition`) — that path just
bypasses the user-filter allowlist. Ordering operators were omitted from
`ENUM_FILTER_OPERATORS` because they aren't meaningful *user* filters
for categorical fields, but keyset pagination legitimately needs them.
## Fix
Add `gt/gte/lt/lte` to `ENUM_FILTER_OPERATORS`, so SELECT/RATING keyset
filters are accepted — exactly as `UUID_FILTER_OPERATORS` already
carries these operators for the `id` cursor tiebreaker. The filter UI
never offers these operands for SELECT, so this only enables the
keyset/cursor use case.
## Tests
- `get-operators-for-field-type.util.spec.ts`: SELECT/RATING now include
`gt/gte/lt/lte`.
- `validate-operator-for-field-type-or-throw.util.spec.ts`: regression —
ordering operators on a SELECT field no longer throw.
|
||
|
|
1efa3567ef |
Rename isUIReadOnly to isUIEditable, add isUICreatable, expose both to app developers (#21504)
<!-- CURSOR_AGENT_PR_BODY_BEGIN -->
# UI capability flags: `isUIEditable` + `isUICreatable`
## Per-verb capability model
This PR replaces the negative `isUIReadOnly` metadata flag with
positive, per-verb capability flags (à la Salesforce
`createable`/`updateable`):
- **`isUIEditable: boolean`, default `true`** — rename of `isUIReadOnly`
with inverted polarity, on **both** `objectMetadata` and
`fieldMetadata`. It is one concept ("can the user edit this through the
generic UI?") at two altitudes, so it carries one name at both levels.
- **`isUICreatable: boolean`, default `true`** — new, **object-level
only** (fields have no create verb). When `false`, no generic UI
affordance to create a record of this object appears anywhere (table "+"
buttons, board column add, calendar add, relation-section "Add new",
record picker "Add new", command-menu create action and its keyboard
shortcut).
Both flags are **UI-affordance flags only**: the server does not block
create/edit mutations based on them, so the system, API, and workflows
continue to mutate these records freely. They are orthogonal statements
about the object's nature with no implication rule in the data model.
Because today's inline creation UX creates a blank record the user must
then edit, the frontend create predicate currently requires both
`isUICreatable` and effective editability.
There is no CREATE permission in `ObjectPermissions`; the frontend keeps
gating creation on `canUpdateObjectRecords` as a proxy, ANDed with the
new flags.
## Unified create predicate
All generic creation entry points now flow through one predicate,
`canCreateRecordsForObjectMetadataItem` (`isUICreatable` && not
`isSystem` && not effectively read-only, where effective read-only
covers `isUIEditable`, `isRemote`, and the `canUpdateObjectRecords`
proxy via `isObjectMetadataReadOnly`). This deletes the previously
hardcoded suppression lists:
- `isRecordTableCreateDisabled.ts` and its hardcoded
`WorkflowRun`/`WorkflowVersion` list — deleted; those objects (plus
`workspaceMember`) now declare `isUICreatable: false` in the standard
application instead.
- The hardcoded `workspaceMember` guard inside
`useAddNewRecordAndOpenSidePanel.ts` — deleted.
- The `CREATE_NEW_RECORD` command menu item's availability expression
now checks `objectMetadataItem.isUICreatable`, `isUIEditable`,
`isSystem`, and `isRemote`; a workspace upgrade command re-syncs the
expression in existing workspaces.
Component-local conditions (soft-delete filter active, layout
customization mode) stay in their components.
## GraphQL compatibility and removal plan
The schema delta versus main is **purely additive plus deprecations —
zero breaking changes**:
- `isUIReadOnly` remains on both the ObjectMetadata and FieldMetadata
GraphQL output types for **one release** as a deprecated field computed
as `!isUIEditable` (`deprecationReason: 'Use isUIEditable'`). The Twenty
frontend no longer queries it.
- `isUIReadOnly` also remains on the **input side** for one release
(`CreateFieldInput`, `UpdateFieldInput`, `FieldFilter`, `ObjectFilter`),
keeping the schema shape identical to main for those members. On create
it acts as a legacy alias mapped to `!isUIReadOnly` (`isUIEditable` wins
when both are provided); on update it is ignored, exactly as on main (it
was never an editable property). Filtering on the deprecated member
keeps working until the column is dropped at upgrade time; after that it
is a deprecated no-op surface kept only for schema compatibility.
**Removal plan for next release: drop `isUIReadOnly` from the output
DTOs (and resolvers' `@ResolveField`s), from the input/filter types,
from the create-input mapping, and the `@WasRemovedInUpgrade`-retained
entity columns and decorators.**
## ⚠️ Webhook / database-event payload shape change
The `database-event-payload` type in `twenty-shared` got a clean rename
(no alias): metadata snapshots in webhook and database-event payloads
now carry `isUIEditable` (and `isUICreatable` at object level) **instead
of** `isUIReadOnly`, with inverted polarity. Consumers of these payloads
that read `isUIReadOnly` must switch to `isUIEditable`.
## New manifest properties (app-developer DX)
Application developers can now set these flags in their app manifests
(purely additive — existing manifests and older `twenty-sdk` versions
are unaffected, defaults apply when omitted):
- `objects[].isUICreatable?: boolean` (default `true`)
- `objects[].isUIEditable?: boolean` (default `true`)
- `fields[].isUIEditable?: boolean` (default `true`)
The manifest converters previously hardcoded `isUIReadOnly: false`; they
now read the manifest values with `?? true` defaults. The types are
re-exported through `twenty-sdk` from `twenty-shared`.
## Migration & backfill
- One fast instance command: adds `isUIEditable` (NOT NULL default
`true`) on `core."objectMetadata"` and `core."fieldMetadata"`, backfills
`isUIEditable = false` exactly where `isUIReadOnly = true`, drops
`isUIReadOnly`, and adds `isUICreatable` (default `true`) on
`objectMetadata`. The `down` is the exact inverse. Uses `ADD/DROP COLUMN
IF (NOT) EXISTS`, matching the 2-12 drop-`isCustom` precedent. Verified
up and down in separate transactions against a dev database with exact
backfill counts.
- **Cross-version upgrade safety (multi-version self-hosted jumps):**
the upgrade sequence interleaves per version (instance → workspace
commands), so pre-2.13 workspace commands run **before** the 2.13 rename
when an old instance jumps several versions. Following the `isCustom`
precedent: `isUIEditable`/`isUICreatable` are marked
`@WasIntroducedInUpgrade` and `isUIReadOnly` stays on both entities as
`@WasRemovedInUpgrade`, so the upgrade-aware entity metadata adapter
hides the not-yet-existing columns (and keeps the legacy column live) at
pre-2.13 cursors. **No committed upgrade command outside the 2-13
directory is modified**: the old 1-21/2-8/2-9 commands keep their
original `isUIReadOnly: true` inputs, which still compile (entity
property retained, deprecated create-input alias mapped) and still
produce the correct legacy column writes pre-rename.
- A 2-13 workspace command (`sync-standard-ui-capability-flags`)
re-syncs `isUICreatable` **and** `isUIEditable` on standard objects and
`isUIEditable` on standard fields from the standard-application
definitions. This backfills `isUICreatable: false` on
`workflowRun`/`workflowVersion`/`workspaceMember` and heals fields
created mid-cross-upgrade by pre-2.13 commands (whose hidden
`isUIEditable` value cannot reach the insert). Both 2-13 sync commands
pass `isSystemBuild: true` — the flat metadata validator otherwise
rejects direct updates to system objects (verified against a
deliberately drifted dev database; the run is idempotent).
- A second 2-13 workspace command re-syncs the create-record command
availability expression.
## Testing
- Unit tests for `canCreateRecordsForObjectMetadataItem`
(flag/permission/system combinations) and for the manifest converters
(flags set / omitted → defaults).
- Full `upgrade --dry-run` boots the sequence (107 steps) and validates
the upgrade-aware decorator references; both 2-13 sync commands verified
end to end against real drift and re-run idempotently.
- Schema verified by live introspection after the input-alias restore:
all four input/filter members match main, output deprecations intact;
frontend metadata types and `twenty-client-sdk` schema regenerated from
the running server.
- Read-only-related and touched jest suites pass on both packages;
typecheck and lint pass on `twenty-server` and `twenty-front`.
<!-- CURSOR_AGENT_PR_BODY_END -->
<div><a
href="https://cursor.com/agents/bc-0f3e04cb-b04a-40be-8330-5609c4538e8a"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-web-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-web-light.png"><img
alt="Open in Web" width="114" height="28"
src="https://cursor.com/assets/images/open-in-web-dark.png"></picture></a> <a
href="https://cursor.com/background-agent?bcId=bc-0f3e04cb-b04a-40be-8330-5609c4538e8a"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cursor.com/assets/images/open-in-cursor-dark.png"><source
media="(prefers-color-scheme: light)"
srcset="https://cursor.com/assets/images/open-in-cursor-light.png"><img
alt="Open in Cursor" width="131" height="28"
src="https://cursor.com/assets/images/open-in-cursor-dark.png"></picture></a> </div>
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21504?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: Cursor Agent <cursoragent@cursor.com>
|
||
|
|
9bb98fa5b5 |
fix(billing): don't crash when workspace has no active subscription (#21510)
## Problem
Sentry (high severity, SLA-breaching): `Billing Subscription Not Found:
No active subscription found for workspace …`
The `billingSubscription` workspace-cache provider
(`WorkspaceBillingSubscriptionCacheService.computeForCache`) called
`getCurrentBillingSubscriptionOrThrow`. For a workspace whose
subscription is fully canceled, `getCurrentBillingSubscription` filters
out `Canceled` and returns `undefined`, so the provider **threw**
`BILLING_SUBSCRIPTION_NOT_FOUND`.
That cache key is read on every usage-recording path:
- workflow execution
(`WorkflowExecutorWorkspaceService.sendWorkflowNodeRunEvent`)
- AI usage (`AiBillingService`)
- logic-function execution (`LogicFunctionExecutorService`)
- app charges (`AppBillingService`)
- the gate `BillingUsageService.canFeatureBeUsed` /
`hasAvailableCredits` / `decrementAvailableCreditsInCache`
- the cancellation webhook
(`invalidateAndRecompute('billingSubscription')`)
So any of these throws an unhandled exception for a
no-active-subscription workspace. The intent was clearly to tolerate
this state — `canFeatureBeUsed` already guards with
`isDefined(billingSubscription)` and the workflow runner logs *"there is
no subscription for this workspace"* — but the throwing provider made
those guards unreachable.
## Fix
- `computeForCache` now returns `FlatBillingSubscription | null` via the
non-throwing `getCurrentBillingSubscription`, and the cache type allows
`null`.
- Every consumer guards the absent case (`isDefined` / optional
chaining) and no-ops: usage events still emit with an undefined
`periodStart`, credits aren't decremented, `hasAvailableCredits` returns
`false`.
- `getCurrentBillingSubscriptionOrThrow` is **left untouched** for the
many callers (resolver, subscription-update, etc.) that genuinely
require a subscription.
## Test
Adds `workspace-billing-subscription-cache.service.spec.ts`: the
provider returns `null` when there's no active subscription (regression)
and the flattened subscription when one exists.
All 142 tests across the billing / ai-billing / workflow-executor suites
pass; `oxlint --type-aware` and `oxfmt` are clean.
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/21510?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. -->
|
||
|
|
247e422eac |
fix(front): prevent timeline "Invalid configuration" on update events without a diff (#21460)
## Fixes #20597 ### Problem A person's (or any record's) timeline renders the whole widget as **"Invalid configuration"** when it contains an `*.updated` event without a usable `properties.diff`. The error-boundary fallback (`PageLayoutWidgetInvalidConfigDisplay`) is triggered because `EventRowMainObjectUpdated` **throws** during render: ```ts const diff = event.properties?.diff; // can be undefined const diffEntries = Object.entries(diff); // throws TypeError when undefined if (diffEntries.length === 0) { throw new Error('Cannot render update description without changes'); } ``` `filterOutInvalidTimelineActivities` only validates activities that **already carry** a diff (`canSkipValidation = !diff`), so a main-object `*.updated` event with a missing diff passes straight through to this renderer and crashes it. A single malformed row takes down the entire timeline. ### Fix Render nothing instead of throwing when an update event has no changes to show. This mirrors the sibling `EventRowMainObject` default branch (which returns `null`) and the filter's own behaviour of dropping empty diffs, and keeps one bad row from crashing the whole widget. The fix is intentionally kept in the renderer rather than the filter: the filter cannot distinguish a diff-less main-object update (must be dropped) from a diff-less `linked-task`/`linked-note` update (legitimately has `properties: {}` and renders fine via `EventRowActivity`) without duplicating routing logic. ### Test Added `EventRowMainObjectUpdated.test.tsx` — a regression test asserting the component renders nothing (no throw) for both a missing-diff and an empty-diff update event. |
||
|
|
577b22df46 |
fix(upgrade): invalidate upgrade-status cache on command end (#21497)
## Problem The "Twenty / Upgrade Status" Grafana dashboard shows stale workspace counts (e.g. `N behind / 0 up-to-date` while the instance reads `UP_TO_DATE`) that disagree with `command:prod upgrade:status`. The CLI is correct; the dashboard lags, sometimes for the full hour. ## Root cause The dashboard is fed by the `twenty_upgrade_workspaces_*` gauges, which read their workspace counts from a Redis snapshot (`UpgradeStatusCacheService`). That snapshot is only invalidated **per-command, inside the runners' `finally` blocks**. Two gaps: 1. An instance command that is already applied returns **before** its invalidation runs (`isAlreadyCompleted` early-return in `InstanceCommandRunnerService`). So a plain **redeploy** — which changes the deployed upgrade sequence, and thus the "behind" answer, without executing any command — never refreshes the snapshot. This is most visible on an instance-only release. 2. The snapshot then stays frozen until its 60-minute TTL, while the CLI reads live and disagrees. "Behind" is derived from the deployed sequence, not just the ledger, so the correct answer changes on events (deploys) that run no command — which is exactly why per-command invalidation isn't enough on its own. ## Fix Invalidate the upgrade-status cache **once, unconditionally, at the end of both upgrade entrypoints** — `run-instance-commands` (the deploy/migrate step) and `upgrade` — in a `finally`. Every run, including a no-op redeploy where all commands are already applied, now clears the snapshot, so the next gauge scrape recomputes against the current sequence. Best-effort (failures are logged, never block the command). The existing per-command invalidation is kept for mid-run progress. This keeps the read path untouched. ## Reproduction + verification (live, local) Served twenty-server (`NODE_PORT=4000`, `METER_DRIVER=prometheus`) against the seeded DB, whose latest version `2.12.0` is instance-only. 1. Froze the gauge at `behind 4 / up_to_date 0` while the DB was brought up-to-date (snapshot not invalidated) — reproduced the dashboard/CLI divergence. 2. Ran the **patched** `run-instance-commands --force`. Every step logged `already executed, skipping` — and the `finally` still deleted the Redis snapshot. 3. On the next recompute the gauge self-healed to `instance_health 1, behind 0, up_to_date 4`, matching the live CLI. With the old code the snapshot stayed frozen at `behind 4` until the TTL. |
||
|
|
ba94c3b857 |
feat(workflow): idempotent stop + retry failed runs from failing step (#21458)
https://github.com/user-attachments/assets/5a25396f-8959-4bd8-93cb-1187559ffe5f ## Summary Two workflow-run improvements, with all non-trivial logic isolated in pure, unit-tested utils. ### 1. Idempotent stop `stopWorkflowRun` no longer throws when a run is already in a terminal status (`COMPLETED` / `FAILED` / `STOPPED`) or already `STOPPING`; it returns the run unchanged. This fixes: - bulk stop aborting on the first non-stoppable run in a mixed/select-all selection, - the click-vs-processing race on a single run (run finishes between click and mutation). It also releases the cached not-started throttle slot when stopping a `NOT_STARTED` run (prevents counter drift), and ends runs with no `state` directly. ### 2. Retry a failed run from the failing step New `retryWorkflowRun` mutation (same guards/passthrough as `stopWorkflowRun`). It resets the failed step(s) to `NOT_STARTED`, flips the run to `RUNNING`, and enqueues a `RunWorkflowJob` with the steps to re-execute; downstream execution and status computation are unchanged. Logic lives in pure utils: - `build-retry-step-infos.util.ts` - decides per failed step what to reset; delegates iterator-specific logic to `build-retry-iterator-step-infos.util.ts` (an iterator that failed mid-loop is restored to `RUNNING` with cursor preserved, an iterator that failed itself restarts its whole loop). - `get-runnable-step-ids.util.ts` - reuses the executor's `shouldExecuteStep` to also resume branches that never started (avoids hangs), excluding loop-interior steps. The service method only orchestrates; the job's status check is a race guard (retriability is enforced in the service before enqueue). A "Retry" command menu item surfaces only for `FAILED` runs (`someEquals(selectedRecords, "status", "FAILED")`). ### 3. Keep the run diagram visible across regenerations The run diagram is regenerated on every run state change, producing fresh nodes without the dimensions Reactflow had measured. Reactflow hides unmeasured nodes until it re-measures them, so the diagram could flicker and disappear when the last regeneration before going idle left nodes unmeasured (reproducible after retrying a failed run). The regenerated nodes now carry over the previously measured dimensions (by id) so they stay rendered. ## Test plan - [x] Unit tests for both retry utils (9 cases: plain failed step, non-failed untouched, iterator mid-loop restore, iterator self-failure, frontier parent gating, entry steps, loop-interior exclusion, parallel branches) - [x] `twenty-server` + `twenty-front` typecheck - [x] `lint:diff-with-main` clean for both packages - [x] Manual: retry a failed run repeatedly and confirm the diagram stays visible - [ ] Manual: stop a COMPLETED/mixed selection (no error), retry a failed run and confirm it resumes from the failing step |
||
|
|
b36c0c51c3 |
fix(server): keep workflow command menu item label in sync with workflow name (#21490)
## Summary Fixes #20766 — manual-trigger workflows showed `Manual Trigger` in the command menu instead of the workflow's name. Root cause (confirmed against a live instance): the command menu item's `label` is written **only at activation** in `createOrUpdateCommandMenuItem`, from `workflow.name`, with a hardcoded `'Manual Trigger'` fallback. So: - a workflow activated while unnamed gets the misleading `Manual Trigger` label, and - renaming the workflow afterwards never updates the label (`workflow.updateOne` had no label-related hook). Changes: - Add `getWorkflowCommandMenuItemLabel` helper and use it in activation; the empty-name fallback is now `Untitled Workflow` (consistent with the rest of the UI) instead of `Manual Trigger`. - Add `WorkflowCommandMenuSyncWorkspaceService` that updates the active version's command menu item label/shortLabel from the workflow name (idempotent, no-op for non-manual / inactive workflows). - Add `workflow.updateOne` and `workflow.updateMany` post-query hooks that call the sync service, registered in `WorkflowQueryHookModule`. Out of scope (separate follow-up): the activation create path can produce duplicate command items for one `workflowVersionId`; recommend making it idempotent / adding a unique constraint. ## Test plan - [x] `oxlint --type-aware` + `oxfmt` clean on changed files - [x] Editor TS diagnostics clean (full `nx typecheck` was starved by local dev servers) - [ ] New integration test `workflow-command-menu-label.integration-spec.ts`: - labels the command menu item with the workflow name on activation - updates the label when the workflow is renamed - falls back to `Untitled Workflow` when the name is cleared <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21490?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. --> |
||
|
|
a8a8bbb2ed |
feat(workflow): add offset to Find Records node for pagination (#21484)
<img width="471" height="362" alt="Capture d’écran 2026-06-12 à 15 38 45" src="https://github.com/user-attachments/assets/9656d3a6-6f56-4587-add6-55c0a0a32482" /> ## Summary The workflow Find Records (search) node previously exposed only `objectName`, `filter`, `sort`, and `limit` (capped at `QUERY_MAX_RECORDS` = 200), with no way to page beyond the first page of results. This adds an optional **Offset** to the node so a workflow can fetch an arbitrary page (`offset = pageIndex * limit`) while keeping the same filter and sort. The underlying `FindRecordsService` already accepts `offset` (it forwards it to the query runner's `skip`, and stabilizes ordering with an `id` tiebreaker), so this change just threads `offset` through the remaining layers: - `workflowFindRecordsActionSettingsSchema` (shared zod schema) — new optional `offset` - `FindRecordsInput` type — new optional `offset?: number` - `find-records.workflow-action.ts` — forwards `offset` to `FindRecordsService.execute` - `WorkflowEditActionFindRecords.tsx` — new "Offset" number input (non-negative, defaults to 0) with form state + persistence - Default `FIND_RECORDS` step settings — `offset: 0` ### Notes / non-goals - Offset-only, single page: the node returns one page. Looping over all pages inside one run is not included (the Iterator action loops a static array and cannot re-query). The node output already returns `totalCount`, so a workflow can compute total pages as `ceil(totalCount / limit)`. - Offset on very large/changing datasets can be slow or skip/duplicate rows; cursor/keyset pagination would be a future follow-up. ## Test plan - [x] Create a Find Records node, set Limit=50, Offset=0 → returns first page - [x] Set Offset=50 with the same filter/sort → returns the second page (no overlap) - [x] Negative offset shows a validation error and is not saved - [x] Existing Find Records nodes (no offset stored) still run, defaulting to offset 0 - [x] Typecheck/lint pass in CI <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21484?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. --> |
||
|
|
49026a7368 |
chore: bump version to 2.13.0 (#21492)
## 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/21492?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> |
||
|
|
7b6b624041 |
fix(server): bypass stale workspace cache when resolving currentUser during onboarding (#21461)
## Context On twenty-main (multi-replica), signing up and naming a workspace lands on a black screen at `/create/profile`; a manual refresh fixes it. From the network trace: the post-activation `currentUser` response carries `onboardingStatus: PROFILE_CREATION` (fresh) together with a non-ACTIVE `currentWorkspace` and `workspaceMember: null` (stale). ## Root cause `activateWorkspace` invalidates the core entity cache only on the instance that served the mutation. When the follow-up `currentUser` query is routed to a sibling instance, the auth context carries a memoized pre-activation workspace snapshot. A stale transient workspace cascades: - `workspaceMember`/`workspaceMembers` resolve to null/empty (`loadWorkspaceMember` skips non-active workspaces), permissions fall back to defaults - the client's metadata store never loads (`MinimalMetadataLoadEffect` skips non-active workspaces), so `MinimalMetadataGater` shows the loading skeleton forever on `/create/profile` #20322 fixed the same staleness for `onboardingStatus` by reading the workspace fresh from the database in the resolver — which is why the status is fresh while the workspace object isn't, and why the client navigates to a page it can't render. #21480 bounds the staleness window to the designed 10s (absolute memoizer TTL), but signup lives entirely inside that window: the client reads `currentUser` ~1s after `activateWorkspace` and never refetches while stuck. ## Fix Apply the #20322 approach at the workspace status resolution layer: `UserService.refreshWorkspaceIfPendingOrOngoingCreation` re-reads the workspace from the database when the auth-context copy is in a transient activation status (`PENDING_CREATION`/`ONGOING_CREATION`). Used in: - `UserResolver.currentUser` — fresh `currentWorkspace` and permissions - `UserService.loadWorkspaceMember` / `loadWorkspaceMembers` — covers the `workspaceMember`/`workspaceMembers` resolve fields No-op for active workspaces; the extra database read only happens for workspaces mid-creation. ## Test plan - Full `user.service.spec.ts` suite passes; lint and format clean. - After deploy to twenty-main: sign up, name the workspace, verify `/create/profile` renders the profile form with a populated `workspaceMember` and ACTIVE `currentWorkspace` without refreshing. |
||
|
|
214dc70b67 |
Fix missing WasIntroducedInUpgrade for overridable view entity (#21483)
<!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21483?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. --> |
||
|
|
08a36aa68f |
fix(server): restore absolute TTL in PromiseMemoizer (#21480)
## Context `PromiseMemoizer` sits in front of the staged lookup (local cache → Redis hash validation → Redis data → DB recompute) of both `CoreEntityCacheService` and `WorkspaceCacheService` (10s TTL each). The Redis hash check is the **only** cross-instance invalidation mechanism — there is no pub/sub — and it runs only when the memo entry expires. The TTL is currently **sliding**: every read refreshes `lastUsed`, and eviction compares against time-since-last-read. So any entry read more often than every 10s on a given instance never revalidates, and that instance serves stale data for as long as traffic continues. Affected data: auth-context entities (workspace, user, userWorkspace), API key revocations, role/permission maps, RLS predicates, feature flags, and all metadata maps. Observed manifestation: after `activateWorkspace`, a sibling instance kept serving a `PENDING_CREATION` workspace snapshot (kept alive indefinitely by the client's own polling), stranding signup on a permanent loading skeleton at `/create/profile` (#21461). Same staleness class as #20322 and the CI flakes investigated in #21435. ## Why it was sliding #11444 (April 2025) deliberately changed the TTL from absolute to sliding because the memoizer's then-consumer was the TypeORM datasource storage: absolute expiry was destroying datasources that were actively in use (`onDelete` → `destroy()`), causing worker `Connection terminated` errors. That consumer no longer exists — datasources moved to `GlobalWorkspaceOrmManager`, and neither remaining consumer passes `onDelete` or holds resources needing keep-alive. ## Fix Restore absolute expiry: `expiresAt` is set at write time and never refreshed on read. Every instance now re-enters the staged lookup (and thus the Redis hash validation) at least once per TTL, restoring the designed ≤10s cross-instance staleness ceiling. Concurrent dedup (`pending` map) and `onDelete` plumbing are unchanged. ## Test plan - New regression test: reads at half-TTL intervals must not extend an entry's lifetime (fails on the sliding implementation, passes now). - Full `promise-memoizer.storage.spec.ts` and `workspace-cache.service.spec.ts` suites pass (25 tests); lint and format clean. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/21480?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. --> |
||
|
|
e334551da9 |
(Fix) Upsert no longer rewrites position on existing records (#21375)
## Fix: upsert no longer rewrites `position` on existing records ### Problem `createX(..., upsert: true)` resets the `position` of records that resolve to an **update**, even when the payload doesn't include a `position`. The create-many/upsert runner backfills `position` (to `"first"`) in `computeArgs` over the **whole batch**, before records are split into insert vs update. So existing rows get a freshly recomputed `position` written on every upsert. For callers that re-upsert their full dataset on a schedule (e.g. a daily sync), this rewrites `position` for every record on each run and drifts the values steadily negative — and it floods audit/event logs with position churn. The dedicated `updateOne`/`updateMany` runners already pass `shouldBackfillPositionIfUndefined: false`; the upsert path did not. ### Fix Only backfill `position` for records that are actually inserted: - `computeArgs` now passes `shouldBackfillPositionIfUndefined: !args.upsert` in both the create-many and create-one runners, so undefined positions are left untouched on upsert. - `performUpsertOperation` backfills `"first"` positions for `recordsToInsert` only, **after** categorization, via `RecordPositionService`. Explicit `position` values (`"first"`, `"last"`, or a number) in the payload are still honored. Plain (non-upsert) create behavior is unchanged. ### Behavior | Scenario | Before | After | |---|---|---| | Upsert updates existing row, no `position` sent | `position` rewritten | `position` untouched | | Upsert inserts new row, no `position` sent | gets `"first"` | gets `"first"` (unchanged) | | Explicit `position` on upsert | applied | applied | | Plain create | unchanged | unchanged | |
||
|
|
fefd9d7704 |
feat(workflow) - Add validation layer (#21422)
Add workflow validation framework and consolidate output schema types/search logic into twenty-shared This PR introduces a comprehensive workflow validation system that catches configuration errors at build-time, and consolidates the fragmented output-schema type definitions and variable-search logic from the front-end into twenty-shared **Workflow validation** — A new system that checks workflows for errors before activation: graph connectivity (unreachable steps, dangling references), step parameter schemas (via Zod), variable references (typos, wrong step order), and workspace metadata (non-existent objects). Returns structured errors/warnings with "did you mean?" suggestions. Runs automatically after create_complete_workflow and update_workflow_version_step, and is also available as a standalone validate_workflow tool. **Output schema consolidation** — Moves all output schema types and the variable-search logic from scattered front-end files into twenty-shared, replacing ~800 lines of duplicated per-schema-type code with a single unified searchVariableInOutputSchema dispatcher. To do : - validation on CODE and AGENT step --------- Co-authored-by: Cursor <cursoragent@cursor.com> |
||
|
|
e6d730cd75 |
chore: sync AI model catalog from models.dev (#21476)
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/21476?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> |
||
|
|
69a7e614ff |
fix: restore isCustom gate in metadata label resolvers (#21432)
## Context #21228 removed the stored `isCustom` column and, with it, the `isCustom` early-return in `resolveObjectMetadataStandardOverride` / `resolveFieldMetadataStandardOverride`, on the assumption that falling through the `standardOverrides` checks was equivalent. It isn't: custom object/field labels now reach the Lingui lookup. A custom label that collides with a standard catalog string (e.g. a custom field labeled "Status") gets translated for non-English locales against the user's intent, and every other custom label pays a hash + catalog miss — and, in production, an "Uncompiled message detected" warning (#21415) — on each metadata resolution. ## Fix Restore the gate. `isCustom` is no longer stored, so call sites that build the resolver input from flat entities (dataloader, minimal-metadata, view controller, command-menu-item navigation context) derive it via `belongsToTwentyStandardApp`; GraphQL resolvers keep passing DTOs, which already carry the derived value. ## Testing - Unit tests for both resolvers, including a new regression test: a custom label matching a standard catalog entry is returned verbatim, Lingui never called. --------- Co-authored-by: Weiko <corentin@twenty.com> |
||
|
|
cfb9772179 |
feat(server): convert view to overridable entity (#21436)
## Context Every entity created as a side effect of object creation must support the overridable pattern (`isActive` + `overrides` + override routing) before we can re-own side effects to their true application. Starting with View. viewField, viewFieldGroup, pageLayoutTab and pageLayoutWidget already extend `OverridableEntity`. This PR brings `view` to the same pattern. ## What this does - `ViewEntity` now extends `OverridableEntity<ViewOverrides>` (adds `isActive` boolean + `overrides` jsonb). All editable view properties are overridable; the 3 fieldMetadata foreign keys are converted to/from universal identifiers like viewField's `viewFieldGroupId`. - **Update**: mutations on a view not owned by the caller (e.g. standard views like "All Companies") are written into `overrides` instead of mutating the row. Reads merge overrides in the DTO. - **Delete/destroy**: views not owned by the caller are deactivated (`isActive = false`) instead of deleted. ~~- **INDEX invariant**: `key = INDEX` views can only be created via object-creation side effect. The API now rejects creating, deleting or destroying INDEX views (object-deletion cascade is unaffected). This was not really needed for this migration but was flagged during implementation.~~ - **Front**: views with `isActive = false` are filtered out of the views selector. - Fast instance command adds the two columns (`2-12-instance-command-fast-...-view-overridable-entity.ts`). ## Notes - Custom (caller-owned) views behave exactly as before: direct updates, soft delete. - View-group side effects (kanban groups) are computed on the override-merged view so overridden `mainGroupByFieldMetadataId` works. |
||
|
|
41cdd83367 |
fix(ai): correct RICH_TEXT and MORPH_RELATION record filter operators (#21106)
## Problem
The AI find-records tool generates filter schemas via
`generateFieldFilterZodSchema`. `RICH_TEXT` currently shares the `TEXT`
case, so the agent is told it can use scalar text operators
(`like`/`ilike`/`startsWith`/`endsWith`/`eq`/…) directly on a rich-text
field.
But `RICH_TEXT` is a **composite** type (`markdown` + `blocknote`
sub-fields, see `rich-text.composite-type.ts`). Applying a scalar
operator to the composite root throws at query time:
```
ERROR [FindRecordsService] Failed to find records: Object person doesn't have any "ilike" field.
ERROR [FindRecordsService] Failed to find records: Sub field "ilike" not found for composite type: RICH_TEXT
```
`FindRecordsService` catches and returns `success: false`, so the agent
retries mid-turn — burning latency/tokens — and can **never** search
rich-text body content (note bodies, `about`, etc.).
## Fix
Give `RICH_TEXT` its own case in the filter-schema generator that
exposes the `markdown` and `blocknote` sub-fields, each carrying the
text operators — mirroring the existing composite patterns for `EMAILS`
(`primaryEmail`), `PHONES` (`primaryPhoneNumber`), `LINKS`
(`primaryLinkUrl`), `FULL_NAME`, and `ADDRESS`.
So the agent now emits:
```jsonc
{ "noteBody": { "markdown": { "ilike": "%onboarding%" } } } // valid composite sub-field filter
```
instead of:
```jsonc
{ "noteBody": { "ilike": "%onboarding%" } } // throws on composite root
```
This both **stops the throw** and **makes rich-text content actually
searchable** (the original intent). `TEXT` keeps its existing root-level
scalar operators unchanged.
## Test
Added `__tests__/field-filters.zod-schema.spec.ts`:
- `RICH_TEXT` routes pattern operators onto `markdown` / `blocknote`
- root-level scalar operators on `RICH_TEXT` are no longer accepted
- `TEXT` root-level operators unchanged
## Notes
- No DB/schema migration; render/tool-schema layer only.
- Reproduced against `twentycrm/twenty:latest`; the faulting code is
unchanged on `main` as of this PR.
---------
Co-authored-by: Rich Roberts <rich.roberts@talentpipe.ai>
Co-authored-by: Etienne <45695613+etiennejouan@users.noreply.github.com>
|
||
|
|
503c689f37 |
security: upgrade typeorm to 0.3.26 (CVE-2025-60542) (#21456)
## Context Retry of the typeorm upgrade that was pulled out of #21448 after CI showed "intermittently lossy metadata sync". **The investigation exonerated typeorm**: the postcard/seed failures were a pre-existing bug in `@ptc-org/nestjs-query-typeorm`'s batched relation paging (global LIMIT across parents) that scan-order luck had been hiding — reproduced byte-for-byte on typeorm **0.3.20** against a frozen repro DB. That bug is fixed in #21455, which this PR is stacked on (base branch = `charles/fix-nestjs-query-batch-relation-paging`; will retarget to main when it merges). ## Changes - typeorm `0.3.20` → `0.3.26` ([CVE-2025-60542](https://github.com/advisories/GHSA-q2pj-6v73-8rgj), MEDIUM). The CVE lives in TypeORM's MySQL path (`sqlstring`/`stringifyObjects`); Postgres-only Twenty never exercises it — this is scanner hygiene + staying current. - The local yarn patch (`PickKeysByType` + `DeleteResult.generatedMaps`) applies **verbatim** to 0.3.26 (verified against the pristine tarball) — renamed to `typeorm+0.3.26.patch`. - `WorkspaceRepository.query` restricted override adapted to the generic `query<T = any>()` base signature introduced in 0.3.24 (one-line change, still throws `RAW_SQL_NOT_ALLOWED`). - 0.3.26 ships `uuid ^11` natively → the scoped `typeorm/uuid` resolution from #21441 and its `//resolutions` comment clause (including the now-disproven "lossy sync" warning) are removed. ## Why we're confident this time The original failure signature was fully understood, not just retried: - On a frozen failing DB, **all fieldMetadata rows + workspace columns were intact** — only the batched metadata API read was truncated (`LIMIT 501` over 558 rows, no ORDER BY). - Same DB, typeorm 0.3.20: identical truncation, identical SQL → not a typeorm regression. - With #21455 applied: postcard install/uninstall stress loop **12/12 green on typeorm 0.3.26** (previously failed within 1–2 iterations), API returns 558/558 fields. ## Verification - `npx nx typecheck twenty-server` — clean - Full `twenty-server` unit suite — green (5651 passed) - `group-by-resolver` integration suite — 19/19 on a fresh 0.3.26-seeded test DB - Postcard app-sync stress loop — 12/12 on this exact stack - Lockfile: typeorm 0.3.26 + new `sql-highlight` dep, `esbuild`/uuid entries untouched |
||
|
|
d75685b8dc |
fix(metadata): nestjs-query batched relation queries truncate results across parents (#21455)
## TL;DR
The metadata API silently drops relation rows whenever a batched
relation query exceeds the requested page size. A dev-seeded workspace
already has **558 fieldMetadata rows across 31 objects**, so
`objects(paging:{first:50}) { fields(paging:{first:500}) }` executes:
```sql
SELECT DISTINCT ... FROM core."fieldMetadata" fields
WHERE workspaceId = $1 AND objectMetadataId IN (...31 ids...)
LIMIT 501 OFFSET 0 -- no ORDER BY
```
…and returns exactly **501 of 558** fields — ~57 rows dropped, and
*which object loses which field is scan-order-dependent*. This is what
made `example-app-postcard` CI flap with "PostCard object missing field
X" (different X per run).
## Root cause
`@ptc-org/nestjs-query-typeorm`'s `batchQueryRelations` (the DataLoader
batch path behind every `@CursorConnection`) applies the **per-parent**
page size as a **single global LIMIT** on the batched query, then groups
rows per parent in memory. Any batch whose combined relation rows exceed
`first + 1` truncates arbitrary parents. This affects production
metadata reads, not just CI — any workspace with enough fields/objects
loses rows in `objects.fields`-style connections.
## Fix
Yarn patch on `@ptc-org/nestjs-query-typeorm@9.4.0` (same vehicle as the
existing `nestjs-query-graphql` patch):
- `RelationQueryBuilder.batchSelect`: only apply LIMIT/OFFSET when the
batch has a **single parent**; multi-parent batches stay **bounded**
with `parents × (offset + limit)` — the upper bound a correct per-parent
pager can ever need, so it cannot wrongly truncate while still guarding
against unbounded fetches on high-cardinality relations;
- `batchQueryRelations`: enforce paging **per parent** by slicing after
`mapRelations` (preserves the `first + 1` hasNextPage probe semantics).
## Verification
- On a frozen repro DB (postcard installed, 558 fields): unpatched
returns 501 fields with `postCard` missing `deliveredAt`; patched
returns **558/558** with the full `postCard` field set. Reproduced
identically on typeorm 0.3.20 and 0.3.26 — pre-existing bug, **not** a
typeorm regression (this unblocks the typeorm upgrade that was reverted
from #21448).
- Postcard install/uninstall stress loop: unpatched fails within 1–2
iterations; patched **12/12 green**.
- `npx nx typecheck twenty-server` clean, full `twenty-server` unit
suite green (5651 passed).
## Related
#21435 chases the **same CI symptom** (postcard randomly missing a
freshly synced field) at a different layer — a workspace-cache write
racing invalidation. The two are complementary: the repro behind this PR
survives a **cold server restart + `redis-cli FLUSHALL`** with all rows
intact in Postgres, which no cache race can explain — the truncation
happens on the DB read itself (`LIMIT 501` over 558 matching rows,
captured via `log_statement=all`). Both fixes are likely needed for the
postcard job to be fully reliable.
## Notes
Worth upstreaming to `@ptc-org/nestjs-query` eventually; the proper
upstream fix is per-parent windowed pagination (`ROW_NUMBER() OVER
(PARTITION BY parentId)`), but the in-memory per-parent slice is correct
and proportionate at metadata-API scale.
|
||
|
|
d0884bd708 |
Fix missing datetime filter type (#21451)
Currently datetime fields are only typed to be filtered by string Add a proper typing to match gql filters ## Before <img width="750" height="492" alt="image" src="https://github.com/user-attachments/assets/ff3a5423-3bb0-4295-84c9-e404489354f6" /> ## After <img width="537" height="511" alt="image" src="https://github.com/user-attachments/assets/d8c8219f-b7de-41b0-96cb-5adbfda7a91d" /> |
||
|
|
0ac4f237c0 |
fix(server): stop redundant lambda rebuilds causing build-lock acquisition failures (#21442)
## Context `Lambda invocation failed for function '<id>' during build: Failed to acquire lock for key: lambda-build:<id>` fires ~1000 times/day in production. ## Root cause `LambdaExecutorManagerService.buildExecutor` re-checks `canSkip` inside the `lambda-build:<functionId>` lock, but the re-check reuses the `flatApplication` snapshot captured when the request started. `canSkip` depends on `!flatApplication.isSdkLayerStale`, so: 1. An app sync/install regenerates the SDK client and sets `isSdkLayerStale = true` 2. All in-flight executions of the function fail `canSkip` and queue on the lock 3. The first holder rebuilds and `markSdkLayerFresh` clears the flag in DB + workspace cache 4. Queued waiters can't see that fix — their in-memory snapshot still says stale — so **each waiter redoes the full rebuild serially** (download SDK archive, delete + republish layer, update function config, wait for update) 5. The lock is held back-to-back for minutes; everyone deeper in the queue exhausts the 120s retry budget and throws The local driver already handles this correctly (`LocalLayerManagerService` refreshes the flat application from the workspace cache inside its lock); the lambda driver missed it. ## Fix - Refresh `flatApplication` from the workspace cache inside the lock before re-checking `canSkip`, so waiters skip in ~100ms once the first holder finishes - Degrade gracefully on lock-acquisition timeout: re-check build status with fresh data and proceed with the invocation if the executor is already usable, instead of failing the run (introduces a typed `CacheLockAcquisitionError` so only that case is caught) ## Test plan - [x] `cache-lock.service.spec.ts` passes - [x] `lint:diff-with-main` + typecheck pass - [ ] Monitor `Failed to acquire lock for key: lambda-build:*` error rate in production after deploy |