mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 15:07:17 +00:00
feat(users): add a batched lookup by email
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user