eidetica/backend/database/sql/
schema.rs1use crate::Result;
23use crate::backend::errors::BackendError;
24
25use super::{SqlxBackend, SqlxResultExt};
26
27pub const SCHEMA_VERSION: i64 = 0;
32
33const CREATE_STORE_STATE_TABLES: &[&str] = &[
34 "CREATE TABLE IF NOT EXISTS store_state_namespaces (
35 namespace_id TEXT PRIMARY KEY NOT NULL,
36 database_id TEXT NOT NULL,
37 store_name TEXT NOT NULL,
38 lifecycle BIGINT NOT NULL,
39 status BIGINT NOT NULL,
40 scope_user_uuid TEXT NOT NULL,
41 projection_name TEXT NOT NULL,
42 projection_version BIGINT NOT NULL,
43 source_key BYTEA NOT NULL,
44 created_revision BIGINT,
45 UNIQUE (database_id, store_name, lifecycle, status, scope_user_uuid,
46 projection_name, projection_version, source_key)
47 )",
48 "CREATE TABLE IF NOT EXISTS store_state_records (
49 namespace_id TEXT NOT NULL,
50 record_key BYTEA NOT NULL,
51 record_value BYTEA,
52 PRIMARY KEY (namespace_id, record_key),
53 -- Dropping a namespace drops its records. SQLite enforces this because
54 -- sqlx sets `PRAGMA foreign_keys = ON` on every connection it opens;
55 -- it is off in a bare sqlite3 session, which makes this look inert.
56 FOREIGN KEY (namespace_id) REFERENCES store_state_namespaces(namespace_id)
57 ON DELETE CASCADE
58 )",
59];
60
61pub const CREATE_TABLES: &[&str] = &[
65 "CREATE TABLE IF NOT EXISTS schema_version (
68 version BIGINT PRIMARY KEY
69 )",
70 "CREATE TABLE IF NOT EXISTS entries (
73 id TEXT PRIMARY KEY NOT NULL,
74 tree_id TEXT NOT NULL,
75 is_root BIGINT NOT NULL DEFAULT 0,
76 verification_status BIGINT NOT NULL DEFAULT 0,
77 height BIGINT NOT NULL DEFAULT 0,
78 entry_cbor BYTEA NOT NULL
79 )",
80 "CREATE TABLE IF NOT EXISTS tree_parents (
83 child_id TEXT NOT NULL,
84 parent_id TEXT NOT NULL,
85 PRIMARY KEY (child_id, parent_id)
86 )",
87 "CREATE TABLE IF NOT EXISTS subtrees (
91 tree_id TEXT NOT NULL,
92 entry_id TEXT NOT NULL,
93 store_name TEXT NOT NULL,
94 height BIGINT NOT NULL,
95 data BLOB,
96 PRIMARY KEY (entry_id, store_name)
97 )",
98 "CREATE TABLE IF NOT EXISTS store_parents (
101 child_id TEXT NOT NULL,
102 parent_id TEXT NOT NULL,
103 store_name TEXT NOT NULL,
104 PRIMARY KEY (child_id, parent_id, store_name)
105 )",
106 "CREATE TABLE IF NOT EXISTS tips (
110 entry_id TEXT NOT NULL,
111 tree_id TEXT NOT NULL,
112 store_name TEXT NOT NULL DEFAULT '',
113 PRIMARY KEY (entry_id, tree_id, store_name)
114 )",
115 "CREATE TABLE IF NOT EXISTS instance_metadata (
119 singleton BIGINT PRIMARY KEY DEFAULT 1 CHECK (singleton = 1),
120 data TEXT NOT NULL
121 )",
122 "CREATE TABLE IF NOT EXISTS instance_secrets (
125 singleton BIGINT PRIMARY KEY DEFAULT 1 CHECK (singleton = 1),
126 data TEXT NOT NULL
127 )",
128];
129
130pub const CREATE_INDEXES: &[&str] = &[
132 "CREATE INDEX IF NOT EXISTS idx_entries_tree_id ON entries(tree_id)",
134 "CREATE INDEX IF NOT EXISTS idx_entries_tree_height ON entries(tree_id, height DESC, id)",
135 "CREATE INDEX IF NOT EXISTS idx_entries_verification ON entries(verification_status)",
136 "CREATE INDEX IF NOT EXISTS idx_entries_is_root ON entries(is_root)",
137 "CREATE INDEX IF NOT EXISTS idx_tree_parents_parent ON tree_parents(parent_id)",
139 "CREATE INDEX IF NOT EXISTS idx_tree_parents_child ON tree_parents(child_id)",
140 "CREATE INDEX IF NOT EXISTS idx_subtrees_tree_store_height ON subtrees(tree_id, store_name, height DESC, entry_id)",
142 "CREATE INDEX IF NOT EXISTS idx_subtrees_store_height ON subtrees(store_name, height DESC, entry_id)",
143 "CREATE INDEX IF NOT EXISTS idx_store_parents_parent ON store_parents(store_name, parent_id)",
144 "CREATE INDEX IF NOT EXISTS idx_store_parents_child ON store_parents(store_name, child_id)",
145 "CREATE INDEX IF NOT EXISTS idx_tips_tree_store ON tips(tree_id, store_name)",
147];
148
149pub async fn initialize(backend: &SqlxBackend) -> Result<()> {
154 let pool = backend.pool();
155
156 let blob_type = if backend.is_sqlite() { "BLOB" } else { "BYTEA" };
158 for statement in CREATE_TABLES {
159 let statement = statement.replace("BLOB", blob_type);
160 sqlx::query(&statement)
161 .execute(pool)
162 .await
163 .sql_context("Schema creation failed")?;
164 }
165
166 let row: Option<(i64,)> = sqlx::query_as("SELECT version FROM schema_version")
168 .fetch_optional(pool)
169 .await
170 .sql_context("Failed to check schema version")?;
171
172 initialize_store_state_tables(backend).await?;
173
174 if row.is_none() {
175 sqlx::query("INSERT INTO schema_version (version) VALUES ($1)")
176 .bind(SCHEMA_VERSION)
177 .execute(pool)
178 .await
179 .sql_context("Failed to initialize schema version")?;
180 } else if let Some((current_version,)) = row
181 && current_version < SCHEMA_VERSION
182 {
183 migrate(backend, current_version, SCHEMA_VERSION).await?;
185 }
186
187 for statement in CREATE_INDEXES {
189 sqlx::query(statement)
190 .execute(pool)
191 .await
192 .sql_context("Index creation failed")?;
193 }
194
195 Ok(())
196}
197
198async fn initialize_store_state_tables(backend: &SqlxBackend) -> Result<()> {
199 let mut tx = backend
200 .pool()
201 .begin()
202 .await
203 .sql_context("Failed to begin schema initialization")?;
204 let blob_type = if backend.is_sqlite() { "BLOB" } else { "BYTEA" };
205 for statement in CREATE_STORE_STATE_TABLES {
206 sqlx::query(&statement.replace("BYTEA", blob_type))
207 .execute(&mut *tx)
208 .await
209 .sql_context("Failed to create Store-state tables")?;
210 }
211 tx.commit()
212 .await
213 .sql_context("Failed to commit Store-state table initialization")
214}
215
216async fn migrate(backend: &SqlxBackend, from: i64, to: i64) -> Result<()> {
225 tracing::info!(from, to, "Starting SQL schema migration");
226
227 let mut current = from;
228 while current < to {
229 let next = current + 1;
230 tracing::info!(from = current, to = next, "Running migration");
231
232 run_migration(backend, current, next).await?;
233
234 tracing::info!(version = next, "Migration completed");
235 current = next;
236 }
237
238 tracing::info!(from, to, "All migrations completed successfully");
239 Ok(())
240}
241
242async fn run_migration(backend: &SqlxBackend, from: i64, to: i64) -> Result<()> {
262 let _ = backend;
263
264 Err(BackendError::SqlxError {
265 reason: format!(
266 "Unknown migration path: v{from} to v{to}. \
267 This likely means SCHEMA_VERSION was incremented without adding a migration."
268 ),
269 source: None,
270 }
271 .into())
272}