Data Dictionary · Khác (phần 6/11)
⚙️ Trang này sinh tự động từ source code bằng
scripts/gen-reference.mjs— đừng sửa tay, sửa code rồi chạynpm run gen:reference.
Thuộc Data Dictionary. 17 bảng, ielts_micro_skill_items tới learner_experience_state.
ielts_micro_skill_items
Bài tập: SIÊU ĐƠN GIẢN, và ràng buộc ấy nằm trong chính hình dạng bảng Chủ dự án 2026-09-18: "làm các bài tập siêu đơn giản, để đảm bảo là learner hiểu được lý thuyết đó phần nào". Nên một bài ở đây là: 2-3 câu ngữ liệu, MỘT câu hỏi, vài lựa chọn, chấm ngay. Không có cột nào cho một bài đọc dài, và cố ý không có. Bài dài đo lẫn hai thứ — có hiểu lý thuyết không, và có đọc hết nổi không — rồi trả về một kết quả không tách được. Chỗ luyện đọc thật đã có sẵn ở content/ielts-reading với 1.560 câu; bảng này phục vụ một việc khác hẳn.
migration: 0233_ielts_micro_skills.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
micro_skill_id | TEXT | NOT NULL REFERENCES ielts_micro_skills(id) ON DELETE CASCADE |
seq | INTEGER | NOT NULL |
stimulus_en | TEXT | NOT NULL |
prompt_en | TEXT | NOT NULL |
options_json | TEXT | NOT NULL |
answer | INTEGER | NOT NULL CHECK (answer >= 0) |
why_vi | TEXT | NOT NULL |
status | TEXT | NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published')) |
created_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
updated_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
lang | TEXT | NOT NULL DEFAULT 'en' CHECK (lang IN ('vi','en')) — thêm ở 0238_micro_skill_item_ladder.sql |
stage | INTEGER | NOT NULL DEFAULT 3 CHECK (stage BETWEEN 1 AND 4) — thêm ở 0238_micro_skill_item_ladder.sql |
technique_vi | TEXT | — thêm ở 0238_micro_skill_item_ladder.sql |
exercise_id | TEXT | REFERENCES ielts_micro_exercises(id) ON DELETE CASCADE — thêm ở 0250_micro_skill_exercises.sql |
pair | INTEGER | — thêm ở 0250_micro_skill_exercises.sql |
Khóa ngoại: micro_skill_id → ielts_micro_skills
Index: idx_micro_skill_items(micro_skill_id, status, seq) · idx_micro_skill_items_ladder(micro_skill_id, status, stage, seq) · idx_micro_skill_items_exercise(exercise_id, status, seq)
ielts_micro_skills
Migration 0233 — Năng lực nhỏ trong mỗi kỹ năng: trang lý thuyết và bài tập kiểm hiểu. SRC-805. Chỉ đạo chủ dự án 2026-09-18: "Trong mỗi kỹ năng nghe, nói, đọc, viết, thì có các năng lực nhỏ hơn. Ví dụ: xác định main idea, hay inference... thì cần có một chỗ nào đó để 'học lý thuyết', rồi làm các bài tập siêu đơn giản, để đảm bảo là learner hiểu được lý thuyết đó phần nào." ## Vì sao trong D1 chứ không trong file như bài đọc Quyết định của chủ dự án 2026-09-18: sửa được từ trang admin. Bài đọc nằm trong content/ vì chúng là ngữ liệu ổn định, sửa một chữ là một commit có review. Lý thuyết thì ngược lại: nó là CHỮ GIẢI THÍCH, và chữ giải thích chỉ hay lên bằng cách sửa đi sửa lại sau khi xem học sinh thật đọc nó. Bắt mỗi lần sửa một câu phải qua một vòng deploy là bảo đảm nó sẽ không được sửa. Đổi lại là mất cổng kiểm của git, nên cột status bên dưới gánh phần ấy. ## status: nháp và phát hành Chủ dự án chọn có duyệt. Sửa giữa chừng không được đẩy một bài viết dở ra trước mắt học sinh — và với nội dung sửa trực tiếp trên production thì "giữa chừng" là trạng thái thường gặp nhất. Đường đọc công khai LỌC theo cột này; đường admin thì thấy hết. ## Một BÀI LÝ THUYẾT một dòng, các phần là cột riêng Năm phần (là gì · cách làm · ví dụ có giải · bẫy · bài tập) là KHUÔN, không phải gợi ý. Nhét cả năm vào một cột markdown thì khuôn biến mất ngay bài thứ ba: người soạn sau sẽ bỏ phần "bẫy" vì không có gì nhắc, mà phần bẫy chính là thứ học sinh Việt mất điểm nhiều nhất và sách ít nói. Cột riêng thì thiếu một phần là thấy ngay ở màn admin.
migration: 0233_ielts_micro_skills.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
skill | TEXT | NOT NULL CHECK (skill IN ('reading','listening','speaking','writing')) |
code | TEXT | NOT NULL |
seq | INTEGER | NOT NULL |
name_vi | TEXT | NOT NULL |
name_en | TEXT | NOT NULL |
what_vi | TEXT | NOT NULL |
steps_json | TEXT | NOT NULL |
example_en | TEXT | NOT NULL |
example_question_en | TEXT | NOT NULL |
example_answer_vi | TEXT | NOT NULL |
traps_json | TEXT | NOT NULL |
status | TEXT | NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published')) |
created_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
updated_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
speak_prompt_en | TEXT | — thêm ở 0256_micro_speaking_turn.sql |
speak_prompt_vi | TEXT | — thêm ở 0256_micro_speaking_turn.sql |
cluster | TEXT | — thêm ở 0260_writing_subskills_four_criteria.sql |
Index: idx_micro_skills_code(skill, code) UNIQUE · idx_micro_skills_skill(skill, status, seq)
ielts_practice_attempts
Migration 0230 — Lượt luyện đọc và nghe IELTS của một learner đã đăng nhập. SRC-792. Chỉ đạo chủ dự án 2026-09-17: thêm đăng nhập vào ielts.nemo12.com để 1.560 câu đọc và nghe không còn rơi vào hư không. ## Vì sao trước nay không có bảng nào apps/ielts dựng ra như một sân tự luyện MỞ: không tài khoản, chấm ngay ở trình duyệt, bài đang làm dở giữ trong localStorage. Đổi lại, mọi lượt làm biến mất cùng cái tab, và Learner Model mù hoàn toàn với phần luyện tập nhiều nhất của cả hệ. ## Đăng nhập là TUỲ CHỌN, và bảng này phản ánh đúng điều đó Người chưa đăng nhập vẫn luyện được y như cũ, không bị chặn một câu nào — sân công khai là lý do trang này có 140 trang nội dung đọc được từ ngoài vào. Bảng này chỉ nhận lượt làm của người ĐÃ đăng nhập, nên một hàng ở đây luôn có chủ. Không có cột anonymous_id nào, và cố ý không có: gắn định danh cho người chưa đăng nhập là dựng một hồ sơ theo dõi sau lưng họ. ## Một BÀI một dòng, không phải một CÂU một dòng Learner chấm từng câu một khi làm, nhưng đơn vị có nghĩa là cả bài: "đúng 9 trên 13 câu của bài đọc này". Ghi từng câu sẽ đẻ ra mười ba dòng cho một bài và một đống bài dở dang không bao giờ kết thúc — mà một bài bỏ dở giữa chừng không nói được điều gì về năng lực. Learner làm lại cùng một bài thì thêm một dòng nữa: mỗi lượt là một việc đã xảy ra thật, và đường đi giữa các lượt chính là thứ đáng xem nhất ở đây.
migration: 0230_ielts_practice_attempts.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
learner_id | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
kind | TEXT | NOT NULL CHECK (kind IN ('reading','listening')) |
cluster | TEXT | NOT NULL |
seq | INTEGER | NOT NULL CHECK (seq > 0) |
correct | INTEGER | NOT NULL CHECK (correct >= 0) |
total | INTEGER | NOT NULL CHECK (total > 0) |
created_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
Khóa ngoại: learner_id → learners
Index: idx_ielts_practice_learner(learner_id, created_at DESC)
ielts_productions
Migration 0228 — Bài tự tạo của learner ở bước chẩn đoán: một bài viết và một đoạn nói. SRC-792 (chỉ đạo chủ dự án 2026-09-17). ── Vì sao KHÔNG nhét vào assessment_responses ──────────────────────────────────────────── Bài đo bốn kỹ năng (assessment_sessions + assessment_responses) đo bằng TRẮC NGHIỆM: mỗi câu có selected_index, chấm xong là ra đúng/sai, và kết quả đi thẳng vào Learner Model dưới dạng learner_evidence. Writing và Speaking không có hình dạng đó: đầu ra là một đoạn văn và một tệp âm thanh, "đúng/sai" không tồn tại, và điểm số là một NHẬN ĐỊNH — của model trước, của mentor sau. Ép chúng vào bảng trắc nghiệm sẽ phải bịa ra một selected_index, và tệ hơn: mọi nhận định của model sẽ chảy vào Learner Model như thể là bằng chứng đo được. Bảng riêng giữ đúng ranh giới ấy — điểm ở đây là ĐỀ XUẤT, và cột mentor_band nói rõ ai mới là người chốt. ── Âm thanh nằm ở R2, không nằm ở đây ────────────────────────────────────────────────────── D1 lưu KHOÁ (audio_key), tệp nằm trong bucket CONTENT. Một đoạn nói ba phút là vài trăm KB; nhét base64 vào D1 là cách chắc chắn nhất để một câu SELECT bình thường kéo về vài megabyte và làm chậm mọi thứ khác dùng chung bảng.
migration: 0228_ielts_productions.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
learner_id | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
skill | TEXT | NOT NULL CHECK (skill IN ('writing','speaking')) |
prompt_id | TEXT | NOT NULL |
text | TEXT | NOT NULL DEFAULT '' |
audio_key | TEXT | — |
audio_seconds | INTEGER | — |
transcript | TEXT | — |
status | TEXT | NOT NULL DEFAULT 'received' CHECK (status IN ('received','scored','failed')) |
ai_band | REAL | — |
ai_notes_json | TEXT | — |
ai_prompt_ref | TEXT | — |
mentor_band | REAL | — |
mentor_note | TEXT | — |
created_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
scored_at | TEXT | — |
track | TEXT | NOT NULL DEFAULT 'diagnostic' — thêm ở 0232_ielts_productions_track.sql |
chosen | INTEGER | NOT NULL DEFAULT 0 — thêm ở 0263_ielts_diagnostic_speaking_takes.sql |
Khóa ngoại: learner_id → learners
Index: idx_ielts_productions_learner(learner_id, skill, created_at DESC) · idx_ielts_productions_track(learner_id, track, skill, created_at DESC) · idx_ielts_productions_micro(learner_id, track, created_at DESC) · idx_ielts_productions_chosen(learner_id, skill, track, chosen)
ielts_subskill_results
Migration 0254 — Kết quả của từng NĂNG LỰC NHỎ trong một bài đọc/nghe. SRC-862. Chỉ đạo chủ dự án 2026-09-19: "Cần có card để hiển thị các sub-skills liên quan tới riêng Reading, và mastery level của từng sub-skills đó. Trong 3 màn hình của 3 skills còn lại, cũng cần Mastery Card của 3 skills còn lại nữa." ## Vì sao phải có bảng này, trong khi sổ đã ghi mọi lượt luyện Sổ cũ ghi một bài đọc thành MỘT dòng: "đúng 22 trên 30". Con số ấy trả lời được "con làm có đúng không" nhưng không trả lời được câu chủ dự án vừa hỏi — trong ba mươi câu ấy, câu Main idea con đúng mấy, câu Inference con đúng mấy. Mà đó mới là thứ nói cho learner biết NÊN LUYỆN GÌ: một tỉ lệ 73% chung chung thì không ai biết phải làm gì với nó. Dữ liệu ấy vốn đã có sẵn ngay lúc chấm — mỗi câu hỏi trong content/ đều mang thẻ năng lực (main-idea, detail, inference...) trùng đúng mã trong ielts_micro_skills. Trước bản này nó bị cộng gộp lại rồi vứt đi ngay tại trình duyệt. Đây là chỗ giữ nó lại. ## Vì sao KHÔNG nhét vào learning_events learning_events là sổ một-lượt-một-dòng: seconds, items, correct của cả bài. Tách một bài thành mười dòng ở đó sẽ hỏng mọi thứ đang đếm trên nó — số phút nhân lên mười lần nếu chia cả seconds, hoặc số buổi học nhân lên mười lần nếu không chia. Mọi con số của trang /ielts đều đang tính từ bảng ấy, nên nó phải giữ nguyên nghĩa "một dòng là một lượt". ## id ghép từ lượt luyện và mã năng lực: gửi lại hai lần không thành hai kết quả Mạng chập thì trình duyệt gửi lại, và một lượt luyện gửi hai lần mà đẻ ra hai bộ kết quả sẽ làm mọi tỉ lệ nhân đôi mẫu số. id = '<attempt_id>:<code>' cộng với INSERT OR IGNORE khoá đúng chỗ ấy: lượt thứ hai của cùng một attempt không ghi thêm dòng nào.
migration: 0254_ielts_subskill_results.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
learner_id | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
skill | TEXT | NOT NULL CHECK (skill IN ('reading','listening','speaking','writing')) |
code | TEXT | NOT NULL |
ref | TEXT | NOT NULL |
items | INTEGER | NOT NULL CHECK (items > 0) |
correct | INTEGER | NOT NULL CHECK (correct >= 0) |
day | TEXT | NOT NULL |
created_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
Khóa ngoại: learner_id → learners
Index: idx_subskill_results_learner(learner_id, skill, code)
ielts_task_types
Migration 0264 — Chiều thứ hai của Reading: DẠNG CÂU HỎI. SRC-895. Chỉ đạo chủ dự án 2026-09-20: "Cần chia lại theo 2 chiều sau, nghĩa là learner có thể lọc theo cả sub-skills lẫn theo task types để luyện tập, và cần tạo đủ mỗi sub skill cần có 5 tasks, và mỗi task types cũng vậy." ## Vì sao một CỘT trên bài tập, không phải một cây thư mục thứ hai Đọc sơ đồ của chủ dự án thì SKILLS và TASK TYPES nằm cạnh nhau như hai nhánh song song, và cách đọc thẳng nhất là dựng cho mỗi nhánh một kho bài riêng. Chủ dự án đã bác cách ấy (2026-09-20, chọn "chung một kho, gắn hai nhãn"), và lý do nằm ở chỗ hai chiều này KHÔNG độc lập với nhau: · Mọi bài Matching Headings đều đang luyện Main Idea — đó là định nghĩa của dạng ấy, không phải một sự trùng hợp. Tách hai kho là soạn lại cùng một thao tác tư duy hai lần, rồi đo nó bằng hai cái thước không cộng được với nhau. · Bảng "Where am I strong?" cộng theo năng lực. Một kho bài tập thứ hai KHÔNG mang năng lực thì mỗi lần learner luyện Matching Headings là một lần thanh Main Idea đứng yên trong khi learner biết rõ mình vừa luyện đúng cái đó. Sai lệch ấy không ai đọc được từ màn hình. Nên một bài tập mang ĐÚNG MỘT năng lực và ĐÚNG MỘT dạng câu hỏi. Lọc theo chiều nào cũng cắt trên cùng một kho, và mọi lần làm bài đều rơi vào đúng một ô của cả hai thước đo. ## Vì sao task_type_id cho phép NULL 102 bài Reading đã nạp ở 0252 có trước khái niệm này. Phần dưới gán chúng vào 'multiple-choice' — một ánh xạ CÓ CƠ SỞ chứ không phải một giá trị lấp chỗ: mọi bài ấy đều là ngữ liệu ngắn + một câu hỏi + bốn lựa chọn, tức là đúng dạng Multiple Choice của đề thi thật. Cột vẫn để mở vì Listening/Speaking/Writing chưa có bộ dạng câu hỏi của mình. Một cột NOT NULL ở đây buộc phải bịa dạng cho cả ba kỹ năng ấy ngay trong migration này, trong khi bộ dạng của Listening (form completion, map labelling…) là một quyết định nội dung chưa tới lượt.
migration: 0264_reading_task_types.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
skill | TEXT | NOT NULL CHECK (skill IN ('reading','listening','speaking','writing')) |
code | TEXT | NOT NULL |
seq | INTEGER | NOT NULL |
name_en | TEXT | NOT NULL |
name_vi | TEXT | NOT NULL |
what_vi | TEXT | NOT NULL |
tips_json | TEXT | NOT NULL DEFAULT '[]' |
status | TEXT | NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','published')) |
created_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
updated_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
Index: idx_task_types_code(skill, code) UNIQUE · idx_task_types_skill(skill, status, seq)
illustrations
Migration 0326 - illustrations table. SRC-1201. ## Vì sao migration này tồn tại Chủ dự án (03.10.2026) muốn mọi ảnh minh họa của các trang nằm trong MỘT BẢNG, mỗi dòng giữ cả prompt sinh ảnh lẫn các thông số để nâng cấp về sau (scene, nhân vật + cảm xúc, đồ vật, bảng màu, seed, phiên bản thuật toán). Nguồn chuẩn là apps/web/src/site/illustrations/registry.json trong repo; bảng này là bản sao trong D1 để công cụ quản trị và agent truy vấn được mà không phải đọc repo. Không có bảng thì "ảnh nào, sinh từ prompt nào" chỉ tồn tại trong git. Dữ liệu nạp bằng scripts/seed-illustrations.sql qua workflow seed-data, không qua migration. ## Chạy lại không đổi gì CREATE TABLE IF NOT EXISTS + index IF NOT EXISTS.
migration: 0326_illustrations_table.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
site | TEXT | NOT NULL DEFAULT 'www' |
pages | TEXT | NOT NULL, -- JSON array of page paths |
placement | TEXT | NOT NULL, -- 'hero' | 'section:<key>' |
title | TEXT | NOT NULL |
alt_vi | TEXT | NOT NULL |
prompt | TEXT | NOT NULL |
scene | TEXT | NOT NULL |
characters | TEXT | NOT NULL, -- JSON array of |
props | TEXT | NOT NULL, -- JSON array |
palette | TEXT | NOT NULL |
seed | INTEGER | NOT NULL |
label | TEXT | — |
generator_version | TEXT | NOT NULL |
width | INTEGER | NOT NULL |
height | INTEGER | NOT NULL |
file_path | TEXT | NOT NULL, -- public URL path of the generated SVG |
status | TEXT | NOT NULL DEFAULT 'auto',-- 'auto' | 'tuned' | 'draft' |
notes | TEXT | — |
created_at | TEXT | NOT NULL |
updated_at | TEXT | NOT NULL |
Index: idx_illustrations_site_placement(site, placement)
inbound_emails
Migration 0336 - inbound mail log and user program interest. SRC-1282. ## Vì sao migration này tồn tại Chỉ đạo chủ dự án 07.10.2026: mỗi sub-brand (NEMO IELTS, SAT, GRAMMAR, MATH...) có một địa chỉ riêng, vừa gửi vừa NHẬN, thư nhận chuyển tiếp tới hộp thư chủ dự án; và hệ thống luôn lưu lại user muốn xem dịch vụ nào. Hai bảng: inbound_emails Mỗi lá thư tới một hộp thư sub-brand: tới đâu, từ ai, đã chuyển tiếp chưa. Không có bảng này thì thư chuyển tiếp hỏng là thư mất không dấu vết — Email Routing không cho xem lại thư đã nhận. user_program_interest Mỗi (user, sub-brand) một dòng: lần đầu, lần gần nhất, số lượt xem, số lượt gửi form. Một dòng gộp chứ không phải một dòng mỗi lượt: câu hỏi là "user này quan tâm dịch vụ nào", không phải dựng lại lịch sử bấm. Không lưu NỘI DUNG thư nhận: thư đã nằm nguyên vẹn trong hộp thư chuyển tới. Giữ thêm một bản trong D1 là giữ dữ liệu cá nhân của người lạ ở một chỗ thứ hai mà không ai cần đọc. ## Chạy lại không đổi gì Chỉ có CREATE TABLE IF NOT EXISTS và CREATE INDEX IF NOT EXISTS.
migration: 0336_inbound_mail_log_and_user_program_interest.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
inbox | TEXT | NOT NULL |
program | TEXT | NOT NULL |
from_email | TEXT | NOT NULL |
subject | TEXT | — |
raw_size | INTEGER | — |
status | TEXT | NOT NULL |
error | TEXT | — |
received_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
Index: idx_inbound_emails_received(received_at DESC) · idx_inbound_emails_program(program, received_at DESC)
item_figures
Hình vẽ cho câu hỏi (SRC-114). Bài hình học phải NHIỀU HÌNH, và hình phải vẽ bằng thuật toán: lưu THAM SỐ (dạng hình + số đo), client sinh SVG. Cấm file bitmap JPEG/PNG — ảnh chụp thì không kéo được, không đo được đúng/sai, và mỗi lần sửa nội dung lại phải vẽ lại ảnh. Để bảng riêng thay vì thêm cột vào items: CREATE TABLE IF NOT EXISTS chạy lại được (QG-004), và câu hỏi không có hình thì không tốn gì.
migration: 0038_item_figures.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
item_id | TEXT | PRIMARY KEY |
subject_id | TEXT | NOT NULL |
node_id | TEXT | — |
figure_json | TEXT | NOT NULL, -- FigureSpec: { kind, variant, angle… } — xem apps/learn/src/figures/engine.tsx |
source | TEXT | NOT NULL DEFAULT 'rule', -- rule = suy từ node + đề bài; hand = biên tập tay |
updated_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
Index: idx_item_figures_node(subject_id, node_id)
journey_events
migration: 0301_journey_engine_state_and_events.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
learner_id | TEXT | NOT NULL |
journey_id | TEXT | NOT NULL |
kind | TEXT | NOT NULL CHECK (kind IN ('transition','foreground','action_accepted','snoozed')) |
from_state | TEXT | — |
to_state | TEXT | — |
action_id | TEXT | — |
definition_version | INTEGER | — |
reasons_json | TEXT | NOT NULL DEFAULT '[]' |
created_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
Index: idx_journey_events_learner(learner_id, created_at) · idx_journey_events_journey(journey_id, kind, created_at)
lab_content
migration: 0037_labs.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
lab_id | TEXT | PRIMARY KEY REFERENCES labs(id) |
content_json | TEXT | NOT NULL |
bytes | INTEGER | NOT NULL DEFAULT 0 |
updated_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
Khóa ngoại: lab_id → labs
lab_favorites
Learner tự đánh dấu lab yêu thích trong Thư viện Lab (SRC-212). Vì sao cần: thư viện của một môn có tới hàng chục lab và learner vào xem cái nào cũng được (SRC-166). Tự do đó chỉ có ích nếu quay lại tìm được — không có chỗ đánh dấu thì lần sau phải lướt lại từ đầu và nhớ tên, mà tên lab thì learner không nhớ. Bảng riêng chứ không phải cột trên labs: đây là dữ liệu CỦA TỪNG LEARNER, không phải thuộc tính của lab. Để lên labs thì hai learner đánh dấu là ghi đè nhau. Không có cột status/removed_at: bỏ thích thì XOÁ dòng. Lịch sử "đã từng thích rồi bỏ" không ai dùng tới, giữ lại chỉ làm mọi truy vấn sau này phải nhớ lọc thêm một điều kiện.
migration: 0050_lab_favorites.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
learner_id | TEXT | NOT NULL |
lab_id | TEXT | NOT NULL |
created_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | table constraint | PRIMARY KEY (learner_id, lab_id) |
Index: idx_lab_favorites_learner(learner_id, created_at DESC)
labs
Lab = một Learning Experience TƯƠNG TÁC CAO (SDD-010 §10, REQ-LAB-02/08 — SRC-108). Có hàng nghìn lab nên tách làm HAI bảng, và đây là điểm mấu chốt về hiệu năng: labs — metadata nhẹ (vài trăm byte/dòng): Phòng Lab chỉ đọc bảng này để biết "unit này có lab nào", không bao giờ tải nội dung. lab_content — nội dung đầy đủ (items, inquiry, viz…), CHỈ đọc khi learner mở đúng lab đó. Nhờ vậy client không phải bundle config như explore/configs.tsx (đang nằm trong bundle JS), và một unit mở ra chỉ tốn vài dòng metadata thay vì vài trăm KB.
migration: 0037_labs.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY, -- slug legacy, trùng skill_nodes.lab_slug |
subject_id | TEXT | NOT NULL |
node_id | TEXT | , -- bài (skill node) mà lab dạy — NULL = chưa map được |
strand | TEXT | — |
grade | INTEGER | — |
unit | TEXT | , -- chép lại từ node để audit "unit nào thiếu lab" không phải join |
module | TEXT | — |
title_vi | TEXT | NOT NULL |
title_en | TEXT | — |
blurb_vi | TEXT | — |
engine | TEXT | NOT NULL CHECK (engine IN ('picker','parametric','stepper','sim','scene3d','inquiry')) |
inquiry_only | INTEGER | NOT NULL DEFAULT 0 |
item_count | INTEGER | NOT NULL DEFAULT 0, -- số bước tương tác (picker items…) |
check_count | INTEGER | NOT NULL DEFAULT 0, -- số câu hỏi kiểm tra trong inquiry |
source | TEXT | NOT NULL DEFAULT 'chuyenchon' |
status | TEXT | NOT NULL DEFAULT 'live' CHECK (status IN ('live','draft','retired')) |
updated_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
review_status | TEXT | NOT NULL DEFAULT 'unreviewed' — thêm ở 0049_experience_review.sql |
hidden_at | TEXT | — thêm ở 0049_experience_review.sql |
hidden_reason | TEXT | — thêm ở 0049_experience_review.sql |
last_reviewed_at | TEXT | — thêm ở 0049_experience_review.sql |
reviewed_by | TEXT | — thêm ở 0049_experience_review.sql |
objective_vi | TEXT | — thêm ở 0049_experience_review.sql |
depth_level | TEXT | — thêm ở 0049_experience_review.sql |
pedagogy | TEXT | — thêm ở 0049_experience_review.sql |
why_vi | TEXT | — thêm ở 0068_lab_meta.sql |
notice_vi | TEXT | — thêm ở 0068_lab_meta.sql |
mentor_vi | TEXT | — thêm ở 0068_lab_meta.sql |
minutes | INTEGER | — thêm ở 0068_lab_meta.sql |
Index: idx_labs_node(subject_id, node_id) · idx_labs_unit(subject_id, unit) · idx_labs_one_per_unit(subject_id, unit) UNIQUE
leaderboard_months
Ảnh chụp bảng xếp hạng MỘT THÁNG. Giải nhất/nhì/ba xếp theo tổng giờ trong tháng (chỉ đạo chủ dự án 2026-09-20), cùng thước đo với bảng tuần — hai bảng cạnh nhau mà đo hai thứ khác nhau là chỗ learner mất lòng tin vào cả hai.
migration: 0261_nemo_leaderboard.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
month | TEXT | NOT NULL |
learner_id | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
seconds | INTEGER | NOT NULL DEFAULT 0 |
days_practised | INTEGER | NOT NULL DEFAULT 0 |
rank | INTEGER | NOT NULL DEFAULT 0 |
closed_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | table constraint | PRIMARY KEY (month, learner_id) |
Khóa ngoại: learner_id → learners
Index: idx_leaderboard_months_learner(learner_id, month DESC)
leaderboard_weeks
Ảnh chụp bảng xếp hạng MỘT TUẦN, đóng lại lúc 02:00 thứ Hai giờ VN của tuần kế tiếp.
migration: 0261_nemo_leaderboard.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
week_start | TEXT | NOT NULL |
learner_id | TEXT | NOT NULL REFERENCES learners(id) ON DELETE CASCADE |
seconds | INTEGER | NOT NULL DEFAULT 0 |
days_practised | INTEGER | NOT NULL DEFAULT 0 |
items | INTEGER | NOT NULL DEFAULT 0 |
learner_week | INTEGER | NOT NULL DEFAULT 0 |
perfect | INTEGER | NOT NULL DEFAULT 0 CHECK (perfect IN (0, 1)) |
rank | INTEGER | NOT NULL DEFAULT 0 |
closed_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | table constraint | PRIMARY KEY (week_start, learner_id) |
Khóa ngoại: learner_id → learners
Index: idx_leaderboard_weeks_learner(learner_id, week_start DESC)
learner_context_events
Migration 0052 — hoàn thiện dạng temporal của Context Event (SRC-205). SRC-343: file này ra đời với số 0046, trùng số đã dùng của 0046_mentor_profiles_albums.sql (AS-04.1.1 chặn ở CI). Đổi sang 0052 — số kế tiếp còn trống. Bản 0046 ĐÃ CHẠY trên production rồi, mà migration này có DROP TABLE learner_context_events và KHÔNG chép dữ liệu sang, nên để nó chạy lại là mất sạch bảng context. Vì thế cùng lúc với đổi tên, tên mới được ghi thẳng vào d1_migrations như đã áp dụng — nội dung không đổi một dòng nào, chỉ đổi cái tên. Chỉ đạo: một context item phải mang đủ event → start → expected end → severity → affected domains → confidence → source Bảng 0030 đã có start/end/confidence/source nhưng THIẾU hai thứ: · severity — "ốm nhẹ một ngày" khác hẳn "nằm viện một tuần", engine phải phân biệt được; · affected_domains — ốm thì ảnh hưởng MỌI môn, còn "tập trung Hình" chỉ chạm một mạch. Thiếu hai cột đó thì mọi event bị đối xử như nhau, và Planning không có cách nào phản ứng đúng mức. Đồng thời BỎ ràng buộc CHECK trên kind. SQLite không sửa được CHECK sau khi tạo bảng, mà danh sách nhóm context còn mở rộng tiếp (external learning, priority override, recent result…). Giữ CHECK ở đây nghĩa là mỗi lần thêm một nhóm lại phải dựng lại bảng. Theo đúng lối đã chọn ở migration 0045: ràng buộc giá trị nằm ở tầng API (zod enum), nơi sửa được mà không đụng dữ liệu. SQLite chỉ bỏ được CHECK bằng cách dựng bảng mới rồi chép sang — làm đúng thứ tự để không mất dòng nào.
migration: 0052_context_event_temporal.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
id | TEXT | PRIMARY KEY |
learner_id | TEXT | NOT NULL REFERENCES learners(id) |
kind | TEXT | NOT NULL, -- giá trị hợp lệ do API chốt (zod), xem modules/models/routes.ts |
statement | TEXT | NOT NULL, -- lời khai bằng tiếng người, hiện nguyên văn khi giải thích plan |
minutes_per_day | INTEGER | , -- chỉ dùng cho time_budget |
subject_id | TEXT | , -- môn chính của event (giữ để tương thích ngược) |
severity | TEXT | , -- 'low' | 'medium' | 'high' — API chốt giá trị |
affected_domains_json | TEXT | , -- ["math","english"] hoặc ["*"] cho "mọi môn" |
effective_from | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
effective_to | TEXT | , -- NULL = còn hiệu lực tới khi bị supersede |
source_role | TEXT | NOT NULL CHECK (source_role IN ('learner','parent','mentor','admin','system')) |
source_user_id | TEXT | — |
confidence | REAL | NOT NULL DEFAULT 0.8 |
status | TEXT | NOT NULL DEFAULT 'active' CHECK (status IN ('active','expired','superseded')) |
superseded_by | TEXT | — |
created_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
Khóa ngoại: learner_id → learners
Index: idx_context_events_learner(learner_id, status, created_at DESC) · idx_context_events_window(learner_id, status, effective_to)
learner_experience_state
Migration 0056 — cho phép trạng thái 'started' trong learner_experience_state (sửa lỗi SRC-396). SRC-396 thêm một bước: mở bài ra là ghi ngay status='started' để việc đang dở không biến mất khỏi hàng chờ. Nhưng bảng 0028 có CHECK (status IN ('completed','failed')), nên câu INSERT ấy vi phạm ràng buộc và ném lỗi — mà nó nằm THẲNG trong đường khởi động bài, sau khi phiên đã được tạo. Hậu quả: mọi lần vào bài từ Đường học đều trả 500 và learner thấy "Chưa mở lúc này". Bài học đắt: một phép ghi PHỤ (ghi sổ để hàng chờ biết) được đặt trong đường đi CHÍNH (vào học) mà không có lưới đỡ. Bản vá đi kèm bọc nó trong try/catch, đúng lối đã dùng cho retention ở SRC-065 ("retention lỗi không chặn luồng học") — hai chỗ giờ theo cùng một luật. SQLite không sửa được CHECK tại chỗ, phải dựng lại bảng rồi chép dữ liệu sang.
migration: 0056_exp_state_started.sql
| Cột | Kiểu | Ràng buộc / ghi chú |
|---|---|---|
learner_id | TEXT | NOT NULL REFERENCES learners(id) |
subject_id | TEXT | NOT NULL |
unit_key | TEXT | NOT NULL |
exp_key | TEXT | NOT NULL |
node_id | TEXT | — |
status | TEXT | NOT NULL CHECK (status IN ('started','completed','failed')) |
score | REAL | — |
updated_at | TEXT | NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) |
| — | table constraint | PRIMARY KEY (learner_id, subject_id, unit_key, exp_key) |
attempts | INTEGER | NOT NULL DEFAULT 1 — thêm ở 0062_exp_state_attempts.sql |
first_started_at | TEXT | — thêm ở 0062_exp_state_attempts.sql |
Khóa ngoại: learner_id → learners
Index: idx_exp_state_learner(learner_id, subject_id)