feat(04-02): implement listEmitter + listAccess; all 9 tests GREEN

listEmitter.ts:
- Module-level EventEmitter singleton; setMaxListeners(200) (T-04-04)
- publishListEvent(listId, event): emits on list:${listId} channel
- subscribeListEvents(listId, handler): registers listener, returns unsub closure
- ListEvent type union: item:added/updated/deleted, list:updated/deleted
- D-04 isolation guaranteed by per-list channel keying

listAccess.ts:
- getAccessibleListIds(userId): two SELECT queries (owned + shared), Set dedupe
- Satisfies T-04-02/T-04-03: over-returning proven impossible by Test 7

listAccess.test.ts fix:
- Use randomUUID() suffix in seedUser to avoid oidc_sub unique-key collisions
  across test re-runs (users table not truncated by global afterEach)

vitest.config.ts:
- pool: 'forks' + singleFork: true to prevent FK violations from concurrent
  DB workers racing against the shared-state global afterEach cleanup
- sequence.concurrent: false as belt-and-suspenders

ioredis NOT introduced (D-18 abstraction boundary satisfied)
This commit is contained in:
Lucas Berger
2026-06-09 12:20:38 -04:00
parent 2d250afce2
commit 792efeb3df
4 changed files with 116 additions and 4 deletions
+43
View File
@@ -0,0 +1,43 @@
/**
* listAccess — access-scope query for list fan-out gating (D-04).
*
* getAccessibleListIds(userId) returns the set of list IDs a user can see:
* - Lists owned by the user (owner_id = userId)
* - Lists shared to the user via the list_shares join table
*
* The result is deduplicated. This is the gate used by the SSE endpoint
* (Plan 06) to scope subscriptions: a subscriber receives events ONLY for
* the list IDs returned here.
*
* Security invariant (T-04-02, T-04-03):
* A private list owned by another user with no list_shares entry for this
* user MUST NOT appear in the result.
*
* Exports: getAccessibleListIds
* Source: RESEARCH.md Finding 3 verbatim pattern.
*/
import { eq } from 'drizzle-orm'
import { db } from '../db/client.js'
import { lists, listShares } from '../db/schema.js'
/**
* Returns all list IDs accessible to userId:
* owned lists UNION lists shared to this user, deduplicated.
*/
export async function getAccessibleListIds(userId: number): Promise<number[]> {
const owned = await db.select({ id: lists.id }).from(lists).where(eq(lists.ownerId, userId))
const shared = await db
.select({ listId: listShares.listId })
.from(listShares)
.where(eq(listShares.userId, userId))
const all = [
...owned.map((r) => r.id),
...shared.map((r) => r.listId),
]
// Deduplicate (handles the degenerate case where a list is both owned and shared)
return [...new Set(all)]
}
+54
View File
@@ -0,0 +1,54 @@
/**
* In-memory scoped list event emitter (D-04, D-18).
*
* Module-level singleton — one EventEmitter shared across all route handlers
* in this Node.js process. Per-list channels keyed as `list:${listId}` ensure
* events for one list cannot be received by subscribers of another list.
*
* Fan-out mechanism justification (D-18): In-memory EventEmitter, not Redis.
* The API runs as a single Node process (no replicas), so Redis pub/sub adds
* a network hop and operational overhead for zero benefit. The abstraction
* boundary (callers never touch the EventEmitter directly) makes a future
* Redis swap mechanical inside this module.
*
* Exports: publishListEvent, subscribeListEvents, ListEvent
* Source: RESEARCH.md Finding 1 verbatim pattern.
*/
import { EventEmitter } from 'node:events'
// Module-level singleton — one emitter shared across all route handlers
// in this Node.js process.
const emitter = new EventEmitter()
emitter.setMaxListeners(200) // 100 members × 2 devices, generous headroom (T-04-04)
/**
* Event type union for list change notifications.
* All events carry the originating listId and an opaque payload.
*/
export type ListEvent = {
type: 'item:added' | 'item:updated' | 'item:deleted' | 'list:updated' | 'list:deleted'
listId: number
payload: unknown
}
/**
* Broadcast an event to all SSE subscribers watching this list.
* Channel is keyed by listId — events for list A never reach subscribers of list B.
*/
export function publishListEvent(listId: number, event: ListEvent): void {
emitter.emit(`list:${listId}`, event)
}
/**
* Subscribe to events for a specific list.
* Returns an unsubscribe function; call it to stop delivery to this handler.
*/
export function subscribeListEvents(
listId: number,
handler: (event: ListEvent) => void,
): () => void {
const channel = `list:${listId}`
emitter.on(channel, handler)
return () => emitter.off(channel, handler)
}