Skip to content

tooljet-db: two-phase DDL commits are non-atomic - first commit success with second commit failure leaves metadata and physical schema permanently out of sync #17497

Description

@harsh4vardhan

Bug Description

Every DDL operation in ooljet-db-table-operations.service.ts commits two independent transactions sequentially: one to the application database (TypeORM queryRunner) and one to the ToolJet Postgres schema ( jdbQueryRunner). If the first commit succeeds and the second fails, the system is permanently split: the metadata and the physical schema are out of sync with no automatic recovery.

Affected files

server/src/modules/tooljet-db/services/tooljet-db-table-operations.service.ts

createTable (lines 388-399):
s await queryRunner.commitTransaction(); // app DB: InternalTable row saved await tjdbQueryRunner.commitTransaction(); // TjDB: physical table - if this fails: // catch block: await queryRunner.rollbackTransaction(); // ERROR: already committed, rollback is a no-op await tjdbQueryRunner.rollbackTransaction();

After failure: InternalTable record exists (table appears in UI), but no physical table exists. All queries against it fail with cryptic Postgres errors.

dropTable (lines 452-457) has the symmetric problem: app DB record deleted, physical table survives.

The same pattern repeats at lines 693-700, 785-793, 851-858, 1275-1283, 1457-1473, and 1539-1547 for editTable, �ddColumn, dropColumn, editColumn, createForeignKey, updateForeignKey, and deleteForeignKey.

Failure scenario

  1. Admin creates a new ToolJet DB table.
  2. App DB commits the InternalTable record successfully.
  3. A network partition between app server and ToolJet Postgres host causes jdbQueryRunner.commitTransaction() to fail.
  4. The catch block calls queryRunner.rollbackTransaction() on an already-committed transaction - this fails or is a no-op.
  5. The table appears in the UI (metadata row committed) but is unusable (no physical table). Retry attempts fail because the InternalTable row already exists.

Fix

Apply a compensating-transaction pattern: catch the second commit's failure and attempt to undo the first commit's effect before re-throwing:

s await queryRunner.commitTransaction(); // commit app DB first try { await tjdbQueryRunner.commitTransaction(); } catch (err) { // Compensate: undo the app DB change await this.internalTableRepository.delete({ id: internalTable.id }); throw err; }

Environment

ToolJet main branch (2026-08-13), NestJS, TypeORM, PostgreSQL.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions