forked from mirror/crumbs-original
feat: Crumbs by Bretzel — self-hostable notes app MVP
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
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 { attachments } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||
const ATTACHMENTS_DIR = join(DATA_DIR, 'attachments');
|
||||
|
||||
// Ensure attachments directory exists
|
||||
if (!existsSync(ATTACHMENTS_DIR)) {
|
||||
mkdirSync(ATTACHMENTS_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
export async function saveAttachment(
|
||||
noteId: string,
|
||||
file: File
|
||||
): Promise<typeof attachments.$inferSelect> {
|
||||
const id = uuidv4();
|
||||
const ext = file.name.split('.').pop() || 'bin';
|
||||
const filename = `${id}.${ext}`;
|
||||
const filePath = join(ATTACHMENTS_DIR, filename);
|
||||
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
await writeFile(filePath, buffer);
|
||||
|
||||
const [attachment] = await db
|
||||
.insert(attachments)
|
||||
.values({
|
||||
id,
|
||||
noteId,
|
||||
filename: file.name,
|
||||
mimeType: file.type,
|
||||
size: file.size,
|
||||
path: filePath,
|
||||
createdAt: new Date()
|
||||
})
|
||||
.returning();
|
||||
|
||||
return attachment;
|
||||
}
|
||||
|
||||
export async function getAttachment(id: string) {
|
||||
return db.select().from(attachments).where(eq(attachments.id, id)).get();
|
||||
}
|
||||
|
||||
export async function getAttachmentsByNote(noteId: string) {
|
||||
return db.select().from(attachments).where(eq(attachments.noteId, noteId));
|
||||
}
|
||||
|
||||
export async function deleteAttachment(id: string) {
|
||||
const attachment = await getAttachment(id);
|
||||
if (attachment) {
|
||||
try {
|
||||
await unlink(attachment.path);
|
||||
} catch {
|
||||
// File may already be deleted
|
||||
}
|
||||
await db.delete(attachments).where(eq(attachments.id, id));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './db/test-helpers.js';
|
||||
import { users, sessions } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomBytes } from 'crypto';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './db/schema.js';
|
||||
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
});
|
||||
|
||||
describe('Auth - Password Hashing', () => {
|
||||
it('should hash and verify a password correctly', async () => {
|
||||
const password = 'mysecurepassword';
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
expect(hash).not.toBe(password);
|
||||
expect(await argon2.verify(hash, password)).toBe(true);
|
||||
expect(await argon2.verify(hash, 'wrongpassword')).toBe(false);
|
||||
});
|
||||
|
||||
it('should produce different hashes for the same password', async () => {
|
||||
const password = 'mysecurepassword';
|
||||
const hash1 = await argon2.hash(password);
|
||||
const hash2 = await argon2.hash(password);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
expect(await argon2.verify(hash1, password)).toBe(true);
|
||||
expect(await argon2.verify(hash2, password)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth - User Setup', () => {
|
||||
it('should create a user with hashed password', async () => {
|
||||
const password = 'testpassword123';
|
||||
const hash = await argon2.hash(password);
|
||||
|
||||
await db.insert(users).values({
|
||||
passwordHash: hash,
|
||||
createdAt: new Date()
|
||||
});
|
||||
|
||||
const user = await db.select().from(users).get();
|
||||
expect(user).toBeDefined();
|
||||
expect(user!.passwordHash).not.toBe(password);
|
||||
expect(await argon2.verify(user!.passwordHash, password)).toBe(true);
|
||||
});
|
||||
|
||||
it('should only allow one user', async () => {
|
||||
await db.insert(users).values({
|
||||
passwordHash: 'hash1',
|
||||
createdAt: new Date()
|
||||
});
|
||||
|
||||
const allUsers = await db.select().from(users);
|
||||
expect(allUsers).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Auth - Sessions', () => {
|
||||
it('should create and validate a session', async () => {
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({ passwordHash: 'hash', createdAt: new Date() })
|
||||
.returning();
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await db.insert(sessions).values({
|
||||
id: token,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 86400000)
|
||||
});
|
||||
|
||||
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
|
||||
expect(session).toBeDefined();
|
||||
expect(session!.userId).toBe(user.id);
|
||||
expect(session!.expiresAt.getTime()).toBeGreaterThan(Date.now());
|
||||
});
|
||||
|
||||
it('should detect expired sessions', async () => {
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({ passwordHash: 'hash', createdAt: new Date() })
|
||||
.returning();
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await db.insert(sessions).values({
|
||||
id: token,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() - 1000) // Expired
|
||||
});
|
||||
|
||||
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
|
||||
expect(session).toBeDefined();
|
||||
expect(session!.expiresAt.getTime()).toBeLessThan(Date.now());
|
||||
});
|
||||
|
||||
it('should delete a session', async () => {
|
||||
const [user] = await db
|
||||
.insert(users)
|
||||
.values({ passwordHash: 'hash', createdAt: new Date() })
|
||||
.returning();
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await db.insert(sessions).values({
|
||||
id: token,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + 86400000)
|
||||
});
|
||||
|
||||
await db.delete(sessions).where(eq(sessions.id, token));
|
||||
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
|
||||
expect(session).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { db } from './db/index.js';
|
||||
import { users, sessions } from './db/schema.js';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import * as argon2 from 'argon2';
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const SESSION_DURATION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
export async function isSetupComplete(): Promise<boolean> {
|
||||
const user = await db.select().from(users).get();
|
||||
return !!user;
|
||||
}
|
||||
|
||||
export async function setupUser(password: string): Promise<boolean> {
|
||||
const existing = await db.select().from(users).get();
|
||||
if (existing) return false; // Already set up
|
||||
|
||||
const hash = await argon2.hash(password);
|
||||
await db.insert(users).values({
|
||||
passwordHash: hash,
|
||||
createdAt: new Date()
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string): Promise<boolean> {
|
||||
const user = await db.select().from(users).get();
|
||||
if (!user) return false;
|
||||
return argon2.verify(user.passwordHash, password);
|
||||
}
|
||||
|
||||
export async function createSession(): Promise<string> {
|
||||
const user = await db.select().from(users).get();
|
||||
if (!user) throw new Error('No user exists');
|
||||
|
||||
const token = randomBytes(32).toString('hex');
|
||||
await db.insert(sessions).values({
|
||||
id: token,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + SESSION_DURATION_MS)
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function validateSession(token: string): Promise<boolean> {
|
||||
if (!token) return false;
|
||||
|
||||
const session = await db.select().from(sessions).where(eq(sessions.id, token)).get();
|
||||
if (!session) return false;
|
||||
|
||||
if (session.expiresAt < new Date()) {
|
||||
await db.delete(sessions).where(eq(sessions.id, token));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function deleteSession(token: string): Promise<void> {
|
||||
await db.delete(sessions).where(eq(sessions.id, token));
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from './schema.js';
|
||||
import { existsSync, mkdirSync } from 'fs';
|
||||
import { dirname } from 'path';
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL || './data/crumbs.db';
|
||||
|
||||
// Ensure the data directory exists
|
||||
const dir = dirname(DATABASE_URL);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const sqlite = new Database(DATABASE_URL);
|
||||
|
||||
// Enable WAL mode for better concurrent read performance
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
// Auto-create tables on first run
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT 'default',
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
trashed INTEGER NOT NULL DEFAULT 0,
|
||||
trashed_at INTEGER,
|
||||
checklist_mode INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS tags_name_unique ON tags(name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS note_tags (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sync_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id),
|
||||
operation TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
client_id TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
export { sqlite };
|
||||
@@ -0,0 +1,294 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './test-helpers.js';
|
||||
import { notes, noteTags, tags } from './schema.js';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './schema.js';
|
||||
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
|
||||
beforeEach(() => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
});
|
||||
|
||||
describe('Notes CRUD', () => {
|
||||
it('should create a note with default values', async () => {
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
title: 'Test Note',
|
||||
content: 'Hello world',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.title).toBe('Test Note');
|
||||
expect(result!.content).toBe('Hello world');
|
||||
expect(result!.color).toBe('default');
|
||||
expect(result!.pinned).toBe(false);
|
||||
expect(result!.archived).toBe(false);
|
||||
expect(result!.trashed).toBe(false);
|
||||
expect(result!.checklistMode).toBe(false);
|
||||
expect(result!.version).toBe(1);
|
||||
});
|
||||
|
||||
it('should create a note with custom color and pinned state', async () => {
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
title: 'Colored Note',
|
||||
content: '',
|
||||
color: 'coral',
|
||||
pinned: true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
|
||||
expect(result!.color).toBe('coral');
|
||||
expect(result!.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it('should read all active notes (not archived, not trashed)', async () => {
|
||||
const now = new Date();
|
||||
await db.insert(notes).values([
|
||||
{ id: 'n1', title: 'Active', content: '', createdAt: now, updatedAt: now },
|
||||
{
|
||||
id: 'n2',
|
||||
title: 'Archived',
|
||||
content: '',
|
||||
archived: true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{ id: 'n3', title: 'Trashed', content: '', trashed: true, createdAt: now, updatedAt: now }
|
||||
]);
|
||||
|
||||
const active = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.archived, false), eq(notes.trashed, false)));
|
||||
|
||||
expect(active).toHaveLength(1);
|
||||
expect(active[0].title).toBe('Active');
|
||||
});
|
||||
|
||||
it('should update a note and increment version', async () => {
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
title: 'Original',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
const later = new Date(now.getTime() + 1000);
|
||||
await db
|
||||
.update(notes)
|
||||
.set({ title: 'Updated', updatedAt: later, version: 2 })
|
||||
.where(eq(notes.id, id));
|
||||
|
||||
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
|
||||
expect(result!.title).toBe('Updated');
|
||||
expect(result!.version).toBe(2);
|
||||
});
|
||||
|
||||
it('should soft-delete a note (move to trash)', async () => {
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
title: 'To Trash',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
await db.update(notes).set({ trashed: true, trashedAt: now }).where(eq(notes.id, id));
|
||||
|
||||
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
expect(result!.trashed).toBe(true);
|
||||
expect(result!.trashedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should permanently delete a note', async () => {
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
title: 'To Delete',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
await db.delete(notes).where(eq(notes.id, id));
|
||||
const result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should archive and unarchive a note', async () => {
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
title: 'To Archive',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
await db.update(notes).set({ archived: true }).where(eq(notes.id, id));
|
||||
let result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
expect(result!.archived).toBe(true);
|
||||
|
||||
await db.update(notes).set({ archived: false }).where(eq(notes.id, id));
|
||||
result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
expect(result!.archived).toBe(false);
|
||||
});
|
||||
|
||||
it('should pin and unpin a note', async () => {
|
||||
const id = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id,
|
||||
title: 'Pin Me',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
await db.update(notes).set({ pinned: true }).where(eq(notes.id, id));
|
||||
let result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
expect(result!.pinned).toBe(true);
|
||||
|
||||
await db.update(notes).set({ pinned: false }).where(eq(notes.id, id));
|
||||
result = await db.select().from(notes).where(eq(notes.id, id)).get();
|
||||
expect(result!.pinned).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Notes with Tags', () => {
|
||||
it('should associate tags with a note', async () => {
|
||||
const noteId = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id: noteId,
|
||||
title: 'Tagged Note',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
// Create tags
|
||||
const [tag1] = await db.insert(tags).values({ name: 'work' }).returning();
|
||||
const [tag2] = await db.insert(tags).values({ name: 'important' }).returning();
|
||||
|
||||
// Associate
|
||||
await db.insert(noteTags).values([
|
||||
{ noteId, tagId: tag1.id },
|
||||
{ noteId, tagId: tag2.id }
|
||||
]);
|
||||
|
||||
// Query
|
||||
const tagRows = await db
|
||||
.select({ name: tags.name })
|
||||
.from(noteTags)
|
||||
.innerJoin(tags, eq(noteTags.tagId, tags.id))
|
||||
.where(eq(noteTags.noteId, noteId));
|
||||
|
||||
expect(tagRows).toHaveLength(2);
|
||||
expect(tagRows.map((t) => t.name).sort()).toEqual(['important', 'work']);
|
||||
});
|
||||
|
||||
it('should cascade delete tags when note is deleted', async () => {
|
||||
const noteId = uuidv4();
|
||||
const now = new Date();
|
||||
|
||||
await db.insert(notes).values({
|
||||
id: noteId,
|
||||
title: '',
|
||||
content: '',
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
const [tag] = await db.insert(tags).values({ name: 'temp' }).returning();
|
||||
await db.insert(noteTags).values({ noteId, tagId: tag.id });
|
||||
|
||||
// Delete note
|
||||
await db.delete(notes).where(eq(notes.id, noteId));
|
||||
|
||||
// note_tags should be gone
|
||||
const remaining = await db
|
||||
.select()
|
||||
.from(noteTags)
|
||||
.where(eq(noteTags.noteId, noteId));
|
||||
expect(remaining).toHaveLength(0);
|
||||
|
||||
// Tag itself should still exist
|
||||
const tagStillExists = await db.select().from(tags).where(eq(tags.id, tag.id)).get();
|
||||
expect(tagStillExists).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Note ordering', () => {
|
||||
it('should return pinned notes first', async () => {
|
||||
const now = new Date();
|
||||
await db.insert(notes).values([
|
||||
{
|
||||
id: 'unpinned',
|
||||
title: 'Unpinned',
|
||||
content: '',
|
||||
pinned: false,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
},
|
||||
{
|
||||
id: 'pinned',
|
||||
title: 'Pinned',
|
||||
content: '',
|
||||
pinned: true,
|
||||
createdAt: now,
|
||||
updatedAt: now
|
||||
}
|
||||
]);
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.orderBy(({ pinned }) => [
|
||||
// SQLite: true = 1, false = 0, DESC puts 1 first
|
||||
eq(pinned, pinned) // just use raw desc
|
||||
]);
|
||||
|
||||
// Manual approach: order by pinned desc
|
||||
const { desc } = await import('drizzle-orm');
|
||||
const ordered = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.orderBy(desc(notes.pinned), desc(notes.updatedAt));
|
||||
|
||||
expect(ordered[0].title).toBe('Pinned');
|
||||
expect(ordered[1].title).toBe('Unpinned');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { sqliteTable, text, integer, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
passwordHash: text('password_hash').notNull(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull()
|
||||
});
|
||||
|
||||
export const sessions = sqliteTable('sessions', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id')
|
||||
.references(() => users.id)
|
||||
.notNull(),
|
||||
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 tags = sqliteTable(
|
||||
'tags',
|
||||
{
|
||||
id: integer('id').primaryKey({ autoIncrement: true }),
|
||||
name: text('name').notNull()
|
||||
},
|
||||
(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 attachments = sqliteTable('attachments', {
|
||||
id: text('id').primaryKey(),
|
||||
noteId: text('note_id')
|
||||
.references(() => notes.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
mimeType: text('mime_type').notNull(),
|
||||
size: integer('size').notNull(),
|
||||
path: text('path').notNull(),
|
||||
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()
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { createTestDb } from './test-helpers.js';
|
||||
import { notes, tags, noteTags } from './schema.js';
|
||||
import { and, eq, like, or } from 'drizzle-orm';
|
||||
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
|
||||
import type * as schema from './schema.js';
|
||||
|
||||
let db: BetterSQLite3Database<typeof schema>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const testDb = createTestDb();
|
||||
db = testDb.db;
|
||||
|
||||
const now = new Date();
|
||||
await db.insert(notes).values([
|
||||
{ id: 'n1', title: 'Shopping List', content: 'Buy milk and eggs', createdAt: now, updatedAt: now },
|
||||
{ id: 'n2', title: 'Work Notes', content: 'Meeting at 3pm #work', createdAt: now, updatedAt: now },
|
||||
{ id: 'n3', title: 'Recipe', content: 'Pasta with tomato sauce', createdAt: now, updatedAt: now },
|
||||
{ id: 'n4', title: 'Trashed', content: 'Should not appear', trashed: true, createdAt: now, updatedAt: now }
|
||||
]);
|
||||
|
||||
const [workTag] = await db.insert(tags).values({ name: 'work' }).returning();
|
||||
await db.insert(noteTags).values({ noteId: 'n2', tagId: workTag.id });
|
||||
});
|
||||
|
||||
describe('Search', () => {
|
||||
it('should search by title', async () => {
|
||||
const pattern = '%shopping%';
|
||||
const results = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.trashed, false), like(notes.title, pattern)));
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('n1');
|
||||
});
|
||||
|
||||
it('should search by content', async () => {
|
||||
const pattern = '%tomato%';
|
||||
const results = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.trashed, false), like(notes.content, pattern)));
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('n3');
|
||||
});
|
||||
|
||||
it('should search across title and content', async () => {
|
||||
const pattern = '%milk%';
|
||||
const results = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(
|
||||
and(
|
||||
eq(notes.trashed, false),
|
||||
or(like(notes.title, pattern), like(notes.content, pattern))
|
||||
)
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].id).toBe('n1');
|
||||
});
|
||||
|
||||
it('should not include trashed notes', async () => {
|
||||
const pattern = '%should not%';
|
||||
const results = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(
|
||||
and(
|
||||
eq(notes.trashed, false),
|
||||
or(like(notes.title, pattern), like(notes.content, pattern))
|
||||
)
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return empty for no matches', async () => {
|
||||
const pattern = '%nonexistent%';
|
||||
const results = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(
|
||||
and(
|
||||
eq(notes.trashed, false),
|
||||
or(like(notes.title, pattern), like(notes.content, pattern))
|
||||
)
|
||||
);
|
||||
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should be case-insensitive with LIKE', async () => {
|
||||
const pattern = '%SHOPPING%';
|
||||
const results = await db
|
||||
.select()
|
||||
.from(notes)
|
||||
.where(and(eq(notes.trashed, false), like(notes.title, pattern)));
|
||||
|
||||
// SQLite LIKE is case-insensitive for ASCII by default
|
||||
expect(results).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should search by tag name', async () => {
|
||||
const tagResults = await db
|
||||
.select({ noteId: noteTags.noteId })
|
||||
.from(noteTags)
|
||||
.innerJoin(tags, eq(noteTags.tagId, tags.id))
|
||||
.where(like(tags.name, '%work%'));
|
||||
|
||||
expect(tagResults).toHaveLength(1);
|
||||
expect(tagResults[0].noteId).toBe('n2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import * as schema from './schema.js';
|
||||
|
||||
/**
|
||||
* Create an in-memory SQLite database for testing.
|
||||
* Each test gets a fresh database.
|
||||
*/
|
||||
export function createTestDb() {
|
||||
const sqlite = new Database(':memory:');
|
||||
sqlite.pragma('journal_mode = WAL');
|
||||
sqlite.pragma('foreign_keys = ON');
|
||||
|
||||
// Create tables
|
||||
sqlite.exec(`
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE notes (
|
||||
id TEXT PRIMARY KEY,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
color TEXT NOT NULL DEFAULT 'default',
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
trashed INTEGER NOT NULL DEFAULT 0,
|
||||
trashed_at INTEGER,
|
||||
checklist_mode INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 1
|
||||
);
|
||||
|
||||
CREATE TABLE tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE UNIQUE INDEX tags_name_unique ON tags(name);
|
||||
|
||||
CREATE TABLE note_tags (
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE attachments (
|
||||
id TEXT PRIMARY KEY,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
|
||||
filename TEXT NOT NULL,
|
||||
mime_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE sync_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
note_id TEXT NOT NULL REFERENCES notes(id),
|
||||
operation TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
client_id TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
const db = drizzle(sqlite, { schema });
|
||||
return { db, sqlite };
|
||||
}
|
||||
Reference in New Issue
Block a user