Purpose & framing

Twill DB speaks a deliberately small, hand-parsed SQL subset (spec 02; user-facing surface in docs/sql-reference). The request behind this spec — "map every SQL that works in PostgreSQL and SQLite to Twill, then specify how to implement it" — is answered in two halves:

  1. The mapping (§"Feature mapping"): every major area of the PostgreSQL and SQLite grammar, classified against Twill's current engine and given a mapping strategy. "Every SQL" is, taken literally, unbounded (PostgreSQL alone is hundreds of statement forms, thousands of built-in functions, and an extensible type system); this spec is exhaustive at the level of language area and representative-but-not-line-complete at the level of individual built-in functions, which it handles by policy rather than enumeration.
  2. The implementation (§"Implementation spec"): a staged plan (Phase 6A–6E) that grows the frontend to cover the common, OLTP-shaped majority of that surface, with the AST / module / test changes for each stage and the invariants every stage must preserve.

Twill is an OLTP engine with a pluggable storage seam, not a PostgreSQL re-implementation. The goal of Phase 6 is "the SQL an application actually issues against an OLTP database parses and runs the same on Twill", not byte-for-byte dialect parity. Where PostgreSQL and SQLite disagree (typing, quoting, boolean handling), Twill picks one documented behaviour and shims the other on the dialect boundary (server mode reports as PostgreSQL; the embedded path leans SQLite).

Invariants this spec must not break

The mapping and every implementation stage are bound by the project's load-bearing rules (.claude/rules/storage-seam.md, rust.md):

  • The Storage trait does not change. SQL surface is entirely a frontend concern: lexer/parser (sql.rs), AST, executor (exec.rs), and the row/value model (value.rs, store.rs). No new feature here may add a method to trait Storage or leak a backend concept into it. STORAGE_TRAIT_VERSION stays 2, the C1–C8 conformance suite stays green, branching/scale-to-zero are unaffected.
  • The C ABI stays frozen unless a symbol is genuinely needed. SQL flows through the existing engine_prepare/engine_exec/result-set path; new statement forms and richer result shapes do not add C symbols. ENGINE_ABI_VERSION stays 3 unless a stage introduces a new export or changes an existing export's contract (called out per stage).
  • MVCC, single-writer, group commit, durability are untouched. New read shapes (joins, grouping, subqueries) run against the same snapshot; new write shapes (RETURNING, upsert, INSERT … SELECT) emit the same WalOp batch and publish at the same commit LSN. No statement may ack before durable.
  • Reject, never mis-parse. Anything still unsupported after a stage must return ENGINE_ERR_SQL with a clear message — never silently ignored or mis-executed (the existing contract; cf. OR REPLACE being parsed-and-ignored is itself a documented hazard this spec tightens).

Status legend

MarkMeaning
✅ SupportedParses and executes today, with the documented semantics.
◑ PartialA subset works; notable limits called out.
↻ Map by rewriteNot native, but expressible in the supported subset by a mechanical client- or shim-side rewrite (no engine change required).
⊕ ProposedOut today; targeted by a named Phase 6 stage below.
✗ Out of scopeDeliberately excluded (OLAP-shaped, server-admin, or extensibility surface that conflicts with the engine's design); kept rejected.

Feature mapping

Data types & storage classes

Twill stores five dynamic storage classes — Value::{Null,Int(i64),Real(f64),Text,Blob,Vector} (value.rs) — with SQLite-style name-based affinity (ColumnType::from_sql). Declared column types are advisory affinities, not hard constraints (the SQLite model), except vector(N), whose dimension is enforced. The map below is therefore "declared type → affinity bucket".

PostgreSQL / SQLite typeTwill classStatusNotes
SMALLINT/INT/INTEGER/BIGINT/INT2/4/8, SQLite INTEGERInteger (i64)Any name containing INT. All integers are 64-bit; SMALLINT range is not separately enforced.
REAL/DOUBLE PRECISION/FLOAT/NUMERIC(p,s)/DECIMALReal (f64)NUMERIC/DECIMAL map to f64no exact-decimal arithmetic. The (p,s) suffix is parsed and ignored. Exact numerics are ✗ (see non-goals).
TEXT/VARCHAR(n)/CHAR(n)/CLOB/STRINGText (UTF-8)(n) length is parsed and ignored (SQLite semantics); no length enforcement.
BYTEA / SQLite BLOBBlobRendered base64 over the string-only C ABI; bound back via the same.
BOOLEAN / true/falseInteger 0/1Literals true/false parse to 1/0. No distinct boolean class; server mode must report the column as bool for round-trip — ⊕ 6E (dialect shim).
vector(N) (pgvector)VectorPhase 5. Dimension declared and enforced; literals [1,2,3] / '[1,2,3]'.
DATE/TIME/TIMESTAMP[TZ]/INTERVALText/Integer◑ (6C)No native temporal type, but the 6C date/time function pack (ISO-8601 Text / epoch Integer, SQLite date()/strftime() model) is shipped. INTERVAL arithmetic stays out.
JSON/JSONBText◑ (6C) / ✗Stored as Text; the minimal accessor pack (json_extract, ->/->>, json_array) shipped in 6C. Full JSONB indexing/operators stay ✗.
uuidText/BlobStore as Text; gen_random_uuid() is a ⊕ 6C function.
Arrays int[], composite types, ranges, enum, hstore, geometric, network typesPostgreSQL extensible/structured types; out of scope for an OLTP core (store as Text/Blob + app-side encode if needed).

DDL — data definition

ConstructStatusMapping / notes
CREATE TABLE [IF NOT EXISTS] with typed columnsAutocommit only (ENGINE_ERR_TXN inside BEGIN) — intentional, spec 02.
PRIMARY KEY (single column), NOT NULL, NULLPK implies NOT NULL + uniqueness (enforced via pk_keys).
UNIQUE (non-PK column / constraint)✅ (6D)Single & composite, enforced on insert/update (NULLs distinct). Not separately checked during an ON CONFLICT upsert.
Composite PRIMARY KEY (a,b), table-level constraints✅ (6D)Table-level PRIMARY KEY/UNIQUE/CHECK all captured and enforced.
DEFAULT, CHECK, AUTOINCREMENT/SERIAL; FOREIGN KEY/REFERENCES✅ (6D) / ◑DEFAULT/CHECK/AUTOINCREMENT shipped (6D). FK is tracked metadata (reflected to clients), not enforced; GENERATED stays ✗.
DROP TABLE [IF EXISTS]Autocommit only.
CREATE INDEX … USING hnsw, DROP INDEXPhase 5 vector index.
CREATE INDEX (B-tree, scalar columns)↻ / ⊕ 6DAccepted-and-no-op today is not offered (only HNSW parses). Scalar secondary indexes are a real planner feature — ⊕ 6D (optional; the engine is in-memory MVCC so a full scan is the current plan).
ALTER TABLE … ADD/DROP/RENAME COLUMN, RENAME TO✅ (6D)Catalog + row-reshape over new WAL ops; ADD backfills the column's default.
CREATE VIEW / DROP VIEWA named, re-parsed SelectStmt resolved as a derived table (composes over joins/subqueries and other views); persisted via an additive CreateView WAL op and re-parsed on replay. Recursive definitions are rejected at create time. Autocommit only.
CREATE SEQUENCE, SERIAL/BIGSERIAL✅ (6D) / ✗SERIAL is an auto-increment integer column (6D). Standalone CREATE SEQUENCE stays ✗.
CREATE TRIGGER, CREATE FUNCTION/PL/pgSQL, CREATE EXTENSION, CREATE SCHEMA, roles/GRANTProcedural / server-admin / extensibility surface. Out of scope for the engine core (auth is composed around, spec 12).

DML — data manipulation

ConstructStatusMapping / notes
INSERT INTO t [(cols)] VALUES (…)[, (…)]Multi-row supported. Fully transactional.
INSERT … DEFAULT VALUES / DEFAULT as a value✅ (6D)Each defaulted column takes its DEFAULT/autoincrement.
INSERT INTO t SELECT …✅ (6A)The VALUES source generalized to "rows from a query" — the executor's result rows feed the same staging loop.
UPDATE … SET … [WHERE], DELETE … [WHERE]Fully transactional; first-committer-wins conflict checks.
UPDATE … FROM …, DELETE … USING …Join-driven DML: the target is nested-loop-joined against the FROM/USING sources, correlated through WHERE over the combined [target | sources] namespace; each target row is written at most once (first match wins, as in Postgres). Feeds the same supersede / constraint / first-committer-wins path; RETURNING works.
… RETURNING * / column list (PG; SQLite 3.35+)✅ (6A)Projects the affected row versions (new for INSERT/UPDATE, old for DELETE) through a result set on the existing exec path.
Upsert: PG INSERT … ON CONFLICT (…) DO UPDATE/NOTHING, SQLite INSERT … ON CONFLICT … / OR REPLACE/OR IGNORE✅ (6A)True conflict actions, with excluded.* in DO UPDATE. The earlier silent-ignore hazard for OR REPLACE/OR IGNORE is closed — they now skip/replace as documented.
TRUNCATEDELETE FROM t with no WHERE (already supported); a native fast-path is ✗.
MERGECovered functionally by upsert (6A).
COPY (bulk load)Use batched multi-row INSERT from the client. Native COPY wire support is a server-mode concern (spec 07), not core SQL.

Queries — SELECT

ConstructStatusMapping / notes
Projection: *, expressions, AS alias
FROM single table / multi-source (joins, derived tables); SELECT with no FROM (constants)Multi-source shipped in 6B.
WHEREFull expression predicate, three-valued logic.
ORDER BY expr [ASC|DESC] [NULLS FIRST|LAST] (multi-key)Ranks by expressions; an alias resolves to its select item. NULLS FIRST/LAST shipped in 6A (default: NULLs first on ASC, last on DESC).
LIMIT nInteger literal only.
OFFSET n, LIMIT … OFFSET …✅ (6A)Integer literal or parameter; FETCH FIRST remains out.
Aggregates COUNT/SUM/MIN/MAX/AVG (whole-table)Work only when all select items are aggregates; cannot mix with plain columns (no grouping).
GROUP BY / HAVING✅ (6B)Per-group aggregation; HAVING filters whole groups; works over joins.
DISTINCT, COUNT(DISTINCT x)✅ (6B)Output-row de-duplication and distinct aggregation.
JOIN (INNER/LEFT/RIGHT/FULL/CROSS), ON/USING, table aliases, multi-table FROM✅ (6B)Nested-loop join over a materialized column namespace; qualified alias.col resolved (ambiguous/unknown rejected).
Subqueries: scalar, IN (SELECT …), EXISTS, derived tables FROM (SELECT …), correlated✅ / ◑Non-correlated scalar/IN/EXISTS and derived tables (6B). Correlated scalar/EXISTS subqueries in a non-aggregated SELECT/WHERE evaluate per outer row (bound to the outer row, then folded). Correlation under aggregation or nested deeper than one level stays ✗.
CTEs WITH … AS (…), recursive CTEs✅ / ✗Non-recursive WITH materialized as derived tables (6B). Recursive CTEs are ✗.
Set ops UNION [ALL] / INTERSECT / EXCEPT✅ (6B)Combine two result sets (arity checked); ALL keeps multiset semantics.
Window functions OVER (…)OLAP-shaped; out of scope for the core (compose DuckDB, spec 12).
LATERAL, GROUPING SETS/ROLLUP/CUBE, TABLESAMPLEOut of scope.

Expressions & operators

ConstructStatusMapping / notes
Literals: integer, real, string ('' escape), NULL, TRUE/FALSE, vector […]
Comparison = <> != < <= > >=, logical AND/OR/NOT, three-valued logic
Arithmetic + - * / %, unary -/+Int overflow promotes to real; integer / and % by zero → NULL (SQLite-ish).
IS [NOT] NULL
[NOT] LIKE / ILIKE / … ESCAPE c◑ (6A)%/_ wildcards, ASCII case-insensitive. ESCAPE and ILIKE parse (6A); the case-sensitive LIKE vs ILIKE split is deferred to 6E.
Vector distance <-> / <=> / <#>Phase 5.
String concat ||✅ (6A)NULL-propagating; binds looser than +/-, tighter than comparison.
BETWEEN … AND …, IN (list), NOT IN✅ (6A)Three-valued logic preserved; IN (SELECT …) waits for 6B.
CASE WHEN … THEN … ELSE … END (searched + simple)✅ (6A)Expr::Case, short-circuiting at the first matching branch.
CAST(x AS type) / PG x::type✅ (6A)Expr::Cast over the affinity-coercion machinery (ColumnType::coerce).
COALESCE/NULLIF/GREATEST/LEAST✅ (6A)Plus ifnull/iif; GREATEST/LEAST skip NULLs.
x LIKE … ESCAPE c, SIMILAR TO, POSIX regex ~/~*✅ / ✗ESCAPE shipped (6A). SIMILAR TO/regex are ✗ (no regex engine; keep deps minimal).
Parameters: ? positional, $1 numbered, :name named✅ (6E)All three normalize to the 1-based bind model.

Functions

Policy, not enumeration. PostgreSQL ships thousands of built-ins and SQLite a few hundred; Twill will implement a curated OLTP-common set as native scalar/aggregate functions and reject the rest with ENGINE_ERR_SQL. Scalar function calls are not parsed at all today (a bare word becomes a column reference; only the five aggregates are special-cased), so this is a clean addition: a Expr::Call{name, args} node plus a dispatch table in exec.rs.

Function groupExamplesStatus
Aggregates (whole-table)count, sum, min, max, avg
Aggregates (grouped + more)the above per-group, group_concat/string_agg, count(distinct)✅ (6B)
Stringlength, lower, upper, substr/substring, trim/ltrim/rtrim, replace, coalesce, concat, instr/strpos, repeat, reverse, left/right, lpad/rpad✅ (6C)
Numeric / mathabs, round, ceil, floor, mod, power, sqrt, sign, trunc, exp/ln/log, random✅ (6C)
Conditional / nullcoalesce, nullif, ifnull, greatest, least, iif✅ (6A)
Date / timenow/current_timestamp, current_date/time, date, time, datetime, strftime, extract, date_trunc, unixepoch✅ (6C)
UUID / miscgen_random_uuid, typeof, hex, cast✅ (6C)
JSONjson_extract, json_array, ->/->>✅ (6C, minimal)
Vectordistance operators; vector_dims, l2_distance etc.✅ ops / ⊕ 6C named forms
Server-administrative / systempg_*, current_setting, nextval, full-text to_tsvector

Transactions & session

ConstructStatusMapping / notes
BEGIN [TRANSACTION|WORK|DEFERRED], START TRANSACTION
COMMIT, ROLLBACK ([TRANSACTION|WORK])
Snapshot isolation (the only level)Single-writer, first-committer-wins; equivalent to PG REPEATABLE READ/SI.
SET TRANSACTION ISOLATION LEVEL …✅ (6E)Accepted no-op (the engine runs one snapshot-isolation mode).
SAVEPOINT / RELEASE / ROLLBACK TO✅ (6D)Partial rollback over the in-flight writer's pending set (and buffered WAL); no durable change.
SET/SHOW (GUCs), SET search_path, PRAGMA (SQLite)✅ (6E)SET/PRAGMA accepted no-ops; SHOW name returns a one-row result.
EXPLAIN / EXPLAIN ANALYZE✅ (6E)Returns a single-line plan description (one strategy per shape).
VACUUM, ANALYZE, ATTACH/DETACH (SQLite), LISTEN/NOTIFY (PG)✅ / ✗VACUUM/ANALYZE accepted no-ops (6E). ATTACH conflicts with the URL-scheme backend model; LISTEN/NOTIFY out of scope.

Dialect-specific quirks (PostgreSQL vs SQLite)

AreaPostgreSQLSQLiteTwill resolution
TypingStrict, staticDynamic affinityTwill is dynamic-affinity (SQLite model); server mode reports affinity-derived OIDs. ⊕ 6E.
Identifier quoting"x""x" or [x] or `x`"x" and `x` shipped (6E); [x] stays a vector literal.
String concat||||⊕ 6A.
LIKE casecase-sensitive (+ILIKE)case-insensitive (ASCII)Split shipped (6E): LIKE case-sensitive, ILIKE folds ASCII case.
Booleanreal bool0/1 integer0/1 integer; report as bool on the wire ⊕ 6E.
Auto IDsSERIAL/IDENTITYINTEGER PRIMARY KEY [AUTOINCREMENT] / rowidAUTOINCREMENT/SERIAL integer column shipped (6D).
Placeholders$1?, ?NNN, :name? / $1 / :name all shipped (6E).

Implementation spec

The gaps cluster into five stages, ordered by value/risk. They are additive frontend changes: each grows the Stmt/Expr AST (sql.rs), the parser, and the executor (exec.rs), plus the catalog (catalog.rs) and transaction manager (conn.rs) for the few stateful items. None touches trait Storage; the WAL op set (wal.rs) only grows if a stage adds a new durable catalog fact (called out). Proposed as Phase 6 — SQL Surface Completeness in the roadmap (spec 13); ship the stages as independent, individually-testable increments.

Stage 6A — Expression & single-table completeness (low risk, high value) Implemented

The cheapest, highest-coverage wins; no new execution model, only richer expressions and result shapes over the existing single-table scan.

Shipped

Delivered: || concat, CASE (searched + simple), CAST(x AS t) alongside ::, IN (list)/NOT IN, BETWEEN/NOT BETWEEN, NULLS FIRST/LAST, LIKE … ESCAPE and ILIKE (parsed; case-fold pending the 6E split), the conditional/null group (ifnull, iif, greatest, least), RETURNING on INSERT/UPDATE/DELETE, upsert (ON CONFLICT DO NOTHING|UPDATE with excluded.*, plus SQLite OR IGNORE/OR REPLACE — the silent-ignore hazard is closed), and INSERT … SELECT. ENGINE_ABI_VERSION stays 3; STORAGE_TRAIT_VERSION stays 2. Tests: crates/engine/tests/expr_6a.rs, plus a RETURNING case in tests/ffi.rs.

  • Lexer: add || (concat) and :: (cast) tokens; keep the existing <-disambiguation discipline (vector ops bind first) in lex_lt.
  • AST / parser: Expr::Case, Expr::Cast{expr, ty}, Expr::InList{expr, list, negated}, Expr::Between{expr, lo, hi, negated}, Expr::Call{name, args}, and BinOp::Concat. Add SelectStmt.offset: Option<i64> and per-key NULLS FIRST/LAST. Add LIKE … ESCAPE.
  • Executor: evaluator arms for the new nodes (CASE short-circuits; CAST routes through ColumnType::coerce + explicit text↔number parsing; || is text concat with NULL propagation; IN/BETWEEN desugar). A scalar-function dispatch table seeded with the conditional/null group (coalesce, nullif, ifnull, iif, greatest, least) — string/math/date land in 6C against the same table.
  • RETURNING: run_insert/run_update/run_delete already build the affected new row versions; thread an optional projection list through and return a ResultSet instead of just a row count. The C ABI already exposes a result-set channel from engine_exec, so this is shape-only — no ABI bump.
  • Upsert: implement ON CONFLICT (col) DO NOTHING|UPDATE SET … and SQLite OR IGNORE/OR REPLACE against the existing PK conflict path in run_insert (it already detects committed vs. concurrent-pending clashes). Stop silently ignoring OR REPLACE/OR IGNORE.
  • INSERT … SELECT: let the insert source be a parsed SelectStmt evaluated to rows, fed into the same staging/validation loop.
  • Tests: extend crates/engine/tests/engine for each operator/function; tests/ffi.rs for RETURNING over the C ABI; a bun test pass after a release rebuild.
  • Versions: ENGINE_ABI_VERSION stays 3; STORAGE_TRAIT_VERSION stays 2.

Stage 6B — Multi-table & aggregation (the big one) Implemented

The structural jump: more than one row source, qualified names, grouping. This is where the executor gains a small relational layer above the per-table scan, staying brute-force (nested-loop, hash-group) — appropriate for an in-memory MVCC OLTP store.

Shipped

A relational executor (exec.rs nested mod relational) materializes the FROM into a single column namespace, then runs the same filter/group/project/order pipeline resolving names against it. Delivered: INNER/LEFT/RIGHT/FULL/CROSS joins with ON/USING and the comma form; table aliases and qualified alias.col resolution (ambiguous/unknown → ENGINE_ERR_SQL); derived tables and non-recursive WITH CTEs; DISTINCT and COUNT(DISTINCT); group_concat/string_agg; set operations UNION/INTERSECT/EXCEPT [ALL]; and non-correlated scalar / IN / EXISTS subqueries (folded to literals before the scan). Joins resolve MVCC visibility per base row exactly as the single-table path does. The single-table fast path (and its HNSW KNN shortcut) is unchanged. Also shipped as deferred-6B follow-ups: CREATE VIEW/DROP VIEW (a stored, re-parsed SelectStmt resolved as a derived table; additive CreateView WAL op; recursion rejected), UPDATE … FROM/DELETE … USING (join-driven DML over the existing write path), and correlated scalar/EXISTS subqueries (evaluated per outer row). Deferred (clean rejection): recursive CTEs, and correlation under aggregation or nested deeper than one level. ENGINE_ABI_VERSION stays 3; STORAGE_TRAIT_VERSION stays 2. Tests: crates/engine/tests/relational_6b.rs.

  • AST: generalize SelectStmt.from from Option<String> to a FromClause tree: base tables with aliases, JOIN nodes (Inner/Left/Right/Full/Cross) with ON/USING, and derived tables ((SELECT …) AS a). Add group_by: Vec<Expr>, having: Option<Expr>, distinct: bool, and a set_op wrapper (UNION/INTERSECT/EXCEPT [ALL]). Add Expr::Subquery (scalar) and Expr::Exists/Expr::InSubquery.
  • Name resolution: replace the "drop the table qualifier, keep the column" shortcut in primary() with a real binder: resolve alias.col against the active FromClause's column namespace; raise ENGINE_ERR_SQL on ambiguous/unknown.
  • Executor: a row-source iterator model — scan, nested-loop join (with LEFT/RIGHT/FULL null-extension), then a grouping operator (hash by the GROUP BY key, fold aggregates per group, filter by HAVING), then DISTINCT, then order/limit/offset. All over the same MVCC snapshot; visibility is still resolved per base-row exactly as today. Correlated subqueries re-evaluate per outer row (documented as O(n·m), fine for OLTP cardinalities).
  • Grouped aggregates: generalize compute_aggregate to run per group; add group_concat/string_agg and COUNT(DISTINCT …).
  • Views & CTEs: non-recursive WITH and CREATE VIEW become named derived tables (the view body is a stored, re-bound SelectStmt; a CreateView WAL op carries the text — a new durable catalog fact, so this sub-item alone touches wal.rs and the catalog, still not the storage trait).
  • Tests: a dedicated tests/joins.rs and tests/grouping.rs with MVCC-visibility cases across joins; parity checks for set ops.
  • Versions: STORAGE_TRAIT_VERSION stays 2. ENGINE_ABI_VERSION stays 3 (still text-in/result-out). The CreateView/(optional) catalog WAL ops are additive WAL records, replayed like any other.

Stage 6C — Function library Implemented

Shipped

The scalar dispatch table now carries the string (substr, replace, instr/strpos, repeat, reverse, left/right, lpad/rpad, …), numeric (ceil/floor, sqrt, power, mod, sign, trunc, round(x,n), exp/ln/log, pi), date/time (now, current_date/time/timestamp, date/time/datetime, date_trunc, extract(field FROM …), strftime, unixepoch), UUID/misc (gen_random_uuid, random, typeof, hex), and JSON (->, ->>, json_extract, json_array) groups. Date/time is the SQLite text/epoch model in two small dependency-free modules (datetime.rs, json.rs); non-deterministic functions evaluate once so the concrete value lands in the WAL (replay-stable). Tests: crates/engine/tests/functions_6c.rs + module unit tests.

  • Populate the 6A scalar-dispatch table with the string, numeric/math, date/time, UUID, and minimal-JSON groups from the functions table. Date/time follows the SQLite model (text/epoch + strftime/date), avoiding a native temporal type and keeping the value model unchanged.
  • Determinism & durability: non-deterministic functions (now(), random(), gen_random_uuid()) are evaluated once at execution time and the resulting concrete value is what lands in the WalOp — so replay reproduces the stored value exactly (never re-rolls). This is the same discipline that keeps the vector index replay-deterministic.
  • Keep dependencies minimal (rust.md): hand-roll or use the existing base64-style in-house helpers; no new crates for date parsing or JSON unless justified.
  • Tests: per-function unit coverage; an explicit replay-determinism test for the non-deterministic functions.

Stage 6D — Constraints, schema evolution & savepoints Implemented

Shipped

Catalog and WAL grew additive facts (backward-compatible CreateTable extras + new ALTER ops): column DEFAULT (re-parsed from stored text), CHECK (column & table level), single & composite UNIQUE, composite/table-level PRIMARY KEY, and AUTOINCREMENT/SERIAL (a per-table counter rebuilt from the rows on replay — the assigned value is concrete in the WAL). DEFAULT as an INSERT value and DEFAULT VALUES are supported. ALTER TABLE ADD/DROP/RENAME COLUMN and RENAME TO reshape the catalog and row versions over new WAL ops. SAVEPOINT/RELEASE/ROLLBACK TO snapshot and restore the in-flight writer's pending set (and truncate its buffered WAL) — no durable change. DDL stays autocommit-only. Tests: crates/engine/tests/constraints_6d.rs. (Scalar B-tree indexes and FK enforcement remain out; secondary UNIQUE during an ON CONFLICT upsert is not separately checked.)

  • Catalog: store DEFAULT expressions, CHECK predicates, secondary UNIQUE, and composite/table-level PRIMARY KEY in TableSchema. Enforce in the insert/update validation pass (which already validates fully before mutating — atomic per statement). Secondary UNIQUE reuses the pk_keys machinery generalized to N key sets.
  • AUTOINCREMENT/SERIAL: a monotonic per-table counter in the catalog, advanced and WAL-logged on insert (a new durable fact → additive WAL op).
  • ALTER TABLE: ADD COLUMN (null/default backfill), DROP COLUMN, RENAME as catalog mutations replayed from new WAL ops; existing in-memory row versions reshape on replay.
  • SAVEPOINT/ROLLBACK TO: tag pending row versions with a savepoint depth in conn.rs; ROLLBACK TO tombstones pending versions above the mark (reuses Store::rollback_pending). No durable change — savepoints live entirely in the in-flight writer's pending set.
  • Scalar (B-tree) secondary indexes: optional. Like the HNSW index, a derived in-memory structure rebuilt on replay (rebuild_indexes); a CreateIndex-style WAL op already exists as the model. Default plan remains a full MVCC scan, so this is a latency optimization, not a correctness requirement.
  • Versions: new WAL ops are additive; STORAGE_TRAIT_VERSION unchanged. DDL remains autocommit-only (spec 02 boundary preserved).

Stage 6E — Dialect shims (compatibility surface) Implemented

Make real PostgreSQL/SQLite clients and ORMs connect without surprises, concentrated at the boundary so the core stays one dialect.

Shipped

Placeholder forms $1 (numbered) and :name (named) join positional ?, all normalized to the engine's 1-based bind model (a repeated $1 reuses its slot; distinct :names get sequential slots). Backtick `x` identifier quoting joins "x" ([x] stays a vector literal). LIKE is now case-sensitive and ILIKE case-folds (the dialect split). SET/SET TRANSACTION ISOLATION LEVEL/PRAGMA/VACUUM/ANALYZE/RESET/DISCARD are accepted no-ops; SHOW name returns a one-row result; EXPLAIN [ANALYZE] … returns a one-line plan. Implemented in the engine lexer/parser/exec so both the embedded and pgwire paths inherit it. Tests: crates/engine/tests/dialect_6e.rs.

  • Placeholder forms $1 and :name (normalize to the existing positional model); identifier quoting [x]/`x`; LIKE vs ILIKE split (case-sensitive default for PG mode); boolean reported as bool on the wire.
  • Accept-and-pin SET TRANSACTION ISOLATION LEVEL to SI; accept-and-no-op the common session SET/SHOW/PRAGMA/VACUUM/ANALYZE that ORMs emit on connect, rejecting unknown ones (never silently swallow semantics-bearing statements).
  • EXPLAIN returns the single chosen plan as text.
  • Most of 6E lives in crates/server (the pgwire boundary, spec 07) and the Bun wrapper, keeping dialect translation out of the embedded engine core where it can.

Deliberate non-goals (kept rejected)

  • OLAP-shaped query features — window functions, GROUPING SETS/ROLLUP/CUBE, LATERAL, recursive CTEs. Composed via DuckDB over shared storage (spec 12), not built into the OLTP core.
  • Procedural & extensibility surface — PL/pgSQL, CREATE FUNCTION/TRIGGER/EXTENSION/SCHEMA, roles/GRANT, LISTEN/NOTIFY, full-text search. Auth and services are composed around the engine, not welded in.
  • Exact-decimal / arbitrary-precision numericsNUMERIC(p,s) maps to f64. A true decimal type would extend the value model (spec 02) and is out of scope here.
  • PostgreSQL's structured/extensible type system — arrays, composite types, ranges, enums, geometric/network types, hstore. Encode app-side into Text/Blob.
  • Storage-engine-conflicting statementsATTACH/DETACH (backend is chosen by URL scheme, not at runtime), tablespaces, physical storage parameters.

Everything in this list stays a clean ENGINE_ERR_SQL rejection — the "reject, never mis-parse" contract is part of the engine's correctness story.

Acceptance & verification

  • Each stage ships with engine tests that would fail without it (testing.md), the C-ABI surface re-checked in tests/ffi.rs, and — after a cargo build -p twill-engine --release — a green bun test.
  • The C1–C8 storage conformance suite, the Phase-4 branching battery, and the Experiment-4 crash gate must stay green at every stage — they cannot regress, because no stage touches the storage seam or the commit/recovery path.
  • Each shipped stage updates docs/sql-reference (the user-facing surface) and this spec's status marks in lock-step (git-workflow.md: specs and docs track behaviour).
  • A compatibility corpus — a checked-in set of representative PostgreSQL- and SQLite-flavoured statements with expected results — runs as the regression gate for "the SQL an application actually issues", growing as stages land.

Related

Twill DB — internal development specification. Draft / proposed. · Author