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.

Commit
831317cad7348cd1007d0087b1d119d840ff1313
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
Changed files
lib/persistence/migration_data.ml
index 00000000..990913a4 000000..100644
@@ -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
index 51e44e41..2c1588e8 100644..100644
@@ -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