System side effect relations (#22882)
Closes twentyhq/core-team-issues#2667 ## What Default relations to the standard relation objects (`timelineActivities`, `attachments`, `noteTargets`, `taskTargets`) are now fully owned by the **metadata side-effect engine**. Neither the API transpilers nor the SDK manifest builder provision them anymore: any object creation, rename or deletion — regardless of the caller — goes through the same engine handlers. ## Why - Provisioning was duplicated across the API path and the SDK manifest builder, with diverging behavior. - Universal identifiers of relation fields were derived from object **names**, so renaming an object mutated them and forced lossy delete+create cycles on manifest sync. ## How ### Engine-owned lifecycle (side-effect handlers) - `objectSystemRelationsOnCreate`: provisions the 8 forward/reverse relation fields (+ join column indexes) when an object is created. - `objectSystemRelationsOnUpdate`: renames the reverse morph fields (`target<ObjectName>`) when their host object is renamed — a lossless `fieldMetadata.update`. - `objectSystemSideEffectsOnDelete`: cascades deletion of engine-owned fields/indexes when the object is deleted. - The API transpilers and the SDK `buildManifest` no longer inject these fields; `isSystemSideEffect: true` marks engine-owned entities, guarded by a granular property allowlist (only `isActive` is user-editable) and excluded from manifest deletion inference. ### Name-free deterministic universal identifiers New `getSystemRelationFieldUniversalIdentifier({ applicationUniversalIdentifier, objectUniversalIdentifier, relationTargetObjectUniversalIdentifier })` in `twenty-shared`, exported from `twenty-sdk/define`. The identifier is keyed on the two **object** identifiers instead of field names (direction encoded by argument order), so object renames never mutate relation field identifiers. It cannot collide with the name-based `getFieldUniversalIdentifier` derivation (field names cannot contain `:`). ### twenty-standard re-owned All 48 forward/reverse system relation field declarations in `STANDARD_OBJECTS` now pin the derived name-free identifiers (computed inline via the shared util) and carry `isSystemSideEffect: true`, with labels/icons declared explicitly (translated via `msg`). `twenty-standard` is projected as if the engine had generated these fields itself. ### 2.23 upgrade commands - `reconcile-system-relation-field-universal-identifier`: structurally matches existing default relation fields per workspace and backfills the derived universal identifiers, `isSystemSideEffect` flags, and standard labels/icons. - `upgrade-people-data-labs-application`: upgrades installed PDL apps to `1.0.7` right after the backfill to close the desync window (its views reference the re-derived identifiers). ### Misc - `people-data-labs` `1.0.7`: views temporarily pin the new derived identifiers (TODO: import from the next released `twenty-sdk`). - `UpgradeStatusModule` split out of `UpgradeModule` so the application module cluster can consume upgrade status/migration services without importing the versioned command bundles (fixes a require cycle that crashed boot). - Docs: `system-fields.mdx` documents the system relation fields and their resolver; `sync-and-recovery.mdx` plan example no longer shows auto-injected relations. ## Known red CI `people-data-labs (dockerhub-latest)` fails by design until the 2.23 server image is published: the app pins the new identifiers which only exist on a 2.23 server. The `local` leg (server built from this branch) is green. ## System fields are no longer manifest-authorable (accepted regression) The manifest converter no longer derives `isSystem` / `isSystemSideEffect` from field names. Reserved-system-named manifest fields (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`, `updatedBy`, `position`, `searchVector`) are now skipped at conversion time when they carry the exact derived universal identifier (keeps manifests built with older SDKs installable), and rejected with `INVALID_INPUT` when they pin any other identifier. System fields are therefore fully engine-canonical: nothing a manifest carries can produce a system-flagged entity anymore. **Accepted regression**: a manifest can no longer influence system field properties at all. Previously a (legacy) re-declaration could shape them at creation — which actually produced broken system fields, e.g. a nullable, non-unique `id` — and could still toggle the allowlisted `isActive` / `universalSettings` afterwards. We consider this acceptable for now: per-app granularity over system fields will be reintroduced later through the **override framework**, which will also settle update semantics by forbidding direct updates over `isSystemSideEffect: true` entities and expressing divergence as overrides. `isSystemSideEffect`-only entities (the default relation fields provisioned by this PR) still have no engine-level update guard (see Follow-up below); that part is unchanged and also lands with the overrides refactor. ## Follow-up `isSystemSideEffect` field update/delete guards intentionally live at the API layer (`sanitize-raw-update-field-input.ts`, `from-delete-field-input-...util.ts`) rather than in the engine-level `FlatFieldMetadataValidatorService`. Moving them into the validator requires threading operation-origin (direct field mutation vs engine cascade) through the migration matrix, otherwise legitimate object rename/delete cascades (which carry `isSystemBuild=false`) would be rejected. Tracked in twentyhq/core-team-issues#2671. <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22882?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
This commit is contained in:
+4
@@ -7,6 +7,8 @@ import { FieldUniqueBackingIndexOnUpdateSideEffectHandlerService } from 'src/eng
|
||||
import { ObjectSearchVectorOnCreateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-search-vector-on-create-side-effect-handler.service';
|
||||
import { ObjectSearchVectorOnUpdateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-search-vector-on-update-side-effect-handler.service';
|
||||
import { ObjectSystemFieldsOnCreateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-fields-on-create-side-effect-handler.service';
|
||||
import { ObjectSystemRelationsOnCreateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-relations-on-create-side-effect-handler.service';
|
||||
import { ObjectSystemRelationsOnUpdateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-relations-on-update-side-effect-handler.service';
|
||||
import { ObjectSystemSideEffectsOnDeleteSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-side-effects-on-delete-side-effect-handler.service';
|
||||
|
||||
@Module({
|
||||
@@ -16,6 +18,8 @@ import { ObjectSystemSideEffectsOnDeleteSideEffectHandlerService } from 'src/eng
|
||||
FieldUniqueBackingIndexOnDeleteSideEffectHandlerService,
|
||||
FieldSearchFieldMetadataOnDeleteSideEffectHandlerService,
|
||||
ObjectSystemFieldsOnCreateSideEffectHandlerService,
|
||||
ObjectSystemRelationsOnCreateSideEffectHandlerService,
|
||||
ObjectSystemRelationsOnUpdateSideEffectHandlerService,
|
||||
ObjectSearchVectorOnCreateSideEffectHandlerService,
|
||||
ObjectSearchVectorOnUpdateSideEffectHandlerService,
|
||||
ObjectSystemSideEffectsOnDeleteSideEffectHandlerService,
|
||||
|
||||
+206
@@ -0,0 +1,206 @@
|
||||
import { getSystemRelationFieldUniversalIdentifier } from 'twenty-shared/application';
|
||||
import {
|
||||
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS,
|
||||
STANDARD_OBJECTS,
|
||||
} from 'twenty-shared/metadata';
|
||||
|
||||
import { type AllFlatEntityOperationRecordByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-operation-record-by-metadata-name.type';
|
||||
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
|
||||
import { type BuildSideEffectsArgs } from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service';
|
||||
import { ObjectSystemRelationsOnCreateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-relations-on-create-side-effect-handler.service';
|
||||
|
||||
const APPLICATION_UNIVERSAL_IDENTIFIER = 'a1a2a3a4-a5a6-4000-8000-000000000001';
|
||||
const SOURCE_OBJECT_UNIVERSAL_IDENTIFIER =
|
||||
'b1b2b3b4-b5b6-4000-8000-000000000001';
|
||||
|
||||
const NAME_PLURAL_BY_STANDARD_OBJECT_NAME_SINGULAR: Record<string, string> = {
|
||||
timelineActivity: 'timelineActivities',
|
||||
attachment: 'attachments',
|
||||
noteTarget: 'noteTargets',
|
||||
taskTarget: 'taskTargets',
|
||||
};
|
||||
|
||||
// The handler resolves the standard relation objects by their pinned standard
|
||||
// universal identifiers, so the mocks must carry them.
|
||||
const buildStandardTargetFlatObjectMetadatas = () =>
|
||||
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS.map((nameSingular) =>
|
||||
getFlatObjectMetadataMock({
|
||||
universalIdentifier: STANDARD_OBJECTS[nameSingular].universalIdentifier,
|
||||
nameSingular,
|
||||
namePlural: NAME_PLURAL_BY_STANDARD_OBJECT_NAME_SINGULAR[nameSingular],
|
||||
labelSingular: nameSingular,
|
||||
labelPlural: NAME_PLURAL_BY_STANDARD_OBJECT_NAME_SINGULAR[nameSingular],
|
||||
}),
|
||||
);
|
||||
|
||||
const SOURCE_FLAT_OBJECT_METADATA = getFlatObjectMetadataMock({
|
||||
universalIdentifier: SOURCE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: 'rocket',
|
||||
namePlural: 'rockets',
|
||||
labelSingular: 'Rocket',
|
||||
labelPlural: 'Rockets',
|
||||
});
|
||||
|
||||
const buildArgs = ({
|
||||
standardTargetFlatObjectMetadatas = buildStandardTargetFlatObjectMetadatas(),
|
||||
callerFieldMetadataToCreateByUniversalIdentifier = {},
|
||||
existingFieldMetadataByUniversalIdentifier = {},
|
||||
}: {
|
||||
standardTargetFlatObjectMetadatas?: ReturnType<
|
||||
typeof buildStandardTargetFlatObjectMetadatas
|
||||
>;
|
||||
callerFieldMetadataToCreateByUniversalIdentifier?: Record<string, unknown>;
|
||||
existingFieldMetadataByUniversalIdentifier?: Record<string, unknown>;
|
||||
}): BuildSideEffectsArgs<'objectMetadata'> => {
|
||||
const objectByUniversalIdentifier: Record<string, unknown> = {};
|
||||
|
||||
for (const flatObjectMetadata of standardTargetFlatObjectMetadatas) {
|
||||
objectByUniversalIdentifier[flatObjectMetadata.universalIdentifier] =
|
||||
flatObjectMetadata;
|
||||
}
|
||||
|
||||
return {
|
||||
flatEntity: SOURCE_FLAT_OBJECT_METADATA,
|
||||
allFlatEntityOperationRecordByMetadataName: {
|
||||
fieldMetadata: {
|
||||
flatEntityToCreate: callerFieldMetadataToCreateByUniversalIdentifier,
|
||||
},
|
||||
} as unknown as AllFlatEntityOperationRecordByMetadataName,
|
||||
relatedFlatEntityMaps: {
|
||||
flatObjectMetadataMaps: {
|
||||
byUniversalIdentifier: objectByUniversalIdentifier,
|
||||
},
|
||||
flatFieldMetadataMaps: {
|
||||
byUniversalIdentifier: existingFieldMetadataByUniversalIdentifier,
|
||||
},
|
||||
},
|
||||
context: {},
|
||||
} as unknown as BuildSideEffectsArgs<'objectMetadata'>;
|
||||
};
|
||||
|
||||
describe('ObjectSystemRelationsOnCreateSideEffectHandlerService', () => {
|
||||
const handler =
|
||||
new (ObjectSystemRelationsOnCreateSideEffectHandlerService as unknown as new () => ObjectSystemRelationsOnCreateSideEffectHandlerService)();
|
||||
|
||||
const targets = buildStandardTargetFlatObjectMetadatas();
|
||||
|
||||
const forwardUniversalIdentifierFor = (
|
||||
standardObjectUniversalIdentifier: string,
|
||||
) =>
|
||||
getSystemRelationFieldUniversalIdentifier({
|
||||
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier: SOURCE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetObjectUniversalIdentifier:
|
||||
standardObjectUniversalIdentifier,
|
||||
});
|
||||
|
||||
const reverseUniversalIdentifierFor = (objectUniversalIdentifier: string) =>
|
||||
getSystemRelationFieldUniversalIdentifier({
|
||||
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
|
||||
objectUniversalIdentifier,
|
||||
relationTargetObjectUniversalIdentifier:
|
||||
SOURCE_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
});
|
||||
|
||||
it('should provision a forward + reverse (both name-free, isSystemSideEffect) field pair and its index for all four standard relation objects', () => {
|
||||
const result = handler.buildSideEffects(buildArgs({}));
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
|
||||
if (result.status !== 'success') {
|
||||
throw new Error('expected success');
|
||||
}
|
||||
|
||||
const fieldMetadataToCreate =
|
||||
result.operations.fieldMetadata?.flatEntityToCreate ?? {};
|
||||
const indexToCreate = result.operations.index?.flatEntityToCreate ?? {};
|
||||
|
||||
expect(Object.keys(fieldMetadataToCreate)).toHaveLength(
|
||||
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS.length * 2,
|
||||
);
|
||||
expect(Object.keys(indexToCreate)).toHaveLength(
|
||||
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS.length,
|
||||
);
|
||||
|
||||
for (const target of targets) {
|
||||
const forwardUniversalIdentifier = forwardUniversalIdentifierFor(
|
||||
target.universalIdentifier,
|
||||
);
|
||||
const reverseUniversalIdentifier = reverseUniversalIdentifierFor(
|
||||
target.universalIdentifier,
|
||||
);
|
||||
|
||||
const forwardField = fieldMetadataToCreate[forwardUniversalIdentifier];
|
||||
const reverseField = fieldMetadataToCreate[reverseUniversalIdentifier];
|
||||
|
||||
expect(forwardField).toBeDefined();
|
||||
expect(reverseField).toBeDefined();
|
||||
expect(forwardField?.isSystemSideEffect).toBe(true);
|
||||
expect(reverseField?.isSystemSideEffect).toBe(true);
|
||||
expect(
|
||||
reverseField?.relationTargetObjectMetadataUniversalIdentifier,
|
||||
).toBe(SOURCE_OBJECT_UNIVERSAL_IDENTIFIER);
|
||||
}
|
||||
});
|
||||
|
||||
// The handler always emits its bundles: a caller field colliding on
|
||||
// universal identifier is not skipped but hard-fails at the engine merge
|
||||
// (RESERVED_SYSTEM_UNIVERSAL_IDENTIFIER collision).
|
||||
it('should emit all bundles even when a caller field collides on universal identifier', () => {
|
||||
const collidingTarget = targets[0];
|
||||
const collidingForwardUniversalIdentifier = forwardUniversalIdentifierFor(
|
||||
collidingTarget.universalIdentifier,
|
||||
);
|
||||
|
||||
const result = handler.buildSideEffects(
|
||||
buildArgs({
|
||||
callerFieldMetadataToCreateByUniversalIdentifier: {
|
||||
[collidingForwardUniversalIdentifier]: {
|
||||
universalIdentifier: collidingForwardUniversalIdentifier,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
|
||||
if (result.status !== 'success') {
|
||||
throw new Error('expected success');
|
||||
}
|
||||
|
||||
const fieldMetadataToCreate =
|
||||
result.operations.fieldMetadata?.flatEntityToCreate ?? {};
|
||||
const indexToCreate = result.operations.index?.flatEntityToCreate ?? {};
|
||||
|
||||
expect(Object.keys(fieldMetadataToCreate)).toHaveLength(
|
||||
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS.length * 2,
|
||||
);
|
||||
expect(Object.keys(indexToCreate)).toHaveLength(
|
||||
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS.length,
|
||||
);
|
||||
expect(
|
||||
fieldMetadataToCreate[collidingForwardUniversalIdentifier],
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('should fail with one error per missing standard relation object', () => {
|
||||
const [, , ...standardTargetFlatObjectMetadatasMissingFirstTwo] =
|
||||
buildStandardTargetFlatObjectMetadatas();
|
||||
|
||||
const result = handler.buildSideEffects(
|
||||
buildArgs({
|
||||
standardTargetFlatObjectMetadatas:
|
||||
standardTargetFlatObjectMetadatasMissingFirstTwo,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe('fail');
|
||||
|
||||
if (result.status !== 'fail') {
|
||||
throw new Error('expected fail');
|
||||
}
|
||||
|
||||
expect(result.errors).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import { type AllFlatEntityOperationRecordByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-operation-record-by-metadata-name.type';
|
||||
import { getFlatObjectMetadataMock } from 'src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock';
|
||||
import { type BuildSideEffectsArgs } from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service';
|
||||
import { ObjectSystemRelationsOnUpdateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-relations-on-update-side-effect-handler.service';
|
||||
|
||||
const OBJECT_UNIVERSAL_IDENTIFIER = 'b1b2b3b4-b5b6-4000-8000-000000000001';
|
||||
|
||||
const buildArgs = ({
|
||||
updatedNameSingular,
|
||||
updatedNamePlural,
|
||||
existingNameSingular = 'rocket',
|
||||
existingNamePlural = 'rockets',
|
||||
includeExisting = true,
|
||||
}: {
|
||||
updatedNameSingular: string;
|
||||
updatedNamePlural: string;
|
||||
existingNameSingular?: string;
|
||||
existingNamePlural?: string;
|
||||
includeExisting?: boolean;
|
||||
}): BuildSideEffectsArgs<'objectMetadata'> => {
|
||||
const existingFlatObjectMetadata = getFlatObjectMetadataMock({
|
||||
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: existingNameSingular,
|
||||
namePlural: existingNamePlural,
|
||||
labelSingular: existingNameSingular,
|
||||
labelPlural: existingNamePlural,
|
||||
});
|
||||
|
||||
const updatedFlatObjectMetadata = getFlatObjectMetadataMock({
|
||||
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
nameSingular: updatedNameSingular,
|
||||
namePlural: updatedNamePlural,
|
||||
labelSingular: updatedNameSingular,
|
||||
labelPlural: updatedNamePlural,
|
||||
});
|
||||
|
||||
return {
|
||||
flatEntity: updatedFlatObjectMetadata,
|
||||
allFlatEntityOperationRecordByMetadataName:
|
||||
{} as unknown as AllFlatEntityOperationRecordByMetadataName,
|
||||
relatedFlatEntityMaps: {
|
||||
flatObjectMetadataMaps: {
|
||||
byUniversalIdentifier: includeExisting
|
||||
? { [OBJECT_UNIVERSAL_IDENTIFIER]: existingFlatObjectMetadata }
|
||||
: {},
|
||||
},
|
||||
flatFieldMetadataMaps: { byUniversalIdentifier: {} },
|
||||
flatIndexMaps: { byUniversalIdentifier: {} },
|
||||
},
|
||||
context: {},
|
||||
} as unknown as BuildSideEffectsArgs<'objectMetadata'>;
|
||||
};
|
||||
|
||||
describe('ObjectSystemRelationsOnUpdateSideEffectHandlerService', () => {
|
||||
const handler =
|
||||
new (ObjectSystemRelationsOnUpdateSideEffectHandlerService as unknown as new () => ObjectSystemRelationsOnUpdateSideEffectHandlerService)();
|
||||
|
||||
it('should noop when the object name is unchanged', () => {
|
||||
const result = handler.buildSideEffects(
|
||||
buildArgs({
|
||||
existingNameSingular: 'rocket',
|
||||
existingNamePlural: 'rockets',
|
||||
updatedNameSingular: 'rocket',
|
||||
updatedNamePlural: 'rockets',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe('noop');
|
||||
});
|
||||
|
||||
it('should fail when the existing object cannot be resolved from the maps', () => {
|
||||
const result = handler.buildSideEffects(
|
||||
buildArgs({
|
||||
includeExisting: false,
|
||||
updatedNameSingular: 'house',
|
||||
updatedNamePlural: 'houses',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe('fail');
|
||||
});
|
||||
|
||||
it('should noop when a renamed object has no system side-effect morph relations to rename', () => {
|
||||
const result = handler.buildSideEffects(
|
||||
buildArgs({
|
||||
updatedNameSingular: 'house',
|
||||
updatedNamePlural: 'houses',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe('noop');
|
||||
});
|
||||
});
|
||||
+68
@@ -99,6 +99,74 @@ describe('ObjectSystemSideEffectsOnDeleteSideEffectHandlerService', () => {
|
||||
).toEqual([SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER]);
|
||||
});
|
||||
|
||||
it('should cascade-delete the default-relation reverse morph field on a standard object and its join-column index', () => {
|
||||
const FORWARD_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'd1d2d3d4-d5d6-4000-8000-000000000003';
|
||||
const REVERSE_FIELD_UNIVERSAL_IDENTIFIER =
|
||||
'd1d2d3d4-d5d6-4000-8000-000000000004';
|
||||
const REVERSE_INDEX_UNIVERSAL_IDENTIFIER =
|
||||
'e1e2e3e4-e5e6-4000-8000-000000000002';
|
||||
|
||||
const result = handler.buildSideEffects(
|
||||
buildArgs({
|
||||
relatedFlatEntityMaps: {
|
||||
flatFieldMetadataMaps: {
|
||||
byUniversalIdentifier: {
|
||||
[FORWARD_FIELD_UNIVERSAL_IDENTIFIER]: {
|
||||
universalIdentifier: FORWARD_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
isSystemSideEffect: true,
|
||||
objectMetadataUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
OTHER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
[REVERSE_FIELD_UNIVERSAL_IDENTIFIER]: {
|
||||
universalIdentifier: REVERSE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
isSystemSideEffect: true,
|
||||
objectMetadataUniversalIdentifier:
|
||||
OTHER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
relationTargetObjectMetadataUniversalIdentifier:
|
||||
OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
},
|
||||
},
|
||||
flatIndexMaps: {
|
||||
byUniversalIdentifier: {
|
||||
[REVERSE_INDEX_UNIVERSAL_IDENTIFIER]: {
|
||||
universalIdentifier: REVERSE_INDEX_UNIVERSAL_IDENTIFIER,
|
||||
isSystemSideEffect: true,
|
||||
objectMetadataUniversalIdentifier:
|
||||
OTHER_OBJECT_UNIVERSAL_IDENTIFIER,
|
||||
universalFlatIndexFieldMetadatas: [
|
||||
{
|
||||
fieldMetadataUniversalIdentifier:
|
||||
REVERSE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
flatSearchFieldMetadataMaps: { byUniversalIdentifier: {} },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe('success');
|
||||
|
||||
if (result.status !== 'success') {
|
||||
throw new Error('expected success');
|
||||
}
|
||||
|
||||
expect(
|
||||
Object.keys(result.operations.fieldMetadata?.flatEntityToDelete ?? {}),
|
||||
).toEqual([
|
||||
FORWARD_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
REVERSE_FIELD_UNIVERSAL_IDENTIFIER,
|
||||
]);
|
||||
expect(
|
||||
Object.keys(result.operations.index?.flatEntityToDelete ?? {}),
|
||||
).toEqual([REVERSE_INDEX_UNIVERSAL_IDENTIFIER]);
|
||||
});
|
||||
|
||||
it('should not delete entities belonging to a different object', () => {
|
||||
const result = handler.buildSideEffects(
|
||||
buildArgs({
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { msg, t } from '@lingui/core/macro';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isNonEmptyArray } from '@sniptt/guards';
|
||||
|
||||
import {
|
||||
DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS,
|
||||
STANDARD_OBJECTS,
|
||||
} from 'twenty-shared/metadata';
|
||||
import { fromArrayToUniqueKeyRecord, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
|
||||
import { MetadataSideEffectExceptionCode } from 'src/engine/metadata-modules/metadata-side-effect/exceptions/metadata-side-effect-exception-code';
|
||||
import {
|
||||
type BuildSideEffectsArgs,
|
||||
MetadataSideEffectHandler,
|
||||
} from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service';
|
||||
import {
|
||||
type MetadataSideEffectFailure,
|
||||
type MetadataSideEffectResult,
|
||||
} from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-result.type';
|
||||
import { buildSystemRelationFlatFieldMetadatasForObject } from 'src/engine/metadata-modules/object-metadata/utils/build-system-relation-flat-field-metadatas-for-object.util';
|
||||
import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type';
|
||||
|
||||
type DefaultRelationStandardObjectNameSingular =
|
||||
(typeof DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS)[number];
|
||||
|
||||
@Injectable()
|
||||
export class ObjectSystemRelationsOnCreateSideEffectHandlerService extends MetadataSideEffectHandler(
|
||||
{
|
||||
operation: 'create',
|
||||
metadataName: 'objectMetadata',
|
||||
name: 'objectSystemRelationsOnCreate',
|
||||
description:
|
||||
'When an object is created, provision its default relations to the four standard relation objects (timelineActivity, attachment, noteTarget, taskTarget): the forward RELATION field on the new object, the reverse MORPH_RELATION field on the standard object (name-free deterministic identifier so an object rename is a lossless update), and the reverse join-column index. All emitted entities are isSystemSideEffect, so the engine owns their lifecycle. twenty-standard authors these fields itself and never reaches this handler (it syncs via the FromTo path). The handler always emits its bundles: a caller-provided field colliding on universal identifier hard-fails at merge time (RESERVED_SYSTEM_UNIVERSAL_IDENTIFIER), and a caller field colliding on name hard-fails in the field validator (NOT_AVAILABLE).',
|
||||
},
|
||||
) {
|
||||
buildSideEffects({
|
||||
flatEntity: flatObjectMetadata,
|
||||
relatedFlatEntityMaps,
|
||||
}: BuildSideEffectsArgs<'objectMetadata'>): MetadataSideEffectResult {
|
||||
const sourceFlatObjectMetadata =
|
||||
flatObjectMetadata as UniversalFlatObjectMetadata;
|
||||
|
||||
const standardTargetFlatObjectMetadataByNameSingular = {} as Record<
|
||||
DefaultRelationStandardObjectNameSingular,
|
||||
UniversalFlatObjectMetadata
|
||||
>;
|
||||
|
||||
const missingStandardObjectErrors: MetadataSideEffectFailure['errors'] = [];
|
||||
|
||||
for (const standardObjectNameSingular of DEFAULT_RELATIONS_OBJECTS_STANDARD_IDS) {
|
||||
const standardTargetFlatObjectMetadata =
|
||||
relatedFlatEntityMaps.flatObjectMetadataMaps.byUniversalIdentifier[
|
||||
STANDARD_OBJECTS[standardObjectNameSingular].universalIdentifier
|
||||
];
|
||||
|
||||
if (!isDefined(standardTargetFlatObjectMetadata)) {
|
||||
missingStandardObjectErrors.push({
|
||||
code: MetadataSideEffectExceptionCode.SIDE_EFFECT_PARENT_METADATA_NOT_FOUND,
|
||||
message: t`Could not resolve standard relation object "${standardObjectNameSingular}" to provision default relations`,
|
||||
userFriendlyMessage: msg`A standard object required to provision default relations could not be found`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
standardTargetFlatObjectMetadataByNameSingular[
|
||||
standardObjectNameSingular
|
||||
] = standardTargetFlatObjectMetadata;
|
||||
}
|
||||
|
||||
if (isNonEmptyArray(missingStandardObjectErrors)) {
|
||||
return {
|
||||
status: 'fail',
|
||||
type: 'create',
|
||||
metadataName: 'objectMetadata',
|
||||
flatEntityMinimalInformation: {
|
||||
universalIdentifier: sourceFlatObjectMetadata.universalIdentifier,
|
||||
nameSingular: sourceFlatObjectMetadata.nameSingular,
|
||||
} as Partial<MetadataFlatEntity<'objectMetadata'>>,
|
||||
errors: missingStandardObjectErrors,
|
||||
};
|
||||
}
|
||||
|
||||
const systemRelationBundles =
|
||||
buildSystemRelationFlatFieldMetadatasForObject({
|
||||
sourceFlatObjectMetadata,
|
||||
standardTargetFlatObjectMetadataByNameSingular,
|
||||
applicationUniversalIdentifier:
|
||||
sourceFlatObjectMetadata.applicationUniversalIdentifier,
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
operations: {
|
||||
fieldMetadata: {
|
||||
flatEntityToCreate: fromArrayToUniqueKeyRecord({
|
||||
array: systemRelationBundles.flatMap(
|
||||
({ forwardFlatFieldMetadata, reverseFlatFieldMetadata }) => [
|
||||
forwardFlatFieldMetadata,
|
||||
reverseFlatFieldMetadata,
|
||||
],
|
||||
),
|
||||
uniqueKey: 'universalIdentifier',
|
||||
}),
|
||||
},
|
||||
index: {
|
||||
flatEntityToCreate: fromArrayToUniqueKeyRecord({
|
||||
array: systemRelationBundles.map(
|
||||
({ flatIndexMetadata }) => flatIndexMetadata,
|
||||
),
|
||||
uniqueKey: 'universalIdentifier',
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import { msg, t } from '@lingui/core/macro';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { fromArrayToUniqueKeyRecord, isDefined } from 'twenty-shared/utils';
|
||||
|
||||
import { type MetadataFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity.type';
|
||||
import { renameRelatedMorphFieldOnObjectNamesUpdate } from 'src/engine/metadata-modules/flat-object-metadata/utils/rename-related-morph-field-on-object-names-update.util';
|
||||
import { MetadataSideEffectExceptionCode } from 'src/engine/metadata-modules/metadata-side-effect/exceptions/metadata-side-effect-exception-code';
|
||||
import {
|
||||
type BuildSideEffectsArgs,
|
||||
MetadataSideEffectHandler,
|
||||
} from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service';
|
||||
import { type MetadataSideEffectResult } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-result.type';
|
||||
import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type';
|
||||
|
||||
@Injectable()
|
||||
export class ObjectSystemRelationsOnUpdateSideEffectHandlerService extends MetadataSideEffectHandler(
|
||||
{
|
||||
operation: 'update',
|
||||
metadataName: 'objectMetadata',
|
||||
name: 'objectSystemRelationsOnUpdate',
|
||||
description:
|
||||
'When an object is renamed, rename the reverse MORPH_RELATION fields of its default relations to the standard objects (timelineActivity, attachment, noteTarget, taskTarget) and recompute their join-column index names. These reverse fields are isSystemSideEffect, so the engine is their sole authority on rename across both the API and manifest-sync paths (the API transpiler renames only user-authored morph relations). The reverse field universal identifier is name-free, so a rename stays a lossless update. The computed updates are emitted unconditionally; any universal identifier collision with a caller-provided operation is arbitrated by the engine merge.',
|
||||
},
|
||||
) {
|
||||
buildSideEffects({
|
||||
flatEntity,
|
||||
relatedFlatEntityMaps,
|
||||
}: BuildSideEffectsArgs<'objectMetadata'>): MetadataSideEffectResult {
|
||||
const updatedFlatObjectMetadata = flatEntity as UniversalFlatObjectMetadata;
|
||||
|
||||
const existingFlatObjectMetadata =
|
||||
relatedFlatEntityMaps.flatObjectMetadataMaps.byUniversalIdentifier[
|
||||
updatedFlatObjectMetadata.universalIdentifier
|
||||
];
|
||||
|
||||
if (!isDefined(existingFlatObjectMetadata)) {
|
||||
return {
|
||||
status: 'fail',
|
||||
type: 'update',
|
||||
metadataName: 'objectMetadata',
|
||||
flatEntityMinimalInformation: {
|
||||
universalIdentifier: updatedFlatObjectMetadata.universalIdentifier,
|
||||
nameSingular: updatedFlatObjectMetadata.nameSingular,
|
||||
} as Partial<MetadataFlatEntity<'objectMetadata'>>,
|
||||
errors: [
|
||||
{
|
||||
code: MetadataSideEffectExceptionCode.SIDE_EFFECT_PARENT_METADATA_NOT_FOUND,
|
||||
message: t`Could not resolve the existing object "${updatedFlatObjectMetadata.nameSingular}" to rename its default relations`,
|
||||
userFriendlyMessage: msg`The object to rename could not be found to update its default relations`,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const isRenamed =
|
||||
existingFlatObjectMetadata.nameSingular !==
|
||||
updatedFlatObjectMetadata.nameSingular ||
|
||||
existingFlatObjectMetadata.namePlural !==
|
||||
updatedFlatObjectMetadata.namePlural;
|
||||
|
||||
if (!isRenamed) {
|
||||
return { status: 'noop' };
|
||||
}
|
||||
|
||||
const { morphFlatFieldMetadatasToUpdate, morphRelatedFlatIndexesToUpdate } =
|
||||
renameRelatedMorphFieldOnObjectNamesUpdate({
|
||||
fromFlatObjectMetadata: existingFlatObjectMetadata,
|
||||
toFlatObjectMetadata: {
|
||||
...existingFlatObjectMetadata,
|
||||
nameSingular: updatedFlatObjectMetadata.nameSingular,
|
||||
namePlural: updatedFlatObjectMetadata.namePlural,
|
||||
},
|
||||
flatFieldMetadataMaps: relatedFlatEntityMaps.flatFieldMetadataMaps,
|
||||
flatObjectMetadataMaps: relatedFlatEntityMaps.flatObjectMetadataMaps,
|
||||
flatIndexMaps: relatedFlatEntityMaps.flatIndexMaps,
|
||||
systemSideEffectMorphFieldsOnly: true,
|
||||
});
|
||||
|
||||
if (
|
||||
morphFlatFieldMetadatasToUpdate.length === 0 &&
|
||||
morphRelatedFlatIndexesToUpdate.length === 0
|
||||
) {
|
||||
return { status: 'noop' };
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
operations: {
|
||||
fieldMetadata: {
|
||||
flatEntityToUpdate: fromArrayToUniqueKeyRecord({
|
||||
array: morphFlatFieldMetadatasToUpdate,
|
||||
uniqueKey: 'universalIdentifier',
|
||||
}),
|
||||
},
|
||||
index: {
|
||||
flatEntityToUpdate: fromArrayToUniqueKeyRecord({
|
||||
array: morphRelatedFlatIndexesToUpdate,
|
||||
uniqueKey: 'universalIdentifier',
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ export class ObjectSystemSideEffectsOnDeleteSideEffectHandlerService extends Met
|
||||
metadataName: 'objectMetadata',
|
||||
name: 'objectSystemSideEffectsOnDelete',
|
||||
description:
|
||||
'When an object is deleted, cascade-delete its engine-owned side effects: the reserved system fields, every system index (including the GIN searchVector index), and its searchFieldMetadata rows. These entities are excluded from manifest deletion inference, so the cascade must be explicit. Caller-provided defaults (name, default relations) are NOT engine-owned and are deleted through normal deletion inference / the object delete transpiler.',
|
||||
'When an object is deleted, cascade-delete its engine-owned side effects: the reserved system fields, the default relation fields (forward field on the deleted object and reverse morph field on the standard object), every system index (reverse join-column indexes, the GIN searchVector index), and its searchFieldMetadata rows. The engine is the sole authority for isSystemSideEffect entities on delete: the API object delete transpiler cascades only user-authored fields and indexes, and manifest deletion inference excludes these entities entirely. Caller-provided defaults (e.g. the name field) are NOT engine-owned and are deleted through normal deletion inference / the object delete transpiler.',
|
||||
},
|
||||
) {
|
||||
buildSideEffects({
|
||||
|
||||
Reference in New Issue
Block a user