[OCaml] High Intensity Training Online
1
(* The SQL schema migrations, as data. This module holds no logic. It holds only
2
the ordered list of migrations and the ledger table that records which ones a
3
database file already carries. {!Migrations} reads this and applies it.
4
5
Keeping the SQL here, apart from the apply logic, means a schema change edits
6
one place and never touches OCaml control flow.
7
8
Seven incremental migrations first grew this schema. A reset of the deployed
9
database consolidated them into a single initial migration. No file was left
10
to migrate, so the drop-and-recreate churn of the old chain served no
11
purpose. Add the next schema change as version 2. *)
12
13
type t = { version : int; name : string; statements : string list }
14
(** One migration: a version, a human name, and the ordered statements it runs.
15
*)
16
17
(* The ledger of applied migrations. A row per version, so a reconnect knows
18
what the file already carries and skips it. *)
19
let ledger =
20
{|CREATE TABLE IF NOT EXISTS schema_migrations (
21
version INTEGER PRIMARY KEY,
22
name TEXT NOT NULL,
23
applied_at REAL NOT NULL
24
)|}
25
26
let migrations =
27
[
28
{
29
version = 1;
30
name = "initial schema";
31
statements =
32
[
33
{|CREATE TABLE IF NOT EXISTS trainee (
34
id TEXT PRIMARY KEY,
35
username TEXT NOT NULL UNIQUE,
36
credential TEXT NOT NULL
37
)|};
38
{|CREATE TABLE IF NOT EXISTS active_routine (
39
trainee_id TEXT PRIMARY KEY,
40
routine_id TEXT NOT NULL
41
)|};
42
{|CREATE TABLE IF NOT EXISTS in_progress (
43
trainee_id TEXT PRIMARY KEY,
44
encoded TEXT NOT NULL
45
)|};
46
{|CREATE TABLE IF NOT EXISTS workout (
47
id TEXT PRIMARY KEY,
48
trainee_id TEXT NOT NULL,
49
seq INTEGER NOT NULL,
50
encoded TEXT NOT NULL
51
)|};
52
{|CREATE INDEX IF NOT EXISTS workout_by_trainee
53
ON workout (trainee_id, seq DESC)|};
54
(* Retained from when Dream's sql_sessions back end stored sessions
55
here. Sessions now live in a signed cookie. Kept so an existing
56
file keeps its rows; otherwise unused. *)
57
{|CREATE TABLE IF NOT EXISTS dream_session (
58
id TEXT PRIMARY KEY,
59
label TEXT NOT NULL,
60
expires_at REAL NOT NULL,
61
payload TEXT NOT NULL
62
)|};
63
{|CREATE TABLE IF NOT EXISTS feedback (
64
trainee_id TEXT NOT NULL,
65
seq INTEGER NOT NULL,
66
encoded TEXT NOT NULL,
67
PRIMARY KEY (trainee_id, seq)
68
)|};
69
{|CREATE INDEX IF NOT EXISTS feedback_by_trainee
70
ON feedback (trainee_id, seq DESC)|};
71
{|CREATE TABLE IF NOT EXISTS app_feedback (
72
trainee_id TEXT NOT NULL,
73
seq INTEGER NOT NULL,
74
submitted_at TEXT NOT NULL,
75
message TEXT NOT NULL,
76
PRIMARY KEY (trainee_id, seq)
77
)|};
78
{|CREATE INDEX IF NOT EXISTS app_feedback_by_trainee
79
ON app_feedback (trainee_id, seq DESC)|};
80
{|CREATE TABLE IF NOT EXISTS app_feedback_vote (
81
feedback_trainee_id TEXT NOT NULL,
82
feedback_seq INTEGER NOT NULL,
83
voter_id TEXT NOT NULL,
84
PRIMARY KEY (feedback_trainee_id, feedback_seq, voter_id)
85
)|};
86
{|CREATE INDEX IF NOT EXISTS app_feedback_vote_by_voter
87
ON app_feedback_vote (voter_id)|};
88
(* An import batch is provisional: it holds the raw export source and
89
the cleaning done so far, encoded as one snapshot blob. A promoted
90
record is canonical evidence with an independent lifecycle, so it
91
carries no foreign key to its batch. Deleting the batch must never
92
cascade to the record. The batch's [imported_at] is a column, so
93
listing can order without decoding every blob. A record is keyed by
94
(trainee, workout) so a re-promotion replaces it. *)
95
{|CREATE TABLE IF NOT EXISTS import_batch (
96
trainee_id TEXT NOT NULL,
97
batch_id TEXT NOT NULL,
98
imported_at TEXT NOT NULL,
99
source TEXT NOT NULL,
100
fingerprint TEXT NOT NULL,
101
snapshot TEXT NOT NULL,
102
PRIMARY KEY (trainee_id, batch_id)
103
)|};
104
{|CREATE INDEX IF NOT EXISTS import_batch_by_trainee
105
ON import_batch (trainee_id, imported_at DESC, batch_id DESC)|};
106
{|CREATE TABLE IF NOT EXISTS imported_record (
107
trainee_id TEXT NOT NULL,
108
workout_id TEXT NOT NULL,
109
batch_id TEXT NOT NULL,
110
revision INTEGER NOT NULL,
111
title TEXT NOT NULL,
112
started_at TEXT NOT NULL,
113
ended_at TEXT NOT NULL,
114
snapshot TEXT NOT NULL,
115
PRIMARY KEY (trainee_id, workout_id)
116
)|};
117
{|CREATE INDEX IF NOT EXISTS imported_record_by_trainee
118
ON imported_record (trainee_id, ended_at DESC)|};
119
];
120
};
121
]
122