mirror of
https://github.com/immich-app/immich.git
synced 2025-06-03 19:19:23 +02:00
WIP
This commit is contained in:
parent
867f6e64f9
commit
ac86975b04
14 changed files with 352 additions and 16 deletions
|
@ -12215,6 +12215,67 @@
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SyncAlbumDeleteV1": {
|
||||||
|
"properties": {
|
||||||
|
"albumId": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"albumId"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SyncAlbumV1": {
|
||||||
|
"properties": {
|
||||||
|
"createdAt": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"isActivityEnabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/AssetOrder"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ownerId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"thumbnailAssetId": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"updatedAt": {
|
||||||
|
"format": "date-time",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"createdAt",
|
||||||
|
"description",
|
||||||
|
"id",
|
||||||
|
"isActivityEnabled",
|
||||||
|
"name",
|
||||||
|
"order",
|
||||||
|
"ownerId",
|
||||||
|
"thumbnailAssetId",
|
||||||
|
"updatedAt"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SyncAssetDeleteV1": {
|
"SyncAssetDeleteV1": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"assetId": {
|
"assetId": {
|
||||||
|
@ -12441,7 +12502,9 @@
|
||||||
"AssetExifV1",
|
"AssetExifV1",
|
||||||
"PartnerAssetV1",
|
"PartnerAssetV1",
|
||||||
"PartnerAssetDeleteV1",
|
"PartnerAssetDeleteV1",
|
||||||
"PartnerAssetExifV1"
|
"PartnerAssetExifV1",
|
||||||
|
"AlbumV1",
|
||||||
|
"AlbumDeleteV1"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
@ -12486,7 +12549,8 @@
|
||||||
"AssetsV1",
|
"AssetsV1",
|
||||||
"AssetExifsV1",
|
"AssetExifsV1",
|
||||||
"PartnerAssetsV1",
|
"PartnerAssetsV1",
|
||||||
"PartnerAssetExifsV1"
|
"PartnerAssetExifsV1",
|
||||||
|
"AlbumsV1"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
8
server/src/db.d.ts
vendored
8
server/src/db.d.ts
vendored
|
@ -74,6 +74,13 @@ export interface Albums {
|
||||||
updateId: Generated<string>;
|
updateId: Generated<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AlbumsAudit {
|
||||||
|
deletedAt: Generated<Timestamp>;
|
||||||
|
id: Generated<string>;
|
||||||
|
albumId: string;
|
||||||
|
ownerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AlbumsAssetsAssets {
|
export interface AlbumsAssetsAssets {
|
||||||
albumsId: string;
|
albumsId: string;
|
||||||
assetsId: string;
|
assetsId: string;
|
||||||
|
@ -463,6 +470,7 @@ export interface VersionHistory {
|
||||||
export interface DB {
|
export interface DB {
|
||||||
activity: Activity;
|
activity: Activity;
|
||||||
albums: Albums;
|
albums: Albums;
|
||||||
|
albums_audit: AlbumsAudit;
|
||||||
albums_assets_assets: AlbumsAssetsAssets;
|
albums_assets_assets: AlbumsAssetsAssets;
|
||||||
albums_shared_users_users: AlbumsSharedUsersUsers;
|
albums_shared_users_users: AlbumsSharedUsersUsers;
|
||||||
api_keys: ApiKeys;
|
api_keys: ApiKeys;
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsEnum, IsInt, IsPositive, IsString } from 'class-validator';
|
import { IsEnum, IsInt, IsPositive, IsString } from 'class-validator';
|
||||||
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
|
||||||
import { AssetType, AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum';
|
import { AssetOrder, AssetType, AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum';
|
||||||
import { Optional, ValidateDate, ValidateUUID } from 'src/validation';
|
import { Optional, ValidateDate, ValidateUUID } from 'src/validation';
|
||||||
|
|
||||||
export class AssetFullSyncDto {
|
export class AssetFullSyncDto {
|
||||||
|
@ -112,6 +112,23 @@ export class SyncAssetExifV1 {
|
||||||
fps!: number | null;
|
fps!: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SyncAlbumDeleteV1 {
|
||||||
|
albumId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = {
|
export type SyncItem = {
|
||||||
[SyncEntityType.UserV1]: SyncUserV1;
|
[SyncEntityType.UserV1]: SyncUserV1;
|
||||||
[SyncEntityType.UserDeleteV1]: SyncUserDeleteV1;
|
[SyncEntityType.UserDeleteV1]: SyncUserDeleteV1;
|
||||||
|
@ -123,10 +140,11 @@ export type SyncItem = {
|
||||||
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
|
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
|
||||||
[SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;
|
[SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;
|
||||||
[SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1;
|
[SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1;
|
||||||
|
[SyncEntityType.AlbumV1]: SyncAlbumV1;
|
||||||
|
[SyncEntityType.AlbumDeleteV1]: SyncAlbumDeleteV1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const responseDtos = [
|
const responseDtos = [
|
||||||
//
|
|
||||||
SyncUserV1,
|
SyncUserV1,
|
||||||
SyncUserDeleteV1,
|
SyncUserDeleteV1,
|
||||||
SyncPartnerV1,
|
SyncPartnerV1,
|
||||||
|
@ -134,6 +152,8 @@ const responseDtos = [
|
||||||
SyncAssetV1,
|
SyncAssetV1,
|
||||||
SyncAssetDeleteV1,
|
SyncAssetDeleteV1,
|
||||||
SyncAssetExifV1,
|
SyncAssetExifV1,
|
||||||
|
SyncAlbumV1,
|
||||||
|
SyncAlbumDeleteV1,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const extraSyncModels = responseDtos;
|
export const extraSyncModels = responseDtos;
|
||||||
|
|
|
@ -574,6 +574,7 @@ export enum SyncRequestType {
|
||||||
AssetExifsV1 = 'AssetExifsV1',
|
AssetExifsV1 = 'AssetExifsV1',
|
||||||
PartnerAssetsV1 = 'PartnerAssetsV1',
|
PartnerAssetsV1 = 'PartnerAssetsV1',
|
||||||
PartnerAssetExifsV1 = 'PartnerAssetExifsV1',
|
PartnerAssetExifsV1 = 'PartnerAssetExifsV1',
|
||||||
|
AlbumsV1 = 'AlbumsV1',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SyncEntityType {
|
export enum SyncEntityType {
|
||||||
|
@ -590,6 +591,9 @@ export enum SyncEntityType {
|
||||||
PartnerAssetV1 = 'PartnerAssetV1',
|
PartnerAssetV1 = 'PartnerAssetV1',
|
||||||
PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1',
|
PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1',
|
||||||
PartnerAssetExifV1 = 'PartnerAssetExifV1',
|
PartnerAssetExifV1 = 'PartnerAssetExifV1',
|
||||||
|
|
||||||
|
AlbumV1 = 'AlbumV1',
|
||||||
|
AlbumDeleteV1 = 'AlbumDeleteV1',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum NotificationLevel {
|
export enum NotificationLevel {
|
||||||
|
|
|
@ -7,8 +7,8 @@ import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
import { SyncEntityType } from 'src/enum';
|
import { SyncEntityType } from 'src/enum';
|
||||||
import { SyncAck } from 'src/types';
|
import { SyncAck } from 'src/types';
|
||||||
|
|
||||||
type auditTables = 'users_audit' | 'partners_audit' | 'assets_audit';
|
type AuditTables = 'users_audit' | 'partners_audit' | 'assets_audit' | 'albums_audit';
|
||||||
type upsertTables = 'users' | 'partners' | 'assets' | 'exif';
|
type UpsertTables = 'users' | 'partners' | 'assets' | 'exif' | 'albums';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SyncRepository {
|
export class SyncRepository {
|
||||||
|
@ -154,19 +154,52 @@ export class SyncRepository {
|
||||||
.stream();
|
.stream();
|
||||||
}
|
}
|
||||||
|
|
||||||
private auditTableFilters<T extends keyof Pick<DB, auditTables>, D>(qb: SelectQueryBuilder<DB, T, D>, ack?: SyncAck) {
|
@GenerateSql({ params: [DummyValue.UUID], stream: true })
|
||||||
const builder = qb as SelectQueryBuilder<DB, auditTables, D>;
|
getAlbumDeletes(userId: string, ack?: SyncAck) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('albums_audit')
|
||||||
|
.select(['id', 'albumId'])
|
||||||
|
.where('ownerId', '=', userId)
|
||||||
|
.$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId))
|
||||||
|
.$call((qb) => this.auditTableFilters(qb, ack))
|
||||||
|
.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GenerateSql({ params: [DummyValue.UUID], stream: true })
|
||||||
|
getAlbumUpserts(userId: string, ack?: SyncAck) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('albums')
|
||||||
|
.$call((qb) => this.upsertTableFilters(qb, ack))
|
||||||
|
.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();
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
return builder
|
||||||
.where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
|
.where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
|
||||||
.$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId))
|
.$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId))
|
||||||
.orderBy('id', 'asc') as SelectQueryBuilder<DB, T, D>;
|
.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>,
|
qb: SelectQueryBuilder<DB, T, D>,
|
||||||
ack?: SyncAck,
|
ack?: SyncAck,
|
||||||
) {
|
) {
|
||||||
const builder = qb as SelectQueryBuilder<DB, upsertTables, D>;
|
const builder = qb as SelectQueryBuilder<DB, UpsertTables, D>;
|
||||||
return builder
|
return builder
|
||||||
.where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
|
.where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
|
||||||
.$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId))
|
.$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId))
|
||||||
|
|
|
@ -23,6 +23,19 @@ export const immich_uuid_v7 = registerFunction({
|
||||||
synchronize: false,
|
synchronize: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const album_after_insert = registerFunction({
|
||||||
|
name: 'album_after_insert',
|
||||||
|
returnType: 'TRIGGER',
|
||||||
|
language: 'PLPGSQL',
|
||||||
|
body: `
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO users_audit ("userId")
|
||||||
|
SELECT "id"
|
||||||
|
FROM OLD;
|
||||||
|
RETURN NULL;
|
||||||
|
END`,
|
||||||
|
});
|
||||||
|
|
||||||
export const updated_at = registerFunction({
|
export const updated_at = registerFunction({
|
||||||
name: 'updated_at',
|
name: 'updated_at',
|
||||||
returnType: 'TRIGGER',
|
returnType: 'TRIGGER',
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { AssetVisibility } from 'src/enum';
|
import { AssetVisibility } from 'src/enum';
|
||||||
import { asset_face_source_type, assets_status_enum } from 'src/schema/enums';
|
import { asset_face_source_type, assets_status_enum } from 'src/schema/enums';
|
||||||
import {
|
import {
|
||||||
|
album_after_insert,
|
||||||
assets_delete_audit,
|
assets_delete_audit,
|
||||||
f_concat_ws,
|
f_concat_ws,
|
||||||
f_unaccent,
|
f_unaccent,
|
||||||
|
@ -46,7 +47,7 @@ import { UserAuditTable } from 'src/schema/tables/user-audit.table';
|
||||||
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
|
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
|
||||||
import { UserTable } from 'src/schema/tables/user.table';
|
import { UserTable } from 'src/schema/tables/user.table';
|
||||||
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
|
import { VersionHistoryTable } from 'src/schema/tables/version-history.table';
|
||||||
import { ConfigurationParameter, Database, Extensions, registerEnum } from 'src/sql-tools';
|
import { Database, Extensions, registerEnum } from 'src/sql-tools';
|
||||||
|
|
||||||
export const asset_visibility_enum = registerEnum({
|
export const asset_visibility_enum = registerEnum({
|
||||||
name: 'asset_visibility_enum',
|
name: 'asset_visibility_enum',
|
||||||
|
@ -54,7 +55,6 @@ export const asset_visibility_enum = registerEnum({
|
||||||
});
|
});
|
||||||
|
|
||||||
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
|
@Extensions(['uuid-ossp', 'unaccent', 'cube', 'earthdistance', 'pg_trgm', 'plpgsql'])
|
||||||
@ConfigurationParameter({ name: 'search_path', value: () => '"$user", public, vectors', scope: 'database' })
|
|
||||||
@Database({ name: 'immich' })
|
@Database({ name: 'immich' })
|
||||||
export class ImmichDatabase {
|
export class ImmichDatabase {
|
||||||
tables = [
|
tables = [
|
||||||
|
@ -105,6 +105,7 @@ export class ImmichDatabase {
|
||||||
users_delete_audit,
|
users_delete_audit,
|
||||||
partners_delete_audit,
|
partners_delete_audit,
|
||||||
assets_delete_audit,
|
assets_delete_audit,
|
||||||
|
album_after_insert,
|
||||||
];
|
];
|
||||||
|
|
||||||
enum = [assets_status_enum, asset_face_source_type];
|
enum = [assets_status_enum, asset_face_source_type];
|
||||||
|
|
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_owner_id' })
|
||||||
|
ownerId!: string;
|
||||||
|
|
||||||
|
@CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_albums_audit_deleted_at' })
|
||||||
|
deletedAt!: Date;
|
||||||
|
}
|
|
@ -1,12 +1,18 @@
|
||||||
import { AlbumUserRole } from 'src/enum';
|
import { AlbumUserRole } from 'src/enum';
|
||||||
|
import { album_after_insert } from 'src/schema/functions';
|
||||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||||
import { UserTable } from 'src/schema/tables/user.table';
|
import { UserTable } from 'src/schema/tables/user.table';
|
||||||
import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
|
import { AfterInsertTrigger, Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
|
||||||
|
|
||||||
@Table({ name: 'albums_shared_users_users', primaryConstraintName: 'PK_7df55657e0b2e8b626330a0ebc8' })
|
@Table({ name: 'albums_shared_users_users', primaryConstraintName: 'PK_7df55657e0b2e8b626330a0ebc8' })
|
||||||
// Pre-existing indices from original album <--> user ManyToMany mapping
|
// Pre-existing indices from original album <--> user ManyToMany mapping
|
||||||
@Index({ name: 'IDX_427c350ad49bd3935a50baab73', columns: ['albumsId'] })
|
@Index({ name: 'IDX_427c350ad49bd3935a50baab73', columns: ['albumsId'] })
|
||||||
@Index({ name: 'IDX_f48513bf9bccefd6ff3ad30bd0', columns: ['usersId'] })
|
@Index({ name: 'IDX_f48513bf9bccefd6ff3ad30bd0', columns: ['usersId'] })
|
||||||
|
@AfterInsertTrigger({
|
||||||
|
name: 'albums_after_insert',
|
||||||
|
scope: 'statement',
|
||||||
|
function: album_after_insert,
|
||||||
|
})
|
||||||
export class AlbumUserTable {
|
export class AlbumUserTable {
|
||||||
@ForeignKeyColumn(() => AlbumTable, {
|
@ForeignKeyColumn(() => AlbumTable, {
|
||||||
onDelete: 'CASCADE',
|
onDelete: 'CASCADE',
|
||||||
|
|
|
@ -23,13 +23,13 @@ import { fromAck, serialize } from 'src/utils/sync';
|
||||||
|
|
||||||
const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] };
|
const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] };
|
||||||
export const SYNC_TYPES_ORDER = [
|
export const SYNC_TYPES_ORDER = [
|
||||||
//
|
|
||||||
SyncRequestType.UsersV1,
|
SyncRequestType.UsersV1,
|
||||||
SyncRequestType.PartnersV1,
|
SyncRequestType.PartnersV1,
|
||||||
SyncRequestType.AssetsV1,
|
SyncRequestType.AssetsV1,
|
||||||
SyncRequestType.AssetExifsV1,
|
SyncRequestType.AssetExifsV1,
|
||||||
SyncRequestType.PartnerAssetsV1,
|
SyncRequestType.PartnerAssetsV1,
|
||||||
SyncRequestType.PartnerAssetExifsV1,
|
SyncRequestType.PartnerAssetExifsV1,
|
||||||
|
SyncRequestType.AlbumsV1,
|
||||||
];
|
];
|
||||||
|
|
||||||
const throwSessionRequired = () => {
|
const throwSessionRequired = () => {
|
||||||
|
@ -205,6 +205,23 @@ export class SyncService extends BaseService {
|
||||||
break;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
this.logger.warn(`Unsupported sync type: ${type}`);
|
this.logger.warn(`Unsupported sync type: ${type}`);
|
||||||
break;
|
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 { schemaDiff } from 'src/sql-tools/diff';
|
||||||
export { schemaFromCode } from 'src/sql-tools/from-code';
|
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-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/before-update.decorator';
|
||||||
export * from 'src/sql-tools/from-code/decorators/check.decorator';
|
export * from 'src/sql-tools/from-code/decorators/check.decorator';
|
||||||
export * from 'src/sql-tools/from-code/decorators/column.decorator';
|
export * from 'src/sql-tools/from-code/decorators/column.decorator';
|
||||||
|
|
|
@ -4,9 +4,10 @@ import { DateTime } from 'luxon';
|
||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { Writable } from 'node:stream';
|
import { Writable } from 'node:stream';
|
||||||
import { AssetFace } from 'src/database';
|
import { AssetFace } from 'src/database';
|
||||||
import { AssetJobStatus, Assets, DB, FaceSearch, Person, Sessions } from 'src/db';
|
import { Albums, AssetJobStatus, Assets, DB, FaceSearch, Person, Sessions } from 'src/db';
|
||||||
import { AssetType, AssetVisibility, SourceType } from 'src/enum';
|
import { AssetType, AssetVisibility, SourceType } from 'src/enum';
|
||||||
import { ActivityRepository } from 'src/repositories/activity.repository';
|
import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||||
|
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||||
|
@ -39,6 +40,7 @@ const sha256 = (value: string) => createHash('sha256').update(value).digest('bas
|
||||||
type RepositoriesTypes = {
|
type RepositoriesTypes = {
|
||||||
activity: ActivityRepository;
|
activity: ActivityRepository;
|
||||||
album: AlbumRepository;
|
album: AlbumRepository;
|
||||||
|
albumUser: AlbumUserRepository;
|
||||||
asset: AssetRepository;
|
asset: AssetRepository;
|
||||||
assetJob: AssetJobRepository;
|
assetJob: AssetJobRepository;
|
||||||
config: ConfigRepository;
|
config: ConfigRepository;
|
||||||
|
@ -125,6 +127,14 @@ export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kys
|
||||||
return new ActivityRepository(db);
|
return new ActivityRepository(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case 'album': {
|
||||||
|
return new AlbumRepository(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'albumUser': {
|
||||||
|
return new AlbumUserRepository(db);
|
||||||
|
}
|
||||||
|
|
||||||
case 'asset': {
|
case 'asset': {
|
||||||
return new AssetRepository(db);
|
return new AssetRepository(db);
|
||||||
}
|
}
|
||||||
|
@ -380,6 +390,19 @@ const assetInsert = (asset: Partial<Insertable<Assets>> = {}) => {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const albumInsert = (album: Partial<Insertable<Albums>> & { ownerId: string }) => {
|
||||||
|
const id = album.id || newUuid();
|
||||||
|
const defaults: Omit<Insertable<Albums>, 'ownerId'> = {
|
||||||
|
albumName: 'Album',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
...album,
|
||||||
|
id,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const faceInsert = (face: Partial<Insertable<FaceSearch>> & { faceId: string }) => {
|
const faceInsert = (face: Partial<Insertable<FaceSearch>> & { faceId: string }) => {
|
||||||
const defaults = {
|
const defaults = {
|
||||||
faceId: face.faceId,
|
faceId: face.faceId,
|
||||||
|
@ -502,6 +525,7 @@ export const mediumFactory = {
|
||||||
assetInsert,
|
assetInsert,
|
||||||
assetFaceInsert,
|
assetFaceInsert,
|
||||||
assetJobStatusInsert,
|
assetJobStatusInsert,
|
||||||
|
albumInsert,
|
||||||
faceInsert,
|
faceInsert,
|
||||||
personInsert,
|
personInsert,
|
||||||
sessionInsert,
|
sessionInsert,
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { SyncEntityType, SyncRequestType } from 'src/enum';
|
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
|
||||||
import { SYNC_TYPES_ORDER, SyncService } from 'src/services/sync.service';
|
import { SYNC_TYPES_ORDER, SyncService } from 'src/services/sync.service';
|
||||||
import { mediumFactory, newMediumService } from 'test/medium.factory';
|
import { mediumFactory, newMediumService } from 'test/medium.factory';
|
||||||
import { factory } from 'test/small.factory';
|
import { factory } from 'test/small.factory';
|
||||||
|
@ -907,4 +907,124 @@ describe(SyncService.name, () => {
|
||||||
await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
|
await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe.concurrent(SyncRequestType.AlbumsV1, () => {
|
||||||
|
it('should sync an album with the correct properties', async () => {
|
||||||
|
const { auth, getRepository, testSync } = await setup();
|
||||||
|
const albumRepo = getRepository('album');
|
||||||
|
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
|
||||||
|
await albumRepo.create(album, [], []);
|
||||||
|
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
|
||||||
|
{
|
||||||
|
ack: expect.any(String),
|
||||||
|
data: expect.objectContaining({
|
||||||
|
id: album.id,
|
||||||
|
name: album.albumName,
|
||||||
|
ownerId: album.ownerId,
|
||||||
|
}),
|
||||||
|
type: SyncEntityType.AlbumV1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect and sync a new album', async () => {
|
||||||
|
const { auth, getRepository, testSync } = await setup();
|
||||||
|
const albumRepo = getRepository('album');
|
||||||
|
const album = mediumFactory.albumInsert({ ownerId: auth.user.id });
|
||||||
|
await albumRepo.create(album, [], []);
|
||||||
|
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
|
||||||
|
{
|
||||||
|
ack: expect.any(String),
|
||||||
|
data: expect.objectContaining({
|
||||||
|
id: album.id,
|
||||||
|
}),
|
||||||
|
type: SyncEntityType.AlbumV1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('shared albums', () => {
|
||||||
|
it('should detect and sync an album create', async () => {
|
||||||
|
const { auth, getRepository, testSync } = await setup();
|
||||||
|
const albumRepo = getRepository('album');
|
||||||
|
const userRepo = getRepository('user');
|
||||||
|
|
||||||
|
const user2 = mediumFactory.userInsert();
|
||||||
|
await userRepo.create(user2);
|
||||||
|
|
||||||
|
const album = mediumFactory.albumInsert({ ownerId: user2.id });
|
||||||
|
await albumRepo.create(album, [], [{ userId: auth.user.id, role: AlbumUserRole.EDITOR }]);
|
||||||
|
|
||||||
|
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
|
||||||
|
{
|
||||||
|
ack: expect.any(String),
|
||||||
|
data: expect.objectContaining({ id: album.id }),
|
||||||
|
type: SyncEntityType.AlbumV1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect and sync an recently shared album', async () => {
|
||||||
|
const { auth, getRepository, testSync } = await setup();
|
||||||
|
const albumRepo = getRepository('album');
|
||||||
|
const albumUserRepo = getRepository('albumUser');
|
||||||
|
const userRepo = getRepository('user');
|
||||||
|
|
||||||
|
const user2 = mediumFactory.userInsert();
|
||||||
|
await userRepo.create(user2);
|
||||||
|
|
||||||
|
const album = mediumFactory.albumInsert({ ownerId: user2.id });
|
||||||
|
await albumRepo.create(album, [], []);
|
||||||
|
await albumUserRepo.create({ usersId: auth.user.id, albumsId: album.id, role: AlbumUserRole.EDITOR });
|
||||||
|
|
||||||
|
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
|
||||||
|
{
|
||||||
|
ack: expect.any(String),
|
||||||
|
data: expect.objectContaining({ id: album.id }),
|
||||||
|
type: SyncEntityType.AlbumV1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle sharing an album that was created before the last the sync', async () => {
|
||||||
|
const { auth, getRepository, sut, testSync } = await setup();
|
||||||
|
const albumRepo = getRepository('album');
|
||||||
|
const albumUserRepo = getRepository('albumUser');
|
||||||
|
const userRepo = getRepository('user');
|
||||||
|
|
||||||
|
const user2 = mediumFactory.userInsert();
|
||||||
|
await userRepo.create(user2);
|
||||||
|
|
||||||
|
const userAlbum = mediumFactory.albumInsert({ ownerId: auth.user.id });
|
||||||
|
const user2Album = mediumFactory.albumInsert({ ownerId: user2.id });
|
||||||
|
await Promise.all([albumRepo.create(user2Album, [], []), albumRepo.create(userAlbum, [], [])]);
|
||||||
|
|
||||||
|
const initialSyncResponse = await testSync(auth, [SyncRequestType.AlbumsV1]);
|
||||||
|
|
||||||
|
expect(initialSyncResponse).toEqual([
|
||||||
|
{
|
||||||
|
ack: expect.any(String),
|
||||||
|
data: expect.objectContaining({ id: userAlbum.id }),
|
||||||
|
type: SyncEntityType.AlbumV1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const acks = [initialSyncResponse[0].ack];
|
||||||
|
await sut.setAcks(auth, { acks });
|
||||||
|
|
||||||
|
await albumUserRepo.create({ usersId: auth.user.id, albumsId: user2Album.id, role: AlbumUserRole.EDITOR });
|
||||||
|
|
||||||
|
await expect(testSync(auth, [SyncRequestType.AlbumsV1])).resolves.toEqual([
|
||||||
|
{
|
||||||
|
ack: expect.any(String),
|
||||||
|
data: expect.objectContaining({ id: user2Album.id }),
|
||||||
|
type: SyncEntityType.AlbumV1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// describe.concurrent(SyncRequestType.AlbumsV1, () => {
|
||||||
|
// });
|
||||||
});
|
});
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue