1use crate::{
4 error::SchemaError,
5 migration::{current_migration, migration_is_usable},
6};
7use sqlx::{FromRow, Row, SqliteConnection, SqlitePool, query, query_as, query_scalar};
8
9#[derive(Debug, FromRow)]
10struct SchemaMarker {
11 schema_version: i64,
12 minimum_crate_major: i64,
13 minimum_crate_minor: i64,
14 minimum_crate_patch: i64,
15 rolling_compatible: i64,
16}
17
18pub async fn check_schema(pool: &SqlitePool) -> Result<(), SchemaError> {
25 let mut connection = pool
26 .acquire()
27 .await
28 .map_err(|source| SchemaError::sql("acquire schema-check connection", source))?;
29 check_schema_connection(&mut connection).await
30}
31
32pub(crate) async fn check_schema_connection(
34 connection: &mut SqliteConnection,
35) -> Result<(), SchemaError> {
36 let enabled: i64 = query_scalar("PRAGMA foreign_keys")
37 .fetch_one(&mut *connection)
38 .await
39 .map_err(|source| SchemaError::sql("check foreign-key enforcement", source))?;
40 if enabled != 1 {
41 return Err(mismatch("foreign-key enforcement is disabled"));
42 }
43
44 let migration = current_migration().map_err(mismatch)?;
45 migration_is_usable(migration).map_err(mismatch)?;
46 check_schema_marker(connection, migration).await?;
47
48 check_columns(
49 connection,
50 "dovecote_events",
51 &[
52 ColumnSpec::required("row_id", "INTEGER", true),
53 ColumnSpec::required("tenant_id", "TEXT", false),
54 ColumnSpec::required("stream", "TEXT", false),
55 ColumnSpec::required("specversion", "TEXT", false),
56 ColumnSpec::required("event_id", "TEXT", false),
57 ColumnSpec::required("source", "TEXT", false),
58 ColumnSpec::required("event_type", "TEXT", false),
59 ColumnSpec::optional("subject", "TEXT", false),
60 ColumnSpec::optional("occurred_at", "TEXT", false),
61 ColumnSpec::optional("datacontenttype", "TEXT", false),
62 ColumnSpec::optional("dataschema", "TEXT", false),
63 ColumnSpec::optional("partitionkey", "TEXT", false),
64 ColumnSpec::required("extensions", "TEXT", false),
65 ColumnSpec::optional("data_kind", "TEXT", false),
66 ColumnSpec::optional("data", "BLOB", false),
67 ColumnSpec::required("enqueued_at", "TEXT", false),
68 ],
69 )
70 .await?;
71 check_columns(
72 connection,
73 "dovecote_deliveries",
74 &[
75 ColumnSpec::required("event_row_id", "INTEGER", true),
76 ColumnSpec::required("tenant_id", "TEXT", false),
77 ColumnSpec::required("state", "TEXT", false),
78 ColumnSpec::required("available_at", "TEXT", false),
79 ColumnSpec::required("attempts", "INTEGER", false),
80 ColumnSpec::optional("claim_token", "BLOB", false),
81 ColumnSpec::optional("claimed_by", "TEXT", false),
82 ColumnSpec::optional("claim_expires_at", "TEXT", false),
83 ColumnSpec::optional("last_failure_code", "TEXT", false),
84 ColumnSpec::optional("last_failure_detail", "TEXT", false),
85 ColumnSpec::optional("delivered_at", "TEXT", false),
86 ColumnSpec::optional("quarantined_at", "TEXT", false),
87 ColumnSpec::optional("quarantine_reason", "TEXT", false),
88 ],
89 )
90 .await?;
91
92 let sources = query_as::<_, TableSource>(
93 "SELECT name, sql FROM sqlite_master WHERE type = 'table' AND name IN ('dovecote_schema', 'dovecote_events', 'dovecote_deliveries')",
94 ).fetch_all(&mut *connection).await
95 .map_err(|source| SchemaError::sql("read schema definitions", source))?;
96 for name in ["dovecote_schema", "dovecote_events", "dovecote_deliveries"] {
97 if !sources.iter().any(|source| source.name == name) {
98 return Err(mismatch(format!("required table {name} is missing")));
99 }
100 }
101
102 for name in ["dovecote_schema", "dovecote_events", "dovecote_deliveries"] {
103 let source = sources
104 .iter()
105 .find(|source| source.name == name)
106 .expect("checked above");
107 let expected = expected_table_source(migration.sql(), name).map_err(mismatch)?;
108 if normalize_sql(&source.sql) != normalize_sql(&expected) {
109 return Err(mismatch(format!(
110 "table {name} definition is incompatible with schema version {}",
111 migration.version()
112 )));
113 }
114 }
115
116 let extra_objects: Vec<SchemaObject> = query_as(
121 "SELECT type, name, COALESCE(tbl_name, '') AS tbl_name FROM sqlite_master WHERE (name LIKE 'dovecote_%' OR tbl_name IN ('dovecote_events', 'dovecote_deliveries')) AND NOT (type = 'table' AND name IN ('dovecote_schema', 'dovecote_events', 'dovecote_deliveries')) AND NOT (type = 'index' AND name IN ('dovecote_events_tenant_source_event_id', 'dovecote_events_tenant_row', 'dovecote_deliveries_claimable', 'dovecote_deliveries_expired_claims', 'sqlite_autoindex_dovecote_events_1')) UNION ALL SELECT type, name, COALESCE(tbl_name, '') AS tbl_name FROM sqlite_temp_master WHERE name LIKE 'dovecote_%' OR tbl_name IN ('dovecote_events', 'dovecote_deliveries')",
122 )
123 .fetch_all(&mut *connection)
124 .await
125 .map_err(|source| SchemaError::sql("check schema object isolation", source))?;
126 if let Some(object) = extra_objects.first() {
127 return Err(mismatch(format!(
128 "unsupported SQLite schema object {} {} on {}",
129 object.object_type, object.name, object.table_name
130 )));
131 }
132
133 check_index(
134 connection,
135 "dovecote_events",
136 "dovecote_events_tenant_source_event_id",
137 true,
138 &["tenant_id", "source", "event_id"],
139 migration.sql(),
140 )
141 .await?;
142 check_index(
143 connection,
144 "dovecote_events",
145 "dovecote_events_tenant_row",
146 false,
147 &["tenant_id", "row_id"],
148 migration.sql(),
149 )
150 .await?;
151 check_index(
152 connection,
153 "dovecote_deliveries",
154 "dovecote_deliveries_claimable",
155 false,
156 &["tenant_id", "state", "available_at", "event_row_id"],
157 migration.sql(),
158 )
159 .await?;
160 check_index(
161 connection,
162 "dovecote_deliveries",
163 "dovecote_deliveries_expired_claims",
164 false,
165 &["tenant_id", "state", "claim_expires_at", "event_row_id"],
166 migration.sql(),
167 )
168 .await?;
169 check_foreign_key(connection).await?;
170 let violations = query("PRAGMA foreign_key_check")
171 .fetch_all(&mut *connection)
172 .await
173 .map_err(|source| SchemaError::sql("check foreign-key integrity", source))?;
174 if !violations.is_empty() {
175 return Err(mismatch("installed schema contains foreign-key violations"));
176 }
177 Ok(())
178}
179
180async fn check_schema_marker(
181 connection: &mut SqliteConnection,
182 migration: crate::migration::Migration,
183) -> Result<(), SchemaError> {
184 let markers = query_as::<_, SchemaMarker>(
185 "SELECT schema_version, minimum_crate_major, minimum_crate_minor, minimum_crate_patch, rolling_compatible FROM dovecote_schema",
186 )
187 .fetch_all(&mut *connection)
188 .await
189 .map_err(|source| SchemaError::sql("check schema marker", source))?;
190 if markers.len() != 1 {
191 return Err(mismatch(format!(
192 "expected exactly one schema marker row, found {}",
193 markers.len()
194 )));
195 }
196
197 let marker = &markers[0];
198 let minimum = migration.compatibility().minimum();
199 if marker.schema_version != i64::from(migration.version())
200 || marker.minimum_crate_major != i64::from(minimum.major())
201 || marker.minimum_crate_minor != i64::from(minimum.minor())
202 || marker.minimum_crate_patch != i64::from(minimum.patch())
203 || marker.rolling_compatible != i64::from(migration.rolling_compatible())
204 {
205 return Err(mismatch("schema marker is incompatible with this adapter"));
206 }
207 Ok(())
208}
209
210fn mismatch(detail: impl Into<String>) -> SchemaError {
211 SchemaError::MigrationMismatch {
212 detail: detail.into(),
213 }
214}
215
216#[derive(Clone, Copy)]
217struct ColumnSpec {
218 name: &'static str,
219 kind: &'static str,
220 primary_key: bool,
221 not_null: bool,
222}
223impl ColumnSpec {
224 const fn required(name: &'static str, kind: &'static str, primary_key: bool) -> Self {
225 Self {
226 name,
227 kind,
228 primary_key,
229 not_null: !primary_key,
230 }
231 }
232 const fn optional(name: &'static str, kind: &'static str, primary_key: bool) -> Self {
233 Self {
234 name,
235 kind,
236 primary_key,
237 not_null: false,
238 }
239 }
240}
241
242#[derive(Debug, FromRow)]
243struct TableSource {
244 name: String,
245 sql: String,
246}
247
248#[derive(Debug, FromRow)]
249struct SchemaObject {
250 #[sqlx(rename = "type")]
251 object_type: String,
252 name: String,
253 #[sqlx(rename = "tbl_name")]
254 table_name: String,
255}
256
257async fn check_columns(
258 connection: &mut SqliteConnection,
259 table: &str,
260 expected: &[ColumnSpec],
261) -> Result<(), SchemaError> {
262 let sql = sqlx::AssertSqlSafe(format!("PRAGMA table_info({table})"));
263 let rows = query(sql)
264 .fetch_all(&mut *connection)
265 .await
266 .map_err(|source| SchemaError::sql("check table columns", source))?;
267 for spec in expected {
268 let Some(row) = rows
269 .iter()
270 .find(|row| row.try_get::<String, _>("name").ok().as_deref() == Some(spec.name))
271 else {
272 return Err(mismatch(format!(
273 "required column {table}.{} is missing",
274 spec.name
275 )));
276 };
277
278 let kind = row
279 .try_get::<String, _>("type")
280 .map_err(|_| mismatch(format!("column {table}.{} has no type", spec.name)))?;
281 if !kind.eq_ignore_ascii_case(spec.kind) {
282 return Err(mismatch(format!(
283 "column {table}.{} has type {kind}, expected {}",
284 spec.name, spec.kind
285 )));
286 }
287
288 let pk = row.try_get::<i64, _>("pk").unwrap_or_default();
289 if spec.primary_key && pk != 1 {
290 return Err(mismatch(format!(
291 "column {table}.{} is not the primary key",
292 spec.name
293 )));
294 }
295
296 let not_null = row.try_get::<i64, _>("notnull").unwrap_or_default() != 0;
297 if spec.not_null && !not_null {
298 return Err(mismatch(format!(
299 "column {table}.{} must be NOT NULL",
300 spec.name
301 )));
302 }
303 }
304
305 Ok(())
306}
307
308async fn check_index(
309 connection: &mut SqliteConnection,
310 table: &str,
311 expected_name: &str,
312 unique: bool,
313 columns: &[&str],
314 migration: &str,
315) -> Result<(), SchemaError> {
316 let sql = sqlx::AssertSqlSafe(format!("PRAGMA index_list({table})"));
317 let indexes = query(sql)
318 .fetch_all(&mut *connection)
319 .await
320 .map_err(|source| SchemaError::sql("check schema indexes", source))?;
321 let Some(index) = indexes
322 .iter()
323 .find(|row| row.try_get::<String, _>("name").ok().as_deref() == Some(expected_name))
324 else {
325 return Err(mismatch(format!(
326 "required index {expected_name} is missing"
327 )));
328 };
329
330 let actual_unique = index.try_get::<i64, _>("unique").unwrap_or_default() != 0;
331 if actual_unique != unique {
332 return Err(mismatch(format!(
333 "index {expected_name} uniqueness is incompatible"
334 )));
335 }
336
337 let source: Option<String> =
338 query_scalar("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?")
339 .bind(expected_name)
340 .fetch_optional(&mut *connection)
341 .await
342 .map_err(|source| SchemaError::sql("read schema index definition", source))?;
343 let Some(source) = source else {
344 return Err(mismatch(format!("index {expected_name} has no definition")));
345 };
346
347 let expected = expected_index_source(migration, expected_name).map_err(mismatch)?;
348 if normalize_sql(&source) != normalize_sql(&expected) {
349 return Err(mismatch(format!(
350 "index {expected_name} definition is incompatible"
351 )));
352 }
353
354 let info_sql = sqlx::AssertSqlSafe(format!("PRAGMA index_info({expected_name})"));
355 let info = query(info_sql)
356 .fetch_all(&mut *connection)
357 .await
358 .map_err(|source| SchemaError::sql("read schema index columns", source))?;
359 let actual = info
360 .iter()
361 .filter_map(|row| row.try_get::<String, _>("name").ok())
362 .collect::<Vec<_>>();
363 if actual
364 != columns
365 .iter()
366 .map(|column| (*column).to_owned())
367 .collect::<Vec<_>>()
368 {
369 return Err(mismatch(format!(
370 "index {expected_name} columns are incompatible"
371 )));
372 }
373
374 if expected_name == "dovecote_events_tenant_source_event_id" {
375 let xinfo_sql = sqlx::AssertSqlSafe(format!("PRAGMA index_xinfo({expected_name})"));
376 let xinfo = query(xinfo_sql)
377 .fetch_all(&mut *connection)
378 .await
379 .map_err(|source| SchemaError::sql("read identity index collation", source))?;
380 let collations = xinfo
381 .iter()
382 .filter_map(|row| row.try_get::<i64, _>("key").ok().filter(|key| *key != 0))
383 .zip(
384 xinfo
385 .iter()
386 .filter_map(|row| row.try_get::<String, _>("coll").ok()),
387 )
388 .map(|(_, collation)| collation)
389 .collect::<Vec<_>>();
390 if collations
391 != [
392 "BINARY".to_owned(),
393 "BINARY".to_owned(),
394 "BINARY".to_owned(),
395 ]
396 {
397 return Err(mismatch("identity index collation is not BINARY"));
398 }
399 }
400
401 Ok(())
402}
403
404async fn check_foreign_key(connection: &mut SqliteConnection) -> Result<(), SchemaError> {
405 let rows = query("PRAGMA foreign_key_list(dovecote_deliveries)")
406 .fetch_all(&mut *connection)
407 .await
408 .map_err(|source| SchemaError::sql("check delivery foreign key", source))?;
409 let matching = rows
410 .iter()
411 .filter(|row| row.try_get::<String, _>("table").ok().as_deref() == Some("dovecote_events"))
412 .collect::<Vec<_>>();
413 if matching.len() != 2 {
414 return Err(mismatch("delivery foreign key is missing"));
415 }
416
417 let columns = matching
418 .iter()
419 .map(|row| {
420 (
421 row.try_get::<String, _>("from").unwrap_or_default(),
422 row.try_get::<String, _>("to").unwrap_or_default(),
423 )
424 })
425 .collect::<Vec<_>>();
426 if !columns.contains(&("tenant_id".to_owned(), "tenant_id".to_owned()))
427 || !columns.contains(&("event_row_id".to_owned(), "row_id".to_owned()))
428 {
429 return Err(mismatch("delivery foreign key is incompatible"));
430 }
431 Ok(())
432}
433
434fn expected_table_source(migration: &str, table: &str) -> Result<String, String> {
435 let needle = format!("CREATE TABLE {table}");
436 let start = migration
437 .find(&needle)
438 .ok_or_else(|| format!("migration does not define {table}"))?;
439 let mut depth = 0_u32;
440 let mut quoted = false;
441 for (offset, character) in migration[start..].char_indices() {
442 match character {
443 '\'' => quoted = !quoted,
444 '(' if !quoted => depth = depth.saturating_add(1),
445 ')' if !quoted => depth = depth.saturating_sub(1),
446 ';' if !quoted && depth == 0 => return Ok(migration[start..start + offset].to_owned()),
447 _ => {}
448 }
449 }
450 Err(format!("migration statement for {table} is unterminated"))
451}
452
453fn expected_index_source(migration: &str, index: &str) -> Result<String, String> {
454 let needle = if migration.contains(&format!("CREATE UNIQUE INDEX {index}")) {
455 format!("CREATE UNIQUE INDEX {index}")
456 } else {
457 format!("CREATE INDEX {index}")
458 };
459 let start = migration
460 .find(&needle)
461 .ok_or_else(|| format!("migration does not define {index}"))?;
462 let end = migration[start..]
463 .find(';')
464 .map(|offset| start + offset)
465 .ok_or_else(|| format!("migration statement for {index} is unterminated"))?;
466 Ok(migration[start..end].to_owned())
467}
468
469fn normalize_sql(value: &str) -> String {
470 let mut normalized = String::with_capacity(value.len());
471 let mut in_string = false;
472 for character in value.chars() {
473 match (character, in_string) {
474 ('\'', _) => {
475 in_string = !in_string;
476 normalized.push(character);
477 }
478 ('"', false) => {
479 }
482 (character, true) if !character.is_ascii_whitespace() => {
483 normalized.push(character);
484 }
485 (character, false) if !character.is_ascii_whitespace() => {
486 normalized.extend(character.to_lowercase());
487 }
488 _ => {}
489 }
490 }
491 normalized
492}