From 333a7da34e6184dbba593d5667f332e4738fdd8e Mon Sep 17 00:00:00 2001 From: wchwawa Date: Wed, 5 Aug 2026 11:30:49 +1000 Subject: [PATCH] feat: backend-agnostic data layer with a NoKV journal backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates EchoJournal's entire data access behind JournalDataRepository (design: docs/nokv-data-migration-design.md), with two implementations: - SupabaseJournalRepository: behavior-preserving consolidation of the previous inline queries (default backend, rollback path) - NokvJournalRepository: one NoKV workbench per user (jr-{userId}) — per-entry create-only documents, day documents with block-level optimistic edits, monthly append-only ledger folded into the read model, keyword sidecar via workbench_grep, create-only edit-protection latches, audio blobs over the CLI collect/materialize channel Also: - All 12 DB routes + reflections/agent libs consume the repository - Browser anon Supabase reads eliminated: hooks/modal now use new /api/mood/today, /api/journals/today, /api/journals/stats routes; supabase queries.ts + client.ts deleted - Timezone unification: every day boundary goes through timezone.ts - DATA_BACKEND=supabase|nokv factory; in nokv mode reads fall back to Supabase on transport failure, writes surface 503 - 21 new tests: serialization discipline, ledger fold, and a contract suite against an in-memory workbench honoring NoKV semantics Co-Authored-By: Claude Fable 5 --- .env.example | 10 + docs/nokv-data-migration-design.md | 426 +++++ src/app/api/agent/tools/context/route.ts | 6 +- src/app/api/audio/[id]/route.ts | 41 +- src/app/api/generate-daily-summary/route.ts | 106 +- src/app/api/journals/[id]/route.ts | 119 +- src/app/api/journals/list/route.ts | 45 +- src/app/api/journals/stats/route.ts | 30 + src/app/api/journals/today/route.ts | 61 + src/app/api/mood/today/route.ts | 82 + src/app/api/reflections/daily/[date]/route.ts | 77 +- src/app/api/reflections/daily/route.ts | 34 +- src/app/api/reflections/monthly/route.ts | 28 +- src/app/api/reflections/period/[id]/route.ts | 64 +- src/app/api/reflections/sync/route.ts | 12 +- src/app/api/reflections/weekly/route.ts | 28 +- src/app/api/transcribe/route.ts | 200 +-- .../components/daily-mood-modal.tsx | 107 +- src/hooks/use-audio-journal.ts | 117 +- src/hooks/use-today-mood.ts | 22 +- src/lib/agent/context.ts | 142 +- src/lib/data/index.ts | 83 + src/lib/data/repository.ts | 218 +++ src/lib/data/supabase-repository.ts | 670 ++++++++ src/lib/nokv/blob-cli.ts | 111 ++ src/lib/nokv/journal-doc.ts | 86 + src/lib/nokv/journal-repository.ts | 1468 +++++++++++++++++ src/lib/nokv/ledger.ts | 108 ++ src/lib/nokv/mcp-client.ts | 29 +- src/lib/reflections/aggregate.ts | 94 +- src/lib/reflections/generator.ts | 111 +- src/lib/reflections/sync.ts | 26 +- src/lib/supabase/client.ts | 14 - src/lib/supabase/queries.ts | 392 ----- .../moduleB/generate-reflection.test.ts | 105 +- tests/mocks/fake-workbench.ts | 152 ++ .../moduleB/reflections-aggregate.test.ts | 2 +- tests/unit/nokv/journal-doc.test.ts | 69 + tests/unit/nokv/journal-repository.test.ts | 351 ++++ tests/unit/nokv/ledger.test.ts | 90 + 40 files changed, 4643 insertions(+), 1293 deletions(-) create mode 100644 docs/nokv-data-migration-design.md create mode 100644 src/app/api/journals/stats/route.ts create mode 100644 src/app/api/journals/today/route.ts create mode 100644 src/app/api/mood/today/route.ts create mode 100644 src/lib/data/index.ts create mode 100644 src/lib/data/repository.ts create mode 100644 src/lib/data/supabase-repository.ts create mode 100644 src/lib/nokv/blob-cli.ts create mode 100644 src/lib/nokv/journal-doc.ts create mode 100644 src/lib/nokv/journal-repository.ts create mode 100644 src/lib/nokv/ledger.ts delete mode 100644 src/lib/supabase/client.ts delete mode 100644 src/lib/supabase/queries.ts create mode 100644 tests/mocks/fake-workbench.ts create mode 100644 tests/unit/nokv/journal-doc.test.ts create mode 100644 tests/unit/nokv/journal-repository.test.ts create mode 100644 tests/unit/nokv/ledger.test.ts diff --git a/.env.example b/.env.example index 4c91030..e76cfd8 100644 --- a/.env.example +++ b/.env.example @@ -85,3 +85,13 @@ NOKV_OBJECT_ACCESS_KEY_ID=rustfsadmin NOKV_OBJECT_SECRET_ACCESS_KEY=rustfsadmin NOKV_WORKBENCH_ROOT=/agents/echojournal/wb NOKV_SESSION_SECRET= # openssl rand -hex 32; signs per-user session refs + +# ================================================================= +# Data backend selection +# ================================================================= +# supabase (default) — Postgres + Storage, current production path +# nokv — journal data lives in per-user NoKV workbenches (jr-{userId}); +# reads fall back to Supabase when NoKV is unreachable, writes 503. +# Requires the full NOKV_* block above. Design: +# docs/nokv-data-migration-design.md +DATA_BACKEND=supabase diff --git a/docs/nokv-data-migration-design.md b/docs/nokv-data-migration-design.md new file mode 100644 index 0000000..0b51431 --- /dev/null +++ b/docs/nokv-data-migration-design.md @@ -0,0 +1,426 @@ +# EchoJournal 数据层 → NoKV 迁移最终设计 + +> 状态:FINAL(synthesis of RECON A/B/C + 3 proposals + 2 judges) +> 日期:2026-08-05 · 目标提交路径:`docs/nokv-data-migration-design.md` +> 前置阅读:`docs/nokv-integration-design.md`(voice-session 集成,本设计复用其 MCP 客户端层) + +**决议**:采纳 P1(journal-workbench,双评审一致第一)为骨架 —— 每用户单一长驻 workbench、**每条目独立文档**(结构性消灭并发丢条目)、日文档三块结构、create-only 闩保护用户编辑。嫁接三处最优落选项:P2 的**月分片账本**(替换 P1 单索引文件)与 **summary.txt grep sidecar**(keyword 检索与 ilike 语义精确对齐)、P2 的契约测试先行 + 切换闸门 + 夜间导出 cron、P3 的**确定性 event_id 幂等重试**(化解超时重试双写陷阱)。同时落实评审两处必改:**search/aggregate 的 path 谓词值必须带 section 前缀**(查询行 path = `outputs/days/...` 全路径,`query_records.rs:497` 实证);**无过滤 totalCount 改由账本折叠给出**(aggregate 数 `days/` 会把 mood-only 日文档计入,与 Supabase 只数 `daily_summaries` 行不平)。预估 80 小时。 + +--- + +## 0. 设计所依赖的已验证 NoKV 事实(main @ 90883d1353;实现时不必重读 NoKV 文档) + +以下每条均经源码核实,是本设计的公理。任何一条被上游改变都需重审对应小节。 + +| # | 事实 | 出处 | +|---|---|---| +| F1 | 契约 18 工具,**无任何 delete/rename/move/upsert**;section 是冻结枚举 `input/scripts/outputs/logs/metadata` | `workbench_contract_schema.json` | +| F2 | `put_file`:create-only(`replace:false`,冲突报 PathExists)XOR replace-only;**replace 无调用方 CAS**(adapter 内部 stat→ReplaceOnly,纯 LWW);无 `index_fields` 参数 | `facade.rs:826-843` | +| F3 | `append`:generation CAS + adapter 重试(`--max-attempts` 默认 3),对不存在的 path 自动创建;**MCP 面唯一无丢失更新的写原语** | `artifact.rs:481-514,2390` | +| F4 | `edit`:`old_string` 精确串匹配 = 调用方乐观锁,冲突 adapter 内置 5 次重读重试;多处命中报 AmbiguousEdit;`required:[id,section,path,old_string,new_string]` | `facade.rs:889-977` | +| F5 | `read`:`format:'structured'`(JSON object 体 = **1 条 json_object record**,一次调用整取)或 `'bytes'`(每次 ≤300 字节,媒体不可用);structured 分页用 **cursor,不接受 offset**(offset 仅 bytes);`if_none_match:` 得 304;读前整体校验 body ≤ max_artifact_bytes(默认 16MiB),**超限文件"只写不可读"** | `facade.rs:1063-1088,2061` | +| F6 | `grep`:`required:[id,pattern,recursive]`,大小写不敏感字面量(非 regex,≤16 patterns),`glob` 只匹配 basename 且**在读 body 之前过滤**,limit≤300,cursor 分页,仅 UTF-8 | `facade.rs:1091-1207` | +| F7 | `search` limit≤10、`aggregate` limit≤100(**measures 每项 required `name`+`op`**)、`list` limit≤100(非递归、仅直接子项)、`read`/`grep` limit≤300;search/aggregate/grep 均有顶层 `section`+`path`(section 内前缀)作用域参数;**谓词 `field:'path'` 的值 = 含 section 的全路径**(如 `outputs/days/2026-08-05.json`);可查询字段仅 8 个内置(path/logical_size/content_type/generation/body_digest_uri/manifest_id/producer/workbench_id);main 上查询是全扫描(SecondaryIndex 只写不读) | schema + `query_records.rs:37-46,497`、`query.rs:993-1048` | +| F8 | 替换/墓碑后**旧 revision 不保留**:强引用归零→GcCandidate→`nokv serve` 的 LifecycleRunner 物理删 S3 字节;无版本历史读取 API;**commit 永久钉住 revision 且 commit 行不 GC** | `publication.rs:4767-4807`、contract.md:126-127,186 | +| F9 | 空载荷合法(`text:""`)⇒ 0 字节墓碑经 MCP 真实可行 | parse_payload | +| F10 | 核心已实现 generation-fenced 单路径原子删除 `RemovePathRequest`,**未暴露到 MCP/CLI 面** | `remove.rs:41-57`、`sdk.rs:339-345` | +| F11 | CLI 一次性通道:`nokv materialize
`(无 300B 分页);`nokv --workbench-root [--max-artifact-bytes N] collect
[--replace] [--content-type ]`(进程内读本地文件直发 put_file,不走 stdio 大行)。collect/mcp 要求 `--workbench-root`;conn flags = mcp-client.ts `mcpArgv()` 现有那套(`--metadata-address/--root-id/--object-*/etcd 路由`) | `main.rs:142-191`、`cli.rs:95-108,365,437-485` | +| F12 | stdio MCP server 逐行**串行**处理(大调用队头阻塞);现 `CALL_TIMEOUT_MS=5000`;**客户端超时 ≠ 服务端失败**(写入仍会完成,重试撞 AlreadyExists) | `workbench_mcp.rs:50`、`mcp-client.ts:31` | +| F13 | Holt 的 ChangeEvent/History **当前格式永不截断**(含路径/尺寸/digest 投影);workbench 不可删(Visible→Retired 非法)、id 永不复用 | metadata-schema.md:408-411,506-512 | +| F14 | append/edit/put 的 CAS 仲裁在 shard owner 元数据命令层,**跨进程安全**(两个 Next dev worker 各自 spawn mcp 子进程也正确) | metadata-schema.md:514-553 | + +--- + +## 1. 架构总览 + +``` + ┌─ 常驻 `nokv mcp` stdio 子进程(现 mcp-client.ts,复用) + │ 全部文档/账本/sidecar/墓碑操作;per-call 超时参数化 +Next.js API routes ─┤ + (全部服务端; └─ 一次性 CLI 子进程(新 blob-cli.ts) + 浏览器 anon 音频 blob 专用:collect / materialize,60s 超时,信号量 2 + client 全部收编) + │ + JournalDataRepository(接口) + ├─ SupabaseRepository(现状封装,回退保底) + └─ NokvRepository ── 读模型 = 账本折叠(fold-first)+ 文档点读 + 写模型 = 三原语(create-only / append / 块级 edit) +``` + +四条设计公理: + +1. **每用户一个长驻 workbench** `jr-{clerkUserId}`(Clerk id 满足 `^[A-Za-z0-9_-]+$` 且 <128B,构造时校验,失败即拒绝该用户的 NoKV 路径)。wbId 由服务端 Clerk `auth()` 派生,**不需要** voice-session 的 HMAC ref(那是给会出服务端边界的 ref 用的);错误分类助手(isPathExists/isNotFound/isCasConflict)照抄 `session-workspace.ts`。 +2. **jr-\* workbench 永不 commit、永不 snapshot**(F8:否则墓碑-GC 删除失效)。与 voice-session 工作台用法**刻意相反**,repo 层加工具 allowlist 硬禁,防复制粘贴。 +3. **一切业务维度编码进 path**(F7:MCP 面无自定义索引字段):日期进路径、entryId 自携带日期。`days/YYYY-MM-DD.json` 的字符串序 = 日期序。 +4. **"一天"的定义** = `APP_TIMEZONE`(默认 Australia/Sydney),一律经 `src/lib/timezone.ts` 的 `getLocalDayRange/getUtcRangeForDate`;迁移前先修 `aggregate.ts:76-77/118-119` 的裸日期界(Phase 2),保证两后端共享同一天定义。 + +--- + +## 2. Workbench 文件布局与 JSON Schema + +``` +jr-{clerkUserId}/ +├─ metadata/ +│ ├─ profile.json # create-only 初始化标记 +│ ├─ legacy_ids.json # Supabase uuid → 定位键;回填一次性写入后冻结;进程内按 generation 缓存 +│ ├─ deleted.json # deleteAllUserData 标记(存在 ⇒ repo 全读路径返回空) +│ └─ latches/ +│ ├─ day-{YYYY-MM-DD}.edited # create-only 编辑保护闩(0 字节即可) +│ └─ period-{type}-{YYYY-MM-DD}.edited +├─ input/ +│ └─ audio/{YYYY-MM-DD}/{entryId}.webm # 音频 blob;create-only;删除=0 字节墓碑;只经 blob-cli 读写 +├─ outputs/ +│ ├─ days/{YYYY-MM-DD}.json # 日文档(mood ⊕ summary ⊕ reflection 三块) +│ ├─ entries/{YYYY-MM-DD}/{entryId}.json # 条目文档(audio_files ⊕ transcripts 折叠,FK 消灭)★每条目一文件 +│ ├─ text/{YYYY-MM-DD}/summary.txt # keyword 检索 sidecar(替换=replace:true,派生物) +│ └─ reflections/{weekly|monthly}/{periodStart}.json +└─ logs/ + └─ ledger/{YYYY-MM}.jsonl # append-only 月账本(按事件 date 的月份分片;派生读模型,可全量重建) +``` + +**entryId** = `e{YYYYMMDD}T{HHMMSS}-{8hex}`,其中 `YYYYMMDD` = APP_TIMEZONE 本地日(即桶日)。任何 by-id 路由 substring 取日期直达文档与 blob,**零全局索引**。legacy Supabase uuid 走 `legacy_ids.json`。 + +### 2.1 条目文档 `echojournal.entry.v1` + +```jsonc +// outputs/entries/{date}/{entryId}.json — create-only 创建;此后仅 transcript 块可 edit +{ + "schema": "echojournal.entry.v1", + "id": "e20260805T143210-8f3a2b1c", + "legacy_audio_id": null, // 回填条目 = Supabase audio_files.id + "user_id": "user_...", + "date": "2026-08-05", + "created_at": "2026-08-05T04:32:10.123Z", + "audio": { + "path": "audio/2026-08-05/e20260805T143210-8f3a2b1c.webm", // input section 内相对路径 + "mime_type": "audio/webm", "size_bytes": 812345, + "duration_ms": null, "deleted": false + }, + "transcript": { + "text": "...", "rephrased_text": "...", "language": "en", + "updated_at": "2026-08-05T04:32:10.123Z" + } +} +``` + +### 2.2 日文档 `echojournal.day.v1` + +顶层必须是 JSON object(F5:structured 读 = 单 record 一次整取)。**三个顶层块永远存在**(骨架即含 `"mood": null` 等占位),使一切后续写都是 `workbench_edit` 块替换,无 create/edit 分叉竞态。app 层守卫:序列化后 >4MiB 拒写(远低于 16MiB 读悬崖)。 + +```jsonc +// outputs/days/{date}.json +{ + "schema": "echojournal.day.v1", + "user_id": "user_...", "date": "2026-08-05", "tz": "Australia/Sydney", + "mood": { // ← daily_question;或 null + "day_quality": "good", "emotions": ["calm","focused"], + "created_at": "ISO", "updated_at": "ISO", + "legacy_id": null + }, + "summary": { // ← daily_summaries 的 summary 列组;或 null + "text": "...", "entry_count": 3, "mood_quality": "good", + "dominant_emotions": ["calm"], "generated_at": "ISO", "updated_at": "ISO" + }, + "reflection": { // ← daily_summaries 的反思列组;或 null + "achievements": [], "commitments": [], "mood_overall": null, "mood_reason": null, + "flashback": null, "stats": {}, "gen_version": 0, "last_generated_at": null, + "edited": false, "legacy_id": null // legacy_id = Supabase daily_summaries.id + } +} +``` + +### 2.3 周期反思文档 `echojournal.period_reflection.v1` + +```jsonc +// outputs/reflections/{type}/{periodStart}.json +{ + "schema": "echojournal.period_reflection.v1", + "id": "weekly:2026-08-03", // 派生键;serialize.ts 的 recordId 承接 + "legacy_id": null, + "user_id": "...", "period_type": "weekly", + "period_start": "2026-08-03", "period_end": "2026-08-09", + "achievements": [], "commitments": [], "mood_overall": null, "mood_reason": null, + "flashback": null, "stats": {}, "edited": false, "gen_version": 1, + "last_generated_at": "ISO", "updated_at": "ISO" +} +``` + +唯一约束对照:`daily_question` 一人一天一条、`daily_summaries (user_id,date)`、`period_reflections (user_id,type,period_start)` —— 全部由 **path 唯一性免费获得**,且比现状(mood 唯一性仅靠应用逻辑)更强。 + +### 2.4 月账本 `echojournal.ledger.v1`(JSONL,每行一事件) + +content_type `text/plain` ⇒ structured 读返回 text_lines、每行一 record、**cursor** 分页 ≤300/页(F5)。分片键 = 事件 `date` 的月份(旧日期的补写落旧分片,靠 generation 缓存失效感知)。重度用户 ~600KB/月,距 16MiB 两个数量级;防呆:append 前若缓存态显示分片 >4MiB → 轮转 `{YYYY-MM}-b.jsonl`。 + +```jsonc +{"v":1,"event_id":"evt_01J...","ts":"ISO-UTC","kind":"entry_created","date":"2026-08-05","entry_id":"e2026...-8f3a2b1c"} +{"v":1,"event_id":"...","ts":"...","kind":"entry_deleted","date":"2026-08-05","entry_id":"..."} +{"v":1,"event_id":"...","ts":"...","kind":"mood_set","date":"2026-08-05","day_quality":"good","emotions":["calm"]} +{"v":1,"event_id":"...","ts":"...","kind":"summary_written","date":"2026-08-05","mood_quality":"good","entry_count":3} +``` + +**event_id 纪律(P3 嫁接,化解 F12 超时陷阱)**:每个 repo 写操作调用开始时生成一次 ULID,**同一调用内的所有重试复用同一 event_id**;回填/对账用确定性派生 id(`evt_sb_{table}_{uuid}` / `evt_rc_{kind}_{key}`)。折叠时按 event_id 去重 ⇒ 超时后盲重 append 无害。 + +**折叠规则(纯函数 `fold.ts`)**: +- `liveEntries(date)` = entry_created − entry_deleted(event_id 去重后) +- `mood(date)` = 最后一条 mood_set +- `summary(date)` = 最后一条 summary_written(presence + mood_quality + entry_count) + +**折叠态消费方**:queryJournalDays 的日期基集/**精确 totalCount**/mood 过滤(mood_quality 取自 summary_written = 与 Supabase `.in('mood_quality')` 扫 `daily_summaries` 的语义**精确对齐**,评审必改项 b)、listDailySummaries{,InRange} 的日期枚举、listMoodsInRange、getEntryStats(total/thisWeek/streak)。折叠态进程内缓存:`Map`,读带 `if_none_match`,304 即复用;旧月分片实践上不变,缓存命中率极高。 + +账本是**派生物**:可由 `list(outputs, 'days/')+list(outputs,'entries/{d}/')+read` 全量重建(`scripts/nokv-reconcile.ts`),丢事件只影响过滤视图与统计,不影响点读。 + +### 2.5 keyword sidecar + +`outputs/text/{date}/summary.txt` = 当日 summary 纯文本,每次 summary 写入后 `put_file replace:true` 同步(派生物,LWW 无害)。keyword 检索 = `grep glob:'summary.txt'`,与现状 **ilike 只扫 summary 列**的语义精确对齐(P1 原方案 grep 日文档 JSON 会误命中键名/emotions,评审弃)。transcript 级全文检索留作后续增强(现状 `searchJournals` 是死代码,不做即 parity)。 + +--- + +## 3. 序列化纪律(块级并发模型的地基) + +`src/lib/nokv/journal-doc.ts` 提供唯一的确定性序列化器:固定键序、2 空格缩进、UTF-8、尾无空白。导出 `serializeDoc(doc)`、`extractBlock(text, key)`(返回从 `\n "{key}":` 起至配对闭合的完整字节段,含前导缩进)、`replaceBlock(...)`。 + +**唯一性论证**(AmbiguousEdit 免疫):`old_string` 恒以 `\n "mood":` 这类"换行 + 2 空格 + 顶层键名"开头;JSON 字符串值内的换行必被转义为 `\n` 两字符,真实换行字节不可能出现在字符串值中,故该模式只能出现在顶层,全文唯一。单测锁死"序列化→提取→edit→再解析"的字节往返稳定性。 + +--- + +## 4. TypeScript 仓储接口(exact signatures) + +```ts +// src/lib/data/repository.ts —— 领域类型脱离 @/types/supabase; +// DailySummary/PeriodReflection 字段名保持 Tables<'daily_summaries'>/Tables<'period_reflections'> 形状, +// serialize.ts、mood-utils.ts 与全部 UI 组件零改动。 + +export type YmdString = string; // "YYYY-MM-DD" in APP_TIMEZONE + +export interface MoodEntry { + id: string; date: YmdString; day_quality: string; emotions: string[]; + created_at: string; updated_at: string; +} +export interface JournalEntry { + id: string; legacy_audio_id: string | null; date: YmdString; created_at: string; + audio: { mime_type: string; size_bytes: number; duration_ms: number | null; deleted: boolean }; + transcript: { text: string; rephrased_text: string | null; language: string; updated_at: string }; +} +export interface DailySummary { /* Tables<'daily_summaries'> 形状;id = legacy uuid 或 `daily:{date}` */ } +export interface PeriodReflection { /* Tables<'period_reflections'> 形状;id = legacy uuid 或 `{type}:{period_start}` */ } +export interface DayBundle { date: YmdString; summary: DailySummary | null; entries: JournalEntry[]; mood: MoodEntry | null; } +export type PeriodType = 'weekly' | 'monthly'; + +export class NokvUnavailableError extends Error {} // transport null → 抛出;工厂层据此回落/503 + +export interface JournalDataRepository { + // ── mood(覆盖 recon 操作 34/35/36/37/12/20/22/32/33) + getMood(userId: string, date: YmdString): Promise; + upsertMood(userId: string, date: YmdString, + input: { dayQuality: string; emotions: string[] }): Promise; + listMoodsInRange(userId: string, start: YmdString, end: YmdString): Promise; + + // ── 条目(覆盖 1-10/11/15/38/40;39 listRecentEntries 死代码,删除不进接口) + createEntry(userId: string, input: { + audio: Buffer; mimeType: string; transcript: string; + rephrasedText: string | null; language: string; + }): Promise; + getEntry(userId: string, entryId: string): Promise; + getAudioBlob(userId: string, entryId: string): Promise<{ data: Buffer; mimeType: string } | null>; + updateRephrasedText(userId: string, entryId: string, text: string): Promise; + deleteEntry(userId: string, entryId: string): Promise; + listEntriesForDay(userId: string, date: YmdString): Promise; + getEntryStats(userId: string): Promise<{ totalEntries: number; thisWeekEntries: number; currentStreak: number }>; + + // ── 日总结(覆盖 13/17/18/19/21/23/30/31/32/33) + getDailySummary(userId: string, date: YmdString): Promise; + upsertDailySummary(userId: string, date: YmdString, fields: { + summary: string; entryCount: number; moodQuality: string | null; dominantEmotions: string[]; + }): Promise; + updateDailySummaryReflection(userId: string, date: YmdString, + patch: Partial>, + opts: { markEdited?: boolean; preserveIfEdited?: boolean }): Promise; + listDailySummaries(userId: string, opts: { before?: YmdString; limit: number }): Promise; + listDailySummariesInRange(userId: string, start: YmdString, end: YmdString): Promise; + queryJournalDays(userId: string, filters: { + startDate?: YmdString; endDate?: YmdString; moods?: string[]; keyword?: string; + page: number; limit: number; // limit ≤ 50(route 钳制不变) + }): Promise<{ days: DayBundle[]; totalCount: number }>; // totalCount 三条过滤路径下均精确 + + // ── 周期反思(覆盖 24/25/26/27/28/33) + getPeriodReflection(userId: string, type: PeriodType, periodStart: YmdString): Promise; + getPeriodReflectionById(userId: string, id: string): Promise; + upsertPeriodReflection(userId: string, type: PeriodType, periodStart: YmdString, periodEnd: YmdString, + fields: Partial): Promise; + updatePeriodReflectionFields(userId: string, id: string, patch: Partial): Promise; + listPeriodReflections(userId: string, type: PeriodType, limit: number): Promise; + + // ── 隐私(新能力) + deleteAllUserData(userId: string): Promise; +} +``` + +工厂 `src/lib/data/index.ts`:`getJournalRepo()` 读 `DATA_BACKEND`(`supabase` 默认 | `shadow` | `nokv`)。 + +--- + +## 5. 方法级 NoKV 映射(wb = `jr-{userId}`;所有调用经 `mustCallWorkbenchTool`,除音频走 blob-cli) + +**repo 基元**: +- `readDoc(sec,p)` → `workbench_read {id, section:sec, path:p, format:'structured', if_none_match?}` → records[0];NotFound→null。 +- `editBlock(sec,p,key,newBlock)` → 循环≤3:readDoc → `workbench_edit {id, section:sec, path:p, old_string:extractBlock(text,key), new_string:newBlock}`;失配/冲突→重读重试;**绝不降级整文档 replace**(评审判定 P2 的该降级是丢更新洞)。 +- `appendLedger(ev)` → `workbench_append {id, section:'logs', path:'ledger/{ev.date月}.jsonl', text: line+'\n', content_type:'text/plain'}`;失败重试复用同一 event_id。 +- `latch(p)` → `workbench_put_file {id, section:'metadata', path:p, replace:false, text:''}`;PathExists 视为成功。 +- `foldState()` → 逐月分片 structured read(cursor 翻页)+ 缓存折叠(§2.4)。 + +| 方法 | 调用序列(精确参数) | +|---|---| +| **getMood** | readDoc(outputs, `days/{d}.json`) → `.mood`;null 安全 | +| **upsertMood** | 日文档不存在 → put_file `{section:'outputs', path:'days/{d}.json', replace:false, text:骨架(含mood)}`(PathExists→重读走 edit);存在 → editBlock(mood);然后 appendLedger mood_set | +| **listMoodsInRange** | foldState() → 范围内 mood_set 折叠(月窗 ≤2 分片读,通常缓存 304);**不读日文档** | +| **createEntry** | ① 校验 blob ≤25MB → tmpfile → blob-cli collect(`input`, `audio/{d}/{id}.webm`, `--content-type`);超时→`workbench_stat {id, section:'input', path:...}` 验证,存在即成功 ② put_file entries 文档 `replace:false` ③ 若当日日文档不存在 → put_file 骨架 `replace:false`(PathExists 容忍) ④ put_file sidecar 占位不需要;appendLedger entry_created。②失败⇒①成孤儿 blob(不可见,reconcile 墓碑化) | +| **getEntry** | id 前缀解析日期(uuid → legacy_ids.json)→ readDoc(outputs, `entries/{d}/{id}.json`);user_id 双验 | +| **getAudioBlob** | getEntry(deleted→null)→ 磁盘 LRU 缓存(键 = stat 的 body_digest_uri)→ 未命中:blob-cli materialize(`input`, path, tmpfile)→ 读→响应→unlink | +| **updateRephrasedText** | editBlock(entries 文档, transcript 块);照旧 fire-and-forget 触发 summary 再生 | +| **deleteEntry** | ① editBlock:`audio.deleted=true` + transcript 置空(骨架保留供对账) ② put_file `{section:'input', path:'audio/{d}/{id}.webm', replace:true, text:''}` 墓碑→GC 物理删字节(F8/F9) ③ appendLedger entry_deleted。原 FK 三步顺序删消失 | +| **listEntriesForDay** | `workbench_list {id, section:'outputs', path:'entries/{d}/', limit:100}` → 逐条 readDoc → 过滤 deleted → 按 entryId 升序(时间戳前缀即时序) | +| **getEntryStats** | foldState() → totalEntries=Σ liveEntries;thisWeek=本周日期(timezone.ts 定界)求和;streak=自今日 desc 走 liveEntries>0 连续段。对账交叉验证(reconcile 脚本用,非热路径):`workbench_aggregate {id, section:'input', path:'audio/', measures:[{name:'n', op:'count'}], predicates:[{field:'logical_size', op:'gt', value:0}, {field:'path', op:'prefix', value:'input/audio/'}]}` —— 注意谓词 path 值带 `input/` 前缀(F7) | +| **getDailySummary** | readDoc 日文档 → summary+reflection 投影为 DailySummary 形状(id = reflection.legacy_id ?? `daily:{d}`) | +| **upsertDailySummary** | 骨架保障(同 upsertMood)→ editBlock(summary 块;**不触 reflection 块** = 现状"只写 summary 列"的列级语义)→ put_file `{section:'outputs', path:'text/{d}/summary.txt', replace:true, text:纯文本}` → appendLedger summary_written {mood_quality, entry_count} | +| **updateDailySummaryReflection** | markEdited(用户 PATCH):latch(`latches/day-{d}.edited`)→ editBlock(reflection,edited:true)。preserveIfEdited(后台再生):`workbench_stat {id, section:'metadata', path:'latches/day-{d}.edited'}` → 存在则合并保留用户字段 → editBlock(reflection)。**create-only 闩把"再生覆盖用户编辑"从时间窗漏洞变成硬保证** | +| **listDailySummaries** | foldState() → summary 日期 desc,`< before` 过滤,取 limit → 逐日 readDoc(≤30 点读) | +| **listDailySummariesInRange** | foldState() → 范围内 summary 日期 asc → 逐日 readDoc(月窗 ≤31 读;mood 同文档,比现状少一次 join) | +| **queryJournalDays** | ① foldState() → 基集 = 有 summary 的日期(**与 Supabase 数 daily_summaries 行精确平账**,评审必改项 a)② 过滤:date range 字符串比较;moods → summary.mood_quality ∈ moods;keyword → `workbench_grep {id, section:'outputs', path:'text/', pattern:kw, glob:'summary.txt', recursive:true, limit:300, cursor…}` 收齐命中(千日 ≤4 页)→ 路径 `text/{date}/summary.txt` 提取日期集 → 交集 ③ totalCount = 过滤后集合大小(**三条路径全精确,分页 UI "Page X of Y" 不降级**)④ desc 切页 → 每日 readDoc 日文档 + listEntriesForDay 组装 DayBundle。每页 ≈12-40 次本地 MCP 调用 vs 现状 1+2N=21 次 SQL | +| **getPeriodReflection** | readDoc(outputs, `reflections/{type}/{start}.json`) | +| **getPeriodReflectionById** | `{type}:{start}` 解析直读;uuid → legacy_ids.json | +| **upsertPeriodReflection** | 不存在 → put_file `replace:false`;PathExists → stat `latches/period-{type}-{start}.edited` → 合并 → **全文档 edit**(old=整份旧文本,new=整份新文本 = 调用方 CAS 的 RMW,冲突重读重试 ×3) | +| **updatePeriodReflectionFields** | latch(`latches/period-{type}-{start}.edited`)→ 全文档 edit | +| **listPeriodReflections** | `workbench_list {id, section:'outputs', path:'reflections/{type}/', limit:100}` → 名字符串 desc 排序取 limit(≤12)→ 逐个 readDoc(list 升序仅列名,量级十级,一页足够;不必动用 search) | +| **deleteAllUserData** | 见 §8 | +| **agent context(30-33)** | 全部组合上述方法 + 请求级 memo:today=1 点读+list;recent=fold+20 点读;custom/week/month=InRange+getPeriodReflection。本地 stdio 毫秒级/次,语音预算 <150ms 内 | + +**路由级变更**:新增 `GET/PUT /api/mood/today`、`GET /api/journals/today`、`GET /api/journals/stats`(收编浏览器 anon 触点 34-40;`use-today-mood.ts`、`use-audio-journal.ts`、`daily-mood-modal.tsx` 改 fetch,顺手清偿 memory 挂账的 anon-read 风险)。改造为走 repo:transcribe、audio/[id]、journals/{list,[id]}、generate-daily-summary、reflections/×6、agent/tools/context。删死代码:`queries.ts` getDailySummary(L325-348)、searchJournals(L358-392)、hook 的 recentEntries。window 事件刷新机制与 UI 层零改动。 + +--- + +## 6. 音频策略 + +- **写**:`blob-cli.ts` 一次性子进程 `nokv --workbench-root --max-artifact-bytes 27262976 collect jr-{uid} input audio/{d}/{id}.webm --content-type audio/webm`(**参数顺序:wb → section → 本地源文件 → 目标 path**,cli.rs 实证;不加 `--replace` = create-only)。60s 超时,并发信号量 2。25MB 上限保留并在 collect 前强校验(26 MiB 旗标之内;**超过 max-artifact-bytes 的文件"只写不可读"是硬悬崖**,F5)。 +- **读**:`nokv materialize jr-{uid} input audio/{d}/{id}.webm ` → 流式响应 → unlink;digest 键磁盘 LRU 缓存,重复播放零 NoKV 调用。无真 Range(现状 `Accept-Ranges` 也是假的,不劣化)。 +- **为什么不走常驻 MCP**:16MiB 默认上限 + 5s 超时 + 逐行串行队头阻塞 + read bytes 每次 300 字节(F5/F12)四重冲突。 +- **明确不做 S3 直写侧门**:对象键含发布分配的 artifact_revision_id,绕过发布状态机的字节在元数据里永不可见(object-layout.md:79-81)——契约违规且功能不可行。 +- 常驻 mcp 进程的 `mcpArgv()` **不改** max-artifact-bytes(文档域 ≤4MiB 守卫已足够)。 + +--- + +## 7. 并发规则(全部可变状态收敛为三原语,不发明锁) + +| 原语 | 用途 | 保证 | +|---|---|---| +| `put_file replace:false` | 条目/骨架/闩/blob 创建;唯一约束 | 原子插入;AlreadyExists = 幂等成功(配 stat 验证覆盖超时陷阱 F12) | +| `workbench_append` + event_id | 账本 | 无丢失更新(F3);去重覆盖盲重 | +| `workbench_edit` 块级/全文档 | 一切可变文档字段 | 调用方乐观锁(F4);冲突=被检测,重读重试 ×3,耗尽→操作失败上抛(再生类下次触发追平) | +| `put_file replace:true` | **仅派生物**(sidecar、墓碑)与回填 | LWW,可由权威文档重建,无害 | + +- **禁止**:对权威文档做 read→mutate→put replace(F2 无调用方 CAS = 静默丢更新);对 jr-* 调 commit/snapshot(mcp-client 外包 allowlist 硬禁)。 +- 结构性免疫:条目 = 独立 create-only 文档 ⇒ 并发录音互不接触;日文档三块独立 ⇒ 跨块并发(entry 追加期间 summary 再生)互不覆盖,等价 Postgres 列级更新。 +- 编辑保护:create-only 闩(§5)⇒ "再生覆盖用户编辑"方向是**硬保证**,严格优于现状 Supabase 的 read-check-upsert 竞态窗口(generator.ts:245-250)。 +- 跨进程:CAS 仲裁在 shard owner(F14),两个 dev worker 各自 mcp 子进程安全。 +- 无事务的诚实账:createEntry 3-4 写、deleteEntry 3 写各有 crash 窗口 → 孤儿 blob / 账本漏事件。点读路径不受影响;过滤视图与统计由 `scripts/nokv-reconcile.ts` 自愈(list 对账 fold,补确定性 event_id 事件、墓碑孤儿)。日记写率(个位数/用户/天)下概率极低且全部可修复。 + +--- + +## 8. 删除与隐私(诚实版) + +- **单条删除**:文档 edit 置 deleted + blob/sidecar 0 字节墓碑 → 旧 revision 强引用归零 → GcCandidate → `nokv serve` 的 LifecycleRunner **物理删除 S3 字节**(F8;GC 依赖 serve 常驻运行 = 部署清单项)。读端过滤 `deleted:true` / `logical_size==0`。 +- **全账户删除** `deleteAllUserData`:逐 section `workbench_list` cursor 下钻(list 非递归)→ 每路径 0 字节墓碑 → 写 `metadata/deleted.json` 标记(repo 全读路径短路返回空)。 +- **残留(必须写进隐私政策)**:① MCP 面无 delete 工具(F1),墓碑后 **path 条目仍在**;② Holt 的 ChangeEvent/History 当前格式永不截断,路径名/尺寸/digest 投影无限期保留(F13);③ workbench 本身不可删、id 不复用(F13)。诚实表述 = "内容字节被物理删除;文件名与技术元数据痕迹保留"。 +- **上游反哺项(本集成最有说服力的贡献回路)**:把已实现未暴露的 `RemovePathRequest`(F10)暴露为 `workbench_remove` 工具 + CLI 子命令 —— 两仓同属 NoKV-Lab。合并后 deleteAllUserData 升级为真删除并补删历史墓碑路径;**落地前 Supabase 侧删除故事保留**,且不对外宣传"彻底删除"。 +- 防呆再强调:jr-* 一旦 commit,revision 永久钉住、墓碑失效(F8)—— allowlist 是安全机制而非风格偏好。 + +--- + +## 9. DATA_BACKEND 切换与回退 + +`DATA_BACKEND=supabase(默认)| shadow | nokv`。失败语义:`callWorkbenchTool` 返回 null(runtime disabled)在数据主存路径**升级为 NokvUnavailableError**,绝不沿用 voice-session 的静默降级; +- `shadow`:主读写 Supabase;写路径影子双写 NoKV(失败仅 console.error,**此处** fail-silent 恰当);读路径异步比对 NoKV 结果,结构化 diff 落日志。 +- `nokv`:读写全走 NoKV;NokvUnavailableError 时**读**回落 SupabaseRepository(双写保鲜故不缺数据),**写**返回 503 让客户端重试(双主写脑裂比短暂不可用更糟);删除路径继续双删 Supabase。 +- 回滚:P4 之前任意时刻拨回 `supabase` 即回滚;停双写之后需先跑反向同步(消费账本事件回放进 Supabase 的脚本,随附)。 + +**回填脚本 `scripts/backfill-nokv.ts` 提纲**(service-role 读 Supabase;全 create-only ⇒ 重跑天然幂等): + +``` +for each user: + workbench_create jr-{uid}(AlreadyExists 容忍)→ metadata/profile.json(create-only) + 1. audio_files ⋈ transcripts 按 created_at asc,经 timezone.ts 分本地日: + storage.download → tmpfile → blob-cli collect(已存在且 size 相符则跳过) + → entries/{d}/{newId}.json create-only(legacy_audio_id=uuid;PathExists=已回填,跳过) + 2. daily_question / daily_summaries → days/{d}.json 骨架+块(create-only 后按需 edit) + edited=true 的行 → 补 latches/day-{d}.edited;summary → text/{d}/summary.txt + 3. period_reflections → reflections/{type}/{start}.json + 闩 + 4. metadata/legacy_ids.json:{audio:{uuid→{date,entry_id}}, daily:{uuid→date}, period:{uuid→{type,period_start}}} + 5. 重建账本:确定性 event_id=evt_sb_{table}_{uuid},按时间序 append 进对应月分片 + 6. 尾校验(不平打印 diff 清单、exit 非零): + aggregate count(input/audio, logical_size>0, path prefix 'input/audio/') == count(audio_files) + days 有 summary 的文档数 == count(daily_summaries);reflections 数 == count(period_reflections) + 随机抽样 N 天 deep-diff + 5% 音频 digest 逐字节比对 +切 shadow 之后再跑一次(尾部增量补偿),diff 清零才算完成。 +``` + +--- + +## 10. Parity 切换闸门(全绿才允许 DATA_BACKEND=nokv → 停双写) + +1. 契约测试套件(vitest,P1 期建立于 SupabaseRepository)在两个实现上同绿; +2. shadow diff 连续 7 天零差异(逐 route 金样对比); +3. 三张计数平账(entries / summary 日 / reflections)+ 5% 音频 digest 抽样; +4. 25MB 上传→播放 e2e(collect/materialize 通路 + LRU 缓存命中); +5. keyword/mood/日期过滤的页内容与 **totalCount** 双后端相等(同关键词命中日期集合相同); +6. streak / thisWeek 逐用户与 SQL 版本一致; +7. 并发折磨:双 worker 并行「录音 + summary 再生 + 用户 PATCH」→ 零丢 entry、用户编辑字段零丢失(闩生效); +8. 删除条目 → 列表/统计/grep 三视图同步消失,GC 后确认 S3 对象删除; +9. legacy uuid 深链(历史 /api/audio/[id]、period PATCH)经 legacy_ids 可达; +10. 停机演练:kill mcp 子进程 → 读回落 Supabase、写 503,无 5xx 级联; +11. 语音 agent 四个 scope 的 context 输出形状相等; +12. 夜间导出 cron(逐用户 materialize 全部 outputs 文档到备份位)已上线并成功跑 3 晚(P2 嫁接,补偿 PITR 缺失)。 + +--- + +## 11. 诚实声明:比 Postgres 变差之处 + +1. **部署拓扑变硬**(最大风险):无 JS SDK,一切经 `NOKV_BIN` 子进程 ⇒ 永别 serverless/Vercel,必须容器/常驻 Node + Rust 二进制;本地开发 = etcd+minio+`nokv serve` 三件套(docker-compose 化,README 置顶声明)。二进制与冻结 schema 需钉版本。 +2. **无版本历史、无 PITR**:replace/墓碑后旧 revision 立即进 GC,误写不可恢复(jr-* 又禁 snapshot)。缓解:editBlock 不做整文档 replace、entries 收缩守卫、迁移期 Supabase 双写即热备、此后夜间导出 cron。这是全设计最尖的一根刺。 +3. **隐私删除不彻底**(§8):字节可删,元数据痕迹永存,workbench 不可删;上游 workbench_remove 落地前如实披露。Postgres 里这是一条 DELETE。 +4. **并发从行锁降为检测-重试**:同块竞争败者重试 ×3 后失败上抛;sidecar 是 LWW。净性质不劣于现状(现状本就是无锁读改写),且 edited 保护变硬,但心智模型更复杂。 +5. **读放大与全扫描**:列表页 12-40 次串行本地 MCP 调用(现状 21 次 SQL,非量级回退);grep O(文件数×body);main 上 search/aggregate 是全扫描(F7)。单实例几十用户安然;上千用户需上游索引寻径(NoKV 已在加 read-amplification 诊断,已知路线)。这是展示 NoKV 的 app,不是通用后端 —— 如实标注。 +6. **音频冷路径 +100-300ms**(spawn + S3 全量下载),缓存命中后归零;无真 Range(不劣化)。 +7. **crash 窗口**:多写操作无事务,孤儿 blob / 账本漏事件靠 reconcile 自愈;过滤视图在自愈前可短暂漏最新条目(点读不受影响)。 +8. **16MiB 读悬崖守卫是纪律**:日文档 4MiB 守卫、账本月分片+轮转、音频 25MB 前置校验 —— 三处都必须存在,漏一处 = 数据"只写不可读"。 +9. **运维面变窄**:无 SQL 控制台/BI,排障靠 nokv CLI 与脚本。 +10. **fold 正确性是应用代码**:折叠规则有向后兼容负担;但与事件溯源方案(P3)不同,本设计账本是**派生物可全量重建**,fold 变更 = 重建账本,不构成永久性历史解释负担。 + +--- + +## 12. 分阶段实施计划 + +**Phase 1 — 仓储接缝(一个工作 session 可落地;独立可发布)** +`src/lib/data/{repository.ts, supabase-repository.ts, index.ts}`:接口 + 领域类型(§4)+ SupabaseRepository(机械收编 queries.ts 与 12 个 route 的内联查询,行为零变化)+ `DATA_BACKEND` 工厂;12 个 route + 4 个共享库(queries/aggregate/generator/context)改为消费 repo;同 PR 删死代码(queries.ts:325-392、recentEntries)。验收:vitest 全绿、全路由行为不变。**此阶段建立契约测试套件**(跑在 SupabaseRepository 上,即后续 NoKV 实现的验收基准)。 + +**Phase 2 — 浏览器触点收编 + 时区统一** +新 3 路由(GET/PUT /api/mood/today、GET /api/journals/{today,stats});3 个客户端文件改 fetch;从客户端 bundle 移除 `NEXT_PUBLIC_SUPABASE_*`;aggregate.ts 裸日期界统一走 timezone.ts。清偿 anon-read 挂账风险。 + +**Phase 3 — NoKV 管道** +mcp-client.ts:`callWorkbenchTool(name, args, {timeoutMs})`(默认 5s;文档 15s、grep/账本读 10s)+ `mustCallWorkbenchTool`(null→NokvUnavailableError)+ jr-* 工具 allowlist(禁 commit/snapshot);`blob-cli.ts`(§6);`journal-doc.ts` 序列化器(往返字节稳定单测);`ledger/{events,fold}.ts`(fold 单测:event_id 去重、双序 edited 皆胜、月分片轮转)。附 spike:grep 对 CJK/JSON 转义关键词在 summary.txt 上的召回验证(退路已备:sidecar 是纯文本,无 JSON 转义问题)。 + +**Phase 4 — NokvRepository 全量实现** +§5 全部方法;契约测试套件跑 mock MCP(tests/mocks 已有基建)+ 本地整栈集成(etcd+minio+nokv serve,env 门控跳过);重点用例 = 闸门清单 §10 的 5/7/8/9。 + +**Phase 5 — 回填 + 影子双写** +`scripts/backfill-nokv.ts`(§9)+ `scripts/nokv-reconcile.ts` + `DATA_BACKEND=shadow` 跑 1-2 周;尾部增量补偿回填。 + +**Phase 6 — 切换** +`DATA_BACKEND=nokv`(读回落/写 503 语义,§9);闸门 §10 全绿后停双写;夜间导出 cron 常态化。 + +**Phase 7 — 收尾** +Supabase 只读冻结 30 天 → pg_dump + Storage 归档 → 退役;移除 SupabaseRepository 与 @/types/supabase 运行时依赖;上游 PR:NoKV `workbench_remove`(暴露 RemovePathRequest),合并后升级 deleteAllUserData 为真删除并补删历史墓碑路径。 + +--- + +**关键文件(绝对路径)** +EchoJournal:`/Users/wangchanghao/journal-app-mvp/src/lib/nokv/{mcp-client.ts,session-workspace.ts}`、`src/lib/supabase/queries.ts`、`src/lib/timezone.ts`、`src/lib/reflections/{aggregate,generator,sync}.ts`、`src/lib/agent/context.ts`、`src/app/api/{transcribe,audio/[id],journals/list,journals/[id],generate-daily-summary,reflections/*,agent/tools/context}/route.ts`、`src/hooks/{use-audio-journal,use-today-mood}.ts`、`src/features/daily-record/components/daily-mood-modal.tsx`。 +NoKV(main):`/Users/wangchanghao/NoKV/crates/nokv-agent/{src/facade.rs,workbench_contract_schema.json}`、`crates/nokv/src/{main.rs,cli.rs,backend.rs}`、`crates/nokv-meta/src/workspace/{query_records.rs,remove.rs,publication.rs}`、`docs/{workbench-contract,metadata-schema,object-layout}.md`。 \ No newline at end of file diff --git a/src/app/api/agent/tools/context/route.ts b/src/app/api/agent/tools/context/route.ts index afe9979..ddb8768 100644 --- a/src/app/api/agent/tools/context/route.ts +++ b/src/app/api/agent/tools/context/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { after } from 'next/server'; import { auth } from '@clerk/nextjs/server'; import { z } from 'zod'; -import { createAdminClient } from '@/lib/supabase/admin'; +import { getJournalRepo } from '@/lib/data'; import { fetchUserContext } from '@/lib/agent/context'; import type { ContextRequest } from '@/lib/agent/context'; import { isTrustedOrigin } from '@/lib/security'; @@ -56,9 +56,9 @@ export async function POST(request: NextRequest) { ); } - const supabase = createAdminClient(); + const repo = getJournalRepo(); const startedAt = Date.now(); - const context = await fetchUserContext(supabase, userId, payload); + const context = await fetchUserContext(repo, userId, payload); // Optional NoKV trace, written after the response is sent. const sessionRef = request.headers.get('x-echo-session'); diff --git a/src/app/api/audio/[id]/route.ts b/src/app/api/audio/[id]/route.ts index 71ff293..d882654 100644 --- a/src/app/api/audio/[id]/route.ts +++ b/src/app/api/audio/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; -import { createAdminClient } from '@/lib/supabase/admin'; import { auth } from '@clerk/nextjs/server'; +import { getJournalRepo } from '@/lib/data'; export async function GET( request: NextRequest, @@ -21,48 +21,23 @@ export async function GET( ); } - const supabase = createAdminClient(); + const repo = getJournalRepo(); + const audio = await repo.getAudioBlob(userId, id); - // First, verify that the audio file belongs to the authenticated user - const { data: audioFile, error: audioFileError } = await supabase - .from('audio_files') - .select('storage_path, mime_type') - .eq('id', id) - .eq('user_id', userId) - .single(); - - if (audioFileError || !audioFile) { + if (!audio) { return NextResponse.json( { error: 'Audio file not found or access denied' }, { status: 404 } ); } - // Get the audio file from Supabase Storage - const { data: audioData, error: storageError } = await supabase.storage - .from('audio-files') - .download(audioFile.storage_path); - - if (storageError || !audioData) { - // eslint-disable-next-line no-console - console.error('Error downloading audio file:', storageError); - return NextResponse.json( - { error: 'Failed to retrieve audio file' }, - { status: 500 } - ); - } - - // Convert blob to array buffer - const buffer = await audioData.arrayBuffer(); - // Return the audio file with appropriate headers - return new NextResponse(buffer, { + return new NextResponse(audio.data, { status: 200, headers: { - 'Content-Type': audioFile.mime_type || 'audio/webm', - 'Content-Length': buffer.byteLength.toString(), - 'Cache-Control': 'private, max-age=3600', // Cache for 1 hour - 'Accept-Ranges': 'bytes' + 'Content-Type': audio.mimeType || 'audio/webm', + 'Content-Length': audio.data.byteLength.toString(), + 'Cache-Control': 'private, max-age=3600' // Cache for 1 hour } }); } catch (error) { diff --git a/src/app/api/generate-daily-summary/route.ts b/src/app/api/generate-daily-summary/route.ts index c1cc736..11c864e 100644 --- a/src/app/api/generate-daily-summary/route.ts +++ b/src/app/api/generate-daily-summary/route.ts @@ -1,10 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; import { after } from 'next/server'; import OpenAI from 'openai'; -import { createAdminClient } from '@/lib/supabase/admin'; import { auth } from '@clerk/nextjs/server'; +import { getJournalRepo, NokvUnavailableError } from '@/lib/data'; +import type { DailySummary, JournalEntry, MoodEntry } from '@/lib/data'; import { syncReflectionsForDate } from '@/lib/reflections/sync'; -import { getUtcRangeForDate } from '@/lib/timezone'; import { isTrustedOrigin } from '@/lib/security'; import { SUMMARY_MODEL } from '@/lib/ai/models'; @@ -41,70 +41,50 @@ export async function POST(request: NextRequest) { ); } - const supabase = createAdminClient(); - const { start: dayStart, end: dayEnd } = getUtcRangeForDate(date); - - const { data: transcripts, error: transcriptsError } = await supabase - .from('transcripts') - .select( - ` - id, - text, - rephrased_text, - created_at, - audio_files!inner( - created_at - ) - ` - ) - .eq('user_id', userId) - .gte('audio_files.created_at', dayStart) - .lte('audio_files.created_at', dayEnd) - .order('created_at', { ascending: true }); - - if (transcriptsError) { + const repo = getJournalRepo(); + + // Step 1: Get all journal entries for the requested date + let entries: JournalEntry[]; + try { + entries = await repo.listEntriesForDay(userId, date); + } catch (entriesError) { // eslint-disable-next-line no-console - console.error('Error fetching transcripts:', transcriptsError); + console.error('Error fetching transcripts:', entriesError); return NextResponse.json( { error: 'Failed to fetch transcripts' }, { status: 500 } ); } - if (!transcripts || transcripts.length === 0) { + if (entries.length === 0) { return NextResponse.json({ success: true, message: 'No journal entries found for this date' }); } - // Step 2: Get daily mood data - const { data: moodData, error: moodError } = await supabase - .from('daily_question') - .select('day_quality, emotions') - .eq('user_id', userId) - .gte('created_at', dayStart) - .lte('created_at', dayEnd) - .single(); - - if (moodError && moodError.code !== 'PGRST116') { + // Step 2: Get daily mood data (non-fatal when unavailable) + let mood: MoodEntry | null = null; + try { + mood = await repo.getMood(userId, date); + } catch (moodError) { // eslint-disable-next-line no-console console.error('Error fetching mood data:', moodError); } // Step 3: Generate summary using GPT-4o - const journalTexts = transcripts - .map((t, index) => { - const time = new Date(t.created_at!).toLocaleTimeString('en-US', { + const journalTexts = entries + .map((entry, index) => { + const time = new Date(entry.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); - return `Entry ${index + 1} (${time}): ${t.rephrased_text || t.text}`; + return `Entry ${index + 1} (${time}): ${entry.transcript.rephrased_text || entry.transcript.text}`; }) .join('\n\n'); - const moodContext = moodData - ? `\nToday's mood: ${moodData.day_quality}, feeling ${moodData.emotions.join(', ')}.` + const moodContext = mood + ? `\nToday's mood: ${mood.day_quality}, feeling ${mood.emotions.join(', ')}.` : ''; const summaryResponse = await openai.chat.completions.create({ @@ -113,7 +93,7 @@ export async function POST(request: NextRequest) { { role: 'system', content: `You are a thoughtful journal assistant that creates concise daily summaries. - + Your task is to: - Synthesize multiple journal entries into a coherent daily narrative - Maintain first-person perspective throughout @@ -122,7 +102,7 @@ export async function POST(request: NextRequest) { - Keep the summary between 3-5 sentences - Make it reflective and meaningful - Consider the overall mood context if provided - + The summary should read like a thoughtful reflection on the day, not just a list of events.` }, { @@ -136,26 +116,21 @@ export async function POST(request: NextRequest) { const summary = summaryResponse.choices[0]?.message?.content || ''; // Step 4: Upsert daily summary - const { data: summaryData, error: summaryError } = await supabase - .from('daily_summaries') - .upsert( - { - user_id: userId, - date: date, - summary: summary, - entry_count: transcripts.length, - mood_quality: moodData?.day_quality || null, - dominant_emotions: moodData?.emotions || null, - updated_at: new Date().toISOString() - }, - { - onConflict: 'user_id,date' - } - ) - .select() - .single(); - - if (summaryError) { + let summaryData: DailySummary; + try { + summaryData = await repo.upsertDailySummary(userId, date, { + summary, + entryCount: entries.length, + moodQuality: mood?.day_quality || null, + dominantEmotions: mood?.emotions || [] + }); + } catch (summaryError) { + if (summaryError instanceof NokvUnavailableError) { + return NextResponse.json( + { error: 'Data backend temporarily unavailable' }, + { status: 503 } + ); + } // eslint-disable-next-line no-console console.error('Error saving summary:', summaryError); return NextResponse.json( @@ -169,7 +144,6 @@ export async function POST(request: NextRequest) { after(async () => { try { await syncReflectionsForDate({ - supabase, openai, userId, anchorDate: date @@ -184,7 +158,7 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, summary: summary, - entryCount: transcripts.length, + entryCount: entries.length, summaryId: summaryData.id }); } catch (error) { diff --git a/src/app/api/journals/[id]/route.ts b/src/app/api/journals/[id]/route.ts index 94d6d45..7584898 100644 --- a/src/app/api/journals/[id]/route.ts +++ b/src/app/api/journals/[id]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; -import { createAdminClient } from '@/lib/supabase/admin'; import { auth } from '@clerk/nextjs/server'; -import { getLocalDayRange } from '@/lib/timezone'; +import { getJournalRepo, NokvUnavailableError } from '@/lib/data'; +import type { JournalEntry } from '@/lib/data'; import { isTrustedOrigin } from '@/lib/security'; export async function PUT( @@ -41,17 +41,11 @@ export async function PUT( ); } - const supabase = createAdminClient(); + const repo = getJournalRepo(); - // First, verify that the audio file belongs to the authenticated user - const { data: audioFile, error: audioFileError } = await supabase - .from('audio_files') - .select('id, user_id, created_at') - .eq('id', id) - .eq('user_id', userId) - .single(); - - if (audioFileError || !audioFile) { + // First, verify that the journal entry belongs to the authenticated user + const entry = await repo.getEntry(userId, id); + if (!entry) { return NextResponse.json( { error: 'Journal entry not found or access denied' }, { status: 404 } @@ -59,17 +53,16 @@ export async function PUT( } // Update the transcript's rephrased text - const { data: transcript, error: updateError } = await supabase - .from('transcripts') - .update({ - rephrased_text: rephrased_text - }) - .eq('audio_id', id) - .eq('user_id', userId) - .select() - .single(); - - if (updateError) { + let updated: JournalEntry; + try { + updated = await repo.updateRephrasedText(userId, id, rephrased_text); + } catch (updateError) { + if (updateError instanceof NokvUnavailableError) { + return NextResponse.json( + { error: 'Data backend temporarily unavailable' }, + { status: 503 } + ); + } // eslint-disable-next-line no-console console.error('Error updating transcript:', updateError); return NextResponse.json( @@ -79,8 +72,7 @@ export async function PUT( } // Trigger daily summary regeneration - const audioCreatedAt = new Date(audioFile.created_at || new Date()); - const { date: entryDate } = getLocalDayRange({ date: audioCreatedAt }); + const entryDate = entry.date; fetch(`${request.nextUrl.origin}/api/generate-daily-summary`, { method: 'POST', @@ -96,7 +88,15 @@ export async function PUT( return NextResponse.json({ success: true, - transcript + transcript: { + id: updated.id, + user_id: userId, + audio_id: updated.id, + text: updated.transcript.text, + rephrased_text: updated.transcript.rephrased_text, + language: updated.transcript.language, + created_at: updated.transcript.updated_at + } }); } catch (error) { // eslint-disable-next-line no-console @@ -134,63 +134,29 @@ export async function DELETE( ); } - const supabase = createAdminClient(); - - // First, verify that the audio file belongs to the authenticated user - const { data: audioFile, error: audioFileError } = await supabase - .from('audio_files') - .select('id, user_id, storage_path, created_at') - .eq('id', id) - .eq('user_id', userId) - .single(); + const repo = getJournalRepo(); - if (audioFileError || !audioFile) { + // First, verify that the journal entry belongs to the authenticated user + const entry = await repo.getEntry(userId, id); + if (!entry) { return NextResponse.json( { error: 'Journal entry not found or access denied' }, { status: 404 } ); } - // Delete the transcript first (due to foreign key constraint) - const { error: transcriptDeleteError } = await supabase - .from('transcripts') - .delete() - .eq('audio_id', id) - .eq('user_id', userId); - - if (transcriptDeleteError) { - // eslint-disable-next-line no-console - console.error('Error deleting transcript:', transcriptDeleteError); - return NextResponse.json( - { error: 'Failed to delete transcript' }, - { status: 500 } - ); - } - - // Delete the audio file from storage - const { error: storageDeleteError } = await supabase.storage - .from('audio-files') - .remove([audioFile.storage_path]); - - if (storageDeleteError) { - // eslint-disable-next-line no-console - console.error( - 'Error deleting audio file from storage:', - storageDeleteError - ); - // Continue with database deletion even if storage deletion fails - } - - // Delete the audio file record from database - const { error: audioDeleteError } = await supabase - .from('audio_files') - .delete() - .eq('id', id) - .eq('user_id', userId); - - if (audioDeleteError) { + // Delete transcript, audio blob and entry row + try { + await repo.deleteEntry(userId, id); + } catch (deleteError) { + if (deleteError instanceof NokvUnavailableError) { + return NextResponse.json( + { error: 'Data backend temporarily unavailable' }, + { status: 503 } + ); + } // eslint-disable-next-line no-console - console.error('Error deleting audio file record:', audioDeleteError); + console.error('Error deleting journal entry:', deleteError); return NextResponse.json( { error: 'Failed to delete journal entry' }, { status: 500 } @@ -198,8 +164,7 @@ export async function DELETE( } // Trigger daily summary regeneration - const audioCreatedAt = new Date(audioFile.created_at || new Date()); - const { date: entryDate } = getLocalDayRange({ date: audioCreatedAt }); + const entryDate = entry.date; fetch(`${request.nextUrl.origin}/api/generate-daily-summary`, { method: 'POST', diff --git a/src/app/api/journals/list/route.ts b/src/app/api/journals/list/route.ts index 25ab7f9..eedc63a 100644 --- a/src/app/api/journals/list/route.ts +++ b/src/app/api/journals/list/route.ts @@ -1,7 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@clerk/nextjs/server'; -import { createAdminClient } from '@/lib/supabase/admin'; -import { getJournalsWithSummaries } from '@/lib/supabase/queries'; +import { getJournalRepo } from '@/lib/data'; import { isTrustedOrigin } from '@/lib/security'; type FiltersPayload = { @@ -47,9 +46,45 @@ export async function POST(request: NextRequest) { }; try { - const supabase = createAdminClient(); - const result = await getJournalsWithSummaries(supabase, userId, options); - return NextResponse.json(result); + const repo = getJournalRepo(); + const { days, totalCount } = await repo.queryJournalDays(userId, options); + + // Map domain DayBundles back to the legacy wire shape the journal list UI + // expects: { ...summaryColumns, journals: [audioRow + transcripts], dailyMood }. + const data = days.map((day) => ({ + ...(day.summary ?? { date: day.date }), + journals: day.entries.map((entry) => ({ + id: entry.id, + user_id: userId, + // Domain entries no longer expose storage paths; key kept for wire + // compatibility only (no consumer reads it). + storage_path: '', + mime_type: entry.audio.mime_type, + duration_ms: entry.audio.duration_ms, + created_at: entry.created_at, + transcripts: [ + { + id: entry.id, + text: entry.transcript.text, + rephrased_text: entry.transcript.rephrased_text, + language: entry.transcript.language, + created_at: entry.transcript.updated_at + } + ] + })), + dailyMood: day.mood + ? { + id: day.mood.id, + user_id: userId, + day_quality: day.mood.day_quality, + emotions: day.mood.emotions, + created_at: day.mood.created_at, + updated_at: day.mood.updated_at + } + : null + })); + + return NextResponse.json({ data, totalCount }); } catch (error) { // eslint-disable-next-line no-console console.error('Failed to fetch journals via API route', error); diff --git a/src/app/api/journals/stats/route.ts b/src/app/api/journals/stats/route.ts new file mode 100644 index 0000000..330b05c --- /dev/null +++ b/src/app/api/journals/stats/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@clerk/nextjs/server'; +import { getJournalRepo, NokvUnavailableError } from '@/lib/data'; + +export async function GET() { + try { + const { userId } = await auth(); + + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const stats = await getJournalRepo().getEntryStats(userId); + + return NextResponse.json({ stats }); + } catch (error) { + if (error instanceof NokvUnavailableError) { + return NextResponse.json( + { error: 'Data backend unavailable' }, + { status: 503 } + ); + } + // eslint-disable-next-line no-console + console.error('Failed to fetch journal stats', error); + return NextResponse.json( + { error: 'Failed to fetch stats' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/journals/today/route.ts b/src/app/api/journals/today/route.ts new file mode 100644 index 0000000..7ce41ae --- /dev/null +++ b/src/app/api/journals/today/route.ts @@ -0,0 +1,61 @@ +import { NextResponse } from 'next/server'; +import { auth } from '@clerk/nextjs/server'; +import { getJournalRepo, NokvUnavailableError } from '@/lib/data'; +import type { JournalEntry } from '@/lib/data'; +import { getLocalDayRange } from '@/lib/timezone'; + +// Wire shape kept identical to the audio_files + transcripts rows the +// browser previously read from Supabase directly (use-audio-journal.ts). +function toWireEntry(userId: string, entry: JournalEntry) { + return { + id: entry.legacy_audio_id ?? entry.id, + user_id: userId, + storage_path: '', + mime_type: entry.audio.mime_type, + duration_ms: entry.audio.duration_ms, + created_at: entry.created_at, + transcripts: [ + { + id: entry.id, + text: entry.transcript.text, + rephrased_text: entry.transcript.rephrased_text, + language: entry.transcript.language, + created_at: entry.transcript.updated_at + } + ] + }; +} + +export async function GET() { + try { + const { userId } = await auth(); + + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { date } = getLocalDayRange(); + const entries = await getJournalRepo().listEntriesForDay(userId, date); + + // Repo returns ascending; the browser expects newest first. + const wireEntries = entries + .slice() + .reverse() + .map((entry) => toWireEntry(userId, entry)); + + return NextResponse.json({ entries: wireEntries }); + } catch (error) { + if (error instanceof NokvUnavailableError) { + return NextResponse.json( + { error: 'Data backend unavailable' }, + { status: 503 } + ); + } + // eslint-disable-next-line no-console + console.error('Failed to fetch today journals', error); + return NextResponse.json( + { error: 'Failed to fetch journals' }, + { status: 500 } + ); + } +} diff --git a/src/app/api/mood/today/route.ts b/src/app/api/mood/today/route.ts new file mode 100644 index 0000000..7803c21 --- /dev/null +++ b/src/app/api/mood/today/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { auth } from '@clerk/nextjs/server'; +import { z } from 'zod'; +import { getJournalRepo, NokvUnavailableError } from '@/lib/data'; +import { getLocalDayRange } from '@/lib/timezone'; +import { isTrustedOrigin } from '@/lib/security'; + +const putBodySchema = z.object({ + dayQuality: z.string().min(1), + emotions: z.array(z.string()).default([]) +}); + +export async function GET() { + try { + const { userId } = await auth(); + + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { date } = getLocalDayRange(); + const mood = await getJournalRepo().getMood(userId, date); + + return NextResponse.json({ mood }); + } catch (error) { + if (error instanceof NokvUnavailableError) { + return NextResponse.json( + { error: 'Data backend unavailable' }, + { status: 503 } + ); + } + // eslint-disable-next-line no-console + console.error('Failed to fetch today mood', error); + return NextResponse.json( + { error: 'Failed to fetch mood' }, + { status: 500 } + ); + } +} + +export async function PUT(request: NextRequest) { + try { + if (!isTrustedOrigin(request)) { + return NextResponse.json( + { error: 'Invalid request origin' }, + { status: 403 } + ); + } + + const { userId } = await auth(); + + if (!userId) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const payload = putBodySchema.parse(await request.json()); + + const { date } = getLocalDayRange(); + const mood = await getJournalRepo().upsertMood(userId, date, { + dayQuality: payload.dayQuality, + emotions: payload.emotions + }); + + return NextResponse.json({ mood }); + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { error: 'Invalid request', details: error.flatten() }, + { status: 422 } + ); + } + if (error instanceof NokvUnavailableError) { + return NextResponse.json( + { error: 'Data backend unavailable' }, + { status: 503 } + ); + } + // eslint-disable-next-line no-console + console.error('Failed to save today mood', error); + return NextResponse.json({ error: 'Failed to save mood' }, { status: 500 }); + } +} diff --git a/src/app/api/reflections/daily/[date]/route.ts b/src/app/api/reflections/daily/[date]/route.ts index dc29104..295507f 100644 --- a/src/app/api/reflections/daily/[date]/route.ts +++ b/src/app/api/reflections/daily/[date]/route.ts @@ -1,10 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@clerk/nextjs/server'; import { z } from 'zod'; -import { createAdminClient } from '@/lib/supabase/admin'; +import { getJournalRepo, NokvUnavailableError } from '@/lib/data'; +import type { DailySummary } from '@/lib/data'; import { patchDailySchema } from '@/lib/reflections/schema'; import { serializeDailyReflection } from '@/lib/reflections/serialize'; -import type { TablesUpdate } from '@/types/supabase'; +import type { Tables } from '@/types/supabase'; import { isTrustedOrigin } from '@/lib/security'; const paramsSchema = z.object({ @@ -32,61 +33,75 @@ export async function PATCH( const finalParams = paramsSchema.parse(await params); const payload = patchDailySchema.parse(await request.json()); - const supabase = createAdminClient(); + const repo = getJournalRepo(); - const { data: existing, error: existingError } = await supabase - .from('daily_summaries') - .select('*') - .eq('user_id', userId) - .eq('date', finalParams.date) - .single(); - - if (existingError) { - if (existingError.code === 'PGRST116') { - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } + let existing: DailySummary | null; + try { + existing = await repo.getDailySummary(userId, finalParams.date); + } catch (error) { // eslint-disable-next-line no-console - console.error('Failed to fetch daily reflection', existingError); + console.error('Failed to fetch daily reflection', error); return NextResponse.json( { error: 'Failed to fetch reflection' }, { status: 500 } ); } - const updatePayload: TablesUpdate<'daily_summaries'> = { - edited: true, + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const patch: Partial< + Pick< + DailySummary, + | 'achievements' + | 'commitments' + | 'mood_overall' + | 'mood_reason' + | 'flashback' + | 'gen_version' + | 'last_generated_at' + > + > = { last_generated_at: existing.last_generated_at, gen_version: existing.gen_version }; if (payload.achievements !== undefined) { - updatePayload.achievements = payload.achievements; + patch.achievements = payload.achievements; } if (payload.commitments !== undefined) { - updatePayload.commitments = payload.commitments; + patch.commitments = payload.commitments; } if (payload.moodOverall !== undefined) { - updatePayload.mood_overall = payload.moodOverall; + patch.mood_overall = payload.moodOverall; } if (payload.moodReason !== undefined) { - updatePayload.mood_reason = payload.moodReason; + patch.mood_reason = payload.moodReason; } if (payload.flashback !== undefined) { - updatePayload.flashback = payload.flashback; + patch.flashback = payload.flashback; } - const { data, error } = await supabase - .from('daily_summaries') - .update(updatePayload) - .eq('id', existing.id) - .select() - .single(); - - if (error) { + let data: DailySummary; + try { + data = await repo.updateDailySummaryReflection( + userId, + finalParams.date, + patch, + { markEdited: true } + ); + } catch (error) { + if (error instanceof NokvUnavailableError) { + return NextResponse.json( + { error: 'Data backend unavailable' }, + { status: 503 } + ); + } // eslint-disable-next-line no-console console.error('Failed to update daily reflection', error); return NextResponse.json( @@ -97,7 +112,7 @@ export async function PATCH( return NextResponse.json({ success: true, - card: serializeDailyReflection(data) + card: serializeDailyReflection(data as Tables<'daily_summaries'>) }); } catch (error) { if (error instanceof z.ZodError) { diff --git a/src/app/api/reflections/daily/route.ts b/src/app/api/reflections/daily/route.ts index eda0cfb..5c27321 100644 --- a/src/app/api/reflections/daily/route.ts +++ b/src/app/api/reflections/daily/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@clerk/nextjs/server'; -import { createAdminClient } from '@/lib/supabase/admin'; +import { getJournalRepo } from '@/lib/data'; import { serializeDailyReflection } from '@/lib/reflections/serialize'; import { MAX_LISTED_REFLECTIONS } from '@/lib/reflections/types'; +import type { Tables } from '@/types/supabase'; export async function GET(request: NextRequest) { const { userId } = await auth(); @@ -11,7 +12,6 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const supabase = createAdminClient(); const searchParams = request.nextUrl.searchParams; const limitParam = Number.parseInt( searchParams.get('limit') ?? `${MAX_LISTED_REFLECTIONS.daily}`, @@ -22,20 +22,18 @@ export async function GET(request: NextRequest) { : Math.min(limitParam, MAX_LISTED_REFLECTIONS.daily); const start = searchParams.get('start'); - let query = supabase - .from('daily_summaries') - .select('*') - .eq('user_id', userId) - .order('date', { ascending: false }) - .limit(limit); - - if (start) { - query = query.lte('date', start); - } - - const { data, error } = await query; - - if (error) { + try { + const data = await getJournalRepo().listDailySummaries(userId, { + before: start ?? undefined, + limit + }); + + return NextResponse.json({ + cards: data.map((row) => + serializeDailyReflection(row as Tables<'daily_summaries'>) + ) + }); + } catch (error) { // eslint-disable-next-line no-console console.error('Failed to fetch daily reflections', error); return NextResponse.json( @@ -43,8 +41,4 @@ export async function GET(request: NextRequest) { { status: 500 } ); } - - return NextResponse.json({ - cards: (data ?? []).map(serializeDailyReflection) - }); } diff --git a/src/app/api/reflections/monthly/route.ts b/src/app/api/reflections/monthly/route.ts index 737aa4c..d597761 100644 --- a/src/app/api/reflections/monthly/route.ts +++ b/src/app/api/reflections/monthly/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@clerk/nextjs/server'; -import { createAdminClient } from '@/lib/supabase/admin'; +import { getJournalRepo } from '@/lib/data'; import { serializePeriodReflection } from '@/lib/reflections/serialize'; import { MAX_LISTED_REFLECTIONS } from '@/lib/reflections/types'; +import type { Tables } from '@/types/supabase'; export async function GET(request: NextRequest) { const { userId } = await auth(); @@ -11,7 +12,6 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const supabase = createAdminClient(); const searchParams = request.nextUrl.searchParams; const limitParam = Number.parseInt( searchParams.get('limit') ?? `${MAX_LISTED_REFLECTIONS.monthly}`, @@ -21,15 +21,19 @@ export async function GET(request: NextRequest) { ? MAX_LISTED_REFLECTIONS.monthly : Math.min(limitParam, MAX_LISTED_REFLECTIONS.monthly); - const { data, error } = await supabase - .from('period_reflections') - .select('*') - .eq('user_id', userId) - .eq('period_type', 'monthly') - .order('period_start', { ascending: false }) - .limit(limit); + try { + const data = await getJournalRepo().listPeriodReflections( + userId, + 'monthly', + limit + ); - if (error) { + return NextResponse.json({ + cards: data.map((row) => + serializePeriodReflection(row as Tables<'period_reflections'>) + ) + }); + } catch (error) { // eslint-disable-next-line no-console console.error('Failed to fetch monthly reflections', error); return NextResponse.json( @@ -37,8 +41,4 @@ export async function GET(request: NextRequest) { { status: 500 } ); } - - return NextResponse.json({ - cards: (data ?? []).map(serializePeriodReflection) - }); } diff --git a/src/app/api/reflections/period/[id]/route.ts b/src/app/api/reflections/period/[id]/route.ts index 14d76bb..0399ca5 100644 --- a/src/app/api/reflections/period/[id]/route.ts +++ b/src/app/api/reflections/period/[id]/route.ts @@ -1,10 +1,11 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@clerk/nextjs/server'; import { z } from 'zod'; -import { createAdminClient } from '@/lib/supabase/admin'; +import { getJournalRepo, NokvUnavailableError } from '@/lib/data'; +import type { PeriodReflection } from '@/lib/data'; import { patchPeriodSchema } from '@/lib/reflections/schema'; import { serializePeriodReflection } from '@/lib/reflections/serialize'; -import type { TablesUpdate } from '@/types/supabase'; +import type { Tables } from '@/types/supabase'; import { isTrustedOrigin } from '@/lib/security'; const paramsSchema = z.object({ @@ -32,61 +33,64 @@ export async function PATCH( const finalParams = paramsSchema.parse(await params); const payload = patchPeriodSchema.parse(await request.json()); - const supabase = createAdminClient(); + const repo = getJournalRepo(); - const { data: existing, error: existingError } = await supabase - .from('period_reflections') - .select('*') - .eq('user_id', userId) - .eq('id', finalParams.id) - .single(); - - if (existingError) { - if (existingError.code === 'PGRST116') { - return NextResponse.json({ error: 'Not found' }, { status: 404 }); - } + let existing: PeriodReflection | null; + try { + existing = await repo.getPeriodReflectionById(userId, finalParams.id); + } catch (error) { // eslint-disable-next-line no-console - console.error('Failed to fetch period reflection', existingError); + console.error('Failed to fetch period reflection', error); return NextResponse.json( { error: 'Failed to fetch reflection' }, { status: 500 } ); } - const updatePayload: TablesUpdate<'period_reflections'> = { + if (!existing) { + return NextResponse.json({ error: 'Not found' }, { status: 404 }); + } + + const patch: Partial = { edited: true, last_generated_at: existing.last_generated_at, gen_version: existing.gen_version }; if (payload.achievements !== undefined) { - updatePayload.achievements = payload.achievements; + patch.achievements = payload.achievements; } if (payload.commitments !== undefined) { - updatePayload.commitments = payload.commitments; + patch.commitments = payload.commitments; } if (payload.moodOverall !== undefined) { - updatePayload.mood_overall = payload.moodOverall; + patch.mood_overall = payload.moodOverall; } if (payload.moodReason !== undefined) { - updatePayload.mood_reason = payload.moodReason; + patch.mood_reason = payload.moodReason; } if (payload.flashback !== undefined) { - updatePayload.flashback = payload.flashback; + patch.flashback = payload.flashback; } - const { data, error } = await supabase - .from('period_reflections') - .update(updatePayload) - .eq('id', existing.id) - .select() - .single(); - - if (error) { + let data: PeriodReflection; + try { + data = await repo.updatePeriodReflectionFields( + userId, + finalParams.id, + patch + ); + } catch (error) { + if (error instanceof NokvUnavailableError) { + return NextResponse.json( + { error: 'Data backend unavailable' }, + { status: 503 } + ); + } // eslint-disable-next-line no-console console.error('Failed to update period reflection', error); return NextResponse.json( @@ -97,7 +101,7 @@ export async function PATCH( return NextResponse.json({ success: true, - card: serializePeriodReflection(data) + card: serializePeriodReflection(data as Tables<'period_reflections'>) }); } catch (error) { if (error instanceof z.ZodError) { diff --git a/src/app/api/reflections/sync/route.ts b/src/app/api/reflections/sync/route.ts index 3ddf5fa..c6f8ecb 100644 --- a/src/app/api/reflections/sync/route.ts +++ b/src/app/api/reflections/sync/route.ts @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@clerk/nextjs/server'; import OpenAI from 'openai'; import { ZodError } from 'zod'; -import { createAdminClient } from '@/lib/supabase/admin'; +import { getJournalRepo, NokvUnavailableError } from '@/lib/data'; import { generateReflection } from '@/lib/reflections/generator'; import { syncPayloadSchema } from '@/lib/reflections/schema'; import { isTrustedOrigin } from '@/lib/security'; @@ -32,13 +32,13 @@ export async function POST(request: NextRequest) { const payload = await request.json(); const parsed = syncPayloadSchema.parse(payload); - const supabase = createAdminClient(); + const repo = getJournalRepo(); const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); const result = await generateReflection({ - supabase, + repo, openai, userId, mode: parsed.mode, @@ -53,6 +53,12 @@ export async function POST(request: NextRequest) { { status: 422 } ); } + if (error instanceof NokvUnavailableError) { + return NextResponse.json( + { error: 'Data backend unavailable' }, + { status: 503 } + ); + } // eslint-disable-next-line no-console console.error('Reflection sync error:', error); return NextResponse.json( diff --git a/src/app/api/reflections/weekly/route.ts b/src/app/api/reflections/weekly/route.ts index 4e1abee..f613ac3 100644 --- a/src/app/api/reflections/weekly/route.ts +++ b/src/app/api/reflections/weekly/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { auth } from '@clerk/nextjs/server'; -import { createAdminClient } from '@/lib/supabase/admin'; +import { getJournalRepo } from '@/lib/data'; import { serializePeriodReflection } from '@/lib/reflections/serialize'; import { MAX_LISTED_REFLECTIONS } from '@/lib/reflections/types'; +import type { Tables } from '@/types/supabase'; export async function GET(request: NextRequest) { const { userId } = await auth(); @@ -11,7 +12,6 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } - const supabase = createAdminClient(); const searchParams = request.nextUrl.searchParams; const limitParam = Number.parseInt( searchParams.get('limit') ?? `${MAX_LISTED_REFLECTIONS.weekly}`, @@ -21,15 +21,19 @@ export async function GET(request: NextRequest) { ? MAX_LISTED_REFLECTIONS.weekly : Math.min(limitParam, MAX_LISTED_REFLECTIONS.weekly); - const { data, error } = await supabase - .from('period_reflections') - .select('*') - .eq('user_id', userId) - .eq('period_type', 'weekly') - .order('period_start', { ascending: false }) - .limit(limit); + try { + const data = await getJournalRepo().listPeriodReflections( + userId, + 'weekly', + limit + ); - if (error) { + return NextResponse.json({ + cards: data.map((row) => + serializePeriodReflection(row as Tables<'period_reflections'>) + ) + }); + } catch (error) { // eslint-disable-next-line no-console console.error('Failed to fetch weekly reflections', error); return NextResponse.json( @@ -37,8 +41,4 @@ export async function GET(request: NextRequest) { { status: 500 } ); } - - return NextResponse.json({ - cards: (data ?? []).map(serializePeriodReflection) - }); } diff --git a/src/app/api/transcribe/route.ts b/src/app/api/transcribe/route.ts index ba699ce..eec5ac7 100644 --- a/src/app/api/transcribe/route.ts +++ b/src/app/api/transcribe/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from 'next/server'; import { after } from 'next/server'; import OpenAI from 'openai'; -import { createAdminClient } from '@/lib/supabase/admin'; import { auth } from '@clerk/nextjs/server'; +import { getJournalRepo, NokvUnavailableError } from '@/lib/data'; +import type { JournalEntry, MoodEntry } from '@/lib/data'; import { syncReflectionsForDate } from '@/lib/reflections/sync'; import { getLocalDayRange } from '@/lib/timezone'; import { isTrustedOrigin } from '@/lib/security'; @@ -30,73 +31,39 @@ const ALLOWED_AUDIO_MIME_TYPES = new Set([ ]); // Helper function to generate daily summary -async function generateDailySummary( - userId: string, - supabase: any, - openaiClient: OpenAI -) { - const { - date: currentDate, - start: dayStart, - end: dayEnd - } = getLocalDayRange(); +async function generateDailySummary(userId: string, openaiClient: OpenAI) { + const repo = getJournalRepo(); + const { date: currentDate } = getLocalDayRange(); - // Step 1: Get all transcripts for today - const { data: transcripts, error: transcriptsError } = await supabase - .from('transcripts') - .select( - ` - id, - text, - rephrased_text, - created_at, - audio_files!inner( - created_at - ) - ` - ) - .eq('user_id', userId) - .gte('audio_files.created_at', dayStart) - .lte('audio_files.created_at', dayEnd) - .order('created_at', { ascending: true }); + // Step 1: Get all journal entries for today + const entries = await repo.listEntriesForDay(userId, currentDate); - if (transcriptsError) { - // eslint-disable-next-line no-console - console.error('Error fetching transcripts for summary:', transcriptsError); - throw transcriptsError; - } - - if (!transcripts || transcripts.length === 0) { + if (entries.length === 0) { return; } - // Step 2: Get daily mood data - const { data: moodData, error: moodError } = await supabase - .from('daily_question') - .select('day_quality, emotions') - .eq('user_id', userId) - .gte('created_at', dayStart) - .lte('created_at', dayEnd) - .single(); - - if (moodError && moodError.code !== 'PGRST116') { + // Step 2: Get daily mood data (non-fatal when unavailable) + let mood: MoodEntry | null = null; + try { + mood = await repo.getMood(userId, currentDate); + } catch (moodError) { // eslint-disable-next-line no-console console.error('Error fetching mood data for summary:', moodError); } // Step 3: Generate summary using GPT-4o - const journalTexts = transcripts - .map((t: any, index: number) => { - const time = new Date(t.created_at!).toLocaleTimeString('en-US', { + const journalTexts = entries + .map((entry, index) => { + const time = new Date(entry.created_at).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); - return `Entry ${index + 1} (${time}): ${t.rephrased_text || t.text}`; + return `Entry ${index + 1} (${time}): ${entry.transcript.rephrased_text || entry.transcript.text}`; }) .join('\n\n'); - const moodContext = moodData - ? `\nToday's mood: ${moodData.day_quality}, feeling ${moodData.emotions.join(', ')}.` + const moodContext = mood + ? `\nToday's mood: ${mood.day_quality}, feeling ${mood.emotions.join(', ')}.` : ''; const summaryResponse = await openaiClient.chat.completions.create({ @@ -105,7 +72,7 @@ async function generateDailySummary( { role: 'system', content: `You are a thoughtful journal assistant that creates concise daily summaries. - + Your task is to: - Synthesize multiple journal entries into a coherent daily narrative - Maintain first-person perspective throughout @@ -114,7 +81,7 @@ async function generateDailySummary( - Keep the summary between 3-5 sentences - Make it reflective and meaningful - Consider the overall mood context if provided - + The summary should read like a thoughtful reflection on the day, not just a list of events.` }, { @@ -128,31 +95,12 @@ async function generateDailySummary( const summary = summaryResponse.choices[0]?.message?.content || ''; // Step 4: Upsert daily summary - const { data: summaryData, error: summaryError } = await supabase - .from('daily_summaries') - .upsert( - { - user_id: userId, - date: currentDate, - summary: summary, - entry_count: transcripts.length, - mood_quality: moodData?.day_quality || null, - dominant_emotions: moodData?.emotions || null, - updated_at: new Date().toISOString() - }, - { - onConflict: 'user_id,date' - } - ) - .select() - .single(); - - if (summaryError) { - // eslint-disable-next-line no-console - console.error('Error saving summary:', summaryError); - throw summaryError; - } - return summaryData; + return repo.upsertDailySummary(userId, currentDate, { + summary, + entryCount: entries.length, + moodQuality: mood?.day_quality || null, + dominantEmotions: mood?.emotions || [] + }); } export async function POST(request: NextRequest) { @@ -217,7 +165,7 @@ export async function POST(request: NextRequest) { { role: 'system', content: `You are a helpful assistant that transforms spoken journal entries into polished first-person summaries. - + Your task is to: - Write ENTIRELY in first-person perspective (I, me, my) - Remove ALL filler words, speech disfluencies (um, uh, like, you know) @@ -227,7 +175,7 @@ export async function POST(request: NextRequest) { - Structure thoughts coherently and logically - Keep the personal, reflective tone - Aim for 3-5 sentences that capture the essence - + Transform the raw transcript into what the person would write if they were journaling directly.` }, { @@ -240,84 +188,40 @@ export async function POST(request: NextRequest) { const rephrasedText = summaryResponse.choices[0]?.message?.content || ''; - // Step 3: Store audio file in Supabase Storage - const supabase = createAdminClient(); - const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); - const fileName = `journal-audio/${userId}/${timestamp}-recording.webm`; - - const audioBuffer = await audioFile.arrayBuffer(); - const { data: storageData, error: storageError } = await supabase.storage - .from('audio-files') - .upload(fileName, audioBuffer, { - contentType: audioFile.type, - upsert: false + // Step 3: Persist audio + transcript through the data repository + const repo = getJournalRepo(); + let entry: JournalEntry; + try { + entry = await repo.createEntry(userId, { + audio: Buffer.from(await audioFile.arrayBuffer()), + mimeType: audioFile.type, + transcript: transcription, + rephrasedText: rephrasedText, + language: 'en' }); - - if (storageError) { - // eslint-disable-next-line no-console - console.error('Storage error:', storageError); - return NextResponse.json( - { error: 'Failed to store audio file' }, - { status: 500 } - ); - } - - // Step 4: Save audio file metadata - const { data: audioFileData, error: audioFileError } = await supabase - .from('audio_files') - .insert({ - user_id: userId, - storage_path: storageData.path, - mime_type: audioFile.type, - duration_ms: null // Could be calculated from audio if needed - }) - .select() - .single(); - - if (audioFileError) { - // eslint-disable-next-line no-console - console.error('Audio file DB error:', audioFileError); - return NextResponse.json( - { error: 'Failed to save audio metadata' }, - { status: 500 } - ); - } - - // Step 5: Save transcript with rephrased text - const { data: transcriptData, error: transcriptError } = await supabase - .from('transcripts') - .insert({ - user_id: userId, - audio_id: audioFileData.id, - text: transcription, - rephrased_text: rephrasedText, - language: 'en' // Could be detected from Whisper if neededs - }) - .select() - .single(); - - if (transcriptError) { + } catch (persistError) { + if (persistError instanceof NokvUnavailableError) { + return NextResponse.json( + { error: 'Data backend temporarily unavailable' }, + { status: 503 } + ); + } // eslint-disable-next-line no-console - console.error('Transcript DB error:', transcriptError); + console.error('Entry persistence error:', persistError); return NextResponse.json( - { error: 'Failed to save transcript' }, + { error: 'Failed to save journal entry' }, { status: 500 } ); } - // Step 6: Daily summary + echos sync after the response is sent. + // Step 4: Daily summary + echos sync after the response is sent. // after() is guaranteed to run on serverless, unlike a floating promise // which is dropped when the instance freezes post-response. after(async () => { try { - const summaryData = await generateDailySummary( - userId, - supabase, - openai - ); + const summaryData = await generateDailySummary(userId, openai); if (summaryData?.date) { await syncReflectionsForDate({ - supabase, openai, userId, anchorDate: summaryData.date @@ -334,8 +238,8 @@ export async function POST(request: NextRequest) { success: true, transcription, rephrasedText, - audioFileId: audioFileData.id, - transcriptId: transcriptData.id + audioFileId: entry.id, + transcriptId: entry.id }); } catch (error) { // eslint-disable-next-line no-console diff --git a/src/features/daily-record/components/daily-mood-modal.tsx b/src/features/daily-record/components/daily-mood-modal.tsx index cc992cb..d7ce3f1 100644 --- a/src/features/daily-record/components/daily-mood-modal.tsx +++ b/src/features/daily-record/components/daily-mood-modal.tsx @@ -18,9 +18,7 @@ import { import { Button } from '@/components/ui/button'; import { CloudRain, CloudSun, Sun } from 'lucide-react'; import { cn } from '@/lib/utils'; -import { createClient } from '@/lib/supabase/client'; -import { getLocalDayRange } from '@/lib/timezone'; -import { TablesInsert, Tables } from '@/types/supabase'; +import type { MoodEntry } from '@/lib/data/repository'; const DAY_QUALITY_OPTIONS = [ { value: 'good', label: 'Good day', icon: Sun }, @@ -43,32 +41,24 @@ const DailyMoodModal = forwardRef( const [isLoading, setIsLoading] = useState(false); const [dayQuality, setDayQuality] = useState(''); const [selectedEmotions, setSelectedEmotions] = useState([]); - const [existingEntry, setExistingEntry] = - useState | null>(null); - const [isUpdateMode, setIsUpdateMode] = useState(false); + const [existingEntry, setExistingEntry] = useState(null); + const isUpdateMode = existingEntry !== null; - const supabase = createClient(); - - const checkDailyEntry = useCallback(async () => { + const checkDailyEntry = useCallback(async (): Promise => { if (!user?.id) return null; - const { start, end } = getLocalDayRange(); - - const { data, error } = await supabase - .from('daily_question') - .select('*') - .eq('user_id', user.id) - .gte('created_at', start) - .lte('created_at', end) - .single(); - - if (error && error.code === 'PGRST116') { - // No entry found for today + try { + const response = await fetch('/api/mood/today'); + if (!response.ok) { + // No readable entry for today + return null; + } + const payload = (await response.json()) as { mood: MoodEntry | null }; + return payload.mood; + } catch { return null; } - - return data; - }, [user?.id, supabase]); + }, [user?.id]); const openModal = async () => { const entry = await checkDailyEntry(); @@ -78,13 +68,11 @@ const DailyMoodModal = forwardRef( setExistingEntry(entry); setDayQuality(entry.day_quality); setSelectedEmotions(entry.emotions || []); - setIsUpdateMode(true); } else { // Create mode - clear form setExistingEntry(null); setDayQuality(''); setSelectedEmotions([]); - setIsUpdateMode(false); } setIsOpen(true); @@ -119,48 +107,33 @@ const DailyMoodModal = forwardRef( setIsLoading(true); - let error; - - if (isUpdateMode && existingEntry) { - // Update existing entry - const updateData = { - day_quality: dayQuality, - emotions: selectedEmotions, - updated_at: new Date().toISOString() - }; - - const result = await supabase - .from('daily_question') - .update(updateData) - .eq('id', existingEntry.id); - - error = result.error; - } else { - // Create new entry - const entry: TablesInsert<'daily_question'> = { - user_id: user.id, - day_quality: dayQuality, - emotions: selectedEmotions - }; - - const result = await supabase.from('daily_question').insert(entry); - error = result.error; - } - - if (!error) { - setIsOpen(false); - // Reset form state - setDayQuality(''); - setSelectedEmotions([]); - setExistingEntry(null); - setIsUpdateMode(false); - - // Dispatch event to notify other components about the mood update - const event = new CustomEvent('moodEntryUpdated'); - window.dispatchEvent(event); + try { + // PUT upserts: creates today's entry or updates the existing one + const response = await fetch('/api/mood/today', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + dayQuality, + emotions: selectedEmotions + }) + }); + + if (response.ok) { + setIsOpen(false); + // Reset form state + setDayQuality(''); + setSelectedEmotions([]); + setExistingEntry(null); + + // Dispatch event to notify other components about the mood update + const event = new CustomEvent('moodEntryUpdated'); + window.dispatchEvent(event); + } + } catch (err) { + console.error('Error saving mood entry:', err); + } finally { + setIsLoading(false); } - - setIsLoading(false); }; const isValid = dayQuality.length > 0; diff --git a/src/hooks/use-audio-journal.ts b/src/hooks/use-audio-journal.ts index 2ec7e04..86e6ebd 100644 --- a/src/hooks/use-audio-journal.ts +++ b/src/hooks/use-audio-journal.ts @@ -2,22 +2,26 @@ import { useState, useEffect, useCallback } from 'react'; import { useUser } from '@clerk/nextjs'; -import { createClient } from '@/lib/supabase/client'; -import { - getTodayAudioJournals, - getRecentAudioJournals, - getAudioJournalStats -} from '@/lib/supabase/queries'; -import type { Tables } from '@/types/supabase'; - -type AudioJournalWithTranscript = Tables<'audio_files'> & { - transcripts: { - id: string; - text: string | null; - language: string | null; - created_at: string | null; - }[]; -}; + +// Wire shape returned by GET /api/journals/today (mirrors the audio_files + +// transcripts rows the browser previously read from Supabase directly). +interface AudioJournalTranscript { + id: string; + text: string | null; + rephrased_text: string | null; + language: string | null; + created_at: string | null; +} + +interface AudioJournalWithTranscript { + id: string; + user_id: string; + storage_path: string; + mime_type: string | null; + duration_ms: number | null; + created_at: string | null; + transcripts: AudioJournalTranscript[]; +} interface AudioJournalStats { totalEntries: number; @@ -31,11 +35,6 @@ interface UseAudioJournalReturn { todayLoading: boolean; todayError: string | null; - // Recent entries - recentEntries: AudioJournalWithTranscript[]; - recentLoading: boolean; - recentError: string | null; - // Stats stats: AudioJournalStats; statsLoading: boolean; @@ -44,13 +43,11 @@ interface UseAudioJournalReturn { // Actions refetchAll: () => Promise; refetchToday: () => Promise; - refetchRecent: () => Promise; refetchStats: () => Promise; } export function useAudioJournal(): UseAudioJournalReturn { const { user } = useUser(); - const supabase = createClient(); // Today's entries state const [todayEntries, setTodayEntries] = useState< @@ -59,13 +56,6 @@ export function useAudioJournal(): UseAudioJournalReturn { const [todayLoading, setTodayLoading] = useState(true); const [todayError, setTodayError] = useState(null); - // Recent entries state - const [recentEntries, setRecentEntries] = useState< - AudioJournalWithTranscript[] - >([]); - const [recentLoading, setRecentLoading] = useState(true); - const [recentError, setRecentError] = useState(null); - // Stats state const [stats, setStats] = useState({ totalEntries: 0, @@ -85,8 +75,14 @@ export function useAudioJournal(): UseAudioJournalReturn { try { setTodayError(null); - const entries = await getTodayAudioJournals(supabase, user.id); - setTodayEntries(entries); + const response = await fetch('/api/journals/today'); + if (!response.ok) { + throw new Error('Failed to fetch today audio entries'); + } + const payload = (await response.json()) as { + entries: AudioJournalWithTranscript[]; + }; + setTodayEntries(payload.entries); } catch (err) { console.error('Error fetching today audio entries:', err); setTodayError( @@ -97,31 +93,7 @@ export function useAudioJournal(): UseAudioJournalReturn { } finally { setTodayLoading(false); } - }, [user?.id, supabase]); - - // Fetch recent entries - const fetchRecentEntries = useCallback(async () => { - if (!user?.id) { - setRecentEntries([]); - setRecentLoading(false); - return; - } - - try { - setRecentError(null); - const entries = await getRecentAudioJournals(supabase, user.id, 10); - setRecentEntries(entries); - } catch (err) { - console.error('Error fetching recent audio entries:', err); - setRecentError( - err instanceof Error - ? err.message - : 'Failed to fetch recent audio entries' - ); - } finally { - setRecentLoading(false); - } - }, [user?.id, supabase]); + }, [user?.id]); // Fetch stats const fetchStats = useCallback(async () => { @@ -133,8 +105,12 @@ export function useAudioJournal(): UseAudioJournalReturn { try { setStatsError(null); - const statsData = await getAudioJournalStats(supabase, user.id); - setStats(statsData); + const response = await fetch('/api/journals/stats'); + if (!response.ok) { + throw new Error('Failed to fetch audio journal stats'); + } + const payload = (await response.json()) as { stats: AudioJournalStats }; + setStats(payload.stats); } catch (err) { console.error('Error fetching audio journal stats:', err); setStatsError( @@ -145,7 +121,7 @@ export function useAudioJournal(): UseAudioJournalReturn { } finally { setStatsLoading(false); } - }, [user?.id, supabase]); + }, [user?.id]); // Refetch functions const refetchToday = useCallback(async () => { @@ -153,11 +129,6 @@ export function useAudioJournal(): UseAudioJournalReturn { await fetchTodayEntries(); }, [fetchTodayEntries]); - const refetchRecent = useCallback(async () => { - setRecentLoading(true); - await fetchRecentEntries(); - }, [fetchRecentEntries]); - const refetchStats = useCallback(async () => { setStatsLoading(true); await fetchStats(); @@ -165,22 +136,16 @@ export function useAudioJournal(): UseAudioJournalReturn { const refetchAll = useCallback(async () => { setTodayLoading(true); - setRecentLoading(true); setStatsLoading(true); - await Promise.all([ - fetchTodayEntries(), - fetchRecentEntries(), - fetchStats() - ]); - }, [fetchTodayEntries, fetchRecentEntries, fetchStats]); + await Promise.all([fetchTodayEntries(), fetchStats()]); + }, [fetchTodayEntries, fetchStats]); // Initial fetch useEffect(() => { fetchTodayEntries(); - fetchRecentEntries(); fetchStats(); - }, [fetchTodayEntries, fetchRecentEntries, fetchStats]); + }, [fetchTodayEntries, fetchStats]); // Listen for audio journal updates useEffect(() => { @@ -201,15 +166,11 @@ export function useAudioJournal(): UseAudioJournalReturn { todayEntries, todayLoading, todayError, - recentEntries, - recentLoading, - recentError, stats, statsLoading, statsError, refetchAll, refetchToday, - refetchRecent, refetchStats }; } diff --git a/src/hooks/use-today-mood.ts b/src/hooks/use-today-mood.ts index 1e95486..02592f8 100644 --- a/src/hooks/use-today-mood.ts +++ b/src/hooks/use-today-mood.ts @@ -2,12 +2,10 @@ import { useState, useEffect, useCallback } from 'react'; import { useUser } from '@clerk/nextjs'; -import { createClient } from '@/lib/supabase/client'; -import { getTodayMoodEntry } from '@/lib/supabase/queries'; -import type { Tables } from '@/types/supabase'; +import type { MoodEntry } from '@/lib/data/repository'; interface UseTodayMoodReturn { - moodEntry: Tables<'daily_question'> | null; + moodEntry: MoodEntry | null; isLoading: boolean; error: string | null; refetch: () => Promise; @@ -15,14 +13,10 @@ interface UseTodayMoodReturn { export function useTodayMood(): UseTodayMoodReturn { const { user } = useUser(); - const [moodEntry, setMoodEntry] = useState | null>( - null - ); + const [moodEntry, setMoodEntry] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - const supabase = createClient(); - const fetchMoodEntry = useCallback(async () => { if (!user?.id) { setMoodEntry(null); @@ -32,8 +26,12 @@ export function useTodayMood(): UseTodayMoodReturn { try { setError(null); - const entry = await getTodayMoodEntry(supabase, user.id); - setMoodEntry(entry); + const response = await fetch('/api/mood/today'); + if (!response.ok) { + throw new Error('Failed to fetch mood entry'); + } + const payload = (await response.json()) as { mood: MoodEntry | null }; + setMoodEntry(payload.mood); } catch (err) { console.error('Error fetching mood entry:', err); setError( @@ -42,7 +40,7 @@ export function useTodayMood(): UseTodayMoodReturn { } finally { setIsLoading(false); } - }, [user?.id, supabase]); + }, [user?.id]); const refetch = useCallback(async () => { setIsLoading(true); diff --git a/src/lib/agent/context.ts b/src/lib/agent/context.ts index e521140..de6ee2b 100644 --- a/src/lib/agent/context.ts +++ b/src/lib/agent/context.ts @@ -1,5 +1,10 @@ -import type { SupabaseClient } from '@supabase/supabase-js'; -import type { Database, Tables } from '@/types/supabase'; +import type { Tables } from '@/types/supabase'; +import type { + DailySummary, + JournalDataRepository, + MoodEntry, + PeriodReflection +} from '@/lib/data'; import { getLocalDayRange } from '@/lib/timezone'; import { getPeriodBounds } from '@/lib/reflections/aggregate'; @@ -52,8 +57,6 @@ export interface ContextResponse { > | null; } -type AdminClient = SupabaseClient; - const pickRange = (scope: ContextScope, anchorDate?: string) => { if (scope === 'today') { const { date, start, end } = getLocalDayRange({ @@ -82,7 +85,7 @@ const pickRange = (scope: ContextScope, anchorDate?: string) => { }; }; -const mapSummaries = (rows: Tables<'daily_summaries'>[]) => +const mapSummaries = (rows: DailySummary[]): ContextResponse['summaries'] => rows.map((row) => ({ id: row.id, date: row.date, @@ -94,10 +97,12 @@ const mapSummaries = (rows: Tables<'daily_summaries'>[]) => achievements: row.achievements, commitments: row.commitments, flashback: row.flashback, - stats: row.stats + stats: row.stats as Tables<'daily_summaries'>['stats'] })); -const mapReflections = (rows: Tables<'period_reflections'>[]) => +const mapReflections = ( + rows: PeriodReflection[] +): ContextResponse['reflections'] => rows.map((row) => ({ id: row.id, period_type: row.period_type, @@ -108,11 +113,17 @@ const mapReflections = (rows: Tables<'period_reflections'>[]) => mood_overall: row.mood_overall, mood_reason: row.mood_reason, flashback: row.flashback, - stats: row.stats + stats: row.stats as Tables<'period_reflections'>['stats'] })); +const mapMood = (mood: MoodEntry): ContextResponse['mood'] => ({ + day_quality: mood.day_quality, + emotions: mood.emotions, + created_at: mood.created_at +}); + export async function fetchUserContext( - client: AdminClient, + repo: JournalDataRepository, userId: string, payload: ContextRequest ): Promise { @@ -126,28 +137,18 @@ export async function fetchUserContext( }; if (payload.scope === 'recent') { - const { data } = await client - .from('daily_summaries') - .select('*') - .eq('user_id', userId) - .order('date', { ascending: false }) - .limit(limit); - - result.summaries = mapSummaries(data ?? []); + const summaries = await repo.listDailySummaries(userId, { limit }); + result.summaries = mapSummaries(summaries); return result; } if (payload.scope === 'custom' && payload.range) { - const { data } = await client - .from('daily_summaries') - .select('*') - .eq('user_id', userId) - .gte('date', payload.range.start) - .lte('date', payload.range.end) - .order('date', { ascending: true }) - .limit(limit); - - result.summaries = mapSummaries(data ?? []); + const summaries = await repo.listDailySummariesInRange( + userId, + payload.range.start, + payload.range.end + ); + result.summaries = mapSummaries(summaries.slice(0, limit)); return result; } @@ -155,75 +156,46 @@ export async function fetchUserContext( result.anchorDate = anchor; if (payload.scope === 'today') { - const { data: summaries } = await client - .from('daily_summaries') - .select('*') - .eq('user_id', userId) - .eq('date', anchor) - .order('date', { ascending: true }) - .limit(limit); - - result.summaries = mapSummaries(summaries ?? []); - - const { data: mood } = await client - .from('daily_question') - .select('day_quality, emotions, created_at') - .eq('user_id', userId) - .gte('created_at', start ?? `${anchor}T00:00:00Z`) - .lte('created_at', end ?? `${anchor}T23:59:59.999Z`) - .order('created_at', { ascending: false }) - .maybeSingle(); - - result.mood = mood ?? null; + const summary = await repo.getDailySummary(userId, anchor); + result.summaries = mapSummaries(summary ? [summary] : []); + + const mood = await repo.getMood(userId, anchor); + result.mood = mood ? mapMood(mood) : null; return result; } if (start && end) { - const { data: summaries } = await client - .from('daily_summaries') - .select('*') - .eq('user_id', userId) - .gte('date', start.slice(0, 10)) - .lte('date', end.slice(0, 10)) - .order('date', { ascending: true }) - .limit(limit); - - result.summaries = mapSummaries(summaries ?? []); - - const { data: mood } = await client - .from('daily_question') - .select('day_quality, emotions, created_at') - .eq('user_id', userId) - .gte('created_at', start) - .lte('created_at', end) - .order('created_at', { ascending: false }) - .maybeSingle(); - - result.mood = mood ?? null; + const rangeStart = start.slice(0, 10); + const rangeEnd = end.slice(0, 10); + + const summaries = await repo.listDailySummariesInRange( + userId, + rangeStart, + rangeEnd + ); + result.summaries = mapSummaries(summaries.slice(0, limit)); + + const moods = await repo.listMoodsInRange(userId, rangeStart, rangeEnd); + const latestMood = moods.length > 0 ? moods[moods.length - 1] : null; + result.mood = latestMood ? mapMood(latestMood) : null; if (payload.scope === 'week' || payload.scope === 'month') { const periodType = payload.scope === 'week' ? 'weekly' : 'monthly'; - const { data: reflections } = await client - .from('period_reflections') - .select('*') - .eq('user_id', userId) - .eq('period_type', periodType) - .eq('period_start', start.slice(0, 10)) - .eq('period_end', end.slice(0, 10)); - - result.reflections = mapReflections(reflections ?? []); + const reflection = await repo.getPeriodReflection( + userId, + periodType, + rangeStart + ); + result.reflections = + reflection && reflection.period_end === rangeEnd + ? mapReflections([reflection]) + : []; } return result; } - const { data } = await client - .from('daily_summaries') - .select('*') - .eq('user_id', userId) - .order('date', { ascending: false }) - .limit(limit); - - result.summaries = mapSummaries(data ?? []); + const summaries = await repo.listDailySummaries(userId, { limit }); + result.summaries = mapSummaries(summaries); return result; } diff --git a/src/lib/data/index.ts b/src/lib/data/index.ts new file mode 100644 index 0000000..417033b --- /dev/null +++ b/src/lib/data/index.ts @@ -0,0 +1,83 @@ +import 'server-only'; + +import type { JournalDataRepository } from './repository'; +import { NokvUnavailableError } from './repository'; +import { SupabaseJournalRepository } from './supabase-repository'; +import { NokvJournalRepository } from '@/lib/nokv/journal-repository'; + +// DATA_BACKEND=supabase (default) | nokv +// In nokv mode reads fall back to Supabase when the NoKV transport is down +// (dual writes keep it fresh during migration); writes surface the error so +// routes return 503 instead of split-braining across two primaries. + +const READ_METHODS = new Set([ + 'getMood', + 'listMoodsInRange', + 'getEntry', + 'getAudioBlob', + 'listEntriesForDay', + 'getEntryStats', + 'getDailySummary', + 'listDailySummaries', + 'listDailySummariesInRange', + 'queryJournalDays', + 'getPeriodReflection', + 'getPeriodReflectionById', + 'listPeriodReflections' +]); + +function withReadFallback( + primary: JournalDataRepository, + fallback: JournalDataRepository +): JournalDataRepository { + return new Proxy(primary, { + get(target, prop: string | symbol) { + const value = Reflect.get(target, prop); + if ( + typeof value !== 'function' || + !READ_METHODS.has(prop as keyof JournalDataRepository) + ) { + return typeof value === 'function' ? value.bind(target) : value; + } + return async (...args: unknown[]) => { + try { + return await (value as (...a: unknown[]) => Promise).apply( + target, + args + ); + } catch (err) { + if (err instanceof NokvUnavailableError) { + // eslint-disable-next-line no-console + console.error( + `[data] nokv unavailable for ${String(prop)}; falling back to supabase` + ); + const fb = Reflect.get(fallback, prop) as ( + ...a: unknown[] + ) => Promise; + return fb.apply(fallback, args); + } + throw err; + } + }; + } + }); +} + +let repoMemo: JournalDataRepository | null = null; + +export function getJournalRepo(): JournalDataRepository { + if (repoMemo) return repoMemo; + const backend = process.env.DATA_BACKEND ?? 'supabase'; + if (backend === 'nokv') { + repoMemo = withReadFallback( + new NokvJournalRepository(), + new SupabaseJournalRepository() + ); + } else { + repoMemo = new SupabaseJournalRepository(); + } + return repoMemo; +} + +export type { JournalDataRepository } from './repository'; +export * from './repository'; diff --git a/src/lib/data/repository.ts b/src/lib/data/repository.ts new file mode 100644 index 0000000..ccd80fd --- /dev/null +++ b/src/lib/data/repository.ts @@ -0,0 +1,218 @@ +// Backend-agnostic data layer for EchoJournal (design: docs/nokv-data-migration-design.md §4). +// Domain types intentionally mirror the Tables<'daily_summaries'> / +// Tables<'period_reflections'> column shapes so serialize.ts, mood-utils.ts +// and every UI component keep working unchanged. + +export type YmdString = string; // "YYYY-MM-DD" in APP_TIMEZONE + +export interface MoodEntry { + id: string; + date: YmdString; + day_quality: string; + emotions: string[]; + created_at: string; + updated_at: string | null; +} + +export interface JournalEntry { + id: string; + legacy_audio_id: string | null; + date: YmdString; + created_at: string; + audio: { + mime_type: string; + size_bytes: number | null; + duration_ms: number | null; + deleted: boolean; + }; + transcript: { + text: string; + rephrased_text: string | null; + language: string | null; + updated_at: string; + }; +} + +export interface DailySummary { + id: string; + user_id: string; + date: YmdString; + summary: string; + entry_count: number | null; + mood_quality: string | null; + mood_overall: string | null; + mood_reason: string | null; + dominant_emotions: string[] | null; + achievements: string[] | null; + commitments: string[] | null; + flashback: string | null; + stats: unknown; + gen_version: string | null; + edited: boolean | null; + created_at: string | null; + updated_at: string | null; + last_generated_at: string | null; +} + +export interface PeriodReflection { + id: string; + user_id: string; + period_type: string; + period_start: YmdString; + period_end: YmdString; + mood_overall: string | null; + mood_reason: string | null; + achievements: string[] | null; + commitments: string[] | null; + flashback: string | null; + stats: unknown; + gen_version: string | null; + edited: boolean | null; + created_at: string | null; + updated_at: string | null; + last_generated_at: string | null; +} + +export interface DayBundle { + date: YmdString; + summary: DailySummary | null; + entries: JournalEntry[]; + mood: MoodEntry | null; +} + +export type PeriodType = 'weekly' | 'monthly'; + +/** Thrown by the NoKV backend when the transport is unavailable; the factory + * falls back (reads) or surfaces 503 (writes). Never thrown by Supabase. */ +export class NokvUnavailableError extends Error {} + +export interface EntryStats { + totalEntries: number; + thisWeekEntries: number; + currentStreak: number; +} + +export interface JournalDataRepository { + // ── mood + getMood(userId: string, date: YmdString): Promise; + upsertMood( + userId: string, + date: YmdString, + input: { dayQuality: string; emotions: string[] } + ): Promise; + listMoodsInRange( + userId: string, + start: YmdString, + end: YmdString + ): Promise; + + // ── journal entries (audio + transcript folded 1:1) + createEntry( + userId: string, + input: { + audio: Buffer; + mimeType: string; + transcript: string; + rephrasedText: string | null; + language: string; + } + ): Promise; + getEntry(userId: string, entryId: string): Promise; + getAudioBlob( + userId: string, + entryId: string + ): Promise<{ data: Buffer; mimeType: string } | null>; + updateRephrasedText( + userId: string, + entryId: string, + text: string + ): Promise; + deleteEntry(userId: string, entryId: string): Promise; + listEntriesForDay(userId: string, date: YmdString): Promise; + getEntryStats(userId: string): Promise; + + // ── daily summaries + getDailySummary( + userId: string, + date: YmdString + ): Promise; + upsertDailySummary( + userId: string, + date: YmdString, + fields: { + summary: string; + entryCount: number; + moodQuality: string | null; + dominantEmotions: string[]; + } + ): Promise; + updateDailySummaryReflection( + userId: string, + date: YmdString, + patch: Partial< + Pick< + DailySummary, + | 'achievements' + | 'commitments' + | 'mood_overall' + | 'mood_reason' + | 'flashback' + | 'stats' + | 'gen_version' + | 'last_generated_at' + > + >, + opts: { markEdited?: boolean; preserveIfEdited?: boolean } + ): Promise; + listDailySummaries( + userId: string, + opts: { before?: YmdString; limit: number } + ): Promise; + listDailySummariesInRange( + userId: string, + start: YmdString, + end: YmdString + ): Promise; + queryJournalDays( + userId: string, + filters: { + startDate?: YmdString; + endDate?: YmdString; + moods?: string[]; + keyword?: string; + page: number; + limit: number; + } + ): Promise<{ days: DayBundle[]; totalCount: number }>; + + // ── period reflections + getPeriodReflection( + userId: string, + type: PeriodType, + periodStart: YmdString + ): Promise; + getPeriodReflectionById( + userId: string, + id: string + ): Promise; + upsertPeriodReflection( + userId: string, + type: PeriodType, + periodStart: YmdString, + periodEnd: YmdString, + fields: Partial + ): Promise; + updatePeriodReflectionFields( + userId: string, + id: string, + patch: Partial + ): Promise; + listPeriodReflections( + userId: string, + type: PeriodType, + limit: number + ): Promise; + + // ── privacy + deleteAllUserData(userId: string): Promise; +} diff --git a/src/lib/data/supabase-repository.ts b/src/lib/data/supabase-repository.ts new file mode 100644 index 0000000..a740bb6 --- /dev/null +++ b/src/lib/data/supabase-repository.ts @@ -0,0 +1,670 @@ +import 'server-only'; + +import type { SupabaseClient } from '@supabase/supabase-js'; +import type { Database, Tables } from '@/types/supabase'; +import { createAdminClient } from '@/lib/supabase/admin'; +import { getLocalDayRange, getUtcRangeForDate } from '@/lib/timezone'; +import type { + DailySummary, + DayBundle, + EntryStats, + JournalDataRepository, + JournalEntry, + MoodEntry, + PeriodReflection, + PeriodType, + YmdString +} from './repository'; + +// Mechanical consolidation of the previous inline Supabase queries +// (src/lib/supabase/queries.ts + 12 API routes). Behavior-preserving, with +// one sanctioned unification: every "day" boundary goes through timezone.ts +// (aggregate.ts previously used bare date-string bounds). + +type Client = SupabaseClient; +type AudioRow = Tables<'audio_files'> & { + transcripts: Array<{ + id: string; + text: string | null; + rephrased_text: string | null; + language: string | null; + created_at: string | null; + }>; +}; + +const AUDIO_WITH_TRANSCRIPTS = ` + *, + transcripts ( + id, + text, + rephrased_text, + language, + created_at + ) +`; + +function toMood(row: Tables<'daily_question'>): MoodEntry { + return { + id: row.id, + date: getLocalDayRange({ date: new Date(row.created_at!) }).date, + day_quality: row.day_quality, + emotions: row.emotions ?? [], + created_at: row.created_at!, + updated_at: row.updated_at + }; +} + +function toEntry(row: AudioRow): JournalEntry { + const transcript = row.transcripts?.[0]; + return { + id: row.id, + legacy_audio_id: row.id, + date: getLocalDayRange({ date: new Date(row.created_at!) }).date, + created_at: row.created_at!, + audio: { + mime_type: row.mime_type ?? 'audio/webm', + size_bytes: null, + duration_ms: row.duration_ms, + deleted: false + }, + transcript: { + text: transcript?.text ?? '', + rephrased_text: transcript?.rephrased_text ?? null, + language: transcript?.language ?? null, + updated_at: transcript?.created_at ?? row.created_at! + } + }; +} + +function toSummary(row: Tables<'daily_summaries'>): DailySummary { + return { ...row } as DailySummary; +} + +function toReflection(row: Tables<'period_reflections'>): PeriodReflection { + return { ...row } as PeriodReflection; +} + +export class SupabaseJournalRepository implements JournalDataRepository { + private db: Client; + + constructor(client?: Client) { + this.db = client ?? createAdminClient(); + } + + // ── mood ────────────────────────────────────────────────────────── + + async getMood(userId: string, date: YmdString): Promise { + const { start, end } = getUtcRangeForDate(date); + const { data, error } = await this.db + .from('daily_question') + .select('*') + .eq('user_id', userId) + .gte('created_at', start) + .lte('created_at', end) + .maybeSingle(); + if (error) throw error; + return data ? toMood(data) : null; + } + + async upsertMood( + userId: string, + date: YmdString, + input: { dayQuality: string; emotions: string[] } + ): Promise { + const { start, end } = getUtcRangeForDate(date); + const { data: existing, error: readError } = await this.db + .from('daily_question') + .select('*') + .eq('user_id', userId) + .gte('created_at', start) + .lte('created_at', end) + .maybeSingle(); + if (readError) throw readError; + + if (existing) { + const { data, error } = await this.db + .from('daily_question') + .update({ + day_quality: input.dayQuality, + emotions: input.emotions, + updated_at: new Date().toISOString() + }) + .eq('id', existing.id) + .select() + .single(); + if (error) throw error; + return toMood(data); + } + + const { data, error } = await this.db + .from('daily_question') + .insert({ + user_id: userId, + day_quality: input.dayQuality, + emotions: input.emotions + }) + .select() + .single(); + if (error) throw error; + return toMood(data); + } + + async listMoodsInRange( + userId: string, + start: YmdString, + end: YmdString + ): Promise { + const { start: startTs } = getUtcRangeForDate(start); + const { end: endTs } = getUtcRangeForDate(end); + const { data, error } = await this.db + .from('daily_question') + .select('*') + .eq('user_id', userId) + .gte('created_at', startTs) + .lte('created_at', endTs) + .order('created_at', { ascending: true }); + if (error) throw error; + return (data ?? []).map(toMood); + } + + // ── journal entries ─────────────────────────────────────────────── + + async createEntry( + userId: string, + input: { + audio: Buffer; + mimeType: string; + transcript: string; + rephrasedText: string | null; + language: string; + } + ): Promise { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const fileName = `journal-audio/${userId}/${timestamp}-recording.webm`; + + const { data: storageData, error: storageError } = await this.db.storage + .from('audio-files') + .upload(fileName, input.audio, { + contentType: input.mimeType, + upsert: false + }); + if (storageError) throw storageError; + + const { data: audioRow, error: audioError } = await this.db + .from('audio_files') + .insert({ + user_id: userId, + storage_path: storageData.path, + mime_type: input.mimeType, + duration_ms: null + }) + .select() + .single(); + if (audioError) throw audioError; + + const { data: transcriptRow, error: transcriptError } = await this.db + .from('transcripts') + .insert({ + user_id: userId, + audio_id: audioRow.id, + text: input.transcript, + rephrased_text: input.rephrasedText, + language: input.language + }) + .select() + .single(); + if (transcriptError) throw transcriptError; + + return toEntry({ + ...audioRow, + transcripts: [transcriptRow] + } as AudioRow); + } + + async getEntry( + userId: string, + entryId: string + ): Promise { + const { data, error } = await this.db + .from('audio_files') + .select(AUDIO_WITH_TRANSCRIPTS) + .eq('id', entryId) + .eq('user_id', userId) + .maybeSingle(); + if (error) throw error; + return data ? toEntry(data as AudioRow) : null; + } + + async getAudioBlob( + userId: string, + entryId: string + ): Promise<{ data: Buffer; mimeType: string } | null> { + const { data: meta, error } = await this.db + .from('audio_files') + .select('storage_path, mime_type') + .eq('id', entryId) + .eq('user_id', userId) + .maybeSingle(); + if (error) throw error; + if (!meta) return null; + + const { data: blob, error: downloadError } = await this.db.storage + .from('audio-files') + .download(meta.storage_path); + if (downloadError || !blob) return null; + + return { + data: Buffer.from(await blob.arrayBuffer()), + mimeType: meta.mime_type ?? 'audio/webm' + }; + } + + async updateRephrasedText( + userId: string, + entryId: string, + text: string + ): Promise { + const { error } = await this.db + .from('transcripts') + .update({ rephrased_text: text }) + .eq('audio_id', entryId) + .eq('user_id', userId); + if (error) throw error; + + const entry = await this.getEntry(userId, entryId); + if (!entry) throw new Error('Entry not found after update'); + return entry; + } + + async deleteEntry(userId: string, entryId: string): Promise { + const { data: audioRow, error: readError } = await this.db + .from('audio_files') + .select('id, storage_path') + .eq('id', entryId) + .eq('user_id', userId) + .maybeSingle(); + if (readError) throw readError; + if (!audioRow) return; + + const { error: transcriptError } = await this.db + .from('transcripts') + .delete() + .eq('audio_id', entryId) + .eq('user_id', userId); + if (transcriptError) throw transcriptError; + + if (audioRow.storage_path) { + const { error: storageError } = await this.db.storage + .from('audio-files') + .remove([audioRow.storage_path]); + if (storageError) { + // eslint-disable-next-line no-console + console.error('Audio blob removal failed:', storageError); + } + } + + const { error: rowError } = await this.db + .from('audio_files') + .delete() + .eq('id', entryId) + .eq('user_id', userId); + if (rowError) throw rowError; + } + + async listEntriesForDay( + userId: string, + date: YmdString + ): Promise { + const { start, end } = getUtcRangeForDate(date); + const { data, error } = await this.db + .from('audio_files') + .select(AUDIO_WITH_TRANSCRIPTS) + .eq('user_id', userId) + .gte('created_at', start) + .lte('created_at', end) + .order('created_at', { ascending: true }); + if (error) throw error; + return (data ?? []).map((row) => toEntry(row as AudioRow)); + } + + async getEntryStats(userId: string): Promise { + const { count: totalEntries } = await this.db + .from('audio_files') + .select('*', { count: 'exact', head: true }) + .eq('user_id', userId); + + const weekStart = new Date(); + weekStart.setDate(weekStart.getDate() - weekStart.getDay()); + weekStart.setHours(0, 0, 0, 0); + const { count: thisWeekEntries } = await this.db + .from('audio_files') + .select('*', { count: 'exact', head: true }) + .eq('user_id', userId) + .gte('created_at', weekStart.toISOString()); + + const { data: recentEntries } = await this.db + .from('audio_files') + .select('created_at') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .limit(30); + + let currentStreak = 0; + if (recentEntries) { + const today = new Date(); + const dates = new Set( + recentEntries.map( + (entry) => + getLocalDayRange({ date: new Date(entry.created_at!) }).date + ) + ); + for (let i = 0; i < 30; i++) { + const checkDate = new Date(today); + checkDate.setDate(checkDate.getDate() - i); + if (dates.has(getLocalDayRange({ date: checkDate }).date)) { + currentStreak++; + } else { + break; + } + } + } + + return { + totalEntries: totalEntries || 0, + thisWeekEntries: thisWeekEntries || 0, + currentStreak + }; + } + + // ── daily summaries ─────────────────────────────────────────────── + + async getDailySummary( + userId: string, + date: YmdString + ): Promise { + const { data, error } = await this.db + .from('daily_summaries') + .select('*') + .eq('user_id', userId) + .eq('date', date) + .maybeSingle(); + if (error) throw error; + return data ? toSummary(data) : null; + } + + async upsertDailySummary( + userId: string, + date: YmdString, + fields: { + summary: string; + entryCount: number; + moodQuality: string | null; + dominantEmotions: string[]; + } + ): Promise { + const { data, error } = await this.db + .from('daily_summaries') + .upsert( + { + user_id: userId, + date, + summary: fields.summary, + entry_count: fields.entryCount, + mood_quality: fields.moodQuality, + dominant_emotions: fields.dominantEmotions, + updated_at: new Date().toISOString() + }, + { onConflict: 'user_id,date' } + ) + .select() + .single(); + if (error) throw error; + return toSummary(data); + } + + async updateDailySummaryReflection( + userId: string, + date: YmdString, + patch: Partial< + Pick< + DailySummary, + | 'achievements' + | 'commitments' + | 'mood_overall' + | 'mood_reason' + | 'flashback' + | 'stats' + | 'gen_version' + | 'last_generated_at' + > + >, + opts: { markEdited?: boolean; preserveIfEdited?: boolean } + ): Promise { + const existing = await this.getDailySummary(userId, date); + if (!existing) throw new Error(`No daily summary for ${date}`); + + const preserveUserFields = Boolean( + opts.preserveIfEdited && existing.edited + ); + const updatePayload: Record = { ...patch }; + if (preserveUserFields) { + delete updatePayload.achievements; + delete updatePayload.commitments; + delete updatePayload.mood_overall; + delete updatePayload.mood_reason; + delete updatePayload.flashback; + } + if (opts.markEdited) updatePayload.edited = true; + updatePayload.updated_at = new Date().toISOString(); + + const { data, error } = await this.db + .from('daily_summaries') + .update(updatePayload) + .eq('id', existing.id) + .select() + .single(); + if (error) throw error; + return toSummary(data); + } + + async listDailySummaries( + userId: string, + opts: { before?: YmdString; limit: number } + ): Promise { + let query = this.db + .from('daily_summaries') + .select('*') + .eq('user_id', userId); + if (opts.before) query = query.lte('date', opts.before); + const { data, error } = await query + .order('date', { ascending: false }) + .limit(opts.limit); + if (error) throw error; + return (data ?? []).map(toSummary); + } + + async listDailySummariesInRange( + userId: string, + start: YmdString, + end: YmdString + ): Promise { + const { data, error } = await this.db + .from('daily_summaries') + .select('*') + .eq('user_id', userId) + .gte('date', start) + .lte('date', end) + .order('date', { ascending: true }); + if (error) throw error; + return (data ?? []).map(toSummary); + } + + async queryJournalDays( + userId: string, + filters: { + startDate?: YmdString; + endDate?: YmdString; + moods?: string[]; + keyword?: string; + page: number; + limit: number; + } + ): Promise<{ days: DayBundle[]; totalCount: number }> { + const offset = (filters.page - 1) * filters.limit; + + let query = this.db + .from('daily_summaries') + .select('*', { count: 'exact' }) + .eq('user_id', userId); + if (filters.startDate) query = query.gte('date', filters.startDate); + if (filters.endDate) query = query.lte('date', filters.endDate); + if (filters.moods && filters.moods.length > 0) { + query = query.in('mood_quality', filters.moods); + } + if (filters.keyword) { + query = query.ilike('summary', `%${filters.keyword}%`); + } + + const { + data: summaries, + error, + count + } = await query + .order('date', { ascending: false }) + .range(offset, offset + filters.limit - 1); + if (error) throw error; + + const days = await Promise.all( + (summaries ?? []).map(async (summary): Promise => { + const [entries, mood] = await Promise.all([ + this.listEntriesForDay(userId, summary.date), + this.getMood(userId, summary.date) + ]); + return { + date: summary.date, + summary: toSummary(summary), + entries, + mood + }; + }) + ); + + return { days, totalCount: count || 0 }; + } + + // ── period reflections ──────────────────────────────────────────── + + async getPeriodReflection( + userId: string, + type: PeriodType, + periodStart: YmdString + ): Promise { + const { data, error } = await this.db + .from('period_reflections') + .select('*') + .eq('user_id', userId) + .eq('period_type', type) + .eq('period_start', periodStart) + .maybeSingle(); + if (error) throw error; + return data ? toReflection(data) : null; + } + + async getPeriodReflectionById( + userId: string, + id: string + ): Promise { + const { data, error } = await this.db + .from('period_reflections') + .select('*') + .eq('user_id', userId) + .eq('id', id) + .maybeSingle(); + if (error) throw error; + return data ? toReflection(data) : null; + } + + async upsertPeriodReflection( + userId: string, + type: PeriodType, + periodStart: YmdString, + periodEnd: YmdString, + fields: Partial + ): Promise { + const { data, error } = await this.db + .from('period_reflections') + .upsert( + { + user_id: userId, + period_type: type, + period_start: periodStart, + period_end: periodEnd, + ...(fields as Record), + updated_at: new Date().toISOString() + }, + { onConflict: 'user_id,period_type,period_start' } + ) + .select() + .single(); + if (error) throw error; + return toReflection(data); + } + + async updatePeriodReflectionFields( + userId: string, + id: string, + patch: Partial + ): Promise { + const { data, error } = await this.db + .from('period_reflections') + .update({ + ...(patch as Record), + updated_at: new Date().toISOString() + }) + .eq('id', id) + .eq('user_id', userId) + .select() + .single(); + if (error) throw error; + return toReflection(data); + } + + async listPeriodReflections( + userId: string, + type: PeriodType, + limit: number + ): Promise { + const { data, error } = await this.db + .from('period_reflections') + .select('*') + .eq('user_id', userId) + .eq('period_type', type) + .order('period_start', { ascending: false }) + .limit(limit); + if (error) throw error; + return (data ?? []).map(toReflection); + } + + // ── privacy ─────────────────────────────────────────────────────── + + async deleteAllUserData(userId: string): Promise { + // Order respects the audio_files <- transcripts FK. + await this.db.from('transcripts').delete().eq('user_id', userId); + + const { data: audioRows } = await this.db + .from('audio_files') + .select('storage_path') + .eq('user_id', userId); + const paths = (audioRows ?? []) + .map((r) => r.storage_path) + .filter((p): p is string => Boolean(p)); + if (paths.length > 0) { + await this.db.storage.from('audio-files').remove(paths); + } + + await this.db.from('audio_files').delete().eq('user_id', userId); + await this.db.from('daily_question').delete().eq('user_id', userId); + await this.db.from('daily_summaries').delete().eq('user_id', userId); + await this.db.from('period_reflections').delete().eq('user_id', userId); + } +} diff --git a/src/lib/nokv/blob-cli.ts b/src/lib/nokv/blob-cli.ts new file mode 100644 index 0000000..c2143fd --- /dev/null +++ b/src/lib/nokv/blob-cli.ts @@ -0,0 +1,111 @@ +import 'server-only'; + +import { execFile } from 'node:child_process'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { connArgv } from './mcp-client'; + +// Audio blobs bypass the resident MCP stdio channel entirely (design §6): +// 16MiB read ceiling + 300-byte read pages + 5s timeouts + head-of-line +// blocking make it unusable for 25MB media. The CLI's collect/materialize +// read/write local files in-process instead. + +const BLOB_TIMEOUT_MS = 60_000; +// collect refuses payloads above --max-artifact-bytes; 26 MiB leaves headroom +// over the app-level 25MB upload cap. +const MAX_ARTIFACT_BYTES = 27_262_976; +const MAX_CONCURRENT = 2; + +let active = 0; +const waiters: Array<() => void> = []; + +async function acquire(): Promise { + if (active < MAX_CONCURRENT) { + active++; + return; + } + await new Promise((resolve) => waiters.push(resolve)); + active++; +} + +function release(): void { + active--; + const next = waiters.shift(); + if (next) next(); +} + +function runCli(args: string[]): Promise { + return new Promise((resolve, reject) => { + execFile( + process.env.NOKV_BIN!, + args, + { timeout: BLOB_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024 }, + (err, _stdout, stderr) => { + if (err) { + reject( + new Error( + `nokv cli failed: ${err.message} ${String(stderr).slice(0, 300)}` + ) + ); + } else { + resolve(); + } + } + ); + }); +} + +/** Uploads a blob as a create-only artifact. AlreadyExists surfaces as an + * error from the CLI; callers treat it as idempotent success where sound. */ +export async function collectBlob( + wbId: string, + section: string, + path: string, + data: Buffer, + contentType: string +): Promise { + await acquire(); + const dir = await mkdtemp(join(tmpdir(), 'nokv-blob-')); + const file = join(dir, 'payload.bin'); + try { + await writeFile(file, data); + await runCli([ + ...connArgv(), + '--max-artifact-bytes', + String(MAX_ARTIFACT_BYTES), + 'collect', + wbId, + section, + file, + path, + '--content-type', + contentType + ]); + } finally { + await rm(dir, { recursive: true, force: true }); + release(); + } +} + +/** Downloads a blob to memory via materialize. Returns null when the CLI + * reports a missing path. */ +export async function materializeBlob( + wbId: string, + section: string, + path: string +): Promise { + await acquire(); + const dir = await mkdtemp(join(tmpdir(), 'nokv-blob-')); + const file = join(dir, 'payload.bin'); + try { + await runCli([...connArgv(), 'materialize', wbId, section, path, file]); + return await readFile(file); + } catch (err) { + if (/not.?found/i.test(String(err))) return null; + throw err; + } finally { + await rm(dir, { recursive: true, force: true }); + release(); + } +} diff --git a/src/lib/nokv/journal-doc.ts b/src/lib/nokv/journal-doc.ts new file mode 100644 index 0000000..d328098 --- /dev/null +++ b/src/lib/nokv/journal-doc.ts @@ -0,0 +1,86 @@ +// Deterministic document serialization for the NoKV journal workbench +// (design §3). Block-level edits rely on byte-exact extraction: a top-level +// block always starts with `\n "":` and that byte pattern cannot occur +// inside JSON string values (real newlines are escaped there), so +// workbench_edit's exact-string matching is unambiguous. + +export type JsonValue = + | string + | number + | boolean + | null + | JsonValue[] + | { [key: string]: JsonValue }; + +/** Serializes a document root: 2-space indent, insertion key order, + * no trailing whitespace. Build docs with keys in canonical order. */ +export function serializeDoc(doc: Record): string { + return JSON.stringify(doc, null, 2); +} + +/** Renders the byte-exact top-level block for `key` as it appears inside a + * serializeDoc output (leading newline + 2-space indent, no trailing comma). */ +export function buildBlock(key: string, value: JsonValue): string { + const single = JSON.stringify({ [key]: value }, null, 2); + // `{` + block + `\n}` -> strip the wrapper. + return single.slice(1, single.length - 2); +} + +/** Extracts the byte-exact top-level block for `key` from a serialized doc. + * Returns null when the key is absent. */ +export function extractBlock(text: string, key: string): string | null { + const marker = `\n ${JSON.stringify(key)}:`; + const start = text.indexOf(marker); + if (start === -1) return null; + + let i = start + marker.length; + // Skip the single space after the colon. + while (text[i] === ' ') i++; + + const end = scanValueEnd(text, i); + if (end === -1) return null; + return text.slice(start, end); +} + +/** Returns the index one past the end of the JSON value starting at `i`. */ +function scanValueEnd(text: string, i: number): number { + const first = text[i]; + if (first === '{' || first === '[') { + const open = first; + const close = first === '{' ? '}' : ']'; + let depth = 0; + let inString = false; + for (let j = i; j < text.length; j++) { + const c = text[j]; + if (inString) { + if (c === '\\') j++; + else if (c === '"') inString = false; + continue; + } + if (c === '"') inString = true; + else if (c === open) depth++; + else if (c === close) { + depth--; + if (depth === 0) return j + 1; + } + } + return -1; + } + if (first === '"') { + for (let j = i + 1; j < text.length; j++) { + if (text[j] === '\\') j++; + else if (text[j] === '"') return j + 1; + } + return -1; + } + // null / number / boolean: ends at comma or newline. + for (let j = i; j < text.length; j++) { + if (text[j] === ',' || text[j] === '\n') return j; + } + return text.length; +} + +/** Parses a serialized doc; throws on malformed content. */ +export function parseDoc>(text: string): T { + return JSON.parse(text) as T; +} diff --git a/src/lib/nokv/journal-repository.ts b/src/lib/nokv/journal-repository.ts new file mode 100644 index 0000000..e30a07d --- /dev/null +++ b/src/lib/nokv/journal-repository.ts @@ -0,0 +1,1468 @@ +import 'server-only'; + +import { randomUUID } from 'node:crypto'; +import { callWorkbenchTool, isNokvEnabled, NokvToolError } from './mcp-client'; +import { collectBlob, materializeBlob } from './blob-cli'; +import { + buildBlock, + extractBlock, + parseDoc, + serializeDoc, + type JsonValue +} from './journal-doc'; +import { + foldLedger, + ledgerShardPath, + newEventId, + serializeEvent, + type FoldedState, + type LedgerEvent +} from './ledger'; +import { getLocalDayRange } from '@/lib/timezone'; +import { + NokvUnavailableError, + type DailySummary, + type DayBundle, + type EntryStats, + type JournalDataRepository, + type JournalEntry, + type MoodEntry, + type PeriodReflection, + type PeriodType, + type YmdString +} from '@/lib/data/repository'; + +// NoKV journal backend (design: docs/nokv-data-migration-design.md). +// One long-lived workbench per user: jr-{clerkUserId}. +// outputs/days/{date}.json day document (mood ⊕ summary ⊕ reflection) +// outputs/entries/{date}/{id}.json per-entry document (audio meta ⊕ transcript) +// outputs/text/{date}/summary.txt keyword-search sidecar (derived, LWW) +// outputs/reflections/{type}/{start}.json +// input/audio/{date}/{id}.webm blob via the CLI channel +// logs/ledger/{YYYY-MM}.jsonl derived event ledger (folded read model) +// metadata/latches/... create-only edit-protection latches +// jr-* workbenches are NEVER committed/snapshotted (tombstone GC depends on it). + +const WB_ID_RE = /^[A-Za-z0-9_-]+$/; +const ENTRY_ID_RE = /^e(\d{4})(\d{2})(\d{2})T\d{6}-[0-9a-f]{8}$/; +const DOC_TIMEOUT_MS = 15_000; +const SCAN_TIMEOUT_MS = 10_000; +const MAX_DOC_BYTES = 4 * 1024 * 1024; + +const ALLOWED_TOOLS = new Set([ + 'workbench_create', + 'workbench_put_file', + 'workbench_append', + 'workbench_read', + 'workbench_list', + 'workbench_grep', + 'workbench_edit', + 'workbench_stat' +]); + +interface DayDoc { + schema: string; + user_id: string; + date: string; + tz: string; + mood: { + day_quality: string; + emotions: string[]; + created_at: string; + updated_at: string; + legacy_id?: string | null; + } | null; + summary: { + text: string; + entry_count: number; + mood_quality: string | null; + dominant_emotions: string[]; + generated_at: string; + updated_at: string; + } | null; + reflection: { + achievements: string[]; + commitments: string[]; + mood_overall: string | null; + mood_reason: string | null; + flashback: string | null; + stats: Record | null; + gen_version: string | null; + edited: boolean; + last_generated_at: string | null; + legacy_id?: string | null; + } | null; +} + +interface EntryDoc { + schema: string; + id: string; + legacy_audio_id: string | null; + user_id: string; + date: string; + created_at: string; + audio: { + path: string; + mime_type: string; + size_bytes: number; + duration_ms: number | null; + deleted: boolean; + }; + transcript: { + text: string; + rephrased_text: string | null; + language: string | null; + updated_at: string; + }; +} + +interface ReflectionDoc { + schema: string; + id: string; + legacy_id: string | null; + user_id: string; + period_type: string; + period_start: string; + period_end: string; + achievements: string[]; + commitments: string[]; + mood_overall: string | null; + mood_reason: string | null; + flashback: string | null; + stats: Record | null; + edited: boolean; + gen_version: string | null; + last_generated_at: string | null; + updated_at: string | null; +} + +const isPathExists = (err: unknown) => + err instanceof NokvToolError && + /path.?exists|already.?exists/i.test(err.message); +const isNotFound = (err: unknown) => + err instanceof NokvToolError && /not.?found/i.test(err.message); +const isRetryable = (err: unknown) => + err instanceof NokvToolError && + /generation|conflict|precondition|no match|mismatch|stale/i.test(err.message); + +function tzName(): string { + return process.env.APP_TIMEZONE ?? 'Australia/Sydney'; +} + +function entryDateFromId(entryId: string): YmdString | null { + const m = ENTRY_ID_RE.exec(entryId); + return m ? `${m[1]}-${m[2]}-${m[3]}` : null; +} + +/** Tolerant extraction across MCP structured-content encodings. */ +function firstRecord(res: unknown): unknown { + if (res == null) return null; + const r = res as Record; + const records = (r.records ?? r.items ?? r.entries) as unknown[] | undefined; + if (Array.isArray(records)) return records[0] ?? null; + return res; +} + +function recordToDoc(record: unknown): T | null { + if (record == null) return null; + if (typeof record === 'string') return parseDoc(record); + const r = record as Record; + if (typeof r.text === 'string') return parseDoc(r.text); + if (r.json && typeof r.json === 'object') return r.json as T; + if (r.schema) return record as T; + return record as T; +} + +function textLines(res: unknown): string[] { + if (res == null) return []; + const r = res as Record; + const records = (r.records ?? r.text_lines ?? r.lines) as + | unknown[] + | undefined; + if (!Array.isArray(records)) { + return typeof r.text === 'string' ? r.text.split('\n') : []; + } + return records.map((rec) => { + if (typeof rec === 'string') return rec; + const rr = rec as Record; + return typeof rr.text === 'string' ? rr.text : JSON.stringify(rec); + }); +} + +function listNames(res: unknown): string[] { + if (res == null) return []; + const r = res as Record; + const items = (r.entries ?? r.records ?? r.items ?? r.paths) as + | unknown[] + | undefined; + if (!Array.isArray(items)) return []; + return items + .map((it) => { + if (typeof it === 'string') return it; + const rr = it as Record; + return (rr.path ?? rr.name ?? '') as string; + }) + .filter(Boolean); +} + +interface FoldCache { + at: number; + state: FoldedState; +} + +export class NokvJournalRepository implements JournalDataRepository { + private ensuredWorkbenches = new Set(); + private foldCache = new Map(); + private deletedCache = new Map(); + + // ── plumbing ────────────────────────────────────────────────────── + + private wbId(userId: string): string { + const id = `jr-${userId}`; + if (!isNokvEnabled() || !WB_ID_RE.test(id) || id.length > 128) { + throw new NokvUnavailableError( + 'NoKV backend disabled or user id not representable' + ); + } + return id; + } + + private async call( + name: string, + args: Record, + timeoutMs = DOC_TIMEOUT_MS + ): Promise { + if (!ALLOWED_TOOLS.has(name)) { + throw new Error(`tool ${name} is not allowed on journal workbenches`); + } + const res = await callWorkbenchTool(name, args, { timeoutMs }); + if (res === null) { + throw new NokvUnavailableError(`nokv transport unavailable for ${name}`); + } + return res; + } + + private async ensureWorkbench(userId: string): Promise { + const id = this.wbId(userId); + if (this.ensuredWorkbenches.has(id)) return id; + try { + await this.call('workbench_create', { id }); + } catch (err) { + if (!isPathExists(err)) throw err; + } + this.ensuredWorkbenches.add(id); + return id; + } + + private async readDocAt( + wb: string, + section: string, + path: string + ): Promise { + try { + const res = await this.call('workbench_read', { + id: wb, + section, + path, + format: 'structured' + }); + return recordToDoc(firstRecord(res)); + } catch (err) { + if (isNotFound(err)) return null; + throw err; + } + } + + private async putDoc( + wb: string, + section: string, + path: string, + doc: Record, + opts?: { replace?: boolean; contentType?: string } + ): Promise { + const text = serializeDoc(doc); + if (Buffer.byteLength(text) > MAX_DOC_BYTES) { + throw new Error(`document too large for ${section}/${path}`); + } + await this.call('workbench_put_file', { + id: wb, + section, + path, + text, + content_type: opts?.contentType ?? 'application/json', + replace: opts?.replace ?? false + }); + } + + /** Block-level optimistic edit on a day/entry document. NEVER falls back to + * whole-document replace (that would be a silent-lost-update hole). */ + private async editBlock>( + wb: string, + section: string, + path: string, + key: string, + nextValue: JsonValue + ): Promise { + let lastErr: unknown = null; + for (let attempt = 0; attempt < 3; attempt++) { + const doc = await this.readDocAt(wb, section, path); + if (!doc) throw new NokvToolError(`NotFound: ${section}/${path}`); + const raw = serializeDoc(doc); + const oldBlock = extractBlock(raw, key); + if (!oldBlock) throw new Error(`block ${key} missing in ${path}`); + const newBlock = buildBlock(key, nextValue); + if (oldBlock === newBlock) return doc; + try { + await this.call('workbench_edit', { + id: wb, + section, + path, + old_string: oldBlock, + new_string: newBlock + }); + const updated = { ...doc, [key]: nextValue } as T; + return updated; + } catch (err) { + lastErr = err; + if (isRetryable(err)) continue; + throw err; + } + } + throw lastErr ?? new Error(`editBlock exhausted retries for ${key}`); + } + + private async appendLedger(wb: string, event: LedgerEvent): Promise { + const args = { + id: wb, + section: 'logs', + path: ledgerShardPath(event.date), + text: serializeEvent(event), + content_type: 'text/plain' + }; + for (let attempt = 0; attempt < 3; attempt++) { + try { + await this.call('workbench_append', args, SCAN_TIMEOUT_MS); + return; + } catch (err) { + if (isRetryable(err) && attempt < 2) continue; + // Derived read model: log and move on, reconcile can rebuild. + // eslint-disable-next-line no-console + console.error('[nokv] ledger append failed', err); + return; + } + } + } + + private async latch(wb: string, path: string): Promise { + try { + await this.call('workbench_put_file', { + id: wb, + section: 'metadata', + path, + text: '', + replace: false + }); + } catch (err) { + if (!isPathExists(err)) throw err; + } + } + + private async latchExists(wb: string, path: string): Promise { + try { + await this.call('workbench_stat', { + id: wb, + section: 'metadata', + path + }); + return true; + } catch (err) { + if (isNotFound(err)) return false; + throw err; + } + } + + private async isUserDeleted(userId: string): Promise { + const cached = this.deletedCache.get(userId); + if (cached && Date.now() - cached.at < 60_000) return cached.deleted; + const wb = await this.ensureWorkbench(userId); + const marker = await this.readDocAt(wb, 'metadata', 'deleted.json'); + const deleted = marker != null; + this.deletedCache.set(userId, { at: Date.now(), deleted }); + return deleted; + } + + private daySkeleton( + userId: string, + date: YmdString + ): Record { + return { + schema: 'echojournal.day.v1', + user_id: userId, + date, + tz: tzName(), + mood: null, + summary: null, + reflection: null + }; + } + + private async ensureDayDoc( + wb: string, + userId: string, + date: YmdString, + withBlocks?: Partial> + ): Promise<'created' | 'exists'> { + const skeleton = { ...this.daySkeleton(userId, date), ...withBlocks }; + try { + await this.putDoc(wb, 'outputs', `days/${date}.json`, skeleton); + return 'created'; + } catch (err) { + if (isPathExists(err)) return 'exists'; + throw err; + } + } + + private async readDay(wb: string, date: YmdString): Promise { + return this.readDocAt(wb, 'outputs', `days/${date}.json`); + } + + private async foldState(userId: string): Promise { + const cached = this.foldCache.get(userId); + if (cached && Date.now() - cached.at < 5_000) return cached.state; + + const wb = await this.ensureWorkbench(userId); + let shardNames: string[] = []; + try { + const res = await this.call( + 'workbench_list', + { id: wb, section: 'logs', path: 'ledger/', limit: 100 }, + SCAN_TIMEOUT_MS + ); + shardNames = listNames(res); + } catch (err) { + if (!isNotFound(err)) throw err; + } + + const state: FoldedState = { byDate: new Map() }; + for (const name of shardNames.sort()) { + const rel = name.includes('/') + ? name.slice(name.lastIndexOf('ledger/')) + : `ledger/${name}`; + try { + const res = await this.call( + 'workbench_read', + { id: wb, section: 'logs', path: rel, format: 'structured' }, + SCAN_TIMEOUT_MS + ); + foldLedger(textLines(res), state); + let cursor = (res as Record)?.cursor as + | string + | undefined; + while (cursor) { + const page = await this.call( + 'workbench_read', + { + id: wb, + section: 'logs', + path: rel, + format: 'structured', + cursor + }, + SCAN_TIMEOUT_MS + ); + foldLedger(textLines(page), state); + cursor = (page as Record)?.cursor as + | string + | undefined; + } + } catch (err) { + if (!isNotFound(err)) throw err; + } + } + + this.foldCache.set(userId, { at: Date.now(), state }); + return state; + } + + private invalidateFold(userId: string): void { + this.foldCache.delete(userId); + } + + // ── projections ─────────────────────────────────────────────────── + + private moodFromDay(day: DayDoc | null): MoodEntry | null { + if (!day?.mood) return null; + return { + id: day.mood.legacy_id ?? `mood:${day.date}`, + date: day.date, + day_quality: day.mood.day_quality, + emotions: day.mood.emotions ?? [], + created_at: day.mood.created_at, + updated_at: day.mood.updated_at + }; + } + + private summaryFromDay( + userId: string, + day: DayDoc | null + ): DailySummary | null { + if (!day?.summary) return null; + const r = day.reflection; + return { + id: r?.legacy_id ?? `daily:${day.date}`, + user_id: userId, + date: day.date, + summary: day.summary.text, + entry_count: day.summary.entry_count, + mood_quality: day.summary.mood_quality, + dominant_emotions: day.summary.dominant_emotions ?? [], + mood_overall: r?.mood_overall ?? null, + mood_reason: r?.mood_reason ?? null, + achievements: r?.achievements ?? null, + commitments: r?.commitments ?? null, + flashback: r?.flashback ?? null, + stats: r?.stats ?? null, + gen_version: r?.gen_version ?? null, + edited: r?.edited ?? false, + created_at: day.summary.generated_at, + updated_at: day.summary.updated_at, + last_generated_at: r?.last_generated_at ?? null + }; + } + + private entryFromDoc(doc: EntryDoc): JournalEntry { + return { + id: doc.id, + legacy_audio_id: doc.legacy_audio_id, + date: doc.date, + created_at: doc.created_at, + audio: { + mime_type: doc.audio.mime_type, + size_bytes: doc.audio.size_bytes, + duration_ms: doc.audio.duration_ms, + deleted: doc.audio.deleted + }, + transcript: { + text: doc.transcript.text, + rephrased_text: doc.transcript.rephrased_text, + language: doc.transcript.language, + updated_at: doc.transcript.updated_at + } + }; + } + + private reflectionFromDoc(doc: ReflectionDoc): PeriodReflection { + return { + id: doc.legacy_id ?? doc.id, + user_id: doc.user_id, + period_type: doc.period_type, + period_start: doc.period_start, + period_end: doc.period_end, + mood_overall: doc.mood_overall, + mood_reason: doc.mood_reason, + achievements: doc.achievements, + commitments: doc.commitments, + flashback: doc.flashback, + stats: doc.stats, + gen_version: doc.gen_version, + edited: doc.edited, + created_at: doc.updated_at, + updated_at: doc.updated_at, + last_generated_at: doc.last_generated_at + }; + } + + private async resolveEntryLocator( + wb: string, + entryId: string + ): Promise<{ date: YmdString; id: string } | null> { + const date = entryDateFromId(entryId); + if (date) return { date, id: entryId }; + // Legacy Supabase uuid. + const legacy = await this.readDocAt<{ + audio?: Record; + }>(wb, 'metadata', 'legacy_ids.json'); + const hit = legacy?.audio?.[entryId]; + return hit ? { date: hit.date, id: hit.entry_id } : null; + } + + // ── mood ────────────────────────────────────────────────────────── + + async getMood(userId: string, date: YmdString): Promise { + if (await this.isUserDeleted(userId)) return null; + const wb = await this.ensureWorkbench(userId); + return this.moodFromDay(await this.readDay(wb, date)); + } + + async upsertMood( + userId: string, + date: YmdString, + input: { dayQuality: string; emotions: string[] } + ): Promise { + const wb = await this.ensureWorkbench(userId); + const now = new Date().toISOString(); + const existing = await this.readDay(wb, date); + const moodBlock = { + day_quality: input.dayQuality, + emotions: input.emotions, + created_at: existing?.mood?.created_at ?? now, + updated_at: now + }; + + if (!existing) { + const created = await this.ensureDayDoc(wb, userId, date, { + mood: moodBlock + }); + if (created === 'exists') { + await this.editBlock>( + wb, + 'outputs', + `days/${date}.json`, + 'mood', + moodBlock + ); + } + } else { + await this.editBlock>( + wb, + 'outputs', + `days/${date}.json`, + 'mood', + moodBlock + ); + } + + await this.appendLedger(wb, { + v: 1, + event_id: newEventId(), + ts: now, + kind: 'mood_set', + date, + day_quality: input.dayQuality, + emotions: input.emotions + }); + this.invalidateFold(userId); + + return { + id: `mood:${date}`, + date, + day_quality: input.dayQuality, + emotions: input.emotions, + created_at: moodBlock.created_at, + updated_at: now + }; + } + + async listMoodsInRange( + userId: string, + start: YmdString, + end: YmdString + ): Promise { + if (await this.isUserDeleted(userId)) return []; + const state = await this.foldState(userId); + const wb = await this.ensureWorkbench(userId); + const dates = [...state.byDate.keys()] + .filter((d) => d >= start && d <= end && state.byDate.get(d)?.mood) + .sort(); + const moods: MoodEntry[] = []; + for (const d of dates) { + const mood = this.moodFromDay(await this.readDay(wb, d)); + if (mood) moods.push(mood); + } + return moods; + } + + // ── journal entries ─────────────────────────────────────────────── + + async createEntry( + userId: string, + input: { + audio: Buffer; + mimeType: string; + transcript: string; + rephrasedText: string | null; + language: string; + } + ): Promise { + const wb = await this.ensureWorkbench(userId); + const now = new Date(); + const nowIso = now.toISOString(); + const { date } = getLocalDayRange({ date: now }); + const compact = date.replaceAll('-', ''); + const hhmmss = nowIso.slice(11, 19).replaceAll(':', ''); + const entryId = `e${compact}T${hhmmss}-${randomUUID().slice(0, 8)}`; + const audioPath = `audio/${date}/${entryId}.webm`; + + await collectBlob(wb, 'input', audioPath, input.audio, input.mimeType); + + const doc: EntryDoc = { + schema: 'echojournal.entry.v1', + id: entryId, + legacy_audio_id: null, + user_id: userId, + date, + created_at: nowIso, + audio: { + path: audioPath, + mime_type: input.mimeType, + size_bytes: input.audio.byteLength, + deleted: false, + duration_ms: null + }, + transcript: { + text: input.transcript, + rephrased_text: input.rephrasedText, + language: input.language, + updated_at: nowIso + } + }; + await this.putDoc( + wb, + 'outputs', + `entries/${date}/${entryId}.json`, + doc as unknown as Record + ); + await this.ensureDayDoc(wb, userId, date); + await this.appendLedger(wb, { + v: 1, + event_id: newEventId(), + ts: nowIso, + kind: 'entry_created', + date, + entry_id: entryId + }); + this.invalidateFold(userId); + + return this.entryFromDoc(doc); + } + + async getEntry( + userId: string, + entryId: string + ): Promise { + if (await this.isUserDeleted(userId)) return null; + const wb = await this.ensureWorkbench(userId); + const loc = await this.resolveEntryLocator(wb, entryId); + if (!loc) return null; + const doc = await this.readDocAt( + wb, + 'outputs', + `entries/${loc.date}/${loc.id}.json` + ); + if (!doc || doc.user_id !== userId || doc.audio.deleted) return null; + return this.entryFromDoc(doc); + } + + async getAudioBlob( + userId: string, + entryId: string + ): Promise<{ data: Buffer; mimeType: string } | null> { + if (await this.isUserDeleted(userId)) return null; + const wb = await this.ensureWorkbench(userId); + const loc = await this.resolveEntryLocator(wb, entryId); + if (!loc) return null; + const doc = await this.readDocAt( + wb, + 'outputs', + `entries/${loc.date}/${loc.id}.json` + ); + if (!doc || doc.audio.deleted) return null; + const data = await materializeBlob(wb, 'input', doc.audio.path); + if (!data || data.byteLength === 0) return null; + return { data, mimeType: doc.audio.mime_type }; + } + + async updateRephrasedText( + userId: string, + entryId: string, + text: string + ): Promise { + const wb = await this.ensureWorkbench(userId); + const loc = await this.resolveEntryLocator(wb, entryId); + if (!loc) throw new Error('Entry not found'); + const path = `entries/${loc.date}/${loc.id}.json`; + const current = await this.readDocAt(wb, 'outputs', path); + if (!current) throw new Error('Entry not found'); + const updated = await this.editBlock>( + wb, + 'outputs', + path, + 'transcript', + { + ...current.transcript, + rephrased_text: text, + updated_at: new Date().toISOString() + } as unknown as JsonValue + ); + return this.entryFromDoc(updated as unknown as EntryDoc); + } + + async deleteEntry(userId: string, entryId: string): Promise { + const wb = await this.ensureWorkbench(userId); + const loc = await this.resolveEntryLocator(wb, entryId); + if (!loc) return; + const path = `entries/${loc.date}/${loc.id}.json`; + const current = await this.readDocAt(wb, 'outputs', path); + if (!current || current.audio.deleted) return; + + await this.editBlock>( + wb, + 'outputs', + path, + 'audio', + { + ...current.audio, + deleted: true + } as unknown as JsonValue + ); + // Tombstone the blob: zero bytes -> revision GC physically deletes S3 data. + await this.call('workbench_put_file', { + id: wb, + section: 'input', + path: current.audio.path, + text: '', + replace: true + }); + await this.appendLedger(wb, { + v: 1, + event_id: newEventId(), + ts: new Date().toISOString(), + kind: 'entry_deleted', + date: loc.date, + entry_id: loc.id + }); + this.invalidateFold(userId); + } + + async listEntriesForDay( + userId: string, + date: YmdString + ): Promise { + if (await this.isUserDeleted(userId)) return []; + const wb = await this.ensureWorkbench(userId); + let names: string[] = []; + try { + const res = await this.call( + 'workbench_list', + { id: wb, section: 'outputs', path: `entries/${date}/`, limit: 100 }, + SCAN_TIMEOUT_MS + ); + names = listNames(res); + } catch (err) { + if (isNotFound(err)) return []; + throw err; + } + + const entries: JournalEntry[] = []; + for (const name of names.sort()) { + const base = name.slice(name.lastIndexOf('/') + 1); + const doc = await this.readDocAt( + wb, + 'outputs', + `entries/${date}/${base}` + ); + if (doc && !doc.audio.deleted) entries.push(this.entryFromDoc(doc)); + } + return entries; + } + + async getEntryStats(userId: string): Promise { + if (await this.isUserDeleted(userId)) { + return { totalEntries: 0, thisWeekEntries: 0, currentStreak: 0 }; + } + const state = await this.foldState(userId); + + let totalEntries = 0; + for (const day of state.byDate.values()) { + totalEntries += day.liveEntryIds.size; + } + + // Same week definition as the Supabase implementation: local Sunday start. + const weekDates = new Set(); + const cursor = new Date(); + for (let i = 0; i <= cursor.getDay(); i++) { + const d = new Date(cursor); + d.setDate(d.getDate() - i); + weekDates.add(getLocalDayRange({ date: d }).date); + } + let thisWeekEntries = 0; + for (const date of weekDates) { + thisWeekEntries += state.byDate.get(date)?.liveEntryIds.size ?? 0; + } + + let currentStreak = 0; + for (let i = 0; i < 30; i++) { + const d = new Date(); + d.setDate(d.getDate() - i); + const date = getLocalDayRange({ date: d }).date; + if ((state.byDate.get(date)?.liveEntryIds.size ?? 0) > 0) { + currentStreak++; + } else { + break; + } + } + + return { totalEntries, thisWeekEntries, currentStreak }; + } + + // ── daily summaries ─────────────────────────────────────────────── + + async getDailySummary( + userId: string, + date: YmdString + ): Promise { + if (await this.isUserDeleted(userId)) return null; + const wb = await this.ensureWorkbench(userId); + return this.summaryFromDay(userId, await this.readDay(wb, date)); + } + + async upsertDailySummary( + userId: string, + date: YmdString, + fields: { + summary: string; + entryCount: number; + moodQuality: string | null; + dominantEmotions: string[]; + } + ): Promise { + const wb = await this.ensureWorkbench(userId); + const now = new Date().toISOString(); + const existing = await this.readDay(wb, date); + const summaryBlock = { + text: fields.summary, + entry_count: fields.entryCount, + mood_quality: fields.moodQuality, + dominant_emotions: fields.dominantEmotions, + generated_at: existing?.summary?.generated_at ?? now, + updated_at: now + }; + + if (!existing) { + const created = await this.ensureDayDoc(wb, userId, date, { + summary: summaryBlock + }); + if (created === 'exists') { + await this.editBlock( + wb, + 'outputs', + `days/${date}.json`, + 'summary', + summaryBlock + ); + } + } else { + await this.editBlock( + wb, + 'outputs', + `days/${date}.json`, + 'summary', + summaryBlock + ); + } + + // Keyword sidecar (derived, replace-on-write is safe). + await this.call('workbench_put_file', { + id: wb, + section: 'outputs', + path: `text/${date}/summary.txt`, + text: fields.summary, + content_type: 'text/plain', + replace: true + }); + await this.appendLedger(wb, { + v: 1, + event_id: newEventId(), + ts: now, + kind: 'summary_written', + date, + mood_quality: fields.moodQuality, + entry_count: fields.entryCount + }); + this.invalidateFold(userId); + + const day = await this.readDay(wb, date); + const projected = this.summaryFromDay(userId, day); + if (!projected) throw new Error('summary projection failed after upsert'); + return projected; + } + + async updateDailySummaryReflection( + userId: string, + date: YmdString, + patch: Partial< + Pick< + DailySummary, + | 'achievements' + | 'commitments' + | 'mood_overall' + | 'mood_reason' + | 'flashback' + | 'stats' + | 'gen_version' + | 'last_generated_at' + > + >, + opts: { markEdited?: boolean; preserveIfEdited?: boolean } + ): Promise { + const wb = await this.ensureWorkbench(userId); + const latchPath = `latches/day-${date}.edited`; + const day = await this.readDay(wb, date); + if (!day) throw new Error(`No daily summary for ${date}`); + + if (opts.markEdited) { + // Create-only latch makes edit protection a hard guarantee. + await this.latch(wb, latchPath); + } + + const edited = opts.markEdited + ? true + : await this.latchExists(wb, latchPath); + const preserveUserFields = Boolean(opts.preserveIfEdited && edited); + + const current = day.reflection ?? { + achievements: [], + commitments: [], + mood_overall: null, + mood_reason: null, + flashback: null, + stats: null, + gen_version: null, + edited: false, + last_generated_at: null + }; + + const next = { ...current, edited } as Record; + const userFields = new Set([ + 'achievements', + 'commitments', + 'mood_overall', + 'mood_reason', + 'flashback' + ]); + for (const [k, v] of Object.entries(patch)) { + if (v === undefined) continue; + if (preserveUserFields && userFields.has(k)) continue; + next[k] = v as JsonValue; + } + + await this.editBlock( + wb, + 'outputs', + `days/${date}.json`, + 'reflection', + next + ); + const updated = await this.readDay(wb, date); + const projected = this.summaryFromDay(userId, updated); + if (!projected) throw new Error('summary projection failed after update'); + return projected; + } + + async listDailySummaries( + userId: string, + opts: { before?: YmdString; limit: number } + ): Promise { + if (await this.isUserDeleted(userId)) return []; + const state = await this.foldState(userId); + const wb = await this.ensureWorkbench(userId); + const dates = [...state.byDate.entries()] + .filter(([d, day]) => day.summary && (!opts.before || d <= opts.before)) + .map(([d]) => d) + .sort() + .reverse() + .slice(0, opts.limit); + const out: DailySummary[] = []; + for (const d of dates) { + const s = this.summaryFromDay(userId, await this.readDay(wb, d)); + if (s) out.push(s); + } + return out; + } + + async listDailySummariesInRange( + userId: string, + start: YmdString, + end: YmdString + ): Promise { + if (await this.isUserDeleted(userId)) return []; + const state = await this.foldState(userId); + const wb = await this.ensureWorkbench(userId); + const dates = [...state.byDate.entries()] + .filter(([d, day]) => day.summary && d >= start && d <= end) + .map(([d]) => d) + .sort(); + const out: DailySummary[] = []; + for (const d of dates) { + const s = this.summaryFromDay(userId, await this.readDay(wb, d)); + if (s) out.push(s); + } + return out; + } + + async queryJournalDays( + userId: string, + filters: { + startDate?: YmdString; + endDate?: YmdString; + moods?: string[]; + keyword?: string; + page: number; + limit: number; + } + ): Promise<{ days: DayBundle[]; totalCount: number }> { + if (await this.isUserDeleted(userId)) return { days: [], totalCount: 0 }; + const state = await this.foldState(userId); + const wb = await this.ensureWorkbench(userId); + + // Base set: dates with a summary (exactly the Supabase daily_summaries rows). + let dates = [...state.byDate.entries()] + .filter(([, day]) => day.summary) + .map(([d]) => d); + if (filters.startDate) dates = dates.filter((d) => d >= filters.startDate!); + if (filters.endDate) dates = dates.filter((d) => d <= filters.endDate!); + if (filters.moods && filters.moods.length > 0) { + const moods = new Set(filters.moods); + dates = dates.filter((d) => { + const mq = state.byDate.get(d)?.summary?.mood_quality; + return mq != null && moods.has(mq); + }); + } + if (filters.keyword) { + const hits = await this.grepSummaryDates(wb, filters.keyword); + dates = dates.filter((d) => hits.has(d)); + } + + dates.sort().reverse(); + const totalCount = dates.length; + const offset = (filters.page - 1) * filters.limit; + const pageDates = dates.slice(offset, offset + filters.limit); + + const days: DayBundle[] = []; + for (const d of pageDates) { + const day = await this.readDay(wb, d); + days.push({ + date: d, + summary: this.summaryFromDay(userId, day), + mood: this.moodFromDay(day), + entries: await this.listEntriesForDay(userId, d) + }); + } + return { days, totalCount }; + } + + private async grepSummaryDates( + wb: string, + keyword: string + ): Promise> { + const hits = new Set(); + let cursor: string | undefined; + do { + const res = (await this.call( + 'workbench_grep', + { + id: wb, + pattern: keyword, + recursive: true, + section: 'outputs', + path: 'text/', + glob: 'summary.txt', + limit: 300, + ...(cursor ? { cursor } : {}) + }, + SCAN_TIMEOUT_MS + )) as Record; + const matches = (res.matches ?? + res.records ?? + res.items ?? + []) as unknown[]; + for (const m of matches) { + const path = + typeof m === 'string' + ? m + : (((m as Record).path as string) ?? ''); + // Full path carries the section prefix: outputs/text/{date}/summary.txt + const match = /text\/(\d{4}-\d{2}-\d{2})\/summary\.txt/.exec(path); + if (match) hits.add(match[1]); + } + cursor = res.cursor as string | undefined; + } while (cursor); + return hits; + } + + // ── period reflections ──────────────────────────────────────────── + + private reflectionPath(type: PeriodType, periodStart: YmdString): string { + return `reflections/${type}/${periodStart}.json`; + } + + async getPeriodReflection( + userId: string, + type: PeriodType, + periodStart: YmdString + ): Promise { + if (await this.isUserDeleted(userId)) return null; + const wb = await this.ensureWorkbench(userId); + const doc = await this.readDocAt( + wb, + 'outputs', + this.reflectionPath(type, periodStart) + ); + return doc ? this.reflectionFromDoc(doc) : null; + } + + async getPeriodReflectionById( + userId: string, + id: string + ): Promise { + const derived = /^(weekly|monthly):(\d{4}-\d{2}-\d{2})$/.exec(id); + if (derived) { + return this.getPeriodReflection( + userId, + derived[1] as PeriodType, + derived[2] + ); + } + // Legacy uuid. + const wb = await this.ensureWorkbench(userId); + const legacy = await this.readDocAt<{ + period?: Record; + }>(wb, 'metadata', 'legacy_ids.json'); + const hit = legacy?.period?.[id]; + return hit + ? this.getPeriodReflection(userId, hit.type, hit.period_start) + : null; + } + + async upsertPeriodReflection( + userId: string, + type: PeriodType, + periodStart: YmdString, + periodEnd: YmdString, + fields: Partial + ): Promise { + const wb = await this.ensureWorkbench(userId); + const path = this.reflectionPath(type, periodStart); + const now = new Date().toISOString(); + const latchPath = `latches/period-${type}-${periodStart}.edited`; + + const base: ReflectionDoc = { + schema: 'echojournal.period_reflection.v1', + id: `${type}:${periodStart}`, + legacy_id: null, + user_id: userId, + period_type: type, + period_start: periodStart, + period_end: periodEnd, + achievements: (fields.achievements as string[]) ?? [], + commitments: (fields.commitments as string[]) ?? [], + mood_overall: fields.mood_overall ?? null, + mood_reason: fields.mood_reason ?? null, + flashback: fields.flashback ?? null, + stats: (fields.stats as Record) ?? null, + edited: false, + gen_version: fields.gen_version ?? null, + last_generated_at: fields.last_generated_at ?? now, + updated_at: now + }; + + try { + await this.putDoc( + wb, + 'outputs', + path, + base as unknown as Record + ); + return this.reflectionFromDoc(base); + } catch (err) { + if (!isPathExists(err)) throw err; + } + + // Exists: read-merge-write with full-document optimistic edit. + const edited = await this.latchExists(wb, latchPath); + for (let attempt = 0; attempt < 3; attempt++) { + const current = await this.readDocAt(wb, 'outputs', path); + if (!current) throw new Error(`reflection vanished: ${path}`); + const next: ReflectionDoc = { + ...current, + period_end: periodEnd, + stats: (fields.stats as Record) ?? current.stats, + gen_version: fields.gen_version ?? current.gen_version, + last_generated_at: fields.last_generated_at ?? now, + updated_at: now, + edited: current.edited || edited, + ...(edited || current.edited + ? {} + : { + achievements: + (fields.achievements as string[]) ?? current.achievements, + commitments: + (fields.commitments as string[]) ?? current.commitments, + mood_overall: fields.mood_overall ?? current.mood_overall, + mood_reason: fields.mood_reason ?? current.mood_reason, + flashback: fields.flashback ?? current.flashback + }) + }; + try { + await this.call('workbench_edit', { + id: wb, + section: 'outputs', + path, + old_string: serializeDoc( + current as unknown as Record + ), + new_string: serializeDoc(next as unknown as Record) + }); + return this.reflectionFromDoc(next); + } catch (err) { + if (isRetryable(err) && attempt < 2) continue; + throw err; + } + } + throw new Error('upsertPeriodReflection exhausted retries'); + } + + async updatePeriodReflectionFields( + userId: string, + id: string, + patch: Partial + ): Promise { + const existing = await this.getPeriodReflectionById(userId, id); + if (!existing) throw new Error('Period reflection not found'); + const type = existing.period_type as PeriodType; + const periodStart = existing.period_start; + const wb = await this.ensureWorkbench(userId); + const path = this.reflectionPath(type, periodStart); + await this.latch(wb, `latches/period-${type}-${periodStart}.edited`); + const now = new Date().toISOString(); + + for (let attempt = 0; attempt < 3; attempt++) { + const current = await this.readDocAt(wb, 'outputs', path); + if (!current) throw new Error('Period reflection not found'); + const next = { + ...current, + ...Object.fromEntries( + Object.entries(patch).filter(([, v]) => v !== undefined) + ), + edited: true, + updated_at: now + } as ReflectionDoc; + try { + await this.call('workbench_edit', { + id: wb, + section: 'outputs', + path, + old_string: serializeDoc( + current as unknown as Record + ), + new_string: serializeDoc(next as unknown as Record) + }); + return this.reflectionFromDoc(next); + } catch (err) { + if (isRetryable(err) && attempt < 2) continue; + throw err; + } + } + throw new Error('updatePeriodReflectionFields exhausted retries'); + } + + async listPeriodReflections( + userId: string, + type: PeriodType, + limit: number + ): Promise { + if (await this.isUserDeleted(userId)) return []; + const wb = await this.ensureWorkbench(userId); + let names: string[] = []; + try { + const res = await this.call( + 'workbench_list', + { + id: wb, + section: 'outputs', + path: `reflections/${type}/`, + limit: 100 + }, + SCAN_TIMEOUT_MS + ); + names = listNames(res); + } catch (err) { + if (isNotFound(err)) return []; + throw err; + } + + const picked = names + .map((n) => n.slice(n.lastIndexOf('/') + 1)) + .filter((n) => n.endsWith('.json')) + .sort() + .reverse() + .slice(0, limit); + + const out: PeriodReflection[] = []; + for (const name of picked) { + const doc = await this.readDocAt( + wb, + 'outputs', + `reflections/${type}/${name}` + ); + if (doc) out.push(this.reflectionFromDoc(doc)); + } + return out; + } + + // ── privacy ─────────────────────────────────────────────────────── + + async deleteAllUserData(userId: string): Promise { + const wb = await this.ensureWorkbench(userId); + + // Marker first: every read path short-circuits immediately. + try { + await this.putDoc(wb, 'metadata', 'deleted.json', { + schema: 'echojournal.deleted.v1', + deleted_at: new Date().toISOString() + }); + } catch (err) { + if (!isPathExists(err)) throw err; + } + this.deletedCache.set(userId, { at: Date.now(), deleted: true }); + + // Best-effort tombstoning: content bytes get GC'd; path names remain + // (honest privacy note lives in docs/nokv-demo.md and the design doc). + const state = await this.foldState(userId).catch(() => null); + const dates = state ? [...state.byDate.keys()] : []; + for (const date of dates) { + const day = state!.byDate.get(date)!; + for (const entryId of day.liveEntryIds) { + await this.call('workbench_put_file', { + id: wb, + section: 'input', + path: `audio/${date}/${entryId}.webm`, + text: '', + replace: true + }).catch(() => {}); + await this.call('workbench_put_file', { + id: wb, + section: 'outputs', + path: `entries/${date}/${entryId}.json`, + text: '', + replace: true + }).catch(() => {}); + } + await this.call('workbench_put_file', { + id: wb, + section: 'outputs', + path: `days/${date}.json`, + text: '', + replace: true + }).catch(() => {}); + await this.call('workbench_put_file', { + id: wb, + section: 'outputs', + path: `text/${date}/summary.txt`, + text: '', + replace: true + }).catch(() => {}); + } + this.invalidateFold(userId); + } +} diff --git a/src/lib/nokv/ledger.ts b/src/lib/nokv/ledger.ts new file mode 100644 index 0000000..094fd56 --- /dev/null +++ b/src/lib/nokv/ledger.ts @@ -0,0 +1,108 @@ +import { randomUUID } from 'node:crypto'; + +// Per-user monthly ledger shards (design §2.4): logs/ledger/{YYYY-MM}.jsonl. +// The ledger is a DERIVED read model — every event is reproducible from the +// authoritative documents — so lost events degrade filters/stats, never reads. + +export type LedgerEventKind = + | 'entry_created' + | 'entry_deleted' + | 'mood_set' + | 'summary_written'; + +export interface LedgerEvent { + v: 1; + event_id: string; + ts: string; + kind: LedgerEventKind; + date: string; // YYYY-MM-DD (APP_TIMEZONE day) + entry_id?: string; + day_quality?: string; + emotions?: string[]; + mood_quality?: string | null; + entry_count?: number; +} + +export interface FoldedDay { + date: string; + liveEntryIds: Set; + mood: { day_quality: string; emotions: string[]; ts: string } | null; + summary: { + mood_quality: string | null; + entry_count: number; + ts: string; + } | null; +} + +export interface FoldedState { + byDate: Map; +} + +/** One ULID-ish id per repo operation; retries within the operation MUST + * reuse it so replays deduplicate at fold time (design: F12 timeout trap). */ +export function newEventId(): string { + return `evt_${Date.now().toString(36)}${randomUUID().replaceAll('-', '').slice(0, 10)}`; +} + +export function ledgerShardPath(date: string): string { + return `ledger/${date.slice(0, 7)}.jsonl`; +} + +export function serializeEvent(event: LedgerEvent): string { + return JSON.stringify(event) + '\n'; +} + +function day(state: FoldedState, date: string): FoldedDay { + let d = state.byDate.get(date); + if (!d) { + d = { date, liveEntryIds: new Set(), mood: null, summary: null }; + state.byDate.set(date, d); + } + return d; +} + +/** Folds ledger lines (any shard order; lines within a shard are ordered by + * append). Deduplicates by event_id. Tolerates malformed lines. */ +export function foldLedger(lines: string[], into?: FoldedState): FoldedState { + const state: FoldedState = into ?? { byDate: new Map() }; + const seen = new Set(); + + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + let ev: LedgerEvent; + try { + ev = JSON.parse(trimmed); + } catch { + continue; + } + if (!ev.event_id || !ev.date || !ev.kind) continue; + if (seen.has(ev.event_id)) continue; + seen.add(ev.event_id); + + const d = day(state, ev.date); + switch (ev.kind) { + case 'entry_created': + if (ev.entry_id) d.liveEntryIds.add(ev.entry_id); + break; + case 'entry_deleted': + if (ev.entry_id) d.liveEntryIds.delete(ev.entry_id); + break; + case 'mood_set': + d.mood = { + day_quality: ev.day_quality ?? '', + emotions: ev.emotions ?? [], + ts: ev.ts + }; + break; + case 'summary_written': + d.summary = { + mood_quality: ev.mood_quality ?? null, + entry_count: ev.entry_count ?? 0, + ts: ev.ts + }; + break; + } + } + return state; +} diff --git a/src/lib/nokv/mcp-client.ts b/src/lib/nokv/mcp-client.ts index ccc19bb..ee5b61b 100644 --- a/src/lib/nokv/mcp-client.ts +++ b/src/lib/nokv/mcp-client.ts @@ -64,7 +64,9 @@ export function isNokvEnabled(): boolean { return enabledMemo; } -function mcpArgv(): string[] { +/** Connection/routing argv shared by the resident MCP subprocess and the + * one-shot blob CLI channel (collect/materialize). */ +export function connArgv(): string[] { const e = process.env; return [ '--root-id', @@ -86,11 +88,14 @@ function mcpArgv(): string[] { '--object-secret-access-key', e.NOKV_OBJECT_SECRET_ACCESS_KEY!, '--workbench-root', - e.NOKV_WORKBENCH_ROOT!, - 'mcp' + e.NOKV_WORKBENCH_ROOT! ]; } +function mcpArgv(): string[] { + return [...connArgv(), 'mcp']; +} + function markRuntimeDisabled(reason: unknown) { // eslint-disable-next-line no-console console.error('[nokv] runtime disabled for 30s:', reason); @@ -178,14 +183,15 @@ function startProcess(): McpState { function rpc( state: McpState, method: string, - params: Record + params: Record, + timeoutMs: number = CALL_TIMEOUT_MS ): Promise { const id = state.nextId++; return new Promise((resolve, reject) => { const timer = setTimeout(() => { state.pending.delete(id); reject(new Error(`nokv mcp timeout: ${method}`)); - }, CALL_TIMEOUT_MS); + }, timeoutMs); state.pending.set(id, { resolve, reject, timer }); state.proc.stdin.write( JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n' @@ -207,7 +213,8 @@ interface ToolResult { */ export async function callWorkbenchTool( name: string, - args: Record + args: Record, + opts?: { timeoutMs?: number } ): Promise { if (!isNokvEnabled()) return null; if (g.__nokvDisabledUntil && Date.now() < g.__nokvDisabledUntil) return null; @@ -226,10 +233,12 @@ export async function callWorkbenchTool( let result: ToolResult; try { - result = (await rpc(state, 'tools/call', { - name, - arguments: args - })) as ToolResult; + result = (await rpc( + state, + 'tools/call', + { name, arguments: args }, + opts?.timeoutMs + )) as ToolResult; } catch (err) { if (err instanceof NokvToolError) throw err; markRuntimeDisabled(err); diff --git a/src/lib/reflections/aggregate.ts b/src/lib/reflections/aggregate.ts index 462b645..b0f5076 100644 --- a/src/lib/reflections/aggregate.ts +++ b/src/lib/reflections/aggregate.ts @@ -1,13 +1,14 @@ import { addDays, endOfMonth, startOfMonth } from 'date-fns'; -import type { SupabaseClient } from '@supabase/supabase-js'; -import type { Database, Tables } from '@/types/supabase'; +import type { + DailySummary, + JournalDataRepository, + MoodEntry +} from '@/lib/data'; import type { ReflectionMode } from './types'; -type AdminClient = SupabaseClient; - export interface DailyAggregate { - summary: Tables<'daily_summaries'>; - mood?: Tables<'daily_question'> | null; + summary: DailySummary; + mood?: MoodEntry | null; } // Format a Date as YYYY-MM-DD in LOCAL time (do not convert to UTC) @@ -51,35 +52,28 @@ export const getPeriodBounds = ( }; export async function fetchDailyAggregate( - client: AdminClient, + repo: JournalDataRepository, userId: string, date: string ): Promise { - const { data: summary, error: summaryError } = await client - .from('daily_summaries') - .select('*') - .eq('user_id', userId) - .eq('date', date) - .maybeSingle(); - - if (summaryError) { - console.error('Failed to fetch daily summary', summaryError); - throw summaryError; + let summary: DailySummary | null; + try { + summary = await repo.getDailySummary(userId, date); + } catch (error) { + console.error('Failed to fetch daily summary', error); + throw error; } if (!summary) return null; - const { data: mood, error: moodError } = await client - .from('daily_question') - .select('*') - .eq('user_id', userId) - .gte('created_at', `${date}T00:00:00`) - .lte('created_at', `${date}T23:59:59.999`) - .maybeSingle(); - - if (moodError && moodError.code !== 'PGRST116') { - console.error('Failed to fetch daily mood', moodError); - throw moodError; + let mood: MoodEntry | null; + try { + // Day boundaries resolved through timezone.ts inside the repository + // (replaces the previous bare `${date}T00:00:00` bounds). + mood = await repo.getMood(userId, date); + } catch (error) { + console.error('Failed to fetch daily mood', error); + throw error; } return { @@ -89,44 +83,34 @@ export async function fetchDailyAggregate( } export async function fetchAggregatesInRange( - client: AdminClient, + repo: JournalDataRepository, userId: string, start: string, end: string ): Promise { - const { data: summaries, error: summariesError } = await client - .from('daily_summaries') - .select('*') - .eq('user_id', userId) - .gte('date', start) - .lte('date', end) - .order('date', { ascending: true }); - - if (summariesError) { - console.error('Failed to fetch summaries range', summariesError); - throw summariesError; + let summaries: DailySummary[]; + try { + summaries = await repo.listDailySummariesInRange(userId, start, end); + } catch (error) { + console.error('Failed to fetch summaries range', error); + throw error; } - if (!summaries || summaries.length === 0) { + if (summaries.length === 0) { return []; } - const { data: moods, error: moodsError } = await client - .from('daily_question') - .select('*') - .eq('user_id', userId) - .gte('created_at', `${start}T00:00:00`) - .lte('created_at', `${end}T23:59:59.999`); - - if (moodsError && moodsError.code !== 'PGRST116') { - console.error('Failed to fetch moods range', moodsError); - throw moodsError; + let moods: MoodEntry[]; + try { + moods = await repo.listMoodsInRange(userId, start, end); + } catch (error) { + console.error('Failed to fetch moods range', error); + throw error; } - const moodMap = new Map>(); - moods?.forEach((mood) => { - const key = localYmd(new Date(mood.created_at ?? `${start}T00:00:00`)); - moodMap.set(key, mood); + const moodMap = new Map(); + moods.forEach((mood) => { + moodMap.set(mood.date, mood); }); return summaries.map((summary) => ({ diff --git a/src/lib/reflections/generator.ts b/src/lib/reflections/generator.ts index 30fcac0..1229944 100644 --- a/src/lib/reflections/generator.ts +++ b/src/lib/reflections/generator.ts @@ -1,6 +1,9 @@ import OpenAI from 'openai'; -import type { SupabaseClient } from '@supabase/supabase-js'; -import type { Database } from '@/types/supabase'; +import type { + DailySummary, + JournalDataRepository, + PeriodReflection +} from '@/lib/data'; import { countEmotions, fetchAggregatesInRange, @@ -11,12 +14,10 @@ import { import type { DailyAggregate } from './aggregate'; import { reflectionAISchema } from './schema'; import type { ReflectionCard, ReflectionMode } from './types'; -import type { Json, TablesInsert, TablesUpdate } from '@/types/supabase'; - -type AdminClient = SupabaseClient; +import type { Json } from '@/types/supabase'; interface GenerateOptions { - supabase: AdminClient; + repo: JournalDataRepository; openai: OpenAI; userId: string; mode: ReflectionMode; @@ -160,7 +161,7 @@ const SYSTEM_PROMPT = `You are an empathetic journaling coach. Respond strictly }`; export async function generateReflection({ - supabase, + repo, openai, userId, mode, @@ -170,7 +171,7 @@ export async function generateReflection({ const bounds = getPeriodBounds(mode, resolvedDate); if (mode === 'daily') { - const dailyData = await fetchDailyAggregate(supabase, userId, resolvedDate); + const dailyData = await fetchDailyAggregate(repo, userId, resolvedDate); if (!dailyData) { throw new Error('No daily summary found for date'); @@ -178,7 +179,7 @@ export async function generateReflection({ const contextText = buildContextForDaily(dailyData); const aggregates = await fetchAggregatesInRange( - supabase, + repo, userId, bounds.start, bounds.end @@ -223,33 +224,27 @@ export async function generateReflection({ topEmotions: countEmotions(aggregates) }; - const shouldPreserve = existing.edited ?? false; - - const updatePayload: TablesUpdate<'daily_summaries'> = { - achievements: shouldPreserve - ? (existing.achievements ?? []) - : parsed.achievements, - commitments: shouldPreserve - ? (existing.commitments ?? []) - : parsed.commitments, - mood_overall: shouldPreserve - ? existing.mood_overall - : parsed.mood.overall, - mood_reason: shouldPreserve ? existing.mood_reason : parsed.mood.reason, - flashback: shouldPreserve ? existing.flashback : parsed.flashback, - stats: cleanStats(stats), - gen_version: GEN_VERSION, - last_generated_at: new Date().toISOString() - }; - - const { data, error } = await supabase - .from('daily_summaries') - .update(updatePayload) - .eq('id', existing.id) - .select() - .single(); - - if (error) { + // preserveIfEdited keeps the user-edited fields (achievements, + // commitments, mood_overall, mood_reason, flashback) untouched when + // existing.edited is true, matching the previous inline logic. + let data: DailySummary; + try { + data = await repo.updateDailySummaryReflection( + userId, + resolvedDate, + { + achievements: parsed.achievements, + commitments: parsed.commitments, + mood_overall: parsed.mood.overall, + mood_reason: parsed.mood.reason, + flashback: parsed.flashback, + stats: cleanStats(stats), + gen_version: GEN_VERSION, + last_generated_at: new Date().toISOString() + }, + { preserveIfEdited: true } + ); + } catch (error) { console.error('Failed to update daily reflection', error); throw error; } @@ -275,7 +270,7 @@ export async function generateReflection({ } const aggregates = await fetchAggregatesInRange( - supabase, + repo, userId, bounds.start, bounds.end @@ -330,17 +325,12 @@ export async function generateReflection({ throw err; } - const { data: existing, error: existingError } = await supabase - .from('period_reflections') - .select('*') - .eq('user_id', userId) - .eq('period_type', mode) - .eq('period_start', bounds.start) - .maybeSingle(); - - if (existingError) { - console.error('Failed to fetch period reflection', existingError); - throw existingError; + let existing: PeriodReflection | null; + try { + existing = await repo.getPeriodReflection(userId, mode, bounds.start); + } catch (error) { + console.error('Failed to fetch period reflection', error); + throw error; } const shouldPreserve = existing?.edited ?? false; @@ -353,11 +343,9 @@ export async function generateReflection({ topEmotions: countEmotions(aggregates) }; - const upsertPayload: TablesInsert<'period_reflections'> = { - user_id: userId, - period_type: mode, - period_start: bounds.start, - period_end: bounds.end, + // Edited reflections keep their user-authored fields; only stats and + // generation metadata are refreshed (same semantics as before). + const upsertFields: Partial = { achievements: shouldPreserve ? (existing?.achievements ?? []) : parsed.achievements, @@ -379,13 +367,16 @@ export async function generateReflection({ edited: existing?.edited ?? false }; - const { data, error } = await supabase - .from('period_reflections') - .upsert(upsertPayload, { onConflict: 'user_id,period_type,period_start' }) - .select() - .single(); - - if (error) { + let data: PeriodReflection; + try { + data = await repo.upsertPeriodReflection( + userId, + mode, + bounds.start, + bounds.end, + upsertFields + ); + } catch (error) { console.error('Failed to upsert period reflection', error); throw error; } diff --git a/src/lib/reflections/sync.ts b/src/lib/reflections/sync.ts index 97d99c9..b2518ac 100644 --- a/src/lib/reflections/sync.ts +++ b/src/lib/reflections/sync.ts @@ -1,32 +1,22 @@ import OpenAI from 'openai'; -import type { SupabaseClient } from '@supabase/supabase-js'; -import type { Database } from '@/types/supabase'; +import { getJournalRepo } from '@/lib/data'; import { generateReflection } from './generator'; import type { ReflectionMode } from './types'; -type AdminClient = SupabaseClient; +const SYNC_MODES: ReflectionMode[] = ['daily', 'weekly', 'monthly']; -const DEFAULT_MODES: ReflectionMode[] = ['daily', 'weekly', 'monthly']; - -interface SyncOptions { - supabase: AdminClient; +export async function syncReflectionsForDate(opts: { openai: OpenAI; userId: string; anchorDate: string; - modes?: ReflectionMode[]; -} +}): Promise { + const { openai, userId, anchorDate } = opts; + const repo = getJournalRepo(); -export async function syncReflectionsForDate({ - supabase, - openai, - userId, - anchorDate, - modes = DEFAULT_MODES -}: SyncOptions) { - for (const mode of modes) { + for (const mode of SYNC_MODES) { try { await generateReflection({ - supabase, + repo, openai, userId, mode, diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts deleted file mode 100644 index 875ba1f..0000000 --- a/src/lib/supabase/client.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { createBrowserClient } from '@supabase/ssr'; -import type { Database } from '@/types/supabase'; - -// Define a function to create a Supabase client for client-side operations (data only) -export const createClient = () => { - const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL; - const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY; - - if (!supabaseUrl || !supabaseAnonKey) { - throw new Error('Missing required Supabase environment variables'); - } - - return createBrowserClient(supabaseUrl, supabaseAnonKey); -}; diff --git a/src/lib/supabase/queries.ts b/src/lib/supabase/queries.ts deleted file mode 100644 index 6ab98ee..0000000 --- a/src/lib/supabase/queries.ts +++ /dev/null @@ -1,392 +0,0 @@ -import type { Database } from '@/types/supabase'; -import type { SupabaseClient } from '@supabase/supabase-js'; -import { cache } from 'react'; -import { getLocalDayRange, getUtcRangeForDate } from '@/lib/timezone'; - -// Type for the Supabase client with your database schema -type TypedSupabaseClient = SupabaseClient; - -/** - * Get today's mood entry for the specified user - * @param supabase - Supabase client instance - * @param userId - User ID - * @returns Today's mood entry or null if not exists - */ -export const getTodayMoodEntry = cache( - async (supabase: TypedSupabaseClient, userId: string) => { - if (!userId) return null; - - const { start, end } = getLocalDayRange(); - - const { data, error } = await supabase - .from('daily_question') - .select('*') - .eq('user_id', userId) - .gte('created_at', start) - .lte('created_at', end) - .single(); - - if (error && error.code === 'PGRST116') { - // No entry found for today - return null; - } - - if (error) { - console.error('Error fetching today mood entry:', error); - return null; - } - - return data; - } -); - -/** - * Get recent audio journal entries for the specified user - * @param supabase - Supabase client instance - * @param userId - User ID - * @param limit - Number of entries to fetch (default: 10) - * @returns Recent audio journal entries with transcripts - */ -export const getRecentAudioJournals = cache( - async (supabase: TypedSupabaseClient, userId: string, limit: number = 10) => { - if (!userId) return []; - - const { data, error } = await supabase - .from('audio_files') - .select( - ` - *, - transcripts ( - id, - text, - language, - created_at - ) - ` - ) - .eq('user_id', userId) - .order('created_at', { ascending: false }) - .limit(limit); - - if (error) { - console.error('Error fetching audio journals:', error); - return []; - } - - return data; - } -); - -/** - * Get today's audio journal entries for the specified user - * @param supabase - Supabase client instance - * @param userId - User ID - * @returns Today's audio journal entries - */ -export const getTodayAudioJournals = cache( - async (supabase: TypedSupabaseClient, userId: string) => { - if (!userId) return []; - - const { start, end } = getLocalDayRange(); - - const { data, error } = await supabase - .from('audio_files') - .select( - ` - *, - transcripts ( - id, - text, - language, - created_at - ) - ` - ) - .eq('user_id', userId) - .gte('created_at', start) - .lte('created_at', end) - .order('created_at', { ascending: false }); - - if (error) { - console.error('Error fetching today audio journals:', error); - return []; - } - - return data; - } -); - -/** - * Get audio journal stats for the specified user - * @param supabase - Supabase client instance - * @param userId - User ID - * @returns Audio journal statistics - */ -export const getAudioJournalStats = cache( - async (supabase: TypedSupabaseClient, userId: string) => { - if (!userId) - return { totalEntries: 0, thisWeekEntries: 0, currentStreak: 0 }; - - // Get total entries - const { count: totalEntries, error: totalError } = await supabase - .from('audio_files') - .select('*', { count: 'exact', head: true }) - .eq('user_id', userId); - - if (totalError) { - console.error('Error fetching total audio entries:', totalError); - } - - // Get this week's entries - const weekStart = new Date(); - weekStart.setDate(weekStart.getDate() - weekStart.getDay()); - weekStart.setHours(0, 0, 0, 0); - - const { count: thisWeekEntries, error: weekError } = await supabase - .from('audio_files') - .select('*', { count: 'exact', head: true }) - .eq('user_id', userId) - .gte('created_at', weekStart.toISOString()); - - if (weekError) { - console.error('Error fetching week audio entries:', weekError); - } - - // Calculate current streak (simplified - consecutive days with entries) - const { data: recentEntries, error: recentError } = await supabase - .from('audio_files') - .select('created_at') - .eq('user_id', userId) - .order('created_at', { ascending: false }) - .limit(30); - - let currentStreak = 0; - if (!recentError && recentEntries) { - const today = new Date(); - const dates = new Set( - recentEntries.map((entry) => { - const { date } = getLocalDayRange({ - date: new Date(entry.created_at!) - }); - return date; - }) - ); - - for (let i = 0; i < 30; i++) { - const checkDate = new Date(today); - checkDate.setDate(checkDate.getDate() - i); - const { date: dateString } = getLocalDayRange({ date: checkDate }); - - if (dates.has(dateString)) { - currentStreak++; - } else { - break; - } - } - } - - return { - totalEntries: totalEntries || 0, - thisWeekEntries: thisWeekEntries || 0, - currentStreak - }; - } -); - -/** - * Get journals with daily summaries for the specified user - * @param supabase - Supabase client instance - * @param userId - User ID - * @param options - Query options for filtering and pagination - * @returns Array of daily records with journals and summaries - */ -export const getJournalsWithSummaries = cache( - async ( - supabase: TypedSupabaseClient, - userId: string, - options?: { - startDate?: string; - endDate?: string; - moods?: string[]; - keyword?: string; - page?: number; - limit?: number; - } - ) => { - if (!userId) return { data: [], totalCount: 0 }; - - const page = options?.page || 1; - const limit = options?.limit || 10; - const offset = (page - 1) * limit; - - // Build the query for daily summaries - let summariesQuery = supabase - .from('daily_summaries') - .select('*', { count: 'exact' }) - .eq('user_id', userId); - - // Apply date filters - if (options?.startDate) { - summariesQuery = summariesQuery.gte('date', options.startDate); - } - if (options?.endDate) { - summariesQuery = summariesQuery.lte('date', options.endDate); - } - - // Apply mood filters - if (options?.moods && options.moods.length > 0) { - summariesQuery = summariesQuery.in('mood_quality', options.moods); - } - - // Apply keyword search in summary - if (options?.keyword) { - summariesQuery = summariesQuery.ilike('summary', `%${options.keyword}%`); - } - - // Execute query with pagination - const { - data: summaries, - error: summariesError, - count - } = await summariesQuery - .order('date', { ascending: false }) - .range(offset, offset + limit - 1); - - if (summariesError) { - console.error('Error fetching journal summaries:', summariesError); - return { data: [], totalCount: 0 }; - } - - // For each summary, fetch the corresponding journal entries - const journalsWithSummaries = await Promise.all( - (summaries || []).map(async (summary) => { - const { start: dayStart, end: dayEnd } = getUtcRangeForDate( - summary.date - ); - - const { data: journals, error: journalsError } = await supabase - .from('audio_files') - .select( - ` - *, - transcripts ( - id, - text, - rephrased_text, - language, - created_at - ) - ` - ) - .eq('user_id', userId) - .gte('created_at', dayStart) - .lte('created_at', dayEnd) - .order('created_at', { ascending: true }); - - if (journalsError) { - console.error( - 'Error fetching journals for date:', - summary.date, - journalsError - ); - } - - // Also fetch the daily mood for this date - const { data: moodData } = await supabase - .from('daily_question') - .select('*') - .eq('user_id', userId) - .gte('created_at', dayStart) - .lte('created_at', dayEnd) - .single(); - - return { - ...summary, - journals: journals || [], - dailyMood: moodData || null - }; - }) - ); - - return { - data: journalsWithSummaries, - totalCount: count || 0 - }; - } -); - -/** - * Get a specific daily summary - * @param supabase - Supabase client instance - * @param userId - User ID - * @param date - Date string (YYYY-MM-DD) - * @returns Daily summary or null - */ -export const getDailySummary = cache( - async (supabase: TypedSupabaseClient, userId: string, date: string) => { - if (!userId || !date) return null; - - const { data, error } = await supabase - .from('daily_summaries') - .select('*') - .eq('user_id', userId) - .eq('date', date) - .single(); - - if (error && error.code === 'PGRST116') { - // No summary found for this date - return null; - } - - if (error) { - console.error('Error fetching daily summary:', error); - return null; - } - - return data; - } -); - -/** - * Search journals by keyword in transcripts - * @param supabase - Supabase client instance - * @param userId - User ID - * @param keyword - Search keyword - * @param limit - Number of results to return - * @returns Array of matching journal entries - */ -export const searchJournals = cache( - async ( - supabase: TypedSupabaseClient, - userId: string, - keyword: string, - limit: number = 20 - ) => { - if (!userId || !keyword) return []; - - const { data, error } = await supabase - .from('transcripts') - .select( - ` - *, - audio_files!inner( - id, - storage_path, - mime_type, - created_at - ) - ` - ) - .eq('user_id', userId) - .or(`text.ilike.%${keyword}%,rephrased_text.ilike.%${keyword}%`) - .order('created_at', { ascending: false }) - .limit(limit); - - if (error) { - console.error('Error searching journals:', error); - return []; - } - - return data; - } -); diff --git a/tests/integration/moduleB/generate-reflection.test.ts b/tests/integration/moduleB/generate-reflection.test.ts index 1bcfec7..dc122b7 100644 --- a/tests/integration/moduleB/generate-reflection.test.ts +++ b/tests/integration/moduleB/generate-reflection.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type OpenAI from 'openai'; -import type { Tables, TablesUpdate } from '@/types/supabase'; +import type { + DailySummary, + JournalDataRepository, + MoodEntry +} from '@/lib/data/repository'; import { generateReflection } from '@/lib/reflections/generator'; import { fetchDailyAggregate, @@ -20,10 +24,7 @@ vi.mock('@/lib/reflections/aggregate', async () => { }; }); -type DailySummaryRow = Tables<'daily_summaries'>; -type DailyMoodRow = Tables<'daily_question'>; - -const baseSummary = (): DailySummaryRow => ({ +const baseSummary = (): DailySummary => ({ id: 'summary-1', user_id: 'user-1', date: '2025-11-11', @@ -44,57 +45,46 @@ const baseSummary = (): DailySummaryRow => ({ updated_at: null }); -const baseMood = (): DailyMoodRow => ({ +const baseMood = (): MoodEntry => ({ id: 'mood-1', - user_id: 'user-1', + date: '2025-11-11', day_quality: 'good', emotions: ['Happy', 'Calm'], created_at: '2025-11-11T08:00:00Z', updated_at: null }); -// Minimal Supabase client stub that captures the latest update payload. -const makeSupabaseStub = () => { - let lastUpdate: TablesUpdate<'daily_summaries'> | null = null; - const stub = { - from: (table: string) => { - if (table !== 'daily_summaries') { - throw new Error(`Unexpected table ${table}`); - } +// Repo stub that applies the preserveIfEdited contract exactly like the real +// repositories: user-owned fields survive, generation metadata advances. +const makeRepoStub = () => { + const updateDailySummaryReflection = vi.fn( + async ( + _userId: string, + _date: string, + patch: Record, + opts: { preserveIfEdited?: boolean; markEdited?: boolean } + ): Promise => { + const existing = baseSummary(); + const preserved = Boolean(opts.preserveIfEdited && existing.edited); return { - update: (payload: TablesUpdate<'daily_summaries'>) => { - lastUpdate = payload; - return { - eq: () => ({ - select: () => ({ - single: async () => ({ - data: { - ...baseSummary(), - ...payload, - achievements: - payload.achievements ?? baseSummary().achievements, - commitments: - payload.commitments ?? baseSummary().commitments, - mood_overall: - payload.mood_overall ?? baseSummary().mood_overall, - mood_reason: - payload.mood_reason ?? baseSummary().mood_reason, - flashback: payload.flashback ?? baseSummary().flashback, - stats: payload.stats ?? null, - edited: payload.edited ?? baseSummary().edited, - last_generated_at: - payload.last_generated_at ?? - baseSummary().last_generated_at - } - }) - }) - }) - }; - } + ...existing, + ...(preserved + ? { + stats: (patch.stats as DailySummary['stats']) ?? existing.stats, + gen_version: + (patch.gen_version as string) ?? existing.gen_version, + last_generated_at: + (patch.last_generated_at as string) ?? + existing.last_generated_at + } + : patch) }; } - }; - return { stub: stub as any, getLastUpdate: () => lastUpdate }; + ); + const stub = { + updateDailySummaryReflection + } as unknown as JournalDataRepository; + return { stub, updateDailySummaryReflection }; }; const openaiStub = { @@ -161,29 +151,30 @@ beforeEach(() => { }); describe('generateReflection – daily mode', () => { - it('keeps edited content while updating stats + metadata', async () => { - const { stub: supabase, getLastUpdate } = makeSupabaseStub(); + it('requests preserveIfEdited and keeps edited content in the card', async () => { + const { stub, updateDailySummaryReflection } = makeRepoStub(); const card = await generateReflection({ - supabase, + repo: stub, openai: openaiStub, userId: 'user-1', mode: 'daily', anchorDate: '2025-11-11' }); - const payload = getLastUpdate(); - expect(payload).toBeTruthy(); - expect(payload?.achievements).toEqual(['Manual win']); - expect(payload?.commitments).toEqual(['Manual focus']); - expect(payload?.mood_overall).toBe('hopeful'); - expect(payload?.gen_version).toBe('module-b-v1'); - expect(payload?.stats).toBeTruthy(); + expect(updateDailySummaryReflection).toHaveBeenCalledTimes(1); + const [, date, patch, opts] = updateDailySummaryReflection.mock.calls[0]; + expect(date).toBe('2025-11-11'); + expect(opts).toEqual({ preserveIfEdited: true }); + // Machine output flows into the patch (repo decides what survives)... + expect(patch.gen_version).toBe('module-b-v1'); + expect(patch.stats).toBeTruthy(); + expect((patch.stats as { keywords: string[] }).keywords).toHaveLength(8); // trimmed to max 8 + // ...while the edited row keeps user content in the projected card. expect(card.period.date).toBe('2025-11-11'); expect(card.achievements).toEqual(['Manual win']); expect(card.commitments).toEqual(['Manual focus']); expect(card.moodOverall).toBe('hopeful'); - expect(card.stats?.keywords).toHaveLength(8); // trimmed to max 8 }); }); diff --git a/tests/mocks/fake-workbench.ts b/tests/mocks/fake-workbench.ts new file mode 100644 index 0000000..d71f0e5 --- /dev/null +++ b/tests/mocks/fake-workbench.ts @@ -0,0 +1,152 @@ +// In-memory workbench honoring the NoKV contract semantics the journal +// repository depends on: create-only put_file (PathExists), ordered append, +// exact-string edit (AmbiguousEdit on multiple hits), structured read, +// non-recursive list, case-insensitive literal grep with basename glob. + +export class FakeToolError extends Error {} + +interface FileEntry { + text: string; + contentType: string; +} + +export class FakeWorkbench { + workbenches = new Map>(); + blobs = new Map(); // `${wb}:${section}/${path}` (CLI channel) + + private files(wb: string): Map { + const files = this.workbenches.get(wb); + if (!files) throw new FakeToolError(`NotFound: workbench ${wb}`); + return files; + } + + dispatch(name: string, args: Record): unknown { + switch (name) { + case 'workbench_create': { + const id = args.id as string; + if (this.workbenches.has(id)) { + throw new FakeToolError('AlreadyExists'); + } + this.workbenches.set(id, new Map()); + return { ok: true }; + } + case 'workbench_put_file': { + const files = this.files(args.id as string); + const key = `${args.section}/${args.path}`; + const replace = Boolean(args.replace); + if (!replace && files.has(key)) { + throw new FakeToolError(`PathExists: ${key}`); + } + if (replace && !files.has(key)) { + // replace-only mode requires an existing path in the contract; + // tolerate create-via-replace for tombstoning ergonomics. + } + files.set(key, { + text: (args.text as string) ?? '', + contentType: (args.content_type as string) ?? 'text/plain' + }); + return { ok: true }; + } + case 'workbench_append': { + const files = this.files(args.id as string); + const key = `${args.section}/${args.path}`; + const prev = files.get(key)?.text ?? ''; + files.set(key, { + text: prev + ((args.text as string) ?? ''), + contentType: (args.content_type as string) ?? 'text/plain' + }); + return { ok: true }; + } + case 'workbench_read': { + const files = this.files(args.id as string); + const key = `${args.section}/${args.path}`; + const file = files.get(key); + if (!file) throw new FakeToolError(`NotFound: ${key}`); + if (file.contentType === 'application/json') { + return { records: [{ text: file.text }] }; + } + return { + records: file.text + .split('\n') + .filter((l) => l.length > 0) + .map((text) => ({ text })) + }; + } + case 'workbench_edit': { + const files = this.files(args.id as string); + const key = `${args.section}/${args.path}`; + const file = files.get(key); + if (!file) throw new FakeToolError(`NotFound: ${key}`); + const oldStr = args.old_string as string; + const first = file.text.indexOf(oldStr); + if (first === -1) throw new FakeToolError('no match for old_string'); + if (file.text.indexOf(oldStr, first + 1) !== -1) { + throw new FakeToolError('AmbiguousEdit: multiple matches'); + } + files.set(key, { + ...file, + text: file.text.replace(oldStr, args.new_string as string) + }); + return { ok: true }; + } + case 'workbench_list': { + const files = this.files(args.id as string); + const prefix = `${args.section}/${args.path ?? ''}`; + const children = new Set(); + for (const key of files.keys()) { + if (!key.startsWith(prefix)) continue; + const rest = key.slice(prefix.length); + if (!rest) continue; + const child = rest.includes('/') + ? rest.slice(0, rest.indexOf('/') + 1) + : rest; + children.add(prefix + child); + } + return { entries: [...children].sort().map((path) => ({ path })) }; + } + case 'workbench_grep': { + const files = this.files(args.id as string); + const prefix = `${args.section ?? ''}/${args.path ?? ''}`; + const pattern = (args.pattern as string).toLowerCase(); + const glob = args.glob as string | undefined; + const matches: Array<{ path: string }> = []; + for (const [key, file] of files.entries()) { + if (!key.startsWith(prefix)) continue; + const basename = key.slice(key.lastIndexOf('/') + 1); + if (glob && basename !== glob) continue; + if (file.text.toLowerCase().includes(pattern)) { + matches.push({ path: key }); + } + } + return { matches }; + } + case 'workbench_stat': { + const files = this.files(args.id as string); + const key = `${args.section}/${args.path}`; + const file = files.get(key); + if (!file) throw new FakeToolError(`NotFound: ${key}`); + return { path: key, logical_size: Buffer.byteLength(file.text) }; + } + default: + throw new FakeToolError(`unknown tool ${name}`); + } + } + + // CLI blob channel used by blob-cli mocks. + collect(wb: string, section: string, path: string, data: Buffer): void { + const key = `${wb}:${section}/${path}`; + if (this.blobs.has(key)) throw new FakeToolError('PathExists'); + this.blobs.set(key, data); + } + + materialize(wb: string, section: string, path: string): Buffer | null { + const key = `${wb}:${section}/${path}`; + const hit = this.blobs.get(key); + if (hit) return hit; + // Tombstoned via put_file replace:true text:'' — reflect emptiness. + const files = this.workbenches.get(wb); + const file = files?.get(`${section}/${path}`); + if (file != null) return Buffer.from(file.text); + return null; + } +} diff --git a/tests/unit/moduleB/reflections-aggregate.test.ts b/tests/unit/moduleB/reflections-aggregate.test.ts index 2da6a87..5891e99 100644 --- a/tests/unit/moduleB/reflections-aggregate.test.ts +++ b/tests/unit/moduleB/reflections-aggregate.test.ts @@ -35,7 +35,7 @@ const makeAggregate = ( }, mood: { id: `mood-${date}`, - user_id: 'user-1', + date, day_quality: 'good', emotions: moodEmotions, created_at: `${date}T08:00:00Z`, diff --git a/tests/unit/nokv/journal-doc.test.ts b/tests/unit/nokv/journal-doc.test.ts new file mode 100644 index 0000000..55d2db2 --- /dev/null +++ b/tests/unit/nokv/journal-doc.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest'; +import { + buildBlock, + extractBlock, + parseDoc, + serializeDoc +} from '@/lib/nokv/journal-doc'; + +const sampleDay = { + schema: 'echojournal.day.v1', + user_id: 'user_abc', + date: '2026-08-05', + tz: 'Australia/Sydney', + mood: { + day_quality: 'good', + emotions: ['calm', 'focused'], + created_at: '2026-08-05T01:00:00.000Z', + updated_at: '2026-08-05T02:00:00.000Z' + }, + summary: null, + reflection: null +}; + +describe('journal-doc serialization discipline', () => { + it('extractBlock returns the byte-exact block buildBlock produces', () => { + const text = serializeDoc(sampleDay); + for (const key of ['mood', 'summary', 'reflection', 'date']) { + const extracted = extractBlock(text, key); + expect(extracted).not.toBeNull(); + expect(extracted).toBe( + buildBlock(key, (sampleDay as Record)[key] as never) + ); + } + }); + + it('survives a serialize -> parse -> serialize byte roundtrip', () => { + const text = serializeDoc(sampleDay); + expect(serializeDoc(parseDoc(text))).toBe(text); + }); + + it('block replacement composes into a valid document', () => { + const text = serializeDoc(sampleDay); + const oldBlock = extractBlock(text, 'summary')!; + const newBlock = buildBlock('summary', { + text: 'A "quoted" day\nwith a newline', + entry_count: 2, + mood_quality: 'good', + dominant_emotions: [], + generated_at: 'x', + updated_at: 'y' + }); + const next = text.replace(oldBlock, newBlock); + const parsed = parseDoc<{ summary: { text: string } }>(next); + expect(parsed.summary.text).toBe('A "quoted" day\nwith a newline'); + // Untouched blocks stay byte-identical. + expect(extractBlock(next, 'mood')).toBe(extractBlock(text, 'mood')); + }); + + it('top-level marker cannot collide with string content', () => { + const tricky = { + a: 'contains \n "mood": fake marker in a string', + mood: null + }; + const text = serializeDoc(tricky); + // The real newline pattern appears exactly once (string newlines are escaped). + const occurrences = text.split('\n "mood":').length - 1; + expect(occurrences).toBe(1); + }); +}); diff --git a/tests/unit/nokv/journal-repository.test.ts b/tests/unit/nokv/journal-repository.test.ts new file mode 100644 index 0000000..7a3c284 --- /dev/null +++ b/tests/unit/nokv/journal-repository.test.ts @@ -0,0 +1,351 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { FakeWorkbench, FakeToolError } from '../../mocks/fake-workbench'; + +// One shared fake per test file run; reset in beforeEach. +const fake = new FakeWorkbench(); + +vi.mock('@/lib/nokv/mcp-client', async (importOriginal) => { + const actual = await importOriginal(); + class NokvToolError extends Error {} + return { + ...actual, + NokvToolError, + isNokvEnabled: () => true, + callWorkbenchTool: async (name: string, args: Record) => { + try { + return fake.dispatch(name, args); + } catch (err) { + if (err instanceof FakeToolError) { + throw new NokvToolError(err.message); + } + throw err; + } + } + }; +}); + +vi.mock('@/lib/nokv/blob-cli', () => ({ + collectBlob: async ( + wb: string, + section: string, + path: string, + data: Buffer + ) => fake.collect(wb, section, path, data), + materializeBlob: async (wb: string, section: string, path: string) => + fake.materialize(wb, section, path) +})); + +import { NokvJournalRepository } from '@/lib/nokv/journal-repository'; + +const USER = 'user_test1'; + +function freshRepo(): NokvJournalRepository { + fake.workbenches.clear(); + fake.blobs.clear(); + return new NokvJournalRepository(); +} + +describe('NokvJournalRepository — mood', () => { + let repo: NokvJournalRepository; + beforeEach(() => { + repo = freshRepo(); + }); + + it('upserts and reads back a mood', async () => { + await repo.upsertMood(USER, '2026-08-05', { + dayQuality: 'good', + emotions: ['calm'] + }); + const mood = await repo.getMood(USER, '2026-08-05'); + expect(mood?.day_quality).toBe('good'); + expect(mood?.emotions).toEqual(['calm']); + }); + + it('update preserves created_at and changes values', async () => { + const first = await repo.upsertMood(USER, '2026-08-05', { + dayQuality: 'bad', + emotions: [] + }); + const second = await repo.upsertMood(USER, '2026-08-05', { + dayQuality: 'good', + emotions: ['happy'] + }); + expect(second.created_at).toBe(first.created_at); + expect((await repo.getMood(USER, '2026-08-05'))?.day_quality).toBe('good'); + }); + + it('returns null for a day without mood', async () => { + expect(await repo.getMood(USER, '2026-01-01')).toBeNull(); + }); +}); + +describe('NokvJournalRepository — entries', () => { + let repo: NokvJournalRepository; + beforeEach(() => { + repo = freshRepo(); + }); + + async function create(text = 'hello world') { + return repo.createEntry(USER, { + audio: Buffer.from([1, 2, 3, 4]), + mimeType: 'audio/webm', + transcript: text, + rephrasedText: `Polished: ${text}`, + language: 'en' + }); + } + + it('creates, lists, reads and serves audio', async () => { + const entry = await create(); + const listed = await repo.listEntriesForDay(USER, entry.date); + expect(listed.map((e) => e.id)).toEqual([entry.id]); + + const roundtrip = await repo.getEntry(USER, entry.id); + expect(roundtrip?.transcript.rephrased_text).toBe('Polished: hello world'); + + const blob = await repo.getAudioBlob(USER, entry.id); + expect(blob?.data.equals(Buffer.from([1, 2, 3, 4]))).toBe(true); + expect(blob?.mimeType).toBe('audio/webm'); + }); + + it('updates rephrased text via block edit', async () => { + const entry = await create(); + const updated = await repo.updateRephrasedText(USER, entry.id, 'better'); + expect(updated.transcript.rephrased_text).toBe('better'); + expect( + (await repo.getEntry(USER, entry.id))?.transcript.rephrased_text + ).toBe('better'); + }); + + it('delete hides the entry from every view', async () => { + const entry = await create(); + await repo.deleteEntry(USER, entry.id); + expect(await repo.getEntry(USER, entry.id)).toBeNull(); + expect(await repo.listEntriesForDay(USER, entry.date)).toEqual([]); + expect(await repo.getAudioBlob(USER, entry.id)).toBeNull(); + const stats = await repo.getEntryStats(USER); + expect(stats.totalEntries).toBe(0); + }); + + it('counts stats from the ledger fold', async () => { + await create('one'); + await create('two'); + const stats = await repo.getEntryStats(USER); + expect(stats.totalEntries).toBe(2); + expect(stats.currentStreak).toBeGreaterThanOrEqual(1); + }); +}); + +describe('NokvJournalRepository — daily summaries', () => { + let repo: NokvJournalRepository; + beforeEach(() => { + repo = freshRepo(); + }); + + it('upserts and projects a summary', async () => { + const s = await repo.upsertDailySummary(USER, '2026-08-05', { + summary: 'A fine day of shipping.', + entryCount: 2, + moodQuality: 'good', + dominantEmotions: ['calm'] + }); + expect(s.summary).toBe('A fine day of shipping.'); + expect(s.id).toBe('daily:2026-08-05'); + const read = await repo.getDailySummary(USER, '2026-08-05'); + expect(read?.entry_count).toBe(2); + }); + + it('generation preserves user edits behind the latch', async () => { + await repo.upsertDailySummary(USER, '2026-08-05', { + summary: 'day', + entryCount: 1, + moodQuality: 'good', + dominantEmotions: [] + }); + // User edit: marks edited (creates the create-only latch). + await repo.updateDailySummaryReflection( + USER, + '2026-08-05', + { achievements: ['user wrote this'], flashback: 'user flashback' }, + { markEdited: true } + ); + // Background regeneration must NOT clobber user fields... + const after = await repo.updateDailySummaryReflection( + USER, + '2026-08-05', + { + achievements: ['machine output'], + flashback: 'machine flashback', + gen_version: 'v2', + last_generated_at: '2026-08-05T10:00:00Z' + }, + { preserveIfEdited: true } + ); + expect(after.achievements).toEqual(['user wrote this']); + expect(after.flashback).toBe('user flashback'); + // ...but generation metadata still advances. + expect(after.gen_version).toBe('v2'); + expect(after.edited).toBe(true); + }); + + it('lists summaries with cursor and range semantics', async () => { + for (const d of ['2026-08-01', '2026-08-03', '2026-08-05']) { + await repo.upsertDailySummary(USER, d, { + summary: `sum ${d}`, + entryCount: 1, + moodQuality: null, + dominantEmotions: [] + }); + } + const latest = await repo.listDailySummaries(USER, { limit: 2 }); + expect(latest.map((s) => s.date)).toEqual(['2026-08-05', '2026-08-03']); + + const upTo = await repo.listDailySummaries(USER, { + before: '2026-08-03', + limit: 10 + }); + expect(upTo.map((s) => s.date)).toEqual(['2026-08-03', '2026-08-01']); + + const range = await repo.listDailySummariesInRange( + USER, + '2026-08-02', + '2026-08-04' + ); + expect(range.map((s) => s.date)).toEqual(['2026-08-03']); + }); +}); + +describe('NokvJournalRepository — queryJournalDays', () => { + let repo: NokvJournalRepository; + beforeEach(async () => { + repo = freshRepo(); + await repo.upsertDailySummary(USER, '2026-08-01', { + summary: 'Walked by the harbour and thought about NoKV.', + entryCount: 1, + moodQuality: 'good', + dominantEmotions: [] + }); + await repo.upsertDailySummary(USER, '2026-08-02', { + summary: 'Rainy day indoors.', + entryCount: 1, + moodQuality: 'bad', + dominantEmotions: [] + }); + await repo.upsertMood(USER, '2026-08-02', { + dayQuality: 'bad', + emotions: ['tired'] + }); + }); + + it('returns the exact total count with no filters', async () => { + const { days, totalCount } = await repo.queryJournalDays(USER, { + page: 1, + limit: 10 + }); + expect(totalCount).toBe(2); + expect(days.map((d) => d.date)).toEqual(['2026-08-02', '2026-08-01']); + expect(days[0].mood?.emotions).toEqual(['tired']); + }); + + it('filters by mood with exact count', async () => { + const { days, totalCount } = await repo.queryJournalDays(USER, { + moods: ['good'], + page: 1, + limit: 10 + }); + expect(totalCount).toBe(1); + expect(days[0].date).toBe('2026-08-01'); + }); + + it('filters by keyword through the sidecar grep', async () => { + const hit = await repo.queryJournalDays(USER, { + keyword: 'harbour', + page: 1, + limit: 10 + }); + expect(hit.totalCount).toBe(1); + expect(hit.days[0].date).toBe('2026-08-01'); + + const miss = await repo.queryJournalDays(USER, { + keyword: 'nonexistent-keyword', + page: 1, + limit: 10 + }); + expect(miss.totalCount).toBe(0); + }); +}); + +describe('NokvJournalRepository — period reflections', () => { + let repo: NokvJournalRepository; + beforeEach(() => { + repo = freshRepo(); + }); + + it('creates, regenerates and preserves user edits', async () => { + const created = await repo.upsertPeriodReflection( + USER, + 'weekly', + '2026-08-03', + '2026-08-09', + { achievements: ['machine v1'], gen_version: 'v1' } + ); + expect(created.id).toBe('weekly:2026-08-03'); + + // User edits -> latch drops. + await repo.updatePeriodReflectionFields(USER, 'weekly:2026-08-03', { + achievements: ['user version'] + }); + + // Regeneration keeps user content, advances metadata. + const regen = await repo.upsertPeriodReflection( + USER, + 'weekly', + '2026-08-03', + '2026-08-09', + { achievements: ['machine v2'], gen_version: 'v2' } + ); + expect(regen.achievements).toEqual(['user version']); + expect(regen.gen_version).toBe('v2'); + expect(regen.edited).toBe(true); + }); + + it('lists newest first', async () => { + await repo.upsertPeriodReflection( + USER, + 'weekly', + '2026-07-27', + '2026-08-02', + {} + ); + await repo.upsertPeriodReflection( + USER, + 'weekly', + '2026-08-03', + '2026-08-09', + {} + ); + const list = await repo.listPeriodReflections(USER, 'weekly', 12); + expect(list.map((r) => r.period_start)).toEqual([ + '2026-08-03', + '2026-07-27' + ]); + }); +}); + +describe('NokvJournalRepository — privacy', () => { + it('deleteAllUserData short-circuits every read', async () => { + const repo = freshRepo(); + await repo.upsertDailySummary(USER, '2026-08-05', { + summary: 'to be deleted', + entryCount: 1, + moodQuality: null, + dominantEmotions: [] + }); + await repo.deleteAllUserData(USER); + expect(await repo.getDailySummary(USER, '2026-08-05')).toBeNull(); + expect(await repo.getMood(USER, '2026-08-05')).toBeNull(); + expect( + (await repo.queryJournalDays(USER, { page: 1, limit: 10 })).totalCount + ).toBe(0); + }); +}); diff --git a/tests/unit/nokv/ledger.test.ts b/tests/unit/nokv/ledger.test.ts new file mode 100644 index 0000000..6081672 --- /dev/null +++ b/tests/unit/nokv/ledger.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { + foldLedger, + ledgerShardPath, + serializeEvent, + type LedgerEvent +} from '@/lib/nokv/ledger'; + +const ev = ( + partial: Partial & + Pick +): string => + serializeEvent({ + v: 1, + ts: '2026-08-05T00:00:00Z', + ...partial + } as LedgerEvent); + +describe('ledger fold', () => { + it('shards by event month', () => { + expect(ledgerShardPath('2026-08-05')).toBe('ledger/2026-08.jsonl'); + }); + + it('folds created minus deleted with event_id dedupe', () => { + const lines = [ + ev({ + event_id: 'e1', + kind: 'entry_created', + date: '2026-08-05', + entry_id: 'a' + }), + ev({ + event_id: 'e1', + kind: 'entry_created', + date: '2026-08-05', + entry_id: 'a' + }), // timeout replay + ev({ + event_id: 'e2', + kind: 'entry_created', + date: '2026-08-05', + entry_id: 'b' + }), + ev({ + event_id: 'e3', + kind: 'entry_deleted', + date: '2026-08-05', + entry_id: 'a' + }) + ].flatMap((l) => l.split('\n').filter(Boolean)); + + const state = foldLedger(lines); + expect([...state.byDate.get('2026-08-05')!.liveEntryIds]).toEqual(['b']); + }); + + it('keeps the last mood and summary per day', () => { + const lines = [ + ev({ + event_id: 'm1', + kind: 'mood_set', + date: '2026-08-05', + day_quality: 'bad', + emotions: [] + }), + ev({ + event_id: 'm2', + kind: 'mood_set', + date: '2026-08-05', + day_quality: 'good', + emotions: ['calm'] + }), + ev({ + event_id: 's1', + kind: 'summary_written', + date: '2026-08-05', + mood_quality: 'good', + entry_count: 3 + }) + ].flatMap((l) => l.split('\n').filter(Boolean)); + + const day = foldLedger(lines).byDate.get('2026-08-05')!; + expect(day.mood?.day_quality).toBe('good'); + expect(day.summary?.entry_count).toBe(3); + }); + + it('tolerates malformed lines', () => { + const state = foldLedger(['not json', '', '{"v":1}']); + expect(state.byDate.size).toBe(0); + }); +});