Files
rdbms-playground/docs/adr/0005-column-type-vocabulary.md
T
claude@clouddev1 1a2002dbf6 docs(adr-0005): Amendment 2 — drop blob from the type vocabulary
blob is a dead-end type: no literal in either mode, seed-unsupported, so
a blob column can only ever hold NULL. It rode along as the engine's
fourth storage class and never got the pedagogical scrutiny UUIDs
(excluded) and compound PKs (defended) received — and there's nothing
natural to do with binary data in a TUI teaching tool. Drop it → a
nine-type vocabulary.

Records the removal scope (Type::Blob + Value/CellValue::Blob + the
base64 CSV path + completion/grammar slot + the help/hint user-facing
strings + the binder refusal, whose message also carried a "DSL"
copy-rule bug subsumed by deletion) and the backward-compat migration:
the ADR-0015 framework's first real use — a v1→v2 migrator converts
type: blob → text and forces a .db rebuild from the migrated text.
convert-to-text chosen over drop-the-column (CSV rewrites) and
refuse-to-load. Updates the ADR index.
2026-06-22 20:00:53 +00:00

14 KiB
Raw Blame History

ADR-0005: Column type vocabulary

Status

Accepted

Context

Real RDBMS engines expose many type variants that exist for historical, performance, or platform reasons. A learner does not benefit from picking between VARCHAR(255), TEXT, CHAR(40), and CLOB. We control the user-facing surface and can present a small, semantically clear set of types that maps cleanly to the chosen backend (SQLite STRICT, ADR-0002).

We also want to teach two distinct lessons about identifiers:

  1. The default, easiest path: a simple auto-incrementing integer primary key. Used in 90% of intro examples.
  2. Why integers aren't always the right answer: short random identifiers that survive merging data sets, sharing, or migration without collisions.

Real UUIDs (36 characters) are too wide to display comfortably in TUI columns and exceed what learners actually need to understand the concept.

Decision

The user-facing column type vocabulary is:

User-facing type SQLite STRICT mapping Notes
text TEXT Strings of any length.
int INTEGER Plain integer.
real REAL IEEE-754 double.
decimal TEXT Stored as decimal string; rendered numeric.
bool INTEGER 0/1 internally; true/false rendered.
date TEXT ISO 8601 (YYYY-MM-DD).
datetime TEXT ISO 8601 (YYYY-MM-DDTHH:MM:SS[.fff][Z]).
blob BLOB Binary data.
serial INTEGER PK AUTOINC. Auto-incrementing integer; PK by default.
shortid TEXT 1012 char base58 random; PK by default.

shortid uses base58 (no ambiguous 0/O/I/l) and is generated client-side at insert time when the column has no value supplied.

Decimal is stored as text to preserve precision — applications that need numeric comparison must use the engine's casts; this is acceptable for a teaching context and the constraint is documented.

Compound primary keys are supported. They are essential for junction tables in m:n relationships (e.g. OrderLines keyed on (order_id, product_id)) and skipping them would teach the wrong lesson. The simplified DSL provides natural syntax for them (specifics in a later ADR).

True UUIDs are intentionally not in the type set.

Consequences

  • The type system is small enough to teach in five minutes.
  • Mapping to SQLite STRICT is mechanical and lossless for the intended use cases.
  • The shortid generator is a small, well-tested utility — bounded scope, no third-party dependency required.
  • Junction tables and other compound-key scenarios are first-class, reinforcing relational fundamentals.
  • Learners who later need a true UUID column will find that the app does not provide one; this is a deliberate trade-off in favour of TUI legibility.

Amendment 1 — display rounding of coerced doubles (2026-06-12)

Issue #32. The Decision keeps decimal exact by storing it as TEXT, noting that "numeric ops require casts" — the engine has no native decimal/BCD type (SQLite's storage classes are only NULL / INTEGER / REAL / TEXT / BLOB; NUMERIC is an affinity, not a type). What the original wording did not anticipate is that the engine performs that cast implicitly: sum(price * qty) over TEXT decimals coerces to an IEEE-754 double with no explicit cast, and the computed result carries no playground type (ADR-0030 §6), so it rendered with the double's full noise — 298.59999999999997 for 298.60. For a teaching tool that is a confusing, off-topic lesson about float representation.

Decision

Round floating-point values to 15 significant figures for display only. A double carries ~1517 significant decimal digits and the noise lives in the last one or two; rounding to 15 then taking the shortest round-tripping form of the rounded value collapses 298.59999999999997298.6 and 0.300000000000000040.3. A clean value rounds to itself, so the result is never longer than before; non-finite values pass through. Implemented as format_real_display in db.rs.

The rounding is wired into exactly one place — format_cell, the result-set / show data cell formatter — because that is the only surface where the IEEE-754 noise actually appears: noise arises from arithmetic/aggregation, whose results flow through format_cell. Every other f64-to-string path deliberately keeps full precision, and the distinction is semantic, not cosmetic:

  • Persistence stays exact. The CSV encoder (persistence::csv_io::format_real) keeps the shortest round-tripping form so a stored real survives save/load byte-for-byte — rounding there would corrupt data.
  • Uniqueness dry-runs key on exact values. render_value (the diagnostic/echo formatter) is reused as a canonical identity key by dry_run_unique (ADR-0029 §5) and check_uniqueness_collisions (ADR-0017 §4.3): they group rows by this string to predict the duplicates the engine would reject. Rounding there would merge two distinct doubles into one key and report a collision the engine — which compares exact values — would not. So render_value keeps format!("{r}"). (It also never displays a computed value, so it has no noise to trim.)
  • FK-key matching and EXPLAIN-SQL literals keep full precision — neither is a data-cell display.

Within format_cell the rounding applies to all REAL cells (stored real columns and computed results alike), for one consistent rule; the lost digits are at the double's precision limit, not real information, and a stored real typed by the user is itself noise-free so its display is unchanged in practice. Raw decimal columns are unaffected — they are TEXT and render verbatim, trailing zeros and all (100.10). Exact decimal arithmetic (a SQLite extension exposing decimal_mul/decimal_sum) was considered and rejected: it would require rewriting the user's standard-SQL operators into function calls, defeating both the "validated SQL runs verbatim" model and the goal of teaching ordinary SQL.

Amendment 2 — drop blob from the vocabulary (2026-06-22)

This supersedes the blob row of the Decision table. The type set is now nine types: text, int, real, decimal, bool, date, datetime, serial, shortid.

Why blob is removed

The original Decision listed blob purely to mirror the engine's storage classes (its only note was "Binary data."). Unlike the two other vocabulary judgements made here — compound PKs defended ("skipping them would teach the wrong lesson") and true UUIDs excluded ("in favour of TUI legibility") — blob never received the same pedagogical scrutiny. It rode along as the fourth storage class and was never re-examined.

In practice blob is a dead-end type in this application:

  • No value can be entered for it through the command surface. There is no blob literal in either mode — neither the simplified syntax nor advanced SQL has one (ADR-0014: "DSL literal not supported this iteration"; ADR-0038: "blob has no literal … neither the DSL nor advanced SQL has a blob-literal syntax"). The binder refuses outright (src/dsl/value.rs).
  • seed cannot generate it (ADR-0048: blob → unsupported), and a NOT NULL blob column is a hard refusal.
  • So a blob column can only ever hold NULL via normal use. The only way bytes get in at all is hand-editing the base64 cell in a CSV — not something the app teaches or surfaces.

The missing literal was filed as a perpetual "pre-existing gap" across many handoffs and never actioned. The right resolution is not to build a blob-entry affordance — there is nothing natural to do with binary data in a terminal teaching tool, and nothing about the blob type that serves this app's learning goals (pedagogy wins ties; this app is not the place to teach storage-class breadth). Removing it makes the type set smaller (still "teachable in five minutes", now with one less dead-end), removes a standing copy-rule bug (the binder's refusal message named the internal "DSL" term — flagged 2026-06-22), and simplifies the type-change matrix (ADR-0017's ↔ blob static refusals vanish) and the seed generator.

What this changes

  • Type::Blob and its keyword/aliases (blob, binary, varbinary) are removed. Because the variant set is the single source of truth, this clears both parse paths at once — from_sql_name (the alias-aware command path) and FromStr/parse::<Type>() (the keyword-only path the .db metadata read uses) — once the explicit blob arms are deleted (compiler-guided). The type slot offered by completion and the advanced-SQL grammar (BLOB_SLOT) loses blob; output_render/describe lose the BLOB rendering. The data-layer value plumbing it fed (Value::Blob / CellValue::Blob(Vec<u8>) and the base64 CSV encode/decode in persistence::csv_io) is removed with it.
  • User-facing strings lose their blob entries: the help types reference (catalog help.types_reference) drops blob from the ten-name list, and the blob value-slot hint ("Type a quoted blob literal or null", hint.value_slot_blob) — plus its keys.rs declaration and the walker mapping — is removed. The binder's blob refusal in src/dsl/value.rs (whose message named the internal "DSL" term — the copy-rule bug that surfaced this whole question) is deleted outright, so that fix needs no separate rewording.
  • The base64 dependency staysclipboard.rs uses it for OSC-52, independent of blob.
  • ADR-0011 (FK target-type compatibility) and ADR-0017 (type-change compatibility) lose their blob rows; ADR-0014/0038/0048's blob-literal/seed notes become moot. Prose that says "ten-type" (ADR-0030/0033/0035, CLAUDE.md, the website type reference) becomes "nine-type".

Backward compatibility — a project migration

blob was a creatable column type, so a saved project may name it. The type appears in two artifacts, and the migration must reconcile both:

  1. project.yaml (authoritative) — a column declared type: blob. On load this is parsed by from_sql_name, which returns None once the type is gone → the project would fail to open.
  2. The derived playground.db (ADR-0004/0015) — which carries the type twice over: the user-facing string "blob" in the __rdbms_playground_columns metadata table, and an actual STRICT … BLOB column in the SQLite schema. Crucially, load uses an existing .db as-isrebuild_from_text runs only when the .db is missing (runtime.rs, ADR-0015 §7). Worse, the metadata read parses the stored type with parse::<Type>().ok() and falls back to Type::Text on failure — so a stale blob .db would not error; it would silently read the column as text while the engine column stays BLOB: an inconsistent, broken state.

So a YAML-only type-swap is insufficient. The migration is:

  1. Migrate the YAML via the ADR-0015 framework (src/persistence/migrations.rs): register the first real migrator (v1 → v2) that rewrites every type: blob to type: text. This is the framework's first production use — note its by-design consequence: bumping latest_version to 2 re-versions every project on next load (a project.yaml.v1.bak is written, the body rewritten) — harmless for blob-free projects (a pure version bump), but it does touch every file once.
  2. Force a rebuild of the .db from the migrated YAML whenever the migration actually changed a column type (i.e. a blob was present) — reusing the existing rebuild_from_text path so the engine schema and metadata are regenerated as text, not left stale. A pure version bump with no schema change does not force a rebuild.

Conversion target: text (decided 2026-06-22). Non-destructive — the CSV already stores blob cells as base64 text, so any bytes survive as a recoverable string, and an empty column (the overwhelmingly common case) simply becomes an empty text column. It is constraint-safe: text is a valid primary-key and FK-target type (fk_target_type is identity for both), so a former-blob PK or FK target stays valid. And it keeps the migration CSV-free: the column keeps its name and position, so every CSV header still matches the rebuilt schema (the rebuild's strict header==schema check, do_rebuild_from_text) and the existing cells decode as text.

The considered alternatives were rejected: drop the column is cleaner semantically (there is no legitimately-entered content to keep) but the strict header check means it would force the migration to also rewrite every affected data/<table>.csv to strip the column — not worth the machinery for a type that has existed unusably for a few days with very few users; and refuse to load strands the project. The rebuild-on-migration step (above) is required regardless of target, because the stale .db keeps a STRICT … BLOB engine column that can neither hold text nor be reported as anything usable until regenerated.

Consequences

  • A smaller, fully-usable type set: every remaining type can be both created and populated through the command surface.
  • The type-change matrix and seed shed their blob special-cases; the binder sheds its blob refusal (and its copy-rule violation).
  • The ADR-0015 migration framework gets its first real exercise, validating that path end-to-end.
  • A breaking change for the rare project carrying a blob column, mitigated to a graceful, non-destructive load by the migration.
  • Re-introducing blob later (should a concrete teaching use appear) is a new ADR superseding this amendment — the same bar every other type change clears.