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