From 38fbff465f218e46e65c66679c572819db1f57bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Malfait?= Date: Thu, 2 Jul 2026 13:18:20 +0200 Subject: [PATCH] chore(server): ship the 2.20 standardOverrides drop as a dormant command (#22448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #22417, per [this thread](https://github.com/twentyhq/twenty/pull/22417#discussion_r3512187719): migrate the `2-20/README.md` placeholder into a real command using the `TWENTY_NEXT_VERSIONS` mechanism. ### What - Add `DropMetadataStandardOverridesColumnFastInstanceCommand`, registered against `2.20.0`. It boots (`2.20.0` is in `TWENTY_ALL_VERSIONS`) but stays **dormant** — the upgrade sequence only runs `TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS` (previous + current), so it never executes during the 2.19 deploy and activates automatically when `nx version:bump` promotes 2.20 to current. - Name constant + unit test (SQL parity, registration against `2.20.0`, name-constant parity). - Register it in `instance-commands.constant.ts`. - Update the `standardOverrides` `@deprecated` comments on object/field metadata to point at the shipped command. - Delete `2-20/README.md`. - Document the "ship a command for a future version" flow in `docs/UPGRADE_COMMANDS.md` and `.cursor/rules/server-migrations.mdc` (the mechanism was previously undocumented). ### Note / correction to the README's plan The old README implied both the command **and** `@WasRemovedInUpgrade` could be added at 2.20 time. Only the command can ship now: the decorator's validator runs against the active sequence, so referencing a still-dormant 2.20 step fails boot with `unknown-step-name`. So the entity keeps its `WasRemovedInUpgrade` type wrapper for now; the decorator gets wired (one line, via the name constant) once 2.20 is current — same deferred-drop shape as `isUIReadOnly`. ### Verification Could not run `jest`/`typecheck`/`lint` in this environment: `yarn install` is blocked by egress policy on a git-based transitive dep (`github.com/electron/node-gyp.git`). Verified by review against the sibling 2-19 add-column and 2-12 drop commands. **Please let CI run before merge.** https://claude.ai/code/session_01KMArJvdEmsX3eAmJLbS1b6 --- _Generated by [Claude Code](https://claude.ai/code/session_01KMArJvdEmsX3eAmJLbS1b6)_ Review in cubic --- .cursor/rules/server-migrations.mdc | 2 + .../twenty-server/docs/UPGRADE_COMMANDS.md | 12 ++ ...drop-metadata-standard-overrides-column.ts | 35 +++++ .../upgrade-version-command/2-20/README.md | 134 ------------------ ...-overrides-column.instance-command.spec.ts | 71 ++++++++++ ...es-column-upgrade-command-name.constant.ts | 4 + .../instance-commands.constant.ts | 2 + .../field-metadata/field-metadata.entity.ts | 6 +- .../object-metadata/object-metadata.entity.ts | 6 +- 9 files changed, 134 insertions(+), 138 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1825000000000-drop-metadata-standard-overrides-column.ts delete mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-20/README.md create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-20/__tests__/drop-metadata-standard-overrides-column.instance-command.spec.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-20/drop-metadata-standard-overrides-column-upgrade-command-name.constant.ts diff --git a/.cursor/rules/server-migrations.mdc b/.cursor/rules/server-migrations.mdc index 91693bed0a..44d93138e1 100644 --- a/.cursor/rules/server-migrations.mdc +++ b/.cursor/rules/server-migrations.mdc @@ -31,6 +31,8 @@ See `packages/twenty-server/docs/UPGRADE_COMMANDS.md` for full documentation. - **Keep commands consistent and reversible**: include both `up` and `down` logic. Do not delete or rewrite existing, committed commands unless on a pre-release branch. +- **Shipping a command for a future version (deferred drops)**: pass `--version ` (a `TWENTY_NEXT_VERSIONS` value) to the generator to write a command that registers but stays dormant until `nx version:bump` makes that version current — e.g. dropping a column one release after its replacement ships. Caveat: `@WasRemovedInUpgrade`/`@WasIntroducedInUpgrade` reject a still-dormant next-version command at boot (`unknown-step-name`), so keep the entity's `WasRemovedInUpgrade` type wrapper and add the decorator only once the version is current. See `docs/UPGRADE_COMMANDS.md`. + ### Workspace Commands - Use the `@RegisteredWorkspaceCommand` decorator alongside nest-commander's `@Command` decorator. diff --git a/packages/twenty-server/docs/UPGRADE_COMMANDS.md b/packages/twenty-server/docs/UPGRADE_COMMANDS.md index 024fc3eb0a..77ad5e3b85 100644 --- a/packages/twenty-server/docs/UPGRADE_COMMANDS.md +++ b/packages/twenty-server/docs/UPGRADE_COMMANDS.md @@ -115,3 +115,15 @@ Within a given version of Twenty, the upgrade pipeline runs commands in this ord 3. **Workspace commands** Workspace commands are executed sequentially across all active/suspended workspaces. + +## Shipping a command for a future version (deferred drops) + +You can write a command for a version listed in `TWENTY_NEXT_VERSIONS` — typically the second half of a zero-downtime migration, e.g. dropping a column one release after its replacement ships. Pass the target version to the generator: + +```bash +npx nx run twenty-server:database:migrate:generate --name --type fast --version 2.20.0 +``` + +It registers and boots (versions are validated against `TWENTY_ALL_VERSIONS`) but stays **dormant** — the sequence only runs `TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS` (previous + current). It activates automatically when `nx version:bump` promotes the version to current. + +**Caveat:** `@WasRemovedInUpgrade` / `@WasIntroducedInUpgrade` are validated against the active sequence, so a decorator pointing at a still-dormant next-version command fails boot with `unknown-step-name`. For a deferred drop, keep the entity's `WasRemovedInUpgrade` type wrapper now and add the decorator only once the version is current. diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1825000000000-drop-metadata-standard-overrides-column.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1825000000000-drop-metadata-standard-overrides-column.ts new file mode 100644 index 0000000000..10146c66d5 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1825000000000-drop-metadata-standard-overrides-column.ts @@ -0,0 +1,35 @@ +import { QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; + +// Drops the legacy "standardOverrides" column, superseded by "overrides" in +// 2.19. Registered against 2.20.0 so it stays dormant until 2.20 is current +// (the sequence only runs previous + current versions). down() restores it. +const TABLES = ['objectMetadata', 'fieldMetadata'] as const; + +@RegisteredInstanceCommand('2.20.0', 1825000000000) +export class DropMetadataStandardOverridesColumnFastInstanceCommand + implements FastInstanceCommand +{ + public async up(queryRunner: QueryRunner): Promise { + for (const table of TABLES) { + await queryRunner.query( + `ALTER TABLE "core"."${table}" DROP COLUMN IF EXISTS "standardOverrides"`, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + for (const table of TABLES) { + await queryRunner.query( + `ALTER TABLE "core"."${table}" ADD COLUMN IF NOT EXISTS "standardOverrides" jsonb`, + ); + // Unconditional copy: a WHERE "overrides" IS NOT NULL guard would leave a + // stale value and resurrect a cleared override. + await queryRunner.query( + `UPDATE "core"."${table}" SET "standardOverrides" = "overrides"`, + ); + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/README.md b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/README.md deleted file mode 100644 index c965216f41..0000000000 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/README.md +++ /dev/null @@ -1,134 +0,0 @@ -# 2.20 — pending metadata-override cleanup - -The `standardOverrides` column on `objectMetadata` and `fieldMetadata` is a -**deferred drop**. As of 2.19 the column is: - -- superseded by `overrides` (backfilled by - `../2-19/2-19-instance-command-slow-1820000110000-backfill-metadata-overrides.ts`), -- excluded from the flat-entity / registry via the `WasRemovedInUpgrade` type - wrapper on both entities, -- still physically present in the database, so a 2.18 → 2.19 rolling deploy never - breaks the previous release's pods, which still `SELECT "standardOverrides"`. - -The physical `DROP COLUMN` is intentionally deferred to the next release (2.20): -by the time it runs, every pod is on a release that reads/writes `overrides` -only. This folder holds the ready-to-wire drop command so whoever opens the 2.20 -upgrade work can drop it in. - -## Why it isn't wired up yet - -`2.20.0` is not in `TWENTY_CROSS_UPGRADE_SUPPORTED_VERSIONS` (currently -`[...TWENTY_PREVIOUS_VERSIONS, TWENTY_CURRENT_VERSION]`, and current is 2.19.0). -Registering a 2.20 instance command — or pointing the entity's -`@WasRemovedInUpgrade` decorator at a 2.20 command name — would fail boot -validation (`unknown-step-name`) until 2.20 is the current version. So the -command lives here as documentation, not as compiled code. - -## Activation checklist (when 2.20 becomes the current version) - -1. Create `2-20-instance-command-fast--drop-metadata-standard-overrides-column.ts` - in this folder with the command below. Regenerate `` (the value - here is a placeholder) so ordering against other 2.20 commands is correct. -2. Register it in - `../instance-commands.constant.ts` (import + add to the array). -3. Add the drop decorator to `standardOverrides` on **both** - `object-metadata.entity.ts` and `field-metadata.entity.ts`: - `@WasRemovedInUpgrade({ upgradeCommandName: DROP_METADATA_STANDARD_OVERRIDES_COLUMN_UPGRADE_COMMAND_NAME })`, - and add the matching constant - (`'2.20.0_DropMetadataStandardOverridesColumnFastInstanceCommand_'`), - mirroring how `isCustom` pairs its decorator with a command name. -4. Add the test below under `2-20/__tests__/`. -5. Run `database:migrate:generate --name pending-migration-check` and confirm no - drift, then run the integration suite. - -## Command - -```ts -import { QueryRunner } from 'typeorm'; - -import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; -import { FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface'; - -// Phase 2 of unifying the metadata override mechanisms. Drops the legacy -// "standardOverrides" column now that every pod runs a release which reads and -// writes "overrides" (backfilled in 2.19). Runs only once the instance reaches -// 2.20, so the 2.19 rolling deploy keeps the column available for older pods. -// down() restores the column and copies the blob back from "overrides". -const TABLES = ['objectMetadata', 'fieldMetadata'] as const; - -@RegisteredInstanceCommand('2.20.0', 1825000000000) -export class DropMetadataStandardOverridesColumnFastInstanceCommand - implements FastInstanceCommand -{ - public async up(queryRunner: QueryRunner): Promise { - for (const table of TABLES) { - await queryRunner.query( - `ALTER TABLE "core"."${table}" DROP COLUMN IF EXISTS "standardOverrides"`, - ); - } - } - - public async down(queryRunner: QueryRunner): Promise { - for (const table of TABLES) { - await queryRunner.query( - `ALTER TABLE "core"."${table}" ADD COLUMN IF NOT EXISTS "standardOverrides" jsonb`, - ); - await queryRunner.query( - `UPDATE "core"."${table}" SET "standardOverrides" = "overrides"`, - ); - } - } -} -``` - -## Test - -```ts -import { type QueryRunner } from 'typeorm'; - -import { DropMetadataStandardOverridesColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1825000000000-drop-metadata-standard-overrides-column'; - -describe('DropMetadataStandardOverridesColumnFastInstanceCommand', () => { - let command: DropMetadataStandardOverridesColumnFastInstanceCommand; - - beforeEach(() => { - command = new DropMetadataStandardOverridesColumnFastInstanceCommand(); - }); - - describe('up', () => { - it('drops standardOverrides from both tables', async () => { - const query = jest.fn().mockResolvedValue(undefined); - const queryRunner = { query } as unknown as QueryRunner; - - await command.up(queryRunner); - - const statements = query.mock.calls.map((call) => call[0] as string); - - expect(statements).toEqual([ - 'ALTER TABLE "core"."objectMetadata" DROP COLUMN IF EXISTS "standardOverrides"', - 'ALTER TABLE "core"."fieldMetadata" DROP COLUMN IF EXISTS "standardOverrides"', - ]); - }); - }); - - describe('down', () => { - it('recreates and backfills standardOverrides from overrides', async () => { - const query = jest.fn().mockResolvedValue(undefined); - const queryRunner = { query } as unknown as QueryRunner; - - await command.down(queryRunner); - - const statements = query.mock.calls.map((call) => call[0] as string); - - for (const table of ['objectMetadata', 'fieldMetadata']) { - expect(statements).toEqual( - expect.arrayContaining([ - `ALTER TABLE "core"."${table}" ADD COLUMN IF NOT EXISTS "standardOverrides" jsonb`, - `UPDATE "core"."${table}" SET "standardOverrides" = "overrides"`, - ]), - ); - } - }); - }); -}); -``` diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/__tests__/drop-metadata-standard-overrides-column.instance-command.spec.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/__tests__/drop-metadata-standard-overrides-column.instance-command.spec.ts new file mode 100644 index 0000000000..535f242758 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/__tests__/drop-metadata-standard-overrides-column.instance-command.spec.ts @@ -0,0 +1,71 @@ +import { type QueryRunner } from 'typeorm'; + +import { DropMetadataStandardOverridesColumnFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-20/2-20-instance-command-fast-1825000000000-drop-metadata-standard-overrides-column'; +import { DROP_METADATA_STANDARD_OVERRIDES_COLUMN_UPGRADE_COMMAND_NAME } from 'src/database/commands/upgrade-version-command/2-20/drop-metadata-standard-overrides-column-upgrade-command-name.constant'; +import { getRegisteredInstanceCommandMetadata } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; + +describe('DropMetadataStandardOverridesColumnFastInstanceCommand', () => { + let command: DropMetadataStandardOverridesColumnFastInstanceCommand; + + beforeEach(() => { + command = new DropMetadataStandardOverridesColumnFastInstanceCommand(); + }); + + describe('registration', () => { + it('is registered against 2.20.0 so it stays dormant until 2.20 is current', () => { + const metadata = getRegisteredInstanceCommandMetadata( + DropMetadataStandardOverridesColumnFastInstanceCommand, + ); + + expect(metadata).toEqual({ + version: '2.20.0', + timestamp: 1825000000000, + type: 'fast', + }); + }); + + it('has a name constant matching its computed registered name', () => { + const metadata = getRegisteredInstanceCommandMetadata( + DropMetadataStandardOverridesColumnFastInstanceCommand, + ); + + expect( + `${metadata?.version}_${DropMetadataStandardOverridesColumnFastInstanceCommand.name}_${metadata?.timestamp}`, + ).toBe(DROP_METADATA_STANDARD_OVERRIDES_COLUMN_UPGRADE_COMMAND_NAME); + }); + }); + + describe('up', () => { + it('drops standardOverrides from both tables', async () => { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + + await command.up(queryRunner); + + const statements = query.mock.calls.map((call) => call[0] as string); + + expect(statements).toEqual([ + 'ALTER TABLE "core"."objectMetadata" DROP COLUMN IF EXISTS "standardOverrides"', + 'ALTER TABLE "core"."fieldMetadata" DROP COLUMN IF EXISTS "standardOverrides"', + ]); + }); + }); + + describe('down', () => { + it('recreates and backfills standardOverrides from overrides', async () => { + const query = jest.fn().mockResolvedValue(undefined); + const queryRunner = { query } as unknown as QueryRunner; + + await command.down(queryRunner); + + const statements = query.mock.calls.map((call) => call[0] as string); + + expect(statements).toEqual([ + 'ALTER TABLE "core"."objectMetadata" ADD COLUMN IF NOT EXISTS "standardOverrides" jsonb', + 'UPDATE "core"."objectMetadata" SET "standardOverrides" = "overrides"', + 'ALTER TABLE "core"."fieldMetadata" ADD COLUMN IF NOT EXISTS "standardOverrides" jsonb', + 'UPDATE "core"."fieldMetadata" SET "standardOverrides" = "overrides"', + ]); + }); + }); +}); diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/drop-metadata-standard-overrides-column-upgrade-command-name.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/drop-metadata-standard-overrides-column-upgrade-command-name.constant.ts new file mode 100644 index 0000000000..d85d376080 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-20/drop-metadata-standard-overrides-column-upgrade-command-name.constant.ts @@ -0,0 +1,4 @@ +// Registered name of the 2.20 drop command. Wire to @WasRemovedInUpgrade on the +// standardOverrides columns once 2.20 is current. +export const DROP_METADATA_STANDARD_OVERRIDES_COLUMN_UPGRADE_COMMAND_NAME = + '2.20.0_DropMetadataStandardOverridesColumnFastInstanceCommand_1825000000000'; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts index 2ef082b2ae..a2ca66bd31 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/instance-commands.constant.ts @@ -86,6 +86,7 @@ import { EncryptNonSecretApplicationVariableSlowInstanceCommand } from 'src/data import { MigrateAiModelPreferencesSlowInstanceCommand } from 'src/database/commands/upgrade-version-command/2-9/2-9-instance-command-slow-1799000010000-migrate-ai-model-preferences'; import { AddFolderImportToMessageFolderPendingSyncActionFastInstanceCommand } from './2-15/2-15-instance-command-fast-1781714499016-add-folder-import-to-message-folder-pending-sync-action'; import { AddViewKanbanColumnWidthFastInstanceCommand } from './2-15/2-15-instance-command-fast-1781900000000-add-view-kanban-column-width'; +import { DropMetadataStandardOverridesColumnFastInstanceCommand } from './2-20/2-20-instance-command-fast-1825000000000-drop-metadata-standard-overrides-column'; export const INSTANCE_COMMANDS = [ AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand, @@ -174,4 +175,5 @@ export const INSTANCE_COMMANDS = [ BackfillTsVectorFieldMetadataIdOnSearchFieldMetadataSlowInstanceCommand, AddMetadataOverridesColumnFastInstanceCommand, BackfillMetadataOverridesSlowInstanceCommand, + DropMetadataStandardOverridesColumnFastInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts index 2a7b759ae9..fd4f9469b0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts @@ -110,8 +110,10 @@ export class FieldMetadataEntity< overrides: JsonbProperty | null; /** - * @deprecated Superseded by `overrides`; kept readable for pods on the - * previous release during a rolling deploy. Drop deferred to 2-20/README.md. + * @deprecated Superseded by `overrides`; kept readable for previous-release + * pods during a rolling deploy. Dropped by the dormant + * DropMetadataStandardOverridesColumnFastInstanceCommand when 2.20 is current; + * add @WasRemovedInUpgrade then (its validator rejects a dormant 2.20 step). */ @Column({ type: 'jsonb', nullable: true }) standardOverrides: WasRemovedInUpgrade | null>; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts index c0d0894d03..996a1cad36 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts @@ -72,8 +72,10 @@ export class ObjectMetadataEntity overrides: JsonbProperty | null; /** - * @deprecated Superseded by `overrides`; kept readable for pods on the - * previous release during a rolling deploy. Drop deferred to 2-20/README.md. + * @deprecated Superseded by `overrides`; kept readable for previous-release + * pods during a rolling deploy. Dropped by the dormant + * DropMetadataStandardOverridesColumnFastInstanceCommand when 2.20 is current; + * add @WasRemovedInUpgrade then (its validator rejects a dormant 2.20 step). */ @Column({ type: 'jsonb', nullable: true }) standardOverrides: WasRemovedInUpgrade | null>;