diff --git a/packages/account-client/src/client.ts b/packages/account-client/src/client.ts index 0736861866..5f942585ef 100644 --- a/packages/account-client/src/client.ts +++ b/packages/account-client/src/client.ts @@ -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 { - return await retry(5, async () => { + return await withRetryUntilMaxAttempts(async () => { const response = await fetch(concatLink(this.url, '/providers')) return await response.json() - }) + })() } - private async rpc(request: Request): Promise { + private async _rpc(request: Request): Promise { const timezone = getClientTimezone() const meta: Record = timezone !== undefined ? { 'X-Timezone': timezone } : {} const response = await fetch(this.url, { @@ -902,18 +904,40 @@ class AccountClientImpl implements AccountClient { } } -async function retry (retries: number, op: () => Promise, delay: number = 100): Promise { - 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 Promise> ( + f: F, + shouldFail: (err: any, attempt: number) => boolean, + intervalMs: number = 1000 +): F { + return async function (...params: any[]): Promise { + let attempt = 0 + while (true) { + try { + return await f(...params) + } catch (err: any) { + if (shouldFail(err, attempt)) { + throw err + } + + attempt++ + await new Promise((resolve) => setTimeout(resolve, intervalMs)) } } - } - throw error + } as F +} + +const connectionErrorCodes = ['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND'] + +function withRetryUntilTimeout Promise> (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 Promise> (f: F, maxAttempts: number = 5): F { + const shouldFail = (err: any, attempt: number): boolean => + !connectionErrorCodes.includes(err?.cause?.code) || attempt === maxAttempts + + return withRetry(f, shouldFail) } diff --git a/server/account/src/__tests__/postgres.test.ts b/server/account/src/__tests__/postgres.test.ts index 0a81955c37..ad8d315216 100644 --- a/server/account/src/__tests__/postgres.test.ts +++ b/server/account/src/__tests__/postgres.test.ts @@ -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('workspace', mockClient as Sql, 'uuid', ns) + collection = new PostgresDbCollection('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('workspace', mockClient as Sql, 'uuid', '') + collection = new PostgresDbCollection('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( - 'workspace', - mockClient as Sql, - 'uuid', - 'custom_account' - ) + collection = new PostgresDbCollection('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('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', () => { diff --git a/server/account/src/collections/postgres.ts b/server/account/src/collections/postgres.ts index 1b45f53845..bde9e0865c 100644 --- a/server/account/src/collections/postgres.ts +++ b/server/account/src/collections/postgres.ts @@ -92,16 +92,37 @@ function formatVar (idx: number, type?: string): string { return type != null ? `$${idx}::${type}` : `$${idx}` } +export interface PostgresDbCollectionOptions, K extends keyof T | undefined = undefined> { + idKey?: K + ns?: string + fieldTypes?: Record + timestampFields?: Array +} + export class PostgresDbCollection, K extends keyof T | undefined = undefined> implements DbCollection { constructor ( readonly name: string, readonly client: Sql, - readonly idKey?: K, - readonly ns?: string, - readonly fieldTypes: Record = {} + readonly options: PostgresDbCollectionOptions = {} ) {} + get ns (): string { + return this.options.ns ?? '' + } + + get idKey (): K | undefined { + return this.options.idKey + } + + get fieldTypes (): Record { + return this.options.fieldTypes ?? {} + } + + get timestampFields (): Array { + return this.options.timestampFields ?? [] + } + getTableName (): string { if (this.ns === '') { return this.name @@ -206,7 +227,13 @@ implements DbCollection { } 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, sort?: Sort, limit?: number, client?: Sql): Promise { @@ -319,11 +346,8 @@ export class AccountPostgresDbCollection implements DbCollection { 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', client, 'uuid', ns) + this.person = new PostgresDbCollection('person', client, { ns, idKey: 'uuid' }) this.account = new AccountPostgresDbCollection(client, ns) - this.socialId = new PostgresDbCollection('social_id', client, '_id', ns) - this.workspaceStatus = new PostgresDbCollection('workspace_status', client, undefined, ns) - this.workspace = new PostgresDbCollection('workspace', client, 'uuid', ns) - this.accountEvent = new PostgresDbCollection('account_events', client, undefined, ns) - this.otp = new PostgresDbCollection('otp', client, undefined, ns) - this.invite = new PostgresDbCollection('invite', client, 'id', ns) - this.mailbox = new PostgresDbCollection('mailbox', client, undefined, ns) - this.mailboxSecret = new PostgresDbCollection('mailbox_secrets', client, undefined, ns) - this.integration = new PostgresDbCollection('integrations', client, undefined, ns) - this.integrationSecret = new PostgresDbCollection('integration_secrets', client, undefined, ns) + this.socialId = new PostgresDbCollection('social_id', client, { + ns, + idKey: '_id', + timestampFields: ['createdOn', 'verifiedOn'] + }) + this.workspaceStatus = new PostgresDbCollection('workspace_status', client, { + ns, + timestampFields: ['lastProcessingTime', 'lastVisit'] + }) + this.workspace = new PostgresDbCollection('workspace', client, { + ns, + idKey: 'uuid', + timestampFields: ['createdOn'] + }) + this.accountEvent = new PostgresDbCollection('account_events', client, { + ns, + timestampFields: ['time'] + }) + this.otp = new PostgresDbCollection('otp', client, { ns, timestampFields: ['expiresOn', 'createdOn'] }) + this.invite = new PostgresDbCollection('invite', client, { + ns, + idKey: 'id', + timestampFields: ['expiresOn'] + }) + this.mailbox = new PostgresDbCollection('mailbox', client, { ns }) + this.mailboxSecret = new PostgresDbCollection('mailbox_secrets', client, { ns }) + this.integration = new PostgresDbCollection('integrations', client, { ns }) + this.integrationSecret = new PostgresDbCollection('integration_secrets', client, { ns }) } getWsMembersTableName (): string { diff --git a/server/account/src/operations.ts b/server/account/src/operations.ts index 758728c165..ea4a1d334f 100644 --- a/server/account/src/operations.ts +++ b/server/account/src/operations.ts @@ -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