diff --git a/src/backend/stores/user/UserStore.test.ts b/src/backend/stores/user/UserStore.test.ts index 4e5e4436f..2be59579e 100644 --- a/src/backend/stores/user/UserStore.test.ts +++ b/src/backend/stores/user/UserStore.test.ts @@ -224,6 +224,52 @@ describe('UserStore batched and uncached lookups', () => { expect((await server.stores.user.getByIds(null as never)).size).toBe(0); }); + it('getByEmails resolves known emails, skips unknown ones and dedupes input', async () => { + const a = await makeUser(); + const b = await makeUser(); + + const found = await server.stores.user.getByEmails([ + a.email, + b.email, + a.email, + 'nobody@nowhere.test', + '', + null as never, + ]); + + expect(found.size).toBe(2); + expect(found.get(a.email)?.username).toBe(a.username); + expect(found.get(b.email)?.username).toBe(b.username); + }); + + it('getByEmails tolerates empty and non-array input', async () => { + expect((await server.stores.user.getByEmails([])).size).toBe(0); + expect( + (await server.stores.user.getByEmails(null as never)).size, + ).toBe(0); + }); + + it('getByEmails serves warm entries from cache and cold ones from the database', async () => { + const warm = await makeUser(); + const cold = await makeUser(); + await server.stores.user.getByEmail(warm.email); + + const found = await server.stores.user.getByEmails([ + warm.email, + cold.email, + ]); + + expect(found.get(warm.email)?.id).toBe(warm.id); + expect(found.get(cold.email)?.id).toBe(cold.id); + }); + + it('getByEmails skips values no latin1 email column could hold', async () => { + // A `=`/`IN` against latin1 with a >U+00FF param is a collation error + // at MySQL, and no stored row could match it anyway. + const found = await server.stores.user.getByEmails(['ünïcodé😀@x.test']); + expect(found.size).toBe(0); + }); + it('getByIds serves warm ids from cache and cold ids from the database', async () => { const warm = await makeUser(); const cold = await makeUser(); diff --git a/src/backend/stores/user/UserStore.ts b/src/backend/stores/user/UserStore.ts index 6529ca571..836e6f9ba 100644 --- a/src/backend/stores/user/UserStore.ts +++ b/src/backend/stores/user/UserStore.ts @@ -238,6 +238,74 @@ export class UserStore extends PuterStore { return result; } + /** + * Batched sibling of {@link getByEmail}, shaped like {@link getByIds}. Keys + * the returned map by the email that was asked for, so a caller resolving a + * list of share recipients can map results back to its input. + * + * Emails that match no row are simply absent. + */ + async getByEmails(emails: string[]): Promise> { + const result = new Map(); + const unique = [ + ...new Set( + (Array.isArray(emails) ? emails : []).filter( + (email): email is string => + typeof email === 'string' && + email !== '' && + isStorableAsLatin1(email), + ), + ), + ]; + if (unique.length === 0) return result; + + const missing: string[] = []; + try { + const pipeline = this.clients.redis.pipeline(); + for (const email of unique) { + pipeline.get(this.#cacheKey('email', email)); + } + const cacheResults = (await pipeline.exec()) ?? []; + for (let i = 0; i < unique.length; i++) { + const email = unique[i]; + const raw = cacheResults[i]?.[1]; + if (typeof raw === 'string') { + try { + result.set(email, JSON.parse(raw) as UserRow); + continue; + } catch { + // Fall through to DB on any parse failure. + } + } + missing.push(email); + } + } catch { + missing.push(...unique); + } + + for ( + let offset = 0; + offset < missing.length; + offset += BULK_QUERY_CHUNK_SIZE + ) { + const chunk = missing.slice(offset, offset + BULK_QUERY_CHUNK_SIZE); + const placeholders = chunk.map(() => '?').join(', '); + const rows = (await this.clients.db.tryHardRead( + `SELECT * FROM \`user\` WHERE \`email\` IN (${placeholders})`, + chunk, + )) as Array>; + for (const row of rows) { + const user = this.#normalizeRow(row); + if (user.email) result.set(user.email, user); + this.#writeCache(user).catch(() => { + // Best-effort cache backfill. + }); + } + } + + return result; + } + /** * Look up a user by the canonical `clean_email` column. Used by signup and * OIDC link flows to collapse gmail-style aliases (`foo.bar+tag@…`) to the