mirror of
https://github.com/immich-app/immich
synced 2025-11-07 17:27:20 +00:00
feat: sync albums and album users (#18377)
This commit is contained in:
parent
58af574241
commit
cd288533a1
41 changed files with 2811 additions and 934 deletions
|
|
@ -125,6 +125,7 @@ const compare = async () => {
|
|||
const down = schemaDiff(target, source, {
|
||||
tables: { ignoreExtra: false },
|
||||
functions: { ignoreExtra: false },
|
||||
extension: { ignoreMissing: true },
|
||||
});
|
||||
|
||||
return { up, down };
|
||||
|
|
|
|||
18
server/src/db.d.ts
vendored
18
server/src/db.d.ts
vendored
|
|
@ -74,6 +74,20 @@ export interface Albums {
|
|||
updateId: Generated<string>;
|
||||
}
|
||||
|
||||
export interface AlbumsAudit {
|
||||
deletedAt: Generated<Timestamp>;
|
||||
id: Generated<string>;
|
||||
albumId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface AlbumUsersAudit {
|
||||
deletedAt: Generated<Timestamp>;
|
||||
id: Generated<string>;
|
||||
albumId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface AlbumsAssetsAssets {
|
||||
albumsId: string;
|
||||
assetsId: string;
|
||||
|
|
@ -84,6 +98,8 @@ export interface AlbumsSharedUsersUsers {
|
|||
albumsId: string;
|
||||
role: Generated<AlbumUserRole>;
|
||||
usersId: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
updateId: Generated<string>;
|
||||
}
|
||||
|
||||
export interface ApiKeys {
|
||||
|
|
@ -466,8 +482,10 @@ export interface VersionHistory {
|
|||
export interface DB {
|
||||
activity: Activity;
|
||||
albums: Albums;
|
||||
albums_audit: AlbumsAudit;
|
||||
albums_assets_assets: AlbumsAssetsAssets;
|
||||
albums_shared_users_users: AlbumsSharedUsersUsers;
|
||||
album_users_audit: AlbumUsersAudit;
|
||||
api_keys: ApiKeys;
|
||||
asset_faces: AssetFaces;
|
||||
asset_files: AssetFiles;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum, IsInt, IsPositive, IsString } from 'class-validator';
|
||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||
import { AssetType, AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum';
|
||||
import { AlbumUserRole, AssetOrder, AssetType, AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum';
|
||||
import { Optional, ValidateDate, ValidateUUID } from 'src/validation';
|
||||
|
||||
export class AssetFullSyncDto {
|
||||
|
|
@ -112,6 +112,34 @@ export class SyncAssetExifV1 {
|
|||
fps!: number | null;
|
||||
}
|
||||
|
||||
export class SyncAlbumDeleteV1 {
|
||||
albumId!: string;
|
||||
}
|
||||
|
||||
export class SyncAlbumUserDeleteV1 {
|
||||
albumId!: string;
|
||||
userId!: string;
|
||||
}
|
||||
|
||||
export class SyncAlbumUserV1 {
|
||||
albumId!: string;
|
||||
userId!: string;
|
||||
role!: AlbumUserRole;
|
||||
}
|
||||
|
||||
export class SyncAlbumV1 {
|
||||
id!: string;
|
||||
ownerId!: string;
|
||||
name!: string;
|
||||
description!: string;
|
||||
createdAt!: Date;
|
||||
updatedAt!: Date;
|
||||
thumbnailAssetId!: string | null;
|
||||
isActivityEnabled!: boolean;
|
||||
@ApiProperty({ enumName: 'AssetOrder', enum: AssetOrder })
|
||||
order!: AssetOrder;
|
||||
}
|
||||
|
||||
export type SyncItem = {
|
||||
[SyncEntityType.UserV1]: SyncUserV1;
|
||||
[SyncEntityType.UserDeleteV1]: SyncUserDeleteV1;
|
||||
|
|
@ -123,10 +151,13 @@ export type SyncItem = {
|
|||
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
|
||||
[SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;
|
||||
[SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1;
|
||||
[SyncEntityType.AlbumV1]: SyncAlbumV1;
|
||||
[SyncEntityType.AlbumDeleteV1]: SyncAlbumDeleteV1;
|
||||
[SyncEntityType.AlbumUserV1]: SyncAlbumUserV1;
|
||||
[SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1;
|
||||
};
|
||||
|
||||
const responseDtos = [
|
||||
//
|
||||
SyncUserV1,
|
||||
SyncUserDeleteV1,
|
||||
SyncPartnerV1,
|
||||
|
|
@ -134,6 +165,10 @@ const responseDtos = [
|
|||
SyncAssetV1,
|
||||
SyncAssetDeleteV1,
|
||||
SyncAssetExifV1,
|
||||
SyncAlbumV1,
|
||||
SyncAlbumDeleteV1,
|
||||
SyncAlbumUserV1,
|
||||
SyncAlbumUserDeleteV1,
|
||||
];
|
||||
|
||||
export const extraSyncModels = responseDtos;
|
||||
|
|
|
|||
|
|
@ -578,6 +578,8 @@ export enum SyncRequestType {
|
|||
AssetExifsV1 = 'AssetExifsV1',
|
||||
PartnerAssetsV1 = 'PartnerAssetsV1',
|
||||
PartnerAssetExifsV1 = 'PartnerAssetExifsV1',
|
||||
AlbumsV1 = 'AlbumsV1',
|
||||
AlbumUsersV1 = 'AlbumUsersV1',
|
||||
}
|
||||
|
||||
export enum SyncEntityType {
|
||||
|
|
@ -594,6 +596,11 @@ export enum SyncEntityType {
|
|||
PartnerAssetV1 = 'PartnerAssetV1',
|
||||
PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1',
|
||||
PartnerAssetExifV1 = 'PartnerAssetExifV1',
|
||||
|
||||
AlbumV1 = 'AlbumV1',
|
||||
AlbumDeleteV1 = 'AlbumDeleteV1',
|
||||
AlbumUserV1 = 'AlbumUserV1',
|
||||
AlbumUserDeleteV1 = 'AlbumUserDeleteV1',
|
||||
}
|
||||
|
||||
export enum NotificationLevel {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,9 @@ insert into
|
|||
values
|
||||
($1, $2)
|
||||
returning
|
||||
*
|
||||
"usersId",
|
||||
"albumsId",
|
||||
"role"
|
||||
|
||||
-- AlbumUserRepository.update
|
||||
update "albums_shared_users_users"
|
||||
|
|
|
|||
|
|
@ -246,3 +246,98 @@ where
|
|||
and "updatedAt" < now() - interval '1 millisecond'
|
||||
order by
|
||||
"updateId" asc
|
||||
|
||||
-- SyncRepository.getAlbumDeletes
|
||||
select
|
||||
"id",
|
||||
"albumId"
|
||||
from
|
||||
"albums_audit"
|
||||
where
|
||||
"userId" = $1
|
||||
and "deletedAt" < now() - interval '1 millisecond'
|
||||
order by
|
||||
"id" asc
|
||||
|
||||
-- SyncRepository.getAlbumUpserts
|
||||
select distinct
|
||||
on ("albums"."id", "albums"."updateId") "albums"."id",
|
||||
"albums"."ownerId",
|
||||
"albums"."albumName" as "name",
|
||||
"albums"."description",
|
||||
"albums"."createdAt",
|
||||
"albums"."updatedAt",
|
||||
"albums"."albumThumbnailAssetId" as "thumbnailAssetId",
|
||||
"albums"."isActivityEnabled",
|
||||
"albums"."order",
|
||||
"albums"."updateId"
|
||||
from
|
||||
"albums"
|
||||
left join "albums_shared_users_users" as "album_users" on "albums"."id" = "album_users"."albumsId"
|
||||
where
|
||||
"albums"."updatedAt" < now() - interval '1 millisecond'
|
||||
and (
|
||||
"albums"."ownerId" = $1
|
||||
or "album_users"."usersId" = $2
|
||||
)
|
||||
order by
|
||||
"albums"."updateId" asc
|
||||
|
||||
-- SyncRepository.getAlbumUserDeletes
|
||||
select
|
||||
"id",
|
||||
"userId",
|
||||
"albumId"
|
||||
from
|
||||
"album_users_audit"
|
||||
where
|
||||
"albumId" in (
|
||||
select
|
||||
"id"
|
||||
from
|
||||
"albums"
|
||||
where
|
||||
"ownerId" = $1
|
||||
union
|
||||
(
|
||||
select
|
||||
"albumUsers"."albumsId" as "id"
|
||||
from
|
||||
"albums_shared_users_users" as "albumUsers"
|
||||
where
|
||||
"albumUsers"."usersId" = $2
|
||||
)
|
||||
)
|
||||
and "deletedAt" < now() - interval '1 millisecond'
|
||||
order by
|
||||
"id" asc
|
||||
|
||||
-- SyncRepository.getAlbumUserUpserts
|
||||
select
|
||||
"albums_shared_users_users"."albumsId" as "albumId",
|
||||
"albums_shared_users_users"."usersId" as "userId",
|
||||
"albums_shared_users_users"."role",
|
||||
"albums_shared_users_users"."updateId"
|
||||
from
|
||||
"albums_shared_users_users"
|
||||
where
|
||||
"albums_shared_users_users"."updatedAt" < now() - interval '1 millisecond'
|
||||
and "albums_shared_users_users"."albumsId" in (
|
||||
select
|
||||
"id"
|
||||
from
|
||||
"albums"
|
||||
where
|
||||
"ownerId" = $1
|
||||
union
|
||||
(
|
||||
select
|
||||
"albumUsers"."albumsId" as "id"
|
||||
from
|
||||
"albums_shared_users_users" as "albumUsers"
|
||||
where
|
||||
"albumUsers"."usersId" = $2
|
||||
)
|
||||
)
|
||||
order by
|
||||
"albums_shared_users_users"."updateId" asc
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely, Selectable, Updateable } from 'kysely';
|
||||
import { Insertable, Kysely, Updateable } from 'kysely';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { AlbumsSharedUsersUsers, DB } from 'src/db';
|
||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||
|
|
@ -15,8 +15,12 @@ export class AlbumUserRepository {
|
|||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }] })
|
||||
create(albumUser: Insertable<AlbumsSharedUsersUsers>): Promise<Selectable<AlbumsSharedUsersUsers>> {
|
||||
return this.db.insertInto('albums_shared_users_users').values(albumUser).returningAll().executeTakeFirstOrThrow();
|
||||
create(albumUser: Insertable<AlbumsSharedUsersUsers>) {
|
||||
return this.db
|
||||
.insertInto('albums_shared_users_users')
|
||||
.values(albumUser)
|
||||
.returning(['usersId', 'albumsId', 'role'])
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [{ usersId: DummyValue.UUID, albumsId: DummyValue.UUID }, { role: AlbumUserRole.VIEWER }] })
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import { DummyValue, GenerateSql } from 'src/decorators';
|
|||
import { SyncEntityType } from 'src/enum';
|
||||
import { SyncAck } from 'src/types';
|
||||
|
||||
type auditTables = 'users_audit' | 'partners_audit' | 'assets_audit';
|
||||
type upsertTables = 'users' | 'partners' | 'assets' | 'exif';
|
||||
type AuditTables = 'users_audit' | 'partners_audit' | 'assets_audit' | 'albums_audit' | 'album_users_audit';
|
||||
type UpsertTables = 'users' | 'partners' | 'assets' | 'exif' | 'albums' | 'albums_shared_users_users';
|
||||
|
||||
@Injectable()
|
||||
export class SyncRepository {
|
||||
|
|
@ -110,7 +110,6 @@ export class SyncRepository {
|
|||
.selectFrom('assets_audit')
|
||||
.select(['id', 'assetId'])
|
||||
.where('ownerId', '=', userId)
|
||||
.$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId))
|
||||
.$call((qb) => this.auditTableFilters(qb, ack))
|
||||
.stream();
|
||||
}
|
||||
|
|
@ -154,19 +153,115 @@ export class SyncRepository {
|
|||
.stream();
|
||||
}
|
||||
|
||||
private auditTableFilters<T extends keyof Pick<DB, auditTables>, D>(qb: SelectQueryBuilder<DB, T, D>, ack?: SyncAck) {
|
||||
const builder = qb as SelectQueryBuilder<DB, auditTables, D>;
|
||||
@GenerateSql({ params: [DummyValue.UUID], stream: true })
|
||||
getAlbumDeletes(userId: string, ack?: SyncAck) {
|
||||
return this.db
|
||||
.selectFrom('albums_audit')
|
||||
.select(['id', 'albumId'])
|
||||
.where('userId', '=', userId)
|
||||
.$call((qb) => this.auditTableFilters(qb, ack))
|
||||
.stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID], stream: true })
|
||||
getAlbumUpserts(userId: string, ack?: SyncAck) {
|
||||
return this.db
|
||||
.selectFrom('albums')
|
||||
.distinctOn(['albums.id', 'albums.updateId'])
|
||||
.where('albums.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
|
||||
.$if(!!ack, (qb) => qb.where('albums.updateId', '>', ack!.updateId))
|
||||
.orderBy('albums.updateId', 'asc')
|
||||
.leftJoin('albums_shared_users_users as album_users', 'albums.id', 'album_users.albumsId')
|
||||
.where((eb) => eb.or([eb('albums.ownerId', '=', userId), eb('album_users.usersId', '=', userId)]))
|
||||
.select([
|
||||
'albums.id',
|
||||
'albums.ownerId',
|
||||
'albums.albumName as name',
|
||||
'albums.description',
|
||||
'albums.createdAt',
|
||||
'albums.updatedAt',
|
||||
'albums.albumThumbnailAssetId as thumbnailAssetId',
|
||||
'albums.isActivityEnabled',
|
||||
'albums.order',
|
||||
'albums.updateId',
|
||||
])
|
||||
.stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID], stream: true })
|
||||
getAlbumUserDeletes(userId: string, ack?: SyncAck) {
|
||||
return this.db
|
||||
.selectFrom('album_users_audit')
|
||||
.select(['id', 'userId', 'albumId'])
|
||||
.where((eb) =>
|
||||
eb(
|
||||
'albumId',
|
||||
'in',
|
||||
eb
|
||||
.selectFrom('albums')
|
||||
.select(['id'])
|
||||
.where('ownerId', '=', userId)
|
||||
.union((eb) =>
|
||||
eb.parens(
|
||||
eb
|
||||
.selectFrom('albums_shared_users_users as albumUsers')
|
||||
.select(['albumUsers.albumsId as id'])
|
||||
.where('albumUsers.usersId', '=', userId),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.$call((qb) => this.auditTableFilters(qb, ack))
|
||||
.stream();
|
||||
}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID], stream: true })
|
||||
getAlbumUserUpserts(userId: string, ack?: SyncAck) {
|
||||
return this.db
|
||||
.selectFrom('albums_shared_users_users')
|
||||
.select([
|
||||
'albums_shared_users_users.albumsId as albumId',
|
||||
'albums_shared_users_users.usersId as userId',
|
||||
'albums_shared_users_users.role',
|
||||
'albums_shared_users_users.updateId',
|
||||
])
|
||||
.where('albums_shared_users_users.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
|
||||
.$if(!!ack, (qb) => qb.where('albums_shared_users_users.updateId', '>', ack!.updateId))
|
||||
.orderBy('albums_shared_users_users.updateId', 'asc')
|
||||
.where((eb) =>
|
||||
eb(
|
||||
'albums_shared_users_users.albumsId',
|
||||
'in',
|
||||
eb
|
||||
.selectFrom('albums')
|
||||
.select(['id'])
|
||||
.where('ownerId', '=', userId)
|
||||
.union((eb) =>
|
||||
eb.parens(
|
||||
eb
|
||||
.selectFrom('albums_shared_users_users as albumUsers')
|
||||
.select(['albumUsers.albumsId as id'])
|
||||
.where('albumUsers.usersId', '=', userId),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.stream();
|
||||
}
|
||||
|
||||
private auditTableFilters<T extends keyof Pick<DB, AuditTables>, D>(qb: SelectQueryBuilder<DB, T, D>, ack?: SyncAck) {
|
||||
const builder = qb as SelectQueryBuilder<DB, AuditTables, D>;
|
||||
return builder
|
||||
.where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
|
||||
.$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId))
|
||||
.orderBy('id', 'asc') as SelectQueryBuilder<DB, T, D>;
|
||||
}
|
||||
|
||||
private upsertTableFilters<T extends keyof Pick<DB, upsertTables>, D>(
|
||||
private upsertTableFilters<T extends keyof Pick<DB, UpsertTables>, D>(
|
||||
qb: SelectQueryBuilder<DB, T, D>,
|
||||
ack?: SyncAck,
|
||||
) {
|
||||
const builder = qb as SelectQueryBuilder<DB, upsertTables, D>;
|
||||
const builder = qb as SelectQueryBuilder<DB, UpsertTables, D>;
|
||||
return builder
|
||||
.where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
|
||||
.$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId))
|
||||
|
|
|
|||
|
|
@ -23,6 +23,19 @@ export const immich_uuid_v7 = registerFunction({
|
|||
synchronize: false,
|
||||
});
|
||||
|
||||
export const album_user_after_insert = registerFunction({
|
||||
name: 'album_user_after_insert',
|
||||
returnType: 'TRIGGER',
|
||||
language: 'PLPGSQL',
|
||||
body: `
|
||||
BEGIN
|
||||
UPDATE albums SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
|
||||
WHERE "id" IN (SELECT DISTINCT "albumsId" FROM inserted_rows);
|
||||
RETURN NULL;
|
||||
END`,
|
||||
synchronize: false,
|
||||
});
|
||||
|
||||
export const updated_at = registerFunction({
|
||||
name: 'updated_at',
|
||||
returnType: 'TRIGGER',
|
||||
|
|
@ -114,3 +127,38 @@ export const assets_delete_audit = registerFunction({
|
|||
END`,
|
||||
synchronize: false,
|
||||
});
|
||||
|
||||
export const albums_delete_audit = registerFunction({
|
||||
name: 'albums_delete_audit',
|
||||
returnType: 'TRIGGER',
|
||||
language: 'PLPGSQL',
|
||||
body: `
|
||||
BEGIN
|
||||
INSERT INTO albums_audit ("albumId", "userId")
|
||||
SELECT "id", "ownerId"
|
||||
FROM OLD;
|
||||
RETURN NULL;
|
||||
END`,
|
||||
synchronize: false,
|
||||
});
|
||||
|
||||
export const album_users_delete_audit = registerFunction({
|
||||
name: 'album_users_delete_audit',
|
||||
returnType: 'TRIGGER',
|
||||
language: 'PLPGSQL',
|
||||
body: `
|
||||
BEGIN
|
||||
INSERT INTO albums_audit ("albumId", "userId")
|
||||
SELECT "albumsId", "usersId"
|
||||
FROM OLD;
|
||||
|
||||
IF pg_trigger_depth() = 1 THEN
|
||||
INSERT INTO album_users_audit ("albumId", "userId")
|
||||
SELECT "albumsId", "usersId"
|
||||
FROM OLD;
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END`,
|
||||
synchronize: false,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
import { asset_face_source_type, asset_visibility_enum, assets_status_enum } from 'src/schema/enums';
|
||||
import {
|
||||
album_user_after_insert,
|
||||
album_users_delete_audit,
|
||||
albums_delete_audit,
|
||||
assets_delete_audit,
|
||||
f_concat_ws,
|
||||
f_unaccent,
|
||||
|
|
@ -11,6 +14,8 @@ import {
|
|||
} from 'src/schema/functions';
|
||||
import { ActivityTable } from 'src/schema/tables/activity.table';
|
||||
import { AlbumAssetTable } from 'src/schema/tables/album-asset.table';
|
||||
import { AlbumAuditTable } from 'src/schema/tables/album-audit.table';
|
||||
import { AlbumUserAuditTable } from 'src/schema/tables/album-user-audit.table';
|
||||
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { APIKeyTable } from 'src/schema/tables/api-key.table';
|
||||
|
|
@ -45,15 +50,16 @@ import { UserAuditTable } from 'src/schema/tables/user-audit.table';
|
|||
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
|
||||
import { ConfigurationParameter, Database, Extensions } from 'src/sql-tools';
|
||||
import { Database, Extensions } from 'src/sql-tools';
|
||||
|
||||
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
|
||||
@ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' })
|
||||
@Database({ name: 'immich' })
|
||||
export class ImmichDatabase {
|
||||
tables = [
|
||||
ActivityTable,
|
||||
AlbumAssetTable,
|
||||
AlbumAuditTable,
|
||||
AlbumUserAuditTable,
|
||||
AlbumUserTable,
|
||||
AlbumTable,
|
||||
APIKeyTable,
|
||||
|
|
@ -99,6 +105,9 @@ export class ImmichDatabase {
|
|||
users_delete_audit,
|
||||
partners_delete_audit,
|
||||
assets_delete_audit,
|
||||
albums_delete_audit,
|
||||
album_user_after_insert,
|
||||
album_users_delete_audit,
|
||||
];
|
||||
|
||||
enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,96 @@
|
|||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
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 albums SET "updatedAt" = clock_timestamp(), "updateId" = immich_uuid_v7(clock_timestamp())
|
||||
WHERE "id" IN (SELECT DISTINCT "albumsId" FROM inserted_rows);
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;`.execute(db);
|
||||
await sql`CREATE OR REPLACE FUNCTION albums_delete_audit()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO albums_audit ("albumId", "userId")
|
||||
SELECT "id", "ownerId"
|
||||
FROM OLD;
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;`.execute(db);
|
||||
await sql`CREATE OR REPLACE FUNCTION album_users_delete_audit()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS $$
|
||||
BEGIN
|
||||
INSERT INTO albums_audit ("albumId", "userId")
|
||||
SELECT "albumsId", "usersId"
|
||||
FROM OLD;
|
||||
|
||||
IF pg_trigger_depth() = 1 THEN
|
||||
INSERT INTO album_users_audit ("albumId", "userId")
|
||||
SELECT "albumsId", "usersId"
|
||||
FROM OLD;
|
||||
END IF;
|
||||
|
||||
RETURN NULL;
|
||||
END
|
||||
$$;`.execute(db);
|
||||
await sql`CREATE TABLE "albums_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "albumId" uuid NOT NULL, "userId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db);
|
||||
await sql`CREATE TABLE "album_users_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "albumId" uuid NOT NULL, "userId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db);
|
||||
await sql`ALTER TABLE "albums_audit" ADD CONSTRAINT "PK_c75efea8d4dce316ad29b851a8b" PRIMARY KEY ("id");`.execute(db);
|
||||
await sql`ALTER TABLE "album_users_audit" ADD CONSTRAINT "PK_f479a2e575b7ebc9698362c1688" PRIMARY KEY ("id");`.execute(db);
|
||||
await sql`ALTER TABLE "albums_shared_users_users" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
|
||||
await sql`ALTER TABLE "albums_shared_users_users" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db);
|
||||
await sql`CREATE INDEX "IDX_album_users_update_id" ON "albums_shared_users_users" ("updateId")`.execute(db);
|
||||
await sql`CREATE INDEX "IDX_albums_audit_album_id" ON "albums_audit" ("albumId")`.execute(db);
|
||||
await sql`CREATE INDEX "IDX_albums_audit_user_id" ON "albums_audit" ("userId")`.execute(db);
|
||||
await sql`CREATE INDEX "IDX_albums_audit_deleted_at" ON "albums_audit" ("deletedAt")`.execute(db);
|
||||
await sql`CREATE INDEX "IDX_album_users_audit_album_id" ON "album_users_audit" ("albumId")`.execute(db);
|
||||
await sql`CREATE INDEX "IDX_album_users_audit_user_id" ON "album_users_audit" ("userId")`.execute(db);
|
||||
await sql`CREATE INDEX "IDX_album_users_audit_deleted_at" ON "album_users_audit" ("deletedAt")`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "albums_delete_audit"
|
||||
AFTER DELETE ON "albums"
|
||||
REFERENCING OLD TABLE AS "old"
|
||||
FOR EACH STATEMENT
|
||||
WHEN (pg_trigger_depth() = 0)
|
||||
EXECUTE FUNCTION albums_delete_audit();`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "album_users_delete_audit"
|
||||
AFTER DELETE ON "albums_shared_users_users"
|
||||
REFERENCING OLD TABLE AS "old"
|
||||
FOR EACH STATEMENT
|
||||
WHEN (pg_trigger_depth() <= 1)
|
||||
EXECUTE FUNCTION album_users_delete_audit();`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "album_user_after_insert"
|
||||
AFTER INSERT ON "albums_shared_users_users"
|
||||
REFERENCING NEW TABLE AS "inserted_rows"
|
||||
FOR EACH STATEMENT
|
||||
EXECUTE FUNCTION album_user_after_insert();`.execute(db);
|
||||
await sql`CREATE OR REPLACE TRIGGER "album_users_updated_at"
|
||||
BEFORE UPDATE ON "albums_shared_users_users"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION updated_at();`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await sql`DROP TRIGGER "albums_delete_audit" ON "albums";`.execute(db);
|
||||
await sql`DROP TRIGGER "album_users_delete_audit" ON "albums_shared_users_users";`.execute(db);
|
||||
await sql`DROP TRIGGER "album_user_after_insert" ON "albums_shared_users_users";`.execute(db);
|
||||
await sql`DROP INDEX "IDX_albums_audit_album_id";`.execute(db);
|
||||
await sql`DROP INDEX "IDX_albums_audit_user_id";`.execute(db);
|
||||
await sql`DROP INDEX "IDX_albums_audit_deleted_at";`.execute(db);
|
||||
await sql`DROP INDEX "IDX_album_users_audit_album_id";`.execute(db);
|
||||
await sql`DROP INDEX "IDX_album_users_audit_user_id";`.execute(db);
|
||||
await sql`DROP INDEX "IDX_album_users_audit_deleted_at";`.execute(db);
|
||||
await sql`ALTER TABLE "albums_audit" DROP CONSTRAINT "PK_c75efea8d4dce316ad29b851a8b";`.execute(db);
|
||||
await sql`ALTER TABLE "album_users_audit" DROP CONSTRAINT "PK_f479a2e575b7ebc9698362c1688";`.execute(db);
|
||||
await sql`DROP TABLE "albums_audit";`.execute(db);
|
||||
await sql`DROP TABLE "album_users_audit";`.execute(db);
|
||||
await sql`DROP FUNCTION album_user_after_insert;`.execute(db);
|
||||
await sql`DROP FUNCTION albums_delete_audit;`.execute(db);
|
||||
await sql`DROP FUNCTION album_users_delete_audit;`.execute(db);
|
||||
}
|
||||
17
server/src/schema/tables/album-audit.table.ts
Normal file
17
server/src/schema/tables/album-audit.table.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { Column, CreateDateColumn, Table } from 'src/sql-tools';
|
||||
|
||||
@Table('albums_audit')
|
||||
export class AlbumAuditTable {
|
||||
@PrimaryGeneratedUuidV7Column()
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'uuid', indexName: 'IDX_albums_audit_album_id' })
|
||||
albumId!: string;
|
||||
|
||||
@Column({ type: 'uuid', indexName: 'IDX_albums_audit_user_id' })
|
||||
userId!: string;
|
||||
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_albums_audit_deleted_at' })
|
||||
deletedAt!: Date;
|
||||
}
|
||||
17
server/src/schema/tables/album-user-audit.table.ts
Normal file
17
server/src/schema/tables/album-user-audit.table.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
|
||||
import { Column, CreateDateColumn, Table } from 'src/sql-tools';
|
||||
|
||||
@Table('album_users_audit')
|
||||
export class AlbumUserAuditTable {
|
||||
@PrimaryGeneratedUuidV7Column()
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'uuid', indexName: 'IDX_album_users_audit_album_id' })
|
||||
albumId!: string;
|
||||
|
||||
@Column({ type: 'uuid', indexName: 'IDX_album_users_audit_user_id' })
|
||||
userId!: string;
|
||||
|
||||
@CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_album_users_audit_deleted_at' })
|
||||
deletedAt!: Date;
|
||||
}
|
||||
|
|
@ -1,12 +1,36 @@
|
|||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AlbumUserRole } from 'src/enum';
|
||||
import { album_user_after_insert, album_users_delete_audit } from 'src/schema/functions';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
AfterInsertTrigger,
|
||||
Column,
|
||||
ForeignKeyColumn,
|
||||
Index,
|
||||
Table,
|
||||
UpdateDateColumn,
|
||||
} from 'src/sql-tools';
|
||||
|
||||
@Table({ name: 'albums_shared_users_users', primaryConstraintName: 'PK_7df55657e0b2e8b626330a0ebc8' })
|
||||
// Pre-existing indices from original album <--> user ManyToMany mapping
|
||||
@Index({ name: 'IDX_427c350ad49bd3935a50baab73', columns: ['albumsId'] })
|
||||
@Index({ name: 'IDX_f48513bf9bccefd6ff3ad30bd0', columns: ['usersId'] })
|
||||
@UpdatedAtTrigger('album_users_updated_at')
|
||||
@AfterInsertTrigger({
|
||||
name: 'album_user_after_insert',
|
||||
scope: 'statement',
|
||||
referencingNewTableAs: 'inserted_rows',
|
||||
function: album_user_after_insert,
|
||||
})
|
||||
@AfterDeleteTrigger({
|
||||
name: 'album_users_delete_audit',
|
||||
scope: 'statement',
|
||||
function: album_users_delete_audit,
|
||||
referencingOldTableAs: 'old',
|
||||
when: 'pg_trigger_depth() <= 1',
|
||||
})
|
||||
export class AlbumUserTable {
|
||||
@ForeignKeyColumn(() => AlbumTable, {
|
||||
onDelete: 'CASCADE',
|
||||
|
|
@ -26,4 +50,10 @@ export class AlbumUserTable {
|
|||
|
||||
@Column({ type: 'character varying', default: AlbumUserRole.EDITOR })
|
||||
role!: AlbumUserRole;
|
||||
|
||||
@UpdateIdColumn({ indexName: 'IDX_album_users_update_id' })
|
||||
updateId?: string;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
|
||||
import { AssetOrder } from 'src/enum';
|
||||
import { albums_delete_audit } from 'src/schema/functions';
|
||||
import { AssetTable } from 'src/schema/tables/asset.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import {
|
||||
AfterDeleteTrigger,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
DeleteDateColumn,
|
||||
|
|
@ -14,6 +16,13 @@ import {
|
|||
|
||||
@Table({ name: 'albums', primaryConstraintName: 'PK_7f71c7b5bc7c87b8f94c9a93a00' })
|
||||
@UpdatedAtTrigger('albums_updated_at')
|
||||
@AfterDeleteTrigger({
|
||||
name: 'albums_delete_audit',
|
||||
scope: 'statement',
|
||||
function: albums_delete_audit,
|
||||
referencingOldTableAs: 'old',
|
||||
when: 'pg_trigger_depth() = 0',
|
||||
})
|
||||
export class AlbumTable {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: string;
|
||||
|
|
|
|||
|
|
@ -24,13 +24,14 @@ import { fromAck, serialize } from 'src/utils/sync';
|
|||
|
||||
const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] };
|
||||
export const SYNC_TYPES_ORDER = [
|
||||
//
|
||||
SyncRequestType.UsersV1,
|
||||
SyncRequestType.PartnersV1,
|
||||
SyncRequestType.AssetsV1,
|
||||
SyncRequestType.AssetExifsV1,
|
||||
SyncRequestType.PartnerAssetsV1,
|
||||
SyncRequestType.PartnerAssetExifsV1,
|
||||
SyncRequestType.AlbumsV1,
|
||||
SyncRequestType.AlbumUsersV1,
|
||||
];
|
||||
|
||||
const throwSessionRequired = () => {
|
||||
|
|
@ -206,6 +207,43 @@ export class SyncService extends BaseService {
|
|||
break;
|
||||
}
|
||||
|
||||
case SyncRequestType.AlbumsV1: {
|
||||
const deletes = this.syncRepository.getAlbumDeletes(
|
||||
auth.user.id,
|
||||
checkpointMap[SyncEntityType.AlbumDeleteV1],
|
||||
);
|
||||
for await (const { id, ...data } of deletes) {
|
||||
response.write(serialize({ type: SyncEntityType.AlbumDeleteV1, updateId: id, data }));
|
||||
}
|
||||
|
||||
const upserts = this.syncRepository.getAlbumUpserts(auth.user.id, checkpointMap[SyncEntityType.AlbumV1]);
|
||||
for await (const { updateId, ...data } of upserts) {
|
||||
response.write(serialize({ type: SyncEntityType.AlbumV1, updateId, data }));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case SyncRequestType.AlbumUsersV1: {
|
||||
const deletes = this.syncRepository.getAlbumUserDeletes(
|
||||
auth.user.id,
|
||||
checkpointMap[SyncEntityType.AlbumUserDeleteV1],
|
||||
);
|
||||
for await (const { id, ...data } of deletes) {
|
||||
response.write(serialize({ type: SyncEntityType.AlbumUserDeleteV1, updateId: id, data }));
|
||||
}
|
||||
|
||||
const upserts = this.syncRepository.getAlbumUserUpserts(
|
||||
auth.user.id,
|
||||
checkpointMap[SyncEntityType.AlbumUserV1],
|
||||
);
|
||||
for await (const { updateId, ...data } of upserts) {
|
||||
response.write(serialize({ type: SyncEntityType.AlbumUserV1, updateId, data }));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
default: {
|
||||
this.logger.warn(`Unsupported sync type: ${type}`);
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
import { TriggerFunction, TriggerFunctionOptions } from 'src/sql-tools/from-code/decorators/trigger-function.decorator';
|
||||
|
||||
export const AfterInsertTrigger = (options: Omit<TriggerFunctionOptions, 'timing' | 'actions'>) =>
|
||||
TriggerFunction({
|
||||
timing: 'after',
|
||||
actions: ['insert'],
|
||||
...options,
|
||||
});
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
export { schemaDiff } from 'src/sql-tools/diff';
|
||||
export { schemaFromCode } from 'src/sql-tools/from-code';
|
||||
export * from 'src/sql-tools/from-code/decorators/after-delete.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/after-insert.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/before-update.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/check.decorator';
|
||||
export * from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue