feat(users): add a batched lookup by email

This commit is contained in:
Juan Castro
2026-08-12 16:07:13 -04:00
parent ec0a196fba
commit c39d48a9ac
2 changed files with 114 additions and 0 deletions
+46
View File
@@ -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();
+68
View File
@@ -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<Map<string, UserRow>> {
const result = new Map<string, UserRow>();
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<Record<string, unknown>>;
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