# ADR-0012: Internal metadata for user-facing column types

## Status

Accepted

## Context

ADR-0005 commits to ten user-facing types (`text`, `int`, `real`,
`decimal`, `bool`, `date`, `datetime`, `blob`, `serial`,
`shortid`) and to mapping them transparently onto SQLite STRICT
types so that learners do not see SQLite's erased forms (Q3 in
the requirements checklist). The mapping is many-to-few:
`shortid`, `decimal`, `date`, `datetime` all map to `TEXT`;
`serial` and `bool` both map to `INTEGER`.

Reading the schema back via `PRAGMA table_info` therefore loses
the original user-facing type. Rendering "id INTEGER" instead of
"id serial", or "created TEXT" instead of "created datetime",
breaks the Q3 promise even though we generated correct DDL.

The same information is needed by track 2 (project file format,
ADR-0004): when serialising the schema to YAML, we need the
user-facing types, not the SQLite-erased ones.

## Decision

Maintain an internal SQLite table that records the user-facing
type each column was declared with. The table is part of the
database itself, so it travels with the `playground.db` file
and is the single source of truth for round-tripping types.

```sql
CREATE TABLE __rdbms_playground_columns (
    table_name  TEXT NOT NULL,
    column_name TEXT NOT NULL,
    user_type   TEXT NOT NULL,
    PRIMARY KEY (table_name, column_name)
) STRICT;
```

- **Bootstrap.** The table is created on every connection open
  with `CREATE TABLE IF NOT EXISTS`, alongside `PRAGMA
  foreign_keys = ON`.
- **Writes.** `create_table` and `add_column` insert one row per
  user column, atomically with the DDL via
  `Connection::unchecked_transaction`. `drop_table` deletes all
  rows for the dropped table. Every DDL operation that adds or
  removes user columns must keep this table in sync.
- **Reads.** `describe_table` LEFT-JOINs `pragma_table_info`
  with this table to populate `ColumnDescription::user_type`.
  Callers prefer `user_type` for display; `sqlite_type` is
  retained for diagnostics and fallback.
- **Visibility.** `list_tables` filters out any name starting
  with `__rdbms_` so the metadata table is hidden from the user.

### Naming convention for internal tables

All future internal tables use the `__rdbms_` prefix (double
underscore plus `rdbms_`). This serves two purposes:

1. **Avoiding collisions.** SQLite reserves `sqlite_` for its
   own use; we reserve `__rdbms_` for ours. A user cannot
   create a table whose name conflicts with our internals
   without explicitly typing the prefix, which is unlikely by
   accident.
2. **Single filter rule.** `list_tables` and any future
   schema-introspection code excludes names matching
   `sqlite_%` and `__rdbms_%`. New internal tables are
   automatically hidden as long as they follow the convention.

## Consequences

- The Q3 / ADR-0005 transparency promise is delivered. Users
  see `id serial`, not `id INTEGER`.
- The DSL parser, type system, and database layer collectively
  control all user-facing type information; SQLite's erased
  view is never the authoritative source.
- Track 2 (project file format) round-trips user types via
  this metadata. The YAML schema dump reads from
  `__rdbms_playground_columns`; loading reconstructs the
  metadata alongside the DDL.
- DDL operations carry a small additional cost (one transaction
  per operation, one row per user column). Negligible for
  interactive use; would matter only for bulk schema operations,
  which are not in scope.
- Adding new user-column-creating operations must extend this
  metadata in step. The single-source-of-truth invariant is
  worth defending — a future code review should reject any new
  DDL that touches columns without updating
  `__rdbms_playground_columns`.
- The convention `__rdbms_<name>` for internal tables is
  established for any future internal-state needs (snapshot
  bookkeeping, replay log indexing, etc.).

## Alternatives considered

- **In-memory cache in `App`.** Simpler, but doesn't survive
  a restart, and would have to be rebuilt by parsing past
  command history. Loses the single-source-of-truth property.
- **Sentinel comments in DDL.** SQLite's
  `sqlite_master.sql` retains the original CREATE TABLE text;
  we could embed `/* user_type: serial */` markers and parse
  them back. Brittle and hard to keep correct under ALTER.
- **Lossy reverse mapping.** Heuristically guess `serial` from
  `INTEGER PRIMARY KEY`, `text` from `TEXT`, etc. Cannot
  distinguish `text`/`shortid`/`decimal`/`date`/`datetime`,
  all backed by `TEXT`. Wrong by construction.

## See also

- ADR-0002 (database engine, STRICT tables, `foreign_keys` ON)
- ADR-0005 (column type vocabulary, mapping commitment)
- ADR-0010 (database access via dedicated worker thread —
  describes where this metadata is read and written from)
