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