mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
feat: sync assets, partner assets, exif, and partner exif (#16658)
* feat: sync assets, partner assets, exif, and partner exif Co-authored-by: Zack Pollard <zack@futo.org> Co-authored-by: Alex Tran <alex.tran1502@gmail.com> * refactor: remove duplicate where clause and orderBy statements in sync queries * fix: asset deletes not filtering by ownerId --------- Co-authored-by: Zack Pollard <zack@futo.org> Co-authored-by: Alex Tran <alex.tran1502@gmail.com> Co-authored-by: Zack Pollard <zackpollard@ymail.com>
This commit is contained in:
parent
e97df503f2
commit
a96bba4b26
28 changed files with 1230 additions and 46 deletions
BIN
mobile/openapi/README.md
generated
BIN
mobile/openapi/README.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api.dart
generated
BIN
mobile/openapi/lib/api.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/api_client.dart
generated
BIN
mobile/openapi/lib/api_client.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/sync_asset_delete_v1.dart
generated
Normal file
BIN
mobile/openapi/lib/model/sync_asset_delete_v1.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/sync_asset_exif_v1.dart
generated
Normal file
BIN
mobile/openapi/lib/model/sync_asset_exif_v1.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/sync_asset_v1.dart
generated
Normal file
BIN
mobile/openapi/lib/model/sync_asset_v1.dart
generated
Normal file
Binary file not shown.
BIN
mobile/openapi/lib/model/sync_entity_type.dart
generated
BIN
mobile/openapi/lib/model/sync_entity_type.dart
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/sync_request_type.dart
generated
BIN
mobile/openapi/lib/model/sync_request_type.dart
generated
Binary file not shown.
|
|
@ -12049,12 +12049,228 @@
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"SyncAssetDeleteV1": {
|
||||||
|
"properties": {
|
||||||
|
"assetId": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"assetId"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SyncAssetExifV1": {
|
||||||
|
"properties": {
|
||||||
|
"assetId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"city": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"country": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"dateTimeOriginal": {
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"exifImageHeight": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"exifImageWidth": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"exposureTime": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"fNumber": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"fileSizeInByte": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"focalLength": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"fps": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"iso": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"latitude": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"lensModel": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"longitude": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"make": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"modifyDate": {
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"orientation": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"profileDescription": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"projectionType": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"rating": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"state": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"timeZone": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"assetId",
|
||||||
|
"city",
|
||||||
|
"country",
|
||||||
|
"dateTimeOriginal",
|
||||||
|
"description",
|
||||||
|
"exifImageHeight",
|
||||||
|
"exifImageWidth",
|
||||||
|
"exposureTime",
|
||||||
|
"fNumber",
|
||||||
|
"fileSizeInByte",
|
||||||
|
"focalLength",
|
||||||
|
"fps",
|
||||||
|
"iso",
|
||||||
|
"latitude",
|
||||||
|
"lensModel",
|
||||||
|
"longitude",
|
||||||
|
"make",
|
||||||
|
"model",
|
||||||
|
"modifyDate",
|
||||||
|
"orientation",
|
||||||
|
"profileDescription",
|
||||||
|
"projectionType",
|
||||||
|
"rating",
|
||||||
|
"state",
|
||||||
|
"timeZone"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
|
"SyncAssetV1": {
|
||||||
|
"properties": {
|
||||||
|
"checksum": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"deletedAt": {
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"fileCreatedAt": {
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"fileModifiedAt": {
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"isFavorite": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"isVisible": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"localDateTime": {
|
||||||
|
"format": "date-time",
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"ownerId": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"thumbhash": {
|
||||||
|
"nullable": true,
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"IMAGE",
|
||||||
|
"VIDEO",
|
||||||
|
"AUDIO",
|
||||||
|
"OTHER"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"checksum",
|
||||||
|
"deletedAt",
|
||||||
|
"fileCreatedAt",
|
||||||
|
"fileModifiedAt",
|
||||||
|
"id",
|
||||||
|
"isFavorite",
|
||||||
|
"isVisible",
|
||||||
|
"localDateTime",
|
||||||
|
"ownerId",
|
||||||
|
"thumbhash",
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"SyncEntityType": {
|
"SyncEntityType": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"UserV1",
|
"UserV1",
|
||||||
"UserDeleteV1",
|
"UserDeleteV1",
|
||||||
"PartnerV1",
|
"PartnerV1",
|
||||||
"PartnerDeleteV1"
|
"PartnerDeleteV1",
|
||||||
|
"AssetV1",
|
||||||
|
"AssetDeleteV1",
|
||||||
|
"AssetExifV1",
|
||||||
|
"PartnerAssetV1",
|
||||||
|
"PartnerAssetDeleteV1",
|
||||||
|
"PartnerAssetExifV1"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
@ -12095,7 +12311,11 @@
|
||||||
"SyncRequestType": {
|
"SyncRequestType": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"UsersV1",
|
"UsersV1",
|
||||||
"PartnersV1"
|
"PartnersV1",
|
||||||
|
"AssetsV1",
|
||||||
|
"AssetExifsV1",
|
||||||
|
"PartnerAssetsV1",
|
||||||
|
"PartnerAssetExifsV1"
|
||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3647,11 +3647,21 @@ export enum SyncEntityType {
|
||||||
UserV1 = "UserV1",
|
UserV1 = "UserV1",
|
||||||
UserDeleteV1 = "UserDeleteV1",
|
UserDeleteV1 = "UserDeleteV1",
|
||||||
PartnerV1 = "PartnerV1",
|
PartnerV1 = "PartnerV1",
|
||||||
PartnerDeleteV1 = "PartnerDeleteV1"
|
PartnerDeleteV1 = "PartnerDeleteV1",
|
||||||
|
AssetV1 = "AssetV1",
|
||||||
|
AssetDeleteV1 = "AssetDeleteV1",
|
||||||
|
AssetExifV1 = "AssetExifV1",
|
||||||
|
PartnerAssetV1 = "PartnerAssetV1",
|
||||||
|
PartnerAssetDeleteV1 = "PartnerAssetDeleteV1",
|
||||||
|
PartnerAssetExifV1 = "PartnerAssetExifV1"
|
||||||
}
|
}
|
||||||
export enum SyncRequestType {
|
export enum SyncRequestType {
|
||||||
UsersV1 = "UsersV1",
|
UsersV1 = "UsersV1",
|
||||||
PartnersV1 = "PartnersV1"
|
PartnersV1 = "PartnersV1",
|
||||||
|
AssetsV1 = "AssetsV1",
|
||||||
|
AssetExifsV1 = "AssetExifsV1",
|
||||||
|
PartnerAssetsV1 = "PartnerAssetsV1",
|
||||||
|
PartnerAssetExifsV1 = "PartnerAssetExifsV1"
|
||||||
}
|
}
|
||||||
export enum TranscodeHWAccel {
|
export enum TranscodeHWAccel {
|
||||||
Nvenc = "nvenc",
|
Nvenc = "nvenc",
|
||||||
|
|
|
||||||
|
|
@ -117,4 +117,46 @@ export const columns = {
|
||||||
userDto: ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'],
|
userDto: ['id', 'name', 'email', 'profileImagePath', 'profileChangedAt'],
|
||||||
tagDto: ['id', 'value', 'createdAt', 'updatedAt', 'color', 'parentId'],
|
tagDto: ['id', 'value', 'createdAt', 'updatedAt', 'color', 'parentId'],
|
||||||
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
|
apiKey: ['id', 'name', 'userId', 'createdAt', 'updatedAt', 'permissions'],
|
||||||
|
syncAsset: [
|
||||||
|
'id',
|
||||||
|
'ownerId',
|
||||||
|
'thumbhash',
|
||||||
|
'checksum',
|
||||||
|
'fileCreatedAt',
|
||||||
|
'fileModifiedAt',
|
||||||
|
'localDateTime',
|
||||||
|
'type',
|
||||||
|
'deletedAt',
|
||||||
|
'isFavorite',
|
||||||
|
'isVisible',
|
||||||
|
'updateId',
|
||||||
|
],
|
||||||
|
syncAssetExif: [
|
||||||
|
'exif.assetId',
|
||||||
|
'exif.description',
|
||||||
|
'exif.exifImageWidth',
|
||||||
|
'exif.exifImageHeight',
|
||||||
|
'exif.fileSizeInByte',
|
||||||
|
'exif.orientation',
|
||||||
|
'exif.dateTimeOriginal',
|
||||||
|
'exif.modifyDate',
|
||||||
|
'exif.timeZone',
|
||||||
|
'exif.latitude',
|
||||||
|
'exif.longitude',
|
||||||
|
'exif.projectionType',
|
||||||
|
'exif.city',
|
||||||
|
'exif.state',
|
||||||
|
'exif.country',
|
||||||
|
'exif.make',
|
||||||
|
'exif.model',
|
||||||
|
'exif.lensModel',
|
||||||
|
'exif.fNumber',
|
||||||
|
'exif.focalLength',
|
||||||
|
'exif.iso',
|
||||||
|
'exif.exposureTime',
|
||||||
|
'exif.profileDescription',
|
||||||
|
'exif.rating',
|
||||||
|
'exif.fps',
|
||||||
|
'exif.updateId',
|
||||||
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
|
||||||
10
server/src/db.d.ts
vendored
10
server/src/db.d.ts
vendored
|
|
@ -119,6 +119,13 @@ export interface AssetJobStatus {
|
||||||
thumbnailAt: Timestamp | null;
|
thumbnailAt: Timestamp | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AssetsAudit {
|
||||||
|
deletedAt: Generated<Timestamp>;
|
||||||
|
id: Generated<string>;
|
||||||
|
assetId: string;
|
||||||
|
ownerId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Assets {
|
export interface Assets {
|
||||||
checksum: Buffer;
|
checksum: Buffer;
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
|
|
@ -168,6 +175,8 @@ export interface Audit {
|
||||||
|
|
||||||
export interface Exif {
|
export interface Exif {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
|
updateId: Generated<string>;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
autoStackId: string | null;
|
autoStackId: string | null;
|
||||||
bitsPerSample: number | null;
|
bitsPerSample: number | null;
|
||||||
city: string | null;
|
city: string | null;
|
||||||
|
|
@ -459,6 +468,7 @@ export interface DB {
|
||||||
asset_job_status: AssetJobStatus;
|
asset_job_status: AssetJobStatus;
|
||||||
asset_stack: AssetStack;
|
asset_stack: AssetStack;
|
||||||
assets: Assets;
|
assets: Assets;
|
||||||
|
assets_audit: AssetsAudit;
|
||||||
audit: Audit;
|
audit: Audit;
|
||||||
exif: Exif;
|
exif: Exif;
|
||||||
face_search: FaceSearch;
|
face_search: FaceSearch;
|
||||||
|
|
|
||||||
|
|
@ -102,7 +102,7 @@ const mapStack = (entity: AssetEntity) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings
|
// if an asset is jsonified in the DB before being returned, its buffer fields will be hex-encoded strings
|
||||||
const hexOrBufferToBase64 = (encoded: string | Buffer) => {
|
export const hexOrBufferToBase64 = (encoded: string | Buffer) => {
|
||||||
if (typeof encoded === 'string') {
|
if (typeof encoded === 'string') {
|
||||||
return Buffer.from(encoded.slice(2), 'hex').toString('base64');
|
return Buffer.from(encoded.slice(2), 'hex').toString('base64');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { SyncEntityType, SyncRequestType } from 'src/enum';
|
import { AssetType, 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 {
|
||||||
|
|
@ -56,11 +56,73 @@ export class SyncPartnerDeleteV1 {
|
||||||
sharedWithId!: string;
|
sharedWithId!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SyncAssetV1 {
|
||||||
|
id!: string;
|
||||||
|
ownerId!: string;
|
||||||
|
thumbhash!: string | null;
|
||||||
|
checksum!: string;
|
||||||
|
fileCreatedAt!: Date | null;
|
||||||
|
fileModifiedAt!: Date | null;
|
||||||
|
localDateTime!: Date | null;
|
||||||
|
type!: AssetType;
|
||||||
|
deletedAt!: Date | null;
|
||||||
|
isFavorite!: boolean;
|
||||||
|
isVisible!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncAssetDeleteV1 {
|
||||||
|
assetId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncAssetExifV1 {
|
||||||
|
assetId!: string;
|
||||||
|
description!: string | null;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
exifImageWidth!: number | null;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
exifImageHeight!: number | null;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
fileSizeInByte!: number | null;
|
||||||
|
orientation!: string | null;
|
||||||
|
dateTimeOriginal!: Date | null;
|
||||||
|
modifyDate!: Date | null;
|
||||||
|
timeZone!: string | null;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
latitude!: number | null;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
longitude!: number | null;
|
||||||
|
projectionType!: string | null;
|
||||||
|
city!: string | null;
|
||||||
|
state!: string | null;
|
||||||
|
country!: string | null;
|
||||||
|
make!: string | null;
|
||||||
|
model!: string | null;
|
||||||
|
lensModel!: string | null;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
fNumber!: number | null;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
focalLength!: number | null;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
iso!: number | null;
|
||||||
|
exposureTime!: string | null;
|
||||||
|
profileDescription!: string | null;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
rating!: number | null;
|
||||||
|
@ApiProperty({ type: 'integer' })
|
||||||
|
fps!: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export type SyncItem = {
|
export type SyncItem = {
|
||||||
[SyncEntityType.UserV1]: SyncUserV1;
|
[SyncEntityType.UserV1]: SyncUserV1;
|
||||||
[SyncEntityType.UserDeleteV1]: SyncUserDeleteV1;
|
[SyncEntityType.UserDeleteV1]: SyncUserDeleteV1;
|
||||||
[SyncEntityType.PartnerV1]: SyncPartnerV1;
|
[SyncEntityType.PartnerV1]: SyncPartnerV1;
|
||||||
[SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1;
|
[SyncEntityType.PartnerDeleteV1]: SyncPartnerDeleteV1;
|
||||||
|
[SyncEntityType.AssetV1]: SyncAssetV1;
|
||||||
|
[SyncEntityType.AssetDeleteV1]: SyncAssetDeleteV1;
|
||||||
|
[SyncEntityType.AssetExifV1]: SyncAssetExifV1;
|
||||||
|
[SyncEntityType.PartnerAssetV1]: SyncAssetV1;
|
||||||
|
[SyncEntityType.PartnerAssetDeleteV1]: SyncAssetDeleteV1;
|
||||||
|
[SyncEntityType.PartnerAssetExifV1]: SyncAssetExifV1;
|
||||||
};
|
};
|
||||||
|
|
||||||
const responseDtos = [
|
const responseDtos = [
|
||||||
|
|
@ -69,6 +131,9 @@ const responseDtos = [
|
||||||
SyncUserDeleteV1,
|
SyncUserDeleteV1,
|
||||||
SyncPartnerV1,
|
SyncPartnerV1,
|
||||||
SyncPartnerDeleteV1,
|
SyncPartnerDeleteV1,
|
||||||
|
SyncAssetV1,
|
||||||
|
SyncAssetDeleteV1,
|
||||||
|
SyncAssetExifV1,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const extraSyncModels = responseDtos;
|
export const extraSyncModels = responseDtos;
|
||||||
|
|
|
||||||
19
server/src/entities/asset-audit.entity.ts
Normal file
19
server/src/entities/asset-audit.entity.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
|
||||||
|
|
||||||
|
@Entity('assets_audit')
|
||||||
|
export class AssetAuditEntity {
|
||||||
|
@PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@Index('IDX_assets_audit_asset_id')
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
assetId!: string;
|
||||||
|
|
||||||
|
@Index('IDX_assets_audit_owner_id')
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
ownerId!: string;
|
||||||
|
|
||||||
|
@Index('IDX_assets_audit_deleted_at')
|
||||||
|
@CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' })
|
||||||
|
deletedAt!: Date;
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { AssetEntity } from 'src/entities/asset.entity';
|
import { AssetEntity } from 'src/entities/asset.entity';
|
||||||
import { Index, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
|
import { Index, JoinColumn, OneToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm';
|
||||||
import { Column } from 'typeorm/decorator/columns/Column.js';
|
import { Column } from 'typeorm/decorator/columns/Column.js';
|
||||||
import { Entity } from 'typeorm/decorator/entity/Entity.js';
|
import { Entity } from 'typeorm/decorator/entity/Entity.js';
|
||||||
|
|
||||||
|
|
@ -12,6 +12,13 @@ export class ExifEntity {
|
||||||
@PrimaryColumn()
|
@PrimaryColumn()
|
||||||
assetId!: string;
|
assetId!: string;
|
||||||
|
|
||||||
|
@UpdateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' })
|
||||||
|
updatedAt?: Date;
|
||||||
|
|
||||||
|
@Index('IDX_asset_exif_update_id')
|
||||||
|
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
|
||||||
|
updateId?: string;
|
||||||
|
|
||||||
/* General info */
|
/* General info */
|
||||||
@Column({ type: 'text', default: '' })
|
@Column({ type: 'text', default: '' })
|
||||||
description!: string; // or caption
|
description!: string; // or caption
|
||||||
|
|
|
||||||
|
|
@ -549,11 +549,24 @@ export enum DatabaseLock {
|
||||||
export enum SyncRequestType {
|
export enum SyncRequestType {
|
||||||
UsersV1 = 'UsersV1',
|
UsersV1 = 'UsersV1',
|
||||||
PartnersV1 = 'PartnersV1',
|
PartnersV1 = 'PartnersV1',
|
||||||
|
AssetsV1 = 'AssetsV1',
|
||||||
|
AssetExifsV1 = 'AssetExifsV1',
|
||||||
|
PartnerAssetsV1 = 'PartnerAssetsV1',
|
||||||
|
PartnerAssetExifsV1 = 'PartnerAssetExifsV1',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SyncEntityType {
|
export enum SyncEntityType {
|
||||||
UserV1 = 'UserV1',
|
UserV1 = 'UserV1',
|
||||||
UserDeleteV1 = 'UserDeleteV1',
|
UserDeleteV1 = 'UserDeleteV1',
|
||||||
|
|
||||||
PartnerV1 = 'PartnerV1',
|
PartnerV1 = 'PartnerV1',
|
||||||
PartnerDeleteV1 = 'PartnerDeleteV1',
|
PartnerDeleteV1 = 'PartnerDeleteV1',
|
||||||
|
|
||||||
|
AssetV1 = 'AssetV1',
|
||||||
|
AssetDeleteV1 = 'AssetDeleteV1',
|
||||||
|
AssetExifV1 = 'AssetExifV1',
|
||||||
|
|
||||||
|
PartnerAssetV1 = 'PartnerAssetV1',
|
||||||
|
PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1',
|
||||||
|
PartnerAssetExifV1 = 'PartnerAssetExifV1',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
server/src/migrations/1741191762113-AssetAuditTable.ts
Normal file
37
server/src/migrations/1741191762113-AssetAuditTable.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||||
|
|
||||||
|
export class AssetAuditTable1741191762113 implements MigrationInterface {
|
||||||
|
name = 'AssetAuditTable1741191762113'
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`CREATE TABLE "assets_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "assetId" uuid NOT NULL, "ownerId" uuid NOT NULL, "deletedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT clock_timestamp(), CONSTRAINT "PK_99bd5c015f81a641927a32b4212" PRIMARY KEY ("id"))`);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_assets_audit_asset_id" ON "assets_audit" ("assetId") `);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_assets_audit_owner_id" ON "assets_audit" ("ownerId") `);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_assets_audit_deleted_at" ON "assets_audit" ("deletedAt") `);
|
||||||
|
await queryRunner.query(`CREATE OR REPLACE FUNCTION assets_delete_audit() RETURNS TRIGGER AS
|
||||||
|
$$
|
||||||
|
BEGIN
|
||||||
|
INSERT INTO assets_audit ("assetId", "ownerId")
|
||||||
|
SELECT "id", "ownerId"
|
||||||
|
FROM OLD;
|
||||||
|
RETURN NULL;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql`
|
||||||
|
);
|
||||||
|
await queryRunner.query(`CREATE OR REPLACE TRIGGER assets_delete_audit
|
||||||
|
AFTER DELETE ON assets
|
||||||
|
REFERENCING OLD TABLE AS OLD
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION assets_delete_audit();
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TRIGGER assets_delete_audit`);
|
||||||
|
await queryRunner.query(`DROP FUNCTION assets_delete_audit`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_assets_audit_deleted_at"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_assets_audit_owner_id"`);
|
||||||
|
await queryRunner.query(`DROP INDEX "IDX_assets_audit_asset_id"`);
|
||||||
|
await queryRunner.query(`DROP TABLE "assets_audit"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class FixAssetAndUserCascadeConditions1741280328985 implements MigrationInterface {
|
||||||
|
name = 'FixAssetAndUserCascadeConditions1741280328985';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE TRIGGER assets_delete_audit
|
||||||
|
AFTER DELETE ON assets
|
||||||
|
REFERENCING OLD TABLE AS OLD
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
WHEN (pg_trigger_depth() = 0)
|
||||||
|
EXECUTE FUNCTION assets_delete_audit();`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE TRIGGER users_delete_audit
|
||||||
|
AFTER DELETE ON users
|
||||||
|
REFERENCING OLD TABLE AS OLD
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
WHEN (pg_trigger_depth() = 0)
|
||||||
|
EXECUTE FUNCTION users_delete_audit();`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE TRIGGER partners_delete_audit
|
||||||
|
AFTER DELETE ON partners
|
||||||
|
REFERENCING OLD TABLE AS OLD
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
WHEN (pg_trigger_depth() = 0)
|
||||||
|
EXECUTE FUNCTION partners_delete_audit();`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE TRIGGER assets_delete_audit
|
||||||
|
AFTER DELETE ON assets
|
||||||
|
REFERENCING OLD TABLE AS OLD
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION assets_delete_audit();`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE TRIGGER users_delete_audit
|
||||||
|
AFTER DELETE ON users
|
||||||
|
REFERENCING OLD TABLE AS OLD
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION users_delete_audit();`);
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE OR REPLACE TRIGGER partners_delete_audit
|
||||||
|
AFTER DELETE ON partners
|
||||||
|
REFERENCING OLD TABLE AS OLD
|
||||||
|
FOR EACH STATEMENT
|
||||||
|
EXECUTE FUNCTION partners_delete_audit();`);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
server/src/migrations/1741281344519-AddExifUpdateId.ts
Normal file
25
server/src/migrations/1741281344519-AddExifUpdateId.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
export class AddExifUpdateId1741281344519 implements MigrationInterface {
|
||||||
|
name = 'AddExifUpdateId1741281344519';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(
|
||||||
|
`ALTER TABLE "exif" ADD "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT clock_timestamp()`,
|
||||||
|
);
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7()`);
|
||||||
|
await queryRunner.query(`CREATE INDEX "IDX_asset_exif_update_id" ON "exif" ("updateId") `);
|
||||||
|
await queryRunner.query(`
|
||||||
|
create trigger asset_exif_updated_at
|
||||||
|
before update on exif
|
||||||
|
for each row execute procedure updated_at()
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP INDEX "public"."IDX_asset_exif_update_id"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "updateId"`);
|
||||||
|
await queryRunner.query(`ALTER TABLE "exif" DROP COLUMN "updatedAt"`);
|
||||||
|
await queryRunner.query(`DROP TRIGGER asset_exif_updated_at on exif`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -420,8 +420,8 @@ from
|
||||||
) as "stacked_assets" on "asset_stack"."id" is not null
|
) as "stacked_assets" on "asset_stack"."id" is not null
|
||||||
where
|
where
|
||||||
"assets"."ownerId" = $1::uuid
|
"assets"."ownerId" = $1::uuid
|
||||||
and "isVisible" = $2
|
and "assets"."isVisible" = $2
|
||||||
and "updatedAt" <= $3
|
and "assets"."updatedAt" <= $3
|
||||||
and "assets"."id" > $4
|
and "assets"."id" > $4
|
||||||
order by
|
order by
|
||||||
"assets"."id"
|
"assets"."id"
|
||||||
|
|
@ -450,7 +450,7 @@ from
|
||||||
) as "stacked_assets" on "asset_stack"."id" is not null
|
) as "stacked_assets" on "asset_stack"."id" is not null
|
||||||
where
|
where
|
||||||
"assets"."ownerId" = any ($1::uuid[])
|
"assets"."ownerId" = any ($1::uuid[])
|
||||||
and "isVisible" = $2
|
and "assets"."isVisible" = $2
|
||||||
and "updatedAt" > $3
|
and "assets"."updatedAt" > $3
|
||||||
limit
|
limit
|
||||||
$4
|
$4
|
||||||
|
|
|
||||||
|
|
@ -551,7 +551,7 @@ export class AssetRepository {
|
||||||
return this.getById(asset.id, { exifInfo: true, faces: { person: true } }) as Promise<AssetEntity>;
|
return this.getById(asset.id, { exifInfo: true, faces: { person: true } }) as Promise<AssetEntity>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(asset: AssetEntity): Promise<void> {
|
async remove(asset: { id: string }): Promise<void> {
|
||||||
await this.db.deleteFrom('assets').where('id', '=', asUuid(asset.id)).execute();
|
await this.db.deleteFrom('assets').where('id', '=', asUuid(asset.id)).execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -968,8 +968,8 @@ export class AssetRepository {
|
||||||
)
|
)
|
||||||
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack'))
|
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack'))
|
||||||
.where('assets.ownerId', '=', asUuid(ownerId))
|
.where('assets.ownerId', '=', asUuid(ownerId))
|
||||||
.where('isVisible', '=', true)
|
.where('assets.isVisible', '=', true)
|
||||||
.where('updatedAt', '<=', updatedUntil)
|
.where('assets.updatedAt', '<=', updatedUntil)
|
||||||
.$if(!!lastId, (qb) => qb.where('assets.id', '>', lastId!))
|
.$if(!!lastId, (qb) => qb.where('assets.id', '>', lastId!))
|
||||||
.orderBy('assets.id')
|
.orderBy('assets.id')
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
|
|
@ -996,8 +996,8 @@ export class AssetRepository {
|
||||||
)
|
)
|
||||||
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack'))
|
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).as('stack'))
|
||||||
.where('assets.ownerId', '=', anyUuid(options.userIds))
|
.where('assets.ownerId', '=', anyUuid(options.userIds))
|
||||||
.where('isVisible', '=', true)
|
.where('assets.isVisible', '=', true)
|
||||||
.where('updatedAt', '>', options.updatedAfter)
|
.where('assets.updatedAt', '>', options.updatedAfter)
|
||||||
.limit(options.limit)
|
.limit(options.limit)
|
||||||
.execute() as any as Promise<AssetEntity[]>;
|
.execute() as any as Promise<AssetEntity[]>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,14 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Insertable, Kysely, sql } from 'kysely';
|
import { Insertable, Kysely, SelectQueryBuilder, sql } from 'kysely';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { columns } from 'src/database';
|
||||||
import { DB, SessionSyncCheckpoints } from 'src/db';
|
import { DB, SessionSyncCheckpoints } from 'src/db';
|
||||||
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 upsertTables = 'users' | 'partners' | 'assets' | 'exif';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SyncRepository {
|
export class SyncRepository {
|
||||||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||||
|
|
@ -41,9 +45,7 @@ export class SyncRepository {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(['id', 'name', 'email', 'deletedAt', 'updateId'])
|
.select(['id', 'name', 'email', 'deletedAt', 'updateId'])
|
||||||
.$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId))
|
.$call((qb) => this.upsertTableFilters(qb, ack))
|
||||||
.where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
|
|
||||||
.orderBy(['updateId asc'])
|
|
||||||
.stream();
|
.stream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,9 +53,7 @@ export class SyncRepository {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('users_audit')
|
.selectFrom('users_audit')
|
||||||
.select(['id', 'userId'])
|
.select(['id', 'userId'])
|
||||||
.$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId))
|
.$call((qb) => this.auditTableFilters(qb, ack))
|
||||||
.where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
|
|
||||||
.orderBy(['id asc'])
|
|
||||||
.stream();
|
.stream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -61,10 +61,8 @@ export class SyncRepository {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('partners')
|
.selectFrom('partners')
|
||||||
.select(['sharedById', 'sharedWithId', 'inTimeline', 'updateId'])
|
.select(['sharedById', 'sharedWithId', 'inTimeline', 'updateId'])
|
||||||
.$if(!!ack, (qb) => qb.where('updateId', '>', ack!.updateId))
|
|
||||||
.where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)]))
|
.where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)]))
|
||||||
.where('updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
|
.$call((qb) => this.upsertTableFilters(qb, ack))
|
||||||
.orderBy(['updateId asc'])
|
|
||||||
.stream();
|
.stream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -72,10 +70,93 @@ export class SyncRepository {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('partners_audit')
|
.selectFrom('partners_audit')
|
||||||
.select(['id', 'sharedById', 'sharedWithId'])
|
.select(['id', 'sharedById', 'sharedWithId'])
|
||||||
.$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId))
|
|
||||||
.where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)]))
|
.where((eb) => eb.or([eb('sharedById', '=', userId), eb('sharedWithId', '=', userId)]))
|
||||||
.where('deletedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
|
.$call((qb) => this.auditTableFilters(qb, ack))
|
||||||
.orderBy(['id asc'])
|
|
||||||
.stream();
|
.stream();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAssetUpserts(userId: string, ack?: SyncAck) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('assets')
|
||||||
|
.select(columns.syncAsset)
|
||||||
|
.where('ownerId', '=', userId)
|
||||||
|
.$call((qb) => this.upsertTableFilters(qb, ack))
|
||||||
|
.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
getPartnerAssetsUpserts(userId: string, ack?: SyncAck) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('assets')
|
||||||
|
.select(columns.syncAsset)
|
||||||
|
.where('ownerId', 'in', (eb) =>
|
||||||
|
eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId),
|
||||||
|
)
|
||||||
|
.$call((qb) => this.upsertTableFilters(qb, ack))
|
||||||
|
.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
getAssetDeletes(userId: string, ack?: SyncAck) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('assets_audit')
|
||||||
|
.select(['id', 'assetId'])
|
||||||
|
.where('ownerId', '=', userId)
|
||||||
|
.$if(!!ack, (qb) => qb.where('id', '>', ack!.updateId))
|
||||||
|
.$call((qb) => this.auditTableFilters(qb, ack))
|
||||||
|
.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
getPartnerAssetDeletes(userId: string, ack?: SyncAck) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('assets_audit')
|
||||||
|
.select(['id', 'assetId'])
|
||||||
|
.where('ownerId', 'in', (eb) =>
|
||||||
|
eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId),
|
||||||
|
)
|
||||||
|
.$call((qb) => this.auditTableFilters(qb, ack))
|
||||||
|
.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
getAssetExifsUpserts(userId: string, ack?: SyncAck) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('exif')
|
||||||
|
.select(columns.syncAssetExif)
|
||||||
|
.where('assetId', 'in', (eb) => eb.selectFrom('assets').select('id').where('ownerId', '=', userId))
|
||||||
|
.$call((qb) => this.upsertTableFilters(qb, ack))
|
||||||
|
.stream();
|
||||||
|
}
|
||||||
|
|
||||||
|
getPartnerAssetExifsUpserts(userId: string, ack?: SyncAck) {
|
||||||
|
return this.db
|
||||||
|
.selectFrom('exif')
|
||||||
|
.select(columns.syncAssetExif)
|
||||||
|
.where('assetId', 'in', (eb) =>
|
||||||
|
eb
|
||||||
|
.selectFrom('assets')
|
||||||
|
.select('id')
|
||||||
|
.where('ownerId', 'in', (eb) =>
|
||||||
|
eb.selectFrom('partners').select(['sharedById']).where('sharedWithId', '=', userId),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.$call((qb) => this.upsertTableFilters(qb, ack))
|
||||||
|
.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>(
|
||||||
|
qb: SelectQueryBuilder<DB, T, D>,
|
||||||
|
ack?: SyncAck,
|
||||||
|
) {
|
||||||
|
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))
|
||||||
|
.orderBy(['updateId asc']) as SelectQueryBuilder<DB, T, D>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { DateTime } from 'luxon';
|
||||||
import { Writable } from 'node:stream';
|
import { Writable } from 'node:stream';
|
||||||
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
|
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
|
||||||
import { SessionSyncCheckpoints } from 'src/db';
|
import { SessionSyncCheckpoints } from 'src/db';
|
||||||
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
import { AssetResponseDto, hexOrBufferToBase64, mapAsset } from 'src/dtos/asset-response.dto';
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import {
|
import {
|
||||||
AssetDeltaSyncDto,
|
AssetDeltaSyncDto,
|
||||||
|
|
@ -22,10 +22,14 @@ import { setIsEqual } from 'src/utils/set';
|
||||||
import { fromAck, serialize } from 'src/utils/sync';
|
import { fromAck, serialize } from 'src/utils/sync';
|
||||||
|
|
||||||
const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] };
|
const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] };
|
||||||
const SYNC_TYPES_ORDER = [
|
export const SYNC_TYPES_ORDER = [
|
||||||
//
|
//
|
||||||
SyncRequestType.UsersV1,
|
SyncRequestType.UsersV1,
|
||||||
SyncRequestType.PartnersV1,
|
SyncRequestType.PartnersV1,
|
||||||
|
SyncRequestType.AssetsV1,
|
||||||
|
SyncRequestType.AssetExifsV1,
|
||||||
|
SyncRequestType.PartnerAssetsV1,
|
||||||
|
SyncRequestType.PartnerAssetExifsV1,
|
||||||
];
|
];
|
||||||
|
|
||||||
const throwSessionRequired = () => {
|
const throwSessionRequired = () => {
|
||||||
|
|
@ -49,17 +53,22 @@ export class SyncService extends BaseService {
|
||||||
return throwSessionRequired();
|
return throwSessionRequired();
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkpoints: Insertable<SessionSyncCheckpoints>[] = [];
|
const checkpoints: Record<string, Insertable<SessionSyncCheckpoints>> = {};
|
||||||
for (const ack of dto.acks) {
|
for (const ack of dto.acks) {
|
||||||
const { type } = fromAck(ack);
|
const { type } = fromAck(ack);
|
||||||
// TODO proper ack validation via class validator
|
// TODO proper ack validation via class validator
|
||||||
if (!Object.values(SyncEntityType).includes(type)) {
|
if (!Object.values(SyncEntityType).includes(type)) {
|
||||||
throw new BadRequestException(`Invalid ack type: ${type}`);
|
throw new BadRequestException(`Invalid ack type: ${type}`);
|
||||||
}
|
}
|
||||||
checkpoints.push({ sessionId, type, ack });
|
|
||||||
|
if (checkpoints[type]) {
|
||||||
|
throw new BadRequestException('Only one ack per type is allowed');
|
||||||
|
}
|
||||||
|
|
||||||
|
checkpoints[type] = { sessionId, type, ack };
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.syncRepository.upsertCheckpoints(checkpoints);
|
await this.syncRepository.upsertCheckpoints(Object.values(checkpoints));
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteAcks(auth: AuthDto, dto: SyncAckDeleteDto) {
|
async deleteAcks(auth: AuthDto, dto: SyncAckDeleteDto) {
|
||||||
|
|
@ -115,6 +124,87 @@ export class SyncService extends BaseService {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case SyncRequestType.AssetsV1: {
|
||||||
|
const deletes = this.syncRepository.getAssetDeletes(
|
||||||
|
auth.user.id,
|
||||||
|
checkpointMap[SyncEntityType.AssetDeleteV1],
|
||||||
|
);
|
||||||
|
for await (const { id, ...data } of deletes) {
|
||||||
|
response.write(serialize({ type: SyncEntityType.AssetDeleteV1, updateId: id, data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const upserts = this.syncRepository.getAssetUpserts(auth.user.id, checkpointMap[SyncEntityType.AssetV1]);
|
||||||
|
for await (const { updateId, checksum, thumbhash, ...data } of upserts) {
|
||||||
|
response.write(
|
||||||
|
serialize({
|
||||||
|
type: SyncEntityType.AssetV1,
|
||||||
|
updateId,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
checksum: hexOrBufferToBase64(checksum),
|
||||||
|
thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SyncRequestType.PartnerAssetsV1: {
|
||||||
|
const deletes = this.syncRepository.getPartnerAssetDeletes(
|
||||||
|
auth.user.id,
|
||||||
|
checkpointMap[SyncEntityType.PartnerAssetDeleteV1],
|
||||||
|
);
|
||||||
|
for await (const { id, ...data } of deletes) {
|
||||||
|
response.write(serialize({ type: SyncEntityType.PartnerAssetDeleteV1, updateId: id, data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const upserts = this.syncRepository.getPartnerAssetsUpserts(
|
||||||
|
auth.user.id,
|
||||||
|
checkpointMap[SyncEntityType.PartnerAssetV1],
|
||||||
|
);
|
||||||
|
for await (const { updateId, checksum, thumbhash, ...data } of upserts) {
|
||||||
|
response.write(
|
||||||
|
serialize({
|
||||||
|
type: SyncEntityType.PartnerAssetV1,
|
||||||
|
updateId,
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
checksum: hexOrBufferToBase64(checksum),
|
||||||
|
thumbhash: thumbhash ? hexOrBufferToBase64(thumbhash) : null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SyncRequestType.AssetExifsV1: {
|
||||||
|
const upserts = this.syncRepository.getAssetExifsUpserts(
|
||||||
|
auth.user.id,
|
||||||
|
checkpointMap[SyncEntityType.AssetExifV1],
|
||||||
|
);
|
||||||
|
for await (const { updateId, ...data } of upserts) {
|
||||||
|
response.write(serialize({ type: SyncEntityType.AssetExifV1, updateId, data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case SyncRequestType.PartnerAssetExifsV1: {
|
||||||
|
const upserts = this.syncRepository.getPartnerAssetExifsUpserts(
|
||||||
|
auth.user.id,
|
||||||
|
checkpointMap[SyncEntityType.PartnerAssetExifV1],
|
||||||
|
);
|
||||||
|
for await (const { updateId, ...data } of upserts) {
|
||||||
|
response.write(serialize({ type: SyncEntityType.PartnerAssetExifV1, updateId, data }));
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
default: {
|
default: {
|
||||||
this.logger.warn(`Unsupported sync type: ${type}`);
|
this.logger.warn(`Unsupported sync type: ${type}`);
|
||||||
break;
|
break;
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ class CustomWritable extends Writable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Asset = Insertable<Assets>;
|
type Asset = Partial<Insertable<Assets>>;
|
||||||
type User = Partial<Insertable<Users>>;
|
type User = Partial<Insertable<Users>>;
|
||||||
type Session = Omit<Insertable<Sessions>, 'token'> & { token?: string };
|
type Session = Omit<Insertable<Sessions>, 'token'> & { token?: string };
|
||||||
type Partner = Insertable<Partners>;
|
type Partner = Insertable<Partners>;
|
||||||
|
|
@ -160,10 +160,6 @@ export class TestFactory {
|
||||||
}
|
}
|
||||||
|
|
||||||
async create() {
|
async create() {
|
||||||
for (const asset of this.assets) {
|
|
||||||
await this.context.createAsset(asset);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const user of this.users) {
|
for (const user of this.users) {
|
||||||
await this.context.createUser(user);
|
await this.context.createUser(user);
|
||||||
}
|
}
|
||||||
|
|
@ -176,6 +172,10 @@ export class TestFactory {
|
||||||
await this.context.createSession(session);
|
await this.context.createSession(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const asset of this.assets) {
|
||||||
|
await this.context.createAsset(asset);
|
||||||
|
}
|
||||||
|
|
||||||
return this.context;
|
return this.context;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -212,7 +212,7 @@ export class TestContext {
|
||||||
versionHistory: VersionHistoryRepository;
|
versionHistory: VersionHistoryRepository;
|
||||||
view: ViewRepository;
|
view: ViewRepository;
|
||||||
|
|
||||||
private constructor(private db: Kysely<DB>) {
|
private constructor(public db: Kysely<DB>) {
|
||||||
const logger = newLoggingRepositoryMock() as unknown as LoggingRepository;
|
const logger = newLoggingRepositoryMock() as unknown as LoggingRepository;
|
||||||
const config = new ConfigRepository();
|
const config = new ConfigRepository();
|
||||||
|
|
||||||
|
|
|
||||||
74
server/test/medium/specs/audit.database.spec.ts
Normal file
74
server/test/medium/specs/audit.database.spec.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { TestContext, TestFactory } from 'test/factory';
|
||||||
|
import { getKyselyDB } from 'test/utils';
|
||||||
|
|
||||||
|
describe('audit', () => {
|
||||||
|
let context: TestContext;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const db = await getKyselyDB();
|
||||||
|
context = await TestContext.from(db).create();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('partners_audit', () => {
|
||||||
|
it('should not cascade user deletes to partners_audit', async () => {
|
||||||
|
const user1 = TestFactory.user();
|
||||||
|
const user2 = TestFactory.user();
|
||||||
|
|
||||||
|
await context
|
||||||
|
.getFactory()
|
||||||
|
.withUser(user1)
|
||||||
|
.withUser(user2)
|
||||||
|
.withPartner({ sharedById: user1.id, sharedWithId: user2.id })
|
||||||
|
.create();
|
||||||
|
|
||||||
|
await context.user.delete(user1, true);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
context.db.selectFrom('partners_audit').select(['id']).where('sharedById', '=', user1.id).execute(),
|
||||||
|
).resolves.toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('assets_audit', () => {
|
||||||
|
it('should not cascade user deletes to assets_audit', async () => {
|
||||||
|
const user = TestFactory.user();
|
||||||
|
const asset = TestFactory.asset({ ownerId: user.id });
|
||||||
|
|
||||||
|
await context.getFactory().withUser(user).withAsset(asset).create();
|
||||||
|
|
||||||
|
await context.user.delete(user, true);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
context.db.selectFrom('assets_audit').select(['id']).where('assetId', '=', asset.id).execute(),
|
||||||
|
).resolves.toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exif', () => {
|
||||||
|
it('should automatically set updatedAt and updateId when the row is updated', async () => {
|
||||||
|
const user = TestFactory.user();
|
||||||
|
const asset = TestFactory.asset({ ownerId: user.id });
|
||||||
|
const exif = { assetId: asset.id, make: 'Canon' };
|
||||||
|
|
||||||
|
await context.getFactory().withUser(user).withAsset(asset).create();
|
||||||
|
await context.asset.upsertExif(exif);
|
||||||
|
|
||||||
|
const before = await context.db
|
||||||
|
.selectFrom('exif')
|
||||||
|
.select(['updatedAt', 'updateId'])
|
||||||
|
.where('assetId', '=', asset.id)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
await context.asset.upsertExif({ assetId: asset.id, make: 'Canon 2' });
|
||||||
|
|
||||||
|
const after = await context.db
|
||||||
|
.selectFrom('exif')
|
||||||
|
.select(['updatedAt', 'updateId'])
|
||||||
|
.where('assetId', '=', asset.id)
|
||||||
|
.executeTakeFirstOrThrow();
|
||||||
|
|
||||||
|
expect(before.updateId).not.toEqual(after.updateId);
|
||||||
|
expect(before.updatedAt).not.toEqual(after.updatedAt);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { AuthDto } from 'src/dtos/auth.dto';
|
import { AuthDto } from 'src/dtos/auth.dto';
|
||||||
import { SyncRequestType } from 'src/enum';
|
import { SyncEntityType, SyncRequestType } from 'src/enum';
|
||||||
import { SyncService } from 'src/services/sync.service';
|
import { SYNC_TYPES_ORDER, SyncService } from 'src/services/sync.service';
|
||||||
import { TestContext, TestFactory } from 'test/factory';
|
import { TestContext, TestFactory } from 'test/factory';
|
||||||
import { getKyselyDB, newTestService } from 'test/utils';
|
import { getKyselyDB, newTestService } from 'test/utils';
|
||||||
|
|
||||||
|
|
@ -33,7 +33,15 @@ const setup = async () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
describe(SyncService.name, () => {
|
describe(SyncService.name, () => {
|
||||||
describe.concurrent('users', () => {
|
it('should have all the types in the ordering variable', () => {
|
||||||
|
for (const key in SyncRequestType) {
|
||||||
|
expect(SYNC_TYPES_ORDER).includes(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(SYNC_TYPES_ORDER.length).toBe(Object.keys(SyncRequestType).length);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.concurrent(SyncEntityType.UserV1, () => {
|
||||||
it('should detect and sync the first user', async () => {
|
it('should detect and sync the first user', async () => {
|
||||||
const { context, auth, sut, testSync } = await setup();
|
const { context, auth, sut, testSync } = await setup();
|
||||||
|
|
||||||
|
|
@ -189,7 +197,7 @@ describe(SyncService.name, () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.concurrent('partners', () => {
|
describe.concurrent(SyncEntityType.PartnerV1, () => {
|
||||||
it('should detect and sync the first partner', async () => {
|
it('should detect and sync the first partner', async () => {
|
||||||
const { auth, context, sut, testSync } = await setup();
|
const { auth, context, sut, testSync } = await setup();
|
||||||
|
|
||||||
|
|
@ -349,7 +357,7 @@ describe(SyncService.name, () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not sync a partner for an unrelated user', async () => {
|
it('should not sync a partner or partner delete for an unrelated user', async () => {
|
||||||
const { auth, context, testSync } = await setup();
|
const { auth, context, testSync } = await setup();
|
||||||
|
|
||||||
const user2 = await context.createUser();
|
const user2 = await context.createUser();
|
||||||
|
|
@ -357,9 +365,436 @@ describe(SyncService.name, () => {
|
||||||
|
|
||||||
await context.createPartner({ sharedById: user2.id, sharedWithId: user3.id });
|
await context.createPartner({ sharedById: user2.id, sharedWithId: user3.id });
|
||||||
|
|
||||||
const response = await testSync(auth, [SyncRequestType.PartnersV1]);
|
expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
|
||||||
|
|
||||||
|
await context.partner.remove({ sharedById: user2.id, sharedWithId: user3.id });
|
||||||
|
|
||||||
|
expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not sync a partner delete after a user is deleted', async () => {
|
||||||
|
const { auth, context, testSync } = await setup();
|
||||||
|
|
||||||
|
const user2 = await context.createUser();
|
||||||
|
await context.createPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
|
||||||
|
await context.user.delete({ id: user2.id }, true);
|
||||||
|
|
||||||
|
expect(await testSync(auth, [SyncRequestType.PartnersV1])).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.concurrent(SyncEntityType.AssetV1, () => {
|
||||||
|
it('should detect and sync the first asset', async () => {
|
||||||
|
const { auth, context, sut, testSync } = await setup();
|
||||||
|
|
||||||
|
const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
|
||||||
|
const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
|
||||||
|
const date = new Date().toISOString();
|
||||||
|
|
||||||
|
const asset = TestFactory.asset({
|
||||||
|
ownerId: auth.user.id,
|
||||||
|
checksum: Buffer.from(checksum, 'base64'),
|
||||||
|
thumbhash: Buffer.from(thumbhash, 'base64'),
|
||||||
|
fileCreatedAt: date,
|
||||||
|
fileModifiedAt: date,
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
await context.createAsset(asset);
|
||||||
|
|
||||||
|
const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]);
|
||||||
|
|
||||||
|
expect(initialSyncResponse).toHaveLength(1);
|
||||||
|
expect(initialSyncResponse).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{
|
||||||
|
ack: expect.any(String),
|
||||||
|
data: {
|
||||||
|
id: asset.id,
|
||||||
|
ownerId: asset.ownerId,
|
||||||
|
thumbhash,
|
||||||
|
checksum,
|
||||||
|
deletedAt: null,
|
||||||
|
fileCreatedAt: date,
|
||||||
|
fileModifiedAt: date,
|
||||||
|
isFavorite: false,
|
||||||
|
isVisible: true,
|
||||||
|
localDateTime: null,
|
||||||
|
type: asset.type,
|
||||||
|
},
|
||||||
|
type: 'AssetV1',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const acks = [initialSyncResponse[0].ack];
|
||||||
|
await sut.setAcks(auth, { acks });
|
||||||
|
|
||||||
|
const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]);
|
||||||
|
|
||||||
|
expect(ackSyncResponse).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect and sync a deleted asset', async () => {
|
||||||
|
const { auth, context, sut, testSync } = await setup();
|
||||||
|
|
||||||
|
const asset = TestFactory.asset({ ownerId: auth.user.id });
|
||||||
|
await context.createAsset(asset);
|
||||||
|
await context.asset.remove(asset);
|
||||||
|
|
||||||
|
const response = await testSync(auth, [SyncRequestType.AssetsV1]);
|
||||||
|
|
||||||
|
expect(response).toHaveLength(1);
|
||||||
|
expect(response).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{
|
||||||
|
ack: expect.any(String),
|
||||||
|
data: {
|
||||||
|
assetId: asset.id,
|
||||||
|
},
|
||||||
|
type: 'AssetDeleteV1',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const acks = response.map(({ ack }) => ack);
|
||||||
|
await sut.setAcks(auth, { acks });
|
||||||
|
|
||||||
|
const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetsV1]);
|
||||||
|
|
||||||
|
expect(ackSyncResponse).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not sync an asset or asset delete for an unrelated user', async () => {
|
||||||
|
const { auth, context, testSync } = await setup();
|
||||||
|
|
||||||
|
const user2 = await context.createUser();
|
||||||
|
const session = TestFactory.session({ userId: user2.id });
|
||||||
|
const auth2 = TestFactory.auth({ session, user: user2 });
|
||||||
|
|
||||||
|
const asset = TestFactory.asset({ ownerId: user2.id });
|
||||||
|
await context.createAsset(asset);
|
||||||
|
|
||||||
|
expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1);
|
||||||
|
expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0);
|
||||||
|
|
||||||
|
await context.asset.remove(asset);
|
||||||
|
expect(await testSync(auth2, [SyncRequestType.AssetsV1])).toHaveLength(1);
|
||||||
|
expect(await testSync(auth, [SyncRequestType.AssetsV1])).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.concurrent(SyncRequestType.PartnerAssetsV1, () => {
|
||||||
|
it('should detect and sync the first partner asset', async () => {
|
||||||
|
const { auth, context, sut, testSync } = await setup();
|
||||||
|
|
||||||
|
const checksum = '1115vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
|
||||||
|
const thumbhash = '2225vHcVkZzNp3Q9G+FEA0nu6zUbGb4Tj4UOXkN0wRA=';
|
||||||
|
const date = new Date().toISOString();
|
||||||
|
|
||||||
|
const user2 = await context.createUser();
|
||||||
|
|
||||||
|
const asset = TestFactory.asset({
|
||||||
|
ownerId: user2.id,
|
||||||
|
checksum: Buffer.from(checksum, 'base64'),
|
||||||
|
thumbhash: Buffer.from(thumbhash, 'base64'),
|
||||||
|
fileCreatedAt: date,
|
||||||
|
fileModifiedAt: date,
|
||||||
|
deletedAt: null,
|
||||||
|
});
|
||||||
|
await context.createAsset(asset);
|
||||||
|
await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id });
|
||||||
|
|
||||||
|
const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
|
||||||
|
|
||||||
|
expect(initialSyncResponse).toHaveLength(1);
|
||||||
|
expect(initialSyncResponse).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{
|
||||||
|
ack: expect.any(String),
|
||||||
|
data: {
|
||||||
|
id: asset.id,
|
||||||
|
ownerId: asset.ownerId,
|
||||||
|
thumbhash,
|
||||||
|
checksum,
|
||||||
|
deletedAt: null,
|
||||||
|
fileCreatedAt: date,
|
||||||
|
fileModifiedAt: date,
|
||||||
|
isFavorite: false,
|
||||||
|
isVisible: true,
|
||||||
|
localDateTime: null,
|
||||||
|
type: asset.type,
|
||||||
|
},
|
||||||
|
type: SyncEntityType.PartnerAssetV1,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const acks = [initialSyncResponse[0].ack];
|
||||||
|
await sut.setAcks(auth, { acks });
|
||||||
|
|
||||||
|
const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
|
||||||
|
|
||||||
|
expect(ackSyncResponse).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect and sync a deleted partner asset', async () => {
|
||||||
|
const { auth, context, sut, testSync } = await setup();
|
||||||
|
|
||||||
|
const user2 = await context.createUser();
|
||||||
|
const asset = TestFactory.asset({ ownerId: user2.id });
|
||||||
|
await context.createAsset(asset);
|
||||||
|
await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id });
|
||||||
|
await context.asset.remove(asset);
|
||||||
|
|
||||||
|
const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
|
||||||
|
|
||||||
|
expect(response).toHaveLength(1);
|
||||||
|
expect(response).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{
|
||||||
|
ack: expect.any(String),
|
||||||
|
data: {
|
||||||
|
assetId: asset.id,
|
||||||
|
},
|
||||||
|
type: SyncEntityType.PartnerAssetDeleteV1,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const acks = response.map(({ ack }) => ack);
|
||||||
|
await sut.setAcks(auth, { acks });
|
||||||
|
|
||||||
|
const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
|
||||||
|
|
||||||
|
expect(ackSyncResponse).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not sync a deleted partner asset due to a user delete', async () => {
|
||||||
|
const { auth, context, testSync } = await setup();
|
||||||
|
|
||||||
|
const user2 = await context.createUser();
|
||||||
|
await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id });
|
||||||
|
await context.createAsset({ ownerId: user2.id });
|
||||||
|
await context.user.delete({ id: user2.id }, true);
|
||||||
|
|
||||||
|
const response = await testSync(auth, [SyncRequestType.PartnerAssetsV1]);
|
||||||
|
|
||||||
expect(response).toHaveLength(0);
|
expect(response).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should not sync a deleted partner asset due to a partner delete (unshare)', async () => {
|
||||||
|
const { auth, context, testSync } = await setup();
|
||||||
|
|
||||||
|
const user2 = await context.createUser();
|
||||||
|
await context.createAsset({ ownerId: user2.id });
|
||||||
|
const partner = { sharedById: user2.id, sharedWithId: auth.user.id };
|
||||||
|
await context.partner.create(partner);
|
||||||
|
|
||||||
|
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(1);
|
||||||
|
|
||||||
|
await context.partner.remove(partner);
|
||||||
|
|
||||||
|
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not sync an asset or asset delete for own user', async () => {
|
||||||
|
const { auth, context, testSync } = await setup();
|
||||||
|
|
||||||
|
const user2 = await context.createUser();
|
||||||
|
const asset = await context.createAsset({ ownerId: auth.user.id });
|
||||||
|
const partner = { sharedById: user2.id, sharedWithId: auth.user.id };
|
||||||
|
await context.partner.create(partner);
|
||||||
|
|
||||||
|
await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
|
||||||
|
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
|
||||||
|
|
||||||
|
await context.asset.remove(asset);
|
||||||
|
|
||||||
|
await expect(testSync(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
|
||||||
|
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not sync an asset or asset delete for unrelated user', async () => {
|
||||||
|
const { auth, context, testSync } = await setup();
|
||||||
|
|
||||||
|
const user2 = await context.createUser();
|
||||||
|
const session = TestFactory.session({ userId: user2.id });
|
||||||
|
const auth2 = TestFactory.auth({ session, user: user2 });
|
||||||
|
const asset = await context.createAsset({ ownerId: user2.id });
|
||||||
|
|
||||||
|
await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
|
||||||
|
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
|
||||||
|
|
||||||
|
await context.asset.remove(asset);
|
||||||
|
|
||||||
|
await expect(testSync(auth2, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1);
|
||||||
|
await expect(testSync(auth, [SyncRequestType.PartnerAssetsV1])).resolves.toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.concurrent(SyncRequestType.AssetExifsV1, () => {
|
||||||
|
it('should detect and sync the first asset exif', async () => {
|
||||||
|
const { auth, context, sut, testSync } = await setup();
|
||||||
|
|
||||||
|
const asset = TestFactory.asset({ ownerId: auth.user.id });
|
||||||
|
const exif = { assetId: asset.id, make: 'Canon' };
|
||||||
|
|
||||||
|
await context.createAsset(asset);
|
||||||
|
await context.asset.upsertExif(exif);
|
||||||
|
|
||||||
|
const initialSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]);
|
||||||
|
|
||||||
|
expect(initialSyncResponse).toHaveLength(1);
|
||||||
|
expect(initialSyncResponse).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{
|
||||||
|
ack: expect.any(String),
|
||||||
|
data: {
|
||||||
|
assetId: asset.id,
|
||||||
|
city: null,
|
||||||
|
country: null,
|
||||||
|
dateTimeOriginal: null,
|
||||||
|
description: '',
|
||||||
|
exifImageHeight: null,
|
||||||
|
exifImageWidth: null,
|
||||||
|
exposureTime: null,
|
||||||
|
fNumber: null,
|
||||||
|
fileSizeInByte: null,
|
||||||
|
focalLength: null,
|
||||||
|
fps: null,
|
||||||
|
iso: null,
|
||||||
|
latitude: null,
|
||||||
|
lensModel: null,
|
||||||
|
longitude: null,
|
||||||
|
make: 'Canon',
|
||||||
|
model: null,
|
||||||
|
modifyDate: null,
|
||||||
|
orientation: null,
|
||||||
|
profileDescription: null,
|
||||||
|
projectionType: null,
|
||||||
|
rating: null,
|
||||||
|
state: null,
|
||||||
|
timeZone: null,
|
||||||
|
},
|
||||||
|
type: SyncEntityType.AssetExifV1,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const acks = [initialSyncResponse[0].ack];
|
||||||
|
await sut.setAcks(auth, { acks });
|
||||||
|
|
||||||
|
const ackSyncResponse = await testSync(auth, [SyncRequestType.AssetExifsV1]);
|
||||||
|
|
||||||
|
expect(ackSyncResponse).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should only sync asset exif for own user', async () => {
|
||||||
|
const { auth, context, testSync } = await setup();
|
||||||
|
|
||||||
|
const user2 = await context.createUser();
|
||||||
|
const session = TestFactory.session({ userId: user2.id });
|
||||||
|
const auth2 = TestFactory.auth({ session, user: user2 });
|
||||||
|
|
||||||
|
await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id });
|
||||||
|
const asset = TestFactory.asset({ ownerId: user2.id });
|
||||||
|
const exif = { assetId: asset.id, make: 'Canon' };
|
||||||
|
|
||||||
|
await context.createAsset(asset);
|
||||||
|
await context.asset.upsertExif(exif);
|
||||||
|
|
||||||
|
await expect(testSync(auth2, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
|
||||||
|
await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe.concurrent(SyncRequestType.PartnerAssetExifsV1, () => {
|
||||||
|
it('should detect and sync the first partner asset exif', async () => {
|
||||||
|
const { auth, context, sut, testSync } = await setup();
|
||||||
|
|
||||||
|
const user2 = await context.createUser();
|
||||||
|
await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id });
|
||||||
|
const asset = TestFactory.asset({ ownerId: user2.id });
|
||||||
|
await context.createAsset(asset);
|
||||||
|
const exif = { assetId: asset.id, make: 'Canon' };
|
||||||
|
await context.asset.upsertExif(exif);
|
||||||
|
|
||||||
|
const initialSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
|
||||||
|
|
||||||
|
expect(initialSyncResponse).toHaveLength(1);
|
||||||
|
expect(initialSyncResponse).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{
|
||||||
|
ack: expect.any(String),
|
||||||
|
data: {
|
||||||
|
assetId: asset.id,
|
||||||
|
city: null,
|
||||||
|
country: null,
|
||||||
|
dateTimeOriginal: null,
|
||||||
|
description: '',
|
||||||
|
exifImageHeight: null,
|
||||||
|
exifImageWidth: null,
|
||||||
|
exposureTime: null,
|
||||||
|
fNumber: null,
|
||||||
|
fileSizeInByte: null,
|
||||||
|
focalLength: null,
|
||||||
|
fps: null,
|
||||||
|
iso: null,
|
||||||
|
latitude: null,
|
||||||
|
lensModel: null,
|
||||||
|
longitude: null,
|
||||||
|
make: 'Canon',
|
||||||
|
model: null,
|
||||||
|
modifyDate: null,
|
||||||
|
orientation: null,
|
||||||
|
profileDescription: null,
|
||||||
|
projectionType: null,
|
||||||
|
rating: null,
|
||||||
|
state: null,
|
||||||
|
timeZone: null,
|
||||||
|
},
|
||||||
|
type: SyncEntityType.PartnerAssetExifV1,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const acks = [initialSyncResponse[0].ack];
|
||||||
|
await sut.setAcks(auth, { acks });
|
||||||
|
|
||||||
|
const ackSyncResponse = await testSync(auth, [SyncRequestType.PartnerAssetExifsV1]);
|
||||||
|
|
||||||
|
expect(ackSyncResponse).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not sync partner asset exif for own user', async () => {
|
||||||
|
const { auth, context, testSync } = await setup();
|
||||||
|
|
||||||
|
const user2 = await context.createUser();
|
||||||
|
await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id });
|
||||||
|
const asset = TestFactory.asset({ ownerId: auth.user.id });
|
||||||
|
const exif = { assetId: asset.id, make: 'Canon' };
|
||||||
|
await context.createAsset(asset);
|
||||||
|
await context.asset.upsertExif(exif);
|
||||||
|
|
||||||
|
await expect(testSync(auth, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
|
||||||
|
await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not sync partner asset exif for unrelated user', async () => {
|
||||||
|
const { auth, context, testSync } = await setup();
|
||||||
|
|
||||||
|
const user2 = await context.createUser();
|
||||||
|
const user3 = await context.createUser();
|
||||||
|
const session = TestFactory.session({ userId: user3.id });
|
||||||
|
const authUser3 = TestFactory.auth({ session, user: user3 });
|
||||||
|
await context.partner.create({ sharedById: user2.id, sharedWithId: auth.user.id });
|
||||||
|
const asset = TestFactory.asset({ ownerId: user3.id });
|
||||||
|
const exif = { assetId: asset.id, make: 'Canon' };
|
||||||
|
await context.createAsset(asset);
|
||||||
|
await context.asset.upsertExif(exif);
|
||||||
|
|
||||||
|
await expect(testSync(authUser3, [SyncRequestType.AssetExifsV1])).resolves.toHaveLength(1);
|
||||||
|
await expect(testSync(auth, [SyncRequestType.PartnerAssetExifsV1])).resolves.toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,5 +11,11 @@ export const newSyncRepositoryMock = (): Mocked<RepositoryInterface<SyncReposito
|
||||||
getUserDeletes: vitest.fn(),
|
getUserDeletes: vitest.fn(),
|
||||||
getPartnerUpserts: vitest.fn(),
|
getPartnerUpserts: vitest.fn(),
|
||||||
getPartnerDeletes: vitest.fn(),
|
getPartnerDeletes: vitest.fn(),
|
||||||
|
getPartnerAssetsUpserts: vitest.fn(),
|
||||||
|
getPartnerAssetDeletes: vitest.fn(),
|
||||||
|
getAssetDeletes: vitest.fn(),
|
||||||
|
getAssetUpserts: vitest.fn(),
|
||||||
|
getAssetExifsUpserts: vitest.fn(),
|
||||||
|
getPartnerAssetExifsUpserts: vitest.fn(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue