haroo 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -0
- package/dist/index.js +84883 -0
- package/package.json +73 -0
- package/src/__tests__/e2e/EventService.test.ts +211 -0
- package/src/__tests__/unit/Event.test.ts +89 -0
- package/src/__tests__/unit/Memory.test.ts +130 -0
- package/src/application/graph/builder.ts +106 -0
- package/src/application/graph/edges.ts +37 -0
- package/src/application/graph/nodes/addEvent.ts +113 -0
- package/src/application/graph/nodes/chat.ts +128 -0
- package/src/application/graph/nodes/extractMemory.ts +135 -0
- package/src/application/graph/nodes/index.ts +8 -0
- package/src/application/graph/nodes/query.ts +194 -0
- package/src/application/graph/nodes/respond.ts +26 -0
- package/src/application/graph/nodes/router.ts +82 -0
- package/src/application/graph/nodes/toolExecutor.ts +79 -0
- package/src/application/graph/nodes/types.ts +2 -0
- package/src/application/index.ts +4 -0
- package/src/application/services/DiaryService.ts +188 -0
- package/src/application/services/EventService.ts +61 -0
- package/src/application/services/index.ts +2 -0
- package/src/application/tools/calendarTool.ts +179 -0
- package/src/application/tools/diaryTool.ts +182 -0
- package/src/application/tools/index.ts +68 -0
- package/src/config/env.ts +33 -0
- package/src/config/index.ts +1 -0
- package/src/domain/entities/DiaryEntry.ts +16 -0
- package/src/domain/entities/Event.ts +13 -0
- package/src/domain/entities/Memory.ts +20 -0
- package/src/domain/index.ts +5 -0
- package/src/domain/interfaces/IDiaryRepository.ts +21 -0
- package/src/domain/interfaces/IEventsRepository.ts +12 -0
- package/src/domain/interfaces/ILanguageModel.ts +23 -0
- package/src/domain/interfaces/IMemoriesRepository.ts +15 -0
- package/src/domain/interfaces/IMemory.ts +19 -0
- package/src/domain/interfaces/index.ts +4 -0
- package/src/domain/state/AgentState.ts +30 -0
- package/src/index.ts +5 -0
- package/src/infrastructure/database/factory.ts +52 -0
- package/src/infrastructure/database/index.ts +21 -0
- package/src/infrastructure/database/sqlite-checkpointer.ts +179 -0
- package/src/infrastructure/database/sqlite-client.ts +69 -0
- package/src/infrastructure/database/sqlite-diary-repository.ts +209 -0
- package/src/infrastructure/database/sqlite-events-repository.ts +167 -0
- package/src/infrastructure/database/sqlite-memories-repository.ts +284 -0
- package/src/infrastructure/database/sqlite-schema.ts +98 -0
- package/src/infrastructure/index.ts +3 -0
- package/src/infrastructure/llm/base.ts +14 -0
- package/src/infrastructure/llm/gemini.ts +139 -0
- package/src/infrastructure/llm/index.ts +22 -0
- package/src/infrastructure/llm/ollama.ts +126 -0
- package/src/infrastructure/llm/openai.ts +148 -0
- package/src/infrastructure/memory/checkpointer.ts +19 -0
- package/src/infrastructure/memory/index.ts +2 -0
- package/src/infrastructure/settings/index.ts +96 -0
- package/src/interface/cli/calendar.ts +120 -0
- package/src/interface/cli/chat.ts +185 -0
- package/src/interface/cli/commands.ts +337 -0
- package/src/interface/cli/printer.ts +65 -0
- package/src/interface/index.ts +1 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { BaseMessage } from "@langchain/core/messages";
|
|
2
|
+
import type { Database } from "bun:sqlite";
|
|
3
|
+
import type { ICheckpointer, CheckpointMetadata } from "../../domain/interfaces/IMemory";
|
|
4
|
+
import type { GraphStateType } from "../../domain/state/AgentState";
|
|
5
|
+
|
|
6
|
+
interface CheckpointRow {
|
|
7
|
+
session_id: string;
|
|
8
|
+
state: string;
|
|
9
|
+
created_at: string;
|
|
10
|
+
updated_at: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class SqliteCheckpointer implements ICheckpointer {
|
|
14
|
+
constructor(private readonly db: Database) {}
|
|
15
|
+
|
|
16
|
+
async save(sessionId: string, state: GraphStateType): Promise<void> {
|
|
17
|
+
const serializedState = JSON.stringify(this.serializeState(state));
|
|
18
|
+
const now = new Date().toISOString();
|
|
19
|
+
|
|
20
|
+
this.db
|
|
21
|
+
.prepare(
|
|
22
|
+
`
|
|
23
|
+
INSERT INTO checkpoints (session_id, state, created_at, updated_at)
|
|
24
|
+
VALUES (?, ?, ?, ?)
|
|
25
|
+
ON CONFLICT (session_id)
|
|
26
|
+
DO UPDATE SET
|
|
27
|
+
state = excluded.state,
|
|
28
|
+
updated_at = excluded.updated_at
|
|
29
|
+
`
|
|
30
|
+
)
|
|
31
|
+
.run(sessionId, serializedState, now, now);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async load(sessionId: string): Promise<GraphStateType | null> {
|
|
35
|
+
const row = this.db
|
|
36
|
+
.prepare(
|
|
37
|
+
"SELECT session_id, state, created_at, updated_at FROM checkpoints WHERE session_id = ?"
|
|
38
|
+
)
|
|
39
|
+
.get(sessionId) as CheckpointRow | undefined;
|
|
40
|
+
|
|
41
|
+
if (!row) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return this.deserializeState(JSON.parse(row.state));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async list(): Promise<string[]> {
|
|
49
|
+
const rows = this.db
|
|
50
|
+
.prepare("SELECT session_id FROM checkpoints ORDER BY updated_at DESC")
|
|
51
|
+
.all() as { session_id: string }[];
|
|
52
|
+
|
|
53
|
+
return rows.map((row) => row.session_id);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async delete(sessionId: string): Promise<boolean> {
|
|
57
|
+
const result = this.db.prepare("DELETE FROM checkpoints WHERE session_id = ?").run(sessionId);
|
|
58
|
+
|
|
59
|
+
return result.changes > 0;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async getMetadata(sessionId: string): Promise<CheckpointMetadata | null> {
|
|
63
|
+
const row = this.db
|
|
64
|
+
.prepare("SELECT created_at, updated_at FROM checkpoints WHERE session_id = ?")
|
|
65
|
+
.get(sessionId) as { created_at: string; updated_at: string } | undefined;
|
|
66
|
+
|
|
67
|
+
if (!row) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
createdAt: new Date(row.created_at),
|
|
73
|
+
updatedAt: new Date(row.updated_at),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async getSessionsForDate(date: Date): Promise<string[]> {
|
|
78
|
+
const startOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
79
|
+
const endOfDay = new Date(date.getFullYear(), date.getMonth(), date.getDate() + 1);
|
|
80
|
+
|
|
81
|
+
const rows = this.db
|
|
82
|
+
.prepare(
|
|
83
|
+
`
|
|
84
|
+
SELECT session_id
|
|
85
|
+
FROM checkpoints
|
|
86
|
+
WHERE updated_at >= ? AND updated_at < ?
|
|
87
|
+
ORDER BY updated_at DESC
|
|
88
|
+
`
|
|
89
|
+
)
|
|
90
|
+
.all(startOfDay.toISOString(), endOfDay.toISOString()) as {
|
|
91
|
+
session_id: string;
|
|
92
|
+
}[];
|
|
93
|
+
|
|
94
|
+
return rows.map((row) => row.session_id);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private serializeState(state: GraphStateType): Record<string, unknown> {
|
|
98
|
+
const messages = state.messages.map((msg: BaseMessage) => ({
|
|
99
|
+
type: msg._getType(),
|
|
100
|
+
content: msg.content,
|
|
101
|
+
additional_kwargs: msg.additional_kwargs,
|
|
102
|
+
response_metadata: msg.response_metadata,
|
|
103
|
+
id: msg.id,
|
|
104
|
+
name: msg.name,
|
|
105
|
+
}));
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
messages,
|
|
109
|
+
intent: state.intent,
|
|
110
|
+
pendingEvent: state.pendingEvent,
|
|
111
|
+
queryResult: state.queryResult,
|
|
112
|
+
relevantMemories: state.relevantMemories,
|
|
113
|
+
todayEvents: state.todayEvents,
|
|
114
|
+
response: state.response,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private deserializeState(data: unknown): GraphStateType {
|
|
119
|
+
const obj = data as Record<string, unknown>;
|
|
120
|
+
|
|
121
|
+
const {
|
|
122
|
+
HumanMessage,
|
|
123
|
+
AIMessage,
|
|
124
|
+
SystemMessage,
|
|
125
|
+
ToolMessage,
|
|
126
|
+
} = require("@langchain/core/messages");
|
|
127
|
+
|
|
128
|
+
const messageData = obj.messages as Array<{
|
|
129
|
+
type: string;
|
|
130
|
+
content: string;
|
|
131
|
+
additional_kwargs?: Record<string, unknown>;
|
|
132
|
+
id?: string;
|
|
133
|
+
name?: string;
|
|
134
|
+
}>;
|
|
135
|
+
|
|
136
|
+
const messages = messageData.map((msg) => {
|
|
137
|
+
switch (msg.type) {
|
|
138
|
+
case "human":
|
|
139
|
+
return new HumanMessage({
|
|
140
|
+
content: msg.content,
|
|
141
|
+
additional_kwargs: msg.additional_kwargs,
|
|
142
|
+
id: msg.id,
|
|
143
|
+
});
|
|
144
|
+
case "ai":
|
|
145
|
+
return new AIMessage({
|
|
146
|
+
content: msg.content,
|
|
147
|
+
additional_kwargs: msg.additional_kwargs,
|
|
148
|
+
id: msg.id,
|
|
149
|
+
});
|
|
150
|
+
case "system":
|
|
151
|
+
return new SystemMessage({
|
|
152
|
+
content: msg.content,
|
|
153
|
+
additional_kwargs: msg.additional_kwargs,
|
|
154
|
+
id: msg.id,
|
|
155
|
+
});
|
|
156
|
+
case "tool":
|
|
157
|
+
return new ToolMessage({
|
|
158
|
+
content: msg.content,
|
|
159
|
+
additional_kwargs: msg.additional_kwargs,
|
|
160
|
+
id: msg.id,
|
|
161
|
+
name: msg.name,
|
|
162
|
+
tool_call_id: (msg.additional_kwargs?.tool_call_id as string) ?? "",
|
|
163
|
+
});
|
|
164
|
+
default:
|
|
165
|
+
return new HumanMessage({ content: msg.content });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
messages,
|
|
171
|
+
intent: obj.intent as GraphStateType["intent"],
|
|
172
|
+
pendingEvent: obj.pendingEvent as GraphStateType["pendingEvent"],
|
|
173
|
+
queryResult: obj.queryResult as GraphStateType["queryResult"],
|
|
174
|
+
relevantMemories: obj.relevantMemories as GraphStateType["relevantMemories"],
|
|
175
|
+
todayEvents: obj.todayEvents as GraphStateType["todayEvents"],
|
|
176
|
+
response: obj.response as GraphStateType["response"],
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import * as sqliteVec from "sqlite-vec";
|
|
3
|
+
import { mkdirSync, existsSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { homedir } from "os";
|
|
6
|
+
|
|
7
|
+
export interface SqliteClientOptions {
|
|
8
|
+
dbPath?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DEFAULT_DB_DIR = join(homedir(), ".log");
|
|
12
|
+
const DEFAULT_DB_PATH = join(DEFAULT_DB_DIR, "data.db");
|
|
13
|
+
|
|
14
|
+
// macOS requires a custom SQLite library that supports extensions
|
|
15
|
+
// The default Apple SQLite disables extension loading
|
|
16
|
+
function setupMacOSSqlite(): void {
|
|
17
|
+
if (process.platform !== "darwin") return;
|
|
18
|
+
|
|
19
|
+
const possiblePaths = [
|
|
20
|
+
"/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib", // Apple Silicon
|
|
21
|
+
"/usr/local/opt/sqlite/lib/libsqlite3.dylib", // Intel Mac
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
for (const sqlitePath of possiblePaths) {
|
|
25
|
+
if (existsSync(sqlitePath)) {
|
|
26
|
+
Database.setCustomSQLite(sqlitePath);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
console.warn(
|
|
32
|
+
"Warning: Could not find Homebrew SQLite. Vector search may not work.\n" +
|
|
33
|
+
"Install with: brew install sqlite"
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Initialize macOS SQLite on module load
|
|
38
|
+
setupMacOSSqlite();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Creates a SQLite database client with sqlite-vec extension loaded.
|
|
42
|
+
* Default path: ~/.log/data.db
|
|
43
|
+
*/
|
|
44
|
+
export function createSqliteClient(options: SqliteClientOptions = {}): Database {
|
|
45
|
+
const dbPath = options.dbPath ?? DEFAULT_DB_PATH;
|
|
46
|
+
|
|
47
|
+
// Ensure directory exists
|
|
48
|
+
const dbDir = join(dbPath, "..");
|
|
49
|
+
if (!existsSync(dbDir)) {
|
|
50
|
+
mkdirSync(dbDir, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const db = new Database(dbPath);
|
|
54
|
+
|
|
55
|
+
// Load sqlite-vec extension
|
|
56
|
+
sqliteVec.load(db);
|
|
57
|
+
|
|
58
|
+
// Enable WAL mode for better concurrent access
|
|
59
|
+
db.exec("PRAGMA journal_mode = WAL");
|
|
60
|
+
|
|
61
|
+
return db;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Get the default database path.
|
|
66
|
+
*/
|
|
67
|
+
export function getDefaultDbPath(): string {
|
|
68
|
+
return DEFAULT_DB_PATH;
|
|
69
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { type DiaryEntry, DiaryEntrySchema } from "../../domain/entities/DiaryEntry";
|
|
4
|
+
import type { IDiaryRepository, MoodTrendEntry } from "../../domain/interfaces/IDiaryRepository";
|
|
5
|
+
|
|
6
|
+
const DiaryRowSchema = z.object({
|
|
7
|
+
id: z.string().uuid(),
|
|
8
|
+
entry_date: z.string(),
|
|
9
|
+
summary: z.string(),
|
|
10
|
+
mood: z.string(),
|
|
11
|
+
mood_score: z.number(),
|
|
12
|
+
therapeutic_advice: z.string(),
|
|
13
|
+
session_ids: z.string().nullable(),
|
|
14
|
+
message_count: z.number(),
|
|
15
|
+
created_at: z.string(),
|
|
16
|
+
updated_at: z.string(),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
type DiaryRow = z.infer<typeof DiaryRowSchema>;
|
|
20
|
+
|
|
21
|
+
function rowToEntry(row: DiaryRow): DiaryEntry {
|
|
22
|
+
return DiaryEntrySchema.parse({
|
|
23
|
+
id: row.id,
|
|
24
|
+
entryDate: new Date(row.entry_date),
|
|
25
|
+
summary: row.summary,
|
|
26
|
+
mood: row.mood,
|
|
27
|
+
moodScore: row.mood_score,
|
|
28
|
+
therapeuticAdvice: row.therapeutic_advice,
|
|
29
|
+
sessionIds: row.session_ids ? JSON.parse(row.session_ids) : [],
|
|
30
|
+
messageCount: row.message_count,
|
|
31
|
+
createdAt: new Date(row.created_at),
|
|
32
|
+
updatedAt: new Date(row.updated_at),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class SqliteDiaryRepository implements IDiaryRepository {
|
|
37
|
+
constructor(private readonly db: Database) {}
|
|
38
|
+
|
|
39
|
+
async create(entry: DiaryEntry): Promise<DiaryEntry> {
|
|
40
|
+
this.db
|
|
41
|
+
.prepare(
|
|
42
|
+
`
|
|
43
|
+
INSERT INTO diary_entries (
|
|
44
|
+
id, entry_date, summary, mood, mood_score,
|
|
45
|
+
therapeutic_advice, session_ids, message_count, created_at, updated_at
|
|
46
|
+
)
|
|
47
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
48
|
+
`
|
|
49
|
+
)
|
|
50
|
+
.run(
|
|
51
|
+
entry.id,
|
|
52
|
+
entry.entryDate.toISOString().split("T")[0],
|
|
53
|
+
entry.summary,
|
|
54
|
+
entry.mood,
|
|
55
|
+
entry.moodScore,
|
|
56
|
+
entry.therapeuticAdvice,
|
|
57
|
+
JSON.stringify(entry.sessionIds),
|
|
58
|
+
entry.messageCount,
|
|
59
|
+
entry.createdAt.toISOString(),
|
|
60
|
+
entry.updatedAt.toISOString()
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
return entry;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async getById(id: string): Promise<DiaryEntry | null> {
|
|
67
|
+
const row = this.db.prepare("SELECT * FROM diary_entries WHERE id = ?").get(id) as
|
|
68
|
+
| DiaryRow
|
|
69
|
+
| undefined;
|
|
70
|
+
|
|
71
|
+
if (!row) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return rowToEntry(DiaryRowSchema.parse(row));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async getByDate(date: Date): Promise<DiaryEntry | null> {
|
|
79
|
+
const dateStr = date.toISOString().split("T")[0];
|
|
80
|
+
|
|
81
|
+
const row = this.db.prepare("SELECT * FROM diary_entries WHERE entry_date = ?").get(dateStr) as
|
|
82
|
+
| DiaryRow
|
|
83
|
+
| undefined;
|
|
84
|
+
|
|
85
|
+
if (!row) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return rowToEntry(DiaryRowSchema.parse(row));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async getByDateRange(startDate: Date, endDate: Date): Promise<DiaryEntry[]> {
|
|
93
|
+
const startStr = startDate.toISOString().split("T")[0];
|
|
94
|
+
const endStr = endDate.toISOString().split("T")[0];
|
|
95
|
+
|
|
96
|
+
const rows = this.db
|
|
97
|
+
.prepare(
|
|
98
|
+
`
|
|
99
|
+
SELECT * FROM diary_entries
|
|
100
|
+
WHERE entry_date >= ? AND entry_date <= ?
|
|
101
|
+
ORDER BY entry_date DESC
|
|
102
|
+
`
|
|
103
|
+
)
|
|
104
|
+
.all(startStr, endStr) as DiaryRow[];
|
|
105
|
+
|
|
106
|
+
return rows.map((row) => rowToEntry(DiaryRowSchema.parse(row)));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async getRecent(limit = 7): Promise<DiaryEntry[]> {
|
|
110
|
+
const rows = this.db
|
|
111
|
+
.prepare(
|
|
112
|
+
`
|
|
113
|
+
SELECT * FROM diary_entries
|
|
114
|
+
ORDER BY entry_date DESC
|
|
115
|
+
LIMIT ?
|
|
116
|
+
`
|
|
117
|
+
)
|
|
118
|
+
.all(limit) as DiaryRow[];
|
|
119
|
+
|
|
120
|
+
return rows.map((row) => rowToEntry(DiaryRowSchema.parse(row)));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async update(
|
|
124
|
+
id: string,
|
|
125
|
+
updates: Partial<Omit<DiaryEntry, "id" | "createdAt">>
|
|
126
|
+
): Promise<DiaryEntry | null> {
|
|
127
|
+
const setters: string[] = [];
|
|
128
|
+
const values: (string | number | null)[] = [];
|
|
129
|
+
|
|
130
|
+
if (updates.entryDate !== undefined) {
|
|
131
|
+
setters.push("entry_date = ?");
|
|
132
|
+
values.push(updates.entryDate.toISOString().split("T")[0]);
|
|
133
|
+
}
|
|
134
|
+
if (updates.summary !== undefined) {
|
|
135
|
+
setters.push("summary = ?");
|
|
136
|
+
values.push(updates.summary);
|
|
137
|
+
}
|
|
138
|
+
if (updates.mood !== undefined) {
|
|
139
|
+
setters.push("mood = ?");
|
|
140
|
+
values.push(updates.mood);
|
|
141
|
+
}
|
|
142
|
+
if (updates.moodScore !== undefined) {
|
|
143
|
+
setters.push("mood_score = ?");
|
|
144
|
+
values.push(updates.moodScore);
|
|
145
|
+
}
|
|
146
|
+
if (updates.therapeuticAdvice !== undefined) {
|
|
147
|
+
setters.push("therapeutic_advice = ?");
|
|
148
|
+
values.push(updates.therapeuticAdvice);
|
|
149
|
+
}
|
|
150
|
+
if (updates.sessionIds !== undefined) {
|
|
151
|
+
setters.push("session_ids = ?");
|
|
152
|
+
values.push(JSON.stringify(updates.sessionIds));
|
|
153
|
+
}
|
|
154
|
+
if (updates.messageCount !== undefined) {
|
|
155
|
+
setters.push("message_count = ?");
|
|
156
|
+
values.push(updates.messageCount);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Always update updated_at
|
|
160
|
+
setters.push("updated_at = ?");
|
|
161
|
+
values.push(new Date().toISOString());
|
|
162
|
+
|
|
163
|
+
if (setters.length === 1) {
|
|
164
|
+
// Only updated_at was added
|
|
165
|
+
return this.getById(id);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
values.push(id);
|
|
169
|
+
|
|
170
|
+
const result = this.db
|
|
171
|
+
.prepare(`UPDATE diary_entries SET ${setters.join(", ")} WHERE id = ?`)
|
|
172
|
+
.run(...values);
|
|
173
|
+
|
|
174
|
+
if (result.changes === 0) {
|
|
175
|
+
return null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return this.getById(id);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async delete(id: string): Promise<boolean> {
|
|
182
|
+
const result = this.db.prepare("DELETE FROM diary_entries WHERE id = ?").run(id);
|
|
183
|
+
|
|
184
|
+
return result.changes > 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async getMoodTrend(days = 30): Promise<MoodTrendEntry[]> {
|
|
188
|
+
const startDate = new Date();
|
|
189
|
+
startDate.setDate(startDate.getDate() - days);
|
|
190
|
+
const startStr = startDate.toISOString().split("T")[0];
|
|
191
|
+
|
|
192
|
+
const rows = this.db
|
|
193
|
+
.prepare(
|
|
194
|
+
`
|
|
195
|
+
SELECT entry_date, mood, mood_score
|
|
196
|
+
FROM diary_entries
|
|
197
|
+
WHERE entry_date >= ?
|
|
198
|
+
ORDER BY entry_date ASC
|
|
199
|
+
`
|
|
200
|
+
)
|
|
201
|
+
.all(startStr) as { entry_date: string; mood: string; mood_score: number }[];
|
|
202
|
+
|
|
203
|
+
return rows.map((row) => ({
|
|
204
|
+
date: new Date(row.entry_date),
|
|
205
|
+
mood: row.mood,
|
|
206
|
+
moodScore: row.mood_score,
|
|
207
|
+
}));
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { type Event, EventSchema } from "../../domain/entities/Event";
|
|
4
|
+
import type { IEventsRepository } from "../../domain/interfaces/IEventsRepository";
|
|
5
|
+
|
|
6
|
+
const EventRowSchema = z.object({
|
|
7
|
+
id: z.string().uuid(),
|
|
8
|
+
title: z.string(),
|
|
9
|
+
datetime: z.string(),
|
|
10
|
+
end_time: z.string().nullable(),
|
|
11
|
+
notes: z.string().nullable(),
|
|
12
|
+
tags: z.string().nullable(),
|
|
13
|
+
created_at: z.string(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
type EventRow = z.infer<typeof EventRowSchema>;
|
|
17
|
+
|
|
18
|
+
function rowToEvent(row: EventRow): Event {
|
|
19
|
+
return EventSchema.parse({
|
|
20
|
+
id: row.id,
|
|
21
|
+
title: row.title,
|
|
22
|
+
datetime: new Date(row.datetime),
|
|
23
|
+
endTime: row.end_time ? new Date(row.end_time) : undefined,
|
|
24
|
+
notes: row.notes ?? undefined,
|
|
25
|
+
tags: row.tags ? JSON.parse(row.tags) : [],
|
|
26
|
+
createdAt: new Date(row.created_at),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class SqliteEventsRepository implements IEventsRepository {
|
|
31
|
+
constructor(private readonly db: Database) {}
|
|
32
|
+
|
|
33
|
+
async create(event: Event): Promise<Event> {
|
|
34
|
+
const stmt = this.db.prepare(`
|
|
35
|
+
INSERT INTO events (id, title, datetime, end_time, notes, tags, created_at)
|
|
36
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
37
|
+
`);
|
|
38
|
+
|
|
39
|
+
stmt.run(
|
|
40
|
+
event.id,
|
|
41
|
+
event.title,
|
|
42
|
+
event.datetime.toISOString(),
|
|
43
|
+
event.endTime?.toISOString() ?? null,
|
|
44
|
+
event.notes ?? null,
|
|
45
|
+
JSON.stringify(event.tags),
|
|
46
|
+
event.createdAt.toISOString()
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
return event;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async getById(id: string): Promise<Event | null> {
|
|
53
|
+
const row = this.db.prepare("SELECT * FROM events WHERE id = ?").get(id) as
|
|
54
|
+
| EventRow
|
|
55
|
+
| undefined;
|
|
56
|
+
|
|
57
|
+
if (!row) {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return rowToEvent(EventRowSchema.parse(row));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async getByDateRange(startDate: Date, endDate: Date): Promise<Event[]> {
|
|
65
|
+
const rows = this.db
|
|
66
|
+
.prepare(
|
|
67
|
+
`
|
|
68
|
+
SELECT * FROM events
|
|
69
|
+
WHERE datetime >= ? AND datetime <= ?
|
|
70
|
+
ORDER BY datetime ASC
|
|
71
|
+
`
|
|
72
|
+
)
|
|
73
|
+
.all(startDate.toISOString(), endDate.toISOString()) as EventRow[];
|
|
74
|
+
|
|
75
|
+
return rows.map((row) => rowToEvent(EventRowSchema.parse(row)));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async getToday(): Promise<Event[]> {
|
|
79
|
+
const today = new Date();
|
|
80
|
+
const startOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate());
|
|
81
|
+
const endOfDay = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
|
|
82
|
+
|
|
83
|
+
return this.getByDateRange(startOfDay, endOfDay);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async getUpcoming(limit = 10): Promise<Event[]> {
|
|
87
|
+
const now = new Date();
|
|
88
|
+
|
|
89
|
+
const rows = this.db
|
|
90
|
+
.prepare(
|
|
91
|
+
`
|
|
92
|
+
SELECT * FROM events
|
|
93
|
+
WHERE datetime >= ?
|
|
94
|
+
ORDER BY datetime ASC
|
|
95
|
+
LIMIT ?
|
|
96
|
+
`
|
|
97
|
+
)
|
|
98
|
+
.all(now.toISOString(), limit) as EventRow[];
|
|
99
|
+
|
|
100
|
+
return rows.map((row) => rowToEvent(EventRowSchema.parse(row)));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async update(
|
|
104
|
+
id: string,
|
|
105
|
+
updates: Partial<Omit<Event, "id" | "createdAt">>
|
|
106
|
+
): Promise<Event | null> {
|
|
107
|
+
const setters: string[] = [];
|
|
108
|
+
const values: (string | null)[] = [];
|
|
109
|
+
|
|
110
|
+
if (updates.title !== undefined) {
|
|
111
|
+
setters.push("title = ?");
|
|
112
|
+
values.push(updates.title);
|
|
113
|
+
}
|
|
114
|
+
if (updates.datetime !== undefined) {
|
|
115
|
+
setters.push("datetime = ?");
|
|
116
|
+
values.push(updates.datetime.toISOString());
|
|
117
|
+
}
|
|
118
|
+
if (updates.endTime !== undefined) {
|
|
119
|
+
setters.push("end_time = ?");
|
|
120
|
+
values.push(updates.endTime?.toISOString() ?? null);
|
|
121
|
+
}
|
|
122
|
+
if (updates.notes !== undefined) {
|
|
123
|
+
setters.push("notes = ?");
|
|
124
|
+
values.push(updates.notes ?? null);
|
|
125
|
+
}
|
|
126
|
+
if (updates.tags !== undefined) {
|
|
127
|
+
setters.push("tags = ?");
|
|
128
|
+
values.push(JSON.stringify(updates.tags));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (setters.length === 0) {
|
|
132
|
+
return this.getById(id);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
values.push(id);
|
|
136
|
+
|
|
137
|
+
const result = this.db
|
|
138
|
+
.prepare(`UPDATE events SET ${setters.join(", ")} WHERE id = ?`)
|
|
139
|
+
.run(...values);
|
|
140
|
+
|
|
141
|
+
if (result.changes === 0) {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return this.getById(id);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async delete(id: string): Promise<boolean> {
|
|
149
|
+
const result = this.db.prepare("DELETE FROM events WHERE id = ?").run(id);
|
|
150
|
+
|
|
151
|
+
return result.changes > 0;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async searchByTitle(query: string): Promise<Event[]> {
|
|
155
|
+
const rows = this.db
|
|
156
|
+
.prepare(
|
|
157
|
+
`
|
|
158
|
+
SELECT * FROM events
|
|
159
|
+
WHERE title LIKE ?
|
|
160
|
+
ORDER BY datetime ASC
|
|
161
|
+
`
|
|
162
|
+
)
|
|
163
|
+
.all(`%${query}%`) as EventRow[];
|
|
164
|
+
|
|
165
|
+
return rows.map((row) => rowToEvent(EventRowSchema.parse(row)));
|
|
166
|
+
}
|
|
167
|
+
}
|