SIGN IN SIGN UP

Declare the objectMetadata -> pageLayout one-to-many so page layouts resolve through an aggregator (#24694)

Closes twentyhq/core-team-issues#2788.

Page layouts had no foreign key aggregator on the object flat entity, so
anything needing "the layouts of this object" scanned the entire
workspace page layout map. This declares the relation and switches those
lookups to index into the flat maps.

Scoped to the record page layout: the relation itself and the reads it
unlocks. Nothing here touches page layout tabs or widgets.

## The relation

`ObjectMetadataEntity` gains a `@OneToMany` to `PageLayoutEntity`,
mirroring how `views` / `indexMetadatas` / `searchFieldMetadatas` are
already declared. `FlatObjectMetadata` then picks up `pageLayoutIds` and
`pageLayoutUniversalIdentifiers` automatically from the mapped types,
and `pageLayout.objectMetadata.inverseOneToManyProperty` goes from
`null` to `'pageLayouts'`, so the generic add/delete machinery maintains
the aggregator on its own.

The aggregators are populated in
`fromObjectMetadataEntityToFlatObjectMetadata` and in
`WorkspaceFlatObjectMetadataMapCacheService`. Workspace-level layouts
keep a null `objectMetadataId` and stay excluded, since
`regroupEntitiesByRelatedEntityId` skips null foreign keys.

No column migration: the FK and its index already exist. Verified
against a live database — `database:migrate:generate` reports *"No
changes in database schema were found - cannot generate a migration."*

## Scans replaced

Both of these need `pageLayoutIds` / `pageLayoutUniversalIdentifiers`,
which did not exist before this PR.

| Site | Before | After |
| --- | --- | --- |
| `computePageLayoutToDelete` | scan every workspace layout | index
`pageLayoutUniversalIdentifiers` |
| `PageLayoutService.findBy` / `findManyPaginated` | scan every layout
even when filtering on one object | index `object.pageLayoutIds` |

`findBy` / `findManyPaginated` now read `flatObjectMetadataMaps`. That
is close to free rather than a new cost: the key is already resident in
the shared per-workspace local cache (it is read on essentially every
REST and GraphQL record request), so it never idles the 60s that packing
requires and resolves live from memory instead of being refetched or
deserialized. Within the 100ms local TTL the read costs nothing; past it
the key just joins the existing hash `mget`. The full scan is kept for
when no `objectMetadataId` is given.

## Validation fix

Declaring the inverse relation means adding a flat page layout to the
optimistic maps now resolves its object to push onto the aggregator. A
layout whose object had itself failed creation validation used to slip
through to that step and surface as an internal error, turning 17
expected `METADATA_VALIDATION_FAILED` results into 500s.
`FlatPageLayoutValidatorService.validateFlatPageLayoutCreation` now
rejects a layout whose object reference does not resolve, the way
`validateFlatViewCreation` already did. The check is skipped for
workspace-level layouts.

This is a required consequence of the relation, not an optimization.

## Deliberately out of scope

An earlier revision of this branch also indexed page layout tabs through
`pageLayout.tabIds` and widgets through `pageLayoutTab.widgetIds`, in
`PageLayoutTabService`, `PageLayoutDuplicationService` and the two
reconstruct utils. That was reverted. Those aggregators already existed
on `main` and are unrelated to the inverse key introduced here.

The widget side is the more interesting reason to leave it alone:
`pageLayoutTabId` is in `PageLayoutWidgetOverrides`, so a widget can be
moved to another tab by an override and no aggregator reflects resolved
placement. Indexing it correctly needed a hand-rolled grouping map — a
stand-in for an aggregator that does not exist. That belongs in whatever
gives tabs and widgets their own inverse foreign keys, not here.

Upgrade commands were left untouched as well, since committed command
logic is not rewritten.

## Follow-ups this surfaced

- **Page layout tab and widget inverse keys.** The natural sequel, and
the right home for override-aware resolved placement.
- **A pre-existing tab/widget bug**, which a review bot flagged on the
reverted code and which still exists on `main`:
`reconstructFlatPageLayoutTabWithWidgets` matches the raw
`pageLayoutTabId`, while `reconstructFlatPageLayoutWithTabsAndWidgets`
and `PageLayoutWidgetService.findByPageLayoutTabId` both resolve the
override. An overridden widget therefore appears under the wrong tab in
the tab APIs. Best fixed structurally alongside the point above rather
than with another grouping pass.
- **Three more relations carrying `inverseOneToManyProperty: null`**
whose consumers scan the whole map, each fixable in exactly this PR's
shape: `searchFieldMetadata.tsVectorFieldMetadata`
(`getTargetSearchFieldMetadatasForTsVectorField`), and
`commandMenuItem.availabilityObjectMetadata` /
`navigationMenuItem.targetObjectMetadata`, which is the second bucket
the issue mentions.
- `validate-chart-configuration-field-references.util.ts` builds a
`fieldsByObjectId` over every workspace field on each widget validation,
but it genuinely needs cross-object reach for morph target resolution,
so it is a larger refactor than an aggregator swap.

## Tests

This PR adds no new spec files; it relies on existing coverage.

- 4 registry snapshots updated, which is what pins the change: the new
relation and its aggregator names appear in the one-to-many registry,
the universal aggregator properties, and the object metadata
related-names snapshot, and `objectMetadata` moves one position later in
the children-first ordering now that it has a seventh one-to-many
- `npx jest --config=packages/twenty-server/jest.config.mjs`: 6491
passed, 15 skipped, 108 snapshots
- Integration, on a freshly reset database, across page layout / object
metadata / view / dashboard suites: 104 suites, 490 tests, all passing.
This is what covers the delete side effect and the cache service
aggregator population end to end
- `npx tsgo -p tsconfig.json --noEmit` clean, `nx lint:diff-with-main
twenty-server` clean

Worth a reviewer's attention: the aggregator walk in
`computePageLayoutToDelete` and the converter's aggregator population
have no direct unit test, so integration is the only behavioural check
on them.

The full integration suite was not run to completion locally — it
exhausted the 6 GB heap cap of the dev container at 144 suites — hence
the scoped run above. The failures seen before that point were
ClickHouse (port 8123 down), local Postgres collation ordering, and
caldav/imap, none of them in the paths this touches, but CI is the real
check.
P
Paul Rastoin committed
85360a7fc0c9ed07756d8caf327fdb542fe1a10f
Parent: 8548895
Committed by GitHub <noreply@github.com> on 8/24/2026, 2:50:27 PM