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:
jjbrunton committed 2026-08-29 21:47:27 +01:00
1 parent 9957fa43f2
commit c7ee8a4851
81 files changed
+3464 -408

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
name = "architecture_reviewer"
description = "Read-only reviewer for cross-cutting or hard-to-reverse Sauci architecture decisions."
model = "gpt-5.6-sol"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Evaluate the delegated decision against current system boundaries, data flow,
migration path, operability, reversibility, and verification. Prefer the smallest
coherent change. Return explicit trade-offs, risks, and a recommendation with
file-backed evidence. Do not edit files or review unrelated local refactors.
"""
+12
View File
@@ -0,0 +1,12 @@
name = "database_reviewer"
description = "Read-only reviewer for Sauci migrations, RLS, authorization, and query boundaries."
model = "gpt-5.6-terra"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Read the applicable AGENTS.md, database migration skill, and migration safety
documentation. Review only the delegated diff for migration tracking,
idempotency, authorization and tenant isolation, portability, rollback impact,
types, and integration coverage. Never mutate a database or edit files. Return
prioritized line-specific findings with concrete evidence and counterevidence.
"""
+12
View File
@@ -0,0 +1,12 @@
name = "implementation"
description = "Default executor for a bounded Sauci product or harness change after scope and acceptance criteria are known."
model = "gpt-5.6-terra"
developer_instructions = """
Read the root and nearest scoped AGENTS.md files plus the applicable repository
skill. Implement only the delegated objective and preserve unrelated work. Update
maintained contract documentation and add focused tests when the contract changes.
Run the required focused checks and return changed files, exact results,
assumptions, and blockers. Stop for missing authority or a materially ambiguous
product choice. Do not expand scope or perform external, destructive, production,
release, or provider writes unless the task explicitly authorizes that action.
"""
+11
View File
@@ -0,0 +1,11 @@
name = "repo_scout"
description = "Read-only Sauci codebase scout for locating ownership, callers, contracts, tests, and existing patterns."
model = "gpt-5.6-luna"
model_reasoning_effort = "low"
sandbox_mode = "read-only"
developer_instructions = """
Read the root and nearest scoped AGENTS.md files. Map only the requested surface.
Return evidence-backed file paths, symbols, current behavior, dependencies, tests,
and uncertainties. Do not edit files, make design decisions, or propose broad
refactors. Do not claim runtime behavior you did not observe.
"""
+12
View File
@@ -0,0 +1,12 @@
name = "security_reviewer"
description = "Read-only reviewer for auth, RLS, cryptography, secrets, payments, privileged MCP, and destructive operations."
model = "gpt-5.6-sol"
model_reasoning_effort = "high"
sandbox_mode = "read-only"
developer_instructions = """
Perform a scoped threat-boundary review of the delegated change. Trace callers
through authentication and authorization to the data sink. Prioritize exploitable
correctness and security risks, include counterevidence, and cite exact files and
lines. Do not edit files or expand into a repository-wide audit unless explicitly
requested.
"""
+11
View File
@@ -0,0 +1,11 @@
name = "test_writer"
description = "Focused Sauci regression and contract-test author for already-defined behavior."
model = "gpt-5.6-terra"
model_reasoning_effort = "medium"
developer_instructions = """
Read the applicable AGENTS.md and testing documentation. Test the intended
observable contract at the lowest useful layer while preserving real auth,
authorization, tenant, and couple boundaries. Do not weaken assertions to
accommodate a failure. Run the focused test and return files changed, exact
commands, results, and any product defect exposed by the test.
"""
+11
View File
@@ -0,0 +1,11 @@
name = "verifier"
description = "Independent Sauci behavior verifier that drives completed work and returns evidence, not fixes."
model = "gpt-5.6-terra"
model_reasoning_effort = "medium"
developer_instructions = """
Read the applicable AGENTS.md and .codex/skills/verify/SKILL.md. Independently
exercise the delegated acceptance criteria against the running non-production
product. You may write generated proof only under evidence/. Do not edit source,
tests, configuration, or acceptance criteria. Return expected behavior, observed
behavior, evidence paths, environment limitations, and a works or broken verdict.
"""
+5
View File
@@ -0,0 +1,5 @@
[agents]
enabled = true
max_concurrent_threads_per_session = 3
default_subagent_model = "gpt-5.6-terra"
default_subagent_reasoning_effort = "medium"
+4 -1
View File
@@ -17,7 +17,10 @@ MCP service in one npm/Turborepo workspace.
5. Read `apps/mobile/DESIGN.md` before mobile UI work.
6. Keep shared package exports type-only and route them through `src/index.ts`.
7. Do not commit secrets, generated evidence, local logs, or runtime state.
8. Scope work to the task. Use high-effort reviewers only for the risk triggers in
8. Scope work to the task. Route bounded implementation through the project
`implementation` agent when delegation is useful; the primary agent retains
acceptance, review, and final verification. Do not delegate trivial edits.
9. Use high-effort reviewers only for the risk triggers in
`docs/agents/routing.md`.
## Repository map
+8
View File
@@ -3,6 +3,14 @@ DATABASE_URL=postgresql://sauci:sauci_local_only@127.0.0.1:54320/sauci
SUPABASE_AUTH_URL=https://your-project.supabase.co
SUPABASE_AUTH_AUDIENCE=authenticated
# Optional connection-pool bounds. The API process opens one pool and shares it
# across every repository, so these are the process-wide limits, not per-domain
# ones. Raising DATABASE_POOL_MAX raises the server connections this instance can
# hold; keep the total across instances inside the database's own limit.
# DATABASE_POOL_MAX=10
# DATABASE_POOL_IDLE_TIMEOUT_MS=30000
# DATABASE_POOL_CONNECTION_TIMEOUT_MS=10000
# Server-only hosted Auth Admin credential. Used for account deletion and for
# enriching admin user lists; it is never exposed to the browser or used as a
# Supabase database client.
+9
View File
@@ -7,6 +7,9 @@ const baseSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().min(1).max(65535).default(3003),
DATABASE_URL: z.string().url(),
DATABASE_POOL_MAX: z.coerce.number().int().min(1).max(200).default(10),
DATABASE_POOL_IDLE_TIMEOUT_MS: z.coerce.number().int().min(0).max(3_600_000).default(30_000),
DATABASE_POOL_CONNECTION_TIMEOUT_MS: z.coerce.number().int().min(0).max(60_000).default(10_000),
SUPABASE_AUTH_URL: z.string().url().optional(),
SUPABASE_AUTH_ISSUER: z.string().url().optional(),
SUPABASE_AUTH_JWKS_URL: z.string().url().optional(),
@@ -28,6 +31,7 @@ export interface AppConfig {
nodeEnv: 'development' | 'test' | 'production';
port: number;
databaseUrl: string;
databasePool: { max: number; idleTimeoutMillis: number; connectionTimeoutMillis: number };
authIssuer: string;
authJwksUrl?: string;
authAudience: string;
@@ -81,6 +85,11 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
nodeEnv: parsed.NODE_ENV,
port: parsed.PORT,
databaseUrl: parsed.DATABASE_URL,
databasePool: {
max: parsed.DATABASE_POOL_MAX,
idleTimeoutMillis: parsed.DATABASE_POOL_IDLE_TIMEOUT_MS,
connectionTimeoutMillis: parsed.DATABASE_POOL_CONNECTION_TIMEOUT_MS,
},
authIssuer,
authJwksUrl,
authAudience: parsed.SUPABASE_AUTH_AUDIENCE,
+50
View File
@@ -0,0 +1,50 @@
import { Pool } from 'pg';
/**
* A repository is constructed either from a connection string, in which case it
* owns the pool it creates and must end it, or from a pool the caller owns. The
* API process takes the second path so every domain shares one bounded pool
* instead of opening a default-sized pool per repository; tests keep taking the
* first so each suite stays independently closable.
*/
export type DatabaseConnection = string | Pool;
export interface DatabasePoolOptions {
/** Server-wide ceiling on concurrent PostgreSQL connections. */
max?: number;
idleTimeoutMillis?: number;
connectionTimeoutMillis?: number;
}
/**
* `pg` defaults to ten connections per pool, which multiplied by one pool per
* repository. One shared pool of the same size keeps the same headroom for a
* single API process while bounding what a self-hosted PostgreSQL must accept.
*/
export const DEFAULT_POOL_MAX = 10;
export function createDatabasePool(databaseUrl: string, options: DatabasePoolOptions = {}): Pool {
return new Pool({
connectionString: databaseUrl,
max: options.max ?? DEFAULT_POOL_MAX,
...(options.idleTimeoutMillis === undefined ? {} : { idleTimeoutMillis: options.idleTimeoutMillis }),
...(options.connectionTimeoutMillis === undefined ? {} : { connectionTimeoutMillis: options.connectionTimeoutMillis }),
});
}
export interface ResolvedPool {
pool: Pool;
/** True when this repository created the pool and is responsible for ending it. */
owned: boolean;
}
export function resolvePool(connection: DatabaseConnection): ResolvedPool {
return typeof connection === 'string'
? { pool: new Pool({ connectionString: connection }), owned: true }
: { pool: connection, owned: false };
}
/** Ends the pool only when this repository created it, so shared pools survive. */
export async function closeResolvedPool(pool: Pool, owned: boolean): Promise<void> {
if (owned) await pool.end();
}
+21 -3
View File
@@ -2,6 +2,7 @@ import type { FeatureInterestResponse, Profile } from '@sauci/shared';
import { and, eq, sql } from 'drizzle-orm';
import { drizzle, type NodePgDatabase } from 'drizzle-orm/node-postgres';
import { Pool } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from './pool.js';
import type { AuthIdentity } from '../auth.js';
import { featureInterests, profiles, type ProfileRow } from './schema.js';
@@ -44,10 +45,13 @@ function toProfile(row: ProfileRow): MobileCompatibleProfile {
export class PostgresRepository implements ApiRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
private readonly db: NodePgDatabase;
constructor(databaseUrl: string) {
this.pool = new Pool({ connectionString: databaseUrl });
constructor(connection: DatabaseConnection) {
const resolved = resolvePool(connection);
this.pool = resolved.pool;
this.ownsPool = resolved.owned;
this.db = drizzle(this.pool);
}
@@ -55,7 +59,21 @@ export class PostgresRepository implements ApiRepository {
await this.db.select({ id: profiles.id }).from(profiles).limit(1);
}
/**
* Idempotent for the common case. `GET /v1/me` runs on every app start and on
* every session refresh, and the previous unconditional upsert made each of
* those a write that advanced `auth_synced_at` and produced dead row versions
* for autovacuum. The only fields this sync can actually change are the ones
* the conflict clause sets, so a profile that already carries the identity's
* email is returned as read and nothing is written. First-use creation and
* email enrichment keep their existing behaviour.
*/
async upsertProfile(identity: AuthIdentity): Promise<MobileCompatibleProfile> {
const [existing] = await this.db.select().from(profiles).where(eq(profiles.id, identity.id)).limit(1);
if (existing && (identity.email === null || identity.email === undefined || existing.email === identity.email)) {
return toProfile(existing);
}
const [row] = await this.db.insert(profiles).values({
id: identity.id,
name: identity.name,
@@ -93,6 +111,6 @@ export class PostgresRepository implements ApiRepository {
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
@@ -1,4 +1,5 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { AccountOperationError } from './types.js';
interface ProfileOperationRecord extends QueryResultRow {
@@ -63,9 +64,12 @@ async function lockCoupleAndPartner(
export class PostgresAccountOperationsRepository implements AccountOperationsRepository {
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;
}
async deleteRelationship(userId: string): Promise<DestructiveOperationResult> {
@@ -135,7 +139,7 @@ export class PostgresAccountOperationsRepository implements AccountOperationsRep
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+5 -3
View File
@@ -1,4 +1,5 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { readFile } from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import { AdminError, type AdminPrincipal, type AdminQuery } from './types.js';
@@ -139,9 +140,10 @@ export interface AdminRepository {
export class PostgresAdminRepository implements AdminRepository {
private readonly pool: Pool;
constructor(databaseUrl: string, private readonly options: {
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection, private readonly options: {
adminPrivateKeyJwk?: string; mediaRoot?: string; authDirectory?: AdminAuthDirectory;
} = {}) { this.pool = new Pool({ connectionString: databaseUrl }); }
} = {}) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
async principal(userId: string): Promise<AdminPrincipal> {
const result = await this.pool.query<{ id: string; user_id: string; role: AdminPrincipal['role']; permissions: string[] }>(
@@ -356,5 +358,5 @@ export class PostgresAdminRepository implements AdminRepository {
await client.query('insert into audit_logs(admin_user_id,actor_user_id,table_name,action,record_id,old_values,new_values) values($1,$2,$3,$4,$5,$6,$7)', [principal.adminId, principal.userId, table, action, recordId || null, redactAudit(oldValues), redactAudit(newValues)]);
}
async close(): Promise<void> { await this.pool.end(); }
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
}
+20 -7
View File
@@ -1,4 +1,5 @@
import { Pool, type PoolClient } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { AnswersError, calculateMatchType, type Answer, type MatchType, type QuestionType, type ResponseData } from './types.js';
interface ContextRow { couple_id: string | null; gender: string | null; max_intensity: number; is_premium: boolean }
@@ -44,7 +45,7 @@ export interface AnswersRepository {
submit(userId: string, input: SubmitInput): Promise<{ response: ResponseRow; match: (MatchRow & { question: QuestionRow }) | null }>;
update(userId: string, input: UpdateInput): Promise<Record<string, unknown>>;
responses(userId: string, page: number, limit: number): Promise<{ responses: unknown[]; totalCount: number }>;
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number }>;
matches(userId: string, page: number, limit: number, archived: boolean): Promise<{ matches: unknown[]; totalCount: number | null; hasMore: boolean }>;
markSeen(userId: string, ids: string[]): Promise<void>;
archive(userId: string, matchId: string, archived: boolean): Promise<void>;
streak(userId: string): Promise<CoupleStreakView | null>;
@@ -53,8 +54,9 @@ export interface AnswersRepository {
export class PostgresAnswersRepository implements AnswersRepository {
private readonly pool: Pool;
private readonly ownsPool: boolean;
private zones: Promise<Set<string>> | null = null;
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 context(client: Pool | PoolClient, userId: string): Promise<ContextRow> {
const result = await client.query<ContextRow>('select couple_id, gender, max_intensity, is_premium from profiles where id=$1', [userId]);
@@ -230,12 +232,23 @@ export class PostgresAnswersRepository implements AnswersRepository {
return {responses:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
}
/**
* Reading one row past the page decides `hasMore` exactly, and the total — which
* the client only records when it refreshes — is counted on the first page only
* instead of on every scroll. Unread is aggregated once per couple rather than as
* a correlated count per row: the sort key depends on it, so the old subquery
* already ran for every candidate match, not just the twenty that were returned.
*/
async matches(userId:string,page:number,limit:number,archived:boolean){
const ctx=await this.context(this.pool,userId); const offset=page*limit;
const params=[userId,ctx.couple_id,archived,limit,offset];
const base=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1 where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
const [rows,count]=await Promise.all([this.pool.query(`select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,(select count(*)::int from messages msg where msg.match_id=m.id and msg.user_id<>$1 and msg.read_at is null) "unreadCount" ${base} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),this.pool.query<{count:number}>(`select count(*)::int count ${base}`,params.slice(0,3))]);
return {matches:rows.rows.map(this.dates),totalCount:count.rows[0]?.count??0};
const params=[userId,ctx.couple_id,archived,limit+1,offset];
const from=`from matches m join questions q on q.id=m.question_id left join match_archives a on a.match_id=m.id and a.user_id=$1`;
const where=`where m.couple_id=$2 and (($3 and a.id is not null) or (not $3 and a.id is null))`;
const unread=`unread as (select msg.match_id,count(*)::int c from messages msg join matches um on um.id=msg.match_id and um.couple_id=$2 where msg.user_id<>$1 and msg.read_at is null group by msg.match_id)`;
const [rows,count]=await Promise.all([
this.pool.query(`with ${unread} select m.*,json_build_object('id',q.id,'pack_id',q.pack_id,'text',q.text,'partner_text',q.partner_text,'intensity',q.intensity,'question_type',q.question_type,'config',q.config,'created_at',q.created_at) question,coalesce(u.c,0) "unreadCount" ${from} left join unread u on u.match_id=m.id ${where} order by "unreadCount" desc,m.created_at desc limit $4 offset $5`,params),
page===0?this.pool.query<{count:number}>(`select count(*)::int count ${from} ${where}`,params.slice(0,3)):null]);
return {matches:rows.rows.slice(0,limit).map(this.dates),totalCount:count?count.rows[0]?.count??0:null,hasMore:rows.rows.length>limit};
}
async markSeen(userId:string,ids:string[]){const ctx=await this.context(this.pool,userId);await this.pool.query('update matches set is_new=false where couple_id=$1 and id=any($2::uuid[])',[ctx.couple_id,ids]);}
async archive(userId:string,matchId:string,archived:boolean){const ctx=await this.context(this.pool,userId);const found=await this.pool.query('select 1 from matches where id=$1 and couple_id=$2',[matchId,ctx.couple_id]);if(!found.rowCount)throw new AnswersError('match_not_found',404,'Match was not found');if(archived)await this.pool.query('insert into match_archives(match_id,user_id) values($1,$2) on conflict do nothing',[matchId,userId]);else await this.pool.query('delete from match_archives where match_id=$1 and user_id=$2',[matchId,userId]);}
@@ -304,5 +317,5 @@ export class PostgresAnswersRepository implements AnswersRepository {
}
private async tx<T>(fn:(client:PoolClient)=>Promise<T>):Promise<T>{const c=await this.pool.connect();try{await c.query('begin');const value=await fn(c);await c.query('commit');return value;}catch(e){await c.query('rollback');throw e;}finally{c.release();}}
private dates<T extends Record<string,any>>(row:T):T{return Object.fromEntries(Object.entries(row).map(([k,v])=>[k,v instanceof Date?v.toISOString():v])) as T;}
async close(){await this.pool.end();}
async close(){await closeResolvedPool(this.pool,this.ownsPool);}
}
+129 -2
View File
@@ -1,4 +1,5 @@
import { Pool, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
export interface PackContext { id: string; name: string; icon: string | null }
export interface AppQuestion {
@@ -19,6 +20,50 @@ export interface StrokeSegment {
}
export interface LiveDrawState { strokes: StrokeSegment[]; revision: number; updated_at: string | null; updated_by: string | null }
/**
* A change summary rather than a payload. The mobile app used to re-fetch profile,
* couple, matches, both pending directions and enabled packs every five seconds to
* notice partner activity; it now compares this summary with the last one it saw and
* refreshes only the domains whose marker actually moved. Every field is a marker,
* not display data, so the response stays a few hundred bytes whatever the couple's
* history looks like.
*/
export interface SyncSummary {
server_time: string;
couple_id: string | null;
profile_updated_at: string | null;
partner_id: string | null;
partner_updated_at: string | null;
/** Matches visible to this member: archiving one of them moves the count. */
match_count: number;
new_match_count: number;
latest_match_at: string | null;
/** Partner answered, this member has not. */
pending_yours: number;
/** This member answered, partner has not. */
pending_theirs: number;
unread_total: number;
/** Digest of the enabled pack ids, so an equal-and-opposite toggle is still a change. */
enabled_packs_fingerprint: string;
/**
* Digest of each visible match's type and response summary. `match_count` /
* `new_match_count` / `latest_match_at` only move when a match is created,
* archived or unarchived — not when a partner edits an existing response and
* changes its `match_type` or `response_summary` — so this fingerprint is
* what actually notices that edit.
*/
match_state_fingerprint: string;
/**
* Digest of each visible match's unread count. `unread_total` can stay equal
* while unread messages redistribute between matches (one read, another
* receives a message), which reorders the unread-first list without moving
* the total; this fingerprint is what notices that.
*/
match_unread_fingerprint: string;
/** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */
streak_updated_at: string | null;
}
export class AppDataError extends Error {
constructor(public readonly code: 'not_found' | 'no_couple' | 'not_recipient' | 'rate_limited' | 'revision_conflict', message: string, public readonly status: 403 | 404 | 409 | 429, public readonly details?: Record<string, unknown>) { super(message); }
}
@@ -31,6 +76,7 @@ export interface AppDataRepository {
markMediaViewed(userId: string, messageId: string, expiresAt: string | null): Promise<{ media_viewed_at: string; media_expires_at: string | null }>;
nudgeStatus(userId: string): Promise<{ last_nudge_sent_at: string | null }>;
sendNudge(userId: string): Promise<{ success: true; notification_sent: boolean; reason?: string; next_nudge_available_at: string }>;
syncSummary(userId: string): Promise<SyncSummary>;
getLiveDraw(userId: string): Promise<LiveDrawState>;
putLiveDraw(userId: string, strokes: StrokeSegment[], baseRevision: number): Promise<LiveDrawState>;
close(): Promise<void>;
@@ -38,11 +84,22 @@ export interface AppDataRepository {
interface QuestionRow extends QueryResultRow, Omit<AppQuestion, 'created_at'> { created_at: Date }
interface SyncRow extends QueryResultRow {
server_time: Date; couple_id: string | null; profile_updated_at: Date | null;
partner_id: string | null; partner_updated_at: Date | null;
match_count: number | null; new_match_count: number | null; latest_match_at: Date | null;
pending_yours: number | null; pending_theirs: number | null; unread_total: number | null;
enabled_packs_fingerprint: string | null;
match_state_fingerprint: string | null; match_unread_fingerprint: string | null;
streak_updated_at: Date | null;
}
function question(row: QuestionRow): AppQuestion { return { ...row, created_at: row.created_at.toISOString() }; }
export class PostgresAppDataRepository implements AppDataRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
private async visiblePack(userId: string, packId: string): Promise<PackContext> {
const result = await this.pool.query<PackContext>(`
@@ -189,5 +246,75 @@ export class PostgresAppDataRepository implements AppDataRepository {
}
return { strokes: row.strokes, revision: Number(row.revision), updated_at: row.updated_at.toISOString(), updated_by: row.updated_by };
}
async close(): Promise<void> { await this.pool.end(); }
/**
* One round trip, all scalar subqueries, nothing that grows with the couple's
* history. An unpaired member's couple-scoped markers all collapse to zero or null
* because `me.couple_id` is null, so the endpoint stays valid before pairing.
*/
async syncSummary(userId: string): Promise<SyncSummary> {
const result = await this.pool.query<SyncRow>(`
with me as (select id, couple_id, updated_at from profiles where id = $1),
visible as (
select m.id, m.is_new, m.created_at, m.match_type, m.response_summary from matches m, me
where m.couple_id = me.couple_id
and not exists (select 1 from match_archives a where a.match_id = m.id and a.user_id = me.id)
),
visible_unread as (
select v.id as match_id, count(msg.id)::int as c
from visible v
left join messages msg on msg.match_id = v.id and msg.user_id <> (select id from me)
and msg.read_at is null and msg.deleted_at is null
and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = (select id from me))
group by v.id
)
select
now() as server_time,
me.couple_id,
me.updated_at as profile_updated_at,
(select p.id from profiles p where p.couple_id = me.couple_id and p.id <> me.id order by p.id limit 1) as partner_id,
(select max(p.updated_at) from profiles p where p.couple_id = me.couple_id and p.id <> me.id) as partner_updated_at,
(select count(*)::int from visible) as match_count,
(select count(*)::int from visible where is_new) as new_match_count,
(select max(created_at) from visible) as latest_match_at,
(select count(*)::int from responses r join questions q on q.id = r.question_id
where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id <> me.id
and not exists (select 1 from responses o
where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id = me.id)) as pending_yours,
(select count(*)::int from responses r join questions q on q.id = r.question_id
where r.couple_id = me.couple_id and q.deleted_at is null and r.user_id = me.id
and not exists (select 1 from responses o
where o.question_id = r.question_id and o.couple_id = me.couple_id and o.user_id <> me.id)) as pending_theirs,
(select count(*)::int from messages msg join matches ma on ma.id = msg.match_id
where ma.couple_id = me.couple_id and msg.user_id <> me.id and msg.read_at is null and msg.deleted_at is null
and not exists (select 1 from message_deletions d where d.message_id = msg.id and d.user_id = me.id)) as unread_total,
(select md5(coalesce(string_agg(cp.pack_id::text, ',' order by cp.pack_id), ''))
from couple_packs cp where cp.couple_id = me.couple_id and cp.enabled) as enabled_packs_fingerprint,
(select md5(coalesce(string_agg(v.id::text || ':' || v.match_type || ':' || coalesce(v.response_summary::text, ''), ',' order by v.id), ''))
from visible v) as match_state_fingerprint,
(select md5(coalesce(string_agg(vu.match_id::text || ':' || vu.c, ',' order by vu.match_id), ''))
from visible_unread vu) as match_unread_fingerprint,
(select cs.updated_at from couple_streaks cs where cs.couple_id = me.couple_id) as streak_updated_at
from me`, [userId]);
const row = result.rows[0];
if (!row) throw new AppDataError('not_found', 'Profile not found', 404);
return {
server_time: row.server_time.toISOString(),
couple_id: row.couple_id,
profile_updated_at: row.profile_updated_at?.toISOString() ?? null,
partner_id: row.partner_id,
partner_updated_at: row.partner_updated_at?.toISOString() ?? null,
match_count: row.match_count ?? 0,
new_match_count: row.new_match_count ?? 0,
latest_match_at: row.latest_match_at?.toISOString() ?? null,
pending_yours: row.pending_yours ?? 0,
pending_theirs: row.pending_theirs ?? 0,
unread_total: row.unread_total ?? 0,
enabled_packs_fingerprint: row.enabled_packs_fingerprint ?? '',
match_state_fingerprint: row.match_state_fingerprint ?? '',
match_unread_fingerprint: row.match_unread_fingerprint ?? '',
streak_updated_at: row.streak_updated_at?.toISOString() ?? null,
};
}
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
}
+5
View File
@@ -44,6 +44,11 @@ export function registerAppDataRoutes(app: App, repository: AppDataRepository):
const result = await run(() => repository.markMediaViewed(c.get('identity').id, id.data, body.data.expires_at));
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
// The client polls this instead of re-fetching every domain; see SyncSummary.
app.get('/v1/me/sync', async c => {
const result = await run(() => repository.syncSummary(c.get('identity').id));
return result.ok ? c.json(result.value) : c.json(error(result.cause.code, result.cause.message), result.cause.status);
});
app.get('/v1/me/nudge-status', async c => c.json(await repository.nudgeStatus(c.get('identity').id)));
app.post('/v1/me/nudge', async c => {
const result = await run(() => repository.sendNudge(c.get('identity').id));
+7 -3
View File
@@ -1,4 +1,5 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { BillingError, type RedemptionResult, type RevenueCatWebhook, type SubscriptionStatus } from './types.js';
interface ProfileRecord extends QueryResultRow {
@@ -43,9 +44,12 @@ async function transaction<T>(pool: Pool, operation: (client: PoolClient) => Pro
export class PostgresBillingRepository implements BillingRepository {
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;
}
async processRevenueCatEvent(
@@ -207,6 +211,6 @@ export class PostgresBillingRepository implements BillingRepository {
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+111 -17
View File
@@ -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>(
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 not exists (
select 1 from message_deletions d where d.message_id = m.id and d.user_id = $2
)
where m.match_id = $1 and ${visible}${since === null ? '' : ` and ${changed}`}
order by m.created_at desc, m.id desc
limit 500`,
[matchId, userId],
);
return result.rows.map(toMessage);
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);
}
}
+12 -2
View File
@@ -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) => {
+7 -3
View File
@@ -1,5 +1,6 @@
import type { Couple, Profile } from '@sauci/shared';
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import { CoupleError, type CoupleStateResponse } from './types.js';
interface ProfileRecord extends QueryResultRow {
@@ -93,9 +94,12 @@ async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<
export class PostgresCoupleRepository implements CoupleRepository {
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;
}
async getState(userId: string): Promise<CoupleStateResponse> {
@@ -199,6 +203,6 @@ export class PostgresCoupleRepository implements CoupleRepository {
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+7 -3
View File
@@ -1,4 +1,5 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
export type DareStatus =
| 'pending' | 'active' | 'submitted' | 'completed' | 'expired' | 'declined' | 'cancelled';
@@ -181,9 +182,12 @@ function toSentDare(row: SentDareRecord, viewerId: string): SentDare {
export class PostgresDaresRepository implements DaresRepository {
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;
}
/**
@@ -475,6 +479,6 @@ export class PostgresDaresRepository implements DaresRepository {
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+4 -2
View File
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import type { MediaKind, MediaObject, MediaUploadContext } from './types.js';
interface MediaRow extends QueryResultRow {
@@ -28,7 +29,8 @@ function object(row: MediaRow): MediaObject {
export class PostgresMediaRepository implements MediaRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
async create(userId: string, kind: MediaKind, storageKey: string, mimeType: string, byteSize: number, context: MediaUploadContext) {
const client = await this.pool.connect();
@@ -102,5 +104,5 @@ export class PostgresMediaRepository implements MediaRepository {
async acknowledgeDeletion(storageKey: string): Promise<void> {
await this.pool.query('delete from media_deletion_queue where storage_key=$1',[storageKey]);
}
async close() { await this.pool.end(); }
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
}
@@ -1,4 +1,5 @@
import { Pool, type PoolClient, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import type { ClassifierRuntimeConfig, MessageForClassification, OperationItem, ProducerSummary } from './types.js';
interface OutboxRow extends QueryResultRow {
@@ -31,7 +32,8 @@ async function transaction<T>(pool: Pool, work: (client: PoolClient) => Promise<
export class PostgresOperationsRepository implements OperationsRepository {
private readonly pool: Pool;
constructor(databaseUrl: string) { this.pool = new Pool({ connectionString: databaseUrl }); }
private readonly ownsPool: boolean;
constructor(connection: DatabaseConnection) { const resolved = resolvePool(connection); this.pool = resolved.pool; this.ownsPool = resolved.owned; }
async produce(now: Date, limit=100): Promise<ProducerSummary> {
return transaction(this.pool, async (client) => {
@@ -312,7 +314,7 @@ export class PostgresOperationsRepository implements OperationsRepository {
async classify(id:string,status:'safe'|'flagged',reason:string|null,category:string) {
await this.pool.query('update messages set moderation_status=$2,flag_reason=$3,category=$4 where id=$1',[id,status,reason,category]);
}
async close() { await this.pool.end(); }
async close(): Promise<void> { await closeResolvedPool(this.pool, this.ownsPool); }
}
function streakCopy(days:number) {
+7 -3
View File
@@ -1,4 +1,5 @@
import { Pool } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
export interface PackCategory {
id: string;
@@ -69,9 +70,12 @@ interface ProfilePreferencesRow {
export class PostgresPacksRepository implements PacksRepository {
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 profile(userId: string): Promise<ProfilePreferencesRow> {
@@ -215,6 +219,6 @@ export class PostgresPacksRepository implements PacksRepository {
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
@@ -1,5 +1,6 @@
import { randomUUID } from 'node:crypto';
import { Pool, type QueryResultRow } from 'pg';
import { closeResolvedPool, resolvePool, type DatabaseConnection } from '../../db/pool.js';
import type {
FeedbackSubmission,
NotificationPreferenceKey,
@@ -80,9 +81,12 @@ function assertChanged(rowCount: number | null): void {
export class PostgresProfileSettingsRepository implements ProfileSettingsRepository {
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;
}
async updateProfile(userId: string, update: ProfileUpdate): Promise<void> {
@@ -146,6 +150,6 @@ export class PostgresProfileSettingsRepository implements ProfileSettingsReposit
}
async close(): Promise<void> {
await this.pool.end();
await closeResolvedPool(this.pool, this.ownsPool);
}
}
+20 -26
View File
@@ -2,6 +2,7 @@ import { serve } from '@hono/node-server';
import { createApp } from './app.js';
import { createAuthVerifier } from './auth.js';
import { loadConfig } from './config.js';
import { createDatabasePool } from './db/pool.js';
import { PostgresRepository } from './db/repository.js';
import { PostgresAppDataRepository } from './domains/app-data/repository.js';
import { AdminRequestAuth } from './domains/admin/auth.js';
@@ -26,30 +27,34 @@ import { FilesystemMediaStorage, PostgresMediaRepository } from './domains/media
const config = loadConfig();
const authVerifier = createAuthVerifier(config);
const repository = new PostgresRepository(config.databaseUrl);
const coupleRepository = new PostgresCoupleRepository(config.databaseUrl);
const packsRepository = new PostgresPacksRepository(config.databaseUrl);
const answersRepository = new PostgresAnswersRepository(config.databaseUrl);
const chatRepository = new PostgresChatRepository(config.databaseUrl);
const daresRepository = new PostgresDaresRepository(config.databaseUrl);
const profileSettingsRepository = new PostgresProfileSettingsRepository(config.databaseUrl);
const accountOperationsRepository = new PostgresAccountOperationsRepository(config.databaseUrl);
// One deliberately bounded pool for the whole process. Repositories used to open
// a default-sized pool each, so a single API instance could hold twelve times the
// connections a self-hosted PostgreSQL was sized for.
const pool = createDatabasePool(config.databaseUrl, config.databasePool);
const repository = new PostgresRepository(pool);
const coupleRepository = new PostgresCoupleRepository(pool);
const packsRepository = new PostgresPacksRepository(pool);
const answersRepository = new PostgresAnswersRepository(pool);
const chatRepository = new PostgresChatRepository(pool);
const daresRepository = new PostgresDaresRepository(pool);
const profileSettingsRepository = new PostgresProfileSettingsRepository(pool);
const accountOperationsRepository = new PostgresAccountOperationsRepository(pool);
const accountOperationsService = new AccountOperationsService(
accountOperationsRepository,
new SupabaseAuthAdminClient(config.authIssuer, config.supabaseAuthServiceRoleKey),
new HttpRevenueCatClient(config.revenueCatApiKey, config.revenueCatEntitlementId),
new ExpoPartnerNotifier(),
);
const mediaRepository = new PostgresMediaRepository(config.databaseUrl);
const mediaRepository = new PostgresMediaRepository(pool);
const mediaStorage = new FilesystemMediaStorage(
config.mediaRoot,
config.mediaSigningSecret,
config.mediaPublicBaseUrl,
);
const appDataRepository = new PostgresAppDataRepository(config.databaseUrl);
const billingRepository = new PostgresBillingRepository(config.databaseUrl);
const appDataRepository = new PostgresAppDataRepository(pool);
const billingRepository = new PostgresBillingRepository(pool);
const billingService = new BillingService(billingRepository, config.revenueCatWebhookSecret);
const adminRepository = new PostgresAdminRepository(config.databaseUrl, {
const adminRepository = new PostgresAdminRepository(pool, {
adminPrivateKeyJwk: config.adminPrivateKeyJwk,
mediaRoot: config.mediaRoot,
authDirectory: new SupabaseAdminAuthDirectory(config.authIssuer, config.supabaseAuthServiceRoleKey),
@@ -94,20 +99,9 @@ async function shutdown(signal: string): Promise<void> {
server.close(async () => {
try {
await Promise.all([
repository.close(),
coupleRepository.close(),
packsRepository.close(),
answersRepository.close(),
chatRepository.close(),
daresRepository.close(),
profileSettingsRepository.close(),
accountOperationsRepository.close(),
mediaRepository.close(),
appDataRepository.close(),
billingRepository.close(),
adminRepository.close(),
]);
// Repositories share the process pool and no longer own a connection each,
// so ending the pool once is the whole of the database shutdown.
await pool.end();
process.exit(0);
} catch (error) {
console.error('Failed to close database connections', error);
+66 -1
View File
@@ -6,7 +6,7 @@ import { PostgresAnswersRepository } from '../src/domains/answers/repository.js'
const url=process.env.DATABASE_URL;const local=url?['127.0.0.1','localhost','::1'].includes(new URL(url).hostname):false;if(url&&!local)throw new Error('Answer integration tests only permit localhost');
describe.skipIf(!url||!local)('answers + PostgreSQL',()=>{const admin=new Pool({connectionString:url});const schema=`answers_${randomUUID().replaceAll('-','')}`;let pool:Pool;let repo:PostgresAnswersRepository;
const u1='11111111-1111-4111-8111-111111111111',u2='22222222-2222-4222-8222-222222222222',outsider='33333333-3333-4333-8333-333333333333',couple='44444444-4444-4444-8444-444444444444',other='55555555-5555-4555-8555-555555555555',pack='66666666-6666-4666-8666-666666666666',q='77777777-7777-4777-8777-777777777777',concurrentQ='88888888-8888-4888-8888-888888888888',femaleOnlyQ='99999999-9999-4999-8999-999999999999',limitQ1='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',limitQ2='bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',catchUpQ='cccccccc-cccc-4ccc-8ccc-cccccccccccc';
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
beforeAll(async()=>{await admin.query(`create schema "${schema}"`);const isolated=new URL(url!);isolated.searchParams.set('options',`-c search_path=${schema}`);pool=new Pool({connectionString:isolated.toString()});repo=new PostgresAnswersRepository(isolated.toString());for(const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql']){const sql=await readFile(new URL(`../drizzle/${name}`,import.meta.url),'utf8');for(const s of sql.split('--> statement-breakpoint'))if(s.trim())await pool.query(s);}await pool.query('insert into couples(id,invite_code) values($1,$2),($3,$4)',[couple,'ANSWERS1',other,'ANSWERS2']);await pool.query("insert into profiles(id,couple_id,gender,max_intensity) values($1,$4,'female',5),($2,$4,'male',3),($3,$5,'male',5)",[u1,u2,outsider,couple,other]);await pool.query("insert into question_packs(id,name) values($1,'Core')",[pack]);await pool.query("insert into questions(id,pack_id,text,intensity,target_user_genders) values($1,$2,'Question',3,array['female']),($3,$2,'Concurrent',2,null),($4,$2,'Female first',2,array['female']),($5,$2,'Limit one',2,null),($6,$2,'Limit two',2,null),($7,$2,'Catch up',2,null)",[q,pack,concurrentQ,femaleOnlyQ,limitQ1,limitQ2,catchUpQ]);await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);});afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
it('targets recommendation by gender and excludes completed responses',async()=>{expect((await repo.recommended(u1)).map(x=>x.id)).toContain(q);expect((await repo.recommended(u2)).map(x=>x.id)).not.toContain(q);await repo.submit(u1,{questionId:q,answer:'yes'});expect((await repo.recommended(u1)).map(x=>x.id)).not.toContain(q);});
it('rejects direct answers that bypass target-gender eligibility',async()=>{await expect(repo.submit(u2,{questionId:femaleOnlyQ,answer:'yes'})).rejects.toMatchObject({code:'question_not_eligible'});});
it('serializes concurrent partner submissions and still creates one match',async()=>{await Promise.all([repo.submit(u1,{questionId:concurrentQ,answer:'yes'}),repo.submit(u2,{questionId:concurrentQ,answer:'yes'})]);const counts=await pool.query('select count(*)::int count from matches where question_id=$1',[concurrentQ]);expect(counts.rows[0].count).toBe(1);});
@@ -18,6 +18,71 @@ it('answers the streak from the caller side rather than by stored position',asyn
expect(await repo.streak(u1)).toMatchObject({you_answered_today:true,partner_answered_today:false,partner_name:'Alex',current_streak:0});
expect(await repo.streak(u2)).toMatchObject({you_answered_today:false,partner_answered_today:true,partner_name:'Sam',current_streak:0});});
describe('match pagination',()=>{
const paged=Array.from({length:5},(_,index)=>`d0000000-0000-4000-8000-00000000000${index}`);
const pagedQuestions=Array.from({length:5},(_,index)=>`f0000000-0000-4000-8000-00000000000${index}`);
const otherMatch='e0000000-0000-4000-8000-000000000000';
beforeAll(async()=>{
await pool.query('delete from match_archives');await pool.query('delete from messages');await pool.query('delete from matches');
// One question per match: a couple may only match a given question once.
for(const[index,id]of pagedQuestions.entries())await pool.query("insert into questions(id,pack_id,text,intensity) values($1,$2,$3,2)",[id,pack,`Paged ${index}`]);
// Distinct creation times so the ordering assertion is about the query, not tie-breaking.
for(const[index,id]of paged.entries())await pool.query("insert into matches(id,couple_id,question_id,match_type,created_at) values($1,$2,$3,'yes_yes',now()-($4::text||' minutes')::interval)",[id,couple,pagedQuestions[index],String(index)]);
// A match belonging to another couple must never appear, whatever the page.
await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[otherMatch,other,q]);
// Two unread from the partner on the oldest match; one message of our own, which is never unread.
await pool.query("insert into messages(match_id,user_id,content) values($1,$2,'a'),($1,$2,'b'),($1,$3,'mine')",[paged[4],u2,u1]);
});
it('counts once on the first page, never again, and still reports hasMore',async()=>{
const first=await repo.matches(u1,0,2,false);
expect(first.totalCount).toBe(5);
expect(first.hasMore).toBe(true);
expect(first.matches).toHaveLength(2);
// Later pages skip the count entirely: the client already has it and paging
// is decided by hasMore, which comes from reading limit + 1 rows.
const second=await repo.matches(u1,1,2,false);
expect(second.totalCount).toBeNull();
expect(second.hasMore).toBe(true);
const last=await repo.matches(u1,2,2,false);
expect(last.totalCount).toBeNull();
expect(last.hasMore).toBe(false);
expect(last.matches).toHaveLength(1);
// An exactly-full final page must not claim another page exists.
expect(await repo.matches(u1,0,5,false)).toMatchObject({hasMore:false,totalCount:5});
});
it('keeps unread-first ordering, aggregates unread once, and isolates the other couple',async()=>{
const page=await repo.matches(u1,0,5,false);
// The unread match sorts ahead of newer ones; the rest stay newest-first.
expect(page.matches.map(item=>item.id)).toEqual([paged[4],paged[0],paged[1],paged[2],paged[3]]);
expect(page.matches[0]).toMatchObject({unreadCount:2});
// Our own message is not unread to us, and everything else is zero.
expect(page.matches.slice(1).every(item=>item.unreadCount===0)).toBe(true);
// Unread is per viewer: the partner's own two messages are not unread to them,
// but ours is, so the same thread reports a different count on each side.
expect((await repo.matches(u2,0,5,false)).matches.find(item=>item.id===paged[4])?.unreadCount).toBe(1);
// The other couple's match is invisible here, and their member sees only theirs.
expect(page.matches.some(item=>item.id===otherMatch)).toBe(false);
expect((await repo.matches(outsider,0,5,false)).matches.map(item=>item.id)).toEqual([otherMatch]);
});
it('pages the archived view separately and per user',async()=>{
await repo.archive(u1,paged[0],true);
const active=await repo.matches(u1,0,5,false);
expect(active.matches.some(item=>item.id===paged[0])).toBe(false);
expect(active.totalCount).toBe(4);
const archived=await repo.matches(u1,0,5,true);
expect(archived.matches.map(item=>item.id)).toEqual([paged[0]]);
expect(archived).toMatchObject({totalCount:1,hasMore:false});
// Archiving is per user, so the partner still sees it in their active list.
expect((await repo.matches(u2,0,5,false)).matches.some(item=>item.id===paged[0])).toBe(true);
await repo.archive(u1,paged[0],false);
});
});
it('counts the day in the couple shared zone and prefers the first reported member zone',async()=>{await pool.query('update profiles set timezone=$2 where id=$1',[u1,'Pacific/Auckland']);await pool.query('update profiles set timezone=$2 where id=$1',[u2,'America/Los_Angeles']);
await pool.query('delete from responses where couple_id=$1',[couple]);await pool.query('delete from matches where couple_id=$1',[couple]);
await repo.submit(u1,{questionId:q,answer:'yes'});
+11 -1
View File
@@ -10,7 +10,7 @@ const questionId='22222222-2222-4222-8222-222222222222';
function repo():AnswersRepository{return {
recommended:vi.fn(async()=>[]),pending:vi.fn(async()=>[]),answerGap:vi.fn(async()=>({unanswered_by_partner:0,threshold:10,is_blocked:false})),
dailyLimit:vi.fn(async()=>({responses_today:0,limit_value:0,remaining:0,reset_at:null,is_blocked:false})),
submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};}
submit:vi.fn(async()=>({response:{} as never,match:null})),update:vi.fn(async()=>({success:true})),responses:vi.fn(async()=>({responses:[],totalCount:0})),matches:vi.fn(async()=>({matches:[],totalCount:0,hasMore:false})),markSeen:vi.fn(async()=>undefined),archive:vi.fn(async()=>undefined),streak:vi.fn(async()=>null),close:vi.fn(async()=>undefined)};}
function app(r:AnswersRepository){const app=new Hono<{Variables:{identity:AuthIdentity}}>();app.use('/v1/*',async(c,n)=>{c.set('identity',identity);await n();});registerAnswerRoutes(app,r);return app;}
describe('answer routes',()=>{
@@ -18,6 +18,16 @@ describe('answer routes',()=>{
it('maps confirmation and domain failures',async()=>{const r=repo();vi.mocked(r.update).mockResolvedValueOnce({success:false,requires_confirmation:true,message_count:2});const a=app(r);expect(await (await a.request(`/v1/responses/${questionId}`,{method:'PATCH',headers:{'content-type':'application/json'},body:'{"new_answer":"no"}'})).json()).toMatchObject({requires_confirmation:true,message_count:2});vi.mocked(r.submit).mockRejectedValueOnce(new AnswersError('daily_limit',429,'Daily response limit reached',{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}));const blocked=await a.request('/v1/responses',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({question_id:questionId,answer:'yes'})});expect(blocked.status).toBe(429);
// The client renders the limit screen straight from this body, so its shape is a contract.
expect(await blocked.json()).toMatchObject({error:{code:'daily_limit'},details:{daily_limit:10,responses_today:10,remaining:0,reset_at:'2026-08-29T00:00:00.000Z'}});});
it('reports pagination from the repository, including a page with no total',async()=>{const r=repo();const a=app(r);
// Later pages skip the count entirely, so `hasMore` — not a client-side
// comparison against totalCount — is what decides whether to keep paging.
vi.mocked(r.matches).mockResolvedValueOnce({matches:[],totalCount:null,hasMore:true});
const later=await a.request('/v1/matches?page=2&limit=20');
expect(later.status).toBe(200);
expect(await later.json()).toMatchObject({totalCount:null,hasMore:true});
expect(r.matches).toHaveBeenCalledWith(identity.id,2,20,false);
await a.request('/v1/matches?page=0&limit=20&archived=true');
expect(r.matches).toHaveBeenLastCalledWith(identity.id,0,20,true);});
it('scopes match mutations to identity',async()=>{const r=repo();const a=app(r);await a.request('/v1/matches/seen',{method:'PATCH',headers:{'content-type':'application/json'},body:JSON.stringify({ids:[questionId]})});expect(r.markSeen).toHaveBeenCalledWith(identity.id,[questionId]);await a.request(`/v1/matches/${questionId}/archive`,{method:'PUT',headers:{'content-type':'application/json'},body:'{"archived":true}'});expect(r.archive).toHaveBeenCalledWith(identity.id,questionId,true);});
});
+25
View File
@@ -75,6 +75,31 @@ describe.skipIf(!databaseUrl || !localDatabase)('API + PostgreSQL integration',
},
});
// Reading the profile again must not rewrite it. Every authenticated request the
// app makes starts here, so an unconditional upsert turned a read into a write
// on the busiest path in the API.
const stamps = async () => (await pool.query<{ auth_synced_at: Date | null; updated_at: Date }>(
'select auth_synced_at, updated_at from profiles where id = $1',
['33333333-3333-4333-8333-333333333333'],
)).rows[0];
const created = await stamps();
expect(created.auth_synced_at).not.toBeNull();
for (let attempt = 0; attempt < 3; attempt += 1) {
expect((await app.request('/v1/me', { headers })).status).toBe(200);
}
const unchanged = await stamps();
expect(unchanged.auth_synced_at?.getTime()).toBe(created.auth_synced_at?.getTime());
expect(unchanged.updated_at.getTime()).toBe(created.updated_at.getTime());
// A genuine identity change still writes through, so first use and a changed
// email are both recorded rather than skipped.
await pool.query('update profiles set email = $2 where id = $1', ['33333333-3333-4333-8333-333333333333', 'stale@sauci.test']);
expect((await app.request('/v1/me', { headers })).status).toBe(200);
const resynced = await stamps();
expect(resynced.auth_synced_at!.getTime()).toBeGreaterThan(created.auth_synced_at!.getTime());
expect(await (await app.request('/v1/me', { headers })).json()).toMatchObject({ profile: { email: 'integration@sauci.test' } });
expect((await app.request('/v1/me/feature-interests/better-chat', { method: 'PUT', headers })).status).toBe(200);
expect(await (await app.request('/v1/me/feature-interests/better-chat', { headers })).json())
.toEqual({ feature: 'better-chat', interested: true });
@@ -30,6 +30,99 @@ describe.skipIf(!databaseUrl || !local)('app-data repository + PostgreSQL', () =
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'x.jpg','image')",[message,match,alice]);
});
afterAll(async()=>{await repo.close();await pool.end();await admin.query(`drop schema "${schema}" cascade`);await admin.end();});
it('summarises change markers per couple without leaking another couple state',async()=>{
// This one small read replaced a five-second refresh of profile, couple, matches,
// pending questions, packs and unread counts, so its markers have to move for
// exactly the changes those refreshes existed to notice.
const before=await repo.syncSummary(alice);
expect(before).toMatchObject({couple_id:couple,partner_id:bob,match_count:1,new_match_count:1,unread_total:0});
expect(before.profile_updated_at).not.toBeNull();
expect(Date.parse(before.server_time)).not.toBeNaN();
// The partner's own message is unread to them and not to us.
expect((await repo.syncSummary(bob)).unread_total).toBe(1);
// A partner profile edit moves the partner marker, which is what tells the
// client to re-read the couple rather than everything.
await pool.query("update profiles set name='Bobby', updated_at=now() where id=$1",[bob]);
const afterPartner=await repo.syncSummary(alice);
expect(Date.parse(afterPartner.partner_updated_at!)).toBeGreaterThan(Date.parse(before.partner_updated_at!));
expect(afterPartner.profile_updated_at).toBe(before.profile_updated_at);
// couple_packs carries no timestamp, so equal-and-opposite toggles have to be
// caught by a digest rather than a max(updated_at).
const emptyDigest=before.enabled_packs_fingerprint;
expect(emptyDigest).toEqual(expect.any(String));
await pool.query('insert into couple_packs(couple_id,pack_id,enabled) values($1,$2,true)',[couple,pack]);
const enabled=await repo.syncSummary(alice);
expect(enabled.enabled_packs_fingerprint).not.toBe(emptyDigest);
await pool.query('update couple_packs set enabled=false where couple_id=$1 and pack_id=$2',[couple,pack]);
expect((await repo.syncSummary(alice)).enabled_packs_fingerprint).toBe(emptyDigest);
// Toggling back must land on the same digest, so the client stops refetching.
await pool.query('update couple_packs set enabled=true where couple_id=$1 and pack_id=$2',[couple,pack]);
expect((await repo.syncSummary(alice)).enabled_packs_fingerprint).toBe(enabled.enabled_packs_fingerprint);
// An outsider's summary is about their own couple and never ours.
const other=await repo.syncSummary(outsider);
expect(other).toMatchObject({couple_id:otherCouple,partner_id:null,match_count:0,unread_total:0});
});
it('moves the match-state fingerprint on an in-place edit that the count and latest-at markers miss',async()=>{
// Editing an existing match's type/response summary (response editing) creates
// no new match and touches no created_at, so match_count/new_match_count/
// latest_match_at all hold steady; only the state fingerprint must move.
const before=await repo.syncSummary(alice);
await pool.query("update matches set match_type='yes_maybe', response_summary=$2 where id=$1",[match,JSON.stringify({edited:true})]);
const after=await repo.syncSummary(alice);
expect(after.match_count).toBe(before.match_count);
expect(after.new_match_count).toBe(before.new_match_count);
expect(after.latest_match_at).toBe(before.latest_match_at);
expect(after.match_state_fingerprint).not.toBe(before.match_state_fingerprint);
// Reverting lands back on the same digest, so the client stops refetching.
await pool.query("update matches set match_type='yes_yes', response_summary=null where id=$1",[match]);
expect((await repo.syncSummary(alice)).match_state_fingerprint).toBe(before.match_state_fingerprint);
});
it('moves the match-unread fingerprint when unread redistributes without changing the total',async()=>{
const secondMatch='cccccccc-cccc-4ccc-8ccc-cccccccccccc';
const secondQuestion='dddddddd-dddd-4ddd-8ddd-dddddddddddd';
const originalUnread='eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee';
const secondUnread='11111111-2222-4333-8444-555555555555';
await pool.query("insert into questions(id,pack_id,text) values($1,$2,'Question 2')",[secondQuestion,pack]);
await pool.query("insert into matches(id,couple_id,question_id,match_type) values($1,$2,$3,'yes_yes')",[secondMatch,couple,secondQuestion]);
// Alice's own message ('message', from beforeAll) is never unread to her, so
// give the original match a message from her partner to actually redistribute.
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'y.jpg','image')",[originalUnread,match,bob]);
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'y2.jpg','image')",[secondUnread,secondMatch,bob]);
const before=await repo.syncSummary(alice);
expect(before.unread_total).toBe(2);
// Read the original match's unread message while a new one lands in the
// second match: unread_total holds steady at 2, but which match owns it moves.
await pool.query('update messages set read_at=now() where id=$1',[originalUnread]);
const thirdMessage='ffffffff-ffff-4fff-8fff-ffffffffffff';
await pool.query("insert into messages(id,match_id,user_id,media_path,media_type) values($1,$2,$3,'z.jpg','image')",[thirdMessage,secondMatch,bob]);
const after=await repo.syncSummary(alice);
expect(after.unread_total).toBe(before.unread_total);
expect(after.match_unread_fingerprint).not.toBe(before.match_unread_fingerprint);
// Clean up so later assertions in this suite see the original single-match state.
await pool.query('delete from messages where id=any($1::uuid[])',[[originalUnread,secondUnread,thirdMessage]]);
await pool.query('delete from matches where id=$1',[secondMatch]);
await pool.query('delete from questions where id=$1',[secondQuestion]);
});
it('moves the streak marker from couple_streaks.updated_at',async()=>{
await pool.query('insert into couple_streaks(couple_id) values($1) on conflict do nothing',[couple]);
const before=await repo.syncSummary(alice);
expect(before.streak_updated_at).toEqual(expect.any(String));
await pool.query('update couple_streaks set current_streak=current_streak+1, updated_at=now() where couple_id=$1',[couple]);
const after=await repo.syncSummary(alice);
expect(Date.parse(after.streak_updated_at!)).toBeGreaterThan(Date.parse(before.streak_updated_at!));
});
it('returns only public catalog data and couple-owned match context',async()=>{
expect((await repo.packQuestions(alice,pack))[0]?.text).toBe('Question');
expect((await repo.matchContext(bob,match)).responses).toHaveLength(2);
+6
View File
@@ -16,6 +16,12 @@ function repository(): AppDataRepository {
markMediaViewed: vi.fn(async () => ({ media_viewed_at: 'now', media_expires_at: null })),
nudgeStatus: vi.fn(async () => ({ last_nudge_sent_at: null })),
sendNudge: vi.fn(async () => ({ success: true as const, notification_sent: false, reason: 'no_push_token', next_nudge_available_at: 'later' })),
syncSummary: vi.fn(async () => ({
server_time: 'now', couple_id: null, profile_updated_at: null, partner_id: null, partner_updated_at: null,
match_count: 0, new_match_count: 0, latest_match_at: null, pending_yours: 0, pending_theirs: 0,
unread_total: 0, enabled_packs_fingerprint: '',
match_state_fingerprint: '', match_unread_fingerprint: '', streak_updated_at: null,
})),
getLiveDraw: vi.fn(async () => ({ strokes: [], revision: 0, updated_at: null, updated_by: null })),
putLiveDraw: vi.fn(async () => ({ strokes: [], revision: 1, updated_at: 'now', updated_by: identity.id })),
close: vi.fn(async () => undefined),
+99 -3
View File
@@ -48,7 +48,7 @@ describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () =>
it('sends, lists, counts and reads text messages for couple members only', async () => {
const sent = await repository.sendText(alice, match, 'Hello');
expect((await repository.listMessages(bob, match))[0]?.content).toBe('Hello');
expect((await repository.listMessages(bob, match)).messages[0]?.content).toBe('Hello');
expect(await repository.unreadCounts(bob)).toEqual({ total: 1, by_match: { [match]: 1 } });
expect((await repository.markDelivered(bob, sent.id)).delivered_at).not.toBeNull();
expect((await repository.markMatchRead(bob, match)).updated).toBe(1);
@@ -58,14 +58,110 @@ describe.skipIf(!databaseUrl || !isLocal)('chat repository + PostgreSQL', () =>
it('enforces per-user and author-only deletions plus reports', async () => {
const sent = await repository.sendText(alice, match, 'Moderate me');
await repository.deleteForSelf(bob, sent.id);
expect((await repository.listMessages(bob, match)).some(item => item.id === sent.id)).toBe(false);
expect((await repository.listMessages(alice, match)).some(item => item.id === sent.id)).toBe(true);
expect((await repository.listMessages(bob, match)).messages.some(item => item.id === sent.id)).toBe(false);
expect((await repository.listMessages(alice, match)).messages.some(item => item.id === sent.id)).toBe(true);
await expect(repository.deleteForEveryone(bob, sent.id)).rejects.toMatchObject({ code: 'not_message_author' });
expect((await repository.deleteForEveryone(alice, sent.id)).deleted_at).not.toBeNull();
await repository.report(bob, sent.id, 'harassment');
await expect(repository.report(bob, sent.id, 'spam')).rejects.toBeInstanceOf(ChatError);
});
it('answers a delta with only what changed, and the same rows a full read would give', async () => {
const before = await repository.listMessages(bob, match);
const cursor = before.server_time;
expect(before.complete).toBe(true);
// Nothing has happened since the cursor, so the delta carries no row the client
// has not already seen. The cursor is deliberately held a couple of seconds
// behind wall clock so a write racing the read is repeated rather than lost;
// that repetition is absorbed by merging on id, never by blanking the thread.
const known = new Set(before.messages.map(item => item.id));
const quiet = await repository.listMessages(bob, match, { since: cursor });
expect(quiet.complete).toBe(false);
expect(quiet.messages.every(item => known.has(item.id))).toBe(true);
expect(quiet.removed_ids?.every(id => !known.has(id))).toBe(true);
const fresh = await repository.sendText(alice, match, 'Delta arrival');
const delta = await repository.listMessages(bob, match, { since: cursor });
expect(delta.messages.map(item => item.id)).toContain(fresh.id);
// Media and moderation columns must survive the narrower query.
expect(delta.messages.find(item => item.id === fresh.id)).toMatchObject({
content: 'Delta arrival', media_path: null, media_type: null, media_expired: false, read_at: null,
});
// A status change with no new row still reaches the sender through the delta.
const afterSend = await repository.listMessages(alice, match);
await repository.markMatchRead(bob, match);
const readDelta = await repository.listMessages(alice, match, { since: afterSend.server_time });
expect(readDelta.messages.find(item => item.id === fresh.id)?.read_at).not.toBeNull();
// So does a deletion, which arrives as a removal rather than a silent gap.
const doomed = await repository.sendText(alice, match, 'Delete me');
const beforeDelete = await repository.listMessages(bob, match);
await repository.deleteForSelf(bob, doomed.id);
const removal = await repository.listMessages(bob, match, { since: beforeDelete.server_time });
expect(removal.removed_ids).toContain(doomed.id);
// Ordering is the contract the screen renders against: newest first, both ways.
const full = await repository.listMessages(bob, match);
const timestamps = full.messages.map(item => Date.parse(item.created_at));
expect([...timestamps].sort((a, b) => b - a)).toEqual(timestamps);
// Access control is unchanged on the delta path.
await expect(repository.listMessages(outsider, match, { since: cursor })).rejects.toMatchObject({ code: 'match_not_found' });
});
it('surfaces an expiring video to a chat that is already open, and only in the window it expires', async () => {
// Expiry is the one visible change no write announces: the deadline simply
// passes. Backdated so neither `created_at` nor `media_viewed_at` can be what
// puts the row in a delta — only the deadline crossing may.
const video = await repository.sendText(alice, match, 'Sent a video');
await pool.query(
`update messages
set media_path = 'media:expiring', media_type = 'video',
moderation_status = 'safe', encrypted_content = 'cipher', encryption_iv = 'iv',
created_at = now() - interval '1 hour',
media_viewed_at = now() - interval '1 hour',
media_expires_at = now() + interval '1 hour'
where id = $1`,
[video.id],
);
const before = await repository.listMessages(bob, match);
expect(before.messages.find(item => item.id === video.id)).toMatchObject({ media_expired: false });
// A deadline still in the future is not a change, so the row is not re-sent on
// every poll until it arrives.
const quiet = await repository.listMessages(bob, match, { since: before.server_time });
expect(quiet.messages.some(item => item.id === video.id)).toBe(false);
// The deadline now falls inside this poll's window.
await pool.query(`update messages set media_expires_at = now() - interval '1 second' where id = $1`, [video.id]);
const delta = await repository.listMessages(bob, match, { since: before.server_time });
const expired = delta.messages.find(item => item.id === video.id);
// Media, encryption and moderation columns still arrive whole on the delta path.
expect(expired).toMatchObject({
media_expired: true, media_type: 'video', media_path: 'media:expiring',
moderation_status: 'safe', encrypted_content: 'cipher', encryption_iv: 'iv',
});
expect(expired?.media_expires_at).not.toBeNull();
expect(expired?.media_viewed_at).not.toBeNull();
// A full read agrees, so leaving and re-entering the chat shows the same thing.
expect((await repository.listMessages(bob, match)).messages.find(item => item.id === video.id))
.toMatchObject({ media_expired: true });
});
it('folds the partner typing check into the message read when asked', async () => {
await repository.setTyping(alice, match, 4_000);
const withTyping = await repository.listMessages(bob, match, { includeTyping: true });
expect(withTyping.typing?.typing).toBe(true);
// Own typing is never reflected back, so the sender does not see themselves.
expect((await repository.listMessages(alice, match, { includeTyping: true })).typing?.typing).toBe(false);
// And it stays absent unless requested, so the default read costs no extra work.
expect((await repository.listMessages(bob, match)).typing).toBeUndefined();
});
it('stores typing state with a short expiry and exposes only the partner state', async () => {
await repository.setTyping(alice, match, 4_000);
expect((await repository.getPartnerTyping(bob, match)).typing).toBe(true);
+24 -1
View File
@@ -11,7 +11,8 @@ const message = { id: messageId, match_id: matchId, user_id: identity.id, conten
function setup() {
const repository: ChatRepository = {
listMessages: vi.fn(async () => [message]), sendText: vi.fn(async () => message),
listMessages: vi.fn(async () => ({ messages: [message], removed_ids: [], server_time: new Date().toISOString(), complete: true })),
sendText: vi.fn(async () => message),
unreadCounts: vi.fn(async () => ({ total: 1, by_match: { [matchId]: 1 } })),
markMatchRead: vi.fn(async () => ({ updated: 1, read_at: new Date().toISOString() })),
markDelivered: vi.fn(async () => message), deleteForSelf: vi.fn(async () => undefined),
@@ -43,6 +44,28 @@ describe('chat routes', () => {
expect(repository.sendText).not.toHaveBeenCalled();
});
it('passes a delta cursor and folded typing check through to the repository', async () => {
const { app, repository } = setup();
const since = '2026-08-28T10:00:00.000Z';
const delta = await app.request(`/v1/matches/${matchId}/messages?since=${encodeURIComponent(since)}&typing=true`);
expect(delta.status).toBe(200);
expect(repository.listMessages).toHaveBeenCalledWith(identity.id, matchId, { since, includeTyping: true });
// Omitting both keeps the full-snapshot contract the client had before.
const snapshot = await app.request(`/v1/matches/${matchId}/messages`);
expect(snapshot.status).toBe(200);
expect(await snapshot.json()).toMatchObject({ messages: [{ id: messageId }] });
expect(repository.listMessages).toHaveBeenLastCalledWith(identity.id, matchId, { includeTyping: false });
});
it('refuses a malformed cursor rather than silently reading everything', async () => {
const { app, repository } = setup();
const response = await app.request(`/v1/matches/${matchId}/messages?since=yesterday`);
expect(response.status).toBe(400);
expect(await response.json()).toMatchObject({ error: { code: 'invalid_since' } });
expect(repository.listMessages).not.toHaveBeenCalled();
});
it('supports unread, typing, deletion and reporting actions', async () => {
const { app, repository } = setup();
expect((await app.request('/v1/chat/unread')).status).toBe(200);
+49
View File
@@ -0,0 +1,49 @@
import { Pool } from 'pg';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
DEFAULT_POOL_MAX,
closeResolvedPool,
createDatabasePool,
resolvePool,
} from '../src/db/pool.js';
const url = 'postgres://user:pass@127.0.0.1:5432/sauci_test';
afterEach(() => vi.restoreAllMocks());
describe('database pool', () => {
it('bounds the shared pool and only forwards timeouts that were configured', () => {
// A process-wide pool is the whole point of the change: an unbounded default
// multiplied by one pool per repository is what the self-hosted database had
// to absorb before.
const bounded = createDatabasePool(url, { max: 4, idleTimeoutMillis: 1_000, connectionTimeoutMillis: 2_000 });
expect(bounded.options.max).toBe(4);
expect(bounded.options.idleTimeoutMillis).toBe(1_000);
expect(bounded.options.connectionTimeoutMillis).toBe(2_000);
const defaults = createDatabasePool(url);
expect(defaults.options.max).toBe(DEFAULT_POOL_MAX);
return Promise.all([bounded.end(), defaults.end()]);
});
it('owns a pool it built from a URL and never ends one it was handed', async () => {
// Repositories in the API process share one pool, so closing any single
// repository must not take the connections out from under the others. Tests
// still construct from a URL and must be able to close what they created.
const owned = resolvePool(url);
expect(owned.owned).toBe(true);
const ownedEnd = vi.spyOn(owned.pool, 'end');
await closeResolvedPool(owned.pool, owned.owned);
expect(ownedEnd).toHaveBeenCalledTimes(1);
const shared = new Pool({ connectionString: url });
const borrowed = resolvePool(shared);
expect(borrowed.owned).toBe(false);
expect(borrowed.pool).toBe(shared);
const sharedEnd = vi.spyOn(shared, 'end');
await closeResolvedPool(borrowed.pool, borrowed.owned);
expect(sharedEnd).not.toHaveBeenCalled();
await shared.end();
});
});
+31 -45
View File
@@ -3,11 +3,12 @@ import { Ionicons } from "@expo/vector-icons";
import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState } from "react-native";
import { BlurView } from "expo-blur";
import { useEffect, useState, useCallback, useRef } from "react";
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store";
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, useStreakStore } from "../../src/store";
import { colors, radius, spacing, typography, shadows } from "../../src/theme";
import { authClient } from "../../src/lib/authClient";
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
import { useCoupleSync } from "../../src/hooks/useCoupleSync";
import { syncBadgeCount } from "../../src/lib/badge";
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
import { Events } from "../../src/lib/analytics";
@@ -59,9 +60,8 @@ export default function AppLayout() {
const router = useRouter();
const segments = useSegments();
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
const { newMatchesCount, pendingQuestions, fetchPendingQuestions, fetchMatches } = useMatchStore();
const { unreadCount, lastMessage, fetchUnreadCount, clearLastMessage } = useMessageStore();
const { fetchEnabledPacks } = usePacksStore();
const { newMatchesCount, pendingQuestions, matches, fetchPendingQuestions, fetchMatches } = useMatchStore();
const { unreadCount, lastMessage, clearLastMessage } = useMessageStore();
const { initializeRevenueCat, refreshSubscriptionStatus } = useSubscriptionStore();
const { fetchStreak } = useStreakStore();
const messageToastAnim = useRef(new Animated.Value(-100)).current;
@@ -173,12 +173,8 @@ export default function AppLayout() {
setIsLocked(false);
}, []);
// Fetch unread message count on mount
useEffect(() => {
if (user?.id) {
fetchUnreadCount();
}
}, [user?.id, fetchUnreadCount]);
// The unread badge arrives with the first sync summary, so it needs no request
// of its own here.
// Fetch streak data when user is in a couple
useEffect(() => {
@@ -187,12 +183,15 @@ export default function AppLayout() {
}
}, [user?.couple_id, fetchStreak]);
// Fetch pending questions (Your Turn) when user is in a couple
// Fetch pending questions (Your Turn) and matches when user is in a couple.
// Both feed tab badges, so they load once here and are kept current by the
// sync summary rather than by re-fetching on a timer.
useEffect(() => {
if (user?.couple_id) {
fetchPendingQuestions();
void fetchPendingQuestions();
void fetchMatches(true);
}
}, [user?.couple_id, fetchPendingQuestions]);
}, [user?.couple_id, fetchPendingQuestions, fetchMatches]);
// Sync app icon badge count whenever matches, messages, or pending questions change
useEffect(() => {
@@ -214,44 +213,31 @@ export default function AppLayout() {
}
}, [user?.id, user?.onboarding_completed]);
// Poll the self-hosted API for profile, match, pending-question and pack changes.
// This replaces the former Supabase Postgres change subscriptions.
// One small change summary drives every domain refresh. It replaces a
// five-second refetch of profile, matches, pending questions and packs plus a
// three-second unread poll, and only asks a store to reload when the server
// reports that its data actually moved.
useCoupleSync(user?.id, user?.couple_id);
// Match analytics are derived from the store rather than from a poll response,
// so they fire once per genuinely new match however it arrived.
useEffect(() => {
if (!user?.id) return;
let cancelled = false;
matchPollInitializedRef.current = false;
knownMatchIdsRef.current = new Set();
}, [user?.id, user?.couple_id]);
const poll = async () => {
await Promise.allSettled([
useAuthStore.getState().fetchUser(),
...(user.couple_id ? [fetchMatches(true), fetchPendingQuestions(), fetchEnabledPacks()] : []),
]);
if (cancelled || !user.couple_id) return;
const refreshed = useMatchStore.getState().matches;
if (matchPollInitializedRef.current) {
const added = refreshed.filter(item => !knownMatchIdsRef.current.has(item.id));
for (const item of added) Events.matchCreated(item.match_type || "unknown");
const previousCount = knownMatchIdsRef.current.size;
if (previousCount === 0 && added.length > 0) Events.firstMatch();
else if (added.length > 0 && refreshed.length % 10 === 0) Events.milestoneMatch(refreshed.length);
}
knownMatchIdsRef.current = new Set(refreshed.map(item => item.id));
matchPollInitializedRef.current = true;
};
void poll();
const timer = setInterval(() => void poll(), 5_000);
return () => { cancelled = true; clearInterval(timer); };
}, [user?.id, user?.couple_id, fetchMatches, fetchPendingQuestions, fetchEnabledPacks]);
// Poll the self-hosted API for unread chat state. Chat screens perform their
// own message polling; this keeps the global badge current without Supabase Realtime.
useEffect(() => {
if (!user?.couple_id) return;
const timer = setInterval(() => void fetchUnreadCount(), 3_000);
return () => clearInterval(timer);
}, [user?.couple_id, fetchUnreadCount]);
const known = knownMatchIdsRef.current;
const added = matches.filter(item => !known.has(item.id));
if (matchPollInitializedRef.current && added.length > 0) {
for (const item of added) Events.matchCreated(item.match_type || "unknown");
if (known.size === 0) Events.firstMatch();
else if (matches.length % 10 === 0) Events.milestoneMatch(matches.length);
}
knownMatchIdsRef.current = new Set(matches.map(item => item.id));
matchPollInitializedRef.current = true;
}, [matches, user?.couple_id]);
// Animate message toast when lastMessage changes
useEffect(() => {
+7 -7
View File
@@ -4,7 +4,7 @@ import { useFocusEffect } from 'expo-router';
import Animated, { FadeInDown } from 'react-native-reanimated';
import { Ionicons } from '@expo/vector-icons';
import { BlurView } from 'expo-blur';
import { useAuthStore, usePacksStore, useStreakStore, useSubscriptionStore } from '../../src/store';
import { useAuthStore, usePacksStore, useSubscriptionStore } from '../../src/store';
import { GradientBackground } from '../../src/components/ui';
import { Paywall } from '../../src/components/paywall';
import { CompactHeader, ContentRow, DaresTile, LiveDrawTile } from '../../src/components/discovery';
@@ -68,8 +68,7 @@ function groupPacksByCategory(
export default function DiscoveryScreen() {
const { user, partner, couple } = useAuthStore();
const { packs, categories, fetchPacks, getPackProgress } = usePacksStore();
const { fetchStreak } = useStreakStore();
const { packs, categories, fetchPacks, ensurePacksLoaded, getPackProgress } = usePacksStore();
const { subscription } = useSubscriptionStore();
const { width } = useWindowDimensions();
const [showPaywall, setShowPaywall] = useState(false);
@@ -79,12 +78,13 @@ export default function DiscoveryScreen() {
const isPremiumUser = user?.is_premium || partner?.is_premium || subscription.isProUser;
const isWideScreen = width > MAX_CONTENT_WIDTH;
// Refresh data when screen gains focus
// Load the catalog once. Returning to this tab shows what is cached; answering a
// question invalidates it, and StreakDisplay owns the streak, so neither needs a
// request here.
useFocusEffect(
useCallback(() => {
fetchPacks();
fetchStreak();
}, [fetchStreak])
void ensurePacksLoaded();
}, [ensurePacksLoaded])
);
// Group packs by category
+18 -8
View File
@@ -39,6 +39,7 @@ export default function MatchesScreen() {
fetchMatches,
markAllAsSeen,
isLoading,
isRefreshing,
hasMore,
isLoadingMore,
totalCount,
@@ -58,7 +59,9 @@ export default function MatchesScreen() {
fetchTheirTurnQuestions,
currentView,
setCurrentView,
ensureViewLoaded,
fetchPendingQuestions,
fetchArchivedMatches,
// Nudge state and methods
nudgeCooldownUntil,
isNudging,
@@ -118,14 +121,12 @@ export default function MatchesScreen() {
setShowTutorial(false);
};
// Load whichever view is showing, once. Returning to this tab reuses what is
// already cached; the sync poll refreshes it when the server reports a change,
// and pull-to-refresh remains the explicit way to force one.
useFocusEffect(
useCallback(() => {
fetchMatches(true);
// Also refresh pending questions when focused
fetchPendingQuestions();
fetchTheirTurnQuestions();
// Check nudge cooldown
checkNudgeCooldown();
ensureViewLoaded();
// Cleanup: mark matches as seen when leaving the screen (if on Complete tab)
return () => {
@@ -134,9 +135,15 @@ export default function MatchesScreen() {
state.markAllAsSeen();
}
};
}, [])
}, [ensureViewLoaded])
);
// The nudge cooldown only moves when this device sends a nudge, and sendNudge
// already records the new cooldown, so one read on mount is enough.
useEffect(() => {
void checkNudgeCooldown();
}, [checkNudgeCooldown]);
// Format cooldown remaining time
const formatCooldownTime = (cooldownUntil: Date): string => {
const now = new Date();
@@ -633,13 +640,16 @@ export default function MatchesScreen() {
refreshing={
currentView === 'pending' ? isLoadingPending :
currentView === 'their_turn' ? isLoadingTheirTurn :
isLoading
currentView === 'archived' ? isLoadingArchived :
isRefreshing
}
onRefresh={() => {
if (currentView === 'pending') {
fetchPendingQuestions();
} else if (currentView === 'their_turn') {
fetchTheirTurnQuestions();
} else if (currentView === 'archived') {
fetchArchivedMatches();
} else {
fetchMatches(true);
}
+6 -5
View File
@@ -47,7 +47,7 @@ const AnimatedSectionList = Animated.createAnimatedComponent(SectionList);
type GroupByOption = "pack" | "date" | "answer";
export default function MyAnswersScreen() {
const { responses, isLoading, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
const { responses, isLoading, isRefreshing, isLoadingMore, hasMore, groupBy, dateSortOrder, fetchResponses, ensureResponsesLoaded, setGroupBy, toggleDateSortOrder, totalCount } = useResponsesStore();
const { user, partner } = useAuthStore();
const router = useRouter();
const params = useLocalSearchParams();
@@ -110,11 +110,12 @@ export default function MyAnswersScreen() {
// Ambient orb breathing animations
const { orbStyle1, orbStyle2 } = useAmbientOrbAnimation();
// Fetch responses on focus
// Load once, then keep showing what is cached. Answering or editing a response
// invalidates it, and pull-to-refresh is always available.
useFocusEffect(
useCallback(() => {
fetchResponses(true);
}, [])
void ensureResponsesLoaded();
}, [ensureResponsesLoaded])
);
const handleLoadMore = useCallback(() => {
@@ -271,7 +272,7 @@ export default function MyAnswersScreen() {
scrollEventThrottle={16}
refreshControl={
<RefreshControl
refreshing={isLoading}
refreshing={isRefreshing}
onRefresh={() => fetchResponses(true)}
tintColor={colors.primary}
colors={[colors.primary]}
+2 -4
View File
@@ -44,7 +44,7 @@ const AnimatedFlatList = Animated.createAnimatedComponent(FlatList<Question>);
export default function PackDetailsScreen() {
const { id } = useLocalSearchParams();
const router = useRouter();
const { packs, fetchPacks } = usePacksStore();
const { packs, ensurePacksLoaded } = usePacksStore();
const { subscription } = useSubscriptionStore();
const { user, partner } = useAuthStore();
const [questions, setQuestions] = useState<Question[]>([]);
@@ -61,9 +61,7 @@ export default function PackDetailsScreen() {
const category = categories.find(c => c.id === pack?.category_id);
useEffect(() => {
if (packs.length === 0) {
fetchPacks();
}
void ensurePacksLoaded();
fetchPackDetails();
}, [id]);
@@ -17,13 +17,19 @@ jest.mock("expo-router", () => ({
jest.mock("@/store", () => {
const auth = { user: { id: "user-1" }, partner: { id: "user-2" }, couple: { id: "couple-1", couple_id: "couple-1" } };
const packs = { enabledPackIds: ["pack-1"], ensureEnabledPacksLoaded: async () => undefined, packs: [] };
const packState = { ...packs, invalidatePacks: jest.fn() };
const responses = { invalidateResponses: jest.fn() };
const useAuthStore = () => auth;
useAuthStore.getState = () => auth.user;
const usePacksStore = () => packs;
usePacksStore.getState = () => packState;
const useResponsesStore = () => responses;
useResponsesStore.getState = () => responses;
// Read lazily: the factory runs before this module's own consts are initialised.
const streak = { fetchStreak: () => mockFetchStreak() };
const useStreakStore = () => streak;
useStreakStore.getState = () => streak;
return { useAuthStore, usePacksStore: () => packs, useStreakStore };
return { useAuthStore, usePacksStore, useResponsesStore, useStreakStore };
});
jest.mock("@/lib/skippedQuestions", () => ({
@@ -45,17 +51,19 @@ const questions = [
{ id: "q2", pack_id: "pack-1", question_type: "swipe", text: "Two" },
];
const futureResetAt = "2099-08-29T00:00:00.000Z";
const openLimit = {
responses_today: 4,
limit_value: 10,
remaining: 6,
reset_at: "2026-08-29T00:00:00.000Z",
reset_at: futureResetAt,
is_blocked: false,
};
const dailyLimit429 = () => new ApiError("Daily response limit reached", 429, {
error: { code: "daily_limit", message: "Daily response limit reached" },
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: "2026-08-29T00:00:00.000Z" },
details: { daily_limit: 10, responses_today: 10, remaining: 0, reset_at: futureResetAt },
});
// clearMocks wipes implementations between tests, so re-arm the whole surface each time.
@@ -92,7 +100,7 @@ describe("useSwipeScreen daily limit", () => {
responses_today: 10,
limit_value: 10,
remaining: 0,
reset_at: "2026-08-29T00:00:00.000Z",
reset_at: futureResetAt,
is_blocked: true,
});
});
@@ -0,0 +1,313 @@
import { act, renderHook } from '@testing-library/react-native';
import { useCoupleSync, SYNC_INTERVAL_MS } from '@/hooks/useCoupleSync';
import { appDataApi, type SyncSummary } from '@/lib/appDataApi';
import { useAuthStore } from '@/store/authStore';
import { useMatchStore } from '@/store/matchStore';
import { useMessageStore } from '@/store/messageStore';
import { usePacksStore } from '@/store/packsStore';
import { useStreakStore } from '@/store/streakStore';
jest.mock('@/lib/appDataApi', () => ({ appDataApi: { syncSummary: jest.fn() } }));
const mockForeground = { value: true };
const mockListeners = new Set<(foreground: boolean) => void>();
jest.mock('@/lib/appForeground', () => ({
isForeground: () => mockForeground.value,
subscribeToForeground: (listener: (foreground: boolean) => void) => {
mockListeners.add(listener);
return () => { mockListeners.delete(listener); };
},
}));
const baseline: SyncSummary = {
server_time: '2026-08-27T12:00:00.000Z',
couple_id: 'couple',
profile_updated_at: '2026-08-01T00:00:00.000Z',
partner_id: 'partner',
partner_updated_at: '2026-08-01T00:00:00.000Z',
match_count: 3,
new_match_count: 0,
latest_match_at: '2026-08-20T00:00:00.000Z',
pending_yours: 1,
pending_theirs: 2,
unread_total: 0,
enabled_packs_fingerprint: 'digest-a',
match_state_fingerprint: 'state-a',
match_unread_fingerprint: 'unread-a',
streak_updated_at: '2026-08-01T00:00:00.000Z',
};
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const tick = async () => {
await flush();
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS); });
await flush();
};
/** Replaces every domain refresh the sync can dispatch with a counter. */
function stubRefreshers() {
const fetchUser = jest.fn(async () => undefined);
const fetchCouple = jest.fn(async () => undefined);
const fetchMatches = jest.fn(async () => undefined);
const fetchPendingQuestions = jest.fn(async () => undefined);
const fetchTheirTurnQuestions = jest.fn(async () => undefined);
const fetchEnabledPacks = jest.fn(async () => undefined);
const refreshStreak = jest.fn(async () => undefined);
useAuthStore.setState({ fetchUser, fetchCouple } as never);
useMatchStore.setState({ fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions } as never);
usePacksStore.setState({ fetchEnabledPacks } as never);
useStreakStore.setState({ refreshStreak } as never);
return { fetchUser, fetchCouple, fetchMatches, fetchPendingQuestions, fetchTheirTurnQuestions, fetchEnabledPacks, refreshStreak };
}
describe('useCoupleSync', () => {
let refreshers: ReturnType<typeof stubRefreshers>;
const originals = {
auth: { fetchUser: useAuthStore.getState().fetchUser, fetchCouple: useAuthStore.getState().fetchCouple },
match: {
fetchMatches: useMatchStore.getState().fetchMatches,
fetchPendingQuestions: useMatchStore.getState().fetchPendingQuestions,
fetchTheirTurnQuestions: useMatchStore.getState().fetchTheirTurnQuestions,
},
packs: { fetchEnabledPacks: usePacksStore.getState().fetchEnabledPacks },
streak: { refreshStreak: useStreakStore.getState().refreshStreak },
};
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockForeground.value = true;
mockListeners.clear();
useMessageStore.setState({ unreadCount: 0 });
// Every view starts loaded: this hook must refresh only what is on screen,
// and the "not loaded" case is asserted separately below.
useMatchStore.setState({ loadedAt: { active: 1, pending: 1, their_turn: 1 } });
usePacksStore.setState({ enabledPacksLoadedAt: Date.now() });
refreshers = stubRefreshers();
});
afterEach(() => {
jest.useRealTimers();
useAuthStore.setState(originals.auth as never);
useMatchStore.setState({ ...originals.match, loadedAt: {} } as never);
usePacksStore.setState({ ...originals.packs, enabledPacksLoadedAt: null } as never);
useStreakStore.setState(originals.streak as never);
});
const answer = (...summaries: SyncSummary[]) => {
const mock = appDataApi.syncSummary as jest.Mock;
mock.mockReset();
for (const summary of summaries) mock.mockResolvedValueOnce(summary);
mock.mockResolvedValue(summaries[summaries.length - 1]);
};
it('spends one request per interval and refreshes nothing while the markers hold still', async () => {
answer(baseline);
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
await tick();
await tick();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(3);
// Three polls, three requests in total: the baseline poll must not trigger
// the refresh of five domains that the old five-second sweep performed.
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
});
it('applies the unread total from the summary without a second request', async () => {
answer(baseline, { ...baseline, unread_total: 4 });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(useMessageStore.getState().unreadCount).toBe(4);
});
it('refreshes only the domain whose marker moved', async () => {
answer(baseline, { ...baseline, latest_match_at: '2026-08-28T00:00:00.000Z', match_count: 4 });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
expect(refreshers.fetchUser).not.toHaveBeenCalled();
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
});
it('routes each remaining marker to its own domain', async () => {
answer(baseline, {
...baseline,
pending_yours: 2,
pending_theirs: 3,
enabled_packs_fingerprint: 'digest-b',
partner_updated_at: '2026-08-29T00:00:00.000Z',
streak_updated_at: '2026-08-29T00:00:00.000Z',
});
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchPendingQuestions).toHaveBeenCalledWith({ silent: true });
expect(refreshers.fetchTheirTurnQuestions).toHaveBeenCalledWith({ silent: true });
expect(refreshers.fetchEnabledPacks).toHaveBeenCalledTimes(1);
expect(refreshers.refreshStreak).toHaveBeenCalledTimes(1);
// A partner profile change is a couple-level read; the whole user is only
// re-read when the user's own profile or couple membership moved.
expect(refreshers.fetchCouple).toHaveBeenCalledTimes(1);
expect(refreshers.fetchUser).not.toHaveBeenCalled();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
});
it('refreshes a loaded Matches view when the match-state fingerprint moves in place', async () => {
// Counts and latest-created-at do not move for an in-place match_type/
// response_summary edit; only the state fingerprint does.
answer(baseline, { ...baseline, match_state_fingerprint: 'state-b' });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
});
it('refreshes a loaded Matches view when unread redistributes without changing the total', async () => {
// One match is read while another receives a message: unread_total holds
// steady but the per-match fingerprint (and unread-first ordering) moves.
answer(baseline, { ...baseline, unread_total: baseline.unread_total, match_unread_fingerprint: 'unread-b' });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).toHaveBeenCalledWith(true, { silent: true });
});
it('does not refresh enabled packs when that domain has never been opened', async () => {
usePacksStore.setState({ enabledPacksLoadedAt: null });
answer(baseline, { ...baseline, enabled_packs_fingerprint: 'digest-b' });
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchEnabledPacks).not.toHaveBeenCalled();
});
it('resets the baseline when the account identity changes, instead of diffing against the prior account', async () => {
answer({ ...baseline, match_count: 3 });
const { rerender } = renderHook(
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
);
await flush();
await tick(); // establishes alice's baseline (match_count: 3)
// Switch accounts: bob's couple has a completely different match_count.
// Without a baseline reset this would look like a 12-count jump and
// trigger a refresh purely from comparing across accounts.
answer({ ...baseline, couple_id: 'couple-b', match_count: 15 });
rerender({ userId: 'bob', coupleId: 'couple-b' });
await flush();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
await tick();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
});
it('re-reads the user once when the profile moves, instead of the couple as well', async () => {
answer(baseline, {
...baseline,
profile_updated_at: '2026-08-29T00:00:00.000Z',
partner_updated_at: '2026-08-29T00:00:00.000Z',
});
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchUser).toHaveBeenCalledTimes(1);
expect(refreshers.fetchCouple).not.toHaveBeenCalled();
});
it('leaves views the user has never opened unloaded', async () => {
useMatchStore.setState({ loadedAt: {} });
answer(baseline, {
...baseline,
match_count: 9,
latest_match_at: '2026-08-29T00:00:00.000Z',
pending_yours: 7,
pending_theirs: 8,
});
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await tick();
expect(refreshers.fetchMatches).not.toHaveBeenCalled();
expect(refreshers.fetchPendingQuestions).not.toHaveBeenCalled();
expect(refreshers.fetchTheirTurnQuestions).not.toHaveBeenCalled();
});
it('stops entirely for a signed-out user and while the app is backgrounded', async () => {
answer(baseline);
const { unmount } = renderHook(() => useCoupleSync(undefined, null));
await flush();
await tick();
expect(appDataApi.syncSummary).not.toHaveBeenCalled();
unmount();
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
await act(async () => {
mockForeground.value = false;
for (const listener of [...mockListeners]) listener(false);
});
await tick();
await tick();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
});
it('discards a summary that lands after the account it belongs to was switched away', async () => {
// Alice's poll is still open when Bob signs in. Its answer describes a
// couple Bob is not in, so none of it may land: not his badge, not the
// baseline his next poll is diffed against, not a refresh decision.
let answerAlice: (summary: SyncSummary) => void = () => undefined;
const mock = appDataApi.syncSummary as jest.Mock;
mock.mockReset();
mock.mockReturnValueOnce(new Promise<SyncSummary>(resolve => { answerAlice = resolve; }));
const { rerender } = renderHook(
({ userId, coupleId }: { userId: string; coupleId: string }) => useCoupleSync(userId, coupleId),
{ initialProps: { userId: 'alice', coupleId: 'couple-a' } },
);
await flush();
expect(mock).toHaveBeenCalledTimes(1);
const bobBaseline: SyncSummary = { ...baseline, couple_id: 'couple-b', unread_total: 1, match_count: 2 };
mock.mockResolvedValue(bobBaseline);
rerender({ userId: 'bob', coupleId: 'couple-b' });
await flush();
expect(useMessageStore.getState().unreadCount).toBe(1);
// Alice's request finally answers, long after she stopped being the user.
await act(async () => {
answerAlice({ ...baseline, couple_id: 'couple-a', unread_total: 9, match_count: 42 });
await Promise.resolve();
});
await flush();
expect(useMessageStore.getState().unreadCount).toBe(1);
// Bob's baseline is still Bob's: a poll identical to his own first one has
// nothing to refresh. Had Alice's answer been recorded as the baseline,
// this would read as a 40-match jump and refresh the Matches view.
await tick();
expect(useMessageStore.getState().unreadCount).toBe(1);
for (const refresh of Object.values(refreshers)) expect(refresh).not.toHaveBeenCalled();
});
it('never runs two summaries at once', async () => {
let release: (summary: SyncSummary) => void = () => undefined;
(appDataApi.syncSummary as jest.Mock).mockReset();
(appDataApi.syncSummary as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
(appDataApi.syncSummary as jest.Mock).mockResolvedValue(baseline);
renderHook(() => useCoupleSync('me', 'couple'));
await flush();
await act(async () => { jest.advanceTimersByTime(SYNC_INTERVAL_MS * 4); });
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(1);
await act(async () => { release(baseline); await Promise.resolve(); });
await tick();
expect(appDataApi.syncSummary).toHaveBeenCalledTimes(2);
});
});
@@ -1,6 +1,7 @@
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { useDares } from "@/features/dares/hooks/useDares";
import { ApiError, apiClient } from "@/lib/apiClient";
import type { UseDaresReturn } from "@/features/dares/hooks/useDares";
import type { SentDare } from "@/features/dares/types";
const incoming: SentDare = {
@@ -90,3 +91,80 @@ describe("useDares", () => {
expect(result.current.error).toBe("offline");
});
});
describe("useDares polling cost", () => {
/** Counts requests per endpoint and lets the active payload be swapped mid-test. */
function mockCounting(active: () => SentDare[]) {
const calls = { active: 0, history: 0, catalog: 0, stats: 0 };
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
if (path.startsWith("/v1/dares?filter=active")) { calls.active += 1; return { dares: active() } as never; }
if (path.startsWith("/v1/dares?filter=history")) { calls.history += 1; return { dares: [] } as never; }
if (path === "/v1/dares/packs") { calls.catalog += 1; return catalog as never; }
if (path === "/v1/dares/stats") { calls.stats += 1; return stats as never; }
throw new Error(`unexpected path ${path}`);
});
return calls;
}
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const tick = async () => {
await flush();
await act(async () => { jest.advanceTimersByTime(5_000); });
await flush();
};
beforeEach(() => { jest.restoreAllMocks(); jest.useFakeTimers(); });
afterEach(() => jest.useRealTimers());
it("polls the active list alone and re-reads the static endpoints only when it changes", async () => {
let current: SentDare[] = [incoming];
const calls = mockCounting(() => current);
const { result } = renderHook(() => useDares({ isFocused: true }));
await flush();
expect(result.current.loading).toBe(false);
// The first pass is a full load: nothing is cached yet.
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
await tick();
await tick();
// Two quiet polls cost one request each, not four. The catalogue and the
// stats cannot move while the active set is byte-for-byte identical.
expect(calls).toEqual({ active: 3, history: 1, catalog: 1, stats: 1 });
current = [{ ...incoming, status: "active" }];
await tick();
expect(calls).toEqual({ active: 4, history: 2, catalog: 2, stats: 2 });
expect(result.current.active[0]?.status).toBe("active");
await tick();
expect(calls).toEqual({ active: 5, history: 2, catalog: 2, stats: 2 });
});
it("issues nothing at all while the screen is not on top", async () => {
const calls = mockCounting(() => [incoming]);
const { rerender } = renderHook<UseDaresReturn, { isFocused: boolean }>(
({ isFocused }) => useDares({ isFocused }),
{ initialProps: { isFocused: false } },
);
await tick();
await tick();
expect(calls).toEqual({ active: 0, history: 0, catalog: 0, stats: 0 });
rerender({ isFocused: true });
await flush();
expect(calls).toEqual({ active: 1, history: 1, catalog: 1, stats: 1 });
});
it("never overlaps two polls", async () => {
let release: (payload: { dares: SentDare[] }) => void = () => undefined;
jest.spyOn(apiClient, "get").mockImplementation(async (path: string) => {
if (path.startsWith("/v1/dares?filter=active")) return new Promise(resolve => { release = resolve; }) as never;
return { dares: [], ...catalog, ...stats } as never;
});
renderHook(() => useDares({ isFocused: true }));
await flush();
await act(async () => { jest.advanceTimersByTime(20_000); });
expect((apiClient.get as jest.Mock).mock.calls.filter(([path]) => String(path).includes("filter=active"))).toHaveLength(1);
await act(async () => { release({ dares: [] }); await Promise.resolve(); });
});
});
@@ -48,6 +48,60 @@ describe('useDrawingSync API polling', () => {
});
});
describe('useDrawingSync focus gating', () => {
const handlers = {
onStrokeStart: jest.fn(), onStrokeContinue: jest.fn(), onStrokeEnd: jest.fn(),
onClearCanvas: jest.fn(), onUndo: jest.fn(), onRedo: jest.fn(),
};
beforeEach(() => { jest.clearAllMocks(); jest.useFakeTimers(); });
afterEach(() => jest.useRealTimers());
it('reads the canvas once on mount and then polls only while the screen is on top', async () => {
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
const onLoadStrokes = jest.fn();
const { rerender } = renderHook<unknown, { isFocused: boolean }>(({ isFocused }) => useDrawingSync({
coupleId: 'couple', userId, onLoadStrokes, isFocused, ...handlers,
}), { initialProps: { isFocused: false } });
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
// A canvas nobody is looking at costs nothing beyond the state it loaded with.
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
rerender({ isFocused: true });
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
rerender({ isFocused: false });
await act(async () => { jest.advanceTimersByTime(750 * 8); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
});
it('never polls over its own write in flight', async () => {
(appDataApi.getLiveDraw as jest.Mock).mockResolvedValue({ strokes: [], revision: 1, updated_at: 'one', updated_by: userId });
let release: (state: unknown) => void = () => undefined;
(appDataApi.putLiveDraw as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; }));
const onLoadStrokes = jest.fn();
const { result } = renderHook(() => useDrawingSync({
coupleId: 'couple', userId, onLoadStrokes, isFocused: true, ...handlers,
}));
await waitFor(() => expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1));
const pending = result.current.persistStrokes([stroke]);
await act(async () => { jest.advanceTimersByTime(750 * 4); await Promise.resolve(); });
// The write returns the authoritative state; reading over the top of it would
// only race a revision we are about to replace.
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(1);
await act(async () => {
release({ strokes: [stroke], revision: 2, updated_at: 'two', updated_by: userId });
await pending;
});
await act(async () => { jest.advanceTimersByTime(750); await Promise.resolve(); });
expect(appDataApi.getLiveDraw).toHaveBeenCalledTimes(2);
});
});
describe('useDrawingSync stroke deduplication', () => {
const at = (id: string, points: number, userId = 'u1') => ({
id, userId, points: Array.from({ length: points }, (_, i) => ({ x: i, y: i })),
@@ -4,85 +4,183 @@ import { chatApi } from '@/lib/chatApi';
jest.mock('@/lib/chatApi', () => ({ chatApi: { listMessages: jest.fn(), markRead: jest.fn() } }));
const message = (id: string, user_id = 'partner') => ({ id, match_id: 'match1', user_id, content: id } as any);
const message = (id: string, user_id = 'partner', overrides: Record<string, unknown> = {}) => ({
id, match_id: 'match1', user_id, content: id,
created_at: '2026-08-27T12:00:00Z', read_at: null, ...overrides,
} as any);
describe('useMessageSubscription API polling', () => {
const snapshot = (messages: any[], server_time = 'cursor-1') => ({ messages, removed_ids: [], server_time, complete: true });
const delta = (messages: any[], options: { removed?: string[]; server_time?: string; typing?: boolean } = {}) => ({
messages,
removed_ids: options.removed ?? [],
server_time: options.server_time ?? 'cursor-2',
complete: false,
...(options.typing === undefined ? {} : { typing: { typing: options.typing, expires_at: null } }),
});
const listMessages = chatApi.listMessages as jest.Mock;
const markRead = chatApi.markRead as jest.Mock;
describe('useMessageSubscription incremental polling', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [] });
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: 'now' });
listMessages.mockResolvedValue(snapshot([]));
markRead.mockResolvedValue({ updated: 0, read_at: 'now' });
});
afterEach(() => jest.useRealTimers());
it('fails closed without both a match and authenticated user', async () => {
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
const mount = (config: Partial<Parameters<typeof useMessageSubscription>[0]> = {}) =>
renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, ...config }));
it('fails closed without both match and authenticated user', () => {
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string | undefined; userId: string | undefined }>(
({ matchId, userId }) => useMessageSubscription({ matchId, userId }),
{ initialProps: { matchId: undefined as string | undefined, userId: 'me' as string | undefined } },
);
expect(result.current.loading).toBe(false);
expect(chatApi.listMessages).not.toHaveBeenCalled();
expect(listMessages).not.toHaveBeenCalled();
rerender({ matchId: 'match1', userId: undefined });
expect(chatApi.listMessages).not.toHaveBeenCalled();
expect(listMessages).not.toHaveBeenCalled();
});
it('loads initial messages without treating them as newly arrived', async () => {
it('loads a snapshot first, then requests only changes since the server cursor', async () => {
listMessages.mockResolvedValueOnce(snapshot([message('m1')], 'cursor-1'));
const { result } = mount();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(listMessages).toHaveBeenNthCalledWith(1, 'match1', { typing: true });
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
expect(listMessages).toHaveBeenNthCalledWith(2, 'match1', { since: 'cursor-1', typing: true });
});
it('keeps cached messages, and their identity, when a delta carries nothing', async () => {
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
listMessages.mockResolvedValue(delta([]));
const { result } = mount();
await waitFor(() => expect(result.current.messages).toHaveLength(1));
const cached = result.current.messages;
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
expect(result.current.messages).toBe(cached);
expect(result.current.loading).toBe(false);
});
it('merges delta rows by id, applies self-deletions, and keeps newest first', async () => {
listMessages.mockResolvedValueOnce(snapshot([
message('m2', 'me', { created_at: '2026-08-27T12:00:02Z' }),
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen' }),
]));
listMessages.mockResolvedValueOnce(delta([
message('m3', 'partner', { created_at: '2026-08-27T12:00:03Z', read_at: 'seen' }),
message('m1', 'partner', { created_at: '2026-08-27T12:00:01Z', read_at: 'seen', deleted_at: 'gone' }),
], { removed: ['m2'] }));
const { result } = mount();
await waitFor(() => expect(result.current.messages).toHaveLength(2));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(result.current.messages).toHaveLength(2));
expect(result.current.messages.map(m => m.id)).toEqual(['m3', 'm1']);
expect(result.current.messages[1]?.deleted_at).toBe('gone');
});
it('marks read only when the response holds an unread partner message', async () => {
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner')]));
listMessages.mockResolvedValueOnce(delta([message('m2', 'me')]));
listMessages.mockResolvedValueOnce(delta([message('m1', 'partner', { read_at: 'seen' })]));
const { result } = mount();
await waitFor(() => expect(markRead).toHaveBeenCalledTimes(1));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(3));
expect(markRead).toHaveBeenCalledTimes(1);
expect(result.current.messages).toHaveLength(2);
});
it('issues no request at all while the chat is not focused', async () => {
const { rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { focused: boolean }>(
({ focused }) => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500, isFocused: focused }),
{ initialProps: { focused: false } },
);
await act(async () => { jest.advanceTimersByTime(2_000); });
expect(listMessages).not.toHaveBeenCalled();
expect(markRead).not.toHaveBeenCalled();
rerender({ focused: true });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(1));
rerender({ focused: false });
const callsAtBlur = listMessages.mock.calls.length;
await act(async () => { jest.advanceTimersByTime(2_000); });
expect(listMessages).toHaveBeenCalledTimes(callsAtBlur);
});
it('reports partner arrivals once, and never for the initial load or own messages', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock).mockResolvedValue({ messages: [message('m1')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage }));
listMessages.mockResolvedValueOnce(snapshot([message('m1', 'partner', { read_at: 'seen' })]));
listMessages.mockResolvedValueOnce(delta([message('mine', 'me')]));
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner')]));
listMessages.mockResolvedValueOnce(delta([message('m2', 'partner', { read_at: 'seen' })]));
const { result } = mount({ onNewMessage });
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toEqual([message('m1')]);
expect(onNewMessage).not.toHaveBeenCalled();
expect(chatApi.markRead).not.toHaveBeenCalled();
});
it('refreshes on the polling interval, marks focused messages read, and reports new partner messages', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock)
.mockResolvedValueOnce({ messages: [message('m1')] })
.mockResolvedValueOnce({ messages: [message('m2'), message('m1')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => { result.current.isFocusedRef.current = true; });
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
await waitFor(() => expect(result.current.messages[0]?.id).toBe('m2'));
expect(chatApi.markRead).toHaveBeenCalledWith('match1');
for (const expected of [2, 3, 4]) {
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(expected));
}
expect(onNewMessage).toHaveBeenCalledTimes(1);
});
it('does not notify for the current user own newly polled message', async () => {
const onNewMessage = jest.fn();
(chatApi.listMessages as jest.Mock)
.mockResolvedValueOnce({ messages: [] })
.mockResolvedValueOnce({ messages: [message('mine', 'me')] });
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', onNewMessage, pollInterval: 500 }));
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => { jest.advanceTimersByTime(500); await Promise.resolve(); });
expect(onNewMessage).not.toHaveBeenCalled();
it('reads partner typing state out of the message response', async () => {
listMessages.mockResolvedValueOnce({ ...snapshot([]), typing: { typing: true, expires_at: 'later' } });
const { result } = mount();
await waitFor(() => expect(result.current.partnerTyping).toBe(true));
listMessages.mockResolvedValueOnce(delta([], { typing: false }));
await act(async () => { jest.advanceTimersByTime(500); });
await waitFor(() => expect(result.current.partnerTyping).toBe(false));
});
it('surfaces loading completion and preserves a usable empty state after an API error', async () => {
(chatApi.listMessages as jest.Mock).mockRejectedValueOnce(new Error('forbidden'));
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
listMessages.mockRejectedValueOnce(new Error('forbidden'));
const { result } = mount();
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages).toEqual([]);
});
it('prevents overlapping polls and clears the interval on unmount', async () => {
let resolveFirst!: (value: { messages: any[] }) => void;
(chatApi.listMessages as jest.Mock).mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
const { unmount } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me', pollInterval: 500 }));
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
act(() => { jest.advanceTimersByTime(1_500); });
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
await act(async () => resolveFirst({ messages: [] }));
it('prevents overlapping polls and stops on unmount', async () => {
let resolveFirst!: (value: unknown) => void;
listMessages.mockImplementationOnce(() => new Promise(resolve => { resolveFirst = resolve; }));
const { unmount } = mount();
expect(listMessages).toHaveBeenCalledTimes(1);
await act(async () => { jest.advanceTimersByTime(1_500); });
expect(listMessages).toHaveBeenCalledTimes(1);
await act(async () => { resolveFirst(snapshot([])); });
unmount();
act(() => { jest.advanceTimersByTime(2_000); });
expect(chatApi.listMessages).toHaveBeenCalledTimes(1);
await act(async () => { jest.advanceTimersByTime(2_000); });
expect(listMessages).toHaveBeenCalledTimes(1);
});
it('restarts immediately, and without a stale cursor, for a different match', async () => {
listMessages.mockResolvedValue(snapshot([message('m1')], 'cursor-1'));
const { result, rerender } = renderHook<ReturnType<typeof useMessageSubscription>, { matchId: string }>(
({ matchId }) => useMessageSubscription({ matchId, userId: 'me', pollInterval: 500 }),
{ initialProps: { matchId: 'match1' } },
);
await waitFor(() => expect(result.current.messages).toHaveLength(1));
rerender({ matchId: 'match2' });
await waitFor(() => expect(listMessages).toHaveBeenCalledTimes(2));
expect(listMessages).toHaveBeenNthCalledWith(2, 'match2', { typing: true });
});
it('supports optimistic external updates', async () => {
const { result } = renderHook(() => useMessageSubscription({ matchId: 'match1', userId: 'me' }));
const { result } = mount();
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => result.current.setMessages([message('optimistic', 'me')]));
expect(result.current.messages[0]?.id).toBe('optimistic');
@@ -0,0 +1,141 @@
import { act, renderHook } from '@testing-library/react-native';
import { usePolling } from '@/hooks/usePolling';
const mockForeground = { value: true };
const mockListeners = new Set<(foreground: boolean) => void>();
jest.mock('@/lib/appForeground', () => ({
isForeground: () => mockForeground.value,
subscribeToForeground: (listener: (foreground: boolean) => void) => {
mockListeners.add(listener);
return () => { mockListeners.delete(listener); };
},
}));
const setForeground = async (value: boolean) => {
await act(async () => {
mockForeground.value = value;
for (const listener of [...mockListeners]) listener(value);
});
};
// The next run is scheduled once the previous one settles, so let pending
// promises land before and after moving the clock.
const flush = async () => { await act(async () => { await Promise.resolve(); await Promise.resolve(); }); };
const tick = async (ms: number) => {
await flush();
await act(async () => { jest.advanceTimersByTime(ms); });
await flush();
};
describe('usePolling', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.useFakeTimers();
mockForeground.value = true;
mockListeners.clear();
});
afterEach(() => jest.useRealTimers());
it('runs once immediately and then on the interval', async () => {
const task = jest.fn(async () => undefined);
renderHook(() => usePolling(task, { intervalMs: 100 }));
expect(task).toHaveBeenCalledTimes(1);
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
await tick(100);
expect(task).toHaveBeenCalledTimes(3);
});
it('skips the leading run when asked to', async () => {
const task = jest.fn(async () => undefined);
renderHook(() => usePolling(task, { intervalMs: 100, leading: false }));
expect(task).not.toHaveBeenCalled();
await tick(100);
expect(task).toHaveBeenCalledTimes(1);
});
it('issues nothing while disabled and starts once enabled', async () => {
const task = jest.fn(async () => undefined);
const { rerender } = renderHook<void, { enabled: boolean }>(({ enabled }) => usePolling(task, { intervalMs: 100, enabled }), {
initialProps: { enabled: false },
});
await tick(1_000);
expect(task).not.toHaveBeenCalled();
rerender({ enabled: true });
expect(task).toHaveBeenCalledTimes(1);
});
it('stops while the app is backgrounded and catches up on return', async () => {
const task = jest.fn(async () => undefined);
renderHook(() => usePolling(task, { intervalMs: 100 }));
expect(task).toHaveBeenCalledTimes(1);
await setForeground(false);
await tick(1_000);
expect(task).toHaveBeenCalledTimes(1);
await setForeground(true);
expect(task).toHaveBeenCalledTimes(2);
await tick(100);
expect(task).toHaveBeenCalledTimes(3);
});
it('never runs two tasks concurrently', async () => {
let release!: () => void;
const task = jest.fn(() => new Promise<void>(resolve => { release = resolve; }));
renderHook(() => usePolling(task, { intervalMs: 100 }));
expect(task).toHaveBeenCalledTimes(1);
await tick(500);
expect(task).toHaveBeenCalledTimes(1);
await act(async () => { release(); });
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
});
it('backs off after consecutive failures and recovers after a success', async () => {
const task = jest.fn(async (): Promise<void> => { throw new Error('offline'); });
renderHook(() => usePolling(task, { intervalMs: 100, maxIntervalMs: 400 }));
expect(task).toHaveBeenCalledTimes(1);
await tick(100);
expect(task).toHaveBeenCalledTimes(1);
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
await tick(399);
expect(task).toHaveBeenCalledTimes(2);
await tick(1);
expect(task).toHaveBeenCalledTimes(3);
task.mockImplementation(async () => undefined);
await tick(400);
expect(task).toHaveBeenCalledTimes(4);
await tick(100);
expect(task).toHaveBeenCalledTimes(5);
});
it('restarts immediately when the reset key changes', async () => {
const task = jest.fn(async () => undefined);
const { rerender } = renderHook<void, { resetKey: string }>(({ resetKey }) => usePolling(task, { intervalMs: 100, resetKey }), {
initialProps: { resetKey: 'a' },
});
expect(task).toHaveBeenCalledTimes(1);
rerender({ resetKey: 'b' });
expect(task).toHaveBeenCalledTimes(2);
await tick(50);
expect(task).toHaveBeenCalledTimes(2);
});
it('stops and unsubscribes on unmount', async () => {
const task = jest.fn(async () => undefined);
const { unmount } = renderHook(() => usePolling(task, { intervalMs: 100 }));
await tick(100);
expect(task).toHaveBeenCalledTimes(2);
unmount();
expect(mockListeners.size).toBe(0);
await tick(1_000);
expect(task).toHaveBeenCalledTimes(2);
});
});
@@ -76,4 +76,22 @@ describe('useTypingIndicator API polling', () => {
act(() => { jest.advanceTimersByTime(2_000); });
expect(chatApi.getTyping).toHaveBeenCalledTimes(calls);
});
it('stops polling and mirrors typing state supplied by the message poll', async () => {
const { result, rerender } = renderHook<ReturnType<typeof useTypingIndicator>, { externalTyping: boolean | undefined }>(
({ externalTyping }) => useTypingIndicator({ channelName: 'chat:match1', userId: 'me', pollInterval: 500, externalTyping }),
{ initialProps: { externalTyping: undefined as boolean | undefined } },
);
await act(async () => { await Promise.resolve(); });
const callsBeforeFolding = (chatApi.getTyping as jest.Mock).mock.calls.length;
rerender({ externalTyping: true });
expect(result.current.partnerTyping).toBe(true);
await act(async () => { jest.advanceTimersByTime(5_000); });
expect(chatApi.getTyping).toHaveBeenCalledTimes(callsBeforeFolding);
act(() => result.current.clearTypingIndicator());
expect(result.current.partnerTyping).toBe(false);
rerender({ externalTyping: false });
expect(result.current.partnerTyping).toBe(false);
});
});
@@ -0,0 +1,36 @@
import { AppState } from 'react-native';
import { isForeground, isForegroundState, subscribeToForeground } from '@/lib/appForeground';
describe('appForeground', () => {
afterEach(() => jest.restoreAllMocks());
it('treats only background and inactive as away', () => {
expect(isForegroundState('active')).toBe(true);
expect(isForegroundState('background')).toBe(false);
expect(isForegroundState('inactive')).toBe(false);
// Unknown states — including whatever a test harness leaves behind — must
// fail towards running, never towards disabling recurring work forever.
expect(isForegroundState(undefined)).toBe(true);
expect(isForegroundState('extension')).toBe(true);
});
it('reads the current state and reports transitions until unsubscribed', () => {
let emit: (state: string) => void = () => undefined;
const remove = jest.fn();
jest.spyOn(AppState, 'addEventListener').mockImplementation(((_: string, handler: (state: string) => void) => {
emit = handler;
return { remove } as never;
}) as never);
expect(isForeground()).toBe(isForegroundState(AppState.currentState));
const seen: boolean[] = [];
const unsubscribe = subscribeToForeground(value => seen.push(value));
emit('background');
emit('active');
expect(seen).toEqual([false, true]);
unsubscribe();
expect(remove).toHaveBeenCalled();
});
});
@@ -24,6 +24,18 @@ describe('chatApi', () => {
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match%2Fid/typing');
});
it('encodes the delta cursor and folded typing request into the message query', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ messages: [], removed_ids: [], server_time: 'now', complete: false });
await chatApi.listMessages('match1', { typing: true });
await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000+01:00' });
await chatApi.listMessages('match1', { since: '2026-08-27T12:00:00.000Z', typing: true });
expect(apiClient.get).toHaveBeenNthCalledWith(1, '/v1/matches/match1/messages?typing=true');
expect(apiClient.get).toHaveBeenNthCalledWith(2, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000%2B01%3A00');
expect(apiClient.get).toHaveBeenNthCalledWith(3, '/v1/matches/match1/messages?since=2026-08-27T12%3A00%3A00.000Z&typing=true');
});
it('uses authenticated unread, receipt, deletion and reporting routes', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ total: 0, by_match: {} });
(apiClient.post as jest.Mock).mockResolvedValue({});
@@ -45,9 +45,18 @@ describe('matchStore API behavior', () => {
});
it('guards concurrent loads and exhausted pagination', async () => {
useMatchStore.setState({ isLoading: true });
await useMatchStore.getState().fetchMatches(true);
useMatchStore.setState({ isLoading: false, hasMore: false });
// Overlap protection cannot key off the loading flags any more: a silent
// refresh raises none of them and still must not issue a second request.
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const inFlight = useMatchStore.getState().fetchMatches(true);
await useMatchStore.getState().fetchMatches(true, { silent: true });
expect(apiClient.get).toHaveBeenCalledTimes(1);
release({ matches: [], totalCount: 0 });
await inFlight;
(apiClient.get as jest.Mock).mockClear();
useMatchStore.setState({ hasMore: false });
await useMatchStore.getState().fetchMatches(false);
expect(apiClient.get).not.toHaveBeenCalled();
});
@@ -147,7 +156,10 @@ describe('matchStore API behavior', () => {
it('toggles archived visibility and triggers an initial fetch only', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ matches: [], totalCount: 0 });
useMatchStore.getState().toggleShowArchived();
await Promise.resolve();
// Let the floating fetch settle: its in-flight guard is module-scoped and
// is only released in a finally, so a half-run request would leak into
// the next test and silently suppress its archived fetch.
await jest.runOnlyPendingTimersAsync();
expect(useMatchStore.getState().showArchived).toBe(true);
expect(apiClient.get).toHaveBeenCalledTimes(1);
useMatchStore.getState().toggleShowArchived();
@@ -178,11 +190,19 @@ describe('matchStore API behavior', () => {
});
it('updates current view and delegates an empty view fetch', () => {
const real = useMatchStore.getState().fetchArchivedMatches;
const fetchArchivedMatches = jest.fn(async () => undefined);
useMatchStore.setState({ fetchArchivedMatches });
try {
useMatchStore.getState().setCurrentView('archived');
expect(useMatchStore.getState()).toMatchObject({ currentView: 'archived', showArchived: true });
expect(fetchArchivedMatches).toHaveBeenCalled();
} finally {
// Actions live in the same store as the data, and clearMatches does not
// reset them: leaving the stub in place would silence every later test's
// archived fetch.
useMatchStore.setState({ fetchArchivedMatches: real });
}
});
});
@@ -225,6 +245,222 @@ describe('matchStore API behavior', () => {
});
});
describe('view switching and cache freshness', () => {
/** Answers each of the four view endpoints with an empty payload. */
const emptyViews = () => (apiClient.get as jest.Mock).mockImplementation(async (path: string) => {
if (path.startsWith('/v1/matches?archived=true')) return { matches: [], totalCount: 0 };
if (path.startsWith('/v1/matches')) return { matches: [], totalCount: 0, hasMore: false };
return { questions: [] };
});
it('switches between loaded views without a single extra request, even when every view is empty', async () => {
emptyViews();
const views = ['pending', 'their_turn', 'active', 'archived'] as const;
// First visit to each view loads it once.
for (const view of views) {
useMatchStore.getState().setCurrentView(view);
await jest.runOnlyPendingTimersAsync();
}
expect(apiClient.get).toHaveBeenCalledTimes(views.length);
expect(useMatchStore.getState()).toMatchObject({
matches: [], pendingQuestions: [], theirTurnQuestions: [], archivedMatches: [],
});
// Every subsequent switch is served from cache. An empty view is loaded,
// not unloaded: this is the bug where `array.length === 0` meant "never
// fetched" and made every visit pay for the same empty answer.
(apiClient.get as jest.Mock).mockClear();
for (const view of [...views, ...views].reverse()) {
useMatchStore.getState().setCurrentView(view);
await jest.runOnlyPendingTimersAsync();
}
expect(apiClient.get).not.toHaveBeenCalled();
});
it('keeps cached rows and raises no foreground spinner during a silent refresh', async () => {
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false });
await useMatchStore.getState().fetchMatches(true);
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const refreshing = useMatchStore.getState().fetchMatches(true, { silent: true });
// Mid-flight: the user still sees the rows they had, and neither the
// full-screen loader nor the pull-to-refresh spinner is showing.
expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: false });
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
release({ matches: [match('fresh', false)], totalCount: 1, hasMore: false });
await refreshing;
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']);
});
it('shows the pull-to-refresh spinner for a user-initiated refresh but never the initial loader', async () => {
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('cached', false)], totalCount: 1, hasMore: false });
await useMatchStore.getState().fetchMatches(true);
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const refreshing = useMatchStore.getState().fetchMatches(true);
expect(useMatchStore.getState()).toMatchObject({ isLoading: false, isRefreshing: true });
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['cached']);
release({ matches: [], totalCount: 0, hasMore: false });
await refreshing;
expect(useMatchStore.getState()).toMatchObject({ isRefreshing: false });
});
it('keeps a silent list refresh out of the pending and their-turn loaders', async () => {
(apiClient.get as jest.Mock).mockResolvedValue({ questions: [pending('p1')] });
await useMatchStore.getState().fetchPendingQuestions();
await useMatchStore.getState().fetchTheirTurnQuestions();
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValue(new Promise(resolve => { release = resolve; }));
const quiet = Promise.all([
useMatchStore.getState().fetchPendingQuestions({ silent: true }),
useMatchStore.getState().fetchTheirTurnQuestions({ silent: true }),
]);
expect(useMatchStore.getState()).toMatchObject({ isLoadingPending: false, isLoadingTheirTurn: false });
expect(useMatchStore.getState().pendingQuestions).toHaveLength(1);
release({ questions: [] });
await quiet;
});
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale fetchMatches response populate the store after sign-out, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = useMatchStore.getState().fetchMatches(true);
// Simulates sign-out/account switch while the request is still in flight.
useMatchStore.getState().clearMatches();
release({ matches: [match('stale')], totalCount: 1, hasMore: false });
await staleFetch;
expect(useMatchStore.getState().matches).toEqual([]);
(apiClient.get as jest.Mock).mockResolvedValueOnce({ matches: [match('fresh')], totalCount: 1, hasMore: false });
await useMatchStore.getState().fetchMatches(true);
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['fresh']);
});
it('does not let a stale seen-marker write over the next account\'s list', async () => {
useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 });
let release: () => void = () => undefined;
(apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise<void>(resolve => { release = () => resolve(); }));
const stale = useMatchStore.getState().markAsSeen('theirs');
useMatchStore.getState().clearMatches();
useMatchStore.setState({ matches: [match('mine')], newMatchesCount: 1 });
const untouched = useMatchStore.getState().matches;
release();
await stale;
// Not merely "the same ids": the previous account's response must not
// rewrite the array at all, because the recount it derives is only true
// of the rows that account had loaded.
expect(useMatchStore.getState().matches).toBe(untouched);
expect(useMatchStore.getState().newMatchesCount).toBe(1);
});
it('does not let a stale mark-all-seen clear the next account\'s new badges', async () => {
useMatchStore.setState({ matches: [match('theirs')], newMatchesCount: 1 });
let release: () => void = () => undefined;
(apiClient.patch as jest.Mock).mockReturnValueOnce(new Promise<void>(resolve => { release = () => resolve(); }));
const stale = useMatchStore.getState().markAllAsSeen();
useMatchStore.getState().clearMatches();
useMatchStore.setState({ matches: [match('mine', true), match('mine2', true)], newMatchesCount: 2 });
release();
await stale;
expect(useMatchStore.getState().matches.every(item => item.is_new)).toBe(true);
expect(useMatchStore.getState().newMatchesCount).toBe(2);
});
it('does not roll a failed archive or unarchive back into the next account', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
let fail: (error: Error) => void = () => undefined;
(apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
useMatchStore.setState({ matches: [match('theirs')], archivedMatches: [], archivedMatchIds: new Set<string>(), totalCount: 1 });
const staleArchive = useMatchStore.getState().archiveMatch('theirs');
useMatchStore.getState().clearMatches();
useMatchStore.setState({ matches: [match('mine')], totalCount: 1 });
fail(new Error('forbidden'));
await staleArchive;
// The revert would otherwise put one couple's match into another's list.
expect(useMatchStore.getState().matches.map(item => item.id)).toEqual(['mine']);
expect(useMatchStore.getState().totalCount).toBe(1);
(apiClient.put as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
useMatchStore.setState({ archivedMatches: [match('theirsArchived')], archivedMatchIds: new Set(['theirsArchived']) });
const staleUnarchive = useMatchStore.getState().unarchiveMatch('theirsArchived');
useMatchStore.getState().clearMatches();
useMatchStore.setState({ archivedMatches: [match('mineArchived')], archivedMatchIds: new Set(['mineArchived']), totalCount: 0 });
fail(new Error('forbidden'));
await staleUnarchive;
expect(useMatchStore.getState().archivedMatches.map(item => item.id)).toEqual(['mineArchived']);
expect(useMatchStore.getState().totalCount).toBe(0);
});
it('does not carry a nudge cooldown across an account switch', async () => {
let release: (data: unknown) => void = () => undefined;
(appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleSend = useMatchStore.getState().sendNudge();
useMatchStore.getState().clearMatches();
release({ success: true, notification_sent: true, next_nudge_available_at: '2026-08-28T00:00:00Z' });
await expect(staleSend).resolves.toEqual({ success: false, notificationSent: false });
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
// The 429 branch writes a cooldown too, and must be gated the same way.
let fail: (error: Error) => void = () => undefined;
(appDataApi.sendNudge as jest.Mock).mockReturnValueOnce(new Promise((_resolve, reject) => { fail = reject; }));
const staleLimit = useMatchStore.getState().sendNudge();
useMatchStore.getState().clearMatches();
fail(new ApiError('rate limited', 429, { cooldown_remaining_seconds: 60 }));
await staleLimit;
expect(useMatchStore.getState().nudgeCooldownUntil).toBeNull();
});
it('does not let a stale cooldown read clear the next account\'s cooldown', async () => {
let release: (profile: unknown) => void = () => undefined;
(appDataApi.nudgeStatus as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const stale = useMatchStore.getState().checkNudgeCooldown();
useMatchStore.getState().clearMatches();
const mine = new Date('2026-08-27T18:00:00Z');
useMatchStore.setState({ nudgeCooldownUntil: mine });
// The previous account not being in cooldown says nothing about this one.
release({ last_nudge_sent_at: null });
await stale;
expect(useMatchStore.getState().nudgeCooldownUntil).toEqual(mine);
});
it('does not let a stale fetchPendingQuestions response populate the store after sign-out, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = useMatchStore.getState().fetchPendingQuestions();
useMatchStore.getState().clearMatches();
release({ questions: [pending('stale')] });
await staleFetch;
expect(useMatchStore.getState().pendingQuestions).toEqual([]);
(apiClient.get as jest.Mock).mockResolvedValueOnce({ questions: [pending('fresh')] });
await useMatchStore.getState().fetchPendingQuestions();
expect(useMatchStore.getState().pendingQuestions.map(item => item.id)).toEqual(['fresh']);
});
});
it('clearMatches resets all user-scoped state', () => {
useMatchStore.setState({ matches: [match('m1')], archivedMatches: [match('m2')], pendingQuestions: [pending('p')], theirTurnQuestions: [pending('t')], currentView: 'archived', showArchived: true, isNudging: true });
useMatchStore.getState().clearMatches();
@@ -47,15 +47,25 @@ describe('messageStore API state', () => {
expect(useMessageStore.getState()).toMatchObject({ unreadCount: 1, lastMessage: { id: 'new' } });
});
it('marks partner messages read then reconciles global and per-match counts', async () => {
it('marks partner messages read and subtracts the cleared count without re-reading the total', async () => {
useMessageStore.setState({ unreadCount: 3 });
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 2, read_at: '2026-08-27T00:00:00.000Z' });
(chatApi.unread as jest.Mock).mockResolvedValue({ total: 1, by_match: { m2: 1 } });
await useMessageStore.getState().markMatchMessagesAsRead('m1');
expect(chatApi.markRead).toHaveBeenCalledWith('m1');
expect(chatApi.unread).not.toHaveBeenCalled();
expect(useMessageStore.getState().unreadCount).toBe(1);
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(1);
});
it('spends no request and touches no count when the read API cleared nothing', async () => {
useMessageStore.setState({ unreadCount: 3 });
(chatApi.markRead as jest.Mock).mockResolvedValue({ updated: 0, read_at: null });
await useMessageStore.getState().markMatchMessagesAsRead('m1');
expect(chatApi.unread).not.toHaveBeenCalled();
expect(useMessageStore.getState().unreadCount).toBe(3);
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
});
it('does not mark read without an authenticated user', async () => {
useAuthStore.setState({ user: null } as any);
await useMessageStore.getState().markMatchMessagesAsRead('m1');
@@ -69,6 +79,42 @@ describe('messageStore API state', () => {
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(3);
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale unread total become the next account\'s badge', async () => {
let release: (value: unknown) => void = () => undefined;
(chatApi.unread as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = useMessageStore.getState().fetchUnreadCount();
// Sign-out or leaving a couple, while the request is still open.
useMessageStore.getState().clearMessages();
useMessageStore.setState({ unreadCount: 2 });
release({ total: 9, by_match: { m1: 9 } });
await staleFetch;
expect(useMessageStore.getState().unreadCount).toBe(2);
});
it('does not subtract a stale read receipt from the next account\'s badge', async () => {
useMessageStore.setState({ unreadCount: 3 });
let release: (value: unknown) => void = () => undefined;
(chatApi.markRead as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleRead = useMessageStore.getState().markMatchMessagesAsRead('m1');
useMessageStore.getState().clearMessages();
useMessageStore.setState({ unreadCount: 2 });
useMatchStore.setState({ matches: [{ id: 'm1', unreadCount: 2 } as any] });
release({ updated: 3, read_at: '2026-08-27T00:00:00.000Z' });
await staleRead;
// The badge is wrong whatever the match id; reusing 'm1' also makes the
// second half of the damage — the per-match count — visible.
expect(useMessageStore.getState().unreadCount).toBe(2);
expect(useMatchStore.getState().matches[0]?.unreadCount).toBe(2);
});
});
it('clears transient message state', () => {
useMessageStore.setState({ unreadCount: 3, lastMessage: { id: 'last' } as any, activeMatchId: 'm1' });
useMessageStore.getState().clearLastMessage();
@@ -64,4 +64,69 @@ describe('packsStore standalone API integration', () => {
expect(getSpy).not.toHaveBeenCalled();
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale fetchEnabledPacks response populate the store after clearPacks, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = usePacksStore.getState().fetchEnabledPacks();
// Simulates sign-out/account switch while the request is still in flight.
usePacksStore.getState().clearPacks();
release({ enabledPackIds: ['stale-pack'] });
await staleFetch;
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
getSpy.mockResolvedValueOnce({ enabledPackIds: ['fresh-pack'] });
await usePacksStore.getState().fetchEnabledPacks();
expect(usePacksStore.getState().enabledPackIds).toEqual(['fresh-pack']);
});
it('does not let a stale fetchPackProgress response populate the store after clearPacks, and lets the next account start immediately', async () => {
let release: (page: unknown) => void = () => undefined;
const getSpy = jest.spyOn(apiClient, 'get').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleFetch = usePacksStore.getState().fetchPackProgress();
usePacksStore.getState().clearPacks();
release({ progress: [{ packId: 'stale', totalQuestions: 1, answeredQuestions: 1 }] });
await staleFetch;
expect(usePacksStore.getState().packProgress.size).toBe(0);
getSpy.mockResolvedValueOnce({ progress: [{ packId: 'fresh', totalQuestions: 2, answeredQuestions: 1 }] });
await usePacksStore.getState().fetchPackProgress();
expect(usePacksStore.getState().packProgress.get('fresh')).toEqual({ totalQuestions: 2, answeredQuestions: 1 });
});
it('does not let a stale toggle write the previous couple\'s pack selection', async () => {
usePacksStore.setState({ enabledPackIds: [] } as any);
let release: (value: unknown) => void = () => undefined;
jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const staleToggle = usePacksStore.getState().togglePack('pack1');
usePacksStore.getState().clearPacks();
usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any);
release({ enabledPackIds: ['pack1'] });
await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' });
expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']);
});
it('does not roll a failed toggle back into the next couple\'s selection', async () => {
usePacksStore.setState({ enabledPackIds: ['pack1'] } as any);
let reject: (reason: unknown) => void = () => undefined;
jest.spyOn(apiClient, 'put').mockReturnValueOnce(new Promise((_resolve, fail) => { reject = fail; }));
jest.spyOn(console, 'error').mockImplementation(() => undefined);
const staleToggle = usePacksStore.getState().togglePack('pack1');
usePacksStore.getState().clearPacks();
usePacksStore.setState({ enabledPackIds: ['next-couple-pack'] } as any);
reject(new Error('offline'));
await expect(staleToggle).resolves.toEqual({ success: false, reason: 'stale' });
// The rollback would have re-added pack1, which this couple never enabled.
expect(usePacksStore.getState().enabledPackIds).toEqual(['next-couple-pack']);
});
});
});
@@ -265,6 +265,53 @@ describe('responsesStore', () => {
});
});
describe('account isolation (generation tokens)', () => {
it('does not let a stale fetchResponses response populate the store after clearResponses, and lets the next account start immediately', async () => {
let resolve!: (value: unknown) => void;
apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const staleFetch = useResponsesStore.getState().fetchResponses(true);
// Simulates sign-out/account switch while the request is still in flight.
useResponsesStore.getState().clearResponses();
resolve({ responses: [response({ id: 'stale' })], totalCount: 1 });
await staleFetch;
expect(useResponsesStore.getState().responses).toEqual([]);
apiGet.mockResolvedValueOnce({ responses: [response({ id: 'fresh' })], totalCount: 1 });
await useResponsesStore.getState().fetchResponses(true);
expect(useResponsesStore.getState().responses.map((r) => r.id)).toEqual(['fresh']);
});
it('does not let a stale edit rewrite the next account\'s row or refresh its matches', async () => {
// Question ids come from the shared catalogue, so the same id names a real
// row in both accounts' lists: a stale edit would land on one of them.
const fetchMatches = jest.fn();
useMatchStore.setState({ fetchMatches } as any);
useResponsesStore.setState({
responses: [response({ id: 'alice', answer: 'maybe', has_match: false, match_id: undefined })],
});
let resolve!: (value: unknown) => void;
apiPatch.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const staleEdit = useResponsesStore.getState().updateResponse('q1', 'yes');
useResponsesStore.getState().clearResponses();
const next = [response({ id: 'bob', answer: 'no', has_match: false, match_id: undefined })];
useResponsesStore.setState({ responses: next });
resolve({ success: true, new_match: { id: 'm2' } });
// The caller still hears what the server did for the account that asked.
await expect(staleEdit).resolves.toMatchObject({ success: true });
expect(useResponsesStore.getState().responses).toBe(next);
expect(useResponsesStore.getState().responses[0]).toMatchObject({
id: 'bob', answer: 'no', has_match: false,
});
expect(fetchMatches).not.toHaveBeenCalled();
});
});
describe('state controls', () => {
it('sets and toggles grouping options', () => {
useResponsesStore.getState().setGroupBy('pack');
@@ -0,0 +1,58 @@
import { apiClient } from '@/lib/apiClient';
import { useAuthStore } from '@/store/authStore';
import { type CoupleStreak, useStreakStore } from '@/store/streakStore';
jest.mock('@/lib/apiClient', () => ({ apiClient: { get: jest.fn() } }));
const apiGet = apiClient.get as jest.Mock;
function streak(overrides: Partial<CoupleStreak> = {}): CoupleStreak {
return {
couple_id: 'couple1', current_streak: 1, longest_streak: 1,
last_active_date: '2026-08-27', last_completed_date: '2026-08-27',
you_answered_today: true, partner_answered_today: false,
partner_name: 'Partner', timezone: 'UTC', streak_celebrated_at: 0,
created_at: '2026-08-01T00:00:00.000Z', updated_at: '2026-08-27T00:00:00.000Z',
...overrides,
};
}
describe('streakStore account isolation (generation tokens)', () => {
beforeEach(() => {
jest.clearAllMocks();
useStreakStore.setState({ streak: null, isLoading: false, error: null, loadedAt: null, generation: 0 });
useAuthStore.setState({ user: { id: 'me', couple_id: 'couple1' } } as any);
});
it('does not let a stale fetchStreak response populate the store after clearStreak, and lets the next account start immediately', async () => {
let resolve!: (value: unknown) => void;
apiGet.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
const staleFetch = useStreakStore.getState().fetchStreak();
// Simulates sign-out/account switch while the request is still in flight.
useStreakStore.getState().clearStreak();
resolve({ streak: streak({ couple_id: 'stale-couple' }) });
await staleFetch;
expect(useStreakStore.getState().streak).toBeNull();
expect(useStreakStore.getState().loadedAt).toBeNull();
apiGet.mockResolvedValueOnce({ streak: streak({ couple_id: 'fresh-couple' }) });
await useStreakStore.getState().fetchStreak();
expect(useStreakStore.getState().streak?.couple_id).toBe('fresh-couple');
expect(useStreakStore.getState().loadedAt).not.toBeNull();
});
it('refreshStreak silently reloads only an already-loaded streak', async () => {
await useStreakStore.getState().refreshStreak();
expect(apiGet).not.toHaveBeenCalled();
apiGet.mockResolvedValueOnce({ streak: streak() });
await useStreakStore.getState().fetchStreak();
expect(useStreakStore.getState().loadedAt).not.toBeNull();
apiGet.mockResolvedValueOnce({ streak: streak({ current_streak: 2 }) });
await useStreakStore.getState().refreshStreak();
expect(useStreakStore.getState().streak?.current_streak).toBe(2);
});
});
+3 -3
View File
@@ -43,12 +43,12 @@ export function streakStatus(streak: CoupleStreak): { headline: string; status:
* Displays the couple's shared streak. The flame pulses while the streak is alive.
*/
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
const { streak, fetchStreak } = useStreakStore();
const { streak, ensureStreakLoaded } = useStreakStore();
// Fetch streak on mount
useEffect(() => {
fetchStreak();
}, [fetchStreak]);
void ensureStreakLoaded();
}, [ensureStreakLoaded]);
// Flame pulse animation
const scale = useSharedValue(1);
+20 -13
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from "react";
import { useState, useEffect, useCallback, useRef } from "react";
import { View, StyleSheet, KeyboardAvoidingView, Platform, Alert } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router";
import { useFocusEffect, useIsFocused } from "@react-navigation/native";
@@ -60,20 +60,31 @@ export const ChatScreen: React.FC = () => {
const isFocused = useIsFocused();
// Typing indicator hook
// Each hook needs a value the other produces: the message poll reports typing
// state, and an arriving message clears the indicator. The ref breaks the cycle.
const clearTypingRef = useRef<() => void>(() => undefined);
const handleNewMessage = useCallback(() => clearTypingRef.current(), []);
// Message subscription hook
const { messages, setMessages, partnerTyping: syncedTyping } = useMessageSubscription({
matchId,
userId: user?.id,
onNewMessage: handleNewMessage,
isFocused,
});
// Typing indicator hook, reading the state the message poll already fetched
const { partnerTyping, sendTypingEvent, clearTypingIndicator } = useTypingIndicator({
channelName: `chat:${matchId}`,
matchId,
userId: user?.id,
isFocused,
externalTyping: syncedTyping,
});
// Message subscription hook
const { messages, setMessages, isFocusedRef } = useMessageSubscription({
matchId,
userId: user?.id,
onNewMessage: clearTypingIndicator,
});
useEffect(() => {
clearTypingRef.current = clearTypingIndicator;
}, [clearTypingIndicator]);
// Message actions hook for deletion and reporting
@@ -174,16 +185,12 @@ export const ChatScreen: React.FC = () => {
// Track active chat to prevent notifications for current chat
useFocusEffect(
useCallback(() => {
isFocusedRef.current = true;
if (matchId) {
setActiveMatchId(matchId);
markMatchMessagesAsRead(matchId);
Events.matchViewed();
}
return () => {
isFocusedRef.current = false;
setActiveMatchId(null);
};
return () => setActiveMatchId(null);
}, [matchId, setActiveMatchId, markMatchMessagesAsRead])
);
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { ActivityIndicator, Pressable, RefreshControl, ScrollView, StyleSheet, Text, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useIsFocused } from "@react-navigation/native";
import Animated, { FadeIn } from "react-native-reanimated";
import { GradientBackground } from "../../components/ui";
import { Paywall } from "../../components/paywall";
@@ -20,7 +21,8 @@ const TABS: { key: Tab; label: string }[] = [
];
export function DaresScreen() {
const dares = useDares();
const isFocused = useIsFocused();
const dares = useDares({ isFocused });
const [tab, setTab] = useState<Tab>("active");
const [sheetPack, setSheetPack] = useState<DarePack | null>(null);
const [sheetOpen, setSheetOpen] = useState(false);
@@ -1,11 +1,21 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { daresApi } from "../../../lib/daresApi";
import { ApiError } from "../../../lib/apiClient";
import { usePolling } from "../../../hooks/usePolling";
import type { DareCatalog, DareStats, SendDarePayload, SentDare } from "../types";
/** Matches the chat poll cadence; the API has no realtime channel. */
const POLL_INTERVAL_MS = 5_000;
export interface UseDaresConfig {
/**
* Supplied by the screen rather than read from navigation here, so the hook
* stays renderable without a navigator. Polling stops when the screen is not
* on top; `usePolling` additionally stops it when the app backgrounds.
*/
isFocused?: boolean;
}
export interface UseDaresReturn {
active: SentDare[];
history: SentDare[];
@@ -25,7 +35,19 @@ export interface UseDaresReturn {
cancel: (dareId: string) => Promise<void>;
}
export function useDares(): UseDaresReturn {
/**
* Identity of the active set as the user experiences it. History, stats and the
* remaining-send allowance only move when a dare enters or leaves this set or
* changes status, so this is the signal that says the slower endpoints are worth
* re-reading.
*/
const activeSignature = (dares: SentDare[]): string =>
dares
.map(dare => `${dare.id}:${dare.status}`)
.sort()
.join("|");
export function useDares({ isFocused = true }: UseDaresConfig = {}): UseDaresReturn {
const [active, setActive] = useState<SentDare[]>([]);
const [history, setHistory] = useState<SentDare[]>([]);
const [catalog, setCatalog] = useState<DareCatalog | null>(null);
@@ -36,42 +58,63 @@ export function useDares(): UseDaresReturn {
const [paywallReason, setPaywallReason] = useState<string | null>(null);
const fetchingRef = useRef(false);
const mountedRef = useRef(true);
const signatureRef = useRef<string | null>(null);
const refresh = useCallback(async () => {
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
/**
* `full` reads everything; the polling path reads only the active list and
* follows up on the rest when that list actually changed. A failure updates
* the UI's error state either way, but is only rethrown for the poll path
* (`throwOnError`): `usePolling` needs the rejection to drive its backoff,
* while pull-to-refresh and post-mutation callers already surface the error
* themselves and must not turn it into an unhandled rejection.
*/
const load = useCallback(async (full: boolean, throwOnError = false) => {
if (fetchingRef.current) return;
fetchingRef.current = true;
try {
const [activeResult, historyResult, catalogResult, statsResult] = await Promise.all([
daresApi.list("active"),
const activeResult = await daresApi.list("active");
if (!mountedRef.current) return;
setActive(activeResult.dares);
const signature = activeSignature(activeResult.dares);
const changed = signature !== signatureRef.current;
signatureRef.current = signature;
if (full || changed) {
const [historyResult, catalogResult, statsResult] = await Promise.all([
daresApi.list("history"),
daresApi.catalog(),
daresApi.stats(),
]);
if (!mountedRef.current) return;
setActive(activeResult.dares);
setHistory(historyResult.dares);
setCatalog(catalogResult);
setStats(statsResult);
}
setError(null);
} catch (cause) {
if (mountedRef.current) {
setError(cause instanceof ApiError ? cause.message : "Could not load dares");
}
if (throwOnError) throw cause;
} finally {
fetchingRef.current = false;
if (mountedRef.current) setLoading(false);
}
}, []);
useEffect(() => {
mountedRef.current = true;
void refresh();
const timer = setInterval(() => void refresh(), POLL_INTERVAL_MS);
return () => {
mountedRef.current = false;
clearInterval(timer);
};
}, [refresh]);
/** Pull-to-refresh and post-mutation reloads always read the slow endpoints too. */
const refresh = useCallback(() => load(true), [load]);
const poll = useCallback(() => load(false, true), [load]);
usePolling(poll, { intervalMs: POLL_INTERVAL_MS, enabled: isFocused });
/** Premium refusals steer to the paywall; everything else surfaces inline. */
const handleFailure = useCallback((cause: unknown) => {
@@ -8,6 +8,7 @@ import {
useWindowDimensions,
} from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useIsFocused } from '@react-navigation/native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import * as Haptics from 'expo-haptics';
@@ -35,6 +36,7 @@ export const LiveDrawScreen: React.FC = () => {
const insets = useSafeAreaInsets();
const { user } = useAuthStore();
const coupleId = coupleIdParam || user?.couple_id;
const isFocused = useIsFocused();
const { width: screenWidth } = useWindowDimensions();
const [selectedColor, setSelectedColor] = useState(PRESET_COLORS[0]);
@@ -95,6 +97,7 @@ export const LiveDrawScreen: React.FC = () => {
const sync = useDrawingSync({
coupleId: coupleId!,
userId: user?.id,
isFocused,
onStrokeStart: handleStrokeStartFromPartner,
onStrokeContinue: handleStrokeContinueFromPartner,
onStrokeEnd: handleStrokeEndFromPartner,
@@ -1,9 +1,13 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { ApiError } from '../../../lib/apiClient';
import { appDataApi, type LiveDrawState } from '../../../lib/appDataApi';
import { usePolling } from '../../../hooks/usePolling';
import type { StrokeSegment, StrokePoint } from '../types';
import { updateWidget } from '../utils/widgetBridge';
/** Fast enough for a shared canvas to feel live, and only while one is on screen. */
const POLL_INTERVAL_MS = 750;
interface UseDrawingSyncConfig {
coupleId: string;
userId: string | undefined;
@@ -14,6 +18,12 @@ interface UseDrawingSyncConfig {
onUndo: (strokeId: string) => void;
onRedo: (stroke: StrokeSegment) => void;
onLoadStrokes: (strokes: StrokeSegment[]) => void;
/**
* Supplied by the screen rather than read from navigation here, so the hook
* stays renderable without a navigator. A canvas nobody is looking at polls
* nothing; `usePolling` also stops it when the app goes to the background.
*/
isFocused?: boolean;
}
interface UseDrawingSyncReturn {
@@ -52,7 +62,7 @@ export function mergeDrawingChanges(base: StrokeSegment[], desired: StrokeSegmen
}
export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncReturn {
const { coupleId, userId, onLoadStrokes } = config;
const { coupleId, userId, onLoadStrokes, isFocused = true } = config;
const revisionRef = useRef(0);
const baselineRef = useRef<StrokeSegment[]>([]);
const workingRef = useRef<StrokeSegment[]>([]);
@@ -132,19 +142,25 @@ export function useDrawingSync(config: UseDrawingSyncConfig): UseDrawingSyncRetu
void appDataApi.getLiveDraw().then(acceptState).catch(error => console.error('Failed to load live drawing:', error));
}, [coupleId, acceptState]);
useEffect(() => {
if (!coupleId || !userId) return;
let cancelled = false;
const poll = async () => {
// Our own writes already return the authoritative state, so reading over the top
// of one in flight would only race it back to a revision we are about to replace.
const poll = useCallback(async () => {
if (pendingWritesRef.current > 0) return;
try {
const state = await appDataApi.getLiveDraw();
if (!cancelled && state.revision > revisionRef.current) acceptState(state);
} catch (error) { console.error('Failed to poll live drawing:', error); }
};
const timer = setInterval(() => void poll(), 750);
return () => { cancelled = true; clearInterval(timer); if (throttleRef.current) clearTimeout(throttleRef.current); };
}, [coupleId, userId, acceptState]);
if (state.revision > revisionRef.current) acceptState(state);
}, [acceptState]);
// `leading: false` because the mount effect above already read the initial state;
// usePolling adds the overlap guard, backoff and background pause the raw
// interval had none of.
usePolling(poll, {
intervalMs: POLL_INTERVAL_MS,
enabled: Boolean(coupleId) && Boolean(userId) && isFocused,
leading: false,
resetKey: `${coupleId}:${userId ?? ''}`,
});
useEffect(() => () => { if (throttleRef.current) clearTimeout(throttleRef.current); }, []);
const persistStrokes = useCallback((strokes: StrokeSegment[]) => {
if (throttleRef.current) clearTimeout(throttleRef.current);
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useFocusEffect, useLocalSearchParams } from "expo-router";
import { useAuthStore, usePacksStore, useStreakStore } from "../../../store";
import { useAuthStore, usePacksStore, useResponsesStore, useStreakStore } from "../../../store";
import { skipQuestion, getSkippedQuestionIds } from "../../../lib/skippedQuestions";
import { ApiError, apiClient } from "../../../lib/apiClient";
import { Events } from "../../../lib/analytics";
@@ -352,6 +352,12 @@ export const useSwipeScreen = () => {
// wrong partner state for the rest of the session. Refreshing it must never
// interfere with recording the answer, so it stays off the critical path.
void useStreakStore.getState().fetchStreak().catch(() => undefined);
// This answer changed pack progress and the answer history. Marking their
// caches stale costs no request: whichever screen the user opens next
// reloads once, and the ones they do not open stay quiet.
usePacksStore.getState().invalidatePacks();
useResponsesStore.getState().invalidateResponses();
} catch (error) {
// A 429 means the server rejected this answer outright. Do not advance the
// index, or the question is silently skipped and lost for the day.
+3
View File
@@ -1,4 +1,7 @@
export { useAmbientOrbAnimation } from './useAmbientOrbAnimation';
export { usePolling } from './usePolling';
export type { UsePollingOptions } from './usePolling';
export { useCoupleSync, SYNC_INTERVAL_MS } from './useCoupleSync';
export type { AmbientOrbConfig } from './useAmbientOrbAnimation';
export { useFeatureInterest } from './useFeatureInterest';
export { useMediaPicker } from './useMediaPicker';
+116
View File
@@ -0,0 +1,116 @@
import { useCallback, useRef } from 'react';
import { appDataApi, type SyncSummary } from '../lib/appDataApi';
import { useAuthStore } from '../store/authStore';
import { useMatchStore } from '../store/matchStore';
import { useMessageStore } from '../store/messageStore';
import { usePacksStore } from '../store/packsStore';
import { useStreakStore } from '../store/streakStore';
import { usePolling } from './usePolling';
/**
* Slower than the five-second full refresh it replaces, because the request it
* makes is a change summary rather than five payloads: the app reacts to a
* partner's action within this window, but pays one small query for it.
*/
export const SYNC_INTERVAL_MS = 15_000;
/**
* Keeps cached domain data honest without re-fetching it. Each poll reads one
* summary of the couple's state and refreshes only the domains whose markers
* moved — and only the ones already on screen, so a view nobody has opened is
* not loaded in the background.
*/
export function useCoupleSync(userId: string | undefined, coupleId: string | null | undefined): void {
const previousRef = useRef<SyncSummary | null>(null);
const identityRef = useRef<string>('');
// `sync` is memoized on a stable callback, so `taskRef` inside usePolling
// never has to restart the schedule on every render; it reads the current
// identity through this ref instead of closing over the arguments from
// whichever render created it.
const identityValuesRef = useRef({ userId, coupleId });
identityValuesRef.current = { userId, coupleId };
const latestIdentity = useCallback(() => {
const { userId, coupleId } = identityValuesRef.current;
return `${userId ?? ''}:${coupleId ?? ''}`;
}, []);
const sync = useCallback(async () => {
const identity = latestIdentity();
if (identity !== identityRef.current) {
// A new account's first summary must not be diffed against the prior
// account's markers, or every domain would silently "refresh" on login.
identityRef.current = identity;
previousRef.current = null;
}
const summary = await appDataApi.syncSummary();
// The account can change while this request is in flight. The response
// describes the identity that asked for it, so once that is no longer the
// current one it may not touch anything: not the badge, not the baseline
// the next account will diff against, and not a single refresh decision.
// Both checks matter — `identityRef` catches a newer sync that has already
// started, and `latestIdentity()` catches a switch that has only rendered.
if (identityRef.current !== identity || latestIdentity() !== identity) return;
const previous = previousRef.current;
previousRef.current = summary;
// The badge count comes straight out of the summary, so keeping it current
// costs nothing beyond this request.
if (summary.unread_total !== previous?.unread_total) {
useMessageStore.getState().setUnreadCount(summary.unread_total);
}
// The first poll only establishes the baseline: every screen loads its own
// data on mount, and refreshing it again here would undo the saving.
if (!previous) return;
const auth = useAuthStore.getState();
const matchStore = useMatchStore.getState();
const refreshes: Promise<unknown>[] = [];
if (summary.profile_updated_at !== previous.profile_updated_at || summary.couple_id !== previous.couple_id) {
// fetchUser also refreshes the couple when one is set.
refreshes.push(auth.fetchUser());
} else if (summary.partner_id !== previous.partner_id || summary.partner_updated_at !== previous.partner_updated_at) {
refreshes.push(auth.fetchCouple());
}
if (matchStore.loadedAt.active !== undefined && (
summary.match_count !== previous.match_count ||
summary.new_match_count !== previous.new_match_count ||
summary.latest_match_at !== previous.latest_match_at ||
// Neither count moves when a partner edits an existing match's type or
// response summary through response editing.
summary.match_state_fingerprint !== previous.match_state_fingerprint ||
// `unread_total` can hold steady while unread redistributes between
// matches (one read, another receives a message), which reorders the
// unread-first list without moving the total.
summary.match_unread_fingerprint !== previous.match_unread_fingerprint)) {
refreshes.push(matchStore.fetchMatches(true, { silent: true }));
}
if (matchStore.loadedAt.pending !== undefined && summary.pending_yours !== previous.pending_yours) {
refreshes.push(matchStore.fetchPendingQuestions({ silent: true }));
}
if (matchStore.loadedAt.their_turn !== undefined && summary.pending_theirs !== previous.pending_theirs) {
refreshes.push(matchStore.fetchTheirTurnQuestions({ silent: true }));
}
// Only refresh a domain the user has actually opened, matching the rule
// that unopened domains stay unloaded.
if (usePacksStore.getState().enabledPacksLoadedAt !== null
&& summary.enabled_packs_fingerprint !== previous.enabled_packs_fingerprint) {
refreshes.push(usePacksStore.getState().fetchEnabledPacks());
}
if (summary.streak_updated_at !== previous.streak_updated_at) {
refreshes.push(useStreakStore.getState().refreshStreak());
}
await Promise.allSettled(refreshes);
}, [latestIdentity]);
usePolling(sync, {
intervalMs: SYNC_INTERVAL_MS,
enabled: Boolean(userId),
resetKey: `${userId ?? ''}:${coupleId ?? ''}`,
});
}
+97 -32
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { chatApi } from '../lib/chatApi';
import { chatApi, type MessagesPage } from '../lib/chatApi';
import { usePolling } from './usePolling';
import type { Message } from '../features/chat/types';
export interface UseMessageSubscriptionConfig {
@@ -7,51 +8,115 @@ export interface UseMessageSubscriptionConfig {
userId: string | undefined;
onNewMessage?: () => void;
pollInterval?: number;
/** Whether the chat is on screen. Polling and read receipts stop when it is not. */
isFocused?: boolean;
}
export interface UseMessageSubscriptionReturn {
messages: Message[];
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
loading: boolean;
isFocusedRef: React.MutableRefObject<boolean>;
/** Partner typing state folded into the message poll; undefined until the server reports it. */
partnerTyping: boolean | undefined;
/** Fetches the next delta now, e.g. straight after sending. */
refresh: () => Promise<void>;
}
/** Authenticated API polling replaces Supabase Realtime for self-hosted chat. */
export const useMessageSubscription = ({ matchId, userId, onNewMessage, pollInterval = 1_500 }: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => {
const timeOf = (message: Message): number => {
const parsed = message.created_at ? Date.parse(message.created_at) : Number.NaN;
return Number.isNaN(parsed) ? 0 : parsed;
};
/** Newest first, matching the order the API returns a snapshot in. */
const byNewest = (a: Message, b: Message): number => {
const delta = timeOf(b) - timeOf(a);
if (delta !== 0) return delta;
return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
};
/**
* Returns the previous array unchanged when a delta carries nothing, so an idle
* poll re-renders neither this hook's consumers nor the message list.
*/
export const mergeMessagePage = (previous: Message[], page: MessagesPage): Message[] => {
if (page.complete !== false) return page.messages;
const removed = page.removed_ids ?? [];
if (page.messages.length === 0 && removed.length === 0) return previous;
const byId = new Map(previous.map(message => [message.id, message]));
for (const message of page.messages) byId.set(message.id, message);
for (const id of removed) byId.delete(id);
return [...byId.values()].sort(byNewest);
};
/**
* Authenticated API polling replaces Supabase Realtime for self-hosted chat.
*
* Each poll asks only for what changed since the cursor the server issued last
* time, so an idle conversation costs an all-but-empty response instead of the
* five hundred messages the screen already has. Read receipts are written only
* when the response actually contains an unread message from the partner, which
* on an idle chat is never.
*/
export const useMessageSubscription = ({
matchId,
userId,
onNewMessage,
pollInterval = 2_000,
isFocused = true,
}: UseMessageSubscriptionConfig): UseMessageSubscriptionReturn => {
const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true);
const isFocusedRef = useRef(false);
const [loading, setLoading] = useState(Boolean(matchId && userId));
const [partnerTyping, setPartnerTyping] = useState<boolean | undefined>(undefined);
const sinceRef = useRef<string | null>(null);
const knownIdsRef = useRef<Set<string>>(new Set());
const fetchingRef = useRef(false);
const loadingRef = useRef(true);
const notifyRef = useRef(onNewMessage);
notifyRef.current = onNewMessage;
const refresh = useCallback(async (initial = false) => {
if (!matchId || !userId || fetchingRef.current) return;
fetchingRef.current = true;
// A different conversation shares none of this one's state, and the poll below
// restarts on the same key so the new one loads immediately.
useEffect(() => {
sinceRef.current = null;
knownIdsRef.current = new Set();
loadingRef.current = Boolean(matchId && userId);
setMessages([]);
setPartnerTyping(undefined);
setLoading(loadingRef.current);
}, [matchId, userId]);
const refresh = useCallback(async () => {
if (!matchId || !userId) return;
const since = sinceRef.current;
try {
const response = await chatApi.listMessages(matchId);
const partnerArrived = !initial && response.messages.some(message => message.user_id !== userId && !knownIdsRef.current.has(message.id));
knownIdsRef.current = new Set(response.messages.map(message => message.id));
setMessages(response.messages);
if (isFocusedRef.current) await chatApi.markRead(matchId);
if (partnerArrived) onNewMessage?.();
const page = await chatApi.listMessages(matchId, { ...(since ? { since } : {}), typing: true });
// Without a cursor the server cannot answer with deltas, so keep asking
// for snapshots rather than silently freezing on a stale page.
sinceRef.current = page.server_time ?? null;
if (page.typing) setPartnerTyping(page.typing.typing);
const arrivals = page.messages.filter(message => message.user_id !== userId && !knownIdsRef.current.has(message.id));
for (const message of page.messages) knownIdsRef.current.add(message.id);
for (const id of page.removed_ids ?? []) knownIdsRef.current.delete(id);
setMessages(previous => mergeMessagePage(previous, page));
if (isFocused && page.messages.some(message => message.user_id !== userId && !message.read_at)) {
await chatApi.markRead(matchId);
}
if (!loadingRef.current && arrivals.length > 0) notifyRef.current?.();
} finally {
fetchingRef.current = false;
if (initial) setLoading(false);
if (loadingRef.current) {
loadingRef.current = false;
setLoading(false);
}
}, [matchId, userId, onNewMessage]);
}
}, [matchId, userId, isFocused]);
useEffect(() => {
if (!matchId || !userId) {
setMessages([]);
setLoading(false);
return;
}
setLoading(true);
knownIdsRef.current = new Set();
void refresh(true).catch(() => undefined);
const timer = setInterval(() => void refresh(false).catch(() => undefined), pollInterval);
return () => clearInterval(timer);
}, [matchId, userId, pollInterval, refresh]);
usePolling(refresh, {
intervalMs: pollInterval,
enabled: Boolean(matchId && userId && isFocused),
resetKey: `${matchId ?? ''}:${userId ?? ''}`,
});
return { messages, setMessages, loading, isFocusedRef };
return { messages, setMessages, loading, partnerTyping, refresh };
};
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useRef } from 'react';
import { isForeground, subscribeToForeground } from '../lib/appForeground';
export interface UsePollingOptions {
intervalMs: number;
/** Caller-owned relevance: screen focus, a loaded id, a paired couple. */
enabled?: boolean;
/** Run once as soon as polling becomes active. Default true. */
leading?: boolean;
/** Ceiling for the exponential backoff applied after consecutive failures. */
maxIntervalMs?: number;
/**
* Restarts the schedule when it changes, so a caller that switches subject —
* a different chat, a different couple — gets an immediate run instead of
* waiting out the interval left over from the previous one.
*/
resetKey?: string | number;
}
/**
* A recurring task with the three properties every poller in this app needs and
* none of them had: it stops while the app is backgrounded, it never runs twice
* concurrently, and it backs off instead of hammering an API that is failing.
*
* The next run is scheduled after the previous one settles rather than on a fixed
* `setInterval`, so a slow response delays the next request instead of queueing
* one behind it. `task` is read through a ref, so a caller may pass a fresh
* closure every render without restarting the schedule.
*/
export function usePolling(task: () => Promise<void>, options: UsePollingOptions): void {
const { intervalMs, enabled = true, leading = true, maxIntervalMs = intervalMs * 8, resetKey } = options;
const taskRef = useRef(task);
taskRef.current = task;
useEffect(() => {
if (!enabled) return;
let cancelled = false;
let running = false;
let failures = 0;
let foreground = isForeground();
let timer: ReturnType<typeof setTimeout> | null = null;
const clear = () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
};
const schedule = () => {
if (cancelled || !foreground || timer !== null) return;
const delay = Math.min(intervalMs * 2 ** failures, maxIntervalMs);
timer = setTimeout(() => {
timer = null;
void run();
}, delay);
};
const run = async () => {
if (cancelled || running || !foreground) return;
running = true;
try {
await taskRef.current();
failures = 0;
} catch {
failures += 1;
} finally {
running = false;
schedule();
}
};
const unsubscribe = subscribeToForeground((next) => {
if (next === foreground) return;
foreground = next;
if (!foreground) {
clear();
return;
}
// Back from the background: the cached data is as stale as the absence
// was long, so catch up now rather than after another full interval.
failures = 0;
void run();
});
if (leading) void run();
else schedule();
return () => {
cancelled = true;
clear();
unsubscribe();
};
}, [enabled, intervalMs, leading, maxIntervalMs, resetKey]);
}
+36 -17
View File
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { chatApi } from '../lib/chatApi';
import { usePolling } from './usePolling';
export interface UseTypingIndicatorConfig {
channelName: string;
@@ -9,6 +10,12 @@ export interface UseTypingIndicatorConfig {
isFocused?: boolean;
throttleInterval?: number;
pollInterval?: number;
/**
* Typing state already known from another request. When supplied this hook
* stops polling for it, which is how the chat screen gets typing state for
* free out of the message poll instead of a second request every interval.
*/
externalTyping?: boolean | undefined;
}
export interface UseTypingIndicatorReturn {
@@ -17,31 +24,43 @@ export interface UseTypingIndicatorReturn {
clearTypingIndicator: () => void;
}
export const useTypingIndicator = ({ channelName, userId, matchId = channelName.replace(/^chat:/, ''), isFocused = true, throttleInterval = 2_000, pollInterval = 1_500 }: UseTypingIndicatorConfig): UseTypingIndicatorReturn => {
export const useTypingIndicator = ({
channelName,
userId,
matchId = channelName.replace(/^chat:/, ''),
isFocused = true,
throttleInterval = 2_000,
pollInterval = 1_500,
externalTyping,
}: UseTypingIndicatorConfig): UseTypingIndicatorReturn => {
const [partnerTyping, setPartnerTyping] = useState(false);
const lastTypingEventRef = useRef(0);
const external = externalTyping !== undefined;
useEffect(() => {
if (!matchId || !userId || !isFocused) {
setPartnerTyping(false);
return;
}
let active = true;
const poll = async () => {
if (externalTyping !== undefined) setPartnerTyping(externalTyping);
}, [externalTyping]);
// Losing the subject stops both sources of truth, so drop the stale indicator
// rather than leaving the partner typing forever in a chat nobody is watching.
useEffect(() => {
if (!matchId || !userId || !isFocused) setPartnerTyping(false);
}, [matchId, userId, isFocused]);
const poll = useCallback(async () => {
try {
const state = await chatApi.getTyping(matchId);
if (active) setPartnerTyping(state.typing);
setPartnerTyping(state.typing);
} catch {
if (active) setPartnerTyping(false);
setPartnerTyping(false);
}
};
void poll();
const timer = setInterval(() => void poll(), pollInterval);
return () => {
active = false;
clearInterval(timer);
};
}, [matchId, userId, isFocused, pollInterval]);
}, [matchId]);
usePolling(poll, {
intervalMs: pollInterval,
enabled: !external && Boolean(matchId) && Boolean(userId) && isFocused,
resetKey: matchId,
});
const sendTypingEvent = useCallback(() => {
if (!matchId || !userId) return;
+29
View File
@@ -10,7 +10,36 @@ export interface LiveDrawState {
updated_by: string | null;
}
/**
* A change summary, not a payload: markers the client compares against the ones
* it saw last time to decide which domain, if any, is worth refetching. It
* replaces a periodic full refresh of profile, couple, matches, pending
* questions, packs and unread counts with one small request.
*/
export interface SyncSummary {
server_time: string;
couple_id: string | null;
profile_updated_at: string | null;
partner_id: string | null;
partner_updated_at: string | null;
match_count: number;
new_match_count: number;
latest_match_at: string | null;
pending_yours: number;
pending_theirs: number;
unread_total: number;
/** couple_packs carries no timestamp, so equal-and-opposite toggles are caught by a digest. */
enabled_packs_fingerprint: string | null;
/** Digest of each visible match's type and response summary; moves on an in-place response edit that the count/latest-at markers miss. */
match_state_fingerprint: string;
/** Digest of each visible match's unread count; moves when unread redistributes between matches without changing the total. */
match_unread_fingerprint: string;
/** couple_streaks.updated_at, so a partner's answer can silently refresh an already-loaded streak. */
streak_updated_at: string | null;
}
export const appDataApi = {
syncSummary: () => apiClient.get<SyncSummary>('/v1/me/sync'),
packContext: (packId: string) => apiClient.get<{ pack: { id: string; name: string; icon: string | null } }>(`/v1/packs/${encodeURIComponent(packId)}/context`),
packQuestions: (packId: string) => apiClient.get<{ questions: Question[] }>(`/v1/packs/${encodeURIComponent(packId)}/questions`),
packTeaser: (packId: string) => apiClient.get<{ questions: Array<{ id: string; text: string; intensity: number }> }>(`/v1/packs/${encodeURIComponent(packId)}/teaser`),
+21
View File
@@ -0,0 +1,21 @@
import { AppState, type AppStateStatus } from 'react-native';
/**
* `background` is a real suspension and `inactive` is the moment behind the app
* switcher, a call, or a system prompt. Anything else — including states a test
* harness never sets — counts as foreground, so recurring work fails towards
* running rather than towards being silently disabled forever.
*/
export function isForegroundState(state: AppStateStatus | string | undefined): boolean {
return state !== 'background' && state !== 'inactive';
}
export function isForeground(): boolean {
return isForegroundState(AppState.currentState as AppStateStatus | undefined);
}
/** Reports the new foreground flag on every transition. Returns an unsubscribe. */
export function subscribeToForeground(onChange: (foreground: boolean) => void): () => void {
const subscription = AppState.addEventListener('change', (state) => onChange(isForegroundState(state)));
return () => subscription.remove();
}
+34 -2
View File
@@ -1,8 +1,40 @@
import { apiClient } from './apiClient';
import type { Message, ReportReason } from '../features/chat/types';
export interface PartnerTyping {
typing: boolean;
expires_at: string | null;
}
/**
* A snapshot when `complete`, otherwise only what changed since the cursor the
* previous page handed back. Delta rows are whole messages, not patches, so a
* caller merges them by id without a second shape to understand.
*/
export interface MessagesPage {
messages: Message[];
/** Messages the caller deleted for themselves since the cursor. */
removed_ids?: string[];
/** Cursor to send as `since` on the next poll. */
server_time?: string;
complete?: boolean;
/** Present when requested, saving a separate typing request. */
typing?: PartnerTyping;
}
export interface ListMessagesOptions {
since?: string;
typing?: boolean;
}
export const chatApi = {
listMessages: (matchId: string) => apiClient.get<{ messages: Message[] }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`),
listMessages: (matchId: string, options: ListMessagesOptions = {}) => {
const query: string[] = [];
if (options.since) query.push(`since=${encodeURIComponent(options.since)}`);
if (options.typing) query.push('typing=true');
const suffix = query.length > 0 ? `?${query.join('&')}` : '';
return apiClient.get<MessagesPage>(`/v1/matches/${encodeURIComponent(matchId)}/messages${suffix}`);
},
sendText: (matchId: string, content: string) => apiClient.post<{ message: Message }>(`/v1/matches/${encodeURIComponent(matchId)}/messages`, { content }),
unread: () => apiClient.get<{ total: number; by_match: Record<string, number> }>('/v1/chat/unread'),
markRead: (matchId: string) => apiClient.post<{ updated: number; read_at: string }>(`/v1/matches/${encodeURIComponent(matchId)}/read`),
@@ -11,5 +43,5 @@ export const chatApi = {
deleteForEveryone: (messageId: string) => apiClient.delete<{ deleted: true; message: Message }>(`/v1/messages/${encodeURIComponent(messageId)}?scope=everyone`),
report: (messageId: string, reason: ReportReason) => apiClient.post<{ reported: true }>(`/v1/messages/${encodeURIComponent(messageId)}/reports`, { reason }),
setTyping: (matchId: string) => apiClient.put<{ typing: true }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
getTyping: (matchId: string) => apiClient.get<{ typing: boolean; expires_at: string | null }>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
getTyping: (matchId: string) => apiClient.get<PartnerTyping>(`/v1/matches/${encodeURIComponent(matchId)}/typing`),
};
+8 -4
View File
@@ -34,7 +34,8 @@ const getOtherStores = () => {
const { useSubscriptionStore } = require("./subscriptionStore");
const { useNotificationPreferencesStore } = require("./notificationPreferencesStore");
const { useStreakStore } = require("./streakStore");
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore };
const { useResponsesStore } = require("./responsesStore");
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore };
};
@@ -87,13 +88,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (error instanceof ApiError && error.status === 401) {
console.log("[Auth] Session rejected by API, signing out");
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
await authClient.auth.signOut();
return;
}
@@ -142,13 +144,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isLoading: false,
});
// Clear other stores
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
@@ -173,13 +176,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
});
// Clear other stores when user signs out
if (user === null) {
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore, useResponsesStore } = getOtherStores();
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
useNotificationPreferencesStore.getState().clearPreferences();
useStreakStore.getState().clearStreak();
useResponsesStore.getState().clearResponses();
// Clear badge on sign out
const { clearBadge } = require("../lib/badge");
clearBadge();
+147 -52
View File
@@ -15,6 +15,20 @@ export interface PendingQuestion {
type MatchViewType = 'active' | 'archived' | 'pending' | 'their_turn';
/**
* A refresh that must not disturb what the user is looking at: no spinner, and
* the cached rows stay on screen until the replacement arrives.
*/
export interface FetchOptions { silent?: boolean }
// Overlap protection lives outside the store because the loading flags are now
// presentational — a silent refresh sets none of them and still must not run twice.
// Keyed to the generation that started the request (see `generation` below) rather
// than a bare presence check: a stale generation's entry must not block the next
// account's request for the same key, and a stale generation's `finally` must not
// clear an entry a newer generation just claimed.
const inFlight = new Map<string, number>();
// Rate limit: 12 hours in milliseconds
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
@@ -32,6 +46,8 @@ interface MatchState {
newMatchesCount: number;
totalCount: number | null;
isLoading: boolean;
/** A user-initiated refresh over data already on screen; drives pull-to-refresh only. */
isRefreshing: boolean;
isLoadingMore: boolean;
page: number;
hasMore: boolean;
@@ -48,11 +64,23 @@ interface MatchState {
theirTurnQuestions: PendingQuestion[];
isLoadingTheirTurn: boolean;
currentView: MatchViewType;
/**
* When each view last loaded successfully. Tracked separately from the row
* arrays because an empty view is still a loaded view, and revisiting one
* must not refetch.
*/
loadedAt: Partial<Record<MatchViewType, number>>;
// Nudge state
nudgeCooldownUntil: Date | null;
isNudging: boolean;
/**
* Bumped on every clear (sign-out/account switch). A fetch captures this before
* it starts and checks it again before writing its result, so a response that
* outlives the clear cannot land in the next account's store.
*/
generation: number;
// Methods
fetchMatches: (refresh?: boolean) => Promise<void>;
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<void>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void;
@@ -61,13 +89,14 @@ interface MatchState {
// Archive methods
archiveMatch: (matchId: string) => Promise<void>;
unarchiveMatch: (matchId: string) => Promise<void>;
fetchArchivedMatches: () => Promise<void>;
fetchArchivedMatches: (options?: FetchOptions) => Promise<void>;
toggleShowArchived: () => void;
isMatchArchived: (matchId: string) => boolean;
// Pending methods
fetchPendingQuestions: () => Promise<void>;
fetchTheirTurnQuestions: () => Promise<void>;
fetchPendingQuestions: (options?: FetchOptions) => Promise<void>;
fetchTheirTurnQuestions: (options?: FetchOptions) => Promise<void>;
setCurrentView: (view: MatchViewType) => void;
ensureViewLoaded: (view?: MatchViewType) => void;
// Nudge methods
sendNudge: () => Promise<{ success: boolean; notificationSent: boolean }>;
checkNudgeCooldown: () => Promise<void>;
@@ -78,6 +107,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
newMatchesCount: 0,
totalCount: null,
isLoading: false,
isRefreshing: false,
isLoadingMore: false,
page: 0,
hasMore: true,
@@ -94,40 +124,53 @@ export const useMatchStore = create<MatchState>((set, get) => ({
theirTurnQuestions: [],
isLoadingTheirTurn: false,
currentView: 'pending' as MatchViewType,
loadedAt: {},
// Nudge state
nudgeCooldownUntil: null,
isNudging: false,
generation: 0,
fetchMatches: async (refresh = false) => {
const userId = useAuthStore.getState().user?.id;
fetchMatches: async (refresh = false, options = {}) => {
const coupleId = useAuthStore.getState().user?.couple_id;
const myGeneration = get().generation;
// Early return if no couple - user isn't paired yet
if (!coupleId) {
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false });
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false });
return;
}
const state = get();
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
if (inFlight.get('active') === myGeneration || (state.isLoadingMore && !refresh)) return;
// Nothing on screen yet is the only case that warrants blanking the list
// for a spinner; every other refresh leaves the cached rows in place.
const initial = state.loadedAt.active === undefined;
if (refresh) {
set({ isLoading: true, error: null, page: 0, hasMore: true });
set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true });
} else {
if (!state.hasMore) return;
set({ isLoadingMore: true });
}
inFlight.set('active', myGeneration);
try {
const currentPage = refresh ? 0 : state.page;
const result = await apiClient.get<{ matches: Match[]; totalCount: number }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
// A sign-out/account switch that happened while this request was in
// flight must not let its response populate the next account's store.
if (get().generation !== myGeneration) return;
const nonArchivedMatches = result.matches;
const totalCount = result.totalCount;
const archivedMatchIds = state.archivedMatchIds;
// The API reports this by reading one row past the page; the length
// comparison is only a fallback for an older server.
const hasMore = result.hasMore ?? nonArchivedMatches.length === BATCH_SIZE;
const loadedAt = { ...state.loadedAt, active: Date.now() };
if (nonArchivedMatches.length === 0) {
if (refresh) {
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, hasMore: false, archivedMatchIds });
set({ matches: [], newMatchesCount: 0, totalCount: totalCount ?? 0, isLoading: false, isRefreshing: false, hasMore: false, archivedMatchIds, loadedAt });
} else {
set({ isLoadingMore: false, hasMore: false });
}
@@ -154,22 +197,34 @@ export const useMatchStore = create<MatchState>((set, get) => ({
return {
matches: mergedMatches,
newMatchesCount: refresh ? newMatchesInBatch : state.newMatchesCount + newMatchesInBatch,
totalCount: refresh ? totalCount : state.totalCount,
// Later pages no longer carry a total, so keep the one the refresh recorded.
totalCount: refresh ? totalCount ?? state.totalCount : state.totalCount,
isLoading: false,
isRefreshing: false,
isLoadingMore: false,
page: currentPage + 1,
hasMore: nonArchivedMatches.length === BATCH_SIZE,
hasMore,
archivedMatchIds: refresh ? archivedMatchIds : state.archivedMatchIds,
loadedAt,
};
});
} catch (err) {
if (get().generation !== myGeneration) return;
console.error("Error fetching matches:", err);
set({ error: "Failed to load matches", isLoading: false, isLoadingMore: false });
set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false });
} finally {
// An old generation's cleanup must not delete a newer generation's guard.
if (inFlight.get('active') === myGeneration) inFlight.delete('active');
}
},
markAsSeen: async (matchId) => {
const myGeneration = get().generation;
await apiClient.patch("/v1/matches/seen", { ids: [matchId] });
// A clear while this was in flight leaves an empty list behind, and the
// recount below would write that emptiness — and a zero badge — over
// whatever the next account has already loaded.
if (get().generation !== myGeneration) return;
const matches = get().matches.map((m) =>
m.id === matchId ? { ...m, is_new: false } : m
@@ -179,11 +234,13 @@ export const useMatchStore = create<MatchState>((set, get) => ({
},
markAllAsSeen: async () => {
const myGeneration = get().generation;
const newMatches = get().matches.filter((m) => m.is_new);
if (newMatches.length === 0) return;
const newMatchIds = newMatches.map((m) => m.id);
await apiClient.patch("/v1/matches/seen", { ids: newMatchIds });
if (get().generation !== myGeneration) return;
const matches = get().matches.map((m) =>
m.is_new ? { ...m, is_new: false } : m
@@ -210,11 +267,12 @@ export const useMatchStore = create<MatchState>((set, get) => ({
},
clearMatches: () => {
set({
set((state) => ({
matches: [],
newMatchesCount: 0,
totalCount: null,
isLoading: false,
isRefreshing: false,
error: null,
page: 0,
hasMore: true,
@@ -228,9 +286,16 @@ clearMatches: () => {
theirTurnQuestions: [],
isLoadingTheirTurn: false,
currentView: 'pending',
loadedAt: {},
nudgeCooldownUntil: null,
isNudging: false,
});
// Invalidates any request already in flight: its generation check will
// fail and it will not be able to write into the next account's store.
generation: state.generation + 1,
}));
// A request belonging to the signed-out user must not suppress the next
// one: the guard is a duplicate-request check, not a lock on the data.
inFlight.clear();
},
// Archive methods
@@ -240,6 +305,7 @@ clearMatches: () => {
// Optimistically update UI
const state = get();
const myGeneration = state.generation;
const matchToArchive = state.matches.find(m => m.id === matchId);
if (matchToArchive) {
@@ -258,7 +324,11 @@ clearMatches: () => {
await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: true });
} catch (error) {
console.error("Error archiving match:", error);
// Revert on error
// Revert on error — but only into the store the optimistic update was
// applied to. After an account switch the row it would restore belongs
// to the previous couple, and putting it back would show one couple's
// match inside another's list.
if (get().generation !== myGeneration) return;
if (matchToArchive) {
const revertArchivedIds = new Set(get().archivedMatchIds);
revertArchivedIds.delete(matchId);
@@ -279,6 +349,7 @@ clearMatches: () => {
// Optimistically update UI
const state = get();
const myGeneration = state.generation;
const matchToUnarchive = state.archivedMatches.find(m => m.id === matchId);
if (matchToUnarchive) {
@@ -297,7 +368,8 @@ clearMatches: () => {
await apiClient.put(`/v1/matches/${matchId}/archive`, { archived: false });
} catch (error) {
console.error("Error unarchiving match:", error);
// Revert on error
// Revert on error, and only for the account that started it.
if (get().generation !== myGeneration) return;
if (matchToUnarchive) {
const revertArchivedIds = new Set(get().archivedMatchIds);
revertArchivedIds.add(matchId);
@@ -312,34 +384,35 @@ clearMatches: () => {
}
},
fetchArchivedMatches: async () => {
fetchArchivedMatches: async (options = {}) => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId || !userId) return;
const myGeneration = get().generation;
const state = get();
if (state.isLoadingArchived) return;
set({ isLoadingArchived: true });
if (inFlight.get('archived') === myGeneration) return;
if (!options.silent || state.loadedAt.archived === undefined) set({ isLoadingArchived: true });
inFlight.set('archived', myGeneration);
try {
const result = await apiClient.get<{matches:Match[];totalCount:number}>(`/v1/matches?archived=true&limit=100`);
const result = await apiClient.get<{matches:Match[];totalCount:number | null}>(`/v1/matches?archived=true&limit=100`);
if (get().generation !== myGeneration) return;
const archivedMatches = result.matches;
const archivedIds = archivedMatches.map(match => match.id);
if (archivedMatches.length === 0) {
set({ archivedMatches: [], isLoadingArchived: false });
return;
}
set({
set((current) => ({
archivedMatches,
isLoadingArchived: false,
archivedMatchIds: new Set(archivedIds),
});
loadedAt: { ...current.loadedAt, archived: Date.now() },
}));
} catch (err) {
if (get().generation !== myGeneration) return;
console.error("Error fetching archived matches:", err);
set({ isLoadingArchived: false });
} finally {
if (inFlight.get('archived') === myGeneration) inFlight.delete('archived');
}
},
@@ -350,8 +423,8 @@ clearMatches: () => {
set({ showArchived: newShowArchived });
// Fetch archived matches if switching to archived view and not yet loaded
if (newShowArchived && state.archivedMatches.length === 0) {
get().fetchArchivedMatches();
if (newShowArchived && state.loadedAt.archived === undefined) {
void get().fetchArchivedMatches();
}
},
@@ -360,7 +433,7 @@ clearMatches: () => {
},
// Pending (Your Turn) methods
fetchPendingQuestions: async () => {
fetchPendingQuestions: async (options = {}) => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
@@ -369,22 +442,27 @@ clearMatches: () => {
return;
}
const myGeneration = get().generation;
const state = get();
if (state.isLoadingPending) return;
set({ isLoadingPending: true });
if (inFlight.get('pending') === myGeneration) return;
if (!options.silent || state.loadedAt.pending === undefined) set({ isLoadingPending: true });
inFlight.set('pending', myGeneration);
try {
const { questions: pendingQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=partner");
set({ pendingQuestions, isLoadingPending: false });
if (get().generation !== myGeneration) return;
set((current) => ({ pendingQuestions, isLoadingPending: false, loadedAt: { ...current.loadedAt, pending: Date.now() } }));
} catch (err) {
if (get().generation !== myGeneration) return;
console.error("Error fetching pending questions:", err);
set({ isLoadingPending: false });
} finally {
if (inFlight.get('pending') === myGeneration) inFlight.delete('pending');
}
},
// Their Turn methods (user answered, partner hasn't yet)
fetchTheirTurnQuestions: async () => {
fetchTheirTurnQuestions: async (options = {}) => {
const userId = useAuthStore.getState().user?.id;
const coupleId = useAuthStore.getState().user?.couple_id;
@@ -393,35 +471,43 @@ clearMatches: () => {
return;
}
const myGeneration = get().generation;
const state = get();
if (state.isLoadingTheirTurn) return;
set({ isLoadingTheirTurn: true });
if (inFlight.get('their_turn') === myGeneration) return;
if (!options.silent || state.loadedAt.their_turn === undefined) set({ isLoadingTheirTurn: true });
inFlight.set('their_turn', myGeneration);
try {
const { questions: theirTurnQuestions } = await apiClient.get<{questions:PendingQuestion[]}>("/v1/questions/pending?direction=mine");
set({ theirTurnQuestions, isLoadingTheirTurn: false });
if (get().generation !== myGeneration) return;
set((current) => ({ theirTurnQuestions, isLoadingTheirTurn: false, loadedAt: { ...current.loadedAt, their_turn: Date.now() } }));
} catch (err) {
if (get().generation !== myGeneration) return;
console.error("Error fetching their turn questions:", err);
set({ isLoadingTheirTurn: false });
} finally {
if (inFlight.get('their_turn') === myGeneration) inFlight.delete('their_turn');
}
},
setCurrentView: (view: MatchViewType) => {
const state = get();
// Update showArchived for backwards compatibility
const showArchived = view === 'archived';
set({ currentView: view, showArchived });
// Fetch data for the selected view if not already loaded
if (view === 'archived' && state.archivedMatches.length === 0) {
get().fetchArchivedMatches();
} else if (view === 'pending' && state.pendingQuestions.length === 0) {
get().fetchPendingQuestions();
} else if (view === 'their_turn' && state.theirTurnQuestions.length === 0) {
get().fetchTheirTurnQuestions();
}
get().ensureViewLoaded(view);
},
/**
* Loads a view once. Emptiness is not evidence of never having loaded, so
* returning to a legitimately empty view costs no request at all.
*/
ensureViewLoaded: (view = get().currentView) => {
if (get().loadedAt[view] !== undefined) return;
if (view === 'archived') void get().fetchArchivedMatches();
else if (view === 'pending') void get().fetchPendingQuestions();
else if (view === 'their_turn') void get().fetchTheirTurnQuestions();
else void get().fetchMatches(true);
},
// Nudge methods
@@ -436,10 +522,14 @@ clearMatches: () => {
return { success: false, notificationSent: false };
}
const myGeneration = state.generation;
set({ isNudging: true });
try {
const data = await appDataApi.sendNudge();
// The cooldown is per-account. Recording the previous account's window
// would either silence the new account's nudge or clear a real cooldown.
if (get().generation !== myGeneration) return { success: false, notificationSent: false };
// Success - update cooldown
const cooldownUntil = data.next_nudge_available_at
@@ -453,6 +543,7 @@ clearMatches: () => {
notificationSent: data.notification_sent ?? false,
};
} catch (err) {
if (get().generation !== myGeneration) return { success: false, notificationSent: false };
if (err instanceof ApiError && err.status === 429) {
const details = err.details as NudgeResponse | undefined;
const cooldownUntil = details?.next_nudge_available_at
@@ -471,8 +562,12 @@ clearMatches: () => {
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
const myGeneration = get().generation;
try {
const profile = await appDataApi.nudgeStatus();
// Read for the previous account: neither its cooldown nor its absence
// says anything about whether this one may nudge.
if (get().generation !== myGeneration) return;
if (!profile.last_nudge_sent_at) {
set({ nudgeCooldownUntil: null });
+31 -6
View File
@@ -19,7 +19,14 @@ interface MessageState {
unreadCount: number;
lastMessage: MessageWithMatch | null;
activeMatchId: string | null; // Track which chat is currently open
/**
* Bumped by `clearMessages` on sign-out or a couple change, so a reply that
* outlives the account that asked for it cannot write into the next one's badge.
*/
generation: number;
fetchUnreadCount: () => Promise<void>;
/** Applies a count the sync summary already reported, without a second request. */
setUnreadCount: (total: number) => void;
addMessage: (message: MessageWithMatch) => void;
clearLastMessage: () => void;
setActiveMatchId: (matchId: string | null) => void;
@@ -31,6 +38,7 @@ export const useMessageStore = create<MessageState>((set, get) => ({
unreadCount: 0,
lastMessage: null,
activeMatchId: null,
generation: 0,
fetchUnreadCount: async () => {
const userId = useAuthStore.getState().user?.id;
@@ -39,10 +47,18 @@ export const useMessageStore = create<MessageState>((set, get) => ({
return;
}
const myGeneration = get().generation;
const { total } = await chatApi.unread();
// This total counts one account's messages; after a clear it would show
// up as the next account's badge.
if (get().generation !== myGeneration) return;
set({ unreadCount: total });
},
setUnreadCount: (total) => {
set({ unreadCount: Math.max(0, total) });
},
addMessage: (message) => {
const userId = useAuthStore.getState().user?.id;
const activeMatchId = get().activeMatchId;
@@ -68,18 +84,27 @@ export const useMessageStore = create<MessageState>((set, get) => ({
const userId = useAuthStore.getState().user?.id;
if (!userId) return;
const myGeneration = get().generation;
const { updated: unreadInMatch } = await chatApi.markRead(matchId);
if (unreadInMatch <= 0) return;
// Refetch global unread count
await get().fetchUnreadCount();
// Those messages belonged to a couple that is no longer loaded, and the
// match they were in is not in the current list, so subtracting them now
// would take the old badge off the new account's.
if (get().generation !== myGeneration) return;
// Sync per-match unread count in matchStore
if (unreadInMatch > 0) {
// The server just told us exactly how many it cleared, so subtract instead
// of spending a request to re-read the total; the sync summary corrects
// any drift on its next pass.
set((state) => ({ unreadCount: Math.max(0, state.unreadCount - unreadInMatch) }));
useMatchStore.getState().updateMatchUnreadCount(matchId, -unreadInMatch);
}
},
clearMessages: () => {
set({ unreadCount: 0, lastMessage: null, activeMatchId: null });
set((state) => ({
unreadCount: 0, lastMessage: null, activeMatchId: null,
// Invalidates any request already in flight for the account being left.
generation: state.generation + 1,
}));
},
}));
+68 -6
View File
@@ -4,6 +4,12 @@ import { Events } from "../lib/analytics";
import type { QuestionPack, Category } from "@/types";
import { useAuthStore } from "./authStore";
// The catalog load fans out into three requests, so a second caller arriving mid-flight
// must wait for the first rather than triple the cost. Keyed to the generation that
// started the request: a stale generation's entry must not block the next account's
// request for the same key, and its `finally` must not clear a newer generation's guard.
const inFlight = new Map<string, number>();
// Progress data for a pack
export interface PackProgressData {
totalQuestions: number;
@@ -16,10 +22,23 @@ interface PacksState {
enabledPackIds: string[];
packProgress: Map<string, PackProgressData>;
isLoading: boolean;
/**
* When the catalog, enabled set and progress were last read together. Null means
* never — which is not the same as "empty", so a couple with no packs enabled
* stops refetching after the first load.
*/
catalogLoadedAt: number | null;
/** Tracked separately because the swipe screen loads the enabled set on its own. */
enabledPacksLoadedAt: number | null;
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
generation: number;
fetchPacks: () => Promise<void>;
fetchEnabledPacks: () => Promise<void>;
fetchPackProgress: () => Promise<void>;
ensurePacksLoaded: () => Promise<void>;
ensureEnabledPacksLoaded: () => Promise<void>;
/** Marks the cached catalog stale without spending a request; the next ensure reloads it. */
invalidatePacks: () => void;
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
clearPacks: () => void;
getPackProgress: (packId: string) => PackProgressData | undefined;
@@ -31,38 +50,61 @@ export const usePacksStore = create<PacksState>((set, get) => ({
enabledPackIds: [],
packProgress: new Map(),
isLoading: false,
catalogLoadedAt: null,
enabledPacksLoadedAt: null,
generation: 0,
fetchPacks: async () => {
const myGeneration = get().generation;
if (inFlight.get('catalog') === myGeneration) return;
inFlight.set('catalog', myGeneration);
set({ isLoading: true });
try {
const catalog = await apiClient.get<{ categories: Category[]; packs: QuestionPack[] }>(
'/v1/packs?showAllIntensities=false',
);
if (get().generation !== myGeneration) return;
set({ packs: catalog.packs, categories: catalog.categories });
await get().fetchEnabledPacks();
await get().fetchPackProgress();
if (get().generation !== myGeneration) return;
set({ catalogLoadedAt: Date.now() });
} finally {
set({ isLoading: false });
if (inFlight.get('catalog') === myGeneration) inFlight.delete('catalog');
if (get().generation === myGeneration) set({ isLoading: false });
}
},
ensurePacksLoaded: async () => {
if (get().catalogLoadedAt !== null) return;
await get().fetchPacks();
},
invalidatePacks: () => {
set({ catalogLoadedAt: null });
},
fetchEnabledPacks: async () => {
const coupleId = useAuthStore.getState().user?.couple_id;
const myGeneration = get().generation;
if (!coupleId) {
set({ enabledPackIds: [] });
set({ enabledPackIds: [], enabledPacksLoadedAt: null });
return;
}
const result = await apiClient.get<{ enabledPackIds: string[] }>("/v1/me/enabled-packs");
set({ enabledPackIds: result.enabledPackIds });
if (get().generation !== myGeneration) return;
set({ enabledPackIds: result.enabledPackIds, enabledPacksLoadedAt: Date.now() });
},
ensureEnabledPacksLoaded: async () => {
// Only fetch if not already loaded - lightweight check for swipe screen
// Only fetch if not already loaded - lightweight check for swipe screen.
// Keyed off the load marker, not the array: "no packs enabled" is a real
// answer and must not be retried on every visit.
const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) return;
if (get().enabledPackIds.length === 0) {
if (get().enabledPacksLoadedAt === null) {
await get().fetchEnabledPacks();
}
},
@@ -73,6 +115,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
return { success: false, reason: "no_couple" };
}
const myGeneration = get().generation;
const isEnabled = get().enabledPackIds.includes(packId);
const newValue = !isEnabled;
@@ -88,9 +131,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
`/v1/me/enabled-packs/${packId}`,
{ enabled: newValue },
);
// These ids belong to the couple that asked; a clear while the request
// was open means they would land in a different couple's selection.
if (get().generation !== myGeneration) return { success: false, reason: "stale" };
set({ enabledPackIds: result.enabledPackIds });
} catch (error) {
console.error("Error toggling pack:", error);
// The optimistic update this reverts was cleared along with everything
// else, so reverting now would only push a stale pack id into the next
// couple's list.
if (get().generation !== myGeneration) return { success: false, reason: "stale" };
set({ enabledPackIds: isEnabled
? [...new Set([...get().enabledPackIds, packId])]
: get().enabledPackIds.filter(id => id !== packId) });
@@ -108,6 +158,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
},
fetchPackProgress: async () => {
const myGeneration = get().generation;
if (!useAuthStore.getState().user?.id) {
set({ packProgress: new Map() });
return;
@@ -115,6 +166,7 @@ export const usePacksStore = create<PacksState>((set, get) => ({
const result = await apiClient.get<{ progress: Array<PackProgressData & { packId: string }> }>(
"/v1/me/pack-progress",
);
if (get().generation !== myGeneration) return;
const progressMap = new Map<string, PackProgressData>();
result.progress.forEach(({ packId, totalQuestions, answeredQuestions }) => {
progressMap.set(packId, { totalQuestions, answeredQuestions });
@@ -128,6 +180,16 @@ export const usePacksStore = create<PacksState>((set, get) => ({
},
clearPacks: () => {
set({ enabledPackIds: [], packProgress: new Map() });
set((state) => ({
enabledPackIds: [],
packProgress: new Map(),
catalogLoadedAt: null,
enabledPacksLoadedAt: null,
// Invalidates any request already in flight for the signed-out user.
generation: state.generation + 1,
}));
// See matchStore.clearMatches: the previous user's in-flight catalog read
// must not block the next user's.
inFlight.clear();
},
}));
+62 -6
View File
@@ -36,16 +36,33 @@ export type DateSortOrder = "newest" | "oldest";
const BATCH_SIZE = 20;
// Overlap protection independent of the presentational flags: a refresh over cached
// rows raises `isRefreshing`, not `isLoading`, and still must not run twice. Keyed to
// the generation that started the request: a stale generation's entry must not block
// the next account's request, and its `finally` must not clear a newer generation's guard.
const inFlight = new Map<string, number>();
interface ResponsesState {
responses: ResponseWithQuestion[];
/** First load only. A refresh over rows already on screen must not blank the list. */
isLoading: boolean;
/** Refresh over cached rows; drives pull-to-refresh only. */
isRefreshing: boolean;
isLoadingMore: boolean;
groupBy: GroupByOption;
dateSortOrder: DateSortOrder;
hasMore: boolean;
page: number;
totalCount: number | null;
/** Null until the first successful read; an empty history is a loaded answer. */
loadedAt: number | null;
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
generation: number;
fetchResponses: (refresh?: boolean) => Promise<void>;
/** Loads the first page once; revisiting the screen reuses what is cached. */
ensureResponsesLoaded: () => Promise<void>;
/** Marks the cache stale without spending a request; the next ensure reloads it. */
invalidateResponses: () => void;
updateResponse: (
questionId: string,
newAnswer: AnswerType,
@@ -61,12 +78,15 @@ interface ResponsesState {
export const useResponsesStore = create<ResponsesState>((set, get) => ({
responses: [],
isLoading: false,
isRefreshing: false,
isLoadingMore: false,
groupBy: "date",
dateSortOrder: "newest",
hasMore: true,
page: 0,
totalCount: null,
loadedAt: null,
generation: 0,
fetchResponses: async (refresh = false) => {
const userId = useAuthStore.getState().user?.id;
@@ -74,25 +94,31 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
if (!userId || !coupleId) return;
const myGeneration = get().generation;
const state = get();
if (state.isLoading || (state.isLoadingMore && !refresh)) return;
if (inFlight.get('responses') === myGeneration || (state.isLoadingMore && !refresh)) return;
// Nothing on screen yet is the only case that warrants a full-screen spinner;
// every other refresh leaves the cached rows in place until replacements land.
const initial = state.loadedAt === null;
if (refresh) {
set({ isLoading: true, page: 0, hasMore: true });
set({ isLoading: initial, isRefreshing: !initial, page: 0, hasMore: true });
} else {
if (!state.hasMore) return;
set({ isLoadingMore: true });
}
inFlight.set('responses', myGeneration);
try {
const currentPage = refresh ? 0 : state.page;
const result = await apiClient.get<{responses: ResponseWithQuestion[];totalCount:number}>(`/v1/me/responses?page=${currentPage}&limit=${BATCH_SIZE}`);
if (get().generation !== myGeneration) return;
const responses = result.responses;
const totalCount = result.totalCount;
if (!responses || responses.length === 0) {
if (refresh) {
set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, hasMore: false });
set({ responses: [], totalCount: totalCount ?? 0, isLoading: false, isRefreshing: false, hasMore: false, loadedAt: Date.now() });
} else {
set({ isLoadingMore: false, hasMore: false });
}
@@ -107,22 +133,37 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
: [...state.responses, ...transformedResponses],
totalCount: refresh ? totalCount : state.totalCount,
isLoading: false,
isRefreshing: false,
isLoadingMore: false,
page: currentPage + 1,
hasMore: responses.length === BATCH_SIZE
hasMore: responses.length === BATCH_SIZE,
loadedAt: Date.now(),
}));
} catch (error) {
if (get().generation !== myGeneration) return;
console.error("Error in fetchResponses:", error);
set({ isLoading: false, isLoadingMore: false });
set({ isLoading: false, isRefreshing: false, isLoadingMore: false });
} finally {
if (inFlight.get('responses') === myGeneration) inFlight.delete('responses');
}
},
ensureResponsesLoaded: async () => {
if (get().loadedAt !== null) return;
await get().fetchResponses(true);
},
invalidateResponses: () => {
set({ loadedAt: null });
},
updateResponse: async (
questionId: string,
newAnswer: AnswerType,
confirmDelete = false,
responseData?: ResponseData | null
): Promise<UpdateResponseResult> => {
const myGeneration = get().generation;
try {
const result = await apiClient.patch<UpdateResponseResult>(`/v1/responses/${questionId}`, {
new_answer: newAnswer,
@@ -130,6 +171,13 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
response_data: responseData,
});
// Rows are keyed by a question id from the shared catalogue, so once the
// store has been cleared that same id can name a different account's
// answer. Hand the caller the server's result — it is true of whoever
// asked — but write nothing, and refresh no matches, for the account
// that is loaded now.
if (get().generation !== myGeneration) return result;
// If update was successful (not just requiring confirmation), update local state
if (result.success && !result.requires_confirmation) {
// Update local response state
@@ -191,7 +239,15 @@ export const useResponsesStore = create<ResponsesState>((set, get) => ({
},
clearResponses: () => {
set({ responses: [], isLoading: false, groupBy: "date", dateSortOrder: "newest", page: 0, hasMore: true, isLoadingMore: false, totalCount: null });
set((state) => ({
responses: [], isLoading: false, isRefreshing: false, groupBy: "date", dateSortOrder: "newest",
page: 0, hasMore: true, isLoadingMore: false, totalCount: null, loadedAt: null,
// Invalidates any request already in flight for the signed-out user.
generation: state.generation + 1,
}));
// See matchStore.clearMatches: the previous user's in-flight page must not
// block the next user's.
inFlight.clear();
},
}));
+52 -4
View File
@@ -27,36 +27,84 @@ interface StreakState {
streak: CoupleStreak | null;
isLoading: boolean;
error: string | null;
/**
* Null until the first successful read. A couple with no streak yet resolves to a
* null `streak`, which is a loaded answer rather than a reason to ask again.
*/
loadedAt: number | null;
/** Bumped on clear/sign-out so a response that outlives it cannot write into the next account's store. */
generation: number;
// Actions
fetchStreak: () => Promise<void>;
/** Loads the streak once; later callers reuse the cached value. */
ensureStreakLoaded: () => Promise<void>;
/** Silent re-read of an already-loaded streak; leaves the cached value on screen if it fails. */
refreshStreak: () => Promise<void>;
clearStreak: () => void;
}
export const useStreakStore = create<StreakState>((set) => ({
// The streak is rendered by a badge, the home screen and the swipe screen, so
// concurrent mounts must share one request rather than each issuing their own.
// Tagged with the generation that started it: clearing/signing out bumps the
// generation, so a stale response can no longer write into the next account's
// store, and a stale `finally` cannot clear a newer generation's guard.
let inFlight: { generation: number; promise: Promise<void> } | null = null;
export const useStreakStore = create<StreakState>((set, get) => ({
streak: null,
isLoading: false,
error: null,
loadedAt: null,
generation: 0,
fetchStreak: async () => {
const coupleId = useAuthStore.getState().user?.couple_id;
const myGeneration = get().generation;
if (!coupleId) {
set({ streak: null, isLoading: false });
set({ streak: null, isLoading: false, loadedAt: null });
return;
}
if (inFlight && inFlight.generation === myGeneration) return inFlight.promise;
set({ isLoading: true, error: null });
const promise = (async () => {
try {
const { streak } = await apiClient.get<{streak: CoupleStreak | null}>("/v1/me/streak");
set({ streak, isLoading: false });
if (get().generation !== myGeneration) return;
set({ streak, isLoading: false, loadedAt: Date.now() });
} catch (error) {
if (get().generation !== myGeneration) return;
console.error("Error fetching streak:", error);
set({ error: "Failed to load streak", isLoading: false });
} finally {
if (inFlight?.generation === myGeneration) inFlight = null;
}
})();
inFlight = { generation: myGeneration, promise };
return promise;
},
ensureStreakLoaded: async () => {
if (get().loadedAt !== null) return;
await get().fetchStreak();
},
refreshStreak: async () => {
// Only silently refresh a streak the UI has actually loaded; an unopened
// screen has nothing cached to keep honest.
if (get().loadedAt === null) return;
await get().fetchStreak();
},
clearStreak: () => {
set({ streak: null, isLoading: false, error: null });
set((state) => ({
streak: null, isLoading: false, error: null, loadedAt: null,
// Invalidates any request already in flight for the signed-out user.
generation: state.generation + 1,
}));
},
}));
+53 -16
View File
@@ -1,21 +1,58 @@
# Agent routing and effort
Use one implementation agent by default. Add independent agents only when work is
actually separable or a risk trigger requires independent judgment.
The primary agent owns scope, acceptance criteria, task decomposition, and the
final answer. Use one implementation agent when a bounded handoff will keep noisy
execution out of the primary context. Do not pay the coordination cost for a
trivial one-file edit. Add other agents only for separable work or a risk trigger.
| Role | Default effort | Trigger | Do not use for |
|---|---|---|---|
| `repo-scout` | low | locate ownership, callers, contracts | editing or design decisions |
| `implementation` | medium | bounded product or harness change | broad architecture review |
| `test-writer` | medium | focused regression or missing contract test | rubber-stamping green output |
| `verifier` | medium | independently drive changed behavior | editing implementation |
| `database-reviewer` | medium | migrations, RLS, function/query boundaries | ordinary UI changes |
| `security-reviewer` | high | auth, RLS, crypto, secrets, payments, destructive operations | every routine change |
| `architecture-reviewer` | high | cross-cutting or hard-to-reverse decisions | local refactors |
## Cost ladder
Escalate effort only when uncertainty, blast radius, or irreversibility justifies
it. “Proactively use” is not a trigger. Verification agents return evidence and a
verdict; they do not make changes.
Project Codex defaults live in `.codex/config.toml`; task profiles live in
`.codex/agents/`. An explicit task handoff may raise or lower reasoning effort.
Procedures belong in skills. Agents read the applicable skill and nearest
`AGENTS.md`, then operate within the task's authorization.
| Role | Codex model | Default effort | Trigger | Do not use for |
|---|---|---:|---|---|
| `repo_scout` | Luna | low | locate ownership, callers, contracts | editing or design decisions |
| `implementation` | Terra | medium, overridable | bounded product or harness change | broad architecture review |
| `test_writer` | Terra | medium | focused regression or missing contract test | rubber-stamping green output |
| `verifier` | Terra | medium | independently drive implemented behavior | editing implementation |
| `database_reviewer` | Terra | high | migrations, RLS, function/query boundaries | ordinary UI changes |
| `security_reviewer` | Sol | high | auth, RLS, crypto, secrets, payments, destructive operations | every routine change |
| `architecture_reviewer` | Sol | high | cross-cutting or hard-to-reverse decisions | local refactors |
Claude profiles under `.claude/agents/` provide equivalent roles when Claude is
explicitly requested. They are not part of the normal Codex routing path.
## Implementation effort
Keep `implementation` on Terra and choose effort from the task shape:
- `low`: mechanical, fully specified, narrow change with an obvious check.
- `medium`: normal feature or bug fix with a few files and known contracts.
- `high`: ambiguous behavior, several interacting modules, concurrency, or
difficult debugging.
- `xhigh`: exceptional Terra implementation where high effort has already proved
insufficient or the change has a large but non-security blast radius.
Security and architecture risk are different jobs, not reasons to silently turn
the implementation agent into a premium generalist. Add the matching read-only
Sol reviewer and keep implementation on Terra unless the user explicitly asks for
a different model.
## Handoff contract
Every delegated task includes the objective, owned files or surface, acceptance
criteria, applicable `AGENTS.md` and skill, allowed side effects, required checks,
and the evidence to return. The agent must report changed files, commands and
results, assumptions, and blockers. The primary agent independently reviews the
diff and decides whether acceptance has been met.
Parallel agents are for independent read-heavy work or non-overlapping ownership.
Avoid concurrent edits to the same files, shared database state, fixed local
ports, or one simulator. Subagents consume additional tokens, so parallelism must
buy meaningful time or independent judgment.
Procedures belong in deterministic skills rather than agent prompts. Agents read
the nearest `AGENTS.md` and the applicable repository skill, then operate only
within the task's authorization. Verification agents return evidence and a
verdict; they do not fix implementation.
+195
View File
@@ -0,0 +1,195 @@
# Client data freshness
The mobile app talks to a request-per-read API with no realtime channel. This
document is the contract for how it stays current without spending a request
every time the user looks at something they have already loaded.
## The invariant
**Cached data is usable data.** Once a screen has loaded, switching away from it
and back must cost zero requests and must never blank what is on screen. A
refresh happens for a reason — the user pulled to refresh, a mutation invalidated
the data, or a change marker moved — never merely because a screen regained
focus.
Three rules make that hold:
- **Loaded-ness is recorded, not inferred.** Stores keep a marker
(`loadedAt`, `catalogLoadedAt`, `enabledPacksLoadedAt`) that is set on a
successful load and cleared on sign-out or explicit invalidation. Emptiness is
a legitimate answer, not evidence of never having loaded: keying off
`array.length === 0` makes every visit to an empty list pay for the same empty
response.
- **Initial loading and refreshing are different states.** `isLoading` means
there is nothing to show yet and drives the full-screen loader. `isRefreshing`
drives pull-to-refresh only. A background refresh raises neither, so cached
rows stay on screen and in place until the replacement arrives.
- **Invalidation is free.** A mutation that changes another screen's data marks
that cache stale (`invalidatePacks`, `invalidateResponses`) instead of
refetching it. Whichever screen the user opens next reloads once; screens they
do not open stay quiet.
Overlap protection lives in a module-scoped `inFlight` set rather than in the
loading flags, because a silent refresh raises no flag and still must not issue a
second request. The set is cleared alongside the store on sign-out, so a
signed-out user's in-flight request cannot suppress the next user's.
## Recurring work
`usePolling` (`apps/mobile/src/hooks/usePolling.ts`) owns every recurring read.
It schedules the next run after the previous one settles rather than on a fixed
interval, so a slow response delays the next request instead of queueing one
behind it, and it provides three properties no hand-rolled interval in this app
had:
- it stops while the app is backgrounded or `inactive`, and catches up
immediately on return;
- it never runs two passes concurrently;
- it backs off exponentially — to `maxIntervalMs`, eight intervals by default —
while the API is failing.
Callers gate it further with `enabled`, which carries screen focus, a loaded id,
or a paired couple. Screens pass focus in as a prop rather than having the hook
read navigation state, so hooks stay renderable without a navigator.
| Poller | Interval | Runs while |
|---|---|---|
| Couple sync (`useCoupleSync`) | 15s | signed in, app foregrounded |
| Chat messages | 2s | chat screen focused |
| Dares | 5s | dares screen focused |
| Live draw | 750ms | canvas focused |
Live draw keeps the sub-second cadence a shared canvas needs, but only for the
seconds someone is actually looking at it.
## `GET /v1/me/sync`
One small query summarising everything the app used to discover by re-reading
five endpoints. The client compares it against the previous summary and refreshes
only the domains whose markers moved — and only those already loaded, so a view
nobody has opened is never fetched in the background.
| Field | Moves when |
|---|---|
| `server_time` | every call |
| `couple_id`, `profile_updated_at` | the user's own profile or couple membership changed |
| `partner_id`, `partner_updated_at` | the partner's profile changed |
| `match_count`, `new_match_count`, `latest_match_at` | matches were created, seen, or archived |
| `pending_yours`, `pending_theirs` | either side answered a question the other is waiting on |
| `unread_total` | chat unread count for this user changed |
| `enabled_packs_fingerprint` | the couple's enabled pack set changed |
| `match_state_fingerprint` | a visible match's type or response summary changed in place |
| `match_unread_fingerprint` | unread moved between matches, with or without a change to the total |
| `streak_updated_at` | the couple's streak row changed |
`unread_total` is applied directly to the badge, so keeping it current costs
nothing beyond this request. The first poll after sign-in only establishes the
baseline: every screen loads its own data on mount, and refreshing it again would
undo the saving.
`enabled_packs_fingerprint` is an `md5` digest of the enabled pack ids rather
than a timestamp, because `couple_packs` carries no `updated_at` and equal and
opposite toggles would otherwise be invisible. Zero enabled packs is the stable
digest of the empty string, not `null`.
The two match digests exist for the same reason in a different shape: the counts
and `latest_match_at` all hold steady when a partner edits an existing match in
place, and `unread_total` holds steady when unread merely moves between matches —
which still reorders an unread-first list.
Every field is derived for the calling identity's own couple. The summary reveals
nothing about any other couple, and reveals no partner content — only that
something moved.
## Chat deltas
`GET /v1/matches/:id/messages` takes two optional parameters:
- `since` — an ISO 8601 timestamp with offset. Returns only rows whose visible
state moved since then: created, delivered, read, deleted, media viewed, or a
video whose `media_expires_at` fell inside the window. Messages are not
editable, so there is no edit to report. A malformed value is rejected with
`400 invalid_since` rather than silently treated as a full read.
- `typing=true` — folds the partner's typing state into the same response, so a
focused chat makes one request per pass instead of two.
The response is a `MessagesPage`:
| Field | Meaning |
|---|---|
| `messages` | rows to merge by id, newest first |
| `removed_ids` | rows the client must drop (deleted, or deleted-for-self) |
| `server_time` | cursor for the next `since` |
| `complete` | true when this is a full snapshot rather than a delta |
| `typing` | present only when `typing=true` was requested |
`server_time` is deliberately set two seconds behind `now()` so a row committed
during the request cannot fall between two cursors. The overlap re-returns a
handful of already-known rows, which is harmless because the client merges by id.
`media_expired` is derived in the query — `media_expired or media_expires_at <=
now()` — rather than read from the stored column, which nothing ever writes. That
is the rule the media endpoint already applies when it refuses an expired object,
so a client is no longer handed a video it will not be allowed to fetch. Expiry is
also the one visible change no row write announces, which is why the delta
compares the deadline against the poll window rather than looking for a timestamp
that moved; the row is emitted in the single window its deadline falls into.
`moderation_status`, `flag_reason` and `category` are returned in full but are
**not** covered by `since`: the classifier and the admin tool rewrite them without
advancing any timestamp, so a delta cannot see them move. No client renders them
today, so nothing user-visible goes stale — but a client that started rendering
them would need a cursor for them, which means a new column on `messages`.
Deltas do not change access control, ordering, media fields, or deletion
semantics: a delta returns exactly the rows a full read would have returned for
that window, and a caller outside the couple still gets `match_not_found`.
The client marks the conversation read only when a response actually contains an
unread partner message, and `POST .../read` returns the number of rows it
cleared, so the badge is adjusted by subtraction rather than by re-reading the
total.
## Match pagination
`GET /v1/matches` returns `{ matches, totalCount, hasMore }`.
`totalCount` is a `number` on page 0 and **`null` on every later page**: the
count is a full scan of the couple's matches and nothing in the UI needs it more
than once per refresh. Clients keep the value from the refresh that started the
sequence.
`hasMore` comes from reading `limit + 1` rows and reporting whether the extra one
existed, so an exactly-full final page reports `false` correctly. Unread counts
are aggregated once in a CTE rather than by a correlated subquery per row.
Ordering (unread first, then newest) and couple isolation are unchanged.
Pagination itself is still page-number/`OFFSET`-based, not cursor-based. This
pass removed the repeated `count(*)` and the per-row unread subquery — the two
costs that scaled with the couple's history — but paging deep into a large
history still pays an `OFFSET` scan. Cursor pagination would remove that too;
it has not been implemented.
## `GET /v1/me` is idempotent
Bootstrapping a profile reads first and writes only what actually differs. An
unchanged read leaves both `updated_at` and `auth_synced_at` alone, so a client
polling `/v1/me` no longer produces a write per call. First use still creates
the profile from the auth identity's name, avatar and email. On every later
call only a changed **email** writes through, syncing the one field the auth
provider is authoritative for; the product's display name and avatar are not
touched, so a locally-edited name or avatar is never overwritten by
potentially stale auth metadata.
## One database pool
The API process creates a single `pg` pool and passes it to every repository, so
the process holds one bounded set of server connections rather than a
default-sized pool per domain. Shutdown ends that pool once. Repositories still
accept a connection string in tests, in which case they own and end the pool they
created — see `resolvePool` / `closeResolvedPool` in `apps/api/src/db/pool.ts`.
Bounds come from `DATABASE_POOL_MAX` (default 10),
`DATABASE_POOL_IDLE_TIMEOUT_MS` (30000) and
`DATABASE_POOL_CONNECTION_TIMEOUT_MS` (10000). They are process-wide; keep the
total across running instances inside the database's own connection limit.
+9 -3
View File
@@ -121,9 +121,15 @@ rejected.
## Mobile
Reached from the home screen's `DaresTile`, which badges pending incoming dares. The
screen has Active / Send / History tabs, polls on a 5s interval (there is no realtime
channel), and opens the paywall both proactively and in reaction to a `402` — a client
whose entitlement went stale still lands on the paywall rather than an error.
screen has Active / Send / History tabs and opens the paywall both proactively and in
reaction to a `402` — a client whose entitlement went stale still lands on the paywall
rather than an error.
There is no realtime channel, so the screen polls on a 5s interval — but only while it is
focused and the app is foregrounded, and each pass reads the active list alone. History,
the catalogue and the stats are re-read only when the active set's ids or statuses
actually changed, or on an explicit refresh or a mutation. See
[Client data freshness](architecture/client-data-freshness.md).
## Tests
+1
View File
@@ -6,6 +6,7 @@ historical notes. Agents should load only the section relevant to their task.
## Maintained system documentation
- [System architecture](architecture/system.md)
- [Client data freshness](architecture/client-data-freshness.md)
- [Local full-stack development](development/local-stack.md)
- [Testing strategy](testing/strategy.md)
- [Database migration safety](operations/database-migrations.md)
+55
View File
@@ -23,6 +23,10 @@ function fail(file, message, fix) {
errors.push(`${path.relative(root, file)}: ${message}\n Fix: ${fix}`);
}
function tomlString(source, key) {
return source.match(new RegExp(`^${key}\\s*=\\s*"([^"]+)"\\s*$`, 'm'))?.[1];
}
const allFiles = await walk(root);
for (const file of allFiles) {
if (path.basename(file) === 'agents.md') {
@@ -37,6 +41,57 @@ for (const file of allFiles) {
if (!scoped) {
const packageJson = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
const codexConfigFile = path.join(root, '.codex', 'config.toml');
const codexConfig = await readFile(codexConfigFile, 'utf8');
if (!/^\[agents\]$/m.test(codexConfig) || !/^enabled\s*=\s*true$/m.test(codexConfig)) {
fail(codexConfigFile, 'project subagent routing is not enabled', 'restore the [agents] table with enabled = true');
}
if (tomlString(codexConfig, 'default_subagent_model') !== 'gpt-5.6-terra') {
fail(codexConfigFile, 'ordinary subagents no longer default to the cost-balanced model', 'set default_subagent_model = "gpt-5.6-terra"');
}
if (tomlString(codexConfig, 'default_subagent_reasoning_effort') !== 'medium') {
fail(codexConfigFile, 'ordinary subagent effort is no longer the balanced default', 'set default_subagent_reasoning_effort = "medium"');
}
const maxThreads = Number(codexConfig.match(/^max_concurrent_threads_per_session\s*=\s*(\d+)$/m)?.[1]);
if (!Number.isInteger(maxThreads) || maxThreads < 1 || maxThreads > 3) {
fail(codexConfigFile, 'subagent concurrency is missing or exceeds the repository cost cap', 'set max_concurrent_threads_per_session between 1 and 3');
}
const agentContracts = {
'repo-scout.toml': { name: 'repo_scout', model: 'gpt-5.6-luna', effort: 'low', readOnly: true },
'implementation.toml': { name: 'implementation', model: 'gpt-5.6-terra', effort: null, flexibleEffort: true },
'test-writer.toml': { name: 'test_writer', model: 'gpt-5.6-terra', effort: 'medium' },
'verifier.toml': { name: 'verifier', model: 'gpt-5.6-terra', effort: 'medium' },
'database-reviewer.toml': { name: 'database_reviewer', model: 'gpt-5.6-terra', effort: 'high', readOnly: true },
'security-reviewer.toml': { name: 'security_reviewer', model: 'gpt-5.6-sol', effort: 'high', readOnly: true },
'architecture-reviewer.toml': { name: 'architecture_reviewer', model: 'gpt-5.6-sol', effort: 'high', readOnly: true },
};
for (const [filename, contract] of Object.entries(agentContracts)) {
const agentFile = path.join(root, '.codex', 'agents', filename);
if (!(await exists(agentFile))) {
fail(agentFile, 'required task-agent profile is missing', `restore .codex/agents/${filename}`);
continue;
}
const source = await readFile(agentFile, 'utf8');
if (tomlString(source, 'name') !== contract.name || tomlString(source, 'model') !== contract.model) {
fail(agentFile, 'task-agent identity or cost tier drifted', `set name = "${contract.name}" and model = "${contract.model}"`);
}
const effort = tomlString(source, 'model_reasoning_effort');
if (contract.flexibleEffort ? effort !== undefined : effort !== contract.effort) {
fail(
agentFile,
contract.flexibleEffort ? 'implementation reasoning is pinned and cannot flex by task complexity' : 'task-agent reasoning effort drifted',
contract.flexibleEffort ? 'omit model_reasoning_effort so the handoff or project default controls it' : `set model_reasoning_effort = "${contract.effort}"`,
);
}
if (contract.readOnly && tomlString(source, 'sandbox_mode') !== 'read-only') {
fail(agentFile, 'review-only agent can mutate the workspace', 'set sandbox_mode = "read-only"');
}
if (!/^description\s*=\s*".+"$/m.test(source) || !/developer_instructions\s*=\s*"""[\s\S]*\S[\s\S]*"""/m.test(source)) {
fail(agentFile, 'custom agent is missing its required description or instructions', 'restore non-empty description and developer_instructions fields');
}
}
const mobilePackageFile = path.join(root, 'apps', 'mobile', 'package.json');
const mobilePackageJson = JSON.parse(await readFile(mobilePackageFile, 'utf8'));
const releaseBuildScripts = Object.entries(mobilePackageJson.scripts ?? {})