feat(server): people sync (#19854)

* chore: fix missing usage of deleteType for syncMemoriesV1

* chore: add src path for proper absolute imports in jetbrains

* feat: people sync
This commit is contained in:
Zack Pollard 2025-07-10 16:32:42 +01:00 committed by GitHub
parent feff1899ee
commit b19884d01e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 368 additions and 5 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

@ -13834,6 +13834,8 @@
"MemoryToAssetDeleteV1", "MemoryToAssetDeleteV1",
"StackV1", "StackV1",
"StackDeleteV1", "StackDeleteV1",
"PersonV1",
"PersonDeleteV1",
"SyncAckV1" "SyncAckV1"
], ],
"type": "string" "type": "string"
@ -13983,6 +13985,74 @@
], ],
"type": "object" "type": "object"
}, },
"SyncPersonDeleteV1": {
"properties": {
"personId": {
"type": "string"
}
},
"required": [
"personId"
],
"type": "object"
},
"SyncPersonV1": {
"properties": {
"birthDate": {
"format": "date-time",
"nullable": true,
"type": "string"
},
"color": {
"nullable": true,
"type": "string"
},
"createdAt": {
"format": "date-time",
"type": "string"
},
"faceAssetId": {
"nullable": true,
"type": "string"
},
"id": {
"type": "string"
},
"isFavorite": {
"type": "boolean"
},
"isHidden": {
"type": "boolean"
},
"name": {
"type": "string"
},
"ownerId": {
"type": "string"
},
"thumbnailPath": {
"type": "string"
},
"updatedAt": {
"format": "date-time",
"type": "string"
}
},
"required": [
"birthDate",
"color",
"createdAt",
"faceAssetId",
"id",
"isFavorite",
"isHidden",
"name",
"ownerId",
"thumbnailPath",
"updatedAt"
],
"type": "object"
},
"SyncRequestType": { "SyncRequestType": {
"enum": [ "enum": [
"AlbumsV1", "AlbumsV1",
@ -13999,7 +14069,8 @@
"PartnerAssetExifsV1", "PartnerAssetExifsV1",
"PartnerStacksV1", "PartnerStacksV1",
"StacksV1", "StacksV1",
"UsersV1" "UsersV1",
"PeopleV1"
], ],
"type": "string" "type": "string"
}, },

View File

@ -4095,6 +4095,8 @@ export enum SyncEntityType {
MemoryToAssetDeleteV1 = "MemoryToAssetDeleteV1", MemoryToAssetDeleteV1 = "MemoryToAssetDeleteV1",
StackV1 = "StackV1", StackV1 = "StackV1",
StackDeleteV1 = "StackDeleteV1", StackDeleteV1 = "StackDeleteV1",
PersonV1 = "PersonV1",
PersonDeleteV1 = "PersonDeleteV1",
SyncAckV1 = "SyncAckV1" SyncAckV1 = "SyncAckV1"
} }
export enum SyncRequestType { export enum SyncRequestType {
@ -4112,7 +4114,8 @@ export enum SyncRequestType {
PartnerAssetExifsV1 = "PartnerAssetExifsV1", PartnerAssetExifsV1 = "PartnerAssetExifsV1",
PartnerStacksV1 = "PartnerStacksV1", PartnerStacksV1 = "PartnerStacksV1",
StacksV1 = "StacksV1", StacksV1 = "StacksV1",
UsersV1 = "UsersV1" UsersV1 = "UsersV1",
PeopleV1 = "PeopleV1"
} }
export enum TranscodeHWAccel { export enum TranscodeHWAccel {
Nvenc = "nvenc", Nvenc = "nvenc",

View File

@ -233,6 +233,26 @@ export class SyncStackDeleteV1 {
stackId!: string; stackId!: string;
} }
@ExtraModel()
export class SyncPersonV1 {
id!: string;
createdAt!: Date;
updatedAt!: Date;
ownerId!: string;
name!: string;
birthDate!: Date | null;
thumbnailPath!: string;
isHidden!: boolean;
isFavorite!: boolean;
color!: string | null;
faceAssetId!: string | null;
}
@ExtraModel()
export class SyncPersonDeleteV1 {
personId!: string;
}
@ExtraModel() @ExtraModel()
export class SyncAckV1 {} export class SyncAckV1 {}
@ -270,6 +290,8 @@ export type SyncItem = {
[SyncEntityType.PartnerStackBackfillV1]: SyncStackV1; [SyncEntityType.PartnerStackBackfillV1]: SyncStackV1;
[SyncEntityType.PartnerStackDeleteV1]: SyncStackDeleteV1; [SyncEntityType.PartnerStackDeleteV1]: SyncStackDeleteV1;
[SyncEntityType.PartnerStackV1]: SyncStackV1; [SyncEntityType.PartnerStackV1]: SyncStackV1;
[SyncEntityType.PersonV1]: SyncPersonV1;
[SyncEntityType.PersonDeleteV1]: SyncPersonDeleteV1;
[SyncEntityType.SyncAckV1]: SyncAckV1; [SyncEntityType.SyncAckV1]: SyncAckV1;
}; };

View File

@ -588,6 +588,7 @@ export enum SyncRequestType {
PartnerStacksV1 = 'PartnerStacksV1', PartnerStacksV1 = 'PartnerStacksV1',
StacksV1 = 'StacksV1', StacksV1 = 'StacksV1',
UsersV1 = 'UsersV1', UsersV1 = 'UsersV1',
PeopleV1 = 'PeopleV1',
} }
export enum SyncEntityType { export enum SyncEntityType {
@ -635,6 +636,9 @@ export enum SyncEntityType {
StackV1 = 'StackV1', StackV1 = 'StackV1',
StackDeleteV1 = 'StackDeleteV1', StackDeleteV1 = 'StackDeleteV1',
PersonV1 = 'PersonV1',
PersonDeleteV1 = 'PersonDeleteV1',
SyncAckV1 = 'SyncAckV1', SyncAckV1 = 'SyncAckV1',
} }

View File

@ -749,6 +749,40 @@ where
order by order by
"updateId" asc "updateId" asc
-- SyncRepository.people.getDeletes
select
"id",
"personId"
from
"person_audit"
where
"ownerId" = $1
and "deletedAt" < now() - interval '1 millisecond'
order by
"id" asc
-- SyncRepository.people.getUpserts
select
"id",
"createdAt",
"updatedAt",
"ownerId",
"name",
"birthDate",
"thumbnailPath",
"isHidden",
"isFavorite",
"color",
"updateId",
"faceAssetId"
from
"person"
where
"ownerId" = $1
and "updatedAt" < now() - interval '1 millisecond'
order by
"updateId" asc
-- SyncRepository.stack.getDeletes -- SyncRepository.stack.getDeletes
select select
"id", "id",

View File

@ -15,7 +15,8 @@ type AuditTables =
| 'album_assets_audit' | 'album_assets_audit'
| 'memories_audit' | 'memories_audit'
| 'memory_assets_audit' | 'memory_assets_audit'
| 'stacks_audit'; | 'stacks_audit'
| 'person_audit';
type UpsertTables = type UpsertTables =
| 'users' | 'users'
| 'partners' | 'partners'
@ -25,7 +26,8 @@ type UpsertTables =
| 'albums_shared_users_users' | 'albums_shared_users_users'
| 'memories' | 'memories'
| 'memories_assets_assets' | 'memories_assets_assets'
| 'asset_stack'; | 'asset_stack'
| 'person';
@Injectable() @Injectable()
export class SyncRepository { export class SyncRepository {
@ -42,6 +44,7 @@ export class SyncRepository {
partnerAsset: PartnerAssetsSync; partnerAsset: PartnerAssetsSync;
partnerAssetExif: PartnerAssetExifsSync; partnerAssetExif: PartnerAssetExifsSync;
partnerStack: PartnerStackSync; partnerStack: PartnerStackSync;
people: PersonSync;
stack: StackSync; stack: StackSync;
user: UserSync; user: UserSync;
@ -59,6 +62,7 @@ export class SyncRepository {
this.partnerAsset = new PartnerAssetsSync(this.db); this.partnerAsset = new PartnerAssetsSync(this.db);
this.partnerAssetExif = new PartnerAssetExifsSync(this.db); this.partnerAssetExif = new PartnerAssetExifsSync(this.db);
this.partnerStack = new PartnerStackSync(this.db); this.partnerStack = new PartnerStackSync(this.db);
this.people = new PersonSync(this.db);
this.stack = new StackSync(this.db); this.stack = new StackSync(this.db);
this.user = new UserSync(this.db); this.user = new UserSync(this.db);
} }
@ -357,6 +361,41 @@ class AssetSync extends BaseSync {
} }
} }
class PersonSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('person_audit')
.select(['id', 'personId'])
.where('ownerId', '=', userId)
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('person')
.select([
'id',
'createdAt',
'updatedAt',
'ownerId',
'name',
'birthDate',
'thumbnailPath',
'isHidden',
'isFavorite',
'color',
'updateId',
'faceAssetId',
])
.where('ownerId', '=', userId)
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
}
class AssetExifSync extends BaseSync { class AssetExifSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID], stream: true }) @GenerateSql({ params: [DummyValue.UUID], stream: true })
getUpserts(userId: string, ack?: SyncAck) { getUpserts(userId: string, ack?: SyncAck) {

View File

@ -203,3 +203,16 @@ export const stacks_delete_audit = registerFunction({
RETURN NULL; RETURN NULL;
END`, END`,
}); });
export const person_delete_audit = registerFunction({
name: 'person_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO person_audit ("personId", "ownerId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END`,
});

View File

@ -11,6 +11,7 @@ import {
memories_delete_audit, memories_delete_audit,
memory_assets_delete_audit, memory_assets_delete_audit,
partners_delete_audit, partners_delete_audit,
person_delete_audit,
stacks_delete_audit, stacks_delete_audit,
updated_at, updated_at,
users_delete_audit, users_delete_audit,
@ -42,6 +43,7 @@ import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-coun
import { NotificationTable } from 'src/schema/tables/notification.table'; import { NotificationTable } from 'src/schema/tables/notification.table';
import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table'; import { PartnerAuditTable } from 'src/schema/tables/partner-audit.table';
import { PartnerTable } from 'src/schema/tables/partner.table'; import { PartnerTable } from 'src/schema/tables/partner.table';
import { PersonAuditTable } from 'src/schema/tables/person-audit.table';
import { PersonTable } from 'src/schema/tables/person.table'; import { PersonTable } from 'src/schema/tables/person.table';
import { SessionTable } from 'src/schema/tables/session.table'; import { SessionTable } from 'src/schema/tables/session.table';
import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table'; import { SharedLinkAssetTable } from 'src/schema/tables/shared-link-asset.table';
@ -92,6 +94,7 @@ export class ImmichDatabase {
PartnerAuditTable, PartnerAuditTable,
PartnerTable, PartnerTable,
PersonTable, PersonTable,
PersonAuditTable,
SessionTable, SessionTable,
SharedLinkAssetTable, SharedLinkAssetTable,
SharedLinkTable, SharedLinkTable,
@ -124,6 +127,7 @@ export class ImmichDatabase {
memories_delete_audit, memories_delete_audit,
memory_assets_delete_audit, memory_assets_delete_audit,
stacks_delete_audit, stacks_delete_audit,
person_delete_audit,
]; ];
enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum]; enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum];
@ -166,6 +170,7 @@ export interface DB {
partners_audit: PartnerAuditTable; partners_audit: PartnerAuditTable;
partners: PartnerTable; partners: PartnerTable;
person: PersonTable; person: PersonTable;
person_audit: PersonAuditTable;
sessions: SessionTable; sessions: SessionTable;
session_sync_checkpoints: SessionSyncCheckpointTable; session_sync_checkpoints: SessionSyncCheckpointTable;
shared_link__asset: SharedLinkAssetTable; shared_link__asset: SharedLinkAssetTable;

View File

@ -0,0 +1,41 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE OR REPLACE FUNCTION person_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO person_audit ("personId", "ownerId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE TABLE "person_audit" (
"id" uuid NOT NULL DEFAULT immich_uuid_v7(),
"personId" uuid NOT NULL,
"ownerId" uuid NOT NULL,
"deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT "PK_46c1ad23490b9312ffaa052aa59" PRIMARY KEY ("id")
);`.execute(db);
await sql`CREATE INDEX "IDX_person_audit_person_id" ON "person_audit" ("personId");`.execute(db);
await sql`CREATE INDEX "IDX_person_audit_owner_id" ON "person_audit" ("ownerId");`.execute(db);
await sql`CREATE INDEX "IDX_person_audit_deleted_at" ON "person_audit" ("deletedAt");`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "person_delete_audit"
AFTER DELETE ON "person"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() = 0)
EXECUTE FUNCTION person_delete_audit();`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('function_person_delete_audit', '{"type":"function","name":"person_delete_audit","sql":"CREATE OR REPLACE FUNCTION person_delete_audit()\\n RETURNS TRIGGER\\n LANGUAGE PLPGSQL\\n AS $$\\n BEGIN\\n INSERT INTO person_audit (\\"personId\\", \\"ownerId\\")\\n SELECT \\"id\\", \\"ownerId\\"\\n FROM OLD;\\n RETURN NULL;\\n END\\n $$;"}'::jsonb);`.execute(db);
await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_person_delete_audit', '{"type":"trigger","name":"person_delete_audit","sql":"CREATE OR REPLACE TRIGGER \\"person_delete_audit\\"\\n AFTER DELETE ON \\"person\\"\\n REFERENCING OLD TABLE AS \\"old\\"\\n FOR EACH STATEMENT\\n WHEN (pg_trigger_depth() = 0)\\n EXECUTE FUNCTION person_delete_audit();"}'::jsonb);`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "person_delete_audit" ON "person";`.execute(db);
await sql`DROP TABLE "person_audit";`.execute(db);
await sql`DROP FUNCTION person_delete_audit;`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'function_person_delete_audit';`.execute(db);
await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_person_delete_audit';`.execute(db);
}

View File

@ -0,0 +1,17 @@
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Generated, Table, Timestamp } from 'src/sql-tools';
@Table('person_audit')
export class PersonAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: Generated<string>;
@Column({ type: 'uuid', indexName: 'IDX_person_audit_person_id' })
personId!: string;
@Column({ type: 'uuid', indexName: 'IDX_person_audit_owner_id' })
ownerId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_person_audit_deleted_at' })
deletedAt!: Generated<Timestamp>;
}

View File

@ -1,7 +1,9 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators'; import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { person_delete_audit } from 'src/schema/functions';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { UserTable } from 'src/schema/tables/user.table'; import { UserTable } from 'src/schema/tables/user.table';
import { import {
AfterDeleteTrigger,
Check, Check,
Column, Column,
CreateDateColumn, CreateDateColumn,
@ -15,6 +17,12 @@ import {
@Table('person') @Table('person')
@UpdatedAtTrigger('person_updated_at') @UpdatedAtTrigger('person_updated_at')
@AfterDeleteTrigger({
scope: 'statement',
function: person_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
@Check({ name: 'CHK_b0f82b0ed662bfc24fbb58bb45', expression: `"birthDate" <= CURRENT_DATE` }) @Check({ name: 'CHK_b0f82b0ed662bfc24fbb58bb45', expression: `"birthDate" <= CURRENT_DATE` })
export class PersonTable { export class PersonTable {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')

View File

@ -69,6 +69,7 @@ export const SYNC_TYPES_ORDER = [
SyncRequestType.PartnerAssetExifsV1, SyncRequestType.PartnerAssetExifsV1,
SyncRequestType.MemoriesV1, SyncRequestType.MemoriesV1,
SyncRequestType.MemoryToAssetsV1, SyncRequestType.MemoryToAssetsV1,
SyncRequestType.PeopleV1,
]; ];
const throwSessionRequired = () => { const throwSessionRequired = () => {
@ -141,6 +142,7 @@ export class SyncService extends BaseService {
[SyncRequestType.MemoryToAssetsV1]: () => this.syncMemoryAssetsV1(response, checkpointMap, auth), [SyncRequestType.MemoryToAssetsV1]: () => this.syncMemoryAssetsV1(response, checkpointMap, auth),
[SyncRequestType.StacksV1]: () => this.syncStackV1(response, checkpointMap, auth), [SyncRequestType.StacksV1]: () => this.syncStackV1(response, checkpointMap, auth),
[SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(response, checkpointMap, auth, sessionId), [SyncRequestType.PartnerStacksV1]: () => this.syncPartnerStackV1(response, checkpointMap, auth, sessionId),
[SyncRequestType.PeopleV1]: () => this.syncPeopleV1(response, checkpointMap, auth),
}; };
for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) { for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) {
@ -488,7 +490,7 @@ export class SyncService extends BaseService {
private async syncMemoriesV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) { private async syncMemoriesV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const deleteType = SyncEntityType.MemoryDeleteV1; const deleteType = SyncEntityType.MemoryDeleteV1;
const deletes = this.syncRepository.memory.getDeletes(auth.user.id, checkpointMap[SyncEntityType.MemoryDeleteV1]); const deletes = this.syncRepository.memory.getDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) { for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data }); send(response, { type: deleteType, ids: [id], data });
} }
@ -576,6 +578,20 @@ export class SyncService extends BaseService {
} }
} }
private async syncPeopleV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const deleteType = SyncEntityType.PersonDeleteV1;
const deletes = this.syncRepository.people.getDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.PersonV1;
const upserts = this.syncRepository.people.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 }) { private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) {
const { type, sessionId, createId } = item; const { type, sessionId, createId } = item;
await this.syncCheckpointRepository.upsertAll([ await this.syncCheckpointRepository.upsertAll([

View File

@ -0,0 +1,87 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { PersonRepository } from 'src/repositories/person.repository';
import { DB } from 'src/schema';
import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.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.PersonV1, () => {
it('should detect and sync the first person', async () => {
const { auth, ctx } = await setup();
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: person.id,
name: person.name,
thumbnailPath: person.thumbnailPath,
isHidden: person.isHidden,
birthDate: person.birthDate,
faceAssetId: person.faceAssetId,
isFavorite: person.isFavorite,
ownerId: auth.user.id,
color: person.color,
}),
type: 'PersonV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.PeopleV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted person', async () => {
const { auth, ctx } = await setup();
const personRepo = ctx.get(PersonRepository);
const { person } = await ctx.newPerson({ ownerId: auth.user.id });
await personRepo.delete([person.id]);
const response = await ctx.syncStream(auth, [SyncRequestType.PeopleV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
personId: person.id,
},
type: 'PersonDeleteV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.PeopleV1])).resolves.toEqual([]);
});
it('should not sync a person or person delete for an unrelated user', async () => {
const { auth, ctx } = await setup();
const personRepo = ctx.get(PersonRepository);
const { user: user2 } = await ctx.newUser();
const { session } = await ctx.newSession({ userId: user2.id });
const { person } = await ctx.newPerson({ ownerId: user2.id });
const auth2 = factory.auth({ session, user: user2 });
expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV1])).toHaveLength(1);
expect(await ctx.syncStream(auth, [SyncRequestType.PeopleV1])).toHaveLength(0);
await personRepo.delete([person.id]);
expect(await ctx.syncStream(auth2, [SyncRequestType.PeopleV1])).toHaveLength(1);
expect(await ctx.syncStream(auth, [SyncRequestType.PeopleV1])).toHaveLength(0);
});
});

View File

@ -17,6 +17,9 @@
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,
"preserveWatchOutput": true, "preserveWatchOutput": true,
"paths": {
"src/*": ["./src/*"],
},
"baseUrl": "./", "baseUrl": "./",
"jsx": "react", "jsx": "react",
"types": ["vitest/globals"], "types": ["vitest/globals"],