feat: sync stacks (#19629)

This commit is contained in:
Jason Rasmussen 2025-06-30 15:26:41 -04:00 committed by GitHub
parent 095ace8687
commit 181a7e115f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 399 additions and 62 deletions

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -13820,6 +13820,8 @@
"MemoryDeleteV1",
"MemoryToAssetV1",
"MemoryToAssetDeleteV1",
"StackV1",
"StackDeleteV1",
"SyncAckV1"
],
"type": "string"
@ -13983,10 +13985,51 @@
"AlbumAssetsV1",
"AlbumAssetExifsV1",
"MemoriesV1",
"MemoryToAssetsV1"
"MemoryToAssetsV1",
"StacksV1"
],
"type": "string"
},
"SyncStackDeleteV1": {
"properties": {
"stackId": {
"type": "string"
}
},
"required": [
"stackId"
],
"type": "object"
},
"SyncStackV1": {
"properties": {
"createdAt": {
"format": "date-time",
"type": "string"
},
"id": {
"type": "string"
},
"ownerId": {
"type": "string"
},
"primaryAssetId": {
"type": "string"
},
"updatedAt": {
"format": "date-time",
"type": "string"
}
},
"required": [
"createdAt",
"id",
"ownerId",
"primaryAssetId",
"updatedAt"
],
"type": "object"
},
"SyncStreamDto": {
"properties": {
"types": {

View file

@ -4089,6 +4089,8 @@ export enum SyncEntityType {
MemoryDeleteV1 = "MemoryDeleteV1",
MemoryToAssetV1 = "MemoryToAssetV1",
MemoryToAssetDeleteV1 = "MemoryToAssetDeleteV1",
StackV1 = "StackV1",
StackDeleteV1 = "StackDeleteV1",
SyncAckV1 = "SyncAckV1"
}
export enum SyncRequestType {
@ -4104,7 +4106,8 @@ export enum SyncRequestType {
AlbumAssetsV1 = "AlbumAssetsV1",
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
MemoriesV1 = "MemoriesV1",
MemoryToAssetsV1 = "MemoryToAssetsV1"
MemoryToAssetsV1 = "MemoryToAssetsV1",
StacksV1 = "StacksV1"
}
export enum TranscodeHWAccel {
Nvenc = "nvenc",

View file

@ -219,6 +219,20 @@ export class SyncMemoryAssetDeleteV1 {
assetId!: string;
}
@ExtraModel()
export class SyncStackV1 {
id!: string;
createdAt!: Date;
updatedAt!: Date;
primaryAssetId!: string;
ownerId!: string;
}
@ExtraModel()
export class SyncStackDeleteV1 {
stackId!: string;
}
@ExtraModel()
export class SyncAckV1 {}
@ -251,6 +265,8 @@ export type SyncItem = {
[SyncEntityType.MemoryDeleteV1]: SyncMemoryDeleteV1;
[SyncEntityType.MemoryToAssetV1]: SyncMemoryAssetV1;
[SyncEntityType.MemoryToAssetDeleteV1]: SyncMemoryAssetDeleteV1;
[SyncEntityType.StackV1]: SyncStackV1;
[SyncEntityType.StackDeleteV1]: SyncStackDeleteV1;
[SyncEntityType.SyncAckV1]: SyncAckV1;
};

View file

@ -586,6 +586,7 @@ export enum SyncRequestType {
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
MemoriesV1 = 'MemoriesV1',
MemoryToAssetsV1 = 'MemoryToAssetsV1',
StacksV1 = 'StacksV1',
}
export enum SyncEntityType {
@ -627,6 +628,9 @@ export enum SyncEntityType {
MemoryToAssetV1 = 'MemoryToAssetV1',
MemoryToAssetDeleteV1 = 'MemoryToAssetDeleteV1',
StackV1 = 'StackV1',
StackDeleteV1 = 'StackDeleteV1',
SyncAckV1 = 'SyncAckV1',
}

View file

@ -689,6 +689,34 @@ where
order by
"updateId" asc
-- SyncRepository.stack.getDeletes
select
"id",
"stackId"
from
"stacks_audit"
where
"userId" = $1
and "deletedAt" < now() - interval '1 millisecond'
order by
"id" asc
-- SyncRepository.stack.getUpserts
select
"id",
"createdAt",
"updatedAt",
"primaryAssetId",
"ownerId",
"updateId"
from
"asset_stack"
where
"ownerId" = $1
and "updatedAt" < now() - interval '1 millisecond'
order by
"updateId" asc
-- SyncRepository.user.getDeletes
select
"id",

View file

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Kysely, Updateable } from 'kysely';
import { ExpressionBuilder, Insertable, Kysely, Updateable } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
@ -55,12 +55,12 @@ export class StackRepository {
.execute();
}
async create(entity: { ownerId: string; assetIds: string[] }) {
async create(entity: Omit<Insertable<StackTable>, 'primaryAssetId'>, assetIds: string[]) {
return this.db.transaction().execute(async (tx) => {
const stacks = await tx
.selectFrom('asset_stack')
.where('asset_stack.ownerId', '=', entity.ownerId)
.where('asset_stack.primaryAssetId', 'in', entity.assetIds)
.where('asset_stack.primaryAssetId', 'in', assetIds)
.select('asset_stack.id')
.select((eb) =>
jsonArrayFrom(
@ -73,13 +73,13 @@ export class StackRepository {
)
.execute();
const assetIds = new Set<string>(entity.assetIds);
const uniqueIds = new Set<string>(assetIds);
// children
for (const stack of stacks) {
if (stack.assets && stack.assets.length > 0) {
for (const asset of stack.assets) {
assetIds.add(asset.id);
uniqueIds.add(asset.id);
}
}
}
@ -97,10 +97,7 @@ export class StackRepository {
const newRecord = await tx
.insertInto('asset_stack')
.values({
ownerId: entity.ownerId,
primaryAssetId: entity.assetIds[0],
})
.values({ ...entity, primaryAssetId: assetIds[0] })
.returning('id')
.executeTakeFirstOrThrow();
@ -110,7 +107,7 @@ export class StackRepository {
stackId: newRecord.id,
updatedAt: new Date(),
})
.where('id', 'in', [...assetIds])
.where('id', 'in', [...uniqueIds])
.execute();
return tx

View file

@ -14,7 +14,8 @@ type AuditTables =
| 'album_users_audit'
| 'album_assets_audit'
| 'memories_audit'
| 'memory_assets_audit';
| 'memory_assets_audit'
| 'stacks_audit';
type UpsertTables =
| 'users'
| 'partners'
@ -23,7 +24,8 @@ type UpsertTables =
| 'albums'
| 'albums_shared_users_users'
| 'memories'
| 'memories_assets_assets';
| 'memories_assets_assets'
| 'asset_stack';
@Injectable()
export class SyncRepository {
@ -39,6 +41,7 @@ export class SyncRepository {
partner: PartnerSync;
partnerAsset: PartnerAssetsSync;
partnerAssetExif: PartnerAssetExifsSync;
stack: StackSync;
user: UserSync;
constructor(@InjectKysely() private db: Kysely<DB>) {
@ -54,6 +57,7 @@ export class SyncRepository {
this.partner = new PartnerSync(this.db);
this.partnerAsset = new PartnerAssetsSync(this.db);
this.partnerAssetExif = new PartnerAssetExifsSync(this.db);
this.stack = new StackSync(this.db);
this.user = new UserSync(this.db);
}
}
@ -533,6 +537,28 @@ class PartnerAssetExifsSync extends BaseSync {
}
}
class StackSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('stacks_audit')
.select(['id', 'stackId'])
.where('userId', '=', userId)
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('asset_stack')
.select(['id', 'createdAt', 'updatedAt', 'primaryAssetId', 'ownerId', 'updateId'])
.where('ownerId', '=', userId)
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
}
class UserSync extends BaseSync {
@GenerateSql({ params: [], stream: true })
getDeletes(ack?: SyncAck) {

View file

@ -204,3 +204,17 @@ export const memory_assets_delete_audit = registerFunction({
END`,
synchronize: false,
});
export const stacks_delete_audit = registerFunction({
name: 'stacks_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO stacks_audit ("stackId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END`,
synchronize: false,
});

View file

@ -11,6 +11,7 @@ import {
memories_delete_audit,
memory_assets_delete_audit,
partners_delete_audit,
stacks_delete_audit,
updated_at,
users_delete_audit,
} from 'src/schema/functions';
@ -46,6 +47,7 @@ import { SessionTable } from 'src/schema/tables/session.table';
import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { SmartSearchTable } from 'src/schema/tables/smart-search.table';
import { StackAuditTable } from 'src/schema/tables/stack-audit.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { SessionSyncCheckpointTable } from 'src/schema/tables/sync-checkpoint.table';
import { SystemMetadataTable } from 'src/schema/tables/system-metadata.table';
@ -95,6 +97,7 @@ export class ImmichDatabase {
SharedLinkTable,
SmartSearchTable,
StackTable,
StackAuditTable,
SessionSyncCheckpointTable,
SystemMetadataTable,
TagTable,
@ -120,6 +123,7 @@ export class ImmichDatabase {
album_users_delete_audit,
memories_delete_audit,
memory_assets_delete_audit,
stacks_delete_audit,
];
enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum];
@ -167,6 +171,7 @@ export interface DB {
shared_link__asset: SharedLinkAssetTable;
shared_links: SharedLinkTable;
smart_search: SmartSearchTable;
stacks_audit: StackAuditTable;
system_metadata: SystemMetadataTable;
tag_asset: TagAssetTable;
tags: TagTable;

View file

@ -0,0 +1,43 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "stacks_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "stackId" uuid NOT NULL, "userId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db);
await sql`ALTER TABLE "asset_stack" ADD "createdAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db);
await sql`ALTER TABLE "asset_stack" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db);
await sql`ALTER TABLE "asset_stack" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
await sql`ALTER TABLE "stacks_audit" ADD CONSTRAINT "PK_dbe4ec648fa032e8973297de07e" PRIMARY KEY ("id");`.execute(db);
await sql`CREATE INDEX "IDX_stacks_audit_deleted_at" ON "stacks_audit" ("deletedAt")`.execute(db);
await sql`CREATE OR REPLACE FUNCTION stacks_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO stacks_audit ("stackId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "stacks_delete_audit"
AFTER DELETE ON "asset_stack"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() = 0)
EXECUTE FUNCTION stacks_delete_audit();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "stacks_updated_at"
BEFORE UPDATE ON "asset_stack"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "stacks_delete_audit" ON "asset_stack";`.execute(db);
await sql`DROP TRIGGER "stacks_updated_at" ON "asset_stack";`.execute(db);
await sql`DROP INDEX "IDX_stacks_audit_deleted_at";`.execute(db);
await sql`ALTER TABLE "stacks_audit" DROP CONSTRAINT "PK_dbe4ec648fa032e8973297de07e";`.execute(db);
await sql`ALTER TABLE "asset_stack" DROP COLUMN "createdAt";`.execute(db);
await sql`ALTER TABLE "asset_stack" DROP COLUMN "updatedAt";`.execute(db);
await sql`ALTER TABLE "asset_stack" DROP COLUMN "updateId";`.execute(db);
await sql`DROP TABLE "stacks_audit";`.execute(db);
await sql`DROP FUNCTION stacks_delete_audit;`.execute(db);
}

View file

@ -15,7 +15,6 @@ import {
@Table({ name: 'albums_assets_assets', primaryConstraintName: 'PK_c67bc36fa845fb7b18e0e398180' })
@UpdatedAtTrigger('album_assets_updated_at')
@AfterDeleteTrigger({
name: 'album_assets_delete_audit',
scope: 'statement',
function: album_assets_delete_audit,
referencingOldTableAs: 'old',

View file

@ -28,7 +28,6 @@ import {
function: album_user_after_insert,
})
@AfterDeleteTrigger({
name: 'album_users_delete_audit',
scope: 'statement',
function: album_users_delete_audit,
referencingOldTableAs: 'old',

View file

@ -19,7 +19,6 @@ import {
@Table({ name: 'albums', primaryConstraintName: 'PK_7f71c7b5bc7c87b8f94c9a93a00' })
@UpdatedAtTrigger('albums_updated_at')
@AfterDeleteTrigger({
name: 'albums_delete_audit',
scope: 'statement',
function: albums_delete_audit,
referencingOldTableAs: 'old',

View file

@ -23,7 +23,6 @@ import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
@Table('assets')
@UpdatedAtTrigger('assets_updated_at')
@AfterDeleteTrigger({
name: 'assets_delete_audit',
scope: 'statement',
function: assets_delete_audit,
referencingOldTableAs: 'old',

View file

@ -1,35 +0,0 @@
import 'src/schema/tables/activity.table';
import 'src/schema/tables/album-asset.table';
import 'src/schema/tables/album-user.table';
import 'src/schema/tables/album.table';
import 'src/schema/tables/api-key.table';
import 'src/schema/tables/asset-audit.table';
import 'src/schema/tables/asset-face.table';
import 'src/schema/tables/asset-files.table';
import 'src/schema/tables/asset-job-status.table';
import 'src/schema/tables/asset.table';
import 'src/schema/tables/audit.table';
import 'src/schema/tables/exif.table';
import 'src/schema/tables/face-search.table';
import 'src/schema/tables/geodata-places.table';
import 'src/schema/tables/library.table';
import 'src/schema/tables/memory-asset.table';
import 'src/schema/tables/memory.table';
import 'src/schema/tables/move.table';
import 'src/schema/tables/natural-earth-countries.table';
import 'src/schema/tables/partner-audit.table';
import 'src/schema/tables/partner.table';
import 'src/schema/tables/person.table';
import 'src/schema/tables/session.table';
import 'src/schema/tables/shared-link-asset.table';
import 'src/schema/tables/shared-link.table';
import 'src/schema/tables/smart-search.table';
import 'src/schema/tables/stack.table';
import 'src/schema/tables/sync-checkpoint.table';
import 'src/schema/tables/system-metadata.table';
import 'src/schema/tables/tag-asset.table';
import 'src/schema/tables/tag-closure.table';
import 'src/schema/tables/user-audit.table';
import 'src/schema/tables/user-metadata.table';
import 'src/schema/tables/user.table';
import 'src/schema/tables/version-history.table';

View file

@ -15,7 +15,6 @@ import {
@Table('memories_assets_assets')
@UpdatedAtTrigger('memory_assets_updated_at')
@AfterDeleteTrigger({
name: 'memory_assets_delete_audit',
scope: 'statement',
function: memory_assets_delete_audit,
referencingOldTableAs: 'old',

View file

@ -18,7 +18,6 @@ import {
@Table('memories')
@UpdatedAtTrigger('memories_updated_at')
@AfterDeleteTrigger({
name: 'memories_delete_audit',
scope: 'statement',
function: memories_delete_audit,
referencingOldTableAs: 'old',

View file

@ -15,7 +15,6 @@ import {
@Table('partners')
@UpdatedAtTrigger('partners_updated_at')
@AfterDeleteTrigger({
name: 'partners_delete_audit',
scope: 'statement',
function: partners_delete_audit,
referencingOldTableAs: 'old',

View file

@ -0,0 +1,17 @@
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('stacks_audit')
export class StackAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: Generated<string>;
@Column({ type: 'uuid' })
stackId!: string;
@Column({ type: 'uuid' })
userId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_stacks_audit_deleted_at' })
deletedAt!: Generated<Timestamp>;
}

View file

@ -1,12 +1,39 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { stacks_delete_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { UserTable } from 'src/schema/tables/user.table';
import { ForeignKeyColumn, Generated, PrimaryGeneratedColumn, Table } from 'src/sql-tools';
import {
AfterDeleteTrigger,
CreateDateColumn,
ForeignKeyColumn,
Generated,
PrimaryGeneratedColumn,
Table,
Timestamp,
UpdateDateColumn,
} from 'src/sql-tools';
@Table('asset_stack')
@UpdatedAtTrigger('stacks_updated_at')
@AfterDeleteTrigger({
scope: 'statement',
function: stacks_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class StackTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn()
updateId!: Generated<string>;
//TODO: Add constraint to ensure primary asset exists in the assets array
@ForeignKeyColumn(() => AssetTable, { nullable: false, unique: true })
primaryAssetId!: string;

View file

@ -18,7 +18,6 @@ import {
@Table('users')
@UpdatedAtTrigger('users_updated_at')
@AfterDeleteTrigger({
name: 'users_delete_audit',
scope: 'statement',
function: users_delete_audit,
referencingOldTableAs: 'old',

View file

@ -19,7 +19,7 @@ export class StackService extends BaseService {
async create(auth: AuthDto, dto: StackCreateDto): Promise<StackResponseDto> {
await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids: dto.assetIds });
const stack = await this.stackRepository.create({ ownerId: auth.user.id, assetIds: dto.assetIds });
const stack = await this.stackRepository.create({ ownerId: auth.user.id }, dto.assetIds);
await this.eventRepository.emit('stack.create', { stackId: stack.id, userId: auth.user.id });

View file

@ -57,6 +57,7 @@ export const SYNC_TYPES_ORDER = [
SyncRequestType.UsersV1,
SyncRequestType.PartnersV1,
SyncRequestType.AssetsV1,
SyncRequestType.StacksV1,
SyncRequestType.PartnerAssetsV1,
SyncRequestType.AlbumAssetsV1,
SyncRequestType.AlbumsV1,
@ -137,6 +138,7 @@ export class SyncService extends BaseService {
[SyncRequestType.AlbumAssetExifsV1]: () => this.syncAlbumAssetExifsV1(response, checkpointMap, auth, sessionId),
[SyncRequestType.MemoriesV1]: () => this.syncMemoriesV1(response, checkpointMap, auth),
[SyncRequestType.MemoryToAssetsV1]: () => this.syncMemoryAssetsV1(response, checkpointMap, auth),
[SyncRequestType.StacksV1]: () => this.syncStackV1(response, checkpointMap, auth),
};
for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) {
@ -510,6 +512,20 @@ export class SyncService extends BaseService {
}
}
private async syncStackV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const deleteType = SyncEntityType.StackDeleteV1;
const deletes = this.syncRepository.stack.getDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.StackV1;
const upserts = this.syncRepository.stack.getUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) {
const { type, sessionId, createId } = item;
await this.syncCheckpointRepository.upsertAll([

View file

@ -3,4 +3,8 @@ import { DatabaseFunction } from 'src/sql-tools/types';
export type TriggerFunctionOptions = Omit<TriggerOptions, 'functionName'> & { function: DatabaseFunction };
export const TriggerFunction = (options: TriggerFunctionOptions) =>
Trigger({ ...options, functionName: options.function.name });
Trigger({
name: options.function.name,
...options,
functionName: options.function.name,
});

View file

@ -25,6 +25,7 @@ import { PartnerRepository } from 'src/repositories/partner.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { SearchRepository } from 'src/repositories/search.repository';
import { SessionRepository } from 'src/repositories/session.repository';
import { StackRepository } from 'src/repositories/stack.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SyncCheckpointRepository } from 'src/repositories/sync-checkpoint.repository';
import { SyncRepository } from 'src/repositories/sync.repository';
@ -40,6 +41,7 @@ import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { SessionTable } from 'src/schema/tables/session.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { UserTable } from 'src/schema/tables/user.table';
import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service';
import { SyncService } from 'src/services/sync.service';
@ -133,6 +135,19 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { partner, result };
}
async newStack(dto: Omit<Insertable<StackTable>, 'primaryAssetId'>, assetIds: string[]) {
const date = factory.date();
const stack = {
id: factory.uuid(),
createdAt: date,
updatedAt: date,
...dto,
};
const result = await this.get(StackRepository).create(stack, assetIds);
return { stack: { ...stack, primaryAssetId: assetIds[0] }, result };
}
async newAsset(dto: Partial<Insertable<AssetTable>> = {}) {
const asset = mediumFactory.assetInsert(dto);
const result = await this.get(AssetRepository).create(asset);
@ -252,6 +267,7 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
case PersonRepository:
case SearchRepository:
case SessionRepository:
case StackRepository:
case SyncRepository:
case SyncCheckpointRepository:
case SystemMetadataRepository:

View file

@ -1,7 +1,7 @@
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PartnerRepository } from 'src/repositories/partner.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { partners_delete_audit } from 'src/schema/functions';
import { partners_delete_audit, stacks_delete_audit } from 'src/schema/functions';
import { BaseService } from 'src/services/base.service';
import { MediumTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
@ -31,6 +31,20 @@ describe('audit', () => {
});
});
describe(stacks_delete_audit.name, () => {
it('should not cascade user deletes to stacks_audit', async () => {
const userRepo = ctx.get(UserRepository);
const { user } = await ctx.newUser();
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
await ctx.newStack({ ownerId: user.id }, [asset1.id, asset2.id]);
await userRepo.delete(user, true);
await expect(
ctx.database.selectFrom('stacks_audit').select(['id']).where('userId', '=', user.id).execute(),
).resolves.toHaveLength(0);
});
});
describe('assets_audit', () => {
it('should not cascade user deletes to assets_audit', async () => {
const userRepo = ctx.get(UserRepository);

View file

@ -0,0 +1,107 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { StackRepository } from 'src/repositories/stack.repository';
import { DB } from 'src/schema';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncEntityType.StackV1, () => {
it('should detect and sync the first stack', async () => {
const { auth, user, ctx } = await setup();
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
const { stack } = await ctx.newStack({ ownerId: user.id }, [asset1.id, asset2.id]);
const response = await ctx.syncStream(auth, [SyncRequestType.StacksV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.stringContaining('StackV1'),
data: {
id: stack.id,
createdAt: (stack.createdAt as Date).toISOString(),
updatedAt: (stack.updatedAt as Date).toISOString(),
primaryAssetId: stack.primaryAssetId,
ownerId: stack.ownerId,
},
type: 'StackV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.StacksV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted stack', async () => {
const { auth, user, ctx } = await setup();
const stackRepo = ctx.get(StackRepository);
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
const { stack } = await ctx.newStack({ ownerId: user.id }, [asset1.id, asset2.id]);
await stackRepo.delete(stack.id);
const response = await ctx.syncStream(auth, [SyncRequestType.StacksV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.stringContaining('StackDeleteV1'),
data: { stackId: stack.id },
type: 'StackDeleteV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.StacksV1])).resolves.toEqual([]);
});
it('should sync a stack and then an update to that same stack', async () => {
const { auth, user, ctx } = await setup();
const stackRepo = ctx.get(StackRepository);
const { asset: asset1 } = await ctx.newAsset({ ownerId: user.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user.id });
const { stack } = await ctx.newStack({ ownerId: user.id }, [asset1.id, asset2.id]);
const response = await ctx.syncStream(auth, [SyncRequestType.StacksV1]);
expect(response).toHaveLength(1);
await ctx.syncAckAll(auth, response);
await stackRepo.update(stack.id, { primaryAssetId: asset2.id });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.StacksV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.stringContaining('StackV1'),
data: expect.objectContaining({ id: stack.id, primaryAssetId: asset2.id }),
type: 'StackV1',
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.StacksV1])).resolves.toEqual([]);
});
it('should not sync a stack or stack delete for an unrelated user', async () => {
const { auth, ctx } = await setup();
const stackRepo = ctx.get(StackRepository);
const { user: user2 } = await ctx.newUser();
const { asset: asset1 } = await ctx.newAsset({ ownerId: user2.id });
const { asset: asset2 } = await ctx.newAsset({ ownerId: user2.id });
const { stack } = await ctx.newStack({ ownerId: user2.id }, [asset1.id, asset2.id]);
await expect(ctx.syncStream(auth, [SyncRequestType.StacksV1])).resolves.toEqual([]);
await stackRepo.delete(stack.id);
await expect(ctx.syncStream(auth, [SyncRequestType.StacksV1])).resolves.toEqual([]);
});
});

View file

@ -14,7 +14,7 @@ const test_fn = registerFunction({
})
export class Table1 {}
export const description = 'should create a trigger ';
export const description = 'should create a trigger';
export const schema: DatabaseSchema = {
name: 'postgres',
schemaName: 'public',