diff --git a/src/lib/server/api-utils.ts b/src/lib/server/api-utils.ts index 73bde53..0549b4f 100644 --- a/src/lib/server/api-utils.ts +++ b/src/lib/server/api-utils.ts @@ -1,5 +1,5 @@ import { error } from '@sveltejs/kit'; -import { db } from './db/index.js'; +import type { Db } from './db/index.js'; import { notes, noteCollaborators } from './db/schema.js'; import { eq, and } from 'drizzle-orm'; @@ -21,7 +21,7 @@ export function requireAdmin(event: EventWithLocals) { /** * Check if a user can access a note (as owner or collaborator). */ -export function canAccessNote(noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } { +export function canAccessNote(db: Db, noteId: string, userId: number): { canAccess: boolean; isOwner: boolean } { const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get(); if (!note) return { canAccess: false, isOwner: false }; if (note.userId === userId) return { canAccess: true, isOwner: true }; @@ -37,8 +37,8 @@ export function canAccessNote(noteId: string, userId: number): { canAccess: bool /** * Require that a user can access a note. Throws 404 if no access. */ -export function requireNoteAccess(noteId: string, userId: number): { isOwner: boolean } { - const { canAccess, isOwner } = canAccessNote(noteId, userId); +export function requireNoteAccess(db: Db, noteId: string, userId: number): { isOwner: boolean } { + const { canAccess, isOwner } = canAccessNote(db, noteId, userId); if (!canAccess) throw error(404, 'Not found'); return { isOwner }; } @@ -46,7 +46,7 @@ export function requireNoteAccess(noteId: string, userId: number): { isOwner: bo /** * Require that a user is the owner of a note. Throws 404 if not owner. */ -export function requireNoteOwnership(noteId: string, userId: number): void { +export function requireNoteOwnership(db: Db, noteId: string, userId: number): void { const note = db.select({ userId: notes.userId }).from(notes).where(eq(notes.id, noteId)).get(); if (!note || note.userId !== userId) throw error(404, 'Not found'); } diff --git a/src/lib/server/attachments.ts b/src/lib/server/attachments.ts index aaacbf1..39bdeaf 100644 --- a/src/lib/server/attachments.ts +++ b/src/lib/server/attachments.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync } from 'fs'; import { writeFile, unlink } from 'fs/promises'; import { join } from 'path'; import { v4 as uuidv4 } from 'uuid'; -import { db } from './db/index.js'; +import type { Db } from './db/index.js'; import { attachments } from './db/schema.js'; import { eq, and, inArray } from 'drizzle-orm'; import type { Attachment } from '$lib/types/index.js'; @@ -16,6 +16,7 @@ if (!existsSync(ATTACHMENTS_DIR)) { } export async function saveAttachment( + db: Db, noteId: string, file: File, userId: number, @@ -59,7 +60,7 @@ export async function saveAttachment( * Batch fetch attachments for multiple notes in a single query. * Returns a Map of noteId → Attachment[]. */ -export function fetchAttachmentsForNotes(noteIds: string[]): Map { +export function fetchAttachmentsForNotes(db: Db, noteIds: string[]): Map { if (noteIds.length === 0) return new Map(); const rows = db @@ -80,7 +81,7 @@ export function fetchAttachmentsForNotes(noteIds: string[]): Map { +export function fetchCollaboratorsForNotes(db: Db, noteIds: string[]): Map { if (noteIds.length === 0) return new Map(); const rows = db @@ -44,7 +44,7 @@ export function fetchCollaboratorsForNotes(noteIds: string[]): Map; export { sqlite }; diff --git a/src/lib/server/mcp/server.ts b/src/lib/server/mcp/server.ts index 0550c7a..8024e53 100644 --- a/src/lib/server/mcp/server.ts +++ b/src/lib/server/mcp/server.ts @@ -12,6 +12,7 @@ import { reorderNotes } from '../notes-service.js'; import { saveAttachment } from '../attachments.js'; +import { db } from '../db/index.js'; const NOTE_COLORS = [ 'default', @@ -45,7 +46,7 @@ export function createMcpServer(userId: number): McpServer { tag: z.string().optional().describe('Filter by tag name') }, async ({ filter = 'all', tag }: { filter?: string; tag?: string }) => { - let result = listNotes(userId, filter as NoteFilter); + let result = listNotes(db, userId, filter as NoteFilter); if (tag) { result = result.filter((n) => @@ -64,7 +65,7 @@ export function createMcpServer(userId: number): McpServer { 'Get a single note by ID with its tags and attachments', { id: z.string().describe('Note ID') }, async ({ id }: { id: string }) => { - const note = getNote(userId, id); + const note = getNote(db, userId, id); if (!note) { return { content: [{ type: 'text' as const, text: 'Note not found' }], @@ -88,7 +89,7 @@ export function createMcpServer(userId: number): McpServer { checklistMode: z.boolean().optional().describe('Enable checklist mode') }, async ({ title, content, color, checklistMode }: { title?: string; content?: string; color?: string; checklistMode?: boolean }) => { - const note = createNote(userId, { title, content, color, checklistMode }); + const note = createNote(db, userId, { title, content, color, checklistMode }); return { content: [{ type: 'text' as const, text: JSON.stringify(note, null, 2) }] }; @@ -107,7 +108,7 @@ export function createMcpServer(userId: number): McpServer { checklistMode: z.boolean().optional().describe('Enable/disable checklist mode') }, async ({ id, ...updates }: { id: string; title?: string; content?: string; color?: string; pinned?: boolean; checklistMode?: boolean }) => { - const result = updateNote(userId, id, updates); + const result = updateNote(db, userId, id, updates); if (!result) { return { content: [{ type: 'text' as const, text: 'Note not found' }], @@ -126,7 +127,7 @@ export function createMcpServer(userId: number): McpServer { 'Move a note to trash', { id: z.string().describe('Note ID') }, async ({ id }: { id: string }) => { - const result = updateNote(userId, id, { trashed: true }); + const result = updateNote(db, userId, id, { trashed: true }); if (!result) { return { content: [{ type: 'text' as const, text: 'Note not found' }], @@ -144,7 +145,7 @@ export function createMcpServer(userId: number): McpServer { 'Restore a note from trash', { id: z.string().describe('Note ID') }, async ({ id }: { id: string }) => { - const result = updateNote(userId, id, { trashed: false }); + const result = updateNote(db, userId, id, { trashed: false }); if (!result) { return { content: [{ type: 'text' as const, text: 'Note not found' }], @@ -162,7 +163,7 @@ export function createMcpServer(userId: number): McpServer { 'Archive a note', { id: z.string().describe('Note ID') }, async ({ id }: { id: string }) => { - const result = updateNote(userId, id, { archived: true }); + const result = updateNote(db, userId, id, { archived: true }); if (!result) { return { content: [{ type: 'text' as const, text: 'Note not found' }], @@ -180,7 +181,7 @@ export function createMcpServer(userId: number): McpServer { 'Unarchive a note', { id: z.string().describe('Note ID') }, async ({ id }: { id: string }) => { - const result = updateNote(userId, id, { archived: false }); + const result = updateNote(db, userId, id, { archived: false }); if (!result) { return { content: [{ type: 'text' as const, text: 'Note not found' }], @@ -198,7 +199,7 @@ export function createMcpServer(userId: number): McpServer { 'Permanently delete a note', { id: z.string().describe('Note ID') }, async ({ id }: { id: string }) => { - const existing = getNote(userId, id); + const existing = getNote(db, userId, id); if (!existing) { return { content: [{ type: 'text' as const, text: 'Note not found' }], @@ -206,7 +207,7 @@ export function createMcpServer(userId: number): McpServer { }; } - deleteNote(userId, id); + deleteNote(db, userId, id); return { content: [ { @@ -223,7 +224,7 @@ export function createMcpServer(userId: number): McpServer { 'Search notes by title, content, or tag name', { query: z.string().describe('Search query') }, async ({ query }: { query: string }) => { - const results = searchNotes(userId, query); + const results = searchNotes(db, userId, query); return { content: [{ type: 'text' as const, text: JSON.stringify(results, null, 2) }] }; @@ -231,7 +232,7 @@ export function createMcpServer(userId: number): McpServer { ); server.tool('list_tags', 'List all tags', {}, async () => { - const allTags = listAllTags(userId); + const allTags = listAllTags(db, userId); return { content: [{ type: 'text' as const, text: JSON.stringify(allTags, null, 2) }] }; @@ -245,7 +246,7 @@ export function createMcpServer(userId: number): McpServer { pinned: z.boolean().describe('Whether to pin (true) or unpin (false)') }, async ({ id, pinned }: { id: string; pinned: boolean }) => { - const result = updateNote(userId, id, { pinned }); + const result = updateNote(db, userId, id, { pinned }); if (!result) { return { content: [{ type: 'text' as const, text: 'Note not found' }], @@ -277,7 +278,7 @@ export function createMcpServer(userId: number): McpServer { .describe('Array of note IDs with their new sort orders') }, async ({ orders }: { orders: { id: string; sortOrder: number }[] }) => { - reorderNotes(userId, orders); + reorderNotes(db, userId, orders); return { content: [{ type: 'text' as const, text: `Reordered ${orders.length} notes` }] }; @@ -292,7 +293,7 @@ export function createMcpServer(userId: number): McpServer { imageUrl: z.string().url().describe('URL of the image to fetch') }, async ({ noteId, imageUrl }: { noteId: string; imageUrl: string }) => { - const existing = getNote(userId, noteId); + const existing = getNote(db, userId, noteId); if (!existing) { return { content: [{ type: 'text' as const, text: 'Note not found' }], @@ -320,7 +321,7 @@ export function createMcpServer(userId: number): McpServer { const filename = `upload_${Date.now()}.${ext}`; const file = new File([buffer], filename, { type: contentType }); - const attachment = await saveAttachment(noteId, file, userId); + const attachment = await saveAttachment(db, noteId, file, userId); return { content: [{ type: 'text' as const, text: JSON.stringify(attachment, null, 2) }] diff --git a/src/lib/server/notes-service.ts b/src/lib/server/notes-service.ts index 21bb741..b500aaa 100644 --- a/src/lib/server/notes-service.ts +++ b/src/lib/server/notes-service.ts @@ -1,4 +1,4 @@ -import { db as defaultDb } from './db/index.js'; +import type { Db } from './db/index.js'; import { notes, noteTags, tags, noteCollaborators, noteUserState } from './db/schema.js'; import { eq, and, desc, like, or, inArray, sql } from 'drizzle-orm'; import { v4 as uuidv4 } from 'uuid'; @@ -9,8 +9,6 @@ import { fetchCollaboratorsForNotes } from './collaborators.js'; import { canAccessNote } from './api-utils.js'; import type { NoteFilter } from '$lib/types/index.js'; -const db = defaultDb; - interface NoteRow { id: string; userId: number; @@ -28,11 +26,11 @@ interface NoteRow { version: number; } -function hydrateNotes(noteRows: NoteRow[], userId: number) { +function hydrateNotes(db: Db, noteRows: NoteRow[], userId: number) { const noteIds = noteRows.map((n) => n.id); - const tagMap = fetchTagsForNotes(noteIds); - const attachmentMap = fetchAttachmentsForNotes(noteIds); - const collabMap = fetchCollaboratorsForNotes(noteIds); + const tagMap = fetchTagsForNotes(db, noteIds); + const attachmentMap = fetchAttachmentsForNotes(db, noteIds); + const collabMap = fetchCollaboratorsForNotes(db, noteIds); return noteRows.map((note) => { const collaborators = collabMap.get(note.id) ?? []; @@ -52,7 +50,7 @@ function hydrateNotes(noteRows: NoteRow[], userId: number) { /** * Get shared notes for a user with per-user state overlay. */ -function getSharedNotes(userId: number, filter: 'all' | 'archived'): NoteRow[] { +function getSharedNotes(db: Db, userId: number, filter: 'all' | 'archived'): NoteRow[] { const rows = db .select({ id: notes.id, @@ -90,7 +88,7 @@ function getSharedNotes(userId: number, filter: 'all' | 'archived'): NoteRow[] { return rows as NoteRow[]; } -export function listNotes(userId: number, filter: NoteFilter = 'all') { +export function listNotes(db: Db, userId: number, filter: NoteFilter = 'all') { // Owned notes let conditions; switch (filter) { @@ -114,7 +112,7 @@ export function listNotes(userId: number, filter: NoteFilter = 'all') { // Shared notes (not for trashed filter — collaborators don't see trash) let sharedNotes: NoteRow[] = []; if (filter !== 'trashed') { - sharedNotes = getSharedNotes(userId, filter === 'archived' ? 'archived' : 'all'); + sharedNotes = getSharedNotes(db, userId, filter === 'archived' ? 'archived' : 'all'); } const combined = [...ownedNotes, ...sharedNotes]; @@ -124,11 +122,11 @@ export function listNotes(userId: number, filter: NoteFilter = 'all') { return b.updatedAt.getTime() - a.updatedAt.getTime(); }); - return hydrateNotes(combined, userId); + return hydrateNotes(db, combined, userId); } -export function getNote(userId: number, id: string) { - const { canAccess, isOwner } = canAccessNote(id, userId); +export function getNote(db: Db, userId: number, id: string) { + const { canAccess, isOwner } = canAccessNote(db, id, userId); if (!canAccess) return null; const note = db.select().from(notes).where(eq(notes.id, id)).get(); @@ -151,7 +149,7 @@ export function getNote(userId: number, id: string) { }; } - const result = hydrateNotes([effectiveNote], userId); + const result = hydrateNotes(db, [effectiveNote], userId); return result[0] ?? null; } @@ -165,7 +163,7 @@ export interface CreateNoteInput { sortOrder?: number; } -export function createNote(userId: number, input: CreateNoteInput) { +export function createNote(db: Db, userId: number, input: CreateNoteInput) { const now = new Date(); const id = input.id || uuidv4(); @@ -192,7 +190,7 @@ export function createNote(userId: number, input: CreateNoteInput) { db.transaction((tx) => { tx.insert(notes).values(newNote).run(); }); - syncNoteTags(id, extractedTags, userId); + syncNoteTags(db, id, extractedTags, userId); return { ...newNote, tags: extractedTags }; } @@ -211,8 +209,8 @@ export interface UpdateNoteInput { /** Per-user fields that go to noteUserState for collaborators */ const PER_USER_FIELDS = ['pinned', 'archived', 'sortOrder'] as const; -export function updateNote(userId: number, id: string, input: UpdateNoteInput) { - const { canAccess, isOwner } = canAccessNote(id, userId); +export function updateNote(db: Db, userId: number, id: string, input: UpdateNoteInput) { + const { canAccess, isOwner } = canAccessNote(db, id, userId); if (!canAccess) return null; const existing = db.select().from(notes).where(eq(notes.id, id)).get(); @@ -291,14 +289,14 @@ export function updateNote(userId: number, id: string, input: UpdateNoteInput) { if (updated) { const content = `${updated.title} ${updated.content}`; const extractedTags = extractTags(content); - syncNoteTags(id, extractedTags, existing.userId); + syncNoteTags(db, id, extractedTags, existing.userId); } } - return getNote(userId, id); + return getNote(db, userId, id); } -export function deleteNote(userId: number, id: string): boolean { +export function deleteNote(db: Db, userId: number, id: string): boolean { const existing = db .select() .from(notes) @@ -312,7 +310,7 @@ export function deleteNote(userId: number, id: string): boolean { return true; } -export function searchNotes(userId: number, query: string) { +export function searchNotes(db: Db, userId: number, query: string) { if (!query) return []; const pattern = `%${query}%`; @@ -386,17 +384,17 @@ export function searchNotes(userId: number, query: string) { } const combined = [...ownedResults, ...sharedResults, ...extraNotes] as NoteRow[]; - return hydrateNotes(combined, userId); + return hydrateNotes(db, combined, userId); } -export function listAllTags(userId: number) { +export function listAllTags(db: Db, userId: number) { return db.select().from(tags).where(eq(tags.userId, userId)).all(); } -export function reorderNotes(userId: number, orders: { id: string; sortOrder: number }[]) { +export function reorderNotes(db: Db, userId: number, orders: { id: string; sortOrder: number }[]) { const now = new Date(); for (const { id, sortOrder } of orders) { - const { canAccess, isOwner } = canAccessNote(id, userId); + const { canAccess, isOwner } = canAccessNote(db, id, userId); if (!canAccess) continue; if (isOwner) { diff --git a/src/lib/server/rate-limit.ts b/src/lib/server/rate-limit.ts index 7978358..e11de40 100644 --- a/src/lib/server/rate-limit.ts +++ b/src/lib/server/rate-limit.ts @@ -1,4 +1,4 @@ -import { db } from './db/index.js'; +import type { Db } from './db/index.js'; import { loginAttempts } from './db/schema.js'; import { and, eq, gte, sql } from 'drizzle-orm'; @@ -7,15 +7,13 @@ const WINDOW_MS = 60 * 1000; // 1 minute const LOCKOUT_MS = 15 * 60 * 1000; // 15 minutes export function checkRateLimit( + db: Db, ip: string, - email: string + email: string, + now?: Date ): { allowed: boolean; retryAfter?: number } { - // Skip rate limiting in test environment - if (process.env.NODE_ENV === 'test' || process.env.PLAYWRIGHT_TEST === '1') { - return { allowed: true }; - } - - const windowStart = new Date(Date.now() - LOCKOUT_MS); + const currentTime = now ?? new Date(); + const windowStart = new Date(currentTime.getTime() - LOCKOUT_MS); // Count failed attempts from this IP in the lockout window const result = db @@ -50,7 +48,7 @@ export function checkRateLimit( if (latest) { const retryAfter = Math.ceil( - (latest.timestamp.getTime() + LOCKOUT_MS - Date.now()) / 1000 + (latest.timestamp.getTime() + LOCKOUT_MS - currentTime.getTime()) / 1000 ); return { allowed: false, retryAfter: Math.max(retryAfter, 1) }; } @@ -60,19 +58,19 @@ export function checkRateLimit( return { allowed: true }; } -export function recordLoginAttempt(ip: string, email: string, success: boolean): void { +export function recordLoginAttempt(db: Db, ip: string, email: string, success: boolean, now?: Date): void { db.insert(loginAttempts) .values({ ip, email, success, - timestamp: new Date() + timestamp: now ?? new Date() }) .run(); } -export function clearOldAttempts(): void { - const cutoff = new Date(Date.now() - LOCKOUT_MS * 2); +export function clearOldAttempts(db: Db, now?: Date): void { + const cutoff = new Date((now ?? new Date()).getTime() - LOCKOUT_MS * 2); db.delete(loginAttempts) .where(sql`${loginAttempts.timestamp} < ${cutoff}`) .run(); diff --git a/src/lib/server/tags.ts b/src/lib/server/tags.ts index 199c7ae..17a2bdf 100644 --- a/src/lib/server/tags.ts +++ b/src/lib/server/tags.ts @@ -1,4 +1,4 @@ -import { db } from './db/index.js'; +import type { Db } from './db/index.js'; import { noteTags, tags } from './db/schema.js'; import { eq, and, inArray } from 'drizzle-orm'; @@ -6,7 +6,7 @@ import { eq, and, inArray } from 'drizzle-orm'; * Batch fetch tags for multiple notes in a single query. * Returns a Map of noteId → tag names. */ -export function fetchTagsForNotes(noteIds: string[]): Map { +export function fetchTagsForNotes(db: Db, noteIds: string[]): Map { if (noteIds.length === 0) return new Map(); const rows = db @@ -32,7 +32,7 @@ export function fetchTagsForNotes(noteIds: string[]): Map { * Sync tags for a note: removes old associations, upserts tags, creates new associations. * Should be called within a transaction for atomicity. */ -export function syncNoteTags(noteId: string, tagNames: string[], userId: number) { +export function syncNoteTags(db: Db, noteId: string, tagNames: string[], userId: number) { db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run(); for (const name of tagNames) { diff --git a/src/lib/sync/server.test.ts b/src/lib/sync/server.test.ts index ab21832..56ceb59 100644 --- a/src/lib/sync/server.test.ts +++ b/src/lib/sync/server.test.ts @@ -51,7 +51,7 @@ describe('getChangesSince — shared notes', () => { seedSharedNote('n1', { updatedAt: t2, createdAt: t1 }); - const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db); + const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID); expect(changes).toHaveLength(1); expect(changes[0].id).toBe('n1'); @@ -65,7 +65,7 @@ describe('getChangesSince — shared notes', () => { seedSharedNote('n1', { updatedAt: t1, createdAt: t1 }); - const changes = await getChangesSince(t2.getTime(), COLLAB_ID, db); + const changes = await getChangesSince(db, t2.getTime(), COLLAB_ID); expect(changes).toHaveLength(0); }); @@ -86,7 +86,7 @@ describe('getChangesSince — shared notes', () => { sortOrder: 5 }).run(); - const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db); + const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID); expect(changes).toHaveLength(1); // Should reflect the collaborator's per-user state, not the owner's @@ -102,7 +102,7 @@ describe('getChangesSince — shared notes', () => { // Owner has pinned=true, sortOrder=10 seedSharedNote('n1', { updatedAt: t2, createdAt: t1, pinned: true, sortOrder: 10 }); - const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db); + const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID); expect(changes).toHaveLength(1); // Without noteUserState, collaborator should get defaults (false/0), not owner's values @@ -116,7 +116,7 @@ describe('getChangesSince — shared notes', () => { seedSharedNote('n1', { updatedAt: t2, createdAt: t1, trashed: true }); - const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db); + const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID); expect(changes).toHaveLength(0); }); @@ -133,7 +133,7 @@ describe('getChangesSince — shared notes', () => { .run(); // Collaborator syncs since t1 - const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db); + const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID); expect(changes).toHaveLength(1); expect(changes[0].content).toBe('Updated by owner'); @@ -157,7 +157,7 @@ describe('getChangesSince — shared notes', () => { // Note shared with COLLAB_ID (owned by OWNER_ID) seedSharedNote('shared-with-collab', { updatedAt: t2, createdAt: t1 }); - const changes = await getChangesSince(t1.getTime(), COLLAB_ID, db); + const changes = await getChangesSince(db, t1.getTime(), COLLAB_ID); expect(changes).toHaveLength(2); const ids = changes.map((c) => c.id).sort(); @@ -171,12 +171,12 @@ describe('processSyncPush — shared notes', () => { seedSharedNote('n1', { updatedAt: t1, createdAt: t1 }); const t2 = t1.getTime() + 1000; - await processSyncPush([{ + await processSyncPush(db, [{ noteId: 'n1', operation: 'update', timestamp: t2, data: { title: 'Updated by collab', content: 'New content' } - }], COLLAB_ID, db); + }], COLLAB_ID); const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!; expect(note.title).toBe('Updated by collab'); @@ -189,12 +189,12 @@ describe('processSyncPush — shared notes', () => { seedSharedNote('n1', { updatedAt: t1, createdAt: t1 }); const t2 = t1.getTime() + 1000; - await processSyncPush([{ + await processSyncPush(db, [{ noteId: 'n1', operation: 'update', timestamp: t2, data: { pinned: true, sortOrder: 3 } - }], COLLAB_ID, db); + }], COLLAB_ID); // Per-user fields should be in noteUserState, not in notes const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!; @@ -213,12 +213,12 @@ describe('processSyncPush — shared notes', () => { seedSharedNote('n1', { updatedAt: t1, createdAt: t1 }); const t2 = t1.getTime() + 1000; - await processSyncPush([{ + await processSyncPush(db, [{ noteId: 'n1', operation: 'update', timestamp: t2, data: { trashed: true } - }], COLLAB_ID, db); + }], COLLAB_ID); const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!; expect(note.trashed).toBe(false); @@ -230,12 +230,12 @@ describe('processSyncPush — shared notes', () => { // Collaborator's change has a timestamp BEFORE the server's updatedAt const t1 = new Date('2024-01-01T12:00:00Z').getTime(); - await processSyncPush([{ + await processSyncPush(db, [{ noteId: 'n1', operation: 'update', timestamp: t1, data: { title: 'Stale edit' } - }], COLLAB_ID, db); + }], COLLAB_ID); const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!; expect(note.title).toBe('Shared Note'); // Unchanged @@ -263,12 +263,12 @@ describe('processSyncPush — shared notes', () => { }).run(); const t2 = t1.getTime() + 1000; - await processSyncPush([{ + await processSyncPush(db, [{ noteId: 'n1', operation: 'update', timestamp: t2, data: { title: 'Hacked' } - }], 3, db); // User 3 is not owner or collaborator + }], 3); // User 3 is not owner or collaborator const note = db.select().from(notes).where(eq(notes.id, 'n1')).get()!; expect(note.title).toBe('Private'); // Unchanged @@ -279,12 +279,12 @@ describe('processSyncPush — shared notes', () => { seedSharedNote('n1', { updatedAt: t1, createdAt: t1 }); const t2 = t1.getTime() + 1000; - await processSyncPush([{ + await processSyncPush(db, [{ noteId: 'n1', operation: 'update', timestamp: t2, data: { title: 'Updated' } - }], COLLAB_ID, db); + }], COLLAB_ID); const logs = db.select().from(syncLog).where(eq(syncLog.noteId, 'n1')).all(); expect(logs).toHaveLength(1); diff --git a/src/lib/sync/server.ts b/src/lib/sync/server.ts index 3beb22f..9426a75 100644 --- a/src/lib/sync/server.ts +++ b/src/lib/sync/server.ts @@ -1,18 +1,14 @@ -import { db as defaultDb } from '$lib/server/db/index.js'; +import type { Db } from '$lib/server/db/index.js'; import { notes, noteCollaborators, noteUserState, syncLog } from '$lib/server/db/schema.js'; import { eq, gt, and, or, sql } from 'drizzle-orm'; import { extractTags } from '$lib/utils/tags.js'; import { syncNoteTags } from '$lib/server/tags.js'; import { canAccessNote } from '$lib/server/api-utils.js'; import type { SyncQueueItem } from './idb.js'; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyDb = import('drizzle-orm/better-sqlite3').BetterSQLite3Database; - /** * Process incoming sync changes from client. */ -export async function processSyncPush(changes: SyncQueueItem[], userId: number, injectedDb?: AnyDb): Promise { - const db = injectedDb ?? defaultDb; +export async function processSyncPush(db: Db, changes: SyncQueueItem[], userId: number): Promise { const noteIdsToSyncTags: string[] = []; db.transaction((tx) => { @@ -140,7 +136,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number, const note = db.select().from(notes).where(eq(notes.id, noteId)).get(); if (note) { const content = `${note.title} ${note.content}`; - syncNoteTags(noteId, extractTags(content), note.userId); + syncNoteTags(db, noteId, extractTags(content), note.userId); } } } @@ -151,8 +147,7 @@ export async function processSyncPush(changes: SyncQueueItem[], userId: number, * For shared notes, per-user state (pinned, archived, sortOrder) is overlaid * from noteUserState so collaborators get their own view. */ -export async function getChangesSince(sinceTimestamp: number, userId: number, injectedDb?: AnyDb) { - const db = injectedDb ?? defaultDb; +export async function getChangesSince(db: Db, sinceTimestamp: number, userId: number) { const since = new Date(sinceTimestamp); // Owned notes diff --git a/src/routes/api/auth/login/+server.ts b/src/routes/api/auth/login/+server.ts index 670993d..ce156ee 100644 --- a/src/routes/api/auth/login/+server.ts +++ b/src/routes/api/auth/login/+server.ts @@ -2,6 +2,7 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import { verifyPassword, createSession } from '$lib/server/auth.js'; import { checkRateLimit, recordLoginAttempt } from '$lib/server/rate-limit.js'; +import { db } from '$lib/server/db/index.js'; export const POST: RequestHandler = async ({ request, cookies, getClientAddress }) => { const ip = getClientAddress(); @@ -11,7 +12,7 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress throw error(400, 'Email and password are required'); } - const rateCheck = checkRateLimit(ip, email); + const rateCheck = checkRateLimit(db, ip, email); if (!rateCheck.allowed) { return json( { error: 'Too many login attempts. Please try again later.', retryAfter: rateCheck.retryAfter }, @@ -21,11 +22,11 @@ export const POST: RequestHandler = async ({ request, cookies, getClientAddress const user = await verifyPassword(email, password); if (!user) { - recordLoginAttempt(ip, email, false); + recordLoginAttempt(db, ip, email, false); throw error(401, 'Invalid email or password'); } - recordLoginAttempt(ip, email, true); + recordLoginAttempt(db, ip, email, true); const userAgent = request.headers.get('user-agent') || undefined; const token = await createSession(user.id, { userAgent, ip }); diff --git a/src/routes/api/notes/+server.ts b/src/routes/api/notes/+server.ts index 79bcff7..e1a2b54 100644 --- a/src/routes/api/notes/+server.ts +++ b/src/routes/api/notes/+server.ts @@ -1,5 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; +import { db } from '$lib/server/db/index.js'; import { getUserId } from '$lib/server/api-utils.js'; import { listNotes, createNote } from '$lib/server/notes-service.js'; import type { NoteFilter } from '$lib/types/index.js'; @@ -7,13 +8,13 @@ import type { NoteFilter } from '$lib/types/index.js'; export const GET: RequestHandler = async ({ url, ...event }) => { const userId = getUserId(event); const filter = (url.searchParams.get('filter') || 'all') as NoteFilter; - const result = listNotes(userId, filter); + const result = listNotes(db, userId, filter); return json(result); }; export const POST: RequestHandler = async ({ request, ...event }) => { const userId = getUserId(event); const body = await request.json(); - const newNote = createNote(userId, body); + const newNote = createNote(db, userId, body); return json(newNote, { status: 201 }); }; diff --git a/src/routes/api/notes/[id]/+server.ts b/src/routes/api/notes/[id]/+server.ts index 4fa1ee4..f5d8755 100644 --- a/src/routes/api/notes/[id]/+server.ts +++ b/src/routes/api/notes/[id]/+server.ts @@ -1,11 +1,12 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; +import { db } from '$lib/server/db/index.js'; import { getUserId, requireNoteAccess, requireNoteOwnership } from '$lib/server/api-utils.js'; import { getNote, updateNote, deleteNote } from '$lib/server/notes-service.js'; export const GET: RequestHandler = async ({ params, ...event }) => { const userId = getUserId(event); - const note = getNote(userId, params.id); + const note = getNote(db, userId, params.id); if (!note) throw error(404, 'Note not found'); return json(note); @@ -14,7 +15,7 @@ export const GET: RequestHandler = async ({ params, ...event }) => { export const PATCH: RequestHandler = async ({ params, request, ...event }) => { const userId = getUserId(event); const body = await request.json(); - const updated = updateNote(userId, params.id, body); + const updated = updateNote(db, userId, params.id, body); if (!updated) throw error(404, 'Note not found'); return json(updated); @@ -22,9 +23,9 @@ export const PATCH: RequestHandler = async ({ params, request, ...event }) => { export const DELETE: RequestHandler = async ({ params, ...event }) => { const userId = getUserId(event); - requireNoteOwnership(params.id, userId); + requireNoteOwnership(db, params.id, userId); - const deleted = deleteNote(userId, params.id); + const deleted = deleteNote(db, userId, params.id); if (!deleted) throw error(404, 'Note not found'); return json({ success: true }); diff --git a/src/routes/api/notes/[id]/attachments/+server.ts b/src/routes/api/notes/[id]/attachments/+server.ts index 438cafd..f8cde99 100644 --- a/src/routes/api/notes/[id]/attachments/+server.ts +++ b/src/routes/api/notes/[id]/attachments/+server.ts @@ -2,16 +2,17 @@ import { json, error } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; import { saveAttachment, getAttachmentsByNote, getAttachment, deleteAttachment, updateAttachment } from '$lib/server/attachments.js'; import { readFile } from 'fs/promises'; +import { db } from '$lib/server/db/index.js'; import { getUserId, requireNoteAccess } from '$lib/server/api-utils.js'; export const GET: RequestHandler = async ({ params, url, ...event }) => { const userId = getUserId(event); - requireNoteAccess(params.id, userId); + requireNoteAccess(db, params.id, userId); const attachmentId = url.searchParams.get('attachmentId'); if (attachmentId) { - const attachment = await getAttachment(attachmentId, params.id); + const attachment = await getAttachment(db, attachmentId, params.id); if (!attachment) throw error(404, 'Attachment not found'); const wantThumb = url.searchParams.get('thumb') === '1'; @@ -28,13 +29,13 @@ export const GET: RequestHandler = async ({ params, url, ...event }) => { }); } - const list = await getAttachmentsByNote(params.id); + const list = await getAttachmentsByNote(db, params.id); return json(list); }; export const POST: RequestHandler = async ({ params, request, ...event }) => { const userId = getUserId(event); - requireNoteAccess(params.id, userId); + requireNoteAccess(db, params.id, userId); const formData = await request.formData(); const file = formData.get('file') as File; @@ -53,13 +54,13 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => { } const thumbnail = formData.get('thumbnail') as File | null; - const attachment = await saveAttachment(params.id, file, userId, thumbnail); + const attachment = await saveAttachment(db, params.id, file, userId, thumbnail); return json(attachment, { status: 201 }); }; export const PATCH: RequestHandler = async ({ params, url, request, ...event }) => { const userId = getUserId(event); - requireNoteAccess(params.id, userId); + requireNoteAccess(db, params.id, userId); const attachmentId = url.searchParams.get('attachmentId'); if (!attachmentId) throw error(400, 'attachmentId required'); @@ -67,7 +68,7 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event }) const body = await request.json(); if (typeof body.featured !== 'boolean') throw error(400, 'featured (boolean) required'); - const updated = await updateAttachment(attachmentId, params.id, { featured: body.featured }); + const updated = await updateAttachment(db, attachmentId, params.id, { featured: body.featured }); if (!updated) throw error(404, 'Attachment not found'); return json(updated); @@ -75,11 +76,11 @@ export const PATCH: RequestHandler = async ({ params, url, request, ...event }) export const DELETE: RequestHandler = async ({ params, url, ...event }) => { const userId = getUserId(event); - requireNoteAccess(params.id, userId); + requireNoteAccess(db, params.id, userId); const attachmentId = url.searchParams.get('attachmentId'); if (!attachmentId) throw error(400, 'attachmentId required'); - await deleteAttachment(attachmentId, params.id); + await deleteAttachment(db, attachmentId, params.id); return json({ success: true }); }; diff --git a/src/routes/api/notes/[id]/collaborators/+server.ts b/src/routes/api/notes/[id]/collaborators/+server.ts index 7fda835..4ecd178 100644 --- a/src/routes/api/notes/[id]/collaborators/+server.ts +++ b/src/routes/api/notes/[id]/collaborators/+server.ts @@ -8,15 +8,15 @@ import { eq, and } from 'drizzle-orm'; export const GET: RequestHandler = async ({ params, ...event }) => { const userId = getUserId(event); - requireNoteAccess(params.id, userId); + requireNoteAccess(db, params.id, userId); - const collaborators = fetchCollaboratorsForNotes([params.id]); + const collaborators = fetchCollaboratorsForNotes(db, [params.id]); return json(collaborators.get(params.id) ?? []); }; export const POST: RequestHandler = async ({ params, request, ...event }) => { const userId = getUserId(event); - requireNoteOwnership(params.id, userId); + requireNoteOwnership(db, params.id, userId); const body = await request.json(); const targetUserId = body.userId; @@ -46,9 +46,9 @@ export const POST: RequestHandler = async ({ params, request, ...event }) => { throw error(409, 'User is already a collaborator'); } - addCollaborator(params.id, targetUserId, userId); + addCollaborator(db, params.id, targetUserId, userId); - const collaborators = fetchCollaboratorsForNotes([params.id]); + const collaborators = fetchCollaboratorsForNotes(db, [params.id]); return json(collaborators.get(params.id) ?? [], { status: 201 }); }; @@ -60,14 +60,14 @@ export const DELETE: RequestHandler = async ({ params, url, ...event }) => { throw error(400, 'userId query parameter is required'); } - const { isOwner } = requireNoteAccess(params.id, userId); + const { isOwner } = requireNoteAccess(db, params.id, userId); // Owner can remove anyone. Collaborator can only remove self. if (!isOwner && targetUserId !== userId) { throw error(403, 'Only the owner can remove other collaborators'); } - removeCollaborator(params.id, targetUserId); + removeCollaborator(db, params.id, targetUserId); return json({ success: true }); }; diff --git a/src/routes/api/notes/reorder/+server.ts b/src/routes/api/notes/reorder/+server.ts index fee8ce2..cd06782 100644 --- a/src/routes/api/notes/reorder/+server.ts +++ b/src/routes/api/notes/reorder/+server.ts @@ -1,5 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; +import { db } from '$lib/server/db/index.js'; import { reorderNotes } from '$lib/server/notes-service.js'; import { getUserId } from '$lib/server/api-utils.js'; @@ -12,6 +13,6 @@ export const POST: RequestHandler = async ({ request, ...event }) => { return json({ error: 'Invalid payload' }, { status: 400 }); } - reorderNotes(userId, orders); + reorderNotes(db, userId, orders); return json({ ok: true }); }; diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts index cf76b4b..ef561ef 100644 --- a/src/routes/api/search/+server.ts +++ b/src/routes/api/search/+server.ts @@ -1,5 +1,6 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types.js'; +import { db } from '$lib/server/db/index.js'; import { getUserId } from '$lib/server/api-utils.js'; import { searchNotes } from '$lib/server/notes-service.js'; @@ -11,6 +12,6 @@ export const GET: RequestHandler = async ({ url, ...event }) => { return json([]); } - const results = searchNotes(userId, query); + const results = searchNotes(db, userId, query); return json(results); }; diff --git a/src/routes/api/sync/+server.ts b/src/routes/api/sync/+server.ts index 208893f..20c86d8 100644 --- a/src/routes/api/sync/+server.ts +++ b/src/routes/api/sync/+server.ts @@ -4,6 +4,7 @@ import { processSyncPush, getChangesSince } from '$lib/sync/server.js'; import { fetchTagsForNotes } from '$lib/server/tags.js'; import { fetchAttachmentsForNotes } from '$lib/server/attachments.js'; import { getUserId } from '$lib/server/api-utils.js'; +import { db } from '$lib/server/db/index.js'; export const POST: RequestHandler = async ({ request, ...event }) => { const userId = getUserId(event); @@ -13,18 +14,18 @@ export const POST: RequestHandler = async ({ request, ...event }) => { return json({ error: 'Invalid changes' }, { status: 400 }); } - await processSyncPush(changes, userId); + await processSyncPush(db, changes, userId); return json({ success: true }); }; export const GET: RequestHandler = async ({ url, ...event }) => { const userId = getUserId(event); const since = parseInt(url.searchParams.get('since') || '0', 10); - const changes = await getChangesSince(since, userId); + const changes = await getChangesSince(db, since, userId); const noteIds = changes.map((n) => n.id); - const tagMap = fetchTagsForNotes(noteIds); - const attachmentMap = fetchAttachmentsForNotes(noteIds); + const tagMap = fetchTagsForNotes(db, noteIds); + const attachmentMap = fetchAttachmentsForNotes(db, noteIds); const changesWithTags = changes.map((note) => ({ ...note, tags: tagMap.get(note.id) ?? [],