|
| 1 | +import { DatabaseSync } from 'node:sqlite' |
| 2 | +import { z } from 'zod' |
| 3 | +import { migrate } from './migrations.ts' |
| 4 | +import { |
| 5 | + type Entry, |
| 6 | + type NewEntry, |
| 7 | + type Tag, |
| 8 | + type NewTag, |
| 9 | + type EntryTag, |
| 10 | + type EntryWithTags, |
| 11 | + entrySchema, |
| 12 | + newEntrySchema, |
| 13 | + tagSchema, |
| 14 | + newTagSchema, |
| 15 | + entryTagSchema, |
| 16 | +} from './schema.ts' |
| 17 | +import { sql, snakeToCamel } from './utils.ts' |
| 18 | + |
| 19 | +export type { Entry, NewEntry, Tag, NewTag, EntryTag } |
| 20 | + |
| 21 | +export class DB { |
| 22 | + #db: DatabaseSync |
| 23 | + #subscribers = new Set< |
| 24 | + (changes: { tags?: number[]; entries?: number[] }) => void |
| 25 | + >() |
| 26 | + |
| 27 | + subscribe( |
| 28 | + subscriber: (changes: { tags?: number[]; entries?: number[] }) => void, |
| 29 | + ) { |
| 30 | + this.#subscribers.add(subscriber) |
| 31 | + return () => { |
| 32 | + this.#subscribers.delete(subscriber) |
| 33 | + } |
| 34 | + } |
| 35 | + |
| 36 | + #notifySubscribers(changes: { tags?: number[]; entries?: number[] }) { |
| 37 | + for (const subscriber of this.#subscribers) { |
| 38 | + subscriber(changes) |
| 39 | + } |
| 40 | + } |
| 41 | + |
| 42 | + constructor(db: DatabaseSync) { |
| 43 | + this.#db = db |
| 44 | + } |
| 45 | + |
| 46 | + static getInstance(path: string) { |
| 47 | + const db = new DB(new DatabaseSync(path)) |
| 48 | + migrate(db.#db) |
| 49 | + return db |
| 50 | + } |
| 51 | + |
| 52 | + // Entry Methods |
| 53 | + async createEntry(entry: z.input<typeof newEntrySchema>) { |
| 54 | + const validatedEntry = newEntrySchema.parse(entry) |
| 55 | + const stmt = this.#db.prepare(sql` |
| 56 | + INSERT INTO entries ( |
| 57 | + title, content, mood, location, weather, |
| 58 | + is_private, is_favorite |
| 59 | + ) VALUES ( |
| 60 | + ?, ?, ?, ?, ?, ?, ? |
| 61 | + ) |
| 62 | + `) |
| 63 | + const result = stmt.run( |
| 64 | + validatedEntry.title, |
| 65 | + validatedEntry.content, |
| 66 | + validatedEntry.mood ?? null, |
| 67 | + validatedEntry.location ?? null, |
| 68 | + validatedEntry.weather ?? null, |
| 69 | + validatedEntry.isPrivate, |
| 70 | + validatedEntry.isFavorite, |
| 71 | + ) |
| 72 | + const id = |
| 73 | + result.lastInsertRowid !== undefined |
| 74 | + ? Number(result.lastInsertRowid) |
| 75 | + : undefined |
| 76 | + if (!id) { |
| 77 | + throw new Error('Failed to create entry') |
| 78 | + } |
| 79 | + const createdEntry = await this.getEntry(id) |
| 80 | + if (!createdEntry) { |
| 81 | + throw new Error('Failed to query created entry') |
| 82 | + } |
| 83 | + this.#notifySubscribers({ entries: [id] }) |
| 84 | + return createdEntry |
| 85 | + } |
| 86 | + |
| 87 | + async getEntries() { |
| 88 | + const stmt = this.#db.prepare( |
| 89 | + sql`SELECT * FROM entries ORDER BY created_at DESC`, |
| 90 | + ) |
| 91 | + const entries = stmt.all().map((entry) => snakeToCamel(entry)) |
| 92 | + return z.array(entrySchema).parse(entries) |
| 93 | + } |
| 94 | + |
| 95 | + async getEntry(id: number) { |
| 96 | + const stmt = this.#db.prepare(sql`SELECT * FROM entries WHERE id = ?`) |
| 97 | + const entryResult = stmt.get(id) |
| 98 | + if (!entryResult) return null |
| 99 | + const entry = entrySchema.parse(snakeToCamel(entryResult)) |
| 100 | + const tagsStmt = this.#db.prepare(sql` |
| 101 | + SELECT t.id, t.name |
| 102 | + FROM tags t |
| 103 | + JOIN entry_tags et ON et.tag_id = t.id |
| 104 | + WHERE et.entry_id = ? |
| 105 | + ORDER BY t.name |
| 106 | + `) |
| 107 | + const tagsResult = tagsStmt.all(id).map((tag) => snakeToCamel(tag)) |
| 108 | + const tags = z |
| 109 | + .array(z.object({ id: z.number(), name: z.string() })) |
| 110 | + .parse(tagsResult) |
| 111 | + return { ...entry, tags } as EntryWithTags |
| 112 | + } |
| 113 | + |
| 114 | + // TODO: listEntries to actually filter by tagIds |
| 115 | + async listEntries(tagIds?: Array<number>) { |
| 116 | + const stmt = this.#db.prepare( |
| 117 | + sql`SELECT * FROM entries ORDER BY created_at DESC`, |
| 118 | + ) |
| 119 | + const results = stmt.all().map((result) => snakeToCamel(result)) |
| 120 | + return z.array(entrySchema).parse(results) |
| 121 | + } |
| 122 | + |
| 123 | + async updateEntry( |
| 124 | + id: number, |
| 125 | + entry: Partial<z.input<typeof newEntrySchema>>, |
| 126 | + ) { |
| 127 | + const existingEntry = await this.getEntry(id) |
| 128 | + if (!existingEntry) { |
| 129 | + throw new Error(`Entry with ID ${id} not found`) |
| 130 | + } |
| 131 | + const updates = Object.entries(entry) |
| 132 | + .filter(([key, value]) => value !== undefined) |
| 133 | + .map(([key]) => `${key} = ?`) |
| 134 | + .join(', ') |
| 135 | + if (!updates) { |
| 136 | + return existingEntry |
| 137 | + } |
| 138 | + const updateValues = [ |
| 139 | + ...Object.entries(entry) |
| 140 | + .filter(([, value]) => value !== undefined) |
| 141 | + .map(([, value]) => value), |
| 142 | + id, |
| 143 | + ] |
| 144 | + if (updateValues.some((v) => v === undefined)) { |
| 145 | + throw new Error('Undefined value in updateEntry parameters') |
| 146 | + } |
| 147 | + const stmt = this.#db.prepare(sql` |
| 148 | + UPDATE entries |
| 149 | + SET ${updates}, updated_at = CURRENT_TIMESTAMP |
| 150 | + WHERE id = ? |
| 151 | + `) |
| 152 | + const result = stmt.run(...updateValues) |
| 153 | + if (!result.changes) { |
| 154 | + throw new Error('Failed to update entry') |
| 155 | + } |
| 156 | + const updatedEntry = await this.getEntry(id) |
| 157 | + if (!updatedEntry) { |
| 158 | + throw new Error('Failed to query updated entry') |
| 159 | + } |
| 160 | + this.#notifySubscribers({ entries: [id] }) |
| 161 | + return updatedEntry |
| 162 | + } |
| 163 | + |
| 164 | + async deleteEntry(id: number) { |
| 165 | + const existingEntry = await this.getEntry(id) |
| 166 | + if (!existingEntry) { |
| 167 | + throw new Error(`Entry with ID ${id} not found`) |
| 168 | + } |
| 169 | + const stmt = this.#db.prepare(sql`DELETE FROM entries WHERE id = ?`) |
| 170 | + const result = stmt.run(id) |
| 171 | + if (!result.changes) { |
| 172 | + throw new Error('Failed to delete entry') |
| 173 | + } |
| 174 | + this.#notifySubscribers({ entries: [id] }) |
| 175 | + return true |
| 176 | + } |
| 177 | + |
| 178 | + // Tag Methods |
| 179 | + async createTag(tag: NewTag) { |
| 180 | + const validatedTag = newTagSchema.parse(tag) |
| 181 | + const stmt = this.#db.prepare(sql` |
| 182 | + INSERT INTO tags (name, description) |
| 183 | + VALUES (?, ?) |
| 184 | + `) |
| 185 | + const result = stmt.run(validatedTag.name, validatedTag.description ?? null) |
| 186 | + const id = |
| 187 | + result.lastInsertRowid !== undefined |
| 188 | + ? Number(result.lastInsertRowid) |
| 189 | + : undefined |
| 190 | + if (!id) { |
| 191 | + throw new Error('Failed to create tag') |
| 192 | + } |
| 193 | + const createdTag = await this.getTag(id) |
| 194 | + if (!createdTag) { |
| 195 | + throw new Error('Failed to query created tag') |
| 196 | + } |
| 197 | + this.#notifySubscribers({ tags: [id] }) |
| 198 | + return createdTag |
| 199 | + } |
| 200 | + |
| 201 | + async getTags() { |
| 202 | + const stmt = this.#db.prepare(sql`SELECT * FROM tags ORDER BY name`) |
| 203 | + const results = stmt.all().map((result) => snakeToCamel(result)) |
| 204 | + return z.array(tagSchema).parse(results) |
| 205 | + } |
| 206 | + |
| 207 | + async getTag(id: number) { |
| 208 | + const stmt = this.#db.prepare(sql`SELECT * FROM tags WHERE id = ?`) |
| 209 | + const result = stmt.get(id) |
| 210 | + if (!result) return null |
| 211 | + return tagSchema.parse(snakeToCamel(result)) |
| 212 | + } |
| 213 | + |
| 214 | + async listTags() { |
| 215 | + const stmt = this.#db.prepare(sql`SELECT id, name FROM tags ORDER BY name`) |
| 216 | + const results = stmt.all().map((result) => snakeToCamel(result)) |
| 217 | + return z |
| 218 | + .array(z.object({ id: z.number(), name: z.string() })) |
| 219 | + .parse(results) |
| 220 | + } |
| 221 | + |
| 222 | + async updateTag(id: number, tag: Partial<z.input<typeof newTagSchema>>) { |
| 223 | + const existingTag = await this.getTag(id) |
| 224 | + if (!existingTag) { |
| 225 | + throw new Error(`Tag with ID ${id} not found`) |
| 226 | + } |
| 227 | + const updates = Object.entries(tag) |
| 228 | + .filter(([, value]) => value !== undefined) |
| 229 | + .map(([key]) => `${key} = ?`) |
| 230 | + .join(', ') |
| 231 | + if (!updates) { |
| 232 | + return existingTag |
| 233 | + } |
| 234 | + const updateValues = [ |
| 235 | + ...Object.entries(tag) |
| 236 | + .filter(([, value]) => value !== undefined) |
| 237 | + .map(([, value]) => value), |
| 238 | + id, |
| 239 | + ] |
| 240 | + if (updateValues.some((v) => v === undefined)) { |
| 241 | + throw new Error('Undefined value in updateTag parameters') |
| 242 | + } |
| 243 | + const stmt = this.#db.prepare(sql` |
| 244 | + UPDATE tags |
| 245 | + SET ${updates}, updated_at = CURRENT_TIMESTAMP |
| 246 | + WHERE id = ? |
| 247 | + `) |
| 248 | + const result = stmt.run(...updateValues) |
| 249 | + if (!result.changes) { |
| 250 | + throw new Error('Failed to update tag') |
| 251 | + } |
| 252 | + const updatedTag = await this.getTag(id) |
| 253 | + if (!updatedTag) { |
| 254 | + throw new Error('Failed to query updated tag') |
| 255 | + } |
| 256 | + this.#notifySubscribers({ tags: [id] }) |
| 257 | + return updatedTag |
| 258 | + } |
| 259 | + |
| 260 | + async deleteTag(id: number) { |
| 261 | + const existingTag = await this.getTag(id) |
| 262 | + if (!existingTag) { |
| 263 | + throw new Error(`Tag with ID ${id} not found`) |
| 264 | + } |
| 265 | + const stmt = this.#db.prepare(sql`DELETE FROM tags WHERE id = ?`) |
| 266 | + const result = stmt.run(id) |
| 267 | + if (!result.changes) { |
| 268 | + throw new Error('Failed to delete tag') |
| 269 | + } |
| 270 | + this.#notifySubscribers({ tags: [id] }) |
| 271 | + return true |
| 272 | + } |
| 273 | + |
| 274 | + // Entry Tag Methods |
| 275 | + async addTagToEntry({ entryId, tagId }: { entryId: number; tagId: number }) { |
| 276 | + const entry = await this.getEntry(entryId) |
| 277 | + if (!entry) { |
| 278 | + throw new Error(`Entry with ID ${entryId} not found`) |
| 279 | + } |
| 280 | + const tag = await this.getTag(tagId) |
| 281 | + if (!tag) { |
| 282 | + throw new Error(`Tag with ID ${tagId} not found`) |
| 283 | + } |
| 284 | + const stmt = this.#db.prepare(sql` |
| 285 | + INSERT INTO entry_tags (entry_id, tag_id) |
| 286 | + VALUES (?, ?) |
| 287 | + `) |
| 288 | + const result = stmt.run(entryId, tagId) |
| 289 | + const id = |
| 290 | + result.lastInsertRowid !== undefined |
| 291 | + ? Number(result.lastInsertRowid) |
| 292 | + : undefined |
| 293 | + if (id === undefined) { |
| 294 | + throw new Error('Failed to add tag to entry') |
| 295 | + } |
| 296 | + const created = await this.getEntryTag(id) |
| 297 | + if (!created) { |
| 298 | + throw new Error('Failed to query created entry tag') |
| 299 | + } |
| 300 | + this.#notifySubscribers({ entries: [entryId], tags: [tagId] }) |
| 301 | + return created |
| 302 | + } |
| 303 | + |
| 304 | + async getEntryTag(id: number) { |
| 305 | + const stmt = this.#db.prepare(sql`SELECT * FROM entry_tags WHERE id = ?`) |
| 306 | + const result = stmt.get(id) |
| 307 | + if (!result) return null |
| 308 | + return entryTagSchema.parse(snakeToCamel(result)) |
| 309 | + } |
| 310 | + |
| 311 | + async getEntryTags(entryId: number) { |
| 312 | + const entry = await this.getEntry(entryId) |
| 313 | + if (!entry) { |
| 314 | + throw new Error(`Entry with ID ${entryId} not found`) |
| 315 | + } |
| 316 | + const stmt = this.#db.prepare(sql` |
| 317 | + SELECT t.* |
| 318 | + FROM tags t |
| 319 | + JOIN entry_tags et ON et.tag_id = t.id |
| 320 | + WHERE et.entry_id = ? |
| 321 | + ORDER BY t.name |
| 322 | + `) |
| 323 | + const results = stmt.all(entryId).map((result) => snakeToCamel(result)) |
| 324 | + return z.array(tagSchema).parse(results) |
| 325 | + } |
| 326 | +} |
0 commit comments