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
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 269 additions and 40 deletions

@ -1 +1 @@
Subproject commit 18736fc27a80c99c68e856cdb4f842bc81ed3445
Subproject commit 37f60ea537c0228f5f92e4f42dc42f0bb39a6d7f

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

@ -9,6 +9,7 @@ abstract final class SyncStreamStub {
email: "admin@admin",
id: "1",
name: "Admin",
avatarColor: null,
),
ack: "1",
);
@ -19,6 +20,7 @@ abstract final class SyncStreamStub {
email: "user@user",
id: "5",
name: "User",
avatarColor: null,
),
ack: "5",
);

View file

@ -13978,8 +13978,79 @@
],
"type": "object"
},
"SyncAuthUserV1": {
"properties": {
"avatarColor": {
"allOf": [
{
"$ref": "#/components/schemas/UserAvatarColor"
}
],
"nullable": true
},
"deletedAt": {
"format": "date-time",
"nullable": true,
"type": "string"
},
"email": {
"type": "string"
},
"hasProfileImage": {
"type": "boolean"
},
"id": {
"type": "string"
},
"isAdmin": {
"type": "boolean"
},
"name": {
"type": "string"
},
"oauthId": {
"type": "string"
},
"pinCode": {
"nullable": true,
"type": "string"
},
"profileChangedAt": {
"format": "date-time",
"type": "string"
},
"quotaSizeInBytes": {
"nullable": true,
"type": "integer"
},
"quotaUsageInBytes": {
"type": "integer"
},
"storageLabel": {
"nullable": true,
"type": "string"
}
},
"required": [
"avatarColor",
"deletedAt",
"email",
"hasProfileImage",
"id",
"isAdmin",
"name",
"oauthId",
"pinCode",
"profileChangedAt",
"quotaSizeInBytes",
"quotaUsageInBytes",
"storageLabel"
],
"type": "object"
},
"SyncEntityType": {
"enum": [
"AuthUserV1",
"UserV1",
"UserDeleteV1",
"AssetV1",
@ -14242,6 +14313,7 @@
"AlbumAssetExifsV1",
"AssetsV1",
"AssetExifsV1",
"AuthUsersV1",
"MemoriesV1",
"MemoryToAssetsV1",
"PartnersV1",
@ -14364,6 +14436,14 @@
},
"SyncUserV1": {
"properties": {
"avatarColor": {
"allOf": [
{
"$ref": "#/components/schemas/UserAvatarColor"
}
],
"nullable": true
},
"deletedAt": {
"format": "date-time",
"nullable": true,
@ -14380,6 +14460,7 @@
}
},
"required": [
"avatarColor",
"deletedAt",
"email",
"id",

View file

@ -4099,6 +4099,7 @@ export enum Error2 {
NotFound = "not_found"
}
export enum SyncEntityType {
AuthUserV1 = "AuthUserV1",
UserV1 = "UserV1",
UserDeleteV1 = "UserDeleteV1",
AssetV1 = "AssetV1",
@ -4149,6 +4150,7 @@ export enum SyncRequestType {
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
AssetsV1 = "AssetsV1",
AssetExifsV1 = "AssetExifsV1",
AuthUsersV1 = "AuthUsersV1",
MemoriesV1 = "MemoriesV1",
MemoryToAssetsV1 = "MemoryToAssetsV1",
PartnersV1 = "PartnersV1",

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',
},
]);