feat: publish Dever core with project scaffolding and versioned AI skills
This commit is contained in:
1 parent
70fa73cdc2
commit
ac458ffa7f
960 files changed
+221319
-1163
No files matched your search
@@ -0,0 +1,570 @@
|
||||
#[path = "support/orm_sqlite.rs"]
|
||||
mod orm_sqlite;
|
||||
mod support;
|
||||
|
||||
use dever_runtime::orm::{ErrorKind, Value};
|
||||
use dever_runtime::sqlite::Database;
|
||||
use orm_sqlite::{
|
||||
TestField, bool_default, bytes_field, created_at, database_state, id, index, migration, model,
|
||||
text, text_field,
|
||||
};
|
||||
|
||||
fn text_model(
|
||||
revision: &str,
|
||||
text_maximum: usize,
|
||||
bytes_maximum: usize,
|
||||
) -> dever_runtime::database::Model {
|
||||
model(
|
||||
revision,
|
||||
"seed-empty",
|
||||
vec![
|
||||
id(),
|
||||
text_field("name", text_maximum, false),
|
||||
bytes_field("payload", bytes_maximum, false),
|
||||
created_at(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_database_uses_only_the_final_schema_and_records_real_catalog() {
|
||||
let root = support::temp::TemporaryDirectory::new();
|
||||
let database = Database::open(root.path().join("new.db"), 1, 50).unwrap();
|
||||
let final_model = model(
|
||||
"1000000000000001",
|
||||
"seed-empty",
|
||||
vec![
|
||||
id(),
|
||||
text_field("display_name", 128, false).renamed_from("nickname"),
|
||||
bytes_field("payload", 16, true),
|
||||
bool_default("enabled"),
|
||||
created_at(),
|
||||
],
|
||||
vec![index(
|
||||
"_dever_idx_user_display_name",
|
||||
&["display_name"],
|
||||
true,
|
||||
)],
|
||||
vec![],
|
||||
vec![],
|
||||
);
|
||||
|
||||
database.migrate(final_model).await.unwrap();
|
||||
let state = database_state(&database).await;
|
||||
|
||||
let columns = state
|
||||
.columns
|
||||
.iter()
|
||||
.map(|row| text(row, 0))
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
columns,
|
||||
["id", "display_name", "payload", "enabled", "created_at"]
|
||||
);
|
||||
assert!(!columns.contains(&"nickname"));
|
||||
assert_eq!(state.history.len(), 3);
|
||||
assert!(
|
||||
state
|
||||
.history
|
||||
.iter()
|
||||
.any(|row| { text(row, 0) == "schema" && text(row, 2) == "1000000000000001" })
|
||||
);
|
||||
assert!(state.catalog.iter().any(|row| {
|
||||
text(row, 0) == "table"
|
||||
&& text(row, 2).contains("length(\"display_name\") <= 128")
|
||||
&& text(row, 2).contains("DEFAULT 1")
|
||||
}));
|
||||
assert!(
|
||||
database
|
||||
.query(
|
||||
"SELECT name FROM sqlite_schema WHERE type = 'table' AND name LIKE '_dever_user_%'",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rename_widen_and_add_fields_preserve_existing_data_and_are_idempotent() {
|
||||
let root = support::temp::TemporaryDirectory::new();
|
||||
let database = Database::open(root.path().join("automatic.db"), 1, 50).unwrap();
|
||||
let initial = model(
|
||||
"2000000000000001",
|
||||
"seed-empty",
|
||||
vec![
|
||||
id(),
|
||||
text_field("nickname", 64, false),
|
||||
bytes_field("payload", 8, false),
|
||||
created_at(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
);
|
||||
database.migrate(initial).await.unwrap();
|
||||
database
|
||||
.execute_batch(
|
||||
"INSERT INTO user (nickname, payload) VALUES ('Administrator', X'0102')".into(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let upgraded = || {
|
||||
model(
|
||||
"2000000000000002",
|
||||
"seed-empty",
|
||||
vec![
|
||||
id(),
|
||||
text_field("display_name", 128, false).renamed_from("nickname"),
|
||||
bytes_field("payload", 16, false),
|
||||
text_field("bio", 32, true),
|
||||
bool_default("enabled"),
|
||||
created_at(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
database.migrate(upgraded()).await.unwrap();
|
||||
let once = database_state(&database).await;
|
||||
database.migrate(upgraded()).await.unwrap();
|
||||
assert_eq!(database_state(&database).await, once);
|
||||
|
||||
let rows = database
|
||||
.query(
|
||||
"SELECT display_name, payload, bio, enabled FROM user",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
rows[0].get(0).unwrap(),
|
||||
&Value::Text("Administrator".into())
|
||||
);
|
||||
assert_eq!(rows[0].get(1).unwrap(), &Value::Bytes(vec![1, 2]));
|
||||
assert_eq!(rows[0].get(2).unwrap(), &Value::Null);
|
||||
assert_eq!(rows[0].get(3).unwrap(), &Value::Int(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn text_and_bytes_tightening_succeeds_or_rolls_back_as_one_unit() {
|
||||
let root = support::temp::TemporaryDirectory::new();
|
||||
let valid = Database::open(root.path().join("narrow-valid.db"), 1, 50).unwrap();
|
||||
valid
|
||||
.migrate(text_model("3000000000000001", 16, 8))
|
||||
.await
|
||||
.unwrap();
|
||||
valid
|
||||
.execute_batch("INSERT INTO user (name, payload) VALUES ('short', X'0102')".into())
|
||||
.await
|
||||
.unwrap();
|
||||
valid
|
||||
.migrate(text_model("3000000000000002", 5, 2))
|
||||
.await
|
||||
.unwrap();
|
||||
let valid_state = database_state(&valid).await;
|
||||
assert!(valid_state.catalog.iter().any(|row| {
|
||||
text(row, 0) == "table"
|
||||
&& text(row, 2).contains("length(\"name\") <= 5")
|
||||
&& text(row, 2).contains("length(\"payload\") <= 2")
|
||||
}));
|
||||
|
||||
let invalid = Database::open(root.path().join("narrow-invalid.db"), 1, 50).unwrap();
|
||||
invalid
|
||||
.migrate(text_model("3000000000000001", 16, 8))
|
||||
.await
|
||||
.unwrap();
|
||||
invalid
|
||||
.execute_batch("INSERT INTO user (name, payload) VALUES ('too-long', X'010203')".into())
|
||||
.await
|
||||
.unwrap();
|
||||
let before = database_state(&invalid).await;
|
||||
assert_eq!(
|
||||
invalid
|
||||
.migrate(text_model("3000000000000002", 5, 2))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
ErrorKind::Constraint
|
||||
);
|
||||
assert_eq!(database_state(&invalid).await, before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn not_null_and_unique_changes_validate_existing_rows_before_commit() {
|
||||
let root = support::temp::TemporaryDirectory::new();
|
||||
|
||||
let nullable = |revision: &str, required: bool| {
|
||||
model(
|
||||
revision,
|
||||
"seed-empty",
|
||||
vec![
|
||||
id(),
|
||||
text_field("code", 16, false),
|
||||
text_field("note", 32, !required),
|
||||
created_at(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
let nullable_valid = Database::open(root.path().join("nullable-valid.db"), 1, 50).unwrap();
|
||||
nullable_valid
|
||||
.migrate(nullable("4000000000000001", false))
|
||||
.await
|
||||
.unwrap();
|
||||
nullable_valid
|
||||
.execute_batch("INSERT INTO user (code, note) VALUES ('one', 'value')".into())
|
||||
.await
|
||||
.unwrap();
|
||||
nullable_valid
|
||||
.migrate(nullable("4000000000000002", true))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
orm_sqlite::integer(&database_state(&nullable_valid).await.columns[2], 2),
|
||||
1
|
||||
);
|
||||
|
||||
let nullable_invalid = Database::open(root.path().join("nullable-invalid.db"), 1, 50).unwrap();
|
||||
nullable_invalid
|
||||
.migrate(nullable("4000000000000001", false))
|
||||
.await
|
||||
.unwrap();
|
||||
nullable_invalid
|
||||
.execute_batch("INSERT INTO user (code, note) VALUES ('one', NULL)".into())
|
||||
.await
|
||||
.unwrap();
|
||||
let before = database_state(&nullable_invalid).await;
|
||||
assert_eq!(
|
||||
nullable_invalid
|
||||
.migrate(nullable("4000000000000002", true))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
ErrorKind::Constraint
|
||||
);
|
||||
assert_eq!(database_state(&nullable_invalid).await, before);
|
||||
|
||||
let unique = |revision: &str, enabled: bool| {
|
||||
model(
|
||||
revision,
|
||||
"seed-empty",
|
||||
vec![id(), text_field("code", 16, false), created_at()],
|
||||
enabled
|
||||
.then(|| index("_dever_idx_user_code", &["code"], true))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
let unique_valid = Database::open(root.path().join("unique-valid.db"), 1, 50).unwrap();
|
||||
unique_valid
|
||||
.migrate(unique("4000000000000011", false))
|
||||
.await
|
||||
.unwrap();
|
||||
unique_valid
|
||||
.execute_batch("INSERT INTO user (code) VALUES ('one'), ('two')".into())
|
||||
.await
|
||||
.unwrap();
|
||||
unique_valid
|
||||
.migrate(unique("4000000000000012", true))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
database_state(&unique_valid)
|
||||
.await
|
||||
.catalog
|
||||
.iter()
|
||||
.any(|row| {
|
||||
text(row, 1) == "_dever_idx_user_code"
|
||||
&& text(row, 2).starts_with("CREATE UNIQUE INDEX")
|
||||
})
|
||||
);
|
||||
|
||||
let unique_invalid = Database::open(root.path().join("unique-invalid.db"), 1, 50).unwrap();
|
||||
unique_invalid
|
||||
.migrate(unique("4000000000000011", false))
|
||||
.await
|
||||
.unwrap();
|
||||
unique_invalid
|
||||
.execute_batch("INSERT INTO user (code) VALUES ('same'), ('same')".into())
|
||||
.await
|
||||
.unwrap();
|
||||
let before = database_state(&unique_invalid).await;
|
||||
assert_eq!(
|
||||
unique_invalid
|
||||
.migrate(unique("4000000000000012", true))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
ErrorKind::Constraint
|
||||
);
|
||||
assert_eq!(database_state(&unique_invalid).await, before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn adding_a_foreign_key_accepts_valid_rows_and_rolls_back_orphans() {
|
||||
let root = support::temp::TemporaryDirectory::new();
|
||||
let schema = |revision: &str, foreign_key: bool| {
|
||||
let owner = if foreign_key {
|
||||
TestField::new(
|
||||
"owner_id",
|
||||
"id:app.model.team",
|
||||
false,
|
||||
"\"owner_id\" INTEGER NOT NULL REFERENCES \"team\" (\"id\")",
|
||||
)
|
||||
} else {
|
||||
TestField::new("owner_id", "Int", false, "\"owner_id\" INTEGER NOT NULL")
|
||||
};
|
||||
model(
|
||||
revision,
|
||||
"seed-empty",
|
||||
vec![id(), owner, created_at()],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
|
||||
let valid = Database::open(root.path().join("foreign-key-valid.db"), 1, 50).unwrap();
|
||||
valid
|
||||
.execute_batch(
|
||||
"CREATE TABLE team (id INTEGER PRIMARY KEY); INSERT INTO team (id) VALUES (1)".into(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
valid
|
||||
.migrate(schema("5000000000000001", false))
|
||||
.await
|
||||
.unwrap();
|
||||
valid
|
||||
.execute_batch("INSERT INTO user (owner_id) VALUES (1)".into())
|
||||
.await
|
||||
.unwrap();
|
||||
valid
|
||||
.migrate(schema("5000000000000002", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let foreign_keys = valid
|
||||
.query(
|
||||
"SELECT \"table\", \"from\", \"to\" FROM pragma_foreign_key_list('user')",
|
||||
vec![],
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(foreign_keys.len(), 1);
|
||||
assert_eq!(text(&foreign_keys[0], 0), "team");
|
||||
assert_eq!(text(&foreign_keys[0], 1), "owner_id");
|
||||
assert_eq!(text(&foreign_keys[0], 2), "id");
|
||||
drop(valid);
|
||||
|
||||
let invalid = Database::open(root.path().join("foreign-key-invalid.db"), 1, 50).unwrap();
|
||||
invalid
|
||||
.execute_batch("CREATE TABLE team (id INTEGER PRIMARY KEY)".into())
|
||||
.await
|
||||
.unwrap();
|
||||
invalid
|
||||
.migrate(schema("5000000000000001", false))
|
||||
.await
|
||||
.unwrap();
|
||||
invalid
|
||||
.execute_batch("INSERT INTO user (owner_id) VALUES (99)".into())
|
||||
.await
|
||||
.unwrap();
|
||||
let before = database_state(&invalid).await;
|
||||
assert_eq!(
|
||||
invalid
|
||||
.migrate(schema("5000000000000002", true))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
ErrorKind::Migration
|
||||
);
|
||||
assert_eq!(database_state(&invalid).await, before);
|
||||
drop(invalid);
|
||||
|
||||
let cross_family = Database::open(root.path().join("foreign-key-type.db"), 1, 50).unwrap();
|
||||
cross_family
|
||||
.execute_batch("CREATE TABLE team (id INTEGER PRIMARY KEY)".into())
|
||||
.await
|
||||
.unwrap();
|
||||
cross_family
|
||||
.migrate(model(
|
||||
"5000000000000011",
|
||||
"seed-empty",
|
||||
vec![
|
||||
id(),
|
||||
TestField::new("owner_id", "Float", false, "\"owner_id\" REAL NOT NULL"),
|
||||
created_at(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
cross_family
|
||||
.execute_batch("INSERT INTO user (owner_id) VALUES (1.0)".into())
|
||||
.await
|
||||
.unwrap();
|
||||
let before = database_state(&cross_family).await;
|
||||
assert_eq!(
|
||||
cross_family
|
||||
.migrate(schema("5000000000000012", true))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
ErrorKind::Migration
|
||||
);
|
||||
assert_eq!(database_state(&cross_family).await, before);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn named_drop_runs_once_and_applied_content_cannot_change_or_disappear() {
|
||||
let root = support::temp::TemporaryDirectory::new();
|
||||
let database = Database::open(root.path().join("named-drop.db"), 1, 50).unwrap();
|
||||
let initial = || {
|
||||
model(
|
||||
"6000000000000001",
|
||||
"seed-empty",
|
||||
vec![
|
||||
id(),
|
||||
text_field("code", 16, false),
|
||||
text_field("legacy", 32, true),
|
||||
created_at(),
|
||||
],
|
||||
vec![],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
database.migrate(initial()).await.unwrap();
|
||||
database
|
||||
.execute_batch("INSERT INTO user (code, legacy) VALUES ('one', 'old')".into())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let dropped = |migration_revision: &str, include_migration: bool| {
|
||||
model(
|
||||
"6000000000000002",
|
||||
"seed-empty",
|
||||
vec![id(), text_field("code", 16, false), created_at()],
|
||||
vec![],
|
||||
include_migration
|
||||
.then(|| migration("remove_legacy", migration_revision, &["legacy"]))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
let before_drop = database_state(&database).await;
|
||||
assert_eq!(
|
||||
database
|
||||
.migrate(dropped("remove-legacy-v1", false))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
ErrorKind::Migration
|
||||
);
|
||||
assert_eq!(database_state(&database).await, before_drop);
|
||||
|
||||
database
|
||||
.migrate(dropped("remove-legacy-v1", true))
|
||||
.await
|
||||
.unwrap();
|
||||
let applied = database_state(&database).await;
|
||||
assert_eq!(
|
||||
applied
|
||||
.columns
|
||||
.iter()
|
||||
.map(|row| text(row, 0))
|
||||
.collect::<Vec<_>>(),
|
||||
["id", "code", "created_at"]
|
||||
);
|
||||
assert!(applied.history.iter().any(|row| {
|
||||
text(row, 0) == "migration"
|
||||
&& text(row, 1) == "remove_legacy"
|
||||
&& text(row, 2) == "remove-legacy-v1"
|
||||
}));
|
||||
|
||||
database
|
||||
.migrate(dropped("remove-legacy-v1", true))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(database_state(&database).await, applied);
|
||||
assert_eq!(
|
||||
database
|
||||
.migrate(dropped("remove-legacy-v2", true))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
ErrorKind::Migration
|
||||
);
|
||||
assert_eq!(database_state(&database).await, applied);
|
||||
assert_eq!(
|
||||
database
|
||||
.migrate(dropped("remove-legacy-v1", false))
|
||||
.await
|
||||
.unwrap_err()
|
||||
.kind(),
|
||||
ErrorKind::Migration
|
||||
);
|
||||
assert_eq!(database_state(&database).await, applied);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn catalog_drift_is_rejected_without_rewriting_history_or_data() {
|
||||
let root = support::temp::TemporaryDirectory::new();
|
||||
|
||||
let indexed = || {
|
||||
model(
|
||||
"8000000000000001",
|
||||
"seed-empty",
|
||||
vec![id(), text_field("code", 16, false), created_at()],
|
||||
vec![index("_dever_idx_user_code", &["code"], true)],
|
||||
vec![],
|
||||
vec![],
|
||||
)
|
||||
};
|
||||
let index_drift = Database::open(root.path().join("index-drift.db"), 1, 50).unwrap();
|
||||
index_drift.migrate(indexed()).await.unwrap();
|
||||
index_drift
|
||||
.execute_batch(
|
||||
"INSERT INTO user (code) VALUES ('one'); \
|
||||
DROP INDEX _dever_idx_user_code; \
|
||||
CREATE INDEX _dever_idx_user_code ON user (code)"
|
||||
.into(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let before = database_state(&index_drift).await;
|
||||
assert_eq!(
|
||||
index_drift.migrate(indexed()).await.unwrap_err().kind(),
|
||||
ErrorKind::Migration
|
||||
);
|
||||
assert_eq!(database_state(&index_drift).await, before);
|
||||
|
||||
let table_drift = Database::open(root.path().join("table-drift.db"), 1, 50).unwrap();
|
||||
table_drift.migrate(indexed()).await.unwrap();
|
||||
table_drift
|
||||
.execute_batch(
|
||||
"INSERT INTO user (code) VALUES ('one'); ALTER TABLE user ADD COLUMN rogue TEXT".into(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let before = database_state(&table_drift).await;
|
||||
assert_eq!(
|
||||
table_drift.migrate(indexed()).await.unwrap_err().kind(),
|
||||
ErrorKind::Migration
|
||||
);
|
||||
assert_eq!(database_state(&table_drift).await, before);
|
||||
}
|
||||
Reference in new issue
Block a user