fix(server): repair missing keyValuePair.applicationId on 2.23 upgrades (#23272)

Fixes #23254

## Problem

Upgrading a self-hosted instance from `2.23.x` to `2.24.0` leaves
`core.keyValuePair` without the `applicationId` column. Database-backed
config loading then fails on startup and on every refresh (~every 15s)
with:

```
column KeyValuePairEntity.applicationId does not exist
```

The frontend shows "Unable to reach the backend".

## Root cause

`AddApplicationIdToKeyValuePairFastInstanceCommand` was added in #23089
(after `2.23.x` shipped) but registered under the already-released
`2.23.0` segment:

```ts
@RegisteredInstanceCommand('2.23.0', 1784659343818)
```

The upgrade cursor is **positional and forward-only**:

- `resolveStartCursor` resumes at `lastAttemptedIndex + 1`. A
fully-upgraded `2.23.x` instance has its cursor at the last `2.23`
workspace command, which sits *after* this newly-inserted fast command
in the sequence. So the runner steps right over it and the DDL never
runs.
- The upgrade-aware metadata layer decides "applied" the same way
(`stepIndex < currentCursor` in
`upgrade-aware-entity-metadata.adapter.ts`). Since the step index is
below the cursor, the column is considered applied and is **not** hidden
from TypeORM SELECTs, so every query references a column that was never
created.

Fresh `2.24.0` installs replay the whole sequence, so only `2.23.x ->
2.24.0` upgrades are affected. The instance log `1 fast instance ... for
2.24.0` confirms the command landed in the `2.23.0` bundle rather than
`2.24.0`.

## Fix

- Add `RepairKeyValuePairApplicationIdFastInstanceCommand` under the
current version (`2.24.0`) with a fresh timestamp, so it sorts last in
the sequence and runs for every existing instance regardless of cursor
position. Its DDL mirrors the original command and is fully idempotent
(`ADD COLUMN IF NOT EXISTS`, `DROP INDEX IF EXISTS` + recreate, `ADD
VALUE IF NOT EXISTS`), so it is a no-op on healthy instances. `down()`
is intentionally empty: the column lifecycle is owned by the `2.23.0`
introduction command.
- Repoint the entity's `@WasIntroducedInUpgrade` to the new command so
the column stays hidden from queries until the repair has actually run,
eliminating the error window during the migration itself.

## Notes

- `2.24.0` (`TWENTY_CURRENT_VERSION`) is the correct target: the upgrade
sequence only covers previous + current versions, so a command under
`2.25.0` (a next version) would not run. If a version bump lands before
this merges, the command should be moved to the new current version.
- Follow-up worth considering: nothing currently prevents registering a
command under a version in `TWENTY_PREVIOUS_VERSIONS`. A startup
validation rejecting that would have caught this at PR time.


<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/23272?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:
Paul Rastoin
2026-07-24 15:46:59 +02:00
committed by GitHub
parent abe4d7491c
commit ba5cb6ba15
3 changed files with 60 additions and 1 deletions
@@ -0,0 +1,57 @@
import { type QueryRunner } from 'typeorm';
import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator';
import { type FastInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/fast-instance-command.interface';
// Repairs instances that upgraded from 2.23.x, where
// AddApplicationIdToKeyValuePairFastInstanceCommand was registered under the
// already-released 2.23.0 segment and got skipped by the forward-only upgrade
// cursor, leaving core.keyValuePair without applicationId. The DDL mirrors that
// command and is fully idempotent, so it is a no-op on healthy instances.
@RegisteredInstanceCommand('2.24.0', 1784897347051)
export class RepairKeyValuePairApplicationIdFastInstanceCommand
implements FastInstanceCommand
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TYPE "core"."keyValuePair_type_enum" ADD VALUE IF NOT EXISTS 'APPLICATION_VARIABLE'`,
);
await queryRunner.query(
'ALTER TABLE "core"."keyValuePair" ADD COLUMN IF NOT EXISTS "applicationId" uuid',
);
await queryRunner.query(
'ALTER TABLE "core"."keyValuePair" DROP CONSTRAINT IF EXISTS "FK_e31d245e30cd82307e5416450fc"',
);
await queryRunner.query(
'ALTER TABLE "core"."keyValuePair" ADD CONSTRAINT "FK_e31d245e30cd82307e5416450fc" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION',
);
await queryRunner.query(
'CREATE INDEX IF NOT EXISTS "IDX_KEY_VALUE_PAIR_APPLICATION_ID" ON "core"."keyValuePair" ("applicationId")',
);
await queryRunner.query(
'DROP INDEX IF EXISTS "core"."IDX_KEY_VALUE_PAIR_KEY_WORKSPACE_ID_NULL_USER_ID_UNIQUE"',
);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_KEY_VALUE_PAIR_KEY_WORKSPACE_ID_NULL_USER_ID_UNIQUE" ON "core"."keyValuePair" ("key", "workspaceId") WHERE "userId" IS NULL AND "applicationId" IS NULL',
);
await queryRunner.query(
'DROP INDEX IF EXISTS "core"."IDX_KEY_VALUE_PAIR_KEY_NULL_USER_ID_NULL_WORKSPACE_ID_UNIQUE"',
);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_KEY_VALUE_PAIR_KEY_NULL_USER_ID_NULL_WORKSPACE_ID_UNIQUE" ON "core"."keyValuePair" ("key") WHERE "userId" IS NULL AND "workspaceId" IS NULL AND "applicationId" IS NULL',
);
await queryRunner.query(
'CREATE UNIQUE INDEX IF NOT EXISTS "IDX_KEY_VALUE_PAIR_KEY_APPLICATION_ID_WORKSPACE_UNIQUE" ON "core"."keyValuePair" ("key", "applicationId") WHERE "applicationId" IS NOT NULL AND "workspaceId" IS NOT NULL',
);
await queryRunner.query(
'CREATE UNIQUE INDEX IF NOT EXISTS "IDX_KEY_VALUE_PAIR_KEY_APPLICATION_ID_GLOBAL_UNIQUE" ON "core"."keyValuePair" ("key", "applicationId") WHERE "applicationId" IS NOT NULL AND "workspaceId" IS NULL',
);
}
// No-op: the applicationId column lifecycle is owned by the 2.23.0
// introduction command. This command only repairs instances that skipped it,
// so rolling it back must not drop the column or its constraints.
public async down(): Promise<void> {}
}
@@ -123,6 +123,7 @@ import { UnlistUnclaimedNpmApplicationRegistrationsSlowInstanceCommand } from '.
import { AddStatusesToBillingSubscriptionIndexSlowInstanceCommand } from './2-23/2-23-instance-command-slow-1784650048045-add-statuses-to-billing-subscription-index';
import { AddOnConnectLogicFunctionToConnectionProviderFastInstanceCommand } from './2-24/2-24-instance-command-fast-1784712843602-add-on-connect-logic-function-to-connection-provider';
import { AddAgentForeignKeyToRoleTargetFastInstanceCommand } from 'src/database/commands/upgrade-version-command/2-24/2-24-instance-command-fast-1784820332810-add-agent-foreign-key-to-role-target';
import { RepairKeyValuePairApplicationIdFastInstanceCommand } from './2-24/2-24-instance-command-fast-1784897347051-repair-key-value-pair-application-id';
export const INSTANCE_COMMANDS = [
AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand,
@@ -248,4 +249,5 @@ export const INSTANCE_COMMANDS = [
AddStatusesToBillingSubscriptionIndexSlowInstanceCommand,
AddOnConnectLogicFunctionToConnectionProviderFastInstanceCommand,
AddAgentForeignKeyToRoleTargetFastInstanceCommand,
RepairKeyValuePairApplicationIdFastInstanceCommand,
];
@@ -108,7 +108,7 @@ export class KeyValuePairEntity {
@Column({ nullable: true, type: 'uuid' })
@WasIntroducedInUpgrade({
upgradeCommandName:
'2.23.0_AddApplicationIdToKeyValuePairFastInstanceCommand_1784659343818',
'2.24.0_RepairKeyValuePairApplicationIdFastInstanceCommand_1784897347051',
})
applicationId: string | null;