chore(server): ship the 2.20 standardOverrides drop as a dormant command (#22448)

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<T>` 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)_

<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22448?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
This commit is contained in:
Félix Malfait
2026-07-02 13:18:20 +02:00
committed by GitHub
parent 7a5896ff5d
commit 38fbff465f
9 changed files with 134 additions and 138 deletions
+2
View File
@@ -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 <next-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<T>` 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.
@@ -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 <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<T>` type wrapper now and add the decorator only once the version is current.
@@ -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<void> {
for (const table of TABLES) {
await queryRunner.query(
`ALTER TABLE "core"."${table}" DROP COLUMN IF EXISTS "standardOverrides"`,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
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"`,
);
}
}
}
@@ -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<T>` 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-<timestamp>-drop-metadata-standard-overrides-column.ts`
in this folder with the command below. Regenerate `<timestamp>` (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_<timestamp>'`),
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<void> {
for (const table of TABLES) {
await queryRunner.query(
`ALTER TABLE "core"."${table}" DROP COLUMN IF EXISTS "standardOverrides"`,
);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
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"`,
]),
);
}
});
});
});
```
@@ -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"',
]);
});
});
});
@@ -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';
@@ -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,
];
@@ -110,8 +110,10 @@ export class FieldMetadataEntity<
overrides: JsonbProperty<FieldMetadataOverrides> | 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<JsonbProperty<FieldMetadataOverrides> | null>;
@@ -72,8 +72,10 @@ export class ObjectMetadataEntity
overrides: JsonbProperty<ObjectMetadataOverrides> | 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<JsonbProperty<ObjectMetadataOverrides> | null>;