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>;