a database layer insipred by caqti and ecto
1# Migrations
2
3Migrations version your database schema, allowing you to evolve it over time in a controlled way.
4
5## Defining Migrations
6
7Each migration has a version number, name, and up/down operations:
8
9```ocaml
10open Repodb
11
12let migration_001 = Migration.migration
13 ~version:1L
14 ~name:"create_users"
15 ~up:[
16 Migration.create_table "users" [
17 Migration.column "id" "BIGSERIAL" ~primary_key:true ~nullable:false;
18 Migration.column "name" "VARCHAR(255)" ~nullable:false;
19 Migration.column "email" "VARCHAR(255)" ~nullable:false ~unique:true;
20 Migration.column "inserted_at" "TIMESTAMPTZ" ~nullable:false ~default:"NOW()";
21 Migration.column "updated_at" "TIMESTAMPTZ" ~nullable:false ~default:"NOW()";
22 ];
23 ]
24 ~down:[
25 Migration.drop_table "users";
26 ]
27```
28
29## Migration Operations
30
31### Create Table
32
33```ocaml
34Migration.create_table "posts" [
35 Migration.column "id" "BIGSERIAL" ~primary_key:true ~nullable:false;
36 Migration.column "author_id" "BIGINT" ~nullable:false
37 ~references:("users", "id");
38 Migration.column "title" "VARCHAR(255)" ~nullable:false;
39 Migration.column "body" "TEXT";
40 Migration.column "published" "BOOLEAN" ~default:"FALSE";
41]
42```
43
44### Drop Table
45
46```ocaml
47Migration.drop_table "posts"
48```
49
50### Alter Table
51
52```ocaml
53Migration.alter_table "users" [
54 Migration.add_column (
55 Migration.column "bio" "TEXT"
56 );
57 Migration.drop_column "legacy_field";
58 Migration.rename_column ~from:"name" ~to_:"full_name";
59]
60```
61
62### Create Index
63
64```ocaml
65(* Simple index *)
66Migration.create_index "posts" ["author_id"]
67
68(* Named index *)
69Migration.create_index "posts" ["author_id"] ~name:"idx_posts_author"
70
71(* Unique index *)
72Migration.create_index "users" ["email"] ~unique:true
73```
74
75### Drop Index
76
77```ocaml
78Migration.drop_index "idx_posts_author"
79```
80
81### Raw SQL
82
83For operations not covered by the DSL:
84
85```ocaml
86Migration.execute "CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\""
87```
88
89## Column Options
90
91```ocaml
92Migration.column "field_name" "SQL_TYPE"
93 ~nullable:false (* NOT NULL constraint *)
94 ~primary_key:true (* PRIMARY KEY constraint *)
95 ~unique:true (* UNIQUE constraint *)
96 ~default:"expression" (* DEFAULT value/expression *)
97 ~references:("table", "column") (* FOREIGN KEY reference *)
98```
99
100## Common Patterns
101
102### Timestamps Helper
103
104```ocaml
105let migration_001 = Migration.migration
106 ~version:1L
107 ~name:"create_users"
108 ~up:[
109 Migration.create_table "users" (
110 [
111 Migration.column "id" "BIGSERIAL" ~primary_key:true ~nullable:false;
112 Migration.column "name" "VARCHAR(255)" ~nullable:false;
113 ]
114 @ Migration.timestamps () (* adds inserted_at and updated_at *)
115 );
116 ]
117 ~down:[Migration.drop_table "users"]
118```
119
120### Foreign Keys
121
122```ocaml
123Migration.column "author_id" "BIGINT" ~nullable:false
124 ~references:("users", "id")
125```
126
127For more control over foreign key behavior, use raw SQL:
128
129```ocaml
130Migration.execute
131 "ALTER TABLE posts ADD CONSTRAINT fk_posts_author
132 FOREIGN KEY (author_id) REFERENCES users(id)
133 ON DELETE CASCADE ON UPDATE CASCADE"
134```
135
136## Generating SQL
137
138Convert operations to SQL strings:
139
140```ocaml
141let sql_statements = Migration.generate_up_sql migration_001
142(* [
143 "CREATE TABLE users (
144 id BIGSERIAL PRIMARY KEY NOT NULL,
145 name VARCHAR(255) NOT NULL,
146 email VARCHAR(255) NOT NULL UNIQUE,
147 ...
148 )";
149 ] *)
150
151let rollback_sql = Migration.generate_down_sql migration_001
152(* ["DROP TABLE users"] *)
153```
154
155## Schema Migrations Table
156
157repodb tracks applied migrations in a `schema_migrations` table:
158
159```ocaml
160(* SQL to create the tracking table *)
161let create_table_sql = Migration.create_schema_migrations_sql
162
163(* The table has: version (BIGINT PK), name (VARCHAR), inserted_at (TIMESTAMPTZ) *)
164```
165
166## Planning Migrations
167
168### Get Pending Migrations
169
170```ocaml
171let all_migrations = [migration_001; migration_002; migration_003]
172let applied_versions = [1L; 2L] (* from schema_migrations table *)
173
174let pending = Migration.pending_migrations ~applied_versions all_migrations
175(* [migration_003] *)
176```
177
178### Plan Migration Run
179
180```ocaml
181(* Plan to run all pending *)
182let actions = Migration.plan_migrate ~applied_versions ~target:None all_migrations
183
184(* Plan to run up to specific version *)
185let actions = Migration.plan_migrate ~applied_versions ~target:(Some 5L) all_migrations
186```
187
188### Plan Rollback
189
190```ocaml
191(* Rollback last migration *)
192let actions = Migration.plan_rollback ~applied_versions ~step:None all_migrations
193
194(* Rollback last 3 migrations *)
195let actions = Migration.plan_rollback ~applied_versions ~step:(Some 3) all_migrations
196```
197
198### Execute Plan
199
200```ocaml
201let sql_list = Migration.actions_to_sql actions
202(* Execute each SQL statement in order *)
203```
204
205## Migration Status
206
207```ocaml
208let status = Migration.format_status ~applied_versions ~migrations:all_migrations
209(*
210 Applied migrations:
211 [✓] 1: create_users
212 [✓] 2: create_posts
213
214 Pending migrations:
215 [ ] 3: add_comments
216 [ ] 4: add_tags
217*)
218```
219
220## Complete Migration Runner Example
221
222```ocaml
223let run_migrations conn migrations =
224 (* Ensure schema_migrations table exists *)
225 let create_table_sql = Migration.create_schema_migrations_sql in
226 match Repo.exec conn create_table_sql ~params:[||] with
227 | Error _ -> () (* Table might already exist *)
228 | Ok () -> ();
229
230 (* Get applied versions *)
231 match Repo.query conn Migration.get_applied_versions_sql ~params:[||] with
232 | Error e -> Error e
233 | Ok rows ->
234 let applied_versions = List.map (fun row ->
235 Driver.row_int64 row 0
236 ) rows in
237
238 (* Plan and run *)
239 let actions = Migration.plan_migrate
240 ~applied_versions
241 ~target:None
242 migrations
243 in
244
245 Repo.transaction conn (fun conn ->
246 let rec run_actions = function
247 | [] -> Ok ()
248 | action :: rest ->
249 let sqls = Migration.action_to_sql action in
250 let rec run_sqls = function
251 | [] -> run_actions rest
252 | sql :: more ->
253 match Repo.exec conn sql ~params:[||] with
254 | Error e -> Error e
255 | Ok () -> run_sqls more
256 in
257 run_sqls sqls
258 in
259 run_actions actions
260 )
261
262let rollback_last conn migrations =
263 match Repo.query conn Migration.get_applied_versions_sql ~params:[||] with
264 | Error e -> Error e
265 | Ok rows ->
266 let applied_versions = List.map (fun row ->
267 Driver.row_int64 row 0
268 ) rows in
269
270 let actions = Migration.plan_rollback
271 ~applied_versions
272 ~step:(Some 1)
273 migrations
274 in
275
276 Repo.transaction conn (fun conn ->
277 let sqls = Migration.actions_to_sql actions in
278 let rec run = function
279 | [] -> Ok ()
280 | sql :: rest ->
281 match Repo.exec conn sql ~params:[||] with
282 | Error e -> Error e
283 | Ok () -> run rest
284 in
285 run sqls
286 )
287```
288
289## Best Practices
290
2911. **Version numbers**: Use timestamps or sequential integers. Timestamps prevent conflicts in teams.
292
2932. **Always provide down**: Every migration should be reversible when possible.
294
2953. **Keep migrations small**: One logical change per migration.
296
2974. **Don't modify old migrations**: Once applied to production, create new migrations for changes.
298
2995. **Test rollbacks**: Verify `down` migrations work correctly.
300
3016. **Data migrations**: For data changes, use `Migration.execute` with INSERT/UPDATE statements.
302
303## Next Steps
304
305- [Schemas](schemas.md) - Defining schemas in code
306- [Repo](repo.md) - Database operations
307- [Getting Started](getting-started.md) - Basic setup