0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-04-08 03:01:32 -05:00

feat: schema diff sql tools ()

This commit is contained in:
Jason Rasmussen 2025-03-28 10:40:09 -04:00 committed by GitHub
parent 3fde5a8328
commit 4b4bcd23f4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
132 changed files with 5837 additions and 1246 deletions
.github/workflows
server
eslint.config.mjspackage.json
src
bin
db.d.ts
entities
repositories
services
sql-tools
subscribers
tables
types.ts
utils
test

View file

@ -525,7 +525,7 @@ jobs:
- name: Generate new migrations
continue-on-error: true
run: npm run typeorm:migrations:generate ./src/migrations/TestMigration
run: npm run migrations:generate TestMigration
- name: Find file changes
uses: tj-actions/verify-changed-files@a1c6acee9df209257a246f2cc6ae8cb6581c1edf # v20
@ -538,7 +538,7 @@ jobs:
run: |
echo "ERROR: Generated migration files not up to date!"
echo "Changed files: ${{ steps.verify-changed-files.outputs.changed_files }}"
cat ./src/migrations/*-TestMigration.ts
cat ./src/*-TestMigration.ts
exit 1
- name: Run SQL generation

View file

@ -77,6 +77,14 @@ export default [
],
},
],
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
},
},
];

View file

@ -23,8 +23,8 @@
"test:medium": "vitest --config test/vitest.config.medium.mjs",
"typeorm": "typeorm",
"lifecycle": "node ./dist/utils/lifecycle.js",
"typeorm:migrations:create": "typeorm migration:create",
"typeorm:migrations:generate": "typeorm migration:generate -d ./dist/bin/database.js",
"migrations:generate": "node ./dist/bin/migrations.js generate",
"migrations:create": "node ./dist/bin/migrations.js create",
"typeorm:migrations:run": "typeorm migration:run -d ./dist/bin/database.js",
"typeorm:migrations:revert": "typeorm migration:revert -d ./dist/bin/database.js",
"typeorm:schema:drop": "typeorm query -d ./dist/bin/database.js 'DROP schema public cascade; CREATE schema public;'",

View file

@ -0,0 +1,112 @@
#!/usr/bin/env node
process.env.DB_URL = 'postgres://postgres:postgres@localhost:5432/immich';
import { writeFileSync } from 'node:fs';
import postgres from 'postgres';
import { ConfigRepository } from 'src/repositories/config.repository';
import { DatabaseTable, schemaDiff, schemaFromDatabase, schemaFromDecorators } from 'src/sql-tools';
import 'src/tables';
const main = async () => {
const command = process.argv[2];
const name = process.argv[3] || 'Migration';
switch (command) {
case 'debug': {
await debug();
return;
}
case 'create': {
create(name, [], []);
return;
}
case 'generate': {
await generate(name);
return;
}
default: {
console.log(`Usage:
node dist/bin/migrations.js create <name>
node dist/bin/migrations.js generate <name>
`);
}
}
};
const debug = async () => {
const { up, down } = await compare();
const upSql = '-- UP\n' + up.asSql({ comments: true }).join('\n');
const downSql = '-- DOWN\n' + down.asSql({ comments: true }).join('\n');
writeFileSync('./migrations.sql', upSql + '\n\n' + downSql);
console.log('Wrote migrations.sql');
};
const generate = async (name: string) => {
const { up, down } = await compare();
if (up.items.length === 0) {
console.log('No changes detected');
return;
}
create(name, up.asSql(), down.asSql());
};
const create = (name: string, up: string[], down: string[]) => {
const { filename, code } = asMigration(name, up, down);
const fullPath = `./src/${filename}`;
writeFileSync(fullPath, code);
console.log(`Wrote ${fullPath}`);
};
const compare = async () => {
const configRepository = new ConfigRepository();
const { database } = configRepository.getEnv();
const db = postgres(database.config.kysely);
const source = schemaFromDecorators();
const target = await schemaFromDatabase(db, {});
console.log(source.warnings.join('\n'));
const isIncluded = (table: DatabaseTable) => source.tables.some(({ name }) => table.name === name);
target.tables = target.tables.filter((table) => isIncluded(table));
const up = schemaDiff(source, target, { ignoreExtraTables: true });
const down = schemaDiff(target, source, { ignoreExtraTables: false });
return { up, down };
};
const asMigration = (name: string, up: string[], down: string[]) => {
const timestamp = Date.now();
const upSql = up.map((sql) => ` await queryRunner.query(\`${sql}\`);`).join('\n');
const downSql = down.map((sql) => ` await queryRunner.query(\`${sql}\`);`).join('\n');
return {
filename: `${timestamp}-${name}.ts`,
code: `import { MigrationInterface, QueryRunner } from 'typeorm';
export class ${name}${timestamp} implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
${upSql}
}
public async down(queryRunner: QueryRunner): Promise<void> {
${downSql}
}
}
`,
};
};
main()
.then(() => {
process.exit(0);
})
.catch((error) => {
console.error(error);
console.log('Something went wrong');
process.exit(1);
});

25
server/src/db.d.ts vendored
View file

@ -4,8 +4,9 @@
*/
import type { ColumnType } from 'kysely';
import { OnThisDayData } from 'src/entities/memory.entity';
import { AssetType, MemoryType, Permission, SyncEntityType } from 'src/enum';
import { UserTable } from 'src/tables/user.table';
import { OnThisDayData } from 'src/types';
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
@ -410,26 +411,6 @@ export interface UserMetadata {
value: Json;
}
export interface Users {
createdAt: Generated<Timestamp>;
deletedAt: Timestamp | null;
email: string;
id: Generated<string>;
isAdmin: Generated<boolean>;
name: Generated<string>;
oauthId: Generated<string>;
password: Generated<string>;
profileChangedAt: Generated<Timestamp>;
profileImagePath: Generated<string>;
quotaSizeInBytes: Int8 | null;
quotaUsageInBytes: Generated<Int8>;
shouldChangePassword: Generated<boolean>;
status: Generated<string>;
storageLabel: string | null;
updatedAt: Generated<Timestamp>;
updateId: Generated<string>;
}
export interface UsersAudit {
id: Generated<string>;
userId: string;
@ -495,7 +476,7 @@ export interface DB {
tags_closure: TagsClosure;
typeorm_metadata: TypeormMetadata;
user_metadata: UserMetadata;
users: Users;
users: UserTable;
users_audit: UsersAudit;
'vectors.pg_vector_index_stat': VectorsPgVectorIndexStat;
version_history: VersionHistory;

View file

@ -1,55 +0,0 @@
import { AlbumEntity } from 'src/entities/album.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
import {
Check,
Column,
CreateDateColumn,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('activity')
@Index('IDX_activity_like', ['assetId', 'userId', 'albumId'], { unique: true, where: '("isLiked" = true)' })
@Check(`("comment" IS NULL AND "isLiked" = true) OR ("comment" IS NOT NULL AND "isLiked" = false)`)
export class ActivityEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_activity_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@Column()
albumId!: string;
@Column()
userId!: string;
@Column({ nullable: true, type: 'uuid' })
assetId!: string | null;
@Column({ type: 'text', default: null })
comment!: string | null;
@Column({ type: 'boolean', default: false })
isLiked!: boolean;
@ManyToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
asset!: AssetEntity | null;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
user!: UserEntity;
@ManyToOne(() => AlbumEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
album!: AlbumEntity;
}

View file

@ -1,27 +1,11 @@
import { AlbumEntity } from 'src/entities/album.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AlbumUserRole } from 'src/enum';
import { Column, Entity, Index, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm';
@Entity('albums_shared_users_users')
// Pre-existing indices from original album <--> user ManyToMany mapping
@Index('IDX_427c350ad49bd3935a50baab73', ['album'])
@Index('IDX_f48513bf9bccefd6ff3ad30bd0', ['user'])
export class AlbumUserEntity {
@PrimaryColumn({ type: 'uuid', name: 'albumsId' })
albumId!: string;
@PrimaryColumn({ type: 'uuid', name: 'usersId' })
userId!: string;
@JoinColumn({ name: 'albumsId' })
@ManyToOne(() => AlbumEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
album!: AlbumEntity;
@JoinColumn({ name: 'usersId' })
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
user!: UserEntity;
@Column({ type: 'varchar', default: AlbumUserRole.EDITOR })
role!: AlbumUserRole;
}

View file

@ -3,69 +3,22 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AssetOrder } from 'src/enum';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('albums')
export class AlbumEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
owner!: UserEntity;
@Column()
ownerId!: string;
@Column({ default: 'Untitled Album' })
albumName!: string;
@Column({ type: 'text', default: '' })
description!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_albums_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt!: Date | null;
@ManyToOne(() => AssetEntity, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
albumThumbnailAsset!: AssetEntity | null;
@Column({ comment: 'Asset ID to be used as thumbnail', nullable: true })
albumThumbnailAssetId!: string | null;
@OneToMany(() => AlbumUserEntity, ({ album }) => album, { cascade: true, onDelete: 'CASCADE' })
albumUsers!: AlbumUserEntity[];
@ManyToMany(() => AssetEntity, (asset) => asset.albums)
@JoinTable({ synchronize: false })
assets!: AssetEntity[];
@OneToMany(() => SharedLinkEntity, (link) => link.album)
sharedLinks!: SharedLinkEntity[];
@Column({ default: true })
isActivityEnabled!: boolean;
@Column({ type: 'varchar', default: AssetOrder.DESC })
order!: AssetOrder;
}

View file

@ -1,34 +0,0 @@
import { UserEntity } from 'src/entities/user.entity';
import { Permission } from 'src/enum';
import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('api_keys')
export class APIKeyEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
name!: string;
@Column({ select: false })
key?: string;
@ManyToOne(() => UserEntity, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
user?: UserEntity;
@Column()
userId!: string;
@Column({ array: true, type: 'varchar' })
permissions!: Permission[];
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_api_keys_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
}

View file

@ -1,19 +0,0 @@
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;
}

View file

@ -2,55 +2,20 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { FaceSearchEntity } from 'src/entities/face-search.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { SourceType } from 'src/enum';
import { Column, Entity, Index, ManyToOne, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
@Entity('asset_faces', { synchronize: false })
@Index('IDX_asset_faces_assetId_personId', ['assetId', 'personId'])
@Index(['personId', 'assetId'])
export class AssetFaceEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
assetId!: string;
@Column({ nullable: true, type: 'uuid' })
personId!: string | null;
@OneToOne(() => FaceSearchEntity, (faceSearchEntity) => faceSearchEntity.face, { cascade: ['insert'] })
faceSearch?: FaceSearchEntity;
@Column({ default: 0, type: 'int' })
imageWidth!: number;
@Column({ default: 0, type: 'int' })
imageHeight!: number;
@Column({ default: 0, type: 'int' })
boundingBoxX1!: number;
@Column({ default: 0, type: 'int' })
boundingBoxY1!: number;
@Column({ default: 0, type: 'int' })
boundingBoxX2!: number;
@Column({ default: 0, type: 'int' })
boundingBoxY2!: number;
@Column({ default: SourceType.MACHINE_LEARNING, type: 'enum', enum: SourceType })
sourceType!: SourceType;
@ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
asset!: AssetEntity;
@ManyToOne(() => PersonEntity, (person) => person.faces, {
onDelete: 'SET NULL',
onUpdate: 'CASCADE',
nullable: true,
})
person!: PersonEntity | null;
@Column({ type: 'timestamptz' })
deletedAt!: Date | null;
}

View file

@ -1,42 +1,13 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetFileType } from 'src/enum';
import {
Column,
CreateDateColumn,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
@Unique('UQ_assetId_type', ['assetId', 'type'])
@Entity('asset_files')
export class AssetFileEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Index('IDX_asset_files_assetId')
@Column()
assetId!: string;
@ManyToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
asset?: AssetEntity;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_asset_files_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@Column()
type!: AssetFileType;
@Column()
path!: string;
}

View file

@ -1,27 +1,11 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { Column, Entity, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
@Entity('asset_job_status')
export class AssetJobStatusEntity {
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
@JoinColumn()
asset!: AssetEntity;
@PrimaryColumn()
assetId!: string;
@Column({ type: 'timestamptz', nullable: true })
facesRecognizedAt!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
metadataExtractedAt!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
duplicatesDetectedAt!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
previewAt!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
thumbnailAt!: Date | null;
}

View file

@ -6,7 +6,6 @@ import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity';
import { ExifEntity } from 'src/entities/exif.entity';
import { LibraryEntity } from 'src/entities/library.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { SmartSearchEntity } from 'src/entities/smart-search.entity';
import { StackEntity } from 'src/entities/stack.entity';
@ -16,171 +15,49 @@ import { AssetFileType, AssetStatus, AssetType } from 'src/enum';
import { TimeBucketSize } from 'src/repositories/asset.repository';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { anyUuid, asUuid } from 'src/utils/database';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
OneToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
@Entity('assets')
// Checksums must be unique per user and library
@Index(ASSET_CHECKSUM_CONSTRAINT, ['owner', 'checksum'], {
unique: true,
where: '"libraryId" IS NULL',
})
@Index('UQ_assets_owner_library_checksum' + '', ['owner', 'library', 'checksum'], {
unique: true,
where: '"libraryId" IS NOT NULL',
})
@Index('idx_local_date_time', { synchronize: false })
@Index('idx_local_date_time_month', { synchronize: false })
@Index('IDX_originalPath_libraryId', ['originalPath', 'libraryId'])
@Index('IDX_asset_id_stackId', ['id', 'stackId'])
@Index('idx_originalFileName_trigram', { synchronize: false })
// For all assets, each originalpath must be unique per user and library
export class AssetEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
deviceAssetId!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
owner!: UserEntity;
@Column()
ownerId!: string;
@ManyToOne(() => LibraryEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
library?: LibraryEntity | null;
@Column({ nullable: true })
libraryId?: string | null;
@Column()
deviceId!: string;
@Column()
type!: AssetType;
@Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE })
status!: AssetStatus;
@Column()
originalPath!: string;
@OneToMany(() => AssetFileEntity, (assetFile) => assetFile.asset)
files!: AssetFileEntity[];
@Column({ type: 'bytea', nullable: true })
thumbhash!: Buffer | null;
@Column({ type: 'varchar', nullable: true, default: '' })
encodedVideoPath!: string | null;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_assets_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@DeleteDateColumn({ type: 'timestamptz', nullable: true })
deletedAt!: Date | null;
@Index('idx_asset_file_created_at')
@Column({ type: 'timestamptz', nullable: true, default: null })
fileCreatedAt!: Date;
@Column({ type: 'timestamptz', nullable: true, default: null })
localDateTime!: Date;
@Column({ type: 'timestamptz', nullable: true, default: null })
fileModifiedAt!: Date;
@Column({ type: 'boolean', default: false })
isFavorite!: boolean;
@Column({ type: 'boolean', default: false })
isArchived!: boolean;
@Column({ type: 'boolean', default: false })
isExternal!: boolean;
@Column({ type: 'boolean', default: false })
isOffline!: boolean;
@Column({ type: 'bytea' })
@Index()
checksum!: Buffer; // sha1 checksum
@Column({ type: 'varchar', nullable: true })
duration!: string | null;
@Column({ type: 'boolean', default: true })
isVisible!: boolean;
@ManyToOne(() => AssetEntity, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' })
@JoinColumn()
livePhotoVideo!: AssetEntity | null;
@Column({ nullable: true })
livePhotoVideoId!: string | null;
@Column({ type: 'varchar' })
@Index()
originalFileName!: string;
@Column({ type: 'varchar', nullable: true })
sidecarPath!: string | null;
@OneToOne(() => ExifEntity, (exifEntity) => exifEntity.asset)
exifInfo?: ExifEntity;
@OneToOne(() => SmartSearchEntity, (smartSearchEntity) => smartSearchEntity.asset)
smartSearch?: SmartSearchEntity;
@ManyToMany(() => TagEntity, (tag) => tag.assets, { cascade: true })
@JoinTable({ name: 'tag_asset', synchronize: false })
tags!: TagEntity[];
@ManyToMany(() => SharedLinkEntity, (link) => link.assets, { cascade: true })
@JoinTable({ name: 'shared_link__asset' })
sharedLinks!: SharedLinkEntity[];
@ManyToMany(() => AlbumEntity, (album) => album.assets, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
albums?: AlbumEntity[];
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.asset)
faces!: AssetFaceEntity[];
@Column({ nullable: true })
stackId?: string | null;
@ManyToOne(() => StackEntity, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
@JoinColumn()
stack?: StackEntity | null;
@OneToOne(() => AssetJobStatusEntity, (jobStatus) => jobStatus.asset, { nullable: true })
jobStatus?: AssetJobStatusEntity;
@Index('IDX_assets_duplicateId')
@Column({ type: 'uuid', nullable: true })
duplicateId!: string | null;
}

View file

@ -1,24 +0,0 @@
import { DatabaseAction, EntityType } from 'src/enum';
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
@Entity('audit')
@Index('IDX_ownerId_createdAt', ['ownerId', 'createdAt'])
export class AuditEntity {
@PrimaryGeneratedColumn('increment')
id!: number;
@Column()
entityType!: EntityType;
@Column({ type: 'uuid' })
entityId!: string;
@Column()
action!: DatabaseAction;
@Column({ type: 'uuid' })
ownerId!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
}

View file

@ -1,111 +1,36 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { Index, JoinColumn, OneToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm';
import { Column } from 'typeorm/decorator/columns/Column.js';
import { Entity } from 'typeorm/decorator/entity/Entity.js';
@Entity('exif')
export class ExifEntity {
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn()
asset?: AssetEntity;
@PrimaryColumn()
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 */
@Column({ type: 'text', default: '' })
description!: string; // or caption
@Column({ type: 'integer', nullable: true })
exifImageWidth!: number | null;
@Column({ type: 'integer', nullable: true })
exifImageHeight!: number | null;
@Column({ type: 'bigint', nullable: true })
fileSizeInByte!: number | null;
@Column({ type: 'varchar', nullable: true })
orientation!: string | null;
@Column({ type: 'timestamptz', nullable: true })
dateTimeOriginal!: Date | null;
@Column({ type: 'timestamptz', nullable: true })
modifyDate!: Date | null;
@Column({ type: 'varchar', nullable: true })
timeZone!: string | null;
@Column({ type: 'float', nullable: true })
latitude!: number | null;
@Column({ type: 'float', nullable: true })
longitude!: number | null;
@Column({ type: 'varchar', nullable: true })
projectionType!: string | null;
@Index('exif_city')
@Column({ type: 'varchar', nullable: true })
city!: string | null;
@Index('IDX_live_photo_cid')
@Column({ type: 'varchar', nullable: true })
livePhotoCID!: string | null;
@Index('IDX_auto_stack_id')
@Column({ type: 'varchar', nullable: true })
autoStackId!: string | null;
@Column({ type: 'varchar', nullable: true })
state!: string | null;
@Column({ type: 'varchar', nullable: true })
country!: string | null;
/* Image info */
@Column({ type: 'varchar', nullable: true })
make!: string | null;
@Column({ type: 'varchar', nullable: true })
model!: string | null;
@Column({ type: 'varchar', nullable: true })
lensModel!: string | null;
@Column({ type: 'float8', nullable: true })
fNumber!: number | null;
@Column({ type: 'float8', nullable: true })
focalLength!: number | null;
@Column({ type: 'integer', nullable: true })
iso!: number | null;
@Column({ type: 'varchar', nullable: true })
exposureTime!: string | null;
@Column({ type: 'varchar', nullable: true })
profileDescription!: string | null;
@Column({ type: 'varchar', nullable: true })
colorspace!: string | null;
@Column({ type: 'integer', nullable: true })
bitsPerSample!: number | null;
@Column({ type: 'integer', nullable: true })
rating!: number | null;
/* Video info */
@Column({ type: 'float8', nullable: true })
fps?: number | null;
}

View file

@ -1,16 +1,7 @@
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { Column, Entity, Index, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
@Entity('face_search', { synchronize: false })
export class FaceSearchEntity {
@OneToOne(() => AssetFaceEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'faceId', referencedColumnName: 'id' })
face?: AssetFaceEntity;
@PrimaryColumn()
faceId!: string;
@Index('face_index', { synchronize: false })
@Column({ type: 'float4', array: true })
embedding!: string;
}

View file

@ -1,73 +1,13 @@
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('geodata_places', { synchronize: false })
export class GeodataPlacesEntity {
@PrimaryColumn({ type: 'integer' })
id!: number;
@Column({ type: 'varchar', length: 200 })
name!: string;
@Column({ type: 'float' })
longitude!: number;
@Column({ type: 'float' })
latitude!: number;
@Column({ type: 'char', length: 2 })
countryCode!: string;
@Column({ type: 'varchar', length: 20, nullable: true })
admin1Code!: string;
@Column({ type: 'varchar', length: 80, nullable: true })
admin2Code!: string;
@Column({ type: 'varchar', nullable: true })
admin1Name!: string;
@Column({ type: 'varchar', nullable: true })
admin2Name!: string;
@Column({ type: 'varchar', nullable: true })
alternateNames!: string;
@Column({ type: 'date' })
modificationDate!: Date;
}
@Entity('geodata_places_tmp', { synchronize: false })
export class GeodataPlacesTempEntity {
@PrimaryColumn({ type: 'integer' })
id!: number;
@Column({ type: 'varchar', length: 200 })
name!: string;
@Column({ type: 'float' })
longitude!: number;
@Column({ type: 'float' })
latitude!: number;
@Column({ type: 'char', length: 2 })
countryCode!: string;
@Column({ type: 'varchar', length: 20, nullable: true })
admin1Code!: string;
@Column({ type: 'varchar', length: 80, nullable: true })
admin2Code!: string;
@Column({ type: 'varchar', nullable: true })
admin1Name!: string;
@Column({ type: 'varchar', nullable: true })
admin2Name!: string;
@Column({ type: 'varchar', nullable: true })
alternateNames!: string;
@Column({ type: 'date' })
modificationDate!: Date;
}

View file

@ -1,55 +0,0 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinTable,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('libraries')
export class LibraryEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
name!: string;
@OneToMany(() => AssetEntity, (asset) => asset.library)
@JoinTable()
assets!: AssetEntity[];
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
owner?: UserEntity;
@Column()
ownerId!: string;
@Column('text', { array: true })
importPaths!: string[];
@Column('text', { array: true })
exclusionPatterns!: string[];
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_libraries_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
@Column({ type: 'timestamptz', nullable: true })
refreshedAt!: Date | null;
}

View file

@ -1,74 +0,0 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
import { MemoryType } from 'src/enum';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinTable,
ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
export type OnThisDayData = { year: number };
export interface MemoryData {
[MemoryType.ON_THIS_DAY]: OnThisDayData;
}
@Entity('memories')
export class MemoryEntity<T extends MemoryType = MemoryType> {
@PrimaryGeneratedColumn('uuid')
id!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_memories_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
owner!: UserEntity;
@Column()
ownerId!: string;
@Column()
type!: T;
@Column({ type: 'jsonb' })
data!: MemoryData[T];
/** unless set to true, will be automatically deleted in the future */
@Column({ default: false })
isSaved!: boolean;
/** memories are sorted in ascending order by this value */
@Column({ type: 'timestamptz' })
memoryAt!: Date;
@Column({ type: 'timestamptz', nullable: true })
showAt?: Date;
@Column({ type: 'timestamptz', nullable: true })
hideAt?: Date;
/** when the user last viewed the memory */
@Column({ type: 'timestamptz', nullable: true })
seenAt?: Date;
@ManyToMany(() => AssetEntity)
@JoinTable()
assets!: AssetEntity[];
}

View file

@ -1,24 +1,9 @@
import { PathType } from 'src/enum';
import { Column, Entity, PrimaryGeneratedColumn, Unique } from 'typeorm';
@Entity('move_history')
// path lock (per entity)
@Unique('UQ_entityId_pathType', ['entityId', 'pathType'])
// new path lock (global)
@Unique('UQ_newPath', ['newPath'])
export class MoveEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'uuid' })
entityId!: string;
@Column({ type: 'varchar' })
pathType!: PathType;
@Column({ type: 'varchar' })
oldPath!: string;
@Column({ type: 'varchar' })
newPath!: string;
}

View file

@ -1,37 +1,7 @@
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('naturalearth_countries', { synchronize: false })
export class NaturalEarthCountriesEntity {
@PrimaryGeneratedColumn('identity', { generatedIdentity: 'ALWAYS' })
id!: number;
@Column({ type: 'varchar', length: 50 })
admin!: string;
@Column({ type: 'varchar', length: 3 })
admin_a3!: string;
@Column({ type: 'varchar', length: 50 })
type!: string;
@Column({ type: 'polygon' })
coordinates!: string;
}
@Entity('naturalearth_countries_tmp', { synchronize: false })
export class NaturalEarthCountriesTempEntity {
@PrimaryGeneratedColumn('identity', { generatedIdentity: 'ALWAYS' })
id!: number;
@Column({ type: 'varchar', length: 50 })
admin!: string;
@Column({ type: 'varchar', length: 3 })
admin_a3!: string;
@Column({ type: 'varchar', length: 50 })
type!: string;
@Column({ type: 'polygon' })
coordinates!: string;
}

View file

@ -1,19 +0,0 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity('partners_audit')
export class PartnerAuditEntity {
@PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
id!: string;
@Index('IDX_partners_audit_shared_by_id')
@Column({ type: 'uuid' })
sharedById!: string;
@Index('IDX_partners_audit_shared_with_id')
@Column({ type: 'uuid' })
sharedWithId!: string;
@Index('IDX_partners_audit_deleted_at')
@CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' })
deletedAt!: Date;
}

View file

@ -1,42 +0,0 @@
import { UserEntity } from 'src/entities/user.entity';
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
/** @deprecated delete after coming up with a migration workflow for kysely */
@Entity('partners')
export class PartnerEntity {
@PrimaryColumn('uuid')
sharedById!: string;
@PrimaryColumn('uuid')
sharedWithId!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', eager: true })
@JoinColumn({ name: 'sharedById' })
sharedBy!: UserEntity;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', eager: true })
@JoinColumn({ name: 'sharedWithId' })
sharedWith!: UserEntity;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_partners_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@Column({ type: 'boolean', default: false })
inTimeline!: boolean;
}

View file

@ -1,63 +1,20 @@
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { UserEntity } from 'src/entities/user.entity';
import {
Check,
Column,
CreateDateColumn,
Entity,
Index,
ManyToOne,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('person')
@Check(`"birthDate" <= CURRENT_DATE`)
export class PersonEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_person_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@Column()
ownerId!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
owner!: UserEntity;
@Column({ default: '' })
name!: string;
@Column({ type: 'date', nullable: true })
birthDate!: Date | string | null;
@Column({ default: '' })
thumbnailPath!: string;
@Column({ nullable: true })
faceAssetId!: string | null;
@ManyToOne(() => AssetFaceEntity, { onDelete: 'SET NULL', nullable: true })
faceAsset!: AssetFaceEntity | null;
@OneToMany(() => AssetFaceEntity, (assetFace) => assetFace.person)
faces!: AssetFaceEntity[];
@Column({ default: false })
isHidden!: boolean;
@Column({ default: false })
isFavorite!: boolean;
@Column({ type: 'varchar', nullable: true, default: null })
color?: string | null;
}

View file

@ -1,36 +1,16 @@
import { ExpressionBuilder } from 'kysely';
import { DB } from 'src/db';
import { UserEntity } from 'src/entities/user.entity';
import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
@Entity('sessions')
export class SessionEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ select: false })
token!: string;
@Column()
userId!: string;
@ManyToOne(() => UserEntity, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
user!: UserEntity;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_sessions_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId!: string;
@Column({ default: '' })
deviceType!: string;
@Column({ default: '' })
deviceOS!: string;
}

View file

@ -2,64 +2,21 @@ import { AlbumEntity } from 'src/entities/album.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
import { SharedLinkType } from 'src/enum';
import {
Column,
CreateDateColumn,
Entity,
Index,
ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
Unique,
} from 'typeorm';
@Entity('shared_links')
@Unique('UQ_sharedlink_key', ['key'])
export class SharedLinkEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ type: 'varchar', nullable: true })
description!: string | null;
@Column({ type: 'varchar', nullable: true })
password!: string | null;
@Column()
userId!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
user!: UserEntity;
@Index('IDX_sharedlink_key')
@Column({ type: 'bytea' })
key!: Buffer; // use to access the inidividual asset
@Column()
type!: SharedLinkType;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@Column({ type: 'timestamptz', nullable: true })
expiresAt!: Date | null;
@Column({ type: 'boolean', default: false })
allowUpload!: boolean;
@Column({ type: 'boolean', default: true })
allowDownload!: boolean;
@Column({ type: 'boolean', default: true })
showExif!: boolean;
@ManyToMany(() => AssetEntity, (asset) => asset.sharedLinks, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
assets!: AssetEntity[];
@Index('IDX_sharedlink_albumId')
@ManyToOne(() => AlbumEntity, (album) => album.sharedLinks, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
album?: AlbumEntity;
@Column({ type: 'varchar', nullable: true })
albumId!: string | null;
}

View file

@ -1,16 +1,7 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { Column, Entity, Index, JoinColumn, OneToOne, PrimaryColumn } from 'typeorm';
@Entity('smart_search', { synchronize: false })
export class SmartSearchEntity {
@OneToOne(() => AssetEntity, { onDelete: 'CASCADE', nullable: true })
@JoinColumn({ name: 'assetId', referencedColumnName: 'id' })
asset?: AssetEntity;
@PrimaryColumn()
assetId!: string;
@Index('clip_index', { synchronize: false })
@Column({ type: 'float4', array: true })
embedding!: string;
}

View file

@ -1,28 +1,12 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
import { Column, Entity, JoinColumn, ManyToOne, OneToMany, OneToOne, PrimaryGeneratedColumn } from 'typeorm';
@Entity('asset_stack')
export class StackEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@ManyToOne(() => UserEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
owner!: UserEntity;
@Column()
ownerId!: string;
@OneToMany(() => AssetEntity, (asset) => asset.stack)
assets!: AssetEntity[];
@OneToOne(() => AssetEntity)
@JoinColumn()
//TODO: Add constraint to ensure primary asset exists in the assets array
primaryAsset!: AssetEntity;
@Column({ nullable: false })
primaryAssetId!: string;
assetCount?: number;
}

View file

@ -1,28 +0,0 @@
import { SessionEntity } from 'src/entities/session.entity';
import { SyncEntityType } from 'src/enum';
import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryColumn, UpdateDateColumn } from 'typeorm';
@Entity('session_sync_checkpoints')
export class SessionSyncCheckpointEntity {
@ManyToOne(() => SessionEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
session?: SessionEntity;
@PrimaryColumn()
sessionId!: string;
@PrimaryColumn({ type: 'varchar' })
type!: SyncEntityType;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_session_sync_checkpoints_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@Column()
ack!: string;
}

View file

@ -1,31 +0,0 @@
import { SystemConfig } from 'src/config';
import { StorageFolder, SystemMetadataKey } from 'src/enum';
import { DeepPartial } from 'src/types';
import { Column, Entity, PrimaryColumn } from 'typeorm';
@Entity('system_metadata')
export class SystemMetadataEntity<T extends keyof SystemMetadata = SystemMetadataKey> {
@PrimaryColumn({ type: 'varchar' })
key!: T;
@Column({ type: 'jsonb' })
value!: SystemMetadata[T];
}
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> };
export type MemoriesState = {
/** memories have already been created through this date */
lastOnThisDayDate: string;
};
export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> {
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
[SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string };
[SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date };
[SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
[SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial<SystemFlags>;
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
[SystemMetadataKey.MEMORIES_STATE]: MemoriesState;
}

View file

@ -1,58 +1,17 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { UserEntity } from 'src/entities/user.entity';
import {
Column,
CreateDateColumn,
Entity,
Index,
ManyToMany,
ManyToOne,
PrimaryGeneratedColumn,
Tree,
TreeChildren,
TreeParent,
Unique,
UpdateDateColumn,
} from 'typeorm';
@Entity('tags')
@Unique(['userId', 'value'])
@Tree('closure-table')
export class TagEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column()
value!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_tags_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@Column({ type: 'varchar', nullable: true, default: null })
color!: string | null;
@Column({ nullable: true })
parentId?: string;
@TreeParent({ onDelete: 'CASCADE' })
parent?: TagEntity;
@TreeChildren()
children?: TagEntity[];
@ManyToOne(() => UserEntity, (user) => user.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
user?: UserEntity;
@Column()
userId!: string;
@ManyToMany(() => AssetEntity, (asset) => asset.tags, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
assets?: AssetEntity[];
}

View file

@ -1,14 +0,0 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity('users_audit')
export class UserAuditEntity {
@PrimaryColumn({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
id!: string;
@Column({ type: 'uuid' })
userId!: string;
@Index('IDX_users_audit_deleted_at')
@CreateDateColumn({ type: 'timestamptz', default: () => 'clock_timestamp()' })
deletedAt!: Date;
}

View file

@ -2,25 +2,16 @@ import { UserEntity } from 'src/entities/user.entity';
import { UserAvatarColor, UserMetadataKey } from 'src/enum';
import { DeepPartial } from 'src/types';
import { HumanReadableSize } from 'src/utils/bytes';
import { Column, Entity, ManyToOne, PrimaryColumn } from 'typeorm';
export type UserMetadataItem<T extends keyof UserMetadata = UserMetadataKey> = {
key: T;
value: UserMetadata[T];
};
@Entity('user_metadata')
export class UserMetadataEntity<T extends keyof UserMetadata = UserMetadataKey> implements UserMetadataItem<T> {
@PrimaryColumn({ type: 'uuid' })
userId!: string;
@ManyToOne(() => UserEntity, (user) => user.metadata, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
user?: UserEntity;
@PrimaryColumn({ type: 'varchar' })
key!: T;
@Column({ type: 'jsonb' })
value!: UserMetadata[T];
}

View file

@ -2,82 +2,28 @@ import { ExpressionBuilder } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { DB } from 'src/db';
import { AssetEntity } from 'src/entities/asset.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserStatus } from 'src/enum';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
OneToMany,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('users')
@Index('IDX_users_updated_at_asc_id_asc', ['updatedAt', 'id'])
export class UserEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@Column({ default: '' })
name!: string;
@Column({ default: false })
isAdmin!: boolean;
@Column({ unique: true })
email!: string;
@Column({ type: 'varchar', unique: true, default: null })
storageLabel!: string | null;
@Column({ default: '', select: false })
password?: string;
@Column({ default: '' })
oauthId!: string;
@Column({ default: '' })
profileImagePath!: string;
@Column({ default: true })
shouldChangePassword!: boolean;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt!: Date | null;
@Column({ type: 'varchar', default: UserStatus.ACTIVE })
status!: UserStatus;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt!: Date;
@Index('IDX_users_update_id')
@Column({ type: 'uuid', nullable: false, default: () => 'immich_uuid_v7()' })
updateId?: string;
@OneToMany(() => TagEntity, (tag) => tag.user)
tags!: TagEntity[];
@OneToMany(() => AssetEntity, (asset) => asset.owner)
assets!: AssetEntity[];
@Column({ type: 'bigint', nullable: true })
quotaSizeInBytes!: number | null;
@Column({ type: 'bigint', default: 0 })
quotaUsageInBytes!: number;
@OneToMany(() => UserMetadataEntity, (metadata) => metadata.user)
metadata!: UserMetadataEntity[];
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
profileChangedAt!: Date;
}

View file

@ -1,13 +0,0 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('version_history')
export class VersionHistoryEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@Column()
version!: string;
}

View file

@ -316,9 +316,9 @@ const getEnv = (): EnvData => {
config: {
typeorm: {
type: 'postgres',
entities: [`${folders.dist}/entities` + '/*.entity.{js,ts}'],
entities: [],
migrations: [`${folders.dist}/migrations` + '/*.{js,ts}'],
subscribers: [`${folders.dist}/subscribers` + '/*.{js,ts}'],
subscribers: [],
migrationsRun: false,
synchronize: false,
connectTimeoutMS: 10_000, // 10 seconds

View file

@ -9,7 +9,6 @@ import { PersonEntity } from 'src/entities/person.entity';
import { SourceType } from 'src/enum';
import { removeUndefinedKeys } from 'src/utils/database';
import { Paginated, PaginationOptions } from 'src/utils/pagination';
import { FindOptionsRelations } from 'typeorm';
export interface PersonSearchOptions {
minimumFaceCount: number;
@ -247,7 +246,7 @@ export class PersonRepository {
@GenerateSql({ params: [DummyValue.UUID] })
getFaceByIdWithAssets(
id: string,
relations?: FindOptionsRelations<AssetFaceEntity>,
relations?: { faceSearch?: boolean },
select?: SelectFaceOptions,
): Promise<AssetFaceEntity | undefined> {
return this.db

View file

@ -4,7 +4,7 @@ import { InjectKysely } from 'nestjs-kysely';
import { readFile } from 'node:fs/promises';
import { DB, SystemMetadata as DbSystemMetadata } from 'src/db';
import { GenerateSql } from 'src/decorators';
import { SystemMetadata } from 'src/entities/system-metadata.entity';
import { SystemMetadata } from 'src/types';
type Upsert = Insertable<DbSystemMetadata>;

View file

@ -3,11 +3,12 @@ import { Insertable, Kysely, sql, Updateable } from 'kysely';
import { DateTime } from 'luxon';
import { InjectKysely } from 'nestjs-kysely';
import { columns, UserAdmin } from 'src/database';
import { DB, UserMetadata as DbUserMetadata, Users } from 'src/db';
import { DB, UserMetadata as DbUserMetadata } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators';
import { UserMetadata, UserMetadataItem } from 'src/entities/user-metadata.entity';
import { UserEntity, withMetadata } from 'src/entities/user.entity';
import { AssetType, UserStatus } from 'src/enum';
import { UserTable } from 'src/tables/user.table';
import { asUuid } from 'src/utils/database';
type Upsert = Insertable<DbUserMetadata>;
@ -128,7 +129,7 @@ export class UserRepository {
.execute() as Promise<UserAdmin[]>;
}
async create(dto: Insertable<Users>): Promise<UserEntity> {
async create(dto: Insertable<UserTable>): Promise<UserEntity> {
return this.db
.insertInto('users')
.values(dto)
@ -136,7 +137,7 @@ export class UserRepository {
.executeTakeFirst() as unknown as Promise<UserEntity>;
}
update(id: string, dto: Updateable<Users>): Promise<UserEntity> {
update(id: string, dto: Updateable<UserTable>): Promise<UserEntity> {
return this.db
.updateTable('users')
.set(dto)

View file

@ -4,7 +4,6 @@ import sanitize from 'sanitize-filename';
import { SystemConfig } from 'src/config';
import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { Users } from 'src/db';
import { UserEntity } from 'src/entities/user.entity';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
@ -47,6 +46,7 @@ import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { ViewRepository } from 'src/repositories/view-repository';
import { UserTable } from 'src/tables/user.table';
import { AccessRequest, checkAccess, requireAccess } from 'src/utils/access';
import { getConfig, updateConfig } from 'src/utils/config';
@ -138,7 +138,7 @@ export class BaseService {
return checkAccess(this.accessRepository, request);
}
async createUser(dto: Insertable<Users> & { email: string }): Promise<UserEntity> {
async createUser(dto: Insertable<UserTable> & { email: string }): Promise<UserEntity> {
const user = await this.userRepository.getByEmail(dto.email);
if (user) {
throw new BadRequestException('User exists');
@ -151,7 +151,7 @@ export class BaseService {
}
}
const payload: Insertable<Users> = { ...dto };
const payload: Insertable<UserTable> = { ...dto };
if (payload.password) {
payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
}

View file

@ -4,9 +4,9 @@ import { OnJob } from 'src/decorators';
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto';
import { OnThisDayData } from 'src/entities/memory.entity';
import { JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { OnThisDayData } from 'src/types';
import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util';
const DAYS = 3;

View file

@ -451,11 +451,11 @@ export class PersonService extends BaseService {
return JobStatus.SKIPPED;
}
const face = await this.personRepository.getFaceByIdWithAssets(
id,
{ person: true, asset: true, faceSearch: true },
['id', 'personId', 'sourceType'],
);
const face = await this.personRepository.getFaceByIdWithAssets(id, { faceSearch: true }, [
'id',
'personId',
'sourceType',
]);
if (!face || !face.asset) {
this.logger.warn(`Face ${id} not found`);
return JobStatus.FAILED;

View file

@ -2,10 +2,9 @@ import { Injectable } from '@nestjs/common';
import { join } from 'node:path';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent, OnJob } from 'src/decorators';
import { SystemFlags } from 'src/entities/system-metadata.entity';
import { DatabaseLock, JobName, JobStatus, QueueName, StorageFolder, SystemMetadataKey } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { JobOf } from 'src/types';
import { JobOf, SystemFlags } from 'src/types';
import { ImmichStartupError } from 'src/utils/misc';
const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`;

View file

@ -4,10 +4,10 @@ import semver, { SemVer } from 'semver';
import { serverVersion } from 'src/constants';
import { OnEvent, OnJob } from 'src/decorators';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { VersionCheckMetadata } from 'src/entities/system-metadata.entity';
import { DatabaseLock, ImmichEnvironment, JobName, JobStatus, QueueName, SystemMetadataKey } from 'src/enum';
import { ArgOf } from 'src/repositories/event.repository';
import { BaseService } from 'src/services/base.service';
import { VersionCheckMetadata } from 'src/types';
const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => {
return {

View file

@ -0,0 +1,107 @@
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { register } from 'src/sql-tools/schema-from-decorators';
import {
CheckOptions,
ColumnDefaultValue,
ColumnIndexOptions,
ColumnOptions,
ForeignKeyColumnOptions,
GenerateColumnOptions,
IndexOptions,
TableOptions,
UniqueOptions,
} from 'src/sql-tools/types';
export const Table = (options: string | TableOptions = {}): ClassDecorator => {
return (object: Function) => void register({ type: 'table', item: { object, options: asOptions(options) } });
};
export const Column = (options: string | ColumnOptions = {}): PropertyDecorator => {
return (object: object, propertyName: string | symbol) =>
void register({ type: 'column', item: { object, propertyName, options: asOptions(options) } });
};
export const Index = (options: string | IndexOptions = {}): ClassDecorator => {
return (object: Function) => void register({ type: 'index', item: { object, options: asOptions(options) } });
};
export const Unique = (options: UniqueOptions): ClassDecorator => {
return (object: Function) => void register({ type: 'uniqueConstraint', item: { object, options } });
};
export const Check = (options: CheckOptions): ClassDecorator => {
return (object: Function) => void register({ type: 'checkConstraint', item: { object, options } });
};
export const ColumnIndex = (options: string | ColumnIndexOptions = {}): PropertyDecorator => {
return (object: object, propertyName: string | symbol) =>
void register({ type: 'columnIndex', item: { object, propertyName, options: asOptions(options) } });
};
export const ForeignKeyColumn = (target: () => object, options: ForeignKeyColumnOptions): PropertyDecorator => {
return (object: object, propertyName: string | symbol) => {
register({ type: 'foreignKeyColumn', item: { object, propertyName, options, target } });
};
};
export const CreateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
return Column({
type: 'timestamp with time zone',
default: () => 'now()',
...options,
});
};
export const UpdateDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
return Column({
type: 'timestamp with time zone',
default: () => 'now()',
...options,
});
};
export const DeleteDateColumn = (options: ColumnOptions = {}): PropertyDecorator => {
return Column({
type: 'timestamp with time zone',
nullable: true,
...options,
});
};
export const PrimaryGeneratedColumn = (options: Omit<GenerateColumnOptions, 'primary'> = {}) =>
GeneratedColumn({ type: 'v4', ...options, primary: true });
export const PrimaryColumn = (options: Omit<ColumnOptions, 'primary'> = {}) => Column({ ...options, primary: true });
export const GeneratedColumn = ({ type = 'v4', ...options }: GenerateColumnOptions): PropertyDecorator => {
const columnType = type === 'v4' || type === 'v7' ? 'uuid' : type;
let columnDefault: ColumnDefaultValue | undefined;
switch (type) {
case 'v4': {
columnDefault = () => 'uuid_generate_v4()';
break;
}
case 'v7': {
columnDefault = () => 'immich_uuid_v7()';
break;
}
}
return Column({
type: columnType,
default: columnDefault,
...options,
});
};
export const UpdateIdColumn = () => GeneratedColumn({ type: 'v7', nullable: false });
const asOptions = <T extends { name?: string }>(options: string | T): T => {
if (typeof options === 'string') {
return { name: options } as T;
}
return options;
};

View file

@ -0,0 +1 @@
export * from 'src/sql-tools/public_api';

View file

@ -0,0 +1,6 @@
export * from 'src/sql-tools/decorators';
export { schemaDiff } from 'src/sql-tools/schema-diff';
export { schemaDiffToSql } from 'src/sql-tools/schema-diff-to-sql';
export { schemaFromDatabase } from 'src/sql-tools/schema-from-database';
export { schemaFromDecorators } from 'src/sql-tools/schema-from-decorators';
export * from 'src/sql-tools/types';

View file

@ -0,0 +1,473 @@
import { DatabaseConstraintType, schemaDiffToSql } from 'src/sql-tools';
import { describe, expect, it } from 'vitest';
describe('diffToSql', () => {
describe('table.drop', () => {
it('should work', () => {
expect(
schemaDiffToSql([
{
type: 'table.drop',
tableName: 'table1',
reason: 'unknown',
},
]),
).toEqual([`DROP TABLE "table1";`]);
});
});
describe('table.create', () => {
it('should work', () => {
expect(
schemaDiffToSql([
{
type: 'table.create',
tableName: 'table1',
columns: [
{
tableName: 'table1',
name: 'column1',
type: 'character varying',
nullable: true,
isArray: false,
synchronize: true,
},
],
reason: 'unknown',
},
]),
).toEqual([`CREATE TABLE "table1" ("column1" character varying);`]);
});
it('should handle a non-nullable column', () => {
expect(
schemaDiffToSql([
{
type: 'table.create',
tableName: 'table1',
columns: [
{
tableName: 'table1',
name: 'column1',
type: 'character varying',
isArray: false,
nullable: false,
synchronize: true,
},
],
reason: 'unknown',
},
]),
).toEqual([`CREATE TABLE "table1" ("column1" character varying NOT NULL);`]);
});
it('should handle a default value', () => {
expect(
schemaDiffToSql([
{
type: 'table.create',
tableName: 'table1',
columns: [
{
tableName: 'table1',
name: 'column1',
type: 'character varying',
isArray: false,
nullable: true,
default: 'uuid_generate_v4()',
synchronize: true,
},
],
reason: 'unknown',
},
]),
).toEqual([`CREATE TABLE "table1" ("column1" character varying DEFAULT uuid_generate_v4());`]);
});
it('should handle an array type', () => {
expect(
schemaDiffToSql([
{
type: 'table.create',
tableName: 'table1',
columns: [
{
tableName: 'table1',
name: 'column1',
type: 'character varying',
isArray: true,
nullable: true,
synchronize: true,
},
],
reason: 'unknown',
},
]),
).toEqual([`CREATE TABLE "table1" ("column1" character varying[]);`]);
});
});
describe('column.add', () => {
it('should work', () => {
expect(
schemaDiffToSql([
{
type: 'column.add',
column: {
name: 'column1',
tableName: 'table1',
type: 'character varying',
nullable: false,
isArray: false,
synchronize: true,
},
reason: 'unknown',
},
]),
).toEqual(['ALTER TABLE "table1" ADD "column1" character varying NOT NULL;']);
});
it('should add a nullable column', () => {
expect(
schemaDiffToSql([
{
type: 'column.add',
column: {
name: 'column1',
tableName: 'table1',
type: 'character varying',
nullable: true,
isArray: false,
synchronize: true,
},
reason: 'unknown',
},
]),
).toEqual(['ALTER TABLE "table1" ADD "column1" character varying;']);
});
it('should add a column with an enum type', () => {
expect(
schemaDiffToSql([
{
type: 'column.add',
column: {
name: 'column1',
tableName: 'table1',
type: 'character varying',
enumName: 'table1_column1_enum',
nullable: true,
isArray: false,
synchronize: true,
},
reason: 'unknown',
},
]),
).toEqual(['ALTER TABLE "table1" ADD "column1" table1_column1_enum;']);
});
it('should add a column that is an array type', () => {
expect(
schemaDiffToSql([
{
type: 'column.add',
column: {
name: 'column1',
tableName: 'table1',
type: 'boolean',
nullable: true,
isArray: true,
synchronize: true,
},
reason: 'unknown',
},
]),
).toEqual(['ALTER TABLE "table1" ADD "column1" boolean[];']);
});
});
describe('column.alter', () => {
it('should make a column nullable', () => {
expect(
schemaDiffToSql([
{
type: 'column.alter',
tableName: 'table1',
columnName: 'column1',
changes: { nullable: true },
reason: 'unknown',
},
]),
).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" DROP NOT NULL;`]);
});
it('should make a column non-nullable', () => {
expect(
schemaDiffToSql([
{
type: 'column.alter',
tableName: 'table1',
columnName: 'column1',
changes: { nullable: false },
reason: 'unknown',
},
]),
).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET NOT NULL;`]);
});
it('should update the default value', () => {
expect(
schemaDiffToSql([
{
type: 'column.alter',
tableName: 'table1',
columnName: 'column1',
changes: { default: 'uuid_generate_v4()' },
reason: 'unknown',
},
]),
).toEqual([`ALTER TABLE "table1" ALTER COLUMN "column1" SET DEFAULT uuid_generate_v4();`]);
});
});
describe('column.drop', () => {
it('should work', () => {
expect(
schemaDiffToSql([
{
type: 'column.drop',
tableName: 'table1',
columnName: 'column1',
reason: 'unknown',
},
]),
).toEqual([`ALTER TABLE "table1" DROP COLUMN "column1";`]);
});
});
describe('constraint.add', () => {
describe('primary keys', () => {
it('should work', () => {
expect(
schemaDiffToSql([
{
type: 'constraint.add',
constraint: {
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_test',
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
},
reason: 'unknown',
},
]),
).toEqual(['ALTER TABLE "table1" ADD CONSTRAINT "PK_test" PRIMARY KEY ("id");']);
});
});
describe('foreign keys', () => {
it('should work', () => {
expect(
schemaDiffToSql([
{
type: 'constraint.add',
constraint: {
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_test',
tableName: 'table1',
columnNames: ['parentId'],
referenceColumnNames: ['id'],
referenceTableName: 'table2',
synchronize: true,
},
reason: 'unknown',
},
]),
).toEqual([
'ALTER TABLE "table1" ADD CONSTRAINT "FK_test" FOREIGN KEY ("parentId") REFERENCES "table2" ("id") ON UPDATE NO ACTION ON DELETE NO ACTION;',
]);
});
});
describe('unique', () => {
it('should work', () => {
expect(
schemaDiffToSql([
{
type: 'constraint.add',
constraint: {
type: DatabaseConstraintType.UNIQUE,
name: 'UQ_test',
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
},
reason: 'unknown',
},
]),
).toEqual(['ALTER TABLE "table1" ADD CONSTRAINT "UQ_test" UNIQUE ("id");']);
});
});
describe('check', () => {
it('should work', () => {
expect(
schemaDiffToSql([
{
type: 'constraint.add',
constraint: {
type: DatabaseConstraintType.CHECK,
name: 'CHK_test',
tableName: 'table1',
expression: '"id" IS NOT NULL',
synchronize: true,
},
reason: 'unknown',
},
]),
).toEqual(['ALTER TABLE "table1" ADD CONSTRAINT "CHK_test" CHECK ("id" IS NOT NULL);']);
});
});
});
describe('constraint.drop', () => {
it('should work', () => {
expect(
schemaDiffToSql([
{
type: 'constraint.drop',
tableName: 'table1',
constraintName: 'PK_test',
reason: 'unknown',
},
]),
).toEqual([`ALTER TABLE "table1" DROP CONSTRAINT "PK_test";`]);
});
});
describe('index.create', () => {
it('should work', () => {
expect(
schemaDiffToSql([
{
type: 'index.create',
index: {
name: 'IDX_test',
tableName: 'table1',
columnNames: ['column1'],
unique: false,
synchronize: true,
},
reason: 'unknown',
},
]),
).toEqual(['CREATE INDEX "IDX_test" ON "table1" ("column1")']);
});
it('should create an unique index', () => {
expect(
schemaDiffToSql([
{
type: 'index.create',
index: {
name: 'IDX_test',
tableName: 'table1',
columnNames: ['column1'],
unique: true,
synchronize: true,
},
reason: 'unknown',
},
]),
).toEqual(['CREATE UNIQUE INDEX "IDX_test" ON "table1" ("column1")']);
});
it('should create an index with a custom expression', () => {
expect(
schemaDiffToSql([
{
type: 'index.create',
index: {
name: 'IDX_test',
tableName: 'table1',
unique: false,
expression: '"id" IS NOT NULL',
synchronize: true,
},
reason: 'unknown',
},
]),
).toEqual(['CREATE INDEX "IDX_test" ON "table1" ("id" IS NOT NULL)']);
});
it('should create an index with a where clause', () => {
expect(
schemaDiffToSql([
{
type: 'index.create',
index: {
name: 'IDX_test',
tableName: 'table1',
columnNames: ['id'],
unique: false,
where: '("id" IS NOT NULL)',
synchronize: true,
},
reason: 'unknown',
},
]),
).toEqual(['CREATE INDEX "IDX_test" ON "table1" ("id") WHERE ("id" IS NOT NULL)']);
});
it('should create an index with a custom expression', () => {
expect(
schemaDiffToSql([
{
type: 'index.create',
index: {
name: 'IDX_test',
tableName: 'table1',
unique: false,
using: 'gin',
expression: '"id" IS NOT NULL',
synchronize: true,
},
reason: 'unknown',
},
]),
).toEqual(['CREATE INDEX "IDX_test" ON "table1" USING gin ("id" IS NOT NULL)']);
});
});
describe('index.drop', () => {
it('should work', () => {
expect(
schemaDiffToSql([
{
type: 'index.drop',
indexName: 'IDX_test',
reason: 'unknown',
},
]),
).toEqual([`DROP INDEX "IDX_test";`]);
});
});
describe('comments', () => {
it('should include the reason in a SQL comment', () => {
expect(
schemaDiffToSql(
[
{
type: 'index.drop',
indexName: 'IDX_test',
reason: 'unknown',
},
],
{ comments: true },
),
).toEqual([`DROP INDEX "IDX_test"; -- unknown`]);
});
});
});

View file

@ -0,0 +1,204 @@
import {
DatabaseActionType,
DatabaseColumn,
DatabaseColumnChanges,
DatabaseConstraint,
DatabaseConstraintType,
DatabaseIndex,
SchemaDiff,
SchemaDiffToSqlOptions,
} from 'src/sql-tools/types';
const asColumnList = (columns: string[]) =>
columns
.toSorted()
.map((column) => `"${column}"`)
.join(', ');
const withNull = (column: DatabaseColumn) => (column.nullable ? '' : ' NOT NULL');
const withDefault = (column: DatabaseColumn) => (column.default ? ` DEFAULT ${column.default}` : '');
const withAction = (constraint: { onDelete?: DatabaseActionType; onUpdate?: DatabaseActionType }) =>
` ON UPDATE ${constraint.onUpdate ?? DatabaseActionType.NO_ACTION} ON DELETE ${constraint.onDelete ?? DatabaseActionType.NO_ACTION}`;
const withComments = (comments: boolean | undefined, item: SchemaDiff): string => {
if (!comments) {
return '';
}
return ` -- ${item.reason}`;
};
const asArray = <T>(items: T | T[]): T[] => {
if (Array.isArray(items)) {
return items;
}
return [items];
};
export const getColumnType = (column: DatabaseColumn) => {
let type = column.enumName || column.type;
if (column.isArray) {
type += '[]';
}
return type;
};
/**
* Convert schema diffs into SQL statements
*/
export const schemaDiffToSql = (items: SchemaDiff[], options: SchemaDiffToSqlOptions = {}): string[] => {
return items.flatMap((item) => asArray(asSql(item)).map((result) => result + withComments(options.comments, item)));
};
const asSql = (item: SchemaDiff): string | string[] => {
switch (item.type) {
case 'table.create': {
return asTableCreate(item.tableName, item.columns);
}
case 'table.drop': {
return asTableDrop(item.tableName);
}
case 'column.add': {
return asColumnAdd(item.column);
}
case 'column.alter': {
return asColumnAlter(item.tableName, item.columnName, item.changes);
}
case 'column.drop': {
return asColumnDrop(item.tableName, item.columnName);
}
case 'constraint.add': {
return asConstraintAdd(item.constraint);
}
case 'constraint.drop': {
return asConstraintDrop(item.tableName, item.constraintName);
}
case 'index.create': {
return asIndexCreate(item.index);
}
case 'index.drop': {
return asIndexDrop(item.indexName);
}
default: {
return [];
}
}
};
const asTableCreate = (tableName: string, tableColumns: DatabaseColumn[]): string => {
const columns = tableColumns
.map((column) => `"${column.name}" ${getColumnType(column)}` + withNull(column) + withDefault(column))
.join(', ');
return `CREATE TABLE "${tableName}" (${columns});`;
};
const asTableDrop = (tableName: string): string => {
return `DROP TABLE "${tableName}";`;
};
const asColumnAdd = (column: DatabaseColumn): string => {
return (
`ALTER TABLE "${column.tableName}" ADD "${column.name}" ${getColumnType(column)}` +
withNull(column) +
withDefault(column) +
';'
);
};
const asColumnAlter = (tableName: string, columnName: string, changes: DatabaseColumnChanges): string[] => {
const base = `ALTER TABLE "${tableName}" ALTER COLUMN "${columnName}"`;
const items: string[] = [];
if (changes.nullable !== undefined) {
items.push(changes.nullable ? `${base} DROP NOT NULL;` : `${base} SET NOT NULL;`);
}
if (changes.default !== undefined) {
items.push(`${base} SET DEFAULT ${changes.default};`);
}
return items;
};
const asColumnDrop = (tableName: string, columnName: string): string => {
return `ALTER TABLE "${tableName}" DROP COLUMN "${columnName}";`;
};
const asConstraintAdd = (constraint: DatabaseConstraint): string | string[] => {
const base = `ALTER TABLE "${constraint.tableName}" ADD CONSTRAINT "${constraint.name}"`;
switch (constraint.type) {
case DatabaseConstraintType.PRIMARY_KEY: {
const columnNames = asColumnList(constraint.columnNames);
return `${base} PRIMARY KEY (${columnNames});`;
}
case DatabaseConstraintType.FOREIGN_KEY: {
const columnNames = asColumnList(constraint.columnNames);
const referenceColumnNames = asColumnList(constraint.referenceColumnNames);
return (
`${base} FOREIGN KEY (${columnNames}) REFERENCES "${constraint.referenceTableName}" (${referenceColumnNames})` +
withAction(constraint) +
';'
);
}
case DatabaseConstraintType.UNIQUE: {
const columnNames = asColumnList(constraint.columnNames);
return `${base} UNIQUE (${columnNames});`;
}
case DatabaseConstraintType.CHECK: {
return `${base} CHECK (${constraint.expression});`;
}
default: {
return [];
}
}
};
const asConstraintDrop = (tableName: string, constraintName: string): string => {
return `ALTER TABLE "${tableName}" DROP CONSTRAINT "${constraintName}";`;
};
const asIndexCreate = (index: DatabaseIndex): string => {
let sql = `CREATE`;
if (index.unique) {
sql += ' UNIQUE';
}
sql += ` INDEX "${index.name}" ON "${index.tableName}"`;
if (index.columnNames) {
const columnNames = asColumnList(index.columnNames);
sql += ` (${columnNames})`;
}
if (index.using && index.using !== 'btree') {
sql += ` USING ${index.using}`;
}
if (index.expression) {
sql += ` (${index.expression})`;
}
if (index.where) {
sql += ` WHERE ${index.where}`;
}
return sql;
};
const asIndexDrop = (indexName: string): string => {
return `DROP INDEX "${indexName}";`;
};

View file

@ -0,0 +1,635 @@
import { schemaDiff } from 'src/sql-tools/schema-diff';
import {
DatabaseActionType,
DatabaseColumn,
DatabaseColumnType,
DatabaseConstraint,
DatabaseConstraintType,
DatabaseIndex,
DatabaseSchema,
DatabaseTable,
} from 'src/sql-tools/types';
import { describe, expect, it } from 'vitest';
const fromColumn = (column: Partial<Omit<DatabaseColumn, 'tableName'>>): DatabaseSchema => {
const tableName = 'table1';
return {
name: 'public',
tables: [
{
name: tableName,
columns: [
{
name: 'column1',
synchronize: true,
isArray: false,
type: 'character varying',
nullable: false,
...column,
tableName,
},
],
indexes: [],
constraints: [],
synchronize: true,
},
],
warnings: [],
};
};
const fromConstraint = (constraint?: DatabaseConstraint): DatabaseSchema => {
const tableName = constraint?.tableName || 'table1';
return {
name: 'public',
tables: [
{
name: tableName,
columns: [
{
name: 'column1',
synchronize: true,
isArray: false,
type: 'character varying',
nullable: false,
tableName,
},
],
indexes: [],
constraints: constraint ? [constraint] : [],
synchronize: true,
},
],
warnings: [],
};
};
const fromIndex = (index?: DatabaseIndex): DatabaseSchema => {
const tableName = index?.tableName || 'table1';
return {
name: 'public',
tables: [
{
name: tableName,
columns: [
{
name: 'column1',
synchronize: true,
isArray: false,
type: 'character varying',
nullable: false,
tableName,
},
],
indexes: index ? [index] : [],
constraints: [],
synchronize: true,
},
],
warnings: [],
};
};
const newSchema = (schema: {
name?: string;
tables: Array<{
name: string;
columns?: Array<{
name: string;
type?: DatabaseColumnType;
nullable?: boolean;
isArray?: boolean;
}>;
indexes?: DatabaseIndex[];
constraints?: DatabaseConstraint[];
}>;
}): DatabaseSchema => {
const tables: DatabaseTable[] = [];
for (const table of schema.tables || []) {
const tableName = table.name;
const columns: DatabaseColumn[] = [];
for (const column of table.columns || []) {
const columnName = column.name;
columns.push({
tableName,
name: columnName,
type: column.type || 'character varying',
isArray: column.isArray ?? false,
nullable: column.nullable ?? false,
synchronize: true,
});
}
tables.push({
name: tableName,
columns,
indexes: table.indexes ?? [],
constraints: table.constraints ?? [],
synchronize: true,
});
}
return {
name: schema?.name || 'public',
tables,
warnings: [],
};
};
describe('schemaDiff', () => {
it('should work', () => {
const diff = schemaDiff(newSchema({ tables: [] }), newSchema({ tables: [] }));
expect(diff.items).toEqual([]);
});
describe('table', () => {
describe('table.create', () => {
it('should find a missing table', () => {
const column: DatabaseColumn = {
type: 'character varying',
tableName: 'table1',
name: 'column1',
isArray: false,
nullable: false,
synchronize: true,
};
const diff = schemaDiff(
newSchema({ tables: [{ name: 'table1', columns: [column] }] }),
newSchema({ tables: [] }),
);
expect(diff.items).toHaveLength(1);
expect(diff.items[0]).toEqual({
type: 'table.create',
tableName: 'table1',
columns: [column],
reason: 'missing in target',
});
});
});
describe('table.drop', () => {
it('should find an extra table', () => {
const diff = schemaDiff(
newSchema({ tables: [] }),
newSchema({
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
}),
{ ignoreExtraTables: false },
);
expect(diff.items).toHaveLength(1);
expect(diff.items[0]).toEqual({
type: 'table.drop',
tableName: 'table1',
reason: 'missing in source',
});
});
});
it('should skip identical tables', () => {
const diff = schemaDiff(
newSchema({
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
}),
newSchema({
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
}),
);
expect(diff.items).toEqual([]);
});
});
describe('column', () => {
describe('column.add', () => {
it('should find a new column', () => {
const diff = schemaDiff(
newSchema({
tables: [
{
name: 'table1',
columns: [{ name: 'column1' }, { name: 'column2' }],
},
],
}),
newSchema({
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
}),
);
expect(diff.items).toEqual([
{
type: 'column.add',
column: {
tableName: 'table1',
isArray: false,
name: 'column2',
nullable: false,
type: 'character varying',
synchronize: true,
},
reason: 'missing in target',
},
]);
});
});
describe('column.drop', () => {
it('should find an extra column', () => {
const diff = schemaDiff(
newSchema({
tables: [{ name: 'table1', columns: [{ name: 'column1' }] }],
}),
newSchema({
tables: [
{
name: 'table1',
columns: [{ name: 'column1' }, { name: 'column2' }],
},
],
}),
);
expect(diff.items).toEqual([
{
type: 'column.drop',
tableName: 'table1',
columnName: 'column2',
reason: 'missing in source',
},
]);
});
});
describe('nullable', () => {
it('should make a column nullable', () => {
const diff = schemaDiff(
fromColumn({ name: 'column1', nullable: true }),
fromColumn({ name: 'column1', nullable: false }),
);
expect(diff.items).toEqual([
{
type: 'column.alter',
tableName: 'table1',
columnName: 'column1',
changes: {
nullable: true,
},
reason: 'nullable is different (true vs false)',
},
]);
});
it('should make a column non-nullable', () => {
const diff = schemaDiff(
fromColumn({ name: 'column1', nullable: false }),
fromColumn({ name: 'column1', nullable: true }),
);
expect(diff.items).toEqual([
{
type: 'column.alter',
tableName: 'table1',
columnName: 'column1',
changes: {
nullable: false,
},
reason: 'nullable is different (false vs true)',
},
]);
});
});
describe('default', () => {
it('should set a default value to a function', () => {
const diff = schemaDiff(
fromColumn({ name: 'column1', default: 'uuid_generate_v4()' }),
fromColumn({ name: 'column1' }),
);
expect(diff.items).toEqual([
{
type: 'column.alter',
tableName: 'table1',
columnName: 'column1',
changes: {
default: 'uuid_generate_v4()',
},
reason: 'default is different (uuid_generate_v4() vs undefined)',
},
]);
});
it('should ignore explicit casts for strings', () => {
const diff = schemaDiff(
fromColumn({ name: 'column1', type: 'character varying', default: `''` }),
fromColumn({ name: 'column1', type: 'character varying', default: `''::character varying` }),
);
expect(diff.items).toEqual([]);
});
it('should ignore explicit casts for numbers', () => {
const diff = schemaDiff(
fromColumn({ name: 'column1', type: 'bigint', default: `0` }),
fromColumn({ name: 'column1', type: 'bigint', default: `'0'::bigint` }),
);
expect(diff.items).toEqual([]);
});
it('should ignore explicit casts for enums', () => {
const diff = schemaDiff(
fromColumn({ name: 'column1', type: 'enum', enumName: 'enum1', default: `test` }),
fromColumn({ name: 'column1', type: 'enum', enumName: 'enum1', default: `'test'::enum1` }),
);
expect(diff.items).toEqual([]);
});
});
});
describe('constraint', () => {
describe('constraint.add', () => {
it('should detect a new constraint', () => {
const diff = schemaDiff(
fromConstraint({
name: 'PK_test',
type: DatabaseConstraintType.PRIMARY_KEY,
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
}),
fromConstraint(),
);
expect(diff.items).toEqual([
{
type: 'constraint.add',
constraint: {
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_test',
columnNames: ['id'],
tableName: 'table1',
synchronize: true,
},
reason: 'missing in target',
},
]);
});
});
describe('constraint.drop', () => {
it('should detect an extra constraint', () => {
const diff = schemaDiff(
fromConstraint(),
fromConstraint({
name: 'PK_test',
type: DatabaseConstraintType.PRIMARY_KEY,
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
}),
);
expect(diff.items).toEqual([
{
type: 'constraint.drop',
tableName: 'table1',
constraintName: 'PK_test',
reason: 'missing in source',
},
]);
});
});
describe('primary key', () => {
it('should skip identical primary key constraints', () => {
const constraint: DatabaseConstraint = {
type: DatabaseConstraintType.PRIMARY_KEY,
name: 'PK_test',
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
};
const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint }));
expect(diff.items).toEqual([]);
});
});
describe('foreign key', () => {
it('should skip identical foreign key constraints', () => {
const constraint: DatabaseConstraint = {
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_test',
tableName: 'table1',
columnNames: ['parentId'],
referenceTableName: 'table2',
referenceColumnNames: ['id'],
synchronize: true,
};
const diff = schemaDiff(fromConstraint(constraint), fromConstraint(constraint));
expect(diff.items).toEqual([]);
});
it('should drop and recreate when the column changes', () => {
const constraint: DatabaseConstraint = {
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_test',
tableName: 'table1',
columnNames: ['parentId'],
referenceTableName: 'table2',
referenceColumnNames: ['id'],
synchronize: true,
};
const diff = schemaDiff(
fromConstraint(constraint),
fromConstraint({ ...constraint, columnNames: ['parentId2'] }),
);
expect(diff.items).toEqual([
{
constraintName: 'FK_test',
reason: 'columns are different (parentId vs parentId2)',
tableName: 'table1',
type: 'constraint.drop',
},
{
constraint: {
columnNames: ['parentId'],
name: 'FK_test',
referenceColumnNames: ['id'],
referenceTableName: 'table2',
synchronize: true,
tableName: 'table1',
type: 'foreign-key',
},
reason: 'columns are different (parentId vs parentId2)',
type: 'constraint.add',
},
]);
});
it('should drop and recreate when the ON DELETE action changes', () => {
const constraint: DatabaseConstraint = {
type: DatabaseConstraintType.FOREIGN_KEY,
name: 'FK_test',
tableName: 'table1',
columnNames: ['parentId'],
referenceTableName: 'table2',
referenceColumnNames: ['id'],
onDelete: DatabaseActionType.CASCADE,
synchronize: true,
};
const diff = schemaDiff(fromConstraint(constraint), fromConstraint({ ...constraint, onDelete: undefined }));
expect(diff.items).toEqual([
{
constraintName: 'FK_test',
reason: 'ON DELETE action is different (CASCADE vs NO ACTION)',
tableName: 'table1',
type: 'constraint.drop',
},
{
constraint: {
columnNames: ['parentId'],
name: 'FK_test',
referenceColumnNames: ['id'],
referenceTableName: 'table2',
onDelete: DatabaseActionType.CASCADE,
synchronize: true,
tableName: 'table1',
type: 'foreign-key',
},
reason: 'ON DELETE action is different (CASCADE vs NO ACTION)',
type: 'constraint.add',
},
]);
});
});
describe('unique', () => {
it('should skip identical unique constraints', () => {
const constraint: DatabaseConstraint = {
type: DatabaseConstraintType.UNIQUE,
name: 'UQ_test',
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
};
const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint }));
expect(diff.items).toEqual([]);
});
});
describe('check', () => {
it('should skip identical check constraints', () => {
const constraint: DatabaseConstraint = {
type: DatabaseConstraintType.CHECK,
name: 'CHK_test',
tableName: 'table1',
expression: 'column1 > 0',
synchronize: true,
};
const diff = schemaDiff(fromConstraint({ ...constraint }), fromConstraint({ ...constraint }));
expect(diff.items).toEqual([]);
});
});
});
describe('index', () => {
describe('index.create', () => {
it('should detect a new index', () => {
const diff = schemaDiff(
fromIndex({
name: 'IDX_test',
tableName: 'table1',
columnNames: ['id'],
unique: false,
synchronize: true,
}),
fromIndex(),
);
expect(diff.items).toEqual([
{
type: 'index.create',
index: {
name: 'IDX_test',
columnNames: ['id'],
tableName: 'table1',
unique: false,
synchronize: true,
},
reason: 'missing in target',
},
]);
});
});
describe('index.drop', () => {
it('should detect an extra index', () => {
const diff = schemaDiff(
fromIndex(),
fromIndex({
name: 'IDX_test',
unique: true,
tableName: 'table1',
columnNames: ['id'],
synchronize: true,
}),
);
expect(diff.items).toEqual([
{
type: 'index.drop',
indexName: 'IDX_test',
reason: 'missing in source',
},
]);
});
});
it('should recreate the index if unique changes', () => {
const index: DatabaseIndex = {
name: 'IDX_test',
tableName: 'table1',
columnNames: ['id'],
unique: true,
synchronize: true,
};
const diff = schemaDiff(fromIndex(index), fromIndex({ ...index, unique: false }));
expect(diff.items).toEqual([
{
type: 'index.drop',
indexName: 'IDX_test',
reason: 'uniqueness is different (true vs false)',
},
{
type: 'index.create',
index,
reason: 'uniqueness is different (true vs false)',
},
]);
});
});
});

View file

@ -0,0 +1,449 @@
import { getColumnType, schemaDiffToSql } from 'src/sql-tools/schema-diff-to-sql';
import {
DatabaseCheckConstraint,
DatabaseColumn,
DatabaseConstraint,
DatabaseConstraintType,
DatabaseForeignKeyConstraint,
DatabaseIndex,
DatabasePrimaryKeyConstraint,
DatabaseSchema,
DatabaseTable,
DatabaseUniqueConstraint,
SchemaDiff,
SchemaDiffToSqlOptions,
} from 'src/sql-tools/types';
enum Reason {
MissingInSource = 'missing in source',
MissingInTarget = 'missing in target',
}
const setIsEqual = (source: Set<unknown>, target: Set<unknown>) =>
source.size === target.size && [...source].every((x) => target.has(x));
const haveEqualColumns = (sourceColumns?: string[], targetColumns?: string[]) => {
return setIsEqual(new Set(sourceColumns ?? []), new Set(targetColumns ?? []));
};
const isSynchronizeDisabled = (source?: { synchronize?: boolean }, target?: { synchronize?: boolean }) => {
return source?.synchronize === false || target?.synchronize === false;
};
const withTypeCast = (value: string, type: string) => {
if (!value.startsWith(`'`)) {
value = `'${value}'`;
}
return `${value}::${type}`;
};
const isDefaultEqual = (source: DatabaseColumn, target: DatabaseColumn) => {
if (source.default === target.default) {
return true;
}
if (source.default === undefined || target.default === undefined) {
return false;
}
if (
withTypeCast(source.default, getColumnType(source)) === target.default ||
source.default === withTypeCast(target.default, getColumnType(target))
) {
return true;
}
return false;
};
/**
* Compute the difference between two database schemas
*/
export const schemaDiff = (
source: DatabaseSchema,
target: DatabaseSchema,
options: { ignoreExtraTables?: boolean } = {},
) => {
const items = diffTables(source.tables, target.tables, {
ignoreExtraTables: options.ignoreExtraTables ?? true,
});
return {
items,
asSql: (options?: SchemaDiffToSqlOptions) => schemaDiffToSql(items, options),
};
};
export const diffTables = (
sources: DatabaseTable[],
targets: DatabaseTable[],
options: { ignoreExtraTables: boolean },
) => {
const items: SchemaDiff[] = [];
const sourceMap = Object.fromEntries(sources.map((table) => [table.name, table]));
const targetMap = Object.fromEntries(targets.map((table) => [table.name, table]));
const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]);
for (const key of keys) {
if (options.ignoreExtraTables && !sourceMap[key]) {
continue;
}
items.push(...diffTable(sourceMap[key], targetMap[key]));
}
return items;
};
const diffTable = (source?: DatabaseTable, target?: DatabaseTable): SchemaDiff[] => {
if (isSynchronizeDisabled(source, target)) {
return [];
}
if (source && !target) {
return [
{
type: 'table.create',
tableName: source.name,
columns: Object.values(source.columns),
reason: Reason.MissingInTarget,
},
...diffIndexes(source.indexes, []),
// TODO merge constraints into table create record when possible
...diffConstraints(source.constraints, []),
];
}
if (!source && target) {
return [
{
type: 'table.drop',
tableName: target.name,
reason: Reason.MissingInSource,
},
];
}
if (!source || !target) {
return [];
}
return [
...diffColumns(source.columns, target.columns),
...diffConstraints(source.constraints, target.constraints),
...diffIndexes(source.indexes, target.indexes),
];
};
const diffColumns = (sources: DatabaseColumn[], targets: DatabaseColumn[]): SchemaDiff[] => {
const items: SchemaDiff[] = [];
const sourceMap = Object.fromEntries(sources.map((column) => [column.name, column]));
const targetMap = Object.fromEntries(targets.map((column) => [column.name, column]));
const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]);
for (const key of keys) {
items.push(...diffColumn(sourceMap[key], targetMap[key]));
}
return items;
};
const diffColumn = (source?: DatabaseColumn, target?: DatabaseColumn): SchemaDiff[] => {
if (isSynchronizeDisabled(source, target)) {
return [];
}
if (source && !target) {
return [
{
type: 'column.add',
column: source,
reason: Reason.MissingInTarget,
},
];
}
if (!source && target) {
return [
{
type: 'column.drop',
tableName: target.tableName,
columnName: target.name,
reason: Reason.MissingInSource,
},
];
}
if (!source || !target) {
return [];
}
const sourceType = getColumnType(source);
const targetType = getColumnType(target);
const isTypeChanged = sourceType !== targetType;
if (isTypeChanged) {
// TODO: convert between types via UPDATE when possible
return dropAndRecreateColumn(source, target, `column type is different (${sourceType} vs ${targetType})`);
}
const items: SchemaDiff[] = [];
if (source.nullable !== target.nullable) {
items.push({
type: 'column.alter',
tableName: source.tableName,
columnName: source.name,
changes: {
nullable: source.nullable,
},
reason: `nullable is different (${source.nullable} vs ${target.nullable})`,
});
}
if (!isDefaultEqual(source, target)) {
items.push({
type: 'column.alter',
tableName: source.tableName,
columnName: source.name,
changes: {
default: String(source.default),
},
reason: `default is different (${source.default} vs ${target.default})`,
});
}
return items;
};
const diffConstraints = (sources: DatabaseConstraint[], targets: DatabaseConstraint[]): SchemaDiff[] => {
const items: SchemaDiff[] = [];
for (const type of Object.values(DatabaseConstraintType)) {
const sourceMap = Object.fromEntries(sources.filter((item) => item.type === type).map((item) => [item.name, item]));
const targetMap = Object.fromEntries(targets.filter((item) => item.type === type).map((item) => [item.name, item]));
const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]);
for (const key of keys) {
items.push(...diffConstraint(sourceMap[key], targetMap[key]));
}
}
return items;
};
const diffConstraint = <T extends DatabaseConstraint>(source?: T, target?: T): SchemaDiff[] => {
if (isSynchronizeDisabled(source, target)) {
return [];
}
if (source && !target) {
return [
{
type: 'constraint.add',
constraint: source,
reason: Reason.MissingInTarget,
},
];
}
if (!source && target) {
return [
{
type: 'constraint.drop',
tableName: target.tableName,
constraintName: target.name,
reason: Reason.MissingInSource,
},
];
}
if (!source || !target) {
return [];
}
switch (source.type) {
case DatabaseConstraintType.PRIMARY_KEY: {
return diffPrimaryKeyConstraint(source, target as DatabasePrimaryKeyConstraint);
}
case DatabaseConstraintType.FOREIGN_KEY: {
return diffForeignKeyConstraint(source, target as DatabaseForeignKeyConstraint);
}
case DatabaseConstraintType.UNIQUE: {
return diffUniqueConstraint(source, target as DatabaseUniqueConstraint);
}
case DatabaseConstraintType.CHECK: {
return diffCheckConstraint(source, target as DatabaseCheckConstraint);
}
default: {
return dropAndRecreateConstraint(source, target, `Unknown constraint type: ${(source as any).type}`);
}
}
};
const diffPrimaryKeyConstraint = (
source: DatabasePrimaryKeyConstraint,
target: DatabasePrimaryKeyConstraint,
): SchemaDiff[] => {
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
return dropAndRecreateConstraint(
source,
target,
`Primary key columns are different: (${source.columnNames} vs ${target.columnNames})`,
);
}
return [];
};
const diffForeignKeyConstraint = (
source: DatabaseForeignKeyConstraint,
target: DatabaseForeignKeyConstraint,
): SchemaDiff[] => {
let reason = '';
const sourceDeleteAction = source.onDelete ?? 'NO ACTION';
const targetDeleteAction = target.onDelete ?? 'NO ACTION';
const sourceUpdateAction = source.onUpdate ?? 'NO ACTION';
const targetUpdateAction = target.onUpdate ?? 'NO ACTION';
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
reason = `columns are different (${source.columnNames} vs ${target.columnNames})`;
} else if (!haveEqualColumns(source.referenceColumnNames, target.referenceColumnNames)) {
reason = `reference columns are different (${source.referenceColumnNames} vs ${target.referenceColumnNames})`;
} else if (source.referenceTableName !== target.referenceTableName) {
reason = `reference table is different (${source.referenceTableName} vs ${target.referenceTableName})`;
} else if (sourceDeleteAction !== targetDeleteAction) {
reason = `ON DELETE action is different (${sourceDeleteAction} vs ${targetDeleteAction})`;
} else if (sourceUpdateAction !== targetUpdateAction) {
reason = `ON UPDATE action is different (${sourceUpdateAction} vs ${targetUpdateAction})`;
}
if (reason) {
return dropAndRecreateConstraint(source, target, reason);
}
return [];
};
const diffUniqueConstraint = (source: DatabaseUniqueConstraint, target: DatabaseUniqueConstraint): SchemaDiff[] => {
let reason = '';
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
reason = `columns are different (${source.columnNames} vs ${target.columnNames})`;
}
if (reason) {
return dropAndRecreateConstraint(source, target, reason);
}
return [];
};
const diffCheckConstraint = (source: DatabaseCheckConstraint, target: DatabaseCheckConstraint): SchemaDiff[] => {
if (source.expression !== target.expression) {
// comparing expressions is hard because postgres reconstructs it with different formatting
// for now if the constraint exists with the same name, we will just skip it
}
return [];
};
const diffIndexes = (sources: DatabaseIndex[], targets: DatabaseIndex[]) => {
const items: SchemaDiff[] = [];
const sourceMap = Object.fromEntries(sources.map((index) => [index.name, index]));
const targetMap = Object.fromEntries(targets.map((index) => [index.name, index]));
const keys = new Set([...Object.keys(sourceMap), ...Object.keys(targetMap)]);
for (const key of keys) {
items.push(...diffIndex(sourceMap[key], targetMap[key]));
}
return items;
};
const diffIndex = (source?: DatabaseIndex, target?: DatabaseIndex): SchemaDiff[] => {
if (isSynchronizeDisabled(source, target)) {
return [];
}
if (source && !target) {
return [{ type: 'index.create', index: source, reason: Reason.MissingInTarget }];
}
if (!source && target) {
return [
{
type: 'index.drop',
indexName: target.name,
reason: Reason.MissingInSource,
},
];
}
if (!target || !source) {
return [];
}
const sourceUsing = source.using ?? 'btree';
const targetUsing = target.using ?? 'btree';
let reason = '';
if (!haveEqualColumns(source.columnNames, target.columnNames)) {
reason = `columns are different (${source.columnNames} vs ${target.columnNames})`;
} else if (source.unique !== target.unique) {
reason = `uniqueness is different (${source.unique} vs ${target.unique})`;
} else if (sourceUsing !== targetUsing) {
reason = `using method is different (${source.using} vs ${target.using})`;
} else if (source.where !== target.where) {
reason = `where clause is different (${source.where} vs ${target.where})`;
} else if (source.expression !== target.expression) {
reason = `expression is different (${source.expression} vs ${target.expression})`;
}
if (reason) {
return dropAndRecreateIndex(source, target, reason);
}
return [];
};
const dropAndRecreateColumn = (source: DatabaseColumn, target: DatabaseColumn, reason: string): SchemaDiff[] => {
return [
{
type: 'column.drop',
tableName: target.tableName,
columnName: target.name,
reason,
},
{ type: 'column.add', column: source, reason },
];
};
const dropAndRecreateConstraint = (
source: DatabaseConstraint,
target: DatabaseConstraint,
reason: string,
): SchemaDiff[] => {
return [
{
type: 'constraint.drop',
tableName: target.tableName,
constraintName: target.name,
reason,
},
{ type: 'constraint.add', constraint: source, reason },
];
};
const dropAndRecreateIndex = (source: DatabaseIndex, target: DatabaseIndex, reason: string): SchemaDiff[] => {
return [
{ type: 'index.drop', indexName: target.name, reason },
{ type: 'index.create', index: source, reason },
];
};

View file

@ -0,0 +1,394 @@
import { Kysely, sql } from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { Sql } from 'postgres';
import {
DatabaseActionType,
DatabaseClient,
DatabaseColumn,
DatabaseColumnType,
DatabaseConstraintType,
DatabaseSchema,
DatabaseTable,
LoadSchemaOptions,
PostgresDB,
} from 'src/sql-tools/types';
/**
* Load the database schema from the database
*/
export const schemaFromDatabase = async (postgres: Sql, options: LoadSchemaOptions = {}): Promise<DatabaseSchema> => {
const db = createDatabaseClient(postgres);
const warnings: string[] = [];
const warn = (message: string) => {
warnings.push(message);
};
const schemaName = options.schemaName || 'public';
const tablesMap: Record<string, DatabaseTable> = {};
const [tables, columns, indexes, constraints, enums] = await Promise.all([
getTables(db, schemaName),
getTableColumns(db, schemaName),
getTableIndexes(db, schemaName),
getTableConstraints(db, schemaName),
getUserDefinedEnums(db, schemaName),
]);
const enumMap = Object.fromEntries(enums.map((e) => [e.name, e.values]));
// add tables
for (const table of tables) {
const tableName = table.table_name;
if (tablesMap[tableName]) {
continue;
}
tablesMap[table.table_name] = {
name: table.table_name,
columns: [],
indexes: [],
constraints: [],
synchronize: true,
};
}
// add columns to tables
for (const column of columns) {
const table = tablesMap[column.table_name];
if (!table) {
continue;
}
const columnName = column.column_name;
const item: DatabaseColumn = {
type: column.data_type as DatabaseColumnType,
name: columnName,
tableName: column.table_name,
nullable: column.is_nullable === 'YES',
isArray: column.array_type !== null,
numericPrecision: column.numeric_precision ?? undefined,
numericScale: column.numeric_scale ?? undefined,
default: column.column_default ?? undefined,
synchronize: true,
};
const columnLabel = `${table.name}.${columnName}`;
switch (column.data_type) {
// array types
case 'ARRAY': {
if (!column.array_type) {
warn(`Unable to find type for ${columnLabel} (ARRAY)`);
continue;
}
item.type = column.array_type as DatabaseColumnType;
break;
}
// enum types
case 'USER-DEFINED': {
if (!enumMap[column.udt_name]) {
warn(`Unable to find type for ${columnLabel} (ENUM)`);
continue;
}
item.type = 'enum';
item.enumName = column.udt_name;
item.enumValues = enumMap[column.udt_name];
break;
}
}
table.columns.push(item);
}
// add table indexes
for (const index of indexes) {
const table = tablesMap[index.table_name];
if (!table) {
continue;
}
const indexName = index.index_name;
table.indexes.push({
name: indexName,
tableName: index.table_name,
columnNames: index.column_names ?? undefined,
expression: index.expression ?? undefined,
using: index.using,
where: index.where ?? undefined,
unique: index.unique,
synchronize: true,
});
}
// add table constraints
for (const constraint of constraints) {
const table = tablesMap[constraint.table_name];
if (!table) {
continue;
}
const constraintName = constraint.constraint_name;
switch (constraint.constraint_type) {
// primary key constraint
case 'p': {
if (!constraint.column_names) {
warn(`Skipping CONSTRAINT "${constraintName}", no columns found`);
continue;
}
table.constraints.push({
type: DatabaseConstraintType.PRIMARY_KEY,
name: constraintName,
tableName: constraint.table_name,
columnNames: constraint.column_names,
synchronize: true,
});
break;
}
// foreign key constraint
case 'f': {
if (!constraint.column_names || !constraint.reference_table_name || !constraint.reference_column_names) {
warn(
`Skipping CONSTRAINT "${constraintName}", missing either columns, referenced table, or referenced columns,`,
);
continue;
}
table.constraints.push({
type: DatabaseConstraintType.FOREIGN_KEY,
name: constraintName,
tableName: constraint.table_name,
columnNames: constraint.column_names,
referenceTableName: constraint.reference_table_name,
referenceColumnNames: constraint.reference_column_names,
onUpdate: asDatabaseAction(constraint.update_action),
onDelete: asDatabaseAction(constraint.delete_action),
synchronize: true,
});
break;
}
// unique constraint
case 'u': {
table.constraints.push({
type: DatabaseConstraintType.UNIQUE,
name: constraintName,
tableName: constraint.table_name,
columnNames: constraint.column_names as string[],
synchronize: true,
});
break;
}
// check constraint
case 'c': {
table.constraints.push({
type: DatabaseConstraintType.CHECK,
name: constraint.constraint_name,
tableName: constraint.table_name,
expression: constraint.expression.replace('CHECK ', ''),
synchronize: true,
});
break;
}
}
}
await db.destroy();
return {
name: schemaName,
tables: Object.values(tablesMap),
warnings,
};
};
const createDatabaseClient = (postgres: Sql): DatabaseClient =>
new Kysely<PostgresDB>({ dialect: new PostgresJSDialect({ postgres }) });
const asDatabaseAction = (action: string) => {
switch (action) {
case 'a': {
return DatabaseActionType.NO_ACTION;
}
case 'c': {
return DatabaseActionType.CASCADE;
}
case 'r': {
return DatabaseActionType.RESTRICT;
}
case 'n': {
return DatabaseActionType.SET_NULL;
}
case 'd': {
return DatabaseActionType.SET_DEFAULT;
}
default: {
return DatabaseActionType.NO_ACTION;
}
}
};
const getTables = (db: DatabaseClient, schemaName: string) => {
return db
.selectFrom('information_schema.tables')
.where('table_schema', '=', schemaName)
.where('table_type', '=', sql.lit('BASE TABLE'))
.selectAll()
.execute();
};
const getUserDefinedEnums = async (db: DatabaseClient, schemaName: string) => {
const items = await db
.selectFrom('pg_type')
.innerJoin('pg_namespace', (join) =>
join.onRef('pg_namespace.oid', '=', 'pg_type.typnamespace').on('pg_namespace.nspname', '=', schemaName),
)
.where('typtype', '=', sql.lit('e'))
.select((eb) => [
'pg_type.typname as name',
jsonArrayFrom(
eb.selectFrom('pg_enum as e').select(['e.enumlabel as value']).whereRef('e.enumtypid', '=', 'pg_type.oid'),
).as('values'),
])
.execute();
return items.map((item) => ({
name: item.name,
values: item.values.map(({ value }) => value),
}));
};
const getTableColumns = (db: DatabaseClient, schemaName: string) => {
return db
.selectFrom('information_schema.columns as c')
.leftJoin('information_schema.element_types as o', (join) =>
join
.onRef('c.table_catalog', '=', 'o.object_catalog')
.onRef('c.table_schema', '=', 'o.object_schema')
.onRef('c.table_name', '=', 'o.object_name')
.on('o.object_type', '=', sql.lit('TABLE'))
.onRef('c.dtd_identifier', '=', 'o.collection_type_identifier'),
)
.leftJoin('pg_type as t', (join) =>
join.onRef('t.typname', '=', 'c.udt_name').on('c.data_type', '=', sql.lit('USER-DEFINED')),
)
.leftJoin('pg_enum as e', (join) => join.onRef('e.enumtypid', '=', 't.oid'))
.select([
'c.table_name',
'c.column_name',
// is ARRAY, USER-DEFINED, or data type
'c.data_type',
'c.column_default',
'c.is_nullable',
// number types
'c.numeric_precision',
'c.numeric_scale',
// date types
'c.datetime_precision',
// user defined type
'c.udt_catalog',
'c.udt_schema',
'c.udt_name',
// data type for ARRAYs
'o.data_type as array_type',
])
.where('table_schema', '=', schemaName)
.execute();
};
const getTableIndexes = (db: DatabaseClient, schemaName: string) => {
return (
db
.selectFrom('pg_index as ix')
// matching index, which has column information
.innerJoin('pg_class as i', 'ix.indexrelid', 'i.oid')
.innerJoin('pg_am as a', 'i.relam', 'a.oid')
// matching table
.innerJoin('pg_class as t', 'ix.indrelid', 't.oid')
// namespace
.innerJoin('pg_namespace', 'pg_namespace.oid', 'i.relnamespace')
// PK and UQ constraints automatically have indexes, so we can ignore those
.leftJoin('pg_constraint', (join) =>
join
.onRef('pg_constraint.conindid', '=', 'i.oid')
.on('pg_constraint.contype', 'in', [sql.lit('p'), sql.lit('u')]),
)
.where('pg_constraint.oid', 'is', null)
.select((eb) => [
'i.relname as index_name',
't.relname as table_name',
'ix.indisunique as unique',
'a.amname as using',
eb.fn<string>('pg_get_expr', ['ix.indexprs', 'ix.indrelid']).as('expression'),
eb.fn<string>('pg_get_expr', ['ix.indpred', 'ix.indrelid']).as('where'),
eb
.selectFrom('pg_attribute as a')
.where('t.relkind', '=', sql.lit('r'))
.whereRef('a.attrelid', '=', 't.oid')
// list of columns numbers in the index
.whereRef('a.attnum', '=', sql`any("ix"."indkey")`)
.select((eb) => eb.fn<string[]>('json_agg', ['a.attname']).as('column_name'))
.as('column_names'),
])
.where('pg_namespace.nspname', '=', schemaName)
.where('ix.indisprimary', '=', sql.lit(false))
.execute()
);
};
const getTableConstraints = (db: DatabaseClient, schemaName: string) => {
return db
.selectFrom('pg_constraint')
.innerJoin('pg_namespace', 'pg_namespace.oid', 'pg_constraint.connamespace') // namespace
.innerJoin('pg_class as source_table', (join) =>
join.onRef('source_table.oid', '=', 'pg_constraint.conrelid').on('source_table.relkind', 'in', [
// ordinary table
sql.lit('r'),
// partitioned table
sql.lit('p'),
// foreign table
sql.lit('f'),
]),
) // table
.leftJoin('pg_class as reference_table', 'reference_table.oid', 'pg_constraint.confrelid') // reference table
.select((eb) => [
'pg_constraint.contype as constraint_type',
'pg_constraint.conname as constraint_name',
'source_table.relname as table_name',
'reference_table.relname as reference_table_name',
'pg_constraint.confupdtype as update_action',
'pg_constraint.confdeltype as delete_action',
// 'pg_constraint.oid as constraint_id',
eb
.selectFrom('pg_attribute')
// matching table for PK, FK, and UQ
.whereRef('pg_attribute.attrelid', '=', 'pg_constraint.conrelid')
.whereRef('pg_attribute.attnum', '=', sql`any("pg_constraint"."conkey")`)
.select((eb) => eb.fn<string[]>('json_agg', ['pg_attribute.attname']).as('column_name'))
.as('column_names'),
eb
.selectFrom('pg_attribute')
// matching foreign table for FK
.whereRef('pg_attribute.attrelid', '=', 'pg_constraint.confrelid')
.whereRef('pg_attribute.attnum', '=', sql`any("pg_constraint"."confkey")`)
.select((eb) => eb.fn<string[]>('json_agg', ['pg_attribute.attname']).as('column_name'))
.as('reference_column_names'),
eb.fn<string>('pg_get_constraintdef', ['pg_constraint.oid']).as('expression'),
])
.where('pg_namespace.nspname', '=', schemaName)
.execute();
};

View file

@ -0,0 +1,31 @@
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { reset, schemaFromDecorators } from 'src/sql-tools/schema-from-decorators';
import { describe, expect, it } from 'vitest';
describe('schemaDiff', () => {
beforeEach(() => {
reset();
});
it('should work', () => {
expect(schemaFromDecorators()).toEqual({
name: 'public',
tables: [],
warnings: [],
});
});
describe('test files', () => {
const files = readdirSync('test/sql-tools', { withFileTypes: true });
for (const file of files) {
const filePath = join(file.parentPath, file.name);
it(filePath, async () => {
const module = await import(filePath);
expect(module.description).toBeDefined();
expect(module.schema).toBeDefined();
expect(schemaFromDecorators(), module.description).toEqual(module.schema);
});
}
});
});

View file

@ -0,0 +1,443 @@
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { createHash } from 'node:crypto';
import 'reflect-metadata';
import {
CheckOptions,
ColumnDefaultValue,
ColumnIndexOptions,
ColumnOptions,
DatabaseActionType,
DatabaseColumn,
DatabaseConstraintType,
DatabaseSchema,
DatabaseTable,
ForeignKeyColumnOptions,
IndexOptions,
TableOptions,
UniqueOptions,
} from 'src/sql-tools/types';
enum SchemaKey {
TableName = 'immich-schema:table-name',
ColumnName = 'immich-schema:column-name',
IndexName = 'immich-schema:index-name',
}
type SchemaTable = DatabaseTable & { options: TableOptions };
type SchemaTables = SchemaTable[];
type ClassBased<T> = { object: Function } & T;
type PropertyBased<T> = { object: object; propertyName: string | symbol } & T;
type RegisterItem =
| { type: 'table'; item: ClassBased<{ options: TableOptions }> }
| { type: 'index'; item: ClassBased<{ options: IndexOptions }> }
| { type: 'uniqueConstraint'; item: ClassBased<{ options: UniqueOptions }> }
| { type: 'checkConstraint'; item: ClassBased<{ options: CheckOptions }> }
| { type: 'column'; item: PropertyBased<{ options: ColumnOptions }> }
| { type: 'columnIndex'; item: PropertyBased<{ options: ColumnIndexOptions }> }
| { type: 'foreignKeyColumn'; item: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }> };
const items: RegisterItem[] = [];
export const register = (item: RegisterItem) => void items.push(item);
const asSnakeCase = (name: string): string => name.replaceAll(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
const asKey = (prefix: string, tableName: string, values: string[]) =>
(prefix + sha1(`${tableName}_${values.toSorted().join('_')}`)).slice(0, 30);
const asPrimaryKeyConstraintName = (table: string, columns: string[]) => asKey('PK_', table, columns);
const asForeignKeyConstraintName = (table: string, columns: string[]) => asKey('FK_', table, columns);
const asRelationKeyConstraintName = (table: string, columns: string[]) => asKey('REL_', table, columns);
const asUniqueConstraintName = (table: string, columns: string[]) => asKey('UQ_', table, columns);
const asCheckConstraintName = (table: string, expression: string) => asKey('CHK_', table, [expression]);
const asIndexName = (table: string, columns: string[] | undefined, where: string | undefined) => {
const items: string[] = [];
for (const columnName of columns ?? []) {
items.push(columnName);
}
if (where) {
items.push(where);
}
return asKey('IDX_', table, items);
};
const makeColumn = ({
name,
tableName,
options,
}: {
name: string;
tableName: string;
options: ColumnOptions;
}): DatabaseColumn => {
const columnName = options.name ?? name;
const enumName = options.enumName ?? `${tableName}_${columnName}_enum`.toLowerCase();
let defaultValue = asDefaultValue(options);
let nullable = options.nullable ?? false;
if (defaultValue === null) {
nullable = true;
defaultValue = undefined;
}
const isEnum = !!options.enum;
return {
name: columnName,
tableName,
primary: options.primary ?? false,
default: defaultValue,
nullable,
enumName: isEnum ? enumName : undefined,
enumValues: isEnum ? Object.values(options.enum as object) : undefined,
isArray: options.array ?? false,
type: isEnum ? 'enum' : options.type || 'character varying',
synchronize: options.synchronize ?? true,
};
};
const asDefaultValue = (options: { nullable?: boolean; default?: ColumnDefaultValue }) => {
if (typeof options.default === 'function') {
return options.default() as string;
}
if (options.default === undefined) {
return;
}
const value = options.default;
if (value === null) {
return value;
}
if (typeof value === 'number') {
return String(value);
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
if (value instanceof Date) {
return `'${value.toISOString()}'`;
}
return `'${String(value)}'`;
};
const missingTableError = (context: string, object: object, propertyName?: string | symbol) => {
const label = object.constructor.name + (propertyName ? '.' + String(propertyName) : '');
return `[${context}] Unable to find table (${label})`;
};
// match TypeORM
const sha1 = (value: string) => createHash('sha1').update(value).digest('hex');
const findByName = <T extends { name: string }>(items: T[], name?: string) =>
name ? items.find((item) => item.name === name) : undefined;
const resolveTable = (tables: SchemaTables, object: object) =>
findByName(tables, Reflect.getMetadata(SchemaKey.TableName, object));
let initialized = false;
let schema: DatabaseSchema;
export const reset = () => {
initialized = false;
items.length = 0;
};
export const schemaFromDecorators = () => {
if (!initialized) {
const schemaTables: SchemaTables = [];
const warnings: string[] = [];
const warn = (message: string) => void warnings.push(message);
for (const { item } of items.filter((item) => item.type === 'table')) {
processTable(schemaTables, item);
}
for (const { item } of items.filter((item) => item.type === 'column')) {
processColumn(schemaTables, item, { warn });
}
for (const { item } of items.filter((item) => item.type === 'foreignKeyColumn')) {
processForeignKeyColumn(schemaTables, item, { warn });
}
for (const { item } of items.filter((item) => item.type === 'uniqueConstraint')) {
processUniqueConstraint(schemaTables, item, { warn });
}
for (const { item } of items.filter((item) => item.type === 'checkConstraint')) {
processCheckConstraint(schemaTables, item, { warn });
}
for (const table of schemaTables) {
processPrimaryKeyConstraint(table);
}
for (const { item } of items.filter((item) => item.type === 'index')) {
processIndex(schemaTables, item, { warn });
}
for (const { item } of items.filter((item) => item.type === 'columnIndex')) {
processColumnIndex(schemaTables, item, { warn });
}
for (const { item } of items.filter((item) => item.type === 'foreignKeyColumn')) {
processForeignKeyConstraint(schemaTables, item, { warn });
}
schema = {
name: 'public',
tables: schemaTables.map(({ options: _, ...table }) => table),
warnings,
};
initialized = true;
}
return schema;
};
const processTable = (tables: SchemaTables, { object, options }: ClassBased<{ options: TableOptions }>) => {
const tableName = options.name || asSnakeCase(object.name);
Reflect.defineMetadata(SchemaKey.TableName, tableName, object);
tables.push({
name: tableName,
columns: [],
constraints: [],
indexes: [],
options,
synchronize: options.synchronize ?? true,
});
};
type OnWarn = (message: string) => void;
const processColumn = (
tables: SchemaTables,
{ object, propertyName, options }: PropertyBased<{ options: ColumnOptions }>,
{ warn }: { warn: OnWarn },
) => {
const table = resolveTable(tables, object.constructor);
if (!table) {
warn(missingTableError('@Column', object, propertyName));
return;
}
// TODO make sure column name is unique
const column = makeColumn({ name: String(propertyName), tableName: table.name, options });
Reflect.defineMetadata(SchemaKey.ColumnName, column.name, object, propertyName);
table.columns.push(column);
if (!options.primary && options.unique) {
table.constraints.push({
type: DatabaseConstraintType.UNIQUE,
name: options.uniqueConstraintName || asUniqueConstraintName(table.name, [column.name]),
tableName: table.name,
columnNames: [column.name],
synchronize: options.synchronize ?? true,
});
}
};
const processUniqueConstraint = (
tables: SchemaTables,
{ object, options }: ClassBased<{ options: UniqueOptions }>,
{ warn }: { warn: OnWarn },
) => {
const table = resolveTable(tables, object);
if (!table) {
warn(missingTableError('@Unique', object));
return;
}
const tableName = table.name;
const columnNames = options.columns;
table.constraints.push({
type: DatabaseConstraintType.UNIQUE,
name: options.name || asUniqueConstraintName(tableName, columnNames),
tableName,
columnNames,
synchronize: options.synchronize ?? true,
});
};
const processCheckConstraint = (
tables: SchemaTables,
{ object, options }: ClassBased<{ options: CheckOptions }>,
{ warn }: { warn: OnWarn },
) => {
const table = resolveTable(tables, object);
if (!table) {
warn(missingTableError('@Check', object));
return;
}
const tableName = table.name;
table.constraints.push({
type: DatabaseConstraintType.CHECK,
name: options.name || asCheckConstraintName(tableName, options.expression),
tableName,
expression: options.expression,
synchronize: options.synchronize ?? true,
});
};
const processPrimaryKeyConstraint = (table: SchemaTable) => {
const columnNames: string[] = [];
for (const column of table.columns) {
if (column.primary) {
columnNames.push(column.name);
}
}
if (columnNames.length > 0) {
table.constraints.push({
type: DatabaseConstraintType.PRIMARY_KEY,
name: table.options.primaryConstraintName || asPrimaryKeyConstraintName(table.name, columnNames),
tableName: table.name,
columnNames,
synchronize: table.options.synchronize ?? true,
});
}
};
const processIndex = (
tables: SchemaTables,
{ object, options }: ClassBased<{ options: IndexOptions }>,
{ warn }: { warn: OnWarn },
) => {
const table = resolveTable(tables, object);
if (!table) {
warn(missingTableError('@Index', object));
return;
}
table.indexes.push({
name: options.name || asIndexName(table.name, options.columns, options.where),
tableName: table.name,
unique: options.unique ?? false,
expression: options.expression,
using: options.using,
where: options.where,
columnNames: options.columns,
synchronize: options.synchronize ?? true,
});
};
const processColumnIndex = (
tables: SchemaTables,
{ object, propertyName, options }: PropertyBased<{ options: ColumnIndexOptions }>,
{ warn }: { warn: OnWarn },
) => {
const table = resolveTable(tables, object.constructor);
if (!table) {
warn(missingTableError('@ColumnIndex', object, propertyName));
return;
}
const column = findByName(table.columns, Reflect.getMetadata(SchemaKey.ColumnName, object, propertyName));
if (!column) {
return;
}
table.indexes.push({
name: options.name || asIndexName(table.name, [column.name], options.where),
tableName: table.name,
unique: options.unique ?? false,
expression: options.expression,
using: options.using,
where: options.where,
columnNames: [column.name],
synchronize: options.synchronize ?? true,
});
};
const processForeignKeyColumn = (
tables: SchemaTables,
{ object, propertyName, options }: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }>,
{ warn }: { warn: OnWarn },
) => {
const table = resolveTable(tables, object.constructor);
if (!table) {
warn(missingTableError('@ForeignKeyColumn', object));
return;
}
const columnName = String(propertyName);
const existingColumn = table.columns.find((column) => column.name === columnName);
if (existingColumn) {
// TODO log warnings if column options and `@Column` is also used
return;
}
const column = makeColumn({ name: columnName, tableName: table.name, options });
Reflect.defineMetadata(SchemaKey.ColumnName, columnName, object, propertyName);
table.columns.push(column);
};
const processForeignKeyConstraint = (
tables: SchemaTables,
{ object, propertyName, options, target }: PropertyBased<{ options: ForeignKeyColumnOptions; target: () => object }>,
{ warn }: { warn: OnWarn },
) => {
const childTable = resolveTable(tables, object.constructor);
if (!childTable) {
warn(missingTableError('@ForeignKeyColumn', object));
return;
}
const parentTable = resolveTable(tables, target());
if (!parentTable) {
warn(missingTableError('@ForeignKeyColumn', object, propertyName));
return;
}
const columnName = String(propertyName);
const column = childTable.columns.find((column) => column.name === columnName);
if (!column) {
warn('@ForeignKeyColumn: Column not found, creating a new one');
return;
}
const columnNames = [column.name];
const referenceColumns = parentTable.columns.filter((column) => column.primary);
// infer FK column type from reference table
if (referenceColumns.length === 1) {
column.type = referenceColumns[0].type;
}
childTable.constraints.push({
name: options.constraintName || asForeignKeyConstraintName(childTable.name, columnNames),
tableName: childTable.name,
columnNames,
type: DatabaseConstraintType.FOREIGN_KEY,
referenceTableName: parentTable.name,
referenceColumnNames: referenceColumns.map((column) => column.name),
onUpdate: options.onUpdate as DatabaseActionType,
onDelete: options.onDelete as DatabaseActionType,
synchronize: options.synchronize ?? true,
});
if (options.unique) {
childTable.constraints.push({
name: options.uniqueConstraintName || asRelationKeyConstraintName(childTable.name, columnNames),
tableName: childTable.name,
columnNames,
type: DatabaseConstraintType.UNIQUE,
synchronize: options.synchronize ?? true,
});
}
};

View file

@ -0,0 +1,363 @@
import { Kysely } from 'kysely';
export type PostgresDB = {
pg_am: {
oid: number;
amname: string;
amhandler: string;
amtype: string;
};
pg_attribute: {
attrelid: number;
attname: string;
attnum: number;
atttypeid: number;
attstattarget: number;
attstatarget: number;
aanum: number;
};
pg_class: {
oid: number;
relname: string;
relkind: string;
relnamespace: string;
reltype: string;
relowner: string;
relam: string;
relfilenode: string;
reltablespace: string;
relpages: number;
reltuples: number;
relallvisible: number;
reltoastrelid: string;
relhasindex: PostgresYesOrNo;
relisshared: PostgresYesOrNo;
relpersistence: string;
};
pg_constraint: {
oid: number;
conname: string;
conrelid: string;
contype: string;
connamespace: string;
conkey: number[];
confkey: number[];
confrelid: string;
confupdtype: string;
confdeltype: string;
confmatchtype: number;
condeferrable: PostgresYesOrNo;
condeferred: PostgresYesOrNo;
convalidated: PostgresYesOrNo;
conindid: number;
};
pg_enum: {
oid: string;
enumtypid: string;
enumsortorder: number;
enumlabel: string;
};
pg_index: {
indexrelid: string;
indrelid: string;
indisready: boolean;
indexprs: string | null;
indpred: string | null;
indkey: number[];
indisprimary: boolean;
indisunique: boolean;
};
pg_indexes: {
schemaname: string;
tablename: string;
indexname: string;
tablespace: string | null;
indexrelid: string;
indexdef: string;
};
pg_namespace: {
oid: number;
nspname: string;
nspowner: number;
nspacl: string[];
};
pg_type: {
oid: string;
typname: string;
typnamespace: string;
typowner: string;
typtype: string;
typcategory: string;
typarray: string;
};
'information_schema.tables': {
table_catalog: string;
table_schema: string;
table_name: string;
table_type: 'VIEW' | 'BASE TABLE' | string;
is_insertable_info: PostgresYesOrNo;
is_typed: PostgresYesOrNo;
commit_action: string | null;
};
'information_schema.columns': {
table_catalog: string;
table_schema: string;
table_name: string;
column_name: string;
ordinal_position: number;
column_default: string | null;
is_nullable: PostgresYesOrNo;
data_type: string;
dtd_identifier: string;
character_maximum_length: number | null;
character_octet_length: number | null;
numeric_precision: number | null;
numeric_precision_radix: number | null;
numeric_scale: number | null;
datetime_precision: number | null;
interval_type: string | null;
interval_precision: number | null;
udt_catalog: string;
udt_schema: string;
udt_name: string;
maximum_cardinality: number | null;
is_updatable: PostgresYesOrNo;
};
'information_schema.element_types': {
object_catalog: string;
object_schema: string;
object_name: string;
object_type: string;
collection_type_identifier: string;
data_type: string;
};
};
type PostgresYesOrNo = 'YES' | 'NO';
export type ColumnDefaultValue = null | boolean | string | number | object | Date | (() => string);
export type DatabaseClient = Kysely<PostgresDB>;
export enum DatabaseConstraintType {
PRIMARY_KEY = 'primary-key',
FOREIGN_KEY = 'foreign-key',
UNIQUE = 'unique',
CHECK = 'check',
}
export enum DatabaseActionType {
NO_ACTION = 'NO ACTION',
RESTRICT = 'RESTRICT',
CASCADE = 'CASCADE',
SET_NULL = 'SET NULL',
SET_DEFAULT = 'SET DEFAULT',
}
export type DatabaseColumnType =
| 'bigint'
| 'boolean'
| 'bytea'
| 'character'
| 'character varying'
| 'date'
| 'double precision'
| 'integer'
| 'jsonb'
| 'polygon'
| 'text'
| 'time'
| 'time with time zone'
| 'time without time zone'
| 'timestamp'
| 'timestamp with time zone'
| 'timestamp without time zone'
| 'uuid'
| 'vector'
| 'enum'
| 'serial';
export type TableOptions = {
name?: string;
primaryConstraintName?: string;
synchronize?: boolean;
};
type ColumnBaseOptions = {
name?: string;
primary?: boolean;
type?: DatabaseColumnType;
nullable?: boolean;
length?: number;
default?: ColumnDefaultValue;
synchronize?: boolean;
};
export type ColumnOptions = ColumnBaseOptions & {
enum?: object;
enumName?: string;
array?: boolean;
unique?: boolean;
uniqueConstraintName?: string;
};
export type GenerateColumnOptions = Omit<ColumnOptions, 'type'> & {
type?: 'v4' | 'v7';
};
export type ColumnIndexOptions = {
name?: string;
unique?: boolean;
expression?: string;
using?: string;
where?: string;
synchronize?: boolean;
};
export type IndexOptions = ColumnIndexOptions & {
columns?: string[];
synchronize?: boolean;
};
export type UniqueOptions = {
name?: string;
columns: string[];
synchronize?: boolean;
};
export type CheckOptions = {
name?: string;
expression: string;
synchronize?: boolean;
};
export type DatabaseSchema = {
name: string;
tables: DatabaseTable[];
warnings: string[];
};
export type DatabaseTable = {
name: string;
columns: DatabaseColumn[];
indexes: DatabaseIndex[];
constraints: DatabaseConstraint[];
synchronize: boolean;
};
export type DatabaseConstraint =
| DatabasePrimaryKeyConstraint
| DatabaseForeignKeyConstraint
| DatabaseUniqueConstraint
| DatabaseCheckConstraint;
export type DatabaseColumn = {
primary?: boolean;
name: string;
tableName: string;
type: DatabaseColumnType;
nullable: boolean;
isArray: boolean;
synchronize: boolean;
default?: string;
length?: number;
// enum values
enumValues?: string[];
enumName?: string;
// numeric types
numericPrecision?: number;
numericScale?: number;
};
export type DatabaseColumnChanges = {
nullable?: boolean;
default?: string;
};
type ColumBasedConstraint = {
name: string;
tableName: string;
columnNames: string[];
};
export type DatabasePrimaryKeyConstraint = ColumBasedConstraint & {
type: DatabaseConstraintType.PRIMARY_KEY;
synchronize: boolean;
};
export type DatabaseUniqueConstraint = ColumBasedConstraint & {
type: DatabaseConstraintType.UNIQUE;
synchronize: boolean;
};
export type DatabaseForeignKeyConstraint = ColumBasedConstraint & {
type: DatabaseConstraintType.FOREIGN_KEY;
referenceTableName: string;
referenceColumnNames: string[];
onUpdate?: DatabaseActionType;
onDelete?: DatabaseActionType;
synchronize: boolean;
};
export type DatabaseCheckConstraint = {
type: DatabaseConstraintType.CHECK;
name: string;
tableName: string;
expression: string;
synchronize: boolean;
};
export type DatabaseIndex = {
name: string;
tableName: string;
columnNames?: string[];
expression?: string;
unique: boolean;
using?: string;
where?: string;
synchronize: boolean;
};
export type LoadSchemaOptions = {
schemaName?: string;
};
export type SchemaDiffToSqlOptions = {
comments?: boolean;
};
export type SchemaDiff = { reason: string } & (
| { type: 'table.create'; tableName: string; columns: DatabaseColumn[] }
| { type: 'table.drop'; tableName: string }
| { type: 'column.add'; column: DatabaseColumn }
| { type: 'column.alter'; tableName: string; columnName: string; changes: DatabaseColumnChanges }
| { type: 'column.drop'; tableName: string; columnName: string }
| { type: 'constraint.add'; constraint: DatabaseConstraint }
| { type: 'constraint.drop'; tableName: string; constraintName: string }
| { type: 'index.create'; index: DatabaseIndex }
| { type: 'index.drop'; indexName: string }
);
type Action = 'CASCADE' | 'SET NULL' | 'SET DEFAULT' | 'RESTRICT' | 'NO ACTION';
export type ForeignKeyColumnOptions = ColumnBaseOptions & {
onUpdate?: Action;
onDelete?: Action;
constraintName?: string;
unique?: boolean;
uniqueConstraintName?: string;
};

View file

@ -1,43 +0,0 @@
import { AlbumEntity } from 'src/entities/album.entity';
import { AssetEntity } from 'src/entities/asset.entity';
import { AuditEntity } from 'src/entities/audit.entity';
import { DatabaseAction, EntityType } from 'src/enum';
import { EntitySubscriberInterface, EventSubscriber, RemoveEvent } from 'typeorm';
@EventSubscriber()
export class AuditSubscriber implements EntitySubscriberInterface<AssetEntity | AlbumEntity> {
async afterRemove(event: RemoveEvent<AssetEntity>): Promise<void> {
await this.onEvent(DatabaseAction.DELETE, event);
}
private async onEvent<T>(action: DatabaseAction, event: RemoveEvent<T>): Promise<any> {
const audit = this.getAudit(event.metadata.name, { ...event.entity, id: event.entityId });
if (audit && audit.entityId && audit.ownerId) {
await event.manager.getRepository(AuditEntity).save({ ...audit, action });
}
}
private getAudit(entityName: string, entity: any): Partial<AuditEntity> | null {
switch (entityName) {
case AssetEntity.name: {
const asset = entity as AssetEntity;
return {
entityType: EntityType.ASSET,
entityId: asset.id,
ownerId: asset.ownerId,
};
}
case AlbumEntity.name: {
const album = entity as AlbumEntity;
return {
entityType: EntityType.ALBUM,
entityId: album.id,
ownerId: album.ownerId,
};
}
}
return null;
}
}

View file

@ -0,0 +1,56 @@
import {
Check,
Column,
ColumnIndex,
CreateDateColumn,
ForeignKeyColumn,
Index,
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
import { AlbumTable } from 'src/tables/album.table';
import { AssetTable } from 'src/tables/asset.table';
import { UserTable } from 'src/tables/user.table';
@Table('activity')
@Index({
name: 'IDX_activity_like',
columns: ['assetId', 'userId', 'albumId'],
unique: true,
where: '("isLiked" = true)',
})
@Check({
name: 'CHK_2ab1e70f113f450eb40c1e3ec8',
expression: `("comment" IS NULL AND "isLiked" = true) OR ("comment" IS NOT NULL AND "isLiked" = false)`,
})
export class ActivityTable {
@PrimaryGeneratedColumn()
id!: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_activity_update_id')
@UpdateIdColumn()
updateId!: string;
@Column({ type: 'text', default: null })
comment!: string | null;
@Column({ type: 'boolean', default: false })
isLiked!: boolean;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
assetId!: string | null;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
userId!: string;
@ForeignKeyColumn(() => AlbumTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
albumId!: string;
}

View file

@ -0,0 +1,27 @@
import { ColumnIndex, CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-tools';
import { AlbumTable } from 'src/tables/album.table';
import { AssetTable } from 'src/tables/asset.table';
@Table({ name: 'albums_assets_assets', primaryConstraintName: 'PK_c67bc36fa845fb7b18e0e398180' })
export class AlbumAssetTable {
@ForeignKeyColumn(() => AssetTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
nullable: false,
primary: true,
})
@ColumnIndex()
assetsId!: string;
@ForeignKeyColumn(() => AlbumTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
nullable: false,
primary: true,
})
@ColumnIndex()
albumsId!: string;
@CreateDateColumn()
createdAt!: Date;
}

View file

@ -0,0 +1,29 @@
import { AlbumUserRole } from 'src/enum';
import { Column, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
import { AlbumTable } from 'src/tables/album.table';
import { UserTable } from 'src/tables/user.table';
@Table({ name: 'albums_shared_users_users', primaryConstraintName: 'PK_7df55657e0b2e8b626330a0ebc8' })
// Pre-existing indices from original album <--> user ManyToMany mapping
@Index({ name: 'IDX_427c350ad49bd3935a50baab73', columns: ['albumsId'] })
@Index({ name: 'IDX_f48513bf9bccefd6ff3ad30bd0', columns: ['usersId'] })
export class AlbumUserTable {
@ForeignKeyColumn(() => AlbumTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
nullable: false,
primary: true,
})
albumsId!: string;
@ForeignKeyColumn(() => UserTable, {
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
nullable: false,
primary: true,
})
usersId!: string;
@Column({ type: 'character varying', default: AlbumUserRole.EDITOR })
role!: AlbumUserRole;
}

View file

@ -0,0 +1,51 @@
import { AssetOrder } from 'src/enum';
import {
Column,
ColumnIndex,
CreateDateColumn,
DeleteDateColumn,
ForeignKeyColumn,
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
import { AssetTable } from 'src/tables/asset.table';
import { UserTable } from 'src/tables/user.table';
@Table({ name: 'albums', primaryConstraintName: 'PK_7f71c7b5bc7c87b8f94c9a93a00' })
export class AlbumTable {
@PrimaryGeneratedColumn()
id!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column({ default: 'Untitled Album' })
albumName!: string;
@Column({ type: 'text', default: '' })
description!: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_albums_update_id')
@UpdateIdColumn()
updateId?: string;
@DeleteDateColumn()
deletedAt!: Date | null;
@ForeignKeyColumn(() => AssetTable, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
albumThumbnailAssetId!: string;
@Column({ type: 'boolean', default: true })
isActivityEnabled!: boolean;
@Column({ default: AssetOrder.DESC })
order!: AssetOrder;
}

View file

@ -0,0 +1,40 @@
import { Permission } from 'src/enum';
import {
Column,
ColumnIndex,
CreateDateColumn,
ForeignKeyColumn,
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
import { UserTable } from 'src/tables/user.table';
@Table('api_keys')
export class APIKeyTable {
@PrimaryGeneratedColumn()
id!: string;
@Column()
name!: string;
@Column()
key!: string;
@Column({ array: true, type: 'character varying' })
permissions!: Permission[];
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex({ name: 'IDX_api_keys_update_id' })
@UpdateIdColumn()
updateId?: string;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
}

View file

@ -0,0 +1,19 @@
import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
@Table('assets_audit')
export class AssetAuditTable {
@PrimaryGeneratedColumn({ type: 'v7' })
id!: string;
@ColumnIndex('IDX_assets_audit_asset_id')
@Column({ type: 'uuid' })
assetId!: string;
@ColumnIndex('IDX_assets_audit_owner_id')
@Column({ type: 'uuid' })
ownerId!: string;
@ColumnIndex('IDX_assets_audit_deleted_at')
@CreateDateColumn({ default: () => 'clock_timestamp()' })
deletedAt!: Date;
}

View file

@ -0,0 +1,42 @@
import { SourceType } from 'src/enum';
import { Column, DeleteDateColumn, ForeignKeyColumn, Index, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
import { AssetTable } from 'src/tables/asset.table';
import { PersonTable } from 'src/tables/person.table';
@Table({ name: 'asset_faces' })
@Index({ name: 'IDX_asset_faces_assetId_personId', columns: ['assetId', 'personId'] })
@Index({ columns: ['personId', 'assetId'] })
export class AssetFaceTable {
@PrimaryGeneratedColumn()
id!: string;
@Column({ default: 0, type: 'integer' })
imageWidth!: number;
@Column({ default: 0, type: 'integer' })
imageHeight!: number;
@Column({ default: 0, type: 'integer' })
boundingBoxX1!: number;
@Column({ default: 0, type: 'integer' })
boundingBoxY1!: number;
@Column({ default: 0, type: 'integer' })
boundingBoxX2!: number;
@Column({ default: 0, type: 'integer' })
boundingBoxY2!: number;
@Column({ default: SourceType.MACHINE_LEARNING, enumName: 'sourcetype', enum: SourceType })
sourceType!: SourceType;
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
assetId!: string;
@ForeignKeyColumn(() => PersonTable, { onDelete: 'SET NULL', onUpdate: 'CASCADE', nullable: true })
personId!: string | null;
@DeleteDateColumn()
deletedAt!: Date | null;
}

View file

@ -0,0 +1,40 @@
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetFileType } from 'src/enum';
import {
Column,
ColumnIndex,
CreateDateColumn,
ForeignKeyColumn,
PrimaryGeneratedColumn,
Table,
Unique,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
@Unique({ name: 'UQ_assetId_type', columns: ['assetId', 'type'] })
@Table('asset_files')
export class AssetFileTable {
@PrimaryGeneratedColumn()
id!: string;
@ColumnIndex('IDX_asset_files_assetId')
@ForeignKeyColumn(() => AssetEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
assetId?: AssetEntity;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_asset_files_update_id')
@UpdateIdColumn()
updateId?: string;
@Column()
type!: AssetFileType;
@Column()
path!: string;
}

View file

@ -0,0 +1,23 @@
import { Column, ForeignKeyColumn, Table } from 'src/sql-tools';
import { AssetTable } from 'src/tables/asset.table';
@Table('asset_job_status')
export class AssetJobStatusTable {
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', primary: true })
assetId!: string;
@Column({ type: 'timestamp with time zone', nullable: true })
facesRecognizedAt!: Date | null;
@Column({ type: 'timestamp with time zone', nullable: true })
metadataExtractedAt!: Date | null;
@Column({ type: 'timestamp with time zone', nullable: true })
duplicatesDetectedAt!: Date | null;
@Column({ type: 'timestamp with time zone', nullable: true })
previewAt!: Date | null;
@Column({ type: 'timestamp with time zone', nullable: true })
thumbnailAt!: Date | null;
}

View file

@ -0,0 +1,138 @@
import { ASSET_CHECKSUM_CONSTRAINT } from 'src/entities/asset.entity';
import { AssetStatus, AssetType } from 'src/enum';
import {
Column,
ColumnIndex,
CreateDateColumn,
DeleteDateColumn,
ForeignKeyColumn,
Index,
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
import { LibraryTable } from 'src/tables/library.table';
import { StackTable } from 'src/tables/stack.table';
import { UserTable } from 'src/tables/user.table';
@Table('assets')
// Checksums must be unique per user and library
@Index({
name: ASSET_CHECKSUM_CONSTRAINT,
columns: ['ownerId', 'checksum'],
unique: true,
where: '("libraryId" IS NULL)',
})
@Index({
name: 'UQ_assets_owner_library_checksum' + '',
columns: ['ownerId', 'libraryId', 'checksum'],
unique: true,
where: '("libraryId" IS NOT NULL)',
})
@Index({ name: 'idx_local_date_time', expression: `(("localDateTime" AT TIME ZONE 'UTC'::text))::date` })
@Index({
name: 'idx_local_date_time_month',
expression: `(date_trunc('MONTH'::text, ("localDateTime" AT TIME ZONE 'UTC'::text)) AT TIME ZONE 'UTC'::text)`,
})
@Index({ name: 'IDX_originalPath_libraryId', columns: ['originalPath', 'libraryId'] })
@Index({ name: 'IDX_asset_id_stackId', columns: ['id', 'stackId'] })
@Index({
name: 'idx_originalFileName_trigram',
using: 'gin',
expression: 'f_unaccent(("originalFileName")::text)',
})
// For all assets, each originalpath must be unique per user and library
export class AssetTable {
@PrimaryGeneratedColumn()
id!: string;
@Column()
deviceAssetId!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@ForeignKeyColumn(() => LibraryTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: true })
libraryId?: string | null;
@Column()
deviceId!: string;
@Column()
type!: AssetType;
@Column({ type: 'enum', enum: AssetStatus, default: AssetStatus.ACTIVE })
status!: AssetStatus;
@Column()
originalPath!: string;
@Column({ type: 'bytea', nullable: true })
thumbhash!: Buffer | null;
@Column({ type: 'character varying', nullable: true, default: '' })
encodedVideoPath!: string | null;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_assets_update_id')
@UpdateIdColumn()
updateId?: string;
@DeleteDateColumn()
deletedAt!: Date | null;
@ColumnIndex('idx_asset_file_created_at')
@Column({ type: 'timestamp with time zone', default: null })
fileCreatedAt!: Date;
@Column({ type: 'timestamp with time zone', default: null })
localDateTime!: Date;
@Column({ type: 'timestamp with time zone', default: null })
fileModifiedAt!: Date;
@Column({ type: 'boolean', default: false })
isFavorite!: boolean;
@Column({ type: 'boolean', default: false })
isArchived!: boolean;
@Column({ type: 'boolean', default: false })
isExternal!: boolean;
@Column({ type: 'boolean', default: false })
isOffline!: boolean;
@Column({ type: 'bytea' })
@ColumnIndex()
checksum!: Buffer; // sha1 checksum
@Column({ type: 'character varying', nullable: true })
duration!: string | null;
@Column({ type: 'boolean', default: true })
isVisible!: boolean;
@ForeignKeyColumn(() => AssetTable, { nullable: true, onUpdate: 'CASCADE', onDelete: 'SET NULL' })
livePhotoVideoId!: string | null;
@Column()
@ColumnIndex()
originalFileName!: string;
@Column({ nullable: true })
sidecarPath!: string | null;
@ForeignKeyColumn(() => StackTable, { nullable: true, onDelete: 'SET NULL', onUpdate: 'CASCADE' })
stackId?: string | null;
@ColumnIndex('IDX_assets_duplicateId')
@Column({ type: 'uuid', nullable: true })
duplicateId!: string | null;
}

View file

@ -0,0 +1,24 @@
import { DatabaseAction, EntityType } from 'src/enum';
import { Column, CreateDateColumn, Index, PrimaryColumn, Table } from 'src/sql-tools';
@Table('audit')
@Index({ name: 'IDX_ownerId_createdAt', columns: ['ownerId', 'createdAt'] })
export class AuditTable {
@PrimaryColumn({ type: 'integer', default: 'increment', synchronize: false })
id!: number;
@Column()
entityType!: EntityType;
@Column({ type: 'uuid' })
entityId!: string;
@Column()
action!: DatabaseAction;
@Column({ type: 'uuid' })
ownerId!: string;
@CreateDateColumn()
createdAt!: Date;
}

View file

@ -0,0 +1,105 @@
import { Column, ColumnIndex, ForeignKeyColumn, Table, UpdateDateColumn, UpdateIdColumn } from 'src/sql-tools';
import { AssetTable } from 'src/tables/asset.table';
@Table('exif')
export class ExifTable {
@ForeignKeyColumn(() => AssetTable, { onDelete: 'CASCADE', primary: true })
assetId!: string;
@UpdateDateColumn({ default: () => 'clock_timestamp()' })
updatedAt?: Date;
@ColumnIndex('IDX_asset_exif_update_id')
@UpdateIdColumn()
updateId?: string;
/* General info */
@Column({ type: 'text', default: '' })
description!: string; // or caption
@Column({ type: 'integer', nullable: true })
exifImageWidth!: number | null;
@Column({ type: 'integer', nullable: true })
exifImageHeight!: number | null;
@Column({ type: 'bigint', nullable: true })
fileSizeInByte!: number | null;
@Column({ type: 'character varying', nullable: true })
orientation!: string | null;
@Column({ type: 'timestamp with time zone', nullable: true })
dateTimeOriginal!: Date | null;
@Column({ type: 'timestamp with time zone', nullable: true })
modifyDate!: Date | null;
@Column({ type: 'character varying', nullable: true })
timeZone!: string | null;
@Column({ type: 'double precision', nullable: true })
latitude!: number | null;
@Column({ type: 'double precision', nullable: true })
longitude!: number | null;
@Column({ type: 'character varying', nullable: true })
projectionType!: string | null;
@ColumnIndex('exif_city')
@Column({ type: 'character varying', nullable: true })
city!: string | null;
@ColumnIndex('IDX_live_photo_cid')
@Column({ type: 'character varying', nullable: true })
livePhotoCID!: string | null;
@ColumnIndex('IDX_auto_stack_id')
@Column({ type: 'character varying', nullable: true })
autoStackId!: string | null;
@Column({ type: 'character varying', nullable: true })
state!: string | null;
@Column({ type: 'character varying', nullable: true })
country!: string | null;
/* Image info */
@Column({ type: 'character varying', nullable: true })
make!: string | null;
@Column({ type: 'character varying', nullable: true })
model!: string | null;
@Column({ type: 'character varying', nullable: true })
lensModel!: string | null;
@Column({ type: 'double precision', nullable: true })
fNumber!: number | null;
@Column({ type: 'double precision', nullable: true })
focalLength!: number | null;
@Column({ type: 'integer', nullable: true })
iso!: number | null;
@Column({ type: 'character varying', nullable: true })
exposureTime!: string | null;
@Column({ type: 'character varying', nullable: true })
profileDescription!: string | null;
@Column({ type: 'character varying', nullable: true })
colorspace!: string | null;
@Column({ type: 'integer', nullable: true })
bitsPerSample!: number | null;
@Column({ type: 'integer', nullable: true })
rating!: number | null;
/* Video info */
@Column({ type: 'double precision', nullable: true })
fps?: number | null;
}

View file

@ -0,0 +1,16 @@
import { Column, ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools';
import { AssetFaceTable } from 'src/tables/asset-face.table';
@Table({ name: 'face_search', primaryConstraintName: 'face_search_pkey' })
export class FaceSearchTable {
@ForeignKeyColumn(() => AssetFaceTable, {
onDelete: 'CASCADE',
primary: true,
constraintName: 'face_search_faceId_fkey',
})
faceId!: string;
@ColumnIndex({ name: 'face_index', synchronize: false })
@Column({ type: 'vector', array: true, length: 512, synchronize: false })
embedding!: string;
}

View file

@ -0,0 +1,73 @@
import { Column, PrimaryColumn, Table } from 'src/sql-tools';
@Table({ name: 'geodata_places', synchronize: false })
export class GeodataPlacesTable {
@PrimaryColumn({ type: 'integer' })
id!: number;
@Column({ type: 'character varying', length: 200 })
name!: string;
@Column({ type: 'double precision' })
longitude!: number;
@Column({ type: 'double precision' })
latitude!: number;
@Column({ type: 'character', length: 2 })
countryCode!: string;
@Column({ type: 'character varying', length: 20, nullable: true })
admin1Code!: string;
@Column({ type: 'character varying', length: 80, nullable: true })
admin2Code!: string;
@Column({ type: 'character varying', nullable: true })
admin1Name!: string;
@Column({ type: 'character varying', nullable: true })
admin2Name!: string;
@Column({ type: 'character varying', nullable: true })
alternateNames!: string;
@Column({ type: 'date' })
modificationDate!: Date;
}
@Table({ name: 'geodata_places_tmp', synchronize: false })
export class GeodataPlacesTempEntity {
@PrimaryColumn({ type: 'integer' })
id!: number;
@Column({ type: 'character varying', length: 200 })
name!: string;
@Column({ type: 'double precision' })
longitude!: number;
@Column({ type: 'double precision' })
latitude!: number;
@Column({ type: 'character', length: 2 })
countryCode!: string;
@Column({ type: 'character varying', length: 20, nullable: true })
admin1Code!: string;
@Column({ type: 'character varying', length: 80, nullable: true })
admin2Code!: string;
@Column({ type: 'character varying', nullable: true })
admin1Name!: string;
@Column({ type: 'character varying', nullable: true })
admin2Name!: string;
@Column({ type: 'character varying', nullable: true })
alternateNames!: string;
@Column({ type: 'date' })
modificationDate!: Date;
}

View file

@ -0,0 +1,70 @@
import { ActivityTable } from 'src/tables/activity.table';
import { AlbumAssetTable } from 'src/tables/album-asset.table';
import { AlbumUserTable } from 'src/tables/album-user.table';
import { AlbumTable } from 'src/tables/album.table';
import { APIKeyTable } from 'src/tables/api-key.table';
import { AssetAuditTable } from 'src/tables/asset-audit.table';
import { AssetFaceTable } from 'src/tables/asset-face.table';
import { AssetJobStatusTable } from 'src/tables/asset-job-status.table';
import { AssetTable } from 'src/tables/asset.table';
import { AuditTable } from 'src/tables/audit.table';
import { ExifTable } from 'src/tables/exif.table';
import { FaceSearchTable } from 'src/tables/face-search.table';
import { GeodataPlacesTable } from 'src/tables/geodata-places.table';
import { LibraryTable } from 'src/tables/library.table';
import { MemoryTable } from 'src/tables/memory.table';
import { MemoryAssetTable } from 'src/tables/memory_asset.table';
import { MoveTable } from 'src/tables/move.table';
import { NaturalEarthCountriesTable, NaturalEarthCountriesTempTable } from 'src/tables/natural-earth-countries.table';
import { PartnerAuditTable } from 'src/tables/partner-audit.table';
import { PartnerTable } from 'src/tables/partner.table';
import { PersonTable } from 'src/tables/person.table';
import { SessionTable } from 'src/tables/session.table';
import { SharedLinkAssetTable } from 'src/tables/shared-link-asset.table';
import { SharedLinkTable } from 'src/tables/shared-link.table';
import { SmartSearchTable } from 'src/tables/smart-search.table';
import { StackTable } from 'src/tables/stack.table';
import { SessionSyncCheckpointTable } from 'src/tables/sync-checkpoint.table';
import { SystemMetadataTable } from 'src/tables/system-metadata.table';
import { TagAssetTable } from 'src/tables/tag-asset.table';
import { UserAuditTable } from 'src/tables/user-audit.table';
import { UserMetadataTable } from 'src/tables/user-metadata.table';
import { UserTable } from 'src/tables/user.table';
import { VersionHistoryTable } from 'src/tables/version-history.table';
export const tables = [
ActivityTable,
AlbumAssetTable,
AlbumUserTable,
AlbumTable,
APIKeyTable,
AssetAuditTable,
AssetFaceTable,
AssetJobStatusTable,
AssetTable,
AuditTable,
ExifTable,
FaceSearchTable,
GeodataPlacesTable,
LibraryTable,
MemoryAssetTable,
MemoryTable,
MoveTable,
NaturalEarthCountriesTable,
NaturalEarthCountriesTempTable,
PartnerAuditTable,
PartnerTable,
PersonTable,
SessionTable,
SharedLinkAssetTable,
SharedLinkTable,
SmartSearchTable,
StackTable,
SessionSyncCheckpointTable,
SystemMetadataTable,
TagAssetTable,
UserAuditTable,
UserMetadataTable,
UserTable,
VersionHistoryTable,
];

View file

@ -0,0 +1,46 @@
import {
Column,
ColumnIndex,
CreateDateColumn,
DeleteDateColumn,
ForeignKeyColumn,
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
import { UserTable } from 'src/tables/user.table';
@Table('libraries')
export class LibraryTable {
@PrimaryGeneratedColumn()
id!: string;
@Column()
name!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column({ type: 'text', array: true })
importPaths!: string[];
@Column({ type: 'text', array: true })
exclusionPatterns!: string[];
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_libraries_update_id')
@UpdateIdColumn()
updateId?: string;
@DeleteDateColumn()
deletedAt?: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
refreshedAt!: Date | null;
}

View file

@ -0,0 +1,60 @@
import { MemoryType } from 'src/enum';
import {
Column,
ColumnIndex,
CreateDateColumn,
DeleteDateColumn,
ForeignKeyColumn,
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
import { UserTable } from 'src/tables/user.table';
import { MemoryData } from 'src/types';
@Table('memories')
export class MemoryTable<T extends MemoryType = MemoryType> {
@PrimaryGeneratedColumn()
id!: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_memories_update_id')
@UpdateIdColumn()
updateId?: string;
@DeleteDateColumn()
deletedAt?: Date;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column()
type!: T;
@Column({ type: 'jsonb' })
data!: MemoryData[T];
/** unless set to true, will be automatically deleted in the future */
@Column({ type: 'boolean', default: false })
isSaved!: boolean;
/** memories are sorted in ascending order by this value */
@Column({ type: 'timestamp with time zone' })
memoryAt!: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
showAt?: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
hideAt?: Date;
/** when the user last viewed the memory */
@Column({ type: 'timestamp with time zone', nullable: true })
seenAt?: Date;
}

View file

@ -0,0 +1,14 @@
import { ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools';
import { AssetTable } from 'src/tables/asset.table';
import { MemoryTable } from 'src/tables/memory.table';
@Table('memories_assets_assets')
export class MemoryAssetTable {
@ColumnIndex()
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
assetsId!: string;
@ColumnIndex()
@ForeignKeyColumn(() => MemoryTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
memoriesId!: string;
}

View file

@ -0,0 +1,24 @@
import { PathType } from 'src/enum';
import { Column, PrimaryGeneratedColumn, Table, Unique } from 'src/sql-tools';
@Table('move_history')
// path lock (per entity)
@Unique({ name: 'UQ_entityId_pathType', columns: ['entityId', 'pathType'] })
// new path lock (global)
@Unique({ name: 'UQ_newPath', columns: ['newPath'] })
export class MoveTable {
@PrimaryGeneratedColumn()
id!: string;
@Column({ type: 'uuid' })
entityId!: string;
@Column({ type: 'character varying' })
pathType!: PathType;
@Column({ type: 'character varying' })
oldPath!: string;
@Column({ type: 'character varying' })
newPath!: string;
}

View file

@ -0,0 +1,37 @@
import { Column, PrimaryColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
@Table({ name: 'naturalearth_countries', synchronize: false })
export class NaturalEarthCountriesTable {
@PrimaryColumn({ type: 'serial' })
id!: number;
@Column({ type: 'character varying', length: 50 })
admin!: string;
@Column({ type: 'character varying', length: 3 })
admin_a3!: string;
@Column({ type: 'character varying', length: 50 })
type!: string;
@Column({ type: 'polygon' })
coordinates!: string;
}
@Table({ name: 'naturalearth_countries_tmp', synchronize: false })
export class NaturalEarthCountriesTempTable {
@PrimaryGeneratedColumn()
id!: number;
@Column({ type: 'character varying', length: 50 })
admin!: string;
@Column({ type: 'character varying', length: 3 })
admin_a3!: string;
@Column({ type: 'character varying', length: 50 })
type!: string;
@Column({ type: 'polygon' })
coordinates!: string;
}

View file

@ -0,0 +1,19 @@
import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
@Table('partners_audit')
export class PartnerAuditTable {
@PrimaryGeneratedColumn({ type: 'v7' })
id!: string;
@ColumnIndex('IDX_partners_audit_shared_by_id')
@Column({ type: 'uuid' })
sharedById!: string;
@ColumnIndex('IDX_partners_audit_shared_with_id')
@Column({ type: 'uuid' })
sharedWithId!: string;
@ColumnIndex('IDX_partners_audit_deleted_at')
@CreateDateColumn({ default: () => 'clock_timestamp()' })
deletedAt!: Date;
}

View file

@ -0,0 +1,32 @@
import {
Column,
ColumnIndex,
CreateDateColumn,
ForeignKeyColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
import { UserTable } from 'src/tables/user.table';
@Table('partners')
export class PartnerTable {
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', primary: true })
sharedById!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', primary: true })
sharedWithId!: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_partners_update_id')
@UpdateIdColumn()
updateId!: string;
@Column({ type: 'boolean', default: false })
inTimeline!: boolean;
}

View file

@ -0,0 +1,54 @@
import {
Check,
Column,
ColumnIndex,
CreateDateColumn,
ForeignKeyColumn,
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
import { AssetFaceTable } from 'src/tables/asset-face.table';
import { UserTable } from 'src/tables/user.table';
@Table('person')
@Check({ name: 'CHK_b0f82b0ed662bfc24fbb58bb45', expression: `"birthDate" <= CURRENT_DATE` })
export class PersonTable {
@PrimaryGeneratedColumn('uuid')
id!: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_person_update_id')
@UpdateIdColumn()
updateId!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', nullable: false })
ownerId!: string;
@Column({ default: '' })
name!: string;
@Column({ type: 'date', nullable: true })
birthDate!: Date | string | null;
@Column({ default: '' })
thumbnailPath!: string;
@ForeignKeyColumn(() => AssetFaceTable, { onDelete: 'SET NULL', nullable: true })
faceAssetId!: string | null;
@Column({ type: 'boolean', default: false })
isHidden!: boolean;
@Column({ type: 'boolean', default: false })
isFavorite!: boolean;
@Column({ type: 'character varying', nullable: true, default: null })
color?: string | null;
}

View file

@ -0,0 +1,40 @@
import {
Column,
ColumnIndex,
CreateDateColumn,
ForeignKeyColumn,
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
import { UserTable } from 'src/tables/user.table';
@Table({ name: 'sessions', primaryConstraintName: 'PK_48cb6b5c20faa63157b3c1baf7f' })
export class SessionTable {
@PrimaryGeneratedColumn()
id!: string;
// TODO convert to byte[]
@Column()
token!: string;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_sessions_update_id')
@UpdateIdColumn()
updateId!: string;
@Column({ default: '' })
deviceType!: string;
@Column({ default: '' })
deviceOS!: string;
}

View file

@ -0,0 +1,14 @@
import { ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools';
import { AssetTable } from 'src/tables/asset.table';
import { SharedLinkTable } from 'src/tables/shared-link.table';
@Table('shared_link__asset')
export class SharedLinkAssetTable {
@ColumnIndex()
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
assetsId!: string;
@ColumnIndex()
@ForeignKeyColumn(() => SharedLinkTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
sharedLinksId!: string;
}

View file

@ -0,0 +1,54 @@
import { SharedLinkType } from 'src/enum';
import {
Column,
ColumnIndex,
CreateDateColumn,
ForeignKeyColumn,
PrimaryGeneratedColumn,
Table,
Unique,
} from 'src/sql-tools';
import { AlbumTable } from 'src/tables/album.table';
import { UserTable } from 'src/tables/user.table';
@Table('shared_links')
@Unique({ name: 'UQ_sharedlink_key', columns: ['key'] })
export class SharedLinkTable {
@PrimaryGeneratedColumn()
id!: string;
@Column({ type: 'character varying', nullable: true })
description!: string | null;
@Column({ type: 'character varying', nullable: true })
password!: string | null;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
userId!: string;
@ColumnIndex('IDX_sharedlink_albumId')
@ForeignKeyColumn(() => AlbumTable, { nullable: true, onDelete: 'CASCADE', onUpdate: 'CASCADE' })
albumId!: string;
@ColumnIndex('IDX_sharedlink_key')
@Column({ type: 'bytea' })
key!: Buffer; // use to access the inidividual asset
@Column()
type!: SharedLinkType;
@CreateDateColumn()
createdAt!: Date;
@Column({ type: 'timestamp with time zone', nullable: true })
expiresAt!: Date | null;
@Column({ type: 'boolean', default: false })
allowUpload!: boolean;
@Column({ type: 'boolean', default: true })
allowDownload!: boolean;
@Column({ type: 'boolean', default: true })
showExif!: boolean;
}

View file

@ -0,0 +1,16 @@
import { Column, ColumnIndex, ForeignKeyColumn, Table } from 'src/sql-tools';
import { AssetTable } from 'src/tables/asset.table';
@Table({ name: 'smart_search', primaryConstraintName: 'smart_search_pkey' })
export class SmartSearchTable {
@ForeignKeyColumn(() => AssetTable, {
onDelete: 'CASCADE',
primary: true,
constraintName: 'smart_search_assetId_fkey',
})
assetId!: string;
@ColumnIndex({ name: 'clip_index', synchronize: false })
@Column({ type: 'vector', array: true, length: 512, synchronize: false })
embedding!: string;
}

View file

@ -0,0 +1,16 @@
import { ForeignKeyColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
import { AssetTable } from 'src/tables/asset.table';
import { UserTable } from 'src/tables/user.table';
@Table('asset_stack')
export class StackTable {
@PrimaryGeneratedColumn()
id!: string;
@ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
ownerId!: string;
//TODO: Add constraint to ensure primary asset exists in the assets array
@ForeignKeyColumn(() => AssetTable, { nullable: false, unique: true })
primaryAssetId!: string;
}

View file

@ -0,0 +1,34 @@
import { SyncEntityType } from 'src/enum';
import {
Column,
ColumnIndex,
CreateDateColumn,
ForeignKeyColumn,
PrimaryColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
import { SessionTable } from 'src/tables/session.table';
@Table('session_sync_checkpoints')
export class SessionSyncCheckpointTable {
@ForeignKeyColumn(() => SessionTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE', primary: true })
sessionId!: string;
@PrimaryColumn({ type: 'character varying' })
type!: SyncEntityType;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_session_sync_checkpoints_update_id')
@UpdateIdColumn()
updateId!: string;
@Column()
ack!: string;
}

View file

@ -0,0 +1,12 @@
import { SystemMetadataKey } from 'src/enum';
import { Column, PrimaryColumn, Table } from 'src/sql-tools';
import { SystemMetadata } from 'src/types';
@Table('system_metadata')
export class SystemMetadataTable<T extends keyof SystemMetadata = SystemMetadataKey> {
@PrimaryColumn({ type: 'character varying' })
key!: T;
@Column({ type: 'jsonb' })
value!: SystemMetadata[T];
}

View file

@ -0,0 +1,15 @@
import { ColumnIndex, ForeignKeyColumn, Index, Table } from 'src/sql-tools';
import { AssetTable } from 'src/tables/asset.table';
import { TagTable } from 'src/tables/tag.table';
@Index({ name: 'IDX_tag_asset_assetsId_tagsId', columns: ['assetsId', 'tagsId'] })
@Table('tag_asset')
export class TagAssetTable {
@ColumnIndex()
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
assetsId!: string;
@ColumnIndex()
@ForeignKeyColumn(() => TagTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
tagsId!: string;
}

View file

@ -0,0 +1,15 @@
import { ColumnIndex, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools';
import { TagTable } from 'src/tables/tag.table';
@Table('tags_closure')
export class TagClosureTable {
@PrimaryColumn()
@ColumnIndex()
@ForeignKeyColumn(() => TagTable, { onDelete: 'CASCADE', onUpdate: 'NO ACTION' })
id_ancestor!: string;
@PrimaryColumn()
@ColumnIndex()
@ForeignKeyColumn(() => TagTable, { onDelete: 'CASCADE', onUpdate: 'NO ACTION' })
id_descendant!: string;
}

View file

@ -0,0 +1,41 @@
import {
Column,
ColumnIndex,
CreateDateColumn,
ForeignKeyColumn,
PrimaryGeneratedColumn,
Table,
Unique,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
import { UserTable } from 'src/tables/user.table';
@Table('tags')
@Unique({ columns: ['userId', 'value'] })
export class TagTable {
@PrimaryGeneratedColumn()
id!: string;
@Column()
value!: string;
@CreateDateColumn()
createdAt!: Date;
@UpdateDateColumn()
updatedAt!: Date;
@ColumnIndex('IDX_tags_update_id')
@UpdateIdColumn()
updateId!: string;
@Column({ type: 'character varying', nullable: true, default: null })
color!: string | null;
@ForeignKeyColumn(() => TagTable, { nullable: true, onDelete: 'CASCADE' })
parentId?: string;
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE' })
userId!: string;
}

View file

@ -0,0 +1,14 @@
import { Column, ColumnIndex, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
@Table('users_audit')
export class UserAuditTable {
@PrimaryGeneratedColumn({ type: 'v7' })
id!: string;
@Column({ type: 'uuid' })
userId!: string;
@ColumnIndex('IDX_users_audit_deleted_at')
@CreateDateColumn({ default: () => 'clock_timestamp()' })
deletedAt!: Date;
}

View file

@ -0,0 +1,16 @@
import { UserMetadata, UserMetadataItem } from 'src/entities/user-metadata.entity';
import { UserMetadataKey } from 'src/enum';
import { Column, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools';
import { UserTable } from 'src/tables/user.table';
@Table('user_metadata')
export class UserMetadataTable<T extends keyof UserMetadata = UserMetadataKey> implements UserMetadataItem<T> {
@ForeignKeyColumn(() => UserTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
userId!: string;
@PrimaryColumn({ type: 'character varying' })
key!: T;
@Column({ type: 'jsonb' })
value!: UserMetadata[T];
}

View file

@ -0,0 +1,73 @@
import { ColumnType } from 'kysely';
import { UserStatus } from 'src/enum';
import {
Column,
ColumnIndex,
CreateDateColumn,
DeleteDateColumn,
Index,
PrimaryGeneratedColumn,
Table,
UpdateDateColumn,
UpdateIdColumn,
} from 'src/sql-tools';
type Timestamp = ColumnType<Date, Date | string, Date | string>;
type Generated<T> =
T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
@Table('users')
@Index({ name: 'IDX_users_updated_at_asc_id_asc', columns: ['updatedAt', 'id'] })
export class UserTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column({ default: '' })
name!: Generated<string>;
@Column({ type: 'boolean', default: false })
isAdmin!: Generated<boolean>;
@Column({ unique: true })
email!: string;
@Column({ unique: true, nullable: true, default: null })
storageLabel!: string | null;
@Column({ default: '' })
password!: Generated<string>;
@Column({ default: '' })
oauthId!: Generated<string>;
@Column({ default: '' })
profileImagePath!: Generated<string>;
@Column({ type: 'boolean', default: true })
shouldChangePassword!: Generated<boolean>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@DeleteDateColumn()
deletedAt!: Timestamp | null;
@Column({ type: 'character varying', default: UserStatus.ACTIVE })
status!: Generated<UserStatus>;
@ColumnIndex({ name: 'IDX_users_update_id' })
@UpdateIdColumn()
updateId!: Generated<string>;
@Column({ type: 'bigint', nullable: true })
quotaSizeInBytes!: ColumnType<number> | null;
@Column({ type: 'bigint', default: 0 })
quotaUsageInBytes!: Generated<ColumnType<number>>;
@Column({ type: 'timestamp with time zone', default: () => 'now()' })
profileChangedAt!: Generated<Timestamp>;
}

View file

@ -0,0 +1,13 @@
import { Column, CreateDateColumn, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
@Table('version_history')
export class VersionHistoryTable {
@PrimaryGeneratedColumn()
id!: string;
@CreateDateColumn()
createdAt!: Date;
@Column()
version!: string;
}

View file

@ -1,11 +1,15 @@
import { SystemConfig } from 'src/config';
import {
AssetType,
DatabaseExtension,
ExifOrientation,
ImageFormat,
JobName,
MemoryType,
QueueName,
StorageFolder,
SyncEntityType,
SystemMetadataKey,
TranscodeTarget,
VideoCodec,
} from 'src/enum';
@ -454,3 +458,27 @@ export type StorageAsset = {
sidecarPath: string | null;
fileSizeInByte: number | null;
};
export type OnThisDayData = { year: number };
export interface MemoryData {
[MemoryType.ON_THIS_DAY]: OnThisDayData;
}
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> };
export type MemoriesState = {
/** memories have already been created through this date */
lastOnThisDayDate: string;
};
export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> {
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
[SystemMetadataKey.FACIAL_RECOGNITION_STATE]: { lastRun?: string };
[SystemMetadataKey.LICENSE]: { licenseKey: string; activationKey: string; activatedAt: Date };
[SystemMetadataKey.REVERSE_GEOCODING_STATE]: { lastUpdate?: string; lastImportFileName?: string };
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
[SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial<SystemFlags>;
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
[SystemMetadataKey.MEMORIES_STATE]: MemoriesState;
}

View file

@ -1,19 +1,4 @@
import { Expression, sql } from 'kysely';
import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
/**
* Allows optional values unlike the regular Between and uses MoreThanOrEqual
* or LessThanOrEqual when only one parameter is specified.
*/
export function OptionalBetween<T>(from?: T, to?: T) {
if (from && to) {
return Between(from, to);
} else if (from) {
return MoreThanOrEqual(from);
} else if (to) {
return LessThanOrEqual(to);
}
}
export const asUuid = (id: string | Expression<string>) => sql<string>`${id}::uuid`;
@ -32,16 +17,3 @@ export const removeUndefinedKeys = <T extends object>(update: T, template: unkno
return update;
};
/**
* Mainly for type debugging to make VS Code display a more useful tooltip.
* Source: https://stackoverflow.com/a/69288824
*/
export type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
/** Recursive version of {@link Expand} from the same source. */
export type ExpandRecursively<T> = T extends object
? T extends infer O
? { [K in keyof O]: ExpandRecursively<O[K]> }
: never
: T;

View file

@ -1,6 +1,5 @@
import { HttpException } from '@nestjs/common';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { TypeORMError } from 'typeorm';
export const logGlobalError = (logger: LoggingRepository, error: Error) => {
if (error instanceof HttpException) {
@ -10,11 +9,6 @@ export const logGlobalError = (logger: LoggingRepository, error: Error) => {
return;
}
if (error instanceof TypeORMError) {
logger.error(`Database error: ${error}`);
return;
}
if (error instanceof Error) {
logger.error(`Unknown error: ${error}`, error?.stack);
return;

View file

@ -1,7 +1,7 @@
import { Insertable, Kysely } from 'kysely';
import { randomBytes } from 'node:crypto';
import { Writable } from 'node:stream';
import { Assets, DB, Partners, Sessions, Users } from 'src/db';
import { Assets, DB, Partners, Sessions } from 'src/db';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
@ -35,6 +35,7 @@ import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { ViewRepository } from 'src/repositories/view-repository';
import { UserTable } from 'src/tables/user.table';
import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
import { newUuid } from 'test/small.factory';
import { automock } from 'test/utils';
@ -57,7 +58,7 @@ class CustomWritable extends Writable {
}
type Asset = Partial<Insertable<Assets>>;
type User = Partial<Insertable<Users>>;
type User = Partial<Insertable<UserTable>>;
type Session = Omit<Insertable<Sessions>, 'token'> & { token?: string };
type Partner = Insertable<Partners>;
@ -103,7 +104,7 @@ export class TestFactory {
static user(user: User = {}) {
const userId = user.id || newUuid();
const defaults: Insertable<Users> = {
const defaults: Insertable<UserTable> = {
email: `${userId}@immich.cloud`,
name: `User ${userId}`,
deletedAt: null,

View file

@ -1,14 +0,0 @@
import { AuditEntity } from 'src/entities/audit.entity';
import { DatabaseAction, EntityType } from 'src/enum';
import { authStub } from 'test/fixtures/auth.stub';
export const auditStub = {
delete: Object.freeze<AuditEntity>({
id: 3,
entityId: 'asset-deleted',
action: DatabaseAction.DELETE,
entityType: EntityType.ASSET,
ownerId: authStub.admin.user.id,
createdAt: new Date(),
}),
};

View file

@ -17,7 +17,6 @@ export const userStub = {
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
metadata: [],
quotaSizeInBytes: null,
@ -36,7 +35,6 @@ export const userStub = {
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
metadata: [
{
@ -62,7 +60,6 @@ export const userStub = {
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
@ -81,7 +78,6 @@ export const userStub = {
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
@ -100,7 +96,6 @@ export const userStub = {
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
tags: [],
assets: [],
quotaSizeInBytes: null,
quotaUsageInBytes: 0,

Some files were not shown because too many files have changed in this diff Show more