feat: Crumbs by Bretzel — self-hostable notes app MVP

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude
2026-03-05 21:16:45 +01:00
committed by Frederic Cabassut
commit 1becc17724
89 changed files with 11359 additions and 0 deletions
+63
View File
@@ -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));
}
}
+120
View File
@@ -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();
});
});
+72
View File
@@ -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));
}
}
}
+82
View File
@@ -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 };
+294
View File
@@ -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');
});
});
+71
View File
@@ -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()
});
+116
View File
@@ -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');
});
});
+76
View File
@@ -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 };
}