feat(metadata): deterministic universalIdentifiers for server-generated side-effects (#21949)
## Context
Server-generated "side-effect" entities created for every object (system
fields, INDEX view, record-page fields view + view fields, search-vector
index, navigation command, record page layout/tabs/widgets) were minted
with random v4() ids. Because they were non-deterministic, nothing could
reference them by id (e.g. point a view field at an object's createdAt
field).
This PR introduces a single shared rule for deriving these ids
deterministically via uuid v5, so the same (owner app, parent, kind)
always yields the same id, making side-effects referable and
reproducible.
This is the **forward-only foundation** (PR1). Follow-ups:
- PR2: SDK with optional universalIdentifier + expose helpers to app
authors.
- PR3: regenerate the standard-app constants to the same scheme +
workspace backfill.
## The rule
```ts
universalIdentifier = computeOwnerScopedUniversalIdentifier({ ownerAppUID, namespace, value })
= v5(value, v5(ownerAppUID, ENTITY_TYPE_NAMESPACE))
value = `${parentUID}:${discriminator}` // entity scoped under a parent
= `${discriminator}` // top-level, app-parented entity
```
- ownerAppUID: The application that owns the entity (already threaded
through every generator as applicationUniversalIdentifier); folded into
the namespace so it both owns and scopes
the id — two apps adding the same-named entity to a shared parent never
collide.
- namespace: Per entity type (ENTITY_TYPE_NAMESPACE_BY_TYPE), so
different types with the same parent+discriminator never collide.
- parentUID: The immediate parent's actual universalIdentifier (omitted
for top-level entities, since the owner app already scopes them).
- discriminator: A stable semantic key (field name, tab/widget title,
generated index name, select-option value, …).
Scope boundary: deterministic v5 applies to system side-effects (unique
by construction) and, later, app-authored manifest entities (uniqueness
enforced at SDK build time).
Entities created through the UI by the workspace "Custom" app (custom
objects/views/fields) keep v4, their natural keys aren't unique and
aren't enforced. A UI-created custom object keeps its v4 id; its
side-effects are deterministic relative to that v4 parent.
Changes
twenty-shared: new application/deterministic-identifier/ module:
- computeDeterministicUuid(value, namespace) primitive + a thin
computeOwnerScopedUniversalIdentifier wrapper (boilerplate only), and
frozen ENTITY_TYPE_NAMESPACE_BY_TYPE.
- One self-contained util per usecase (no central registry, no generic
engine): each util bakes in its own discriminator + namespace, so a key
lives next to the code that uses it and is individually testable. ~28
utils covering side-effect and (future) app-authored entities, e.g.
getFieldUniversalIdentifier, getIndexViewUniversalIdentifier,
getFieldsWidgetViewUniversalIdentifier, getViewFieldUniversalIdentifier,
getIndexUniversalIdentifier, getRecordPageLayoutUniversalIdentifier,
getPageLayoutTab/WidgetUniversalIdentifier,
getNavigationCommandUniversalIdentifier, plus the general
getViewUniversalIdentifier / getPageLayoutUniversalIdentifier and
app-authored
getObject/Role/PermissionFlag/Agent/Skill/…UniversalIdentifier.
- Golden snapshot test locking every util's output for fixed inputs,
plus a cross-type no-collision test.
twenty-server: side-effect generators now derive universalIdentifier via
the helpers (local id PKs stay v4()): system fields + name, INDEX view,
record-page fields (fields-widget) view, default view fields,
search-vector index, nav command, page layout/tabs/widgets. Index ids
key off the generated Postgres index name; extracted
computeFlatIndexNameOrThrow so the name (and therefore the id) is
computed once with no placeholder.
## Timeline
### What actually changes
- New objects (custom objects created via Settings/metadata API) and
fresh standard installs now get deterministic v5 universalIdentifiers
for all side-effect entities (system fields,
views, view fields, search index, nav command, page layout/tabs/widgets)
instead of random v4().
- The nav-command id formula changed (new owner-scoped) for new objects,
fresh standard installs, and the runtime lookup.
### What does NOT change
- Existing objects' side-effect ids — untouched (no migration;
forward-only).
- Standard object UIDs — untouched
- UI-created custom entities' own ids stay v4 (see scope boundary
above).
- Fresh installs are behaviorally a no-op — ids are internal; re-sync
produces no diff (verified). Nothing user-visible.
### The one real-world impact / risk (existing workspaces)
The nav-command runtime lookup (findNavigationCommandMenuItemForObject)
now computes the new formula, but existing workspaces' nav commands were
stored with the old formula. So on an upgraded existing workspace, until
the PR3 backfill:
- Object activate/deactivate toggle for existing objects won't find the
nav command → re-activating can create a duplicate nav command;
deactivating may no-op.
- Object deletion won't find/clean up the old nav command → orphaned
nav-command row.
### What app developers get right now
Nothing usable yet. The helpers exist in twenty-shared but aren't
re-exported from twenty-sdk (PR2), and app-authored objects still get
SDK-derived ids in the old format until PR2
re-mints them. So "reference a server entity by deterministic id"
doesn't work end-to-end until PR2
This commit is contained in:
+3
-11
@@ -1,5 +1,4 @@
|
||||
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
import { v5 } from 'uuid';
|
||||
|
||||
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
|
||||
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
|
||||
@@ -11,9 +10,6 @@ import {
|
||||
NAVIGATION_INTERPOLATED_SHORT_LABEL,
|
||||
} from 'src/engine/metadata-modules/flat-command-menu-item/utils/build-navigation-flat-command-menu-item.util';
|
||||
|
||||
const NAVIGATION_COMMAND_UUID_NAMESPACE =
|
||||
'b31830da-2ae0-48eb-a915-12fa4ab96dd3';
|
||||
|
||||
const baseObjectMetadata = {
|
||||
id: 'obj-id-1',
|
||||
universalIdentifier: 'obj-universal-1',
|
||||
@@ -29,18 +25,14 @@ const baseArgs = {
|
||||
workspaceId: 'ws-id-1',
|
||||
position: 5,
|
||||
now: '2026-01-01T00:00:00.000Z',
|
||||
universalIdentifier: 'nav-universal-1',
|
||||
};
|
||||
|
||||
describe('buildNavigationFlatCommandMenuItem', () => {
|
||||
it('should produce a deterministic universalIdentifier via UUID v5', () => {
|
||||
it('should use the provided universalIdentifier', () => {
|
||||
const result = buildNavigationFlatCommandMenuItem(baseArgs);
|
||||
|
||||
const expectedUniversalIdentifier = v5(
|
||||
baseObjectMetadata.universalIdentifier,
|
||||
NAVIGATION_COMMAND_UUID_NAMESPACE,
|
||||
);
|
||||
|
||||
expect(result.universalIdentifier).toBe(expectedUniversalIdentifier);
|
||||
expect(result.universalIdentifier).toBe('nav-universal-1');
|
||||
});
|
||||
|
||||
it('should set label and shortLabel as interpolation templates', () => {
|
||||
|
||||
+2
-6
@@ -1,7 +1,6 @@
|
||||
import { STANDARD_OBJECTS } from 'twenty-shared/metadata';
|
||||
import { FeatureFlagKey } from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { v5 } from 'uuid';
|
||||
|
||||
import { CommandMenuItemAvailabilityType } from 'src/engine/metadata-modules/command-menu-item/enums/command-menu-item-availability-type.enum';
|
||||
import { EngineComponentKey } from 'src/engine/metadata-modules/command-menu-item/enums/engine-component-key.enum';
|
||||
@@ -54,6 +53,7 @@ export const buildNavigationFlatCommandMenuItem = ({
|
||||
workspaceId,
|
||||
position,
|
||||
now,
|
||||
universalIdentifier,
|
||||
}: {
|
||||
objectMetadata: {
|
||||
id: string;
|
||||
@@ -67,12 +67,8 @@ export const buildNavigationFlatCommandMenuItem = ({
|
||||
workspaceId: string;
|
||||
position: number;
|
||||
now: string;
|
||||
universalIdentifier: string;
|
||||
}): FlatCommandMenuItem => {
|
||||
const universalIdentifier = v5(
|
||||
objectMetadata.universalIdentifier,
|
||||
NAVIGATION_COMMAND_UUID_NAMESPACE,
|
||||
);
|
||||
|
||||
const conditionalAvailabilityExpression =
|
||||
buildNavigationConditionalAvailabilityExpression({
|
||||
universalIdentifier: objectMetadata.universalIdentifier,
|
||||
|
||||
Reference in New Issue
Block a user