Skip to content

Commit 6a94a41

Browse files
committed
more progress
1 parent 80ea79a commit 6a94a41

34 files changed

+3674
-0
lines changed
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Elicite Confirmation
2+
3+
{/* TODO: put an example of elicitation here because it's a lot */}
Binary file not shown.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
{
2+
"name": "exercises_02.elicitation_01.problem",
3+
"private": true,
4+
"type": "module",
5+
"scripts": {
6+
"dev": "mcp-dev",
7+
"dev:mcp": "tsx src/index.ts",
8+
"test": "vitest",
9+
"typecheck": "tsc",
10+
"inspect": "mcp-inspector"
11+
},
12+
"dependencies": {
13+
"@epic-web/invariant": "^1.0.0",
14+
"@modelcontextprotocol/sdk": "^1.15.0",
15+
"zod": "^3.25.67"
16+
},
17+
"devDependencies": {
18+
"@epic-web/config": "^1.21.0",
19+
"@epic-web/mcp-dev": "*",
20+
"@faker-js/faker": "^9.8.0",
21+
"@modelcontextprotocol/inspector": "^0.15.0",
22+
"@types/node": "^24.0.3",
23+
"tsx": "^4.20.3",
24+
"typescript": "^5.8.3",
25+
"vitest": "^3.2.4"
26+
},
27+
"license": "GPL-3.0-only"
28+
}
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
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

Comments
 (0)