Merge pull request #10309 from ever-co/fix/release-batch-2

fix: role_permission unique index, prod snaps to stable, desktop updater quirks
This commit is contained in:
Ruslan Konviser
2026-09-26 01:16:58 +02:00
committed by GitHub
21 changed files with 1199 additions and 69 deletions
+8 -2
View File
@@ -213,7 +213,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Agent
run: 'yarn build:agent:linux:release:gh:x64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:agent:linux:release:gh:x64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=stable'
env:
USE_HARD_LINKS: false
GH_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -476,7 +479,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Agent
run: 'yarn build:agent:linux:release:gh:arm64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:agent:linux:release:gh:arm64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=stable'
env:
USE_HARD_LINKS: false
USE_SYSTEM_FPM: true
+8 -2
View File
@@ -217,7 +217,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Agent
run: 'yarn build:agent:linux:release:gh:x64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:agent:linux:release:gh:x64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=edge'
env:
USE_HARD_LINKS: false
GH_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -480,7 +483,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Agent
run: 'yarn build:agent:linux:release:gh:arm64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:agent:linux:release:gh:arm64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=edge'
env:
USE_HARD_LINKS: false
USE_SYSTEM_FPM: true
+8 -2
View File
@@ -213,7 +213,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Desktop App
run: 'yarn build:desktop:linux:release:gh:x64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:desktop:linux:release:gh:x64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=stable'
env:
USE_HARD_LINKS: false
GH_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -476,7 +479,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Desktop App
run: 'yarn build:desktop:linux:release:gh:arm64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:desktop:linux:release:gh:arm64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=stable'
env:
USE_HARD_LINKS: false
USE_SYSTEM_FPM: true
+8 -2
View File
@@ -218,7 +218,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Desktop App
run: 'yarn build:desktop:linux:release:gh:x64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:desktop:linux:release:gh:x64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=edge'
env:
USE_HARD_LINKS: false
GH_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -481,7 +484,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Desktop App
run: 'yarn build:desktop:linux:release:gh:arm64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:desktop:linux:release:gh:arm64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=edge'
env:
USE_HARD_LINKS: false
USE_SYSTEM_FPM: true
+8 -2
View File
@@ -213,7 +213,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Desktop Timer App
run: 'yarn build:desktop-timer:linux:release:gh:x64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:desktop-timer:linux:release:gh:x64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=stable'
env:
USE_HARD_LINKS: false
GH_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -476,7 +479,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Desktop Timer App
run: 'yarn build:desktop-timer:linux:release:gh:arm64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:desktop-timer:linux:release:gh:arm64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=stable'
env:
USE_HARD_LINKS: false
USE_SYSTEM_FPM: true
@@ -217,7 +217,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Desktop Timer App
run: 'yarn build:desktop-timer:linux:release:gh:x64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:desktop-timer:linux:release:gh:x64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=edge'
env:
USE_HARD_LINKS: false
GH_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -480,7 +483,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Desktop Timer App
run: 'yarn build:desktop-timer:linux:release:gh:arm64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:desktop-timer:linux:release:gh:arm64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=edge'
env:
USE_HARD_LINKS: false
USE_SYSTEM_FPM: true
+8 -2
View File
@@ -213,7 +213,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Server
run: 'yarn build:gauzy-api-server:linux:release:gh:x64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:gauzy-api-server:linux:release:gh:x64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=stable'
env:
USE_HARD_LINKS: false
GH_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -476,7 +479,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Server API
run: 'yarn build:gauzy-api-server:linux:release:gh:arm64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:gauzy-api-server:linux:release:gh:arm64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=stable'
env:
USE_HARD_LINKS: false
USE_SYSTEM_FPM: true
+8 -2
View File
@@ -218,7 +218,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Server
run: 'yarn build:gauzy-api-server:linux:release:gh:x64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:gauzy-api-server:linux:release:gh:x64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=edge'
env:
USE_HARD_LINKS: false
GH_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -481,7 +484,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Server API
run: 'yarn build:gauzy-api-server:linux:release:gh:arm64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:gauzy-api-server:linux:release:gh:arm64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=edge'
env:
USE_HARD_LINKS: false
USE_SYSTEM_FPM: true
+8 -2
View File
@@ -204,7 +204,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Server
run: 'yarn build:gauzy-mcp-server:linux:release:gh:x64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:gauzy-mcp-server:linux:release:gh:x64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=stable'
env:
USE_HARD_LINKS: false
GH_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -458,7 +461,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Server MCP
run: 'yarn build:gauzy-mcp-server:linux:release:gh:arm64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:gauzy-mcp-server:linux:release:gh:arm64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=stable'
env:
USE_HARD_LINKS: false
USE_SYSTEM_FPM: true
+8 -2
View File
@@ -209,7 +209,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Server
run: 'yarn build:gauzy-mcp-server:linux:release:gh:x64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:gauzy-mcp-server:linux:release:gh:x64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=edge'
env:
USE_HARD_LINKS: false
GH_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -463,7 +466,10 @@ jobs:
run: mkdir -p dist/packages
- name: Build Server MCP
run: 'yarn build:gauzy-mcp-server:linux:release:gh:arm64'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:gauzy-mcp-server:linux:release:gh:arm64 -c.snap.publish.provider=snapStore -c.snap.publish.channels=edge'
env:
USE_HARD_LINKS: false
USE_SYSTEM_FPM: true
+8 -2
View File
@@ -245,7 +245,10 @@ jobs:
NX_DAEMON: false
- name: Build Server App
run: 'yarn build:gauzy-server:linux:release:gh:x64:isolated'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:gauzy-server:linux:release:gh:x64:isolated -c.snap.publish.provider=snapStore -c.snap.publish.channels=stable'
env:
USE_HARD_LINKS: false
GH_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -542,7 +545,10 @@ jobs:
NX_DAEMON: false
- name: Build Server App
run: 'yarn build:gauzy-server:linux:release:gh:arm64:isolated'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:gauzy-server:linux:release:gh:arm64:isolated -c.snap.publish.provider=snapStore -c.snap.publish.channels=stable'
env:
USE_HARD_LINKS: false
USE_SYSTEM_FPM: true
+8 -2
View File
@@ -249,7 +249,10 @@ jobs:
NX_DAEMON: false
- name: Build Server App
run: 'yarn build:gauzy-server:linux:release:gh:x64:isolated'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:gauzy-server:linux:release:gh:x64:isolated -c.snap.publish.provider=snapStore -c.snap.publish.channels=edge'
env:
USE_HARD_LINKS: false
GH_TOKEN: ${{ secrets.GH_TOKEN }}
@@ -546,7 +549,10 @@ jobs:
NX_DAEMON: false
- name: Build Server App
run: 'yarn build:gauzy-server:linux:release:gh:arm64:isolated'
# Snap Store channel: prod ('apps') releases to 'stable', stage ('stage-apps') to 'edge' (the
# electron-builder default). yarn appends these flags to the script's last command, the
# electron-builder call. Override snap.publish only: a -c.publish override lands in the GitHub entry.
run: 'yarn build:gauzy-server:linux:release:gh:arm64:isolated -c.snap.publish.provider=snapStore -c.snap.publish.channels=edge'
env:
USE_HARD_LINKS: false
USE_SYSTEM_FPM: true
@@ -0,0 +1,280 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
import * as chalk from 'chalk';
import { DatabaseTypeEnum } from '@gauzy/config';
/**
* Make `role_permission` unique on (tenantId, roleId, permission).
*
* Nothing stopped a role from holding the same permission twice. The `*RolePermissionsReload*`
* migrations read which permissions a role lacks and then insert them, so two API processes running
* them at the same time against one database both saw a permission as missing and both inserted it.
* Any install that ever ran two instances through those migrations can hold millions of such
* duplicate rows.
* With the index in place `RolePermissionUtils` inserts with an ignore-duplicates clause, so that
* race now costs nothing.
*
* The index is simply built first. If that works there were no duplicates, and the build was the
* only pass over the table. If it fails on a duplicate key, the duplicates are removed and the index
* is built again. The clean-up keeps exactly ONE row per (tenantId, roleId, permission):
* - a row that actually grants the permission (enabled, active, not archived, not soft-deleted —
* the predicate `RolePermissionService.checkRolePermission` applies), so no role loses a
* permission it holds today;
* - otherwise a row the application can still see (not soft-deleted);
* - then an enabled row, then the oldest `createdAt`, then the smallest id, so the choice is
* deterministic.
* Rows with a NULL tenantId are left alone: every dialect treats NULLs as distinct in a unique index,
* so they can never violate it.
*
* Postgres. The index is built inside the migration's transaction, not CONCURRENTLY. A plain build
* blocks INSERT/UPDATE/DELETE on `role_permission` until the migration commits, but never SELECT, so
* the pods still serving keep answering permission checks. Measured on PostgreSQL 16 with default
* settings: 2,000,000 rows without duplicates hold the lock for about 5 s (a concurrent SELECT took
* 11 ms, a concurrent INSERT waited the 5 s), so roughly 25-30 s at production's ~9M rows. The table
* is written only when tenants, roles or permission settings change, and those requests wait that
* long once. CONCURRENTLY would avoid the pause, but it cannot run in a transaction, it first waits
* for every older transaction in the whole database, and any failure (a killed pod, a duplicate
* inserted mid-build) leaves an INVALID index behind that `IF NOT EXISTS` would accept as done. A
* plain build that fails simply rolls back and runs again on the next start.
*
* The lock is taken up front, SHARE ROW EXCLUSIVE: reads pass, writers wait, and so does a second
* copy of this migration. So no duplicate can slip in between the clean-up and the build, and two
* processes starting together (two replicas, or two API deployments sharing one database) cannot run
* it at the same time. `lock_timeout` keeps any wait short: the loser gives up after 5 s instead of
* queueing every writer behind it, fails that boot, and on restart finds the index in place.
*
* MySQL commits DDL on its own, so a crash cannot roll the index back: the index is looked up by name
* before it is built, and again when the build fails, so a retry — or a second process — never dies
* on `Duplicate key name`. SQLite runs the whole migration in its transaction.
*/
export class AddRolePermissionUniqueIndex1790000017000 implements MigrationInterface {
name = 'AddRolePermissionUniqueIndex1790000017000';
/** The name `RolePermission` declares for this index, so schema diffs stay quiet. */
private readonly indexName = 'IDX_role_permission_unique';
/**
* Up Migration
*
* @param queryRunner
*/
public async up(queryRunner: QueryRunner): Promise<void> {
console.log(chalk.yellow(`${this.name} start running!`));
const type = this.databaseType(queryRunner);
if (await this.indexExists(queryRunner, type)) {
console.log(chalk.green(`${this.name}: ${this.indexName} already exists, nothing to do`));
return;
}
if (type === DatabaseTypeEnum.postgres && queryRunner.isTransactionActive) {
await queryRunner.query(`SET LOCAL lock_timeout = '5s'`);
await queryRunner.query(`LOCK TABLE "role_permission" IN SHARE ROW EXCLUSIVE MODE`);
// Another process may have finished this migration while we waited for the lock.
if (await this.indexExists(queryRunner, type)) {
console.log(chalk.green(`${this.name}: ${this.indexName} was created meanwhile, nothing to do`));
return;
}
}
if (await this.tryCreateIndex(queryRunner, type)) {
return;
}
await this.removeDuplicates(queryRunner, type);
if (!(await this.tryCreateIndex(queryRunner, type))) {
throw new Error(`${this.name}: duplicate role permissions remain after the clean-up`);
}
}
/**
* Down Migration
*
* Drops the index only. The duplicate rows `up()` removed are not restored: they granted nothing
* the surviving row does not.
*
* @param queryRunner
*/
public async down(queryRunner: QueryRunner): Promise<void> {
console.log(chalk.yellow(`${this.name} reverting changes!`));
const type = this.databaseType(queryRunner);
switch (type) {
case DatabaseTypeEnum.postgres:
// DROP INDEX locks the table against reads too, so never queue behind a long transaction.
if (queryRunner.isTransactionActive) {
await queryRunner.query(`SET LOCAL lock_timeout = '5s'`);
}
await queryRunner.query(`DROP INDEX IF EXISTS "${this.indexName}"`);
break;
case DatabaseTypeEnum.mysql:
if (await this.indexExists(queryRunner, type)) {
await queryRunner.query(`DROP INDEX \`${this.indexName}\` ON \`role_permission\``);
}
break;
default:
await queryRunner.query(`DROP INDEX IF EXISTS "${this.indexName}"`);
break;
}
}
/** The connection's dialect, refusing the ones this migration has no SQL for. */
private databaseType(queryRunner: QueryRunner): DatabaseTypeEnum {
const type = queryRunner.connection.options.type as DatabaseTypeEnum;
const supported = [
DatabaseTypeEnum.sqlite,
DatabaseTypeEnum.betterSqlite3,
DatabaseTypeEnum.postgres,
DatabaseTypeEnum.mysql
];
if (!supported.includes(type)) {
throw new Error(`Unsupported database: ${type}`);
}
return type;
}
/** Writes identifiers with MySQL's backticks instead of double quotes. */
private sql(type: DatabaseTypeEnum, text: string): string {
return type === DatabaseTypeEnum.mysql ? text.replace(/"/g, '`') : text;
}
/**
* Whether the index is already in place.
*
* On Postgres an index of that name that is not a valid unique index (an interrupted CREATE INDEX
* CONCURRENTLY run by hand leaves an INVALID one) is refused rather than accepted: treating it as
* done would record the migration with no uniqueness enforced, and dropping it here would lock the
* table against reads until the migration commits.
*/
private async indexExists(queryRunner: QueryRunner, type: DatabaseTypeEnum): Promise<boolean> {
switch (type) {
case DatabaseTypeEnum.postgres: {
const rows: Array<{ valid: boolean; unique: boolean }> = await queryRunner.query(
`SELECT i."indisvalid" AS "valid", i."indisunique" AS "unique" FROM "pg_index" i WHERE i."indexrelid" = to_regclass($1)`,
[`"${this.indexName}"`]
);
if (!rows?.length) {
return false;
}
if (!rows[0].valid || !rows[0].unique) {
throw new Error(
`${this.name}: index "${this.indexName}" exists but is not a valid unique index. ` +
`Remove it with DROP INDEX CONCURRENTLY "${this.indexName}" and restart.`
);
}
return true;
}
case DatabaseTypeEnum.mysql: {
const rows: unknown[] = await queryRunner.query(
`SELECT \`INDEX_NAME\` FROM \`information_schema\`.\`STATISTICS\` WHERE \`TABLE_SCHEMA\` = DATABASE() AND \`TABLE_NAME\` = 'role_permission' AND \`INDEX_NAME\` = ?`,
[this.indexName]
);
return !!rows?.length;
}
default: {
const rows: unknown[] = await queryRunner.query(
`SELECT "name" FROM "sqlite_master" WHERE "type" = 'index' AND "tbl_name" = 'role_permission' AND "name" = ?`,
[this.indexName]
);
return !!rows?.length;
}
}
}
/**
* Builds the unique index. Returns false, having changed nothing, when duplicates stop the build.
*
* A failed statement aborts the whole transaction on Postgres, so there the build runs under a
* savepoint that is rolled back on failure; SQLite undoes just the failed statement, and MySQL
* commits DDL on its own anyway.
*/
private async tryCreateIndex(queryRunner: QueryRunner, type: DatabaseTypeEnum): Promise<boolean> {
const savepoint = type === DatabaseTypeEnum.postgres && queryRunner.isTransactionActive;
if (savepoint) {
await queryRunner.query(`SAVEPOINT "role_permission_unique"`);
}
try {
await queryRunner.query(
this.sql(
type,
`CREATE UNIQUE INDEX "${this.indexName}" ON "role_permission" ("tenantId", "roleId", "permission")`
)
);
} catch (error) {
if (savepoint) {
await queryRunner.query(`ROLLBACK TO SAVEPOINT "role_permission_unique"`);
}
if (this.isDuplicateKeyError(error)) {
return false;
}
// MySQL: another process built it between our lookup and our build.
if (type === DatabaseTypeEnum.mysql && (await this.indexExists(queryRunner, type))) {
console.log(chalk.green(`${this.name}: ${this.indexName} was created meanwhile, nothing to do`));
return true;
}
throw error;
}
if (savepoint) {
await queryRunner.query(`RELEASE SAVEPOINT "role_permission_unique"`);
}
return true;
}
/**
* A unique violation, as each driver reports it: Postgres 23505; MySQL 1062, or 1859 when an online
* index build cannot name the duplicate value; SQLite a UNIQUE constraint failure.
*/
private isDuplicateKeyError(error: any): boolean {
const code = String(error?.code ?? error?.driverError?.code ?? '');
return (
code === '23505' ||
code === 'ER_DUP_ENTRY' ||
code === 'ER_DUP_UNKNOWN_IN_INDEX' ||
(code.startsWith('SQLITE_CONSTRAINT') && /UNIQUE/i.test(String(error?.message)))
);
}
/**
* Deletes every duplicate (tenantId, roleId, permission) row but the one to keep (see the class
* comment for the order) in one statement, and logs how many rows went. It runs only after a build
* has failed on a duplicate, and on Postgres while writers are locked out, so no extra pass over the
* table is spent counting first.
*/
private async removeDuplicates(queryRunner: QueryRunner, type: DatabaseTypeEnum): Promise<void> {
// `true`/`false` literals, not 1/0: Postgres has real booleans, and MySQL and SQLite read
// TRUE/FALSE as 1/0. The derived table is what lets MySQL delete from the table it reads.
const { affected } = await queryRunner.query(
this.sql(
type,
`DELETE FROM "role_permission" WHERE "id" IN (
SELECT "id" FROM (
SELECT "id", ROW_NUMBER() OVER (
PARTITION BY "tenantId", "roleId", "permission"
ORDER BY
CASE
WHEN "deletedAt" IS NOT NULL THEN 2
WHEN "enabled" = true AND "isActive" = true AND "isArchived" = false THEN 0
ELSE 1
END,
CASE WHEN "enabled" = true THEN 0 ELSE 1 END,
"createdAt",
"id"
) AS "rowNumber"
FROM "role_permission"
WHERE "tenantId" IS NOT NULL
) AS "ranked"
WHERE "rowNumber" > 1
)`
),
[],
true
);
console.log(
chalk.magenta(
`${this.name}: removed ${affected ?? 'an unknown number of'} duplicate role permission row(s), ` +
`keeping one row per (tenantId, roleId, permission)`
)
);
}
}
@@ -0,0 +1,405 @@
// cspell:words SAVEPOINT KEYNAME
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { DataSource } from 'typeorm';
import { AddRolePermissionUniqueIndex1790000017000 } from './migrations/1790000017000-AddRolePermissionUniqueIndex';
/**
* `role_permission` becomes unique on (tenantId, roleId, permission), after its duplicates are
* removed without taking a permission away from any role.
*
* The SQLite branch runs for real, on the `role_permission` DDL the migration chain leaves behind
* (the last SQLite rebuild of the table, in the `deletedByUserId` migration), read from that
* migration's source so the fixture cannot drift from the chain. MySQL and Postgres need a server,
* so their branches run against stub query runners that check the statements issued and simulate
* what the server answers; the Postgres branch was also run against a real PostgreSQL 16.
*/
const LAST_SQLITE_ROLE_PERMISSION_MIGRATION = '1740811220961-AlterBaseEntityAddDeletedByUserIdColumn.ts';
const INDEX = 'IDX_role_permission_unique';
/** The `role_permission` CREATE TABLE and its indexes exactly as the migration chain leaves them. */
function currentSqliteRolePermissionSchema(): { table: string; indexes: string[] } {
const source = fs.readFileSync(path.join(__dirname, 'migrations', LAST_SQLITE_ROLE_PERMISSION_MIGRATION), 'utf8');
const up = source.slice(
source.indexOf('public async sqliteUpQueryRunner'),
source.indexOf('public async sqliteDownQueryRunner')
);
const tables = up.match(/CREATE TABLE "temporary_role_permission" \([^`]*\)/g) ?? [];
const table = tables[tables.length - 1].replace('"temporary_role_permission"', '"role_permission"');
const indexes = up.match(/CREATE INDEX "[^"]+" ON "role_permission" \([^)]*\)/g) ?? [];
return { table, indexes: [...new Set(indexes)] };
}
const TENANT_A = 'a0000000-0000-4000-8000-000000000001';
const TENANT_B = 'a0000000-0000-4000-8000-000000000002';
const ROLE_A = 'b0000000-0000-4000-8000-000000000001';
const ROLE_B = 'b0000000-0000-4000-8000-000000000002';
const id = (n: number) => `c0000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
/** [id, tenantId, roleId, permission, enabled, isActive, isArchived, deletedAt, createdAt] */
type Row = [number, string | null, string, string, number | null, number, number, string | null, string];
/**
* One duplicate group per rule of the order the migration keeps rows in, plus rows it must not
* touch. `KEPT` lists the one row each group must end with.
*/
const ROWS: Row[] = [
// Only the NEWEST of three copies is enabled: keep it, or the role loses the permission.
[1, TENANT_A, ROLE_A, 'P1', 0, 1, 0, null, '2026-01-01 00:00:00'],
[2, TENANT_A, ROLE_A, 'P1', 0, 1, 0, null, '2026-01-02 00:00:00'],
[3, TENANT_A, ROLE_A, 'P1', 1, 1, 0, null, '2026-01-03 00:00:00'],
// Nothing enabled: keep the oldest.
[5, TENANT_A, ROLE_A, 'P2', 0, 1, 0, null, '2026-02-02 00:00:00'],
[4, TENANT_A, ROLE_A, 'P2', 0, 1, 0, null, '2026-02-01 00:00:00'],
// An older ARCHIVED enabled row grants nothing; the live enabled one does.
[6, TENANT_A, ROLE_A, 'P3', 1, 1, 1, null, '2026-03-01 00:00:00'],
[7, TENANT_A, ROLE_A, 'P3', 1, 1, 0, null, '2026-03-02 00:00:00'],
// An older SOFT-DELETED enabled row is invisible to the app; keep the row it can see.
[8, TENANT_A, ROLE_A, 'P4', 1, 1, 0, '2026-04-05 00:00:00', '2026-04-01 00:00:00'],
[9, TENANT_A, ROLE_A, 'P4', 0, 1, 0, null, '2026-04-02 00:00:00'],
// Same createdAt: keep the smallest id.
[11, TENANT_A, ROLE_A, 'P5', 1, 1, 0, null, '2026-05-01 00:00:00'],
[10, TENANT_A, ROLE_A, 'P5', 1, 1, 0, null, '2026-05-01 00:00:00'],
// An older INACTIVE enabled row grants nothing; the active one does.
[12, TENANT_A, ROLE_A, 'P6', 1, 0, 0, null, '2026-06-01 00:00:00'],
[13, TENANT_A, ROLE_A, 'P6', 1, 1, 0, null, '2026-06-02 00:00:00'],
// Not duplicates: same permission for another role, and in another tenant.
[20, TENANT_A, ROLE_B, 'P1', 0, 1, 0, null, '2026-01-01 00:00:00'],
[21, TENANT_B, ROLE_A, 'P1', 1, 1, 0, null, '2026-01-01 00:00:00'],
// A NULL tenant never violates a unique index, so these copies stay.
[30, null, ROLE_A, 'P1', 0, 1, 0, null, '2026-01-01 00:00:00'],
[31, null, ROLE_A, 'P1', 0, 1, 0, null, '2026-01-02 00:00:00']
];
const KEPT = [3, 4, 7, 9, 10, 13, 20, 21, 30, 31].map(id).sort();
/** Permissions a role is actually granted: what `RolePermissionService.checkRolePermission` counts. */
const GRANTS_SQL = `SELECT DISTINCT "tenantId", "roleId", "permission" FROM "role_permission"
WHERE "enabled" = 1 AND "isActive" = 1 AND "isArchived" = 0 AND "deletedAt" IS NULL
ORDER BY "tenantId", "roleId", "permission"`;
describe('AddRolePermissionUniqueIndex (SQLite)', () => {
let dbPath: string;
let dataSource: DataSource;
const insert = (tenantId: string | null, roleId: string, permission: string, rowId: string) =>
dataSource.query(
`INSERT INTO "role_permission" ("id", "tenantId", "roleId", "permission") VALUES (?, ?, ?, ?)`,
[rowId, tenantId, roleId, permission]
);
const indexNames = async (): Promise<string[]> =>
(
await dataSource.query(
`SELECT "name" FROM "sqlite_master" WHERE "type" = 'index' AND "tbl_name" = 'role_permission' AND "sql" IS NOT NULL ORDER BY "name"`
)
).map((index: { name: string }) => index.name);
const ids = async (): Promise<string[]> =>
(await dataSource.query(`SELECT "id" FROM "role_permission" ORDER BY "id"`)).map(
(row: { id: string }) => row.id
);
beforeEach(async () => {
dbPath = path.join(
os.tmpdir(),
`gauzy-role-permission-unique-${process.pid}-${Date.now()}-${Math.random()}.sqlite3`
);
dataSource = new DataSource({
type: 'better-sqlite3',
database: dbPath,
migrations: [AddRolePermissionUniqueIndex1790000017000],
synchronize: false,
logging: false
});
await dataSource.initialize();
const { table, indexes } = currentSqliteRolePermissionSchema();
for (const referenced of ['tenant', 'role', 'user']) {
await dataSource.query(`CREATE TABLE "${referenced}" ("id" varchar PRIMARY KEY NOT NULL)`);
}
await dataSource.query(`INSERT INTO "tenant" ("id") VALUES (?), (?)`, [TENANT_A, TENANT_B]);
await dataSource.query(`INSERT INTO "role" ("id") VALUES (?), (?)`, [ROLE_A, ROLE_B]);
await dataSource.query(table);
for (const index of indexes) {
await dataSource.query(index);
}
for (const [n, tenantId, roleId, permission, enabled, isActive, isArchived, deletedAt, createdAt] of ROWS) {
await dataSource.query(
`INSERT INTO "role_permission" ("id", "tenantId", "roleId", "permission", "enabled", "isActive", "isArchived", "deletedAt", "createdAt")
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id(n), tenantId, roleId, permission, enabled, isActive, isArchived, deletedAt, createdAt]
);
}
});
afterEach(async () => {
if (dataSource?.isInitialized) {
await dataSource.destroy();
}
fs.rmSync(dbPath, { force: true });
});
it('control: before the migration the table accepts a duplicate', async () => {
await expect(insert(TENANT_A, ROLE_A, 'P2', id(99))).resolves.toBeDefined();
});
it('keeps exactly one row per group, choosing the one that grants the permission', async () => {
const grantsBefore = await dataSource.query(GRANTS_SQL);
await dataSource.runMigrations({ transaction: 'each' });
expect(await ids()).toEqual(KEPT);
// No role gained or lost a permission.
expect(await dataSource.query(GRANTS_SQL)).toEqual(grantsBefore);
});
it('then refuses a duplicate, but not a copy with a NULL tenant', async () => {
await dataSource.runMigrations({ transaction: 'each' });
await expect(insert(TENANT_A, ROLE_A, 'P2', id(99))).rejects.toThrow(/UNIQUE constraint failed/);
await expect(insert(null, ROLE_A, 'P1', id(98))).resolves.toBeDefined();
// Another role or tenant may still hold the same permission.
await expect(insert(TENANT_B, ROLE_B, 'P2', id(97))).resolves.toBeDefined();
});
it('adds only the unique index, and running it again changes nothing', async () => {
const indexesBefore = await indexNames();
await dataSource.runMigrations({ transaction: 'each' });
expect(await indexNames()).toEqual([...indexesBefore, INDEX].sort());
const queryRunner = dataSource.createQueryRunner();
await queryRunner.startTransaction();
await new AddRolePermissionUniqueIndex1790000017000().up(queryRunner);
await queryRunner.commitTransaction();
await queryRunner.release();
expect(await indexNames()).toEqual([...indexesBefore, INDEX].sort());
expect(await ids()).toEqual(KEPT);
});
it('leaves the table untouched when its transaction is rolled back', async () => {
const queryRunner = dataSource.createQueryRunner();
await queryRunner.startTransaction();
await new AddRolePermissionUniqueIndex1790000017000().up(queryRunner);
await queryRunner.rollbackTransaction();
await queryRunner.release();
expect(await ids()).toEqual(ROWS.map(([n]) => id(n)).sort());
expect(await indexNames()).not.toContain(INDEX);
});
it('reverting drops the unique index and nothing else', async () => {
const indexesBefore = await indexNames();
await dataSource.runMigrations({ transaction: 'each' });
await dataSource.undoLastMigration({ transaction: 'each' });
expect(await indexNames()).toEqual(indexesBefore);
expect(await ids()).toEqual(KEPT);
await expect(insert(TENANT_A, ROLE_A, 'P2', id(99))).resolves.toBeDefined();
});
});
/** A stub query runner: records every statement, answers from `respond`. */
function stubRunner(type: 'mysql' | 'postgres', respond: (sql: string) => unknown) {
const statements: string[] = [];
const runner = {
connection: { options: { type } },
isTransactionActive: true,
query: async (sql: string) => {
statements.push(sql.replace(/\s+/g, ' ').trim());
return respond(sql);
}
};
return { runner: runner as any, statements };
}
/** The error a driver throws when a unique index cannot be built over duplicate rows. */
const duplicateError = (code: string) => Object.assign(new Error('duplicate key'), { code });
describe('AddRolePermissionUniqueIndex (MySQL)', () => {
/** A MySQL stub: `built` says whether the index exists, `duplicates` whether the build fails. */
const mysql = (state: { built: boolean; duplicates: boolean }) =>
stubRunner('mysql', (sql) => {
if (sql.includes('information_schema')) {
return state.built ? [{ INDEX_NAME: INDEX }] : [];
}
if (sql.startsWith('CREATE UNIQUE INDEX')) {
if (state.duplicates) {
throw duplicateError('ER_DUP_ENTRY');
}
state.built = true;
}
if (sql.startsWith('DROP INDEX')) {
state.built = false;
}
if (sql.startsWith('DELETE')) {
state.duplicates = false;
return { records: [], affected: 2 };
}
return [];
});
it('builds the index directly when there are no duplicates, without reading for them', async () => {
const state = { built: false, duplicates: false };
const { runner, statements } = mysql(state);
await new AddRolePermissionUniqueIndex1790000017000().up(runner);
expect(statements.filter((sql) => !sql.includes('information_schema'))).toEqual([
'CREATE UNIQUE INDEX `IDX_role_permission_unique` ON `role_permission` (`tenantId`, `roleId`, `permission`)'
]);
expect(state.built).toBe(true);
});
it('removes duplicates and builds again when the first build hits one', async () => {
const state = { built: false, duplicates: true };
const { runner, statements } = mysql(state);
await new AddRolePermissionUniqueIndex1790000017000().up(runner);
const issued = statements.filter((sql) => !sql.includes('information_schema'));
expect(issued.map((sql) => sql.split(' ').slice(0, 2).join(' '))).toEqual([
'CREATE UNIQUE',
'DELETE FROM',
'CREATE UNIQUE'
]);
// MySQL reads a double-quoted name as a string: `ORDER BY "createdAt"` would silently sort by a
// constant. Every identifier must reach it in backticks.
for (const sql of issued) {
expect(sql).not.toContain('"');
}
expect(issued[1]).toContain(
'ROW_NUMBER() OVER ( PARTITION BY `tenantId`, `roleId`, `permission` ORDER BY CASE WHEN `deletedAt` IS NOT NULL THEN 2'
);
expect(state.built).toBe(true);
});
it('does nothing when the index is already there (a retry after a crash)', async () => {
const { runner, statements } = mysql({ built: true, duplicates: false });
await new AddRolePermissionUniqueIndex1790000017000().up(runner);
expect(statements.filter((sql) => !sql.includes('information_schema'))).toEqual([]);
});
it('counts a build lost to another process as done', async () => {
const state = { built: false, duplicates: false };
const { runner } = stubRunner('mysql', (sql) => {
if (sql.includes('information_schema')) {
return state.built ? [{ INDEX_NAME: INDEX }] : [];
}
if (sql.startsWith('CREATE UNIQUE INDEX')) {
state.built = true; // the other process's build lands first
throw duplicateError('ER_DUP_KEYNAME');
}
return [];
});
await expect(new AddRolePermissionUniqueIndex1790000017000().up(runner)).resolves.toBeUndefined();
});
it('reverting drops the index when present, and is a no-op otherwise', async () => {
const present = mysql({ built: true, duplicates: false });
await new AddRolePermissionUniqueIndex1790000017000().down(present.runner);
expect(present.statements).toContain('DROP INDEX `IDX_role_permission_unique` ON `role_permission`');
const absent = mysql({ built: false, duplicates: false });
await new AddRolePermissionUniqueIndex1790000017000().down(absent.runner);
expect(absent.statements.some((sql) => sql.startsWith('DROP'))).toBe(false);
});
});
describe('AddRolePermissionUniqueIndex (Postgres)', () => {
/** A Postgres stub; `index` is what `pg_index` reports for the index name, if anything. */
const postgres = (state: { index?: { valid: boolean; unique: boolean }; duplicates: boolean }) =>
stubRunner('postgres', (sql) => {
if (sql.includes('"pg_index"')) {
return state.index ? [state.index] : [];
}
if (sql.startsWith('CREATE UNIQUE INDEX')) {
if (state.duplicates) {
throw duplicateError('23505');
}
state.index = { valid: true, unique: true };
}
if (sql.startsWith('DELETE')) {
state.duplicates = false;
return { records: [], affected: 2 };
}
return [];
});
const withoutLookups = (statements: string[]) => statements.filter((sql) => !sql.includes('"pg_index"'));
it('locks writers out first, then builds under a savepoint', async () => {
const { runner, statements } = postgres({ duplicates: false });
await new AddRolePermissionUniqueIndex1790000017000().up(runner);
expect(withoutLookups(statements)).toEqual([
`SET LOCAL lock_timeout = '5s'`,
`LOCK TABLE "role_permission" IN SHARE ROW EXCLUSIVE MODE`,
`SAVEPOINT "role_permission_unique"`,
`CREATE UNIQUE INDEX "${INDEX}" ON "role_permission" ("tenantId", "roleId", "permission")`,
`RELEASE SAVEPOINT "role_permission_unique"`
]);
});
it('rolls the failed build back to the savepoint, removes duplicates and builds again', async () => {
const { runner, statements } = postgres({ duplicates: true });
await new AddRolePermissionUniqueIndex1790000017000().up(runner);
expect(withoutLookups(statements).map((sql) => sql.split(' ').slice(0, 2).join(' '))).toEqual([
'SET LOCAL',
'LOCK TABLE',
'SAVEPOINT "role_permission_unique"',
'CREATE UNIQUE',
'ROLLBACK TO',
'DELETE FROM',
'SAVEPOINT "role_permission_unique"',
'CREATE UNIQUE',
'RELEASE SAVEPOINT'
]);
});
it('does nothing, and takes no lock, when the index already exists', async () => {
const { runner, statements } = postgres({ index: { valid: true, unique: true }, duplicates: false });
await new AddRolePermissionUniqueIndex1790000017000().up(runner);
expect(withoutLookups(statements)).toEqual([]);
});
it('refuses an INVALID index of that name instead of recording the migration as done', async () => {
const { runner } = postgres({ index: { valid: false, unique: true }, duplicates: false });
await expect(new AddRolePermissionUniqueIndex1790000017000().up(runner)).rejects.toThrow(
/not a valid unique index.*DROP INDEX CONCURRENTLY/
);
});
it('does not swallow an error that is not a duplicate key', async () => {
const { runner } = stubRunner('postgres', (sql) => {
if (sql.startsWith('CREATE UNIQUE INDEX')) {
throw Object.assign(new Error('canceling statement due to lock timeout'), { code: '55P03' });
}
return [];
});
await expect(new AddRolePermissionUniqueIndex1790000017000().up(runner)).rejects.toThrow(/lock timeout/);
});
it('reverting drops only the index, without queueing behind a long transaction', async () => {
const { runner, statements } = postgres({ index: { valid: true, unique: true }, duplicates: false });
await new AddRolePermissionUniqueIndex1790000017000().down(runner);
expect(statements).toEqual([`SET LOCAL lock_timeout = '5s'`, `DROP INDEX IF EXISTS "${INDEX}"`]);
});
});
@@ -6,6 +6,17 @@ import { Role, TenantBaseEntity } from '../core/entities/internal';
import { ColumnIndex, MultiORMColumn, MultiORMEntity, MultiORMManyToOne } from './../core/decorators/entity';
import { MikroOrmRolePermissionRepository } from './repository/mikro-orm-role-permission.repository';
/**
* A role holds each permission once: `(tenantId, roleId, permission)` is UNIQUE.
*
* Without it, two processes reloading the default permissions at the same time each inserted the
* ones they saw missing, and production collected millions of duplicate rows. The index (created by
* `AddRolePermissionUniqueIndex1790000017000`) makes that impossible; the batched reload inserts
* with an ignore-duplicates clause, so losing that race is harmless.
*/
@ColumnIndex('IDX_role_permission_unique', ['tenantId', 'roleId', 'permission'], {
unique: true
})
@MultiORMEntity('role_permission', { mikroOrmRepository: () => MikroOrmRolePermissionRepository })
export class RolePermission extends TenantBaseEntity implements IRolePermission {
@@ -81,8 +81,11 @@ interface Recorded {
/**
* Build an in-memory database seeded with tenants and roles, and record every statement the
* migration issues so the test can assert on round-trip COUNT, not only on the final rows.
*
* `uniqueIndex` adds the (tenantId, roleId, permission) unique index that
* `AddRolePermissionUniqueIndex1790000017000` creates on real databases.
*/
async function createSeededDataSource(): Promise<{
async function createSeededDataSource({ uniqueIndex = false } = {}): Promise<{
dataSource: DataSource;
queryRunner: QueryRunner;
recorded: Recorded[];
@@ -97,6 +100,12 @@ async function createSeededDataSource(): Promise<{
await dataSource.initialize();
if (uniqueIndex) {
await dataSource.manager.query(
`CREATE UNIQUE INDEX "IDX_role_permission_unique" ON "role_permission" ("tenantId", "roleId", "permission")`
);
}
for (let t = 0; t < TENANT_COUNT; t++) {
const tenantId = `tenant-${t}`;
await dataSource.manager.query(`INSERT INTO "tenant" ("id", "name") VALUES (?, ?)`, [tenantId, `Tenant ${t}`]);
@@ -271,4 +280,56 @@ describe('RolePermissionUtils.migrateRolePermissions', () => {
await dataSource.destroy();
});
});
describe('two processes reloading at the same time', () => {
/**
* The race that filled production with duplicates: another process inserts a role's missing
* permissions after this one has read them as missing. With the unique index in place the
* second insert must skip those rows. Failing instead is worse than it looks: the reload
* migrations catch the error and move on, so every tenant after this one would silently miss
* its new permissions.
*/
it('skips rows the other process inserted first, and leaves them as they were', async () => {
const { dataSource, queryRunner } = await createSeededDataSource({ uniqueIndex: true });
// The other process gets there first.
await RolePermissionUtils.migrateRolePermissions(queryRunner);
const roleId = `tenant-0-${RolesEnum.EMPLOYEE}`;
const defaults = DEFAULT_ROLE_PERMISSIONS.find((entry) => entry.role === RolesEnum.EMPLOYEE);
const enabledByDefault = (defaults?.defaultEnabledPermissions ?? [])[0] as string;
await dataSource.manager.query(
`UPDATE "role_permission" SET "enabled" = 0 WHERE "roleId" = ? AND "permission" = ?`,
[roleId, enabledByDefault]
);
// CONTROL: the fixture enforces the index, so a plain duplicate insert is refused. Without
// this, the pass below could come from a table that accepts duplicates.
await expect(
dataSource.manager.query(
`INSERT INTO "role_permission" ("id", "tenantId", "roleId", "permission", "enabled") VALUES (?, ?, ?, ?, ?)`,
['duplicate-probe', 'tenant-0', roleId, enabledByDefault, 1]
)
).rejects.toThrow(/UNIQUE constraint failed/);
// This process read before the other one inserted, so it still sees every permission missing.
jest.spyOn(RolePermissionUtils as any, 'getExistingPermissions').mockResolvedValue(new Set<string>());
await expect(RolePermissionUtils.migrateRolePermissions(queryRunner)).resolves.toBeUndefined();
const [{ total }] = await dataSource.manager.query(`SELECT COUNT(*) AS total FROM "role_permission"`);
expect(Number(total)).toBe(EXPECTED_ROLE_COUNT * ALL_PERMISSIONS.length);
// The existing row was skipped, not overwritten: the operator's change survives.
const rows = await dataSource.manager.query(
`SELECT "enabled" FROM "role_permission" WHERE "roleId" = ? AND "permission" = ?`,
[roleId, enabledByDefault]
);
expect(rows).toHaveLength(1);
expect(Number(rows[0].enabled)).toBe(0);
jest.restoreAllMocks();
await dataSource.destroy();
});
});
});
+11 -1
View File
@@ -163,6 +163,14 @@ export class RolePermissionUtils {
: `("tenantId", "roleId", "permission", "enabled")`;
const columnsPerRow = needsExplicitId ? 5 : 4;
// `role_permission` is unique on (tenantId, roleId, permission). Another process running this
// same reload can insert a row between our read and our insert; skip that row instead of failing,
// which would leave every tenant after this one without its new permissions. Only duplicates are
// skipped: Postgres and SQLite (3.24+) share the ON CONFLICT form, and MySQL's no-op update is
// its equivalent (INSERT IGNORE would also hide other errors).
const onDuplicate =
dbType === DatabaseTypeEnum.mysql ? ` ON DUPLICATE KEY UPDATE "id" = "id"` : ` ON CONFLICT DO NOTHING`;
// Keep each statement well inside the strictest driver's bind-parameter ceiling
// (SQLite defaults to 999 in older builds).
const maxRowsPerStatement = Math.max(1, Math.floor(900 / columnsPerRow));
@@ -186,7 +194,9 @@ export class RolePermissionUtils {
values.push(...payload);
}
let query = p(`INSERT INTO "role_permission" ${columns} VALUES ${placeholderGroups.join(', ')}`);
let query = p(
`INSERT INTO "role_permission" ${columns} VALUES ${placeholderGroups.join(', ')}${onDuplicate}`
);
query = replacePlaceholders(query, dbType);
await queryRunner.dataSource.manager.query(query, values);
@@ -21,7 +21,7 @@ export class AutomaticUpdate {
*/
constructor(context: UpdateContext, settingWindow: BrowserWindow) {
this._context = context;
this._delay = 1; // Default value is 1 hour
this._delay = null; // Hours set from the settings page; until then the stored setting, or 1 hour
this._intervalId = null;
this._window = settingWindow;
}
@@ -64,8 +64,10 @@ export class AutomaticUpdate {
*/
public get delay(): number {
const setting = LocalStore.getStore('appSetting');
this._delay = setting?.automaticUpdateDelay ? setting?.automaticUpdateDelay : 1;
return moment.duration(this._delay, 'hours').asMilliseconds();
// The delay last sent from the settings page, else the stored one (e.g. at startup), else 1 hour.
const hours =
[this._delay, setting?.automaticUpdateDelay].map(Number).find((value) => this._isValidDelay(value)) ?? 1;
return moment.duration(hours, 'hours').asMilliseconds();
}
public set delay(value: number) {
@@ -78,4 +80,12 @@ export class AutomaticUpdate {
const setting = LocalStore.getStore('appSetting');
return setting?.automaticUpdate == true;
}
/**
* A positive number of hours that setInterval can hold: a longer delay overflows its 32-bit limit
* and would fire every millisecond.
*/
private _isValidDelay(hours: number): boolean {
return hours > 0 && moment.duration(hours, 'hours').asMilliseconds() <= 2 ** 31 - 1;
}
}
@@ -8,6 +8,9 @@ import { CdnUpdate } from './strategies/concretes/cdn-update';
* limit) used to produce the tag `111.44.45` instead of `v111.44.45`, a 404 feed, and the tag was only
* looked up at startup, so a long-running app never saw a newer release.
*
* Since each check can now find a newer release, the "new version available" dialog and the automatic
* update setting from the settings page are covered here too.
*
* Electron cannot run here, so electron, electron-updater and node-fetch are mocked.
*/
jest.mock(
@@ -22,24 +25,45 @@ jest.mock(
jest.mock(
'electron-updater',
() => {
const autoUpdater: any = {
const { EventEmitter } = jest.requireActual('events');
const autoUpdater: any = Object.assign(new EventEmitter(), {
checked: [],
downloaded: [],
setFeedURL: jest.fn(),
on: jest.fn(),
once: jest.fn()
};
setFeedURL: jest.fn()
});
const feedUrl = () => autoUpdater.setFeedURL.mock.calls.at(-1)[0].url;
autoUpdater.checkForUpdates = jest.fn(async () => autoUpdater.checked.push(feedUrl()));
autoUpdater.checkForUpdatesAndNotify = jest.fn(async () => autoUpdater.downloaded.push(feedUrl()));
// Like electron-updater, a check whose feed holds a version other than the running one announces it.
const announce = () => {
const version = /\/download\/v(.+)$/.exec(feedUrl())?.[1];
if (version && version !== '111.44.45') autoUpdater.emit('update-available', { version });
};
autoUpdater.checkForUpdates = jest.fn(async () => {
autoUpdater.checked.push(feedUrl());
announce();
});
autoUpdater.checkForUpdatesAndNotify = jest.fn(async () => {
autoUpdater.downloaded.push(feedUrl());
announce();
});
return { autoUpdater, CancellationToken: class {} };
},
{ virtual: true }
);
jest.mock('node-fetch', () => ({ __esModule: true, default: jest.fn() }), { virtual: true });
jest.mock('./desktop-store', () => ({ LocalStore: { getStore: jest.fn() } }));
jest.mock('./desktop-notifier', () => ({ __esModule: true, default: class {} }));
jest.mock('./translation', () => ({ TranslateService: { instant: (key: string) => key } }));
jest.mock('./desktop-notifier', () => {
const customNotification = jest.fn();
return {
__esModule: true,
default: class {
customNotification = customNotification;
},
customNotification
};
});
jest.mock('./translation', () => ({
TranslateService: { instant: (key: string, params?: object) => (params ? `${key} ${JSON.stringify(params)}` : key) }
}));
jest.mock('./config', () => ({ LOCAL_SERVER_UPDATE_CONFIG: { PORT: 11999 } }));
jest.mock('./desktop-dialog', () => ({ DesktopDialog: class {} }));
jest.mock('./update-server/desktop-local-update-server', () => ({
@@ -59,7 +83,7 @@ jest.mock('./strategies', () => ({
}));
jest.mock('./decorators', () => {
class Dialog {
options = {};
options: any = {};
async show() {
return { response: 1 };
}
@@ -67,11 +91,27 @@ jest.mock('./decorators', () => {
return ['C:/updates'];
}
}
// The "new version available, download it?" dialog; each test decides how the user answers it.
const showUpgradeDialog = jest.fn();
class UpgradeDialog extends Dialog {
show() {
return showUpgradeDialog(this.options);
}
}
// The "ready to install, restart now?" dialog shown when a download has finished.
const showInstallDialog = jest.fn();
class InstallDialog extends Dialog {
show() {
return showInstallDialog(this.options);
}
}
return {
...jest.requireActual('./decorators/concretes/github-cdn'),
DialogConfirmInstallDownload: Dialog,
DialogConfirmUpgradeDownload: Dialog,
DialogLocalUpdate: Dialog
DialogConfirmInstallDownload: InstallDialog,
DialogConfirmUpgradeDownload: UpgradeDialog,
DialogLocalUpdate: Dialog,
showUpgradeDialog,
showInstallDialog
};
});
@@ -90,6 +130,9 @@ const fetchMock: jest.Mock = jest.requireMock('node-fetch').default;
const getStore: jest.Mock = jest.requireMock('./desktop-store').LocalStore.getStore;
const autoUpdater = jest.requireMock('electron-updater').autoUpdater;
const ipcOn: jest.Mock = jest.requireMock('electron').ipcMain.on;
const showUpgradeDialog: jest.Mock = jest.requireMock('./decorators').showUpgradeDialog;
const showInstallDialog: jest.Mock = jest.requireMock('./decorators').showInstallDialog;
const customNotification: jest.Mock = jest.requireMock('./desktop-notifier').customNotification;
const release = (tag: string, prerelease = false, files = ALL_FILES) => ({
tag_name: tag,
@@ -100,6 +143,8 @@ const flush = () => new Promise((resolve) => setImmediate(resolve));
/** The handler the most recently constructed DesktopUpdater registered for an ipc channel. */
const ipc = (channel: string) => ipcOn.mock.calls.filter(([name]) => name === channel).at(-1)[1];
const lastChecked = () => autoUpdater.checked.at(-1);
/** The versions the "new version available" dialog was shown for, in order. */
const offered = () => showUpgradeDialog.mock.calls.map(([options]) => /"next":"([^"]+)"/.exec(options.detail)[1]);
describe('GitHub update feed', () => {
let releases: any[];
@@ -129,8 +174,11 @@ describe('GitHub update feed', () => {
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'], now: new Date('2026-09-24T12:00:00Z') });
jest.spyOn(console, 'log').mockImplementation(() => undefined);
setPlatform('win32', 'x64');
autoUpdater.removeAllListeners();
autoUpdater.checked = [];
autoUpdater.downloaded = [];
showUpgradeDialog.mockResolvedValue({ response: 1 });
showInstallDialog.mockResolvedValue({ response: 1 });
releases = [release('v111.44.45')];
appSetting = {
automaticUpdate: true,
@@ -198,10 +246,35 @@ describe('GitHub update feed', () => {
});
it('picks the newest prerelease when the prerelease channel is enabled', async () => {
appSetting.prerelease = true;
releases = [release('v111.44.49', true), release('v111.44.48'), release('v111.44.15', true)];
expect(await new CdnUpdate(CONFIG).tagName()).toBe('v111.44.49');
});
it('still offers a newer stable release when the prerelease channel is enabled', async () => {
appSetting.prerelease = true;
releases = [release('v111.44.48'), release('v111.44.47', true), release('v111.44.15', true)];
expect(await new CdnUpdate(CONFIG).tagName()).toBe('v111.44.47');
expect(await new CdnUpdate(CONFIG).tagName()).toBe('v111.44.48');
});
it('never offers a prerelease when the prerelease channel is disabled', async () => {
releases = [release('v111.44.49', true), release('v111.44.48'), release('v111.44.47', true)];
expect(await new CdnUpdate(CONFIG).tagName()).toBe('v111.44.48');
});
it('on the prerelease channel, prefers the newest release of either kind that has the update file', async () => {
appSetting.prerelease = true;
const withoutWindowsFile = ALL_FILES.filter((name) => name !== 'latest-x64.yml');
releases = [
release('v111.44.49', true, withoutWindowsFile),
release('v111.44.48'),
release('v111.44.47', true)
];
expect(await new CdnUpdate(CONFIG).tagName()).toBe('v111.44.48');
});
it.each([
@@ -275,4 +348,183 @@ describe('GitHub update feed', () => {
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('new version dialog', () => {
it('offers each newer release the hourly check finds, once per version', async () => {
new DesktopUpdater(CONFIG);
await flush();
releases.unshift(release('v111.44.46'));
await jest.advanceTimersByTimeAsync(HOUR);
await flush();
// The same version again, already turned down with "skip now".
await jest.advanceTimersByTimeAsync(HOUR);
await flush();
releases.unshift(release('v111.44.47'));
await jest.advanceTimersByTimeAsync(HOUR);
await flush();
expect(offered()).toEqual(['111.44.46', '111.44.47']);
// The system notification still comes with every check that finds a version.
expect(customNotification).toHaveBeenCalledTimes(3);
});
it('does not open a second dialog while one is still open', async () => {
let answer: (button: { response: number }) => void;
showUpgradeDialog.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)));
new DesktopUpdater(CONFIG);
await flush();
autoUpdater.emit('update-available', { version: '111.44.46' });
autoUpdater.emit('update-available', { version: '111.44.47' });
expect(offered()).toEqual(['111.44.46']);
answer({ response: 1 });
await flush();
autoUpdater.emit('update-available', { version: '111.44.47' });
expect(offered()).toEqual(['111.44.46', '111.44.47']);
});
it('does not open on top of the install prompt, and asks once it is closed', async () => {
let close: (button: { response: number }) => void;
showUpgradeDialog.mockResolvedValue({ response: 0 });
showInstallDialog.mockImplementationOnce(() => new Promise((resolve) => (close = resolve)));
new DesktopUpdater(CONFIG);
await flush();
releases.unshift(release('v111.44.46'));
await jest.advanceTimersByTimeAsync(HOUR);
await flush();
// The download chosen with Upgrade finishes, and its install prompt is left open.
autoUpdater.emit('update-downloaded', { version: '111.44.46' });
expect(showInstallDialog).toHaveBeenCalledTimes(1);
releases.unshift(release('v111.44.47'));
await jest.advanceTimersByTimeAsync(HOUR);
await flush();
expect(offered()).toEqual(['111.44.46']);
close({ response: 1 });
await flush();
await jest.advanceTimersByTimeAsync(HOUR);
await flush();
expect(offered()).toEqual(['111.44.46', '111.44.47']);
});
it('shows the install prompt only after the new version dialog is answered', async () => {
let answer: (button: { response: number }) => void;
showUpgradeDialog.mockImplementationOnce(() => new Promise((resolve) => (answer = resolve)));
new DesktopUpdater(CONFIG);
await flush();
autoUpdater.emit('update-available', { version: '111.44.47' });
// A download started earlier, e.g. from the settings page, finishes meanwhile.
autoUpdater.emit('update-downloaded', { version: '111.44.46' });
await flush();
expect(showInstallDialog).not.toHaveBeenCalled();
answer({ response: 1 });
await flush();
expect(showInstallDialog).toHaveBeenCalledTimes(1);
expect(showInstallDialog.mock.calls[0][0].detail).toContain('"version":"111.44.46"');
});
it('does not ask again about a version the user chose to download', async () => {
showUpgradeDialog.mockResolvedValue({ response: 0 });
new DesktopUpdater(CONFIG);
await flush();
releases.unshift(release('v111.44.46'));
await jest.advanceTimersByTimeAsync(HOUR);
await flush();
await jest.advanceTimersByTimeAsync(HOUR);
await flush();
expect(autoUpdater.downloaded).toEqual([FEED + 'v111.44.46']);
expect(offered()).toEqual(['111.44.46']);
});
it('asks once automatic updates are turned on, not only for the first version found', async () => {
appSetting.automaticUpdate = false;
new DesktopUpdater(CONFIG);
await flush();
autoUpdater.emit('update-available', { version: '111.44.46' });
expect(offered()).toEqual([]);
appSetting.automaticUpdate = true;
autoUpdater.emit('update-available', { version: '111.44.46' });
expect(offered()).toEqual(['111.44.46']);
});
});
describe('automatic update setting', () => {
const changeSetting = (args: object) => ipc('automatic_update_setting')({}, args);
const checks = () => autoUpdater.checked.length;
it('applies a new delay chosen in the settings page to the automatic check', async () => {
new DesktopUpdater(CONFIG);
await flush();
appSetting.automaticUpdateDelay = 24;
changeSetting({ isEnabled: true, delay: 24 });
await jest.advanceTimersByTimeAsync(23 * HOUR);
expect(checks()).toBe(0);
await jest.advanceTimersByTimeAsync(HOUR);
await flush();
expect(checks()).toBe(1);
});
it.each([
['delay', { isEnabled: true, delay: 3 }],
['automaticUpdateDelay', { isEnabled: true, automaticUpdateDelay: 3 }]
])('uses the delay sent as %s, not only the stored one', async (_, args) => {
new DesktopUpdater(CONFIG);
await flush();
changeSetting(args);
await jest.advanceTimersByTimeAsync(2 * HOUR);
expect(checks()).toBe(0);
await jest.advanceTimersByTimeAsync(HOUR);
await flush();
expect(checks()).toBe(1);
});
it.each([undefined, 0, 1000])('falls back to the stored delay when the delay sent is %p', async (delay) => {
appSetting.automaticUpdateDelay = 3;
new DesktopUpdater(CONFIG);
await flush();
changeSetting({ isEnabled: true, delay });
// 1000 hours does not fit in setInterval, which would then fire every millisecond.
await jest.advanceTimersByTimeAsync(1000);
expect(checks()).toBe(0);
await jest.advanceTimersByTimeAsync(3 * HOUR - 1000);
await flush();
expect(checks()).toBe(1);
});
it('stops the automatic check when turned off and starts it again when turned on', async () => {
new DesktopUpdater(CONFIG);
await flush();
appSetting.automaticUpdate = false;
changeSetting({ isEnabled: false, delay: 1 });
await jest.advanceTimersByTimeAsync(2 * HOUR);
expect(checks()).toBe(0);
appSetting.automaticUpdate = true;
changeSetting({ isEnabled: true, delay: 1 });
await jest.advanceTimersByTimeAsync(HOUR);
await flush();
expect(checks()).toBe(1);
});
});
});
+47 -21
View File
@@ -24,6 +24,9 @@ export class DesktopUpdater {
private _gauzyWindow: BrowserWindow;
private _config: IUpdaterConfig;
private _automaticUpdate: AutomaticUpdate;
private _answeredVersions = new Set<string>();
/** The update dialog now showing on the gauzy window, if any: only one is shown at a time. */
private _openDialog: Promise<any> = null;
constructor(config: IUpdaterConfig) {
this._updateContext = new UpdateContext();
@@ -129,32 +132,45 @@ export class DesktopUpdater {
});
ipcMain.on('automatic_update_setting', (event, args) => {
const { isEnabled, automaticUpdateDelay } = args;
isEnabled ? (this._automaticUpdate.delay = automaticUpdateDelay) : this._automaticUpdate.stop();
// The settings page sends the delay as `delay`; `automaticUpdateDelay` is still accepted.
const { isEnabled, delay, automaticUpdateDelay } = args ?? {};
isEnabled ? (this._automaticUpdate.delay = delay ?? automaticUpdateDelay) : this._automaticUpdate.stop();
});
}
private _updaterProcess(): void {
autoUpdater.once('update-available', async (info: UpdateInfo) => {
// Every check looks up the newest release again, so offer each new version once: not a
// version already answered in this run, and not while an update dialog (this one or the
// install prompt) is still showing; the next check offers it again.
autoUpdater.on('update-available', async (info: UpdateInfo) => {
const setting = LocalStore.getStore('appSetting');
if (setting && !setting.automaticUpdate) return;
const dialog = new DialogConfirmUpgradeDownload(
new DesktopDialog(
process.env.DESCRIPTION,
TranslateService.instant('TIMER_TRACKER.DIALOG.UPDATE_READY'),
this._gauzyWindow
)
);
dialog.options = {
...dialog.options,
detail: TranslateService.instant('TIMER_TRACKER.DIALOG.NEW_VERSION_AVAILABLE', {
next: info.version,
current: app.getVersion()
})
};
const button = await dialog.show();
if (button?.response === 0) {
this._updateContext.update();
if (this._openDialog || this._answeredVersions.has(info.version)) return;
try {
const dialog = new DialogConfirmUpgradeDownload(
new DesktopDialog(
process.env.DESCRIPTION,
TranslateService.instant('TIMER_TRACKER.DIALOG.UPDATE_READY'),
this._gauzyWindow
)
);
dialog.options = {
...dialog.options,
detail: TranslateService.instant('TIMER_TRACKER.DIALOG.NEW_VERSION_AVAILABLE', {
next: info.version,
current: app.getVersion()
})
};
this._openDialog = dialog.show();
const button = await this._openDialog;
this._answeredVersions.add(info.version);
if (button?.response === 0) {
this._updateContext.update();
}
} catch (e) {
console.log('Error on showing the update dialog:', e);
} finally {
this._openDialog = null;
}
});
@@ -164,6 +180,8 @@ export class DesktopUpdater {
type: 'update_downloaded'
});
if (setting && !setting.automaticUpdate) return;
// Wait until an open "new version" dialog is answered instead of opening on top of it.
while (this._openDialog) await this._openDialog.catch(() => undefined);
const dialog = new DialogConfirmInstallDownload(
new DesktopDialog(
process.env.DESCRIPTION,
@@ -174,7 +192,15 @@ export class DesktopUpdater {
dialog.options.detail = TranslateService.instant('TIMER_TRACKER.DIALOG.HAS_BEEN_DOWNLOADED', {
version: event.version
});
const button = await dialog.show();
let button: any;
try {
this._openDialog = dialog.show();
button = await this._openDialog;
} catch (e) {
console.log('Error on showing the install dialog:', e);
} finally {
this._openDialog = null;
}
if (button?.response === 0) {
this._settingWindow?.webContents?.send?.('setting_page_ipc', {
type: '_logout_quit_install_'
@@ -28,9 +28,10 @@ export class CdnUpdate extends UpdateStrategy implements IDesktopCdnUpdate {
}
/**
* Resolves the tag of the newest release on the user's channel (stable or prerelease). A failed
* lookup keeps the last resolved tag, or falls back to the running version's tag (releases are
* tagged v<version>), so the feed URL is never built from a malformed tag.
* Resolves the tag of the newest release on the user's channel: stable releases only, or, with the
* prerelease channel enabled, the newest release of either kind. A failed lookup keeps the last
* resolved tag, or falls back to the running version's tag (releases are tagged v<version>), so the
* feed URL is never built from a malformed tag.
*/
public async tagName(): Promise<string> {
let prerelease: boolean;
@@ -55,7 +56,9 @@ export class CdnUpdate extends UpdateStrategy implements IDesktopCdnUpdate {
if (!Array.isArray(releases)) {
throw new Error(`GitHub API ${response.status}: ${releases?.message}`);
}
const matching = releases.filter((release) => release.prerelease === prerelease);
// The prerelease channel adds prerelease builds to the stable releases instead of replacing
// them, so a stable release published after the last prerelease still reaches those users.
const matching = releases.filter((release) => prerelease || release.prerelease === false);
// A release is published before its per-platform update files are uploaded (up to an hour
// later), so prefer the newest one that already carries this platform's file.
const updateInfoFile = this._updateInfoFile;
@@ -63,7 +66,7 @@ export class CdnUpdate extends UpdateStrategy implements IDesktopCdnUpdate {
matching.find((release) => release.assets?.some((asset) => asset.name === updateInfoFile)) ??
matching[0];
if (!release?.tag_name) {
throw new Error(`No ${prerelease ? 'prerelease' : 'release'} found`);
throw new Error(`No ${prerelease ? '' : 'stable '}release found`);
}
this._lastTag = { name: release.tag_name, prerelease, resolvedAt: Date.now() };
return release.tag_name;