fix: resolve N+1 queries, add indexes, and improve DB operations

- Batch-fetch tags for notes in a single query instead of per-note loops
- Add composite indexes on notes, note_tags, and sync_log tables
- Wrap sync push in a transaction for atomicity
- Replace O(n) cleanExpiredSessions with single DELETE query
- Use SQL WHERE in getChangesSince instead of loading all notes
- Extract shared syncNoteTags utility to eliminate duplication
- Add root +error.svelte boundary matching app theme

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-06 17:12:39 +01:00
co-authored by Claude Opus 4.6
parent 02e6b57764
commit ef3ba163dd
9 changed files with 237 additions and 186 deletions
+2 -9
View File
@@ -1,6 +1,6 @@
import { db } from './db/index.js';
import { users, sessions } from './db/schema.js';
import { eq } from 'drizzle-orm';
import { eq, lt } from 'drizzle-orm';
import * as argon2 from 'argon2';
import { randomBytes } from 'crypto';
@@ -61,12 +61,5 @@ export async function deleteSession(token: string): Promise<void> {
}
export async function cleanExpiredSessions(): Promise<void> {
const now = new Date();
// Delete all expired sessions by checking each one
const allSessions = await db.select().from(sessions);
for (const session of allSessions) {
if (session.expiresAt < now) {
await db.delete(sessions).where(eq(sessions.id, session.id));
}
}
await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
}
+6
View File
@@ -76,6 +76,12 @@ sqlite.exec(`
timestamp INTEGER NOT NULL,
client_id TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS notes_trashed_archived_idx ON notes(trashed, archived);
CREATE INDEX IF NOT EXISTS notes_updated_at_idx ON notes(updated_at);
CREATE INDEX IF NOT EXISTS note_tags_note_id_idx ON note_tags(note_id);
CREATE INDEX IF NOT EXISTS note_tags_tag_id_idx ON note_tags(tag_id);
CREATE INDEX IF NOT EXISTS sync_log_timestamp_idx ON sync_log(timestamp);
`);
export const db = drizzle(sqlite, { schema });
+51 -33
View File
@@ -1,4 +1,4 @@
import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { sqliteTable, text, integer, uniqueIndex, index } from 'drizzle-orm/sqlite-core';
export const users = sqliteTable('users', {
id: integer('id').primaryKey({ autoIncrement: true }),
@@ -14,21 +14,28 @@ export const sessions = sqliteTable('sessions', {
expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull()
});
export const notes = sqliteTable('notes', {
id: text('id').primaryKey(),
title: text('title').default('').notNull(),
content: text('content').default('').notNull(),
color: text('color').default('default').notNull(),
pinned: integer('pinned', { mode: 'boolean' }).default(false).notNull(),
archived: integer('archived', { mode: 'boolean' }).default(false).notNull(),
trashed: integer('trashed', { mode: 'boolean' }).default(false).notNull(),
trashedAt: integer('trashed_at', { mode: 'timestamp' }),
checklistMode: integer('checklist_mode', { mode: 'boolean' }).default(false).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
version: integer('version').default(1).notNull()
});
export const notes = sqliteTable(
'notes',
{
id: text('id').primaryKey(),
title: text('title').default('').notNull(),
content: text('content').default('').notNull(),
color: text('color').default('default').notNull(),
pinned: integer('pinned', { mode: 'boolean' }).default(false).notNull(),
archived: integer('archived', { mode: 'boolean' }).default(false).notNull(),
trashed: integer('trashed', { mode: 'boolean' }).default(false).notNull(),
trashedAt: integer('trashed_at', { mode: 'timestamp' }),
checklistMode: integer('checklist_mode', { mode: 'boolean' }).default(false).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
version: integer('version').default(1).notNull()
},
(table) => [
index('notes_trashed_archived_idx').on(table.trashed, table.archived),
index('notes_updated_at_idx').on(table.updatedAt)
]
);
export const tags = sqliteTable(
'tags',
@@ -39,14 +46,21 @@ export const tags = sqliteTable(
(table) => [uniqueIndex('tags_name_unique').on(table.name)]
);
export const noteTags = sqliteTable('note_tags', {
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
tagId: integer('tag_id')
.references(() => tags.id, { onDelete: 'cascade' })
.notNull()
});
export const noteTags = sqliteTable(
'note_tags',
{
noteId: text('note_id')
.references(() => notes.id, { onDelete: 'cascade' })
.notNull(),
tagId: integer('tag_id')
.references(() => tags.id, { onDelete: 'cascade' })
.notNull()
},
(table) => [
index('note_tags_note_id_idx').on(table.noteId),
index('note_tags_tag_id_idx').on(table.tagId)
]
);
export const attachments = sqliteTable('attachments', {
id: text('id').primaryKey(),
@@ -60,12 +74,16 @@ export const attachments = sqliteTable('attachments', {
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
});
export const syncLog = sqliteTable('sync_log', {
id: integer('id').primaryKey({ autoIncrement: true }),
noteId: text('note_id')
.references(() => notes.id)
.notNull(),
operation: text('operation').notNull(),
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(),
clientId: text('client_id').notNull()
});
export const syncLog = sqliteTable(
'sync_log',
{
id: integer('id').primaryKey({ autoIncrement: true }),
noteId: text('note_id')
.references(() => notes.id)
.notNull(),
operation: text('operation').notNull(),
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(),
clientId: text('client_id').notNull()
},
(table) => [index('sync_log_timestamp_idx').on(table.timestamp)]
);
+46
View File
@@ -0,0 +1,46 @@
import { db } from './db/index.js';
import { noteTags, tags } from './db/schema.js';
import { eq, 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<string, string[]> {
if (noteIds.length === 0) return new Map();
const rows = db
.select({ noteId: noteTags.noteId, name: tags.name })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(inArray(noteTags.noteId, noteIds))
.all();
const map = new Map<string, string[]>();
for (const row of rows) {
const existing = map.get(row.noteId);
if (existing) {
existing.push(row.name);
} else {
map.set(row.noteId, [row.name]);
}
}
return 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[]) {
db.delete(noteTags).where(eq(noteTags.noteId, noteId)).run();
for (const name of tagNames) {
let tagRow = db.select().from(tags).where(eq(tags.name, name)).get();
if (!tagRow) {
const result = db.insert(tags).values({ name }).returning().all();
tagRow = result[0];
}
db.insert(noteTags).values({ noteId, tagId: tagRow.id }).run();
}
}
+57 -48
View File
@@ -7,65 +7,74 @@ import type { SyncQueueItem } from './idb.js';
* Process incoming sync changes from client.
*/
export async function processSyncPush(changes: SyncQueueItem[]): Promise<void> {
for (const change of changes) {
switch (change.operation) {
case 'create':
case 'update': {
if (!change.data) continue;
const existing = await db.select().from(notes).where(eq(notes.id, change.noteId)).get();
db.transaction((tx) => {
for (const change of changes) {
switch (change.operation) {
case 'create':
case 'update': {
if (!change.data) continue;
const existing = tx
.select()
.from(notes)
.where(eq(notes.id, change.noteId))
.get();
if (existing) {
// LWW: only update if incoming timestamp is newer
if (change.timestamp > existing.updatedAt.getTime()) {
await db
.update(notes)
.set({
...change.data,
if (existing) {
if (change.timestamp > existing.updatedAt.getTime()) {
tx.update(notes)
.set({
...change.data,
updatedAt: new Date(change.timestamp),
version: existing.version + 1
})
.where(eq(notes.id, change.noteId))
.run();
}
} else if (change.operation === 'create' && change.data) {
tx.insert(notes)
.values({
id: change.noteId,
title: change.data.title || '',
content: change.data.content || '',
color: change.data.color || 'default',
pinned: change.data.pinned || false,
archived: change.data.archived || false,
trashed: change.data.trashed || false,
checklistMode: change.data.checklistMode || false,
sortOrder: change.data.sortOrder || 0,
createdAt: new Date(change.timestamp),
updatedAt: new Date(change.timestamp),
version: existing.version + 1
version: 1
})
.where(eq(notes.id, change.noteId));
.run();
}
} else if (change.operation === 'create' && change.data) {
await db.insert(notes).values({
id: change.noteId,
title: change.data.title || '',
content: change.data.content || '',
color: change.data.color || 'default',
pinned: change.data.pinned || false,
archived: change.data.archived || false,
trashed: change.data.trashed || false,
checklistMode: change.data.checklistMode || false,
sortOrder: change.data.sortOrder || 0,
createdAt: new Date(change.timestamp),
updatedAt: new Date(change.timestamp),
version: 1
});
break;
}
case 'delete': {
tx.delete(notes).where(eq(notes.id, change.noteId)).run();
break;
}
break;
}
case 'delete': {
await db.delete(notes).where(eq(notes.id, change.noteId));
break;
}
}
// Log sync operation
await db.insert(syncLog).values({
noteId: change.noteId,
operation: change.operation,
timestamp: new Date(change.timestamp),
clientId: 'default'
});
}
tx.insert(syncLog)
.values({
noteId: change.noteId,
operation: change.operation,
timestamp: new Date(change.timestamp),
clientId: 'default'
})
.run();
}
});
}
/**
* Get all notes updated since a given timestamp.
*/
export async function getChangesSince(sinceTimestamp: number) {
const since = new Date(sinceTimestamp);
const allNotes = await db.select().from(notes);
// Filter notes updated after the timestamp
return allNotes.filter((n) => n.updatedAt > since);
return db
.select()
.from(notes)
.where(gt(notes.updatedAt, new Date(sinceTimestamp)))
.all();
}
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import { page } from '$app/state';
</script>
<svelte:head>
<title>Error - Crumbs</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center bg-[var(--bg-base)]">
<div class="w-full max-w-sm rounded-sm border border-[var(--border)] bg-[var(--bg-surface)] p-8 text-center">
<span class="text-5xl">🥨</span>
<h1 class="mt-4 font-['Press_Start_2P'] text-xl text-[var(--primary)]">
{page.status}
</h1>
<p class="mt-4 text-sm text-[var(--text-muted)]">
{page.error?.message ?? 'Something went wrong'}
</p>
<a
href="/"
class="mt-6 inline-block rounded-sm bg-[var(--primary)] px-6 py-2 text-sm font-medium text-white transition-colors hover:bg-[var(--primary-hover)]"
>
Back to notes
</a>
</div>
</div>
+15 -33
View File
@@ -1,10 +1,11 @@
import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
import { notes } from '$lib/server/db/schema.js';
import { eq, and, desc } from 'drizzle-orm';
import { v4 as uuidv4 } from 'uuid';
import { extractTags } from '$lib/utils/tags.js';
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
export const GET: RequestHandler = async ({ url }) => {
const filter = url.searchParams.get('filter') || 'all';
@@ -21,23 +22,18 @@ export const GET: RequestHandler = async ({ url }) => {
conditions = and(eq(notes.archived, false), eq(notes.trashed, false));
}
const result = await db
const result = db
.select()
.from(notes)
.where(conditions)
.orderBy(desc(notes.pinned), desc(notes.updatedAt));
.orderBy(desc(notes.pinned), desc(notes.updatedAt))
.all();
// Fetch tags for each note
const notesWithTags = await Promise.all(
result.map(async (note) => {
const tagRows = await db
.select({ name: tags.name })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(eq(noteTags.noteId, note.id));
return { ...note, tags: tagRows.map((t) => t.name) };
})
);
const tagMap = fetchTagsForNotes(result.map((n) => n.id));
const notesWithTags = result.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? []
}));
return json(notesWithTags);
};
@@ -63,27 +59,13 @@ export const POST: RequestHandler = async ({ request }) => {
version: 1
};
await db.insert(notes).values(newNote);
// Extract and save tags
const content = `${newNote.title} ${newNote.content}`;
const extractedTags = extractTags(content);
await syncNoteTags(id, extractedTags);
db.transaction((tx) => {
tx.insert(notes).values(newNote).run();
});
syncNoteTags(id, extractedTags);
return json({ ...newNote, tags: extractedTags }, { status: 201 });
};
async function syncNoteTags(noteId: string, tagNames: string[]) {
// Remove existing tag associations
await db.delete(noteTags).where(eq(noteTags.noteId, noteId));
for (const name of tagNames) {
// Upsert tag
let tagRow = await db.select().from(tags).where(eq(tags.name, name)).get();
if (!tagRow) {
const result = await db.insert(tags).values({ name }).returning();
tagRow = result[0];
}
await db.insert(noteTags).values({ noteId, tagId: tagRow.id });
}
}
+13 -34
View File
@@ -1,29 +1,26 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
import { notes } from '$lib/server/db/schema.js';
import { eq } from 'drizzle-orm';
import { extractTags } from '$lib/utils/tags.js';
import { fetchTagsForNotes, syncNoteTags } from '$lib/server/tags.js';
export const GET: RequestHandler = async ({ params }) => {
const note = await db.select().from(notes).where(eq(notes.id, params.id)).get();
const note = db.select().from(notes).where(eq(notes.id, params.id)).get();
if (!note) {
throw error(404, 'Note not found');
}
const tagRows = await db
.select({ name: tags.name })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(eq(noteTags.noteId, note.id));
const tagMap = fetchTagsForNotes([note.id]);
return json({ ...note, tags: tagRows.map((t) => t.name) });
return json({ ...note, tags: tagMap.get(note.id) ?? [] });
};
export const PATCH: RequestHandler = async ({ params, request }) => {
const body = await request.json();
const existing = await db.select().from(notes).where(eq(notes.id, params.id)).get();
const existing = db.select().from(notes).where(eq(notes.id, params.id)).get();
if (!existing) {
throw error(404, 'Note not found');
@@ -47,46 +44,28 @@ export const PATCH: RequestHandler = async ({ params, request }) => {
if (body.checklistMode !== undefined) updates.checklistMode = body.checklistMode;
if (body.sortOrder !== undefined) updates.sortOrder = body.sortOrder;
await db.update(notes).set(updates).where(eq(notes.id, params.id));
db.update(notes).set(updates).where(eq(notes.id, params.id)).run();
const updated = await db.select().from(notes).where(eq(notes.id, params.id)).get();
const updated = db.select().from(notes).where(eq(notes.id, params.id)).get();
// Re-sync tags if content or title changed
if (body.title !== undefined || body.content !== undefined) {
const content = `${updated!.title} ${updated!.content}`;
const extractedTags = extractTags(content);
await syncNoteTags(params.id, extractedTags);
syncNoteTags(params.id, extractedTags);
}
const tagRows = await db
.select({ name: tags.name })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(eq(noteTags.noteId, params.id));
const tagMap = fetchTagsForNotes([params.id]);
return json({ ...updated, tags: tagRows.map((t) => t.name) });
return json({ ...updated, tags: tagMap.get(params.id) ?? [] });
};
export const DELETE: RequestHandler = async ({ params }) => {
const existing = await db.select().from(notes).where(eq(notes.id, params.id)).get();
const existing = db.select().from(notes).where(eq(notes.id, params.id)).get();
if (!existing) {
throw error(404, 'Note not found');
}
await db.delete(notes).where(eq(notes.id, params.id));
db.delete(notes).where(eq(notes.id, params.id)).run();
return json({ success: true });
};
async function syncNoteTags(noteId: string, tagNames: string[]) {
await db.delete(noteTags).where(eq(noteTags.noteId, noteId));
for (const name of tagNames) {
let tagRow = await db.select().from(tags).where(eq(tags.name, name)).get();
if (!tagRow) {
const result = await db.insert(tags).values({ name }).returning();
tagRow = result[0];
}
await db.insert(noteTags).values({ noteId, tagId: tagRow.id });
}
}
+22 -29
View File
@@ -2,7 +2,8 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types.js';
import { db } from '$lib/server/db/index.js';
import { notes, noteTags, tags } from '$lib/server/db/schema.js';
import { and, eq, like, or } from 'drizzle-orm';
import { and, eq, like, or, inArray } from 'drizzle-orm';
import { fetchTagsForNotes } from '$lib/server/tags.js';
export const GET: RequestHandler = async ({ url }) => {
const query = url.searchParams.get('q')?.trim();
@@ -13,7 +14,7 @@ export const GET: RequestHandler = async ({ url }) => {
const pattern = `%${query}%`;
const results = await db
const results = db
.select()
.from(notes)
.where(
@@ -21,45 +22,37 @@ export const GET: RequestHandler = async ({ url }) => {
eq(notes.trashed, false),
or(like(notes.title, pattern), like(notes.content, pattern))
)
);
)
.all();
// Also search by tag name
const tagResults = await db
const tagResults = db
.select({ noteId: noteTags.noteId })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(like(tags.name, pattern));
.where(like(tags.name, pattern))
.all();
const tagNoteIds = new Set(tagResults.map((r) => r.noteId));
const allNoteIds = new Set(results.map((n) => n.id));
const resultIds = new Set(results.map((n) => n.id));
const extraNoteIds = [...new Set(tagResults.map((r) => r.noteId))].filter(
(id) => !resultIds.has(id)
);
// Fetch tag-matched notes not already in results
const extraNoteIds = [...tagNoteIds].filter((id) => !allNoteIds.has(id));
let extraNotes: typeof results = [];
if (extraNoteIds.length > 0) {
for (const id of extraNoteIds) {
const note = await db
.select()
.from(notes)
.where(and(eq(notes.id, id), eq(notes.trashed, false)))
.get();
if (note) extraNotes.push(note);
}
extraNotes = db
.select()
.from(notes)
.where(and(inArray(notes.id, extraNoteIds), eq(notes.trashed, false)))
.all();
}
const combined = [...results, ...extraNotes];
// Attach tags to each note
const withTags = await Promise.all(
combined.map(async (note) => {
const tagRows = await db
.select({ name: tags.name })
.from(noteTags)
.innerJoin(tags, eq(noteTags.tagId, tags.id))
.where(eq(noteTags.noteId, note.id));
return { ...note, tags: tagRows.map((t) => t.name) };
})
);
const tagMap = fetchTagsForNotes(combined.map((n) => n.id));
const withTags = combined.map((note) => ({
...note,
tags: tagMap.get(note.id) ?? []
}));
return json(withTags);
};