feat(types)!: drop the blob column type (ADR-0005 Amendment 2)
blob was a dead-end: declarable but never fillable (no literal in either mode, seed-unsupported), so a blob column could only ever hold NULL. Remove Type::Blob + Value/CellValue::Blob + the base64 CSV path + the grammar slot + completion/render/type-change/seed handling + the binder refusal (whose message also carried a user-facing "DSL" copy-rule bug). The vocabulary is now nine types; base64 stays (clipboard OSC-52). Backward compat is the ADR-0015 migration framework's first real use: a v1->v2 format bump (CURRENT_SCHEMA_VERSION across serializer/parser/skeleton) with a migrator that rewrites `type: blob` -> `type: text`, and a forced .db rebuild from the migrated text when a blob column was actually converted (the stale .db keeps a STRICT BLOB engine column). Conversion to text is non-destructive and CSV-free. Covered by a full-stack integration test and a Tier-4 PTY test that opens a real legacy v1-blob project. Also sweeps the nine-type vocabulary through CLAUDE.md, requirements.md, the website (type reference, seed doc, highlight grammar), and the ADR-0030/0033/ 0035 cross-references; CHANGELOG Removed entry; handoff-79. BREAKING CHANGE: the `blob` column type is removed. Existing projects that declared a blob column are migrated on first open (the column becomes text; the original project.yaml is kept as a .v1.bak).
This commit is contained in:
+104
-16
@@ -44,6 +44,35 @@ use serde::Deserialize;
|
||||
/// migrator's job is purely the format transformation.
|
||||
pub type MigrateFn = fn(&str) -> Result<String, MigrateError>;
|
||||
|
||||
/// The newest `project.yaml` format version this build writes and reads.
|
||||
///
|
||||
/// Must equal `MigratorRegistry::production().latest_version()` (asserted
|
||||
/// in tests) and is what the YAML serializer stamps and the parser
|
||||
/// accepts. Bumped 1 → 2 by **ADR-0005 Amendment 2** (drop `blob`).
|
||||
pub const CURRENT_SCHEMA_VERSION: u32 = 2;
|
||||
|
||||
/// v1 → v2 migrator (ADR-0005 Amendment 2). `blob` was dropped from the
|
||||
/// type vocabulary, so any `type: blob` column is rewritten to
|
||||
/// `type: text` (the chosen conversion target — see the ADR). A pure,
|
||||
/// line-oriented text transform: only column-definition lines (the
|
||||
/// `- { name: …, type: … }` shape the serializer emits) are touched, so a
|
||||
/// `blob` substring inside a CHECK expression or a default value is left
|
||||
/// alone. Also bumps the `version:` field to 2 (the framework asserts the
|
||||
/// advertised version, so this must stay in step).
|
||||
fn migrate_v1_to_v2(body: &str) -> Result<String, MigrateError> {
|
||||
let mut out = String::with_capacity(body.len() + 1);
|
||||
for line in body.split_inclusive('\n') {
|
||||
if line.trim_end() == "version: 1" {
|
||||
out.push_str(&line.replacen("version: 1", "version: 2", 1));
|
||||
} else if line.contains("{ name:") {
|
||||
out.push_str(&line.replace(", type: blob", ", type: text"));
|
||||
} else {
|
||||
out.push_str(line);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Ordered list of migrators. `migrators[i]` runs from
|
||||
/// version `i + 1` to version `i + 2` (so index 0 is v1→v2,
|
||||
/// index 1 is v2→v3, etc.).
|
||||
@@ -56,13 +85,12 @@ pub struct MigratorRegistry {
|
||||
}
|
||||
|
||||
impl MigratorRegistry {
|
||||
/// Production-default registry: empty. As new versions
|
||||
/// land, register the migrators here in source-version
|
||||
/// order.
|
||||
/// Production-default registry. Migrators are listed in
|
||||
/// source-version order (index 0 = v1→v2, …).
|
||||
#[must_use]
|
||||
pub const fn production() -> Self {
|
||||
pub fn production() -> Self {
|
||||
Self {
|
||||
migrators: Vec::new(),
|
||||
migrators: vec![migrate_v1_to_v2],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +184,20 @@ pub struct MigrationOutcome {
|
||||
pub migrated_from: Option<u32>,
|
||||
}
|
||||
|
||||
/// Whether `body` declares at least one `type: blob` column.
|
||||
///
|
||||
/// The runtime uses this as the signal to force a `.db` rebuild after the
|
||||
/// v1→v2 migration: the migration rewrites the YAML, but the derived
|
||||
/// `.db` keeps a `STRICT … BLOB` engine column + `"blob"` metadata that
|
||||
/// load otherwise uses as-is (ADR-0005 Amendment 2). Scoped to
|
||||
/// column-definition lines, mirroring [`migrate_v1_to_v2`], so a `blob`
|
||||
/// substring inside a CHECK expression or default doesn't trip it.
|
||||
#[must_use]
|
||||
pub fn body_declares_blob_column(body: &str) -> bool {
|
||||
body.lines()
|
||||
.any(|l| l.contains("{ name:") && l.contains(", type: blob"))
|
||||
}
|
||||
|
||||
/// Detect the version of `body` and migrate it to the
|
||||
/// registry's `latest_version()`.
|
||||
///
|
||||
@@ -304,22 +346,68 @@ mod tests {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A project at the current (latest) version — nothing to migrate.
|
||||
fn v2_body() -> String {
|
||||
"version: 2\nproject:\n created_at: 2026-01-01T00:00:00Z\ntables: []\nrelationships: []\n"
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn production_registry_latest_version_is_1() {
|
||||
fn production_registry_latest_version_matches_current_schema_version() {
|
||||
let r = MigratorRegistry::production();
|
||||
assert_eq!(r.latest_version(), 1);
|
||||
assert_eq!(r.latest_version(), CURRENT_SCHEMA_VERSION);
|
||||
assert_eq!(r.latest_version(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_migration_runs_when_body_already_latest() {
|
||||
let tmp = tempdir();
|
||||
let outcome =
|
||||
migrate_to_latest(&v1_body(), &MigratorRegistry::production(), tmp.path()).unwrap();
|
||||
assert_eq!(outcome.body, v1_body());
|
||||
migrate_to_latest(&v2_body(), &MigratorRegistry::production(), tmp.path()).unwrap();
|
||||
assert_eq!(outcome.body, v2_body());
|
||||
assert_eq!(outcome.migrated_from, None);
|
||||
// No .bak written when nothing migrated.
|
||||
let bak = tmp.path().join("project.yaml.v1.bak");
|
||||
assert!(!bak.exists(), "no .bak when no migration");
|
||||
assert!(
|
||||
!tmp.path().join("project.yaml.v2.bak").exists(),
|
||||
"no .bak when no migration"
|
||||
);
|
||||
}
|
||||
|
||||
/// ADR-0005 Amendment 2: the real production v1→v2 migrator rewrites a
|
||||
/// `blob` column to `text` and bumps the version, leaving everything
|
||||
/// else (incl. a column literally named `blob`, and a `blob` substring
|
||||
/// inside a CHECK) untouched.
|
||||
#[test]
|
||||
fn production_v1_to_v2_converts_blob_columns_to_text() {
|
||||
let tmp = tempdir();
|
||||
let body = concat!(
|
||||
"version: 1\n",
|
||||
"project:\n created_at: x\n",
|
||||
"tables:\n",
|
||||
" - name: Files\n",
|
||||
" primary_key: [id]\n",
|
||||
" columns:\n",
|
||||
" - { name: id, type: serial }\n",
|
||||
" - { name: payload, type: blob }\n",
|
||||
" - { name: blob, type: text }\n",
|
||||
" check_constraints:\n",
|
||||
" - \"note <> 'type: blob'\"\n",
|
||||
"relationships: []\n",
|
||||
);
|
||||
let outcome =
|
||||
migrate_to_latest(body, &MigratorRegistry::production(), tmp.path()).unwrap();
|
||||
assert_eq!(outcome.migrated_from, Some(1));
|
||||
assert!(outcome.body.contains("version: 2"));
|
||||
// The blob column became text.
|
||||
assert!(
|
||||
outcome.body.contains("{ name: payload, type: text }"),
|
||||
"blob column converted: {}",
|
||||
outcome.body
|
||||
);
|
||||
// A column NAMED blob is untouched; the CHECK string is untouched.
|
||||
assert!(outcome.body.contains("{ name: blob, type: text }"));
|
||||
assert!(outcome.body.contains("note <> 'type: blob'"));
|
||||
assert!(!outcome.body.contains("type: blob }"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -332,7 +420,7 @@ mod tests {
|
||||
err,
|
||||
MigrateError::NewerThanSupported {
|
||||
file: 99,
|
||||
latest: 1
|
||||
latest: 2
|
||||
}
|
||||
),
|
||||
"got: {err:?}",
|
||||
@@ -393,17 +481,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_yaml_migrated_no_op_on_v1_with_empty_registry() {
|
||||
fn ensure_yaml_migrated_no_op_when_already_latest() {
|
||||
let tmp = tempdir();
|
||||
let yaml_path = tmp.path().join("project.yaml");
|
||||
std::fs::write(&yaml_path, v1_body()).unwrap();
|
||||
std::fs::write(&yaml_path, v2_body()).unwrap();
|
||||
let outcome =
|
||||
ensure_project_yaml_migrated(tmp.path(), &MigratorRegistry::production()).unwrap();
|
||||
assert_eq!(outcome.migrated_from, None);
|
||||
// File unchanged.
|
||||
let on_disk = std::fs::read_to_string(&yaml_path).unwrap();
|
||||
assert_eq!(on_disk, v1_body());
|
||||
assert!(!tmp.path().join("project.yaml.v1.bak").exists());
|
||||
assert_eq!(on_disk, v2_body());
|
||||
assert!(!tmp.path().join("project.yaml.v2.bak").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user