43730d7748
# Introduction
This PR introduces a centralized, strictly-typed **metadata side-effect
engine** that unifies how system metadata side effects are derived and
applied across both metadata entry points — the **metadata GraphQL API**
and the **application sync / manifest** flow — and migrates the first
side effect end-to-end: **a unique scalar field owns its backing
single-field `UNIQUE` index** (full create / update / delete lifecycle).
## New conventions
- **Engine-owned companions**: metadata flagged `isSystemSideEffect:
true` is owned by the engine. Its deletion is never inferred from
absence in a manifest — it results from PG-level cascade or from a
delete side effect (a side effect always has a cause, its parent
metadata).
- **Reserved deterministic identifiers**: apps cannot declare metadata
reusing an engine-owned deterministic `universalIdentifier`. Doing so
fails validation with `RESERVED_SYSTEM_UNIVERSAL_IDENTIFIER` (until an
explicit override API exists).
- **Record-native operation matrix**: the operation matrix is keyed by
`universalIdentifier` (`AllFlatEntityOperationRecordByMetadataName`)
instead of arrays, making parent resolution and deduplication O(1).
Array-based API callers are transpiled to records at the
validate-build-and-run boundary.
- Twenty-sdk user-facing experience with system fields will only be
related to overrides.
# What this PR does
## 1. Side-effect engine (foundation)
- `MetadataSideEffectEngineService.expandWithSideEffects(...)` takes the
intention-carrying record matrix and returns it expanded with derived
side effects, or a structured failure.
- Handlers are registered via a typed **decorator + registry** pattern
(`MetadataSideEffectHandler({ operation, metadataName, name, description
})`), with runtime duplicate-name detection. Multiple handlers per
(operation, metadataName) are supported.
- Handler contract mirrors the validator pattern:
- receives the trigger flat entity, the live record matrix, and
**strictly-typed related flat entity maps**
(`MetadataFlatEntityAndRelatedFlatEntityMapsForSideEffect<P>`, derived
from declared companion metadata names — no loose
`Partial<AllFlatEntityMaps>` context)
- returns `MetadataSideEffectResult`: `success` (operations record) |
`noop` | `fail` (structured failure)
- **Non-recursion is structural**: triggers are read from the original
caller input, never from the expanded matrix, so a side effect can never
trigger another side effect.
- **Deduplication + collision detection**: side effects are deduped by
`universalIdentifier` per operation; a caller-declared entity colliding
with an engine-owned deterministic identifier is recorded as a
collision.
- **Unified failure channel**: handler failures and reserved-identifier
collisions are merged into the same `OrchestratorFailureReport` contract
as builder validation errors, and the run short-circuits (fail-closed,
nothing is applied).
## 2. First migrated side effect — unique field → backing unique index
Three handlers own the complete lifecycle of the deterministic
single-field `UNIQUE` index backing a unique scalar field:
- **create**: unique scalar field → generate the deterministic backing
index (`fieldUniqueBackingIndexOnCreate`)
- **update**: `isUnique` flips and renames of still-unique fields (the
index name — and therefore its deterministic identifier — derives from
the field name, so a rename drops the stale index and recreates the
deterministic one) (`fieldUniqueBackingIndexOnUpdate`)
- **delete**: cascade-delete the backing index
(`fieldUniqueBackingIndexOnDelete`)
Supporting rules:
- The primary key `id` field never spawns a backing index (uniqueness
comes from the PK constraint) — explicit `isPrimaryKeyFlatFieldMetadata`
guard.
- Parent object resolution is **optimistic-first**: an object created or
updated in the same batch wins over the workspace cache (so e.g.
renaming an object while flipping a field to unique builds the index
from the post-rename object), resolved in O(1) via the record matrix.
- A missing parent object is reported as a structured side-effect
failure, never silently skipped.
## 3. Path convergence — manifest and API share one flow
- The manifest sync now derives a from→to **record matrix** from the
cache and feeds `validateBuildAndRunWorkspaceMigrationFromRecord`, the
same flow the API uses — both paths converge on the engine.
- Manifest-side unique-index generation and API transpiler
system-unique-index handling were removed (declared/composite/relation
indexes stay untouched).
- New `WorkspaceMigrationFlatEntityMapsService` mutualizes
flat-entity-maps computation between the side-effect engine and the
builder: cache keys are derived from the caller metadata names (+
validation- and side-effect-related closures) instead of hardcoded
loads.
- App-scoping and pruning are folded into one shared primitive
(`getSubAllFlatEntityMapsByApplicationIdsOrThrow`): slicing dependency
maps to the involved applications always prunes dangling one-to-many
aggregators — callers can no longer forget it.
- **Behavior change**: an app extending another app's view with a view
field now syncs successfully (cross-app view-field extension), covered
by a dedicated integration test.
## 4. Backfill upgrade command (2.19)
`upgrade:2-19:backfill-system-unique-index-universal-identifier`
rewrites legacy system unique-index `universalIdentifier`s to their
deterministic value so the engine can own pre-existing indexes. The
backfill is **driven from `isUnique: true` fields** (mirroring the
engine ownership predicate — excludes PK / morph / relation fields) and
resolves each field's backing index in O(1).
# Bugs fixed along the way
- `database:reset` seeding failed with
`INDEX_FIELD_INVALID_DEFAULT_VALUE`: the engine derived a backing
`UNIQUE` index for the default `id` primary key. Fixed with the explicit
primary-key guard.
- `isUnique` updates on system-flagged standard fields (e.g.
auto-created `name`) did not trigger the backing-index side effect.
- Manifest sync crashed with "Could not find flat entity with universal
identifier ..." when app-scoped slices left dangling aggregator
references — fixed by centralizing pruning in the shared slice primitive
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
|
|
import { type AllMetadataName } from 'twenty-shared/metadata';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
|
|
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
|
|
import { type AllFlatEntityOperationRecordByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-operation-record-by-metadata-name.type';
|
|
import { type MetadataFlatEntityAndRelatedFlatEntityMapsForSideEffect } from 'src/engine/metadata-modules/flat-entity/types/metadata-flat-entity-and-related-flat-entity-maps-for-side-effect.type';
|
|
import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type';
|
|
import { getMetadataManyToOneRelatedNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-many-to-one-related-names.util';
|
|
import { getMetadataSideEffectCompanionNames } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-side-effect-companion-names.util';
|
|
import { isSystemSideEffectFlatEntity } from 'src/engine/metadata-modules/flat-entity/utils/is-system-side-effect-flat-entity.util';
|
|
import { MetadataSideEffectHandlerRegistryService } from 'src/engine/metadata-modules/metadata-side-effect/registry/metadata-side-effect-handler-registry.service';
|
|
import { type MetadataSideEffectContext } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-context.type';
|
|
import { type MetadataSideEffectExpansionResult } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-expansion-result.type';
|
|
import {
|
|
METADATA_SIDE_EFFECT_OPERATIONS,
|
|
type MetadataSideEffectOperation,
|
|
} from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-operation.type';
|
|
import { type MetadataSideEffectFailure } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-result.type';
|
|
import { type SystemSideEffectUniversalIdentifierCollision } from 'src/engine/metadata-modules/metadata-side-effect/types/system-side-effect-universal-identifier-collision.type';
|
|
import { mapSystemSideEffectCollisionToFailure } from 'src/engine/metadata-modules/metadata-side-effect/utils/map-system-side-effect-collision-to-failure.util';
|
|
import { EMPTY_ORCHESTRATOR_FAILURE_REPORT } from 'src/engine/workspace-manager/workspace-migration/constant/empty-orchestrator-failure-report.constant';
|
|
import { pushToOrchestratorFailureReport } from 'src/engine/workspace-manager/workspace-migration/utils/merge-orchestrator-failure-reports.util';
|
|
|
|
type GenericUniversalFlatEntity = { universalIdentifier: string };
|
|
type GenericFlatEntityOperationRecord = {
|
|
flatEntityToCreate: Record<string, GenericUniversalFlatEntity>;
|
|
flatEntityToUpdate: Record<string, GenericUniversalFlatEntity>;
|
|
flatEntityToDelete: Record<string, GenericUniversalFlatEntity>;
|
|
};
|
|
type GenericAllFlatEntityOperationRecordByMetadataName = Record<
|
|
string,
|
|
GenericFlatEntityOperationRecord | undefined
|
|
>;
|
|
|
|
type GenericPartialFlatEntityOperationRecord = {
|
|
flatEntityToCreate?: Record<string, GenericUniversalFlatEntity>;
|
|
flatEntityToUpdate?: Record<string, GenericUniversalFlatEntity>;
|
|
flatEntityToDelete?: Record<string, GenericUniversalFlatEntity>;
|
|
};
|
|
type GenericMetadataSideEffectOperationsByMetadataName = Record<
|
|
string,
|
|
GenericPartialFlatEntityOperationRecord | undefined
|
|
>;
|
|
|
|
const OPERATION_TO_FLAT_ENTITY_RECORD_KEY = {
|
|
create: 'flatEntityToCreate',
|
|
update: 'flatEntityToUpdate',
|
|
delete: 'flatEntityToDelete',
|
|
} as const satisfies Record<
|
|
MetadataSideEffectOperation,
|
|
keyof GenericFlatEntityOperationRecord
|
|
>;
|
|
|
|
@Injectable()
|
|
export class MetadataSideEffectEngineService {
|
|
constructor(
|
|
private readonly metadataSideEffectHandlerRegistryService: MetadataSideEffectHandlerRegistryService,
|
|
) {}
|
|
|
|
getSideEffectRelatedMetadataNames(
|
|
triggerMetadataNames: AllMetadataName[],
|
|
): AllMetadataName[] {
|
|
const relatedMetadataNames = new Set<AllMetadataName>();
|
|
|
|
for (const {
|
|
metadataName,
|
|
} of this.metadataSideEffectHandlerRegistryService.getRegisteredHandlerKeys()) {
|
|
if (!triggerMetadataNames.includes(metadataName)) {
|
|
continue;
|
|
}
|
|
|
|
for (const relatedMetadataName of [
|
|
metadataName,
|
|
...getMetadataManyToOneRelatedNames(metadataName),
|
|
...getMetadataSideEffectCompanionNames(metadataName),
|
|
]) {
|
|
relatedMetadataNames.add(relatedMetadataName);
|
|
}
|
|
}
|
|
|
|
return [...relatedMetadataNames];
|
|
}
|
|
|
|
expandWithSideEffects({
|
|
allFlatEntityOperationRecordByMetadataName,
|
|
sideEffectRelatedFlatEntityMaps,
|
|
context,
|
|
}: {
|
|
allFlatEntityOperationRecordByMetadataName: AllFlatEntityOperationRecordByMetadataName;
|
|
sideEffectRelatedFlatEntityMaps: Partial<AllFlatEntityMaps>;
|
|
context: MetadataSideEffectContext;
|
|
}): MetadataSideEffectExpansionResult {
|
|
const expandedMatrix = this.cloneMatrix(
|
|
allFlatEntityOperationRecordByMetadataName,
|
|
);
|
|
const systemSideEffectUniversalIdentifierCollisions: SystemSideEffectUniversalIdentifierCollision[] =
|
|
[];
|
|
const sideEffectFailures: MetadataSideEffectFailure[] = [];
|
|
|
|
const triggerMatrix =
|
|
allFlatEntityOperationRecordByMetadataName as unknown as GenericAllFlatEntityOperationRecordByMetadataName;
|
|
|
|
for (const {
|
|
operation,
|
|
metadataName,
|
|
} of this.metadataSideEffectHandlerRegistryService.getRegisteredHandlerKeys()) {
|
|
const handlers =
|
|
this.metadataSideEffectHandlerRegistryService.getHandlers(
|
|
operation,
|
|
metadataName,
|
|
);
|
|
|
|
if (handlers.length === 0) {
|
|
continue;
|
|
}
|
|
|
|
const triggerFlatEntities = Object.values(
|
|
triggerMatrix[metadataName]?.[
|
|
OPERATION_TO_FLAT_ENTITY_RECORD_KEY[operation]
|
|
] ?? {},
|
|
);
|
|
|
|
for (const triggerFlatEntity of triggerFlatEntities) {
|
|
for (const handler of handlers) {
|
|
const sideEffectResult = handler.buildSideEffects({
|
|
flatEntity:
|
|
triggerFlatEntity as unknown as MetadataUniversalFlatEntity<AllMetadataName>,
|
|
allFlatEntityOperationRecordByMetadataName:
|
|
expandedMatrix as unknown as AllFlatEntityOperationRecordByMetadataName,
|
|
relatedFlatEntityMaps:
|
|
sideEffectRelatedFlatEntityMaps as unknown as MetadataFlatEntityAndRelatedFlatEntityMapsForSideEffect<AllMetadataName>,
|
|
context,
|
|
});
|
|
|
|
if (sideEffectResult.status === 'fail') {
|
|
sideEffectFailures.push(sideEffectResult);
|
|
continue;
|
|
}
|
|
|
|
if (sideEffectResult.status === 'noop') {
|
|
continue;
|
|
}
|
|
|
|
this.mergeSideEffectsIntoMatrix({
|
|
expandedMatrix,
|
|
sideEffectOperations:
|
|
sideEffectResult.operations as unknown as GenericMetadataSideEffectOperationsByMetadataName,
|
|
systemSideEffectUniversalIdentifierCollisions,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
const allSideEffectFailures: MetadataSideEffectFailure[] = [
|
|
...sideEffectFailures,
|
|
...systemSideEffectUniversalIdentifierCollisions.map(
|
|
mapSystemSideEffectCollisionToFailure,
|
|
),
|
|
];
|
|
|
|
if (allSideEffectFailures.length > 0) {
|
|
const report = EMPTY_ORCHESTRATOR_FAILURE_REPORT();
|
|
|
|
for (const sideEffectFailure of allSideEffectFailures) {
|
|
pushToOrchestratorFailureReport({
|
|
report,
|
|
metadataName: sideEffectFailure.metadataName,
|
|
items: [sideEffectFailure],
|
|
});
|
|
}
|
|
|
|
return {
|
|
status: 'fail',
|
|
report,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: 'success',
|
|
allFlatEntityOperationRecordByMetadataName:
|
|
expandedMatrix as unknown as AllFlatEntityOperationRecordByMetadataName,
|
|
};
|
|
}
|
|
|
|
private mergeSideEffectsIntoMatrix({
|
|
expandedMatrix,
|
|
sideEffectOperations,
|
|
systemSideEffectUniversalIdentifierCollisions,
|
|
}: {
|
|
expandedMatrix: GenericAllFlatEntityOperationRecordByMetadataName;
|
|
sideEffectOperations: GenericMetadataSideEffectOperationsByMetadataName;
|
|
systemSideEffectUniversalIdentifierCollisions: SystemSideEffectUniversalIdentifierCollision[];
|
|
}): void {
|
|
for (const metadataName of Object.keys(sideEffectOperations)) {
|
|
const operationBuckets = sideEffectOperations[metadataName];
|
|
|
|
if (!isDefined(operationBuckets)) {
|
|
continue;
|
|
}
|
|
|
|
for (const operation of METADATA_SIDE_EFFECT_OPERATIONS) {
|
|
const sideEffectFlatEntities = Object.values(
|
|
operationBuckets[OPERATION_TO_FLAT_ENTITY_RECORD_KEY[operation]] ??
|
|
{},
|
|
);
|
|
|
|
for (const sideEffectFlatEntity of sideEffectFlatEntities) {
|
|
this.addToOperationIfAbsent({
|
|
expandedMatrix,
|
|
operation,
|
|
metadataName,
|
|
flatEntity: sideEffectFlatEntity,
|
|
systemSideEffectUniversalIdentifierCollisions,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private cloneMatrix(
|
|
allFlatEntityOperationRecordByMetadataName: AllFlatEntityOperationRecordByMetadataName,
|
|
): GenericAllFlatEntityOperationRecordByMetadataName {
|
|
const genericMatrix =
|
|
allFlatEntityOperationRecordByMetadataName as unknown as GenericAllFlatEntityOperationRecordByMetadataName;
|
|
const clonedMatrix: GenericAllFlatEntityOperationRecordByMetadataName = {};
|
|
|
|
for (const metadataName of Object.keys(genericMatrix)) {
|
|
const operations = genericMatrix[metadataName];
|
|
|
|
if (!isDefined(operations)) {
|
|
continue;
|
|
}
|
|
|
|
clonedMatrix[metadataName] = {
|
|
flatEntityToCreate: { ...operations.flatEntityToCreate },
|
|
flatEntityToUpdate: { ...operations.flatEntityToUpdate },
|
|
flatEntityToDelete: { ...operations.flatEntityToDelete },
|
|
};
|
|
}
|
|
|
|
return clonedMatrix;
|
|
}
|
|
|
|
private addToOperationIfAbsent({
|
|
expandedMatrix,
|
|
operation,
|
|
metadataName,
|
|
flatEntity,
|
|
systemSideEffectUniversalIdentifierCollisions,
|
|
}: {
|
|
expandedMatrix: GenericAllFlatEntityOperationRecordByMetadataName;
|
|
operation: MetadataSideEffectOperation;
|
|
metadataName: string;
|
|
flatEntity: GenericUniversalFlatEntity;
|
|
systemSideEffectUniversalIdentifierCollisions: SystemSideEffectUniversalIdentifierCollision[];
|
|
}): void {
|
|
const operations = (expandedMatrix[metadataName] ??= {
|
|
flatEntityToCreate: {},
|
|
flatEntityToUpdate: {},
|
|
flatEntityToDelete: {},
|
|
});
|
|
|
|
const flatEntityRecordKey = OPERATION_TO_FLAT_ENTITY_RECORD_KEY[operation];
|
|
const flatEntityRecord = operations[flatEntityRecordKey];
|
|
|
|
const existingFlatEntity = flatEntityRecord[flatEntity.universalIdentifier];
|
|
|
|
if (isDefined(existingFlatEntity)) {
|
|
this.recordUniversalIdentifierCollisionIfNeeded({
|
|
existingFlatEntity,
|
|
operation,
|
|
metadataName,
|
|
flatEntity,
|
|
systemSideEffectUniversalIdentifierCollisions,
|
|
});
|
|
|
|
return;
|
|
}
|
|
|
|
flatEntityRecord[flatEntity.universalIdentifier] = flatEntity;
|
|
}
|
|
|
|
private recordUniversalIdentifierCollisionIfNeeded({
|
|
existingFlatEntity,
|
|
operation,
|
|
metadataName,
|
|
flatEntity,
|
|
systemSideEffectUniversalIdentifierCollisions,
|
|
}: {
|
|
existingFlatEntity: GenericUniversalFlatEntity;
|
|
operation: MetadataSideEffectOperation;
|
|
metadataName: string;
|
|
flatEntity: GenericUniversalFlatEntity;
|
|
systemSideEffectUniversalIdentifierCollisions: SystemSideEffectUniversalIdentifierCollision[];
|
|
}): void {
|
|
const isIncomingSystemSideEffect = isSystemSideEffectFlatEntity(
|
|
flatEntity as unknown as MetadataUniversalFlatEntity<AllMetadataName>,
|
|
);
|
|
|
|
if (!isIncomingSystemSideEffect) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
isSystemSideEffectFlatEntity(
|
|
existingFlatEntity as unknown as MetadataUniversalFlatEntity<AllMetadataName>,
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
systemSideEffectUniversalIdentifierCollisions.push({
|
|
metadataName: metadataName as AllMetadataName,
|
|
operation,
|
|
universalIdentifier: flatEntity.universalIdentifier,
|
|
name: this.extractFlatEntityName(flatEntity),
|
|
});
|
|
}
|
|
|
|
private extractFlatEntityName(
|
|
flatEntity: GenericUniversalFlatEntity,
|
|
): string | undefined {
|
|
const { name } = flatEntity as { name?: unknown };
|
|
|
|
return typeof name === 'string' ? name : undefined;
|
|
}
|
|
}
|