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:
@@ -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)]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user