1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
use r2d2_sqlite::SqliteConnectionManager;
use super::config::Schema;
/// A worktree's database.
mod worktree {
refinery::embed_migrations!("migrations");
}
/// The machine's sessions database.
mod sessions {
refinery::embed_migrations!("migrations-sessions");
}
fn runner(schema: Schema) -> refinery::Runner {
match schema {
Schema::Worktree => worktree::migrations::runner(),
Schema::Sessions => sessions::migrations::runner(),
}
}
pub fn run_migrations(
mut conn: r2d2::PooledConnection<SqliteConnectionManager>,
schema: Schema,
) -> anyhow::Result<()> {
runner(schema).run(&mut *conn).map_err(|e| {
// refinery's own words for a database it cannot reconcile name two
// 64-bit checksums and nothing else. The useful fact is which file and
// what to do, in one line -- the schema is in the error it wraps, and
// repeating a thousand lines of DDL at somebody is not a diagnosis.
//
// What deleting it costs is said too, per database: neither is all
// derived, and a person deciding to delete one is deciding about that.
let path = conn.path().unwrap_or("the database");
let lost = match schema {
Schema::Worktree => {
"this worktree's notes, path notes, tasks, features and workbench layout go with it"
}
Schema::Sessions => {
"sessions are read again from the transcripts that still exist, \
sessions you deleted included; stars and pins go with it"
}
};
anyhow::anyhow!(
"{path} does not match this build's schema. \
Deleting it lets wsb make a new one on the next start: {lost}. Cause: {e}"
)
})?;
tracing::debug!("Database migrations completed");
Ok(())
}
#[cfg(test)]
mod tests {
/// TEST12071: the schema applies over one that already exists.
///
/// Every CREATE says IF NOT EXISTS, as the eight migrations this was
/// flattened from did. Dumping `sqlite_master` to build the flattened file
/// dropped that -- SQLite does not store the clause -- and the result was a
/// migration that could only ever run against an empty file.
///
/// Two openers race in the ordinary case: deleting `.wsb/wsb.db` while the
/// daemon is running has it recreate the file immediately, and the next
/// command to open it dies on a table the daemon just made. That is what
/// `rm -rf .wsb/wsb.db && wsb upgrade` did -- it failed with `table
/// workspace_sections already exists` and a thousand lines of DDL.
///
/// Asserted by applying the schema TWICE to one database, because a single
/// application passes either way.
#[test]
fn test12071_the_schema_applies_over_an_existing_one() {
for sql in [
include_str!("../../../migrations/V1__schema.sql"),
include_str!("../../../migrations-sessions/V1__sessions.sql"),
] {
let conn = rusqlite::Connection::open_in_memory().expect("a database");
conn.execute_batch(sql).expect("the first application");
conn.execute_batch(sql).expect(
"applying the schema over one that already exists failed; a second \
opener -- the daemon recreating the file it was just deleted from, \
or any concurrent command -- then dies on a table the first one made",
);
}
}
/// TEST20049: a database an older wsb made is refused, saying what deleting
/// it costs.
///
/// The refusal used to say everything in it was derived. A worktree's
/// database holds its notes and tasks, which nothing rebuilds, and a person
/// told otherwise deletes them believing nothing is lost.
#[test]
fn test20049_an_old_database_is_refused_naming_what_deleting_it_loses() {
let dir = tempfile::tempdir().expect("a directory");
let path = dir.path().join("wsb.db");
let pool = r2d2::Pool::new(r2d2_sqlite::SqliteConnectionManager::file(&path)).expect("a pool");
// What the wsb before this schema applied as its first migration.
let old = refinery::Migration::unapplied("V1__base_schema", "CREATE TABLE notes (id INTEGER);")
.expect("a migration");
refinery::Runner::new(&[old])
.run(&mut *pool.get().expect("a connection"))
.expect("the old schema applies");
let error = super::run_migrations(pool.get().expect("a connection"), super::Schema::Worktree)
.expect_err("a database another schema made was opened")
.to_string();
assert!(error.contains(&path.display().to_string()), "the file is not named: {error}");
for lost in ["notes", "tasks", "workbench layout"] {
assert!(error.contains(lost), "deleting it loses its {lost}, and it does not say so: {error}");
}
}
/// TEST8925: an applied migration is never edited again.
///
/// refinery records a checksum of each migration's text when it applies it,
/// and refuses to run against a database whose recorded checksum no longer
/// matches the file — every existing database stops opening, with an error
/// that names the checksum and nothing else. That is exactly what happened
/// here: V1 had been edited after it was applied, and the live database
/// could not be opened until the two were reconciled by hand.
///
/// So the checksums are pinned, for both databases. Editing a migration
/// that has shipped fails here, at the moment of the edit, with somewhere
/// to go: add the next migration file instead. Adding a new one is expected
/// and only needs its checksum recorded below.
#[test]
fn test8925_shipped_migrations_are_frozen() {
// version, name, checksum — as recorded when the migration first ran.
//
// The worktree's V1 replaced the `V1__base_schema` wsb shipped before
// it took sdx's schema. A database that applied that one is refused by
// `run_migrations`, which says to delete it and have it rebuilt.
assert_frozen(
super::Schema::Worktree,
&[
(1, "schema", "16186640057407429720"),
(2, "github_mirror_moved", "1355168889716370572"),
(3, "sessions_moved", "12725709392405332032"),
],
);
assert_frozen(super::Schema::Sessions, &[(1, "sessions", "12586225742379135953")]);
}
fn assert_frozen(schema: super::Schema, frozen: &[(u32, &str, &str)]) {
let mut applied: Vec<(u32, String, String)> = super::runner(schema)
.get_migrations()
.iter()
.map(|m| (m.version(), m.name().to_string(), m.checksum().to_string()))
.collect();
// Embedded order is not version order.
applied.sort_by_key(|(version, ..)| *version);
assert_eq!(
applied.len(),
frozen.len(),
"{schema:?}: the migration set changed: {:?} on disk, {:?} pinned. A NEW \
migration is fine — add its (version, name, checksum) to `frozen`. A \
migration that disappeared is not: every database that ran it still \
records it.",
applied.iter().map(|(v, n, c)| (*v, n.as_str(), c.as_str())).collect::<Vec<_>>(),
frozen.iter().map(|(v, n, _)| (*v, *n)).collect::<Vec<_>>(),
);
for ((version, name, checksum), (want_version, want_name, want_checksum)) in
applied.iter().zip(frozen)
{
assert_eq!(version, want_version, "{schema:?}: migration order changed");
assert_eq!(name, want_name, "{schema:?}: V{version} was renamed");
assert_eq!(
checksum, want_checksum,
"{schema:?}: V{version}__{name}.sql was edited after it shipped. Every \
database that already applied it refuses to open against the new text. \
Revert the edit and put the change in the next migration file."
);
}
}
}