mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 20:06:57 +02:00
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:
co-authored by
Claude Opus 4.6
parent
02e6b57764
commit
ef3ba163dd
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user