Skip to content

Data Dictionary · Khác (phần 7/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ạy npm run gen:reference.

Thuộc Data Dictionary. 17 bảng, learner_gate_offers tới learner_sat_membership.

learner_gate_offers ​

Migration 0110 — Sổ đề nghị của cổng readiness vào Unit (SRC-618, REQ-LRN-45). Cổng chỉ cần nhớ hai con số cho mỗi (learner, unit): ĐỀ NGHỊ LẦN CUỐI LÚC NÀO và ĐÃ ĐỀ NGHỊ MẤY LẦN. Thiếu chúng thì cổng không có trí nhớ: learner từ chối một đường vá xong, mở lại Unit là gặp đúng lời mời ấy — và một lời mời lặp lại là cách nhanh nhất để learner học được rằng cứ bấm bỏ qua. Đủ hai con số thì có luật nghỉ 7 ngày, và có chỗ dừng: thử tới sàn hai lần không qua thì cổng im và gọi người, không dẫn learner đi vòng nữa. Vì sao KHÔNG dùng learner_experience_state: bảng đó ghi việc learner ĐÃ LÀM. Một lời đề nghị learner chưa nhận không phải là việc đã làm, và trộn hai thứ vào một bảng thì "đang dở" của hàng chờ (SDD-021 §9) sẽ đếm nhầm cả những bài learner chưa hề mở. Số 0110 thay vì 0104: dải 0103-0105 đang được một phiên khác dùng cho nội dung course và họ báo sẽ đi tiếp từ 0106 (REQ-PLT-19 — trùng số migration là tai nạn đã xảy ra hai lần).

migration: 0110_learner_gate_offers.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTNOT NULL REFERENCES learners(id)
unit_keyTEXTNOT NULL, -- 'strand|module|unit', cùng khoá với unit_prereqs
subject_idTEXTNOT NULL
attemptsINTEGERNOT NULL DEFAULT 0, -- số lần đã đề nghị một đường vá cho chính Unit này
last_offered_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
entered_anywayINTEGERNOT NULL DEFAULT 0
—table constraintPRIMARY KEY (learner_id, unit_key)

Khóa ngoại: learner_id → learners

Index: idx_gate_offers_learner(learner_id, subject_id)

learner_goal_dates ​

0040_goal_dates — lịch thi của từng mục tiêu (SRC-180) Trang Mục tiêu cần hai loại ngày, và chúng khác nhau về BẢN CHẤT nên không nhét chung một cột exam_date_approx của learner_exam_targets được: · entrance — ngày thi tuyển của chính trường/chuyên đang nhắm tới (thường 2-3 ngày liền nhau, mỗi trường một lịch khác) · school — lịch kiểm tra ở trường ĐANG HỌC (giữa kỳ 1 · cuối kỳ 1 · giữa kỳ 2 · cuối kỳ 2), không phụ thuộc mục tiêu nào nên target_id để rỗng confirmed là cột quan trọng nhất của bảng này. Hệ thống TỰ tạo sẵn các mục kèm ngày gợi ý để learner chỉ việc bấm xác nhận — nhưng ngày do máy đoán mà đem dựng kế hoạch gấp rút thì learner bị hối theo một mốc không có thật. Nên: confirmed = 0 → Planning Engine coi là ƯỚC LƯỢNG, chỉ dùng để xếp thứ tự ưu tiên confirmed = 1 → mốc cứng, được phép dựng lịch ôn dồn về phía nó source giữ lại vì sao có ngày này: 'suggested' (máy gợi ý theo mùa/khung năm học) hay 'learner' (người sửa tay). Mất dấu vết này thì sau không biết con số nào là do ai đặt.

migration: 0040_goal_dates.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL
target_idTEXT, -- NULL với lịch thi ở trường đang học
kindTEXTNOT NULL CHECK (kind IN ('entrance', 'school'))
labelTEXTNOT NULL, -- "Ngày thi 1" · "Giữa kỳ 1" …
exam_dateTEXT, -- ISO yyyy-mm-dd; NULL = chưa có ngày
confirmedINTEGERNOT NULL DEFAULT 0
sourceTEXTNOT NULL DEFAULT 'suggested'
ordINTEGERNOT NULL DEFAULT 0
created_atTEXTNOT NULL
updated_atTEXTNOT NULL

Index: idx_goal_dates_learner(learner_id, kind, ord) · idx_goal_dates_target(target_id)

learner_grammar_membership ​

Migration 0311 - kỳ miễn phí 14 ngày RIÊNG của NEMO GRAMMAR. SRC-1123. ## Vì sao migration này tồn tại Chủ dự án 28.09.2026: "NEMO GRAMMAR, free riêng 14 ngày". Tới bản này chỉ IELTS có bảng gói (learner_ielts_membership, 0286). Dùng chung bảng ấy thì learner bắt đầu Grammar hôm nay sẽ thấy kỳ IELTS cũng đã chạy từ hôm nay, và ngược lại - một kỳ bị tiêu bởi program kia. Bảng này cùng hình dạng với 0286 (như learner_sat_membership ở 0308) để hai kỳ đếm độc lập. Dòng được tạo lúc learner mở /grammar lần đầu, cùng lý do như 0286. ## Chạy lại không đổi gì CREATE TABLE IF NOT EXISTS.

migration: 0311_grammar_membership_trial.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTPRIMARY KEY REFERENCES learners(id) ON DELETE CASCADE
planTEXTNOT NULL DEFAULT 'trial' CHECK (plan IN ('trial', 'member'))
started_onTEXTNOT NULL
ends_onTEXTNOT NULL
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: learner_id → learners

learner_ielts_baselines ​

Điểm hiện tại của learner (tự khai) — SRC-717 (chỉ đạo chủ dự án 2026-09-14). Vì sao cần một bảng riêng chứ không thêm cột vào learner_ielts_goals: · Điểm hiện tại thuộc về LEARNER, không thuộc về mục tiêu. Learner có thể tự đánh giá trước khi đặt mục tiêu, và khi đổi mục tiêu thì điểm hiện tại không đổi theo. Nhét vào bảng goals nghĩa là đổi đích thì mất luôn điểm xuất phát, hoặc phải chép nó sang dòng mới. · Đây là một con số CÓ THỜI ĐIỂM: "hôm nay tôi nghĩ mình khoảng 6.0" chỉ đúng vào hôm đó. assessed_on giữ ngày ấy, nên ba tháng sau màn hình nói được rằng con số này đã cũ thay vì bày nó ra như thể vừa đo xong. MỘT baseline đang hiệu lực cho mỗi learner. Tự đánh giá lại thì GHI ĐÈ chứ không thêm dòng: đây không phải sổ theo dõi tiến bộ (thứ đó là bằng chứng từ bài làm thật, do Learner Model lo), mà là điểm xuất phát để vẽ lộ trình. source nói con số này ở đâu ra, và nó phải nói thật: self_estimate — learner tự đoán. Đây là mặc định, và là thứ duy nhất có ngay hôm nay. mock_test — learner khai theo một bài thi thử đã làm. official — điểm từ một kỳ thi IELTS thật. Ba mức tin cậy rất khác nhau, nên chỗ nào bày con số ra cũng phải bày kèm nguồn của nó: một lộ trình dựng trên phỏng đoán mà trông như dựng trên điểm thi thật là một lời hứa hão.

migration: 0217_ielts_baseline.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id)
assessed_onTEXTNOT NULL, -- ISO yyyy-mm-dd: con số này đúng vào ngày nào
listeningREALNOT NULL CHECK (listening >= 0 AND listening <= 9 AND listening * 2 = CAST(listening * 2 AS INTEGER))
readingREALNOT NULL CHECK (reading >= 0 AND reading <= 9 AND reading * 2 = CAST(reading * 2 AS INTEGER))
writingREALNOT NULL CHECK (writing >= 0 AND writing <= 9 AND writing * 2 = CAST(writing * 2 AS INTEGER))
speakingREALNOT NULL CHECK (speaking >= 0 AND speaking <= 9 AND speaking * 2 = CAST(speaking * 2 AS INTEGER))
overallREALNOT NULL
sourceTEXTNOT NULL DEFAULT 'self_estimate' CHECK (source IN ('self_estimate','mock_test','official'))
statusTEXTNOT NULL DEFAULT 'active' CHECK (status IN ('active','archived'))
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: learner_id → learners

Index: idx_ielts_baseline_active(learner_id) UNIQUE

learner_ielts_declarations ​

Lời khai IELTS của learner — SRC-719 (chỉ đạo chủ dự án 2026-09-14). ── Vì sao KHÔNG dùng learner_context_events ────────────────────────────────────────────── Context Event Registry (0030, 0052) giữ những SỰ VIỆC có thời hạn quanh learner: ốm, bận, sắp thi, quỹ thời gian tuần này. Chúng hết hiệu lực, chúng bị supersede, và engine đọc chúng để biết "tuần này đừng xếp nặng". Những gì ghi ở đây thì khác: chúng là TÌNH TRẠNG ĐANG ĐÚNG của learner với riêng kỳ thi IELTS — đã học bao lâu, bài viết có ai chữa không, tự thấy kỹ năng nào khó nhất. Chúng không hết hạn, chúng đổi khi learner khai lại. Nhét vào registry sự việc thì hoặc phải bịa effective_to, hoặc phải đọc một bản khai từ ba tháng trước như thể nó vừa xảy ra. ── Vì sao lời khai phải tách khỏi bằng chứng ──────────────────────────────────────────────── Bảng này chứa thứ learner TIN về mình. Bằng chứng (learner_evidence) chứa thứ learner LÀM ĐƯỢC. Hai thứ ấy lệch nhau là chuyện thường, và chính KHOẢNG LỆCH mới là thông tin đáng giá nhất: một người tin mình yếu Listening trong khi bài làm cho thấy Listening đang tốt nhất thì đang phân bổ sai thời gian học của chính mình. Trộn hai thứ vào một bảng là mất khoảng lệch đó. MỘT bản khai đang hiệu lực cho mỗi learner; khai lại thì ghi đè.

migration: 0218_ielts_declarations.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id)
purposeTEXTCHECK (purpose IN ('study_abroad','university_vn','scholarship','work','immigration','self_check','unsure'))
study_monthsINTEGERCHECK (study_months IS NULL OR (study_months >= 0 AND study_months <= 240))
weekly_hoursINTEGERCHECK (weekly_hours IS NULL OR (weekly_hours >= 0 AND weekly_hours <= 80))
hardest_skillTEXTCHECK (hardest_skill IS NULL OR hardest_skill IN ('listening','reading','writing','speaking'))
writing_feedbackTEXTCHECK (writing_feedback IS NULL OR writing_feedback IN ('regular','sometimes','none'))
speaking_practiceTEXTCHECK (speaking_practice IS NULL OR speaking_practice IN ('regular','sometimes','none'))
taken_examINTEGERNOT NULL DEFAULT 0 CHECK (taken_exam IN (0,1))
noteTEXTCHECK (note IS NULL OR length(note) <= 500)
statusTEXTNOT NULL DEFAULT 'active' CHECK (status IN ('active','archived'))
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: learner_id → learners

Index: idx_ielts_decl_active(learner_id) UNIQUE

learner_ielts_goals ​

Mục tiêu IELTS của learner — SRC-696 (chỉ đạo chủ dự án 2026-09-09). Vì sao KHÔNG dùng lại learner_exam_targets: bảng đó lưu điểm dưới dạng MỘT chuỗi (target_score TEXT), đủ cho "SAT 1500" nhưng không giữ nổi bốn kỹ năng tách rời — mà chính bốn con số ấy mới là thứ learner đặt ra, còn Overall là hệ tự tính. Nhét bốn số vào một chuỗi rồi tách bằng regex là cách chắc chắn để một ngày nào đó Overall lệch với bốn kỹ năng. MỘT mục tiêu active cho mỗi learner (unique index bên dưới). Mục tiêu IELTS là đích cuối cùng, không phải danh sách nguyện vọng; muốn đổi thì sửa chính nó, và bản cũ chuyển 'archived'.

migration: 0210_ielts_goals.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id)
target_dateTEXTNOT NULL, -- ISO yyyy-mm-dd: ngày muốn ĐẠT điểm này
listeningREALNOT NULL CHECK (listening >= 0 AND listening <= 9 AND listening * 2 = CAST(listening * 2 AS INTEGER))
readingREALNOT NULL CHECK (reading >= 0 AND reading <= 9 AND reading * 2 = CAST(reading * 2 AS INTEGER))
writingREALNOT NULL CHECK (writing >= 0 AND writing <= 9 AND writing * 2 = CAST(writing * 2 AS INTEGER))
speakingREALNOT NULL CHECK (speaking >= 0 AND speaking <= 9 AND speaking * 2 = CAST(speaking * 2 AS INTEGER))
overallREALNOT NULL
statusTEXTNOT NULL DEFAULT 'active' CHECK (status IN ('active','archived'))
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: learner_id → learners

Index: idx_ielts_goal_active(learner_id) UNIQUE

learner_ielts_membership ​

Migration 0286 - gói hiện tại của learner ở sân IELTS: đang dùng thử hay đã là thành viên. SRC-1010. ## Vì sao migration này tồn tại Chỉ đạo chủ dự án 23.09.2026: learner cần một trang nhìn thấy học phí, số dư, TÌNH TRẠNG GÓI (ví dụ gói Trial), và NGÀY HẾT HẠN dùng thử - mặc định 30 ngày. Tới bản này repo không có chỗ nào lưu điều ấy. curriculum_entitlements cấp quyền theo từng KHOÁ học của tầng curriculum, không phải một kỳ thành viên IELTS; membership_pricing (0278) nói GIÁ, không nói ai đã mua. Suy ngày hết hạn từ ngày tạo tài khoản mỗi lần đọc thì được một con số, nhưng không đổi được: ngày admin gia hạn cho một learner, không có chỗ nào để ghi. ## Vì sao dòng được tạo LÚC ĐỌC LẦN ĐẦU, không lùi về ngày tạo tài khoản Learner đang học hôm nay chưa từng được nói rằng họ đang "dùng thử". Tính 30 ngày từ ngày tạo tài khoản thì phần lớn trong số họ mở trang này ra sẽ thấy chữ "hết hạn" cho một khái niệm vừa mới có - một bất ngờ khó chịu, và sai sự thật: không ai hứa với họ một kỳ hạn nào. Nên kỳ dùng thử bắt đầu từ lần đầu hệ thống ghi nhận nó. Với learner mới, lần ấy rơi vào ngày đầu họ vào sân IELTS, tức là gần như trùng ngày tạo tài khoản. ## Chạy lại không đổi gì CREATE TABLE IF NOT EXISTS.

migration: 0286_ielts_membership_trial.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTPRIMARY KEY REFERENCES learners(id) ON DELETE CASCADE
planTEXTNOT NULL DEFAULT 'trial' CHECK (plan IN ('trial', 'member'))
started_onTEXTNOT NULL
ends_onTEXTNOT NULL
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: learner_id → learners

learner_ielts_milestones ​

Mốc trung gian: tối đa 5 mốc cho mỗi mục tiêu. Trần 5 canh ở service (API trả 409), không ở DB — SQLite không có CHECK đếm dòng, và một trigger chỉ để đếm là thứ không ai nhớ khi đọc lại.

migration: 0210_ielts_goals.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
goal_idTEXTNOT NULL REFERENCES learner_ielts_goals(id)
learner_idTEXTNOT NULL REFERENCES learners(id)
target_dateTEXTNOT NULL
listeningREALNOT NULL CHECK (listening >= 0 AND listening <= 9 AND listening * 2 = CAST(listening * 2 AS INTEGER))
readingREALNOT NULL CHECK (reading >= 0 AND reading <= 9 AND reading * 2 = CAST(reading * 2 AS INTEGER))
writingREALNOT NULL CHECK (writing >= 0 AND writing <= 9 AND writing * 2 = CAST(writing * 2 AS INTEGER))
speakingREALNOT NULL CHECK (speaking >= 0 AND speaking <= 9 AND speaking * 2 = CAST(speaking * 2 AS INTEGER))
overallREALNOT NULL
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
originTEXTNOT NULL DEFAULT 'learner' — thêm ở 0217_ielts_baseline.sql

Khóa ngoại: goal_id → learner_ielts_goals · learner_id → learners

Index: idx_ielts_milestone_goal(goal_id, target_date)

learner_ielts_plan ​

Migration 0270 - Learning Plan lưu xuống CSDL. SRC-960. ## Vì sao lưu theo NGÀY MỐC, không theo số thứ tự cột Bảng Learning Plan trên màn hình là bốn kỹ năng nhân với các mốc ngày 1 hoặc 15 hàng tháng, và các mốc ấy được tính từ NGÀY HÔM NAY. Nếu chỉ lưu một mảng "tăng bao nhiêu ở cột thứ n", thì learner mở lại sau hai tháng sẽ thấy đúng những con số cũ nằm dưới những cái tháng MỚI: cột từng là 10.2026 nay thành 12.2026, và cả kế hoạch đổi nghĩa mà không ai đụng vào nó. Nên mỗi ô lưu kèm ngày mốc của chính nó. Đọc lại là đọc lại đúng cái kế hoạch đã lập, kể cả khi một phần của nó đã thành quá khứ - và một mốc đã trôi qua là thông tin, không phải rác: nó nói learner đã chậm bao nhiêu so với thứ chính họ đặt ra. ## Hai bảng, không phải một cột JSON Một cột plan_json thì ghi nhanh hơn, nhưng mọi câu hỏi về sau đều phải kéo cả cục JSON ra rồi tự bóc: "tháng này learner nào đang đặt quá 40 giờ", "kỹ năng nào hay bị hoãn". Một dòng cho mỗi ô trả lời được những câu ấy bằng SQL, và chi phí là vài chục dòng cho mỗi learner. ## Idempotent IF NOT EXISTS ở mọi lệnh: migration có thể chạy lại trên một CSDL đã có bảng (AS-04.6.4), và một lần chạy lại không được phép làm đỏ cả lượt deploy.

migration: 0270_learner_ielts_learning_plan.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTPRIMARY KEY
monthly_hoursINTEGERNOT NULL DEFAULT 30
updated_atTEXTNOT NULL DEFAULT (datetime('now'))
monthsINTEGER— thêm ở 0283_learning_plan_months_column.sql

learner_ielts_plan_cells ​

Migration 0273 - mốc kế hoạch là NGÀY CUỐI THÁNG. SRC-974. ## Vì sao phải dựng lại bảng learner_ielts_plan_cells (0270) có CHECK ngầm qua tầng ứng dụng rằng milestone_date rơi vào ngày 1 hoặc ngày 15. Chỉ đạo chủ dự án 22.09.2026 đổi luật: mỗi cột là MỘT THÁNG, và mốc của tháng ấy là NGÀY CUỐI THÁNG - "cái TARGET REACHED, cần là 'ngày cuối tháng'". Ngày cuối tháng là 28, 29, 30 hoặc 31 tuỳ tháng, nên luật cũ chặn đúng thứ nay là chuẩn. SQLite không sửa được ràng buộc tại chỗ, nên bảng dựng lại rồi chép dữ liệu sang - cách duy nhất. ## Dữ liệu cũ đi đâu Chép nguyên vẹn. Một kế hoạch đã lập với mốc ngày 1 hoặc 15 vẫn đọc lại được và vẫn vẽ ra đúng những con số learner đã đặt; chỉ những cột SINH RA từ nay mới rơi vào ngày cuối tháng. Đổi ngày của dữ liệu cũ cho "đẹp" là sửa một kế hoạch mà chủ của nó không hề bấm gì.

migration: 0273_learning_plan_milestone_any_day.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTNOT NULL
milestone_dateTEXTNOT NULL
skillTEXTNOT NULL CHECK (skill IN ('listening','reading','writing','speaking'))
gainREALNOT NULL DEFAULT 0
—table constraintPRIMARY KEY (learner_id, milestone_date, skill)

Index: idx_ielts_plan_cells_learner_date(learner_id, milestone_date)

learner_ielts_plan_shares ​

Migration 0282 - chia sẻ Learning Plan ra một đường dẫn công khai. SRC-994. ## Vì sao migration này tồn tại Chỉ đạo chủ dự án 23.09.2026: "Tạo button để Learner có thể 'Share my plan'. Mọi người có thể vào xem từ trang nào đó trên www.nemo12.com/ielts/learning-plans/slug". ## Vì sao một BẢNG riêng, không phải một cột trong learner_ielts_plan Cái được chia sẻ không phải một bản sao của kế hoạch mà là một CÁI KHOÁ trỏ vào kế hoạch đang sống: learner sửa bảng thì người cầm đường dẫn thấy bản mới, đó chính là điều họ muốn khi gửi link cho bố mẹ hay cho thầy cô. Một bảng riêng nói đúng điều đó, và nó còn giữ được hai thứ mà một cột không giữ nổi: thời điểm bật chia sẻ, và thời điểm TẮT. ## slug là một chuỗi ngẫu nhiên, không phải tên learner Đường dẫn này ai cầm cũng mở được, nên nó không được đoán ra. Tên hay ID learner đặt vào URL là mời người ta thử tên khác; một chuỗi ngẫu nhiên đủ dài thì không có gì để thử. revoked_at có mặt ngay từ đầu dù màn hình chưa có nút tắt: một đường dẫn công khai mà không có đường thu hồi là thứ không nên dựng, và thêm cột sau khi đã có dữ liệu thì tốn hơn. MỘT learner MỘT slug (UNIQUE): bấm Share nhiều lần thì vẫn ra đúng một đường dẫn, nên link đã gửi cho ai đó không chết vì một cú bấm sau này. ## Chạy lại không đổi gì CREATE TABLE IF NOT EXISTS.

migration: 0282_learning_plan_public_share.sql

CộtKiểuRàng buộc / ghi chú
slugTEXTPRIMARY KEY
learner_idTEXTNOT NULL UNIQUE REFERENCES learners(id) ON DELETE CASCADE
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
revoked_atTEXT—

Khóa ngoại: learner_id → learners

learner_ielts_profile_facts ​

Migration 0271 - Learner Profile. SRC-963. ## Vì sao MỘT bảng khoá-giá trị, không phải ba chục cột Learner Profile hỏi sáu nhóm câu, và phần lớn là chọn NHIỀU đáp án: "cách học hợp với con" chọn được ba, "điều hay cản trở" chọn được năm. Ba chục cột thì mỗi câu nhiều lựa chọn phải thành một cột JSON, và khi ấy câu hỏi đầu tiên của người vận hành - "bao nhiêu learner đang vướng vì bài vở ở trường" - lại phải kéo cả cục JSON ra rồi tự bóc. Một dòng cho mỗi (learner, câu, đáp án) trả lời được câu ấy bằng SQL, và nó cũng là hình dạng đúng với thứ hồ sơ này sẽ trở thành: một tập dữ kiện lớn dần, chứ không phải một biểu mẫu có số trường cố định. Thêm một câu hỏi mới KHÔNG cần migration. Cái giá, nói ra để sau này không ai ngạc nhiên: không có ràng buộc nào ở tầng CSDL bắt key phải là một câu có thật. Luật ấy nằm ở learnerProfile.ts, và đó là chỗ DUY NHẤT được quyết câu nào tồn tại. ## source phân biệt điều learner KHAI với điều Nemo QUAN SÁT Chỉ đạo 22.09.2026 nói rõ hồ sơ này phải tiến hoá: "Learner says -> NEMO observes -> NEMO infers -> NEMO asks -> Learner confirms". Trộn hai loại vào một dòng là cách chắc chắn để sáu tháng sau không ai biết một dữ kiện ở đâu ra, và cũng không ai dám sửa nó. Vòng này chỉ ghi learner; nemo để sẵn cho tầng suy luận, và một cột thêm sau sẽ tốn một migration nữa cộng một lượt backfill.

migration: 0271_learner_ielts_profile_facts.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTNOT NULL
keyTEXTNOT NULL
valueTEXTNOT NULL
sourceTEXTNOT NULL DEFAULT 'learner' CHECK (source IN ('learner','nemo'))
updated_atTEXTNOT NULL DEFAULT (datetime('now'))
—table constraintPRIMARY KEY (learner_id, key, value)

Index: idx_ielts_profile_facts_learner(learner_id, key) · idx_ielts_profile_facts_key(key, value)

learner_ielts_setup ​

0253 — Learning Setup: lối vào learner tự chọn, và các kỹ năng họ muốn nâng (SRC-860). Chỉ đạo chủ dự án 2026-09-19: "tôi sẽ không bắt mọi learner đi qua cùng một quy trình." ── Vì sao cần một cột, khi đã có bảng baseline ───────────────────────────────────────────── Gần như mọi thứ Learning Setup cần đã nằm sẵn trong learner_ielts_baselines: có điểm hay chưa, và điểm ấy chắc tới đâu (source). Ba mức Evidence Level suy thẳng ra từ đó, nên KHÔNG có cột evidence_level ở đây — một cột như vậy sẽ lệch khỏi sự thật ngay lần đầu learner sửa điểm, đúng lỗi mà journey.ts đã tránh khi từ chối lưu "đang ở bước mấy". Thứ KHÔNG suy ra được là chỗ này: learner chưa có điểm vì họ MỚI BẮT ĐẦU và muốn học luôn, hay vì họ CHƯA CHỌN gì cả. Hai trạng thái ấy nhìn từ dữ liệu giống hệt nhau — cùng là "không có dòng baseline nào" — nhưng màn hình phải đối xử ngược nhau: một bên đi học được ngay, một bên còn phải hỏi. Thiếu cột này thì learner đã chọn "I'm new to IELTS" vẫn bị hỏi lại mỗi lần mở trang, tức là đúng thứ chỉ đạo yêu cầu bỏ đi.

migration: 0253_ielts_learning_setup.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTPRIMARY KEY REFERENCES learners(id)
start_modeTEXTCHECK (start_mode IS NULL OR start_mode IN ('new','has_score','unsure'))
focus_skills_jsonTEXT—
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: learner_id → learners

learner_journey_states ​

Migration 0301 - journey engine state and events. SRC-1077, SDD-045 §7. ## Vì sao migration này tồn tại Journey Engine SUY RA trạng thái của từng hành trình từ dữ liệu thật mỗi lần đọc (SDD-045 §3, cùng lý do ielts/journey.ts cũ từ chối lưu "đang ở bước mấy": một cột trạng thái chắc chắn sẽ lệch khỏi sự thật). Nhưng có ba thứ không suy ra được từ dữ liệu hiện tại: 1. LỊCH SỬ chuyển trạng thái - hành trình Recovery được kích hoạt ngày nào, xong ngày nào. Thiếu nó thì không đo được tỉ lệ kích hoạt, hoàn thành, bỏ dở (PRD-005 FR10). 2. Lựa chọn của learner: "Để sau" một gợi ý (PAUSED), và việc họ đã BẤM vào gợi ý nào. 3. Hành trình nào đang đứng trước ở lần đọc trước - để giữ mạch (continuity), không đổi việc liên tục chỉ vì hai điểm ưu tiên chênh nhau 0.01. learner_journey_states là ẢNH CHỤP lần đánh giá gần nhất, một dòng cho mỗi (learner, hành trình). Nó không phải nguồn sự thật của trạng thái: nguồn sự thật là dữ liệu học, ảnh chụp chỉ để so "lần trước khác lần này không" và giữ snoozed_until. journey_events là sổ CHỈ CHÈN: mỗi lần chuyển trạng thái, mỗi lần bấm gợi ý, mỗi lần "Để sau". ## Chạy lại không đổi gì Chỉ CREATE TABLE IF NOT EXISTS và CREATE INDEX IF NOT EXISTS.

migration: 0301_journey_engine_state_and_events.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTNOT NULL
journey_idTEXTNOT NULL
definition_versionINTEGERNOT NULL
stateTEXTNOT NULL CHECK (state IN ('NOT_ELIGIBLE','ELIGIBLE','ACTIVE','PAUSED','COMPLETED','EXPIRED'))
priority_scoreREALNOT NULL DEFAULT 0
is_foregroundINTEGERNOT NULL DEFAULT 0 CHECK (is_foreground IN (0,1))
action_idTEXT—
snoozed_untilTEXT—
evaluated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (learner_id, journey_id)

learner_model_versions ​

Migration 0051 — bỏ CHECK cứng trên learner_model_versions.model_kind (SRC-207). Migration 0027 chốt danh sách 7 model vào một CHECK. Thêm model thứ 8 (Recommendation) là mọi lần ghi version của nó bị SQLite từ chối, và vì saveModelVersion() nuốt lỗi để không làm hỏng nghiệp vụ chính nên nó hỏng ÂM THẦM: engine báo chạy xong, admin thì mãi "not built yet". Đây là lần thứ hai cùng một loại bẫy trong dự án (lần trước là kind của context event, migration 0046). Bài học đã ghi ở 0045 và 0046: danh sách còn mở rộng thì đừng chốt bằng CHECK, vì SQLite không sửa được CHECK — mỗi lần thêm giá trị lại phải dựng lại bảng. Chốt ở tầng API (MODEL_KINDS trong shared/runlog.ts) là nơi sửa được mà không đụng dữ liệu. Dựng bảng mới rồi chép sang, giữ nguyên mọi dòng và mọi ràng buộc còn lại.

migration: 0051_model_kind_open.sql

CộtKiểuRàng buộc / ghi chú
idTEXTPRIMARY KEY
learner_idTEXTNOT NULL REFERENCES learners(id)
model_kindTEXTNOT NULL, -- giá trị hợp lệ chốt ở MODEL_KINDS (shared/runlog.ts)
versionINTEGERNOT NULL
algorithm_versionTEXTNOT NULL
generated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
evidence_cutoffTEXT—
stateTEXTNOT NULL DEFAULT 'active' CHECK (state IN ('active','superseded'))
triggerTEXT—
engine_run_idTEXT—
workflow_run_idTEXT—
content_jsonTEXTNOT NULL
content_hashTEXTNOT NULL
model_versionTEXT—
confidenceREAL—
input_refTEXT—
—table constraintUNIQUE (learner_id, model_kind, version)

Khóa ngoại: learner_id → learners

Index: idx_model_versions_lookup(learner_id, model_kind, version DESC)

learner_pins ​

Learner tự ghim ưu tiên vài Module hoặc vài Unit (SRC-418). Chủ dự án 2026-08-20: "tôi muốn learner có thể đánh dấu ưu tiên một vài module hoặc một vài unit nào đó. Sau khi đánh dấu thì các cái đã được đánh dấu sẽ được đưa lên một khu vực phía trên." Bốn luật chủ dự án chốt sau khi được hỏi từng cái: 1. ƯU TIÊN MỀM — được chọn trước trong nhóm "bước tiếp", nhưng "đang dở" và "sắp quên" vẫn thắng, và luật bế tắc của SRC-417 vẫn áp dụng. Ưu tiên CỨNG bị loại vì nó dựng lại đúng vòng lặp vừa vá: learner ghim một Unit quá khó là tự nhốt mình. 2. CÁI ĐẦU TIÊN QUYẾT ĐỊNH LOẠI — ghim Unit rồi thì không ghim Module được nữa cho tới khi bỏ hết. Ràng buộc này canh ở API chứ không ở đây: SQLite không có CHECK liên-hàng. 3. TỰ BỎ KHI ĐÃ VỮNG — ghim là một cái đích; đạt đích thì nhường chỗ, learner không phải nhớ. 4. TỐI ĐA 3 CHO MỖI MÔN — khớp với Dashboard vốn đã chia theo môn. target_key mang khoá của thứ được ghim, cùng dạng với unit_prereqs.unit_key: unit → strand|module|unit module → strand|module Dùng chuỗi chứ không dùng id vì Module KHÔNG có bảng riêng — nó chỉ là một cột trên skill_nodes.

migration: 0060_learner_pins.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTNOT NULL REFERENCES learners(id)
subject_idTEXTNOT NULL
kindTEXTNOT NULL CHECK (kind IN ('module','unit'))
target_keyTEXTNOT NULL
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
—table constraintPRIMARY KEY (learner_id, subject_id, target_key)

Khóa ngoại: learner_id → learners

Index: idx_learner_pins_subject(learner_id, subject_id, created_at)

learner_sat_membership ​

migration: 0308_school_codes_and_outreach.sql

CộtKiểuRàng buộc / ghi chú
learner_idTEXTPRIMARY KEY REFERENCES learners(id) ON DELETE CASCADE
planTEXTNOT NULL DEFAULT 'member' CHECK (plan IN ('member'))
started_onTEXTNOT NULL
ends_onTEXTNOT NULL
sourceTEXT, -- vd 'school:NEMO-7K3QX9'
created_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
updated_atTEXTNOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))

Khóa ngoại: learner_id → learners