feat: sync implementation for the user entity (#16234)

* ci: print out typeorm generation changes

* feat: sync implementation for the user entity

wip

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Zack Pollard 2025-02-21 04:37:57 +00:00 committed by GitHub
parent 02cd8da871
commit ac36effb45
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1774 additions and 10 deletions

View file

@ -1,18 +1,112 @@
import { Injectable } from '@nestjs/common';
import { ForbiddenException, Injectable } from '@nestjs/common';
import { Insertable } from 'kysely';
import { DateTime } from 'luxon';
import { Writable } from 'node:stream';
import { AUDIT_LOG_MAX_DURATION } from 'src/constants';
import { SessionSyncCheckpoints } from 'src/db';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetDeltaSyncDto, AssetDeltaSyncResponseDto, AssetFullSyncDto } from 'src/dtos/sync.dto';
import { DatabaseAction, EntityType, Permission } from 'src/enum';
import {
AssetDeltaSyncDto,
AssetDeltaSyncResponseDto,
AssetFullSyncDto,
SyncAckDeleteDto,
SyncAckSetDto,
SyncStreamDto,
} from 'src/dtos/sync.dto';
import { DatabaseAction, EntityType, Permission, SyncEntityType, SyncRequestType } from 'src/enum';
import { BaseService } from 'src/services/base.service';
import { SyncAck } from 'src/types';
import { getMyPartnerIds } from 'src/utils/asset.util';
import { setIsEqual } from 'src/utils/set';
import { fromAck, serialize } from 'src/utils/sync';
const FULL_SYNC = { needsFullSync: true, deleted: [], upserted: [] };
const SYNC_TYPES_ORDER = [
//
SyncRequestType.UsersV1,
];
const throwSessionRequired = () => {
throw new ForbiddenException('Sync endpoints cannot be used with API keys');
};
@Injectable()
export class SyncService extends BaseService {
getAcks(auth: AuthDto) {
const sessionId = auth.session?.id;
if (!sessionId) {
return throwSessionRequired();
}
return this.syncRepository.getCheckpoints(sessionId);
}
async setAcks(auth: AuthDto, dto: SyncAckSetDto) {
// TODO ack validation
const sessionId = auth.session?.id;
if (!sessionId) {
return throwSessionRequired();
}
const checkpoints: Insertable<SessionSyncCheckpoints>[] = [];
for (const ack of dto.acks) {
const { type } = fromAck(ack);
checkpoints.push({ sessionId, type, ack });
}
await this.syncRepository.upsertCheckpoints(checkpoints);
}
async deleteAcks(auth: AuthDto, dto: SyncAckDeleteDto) {
const sessionId = auth.session?.id;
if (!sessionId) {
return throwSessionRequired();
}
await this.syncRepository.deleteCheckpoints(sessionId, dto.types);
}
async stream(auth: AuthDto, response: Writable, dto: SyncStreamDto) {
const sessionId = auth.session?.id;
if (!sessionId) {
return throwSessionRequired();
}
const checkpoints = await this.syncRepository.getCheckpoints(sessionId);
const checkpointMap: Partial<Record<SyncEntityType, SyncAck>> = Object.fromEntries(
checkpoints.map(({ type, ack }) => [type, fromAck(ack)]),
);
// TODO pre-filter/sort list based on optimal sync order
for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) {
switch (type) {
case SyncRequestType.UsersV1: {
const deletes = this.syncRepository.getUserDeletes(checkpointMap[SyncEntityType.UserDeleteV1]);
for await (const { ackEpoch, ...data } of deletes) {
response.write(serialize({ type: SyncEntityType.UserDeleteV1, ackEpoch, ids: [data.userId], data }));
}
const upserts = this.syncRepository.getUserUpserts(checkpointMap[SyncEntityType.UserV1]);
for await (const { ackEpoch, ...data } of upserts) {
response.write(serialize({ type: SyncEntityType.UserV1, ackEpoch, ids: [data.id], data }));
}
break;
}
default: {
this.logger.warn(`Unsupported sync type: ${type}`);
break;
}
}
}
response.end();
}
async getFullSync(auth: AuthDto, dto: AssetFullSyncDto): Promise<AssetResponseDto[]> {
// mobile implementation is faster if this is a single id
const userId = dto.userId || auth.user.id;