Centralize system field side effects + search field metadata (#22594)

## Introduction

Closes twentyhq/core-team-issues#2635 and twentyhq/core-team-issues#2642
and twentyhq/core-team-issues#2589

Object system fields (`searchVector` + its GIN index +
`searchFieldMetadata`, the reserved system fields, default relations)
were provisioned through several scattered, path-specific code paths. As
a result the **app-manifest sync path** authored objects with an
empty/`NULL` `searchVector` and **zero `searchFieldMetadata`**, so
app-owned objects shipped a broken generated search column (see #22657).
The generation logic also lived partly in imperative services rather
than in the metadata side-effect engine, and relied on non-deterministic
(`v4`) universal identifiers that `twenty apply` could not converge,
destroying manually backfilled rows.

This PR centralizes every object-creation system side effect into the
**metadata side-effect engine**, extends the engine to keep search
metadata consistent on field delete and object relabel, makes the
standard app's search identifiers deterministic, and ships upgrade
commands to reconcile existing workspaces.

## What changed

### Side effects moved into the metadata side-effect engine

New dedicated, self-contained handlers — so every write path (API and
app manifest) gets identical results, and side effects never trigger
other side effects.

**Object create / delete** (`handlers/object-metadata`)

* **`objectSystemFieldsOnCreate`** — generates the 7 reserved system
fields (`id`, `createdAt`, `updatedAt`, `deletedAt`, `createdBy`,
`updatedBy`, `position`).
* **`objectSearchVectorOnCreate`** — provisions the full-text search
surface as one unit: the `searchVector` `TS_VECTOR` field, its backing
GIN index, and the `searchFieldMetadata` row (for searchable objects
whose label identifier is a searchable field) that keeps `searchVector`
populated instead of `NULL`.
* **`objectSystemSideEffectsOnDelete`** — tears the above down on object
deletion.

**Search-metadata consistency on relabel / field delete** (new — these
are what close the manifest-path gaps)

* **`objectSearchVectorOnUpdate`** (`handlers/object-metadata`) — when a
searchable object is relabeled onto a new searchable field, provisions
the `searchFieldMetadata` row that indexes it. Relabeling is
**additive**: existing rows (e.g. the provisioned `name` row) are
preserved, so the previous label identifier stays searchable. Mirrors
the API update path so a manifest re-sync that changes the label
identifier reaches search parity. No-ops for junction objects (`id`
label identifier) and non-searchable field types.
* **`fieldSearchFieldMetadataOnDelete`** (`handlers/field-metadata`) —
when a field is deleted, cascade-deletes every `searchFieldMetadata` row
that indexes it. `searchFieldMetadata` is excluded from manifest
deletion inference, so this explicit cascade is what covers **both the
API and manifest paths** (the object-scoped DB cascade only fires on
object deletion). Uses the `searchFieldMetadataUniversalIdentifiers`
aggregator on the flat field for an O(k) lookup instead of scanning all
rows.

The **default `name` field and default relations are now caller-provided
default fields** (SDK autocomplete on the manifest path, input
transpiler on the API path) rather than system side effects — removing
duplicate name generation, the imperative
`build-default-*-for-custom-object` utilities, and the ad-hoc
system-field integrity validator.

### Deterministic identifiers for the standard app

The twenty-standard search GIN index and `searchFieldMetadata` now
derive deterministic universal identifiers
(`getIndexUniversalIdentifier` / `getSearchFieldUniversalIdentifier`)
instead of `v4`, so `twenty apply` converges instead of recreating.

### Upgrade commands (`2-20`) to reconcile existing workspaces

**Instance commands** (run once per instance; ordered fast → slow →
workspace):

1. **`AddIsSystemSideEffectToSearchFieldMetadata`** (fast) — adds the
`isSystemSideEffect` column to `core.searchFieldMetadata`. Defaults to
`true`, which also correctly backfills every existing row since
`searchFieldMetadata` is always system-derived (never user-authored).
2. **`BackfillNameFieldIsSystemSideEffect`** (slow) — re-flags existing
`name` fields from `isSystemSideEffect: true` → `false`, since the
default `name` field is now a caller-provided default like any other
user-owned field (it was provisioned as `true` in 2.15 → 2.19). This is
a pure data backfill, so the bulk `UPDATE` lives in `runDataMigration()`
rather than `up()` — keeping it out of the fast schema transaction
avoids holding an `ACCESS EXCLUSIVE` lock that could stall reads during
the deploy. Slow instance commands still run before every workspace
command of the version, so the fresh value is in place before the
search-reconcile workspace commands recompute the `fieldMetadata`
flat-entity cache. Scoping by name alone is safe (no engine-owned field
is named `name`); `down()` is best-effort (pre-2.15 `false` rows are
indistinguishable from flipped ones).

**Workspace commands** (idempotent, dry-run supported):

1. **`reconcile-search-vector-gin-index-universal-identifier`** —
re-owns every searchVector GIN index UID to its deterministic value (all
applications), then backfills the missing GIN index for installed-app
objects.
2. **`reconcile-search-field-metadata`** — re-owns every
`searchFieldMetadata` UID (all applications), then backfills the missing
rows for installed-app searchable objects.
3. **`rebuild-installed-app-search-vectors`** — rebuilds the
`searchVector` column of every installed-app `TS_VECTOR` field, once the
index and rows exist.

Design notes:

* **Re-own is global** (twenty-standard, workspace-custom, installed) —
a UID convergence keyed on each row's own application.
* **Backfill is installed-app only** — standard/custom objects already
have these rows via the manifest funnel.
* Re-own runs **before** backfill and is transaction-guarded; a failure
aborts that workspace to avoid a unique-identifier collision.

## Tests

* Integration: app manifest sync now asserts system fields + searchable
objects (searchVector, GIN index, searchFieldMetadata) are created; a
new relabel suite drives three manifest syncs and asserts records stay
searchable through the old + new label identifiers and lose
searchability when a field is removed; removed the obsolete
system-fields-integrity suite/snapshots.
* Unit: per-handler side-effect specs (including the new
`objectSearchVectorOnUpdate` and `fieldSearchFieldMetadataOnDelete`
handlers), and per-util specs for the re-own / backfill operation
builders and the GIN-index classifier.

## Upgrade / migration notes

* Existing workspaces converge on the next upgrade run via the `2-20`
instance + workspace commands (idempotent, dry-run supported).
* Backfill and rebuild go through the workspace-migration runner
(automatic cache invalidation); the re-own step invalidates only the
affected flat-entity maps directly.
* The cross-version upgrade CI now flushes the cache before running the
upgrade, so the new version recomputes every flat-entity map from the
database instead of reading blobs the old version serialized in an older
shape.

## Follow-up

* `object-metadata.service.ts` still carries a `TODO: remove once
default view fields move to the metadata side effect engine` — default
view fields are the next candidate to move into the engine.
* A single manifest sync cannot yet both create a field and relabel the
object onto it, because `objectMetadata.update` is ordered before
`fieldMetadata.create` in the migration runner. Tracked in
twentyhq/core-team-issues#2655; to be fixed in a follow-up.
This commit is contained in:
Paul Rastoin
2026-07-09 16:59:54 +02:00
committed by GitHub
parent b47e64c9e3
commit 60fd322b49
88 changed files with 5246 additions and 3095 deletions
@@ -94,7 +94,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
flatIndexMaps: existingFlatIndexMaps,
flatFieldMetadataMaps: existingFlatFieldMetadataMaps,
flatPageLayoutWidgetMaps: existingFlatPageLayoutWidgetMaps,
flatSearchFieldMetadataMaps: existingFlatSearchFieldMetadataMaps,
} = await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
@@ -103,7 +102,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
'flatIndexMaps',
'flatFieldMetadataMaps',
'flatPageLayoutWidgetMaps',
'flatSearchFieldMetadataMaps',
],
},
);
@@ -112,13 +110,11 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
flatFieldMetadatasToDelete,
flatIndexesToDelete,
flatIndexesToUpdate,
searchFieldMetadatasToDelete,
} = fromDeleteFieldInputToFlatFieldMetadatasToDelete({
deleteOneFieldInput,
flatFieldMetadataMaps: existingFlatFieldMetadataMaps,
flatIndexMaps: existingFlatIndexMaps,
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
flatSearchFieldMetadataMaps: existingFlatSearchFieldMetadataMaps,
});
const deletedFlatFieldMetadata = findFlatEntityByUniversalIdentifierOrThrow(
@@ -167,11 +163,6 @@ export class FieldMetadataService extends TypeOrmQueryService<FieldMetadataEntit
flatEntityToDelete: flatIndexesToDelete,
flatEntityToUpdate: flatIndexesToUpdate,
},
searchFieldMetadata: {
flatEntityToCreate: [],
flatEntityToDelete: searchFieldMetadatasToDelete,
flatEntityToUpdate: [],
},
...(flatPageLayoutWidgetsToDelete.length > 0
? {
pageLayoutWidget: {
@@ -1807,6 +1807,11 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = {
},
},
searchFieldMetadata: {
isSystemSideEffect: {
toCompare: false,
toStringify: false,
universalProperty: undefined,
},
objectMetadataId: {
toCompare: false,
toStringify: false,
@@ -1,7 +1,8 @@
import { type AllMetadataName } from 'twenty-shared/metadata';
export const ALL_METADATA_SIDE_EFFECT_COMPANION_METADATA_NAMES = {
fieldMetadata: ['index'],
fieldMetadata: ['index', 'searchFieldMetadata'],
objectMetadata: ['fieldMetadata', 'index', 'searchFieldMetadata'],
} as const satisfies Partial<
Record<AllMetadataName, readonly AllMetadataName[]>
>;
@@ -46,7 +46,6 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => {
flatFieldMetadataMaps: buildFlatFieldMetadataMaps([]),
flatObjectMetadataMaps: buildFlatObjectMetadataMaps('obj-1'),
flatIndexMaps: createEmptyFlatEntityMaps(),
flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(),
}),
).toThrow(
expect.objectContaining({
@@ -70,7 +69,6 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => {
flatFieldMetadataMaps: buildFlatFieldMetadataMaps([standardField]),
flatObjectMetadataMaps: buildFlatObjectMetadataMaps(objectId),
flatIndexMaps: createEmptyFlatEntityMaps(),
flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(),
}),
).toThrow(
expect.objectContaining({
@@ -94,7 +92,6 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => {
flatFieldMetadataMaps: buildFlatFieldMetadataMaps([standardField]),
flatObjectMetadataMaps: buildFlatObjectMetadataMaps(objectId),
flatIndexMaps: createEmptyFlatEntityMaps(),
flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(),
}),
).toThrow(new RegExp('Cannot delete standard field "city"'));
});
@@ -113,7 +110,6 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => {
flatFieldMetadataMaps: buildFlatFieldMetadataMaps([customField]),
flatObjectMetadataMaps: buildFlatObjectMetadataMaps(objectId),
flatIndexMaps: createEmptyFlatEntityMaps(),
flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(),
});
expect(result.flatFieldMetadatasToDelete).toContainEqual(
@@ -136,7 +132,6 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => {
flatFieldMetadataMaps: buildFlatFieldMetadataMaps([standardField]),
flatObjectMetadataMaps: buildFlatObjectMetadataMaps(objectId),
flatIndexMaps: createEmptyFlatEntityMaps(),
flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(),
}),
).toThrow(FieldMetadataException);
});
@@ -160,7 +155,6 @@ describe('fromDeleteFieldInputToFlatFieldMetadatasToDelete', () => {
flatFieldMetadataMaps: buildFlatFieldMetadataMaps([standardAppField]),
flatObjectMetadataMaps: buildFlatObjectMetadataMaps(objectId),
flatIndexMaps: createEmptyFlatEntityMaps(),
flatSearchFieldMetadataMaps: createEmptyFlatEntityMaps(),
}),
).toThrow(
expect.objectContaining({
@@ -1,60 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { findManyFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatSearchFieldMetadata } from 'src/engine/metadata-modules/flat-search-field-metadata/types/flat-search-field-metadata.type';
import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type';
export const computeSearchFieldMetadataDeletionForDeletedFields = ({
flatFieldMetadatasToDelete,
flatObjectMetadataMaps,
flatSearchFieldMetadataMaps,
}: {
flatFieldMetadatasToDelete: FlatFieldMetadata[];
} & Pick<
AllFlatEntityMaps,
'flatObjectMetadataMaps' | 'flatSearchFieldMetadataMaps'
>): {
searchFieldMetadatasToDelete: UniversalFlatSearchFieldMetadata[];
} => {
const deletedFieldIds = new Set(
flatFieldMetadatasToDelete.map((flatFieldMetadata) => flatFieldMetadata.id),
);
const affectedObjectMetadataIds = new Set(
flatFieldMetadatasToDelete.map(
(flatFieldMetadata) => flatFieldMetadata.objectMetadataId,
),
);
const searchFieldMetadatasToDelete: UniversalFlatSearchFieldMetadata[] = [];
for (const objectMetadataId of affectedObjectMetadataIds) {
const flatObjectMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: objectMetadataId,
flatEntityMaps: flatObjectMetadataMaps,
});
if (!isDefined(flatObjectMetadata) || !flatObjectMetadata.isSearchable) {
continue;
}
const objectSearchFieldMetadatas =
findManyFlatEntityByIdInFlatEntityMapsOrThrow<FlatSearchFieldMetadata>({
flatEntityMaps: flatSearchFieldMetadataMaps,
flatEntityIds: flatObjectMetadata.searchFieldMetadataIds,
});
const rowsToDelete = objectSearchFieldMetadatas.filter(
(searchFieldMetadata) =>
deletedFieldIds.has(searchFieldMetadata.fieldMetadataId),
);
searchFieldMetadatasToDelete.push(...rowsToDelete);
}
return {
searchFieldMetadatasToDelete,
};
};
@@ -13,7 +13,6 @@ import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { findManyFlatEntityByUniversalIdentifierInUniversalFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-universal-identifier-in-universal-flat-entity-maps-or-throw.util';
import { computeFlatFieldMetadataRelatedFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/compute-flat-field-metadata-related-flat-field-metadata.util';
import { computeSearchFieldMetadataDeletionForDeletedFields } from 'src/engine/metadata-modules/flat-field-metadata/utils/compute-search-field-metadata-deletion-for-deleted-fields.util';
import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util';
import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type';
import { isSystemUniqueFlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/utils/is-system-unique-flat-index-metadata.util';
@@ -21,16 +20,12 @@ import { generateFlatIndexMetadataWithNameOrThrow } from 'src/engine/metadata-mo
import { belongsToTwentyStandardApp } from 'src/engine/metadata-modules/utils/belongs-to-twenty-standard-app.util';
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
import { type UniversalFlatIndexMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-index-metadata.type';
import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type';
type FromDeleteFieldInputToFlatFieldMetadatasToDeleteArgs = {
deleteOneFieldInput: DeleteOneFieldInput;
} & Pick<
AllFlatEntityMaps,
| 'flatFieldMetadataMaps'
| 'flatIndexMaps'
| 'flatObjectMetadataMaps'
| 'flatSearchFieldMetadataMaps'
'flatFieldMetadataMaps' | 'flatIndexMaps' | 'flatObjectMetadataMaps'
>;
// TODO refactor as a side effect service
export const fromDeleteFieldInputToFlatFieldMetadatasToDelete = ({
@@ -38,12 +33,10 @@ export const fromDeleteFieldInputToFlatFieldMetadatasToDelete = ({
deleteOneFieldInput: rawDeleteOneInput,
flatIndexMaps: existingFlatIndexMaps,
flatFieldMetadataMaps: existingFlatFieldMetadataMaps,
flatSearchFieldMetadataMaps: existingFlatSearchFieldMetadataMaps,
}: FromDeleteFieldInputToFlatFieldMetadatasToDeleteArgs): {
flatFieldMetadatasToDelete: UniversalFlatFieldMetadata[];
flatIndexesToUpdate: UniversalFlatIndexMetadata[];
flatIndexesToDelete: UniversalFlatIndexMetadata[];
searchFieldMetadatasToDelete: UniversalFlatSearchFieldMetadata[];
} => {
const { id: fieldMetadataToDeleteId } =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
@@ -214,17 +207,9 @@ export const fromDeleteFieldInputToFlatFieldMetadatasToDelete = ({
},
);
const { searchFieldMetadatasToDelete } =
computeSearchFieldMetadataDeletionForDeletedFields({
flatFieldMetadatasToDelete,
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
flatSearchFieldMetadataMaps: existingFlatSearchFieldMetadataMaps,
});
return {
flatFieldMetadatasToDelete,
flatIndexesToDelete,
flatIndexesToUpdate,
searchFieldMetadatasToDelete,
};
};
@@ -1,5 +1,7 @@
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
import {
capitalize,
isDefined,
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties,
} from 'twenty-shared/utils';
import { v4 } from 'uuid';
@@ -7,20 +9,18 @@ import { v4 } from 'uuid';
import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type';
import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type';
import { type CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { buildDefaultFlatFieldMetadatasForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util';
import { buildDefaultIndexesForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-index-for-custom-object.util';
import { buildNameFlatFieldMetadataForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-name-flat-field-metadata-for-custom-object.util';
import { buildDefaultRelationFlatFieldMetadatasForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-relation-flat-field-metadatas-for-custom-object.util';
import { buildDefaultSearchFieldMetadatasForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-default-search-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 { type UniversalFlatIndexMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-index-metadata.type';
import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type';
import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type';
type FromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreateArgs =
{
createObjectInput: CreateObjectInput;
flatApplication: FlatApplication;
} & Pick<AllFlatEntityMaps, 'flatObjectMetadataMaps'>;
export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreate =
({
createObjectInput: rawCreateObjectInput,
@@ -28,10 +28,9 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
}: FromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreateArgs): {
flatObjectMetadataToCreate: UniversalFlatObjectMetadata & { id: string };
relationTargetFlatFieldMetadataToCreate: UniversalFlatFieldMetadata[];
flatFieldMetadataToCreateOnObject: UniversalFlatFieldMetadata[];
relationTargetFlatFieldMetadataToCreate: UniversalFlatFieldMetadata[];
flatIndexMetadataToCreate: UniversalFlatIndexMetadata[];
flatSearchFieldMetadataToCreate: UniversalFlatSearchFieldMetadata[];
} => {
const createObjectInput =
trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties(
@@ -49,21 +48,14 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
const objectMetadataId = v4();
const universalIdentifier = createObjectInput.universalIdentifier ?? v4();
const defaultFlatFieldForCustomObjectMaps =
buildDefaultFlatFieldMetadatasForCustomObject({
flatObjectMetadata: {
applicationUniversalIdentifier: flatApplication.universalIdentifier,
universalIdentifier,
},
skipNameField: createObjectInput.skipNameField,
});
const createdAt = new Date().toISOString();
// Use nameField.id if it exists, otherwise use idField.id (for junction tables without name)
const nameField = defaultFlatFieldForCustomObjectMaps.fields.nameField;
const labelIdentifierFieldMetadataUniversalIdentifier =
nameField?.universalIdentifier ??
defaultFlatFieldForCustomObjectMaps.fields.id.universalIdentifier;
getFieldUniversalIdentifier({
applicationUniversalIdentifier: flatApplication.universalIdentifier,
objectUniversalIdentifier: universalIdentifier,
name: createObjectInput.skipNameField ? 'id' : 'name',
});
const universalFlatObjectMetadataToCreate: UniversalFlatObjectMetadata & {
id: string;
@@ -102,6 +94,17 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
imageIdentifierFieldMetadataUniversalIdentifier: null,
};
const nameFlatFieldMetadata =
createObjectInput.skipNameField === true
? null
: buildNameFlatFieldMetadataForCustomObject({
flatObjectMetadata: {
applicationUniversalIdentifier:
flatApplication.universalIdentifier,
universalIdentifier,
},
});
const {
standardSourceFlatFieldMetadatas,
standardTargetFlatFieldMetadatas,
@@ -112,32 +115,15 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre
flatApplication,
});
const objectFlatFieldMetadatas: UniversalFlatFieldMetadata[] = [
...Object.values(defaultFlatFieldForCustomObjectMaps.fields),
const flatFieldMetadataToCreateOnObject: UniversalFlatFieldMetadata[] = [
...(isDefined(nameFlatFieldMetadata) ? [nameFlatFieldMetadata] : []),
...standardSourceFlatFieldMetadatas,
];
const defaultIndexesForCustomObject = buildDefaultIndexesForCustomObject({
objectFlatFieldMetadatas,
defaultFlatFieldForCustomObjectMaps,
flatObjectMetadata: universalFlatObjectMetadataToCreate,
});
const defaultSearchFieldMetadatasForCustomObject =
buildDefaultSearchFieldMetadatasForCustomObject({
defaultFlatFieldForCustomObjectMaps,
flatObjectMetadata: universalFlatObjectMetadataToCreate,
});
return {
flatObjectMetadataToCreate: universalFlatObjectMetadataToCreate,
flatIndexMetadataToCreate: [
...Object.values(defaultIndexesForCustomObject.indexes),
...standardTargetFlatIndexMetadatas,
],
flatSearchFieldMetadataToCreate:
defaultSearchFieldMetadatasForCustomObject.searchFieldMetadatas,
flatFieldMetadataToCreateOnObject,
relationTargetFlatFieldMetadataToCreate: standardTargetFlatFieldMetadatas,
flatFieldMetadataToCreateOnObject: objectFlatFieldMetadatas,
flatIndexMetadataToCreate: standardTargetFlatIndexMetadatas,
};
};
@@ -3,7 +3,6 @@ import { isDefined } from 'twenty-shared/utils';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { findManyFlatEntityByUniversalIdentifierInUniversalFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-many-flat-entity-by-universal-identifier-in-universal-flat-entity-maps-or-throw.util';
import { validateFlatObjectMetadataIdentifiers } from 'src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-flat-object-metadata-identifiers.util';
import { validateObjectMetadataSystemFieldsIntegrity } from 'src/engine/metadata-modules/flat-object-metadata/validators/utils/validate-object-metadata-system-fields-integrity.util';
import {
type OrchestratorActionsReport,
type OrchestratorFailureReport,
@@ -57,14 +56,6 @@ export const validateObjectMetadataCrossEntity = ({
},
);
createFailedFlatEntityValidations.errors.push(
...validateObjectMetadataSystemFieldsIntegrity({
universalFlatFieldMetadataMaps:
optimisticUniversalFlatMaps.flatFieldMetadataMaps,
universalFlatObjectMetadata,
}),
);
createFailedFlatEntityValidations.errors.push(
...validateFlatObjectMetadataIdentifiers({
universalFlatObjectMetadata,
@@ -1,98 +0,0 @@
import { msg } from '@lingui/core/macro';
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
import { isDefined } from 'twenty-shared/utils';
import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util';
import { PARTIAL_SYSTEM_FLAT_FIELD_METADATAS } from 'src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant';
import { ObjectMetadataExceptionCode } from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { type AllUniversalFlatEntityMaps } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/all-universal-flat-entity-maps.type';
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';
import { type FlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
import { buildUniversalFlatObjectFieldByNameAndJoinColumnMaps } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/utils/build-universal-flat-object-field-by-name-and-join-column-maps.util';
type ValidateObjectMetadataSystemFieldsIntegrityArgs = {
universalFlatObjectMetadata: UniversalFlatObjectMetadata;
universalFlatFieldMetadataMaps: AllUniversalFlatEntityMaps['flatFieldMetadataMaps'];
};
export const validateObjectMetadataSystemFieldsIntegrity = ({
universalFlatFieldMetadataMaps,
universalFlatObjectMetadata,
}: ValidateObjectMetadataSystemFieldsIntegrityArgs): FlatEntityValidationError[] => {
const errors: FlatEntityValidationError[] = [];
const { fieldUniversalIdentifierByName } =
buildUniversalFlatObjectFieldByNameAndJoinColumnMaps({
flatFieldMetadataMaps: universalFlatFieldMetadataMaps,
flatObjectMetadata: universalFlatObjectMetadata,
});
for (const expectedSystemField of Object.values(
PARTIAL_SYSTEM_FLAT_FIELD_METADATAS,
)) {
const matchingFieldUniversalIdentifier =
fieldUniversalIdentifierByName[expectedSystemField.name];
const expectedFieldName = expectedSystemField.name;
if (!isDefined(matchingFieldUniversalIdentifier)) {
errors.push({
code: ObjectMetadataExceptionCode.MISSING_SYSTEM_FIELD,
message: `System field ${expectedFieldName} is missing`,
userFriendlyMessage: msg`System field ${expectedFieldName} is missing`,
value: expectedFieldName,
});
} else {
const universalFlatFieldMetadata =
findFlatEntityByUniversalIdentifierOrThrow({
flatEntityMaps: universalFlatFieldMetadataMaps,
universalIdentifier: matchingFieldUniversalIdentifier,
});
const propertiesToValidate = [
'type',
'isSystem',
] as const satisfies (keyof UniversalFlatFieldMetadata)[];
for (const property of propertiesToValidate) {
const expectedValue = expectedSystemField[property];
const actualValue = universalFlatFieldMetadata[property];
if (actualValue !== expectedValue) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_SYSTEM_FIELD,
message: `System field ${expectedFieldName} has invalid ${property}: expected ${String(expectedValue)}, got ${String(actualValue)}`,
userFriendlyMessage: msg`System field ${expectedFieldName} has invalid ${property}`,
value: actualValue,
});
}
}
// System field universal identifiers are server-owned and must match
// the deterministic derivation; clients cannot provide custom ones.
// TODO: remove once system fields are generated server side only by
// the metadata side effect engine and stripped from client inputs.
const expectedUniversalIdentifier = getFieldUniversalIdentifier({
applicationUniversalIdentifier:
universalFlatObjectMetadata.applicationUniversalIdentifier,
objectUniversalIdentifier:
universalFlatObjectMetadata.universalIdentifier,
name: expectedFieldName,
});
if (
universalFlatFieldMetadata.universalIdentifier !==
expectedUniversalIdentifier
) {
errors.push({
code: ObjectMetadataExceptionCode.INVALID_SYSTEM_FIELD,
message: `System field ${expectedFieldName} has invalid universalIdentifier: expected ${expectedUniversalIdentifier}, got ${universalFlatFieldMetadata.universalIdentifier}`,
userFriendlyMessage: msg`System field ${expectedFieldName} universal identifier is not deterministic; it is derived by the server and cannot be customized`,
value: universalFlatFieldMetadata.universalIdentifier,
});
}
}
}
return errors;
};
@@ -1,4 +1,4 @@
import { v4 } from 'uuid';
import { getSearchFieldUniversalIdentifier } from 'twenty-shared/application';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type';
@@ -20,10 +20,15 @@ export const buildFlatSearchFieldMetadataForField = ({
const createdAt = new Date().toISOString();
return {
universalIdentifier: v4(),
universalIdentifier: getSearchFieldUniversalIdentifier({
applicationUniversalIdentifier:
flatObjectMetadata.applicationUniversalIdentifier,
fieldMetadataUniversalIdentifier: flatFieldMetadata.universalIdentifier,
}),
createdAt,
updatedAt: createdAt,
position,
isSystemSideEffect: true,
applicationUniversalIdentifier:
flatObjectMetadata.applicationUniversalIdentifier,
objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier,
@@ -0,0 +1,102 @@
import { type BuildSideEffectsArgs } from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service';
import { FieldSearchFieldMetadataOnDeleteSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-search-field-metadata-on-delete-side-effect-handler.service';
const DELETED_FIELD_UNIVERSAL_IDENTIFIER =
'd1d2d3d4-d5d6-4000-8000-000000000001';
const OTHER_FIELD_UNIVERSAL_IDENTIFIER = 'd1d2d3d4-d5d6-4000-8000-000000000002';
const SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER =
'f1f2f3f4-f5f6-4000-8000-000000000001';
const OTHER_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER =
'f1f2f3f4-f5f6-4000-8000-000000000002';
const buildArgs = ({
searchFieldMetadataUniversalIdentifiers,
relatedFlatEntityMaps,
}: {
searchFieldMetadataUniversalIdentifiers: string[];
relatedFlatEntityMaps: object;
}): BuildSideEffectsArgs<'fieldMetadata'> =>
({
flatEntity: {
universalIdentifier: DELETED_FIELD_UNIVERSAL_IDENTIFIER,
searchFieldMetadataUniversalIdentifiers,
},
allFlatEntityOperationRecordByMetadataName: {},
relatedFlatEntityMaps,
context: {},
}) as unknown as BuildSideEffectsArgs<'fieldMetadata'>;
describe('FieldSearchFieldMetadataOnDeleteSideEffectHandlerService', () => {
const handler =
new (FieldSearchFieldMetadataOnDeleteSideEffectHandlerService as unknown as new () => FieldSearchFieldMetadataOnDeleteSideEffectHandlerService)();
it('should cascade-delete every searchFieldMetadata row indexing the deleted field', () => {
const result = handler.buildSideEffects(
buildArgs({
searchFieldMetadataUniversalIdentifiers: [
SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER,
],
relatedFlatEntityMaps: {
flatSearchFieldMetadataMaps: {
byUniversalIdentifier: {
[SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER]: {
universalIdentifier: SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
DELETED_FIELD_UNIVERSAL_IDENTIFIER,
},
},
},
},
}),
);
expect(result.status).toBe('success');
if (result.status !== 'success') {
throw new Error('expected success');
}
expect(
Object.keys(
result.operations.searchFieldMetadata?.flatEntityToDelete ?? {},
),
).toEqual([SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER]);
});
it('should not delete searchFieldMetadata rows indexing a different field', () => {
const result = handler.buildSideEffects(
buildArgs({
searchFieldMetadataUniversalIdentifiers: [],
relatedFlatEntityMaps: {
flatSearchFieldMetadataMaps: {
byUniversalIdentifier: {
[OTHER_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER]: {
universalIdentifier:
OTHER_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier:
OTHER_FIELD_UNIVERSAL_IDENTIFIER,
},
},
},
},
}),
);
expect(result.status).toBe('noop');
});
it('should be a noop when the workspace has no searchFieldMetadata rows', () => {
const result = handler.buildSideEffects(
buildArgs({
searchFieldMetadataUniversalIdentifiers: [
SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER,
],
relatedFlatEntityMaps: {
flatSearchFieldMetadataMaps: { byUniversalIdentifier: {} },
},
}),
);
expect(result.status).toBe('noop');
});
});
@@ -0,0 +1,58 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type';
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';
@Injectable()
export class FieldSearchFieldMetadataOnDeleteSideEffectHandlerService extends MetadataSideEffectHandler(
{
operation: 'delete',
metadataName: 'fieldMetadata',
name: 'fieldSearchFieldMetadataOnDelete',
description:
'When a field is deleted, cascade-delete every searchFieldMetadata row that indexes it. searchFieldMetadata is excluded from manifest deletion inference, so the cascade must be explicit here to cover both the API and manifest paths (the object-scoped cascade only fires on object deletion).',
},
) {
buildSideEffects({
flatEntity: flatFieldMetadata,
relatedFlatEntityMaps,
}: BuildSideEffectsArgs<'fieldMetadata'>): MetadataSideEffectResult {
const searchFieldMetadataToDelete: Record<
string,
MetadataUniversalFlatEntity<'searchFieldMetadata'>
> = {};
for (const searchFieldMetadataUniversalIdentifier of flatFieldMetadata.searchFieldMetadataUniversalIdentifiers) {
const flatSearchFieldMetadata =
relatedFlatEntityMaps.flatSearchFieldMetadataMaps.byUniversalIdentifier[
searchFieldMetadataUniversalIdentifier
];
if (!isDefined(flatSearchFieldMetadata)) {
continue;
}
searchFieldMetadataToDelete[flatSearchFieldMetadata.universalIdentifier] =
flatSearchFieldMetadata;
}
if (Object.keys(searchFieldMetadataToDelete).length === 0) {
return { status: 'noop' };
}
return {
status: 'success',
operations: {
searchFieldMetadata: {
flatEntityToDelete: searchFieldMetadataToDelete,
},
},
};
}
}
@@ -1,14 +1,24 @@
import { Module } from '@nestjs/common';
import { FieldSearchFieldMetadataOnDeleteSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-search-field-metadata-on-delete-side-effect-handler.service';
import { FieldUniqueBackingIndexOnCreateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-unique-backing-index-on-create-side-effect-handler.service';
import { FieldUniqueBackingIndexOnDeleteSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-unique-backing-index-on-delete-side-effect-handler.service';
import { FieldUniqueBackingIndexOnUpdateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/field-metadata/services/field-unique-backing-index-on-update-side-effect-handler.service';
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 { ObjectSystemSideEffectsOnDeleteSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-system-side-effects-on-delete-side-effect-handler.service';
@Module({
providers: [
FieldUniqueBackingIndexOnCreateSideEffectHandlerService,
FieldUniqueBackingIndexOnUpdateSideEffectHandlerService,
FieldUniqueBackingIndexOnDeleteSideEffectHandlerService,
FieldSearchFieldMetadataOnDeleteSideEffectHandlerService,
ObjectSystemFieldsOnCreateSideEffectHandlerService,
ObjectSearchVectorOnCreateSideEffectHandlerService,
ObjectSearchVectorOnUpdateSideEffectHandlerService,
ObjectSystemSideEffectsOnDeleteSideEffectHandlerService,
],
})
export class MetadataSideEffectHandlersModule {}
@@ -0,0 +1,275 @@
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { type AllFlatEntityOperationRecordByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-operation-record-by-metadata-name.type';
import { type BuildSideEffectsArgs } from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service';
import { ObjectSearchVectorOnCreateSideEffectHandlerService } from 'src/engine/metadata-modules/metadata-side-effect/handlers/object-metadata/services/object-search-vector-on-create-side-effect-handler.service';
const APPLICATION_UNIVERSAL_IDENTIFIER = 'a1a2a3a4-a5a6-4000-8000-000000000001';
const OBJECT_UNIVERSAL_IDENTIFIER = 'b1b2b3b4-b5b6-4000-8000-000000000001';
const NAME_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
name: 'name',
});
const SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
name: 'searchVector',
});
type PendingFieldMetadata = {
universalIdentifier: string;
type: FieldMetadataType;
};
const buildArgs = ({
isSearchable,
labelIdentifierFieldMetadataUniversalIdentifier,
pendingFieldMetadatas = [],
relatedFlatEntityMaps = {
flatFieldMetadataMaps: { byUniversalIdentifier: {} },
flatSearchFieldMetadataMaps: { byUniversalIdentifier: {} },
flatIndexMaps: { byUniversalIdentifier: {} },
},
}: {
isSearchable: boolean;
labelIdentifierFieldMetadataUniversalIdentifier: string | null;
pendingFieldMetadatas?: PendingFieldMetadata[];
relatedFlatEntityMaps?: object;
}): BuildSideEffectsArgs<'objectMetadata'> =>
({
flatEntity: {
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'ticket',
isSearchable,
labelIdentifierFieldMetadataUniversalIdentifier,
},
allFlatEntityOperationRecordByMetadataName: {
...(pendingFieldMetadatas.length > 0 && {
fieldMetadata: {
flatEntityToCreate: Object.fromEntries(
pendingFieldMetadatas.map((pendingFieldMetadata) => [
pendingFieldMetadata.universalIdentifier,
pendingFieldMetadata,
]),
),
flatEntityToUpdate: {},
flatEntityToDelete: {},
},
}),
} as unknown as AllFlatEntityOperationRecordByMetadataName,
relatedFlatEntityMaps,
context: {},
}) as unknown as BuildSideEffectsArgs<'objectMetadata'>;
const PENDING_TEXT_NAME_FIELD: PendingFieldMetadata = {
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldMetadataType.TEXT,
};
describe('ObjectSearchVectorOnCreateSideEffectHandlerService', () => {
const handler =
new (ObjectSearchVectorOnCreateSideEffectHandlerService as unknown as new () => ObjectSearchVectorOnCreateSideEffectHandlerService)();
it('should always provision the searchVector field and its GIN index, plus a searchFieldMetadata for a searchable object with a searchable label identifier', () => {
const result = handler.buildSideEffects(
buildArgs({
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
pendingFieldMetadatas: [PENDING_TEXT_NAME_FIELD],
}),
);
expect(result.status).toBe('success');
if (result.status !== 'success') {
throw new Error('expected success');
}
const createdFieldUniversalIdentifiers = Object.keys(
result.operations.fieldMetadata?.flatEntityToCreate ?? {},
);
expect(createdFieldUniversalIdentifiers).toEqual([
SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER,
]);
expect(
Object.keys(result.operations.index?.flatEntityToCreate ?? {}),
).toHaveLength(1);
expect(
Object.keys(
result.operations.searchFieldMetadata?.flatEntityToCreate ?? {},
),
).toHaveLength(1);
});
it('should still provision the searchVector field and GIN index but no searchFieldMetadata when the object is not searchable', () => {
const result = handler.buildSideEffects(
buildArgs({
isSearchable: false,
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
pendingFieldMetadatas: [PENDING_TEXT_NAME_FIELD],
}),
);
expect(result.status).toBe('success');
if (result.status !== 'success') {
throw new Error('expected success');
}
expect(
Object.keys(result.operations.fieldMetadata?.flatEntityToCreate ?? {}),
).toHaveLength(1);
expect(
Object.keys(result.operations.index?.flatEntityToCreate ?? {}),
).toHaveLength(1);
expect(result.operations.searchFieldMetadata).toBeUndefined();
});
it('should not provision a searchFieldMetadata for junction objects whose label identifier is the id field', () => {
const idFieldUniversalIdentifier = getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
name: 'id',
});
const result = handler.buildSideEffects(
buildArgs({
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier:
idFieldUniversalIdentifier,
pendingFieldMetadatas: [
{
universalIdentifier: idFieldUniversalIdentifier,
type: FieldMetadataType.UUID,
},
],
}),
);
expect(result.status).toBe('success');
if (result.status !== 'success') {
throw new Error('expected success');
}
expect(result.operations.searchFieldMetadata).toBeUndefined();
});
it('should not provision a searchFieldMetadata when the caller published a non-searchable field at the derived name identifier', () => {
// The universal identifier derivation is name-based, so a caller-provided
// `name` field of any type lands on the derived identifier. The handler must
// resolve the actual type instead of assuming TEXT.
const result = handler.buildSideEffects(
buildArgs({
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
pendingFieldMetadatas: [
{
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldMetadataType.NUMBER,
},
],
}),
);
expect(result.status).toBe('success');
if (result.status !== 'success') {
throw new Error('expected success');
}
expect(result.operations.searchFieldMetadata).toBeUndefined();
});
it('should not provision a searchFieldMetadata when the label identifier field cannot be resolved', () => {
const result = handler.buildSideEffects(
buildArgs({
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
}),
);
expect(result.status).toBe('success');
if (result.status !== 'success') {
throw new Error('expected success');
}
expect(result.operations.searchFieldMetadata).toBeUndefined();
});
it('should resolve the label identifier field type from the existing maps when it is not part of the pending operations', () => {
const result = handler.buildSideEffects(
buildArgs({
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
relatedFlatEntityMaps: {
flatFieldMetadataMaps: {
byUniversalIdentifier: {
[NAME_FIELD_UNIVERSAL_IDENTIFIER]: {
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldMetadataType.TEXT,
},
},
},
flatSearchFieldMetadataMaps: { byUniversalIdentifier: {} },
flatIndexMaps: { byUniversalIdentifier: {} },
},
}),
);
expect(result.status).toBe('success');
if (result.status !== 'success') {
throw new Error('expected success');
}
expect(
Object.keys(
result.operations.searchFieldMetadata?.flatEntityToCreate ?? {},
),
).toHaveLength(1);
});
it('should be deterministic across invocations', () => {
const firstResult = handler.buildSideEffects(
buildArgs({
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
pendingFieldMetadatas: [PENDING_TEXT_NAME_FIELD],
}),
);
const secondResult = handler.buildSideEffects(
buildArgs({
isSearchable: true,
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
pendingFieldMetadatas: [PENDING_TEXT_NAME_FIELD],
}),
);
if (firstResult.status !== 'success' || secondResult.status !== 'success') {
throw new Error('expected success');
}
expect(
Object.keys(firstResult.operations.index?.flatEntityToCreate ?? {}),
).toEqual(
Object.keys(secondResult.operations.index?.flatEntityToCreate ?? {}),
);
});
});
@@ -0,0 +1,300 @@
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { type AllFlatEntityOperationRecordByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-operation-record-by-metadata-name.type';
import { type BuildSideEffectsArgs } from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-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';
const APPLICATION_UNIVERSAL_IDENTIFIER = 'a1a2a3a4-a5a6-4000-8000-000000000001';
const OBJECT_UNIVERSAL_IDENTIFIER = 'b1b2b3b4-b5b6-4000-8000-000000000001';
const NAME_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
name: 'name',
});
const TOTO_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
name: 'toto',
});
const SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
name: 'searchVector',
});
const NAME_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER =
'c1c2c3c4-c5c6-4000-8000-000000000001';
type PendingFieldMetadata = {
universalIdentifier: string;
type: FieldMetadataType;
};
const SEARCH_VECTOR_FLAT_FIELD = {
universalIdentifier: SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldMetadataType.TS_VECTOR,
name: 'searchVector',
};
const NAME_SEARCH_FIELD_METADATA = {
universalIdentifier: NAME_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER,
fieldMetadataUniversalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
position: 0,
};
const buildArgs = ({
isSearchable = true,
toLabelIdentifier,
fromLabelIdentifier,
pendingFieldMetadatas = [],
existingFlatFieldMetadataByUniversalIdentifier = {
[SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER]: SEARCH_VECTOR_FLAT_FIELD,
},
existingSearchFieldMetadataByUniversalIdentifier = {
[NAME_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER]:
NAME_SEARCH_FIELD_METADATA,
},
existingObjectSearchFieldMetadataUniversalIdentifiers = [
NAME_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER,
],
existingObjectFieldUniversalIdentifiers = [
NAME_FIELD_UNIVERSAL_IDENTIFIER,
SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER,
],
}: {
isSearchable?: boolean;
toLabelIdentifier: string | null;
fromLabelIdentifier: string | null;
pendingFieldMetadatas?: PendingFieldMetadata[];
existingFlatFieldMetadataByUniversalIdentifier?: object;
existingSearchFieldMetadataByUniversalIdentifier?: object;
existingObjectSearchFieldMetadataUniversalIdentifiers?: string[];
existingObjectFieldUniversalIdentifiers?: string[];
}): BuildSideEffectsArgs<'objectMetadata'> =>
({
flatEntity: {
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: 'ticket',
isSearchable,
labelIdentifierFieldMetadataUniversalIdentifier: toLabelIdentifier,
},
allFlatEntityOperationRecordByMetadataName: {
...(pendingFieldMetadatas.length > 0 && {
fieldMetadata: {
flatEntityToCreate: Object.fromEntries(
pendingFieldMetadatas.map((pendingFieldMetadata) => [
pendingFieldMetadata.universalIdentifier,
pendingFieldMetadata,
]),
),
flatEntityToUpdate: {},
flatEntityToDelete: {},
},
}),
} as unknown as AllFlatEntityOperationRecordByMetadataName,
relatedFlatEntityMaps: {
flatObjectMetadataMaps: {
byUniversalIdentifier: {
[OBJECT_UNIVERSAL_IDENTIFIER]: {
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
labelIdentifierFieldMetadataUniversalIdentifier:
fromLabelIdentifier,
fieldUniversalIdentifiers: existingObjectFieldUniversalIdentifiers,
searchFieldMetadataUniversalIdentifiers:
existingObjectSearchFieldMetadataUniversalIdentifiers,
},
},
},
flatFieldMetadataMaps: {
byUniversalIdentifier: existingFlatFieldMetadataByUniversalIdentifier,
},
flatSearchFieldMetadataMaps: {
byUniversalIdentifier: existingSearchFieldMetadataByUniversalIdentifier,
},
},
context: {},
}) as unknown as BuildSideEffectsArgs<'objectMetadata'>;
const PENDING_TEXT_TOTO_FIELD: PendingFieldMetadata = {
universalIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldMetadataType.TEXT,
};
describe('ObjectSearchVectorOnUpdateSideEffectHandlerService', () => {
const handler =
new (ObjectSearchVectorOnUpdateSideEffectHandlerService as unknown as new () => ObjectSearchVectorOnUpdateSideEffectHandlerService)();
it('should provision a searchFieldMetadata for the new searchable label identifier while preserving the existing surface', () => {
const result = handler.buildSideEffects(
buildArgs({
fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
toLabelIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER,
pendingFieldMetadatas: [PENDING_TEXT_TOTO_FIELD],
}),
);
expect(result.status).toBe('success');
if (result.status !== 'success') {
throw new Error('expected success');
}
const createdSearchFieldMetadatas =
result.operations.searchFieldMetadata?.flatEntityToCreate ?? {};
expect(Object.keys(createdSearchFieldMetadatas)).toHaveLength(1);
const [createdSearchFieldMetadata] = Object.values(
createdSearchFieldMetadatas,
);
expect(createdSearchFieldMetadata.fieldMetadataUniversalIdentifier).toBe(
TOTO_FIELD_UNIVERSAL_IDENTIFIER,
);
expect(
createdSearchFieldMetadata.tsVectorFieldMetadataUniversalIdentifier,
).toBe(SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER);
// Appended after the pre-existing name row (position 0).
expect(createdSearchFieldMetadata.position).toBe(1);
});
it('should resolve the new label identifier field type from the existing maps', () => {
const result = handler.buildSideEffects(
buildArgs({
fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
toLabelIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER,
existingFlatFieldMetadataByUniversalIdentifier: {
[SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER]: SEARCH_VECTOR_FLAT_FIELD,
[TOTO_FIELD_UNIVERSAL_IDENTIFIER]: {
universalIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldMetadataType.TEXT,
},
},
}),
);
expect(result.status).toBe('success');
if (result.status !== 'success') {
throw new Error('expected success');
}
expect(
Object.keys(
result.operations.searchFieldMetadata?.flatEntityToCreate ?? {},
),
).toHaveLength(1);
});
it('should be a noop when the object is not searchable', () => {
const result = handler.buildSideEffects(
buildArgs({
isSearchable: false,
fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
toLabelIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER,
pendingFieldMetadatas: [PENDING_TEXT_TOTO_FIELD],
}),
);
expect(result.status).toBe('noop');
});
it('should be a noop when the label identifier did not change', () => {
const result = handler.buildSideEffects(
buildArgs({
fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
toLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
pendingFieldMetadatas: [PENDING_TEXT_TOTO_FIELD],
}),
);
expect(result.status).toBe('noop');
});
it('should be a noop when the new label identifier is the system id field (junction object)', () => {
const idFieldUniversalIdentifier = getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
name: 'id',
});
const result = handler.buildSideEffects(
buildArgs({
fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
toLabelIdentifier: idFieldUniversalIdentifier,
pendingFieldMetadatas: [
{
universalIdentifier: idFieldUniversalIdentifier,
type: FieldMetadataType.UUID,
},
],
}),
);
expect(result.status).toBe('noop');
});
it('should be a noop when the new label identifier field is not a searchable type', () => {
const result = handler.buildSideEffects(
buildArgs({
fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
toLabelIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER,
pendingFieldMetadatas: [
{
universalIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldMetadataType.NUMBER,
},
],
}),
);
expect(result.status).toBe('noop');
});
it('should be a noop when the new label identifier is already indexed', () => {
const result = handler.buildSideEffects(
buildArgs({
fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
toLabelIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER,
pendingFieldMetadatas: [PENDING_TEXT_TOTO_FIELD],
existingSearchFieldMetadataByUniversalIdentifier: {
[NAME_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER]:
NAME_SEARCH_FIELD_METADATA,
'toto-search-row': {
universalIdentifier: 'toto-search-row',
fieldMetadataUniversalIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER,
position: 1,
},
},
existingObjectSearchFieldMetadataUniversalIdentifiers: [
NAME_SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER,
'toto-search-row',
],
}),
);
expect(result.status).toBe('noop');
});
it('should be a noop when the object has no searchVector field to attach to', () => {
const result = handler.buildSideEffects(
buildArgs({
fromLabelIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
toLabelIdentifier: TOTO_FIELD_UNIVERSAL_IDENTIFIER,
pendingFieldMetadatas: [PENDING_TEXT_TOTO_FIELD],
existingFlatFieldMetadataByUniversalIdentifier: {},
existingObjectFieldUniversalIdentifiers: [
NAME_FIELD_UNIVERSAL_IDENTIFIER,
],
}),
);
expect(result.status).toBe('noop');
});
});
@@ -0,0 +1,130 @@
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
import { type AllFlatEntityOperationRecordByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-operation-record-by-metadata-name.type';
import { type BuildSideEffectsArgs } from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-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';
const APPLICATION_UNIVERSAL_IDENTIFIER = 'a1a2a3a4-a5a6-4000-8000-000000000001';
const OBJECT_UNIVERSAL_IDENTIFIER = 'b1b2b3b4-b5b6-4000-8000-000000000001';
const SYSTEM_FIELD_NAMES = [
'id',
'createdAt',
'updatedAt',
'deletedAt',
'createdBy',
'updatedBy',
'position',
] as const;
const NAME_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
name: 'name',
});
const SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER = getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
name: 'searchVector',
});
const buildArgs = ({
labelIdentifierFieldMetadataUniversalIdentifier,
allFlatEntityOperationRecordByMetadataName = {} as unknown as AllFlatEntityOperationRecordByMetadataName,
relatedFlatEntityMaps = {},
}: {
labelIdentifierFieldMetadataUniversalIdentifier: string;
allFlatEntityOperationRecordByMetadataName?: AllFlatEntityOperationRecordByMetadataName;
relatedFlatEntityMaps?: object;
}): BuildSideEffectsArgs<'objectMetadata'> =>
({
flatEntity: {
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
labelIdentifierFieldMetadataUniversalIdentifier,
},
allFlatEntityOperationRecordByMetadataName,
relatedFlatEntityMaps,
context: {},
}) as unknown as BuildSideEffectsArgs<'objectMetadata'>;
describe('ObjectSystemFieldsOnCreateSideEffectHandlerService', () => {
const handler =
new (ObjectSystemFieldsOnCreateSideEffectHandlerService as unknown as new () => ObjectSystemFieldsOnCreateSideEffectHandlerService)();
it('should synthesize exactly the 7 reserved system fields and never the caller-provided name field nor the searchVector field', () => {
const result = handler.buildSideEffects(
buildArgs({
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
}),
);
expect(result.status).toBe('success');
if (result.status !== 'success') {
throw new Error('expected success');
}
const createdUniversalIdentifiers = Object.keys(
result.operations.fieldMetadata?.flatEntityToCreate ?? {},
);
expect(createdUniversalIdentifiers).toHaveLength(7);
expect(createdUniversalIdentifiers).not.toContain(
NAME_FIELD_UNIVERSAL_IDENTIFIER,
);
expect(createdUniversalIdentifiers).not.toContain(
SEARCH_VECTOR_FIELD_UNIVERSAL_IDENTIFIER,
);
for (const name of SYSTEM_FIELD_NAMES) {
expect(createdUniversalIdentifiers).toContain(
getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
name,
}),
);
}
});
it('should still emit all system fields even when they already exist in the from-state (dedup delegated to the engine merge)', () => {
const alreadyPresentByUniversalIdentifier: Record<string, unknown> = {};
for (const name of SYSTEM_FIELD_NAMES) {
const universalIdentifier = getFieldUniversalIdentifier({
applicationUniversalIdentifier: APPLICATION_UNIVERSAL_IDENTIFIER,
objectUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
name,
});
alreadyPresentByUniversalIdentifier[universalIdentifier] = {
universalIdentifier,
};
}
const result = handler.buildSideEffects(
buildArgs({
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
relatedFlatEntityMaps: {
flatFieldMetadataMaps: {
byUniversalIdentifier: alreadyPresentByUniversalIdentifier,
},
},
}),
);
expect(result.status).toBe('success');
if (result.status !== 'success') {
throw new Error('expected success');
}
expect(
Object.keys(result.operations.fieldMetadata?.flatEntityToCreate ?? {}),
).toHaveLength(7);
});
});
@@ -0,0 +1,125 @@
import { type BuildSideEffectsArgs } from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-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';
const OBJECT_UNIVERSAL_IDENTIFIER = 'b1b2b3b4-b5b6-4000-8000-000000000001';
const OTHER_OBJECT_UNIVERSAL_IDENTIFIER =
'c1c2c3c4-c5c6-4000-8000-000000000001';
const SYSTEM_FIELD_UNIVERSAL_IDENTIFIER =
'd1d2d3d4-d5d6-4000-8000-000000000001';
const AUTHOR_FIELD_UNIVERSAL_IDENTIFIER =
'd1d2d3d4-d5d6-4000-8000-000000000002';
const GIN_INDEX_UNIVERSAL_IDENTIFIER = 'e1e2e3e4-e5e6-4000-8000-000000000001';
const SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER =
'f1f2f3f4-f5f6-4000-8000-000000000001';
const buildArgs = ({
relatedFlatEntityMaps,
}: {
relatedFlatEntityMaps: object;
}): BuildSideEffectsArgs<'objectMetadata'> =>
({
flatEntity: {
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
},
allFlatEntityOperationRecordByMetadataName: {},
relatedFlatEntityMaps,
context: {},
}) as unknown as BuildSideEffectsArgs<'objectMetadata'>;
describe('ObjectSystemSideEffectsOnDeleteSideEffectHandlerService', () => {
const handler =
new (ObjectSystemSideEffectsOnDeleteSideEffectHandlerService as unknown as new () => ObjectSystemSideEffectsOnDeleteSideEffectHandlerService)();
it('should cascade-delete engine-owned fields, indexes and searchFieldMetadata while leaving author fields untouched', () => {
const result = handler.buildSideEffects(
buildArgs({
relatedFlatEntityMaps: {
flatFieldMetadataMaps: {
byUniversalIdentifier: {
[SYSTEM_FIELD_UNIVERSAL_IDENTIFIER]: {
universalIdentifier: SYSTEM_FIELD_UNIVERSAL_IDENTIFIER,
isSystemSideEffect: true,
objectMetadataUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetObjectMetadataUniversalIdentifier: null,
},
[AUTHOR_FIELD_UNIVERSAL_IDENTIFIER]: {
universalIdentifier: AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
isSystemSideEffect: false,
objectMetadataUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetObjectMetadataUniversalIdentifier: null,
},
},
},
flatIndexMaps: {
byUniversalIdentifier: {
[GIN_INDEX_UNIVERSAL_IDENTIFIER]: {
universalIdentifier: GIN_INDEX_UNIVERSAL_IDENTIFIER,
isSystemSideEffect: true,
objectMetadataUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
universalFlatIndexFieldMetadatas: [],
},
},
},
flatSearchFieldMetadataMaps: {
byUniversalIdentifier: {
[SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER]: {
universalIdentifier: SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER,
objectMetadataUniversalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
},
},
},
},
}),
);
expect(result.status).toBe('success');
if (result.status !== 'success') {
throw new Error('expected success');
}
const deletedFieldUniversalIdentifiers = Object.keys(
result.operations.fieldMetadata?.flatEntityToDelete ?? {},
);
expect(deletedFieldUniversalIdentifiers).toEqual([
SYSTEM_FIELD_UNIVERSAL_IDENTIFIER,
]);
expect(deletedFieldUniversalIdentifiers).not.toContain(
AUTHOR_FIELD_UNIVERSAL_IDENTIFIER,
);
expect(
Object.keys(result.operations.index?.flatEntityToDelete ?? {}),
).toEqual([GIN_INDEX_UNIVERSAL_IDENTIFIER]);
expect(
Object.keys(
result.operations.searchFieldMetadata?.flatEntityToDelete ?? {},
),
).toEqual([SEARCH_FIELD_METADATA_UNIVERSAL_IDENTIFIER]);
});
it('should not delete entities belonging to a different object', () => {
const result = handler.buildSideEffects(
buildArgs({
relatedFlatEntityMaps: {
flatFieldMetadataMaps: {
byUniversalIdentifier: {
[SYSTEM_FIELD_UNIVERSAL_IDENTIFIER]: {
universalIdentifier: SYSTEM_FIELD_UNIVERSAL_IDENTIFIER,
isSystemSideEffect: true,
objectMetadataUniversalIdentifier:
OTHER_OBJECT_UNIVERSAL_IDENTIFIER,
relationTargetObjectMetadataUniversalIdentifier: null,
},
},
},
flatIndexMaps: { byUniversalIdentifier: {} },
flatSearchFieldMetadataMaps: { byUniversalIdentifier: {} },
},
}),
);
expect(result.status).toBe('noop');
});
});
@@ -0,0 +1,169 @@
import { Injectable } from '@nestjs/common';
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
import { type FieldMetadataType } from 'twenty-shared/types';
import { isDefined, isSearchableFieldType } from 'twenty-shared/utils';
import { buildFlatSearchFieldMetadataForField } from 'src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util';
import {
type BuildSideEffectsArgs,
MetadataSideEffectHandler,
} from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service';
import { type MetadataSideEffectOperationsByMetadataName } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-operations-by-metadata-name.type';
import { type MetadataSideEffectResult } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-result.type';
import { buildSearchVectorFlatFieldMetadataForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-search-vector-flat-field-metadata-for-custom-object.util';
import { buildSearchVectorGinIndexForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-search-vector-gin-index-for-custom-object.util';
import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type';
@Injectable()
export class ObjectSearchVectorOnCreateSideEffectHandlerService extends MetadataSideEffectHandler(
{
operation: 'create',
metadataName: 'objectMetadata',
name: 'objectSearchVectorOnCreate',
description:
'When an object is created, provision its full-text search surface as a single self-contained side effect: the searchVector system field, the GIN index backing it, and (for searchable objects whose label identifier is a searchable field) the searchFieldMetadata row that keeps the searchVector populated instead of NULL.',
},
) {
buildSideEffects({
flatEntity: flatObjectMetadata,
allFlatEntityOperationRecordByMetadataName,
relatedFlatEntityMaps,
}: BuildSideEffectsArgs<'objectMetadata'>): MetadataSideEffectResult {
const { applicationUniversalIdentifier, universalIdentifier } =
flatObjectMetadata;
const searchVectorFlatFieldMetadata =
buildSearchVectorFlatFieldMetadataForCustomObject({
flatObjectMetadata: {
applicationUniversalIdentifier,
universalIdentifier,
},
});
const tsVectorFlatIndex = buildSearchVectorGinIndexForCustomObject({
flatObjectMetadata,
searchVectorFlatFieldMetadata,
});
const operations: MetadataSideEffectOperationsByMetadataName = {
fieldMetadata: {
flatEntityToCreate: {
[searchVectorFlatFieldMetadata.universalIdentifier]:
searchVectorFlatFieldMetadata,
},
},
index: {
flatEntityToCreate: {
[tsVectorFlatIndex.universalIdentifier]: tsVectorFlatIndex,
},
},
};
const searchFieldMetadata = this.buildSearchFieldMetadata({
flatObjectMetadata,
searchVectorFlatFieldMetadata,
allFlatEntityOperationRecordByMetadataName,
relatedFlatEntityMaps,
});
if (isDefined(searchFieldMetadata)) {
operations.searchFieldMetadata = {
flatEntityToCreate: {
[searchFieldMetadata.universalIdentifier]: searchFieldMetadata,
},
};
}
return {
status: 'success',
operations,
};
}
private buildSearchFieldMetadata({
flatObjectMetadata,
searchVectorFlatFieldMetadata,
allFlatEntityOperationRecordByMetadataName,
relatedFlatEntityMaps,
}: {
flatObjectMetadata: BuildSideEffectsArgs<'objectMetadata'>['flatEntity'];
searchVectorFlatFieldMetadata: { universalIdentifier: string };
allFlatEntityOperationRecordByMetadataName: BuildSideEffectsArgs<'objectMetadata'>['allFlatEntityOperationRecordByMetadataName'];
relatedFlatEntityMaps: BuildSideEffectsArgs<'objectMetadata'>['relatedFlatEntityMaps'];
}): UniversalFlatSearchFieldMetadata | undefined {
if (flatObjectMetadata.isSearchable !== true) {
return undefined;
}
const labelIdentifierFieldMetadataUniversalIdentifier =
flatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier;
if (!isDefined(labelIdentifierFieldMetadataUniversalIdentifier)) {
return undefined;
}
const derivedIdFieldUniversalIdentifier = getFieldUniversalIdentifier({
applicationUniversalIdentifier:
flatObjectMetadata.applicationUniversalIdentifier,
objectUniversalIdentifier: flatObjectMetadata.universalIdentifier,
name: 'id',
});
if (
labelIdentifierFieldMetadataUniversalIdentifier ===
derivedIdFieldUniversalIdentifier
) {
return undefined;
}
const labelIdentifierFieldType = this.resolveLabelIdentifierFieldType({
labelIdentifierFieldMetadataUniversalIdentifier,
allFlatEntityOperationRecordByMetadataName,
relatedFlatEntityMaps,
});
if (
!isDefined(labelIdentifierFieldType) ||
!isSearchableFieldType(labelIdentifierFieldType)
) {
return undefined;
}
return buildFlatSearchFieldMetadataForField({
flatObjectMetadata,
flatFieldMetadata: {
universalIdentifier: labelIdentifierFieldMetadataUniversalIdentifier,
},
tsVectorFlatFieldMetadata: {
universalIdentifier: searchVectorFlatFieldMetadata.universalIdentifier,
},
position: 0,
});
}
private resolveLabelIdentifierFieldType({
labelIdentifierFieldMetadataUniversalIdentifier,
allFlatEntityOperationRecordByMetadataName,
relatedFlatEntityMaps,
}: {
labelIdentifierFieldMetadataUniversalIdentifier: string;
allFlatEntityOperationRecordByMetadataName: BuildSideEffectsArgs<'objectMetadata'>['allFlatEntityOperationRecordByMetadataName'];
relatedFlatEntityMaps: BuildSideEffectsArgs<'objectMetadata'>['relatedFlatEntityMaps'];
}): FieldMetadataType | undefined {
const pendingField =
allFlatEntityOperationRecordByMetadataName.fieldMetadata
?.flatEntityToCreate[labelIdentifierFieldMetadataUniversalIdentifier];
if (isDefined(pendingField)) {
return pendingField.type;
}
const existingField =
relatedFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier[
labelIdentifierFieldMetadataUniversalIdentifier
];
return existingField?.type;
}
}
@@ -0,0 +1,173 @@
import { Injectable } from '@nestjs/common';
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
import { type FieldMetadataType } from 'twenty-shared/types';
import { isDefined, isSearchableFieldType } from 'twenty-shared/utils';
import { buildFlatSearchFieldMetadataForField } from 'src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util';
import { findTsVectorFlatFieldMetadataForObject } from 'src/engine/metadata-modules/flat-search-field-metadata/utils/find-ts-vector-flat-field-metadata-for-object.util';
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';
@Injectable()
export class ObjectSearchVectorOnUpdateSideEffectHandlerService extends MetadataSideEffectHandler(
{
operation: 'update',
metadataName: 'objectMetadata',
name: 'objectSearchVectorOnUpdate',
description:
'When a searchable object is relabeled onto a new searchable field, provision the searchFieldMetadata row that indexes it. Relabeling is additive: existing search rows (e.g. the provisioned name row) are preserved so the previous label identifier stays searchable. Mirrors the API update path so a manifest re-sync that changes the label identifier reaches search parity.',
},
) {
buildSideEffects({
flatEntity: updatedFlatObjectMetadata,
allFlatEntityOperationRecordByMetadataName,
relatedFlatEntityMaps,
}: BuildSideEffectsArgs<'objectMetadata'>): MetadataSideEffectResult {
if (updatedFlatObjectMetadata.isSearchable !== true) {
return { status: 'noop' };
}
const newLabelIdentifierFieldMetadataUniversalIdentifier =
updatedFlatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier;
if (!isDefined(newLabelIdentifierFieldMetadataUniversalIdentifier)) {
return { status: 'noop' };
}
// The trigger entity is the incoming (manifest/API) object with empty foreign
// key aggregators; the existing search rows, positions and searchVector field
// are resolved from the current cached object.
const existingFlatObjectMetadata =
relatedFlatEntityMaps.flatObjectMetadataMaps.byUniversalIdentifier[
updatedFlatObjectMetadata.universalIdentifier
];
if (!isDefined(existingFlatObjectMetadata)) {
return { status: 'noop' };
}
if (
existingFlatObjectMetadata.labelIdentifierFieldMetadataUniversalIdentifier ===
newLabelIdentifierFieldMetadataUniversalIdentifier
) {
return { status: 'noop' };
}
// Junction objects use the system id field as label identifier; UUID is a
// searchable type, so a type-based check would wrongly index them.
const derivedIdFieldUniversalIdentifier = getFieldUniversalIdentifier({
applicationUniversalIdentifier:
updatedFlatObjectMetadata.applicationUniversalIdentifier,
objectUniversalIdentifier: updatedFlatObjectMetadata.universalIdentifier,
name: 'id',
});
if (
newLabelIdentifierFieldMetadataUniversalIdentifier ===
derivedIdFieldUniversalIdentifier
) {
return { status: 'noop' };
}
const newLabelIdentifierFieldType = this.resolveFieldType({
fieldMetadataUniversalIdentifier:
newLabelIdentifierFieldMetadataUniversalIdentifier,
allFlatEntityOperationRecordByMetadataName,
relatedFlatEntityMaps,
});
if (
!isDefined(newLabelIdentifierFieldType) ||
!isSearchableFieldType(newLabelIdentifierFieldType)
) {
return { status: 'noop' };
}
const existingSearchFieldMetadatas =
existingFlatObjectMetadata.searchFieldMetadataUniversalIdentifiers
.map(
(searchFieldMetadataUniversalIdentifier) =>
relatedFlatEntityMaps.flatSearchFieldMetadataMaps
.byUniversalIdentifier[searchFieldMetadataUniversalIdentifier],
)
.filter(isDefined);
const newLabelIdentifierAlreadyIndexed = existingSearchFieldMetadatas.some(
(searchFieldMetadata) =>
searchFieldMetadata.fieldMetadataUniversalIdentifier ===
newLabelIdentifierFieldMetadataUniversalIdentifier,
);
if (newLabelIdentifierAlreadyIndexed) {
return { status: 'noop' };
}
const tsVectorFlatFieldMetadata = findTsVectorFlatFieldMetadataForObject({
fieldUniversalIdentifiers:
existingFlatObjectMetadata.fieldUniversalIdentifiers,
flatFieldMetadataMaps: relatedFlatEntityMaps.flatFieldMetadataMaps,
});
if (!isDefined(tsVectorFlatFieldMetadata)) {
return { status: 'noop' };
}
const newLabelIdentifierPosition =
existingSearchFieldMetadatas.reduce(
(maxPosition, searchFieldMetadata) =>
Math.max(maxPosition, searchFieldMetadata.position),
-1,
) + 1;
const searchFieldMetadata = buildFlatSearchFieldMetadataForField({
flatObjectMetadata: updatedFlatObjectMetadata,
flatFieldMetadata: {
universalIdentifier: newLabelIdentifierFieldMetadataUniversalIdentifier,
},
tsVectorFlatFieldMetadata: {
universalIdentifier: tsVectorFlatFieldMetadata.universalIdentifier,
},
position: newLabelIdentifierPosition,
});
return {
status: 'success',
operations: {
searchFieldMetadata: {
flatEntityToCreate: {
[searchFieldMetadata.universalIdentifier]: searchFieldMetadata,
},
},
},
};
}
private resolveFieldType({
fieldMetadataUniversalIdentifier,
allFlatEntityOperationRecordByMetadataName,
relatedFlatEntityMaps,
}: {
fieldMetadataUniversalIdentifier: string;
allFlatEntityOperationRecordByMetadataName: BuildSideEffectsArgs<'objectMetadata'>['allFlatEntityOperationRecordByMetadataName'];
relatedFlatEntityMaps: BuildSideEffectsArgs<'objectMetadata'>['relatedFlatEntityMaps'];
}): FieldMetadataType | undefined {
const pendingField =
allFlatEntityOperationRecordByMetadataName.fieldMetadata
?.flatEntityToCreate[fieldMetadataUniversalIdentifier];
if (isDefined(pendingField)) {
return pendingField.type;
}
const existingField =
relatedFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier[
fieldMetadataUniversalIdentifier
];
return existingField?.type;
}
}
@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type';
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 { buildReservedSystemFlatFieldMetadatasForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-reserved-system-flat-field-metadatas-for-custom-object.util';
@Injectable()
export class ObjectSystemFieldsOnCreateSideEffectHandlerService extends MetadataSideEffectHandler(
{
operation: 'create',
metadataName: 'objectMetadata',
name: 'objectSystemFieldsOnCreate',
description:
'When an object is created, generate its 7 reserved system fields (id, createdAt, updatedAt, deletedAt, createdBy, updatedBy, position). The searchVector field is provisioned by the self-contained objectSearchVectorOnCreate handler alongside its GIN index and searchFieldMetadata. The default name field is NOT synthesized here: it is a caller-provided default field (SDK auto-complete on the manifest path, input transpiler on the API path).',
},
) {
buildSideEffects({
flatEntity: flatObjectMetadata,
}: BuildSideEffectsArgs<'objectMetadata'>): MetadataSideEffectResult {
const { applicationUniversalIdentifier, universalIdentifier } =
flatObjectMetadata;
const reservedSystemFlatFieldMetadatas =
buildReservedSystemFlatFieldMetadatasForCustomObject({
flatObjectMetadata: {
applicationUniversalIdentifier,
universalIdentifier,
},
});
const flatEntityToCreate: Record<
string,
MetadataUniversalFlatEntity<'fieldMetadata'>
> = {};
for (const flatFieldMetadata of Object.values(
reservedSystemFlatFieldMetadatas,
)) {
flatEntityToCreate[flatFieldMetadata.universalIdentifier] =
flatFieldMetadata;
}
return {
status: 'success',
operations: {
fieldMetadata: {
flatEntityToCreate,
},
},
};
}
}
@@ -0,0 +1,155 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { type MetadataUniversalFlatEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-universal-flat-entity.type';
import {
type BuildSideEffectsArgs,
MetadataSideEffectHandler,
} from 'src/engine/metadata-modules/metadata-side-effect/interfaces/base-metadata-side-effect-handler.service';
import { type MetadataSideEffectOperationsByMetadataName } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-operations-by-metadata-name.type';
import { type MetadataSideEffectResult } from 'src/engine/metadata-modules/metadata-side-effect/types/metadata-side-effect-result.type';
@Injectable()
export class ObjectSystemSideEffectsOnDeleteSideEffectHandlerService extends MetadataSideEffectHandler(
{
operation: 'delete',
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.',
},
) {
buildSideEffects({
flatEntity: flatObjectMetadata,
relatedFlatEntityMaps,
}: BuildSideEffectsArgs<'objectMetadata'>): MetadataSideEffectResult {
const objectMetadataUniversalIdentifier =
flatObjectMetadata.universalIdentifier;
const fieldMetadataToDelete: Record<
string,
MetadataUniversalFlatEntity<'fieldMetadata'>
> = {};
const deletedFieldUniversalIdentifiers = new Set<string>();
for (const flatFieldMetadata of Object.values(
relatedFlatEntityMaps.flatFieldMetadataMaps.byUniversalIdentifier,
)) {
if (!isDefined(flatFieldMetadata)) {
continue;
}
if (flatFieldMetadata.isSystemSideEffect !== true) {
continue;
}
const belongsToObject =
flatFieldMetadata.objectMetadataUniversalIdentifier ===
objectMetadataUniversalIdentifier;
const targetsObject =
flatFieldMetadata.relationTargetObjectMetadataUniversalIdentifier ===
objectMetadataUniversalIdentifier;
if (!belongsToObject && !targetsObject) {
continue;
}
fieldMetadataToDelete[flatFieldMetadata.universalIdentifier] =
flatFieldMetadata;
deletedFieldUniversalIdentifiers.add(
flatFieldMetadata.universalIdentifier,
);
}
const indexToDelete: Record<
string,
MetadataUniversalFlatEntity<'index'>
> = {};
for (const flatIndexMetadata of Object.values(
relatedFlatEntityMaps.flatIndexMaps.byUniversalIdentifier,
)) {
if (!isDefined(flatIndexMetadata)) {
continue;
}
if (flatIndexMetadata.isSystemSideEffect !== true) {
continue;
}
const belongsToObject =
flatIndexMetadata.objectMetadataUniversalIdentifier ===
objectMetadataUniversalIdentifier;
const referencesDeletedField =
flatIndexMetadata.universalFlatIndexFieldMetadatas.some(
(universalFlatIndexFieldMetadata) =>
deletedFieldUniversalIdentifiers.has(
universalFlatIndexFieldMetadata.fieldMetadataUniversalIdentifier,
),
);
if (!belongsToObject && !referencesDeletedField) {
continue;
}
indexToDelete[flatIndexMetadata.universalIdentifier] = flatIndexMetadata;
}
const searchFieldMetadataToDelete: Record<
string,
MetadataUniversalFlatEntity<'searchFieldMetadata'>
> = {};
for (const flatSearchFieldMetadata of Object.values(
relatedFlatEntityMaps.flatSearchFieldMetadataMaps.byUniversalIdentifier,
)) {
if (!isDefined(flatSearchFieldMetadata)) {
continue;
}
if (
flatSearchFieldMetadata.objectMetadataUniversalIdentifier !==
objectMetadataUniversalIdentifier
) {
continue;
}
searchFieldMetadataToDelete[flatSearchFieldMetadata.universalIdentifier] =
flatSearchFieldMetadata;
}
const hasFieldMetadataToDelete =
Object.keys(fieldMetadataToDelete).length > 0;
const hasIndexToDelete = Object.keys(indexToDelete).length > 0;
const hasSearchFieldMetadataToDelete =
Object.keys(searchFieldMetadataToDelete).length > 0;
if (
!hasFieldMetadataToDelete &&
!hasIndexToDelete &&
!hasSearchFieldMetadataToDelete
) {
return { status: 'noop' };
}
const operations: MetadataSideEffectOperationsByMetadataName = {
...(hasFieldMetadataToDelete && {
fieldMetadata: { flatEntityToDelete: fieldMetadataToDelete },
}),
...(hasIndexToDelete && {
index: { flatEntityToDelete: indexToDelete },
}),
...(hasSearchFieldMetadataToDelete && {
searchFieldMetadata: {
flatEntityToDelete: searchFieldMetadataToDelete,
},
}),
};
return {
status: 'success',
operations,
};
}
}
@@ -15,8 +15,6 @@ export enum ObjectMetadataExceptionCode {
INVALID_ORM_OUTPUT = 'INVALID_ORM_OUTPUT',
INTERNAL_SERVER_ERROR = 'INTERNAL_SERVER_ERROR',
NAME_CONFLICT = 'NAME_CONFLICT',
MISSING_SYSTEM_FIELD = 'MISSING_SYSTEM_FIELD',
INVALID_SYSTEM_FIELD = 'INVALID_SYSTEM_FIELD',
}
const getObjectMetadataExceptionUserFriendlyMessage = (
@@ -41,10 +39,6 @@ const getObjectMetadataExceptionUserFriendlyMessage = (
return STANDARD_ERROR_MESSAGE;
case ObjectMetadataExceptionCode.NAME_CONFLICT:
return msg`A name conflict occurred.`;
case ObjectMetadataExceptionCode.MISSING_SYSTEM_FIELD:
return msg`A system field is missing.`;
case ObjectMetadataExceptionCode.INVALID_SYSTEM_FIELD:
return msg`A system field has invalid properties.`;
default:
assertUnreachable(code);
}
@@ -42,6 +42,7 @@ import {
ObjectMetadataException,
ObjectMetadataExceptionCode,
} from 'src/engine/metadata-modules/object-metadata/object-metadata.exception';
import { buildReservedSystemFlatFieldMetadatasForCustomObject } from 'src/engine/metadata-modules/object-metadata/utils/build-reserved-system-flat-field-metadatas-for-custom-object.util';
import { computeFlatDefaultRecordPageLayoutToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-default-record-page-layout-to-create.util';
import { computeFlatRecordPageFieldsViewToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-record-page-fields-view-to-create.util';
import { computeFlatViewFieldsToCreate } from 'src/engine/metadata-modules/object-metadata/utils/compute-flat-view-fields-to-create.util';
@@ -483,22 +484,43 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
ownerFlatApplication ?? workspaceCustomFlatApplication;
const { flatObjectMetadataMaps: existingFlatObjectMetadataMaps } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatObjectMetadataMaps',
]);
await this.flatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps'],
},
);
const {
flatObjectMetadataToCreate,
flatIndexMetadataToCreate,
flatSearchFieldMetadataToCreate,
flatFieldMetadataToCreateOnObject,
relationTargetFlatFieldMetadataToCreate,
flatIndexMetadataToCreate,
} = fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCreate({
createObjectInput,
flatApplication: resolvedOwnerFlatApplication,
flatObjectMetadataMaps: existingFlatObjectMetadataMaps,
});
// Default view fields reference the caller-provided fields (reused as-is from
// the transpiler output) plus the engine-owned reserved system fields, whose
// deterministic identifiers we re-derive here (searchVector is never shown).
// TODO: remove once default view fields move to the metadata side effect engine.
const defaultFlatFieldMetadatasForViewFields: UniversalFlatFieldMetadata[] =
[
...flatFieldMetadataToCreateOnObject,
...Object.values(
buildReservedSystemFlatFieldMetadatasForCustomObject({
flatObjectMetadata: {
applicationUniversalIdentifier:
resolvedOwnerFlatApplication.universalIdentifier,
universalIdentifier:
flatObjectMetadataToCreate.universalIdentifier,
},
}),
),
];
const flatDefaultViewToCreate = this.computeFlatViewToCreate({
objectMetadata: flatObjectMetadataToCreate,
flatApplication: resolvedOwnerFlatApplication,
@@ -506,7 +528,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
const flatDefaultViewFieldsToCreate = computeFlatViewFieldsToCreate({
flatApplication: resolvedOwnerFlatApplication,
objectFlatFieldMetadatas: flatFieldMetadataToCreateOnObject,
objectFlatFieldMetadatas: defaultFlatFieldMetadatasForViewFields,
labelIdentifierFieldMetadataUniversalIdentifier:
flatObjectMetadataToCreate.labelIdentifierFieldMetadataUniversalIdentifier,
viewUniversalIdentifier: flatDefaultViewToCreate.universalIdentifier,
@@ -549,7 +571,7 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
const flatRecordPageFieldsViewFieldsToCreate =
computeFlatViewFieldsToCreate({
flatApplication: resolvedOwnerFlatApplication,
objectFlatFieldMetadatas: flatFieldMetadataToCreateOnObject,
objectFlatFieldMetadatas: defaultFlatFieldMetadatasForViewFields,
labelIdentifierFieldMetadataUniversalIdentifier:
flatObjectMetadataToCreate.labelIdentifierFieldMetadataUniversalIdentifier,
viewUniversalIdentifier:
@@ -574,6 +596,19 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
fieldMetadata: {
flatEntityToCreate: [
...flatFieldMetadataToCreateOnObject,
...relationTargetFlatFieldMetadataToCreate,
],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
index: {
flatEntityToCreate: flatIndexMetadataToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
view: {
flatEntityToCreate: [
flatDefaultViewToCreate,
@@ -590,19 +625,6 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
fieldMetadata: {
flatEntityToCreate: [
...flatFieldMetadataToCreateOnObject,
...relationTargetFlatFieldMetadataToCreate,
],
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
index: {
flatEntityToCreate: flatIndexMetadataToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
commandMenuItem: {
flatEntityToCreate: [flatCommandMenuItemToCreate],
flatEntityToDelete: [],
@@ -626,11 +648,6 @@ export class ObjectMetadataService extends TypeOrmQueryService<ObjectMetadataEnt
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
searchFieldMetadata: {
flatEntityToCreate: flatSearchFieldMetadataToCreate,
flatEntityToDelete: [],
flatEntityToUpdate: [],
},
...(isDefined(flatNavigationMenuItemToCreate)
? {
navigationMenuItem: {
@@ -1,186 +0,0 @@
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
import { PARTIAL_SYSTEM_FLAT_FIELD_METADATAS } from 'src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant';
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';
type BuildDefaultFlatFieldMetadataForCustomObjectArgs = {
flatObjectMetadata: Pick<
UniversalFlatObjectMetadata,
'universalIdentifier' | 'applicationUniversalIdentifier'
>;
skipNameField?: boolean;
};
export type DefaultFlatFieldForCustomObjectMaps = ReturnType<
typeof buildDefaultFlatFieldMetadatasForCustomObject
>;
const buildObjectSystemFlatFieldMetadatas = ({
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
now,
searchVectorUniversalSettings,
}: {
applicationUniversalIdentifier: string;
objectMetadataUniversalIdentifier: string;
now: string;
searchVectorUniversalSettings: UniversalFlatFieldMetadata<FieldMetadataType.TS_VECTOR>['universalSettings'];
}) => {
const {
createdAt,
createdBy,
deletedAt,
id,
position,
searchVector,
updatedAt,
updatedBy,
} = PARTIAL_SYSTEM_FLAT_FIELD_METADATAS;
const computeFieldUniversalIdentifier = (name: string) =>
getFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier: objectMetadataUniversalIdentifier,
name,
});
return {
id: {
...id,
universalIdentifier: computeFieldUniversalIdentifier(id.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
},
createdAt: {
...createdAt,
universalIdentifier: computeFieldUniversalIdentifier(createdAt.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
},
createdBy: {
...createdBy,
universalIdentifier: computeFieldUniversalIdentifier(createdBy.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
},
deletedAt: {
...deletedAt,
universalIdentifier: computeFieldUniversalIdentifier(deletedAt.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
},
position: {
...position,
universalIdentifier: computeFieldUniversalIdentifier(position.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
},
searchVector: {
...searchVector,
universalIdentifier: computeFieldUniversalIdentifier(searchVector.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
universalSettings: searchVectorUniversalSettings,
},
updatedAt: {
...updatedAt,
universalIdentifier: computeFieldUniversalIdentifier(updatedAt.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
},
updatedBy: {
...updatedBy,
universalIdentifier: computeFieldUniversalIdentifier(updatedBy.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
},
} as const satisfies Record<string, UniversalFlatFieldMetadata>;
};
// This could be replaced totally by an import schema + its transpilation when it's ready
export const buildDefaultFlatFieldMetadatasForCustomObject = ({
flatObjectMetadata: {
applicationUniversalIdentifier,
universalIdentifier: objectMetadataUniversalIdentifier,
},
skipNameField = false,
}: BuildDefaultFlatFieldMetadataForCustomObjectArgs) => {
const now = new Date().toISOString();
const nameField: UniversalFlatFieldMetadata<FieldMetadataType.TEXT> | null =
skipNameField
? null
: {
type: FieldMetadataType.TEXT,
isLabelSyncedWithName: false,
isUnique: false,
universalIdentifier: getFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier: objectMetadataUniversalIdentifier,
name: 'name',
}),
name: 'name',
label: 'Name',
icon: 'IconAbc',
description: 'Name',
isNullable: true,
isActive: true,
isSystem: false,
isSystemSideEffect: true,
isUIEditable: true,
defaultValue: null,
createdAt: now,
updatedAt: now,
options: null,
overrides: null,
morphId: null,
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
relationTargetObjectMetadataUniversalIdentifier: null,
relationTargetFieldMetadataUniversalIdentifier: null,
viewFilterUniversalIdentifiers: [],
viewFieldUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
fieldPermissionUniversalIdentifiers: [],
universalSettings: null,
viewSortUniversalIdentifiers: [],
searchFieldMetadataUniversalIdentifiers: [],
};
const searchVectorUniversalSettings: UniversalFlatFieldMetadata<FieldMetadataType.TS_VECTOR>['universalSettings'] =
null;
return {
fields: {
...(nameField && { nameField }),
...buildObjectSystemFlatFieldMetadatas({
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
now,
searchVectorUniversalSettings,
}),
},
} as const satisfies {
fields: Record<string, UniversalFlatFieldMetadata>;
};
};
@@ -1,58 +0,0 @@
import { v4 } from 'uuid';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import { generateFlatIndexMetadataWithNameOrThrow } from 'src/engine/metadata-modules/index-metadata/utils/generate-flat-index.util';
import { type DefaultFlatFieldForCustomObjectMaps } 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 { type UniversalFlatIndexMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-index-metadata.type';
import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type';
export const buildDefaultIndexesForCustomObject = ({
flatObjectMetadata,
defaultFlatFieldForCustomObjectMaps,
objectFlatFieldMetadatas,
}: {
flatObjectMetadata: UniversalFlatObjectMetadata & { id: string };
objectFlatFieldMetadatas: UniversalFlatFieldMetadata[];
defaultFlatFieldForCustomObjectMaps: DefaultFlatFieldForCustomObjectMaps;
}) => {
const tsFlatVectorIndexUniversalIdentifier = v4();
const createdAt = new Date();
const tsVectorFlatIndex = generateFlatIndexMetadataWithNameOrThrow({
objectFlatFieldMetadatas,
flatIndex: {
createdAt: createdAt.toISOString(),
universalFlatIndexFieldMetadatas: [
{
createdAt: createdAt.toISOString(),
fieldMetadataUniversalIdentifier:
defaultFlatFieldForCustomObjectMaps.fields.searchVector
.universalIdentifier,
indexMetadataUniversalIdentifier:
tsFlatVectorIndexUniversalIdentifier,
order: 0,
subFieldName: null,
updatedAt: createdAt.toISOString(),
},
],
indexType: IndexType.GIN,
indexWhereClause: null,
isCustom: false,
isUnique: false,
isSystemSideEffect: true,
objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier,
universalIdentifier: tsFlatVectorIndexUniversalIdentifier,
updatedAt: createdAt.toISOString(),
applicationUniversalIdentifier:
flatObjectMetadata.applicationUniversalIdentifier,
},
flatObjectMetadata,
});
return {
indexes: {
tsVectorFlatIndex,
},
} as const satisfies { indexes: Record<string, UniversalFlatIndexMetadata> };
};
@@ -1,38 +0,0 @@
import { isDefined } from 'twenty-shared/utils';
import { buildFlatSearchFieldMetadataForField } from 'src/engine/metadata-modules/flat-search-field-metadata/utils/build-flat-search-field-metadata-for-field.util';
import { type DefaultFlatFieldForCustomObjectMaps } from 'src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util';
import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type';
import { type UniversalFlatSearchFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-search-field-metadata.type';
// Mirrors the custom-object searchVector, which indexes the name field only. Junction
// objects (skipNameField) have no name field and therefore get no row.
export const buildDefaultSearchFieldMetadatasForCustomObject = ({
flatObjectMetadata,
defaultFlatFieldForCustomObjectMaps,
}: {
flatObjectMetadata: UniversalFlatObjectMetadata & { id: string };
defaultFlatFieldForCustomObjectMaps: DefaultFlatFieldForCustomObjectMaps;
}): {
searchFieldMetadatas: UniversalFlatSearchFieldMetadata[];
} => {
const nameField = defaultFlatFieldForCustomObjectMaps.fields.nameField;
if (!isDefined(nameField)) {
return {
searchFieldMetadatas: [],
};
}
const nameSearchFieldMetadata = buildFlatSearchFieldMetadataForField({
flatObjectMetadata,
flatFieldMetadata: nameField,
tsVectorFlatFieldMetadata:
defaultFlatFieldForCustomObjectMaps.fields.searchVector,
position: 0,
});
return {
searchFieldMetadatas: [nameSearchFieldMetadata],
};
};
@@ -0,0 +1,60 @@
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
import { FieldMetadataType } from 'twenty-shared/types';
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';
type BuildNameFlatFieldMetadataForCustomObjectArgs = {
flatObjectMetadata: Pick<
UniversalFlatObjectMetadata,
'universalIdentifier' | 'applicationUniversalIdentifier'
>;
};
export const buildNameFlatFieldMetadataForCustomObject = ({
flatObjectMetadata: {
applicationUniversalIdentifier,
universalIdentifier: objectMetadataUniversalIdentifier,
},
}: BuildNameFlatFieldMetadataForCustomObjectArgs): UniversalFlatFieldMetadata<FieldMetadataType.TEXT> => {
const now = new Date().toISOString();
return {
type: FieldMetadataType.TEXT,
isLabelSyncedWithName: false,
isUnique: false,
universalIdentifier: getFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier: objectMetadataUniversalIdentifier,
name: 'name',
}),
name: 'name',
label: 'Name',
icon: 'IconAbc',
description: 'Name',
isNullable: true,
isActive: true,
isSystem: false,
isSystemSideEffect: false,
isUIEditable: true,
defaultValue: null,
createdAt: now,
updatedAt: now,
options: null,
overrides: null,
morphId: null,
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
relationTargetObjectMetadataUniversalIdentifier: null,
relationTargetFieldMetadataUniversalIdentifier: null,
viewFilterUniversalIdentifiers: [],
viewFieldUniversalIdentifiers: [],
kanbanAggregateOperationViewUniversalIdentifiers: [],
calendarViewUniversalIdentifiers: [],
mainGroupByFieldMetadataViewUniversalIdentifiers: [],
fieldPermissionUniversalIdentifiers: [],
universalSettings: null,
viewSortUniversalIdentifiers: [],
searchFieldMetadataUniversalIdentifiers: [],
};
};
@@ -0,0 +1,100 @@
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
import { PARTIAL_SYSTEM_FLAT_FIELD_METADATAS } from 'src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant';
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';
type BuildReservedSystemFlatFieldMetadatasForCustomObjectArgs = {
flatObjectMetadata: Pick<
UniversalFlatObjectMetadata,
'universalIdentifier' | 'applicationUniversalIdentifier'
>;
};
export const buildReservedSystemFlatFieldMetadatasForCustomObject = ({
flatObjectMetadata: {
applicationUniversalIdentifier,
universalIdentifier: objectMetadataUniversalIdentifier,
},
}: BuildReservedSystemFlatFieldMetadatasForCustomObjectArgs): Record<
string,
UniversalFlatFieldMetadata
> => {
const now = new Date().toISOString();
const {
createdAt,
createdBy,
deletedAt,
id,
position,
updatedAt,
updatedBy,
} = PARTIAL_SYSTEM_FLAT_FIELD_METADATAS;
const computeFieldUniversalIdentifier = (name: string) =>
getFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier: objectMetadataUniversalIdentifier,
name,
});
return {
id: {
...id,
universalIdentifier: computeFieldUniversalIdentifier(id.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
},
createdAt: {
...createdAt,
universalIdentifier: computeFieldUniversalIdentifier(createdAt.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
},
createdBy: {
...createdBy,
universalIdentifier: computeFieldUniversalIdentifier(createdBy.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
},
deletedAt: {
...deletedAt,
universalIdentifier: computeFieldUniversalIdentifier(deletedAt.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
},
position: {
...position,
universalIdentifier: computeFieldUniversalIdentifier(position.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
},
updatedAt: {
...updatedAt,
universalIdentifier: computeFieldUniversalIdentifier(updatedAt.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
},
updatedBy: {
...updatedBy,
universalIdentifier: computeFieldUniversalIdentifier(updatedBy.name),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
},
} as const satisfies Record<string, UniversalFlatFieldMetadata>;
};
@@ -0,0 +1,38 @@
import { getFieldUniversalIdentifier } from 'twenty-shared/application';
import { type FieldMetadataType } from 'twenty-shared/types';
import { PARTIAL_SYSTEM_FLAT_FIELD_METADATAS } from 'src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant';
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';
type BuildSearchVectorFlatFieldMetadataForCustomObjectArgs = {
flatObjectMetadata: Pick<
UniversalFlatObjectMetadata,
'universalIdentifier' | 'applicationUniversalIdentifier'
>;
};
export const buildSearchVectorFlatFieldMetadataForCustomObject = ({
flatObjectMetadata: {
applicationUniversalIdentifier,
universalIdentifier: objectMetadataUniversalIdentifier,
},
}: BuildSearchVectorFlatFieldMetadataForCustomObjectArgs): UniversalFlatFieldMetadata<FieldMetadataType.TS_VECTOR> => {
const now = new Date().toISOString();
const { searchVector } = PARTIAL_SYSTEM_FLAT_FIELD_METADATAS;
return {
...searchVector,
universalIdentifier: getFieldUniversalIdentifier({
applicationUniversalIdentifier,
objectUniversalIdentifier: objectMetadataUniversalIdentifier,
name: searchVector.name,
}),
applicationUniversalIdentifier,
objectMetadataUniversalIdentifier,
createdAt: now,
updatedAt: now,
universalSettings: null,
};
};
@@ -0,0 +1,42 @@
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import { generateDeterministicFlatIndexMetadataOrThrow } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-flat-index.util';
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
import { type UniversalFlatIndexMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-index-metadata.type';
import { type UniversalFlatObjectMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-object-metadata.type';
export const buildSearchVectorGinIndexForCustomObject = ({
flatObjectMetadata,
searchVectorFlatFieldMetadata,
}: {
flatObjectMetadata: UniversalFlatObjectMetadata;
searchVectorFlatFieldMetadata: UniversalFlatFieldMetadata;
}): UniversalFlatIndexMetadata => {
const createdAt = new Date().toISOString();
return generateDeterministicFlatIndexMetadataOrThrow({
objectFlatFieldMetadatas: [searchVectorFlatFieldMetadata],
flatIndex: {
createdAt,
universalFlatIndexFieldMetadatas: [
{
createdAt,
fieldMetadataUniversalIdentifier:
searchVectorFlatFieldMetadata.universalIdentifier,
order: 0,
subFieldName: null,
updatedAt: createdAt,
},
],
indexType: IndexType.GIN,
indexWhereClause: null,
isCustom: false,
isUnique: false,
isSystemSideEffect: true,
objectMetadataUniversalIdentifier: flatObjectMetadata.universalIdentifier,
updatedAt: createdAt,
applicationUniversalIdentifier:
flatObjectMetadata.applicationUniversalIdentifier,
},
flatObjectMetadata,
});
};
@@ -14,8 +14,6 @@ export const objectMetadataExceptionCodeToHttpStatus = (
case ObjectMetadataExceptionCode.NAME_CONFLICT:
return 403;
case ObjectMetadataExceptionCode.INVALID_OBJECT_INPUT:
case ObjectMetadataExceptionCode.MISSING_SYSTEM_FIELD:
case ObjectMetadataExceptionCode.INVALID_SYSTEM_FIELD:
case ObjectMetadataExceptionCode.MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD:
case ObjectMetadataExceptionCode.APPLICATION_NOT_FOUND:
return 400;
@@ -41,9 +41,6 @@ export const objectMetadataGraphqlApiExceptionHandler = (error: Error) => {
case ObjectMetadataExceptionCode.MISSING_CUSTOM_OBJECT_DEFAULT_LABEL_IDENTIFIER_FIELD:
case ObjectMetadataExceptionCode.APPLICATION_NOT_FOUND:
throw error;
case ObjectMetadataExceptionCode.MISSING_SYSTEM_FIELD:
case ObjectMetadataExceptionCode.INVALID_SYSTEM_FIELD:
throw new UserInputError(error);
default: {
return assertUnreachable(error.code);
}
@@ -13,6 +13,7 @@ import {
import { ADD_UNIVERSAL_IDENTIFIER_AND_APPLICATION_ID_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-16/add-universal-identifier-and-application-id-to-search-field-metadata-upgrade-command-name.constant';
import { ADD_TS_VECTOR_FIELD_METADATA_ID_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-18/add-ts-vector-field-metadata-id-to-search-field-metadata-upgrade-command-name.constant';
import { ADD_IS_SYSTEM_SIDE_EFFECT_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-20/add-is-system-side-effect-to-search-field-metadata-upgrade-command-name.constant';
import { WasIntroducedInUpgrade } from 'src/engine/core-modules/upgrade/decorators/was-introduced-in-upgrade.decorator';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
@@ -69,6 +70,13 @@ export class SearchFieldMetadataEntity extends SyncableEntity {
@Column({ nullable: false, type: 'float' })
position: number;
@WasIntroducedInUpgrade({
upgradeCommandName:
ADD_IS_SYSTEM_SIDE_EFFECT_TO_SEARCH_FIELD_METADATA_UPGRADE_COMMAND_NAME,
})
@Column({ nullable: false, default: true, type: 'boolean' })
isSystemSideEffect: boolean;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;