mirror of
https://github.com/twentyhq/twenty.git
synced 2026-05-24 00:09:05 -04:00
fix(server): derive system field universalIdentifiers from v5 and auto-attach them in manifest sync
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 {}
|
||||
|
||||
@@ -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<void> {
|
||||
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;
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user