Skip to content

System field metadata deterministic universal identifier #2641

Description

@prastoin

name: Deterministic field universal identifiers
overview: Make all fieldMetadata universal identifiers deterministic via getFieldUniversalIdentifier across the custom-object creation path, the twenty-standard app static definition, and the SDK, then backfill every existing fieldMetadata row (and stored app manifests) to the new derivation.
todos:

  • id: custom-object-defaults
    content: Switch build-default-flat-field-metadatas-for-custom-object and create-field-input default to getFieldUniversalIdentifier
    status: pending
  • id: standard-objects
    content: Compute STANDARD_OBJECTS field UIDs via getFieldUniversalIdentifier at module load
    status: pending
  • id: sdk-alignment
    content: Align SDK generateDefaultFieldUniversalIdentifier with getFieldUniversalIdentifier, thread app UID through manifest build
    status: pending
  • id: workspace-backfill
    content: "2-19 workspace command: backfill all fieldMetadata.universalIdentifier via raw SQL + cache invalidation"
    status: pending
  • id: manifest-backfill
    content: "2-19 slow instance command: rewrite field UIDs in stored application manifests"
    status: pending
  • id: tests-verification
    content: Update tests/snapshots/seeds, run typecheck, lint, and integration verification
    status: pending
    isProject: false

Deterministic Field Universal Identifiers

Goal

One single derivation everywhere: getFieldUniversalIdentifier({ applicationUniversalIdentifier, objectUniversalIdentifier, name }) (UUID v5, namespace = app UID, seed = fieldMetadata:{objectUID}:{name}) from packages/twenty-shared/src/application/deterministic-identifier/get-field-universal-identifier.util.ts. Existing rows are backfilled to match.

1. Custom object default fields (server create path)

In packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util.ts:

  • Replace all 9 universalIdentifier: v4() (8 system fields + nameField) with getFieldUniversalIdentifier({ applicationUniversalIdentifier, objectUniversalIdentifier: objectMetadataUniversalIdentifier, name: <fieldName> }). Both inputs are already available in scope.
  • Also update get-default-flat-field-metadata-from-create-field-input.util.ts (createFieldInput.universalIdentifier ?? v4()) so user-created fields follow the same derivation when no UID is provided — otherwise the "all fields deterministic" invariant breaks the moment a user creates a field. Note: this assumes hard-deleted fields free their name; if a soft-deleted row can coexist with a same-name recreation we'd get a UID collision (verify during implementation).

2. Twenty-standard app static definition

In packages/twenty-shared/src/metadata/constants/standard-object.constant.ts (~900 field UIDs):

  • Replace every hardcoded field universalIdentifier literal with a computed call: getFieldUniversalIdentifier({ applicationUniversalIdentifier: TWENTY_STANDARD_APPLICATION_UNIVERSAL_IDENTIFIER, objectUniversalIdentifier: <object's UID>, name: '<fieldName>' }), evaluated at module load. Likely via a small local helper to keep the file readable, e.g. standardField(objectUniversalIdentifier, name).
  • Object UIDs, morphIds, and index UIDs stay hardcoded (out of scope).
  • Update the "never mutate" header comment to explain the new derivation contract (field UIDs are now derived; renaming a standard field name IS a UID mutation and must never happen without a coordinated backfill).
  • All standard-app builders (create-standard-field-flat-metadata.util.ts, create-standard-relation-field-flat-metadata.util.ts, view/index definitions) read from STANDARD_OBJECTS, so they pick up new values automatically. Check for any snapshot tests / seed data asserting hardcoded field UIDs and update them.
  • Watch for import cycles: standard-object.constant.ts (twenty-shared/metadata) will now import from twenty-shared/application.

3. SDK default system fields

  • Rewrite packages/twenty-sdk/src/sdk/define/objects/generate-default-field-universal-identifier.ts to delegate to getFieldUniversalIdentifier. New required input: applicationUniversalIdentifier — thread it through:
    • getDefaultObjectFields / getDefaultRelationObjectFields and their caller getDefaultFieldsInObjectFields in packages/twenty-sdk/src/cli/utilities/build/manifest/ (the manifest build knows the application config, so pass the app UID down from manifest-build.ts).
    • The public export used by app authors (entity-view-template.ts and @/sdk/define index) — signature change is breaking for app authors; update templates and tests/snapshots (get-default-object-fields.spec.ts, stub-twenty-sdk-define.plugin.spec.ts.snap, get-view-base-file.spec.ts).
  • Keep the old derivation function (fixed namespace 142046f0-..., seed {objectUID}-{fieldName}) available server-side (or copied into the backfill command) as computeLegacySdkDefaultFieldUniversalIdentifier — needed for the backfill's old→new mapping in stored manifests.

4. Backfill commands (version 2.19)

universalIdentifier is excluded from the migration-runner updatable properties, so this is raw SQL + manual cache invalidation (same pattern as the 2-18 normalize-legacy-index-names command).

4a. Workspace command: backfill fieldMetadata rows

New file in packages/twenty-server/src/database/commands/upgrade-version-command/2-19/, registered in 2-19-upgrade-version-command.module.ts, extending ActiveOrSuspendedWorkspaceCommandRunner:

  • For every fieldMetadata row in the workspace (standard, workspace-custom, and installed-SDK-app fields alike): compute newUniversalIdentifier = getFieldUniversalIdentifier({ applicationUniversalIdentifier: <row's application.universalIdentifier>, objectUniversalIdentifier: <row's objectMetadata.universalIdentifier>, name: row.name }) and update via raw SQL where it differs. Idempotent by construction (already-deterministic rows are no-ops).
  • Support --dry-run and log per-workspace update counts.
  • After update: workspaceCacheService.invalidateAndRecompute(workspaceId, [...]) for flatFieldMetadataMaps plus related maps (getMetadataRelatedMetadataNames('fieldMetadata')), wrapped in try/catch so cache failure doesn't fail the upgrade.
  • DB relations reference fields by id, not UID, so the row update is self-contained — but verify no jsonb settings store field UIDs (e.g. universalSettings.junctionTargetFieldUniversalIdentifier is derived at cache-compute time, not stored).
  • Ordering: must run before the standard-app / application sync during upgrade (it does, since version commands run before the final sync-metadata step) — confirm during implementation.

4b. Instance command (slow): rewrite stored app manifests

Installed SDK apps have applicationRegistration.manifest (jsonb) still containing old-derivation default-field UIDs (in fields[].universalIdentifier, labelIdentifierFieldMetadataUniversalIdentifier, and view field references). Without this, the next app deploy/sync diffs against mismatched UIDs and deletes/recreates fields.

  • Generate via npx nx run twenty-server:database:migrate:generate --name <name> --type slow.
  • For each registration manifest: for each object, compute the old SDK UID and the new UID for each default field name, and replace every occurrence throughout the manifest json (string-level old→new replacement is safe since UUIDs are globally unique). Also handle explicitly-authored (non-default) field UIDs? No — those were author-chosen and are backfilled workspace-side by 4a; but then manifest vs workspace diverges for those too. Decision baked in: 4a recomputes ALL fields including author-defined ones, so 4b must rewrite ALL field UIDs in manifests using the same derivation (new UID from app UID + object UID + field name), not just default fields.
  • Check whether application (workspace-scoped) also stores a manifest copy and rewrite it too if so.

5. Verification

  • Unit tests: updated SDK specs and snapshots; add a spec asserting buildDefaultFlatFieldMetadatasForCustomObject produces stable UIDs for a fixed input.
  • npx nx run twenty-server:typecheck, npx nx typecheck twenty-shared, SDK typecheck, lint:diff-with-main on touched packages.
  • Integration: run existing metadata suites (object creation, application sync); run the backfill command against a seeded database and re-run sync-metadata to confirm the standard sync produces no diff.

Risks / notes

  • The STANDARD_OBJECTS "never mutate" rule is deliberately violated once, in lockstep with the 4a backfill; any workspace upgraded without the command running would have its standard fields deleted/recreated by sync. The command registration in 2-19 guarantees ordering for the normal upgrade path.
  • Renaming a field after creation keeps its UID (UID is derived at creation/backfill time only) — that's expected and consistent with UID-as-stable-identity.
  • Any hardcoded field UIDs in tests, seeds (prefill data), or dev fixtures will need regeneration — will grep for the old literal values.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

Priority

None yet

Dev status

None yet

Start date

None yet

Target date

None yet

Quarter

None yet

Projects

Status
In project

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions