feat: sync auth user (#20067)

This commit is contained in:
Jason Rasmussen
2025-07-23 09:59:33 -04:00
committed by GitHub
parent ab597155fa
commit 92384c28de
20 changed files with 507 additions and 41 deletions

View File

@@ -356,6 +356,7 @@ export const columns = {
],
syncAlbumUser: ['album_user.albumsId as albumId', 'album_user.usersId as userId', 'album_user.role'],
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],
syncUser: ['id', 'name', 'email', 'avatarColor', 'deletedAt', 'updateId'],
stack: ['stack.id', 'stack.primaryAssetId', 'ownerId'],
syncAssetExif: [
'asset_exif.assetId',

View File

@@ -10,6 +10,7 @@ import {
MemoryType,
SyncEntityType,
SyncRequestType,
UserAvatarColor,
UserMetadataKey,
} from 'src/enum';
import { UserMetadata } from 'src/types';
@@ -58,9 +59,25 @@ export class SyncUserV1 {
id!: string;
name!: string;
email!: string;
@ValidateEnum({ enum: UserAvatarColor, name: 'UserAvatarColor', nullable: true })
avatarColor!: UserAvatarColor | null;
deletedAt!: Date | null;
}
@ExtraModel()
export class SyncAuthUserV1 extends SyncUserV1 {
isAdmin!: boolean;
pinCode!: string | null;
oauthId!: string;
storageLabel!: string | null;
@ApiProperty({ type: 'integer' })
quotaSizeInBytes!: number | null;
@ApiProperty({ type: 'integer' })
quotaUsageInBytes!: number;
hasProfileImage!: boolean;
profileChangedAt!: Date;
}
@ExtraModel()
export class SyncUserDeleteV1 {
userId!: string;
@@ -301,6 +318,7 @@ export class SyncAckV1 {}
export class SyncResetV1 {}
export type SyncItem = {
[SyncEntityType.AuthUserV1]: SyncAuthUserV1;
[SyncEntityType.UserV1]: SyncUserV1;
[SyncEntityType.UserDeleteV1]: SyncUserDeleteV1;
[SyncEntityType.PartnerV1]: SyncPartnerV1;

View File

@@ -559,6 +559,7 @@ export enum SyncRequestType {
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
AssetsV1 = 'AssetsV1',
AssetExifsV1 = 'AssetExifsV1',
AuthUsersV1 = 'AuthUsersV1',
MemoriesV1 = 'MemoriesV1',
MemoryToAssetsV1 = 'MemoryToAssetsV1',
PartnersV1 = 'PartnersV1',
@@ -573,6 +574,8 @@ export enum SyncRequestType {
}
export enum SyncEntityType {
AuthUserV1 = 'AuthUserV1',
UserV1 = 'UserV1',
UserDeleteV1 = 'UserDeleteV1',

View File

@@ -444,6 +444,29 @@ where
order by
"asset_face"."updateId" asc
-- SyncRepository.authUser.getUpserts
select
"id",
"name",
"email",
"avatarColor",
"deletedAt",
"updateId",
"isAdmin",
"pinCode",
"oauthId",
"storageLabel",
"quotaSizeInBytes",
"quotaUsageInBytes",
"profileImagePath",
"profileChangedAt"
from
"user"
where
"updatedAt" < now() - interval '1 millisecond'
order by
"updateId" asc
-- SyncRepository.memory.getDeletes
select
"id",
@@ -871,6 +894,7 @@ select
"id",
"name",
"email",
"avatarColor",
"deletedAt",
"updateId"
from

View File

@@ -43,6 +43,7 @@ export class SyncRepository {
asset: AssetSync;
assetExif: AssetExifSync;
assetFace: AssetFaceSync;
authUser: AuthUserSync;
memory: MemorySync;
memoryToAsset: MemoryToAssetSync;
partner: PartnerSync;
@@ -63,6 +64,7 @@ export class SyncRepository {
this.asset = new AssetSync(this.db);
this.assetExif = new AssetExifSync(this.db);
this.assetFace = new AssetFaceSync(this.db);
this.authUser = new AuthUserSync(this.db);
this.memory = new MemorySync(this.db);
this.memoryToAsset = new MemoryToAssetSync(this.db);
this.partner = new PartnerSync(this.db);
@@ -367,6 +369,27 @@ class AssetSync extends BaseSync {
}
}
class AuthUserSync extends BaseSync {
@GenerateSql({ params: [], stream: true })
getUpserts(ack?: SyncAck) {
return this.db
.selectFrom('user')
.select(columns.syncUser)
.select([
'isAdmin',
'pinCode',
'oauthId',
'storageLabel',
'quotaSizeInBytes',
'quotaUsageInBytes',
'profileImagePath',
'profileChangedAt',
])
.$call(this.upsertTableFilters(ack))
.stream();
}
}
class PersonSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getDeletes(userId: string, ack?: SyncAck) {
@@ -693,11 +716,7 @@ class UserSync extends BaseSync {
@GenerateSql({ params: [], stream: true })
getUpserts(ack?: SyncAck) {
return this.db
.selectFrom('user')
.select(['id', 'name', 'email', 'deletedAt', 'updateId'])
.$call(this.upsertTableFilters(ack))
.stream();
return this.db.selectFrom('user').select(columns.syncUser).$call(this.upsertTableFilters(ack)).stream();
}
}

View File

@@ -54,6 +54,7 @@ const sendEntityBackfillCompleteAck = (response: Writable, ackType: SyncEntityTy
const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] };
export const SYNC_TYPES_ORDER = [
SyncRequestType.AuthUsersV1,
SyncRequestType.UsersV1,
SyncRequestType.PartnersV1,
SyncRequestType.AssetsV1,
@@ -140,6 +141,7 @@ export class SyncService extends BaseService {
const checkpointMap: CheckpointMap = Object.fromEntries(checkpoints.map(({ type, ack }) => [type, fromAck(ack)]));
const handlers: Record<SyncRequestType, () => Promise<void>> = {
[SyncRequestType.AuthUsersV1]: () => this.syncAuthUsersV1(response, checkpointMap),
[SyncRequestType.UsersV1]: () => this.syncUsersV1(response, checkpointMap),
[SyncRequestType.PartnersV1]: () => this.syncPartnersV1(response, checkpointMap, auth),
[SyncRequestType.AssetsV1]: () => this.syncAssetsV1(response, checkpointMap, auth),
@@ -169,6 +171,14 @@ export class SyncService extends BaseService {
response.end();
}
private async syncAuthUsersV1(response: Writable, checkpointMap: CheckpointMap) {
const upsertType = SyncEntityType.AuthUserV1;
const upserts = this.syncRepository.authUser.getUpserts(checkpointMap[upsertType]);
for await (const { updateId, profileImagePath, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data: { ...data, hasProfileImage: !!profileImagePath } });
}
}
private async syncUsersV1(response: Writable, checkpointMap: CheckpointMap) {
const deleteType = SyncEntityType.UserDeleteV1;
const deletes = this.syncRepository.user.getDeletes(checkpointMap[deleteType]);

View File

@@ -507,7 +507,14 @@ const userInsert = (user: Partial<Insertable<UserTable>> = {}) => {
deletedAt: null,
isAdmin: false,
profileImagePath: '',
profileChangedAt: newDate(),
shouldChangePassword: true,
storageLabel: null,
pinCode: null,
oauthId: '',
avatarColor: null,
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
};
return { ...defaults, ...user, id };

View File

@@ -0,0 +1,87 @@
import { Kysely } from 'kysely';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { UserRepository } from 'src/repositories/user.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.AuthUserV1, () => {
it('should detect and sync the first user', async () => {
const { auth, user, ctx } = await setup(await getKyselyDB());
const response = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: user.id,
isAdmin: user.isAdmin,
deletedAt: user.deletedAt,
name: user.name,
avatarColor: user.avatarColor,
email: user.email,
pinCode: user.pinCode,
hasProfileImage: false,
profileChangedAt: (user.profileChangedAt as Date).toISOString(),
oauthId: user.oauthId,
quotaSizeInBytes: user.quotaSizeInBytes,
quotaUsageInBytes: user.quotaUsageInBytes,
storageLabel: user.storageLabel,
},
type: 'AuthUserV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.AuthUsersV1])).resolves.toEqual([]);
});
it('should sync a change and then another change to that same user', async () => {
const { auth, user, ctx } = await setup(await getKyselyDB());
const userRepo = ctx.get(UserRepository);
const response = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: user.id,
isAdmin: false,
}),
type: 'AuthUserV1',
},
]);
await ctx.syncAckAll(auth, response);
await userRepo.update(user.id, { isAdmin: true });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AuthUsersV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: user.id,
isAdmin: true,
}),
type: 'AuthUserV1',
},
]);
});
});

View File

@@ -37,6 +37,7 @@ describe(SyncEntityType.UserV1, () => {
email: user.email,
id: user.id,
name: user.name,
avatarColor: user.avatarColor,
},
type: 'UserV1',
},
@@ -49,8 +50,7 @@ describe(SyncEntityType.UserV1, () => {
it('should detect and sync a soft deleted user', async () => {
const { auth, ctx } = await setup(await getKyselyDB());
const deletedAt = new Date().toISOString();
const { user: deleted } = await ctx.newUser({ deletedAt });
const { user: deleted } = await ctx.newUser({ deletedAt: new Date().toISOString() });
const response = await ctx.syncStream(auth, [SyncRequestType.UsersV1]);
@@ -59,22 +59,12 @@ describe(SyncEntityType.UserV1, () => {
expect.arrayContaining([
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: auth.user.name,
},
data: expect.objectContaining({ id: auth.user.id }),
type: 'UserV1',
},
{
ack: expect.any(String),
data: {
deletedAt,
email: deleted.email,
id: deleted.id,
name: deleted.name,
},
data: expect.objectContaining({ id: deleted.id }),
type: 'UserV1',
},
]),
@@ -85,7 +75,7 @@ describe(SyncEntityType.UserV1, () => {
});
it('should detect and sync a deleted user', async () => {
const { auth, ctx } = await setup(await getKyselyDB());
const { auth, user: authUser, ctx } = await setup(await getKyselyDB());
const userRepo = ctx.get(UserRepository);
@@ -104,12 +94,7 @@ describe(SyncEntityType.UserV1, () => {
},
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: auth.user.name,
},
data: expect.objectContaining({ id: authUser.id }),
type: 'UserV1',
},
]);
@@ -119,7 +104,7 @@ describe(SyncEntityType.UserV1, () => {
});
it('should sync a user and then an update to that same user', async () => {
const { auth, ctx } = await setup(await getKyselyDB());
const { auth, user, ctx } = await setup(await getKyselyDB());
const userRepo = ctx.get(UserRepository);
@@ -128,12 +113,7 @@ describe(SyncEntityType.UserV1, () => {
expect(response).toEqual([
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: auth.user.name,
},
data: expect.objectContaining({ id: user.id }),
type: 'UserV1',
},
]);
@@ -147,12 +127,7 @@ describe(SyncEntityType.UserV1, () => {
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: {
deletedAt: null,
email: auth.user.email,
id: auth.user.id,
name: updated.name,
},
data: expect.objectContaining({ id: user.id, name: updated.name }),
type: 'UserV1',
},
]);