diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index d7b6310667..8317640db5 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -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
diff --git a/server/eslint.config.mjs b/server/eslint.config.mjs
index 5fe62b9651..b1e7d409b1 100644
--- a/server/eslint.config.mjs
+++ b/server/eslint.config.mjs
@@ -77,6 +77,14 @@ export default [
           ],
         },
       ],
+
+      '@typescript-eslint/no-unused-vars': [
+        'warn',
+        {
+          argsIgnorePattern: '^_',
+          varsIgnorePattern: '^_',
+        },
+      ],
     },
   },
 ];
diff --git a/server/package.json b/server/package.json
index d600fbad9a..f1d5d4c6b8 100644
--- a/server/package.json
+++ b/server/package.json
@@ -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;'",
diff --git a/server/src/bin/migrations.ts b/server/src/bin/migrations.ts
new file mode 100644
index 0000000000..13a149a1a1
--- /dev/null
+++ b/server/src/bin/migrations.ts
@@ -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);
+  });
diff --git a/server/src/db.d.ts b/server/src/db.d.ts
index 85aade2c9b..e315a266cf 100644
--- a/server/src/db.d.ts
+++ b/server/src/db.d.ts
@@ -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;
diff --git a/server/src/entities/activity.entity.ts b/server/src/entities/activity.entity.ts
deleted file mode 100644
index dabb371977..0000000000
--- a/server/src/entities/activity.entity.ts
+++ /dev/null
@@ -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;
-}
diff --git a/server/src/entities/album-user.entity.ts b/server/src/entities/album-user.entity.ts
index e75b3cd43e..7950ffab7d 100644
--- a/server/src/entities/album-user.entity.ts
+++ b/server/src/entities/album-user.entity.ts
@@ -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;
 }
diff --git a/server/src/entities/album.entity.ts b/server/src/entities/album.entity.ts
index 4cd7c82394..946c807a1a 100644
--- a/server/src/entities/album.entity.ts
+++ b/server/src/entities/album.entity.ts
@@ -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;
 }
diff --git a/server/src/entities/api-key.entity.ts b/server/src/entities/api-key.entity.ts
deleted file mode 100644
index f59bf0d918..0000000000
--- a/server/src/entities/api-key.entity.ts
+++ /dev/null
@@ -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;
-}
diff --git a/server/src/entities/asset-audit.entity.ts b/server/src/entities/asset-audit.entity.ts
deleted file mode 100644
index 0172d15ce6..0000000000
--- a/server/src/entities/asset-audit.entity.ts
+++ /dev/null
@@ -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;
-}
diff --git a/server/src/entities/asset-face.entity.ts b/server/src/entities/asset-face.entity.ts
index b556a8b7cf..dddb6b0f3f 100644
--- a/server/src/entities/asset-face.entity.ts
+++ b/server/src/entities/asset-face.entity.ts
@@ -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;
 }
diff --git a/server/src/entities/asset-files.entity.ts b/server/src/entities/asset-files.entity.ts
index 09f96e849d..3bd80784b6 100644
--- a/server/src/entities/asset-files.entity.ts
+++ b/server/src/entities/asset-files.entity.ts
@@ -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;
 }
diff --git a/server/src/entities/asset-job-status.entity.ts b/server/src/entities/asset-job-status.entity.ts
index 353055df43..2cccfcab3a 100644
--- a/server/src/entities/asset-job-status.entity.ts
+++ b/server/src/entities/asset-job-status.entity.ts
@@ -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;
 }
diff --git a/server/src/entities/asset.entity.ts b/server/src/entities/asset.entity.ts
index b2589e1231..836fc409af 100644
--- a/server/src/entities/asset.entity.ts
+++ b/server/src/entities/asset.entity.ts
@@ -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;
 }
 
diff --git a/server/src/entities/audit.entity.ts b/server/src/entities/audit.entity.ts
deleted file mode 100644
index 7f51e17585..0000000000
--- a/server/src/entities/audit.entity.ts
+++ /dev/null
@@ -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;
-}
diff --git a/server/src/entities/exif.entity.ts b/server/src/entities/exif.entity.ts
index 5b402109a5..75064b7917 100644
--- a/server/src/entities/exif.entity.ts
+++ b/server/src/entities/exif.entity.ts
@@ -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;
 }
diff --git a/server/src/entities/face-search.entity.ts b/server/src/entities/face-search.entity.ts
index e907ba6c9e..701fd9e580 100644
--- a/server/src/entities/face-search.entity.ts
+++ b/server/src/entities/face-search.entity.ts
@@ -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;
 }
diff --git a/server/src/entities/geodata-places.entity.ts b/server/src/entities/geodata-places.entity.ts
index eb32d1b99b..aad6c38dda 100644
--- a/server/src/entities/geodata-places.entity.ts
+++ b/server/src/entities/geodata-places.entity.ts
@@ -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;
 }
diff --git a/server/src/entities/library.entity.ts b/server/src/entities/library.entity.ts
deleted file mode 100644
index 0471661fca..0000000000
--- a/server/src/entities/library.entity.ts
+++ /dev/null
@@ -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;
-}
diff --git a/server/src/entities/memory.entity.ts b/server/src/entities/memory.entity.ts
deleted file mode 100644
index dafd7eb21c..0000000000
--- a/server/src/entities/memory.entity.ts
+++ /dev/null
@@ -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[];
-}
diff --git a/server/src/entities/move.entity.ts b/server/src/entities/move.entity.ts
index 7a998eaebe..0570d98edc 100644
--- a/server/src/entities/move.entity.ts
+++ b/server/src/entities/move.entity.ts
@@ -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;
 }
diff --git a/server/src/entities/natural-earth-countries.entity.ts b/server/src/entities/natural-earth-countries.entity.ts
index 0f97132045..50bce3e034 100644
--- a/server/src/entities/natural-earth-countries.entity.ts
+++ b/server/src/entities/natural-earth-countries.entity.ts
@@ -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;
 }
diff --git a/server/src/entities/partner-audit.entity.ts b/server/src/entities/partner-audit.entity.ts
deleted file mode 100644
index a731e017dc..0000000000
--- a/server/src/entities/partner-audit.entity.ts
+++ /dev/null
@@ -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;
-}
diff --git a/server/src/entities/partner.entity.ts b/server/src/entities/partner.entity.ts
deleted file mode 100644
index 5326757736..0000000000
--- a/server/src/entities/partner.entity.ts
+++ /dev/null
@@ -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;
-}
diff --git a/server/src/entities/person.entity.ts b/server/src/entities/person.entity.ts
index 5efa602cc8..6ea97b21bc 100644
--- a/server/src/entities/person.entity.ts
+++ b/server/src/entities/person.entity.ts
@@ -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;
 }
diff --git a/server/src/entities/session.entity.ts b/server/src/entities/session.entity.ts
index cb208c958e..45856ff2af 100644
--- a/server/src/entities/session.entity.ts
+++ b/server/src/entities/session.entity.ts
@@ -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;
 }
 
diff --git a/server/src/entities/shared-link.entity.ts b/server/src/entities/shared-link.entity.ts
index 1fed44b301..5ce0247be7 100644
--- a/server/src/entities/shared-link.entity.ts
+++ b/server/src/entities/shared-link.entity.ts
@@ -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;
 }
diff --git a/server/src/entities/smart-search.entity.ts b/server/src/entities/smart-search.entity.ts
index 42245a17fb..e8a8f27cb1 100644
--- a/server/src/entities/smart-search.entity.ts
+++ b/server/src/entities/smart-search.entity.ts
@@ -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;
 }
diff --git a/server/src/entities/stack.entity.ts b/server/src/entities/stack.entity.ts
index 883f5cf246..8b8fd94f38 100644
--- a/server/src/entities/stack.entity.ts
+++ b/server/src/entities/stack.entity.ts
@@ -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;
 }
diff --git a/server/src/entities/sync-checkpoint.entity.ts b/server/src/entities/sync-checkpoint.entity.ts
deleted file mode 100644
index 7c6818aba0..0000000000
--- a/server/src/entities/sync-checkpoint.entity.ts
+++ /dev/null
@@ -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;
-}
diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts
deleted file mode 100644
index b024862ba5..0000000000
--- a/server/src/entities/system-metadata.entity.ts
+++ /dev/null
@@ -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;
-}
diff --git a/server/src/entities/tag.entity.ts b/server/src/entities/tag.entity.ts
index fcbde6c779..01235085a4 100644
--- a/server/src/entities/tag.entity.ts
+++ b/server/src/entities/tag.entity.ts
@@ -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[];
 }
diff --git a/server/src/entities/user-audit.entity.ts b/server/src/entities/user-audit.entity.ts
deleted file mode 100644
index c29bc94d97..0000000000
--- a/server/src/entities/user-audit.entity.ts
+++ /dev/null
@@ -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;
-}
diff --git a/server/src/entities/user-metadata.entity.ts b/server/src/entities/user-metadata.entity.ts
index 8c7a13ed0d..065f4deac3 100644
--- a/server/src/entities/user-metadata.entity.ts
+++ b/server/src/entities/user-metadata.entity.ts
@@ -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];
 }
 
diff --git a/server/src/entities/user.entity.ts b/server/src/entities/user.entity.ts
index 5758e29098..5035f96274 100644
--- a/server/src/entities/user.entity.ts
+++ b/server/src/entities/user.entity.ts
@@ -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;
 }
 
diff --git a/server/src/entities/version-history.entity.ts b/server/src/entities/version-history.entity.ts
deleted file mode 100644
index edccd9aed6..0000000000
--- a/server/src/entities/version-history.entity.ts
+++ /dev/null
@@ -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;
-}
diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts
index 2d5f2bc2e2..8cbb87b0c5 100644
--- a/server/src/repositories/config.repository.ts
+++ b/server/src/repositories/config.repository.ts
@@ -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
diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts
index d5855d3b91..01b45bd94b 100644
--- a/server/src/repositories/person.repository.ts
+++ b/server/src/repositories/person.repository.ts
@@ -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
diff --git a/server/src/repositories/system-metadata.repository.ts b/server/src/repositories/system-metadata.repository.ts
index a110b9bc44..2038f204f7 100644
--- a/server/src/repositories/system-metadata.repository.ts
+++ b/server/src/repositories/system-metadata.repository.ts
@@ -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>;
 
diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts
index 055df9dfdc..758f99eec1 100644
--- a/server/src/repositories/user.repository.ts
+++ b/server/src/repositories/user.repository.ts
@@ -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)
diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts
index f8c995c007..efdff0e480 100644
--- a/server/src/services/base.service.ts
+++ b/server/src/services/base.service.ts
@@ -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);
     }
diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts
index 8ad3c27b4d..3d3d10540b 100644
--- a/server/src/services/memory.service.ts
+++ b/server/src/services/memory.service.ts
@@ -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;
diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts
index e297910a95..c6c3ce4e4f 100644
--- a/server/src/services/person.service.ts
+++ b/server/src/services/person.service.ts
@@ -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;
diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts
index ca1d9e7921..99d89df099 100644
--- a/server/src/services/storage.service.ts
+++ b/server/src/services/storage.service.ts
@@ -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.`;
diff --git a/server/src/services/version.service.ts b/server/src/services/version.service.ts
index ee28a20d4d..869acc269c 100644
--- a/server/src/services/version.service.ts
+++ b/server/src/services/version.service.ts
@@ -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 {
diff --git a/server/src/sql-tools/decorators.ts b/server/src/sql-tools/decorators.ts
new file mode 100644
index 0000000000..88b3e4c7d1
--- /dev/null
+++ b/server/src/sql-tools/decorators.ts
@@ -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;
+};
diff --git a/server/src/sql-tools/index.ts b/server/src/sql-tools/index.ts
new file mode 100644
index 0000000000..0d3e53df51
--- /dev/null
+++ b/server/src/sql-tools/index.ts
@@ -0,0 +1 @@
+export * from 'src/sql-tools/public_api';
diff --git a/server/src/sql-tools/public_api.ts b/server/src/sql-tools/public_api.ts
new file mode 100644
index 0000000000..8b5a36e6a5
--- /dev/null
+++ b/server/src/sql-tools/public_api.ts
@@ -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';
diff --git a/server/src/sql-tools/schema-diff-to-sql.spec.ts b/server/src/sql-tools/schema-diff-to-sql.spec.ts
new file mode 100644
index 0000000000..c44d87e6bd
--- /dev/null
+++ b/server/src/sql-tools/schema-diff-to-sql.spec.ts
@@ -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`]);
+    });
+  });
+});
diff --git a/server/src/sql-tools/schema-diff-to-sql.ts b/server/src/sql-tools/schema-diff-to-sql.ts
new file mode 100644
index 0000000000..0a537c600b
--- /dev/null
+++ b/server/src/sql-tools/schema-diff-to-sql.ts
@@ -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}";`;
+};
diff --git a/server/src/sql-tools/schema-diff.spec.ts b/server/src/sql-tools/schema-diff.spec.ts
new file mode 100644
index 0000000000..2f536cfabd
--- /dev/null
+++ b/server/src/sql-tools/schema-diff.spec.ts
@@ -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)',
+        },
+      ]);
+    });
+  });
+});
diff --git a/server/src/sql-tools/schema-diff.ts b/server/src/sql-tools/schema-diff.ts
new file mode 100644
index 0000000000..ca7f35a45f
--- /dev/null
+++ b/server/src/sql-tools/schema-diff.ts
@@ -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 },
+  ];
+};
diff --git a/server/src/sql-tools/schema-from-database.ts b/server/src/sql-tools/schema-from-database.ts
new file mode 100644
index 0000000000..fe7af6b623
--- /dev/null
+++ b/server/src/sql-tools/schema-from-database.ts
@@ -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();
+};
diff --git a/server/src/sql-tools/schema-from-decorators.spec.ts b/server/src/sql-tools/schema-from-decorators.spec.ts
new file mode 100644
index 0000000000..6703277844
--- /dev/null
+++ b/server/src/sql-tools/schema-from-decorators.spec.ts
@@ -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);
+      });
+    }
+  });
+});
diff --git a/server/src/sql-tools/schema-from-decorators.ts b/server/src/sql-tools/schema-from-decorators.ts
new file mode 100644
index 0000000000..b11817678e
--- /dev/null
+++ b/server/src/sql-tools/schema-from-decorators.ts
@@ -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,
+    });
+  }
+};
diff --git a/server/src/sql-tools/types.ts b/server/src/sql-tools/types.ts
new file mode 100644
index 0000000000..64813ca348
--- /dev/null
+++ b/server/src/sql-tools/types.ts
@@ -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;
+};
diff --git a/server/src/subscribers/audit.subscriber.ts b/server/src/subscribers/audit.subscriber.ts
deleted file mode 100644
index 8c2ad3e18d..0000000000
--- a/server/src/subscribers/audit.subscriber.ts
+++ /dev/null
@@ -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;
-  }
-}
diff --git a/server/src/tables/activity.table.ts b/server/src/tables/activity.table.ts
new file mode 100644
index 0000000000..d7bc7a7bc0
--- /dev/null
+++ b/server/src/tables/activity.table.ts
@@ -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;
+}
diff --git a/server/src/tables/album-asset.table.ts b/server/src/tables/album-asset.table.ts
new file mode 100644
index 0000000000..7c51ee9ac2
--- /dev/null
+++ b/server/src/tables/album-asset.table.ts
@@ -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;
+}
diff --git a/server/src/tables/album-user.table.ts b/server/src/tables/album-user.table.ts
new file mode 100644
index 0000000000..3f9df51723
--- /dev/null
+++ b/server/src/tables/album-user.table.ts
@@ -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;
+}
diff --git a/server/src/tables/album.table.ts b/server/src/tables/album.table.ts
new file mode 100644
index 0000000000..4f2f7d88f9
--- /dev/null
+++ b/server/src/tables/album.table.ts
@@ -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;
+}
diff --git a/server/src/tables/api-key.table.ts b/server/src/tables/api-key.table.ts
new file mode 100644
index 0000000000..dd4100e86f
--- /dev/null
+++ b/server/src/tables/api-key.table.ts
@@ -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;
+}
diff --git a/server/src/tables/asset-audit.table.ts b/server/src/tables/asset-audit.table.ts
new file mode 100644
index 0000000000..10f7b535bc
--- /dev/null
+++ b/server/src/tables/asset-audit.table.ts
@@ -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;
+}
diff --git a/server/src/tables/asset-face.table.ts b/server/src/tables/asset-face.table.ts
new file mode 100644
index 0000000000..623df937af
--- /dev/null
+++ b/server/src/tables/asset-face.table.ts
@@ -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;
+}
diff --git a/server/src/tables/asset-files.table.ts b/server/src/tables/asset-files.table.ts
new file mode 100644
index 0000000000..fb32070751
--- /dev/null
+++ b/server/src/tables/asset-files.table.ts
@@ -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;
+}
diff --git a/server/src/tables/asset-job-status.table.ts b/server/src/tables/asset-job-status.table.ts
new file mode 100644
index 0000000000..d996577ae4
--- /dev/null
+++ b/server/src/tables/asset-job-status.table.ts
@@ -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;
+}
diff --git a/server/src/tables/asset.table.ts b/server/src/tables/asset.table.ts
new file mode 100644
index 0000000000..7e857b8423
--- /dev/null
+++ b/server/src/tables/asset.table.ts
@@ -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;
+}
diff --git a/server/src/tables/audit.table.ts b/server/src/tables/audit.table.ts
new file mode 100644
index 0000000000..a05b070ba7
--- /dev/null
+++ b/server/src/tables/audit.table.ts
@@ -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;
+}
diff --git a/server/src/tables/exif.table.ts b/server/src/tables/exif.table.ts
new file mode 100644
index 0000000000..e06659d811
--- /dev/null
+++ b/server/src/tables/exif.table.ts
@@ -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;
+}
diff --git a/server/src/tables/face-search.table.ts b/server/src/tables/face-search.table.ts
new file mode 100644
index 0000000000..286d09c677
--- /dev/null
+++ b/server/src/tables/face-search.table.ts
@@ -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;
+}
diff --git a/server/src/tables/geodata-places.table.ts b/server/src/tables/geodata-places.table.ts
new file mode 100644
index 0000000000..5216a295cb
--- /dev/null
+++ b/server/src/tables/geodata-places.table.ts
@@ -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;
+}
diff --git a/server/src/tables/index.ts b/server/src/tables/index.ts
new file mode 100644
index 0000000000..8b92b55187
--- /dev/null
+++ b/server/src/tables/index.ts
@@ -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,
+];
diff --git a/server/src/tables/library.table.ts b/server/src/tables/library.table.ts
new file mode 100644
index 0000000000..9119c517ea
--- /dev/null
+++ b/server/src/tables/library.table.ts
@@ -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;
+}
diff --git a/server/src/tables/memory.table.ts b/server/src/tables/memory.table.ts
new file mode 100644
index 0000000000..9523e72610
--- /dev/null
+++ b/server/src/tables/memory.table.ts
@@ -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;
+}
diff --git a/server/src/tables/memory_asset.table.ts b/server/src/tables/memory_asset.table.ts
new file mode 100644
index 0000000000..543c81c597
--- /dev/null
+++ b/server/src/tables/memory_asset.table.ts
@@ -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;
+}
diff --git a/server/src/tables/move.table.ts b/server/src/tables/move.table.ts
new file mode 100644
index 0000000000..cdc00efcaf
--- /dev/null
+++ b/server/src/tables/move.table.ts
@@ -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;
+}
diff --git a/server/src/tables/natural-earth-countries.table.ts b/server/src/tables/natural-earth-countries.table.ts
new file mode 100644
index 0000000000..5ac5384afc
--- /dev/null
+++ b/server/src/tables/natural-earth-countries.table.ts
@@ -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;
+}
diff --git a/server/src/tables/partner-audit.table.ts b/server/src/tables/partner-audit.table.ts
new file mode 100644
index 0000000000..77d9f976b1
--- /dev/null
+++ b/server/src/tables/partner-audit.table.ts
@@ -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;
+}
diff --git a/server/src/tables/partner.table.ts b/server/src/tables/partner.table.ts
new file mode 100644
index 0000000000..900f5fa834
--- /dev/null
+++ b/server/src/tables/partner.table.ts
@@ -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;
+}
diff --git a/server/src/tables/person.table.ts b/server/src/tables/person.table.ts
new file mode 100644
index 0000000000..206e91e68c
--- /dev/null
+++ b/server/src/tables/person.table.ts
@@ -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;
+}
diff --git a/server/src/tables/session.table.ts b/server/src/tables/session.table.ts
new file mode 100644
index 0000000000..4b6afef099
--- /dev/null
+++ b/server/src/tables/session.table.ts
@@ -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;
+}
diff --git a/server/src/tables/shared-link-asset.table.ts b/server/src/tables/shared-link-asset.table.ts
new file mode 100644
index 0000000000..da6526dfc8
--- /dev/null
+++ b/server/src/tables/shared-link-asset.table.ts
@@ -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;
+}
diff --git a/server/src/tables/shared-link.table.ts b/server/src/tables/shared-link.table.ts
new file mode 100644
index 0000000000..3a41f5a8f5
--- /dev/null
+++ b/server/src/tables/shared-link.table.ts
@@ -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;
+}
diff --git a/server/src/tables/smart-search.table.ts b/server/src/tables/smart-search.table.ts
new file mode 100644
index 0000000000..8647756550
--- /dev/null
+++ b/server/src/tables/smart-search.table.ts
@@ -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;
+}
diff --git a/server/src/tables/stack.table.ts b/server/src/tables/stack.table.ts
new file mode 100644
index 0000000000..fc711233a4
--- /dev/null
+++ b/server/src/tables/stack.table.ts
@@ -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;
+}
diff --git a/server/src/tables/sync-checkpoint.table.ts b/server/src/tables/sync-checkpoint.table.ts
new file mode 100644
index 0000000000..3fbffccb6c
--- /dev/null
+++ b/server/src/tables/sync-checkpoint.table.ts
@@ -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;
+}
diff --git a/server/src/tables/system-metadata.table.ts b/server/src/tables/system-metadata.table.ts
new file mode 100644
index 0000000000..8657768db6
--- /dev/null
+++ b/server/src/tables/system-metadata.table.ts
@@ -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];
+}
diff --git a/server/src/tables/tag-asset.table.ts b/server/src/tables/tag-asset.table.ts
new file mode 100644
index 0000000000..6080c432b5
--- /dev/null
+++ b/server/src/tables/tag-asset.table.ts
@@ -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;
+}
diff --git a/server/src/tables/tag-closure.table.ts b/server/src/tables/tag-closure.table.ts
new file mode 100644
index 0000000000..a661904741
--- /dev/null
+++ b/server/src/tables/tag-closure.table.ts
@@ -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;
+}
diff --git a/server/src/tables/tag.table.ts b/server/src/tables/tag.table.ts
new file mode 100644
index 0000000000..5b74075647
--- /dev/null
+++ b/server/src/tables/tag.table.ts
@@ -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;
+}
diff --git a/server/src/tables/user-audit.table.ts b/server/src/tables/user-audit.table.ts
new file mode 100644
index 0000000000..e3f117381c
--- /dev/null
+++ b/server/src/tables/user-audit.table.ts
@@ -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;
+}
diff --git a/server/src/tables/user-metadata.table.ts b/server/src/tables/user-metadata.table.ts
new file mode 100644
index 0000000000..2f83287b6c
--- /dev/null
+++ b/server/src/tables/user-metadata.table.ts
@@ -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];
+}
diff --git a/server/src/tables/user.table.ts b/server/src/tables/user.table.ts
new file mode 100644
index 0000000000..5bd9cd94c6
--- /dev/null
+++ b/server/src/tables/user.table.ts
@@ -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>;
+}
diff --git a/server/src/tables/version-history.table.ts b/server/src/tables/version-history.table.ts
new file mode 100644
index 0000000000..18805a2de3
--- /dev/null
+++ b/server/src/tables/version-history.table.ts
@@ -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;
+}
diff --git a/server/src/types.ts b/server/src/types.ts
index 1c0a61b259..6a3860830c 100644
--- a/server/src/types.ts
+++ b/server/src/types.ts
@@ -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;
+}
diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts
index 456165063c..8e07f388a0 100644
--- a/server/src/utils/database.ts
+++ b/server/src/utils/database.ts
@@ -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;
diff --git a/server/src/utils/logger.ts b/server/src/utils/logger.ts
index f2f47e0471..ecc8847043 100644
--- a/server/src/utils/logger.ts
+++ b/server/src/utils/logger.ts
@@ -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;
diff --git a/server/test/factory.ts b/server/test/factory.ts
index 69160aa8a4..0becc705bc 100644
--- a/server/test/factory.ts
+++ b/server/test/factory.ts
@@ -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,
diff --git a/server/test/fixtures/audit.stub.ts b/server/test/fixtures/audit.stub.ts
deleted file mode 100644
index 24f78a17ce..0000000000
--- a/server/test/fixtures/audit.stub.ts
+++ /dev/null
@@ -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(),
-  }),
-};
diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts
index 9153cfa8f2..0ed1502fb9 100644
--- a/server/test/fixtures/user.stub.ts
+++ b/server/test/fixtures/user.stub.ts
@@ -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,
diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts
index 5b228d3afb..0f6d059b6a 100644
--- a/server/test/small.factory.ts
+++ b/server/test/small.factory.ts
@@ -1,9 +1,8 @@
 import { randomUUID } from 'node:crypto';
 import { ApiKey, Asset, AuthApiKey, AuthUser, Library, Partner, User, UserAdmin } from 'src/database';
 import { AuthDto } from 'src/dtos/auth.dto';
-import { OnThisDayData } from 'src/entities/memory.entity';
 import { AssetStatus, AssetType, MemoryType, Permission, UserStatus } from 'src/enum';
-import { ActivityItem, MemoryItem } from 'src/types';
+import { ActivityItem, MemoryItem, OnThisDayData } from 'src/types';
 
 export const newUuid = () => randomUUID() as string;
 export const newUuids = () =>
diff --git a/server/test/sql-tools/check-constraint-default-name.stub.ts b/server/test/sql-tools/check-constraint-default-name.stub.ts
new file mode 100644
index 0000000000..42ee336b94
--- /dev/null
+++ b/server/test/sql-tools/check-constraint-default-name.stub.ts
@@ -0,0 +1,41 @@
+import { Check, Column, DatabaseConstraintType, DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+@Check({ expression: '1=1' })
+export class Table1 {
+  @Column({ type: 'uuid' })
+  id!: string;
+}
+
+export const description = 'should create a check constraint with a default name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'id',
+          tableName: 'table1',
+          type: 'uuid',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [
+        {
+          type: DatabaseConstraintType.CHECK,
+          name: 'CHK_8d2ecfd49b984941f6b2589799',
+          tableName: 'table1',
+          expression: '1=1',
+          synchronize: true,
+        },
+      ],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/check-constraint-override-name.stub.ts b/server/test/sql-tools/check-constraint-override-name.stub.ts
new file mode 100644
index 0000000000..89db6044a2
--- /dev/null
+++ b/server/test/sql-tools/check-constraint-override-name.stub.ts
@@ -0,0 +1,41 @@
+import { Check, Column, DatabaseConstraintType, DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+@Check({ name: 'CHK_test', expression: '1=1' })
+export class Table1 {
+  @Column({ type: 'uuid' })
+  id!: string;
+}
+
+export const description = 'should create a check constraint with a specific name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'id',
+          tableName: 'table1',
+          type: 'uuid',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [
+        {
+          type: DatabaseConstraintType.CHECK,
+          name: 'CHK_test',
+          tableName: 'table1',
+          expression: '1=1',
+          synchronize: true,
+        },
+      ],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/column-default-boolean.stub.ts b/server/test/sql-tools/column-default-boolean.stub.ts
new file mode 100644
index 0000000000..464a34b26e
--- /dev/null
+++ b/server/test/sql-tools/column-default-boolean.stub.ts
@@ -0,0 +1,33 @@
+import { Column, DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @Column({ type: 'boolean', default: true })
+  column1!: boolean;
+}
+
+export const description = 'should register a table with a column with a default value (boolean)';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'column1',
+          tableName: 'table1',
+          type: 'boolean',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+          default: 'true',
+        },
+      ],
+      indexes: [],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/column-default-date.stub.ts b/server/test/sql-tools/column-default-date.stub.ts
new file mode 100644
index 0000000000..72c06b3bd9
--- /dev/null
+++ b/server/test/sql-tools/column-default-date.stub.ts
@@ -0,0 +1,35 @@
+import { Column, DatabaseSchema, Table } from 'src/sql-tools';
+
+const date = new Date(2023, 0, 1);
+
+@Table()
+export class Table1 {
+  @Column({ type: 'character varying', default: date })
+  column1!: string;
+}
+
+export const description = 'should register a table with a column with a default value (date)';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'column1',
+          tableName: 'table1',
+          type: 'character varying',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+          default: "'2023-01-01T00:00:00.000Z'",
+        },
+      ],
+      indexes: [],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/column-default-function.stub.ts b/server/test/sql-tools/column-default-function.stub.ts
new file mode 100644
index 0000000000..ceb03b50f0
--- /dev/null
+++ b/server/test/sql-tools/column-default-function.stub.ts
@@ -0,0 +1,33 @@
+import { Column, DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @Column({ type: 'character varying', default: () => 'now()' })
+  column1!: string;
+}
+
+export const description = 'should register a table with a column with a default function';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'column1',
+          tableName: 'table1',
+          type: 'character varying',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+          default: 'now()',
+        },
+      ],
+      indexes: [],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/column-default-null.stub.ts b/server/test/sql-tools/column-default-null.stub.ts
new file mode 100644
index 0000000000..b4aa83788b
--- /dev/null
+++ b/server/test/sql-tools/column-default-null.stub.ts
@@ -0,0 +1,32 @@
+import { Column, DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @Column({ type: 'character varying', default: null })
+  column1!: string;
+}
+
+export const description = 'should register a nullable column from a default of null';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'column1',
+          tableName: 'table1',
+          type: 'character varying',
+          nullable: true,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/column-default-number.stub.ts b/server/test/sql-tools/column-default-number.stub.ts
new file mode 100644
index 0000000000..f3fac229c7
--- /dev/null
+++ b/server/test/sql-tools/column-default-number.stub.ts
@@ -0,0 +1,33 @@
+import { Column, DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @Column({ type: 'integer', default: 0 })
+  column1!: string;
+}
+
+export const description = 'should register a table with a column with a default value (number)';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'column1',
+          tableName: 'table1',
+          type: 'integer',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+          default: '0',
+        },
+      ],
+      indexes: [],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/column-default-string.stub.ts b/server/test/sql-tools/column-default-string.stub.ts
new file mode 100644
index 0000000000..36aa584eeb
--- /dev/null
+++ b/server/test/sql-tools/column-default-string.stub.ts
@@ -0,0 +1,33 @@
+import { Column, DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @Column({ type: 'character varying', default: 'foo' })
+  column1!: string;
+}
+
+export const description = 'should register a table with a column with a default value (string)';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'column1',
+          tableName: 'table1',
+          type: 'character varying',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+          default: "'foo'",
+        },
+      ],
+      indexes: [],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/column-enum-name.stub.ts b/server/test/sql-tools/column-enum-name.stub.ts
new file mode 100644
index 0000000000..9ae1b4310d
--- /dev/null
+++ b/server/test/sql-tools/column-enum-name.stub.ts
@@ -0,0 +1,39 @@
+import { Column, DatabaseSchema, Table } from 'src/sql-tools';
+
+enum Test {
+  Foo = 'foo',
+  Bar = 'bar',
+}
+
+@Table()
+export class Table1 {
+  @Column({ enum: Test })
+  column1!: string;
+}
+
+export const description = 'should use a default enum naming convention';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'column1',
+          tableName: 'table1',
+          type: 'enum',
+          enumName: 'table1_column1_enum',
+          enumValues: ['foo', 'bar'],
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/column-index-name-default.ts b/server/test/sql-tools/column-index-name-default.ts
new file mode 100644
index 0000000000..d3b5aba112
--- /dev/null
+++ b/server/test/sql-tools/column-index-name-default.ts
@@ -0,0 +1,41 @@
+import { Column, ColumnIndex, DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @ColumnIndex()
+  @Column()
+  column1!: string;
+}
+
+export const description = 'should create a column with an index';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'column1',
+          tableName: 'table1',
+          type: 'character varying',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [
+        {
+          name: 'IDX_50c4f9905061b1e506d38a2a38',
+          columnNames: ['column1'],
+          tableName: 'table1',
+          unique: false,
+          synchronize: true,
+        },
+      ],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/column-inferred-nullable.stub.ts b/server/test/sql-tools/column-inferred-nullable.stub.ts
new file mode 100644
index 0000000000..d866b59093
--- /dev/null
+++ b/server/test/sql-tools/column-inferred-nullable.stub.ts
@@ -0,0 +1,32 @@
+import { Column, DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @Column({ default: null })
+  column1!: string;
+}
+
+export const description = 'should infer nullable from the default value';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'column1',
+          tableName: 'table1',
+          type: 'character varying',
+          nullable: true,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/column-name-default.stub.ts b/server/test/sql-tools/column-name-default.stub.ts
new file mode 100644
index 0000000000..3c6df97fe4
--- /dev/null
+++ b/server/test/sql-tools/column-name-default.stub.ts
@@ -0,0 +1,32 @@
+import { Column, DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @Column()
+  column1!: string;
+}
+
+export const description = 'should register a table with a column with a default name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'column1',
+          tableName: 'table1',
+          type: 'character varying',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/column-name-override.stub.ts b/server/test/sql-tools/column-name-override.stub.ts
new file mode 100644
index 0000000000..b5e86e47d0
--- /dev/null
+++ b/server/test/sql-tools/column-name-override.stub.ts
@@ -0,0 +1,32 @@
+import { Column, DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @Column({ name: 'column-1' })
+  column1!: string;
+}
+
+export const description = 'should register a table with a column with a specific name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'column-1',
+          tableName: 'table1',
+          type: 'character varying',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/column-name-string.stub.ts b/server/test/sql-tools/column-name-string.stub.ts
new file mode 100644
index 0000000000..013e74e7da
--- /dev/null
+++ b/server/test/sql-tools/column-name-string.stub.ts
@@ -0,0 +1,32 @@
+import { Column, DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @Column('column-1')
+  column1!: string;
+}
+
+export const description = 'should register a table with a column with a specific name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'column-1',
+          tableName: 'table1',
+          type: 'character varying',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/column-nullable.stub.ts b/server/test/sql-tools/column-nullable.stub.ts
new file mode 100644
index 0000000000..2704fb7cf6
--- /dev/null
+++ b/server/test/sql-tools/column-nullable.stub.ts
@@ -0,0 +1,32 @@
+import { Column, DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @Column({ nullable: true })
+  column1!: string;
+}
+
+export const description = 'should set nullable correctly';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'column1',
+          tableName: 'table1',
+          type: 'character varying',
+          nullable: true,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/column-unique-constraint-name-default.stub.ts b/server/test/sql-tools/column-unique-constraint-name-default.stub.ts
new file mode 100644
index 0000000000..6446a2069d
--- /dev/null
+++ b/server/test/sql-tools/column-unique-constraint-name-default.stub.ts
@@ -0,0 +1,40 @@
+import { Column, DatabaseConstraintType, DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @Column({ type: 'uuid', unique: true })
+  id!: string;
+}
+
+export const description = 'should create a unique key constraint with a default name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'id',
+          tableName: 'table1',
+          type: 'uuid',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [
+        {
+          type: DatabaseConstraintType.UNIQUE,
+          name: 'UQ_b249cc64cf63b8a22557cdc8537',
+          tableName: 'table1',
+          columnNames: ['id'],
+          synchronize: true,
+        },
+      ],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/column-unique-constraint-name-override.stub.ts b/server/test/sql-tools/column-unique-constraint-name-override.stub.ts
new file mode 100644
index 0000000000..fb96ff06b2
--- /dev/null
+++ b/server/test/sql-tools/column-unique-constraint-name-override.stub.ts
@@ -0,0 +1,40 @@
+import { Column, DatabaseConstraintType, DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @Column({ type: 'uuid', unique: true, uniqueConstraintName: 'UQ_test' })
+  id!: string;
+}
+
+export const description = 'should create a unique key constraint with a specific name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'id',
+          tableName: 'table1',
+          type: 'uuid',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [
+        {
+          type: DatabaseConstraintType.UNIQUE,
+          name: 'UQ_test',
+          tableName: 'table1',
+          columnNames: ['id'],
+          synchronize: true,
+        },
+      ],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/foreign-key-inferred-type.stub.ts b/server/test/sql-tools/foreign-key-inferred-type.stub.ts
new file mode 100644
index 0000000000..b88d834a76
--- /dev/null
+++ b/server/test/sql-tools/foreign-key-inferred-type.stub.ts
@@ -0,0 +1,73 @@
+import { DatabaseConstraintType, DatabaseSchema, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @PrimaryColumn({ type: 'uuid' })
+  id!: string;
+}
+
+@Table()
+export class Table2 {
+  @ForeignKeyColumn(() => Table1, {})
+  parentId!: string;
+}
+
+export const description = 'should infer the column type from the reference column';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'id',
+          tableName: 'table1',
+          type: 'uuid',
+          nullable: false,
+          isArray: false,
+          primary: true,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [
+        {
+          type: DatabaseConstraintType.PRIMARY_KEY,
+          name: 'PK_b249cc64cf63b8a22557cdc8537',
+          tableName: 'table1',
+          columnNames: ['id'],
+          synchronize: true,
+        },
+      ],
+      synchronize: true,
+    },
+    {
+      name: 'table2',
+      columns: [
+        {
+          name: 'parentId',
+          tableName: 'table2',
+          type: 'uuid',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [
+        {
+          type: DatabaseConstraintType.FOREIGN_KEY,
+          name: 'FK_3fcca5cc563abf256fc346e3ff4',
+          tableName: 'table2',
+          columnNames: ['parentId'],
+          referenceColumnNames: ['id'],
+          referenceTableName: 'table1',
+          synchronize: true,
+        },
+      ],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts b/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts
new file mode 100644
index 0000000000..8bf2328fc3
--- /dev/null
+++ b/server/test/sql-tools/foreign-key-with-unique-constraint.stub.ts
@@ -0,0 +1,80 @@
+import { DatabaseConstraintType, DatabaseSchema, ForeignKeyColumn, PrimaryColumn, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @PrimaryColumn({ type: 'uuid' })
+  id!: string;
+}
+
+@Table()
+export class Table2 {
+  @ForeignKeyColumn(() => Table1, { unique: true })
+  parentId!: string;
+}
+
+export const description = 'should create a foreign key constraint with a unique constraint';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'id',
+          tableName: 'table1',
+          type: 'uuid',
+          nullable: false,
+          isArray: false,
+          primary: true,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [
+        {
+          type: DatabaseConstraintType.PRIMARY_KEY,
+          name: 'PK_b249cc64cf63b8a22557cdc8537',
+          tableName: 'table1',
+          columnNames: ['id'],
+          synchronize: true,
+        },
+      ],
+      synchronize: true,
+    },
+    {
+      name: 'table2',
+      columns: [
+        {
+          name: 'parentId',
+          tableName: 'table2',
+          type: 'uuid',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [
+        {
+          type: DatabaseConstraintType.FOREIGN_KEY,
+          name: 'FK_3fcca5cc563abf256fc346e3ff4',
+          tableName: 'table2',
+          columnNames: ['parentId'],
+          referenceColumnNames: ['id'],
+          referenceTableName: 'table1',
+          synchronize: true,
+        },
+        {
+          type: DatabaseConstraintType.UNIQUE,
+          name: 'REL_3fcca5cc563abf256fc346e3ff',
+          tableName: 'table2',
+          columnNames: ['parentId'],
+          synchronize: true,
+        },
+      ],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/index-name-default.stub.ts b/server/test/sql-tools/index-name-default.stub.ts
new file mode 100644
index 0000000000..ffadfb0b32
--- /dev/null
+++ b/server/test/sql-tools/index-name-default.stub.ts
@@ -0,0 +1,41 @@
+import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools';
+
+@Table()
+@Index({ columns: ['id'] })
+export class Table1 {
+  @Column({ type: 'uuid' })
+  id!: string;
+}
+
+export const description = 'should create an index with a default name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'id',
+          tableName: 'table1',
+          type: 'uuid',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [
+        {
+          name: 'IDX_b249cc64cf63b8a22557cdc853',
+          tableName: 'table1',
+          unique: false,
+          columnNames: ['id'],
+          synchronize: true,
+        },
+      ],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/index-name-override.stub.ts b/server/test/sql-tools/index-name-override.stub.ts
new file mode 100644
index 0000000000..f72a0cbeb1
--- /dev/null
+++ b/server/test/sql-tools/index-name-override.stub.ts
@@ -0,0 +1,41 @@
+import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools';
+
+@Table()
+@Index({ name: 'IDX_test', columns: ['id'] })
+export class Table1 {
+  @Column({ type: 'uuid' })
+  id!: string;
+}
+
+export const description = 'should create an index with a specific name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'id',
+          tableName: 'table1',
+          type: 'uuid',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [
+        {
+          name: 'IDX_test',
+          tableName: 'table1',
+          unique: false,
+          columnNames: ['id'],
+          synchronize: true,
+        },
+      ],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/index-with-where.stub copy.ts b/server/test/sql-tools/index-with-where.stub copy.ts
new file mode 100644
index 0000000000..0d22f4e115
--- /dev/null
+++ b/server/test/sql-tools/index-with-where.stub copy.ts	
@@ -0,0 +1,41 @@
+import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools';
+
+@Table()
+@Index({ expression: '"id" IS NOT NULL' })
+export class Table1 {
+  @Column({ nullable: true })
+  column1!: string;
+}
+
+export const description = 'should create an index based off of an expression';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'column1',
+          tableName: 'table1',
+          type: 'character varying',
+          nullable: true,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [
+        {
+          name: 'IDX_376788d186160c4faa5aaaef63',
+          tableName: 'table1',
+          unique: false,
+          expression: '"id" IS NOT NULL',
+          synchronize: true,
+        },
+      ],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/index-with-where.stub.ts b/server/test/sql-tools/index-with-where.stub.ts
new file mode 100644
index 0000000000..e59d2ec36b
--- /dev/null
+++ b/server/test/sql-tools/index-with-where.stub.ts
@@ -0,0 +1,42 @@
+import { Column, DatabaseSchema, Index, Table } from 'src/sql-tools';
+
+@Table()
+@Index({ columns: ['id'], where: '"id" IS NOT NULL' })
+export class Table1 {
+  @Column({ nullable: true })
+  column1!: string;
+}
+
+export const description = 'should create an index with a where clause';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'column1',
+          tableName: 'table1',
+          type: 'character varying',
+          nullable: true,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [
+        {
+          name: 'IDX_9f4e073964c0395f51f9b39900',
+          tableName: 'table1',
+          unique: false,
+          columnNames: ['id'],
+          where: '"id" IS NOT NULL',
+          synchronize: true,
+        },
+      ],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/primary-key-constraint-name-default.stub.ts b/server/test/sql-tools/primary-key-constraint-name-default.stub.ts
new file mode 100644
index 0000000000..d4b426b9f1
--- /dev/null
+++ b/server/test/sql-tools/primary-key-constraint-name-default.stub.ts
@@ -0,0 +1,40 @@
+import { DatabaseConstraintType, DatabaseSchema, PrimaryColumn, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {
+  @PrimaryColumn({ type: 'uuid' })
+  id!: string;
+}
+
+export const description = 'should add a primary key constraint to the table with a default name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'id',
+          tableName: 'table1',
+          type: 'uuid',
+          nullable: false,
+          isArray: false,
+          primary: true,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [
+        {
+          type: DatabaseConstraintType.PRIMARY_KEY,
+          name: 'PK_b249cc64cf63b8a22557cdc8537',
+          tableName: 'table1',
+          columnNames: ['id'],
+          synchronize: true,
+        },
+      ],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/primary-key-constraint-name-override.stub.ts b/server/test/sql-tools/primary-key-constraint-name-override.stub.ts
new file mode 100644
index 0000000000..717d9165b3
--- /dev/null
+++ b/server/test/sql-tools/primary-key-constraint-name-override.stub.ts
@@ -0,0 +1,40 @@
+import { DatabaseConstraintType, DatabaseSchema, PrimaryColumn, Table } from 'src/sql-tools';
+
+@Table({ primaryConstraintName: 'PK_test' })
+export class Table1 {
+  @PrimaryColumn({ type: 'uuid' })
+  id!: string;
+}
+
+export const description = 'should add a primary key constraint to the table with a specific name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'id',
+          tableName: 'table1',
+          type: 'uuid',
+          nullable: false,
+          isArray: false,
+          primary: true,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [
+        {
+          type: DatabaseConstraintType.PRIMARY_KEY,
+          name: 'PK_test',
+          tableName: 'table1',
+          columnNames: ['id'],
+          synchronize: true,
+        },
+      ],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/table-name-default.stub.ts b/server/test/sql-tools/table-name-default.stub.ts
new file mode 100644
index 0000000000..a76a5b6dbb
--- /dev/null
+++ b/server/test/sql-tools/table-name-default.stub.ts
@@ -0,0 +1,19 @@
+import { DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table()
+export class Table1 {}
+
+export const description = 'should register a table with a default name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [],
+      indexes: [],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/table-name-override.stub.ts b/server/test/sql-tools/table-name-override.stub.ts
new file mode 100644
index 0000000000..3290fab6a4
--- /dev/null
+++ b/server/test/sql-tools/table-name-override.stub.ts
@@ -0,0 +1,19 @@
+import { DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table({ name: 'table-1' })
+export class Table1 {}
+
+export const description = 'should register a table with a specific name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table-1',
+      columns: [],
+      indexes: [],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/table-name-string-option.stub.ts b/server/test/sql-tools/table-name-string-option.stub.ts
new file mode 100644
index 0000000000..0c9a045d5b
--- /dev/null
+++ b/server/test/sql-tools/table-name-string-option.stub.ts
@@ -0,0 +1,19 @@
+import { DatabaseSchema, Table } from 'src/sql-tools';
+
+@Table('table-1')
+export class Table1 {}
+
+export const description = 'should register a table with a specific name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table-1',
+      columns: [],
+      indexes: [],
+      constraints: [],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/unique-constraint-name-default.stub.ts b/server/test/sql-tools/unique-constraint-name-default.stub.ts
new file mode 100644
index 0000000000..42fc63bc46
--- /dev/null
+++ b/server/test/sql-tools/unique-constraint-name-default.stub.ts
@@ -0,0 +1,41 @@
+import { Column, DatabaseConstraintType, DatabaseSchema, Table, Unique } from 'src/sql-tools';
+
+@Table()
+@Unique({ columns: ['id'] })
+export class Table1 {
+  @Column({ type: 'uuid' })
+  id!: string;
+}
+
+export const description = 'should add a unique constraint to the table with a default name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'id',
+          tableName: 'table1',
+          type: 'uuid',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [
+        {
+          type: DatabaseConstraintType.UNIQUE,
+          name: 'UQ_b249cc64cf63b8a22557cdc8537',
+          tableName: 'table1',
+          columnNames: ['id'],
+          synchronize: true,
+        },
+      ],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/sql-tools/unique-constraint-name-override.stub.ts b/server/test/sql-tools/unique-constraint-name-override.stub.ts
new file mode 100644
index 0000000000..e7f6fcf83c
--- /dev/null
+++ b/server/test/sql-tools/unique-constraint-name-override.stub.ts
@@ -0,0 +1,41 @@
+import { Column, DatabaseConstraintType, DatabaseSchema, Table, Unique } from 'src/sql-tools';
+
+@Table()
+@Unique({ name: 'UQ_test', columns: ['id'] })
+export class Table1 {
+  @Column({ type: 'uuid' })
+  id!: string;
+}
+
+export const description = 'should add a unique constraint to the table with a specific name';
+export const schema: DatabaseSchema = {
+  name: 'public',
+  tables: [
+    {
+      name: 'table1',
+      columns: [
+        {
+          name: 'id',
+          tableName: 'table1',
+          type: 'uuid',
+          nullable: false,
+          isArray: false,
+          primary: false,
+          synchronize: true,
+        },
+      ],
+      indexes: [],
+      constraints: [
+        {
+          type: DatabaseConstraintType.UNIQUE,
+          name: 'UQ_test',
+          tableName: 'table1',
+          columnNames: ['id'],
+          synchronize: true,
+        },
+      ],
+      synchronize: true,
+    },
+  ],
+  warnings: [],
+};
diff --git a/server/test/vitest.config.mjs b/server/test/vitest.config.mjs
index 071e4886f2..d3d1c98f5d 100644
--- a/server/test/vitest.config.mjs
+++ b/server/test/vitest.config.mjs
@@ -12,12 +12,13 @@ export default defineConfig({
     include: ['src/**/*.spec.ts'],
     coverage: {
       provider: 'v8',
-      include: ['src/cores/**', 'src/interfaces/**', 'src/services/**', 'src/utils/**'],
+      include: ['src/cores/**', 'src/interfaces/**', 'src/services/**', 'src/utils/**', 'src/sql-tools/**'],
       exclude: [
         'src/services/*.spec.ts',
         'src/services/api.service.ts',
         'src/services/microservices.service.ts',
         'src/services/index.ts',
+        'src/sql-tools/schema-from-database.ts',
       ],
       thresholds: {
         lines: 85,