โมเดลข้อมูลแชต · Chat data model (D1 + R2)

View raw
On this page

หน้านี้อธิบายว่าเราเก็บประวัติแชตระหว่างผู้ใช้กับ AI ไว้อย่างไร ตอนนี้ มีแค่ schema ใน D1 (nvx-db) ยังไม่มี endpoint หรือ UI ไหนอ่านหรือเขียนตารางพวกนี้ แผนขั้นต่อไปอยู่ท้ายหน้า

เป้าหมาย#

  1. เก็บบทสนทนาของผู้ใช้กับ AI ได้ครบ (ข้อความทุก role, โมเดลที่ใช้, จำนวน token) เพื่อเปิดต่อ ค้นย้อนหลัง และคิดต้นทุนได้
  2. ไฟล์แนบอยู่ใน R2 ส่วน D1 เก็บแค่ key และ metadata เพื่อให้ฐานข้อมูลเล็กและเร็ว
  3. ให้ AI หรือบริการอื่นเชื่อมต่อผ่าน API key ที่ เราออกและเพิกถอนได้เอง โดยเก็บแค่ hash ของ key
  4. ไม่ผูกกับผู้ให้บริการ auth รายใด คอลัมน์ auth จึงเป็น nullable ไว้ก่อน

แผนภาพ#

Mermaid diagram (source below)
Mermaid source
erDiagram
  users ||--o{ conversations : owns
  conversations ||--o{ messages : contains
  messages ||--o{ attachments : has
  users {
    TEXT id PK
    INTEGER created_at
    TEXT display_name
    TEXT auth_provider
    TEXT auth_subject
    TEXT email
  }
  conversations {
    TEXT id PK
    TEXT user_id FK
    TEXT title
    TEXT model
    INTEGER created_at
    INTEGER updated_at
    INTEGER archived
  }
  messages {
    TEXT id PK
    TEXT conversation_id FK
    TEXT role
    TEXT content
    INTEGER tokens_in
    INTEGER tokens_out
    TEXT model
    INTEGER created_at
  }
  attachments {
    TEXT id PK
    TEXT message_id FK
    TEXT r2_key
    TEXT mime
    INTEGER size
    INTEGER created_at
  }
  api_clients {
    TEXT id PK
    TEXT name
    TEXT key_hash
    TEXT scopes
    INTEGER rate_limit
    INTEGER created_at
    INTEGER revoked_at
  }

api_clients ไม่ผูก foreign key กับตารางอื่น เพราะเป็นตัวตนของ "เครื่อง" ที่เข้ามาใช้ API ไม่ใช่ผู้ใช้คน

การตัดสินใจหลัก#

เรื่อง ทางที่เลือก เหตุผล
ชนิดของ id TEXT (UUIDv7 หรือ ULID ที่แอปสร้าง) เรียงตามเวลาได้ ไม่เดาง่ายแบบเลขรัน และสร้างได้ก่อน insert (ใช้ทำ R2 key ได้ทันที)
เวลา INTEGER เป็น Unix epoch มิลลิวินาที (UTC) เทียบและเรียงได้เร็ว ไม่มีปัญหา timezone; ค่า default มาจาก unixepoch('subsec') * 1000
boolean INTEGER 0/1 พร้อม CHECK SQLite ไม่มีชนิด boolean
ตาราง STRICT SQLite บังคับชนิดคอลัมน์จริง ไม่ยอมให้ใส่ข้อความลงคอลัมน์ตัวเลข
role CHECK (role IN ('user','assistant','system','tool')) กันค่าแปลกตั้งแต่ระดับฐานข้อมูล
การลบ ON DELETE CASCADE จาก users → conversations → messages → attachments ลบผู้ใช้ทีเดียวได้ข้อมูลหายครบ (ตรงกับคำขอลบข้อมูลส่วนบุคคล) แต่ วัตถุใน R2 ต้องลบเองจากโค้ด เพราะ D1 สั่ง R2 ไม่ได้
ไฟล์แนบ bytes อยู่ R2, D1 เก็บ r2_key (unique), mime, size D1 จำกัดขนาดแถวและฐานข้อมูล ไฟล์ใหญ่จึงไม่ควรอยู่ใน D1
API key เก็บ key_hash = SHA-256 hex ของ key เต็ม (unique) key จริงแสดงครั้งเดียวตอนสร้าง ถ้าฐานข้อมูลรั่วก็ใช้ key ไม่ได้; key สุ่มยาวพอจึงไม่ต้องใช้ hash แบบช้า
scopes JSON array ในคอลัมน์ TEXT พร้อม CHECK (json_valid(...)) ยืดหยุ่น เพิ่ม scope ใหม่ได้โดยไม่ต้อง migrate
updated_at ของบทสนทนา แอปอัปเดตเองทุกครั้งที่เพิ่มข้อความ (ไม่ใช้ trigger) ควบคุมได้ชัดและอยู่ใน batch เดียวกับ insert ข้อความ

Index และ query ที่รองรับ#

Query Index
รายการบทสนทนาของผู้ใช้ เรียงล่าสุดก่อน ซ่อนที่เก็บถาวร conversations_user_updated_idx (user_id, archived, updated_at DESC)
โหลดข้อความในบทสนทนาตามลำดับ และแบ่งหน้าด้วย created_at messages_conversation_created_idx (conversation_id, created_at, id)
ไฟล์แนบของข้อความ attachments_message_idx (message_id)
หา API client จาก key unique index บน key_hash
รายการ API client ที่ยังใช้งานได้ api_clients_active_created_idx (created_at DESC) WHERE revoked_at IS NULL
หาผู้ใช้จากตัวตนของผู้ให้บริการ auth users_auth_identity_uq (auth_provider, auth_subject) (unique)

Migration#

ไฟล์อยู่ในโฟลเดอร์ migrations/ และรันตามลำดับชื่อ:

  • 0001_schema_meta.sql: ตาราง schema_meta (key/value) พร้อม schema_version = 1
  • 0002_chat_history.sql: ทั้ง 5 ตารางและ index แล้วตั้ง schema_version = 2

wrangler บันทึกว่ารันไฟล์ไหนไปแล้วในตาราง d1_migrations ห้ามแก้ไฟล์ที่ apply ไปแล้ว ถ้าจะเปลี่ยน schema ให้สร้างไฟล์ใหม่ (0003_...sql) ขั้นตอนดูที่ operations/d1-database.md รายละเอียดคอลัมน์ดูที่ reference/d1-schema.md

สถานะ R2#

bucket nvx-assets (binding ASSETS_BUCKET) ยังไม่ได้สร้าง เพราะบัญชียังไม่เปิดใช้ R2 (API ตอบ error 10042 "Please enable R2 through the Cloudflare Dashboard") การเปิด R2 ต้องยอมรับเงื่อนไขการใช้งานและการคิดเงินใน dashboard ซึ่งเจ้าของบัญชีต้องตัดสินใจเอง ตาราง attachments ออกแบบรอไว้แล้ว และ binding จะเพิ่มใน wrangler.jsonc หลัง bucket มีจริงเท่านั้น (ถ้าใส่ก่อน deploy จะล้ม)

แผนขั้นต่อไป (ยังไม่ได้สร้าง)#

API routes (ทั้งหมดอยู่ใต้ /api, ใช้ zod ตรวจ input และ rate limit แบบเดียวกับ /api/plan):

Route ทำอะไร
GET /api/chat/conversations รายการบทสนทนาของผู้ใช้ (cursor ด้วย updated_at)
POST /api/chat/conversations สร้างบทสนทนา
GET/PATCH/DELETE /api/chat/conversations/:id อ่าน, เปลี่ยนชื่อ/เก็บถาวร, ลบ (ลบวัตถุ R2 ด้วย)
GET /api/chat/conversations/:id/messages ข้อความ แบ่งหน้าด้วย created_at
POST /api/chat/conversations/:id/messages ส่งข้อความผู้ใช้แล้ว stream คำตอบ AI (SSE) จากนั้นบันทึกข้อความ assistant พร้อม tokens_in/out ใน batch เดียว
POST /api/chat/attachments อัปโหลดผ่าน Worker เข้า R2 (จำกัดชนิดและขนาด) หรือออก presigned URL
/api/v1/... API เดียวกันสำหรับ AI ภายนอก ใช้ Authorization: Bearer <key>

Auth:

  • ผู้ใช้คน: เริ่มด้วย OAuth (GitHub/Google) หรือ passkey ผ่าน session cookie แบบ HttpOnly; Secure; SameSite=Lax; ทางเลือกที่เร็วกว่าสำหรับใช้ภายในคือ Cloudflare Access
  • AI ภายนอก: key รูปแบบ nvx_live_<random 32 bytes> แสดงครั้งเดียว เก็บ SHA-256 ใน api_clients.key_hash ตรวจ revoked_at IS NULL และ scope (เช่น chat:read, chat:write) ทุกคำขอ
  • Rate limit ต่อ client ตาม api_clients.rate_limit ด้วย Workers Rate Limiting (ต้องเพิ่ม binding และ namespace ใหม่)

UI:

  • หน้า /chat ใช้ shell และ component ของ NVX ที่มีอยู่ (ไม่เปลี่ยน layout หน้าเดิม): sidebar รายการบทสนทนา, หน้าต่างข้อความที่ render Markdown ด้วย pipeline ที่ sanitize แล้ว, ช่องพิมพ์พร้อมแนบไฟล์, เลือกโมเดล
  • สองภาษา (ไทย/อังกฤษ) และผ่าน WCAG AA ทั้งสองธีม ตามมาตรฐานเดิมของโปรเจกต์

ดูเพิ่ม#

reference/d1-schema.md, operations/d1-database.md, reference/config-env.md, cloudflare-runtime.md

Source: docs/architecture/chat-data-model.md · /docs/architecture/chat-data-model.md