chore!: migrate album owner to album_user (#27467)

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Daniel Dietzler
2026-04-22 16:52:23 +02:00
committed by GitHub
parent dfacde5af8
commit 4bfb8b36c2
75 changed files with 14750 additions and 1104 deletions
+6 -1
View File
@@ -1,5 +1,10 @@
import { registerEnum } from '@immich/sql-tools';
import { AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
import { AlbumUserRole, AssetStatus, AssetVisibility, ChecksumAlgorithm, SourceType } from 'src/enum';
export const album_user_role_enum = registerEnum({
name: 'album_user_role_enum',
values: [AlbumUserRole.Owner, AlbumUserRole.Editor, AlbumUserRole.Viewer],
});
export const assets_status_enum = registerEnum({
name: 'assets_status_enum',
+2 -14
View File
@@ -29,7 +29,8 @@ export const album_user_after_insert = registerFunction({
body: `
BEGIN
UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
WHERE "id" IN (SELECT DISTINCT "albumId" FROM inserted_rows);
WHERE "id" IN (SELECT "albumId" FROM inserted_rows)
AND NOT EXISTS (SELECT FROM inserted_rows WHERE role = 'owner');
RETURN NULL;
END`,
});
@@ -119,19 +120,6 @@ export const asset_delete_audit = registerFunction({
END`,
});
export const album_delete_audit = registerFunction({
name: 'album_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO album_audit ("albumId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END`,
});
export const album_asset_delete_audit = registerFunction({
name: 'album_asset_delete_audit',
returnType: 'TRIGGER',
+7 -4
View File
@@ -1,7 +1,11 @@
import { Database, Extensions, Generated, Int8 } from '@immich/sql-tools';
import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
import {
album_delete_audit,
album_user_role_enum,
asset_face_source_type,
asset_visibility_enum,
assets_status_enum,
} from 'src/schema/enums';
import {
album_user_after_insert,
album_user_delete_audit,
asset_delete_audit,
@@ -146,7 +150,6 @@ export class ImmichDatabase {
user_delete_audit,
partner_delete_audit,
asset_delete_audit,
album_delete_audit,
album_user_after_insert,
album_user_delete_audit,
memory_delete_audit,
@@ -158,7 +161,7 @@ export class ImmichDatabase {
asset_face_audit,
];
enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum];
enum = [album_user_role_enum, assets_status_enum, asset_face_source_type, asset_visibility_enum];
}
export interface Migrations {
@@ -0,0 +1,92 @@
import { Kysely, sql } from 'kysely';
import { AlbumUserRole } from 'src/enum';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION album_user_after_insert()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
WHERE "id" IN (SELECT "albumId" FROM inserted_rows)
AND NOT EXISTS (SELECT FROM inserted_rows WHERE role = 'owner');
RETURN NULL;
END
$$;`.execute(db);
await sql`DROP TRIGGER "album_delete_audit" ON "album";`.execute(db);
await sql`DROP FUNCTION album_delete_audit;`.execute(db);
await sql`CREATE TYPE "album_user_role_enum" AS ENUM ('owner','editor','viewer');`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" DROP DEFAULT;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" TYPE album_user_role_enum USING "role"::album_user_role_enum;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" SET DEFAULT 'editor'::album_user_role_enum;`.execute(db);
await db
.insertInto('album_user')
.expression((eb) =>
eb
.selectFrom('album')
.select(['album.id as albumId', 'album.ownerId as userId', eb.val(AlbumUserRole.Owner).as('role')]),
)
.execute();
await sql`ALTER TABLE "album" DROP CONSTRAINT "album_ownerId_fkey";`.execute(db);
await sql`ALTER TABLE "album" DROP COLUMN "ownerId";`.execute(db);
await sql`CREATE UNIQUE INDEX "album_user_unique_owner" ON "album_user" ("albumId") WHERE (role = 'owner');`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"type":"function","name":"album_user_after_insert","sql":"CREATE OR REPLACE FUNCTION album_user_after_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE album SET \\"updatedAt\\" = clock_timestamp(), \\"updateId\\" = immich_uuid_v7(clock_timestamp())\\n WHERE \\"id\\" IN (SELECT \\"albumId\\" FROM inserted_rows)\\n AND NOT EXISTS (SELECT FROM inserted_rows WHERE role = ''owner'');\\n RETURN NULL;\\n END\\n $$;"}'::jsonb WHERE "name" = 'function_album_user_after_insert';`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('index_album_user_unique_owner', '{"type":"index","name":"album_user_unique_owner","sql":"CREATE UNIQUE INDEX \\"album_user_unique_owner\\" ON \\"album_user\\" (\\"albumId\\") WHERE (role = ''owner'');"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_album_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_album_delete_audit';`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION public.album_user_after_insert()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
UPDATE album SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
WHERE "id" IN (SELECT DISTINCT "albumId" FROM inserted_rows);
RETURN NULL;
END
$function$
`.execute(db);
await sql`CREATE OR REPLACE FUNCTION public.album_delete_audit()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
INSERT INTO album_audit ("albumId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END
$function$
`.execute(db);
await sql`ALTER TABLE "album" ADD "ownerId" uuid NOT NULL;`.execute(db);
await db
.updateTable('album')
.set((eb) =>
({
id: eb.ref('album_user.albumId'),
ownerId: eb.ref('album_user.userId')
})
)
.from('album_user')
.where('album_user.role', '=', AlbumUserRole.Owner)
.execute();
await sql`DROP INDEX "album_user_unique_owner";`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" DROP DEFAULT;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" TYPE character varying USING "role"::text;`.execute(db);
await sql`ALTER TABLE "album_user" ALTER COLUMN "role" SET DEFAULT 'editor';`.execute(db);
await sql`DROP TYPE "album_user_role_enum";`.execute(db);
await sql`CREATE INDEX "album_ownerId_idx" ON "album" ("ownerId");`.execute(db);
await sql`ALTER TABLE "album" ADD CONSTRAINT "album_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "album_delete_audit"
AFTER DELETE ON "album"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN ((pg_trigger_depth() = 0))
EXECUTE FUNCTION album_delete_audit();`.execute(db);
await sql`UPDATE "migration_overrides" SET "value" = '{"sql":"CREATE OR REPLACE FUNCTION album_user_after_insert()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n UPDATE album SET \\"updatedAt\\" = clock_timestamp(), \\"updateId\\" = immich_uuid_v7(clock_timestamp())\\n WHERE \\"id\\" IN (SELECT DISTINCT \\"albumId\\" FROM inserted_rows);\\n RETURN NULL;\\n END\\n $$;","name":"album_user_after_insert","type":"function"}'::jsonb WHERE "name" = 'function_album_user_after_insert';`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_album_delete_audit', '{"sql":"CREATE OR REPLACE FUNCTION album_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO album_audit (\\"albumId\\", \\"userId\\")\\n SELECT \\"id\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;","name":"album_delete_audit","type":"function"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_album_delete_audit', '{"sql":"CREATE OR REPLACE TRIGGER \\"album_delete_audit\\"\\n AFTER DELETE ON \\"album\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION album_delete_audit();","name":"album_delete_audit","type":"trigger"}'::jsonb);`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'index_album_user_unique_owner';`.execute(db);
}
+9 -1
View File
@@ -5,17 +5,25 @@ import {
CreateDateColumn,
ForeignKeyColumn,
Generated,
Index,
Table,
Timestamp,
UpdateDateColumn,
} from '@immich/sql-tools';
import { CreateIdColumn, UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AlbumUserRole } from 'src/enum';
import { album_user_role_enum } from 'src/schema/enums';
import { album_user_after_insert, album_user_delete_audit } from 'src/schema/functions';
import { AlbumTable } from 'src/schema/tables/album.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table({ name: 'album_user' })
@Index({
name: 'album_user_unique_owner',
columns: ['albumId'],
unique: true,
where: `role = 'owner'`,
})
// Pre-existing indices from original album <--> user ManyToMany mapping
@UpdatedAtTrigger('album_user_updatedAt')
@AfterInsertTrigger({
@@ -47,7 +55,7 @@ export class AlbumUserTable {
})
userId!: string;
@Column({ type: 'character varying', default: AlbumUserRole.Editor })
@Column({ enum: album_user_role_enum, default: AlbumUserRole.Editor })
role!: Generated<AlbumUserRole>;
@CreateIdColumn({ index: true })
-12
View File
@@ -1,5 +1,4 @@
import {
AfterDeleteTrigger,
Column,
CreateDateColumn,
DeleteDateColumn,
@@ -12,25 +11,14 @@ import {
} from '@immich/sql-tools';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { AssetOrder } from 'src/enum';
import { album_delete_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table';
@Table({ name: 'album' })
@UpdatedAtTrigger('album_updatedAt')
@AfterDeleteTrigger({
scope: 'statement',
function: album_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class AlbumTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column({ default: 'Untitled Album' })
albumName!: Generated<string>;