[OCaml] High Intensity Training Online
refactor extract SQL migrations into migration_data
Move the migration list and the ledger table SQL out of migrations.ml into a new data-only module, Migration_data. migrations.ml now holds only OCaml: the ledger check, the per-migration transaction, and apply. A schema change now edits data in one place and never touches control flow. The public interface is unchanged: Migrations re-exports the migration type, migrations, statements, and apply.
lib/persistence/migration_data.ml
@@ -0,0 +1,211 @@
1
Added:
(* The SQL schema migrations, as data. This module holds no logic — only the
2
Added:
ordered list of migrations and the ledger table that records which ones a
3
Added:
database file already carries. {!Migrations} reads this and applies it.
4
Added:
5
Added:
Keeping the SQL here, apart from the apply logic, means a schema change edits
6
Added:
one place and never touches OCaml control flow. *)
7
Added:
8
Added:
type migration = { version : int; name : string; statements : string list }
9
Added:
(** One migration: a version, a human name, and the ordered statements it runs.
10
Added:
*)
11
Added:
12
Added:
(* The ledger of applied migrations. A row per version, so a reconnect knows
13
Added:
what the file already carries and skips it. *)
14
Added:
let ledger =
15
Added:
{|CREATE TABLE IF NOT EXISTS schema_migrations (
16
Added:
version INTEGER PRIMARY KEY,
17
Added:
name TEXT NOT NULL,
18
Added:
applied_at REAL NOT NULL
19
Added:
)|}
20
Added:
21
Added:
let migrations =
22
Added:
[
23
Added:
{
24
Added:
version = 1;
25
Added:
name = "initial schema";
26
Added:
statements =
27
Added:
[
28
Added:
{|CREATE TABLE IF NOT EXISTS trainee (
29
Added:
id TEXT PRIMARY KEY,
30
Added:
username TEXT NOT NULL UNIQUE,
31
Added:
credential TEXT NOT NULL
32
Added:
)|};
33
Added:
{|CREATE TABLE IF NOT EXISTS active_routine (
34
Added:
trainee_id TEXT PRIMARY KEY,
35
Added:
routine_id TEXT NOT NULL
36
Added:
)|};
37
Added:
{|CREATE TABLE IF NOT EXISTS in_progress (
38
Added:
trainee_id TEXT PRIMARY KEY,
39
Added:
encoded TEXT NOT NULL
40
Added:
)|};
41
Added:
{|CREATE TABLE IF NOT EXISTS workout (
42
Added:
id TEXT PRIMARY KEY,
43
Added:
trainee_id TEXT NOT NULL,
44
Added:
seq INTEGER NOT NULL,
45
Added:
encoded TEXT NOT NULL
46
Added:
)|};
47
Added:
{|CREATE INDEX IF NOT EXISTS workout_by_trainee
48
Added:
ON workout (trainee_id, seq DESC)|};
49
Added:
(* The table Dream's sql_sessions back end expects. *)
50
Added:
{|CREATE TABLE IF NOT EXISTS dream_session (
51
Added:
id TEXT PRIMARY KEY,
52
Added:
label TEXT NOT NULL,
53
Added:
expires_at REAL NOT NULL,
54
Added:
payload TEXT NOT NULL
55
Added:
)|};
56
Added:
];
57
Added:
};
58
Added:
{
59
Added:
version = 2;
60
Added:
name = "subjective feedback";
61
Added:
statements =
62
Added:
[
63
Added:
{|CREATE TABLE IF NOT EXISTS feedback (
64
Added:
trainee_id TEXT NOT NULL,
65
Added:
seq INTEGER NOT NULL,
66
Added:
encoded TEXT NOT NULL,
67
Added:
PRIMARY KEY (trainee_id, seq)
68
Added:
)|};
69
Added:
{|CREATE INDEX IF NOT EXISTS feedback_by_trainee
70
Added:
ON feedback (trainee_id, seq DESC)|};
71
Added:
];
72
Added:
};
73
Added:
{
74
Added:
version = 3;
75
Added:
name = "application feedback";
76
Added:
statements =
77
Added:
[
78
Added:
{|CREATE TABLE IF NOT EXISTS app_feedback (
79
Added:
trainee_id TEXT NOT NULL,
80
Added:
seq INTEGER NOT NULL,
81
Added:
submitted_at TEXT NOT NULL,
82
Added:
message TEXT NOT NULL,
83
Added:
PRIMARY KEY (trainee_id, seq)
84
Added:
)|};
85
Added:
{|CREATE INDEX IF NOT EXISTS app_feedback_by_trainee
86
Added:
ON app_feedback (trainee_id, seq DESC)|};
87
Added:
];
88
Added:
};
89
Added:
{
90
Added:
version = 4;
91
Added:
name = "application feedback votes";
92
Added:
statements =
93
Added:
[
94
Added:
{|CREATE TABLE IF NOT EXISTS app_feedback_vote (
95
Added:
feedback_trainee_id TEXT NOT NULL,
96
Added:
feedback_seq INTEGER NOT NULL,
97
Added:
voter_id TEXT NOT NULL,
98
Added:
PRIMARY KEY (feedback_trainee_id, feedback_seq, voter_id)
99
Added:
)|};
100
Added:
{|CREATE INDEX IF NOT EXISTS app_feedback_vote_by_voter
101
Added:
ON app_feedback_vote (voter_id)|};
102
Added:
];
103
Added:
};
104
Added:
{
105
Added:
version = 5;
106
Added:
name = "ISO 8601 UTC timestamps";
107
Added:
statements =
108
Added:
[
109
Added:
{|DROP TABLE IF EXISTS app_feedback_vote|};
110
Added:
{|DROP TABLE IF EXISTS app_feedback|};
111
Added:
{|DROP TABLE IF EXISTS feedback|};
112
Added:
{|DROP TABLE IF EXISTS workout|};
113
Added:
{|DROP TABLE IF EXISTS in_progress|};
114
Added:
{|CREATE TABLE IF NOT EXISTS in_progress (
115
Added:
trainee_id TEXT PRIMARY KEY,
116
Added:
encoded TEXT NOT NULL
117
Added:
)|};
118
Added:
{|CREATE TABLE IF NOT EXISTS workout (
119
Added:
id TEXT PRIMARY KEY,
120
Added:
trainee_id TEXT NOT NULL,
121
Added:
seq INTEGER NOT NULL,
122
Added:
encoded TEXT NOT NULL
123
Added:
)|};
124
Added:
{|CREATE INDEX IF NOT EXISTS workout_by_trainee
125
Added:
ON workout (trainee_id, seq DESC)|};
126
Added:
{|CREATE TABLE IF NOT EXISTS feedback (
127
Added:
trainee_id TEXT NOT NULL,
128
Added:
seq INTEGER NOT NULL,
129
Added:
encoded TEXT NOT NULL,
130
Added:
PRIMARY KEY (trainee_id, seq)
131
Added:
)|};
132
Added:
{|CREATE INDEX IF NOT EXISTS feedback_by_trainee
133
Added:
ON feedback (trainee_id, seq DESC)|};
134
Added:
{|CREATE TABLE IF NOT EXISTS app_feedback (
135
Added:
trainee_id TEXT NOT NULL,
136
Added:
seq INTEGER NOT NULL,
137
Added:
submitted_at TEXT NOT NULL,
138
Added:
message TEXT NOT NULL,
139
Added:
PRIMARY KEY (trainee_id, seq)
140
Added:
)|};
141
Added:
{|CREATE INDEX IF NOT EXISTS app_feedback_by_trainee
142
Added:
ON app_feedback (trainee_id, seq DESC)|};
143
Added:
{|CREATE TABLE IF NOT EXISTS app_feedback_vote (
144
Added:
feedback_trainee_id TEXT NOT NULL,
145
Added:
feedback_seq INTEGER NOT NULL,
146
Added:
voter_id TEXT NOT NULL,
147
Added:
PRIMARY KEY (feedback_trainee_id, feedback_seq, voter_id)
148
Added:
)|};
149
Added:
{|CREATE INDEX IF NOT EXISTS app_feedback_vote_by_voter
150
Added:
ON app_feedback_vote (voter_id)|};
151
Added:
];
152
Added:
};
153
Added:
{
154
Added:
version = 6;
155
Added:
name = "five-point feedback scores";
156
Added:
(* Subjective feedback now encodes levels as scores 1..5, so rows written
157
Added:
with the old below/usual/above codes no longer decode. Feedback is
158
Added:
observed, not authored, so the domain permits dropping it rather than
159
Added:
migrating it. *)
160
Added:
statements =
161
Added:
[
162
Added:
{|DROP TABLE IF EXISTS feedback|};
163
Added:
{|CREATE TABLE IF NOT EXISTS feedback (
164
Added:
trainee_id TEXT NOT NULL,
165
Added:
seq INTEGER NOT NULL,
166
Added:
encoded TEXT NOT NULL,
167
Added:
PRIMARY KEY (trainee_id, seq)
168
Added:
)|};
169
Added:
{|CREATE INDEX IF NOT EXISTS feedback_by_trainee
170
Added:
ON feedback (trainee_id, seq DESC)|};
171
Added:
];
172
Added:
};
173
Added:
{
174
Added:
version = 7;
175
Added:
name = "workout import batches and promoted records";
176
Added:
(* An import batch is provisional: it holds the raw export source and the
177
Added:
cleaning done so far, encoded as one snapshot blob. A promoted record
178
Added:
is canonical evidence with an independent lifecycle, so it carries no
179
Added:
foreign key to its batch — deleting the batch must never cascade to the
180
Added:
record. The batch's [imported_at] is stored as a column so listing can
181
Added:
order without decoding every blob. A record is keyed by (trainee,
182
Added:
workout) so a re-promotion replaces it. *)
183
Added:
statements =
184
Added:
[
185
Added:
{|CREATE TABLE IF NOT EXISTS import_batch (
186
Added:
trainee_id TEXT NOT NULL,
187
Added:
batch_id TEXT NOT NULL,
188
Added:
imported_at TEXT NOT NULL,
189
Added:
source TEXT NOT NULL,
190
Added:
fingerprint TEXT NOT NULL,
191
Added:
snapshot TEXT NOT NULL,
192
Added:
PRIMARY KEY (trainee_id, batch_id)
193
Added:
)|};
194
Added:
{|CREATE INDEX IF NOT EXISTS import_batch_by_trainee
195
Added:
ON import_batch (trainee_id, imported_at DESC, batch_id DESC)|};
196
Added:
{|CREATE TABLE IF NOT EXISTS imported_record (
197
Added:
trainee_id TEXT NOT NULL,
198
Added:
workout_id TEXT NOT NULL,
199
Added:
batch_id TEXT NOT NULL,
200
Added:
revision INTEGER NOT NULL,
201
Added:
title TEXT NOT NULL,
202
Added:
started_at TEXT NOT NULL,
203
Added:
ended_at TEXT NOT NULL,
204
Added:
snapshot TEXT NOT NULL,
205
Added:
PRIMARY KEY (trainee_id, workout_id)
206
Added:
)|};
207
Added:
{|CREATE INDEX IF NOT EXISTS imported_record_by_trainee
208
Added:
ON imported_record (trainee_id, ended_at DESC)|};
209
Added:
];
210
Added:
};
211
Added:
]
lib/persistence/migrations.ml
@@ -1,205 +1,16 @@
1
Removed:
type migration = { version : int; name : string; statements : string list }
1
Added:
(* Applies the versioned schema migrations. The migrations themselves — the SQL
2
Added:
and the ledger table — live in {!Migration_data}; this module holds only the
3
Added:
logic that runs them: the ledger check that makes applying idempotent, and
4
Added:
the per-migration transaction. *)
2
5
3
Removed:
let migrations =
4
Removed:
[
5
Removed:
{
6
Removed:
version = 1;
7
Removed:
name = "initial schema";
8
Removed:
statements =
9
Removed:
[
10
Removed:
{|CREATE TABLE IF NOT EXISTS trainee (
11
Removed:
id TEXT PRIMARY KEY,
12
Removed:
username TEXT NOT NULL UNIQUE,
13
Removed:
credential TEXT NOT NULL
14
Removed:
)|};
15
Removed:
{|CREATE TABLE IF NOT EXISTS active_routine (
16
Removed:
trainee_id TEXT PRIMARY KEY,
17
Removed:
routine_id TEXT NOT NULL
18
Removed:
)|};
19
Removed:
{|CREATE TABLE IF NOT EXISTS in_progress (
20
Removed:
trainee_id TEXT PRIMARY KEY,
21
Removed:
encoded TEXT NOT NULL
22
Removed:
)|};
23
Removed:
{|CREATE TABLE IF NOT EXISTS workout (
24
Removed:
id TEXT PRIMARY KEY,
25
Removed:
trainee_id TEXT NOT NULL,
26
Removed:
seq INTEGER NOT NULL,
27
Removed:
encoded TEXT NOT NULL
28
Removed:
)|};
29
Removed:
{|CREATE INDEX IF NOT EXISTS workout_by_trainee
30
Removed:
ON workout (trainee_id, seq DESC)|};
31
Removed:
(* The table Dream's sql_sessions back end expects. *)
32
Removed:
{|CREATE TABLE IF NOT EXISTS dream_session (
33
Removed:
id TEXT PRIMARY KEY,
34
Removed:
label TEXT NOT NULL,
35
Removed:
expires_at REAL NOT NULL,
36
Removed:
payload TEXT NOT NULL
37
Removed:
)|};
38
Removed:
];
39
Removed:
};
40
Removed:
{
41
Removed:
version = 2;
42
Removed:
name = "subjective feedback";
43
Removed:
statements =
44
Removed:
[
45
Removed:
{|CREATE TABLE IF NOT EXISTS feedback (
46
Removed:
trainee_id TEXT NOT NULL,
47
Removed:
seq INTEGER NOT NULL,
48
Removed:
encoded TEXT NOT NULL,
49
Removed:
PRIMARY KEY (trainee_id, seq)
50
Removed:
)|};
51
Removed:
{|CREATE INDEX IF NOT EXISTS feedback_by_trainee
52
Removed:
ON feedback (trainee_id, seq DESC)|};
53
Removed:
];
54
Removed:
};
55
Removed:
{
56
Removed:
version = 3;
57
Removed:
name = "application feedback";
58
Removed:
statements =
59
Removed:
[
60
Removed:
{|CREATE TABLE IF NOT EXISTS app_feedback (
61
Removed:
trainee_id TEXT NOT NULL,
62
Removed:
seq INTEGER NOT NULL,
63
Removed:
submitted_at TEXT NOT NULL,
64
Removed:
message TEXT NOT NULL,
65
Removed:
PRIMARY KEY (trainee_id, seq)
66
Removed:
)|};
67
Removed:
{|CREATE INDEX IF NOT EXISTS app_feedback_by_trainee
68
Removed:
ON app_feedback (trainee_id, seq DESC)|};
69
Removed:
];
70
Removed:
};
71
Removed:
{
72
Removed:
version = 4;
73
Removed:
name = "application feedback votes";
74
Removed:
statements =
75
Removed:
[
76
Removed:
{|CREATE TABLE IF NOT EXISTS app_feedback_vote (
77
Removed:
feedback_trainee_id TEXT NOT NULL,
78
Removed:
feedback_seq INTEGER NOT NULL,
79
Removed:
voter_id TEXT NOT NULL,
80
Removed:
PRIMARY KEY (feedback_trainee_id, feedback_seq, voter_id)
81
Removed:
)|};
82
Removed:
{|CREATE INDEX IF NOT EXISTS app_feedback_vote_by_voter
83
Removed:
ON app_feedback_vote (voter_id)|};
84
Removed:
];
85
Removed:
};
86
Removed:
{
87
Removed:
version = 5;
88
Removed:
name = "ISO 8601 UTC timestamps";
89
Removed:
statements =
90
Removed:
[
91
Removed:
{|DROP TABLE IF EXISTS app_feedback_vote|};
92
Removed:
{|DROP TABLE IF EXISTS app_feedback|};
93
Removed:
{|DROP TABLE IF EXISTS feedback|};
94
Removed:
{|DROP TABLE IF EXISTS workout|};
95
Removed:
{|DROP TABLE IF EXISTS in_progress|};
96
Removed:
{|CREATE TABLE IF NOT EXISTS in_progress (
97
Removed:
trainee_id TEXT PRIMARY KEY,
98
Removed:
encoded TEXT NOT NULL
99
Removed:
)|};
100
Removed:
{|CREATE TABLE IF NOT EXISTS workout (
101
Removed:
id TEXT PRIMARY KEY,
102
Removed:
trainee_id TEXT NOT NULL,
103
Removed:
seq INTEGER NOT NULL,
104
Removed:
encoded TEXT NOT NULL
105
Removed:
)|};
106
Removed:
{|CREATE INDEX IF NOT EXISTS workout_by_trainee
107
Removed:
ON workout (trainee_id, seq DESC)|};
108
Removed:
{|CREATE TABLE IF NOT EXISTS feedback (
109
Removed:
trainee_id TEXT NOT NULL,
110
Removed:
seq INTEGER NOT NULL,
111
Removed:
encoded TEXT NOT NULL,
112
Removed:
PRIMARY KEY (trainee_id, seq)
113
Removed:
)|};
114
Removed:
{|CREATE INDEX IF NOT EXISTS feedback_by_trainee
115
Removed:
ON feedback (trainee_id, seq DESC)|};
116
Removed:
{|CREATE TABLE IF NOT EXISTS app_feedback (
117
Removed:
trainee_id TEXT NOT NULL,
118
Removed:
seq INTEGER NOT NULL,
119
Removed:
submitted_at TEXT NOT NULL,
120
Removed:
message TEXT NOT NULL,
121
Removed:
PRIMARY KEY (trainee_id, seq)
122
Removed:
)|};
123
Removed:
{|CREATE INDEX IF NOT EXISTS app_feedback_by_trainee
124
Removed:
ON app_feedback (trainee_id, seq DESC)|};
125
Removed:
{|CREATE TABLE IF NOT EXISTS app_feedback_vote (
126
Removed:
feedback_trainee_id TEXT NOT NULL,
127
Removed:
feedback_seq INTEGER NOT NULL,
128
Removed:
voter_id TEXT NOT NULL,
129
Removed:
PRIMARY KEY (feedback_trainee_id, feedback_seq, voter_id)
130
Removed:
)|};
131
Removed:
{|CREATE INDEX IF NOT EXISTS app_feedback_vote_by_voter
132
Removed:
ON app_feedback_vote (voter_id)|};
133
Removed:
];
134
Removed:
};
135
Removed:
{
136
Removed:
version = 6;
137
Removed:
name = "five-point feedback scores";
138
Removed:
(* Subjective feedback now encodes levels as scores 1..5, so rows written
139
Removed:
with the old below/usual/above codes no longer decode. Feedback is
140
Removed:
observed, not authored, so the domain permits dropping it rather than
141
Removed:
migrating it. *)
142
Removed:
statements =
143
Removed:
[
144
Removed:
{|DROP TABLE IF EXISTS feedback|};
145
Removed:
{|CREATE TABLE IF NOT EXISTS feedback (
146
Removed:
trainee_id TEXT NOT NULL,
147
Removed:
seq INTEGER NOT NULL,
148
Removed:
encoded TEXT NOT NULL,
149
Removed:
PRIMARY KEY (trainee_id, seq)
150
Removed:
)|};
151
Removed:
{|CREATE INDEX IF NOT EXISTS feedback_by_trainee
152
Removed:
ON feedback (trainee_id, seq DESC)|};
153
Removed:
];
154
Removed:
};
155
Removed:
{
156
Removed:
version = 7;
157
Removed:
name = "workout import batches and promoted records";
158
Removed:
(* An import batch is provisional: it holds the raw export source and the
159
Removed:
cleaning done so far, encoded as one snapshot blob. A promoted record
160
Removed:
is canonical evidence with an independent lifecycle, so it carries no
161
Removed:
foreign key to its batch — deleting the batch must never cascade to the
162
Removed:
record. The batch's [imported_at] is stored as a column so listing can
163
Removed:
order without decoding every blob. A record is keyed by (trainee,
164
Removed:
workout) so a re-promotion replaces it. *)
165
Removed:
statements =
166
Removed:
[
167
Removed:
{|CREATE TABLE IF NOT EXISTS import_batch (
168
Removed:
trainee_id TEXT NOT NULL,
169
Removed:
batch_id TEXT NOT NULL,
170
Removed:
imported_at TEXT NOT NULL,
171
Removed:
source TEXT NOT NULL,
172
Removed:
fingerprint TEXT NOT NULL,
173
Removed:
snapshot TEXT NOT NULL,
174
Removed:
PRIMARY KEY (trainee_id, batch_id)
175
Removed:
)|};
176
Removed:
{|CREATE INDEX IF NOT EXISTS import_batch_by_trainee
177
Removed:
ON import_batch (trainee_id, imported_at DESC, batch_id DESC)|};
178
Removed:
{|CREATE TABLE IF NOT EXISTS imported_record (
179
Removed:
trainee_id TEXT NOT NULL,
180
Removed:
workout_id TEXT NOT NULL,
181
Removed:
batch_id TEXT NOT NULL,
182
Removed:
revision INTEGER NOT NULL,
183
Removed:
title TEXT NOT NULL,
184
Removed:
started_at TEXT NOT NULL,
185
Removed:
ended_at TEXT NOT NULL,
186
Removed:
snapshot TEXT NOT NULL,
187
Removed:
PRIMARY KEY (trainee_id, workout_id)
188
Removed:
)|};
189
Removed:
{|CREATE INDEX IF NOT EXISTS imported_record_by_trainee
190
Removed:
ON imported_record (trainee_id, ended_at DESC)|};
191
Removed:
];
192
Removed:
};
193
Removed:
]
6
Added:
type migration = Migration_data.migration = {
7
Added:
version : int;
8
Added:
name : string;
9
Added:
statements : string list;
10
Added:
}
194
11
195
Removed:
(* The ledger of applied migrations. A row per version, so a reconnect knows
196
Removed:
what the file already carries and skips it. *)
197
Removed:
let ledger =
198
Removed:
{|CREATE TABLE IF NOT EXISTS schema_migrations (
199
Removed:
version INTEGER PRIMARY KEY,
200
Removed:
name TEXT NOT NULL,
201
Removed:
applied_at REAL NOT NULL
202
Removed:
)|}
12
Added:
let migrations = Migration_data.migrations
13
Added:
let ledger = Migration_data.ledger
203
14
204
15
let statements =
205
16
List.concat_map (fun migration -> migration.statements) migrations