mirror of
https://github.com/ever-co/ever-gauzy.git
synced 2026-10-02 01:54:50 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+280
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user