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)
}
+7 -4
View File
@@ -11,17 +11,20 @@
* Run: pnpm --filter @familysync/api exec vitest run tests/lib/listAccess.test.ts * Run: pnpm --filter @familysync/api exec vitest run tests/lib/listAccess.test.ts
*/ */
import { describe, it, expect, beforeEach } from 'vitest' import { describe, it, expect } from 'vitest'
import { randomUUID } from 'node:crypto'
import { db } from '../../src/db/client.js' import { db } from '../../src/db/client.js'
import { users, lists, listShares } from '../../src/db/schema.js' import { users, lists, listShares } from '../../src/db/schema.js'
import { getAccessibleListIds } from '../../src/lib/listAccess.js' import { getAccessibleListIds } from '../../src/lib/listAccess.js'
// Seed helpers — insert minimal rows and return their IDs. // Seed helpers — insert minimal rows and return their IDs.
async function seedUser(suffix: string): Promise<number> { // oidc_sub uses a UUID suffix so rows never collide across test runs
// even when the users table is not truncated between runs.
async function seedUser(label: string): Promise<number> {
const [result] = await db.insert(users).values({ const [result] = await db.insert(users).values({
oidcIss: 'https://auth.test', oidcIss: 'https://auth.test',
oidcSub: `sub-${suffix}`, oidcSub: `sub-${label}-${randomUUID()}`,
displayName: `User ${suffix}`, displayName: `User ${label}`,
color: '#000000', color: '#000000',
}).$returningId() }).$returningId()
return result.id return result.id
+12
View File
@@ -5,5 +5,17 @@ export default defineConfig({
environment: 'node', environment: 'node',
globals: true, globals: true,
setupFiles: ['./test/setup.ts'], setupFiles: ['./test/setup.ts'],
// Run test files sequentially so concurrent DB tests do not interfere via
// the shared MariaDB (global afterEach in test/setup.ts truncates list tables
// which causes FK violations if two workers share the DB concurrently).
sequence: {
concurrent: false,
},
pool: 'forks',
// singleFork: top-level in vitest 4.x (poolOptions removed in v4).
// Runs all test files in one forked process so the shared MariaDB
// teardown (afterEach in test/setup.ts) never races with inserts
// from a concurrent worker.
singleFork: true,
}, },
}) })