feat(types)!: drop the blob column type (ADR-0005 Amendment 2)
ci / gate (push) Failing after 35s
ci / manifests (push) Successful in 3s
website / deploy (push) Successful in 35s

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:
claude@clouddev1
2026-06-22 21:25:38 +00:00
parent 1a2002dbf6
commit 6b4c4dcea4
41 changed files with 506 additions and 390 deletions
+7 -53
View File
@@ -3169,7 +3169,10 @@ fn row_value_to_cell(row: &rusqlite::Row<'_>, idx: usize) -> Result<CellValue, D
ValueRef::Integer(n) => CellValue::Integer(n),
ValueRef::Real(f) => CellValue::Real(f),
ValueRef::Text(bytes) => CellValue::Text(String::from_utf8_lossy(bytes).into_owned()),
ValueRef::Blob(bytes) => CellValue::Blob(bytes.to_vec()),
// `blob` was removed from the vocabulary (ADR-0005 Amendment 2), so no
// column is BLOB-typed any more and a blob value-ref is not expected.
// Surface any stray bytes as lossy text rather than panicking.
ValueRef::Blob(bytes) => CellValue::Text(String::from_utf8_lossy(bytes).into_owned()),
})
}
@@ -3627,11 +3630,6 @@ fn do_drop_table(
/// rows receive a generated value (1..N for serial, fresh
/// shortids for shortid) and the column gains UNIQUE +
/// NOT NULL.
///
/// `blob` is statically refused as a column-add target by the
/// existing infrastructure (no DSL literal, downstream INSERT
/// would fail), but the path itself is allowed via the plain
/// branch — same as today.
fn do_add_column(
conn: &Connection,
persistence: Option<&Persistence>,
@@ -5043,7 +5041,7 @@ fn do_rename_table(
///
/// 1. Target is `serial` (auto-increment is create-table-only).
/// 2. Source ↔ target is statically refused per the matrix
/// (same-type, blob, date↔datetime, undefined cross-domain).
/// (same-type, date↔datetime, undefined cross-domain).
/// 3. Column is the *child* side of any relationship (outbound
/// FK) — drop the relationship first.
/// 4. Column has any inbound FK (parent side) and the new type
@@ -8641,9 +8639,7 @@ fn sample_parent_key_tuples(
///
/// Foreign-key columns are filled by sampling existing parent rows
/// (D14); a compound FK reads all its child columns from one sampled
/// parent row. An empty parent is refused with a friendly error. A
/// `NOT NULL blob` column (which seed cannot generate) is refused by
/// the block guard (D1); a nullable blob is omitted (→ NULL).
/// parent row. An empty parent is refused with a friendly error.
///
/// **Phase 2 (SD2):** when `target_column` is `Some`, this delegates to
/// [`do_seed_column_fill`] (fill one column across existing rows, D1
@@ -8719,17 +8715,6 @@ fn do_seed(
if matches!(ty, Type::Serial) {
continue;
}
// blob has no DSL value path: refuse if required (D1), else omit.
if matches!(ty, Type::Blob) {
if c.notnull {
return Err(DbError::Unsupported(format!(
"cannot seed `{table}`: column `{}` is `NOT NULL` but has type `blob`, \
which seed cannot generate. Add the rows another way or make it nullable.",
c.name,
)));
}
continue;
}
col_names.push(c.name.clone());
if let Some(&(fk_idx, pos)) = fk_child_pos.get(c.name.as_str()) {
plans.push(SeedColPlan::ForeignKey { fk_idx, pos });
@@ -9122,7 +9107,7 @@ fn seed_override_literal(value: &Value, column: &str) -> Result<String, DbError>
/// Column-fill (ADR-0048 D1 form 2): fill one column across the table's
/// **existing** rows (an UPDATE), the natural follow-up to `add column`.
///
/// Refuses PK and auto-generated (`serial`/`shortid`/`blob`) targets;
/// Refuses PK and auto-generated (`serial`/`shortid`) targets;
/// an empty table is a friendly no-op. The `set` clause may only adjust
/// the column being filled (the rest of the per-column heuristics do not
/// apply — there is exactly one column). A UNIQUE / identifier target
@@ -9178,12 +9163,6 @@ fn do_seed_column_fill(
ty.keyword(),
)));
}
if matches!(ty, Type::Blob) {
return Err(DbError::Unsupported(format!(
"cannot fill `{table}.{canonical_col}`: seed cannot generate `blob` values."
)));
}
// The `set` clause may only adjust the filled column (user decision).
for ov in overrides {
if !ov.column.eq_ignore_ascii_case(&canonical_col) {
@@ -11230,7 +11209,6 @@ fn cell_value_to_sqlite(cell: &CellValue) -> rusqlite::types::Value {
CellValue::Integer(n) => Value::Integer(*n),
CellValue::Real(f) => Value::Real(*f),
CellValue::Text(s) => Value::Text(s.clone()),
CellValue::Blob(b) => Value::Blob(b.clone()),
}
}
@@ -12912,30 +12890,6 @@ mod tests {
}
}
#[tokio::test]
async fn change_column_type_blob_target_refused_statically() {
let db = db();
make_id_table(&db, "T").await;
db.add_column(
"T".to_string(),
ColumnSpec::new("Note".to_string(), Type::Text),
None,
)
.await
.unwrap();
let err = db
.change_column_type(
"T".to_string(),
"Note".to_string(),
Type::Blob,
ChangeColumnMode::Default,
None,
)
.await
.unwrap_err();
assert!(matches!(err, DbError::Unsupported(_)), "got {err:?}");
}
#[tokio::test]
async fn change_column_type_outbound_fk_refused() {
// Child-side FK column: §4.2 says always refuse,