From de75be16e2d4e737075909e70f1bd84f260d22df Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:12:43 +0200 Subject: [PATCH] harden(server): backfill and enforce workspace.databaseSchema invariant with a check constraint (#22855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What & why `core.workspace.databaseSchema` is meant to be set for every workspace past the creation phase. It only started being written at creation time in 2.x (dual-write since 2026-03-28, direct write since 2026-04-10); older workspaces relied on the `1-21 backfill-datasource-to-workspace` instance command, which never effectively ran on some instances. On affected rows the column could be left `NULL`. A null value on a post-creation workspace is a real integrity problem — several paths trust the column: - **REST API**: `hydrateRestRequest` throws `No data sources found` for authenticated requests. - **GraphQL API**: `getOrComputeSchemaSDL` returns `null`, so `WorkspaceSchemaFactory` hands back an empty schema. - **GraphQL introspection** (direct execution) returns `null`. This PR makes the invariant impossible to silently violate, and repairs any instance still lagging. ### On the original "No data source, skipping" logs This investigation started from `BackfillActorSourceEnumValuesCommand` logging `No data source for workspace , skipping` at high volume. **That symptom is not explained by this change, and this PR is not a fix for it.** Findings: - The workspace iterator only processes `ACTIVE` + `SUSPENDED` workspaces, and on the affected instance all of those already have `databaseSchema` set (only `PENDING_CREATION` rows are null, and those are never iterated). - `getGlobalWorkspaceDataSource()` never resolves to `undefined` (it returns a value or throws), so a defined-schema workspace should never hit the skip branch. - The upgrade-aware repository proxy was investigated as a possible cause (it can short-circuit `findOne` to `null` for entities marked unavailable during an upgrade) and **exonerated**: `WorkspaceEntity` and its `databaseSchema` column carry no `@WasIntroducedInUpgrade`/`@WasRemovedInUpgrade` decorators, so `resolveEntityShapeAtUpgradeCursor` always reports the entity available and the column visible at every cursor. In other words, current code should emit zero such skips for that instance's data, so the root cause of the observed logs remains undetermined and is tracked separately. See twentyhq/core-team-issues#2666. ## Changes - **Check constraint `workspace_requires_database_schema`** (the core of this PR): enforces `databaseSchema IS NOT NULL` for any workspace past creation (`activationStatus NOT IN ('PENDING_CREATION', 'ONGOING_CREATION')`). Declared on `WorkspaceEntity` and applied in the slow instance command's `up()`. Safe against the creation flow: `databaseSchema` is written in `WorkspaceManagerService.init` (right after schema creation) long before a workspace becomes `ACTIVE`. - **Defensive backfill** (`2-21` slow instance command): repopulates `databaseSchema` where it is `NULL`/empty, deriving the schema name deterministically from the workspace id (`getWorkspaceSchemaName`) and only setting it for workspaces whose schema actually exists in `information_schema.schemata` (so `PENDING_CREATION` rows without a provisioned schema are left untouched, and stay exempt via the constraint). No-op on instances already backfilled. - `runDataMigration` runs before `up()`, so the backfill repairs legacy rows before the constraint is enforced. Keeping both in the same slow command (rather than a standalone fast command) guarantees the constraint is never added ahead of the repair. - `checkSchemaExists` gets an explicit `: Promise` return type. ## Notes - Backfill + constraint live in a **slow** instance command, so they only apply on upgrades run with `--include-slow`. - The constraint is added **`NOT VALID`**: the backfill repairs every workspace whose Postgres schema exists, but some legacy active/suspended workspaces (e.g. carried over from very old versions, as reproduced by the cross-version upgrade from v1.22) have a null `databaseSchema` with no schema to point at and are unrepairable. `NOT VALID` enforces the invariant on all future inserts/updates without failing the upgrade on that pre-existing corruption. - No production request path was changed — the iterator and `checkSchemaExists` keep trusting the (now backfilled + constrained) column. ## Test plan - [ ] Run `database:migrate:prod --include-slow` on an instance with null `databaseSchema` rows; verify rows whose schema exists get backfilled and `PENDING_CREATION` rows are left null. - [ ] Verify the `workspace_requires_database_schema` constraint exists on `core.workspace` and rejects nulling `databaseSchema` on an active workspace. - [ ] Verify a fresh workspace creation still succeeds (constraint does not fight the `PENDING_CREATION` → `ACTIVE` transition). --- .../workspace-iterator.service.ts | 9 +++ ...7089-backfill-workspace-database-schema.ts | 55 +++++++++++++++++++ .../instance-commands.constant.ts | 2 + .../workspace/workspace.entity.ts | 4 ++ .../workspace-datasource.service.ts | 2 +- 5 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/2-21/2-21-instance-command-slow-1783934147089-backfill-workspace-database-schema.ts diff --git a/packages/twenty-server/src/database/commands/command-runners/workspace-iterator.service.ts b/packages/twenty-server/src/database/commands/command-runners/workspace-iterator.service.ts index ebd92496c5..5f761d9725 100644 --- a/packages/twenty-server/src/database/commands/command-runners/workspace-iterator.service.ts +++ b/packages/twenty-server/src/database/commands/command-runners/workspace-iterator.service.ts @@ -90,6 +90,15 @@ export class WorkspaceIteratorService { ? await this.globalWorkspaceOrmManager.getGlobalWorkspaceDataSource() : undefined; + if (!isDefined(dataSource)) { + this.logger.warn( + `Could not retrieve a workspace data source for workspace ${workspaceId} ` + + `(index ${index + 1}/${workspaceIdsToProcess.length}): ` + + `workspaceRowFound=${isDefined(workspace)}, ` + + `databaseSchema=${JSON.stringify(workspace?.databaseSchema ?? null)}`, + ); + } + await callback({ workspaceId, dataSource, diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/2-21/2-21-instance-command-slow-1783934147089-backfill-workspace-database-schema.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/2-21/2-21-instance-command-slow-1783934147089-backfill-workspace-database-schema.ts new file mode 100644 index 0000000000..9ae3ccab99 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/2-21/2-21-instance-command-slow-1783934147089-backfill-workspace-database-schema.ts @@ -0,0 +1,55 @@ +import { DataSource, QueryRunner } from 'typeorm'; + +import { RegisteredInstanceCommand } from 'src/engine/core-modules/upgrade/decorators/registered-instance-command.decorator'; +import { SlowInstanceCommand } from 'src/engine/core-modules/upgrade/interfaces/slow-instance-command.interface'; +import { getWorkspaceSchemaName } from 'src/engine/workspace-datasource/utils/get-workspace-schema-name.util'; + +@RegisteredInstanceCommand('2.21.0', 1783934147089, { type: 'slow' }) +export class BackfillWorkspaceDatabaseSchemaSlowInstanceCommand + implements SlowInstanceCommand +{ + // Schema names are deterministic from the workspace id, so we backfill them + // unconditionally — even if the schema doesn't exist yet in Postgres — which + // lets up() add the check constraint fully validated. + async runDataMigration(dataSource: DataSource): Promise { + const workspacesWithoutSchema: { id: string }[] = await dataSource.query( + `SELECT id FROM "core"."workspace" + WHERE ("databaseSchema" IS NULL OR "databaseSchema" = '') + AND "activationStatus" NOT IN ('PENDING_CREATION', 'ONGOING_CREATION')`, + ); + + if (workspacesWithoutSchema.length === 0) { + return; + } + + await dataSource.query( + `UPDATE "core"."workspace" AS w + SET "databaseSchema" = data.schema_name + FROM ( + SELECT UNNEST($1::uuid[]) AS id, UNNEST($2::text[]) AS schema_name + ) AS data + WHERE w.id = data.id`, + [ + workspacesWithoutSchema.map((workspace) => workspace.id), + workspacesWithoutSchema.map((workspace) => + getWorkspaceSchemaName(workspace.id), + ), + ], + ); + } + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP CONSTRAINT IF EXISTS "workspace_requires_database_schema"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."workspace" ADD CONSTRAINT "workspace_requires_database_schema" CHECK ("activationStatus" IN ('PENDING_CREATION', 'ONGOING_CREATION') OR ("databaseSchema" IS NOT NULL AND "databaseSchema" <> ''))`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."workspace" DROP CONSTRAINT IF EXISTS "workspace_requires_database_schema"`, + ); + } +} 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 b17594410d..c219c8da66 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 @@ -108,6 +108,7 @@ import { CreateWorkflowCoreTableFastInstanceCommand } from './2-20/2-20-instance import { AddGalleryImagesToApplicationRegistrationFastInstanceCommand } from './2-20/2-20-instance-command-fast-1783615890055-add-gallery-images-to-application-registration'; import { BackfillGalleryImagesOnApplicationRegistrationSlowInstanceCommand } from './2-20/2-20-instance-command-slow-1783615890056-backfill-gallery-images-on-application-registration'; import { AddWorkflowVersionSyncableColumnsFastInstanceCommand } from './2-20/2-20-instance-command-fast-1783603454480-add-workflow-version-syncable-columns'; +import { BackfillWorkspaceDatabaseSchemaSlowInstanceCommand } from './2-21/2-21-instance-command-slow-1783934147089-backfill-workspace-database-schema'; export const INSTANCE_COMMANDS = [ AddViewFieldGroupIdIndexOnViewFieldFastInstanceCommand, @@ -218,4 +219,5 @@ export const INSTANCE_COMMANDS = [ AddGalleryImagesToApplicationRegistrationFastInstanceCommand, BackfillGalleryImagesOnApplicationRegistrationSlowInstanceCommand, AddWorkflowVersionSyncableColumnsFastInstanceCommand, + BackfillWorkspaceDatabaseSchemaSlowInstanceCommand, ]; diff --git a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts index 8c2a42bd59..141e09abb8 100644 --- a/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts +++ b/packages/twenty-server/src/engine/core-modules/workspace/workspace.entity.ts @@ -67,6 +67,10 @@ registerEnumType(WorkspaceDiscoverability, { 'onboarded_workspace_requires_default_role', `"activationStatus" IN ('PENDING_CREATION', 'ONGOING_CREATION') OR "defaultRoleId" IS NOT NULL`, ) +@Check( + 'workspace_requires_database_schema', + `"activationStatus" IN ('PENDING_CREATION', 'ONGOING_CREATION') OR ("databaseSchema" IS NOT NULL AND "databaseSchema" <> '')`, +) @Entity({ name: 'workspace', schema: 'core' }) @ObjectType('Workspace') export class WorkspaceEntity { diff --git a/packages/twenty-server/src/engine/workspace-datasource/workspace-datasource.service.ts b/packages/twenty-server/src/engine/workspace-datasource/workspace-datasource.service.ts index 3cd4cafd84..7411b4bb78 100644 --- a/packages/twenty-server/src/engine/workspace-datasource/workspace-datasource.service.ts +++ b/packages/twenty-server/src/engine/workspace-datasource/workspace-datasource.service.ts @@ -37,7 +37,7 @@ export class WorkspaceDataSourceService { } } - public async checkSchemaExists(workspaceId: string) { + public async checkSchemaExists(workspaceId: string): Promise { const workspace = await this.workspaceRepository.findOne({ select: ['databaseSchema'], where: { id: workspaceId },