mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 02:19:43 +00:00
perf(app): reduce redundant data fetching
Share bounded database pools across API repositories, consolidate sync and chat queries, and make mobile refreshes cache-aware and foreground-aware. Add focused regression coverage, client freshness documentation, and repository agent role configuration.
This commit is contained in:
1 parent
9957fa43f2
commit
c7ee8a4851
81 files changed
+4456
-1400
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import { Pool, type QueryResultRow } from 'pg';
|
||||
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
|
||||
|
||||
export type ChatMediaType = 'image' | 'video' | null;
|
||||
export type ReportReason = 'harassment' | 'spam' | 'inappropriate_content' | 'other';
|
||||
@@ -45,8 +46,37 @@ export class ChatError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reply to a message poll. A request without a cursor is a full snapshot; a
|
||||
* request with one carries only what changed, so a quiet chat costs an empty
|
||||
* array rather than the whole backlog.
|
||||
*/
|
||||
export interface MessagesPage {
|
||||
/** Newest first. Messages created or changed since the cursor, or the snapshot. */
|
||||
messages: ChatMessage[];
|
||||
/** Messages the caller deleted for themselves since the cursor. */
|
||||
removed_ids: string[];
|
||||
/** Cursor to send as `since` on the next poll. */
|
||||
server_time: string;
|
||||
/** True when `messages` is a complete snapshot the client may replace its cache with. */
|
||||
complete: boolean;
|
||||
/** Present only when the caller asked for it, folding the typing poll into this one. */
|
||||
typing?: PartnerTyping;
|
||||
}
|
||||
|
||||
export interface PartnerTyping {
|
||||
typing: boolean;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
export interface ListMessagesOptions {
|
||||
/** ISO timestamp from a previous `server_time`. Omit for a full snapshot. */
|
||||
since?: string;
|
||||
includeTyping?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatRepository {
|
||||
listMessages(userId: string, matchId: string): Promise<ChatMessage[]>;
|
||||
listMessages(userId: string, matchId: string, options?: ListMessagesOptions): Promise<MessagesPage>;
|
||||
sendText(userId: string, matchId: string, content: string): Promise<ChatMessage>;
|
||||
unreadCounts(userId: string): Promise<{ total: number; by_match: Record<string, number> }>;
|
||||
markMatchRead(userId: string, matchId: string): Promise<{ updated: number; read_at: string }>;
|
||||
@@ -55,13 +85,21 @@ export interface ChatRepository {
|
||||
deleteForEveryone(userId: string, messageId: string): Promise<ChatMessage>;
|
||||
report(userId: string, messageId: string, reason: ReportReason): Promise<void>;
|
||||
setTyping(userId: string, matchId: string, ttlMs: number): Promise<void>;
|
||||
getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }>;
|
||||
getPartnerTyping(userId: string, matchId: string): Promise<PartnerTyping>;
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
// `media_expired` is derived rather than read. Nothing ever writes the stored
|
||||
// column — it is inserted `false` and never updated — so a video that reached
|
||||
// `media_expires_at` still arrived flagged as playable, while the media itself
|
||||
// was already refused (see `accessible` in the media repository, which treats
|
||||
// `media_expired or media_expires_at <= now()` as gone). Deriving it here is the
|
||||
// same rule the media side already applies, and it is what makes the client's
|
||||
// "Video expired" state reachable at all.
|
||||
const columns = `m.id, m.match_id, m.user_id, m.content, m.created_at, m.read_at,
|
||||
m.delivered_at, m.deleted_at, m.media_path, m.media_type, m.media_expires_at,
|
||||
m.media_expired, m.media_viewed_at, m.version, m.encrypted_content,
|
||||
(m.media_expired or (m.media_expires_at is not null and m.media_expires_at <= now())) as media_expired,
|
||||
m.media_viewed_at, m.version, m.encrypted_content,
|
||||
m.encryption_iv, m.keys_metadata, m.moderation_status, m.flag_reason, m.category`;
|
||||
|
||||
function iso(value: Date | null): string | null {
|
||||
@@ -82,9 +120,12 @@ function toMessage(row: MessageRecord): ChatMessage {
|
||||
|
||||
export class PostgresChatRepository implements ChatRepository {
|
||||
private readonly pool: Pool;
|
||||
private readonly ownsPool: boolean;
|
||||
|
||||
constructor(databaseUrl: string) {
|
||||
this.pool = new Pool({ connectionString: databaseUrl });
|
||||
constructor(connection: DatabaseConnection) {
|
||||
const resolved = resolvePool(connection);
|
||||
this.pool = resolved.pool;
|
||||
this.ownsPool = resolved.owned;
|
||||
}
|
||||
|
||||
private async assertMatchAccess(userId: string, matchId: string): Promise<void> {
|
||||
@@ -112,20 +153,68 @@ export class PostgresChatRepository implements ChatRepository {
|
||||
return row;
|
||||
}
|
||||
|
||||
async listMessages(userId: string, matchId: string): Promise<ChatMessage[]> {
|
||||
/**
|
||||
* Without `since` this is the previous behaviour: the newest 500 visible messages.
|
||||
* With `since` it returns only rows whose visible state moved after that instant —
|
||||
* new messages, existing ones whose delivery, read, deletion or media-view
|
||||
* timestamp advanced, and videos whose media expired inside the window — plus the
|
||||
* ids the caller has since deleted for themselves.
|
||||
*
|
||||
* Moderation metadata (`moderation_status`, `flag_reason`, `category`) is not
|
||||
* covered: the classifier and the admin tool rewrite those columns without
|
||||
* advancing any timestamp, so a delta cannot see them move. No client renders
|
||||
* them today, so nothing user-visible goes stale; giving them a cursor would
|
||||
* mean adding a column to `messages`.
|
||||
* Rows are returned whole rather than as patches so the client merges by id and
|
||||
* needs no second shape to interpret.
|
||||
*
|
||||
* The returned cursor is deliberately a couple of seconds behind the server clock:
|
||||
* a transaction that started before this read but committed after it would
|
||||
* otherwise fall into the gap between two polls. The overlap re-delivers a handful
|
||||
* of rows the client already has, which its merge-by-id absorbs.
|
||||
*/
|
||||
async listMessages(userId: string, matchId: string, options: ListMessagesOptions = {}): Promise<MessagesPage> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
const result = await this.pool.query<MessageRecord>(
|
||||
`select ${columns}
|
||||
from messages m
|
||||
where m.match_id = $1
|
||||
and not exists (
|
||||
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2
|
||||
)
|
||||
order by m.created_at desc, m.id desc
|
||||
limit 500`,
|
||||
[matchId, userId],
|
||||
);
|
||||
return result.rows.map(toMessage);
|
||||
const since = options.since ?? null;
|
||||
const visible = `not exists (select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2)`;
|
||||
// Expiry is the one visible change no row write announces: the clock crosses
|
||||
// `media_expires_at` and nothing is updated, so no timestamp moves past the
|
||||
// cursor. Comparing the deadline against the window instead emits the row in
|
||||
// the single poll whose `(since, now()]` contains it, which is exactly when
|
||||
// the derived `media_expired` above flips.
|
||||
const changed = `(m.created_at > $3 or m.read_at > $3 or m.delivered_at > $3
|
||||
or m.deleted_at > $3 or m.media_viewed_at > $3
|
||||
or (m.media_expires_at > $3 and m.media_expires_at <= now()))`;
|
||||
|
||||
const [clock, messages, removed, typing] = await Promise.all([
|
||||
this.pool.query<{ cursor: Date }>(`select now() - interval '2 seconds' as cursor`),
|
||||
this.pool.query<MessageRecord>(
|
||||
`select ${columns}
|
||||
from messages m
|
||||
where m.match_id = $1 and ${visible}${since === null ? '' : ` and ${changed}`}
|
||||
order by m.created_at desc, m.id desc
|
||||
limit 500`,
|
||||
since === null ? [matchId, userId] : [matchId, userId, since],
|
||||
),
|
||||
since === null
|
||||
? null
|
||||
: this.pool.query<{ message_id: string }>(
|
||||
`select d.message_id
|
||||
from message_deletions d
|
||||
join messages m on m.id = d.message_id
|
||||
where m.match_id = $1 and d.user_id = $2 and d.deleted_at > $3`,
|
||||
[matchId, userId, since],
|
||||
),
|
||||
options.includeTyping ? this.partnerTyping(userId, matchId) : null,
|
||||
]);
|
||||
|
||||
return {
|
||||
messages: messages.rows.map(toMessage),
|
||||
removed_ids: removed?.rows.map((row) => row.message_id) ?? [],
|
||||
server_time: clock.rows[0]!.cursor.toISOString(),
|
||||
complete: since === null,
|
||||
...(typing ? { typing } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async sendText(userId: string, matchId: string, content: string): Promise<ChatMessage> {
|
||||
@@ -226,8 +315,8 @@ export class PostgresChatRepository implements ChatRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getPartnerTyping(userId: string, matchId: string): Promise<{ typing: boolean; expires_at: string | null }> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
/** Access already asserted by the caller, so the message poll can fold this in for free. */
|
||||
private async partnerTyping(userId: string, matchId: string): Promise<PartnerTyping> {
|
||||
const result = await this.pool.query<{ expires_at: Date }>(
|
||||
`select expires_at from chat_typing_states
|
||||
where match_id = $1 and user_id <> $2 and expires_at > now()
|
||||
@@ -238,8 +327,13 @@ export class PostgresChatRepository implements ChatRepository {
|
||||
return { typing: Boolean(expiresAt), expires_at: iso(expiresAt) };
|
||||
}
|
||||
|
||||
async getPartnerTyping(userId: string, matchId: string): Promise<PartnerTyping> {
|
||||
await this.assertMatchAccess(userId, matchId);
|
||||
return this.partnerTyping(userId, matchId);
|
||||
}
|
||||
|
||||
async close(): Promise<void> {
|
||||
await this.pool.end();
|
||||
await closeResolvedPool(this.pool, this.ownsPool);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ type AuthenticatedApp = Hono<{ Variables: { identity: AuthIdentity } }>;
|
||||
const id = z.string().uuid();
|
||||
const sendBody = z.object({ content: z.string().trim().min(1).max(10_000) }).strict();
|
||||
const reportBody = z.object({ reason: z.enum(['harassment', 'spam', 'inappropriate_content', 'other']) }).strict();
|
||||
const since = z.string().datetime({ offset: true });
|
||||
|
||||
function error(code: string, message: string): ApiErrorResponse {
|
||||
return { error: { code, message } };
|
||||
@@ -25,11 +26,20 @@ async function result<T>(operation: () => Promise<T>) {
|
||||
export function registerChatRoutes(app: AuthenticatedApp, repository: ChatRepository): void {
|
||||
app.get('/v1/chat/unread', async (c) => c.json(await repository.unreadCounts(c.get('identity').id)));
|
||||
|
||||
// `since` turns this into a delta poll and `typing=true` folds the typing check
|
||||
// into the same request. Both are optional: a call with neither still answers with
|
||||
// the full snapshot under the `messages` key it always used.
|
||||
app.get('/v1/matches/:matchId/messages', async (c) => {
|
||||
const parsedId = id.safeParse(c.req.param('matchId'));
|
||||
if (!parsedId.success) return c.json(error('invalid_match_id', 'A valid match ID is required'), 400);
|
||||
const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data));
|
||||
return response.ok ? c.json({ messages: response.value }) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
const rawSince = c.req.query('since');
|
||||
const parsedSince = rawSince === undefined ? undefined : since.safeParse(rawSince);
|
||||
if (parsedSince && !parsedSince.success) return c.json(error('invalid_since', 'since must be an ISO-8601 timestamp'), 400);
|
||||
const response = await result(() => repository.listMessages(c.get('identity').id, parsedId.data, {
|
||||
...(parsedSince?.success ? { since: parsedSince.data } : {}),
|
||||
includeTyping: c.req.query('typing') === 'true',
|
||||
}));
|
||||
return response.ok ? c.json(response.value) : c.json(error(response.cause.code, response.cause.message), response.cause.status);
|
||||
});
|
||||
|
||||
app.post('/v1/matches/:matchId/messages', async (c) => {
|
||||
|
||||
Reference in new issue
Block a user