uberf-9754: fix account timestamp (#8520)

This commit is contained in:
Alexey Zinoviev
2025-04-10 20:24:58 +07:00
committed by GitHub
parent 7537d0014a
commit aa9215ed13
4 changed files with 203 additions and 45 deletions
+39 -15
View File
@@ -184,6 +184,7 @@ interface Request {
class AccountClientImpl implements AccountClient {
private readonly request: RequestInit
private readonly rpc: typeof this._rpc
constructor (
private readonly url: string,
@@ -206,17 +207,18 @@ class AccountClientImpl implements AccountClient {
},
...(isBrowser ? { credentials: 'include' } : {})
}
this.rpc = withRetryUntilTimeout(this._rpc.bind(this))
}
async getProviders (): Promise<string[]> {
return await retry(5, async () => {
return await withRetryUntilMaxAttempts(async () => {
const response = await fetch(concatLink(this.url, '/providers'))
return await response.json()
})
})()
}
private async rpc<T>(request: Request): Promise<T> {
private async _rpc<T>(request: Request): Promise<T> {
const timezone = getClientTimezone()
const meta: Record<string, string> = timezone !== undefined ? { 'X-Timezone': timezone } : {}
const response = await fetch(this.url, {
@@ -902,18 +904,40 @@ class AccountClientImpl implements AccountClient {
}
}
async function retry<T> (retries: number, op: () => Promise<T>, delay: number = 100): Promise<T> {
let error: any
while (retries > 0) {
retries--
try {
return await op()
} catch (err: any) {
error = err
if (retries !== 0) {
await new Promise((resolve) => setTimeout(resolve, delay))
function withRetry<T, F extends (...args: any[]) => Promise<T>> (
f: F,
shouldFail: (err: any, attempt: number) => boolean,
intervalMs: number = 1000
): F {
return async function (...params: any[]): Promise<T> {
let attempt = 0
while (true) {
try {
return await f(...params)
} catch (err: any) {
if (shouldFail(err, attempt)) {
throw err
}
attempt++
await new Promise<void>((resolve) => setTimeout(resolve, intervalMs))
}
}
}
throw error
} as F
}
const connectionErrorCodes = ['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND']
function withRetryUntilTimeout<T, F extends (...args: any[]) => Promise<T>> (f: F, timeoutMs: number = 5000): F {
const timeout = Date.now() + timeoutMs
const shouldFail = (err: any): boolean => !connectionErrorCodes.includes(err?.cause?.code) || timeout < Date.now()
return withRetry(f, shouldFail)
}
function withRetryUntilMaxAttempts<T, F extends (...args: any[]) => Promise<T>> (f: F, maxAttempts: number = 5): F {
const shouldFail = (err: any, attempt: number): boolean =>
!connectionErrorCodes.includes(err?.cause?.code) || attempt === maxAttempts
return withRetry(f, shouldFail)
}
+96 -8
View File
@@ -27,6 +27,7 @@ interface TestWorkspace {
uuid: WorkspaceUuid
mode: WorkspaceMode
name: string
createdOn: number
processingAttempts?: number
lastProcessingTime?: number
}
@@ -42,7 +43,7 @@ describe('PostgresDbCollection', () => {
unsafe: jest.fn().mockResolvedValue([]) // Default to empty array result
}
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, 'uuid', ns)
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, { idKey: 'uuid', ns })
})
describe('getTableName', () => {
@@ -51,17 +52,18 @@ describe('PostgresDbCollection', () => {
})
it('should return table name without namespace when ns is empty', () => {
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, 'uuid', '')
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, {
idKey: 'uuid',
ns: ''
})
expect(collection.getTableName()).toBe('workspace')
})
it('should return table name with custom namespace when ns is provided', () => {
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>(
'workspace',
mockClient as Sql,
'uuid',
'custom_account'
)
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, {
idKey: 'uuid',
ns: 'custom_account'
})
expect(collection.getTableName()).toBe('custom_account.workspace')
})
})
@@ -199,6 +201,92 @@ describe('PostgresDbCollection', () => {
])
})
})
describe('timestamp field handling', () => {
beforeEach(() => {
// Create collection with timestamp fields specified
collection = new PostgresDbCollection<TestWorkspace, 'uuid'>('workspace', mockClient as Sql, {
idKey: 'uuid',
ns,
timestampFields: ['lastProcessingTime', 'createdOn']
})
})
it('should convert string timestamps to numbers', async () => {
// Mock database returning string timestamps
mockClient.unsafe.mockResolvedValue([
{
uuid: 'ws1',
mode: 'active',
name: 'Test',
last_processing_time: '1234567890000',
created_on: '1234567891000'
}
])
const result = await collection.find({})
expect(result).toEqual([
{
uuid: 'ws1',
mode: 'active',
name: 'Test',
lastProcessingTime: 1234567890000,
createdOn: 1234567891000
}
])
expect(typeof result[0].lastProcessingTime).toBe('number')
expect(typeof result[0].createdOn).toBe('number')
})
it('should handle null timestamp values', async () => {
mockClient.unsafe.mockResolvedValue([
{
uuid: 'ws1',
mode: 'active',
name: 'Test',
last_processing_time: null,
created_on: null
}
])
const result = await collection.find({})
expect(result).toEqual([
{
uuid: 'ws1',
mode: 'active',
name: 'Test',
lastProcessingTime: null,
createdOn: null
}
])
})
it('should handle invalid timestamp strings', async () => {
mockClient.unsafe.mockResolvedValue([
{
uuid: 'ws1',
mode: 'active',
name: 'Test',
last_processing_time: 'invalid',
created_on: ''
}
])
const result = await collection.find({})
expect(result).toEqual([
{
uuid: 'ws1',
mode: 'active',
name: 'Test',
lastProcessingTime: null,
createdOn: null
}
])
})
})
})
describe('AccountPostgresDbCollection', () => {
+62 -20
View File
@@ -92,16 +92,37 @@ function formatVar (idx: number, type?: string): string {
return type != null ? `$${idx}::${type}` : `$${idx}`
}
export interface PostgresDbCollectionOptions<T extends Record<string, any>, K extends keyof T | undefined = undefined> {
idKey?: K
ns?: string
fieldTypes?: Record<string, string>
timestampFields?: Array<keyof T>
}
export class PostgresDbCollection<T extends Record<string, any>, K extends keyof T | undefined = undefined>
implements DbCollection<T> {
constructor (
readonly name: string,
readonly client: Sql,
readonly idKey?: K,
readonly ns?: string,
readonly fieldTypes: Record<string, string> = {}
readonly options: PostgresDbCollectionOptions<T, K> = {}
) {}
get ns (): string {
return this.options.ns ?? ''
}
get idKey (): K | undefined {
return this.options.idKey
}
get fieldTypes (): Record<string, string> {
return this.options.fieldTypes ?? {}
}
get timestampFields (): Array<keyof T> {
return this.options.timestampFields ?? []
}
getTableName (): string {
if (this.ns === '') {
return this.name
@@ -206,7 +227,13 @@ implements DbCollection<T> {
}
protected convertToObj (row: unknown): T {
return convertKeysToCamelCase(row) as T
const res = convertKeysToCamelCase(row)
for (const field of this.timestampFields) {
const val = Number.parseInt(res[field])
res[field] = Number.isNaN(val) ? null : val
}
return res as T
}
async find (query: Query<T>, sort?: Sort<T>, limit?: number, client?: Sql): Promise<T[]> {
@@ -319,11 +346,8 @@ export class AccountPostgresDbCollection
implements DbCollection<Account> {
private readonly passwordKeys = ['hash', 'salt']
constructor (
readonly client: Sql,
readonly ns?: string
) {
super('account', client, 'uuid', ns)
constructor (client: Sql, ns?: string) {
super('account', client, { idKey: 'uuid', ns })
}
getPasswordsTableName (): string {
@@ -413,18 +437,36 @@ export class PostgresAccountDB implements AccountDB {
readonly client: Sql,
readonly ns: string = 'global_account'
) {
this.person = new PostgresDbCollection<Person, 'uuid'>('person', client, 'uuid', ns)
this.person = new PostgresDbCollection<Person, 'uuid'>('person', client, { ns, idKey: 'uuid' })
this.account = new AccountPostgresDbCollection(client, ns)
this.socialId = new PostgresDbCollection<SocialId, '_id'>('social_id', client, '_id', ns)
this.workspaceStatus = new PostgresDbCollection<WorkspaceStatus>('workspace_status', client, undefined, ns)
this.workspace = new PostgresDbCollection<Workspace, 'uuid'>('workspace', client, 'uuid', ns)
this.accountEvent = new PostgresDbCollection<AccountEvent>('account_events', client, undefined, ns)
this.otp = new PostgresDbCollection<OTP>('otp', client, undefined, ns)
this.invite = new PostgresDbCollection<WorkspaceInvite, 'id'>('invite', client, 'id', ns)
this.mailbox = new PostgresDbCollection<Mailbox, 'mailbox'>('mailbox', client, undefined, ns)
this.mailboxSecret = new PostgresDbCollection<MailboxSecret>('mailbox_secrets', client, undefined, ns)
this.integration = new PostgresDbCollection<Integration>('integrations', client, undefined, ns)
this.integrationSecret = new PostgresDbCollection<IntegrationSecret>('integration_secrets', client, undefined, ns)
this.socialId = new PostgresDbCollection<SocialId, '_id'>('social_id', client, {
ns,
idKey: '_id',
timestampFields: ['createdOn', 'verifiedOn']
})
this.workspaceStatus = new PostgresDbCollection<WorkspaceStatus>('workspace_status', client, {
ns,
timestampFields: ['lastProcessingTime', 'lastVisit']
})
this.workspace = new PostgresDbCollection<Workspace, 'uuid'>('workspace', client, {
ns,
idKey: 'uuid',
timestampFields: ['createdOn']
})
this.accountEvent = new PostgresDbCollection<AccountEvent>('account_events', client, {
ns,
timestampFields: ['time']
})
this.otp = new PostgresDbCollection<OTP>('otp', client, { ns, timestampFields: ['expiresOn', 'createdOn'] })
this.invite = new PostgresDbCollection<WorkspaceInvite, 'id'>('invite', client, {
ns,
idKey: 'id',
timestampFields: ['expiresOn']
})
this.mailbox = new PostgresDbCollection<Mailbox, 'mailbox'>('mailbox', client, { ns })
this.mailboxSecret = new PostgresDbCollection<MailboxSecret>('mailbox_secrets', client, { ns })
this.integration = new PostgresDbCollection<Integration>('integrations', client, { ns })
this.integrationSecret = new PostgresDbCollection<IntegrationSecret>('integration_secrets', client, { ns })
}
getWsMembersTableName (): string {
+6 -2
View File
@@ -1449,6 +1449,10 @@ export async function findSocialIdBySocialKey (
const { socialKey, requireAccount } = params
decodeTokenVerbose(ctx, token)
if (socialKey == null || socialKey === '') {
throw new PlatformError(new Status(Severity.ERROR, platform.status.BadRequest, {}))
}
const socialIdObj = await db.socialId.findOne({ key: socialKey })
if (socialIdObj == null) {
@@ -1684,7 +1688,7 @@ export type AccountMethods =
| 'signUpJoin'
| 'confirm'
| 'changePassword'
| 'requestPassword'
| 'requestPasswordReset'
| 'restorePassword'
| 'leaveWorkspace'
| 'changeUsername'
@@ -1736,7 +1740,7 @@ export function getMethods (hasSignUp: boolean = true): Partial<Record<AccountMe
signUpJoin: wrap(signUpJoin),
confirm: wrap(confirm),
changePassword: wrap(changePassword),
requestPassword: wrap(requestPasswordReset),
requestPasswordReset: wrap(requestPasswordReset),
restorePassword: wrap(restorePassword),
leaveWorkspace: wrap(leaveWorkspace),
changeUsername: wrap(changeUsername),