From de8f2addfbe2b5d1611df3de92f434df548c088a Mon Sep 17 00:00:00 2001 From: Charles Bochet Date: Fri, 15 May 2026 16:06:20 +0200 Subject: [PATCH] fix(server): derive system field universalIdentifiers from v5 and auto-attach them in manifest sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes app re-install on workspaces that have already received the v2.5 `NormalizeCompositeFieldDefaultsCommand` backfill: those workspaces now have non-null `defaultValue` on every composite system field (createdBy, updatedBy, position, searchVector, …). The next `installApplication` → `synchronizeFromManifest(isSystemBuild: false)` rebuilds the diff and hits `FIELD_MUTATION_NOT_ALLOWED` on each row because the manifest TO state never declared system fields, so the dispatcher saw them as FROM-only and emitted bad delete/update actions. ## Root cause `computeApplicationManifestAllUniversalFlatEntityMaps` walks `manifest.objects[].fields` and never scaffolds the eight standard system fields the runtime actually attaches on object creation (`buildDefaultFlatFieldMetadatasForCustomObject`). On top of that, the scaffolder seeded each system field's `universalIdentifier` with `v4()`, so even if we wanted to mirror them on the TO side from the manifest converter, the random UIDs would never line up with the rows already in the workspace. ## Fix (three pieces) 1. **Switch `buildDefaultFlatFieldMetadatasForCustomObject` from `v4` to `v5`.** Same pattern as `NAVIGATION_COMMAND_UUID_NAMESPACE`: every system field's universalIdentifier is derived deterministically from `${objectMetadataUniversalIdentifier}/${name}` against a new `SYSTEM_FIELD_UUID_NAMESPACE`. Exported as `computeSystemFieldUniversalIdentifier` so the manifest converter and the upgrade migration both compute identical UUIDs. 2. **Inject the eight system fields into manifest TO.** Inside `computeApplicationManifestAllUniversalFlatEntityMaps`, after each `flatObjectMetadata` is added, call `buildDefaultFlatFieldMetadatasForCustomObject({ skipNameField: true })` and merge its eight rows into `flatFieldMetadataMaps`. Apps that explicitly declare a field with one of the system names (e.g. an `id: TEXT` regression) are left untouched so the existing `validateObjectMetadataSystemFieldsIntegrity` validator still surfaces the mistake. 3. **2-5 workspace upgrade migration:** `2-5-workspace-command-1798600000000-refactor-system-field-universal-identifiers.command.ts` rewrites every existing `isSystem: true` field whose name is one of the eight standard names to the new v5 identifier (direct `UPDATE core."fieldMetadata"` on the `universalIdentifier` column — no FK references it, all DB joins are on the per-workspace `id` PK), then invalidates the `flatFieldMetadataMaps` and `flatObjectMetadataMaps` cache entries. Direct analogue of `1-21-workspace-command-1775500013000-refactor-navigation-commands`. After (1)+(2)+(3) ship, the dispatcher computes an empty diff for system fields on app re-installs: TO and FROM contain the same eight rows with the same v5 universalIdentifiers and the same scaffolder defaults. The validator path is never hit. ## CI `sync-application-system-fields-auto-attached.integration-spec.ts` exercises three scenarios that would have caught this regression: - First sync of an app whose object declares zero system fields ends with the eight rows present and v5-addressed. - Re-syncing the identical manifest is a no-op. - A re-sync after a manually-cleared `defaultValue` on `createdBy` (the exact post-2.5-backfill shape) succeeds — this is the case that failed on prod after PR #20581 / the rolling 2.5 upgrade. The existing dev-sync vitest suite never exercised `installApplication` directly because the LOCAL sourceType short- circuits the install handler, so the bug stayed silent until the CD pipeline hit it on the registry-install path. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2-5/2-5-upgrade-version-command.module.ts | 2 + ...tem-field-universal-identifiers.command.ts | 139 ++++++++++++++ ...est-all-universal-flat-entity-maps.util.ts | 52 +++++ ...-field-metadatas-for-custom-object.util.ts | 52 ++++- ...m-fields-auto-attached.integration-spec.ts | 180 ++++++++++++++++++ 5 files changed, 416 insertions(+), 9 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1798600000000-refactor-system-field-universal-identifiers.command.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/application/sync-application-system-fields-auto-attached.integration-spec.ts diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-upgrade-version-command.module.ts index 6f29e4466bc..a4cf9c94f4a 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-upgrade-version-command.module.ts @@ -3,6 +3,7 @@ import { Module } from '@nestjs/common'; import { WorkspaceIteratorModule } from 'src/database/commands/command-runners/workspace-iterator.module'; import { RebuildUniquePhoneIndexesCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000000000-rebuild-unique-phone-indexes.command'; import { NormalizeCompositeFieldDefaultsCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1778000001000-normalize-composite-field-defaults.command'; +import { RefactorSystemFieldUniversalIdentifiersCommand } from 'src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1798600000000-refactor-system-field-universal-identifiers.command'; import { WorkspaceSchemaManagerModule } from 'src/engine/twenty-orm/workspace-schema-manager/workspace-schema-manager.module'; import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module'; import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace-migration/workspace-migration.module'; @@ -17,6 +18,7 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace providers: [ RebuildUniquePhoneIndexesCommand, NormalizeCompositeFieldDefaultsCommand, + RefactorSystemFieldUniversalIdentifiersCommand, ], }) export class V2_5_UpgradeVersionCommandModule {} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1798600000000-refactor-system-field-universal-identifiers.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1798600000000-refactor-system-field-universal-identifiers.command.ts new file mode 100644 index 00000000000..9835580d9ec --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-5/2-5-workspace-command-1798600000000-refactor-system-field-universal-identifiers.command.ts @@ -0,0 +1,139 @@ +import { Command } from 'nest-commander'; +import { isDefined } from 'twenty-shared/utils'; + +import { ActiveOrSuspendedWorkspaceCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspace.command-runner'; +import { WorkspaceIteratorService } from 'src/database/commands/command-runners/workspace-iterator.service'; +import { type RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspace.command-runner'; +import { RegisteredWorkspaceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-workspace-command.decorator'; +import { PARTIAL_SYSTEM_FLAT_FIELD_METADATAS } from 'src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant'; +import { computeSystemFieldUniversalIdentifier } from 'src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util'; +import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; + +// Names of the eight standard system fields scaffolded by +// `buildDefaultFlatFieldMetadatasForCustomObject` for every custom object. +// Anything outside this set is left untouched, even if `isSystem = true` +// (e.g. relation-source fields the SDK marks system but doesn't scaffold). +const SYSTEM_FIELD_NAMES_TO_REFACTOR = Object.values( + PARTIAL_SYSTEM_FLAT_FIELD_METADATAS, +).map((field) => field.name); + +type SystemFieldRow = { + id: string; + current_universal_identifier: string; + object_metadata_universal_identifier: string; + name: string; +}; + +@RegisteredWorkspaceCommand('2.5.0', 1798600000000) +@Command({ + name: 'upgrade:2-5:refactor-system-field-universal-identifiers', + description: + 'Rewrite system field universalIdentifiers to v5(`${objectMetadataUniversalIdentifier}/${name}`, SYSTEM_FIELD_UUID_NAMESPACE) so app-install diffs do not see them as changed rows.', +}) +export class RefactorSystemFieldUniversalIdentifiersCommand extends ActiveOrSuspendedWorkspaceCommandRunner { + constructor( + protected readonly workspaceIteratorService: WorkspaceIteratorService, + private readonly workspaceCacheService: WorkspaceCacheService, + ) { + super(workspaceIteratorService); + } + + override async runOnWorkspace({ + workspaceId, + dataSource, + options, + }: RunOnWorkspaceArgs): Promise { + if (!dataSource) { + this.logger.log(`No data source for workspace ${workspaceId}, skipping`); + + return; + } + + const rows: SystemFieldRow[] = await dataSource.query( + `SELECT fm.id, + fm."universalIdentifier" AS current_universal_identifier, + om."universalIdentifier" AS object_metadata_universal_identifier, + fm.name + FROM core."fieldMetadata" fm + JOIN core."objectMetadata" om ON om.id = fm."objectMetadataId" + WHERE fm."workspaceId" = $1 + AND fm."isSystem" = true + AND fm.name = ANY($2)`, + [workspaceId, SYSTEM_FIELD_NAMES_TO_REFACTOR], + ); + + if (rows.length === 0) { + this.logger.log( + `No system fields to refactor for workspace ${workspaceId}, skipping`, + ); + + return; + } + + const updates = rows + .map((row) => ({ + id: row.id, + from: row.current_universal_identifier, + to: computeSystemFieldUniversalIdentifier({ + objectMetadataUniversalIdentifier: + row.object_metadata_universal_identifier, + name: row.name, + }), + name: row.name, + })) + .filter((row) => row.from !== row.to); + + if (updates.length === 0) { + this.logger.log( + `All ${rows.length} system field universalIdentifiers already match the v5 derivation for workspace ${workspaceId}, skipping`, + ); + + return; + } + + if (options.dryRun) { + this.logger.log( + `[DRY RUN] Would rewrite ${updates.length}/${rows.length} system field universalIdentifier(s) for workspace ${workspaceId}: ${updates + .map((u) => `${u.name}@${u.id} ${u.from} -> ${u.to}`) + .slice(0, 5) + .join(', ')}${updates.length > 5 ? ', ...' : ''}`, + ); + + return; + } + + // We update one row at a time with the FK-safe SET universalIdentifier + // — universalIdentifier is purely the cross-workspace flat-entity + // identifier; no DB foreign key references it (other tables join on + // the per-workspace `id` PK), so a plain UPDATE is safe. + // + // The unique index `(workspaceId, universalIdentifier)` is preserved + // because v5(`${objectMetadataUniversalIdentifier}/${name}`, NAMESPACE) + // is unique per (object, field name) — and a given workspace cannot + // host two custom objects with the same universalIdentifier. + let updatedCount = 0; + + for (const update of updates) { + await dataSource.query( + `UPDATE core."fieldMetadata" + SET "universalIdentifier" = $2, + "updatedAt" = NOW() + WHERE id = $1`, + [update.id, update.to], + ); + updatedCount += 1; + } + + this.logger.log( + `Refactored ${updatedCount} system field universalIdentifier(s) to v5 for workspace ${workspaceId}`, + ); + + await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [ + 'flatFieldMetadataMaps', + 'flatObjectMetadataMaps', + ]); + } +} + +export const _SYSTEM_FIELD_NAMES_TO_REFACTOR_FOR_TEST = + SYSTEM_FIELD_NAMES_TO_REFACTOR; diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts index 0605c5de273..429bd4bb49b 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/utils/compute-application-manifest-all-universal-flat-entity-maps.util.ts @@ -3,6 +3,8 @@ import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { generateIndexForFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/generate-index-for-flat-field-metadata.util'; +import { buildDefaultFlatFieldMetadatasForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util'; +import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; import { fromApplicationVariableManifestToUniversalFlatApplicationVariable } from 'src/engine/core-modules/application/application-manifest/converters/from-application-variable-manifest-to-universal-flat-application-variable.util'; import { fromCommandMenuItemManifestToUniversalFlatCommandMenuItem } from 'src/engine/core-modules/application/application-manifest/converters/from-command-menu-item-manifest-to-universal-flat-command-menu-item.util'; @@ -61,6 +63,56 @@ export const computeApplicationManifestAllUniversalFlatEntityMaps = ({ allUniversalFlatEntityMaps.flatObjectMetadataMaps, }); + // Inject the 8 standard system fields (id / createdAt / createdBy / + // deletedAt / position / searchVector / updatedAt / updatedBy) into + // the manifest-derived TO map. The runtime attaches the same set to + // every custom object on creation; without these here, the diff + // sees `FROM = { …system fields }` and `TO = { }` and either tries + // to delete (rejected by the deletion validator) or update them + // (rejected by `FIELD_MUTATION_NOT_ALLOWED` once any property + // drifts — e.g. after NormalizeCompositeFieldDefaultsCommand). + // + // universalIdentifiers are derived deterministically via v5 from + // `${objectMetadataUniversalIdentifier}/${name}` so they line up + // byte-for-byte with the rows the workspace already has (post the + // matching 2-5 refactor-system-field-universal-identifiers migration). + // + // If the manifest already declares a field with one of the system + // names (e.g. an app trying to override `id` with a TEXT type), we + // intentionally skip the injection so the existing + // `validateObjectMetadataSystemFieldsIntegrity` validator still gets + // to surface the mistake as `INVALID_SYSTEM_FIELD`. + // `skipNameField: true` because the manifest is responsible for + // declaring the label-identifier field itself. + const fieldNamesDeclaredInObjectManifest = new Set( + objectManifest.fields.map((field) => field.name), + ); + const defaultFlatFieldsForObject = + buildDefaultFlatFieldMetadatasForCustomObject({ + flatObjectMetadata: { + applicationUniversalIdentifier, + universalIdentifier: flatObjectMetadata.universalIdentifier, + }, + skipNameField: true, + }); + + const systemFlatFieldMetadatasToInject: UniversalFlatFieldMetadata[] = + Object.values(defaultFlatFieldsForObject.fields); + + for (const systemFlatFieldMetadata of systemFlatFieldMetadatasToInject) { + if ( + fieldNamesDeclaredInObjectManifest.has(systemFlatFieldMetadata.name) + ) { + continue; + } + + addUniversalFlatEntityToUniversalFlatEntityMapsThroughMutationOrThrow({ + universalFlatEntity: systemFlatFieldMetadata, + universalFlatEntityMapsToMutate: + allUniversalFlatEntityMaps.flatFieldMetadataMaps, + }); + } + for (const fieldManifest of objectManifest.fields) { const enrichedFieldManifest = fieldManifest.type === FieldMetadataType.TS_VECTOR && diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util.ts index 84bbc6c3d3c..f319366edae 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util.ts @@ -1,7 +1,30 @@ import { FieldMetadataType } from 'twenty-shared/types'; -import { v4 } from 'uuid'; +import { v4, v5 } from 'uuid'; import { PARTIAL_SYSTEM_FLAT_FIELD_METADATAS } from 'src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant'; + +// Namespace used to derive deterministic v5 UUIDs for an object's +// standard system fields (id, createdAt, createdBy, deletedAt, position, +// searchVector, updatedAt, updatedBy). Same approach as +// `NAVIGATION_COMMAND_UUID_NAMESPACE` for navigation command menu items: +// any consumer that can compute the seed `${objectMetadataUniversalIdentifier}/${name}` +// can reproduce the exact same universalIdentifier, which is what lets +// the app-install diff path emit zero updates for system fields it does +// not control. +export const SYSTEM_FIELD_UUID_NAMESPACE = + '7c8e0e1c-2d4f-4ab1-b25b-9d3a8d6a1f02'; + +export const computeSystemFieldUniversalIdentifier = ({ + objectMetadataUniversalIdentifier, + name, +}: { + objectMetadataUniversalIdentifier: string; + name: string; +}): string => + v5( + `${objectMetadataUniversalIdentifier}/${name}`, + SYSTEM_FIELD_UUID_NAMESPACE, + ); import { getTsVectorColumnExpressionFromFields } from 'src/engine/workspace-manager/utils/get-ts-vector-column-expression.util'; import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type'; import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type'; @@ -40,10 +63,21 @@ const buildObjectSystemFlatFieldMetadatas = ({ updatedBy, } = PARTIAL_SYSTEM_FLAT_FIELD_METADATAS; + // Each system field's universalIdentifier is derived deterministically + // from `${objectMetadataUniversalIdentifier}/${name}` so this scaffolder + // produces the same UUIDs every time, for both runtime object creation + // and manifest-derived "TO" computation. That alignment is what makes + // app-install diffs empty for system fields the app doesn't define. + const systemFieldUniversalIdentifier = (name: string): string => + computeSystemFieldUniversalIdentifier({ + objectMetadataUniversalIdentifier, + name, + }); + return { id: { ...id, - universalIdentifier: v4(), + universalIdentifier: systemFieldUniversalIdentifier(id.name), applicationUniversalIdentifier, objectMetadataUniversalIdentifier, createdAt: now, @@ -51,7 +85,7 @@ const buildObjectSystemFlatFieldMetadatas = ({ }, createdAt: { ...createdAt, - universalIdentifier: v4(), + universalIdentifier: systemFieldUniversalIdentifier(createdAt.name), applicationUniversalIdentifier, objectMetadataUniversalIdentifier, createdAt: now, @@ -59,7 +93,7 @@ const buildObjectSystemFlatFieldMetadatas = ({ }, createdBy: { ...createdBy, - universalIdentifier: v4(), + universalIdentifier: systemFieldUniversalIdentifier(createdBy.name), applicationUniversalIdentifier, objectMetadataUniversalIdentifier, createdAt: now, @@ -67,7 +101,7 @@ const buildObjectSystemFlatFieldMetadatas = ({ }, deletedAt: { ...deletedAt, - universalIdentifier: v4(), + universalIdentifier: systemFieldUniversalIdentifier(deletedAt.name), applicationUniversalIdentifier, objectMetadataUniversalIdentifier, createdAt: now, @@ -75,7 +109,7 @@ const buildObjectSystemFlatFieldMetadatas = ({ }, position: { ...position, - universalIdentifier: v4(), + universalIdentifier: systemFieldUniversalIdentifier(position.name), applicationUniversalIdentifier, objectMetadataUniversalIdentifier, createdAt: now, @@ -83,7 +117,7 @@ const buildObjectSystemFlatFieldMetadatas = ({ }, searchVector: { ...searchVector, - universalIdentifier: v4(), + universalIdentifier: systemFieldUniversalIdentifier(searchVector.name), applicationUniversalIdentifier, objectMetadataUniversalIdentifier, createdAt: now, @@ -92,7 +126,7 @@ const buildObjectSystemFlatFieldMetadatas = ({ }, updatedAt: { ...updatedAt, - universalIdentifier: v4(), + universalIdentifier: systemFieldUniversalIdentifier(updatedAt.name), applicationUniversalIdentifier, objectMetadataUniversalIdentifier, createdAt: now, @@ -100,7 +134,7 @@ const buildObjectSystemFlatFieldMetadatas = ({ }, updatedBy: { ...updatedBy, - universalIdentifier: v4(), + universalIdentifier: systemFieldUniversalIdentifier(updatedBy.name), applicationUniversalIdentifier, objectMetadataUniversalIdentifier, createdAt: now, diff --git a/packages/twenty-server/test/integration/metadata/suites/application/sync-application-system-fields-auto-attached.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/application/sync-application-system-fields-auto-attached.integration-spec.ts new file mode 100644 index 00000000000..d4c0896654c --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/application/sync-application-system-fields-auto-attached.integration-spec.ts @@ -0,0 +1,180 @@ +import { type Manifest } from 'twenty-shared/application'; +import { FieldMetadataType } from 'twenty-shared/types'; +import { buildBaseManifest } from 'test/integration/metadata/suites/application/utils/build-base-manifest.util'; +import { cleanupApplicationAndAppRegistration } from 'test/integration/metadata/suites/application/utils/cleanup-application-and-app-registration.util'; +import { setupApplicationForSync } from 'test/integration/metadata/suites/application/utils/setup-application-for-sync.util'; +import { syncApplication } from 'test/integration/metadata/suites/application/utils/sync-application.util'; +import { v4 as uuidv4, v5 as uuidv5 } from 'uuid'; + +import { SYSTEM_FIELD_UUID_NAMESPACE } from 'src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util'; + +// Regression test for the bug discovered after the v2.5 upgrade rolled +// `NormalizeCompositeFieldDefaultsCommand` to prod: app re-installs +// failed with `FIELD_MUTATION_NOT_ALLOWED` on 30 system fields because +// the manifest-derived TO map didn't include them while the workspace +// FROM map did (now with non-null defaultValues from the backfill). +// +// Post-fix, the server attaches the eight standard system fields to TO +// itself, with deterministic v5 universalIdentifiers derived from +// `${objectMetadataUniversalIdentifier}/${name}` so they line up with +// the rows already in the workspace. +const TEST_APP_ID = uuidv4(); +const TEST_ROLE_ID = uuidv4(); +const OBJECT_UNIVERSAL_IDENTIFIER = uuidv4(); +const LABEL_FIELD_UNIVERSAL_IDENTIFIER = uuidv4(); +const TEST_WORKSPACE_ID = '20202020-1c25-4d02-bf25-6aeccf7ea419'; + +const SYSTEM_FIELD_NAMES = [ + 'id', + 'createdAt', + 'createdBy', + 'deletedAt', + 'position', + 'searchVector', + 'updatedAt', + 'updatedBy', +] as const; + +const buildManifestWithObjectThatHasNoSystemFields = (): Manifest => + buildBaseManifest({ + appId: TEST_APP_ID, + roleId: TEST_ROLE_ID, + overrides: { + objects: [ + { + universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER, + labelIdentifierFieldMetadataUniversalIdentifier: + LABEL_FIELD_UNIVERSAL_IDENTIFIER, + nameSingular: 'autoSystemFieldTicket', + namePlural: 'autoSystemFieldTickets', + labelSingular: 'Auto System Field Ticket', + labelPlural: 'Auto System Field Tickets', + description: 'Object that does not declare its own system fields', + icon: 'IconTicket', + fields: [ + { + universalIdentifier: LABEL_FIELD_UNIVERSAL_IDENTIFIER, + type: FieldMetadataType.TEXT, + name: 'title', + label: 'Title', + description: 'Label identifier field', + icon: 'IconTextCaption', + }, + ], + }, + ], + }, + }); + +const expectedSystemFieldUniversalIdentifier = (name: string): string => + uuidv5( + `${OBJECT_UNIVERSAL_IDENTIFIER}/${name}`, + SYSTEM_FIELD_UUID_NAMESPACE, + ); + +const fetchSystemFieldRows = async () => { + return (await globalThis.testDataSource.query( + `SELECT name, "universalIdentifier", "isSystem", "defaultValue" + FROM core."fieldMetadata" + WHERE "workspaceId" = $1 + AND "objectMetadataId" IN ( + SELECT id FROM core."objectMetadata" + WHERE "workspaceId" = $1 + AND "universalIdentifier" = $2 + ) + AND name = ANY($3)`, + [TEST_WORKSPACE_ID, OBJECT_UNIVERSAL_IDENTIFIER, SYSTEM_FIELD_NAMES], + )) as Array<{ + name: string; + universalIdentifier: string; + isSystem: boolean; + defaultValue: unknown; + }>; +}; + +describe('Sync application auto-attaches system fields to a custom object', () => { + beforeEach(async () => { + await setupApplicationForSync({ + applicationUniversalIdentifier: TEST_APP_ID, + name: 'Auto System Fields App', + description: + 'App used to exercise auto-attached system fields on object sync', + sourcePath: 'test-auto-system-fields', + }); + }, 60000); + + afterEach(async () => { + await cleanupApplicationAndAppRegistration({ + applicationUniversalIdentifier: TEST_APP_ID, + }); + }); + + it('creates the eight standard system fields with v5 universalIdentifiers on first sync', async () => { + const manifest = buildManifestWithObjectThatHasNoSystemFields(); + + const { errors } = await syncApplication({ manifest }); + + expect(errors).toBeUndefined(); + + const rows = await fetchSystemFieldRows(); + + expect(rows).toHaveLength(SYSTEM_FIELD_NAMES.length); + + for (const name of SYSTEM_FIELD_NAMES) { + const row = rows.find((r) => r.name === name); + + expect(row).toBeDefined(); + expect(row?.isSystem).toBe(true); + expect(row?.universalIdentifier).toBe( + expectedSystemFieldUniversalIdentifier(name), + ); + } + }, 60000); + + it('re-syncing the same manifest is a no-op for system fields', async () => { + const manifest = buildManifestWithObjectThatHasNoSystemFields(); + + const firstSync = await syncApplication({ manifest }); + + expect(firstSync.errors).toBeUndefined(); + + const secondSync = await syncApplication({ manifest }); + + expect(secondSync.errors).toBeUndefined(); + + const rows = await fetchSystemFieldRows(); + + expect(rows).toHaveLength(SYSTEM_FIELD_NAMES.length); + }, 60000); + + // This is the precise shape that failed on prod after the v2.5 + // NormalizeCompositeFieldDefaultsCommand ran: the workspace had its + // composite system fields' defaultValue rewritten, then a subsequent + // app re-sync diffed the (FROM with new defaults) against the + // (TO without system fields) and was rejected on every one. + it('handles a re-sync after the workspace mutated a system field defaultValue (post-2.5-backfill shape)', async () => { + const manifest = buildManifestWithObjectThatHasNoSystemFields(); + + const firstSync = await syncApplication({ manifest }); + + expect(firstSync.errors).toBeUndefined(); + + // Simulate `NormalizeCompositeFieldDefaultsCommand` clearing the + // composite system field's defaultValue. The v5 universalIdentifier + // and every other property are preserved. + await globalThis.testDataSource.query( + `UPDATE core."fieldMetadata" + SET "defaultValue" = NULL + WHERE "workspaceId" = $1 + AND "universalIdentifier" = $2`, + [ + TEST_WORKSPACE_ID, + expectedSystemFieldUniversalIdentifier('createdBy'), + ], + ); + + const secondSync = await syncApplication({ manifest }); + + expect(secondSync.errors).toBeUndefined(); + }, 60000); +});