SQL Compatibility & Mapping — PostgreSQL / SQLite → Twill DB
A complete, honest map of the PostgreSQL and SQLite SQL surface onto Twill DB: what the hand-written frontend (sql.rs → exec.rs) accepts today, what is mappable by rewrite, what is proposed for a Phase 6 surface-completeness effort, and what stays out of scope by design. Then a concrete implementation spec for the gaps — all of it parser + executor work; the storage seam never moves (STORAGE_TRAIT_VERSION stays 2).
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:
- 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.
- 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
Storagetrait 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 totrait Storageor leak a backend concept into it.STORAGE_TRAIT_VERSIONstays2, 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_VERSIONstays3unless 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 sameWalOpbatch 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_SQLwith a clear message — never silently ignored or mis-executed (the existing contract; cf.OR REPLACEbeing parsed-and-ignored is itself a documented hazard this spec tightens).
Status legend
| Mark | Meaning |
|---|---|
| ✅ Supported | Parses and executes today, with the documented semantics. |
| ◑ Partial | A subset works; notable limits called out. |
| ↻ Map by rewrite | Not native, but expressible in the supported subset by a mechanical client- or shim-side rewrite (no engine change required). |
| ⊕ Proposed | Out today; targeted by a named Phase 6 stage below. |
| ✗ Out of scope | Deliberately 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 type | Twill class | Status | Notes |
|---|---|---|---|
SMALLINT/INT/INTEGER/BIGINT/INT2/4/8, SQLite INTEGER | Integer (i64) | ✅ | Any name containing INT. All integers are 64-bit; SMALLINT range is not separately enforced. |
REAL/DOUBLE PRECISION/FLOAT/NUMERIC(p,s)/DECIMAL | Real (f64) | ◑ | NUMERIC/DECIMAL map to f64 — no exact-decimal arithmetic. The (p,s) suffix is parsed and ignored. Exact numerics are ✗ (see non-goals). |
TEXT/VARCHAR(n)/CHAR(n)/CLOB/STRING | Text (UTF-8) | ✅ | (n) length is parsed and ignored (SQLite semantics); no length enforcement. |
BYTEA / SQLite BLOB | Blob | ✅ | Rendered base64 over the string-only C ABI; bound back via the same. |
BOOLEAN / true/false | Integer 0/1 | ◑ | Literals 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) | Vector | ✅ | Phase 5. Dimension declared and enforced; literals [1,2,3] / '[1,2,3]'. |
DATE/TIME/TIMESTAMP[TZ]/INTERVAL | Text/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/JSONB | Text | ◑ (6C) / ✗ | Stored as Text; the minimal accessor pack (json_extract, ->/->>, json_array) shipped in 6C. Full JSONB indexing/operators stay ✗. |
uuid | Text/Blob | ↻ | Store as Text; gen_random_uuid() is a ⊕ 6C function. |
Arrays int[], composite types, ranges, enum, hstore, geometric, network types | — | ✗ | PostgreSQL extensible/structured types; out of scope for an OLTP core (store as Text/Blob + app-side encode if needed). |
DDL — data definition
| Construct | Status | Mapping / notes |
|---|---|---|
CREATE TABLE [IF NOT EXISTS] with typed columns | ✅ | Autocommit only (ENGINE_ERR_TXN inside BEGIN) — intentional, spec 02. |
PRIMARY KEY (single column), NOT NULL, NULL | ✅ | PK 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 INDEX | ✅ | Phase 5 vector index. |
CREATE INDEX (B-tree, scalar columns) | ↻ / ⊕ 6D | Accepted-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 VIEW | ✅ | A 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/GRANT | ✗ | Procedural / server-admin / extensibility surface. Out of scope for the engine core (auth is composed around, spec 12). |
DML — data manipulation
| Construct | Status | Mapping / 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. |
TRUNCATE | ↻ | DELETE FROM t with no WHERE (already supported); a native fast-path is ✗. |
MERGE | ✗ | Covered 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
| Construct | Status | Mapping / notes |
|---|---|---|
Projection: *, expressions, AS alias | ✅ | — |
FROM single table / multi-source (joins, derived tables); SELECT with no FROM (constants) | ✅ | Multi-source shipped in 6B. |
WHERE | ✅ | Full 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 n | ✅ | Integer 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, TABLESAMPLE | ✗ | Out of scope. |
Expressions & operators
| Construct | Status | Mapping / 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 group | Examples | Status |
|---|---|---|
| Aggregates (whole-table) | count, sum, min, max, avg | ✅ |
| Aggregates (grouped + more) | the above per-group, group_concat/string_agg, count(distinct) | ✅ (6B) |
| String | length, lower, upper, substr/substring, trim/ltrim/rtrim, replace, coalesce, concat, instr/strpos, repeat, reverse, left/right, lpad/rpad | ✅ (6C) |
| Numeric / math | abs, round, ceil, floor, mod, power, sqrt, sign, trunc, exp/ln/log, random | ✅ (6C) |
| Conditional / null | coalesce, nullif, ifnull, greatest, least, iif | ✅ (6A) |
| Date / time | now/current_timestamp, current_date/time, date, time, datetime, strftime, extract, date_trunc, unixepoch | ✅ (6C) |
| UUID / misc | gen_random_uuid, typeof, hex, cast | ✅ (6C) |
| JSON | json_extract, json_array, ->/->> | ✅ (6C, minimal) |
| Vector | distance operators; vector_dims, l2_distance etc. | ✅ ops / ⊕ 6C named forms |
| Server-administrative / system | pg_*, current_setting, nextval, full-text to_tsvector | ✗ |
Transactions & session
| Construct | Status | Mapping / 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)
| Area | PostgreSQL | SQLite | Twill resolution |
|---|---|---|---|
| Typing | Strict, static | Dynamic affinity | Twill 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 case | case-sensitive (+ILIKE) | case-insensitive (ASCII) | Split shipped (6E): LIKE case-sensitive, ILIKE folds ASCII case. |
| Boolean | real bool | 0/1 integer | 0/1 integer; report as bool on the wire ⊕ 6E. |
| Auto IDs | SERIAL/IDENTITY | INTEGER PRIMARY KEY [AUTOINCREMENT] / rowid | AUTOINCREMENT/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) inlex_lt. - AST / parser:
Expr::Case,Expr::Cast{expr, ty},Expr::InList{expr, list, negated},Expr::Between{expr, lo, hi, negated},Expr::Call{name, args}, andBinOp::Concat. AddSelectStmt.offset: Option<i64>and per-keyNULLS FIRST/LAST. AddLIKE … 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/BETWEENdesugar). 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_deletealready build the affected new row versions; thread an optional projection list through and return aResultSetinstead of just a row count. The C ABI already exposes a result-set channel fromengine_exec, so this is shape-only — no ABI bump.- Upsert: implement
ON CONFLICT (col) DO NOTHING|UPDATE SET …and SQLiteOR IGNORE/OR REPLACEagainst the existing PK conflict path inrun_insert(it already detects committed vs. concurrent-pending clashes). Stop silently ignoringOR REPLACE/OR IGNORE. INSERT … SELECT: let the insert source be a parsedSelectStmtevaluated to rows, fed into the same staging/validation loop.- Tests: extend
crates/engine/tests/enginefor each operator/function;tests/ffi.rsforRETURNINGover the C ABI; abun testpass after a release rebuild. - Versions:
ENGINE_ABI_VERSIONstays3;STORAGE_TRAIT_VERSIONstays2.
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.fromfromOption<String>to aFromClausetree: base tables with aliases,JOINnodes (Inner/Left/Right/Full/Cross) withON/USING, and derived tables ((SELECT …) AS a). Addgroup_by: Vec<Expr>,having: Option<Expr>,distinct: bool, and aset_opwrapper (UNION/INTERSECT/EXCEPT [ALL]). AddExpr::Subquery(scalar) andExpr::Exists/Expr::InSubquery. - Name resolution: replace the "drop the table qualifier, keep the column" shortcut in
primary()with a real binder: resolvealias.colagainst the activeFromClause's column namespace; raiseENGINE_ERR_SQLon 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 BYkey, fold aggregates per group, filter byHAVING), 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_aggregateto run per group; addgroup_concat/string_aggandCOUNT(DISTINCT …). - Views & CTEs: non-recursive
WITHandCREATE VIEWbecome named derived tables (the view body is a stored, re-boundSelectStmt; aCreateViewWAL op carries the text — a new durable catalog fact, so this sub-item alone toucheswal.rsand the catalog, still not the storage trait). - Tests: a dedicated
tests/joins.rsandtests/grouping.rswith MVCC-visibility cases across joins; parity checks for set ops. - Versions:
STORAGE_TRAIT_VERSIONstays2.ENGINE_ABI_VERSIONstays3(still text-in/result-out). TheCreateView/(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 theWalOp— 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
DEFAULTexpressions,CHECKpredicates, secondaryUNIQUE, and composite/table-levelPRIMARY KEYinTableSchema. Enforce in the insert/update validation pass (which already validates fully before mutating — atomic per statement). Secondary UNIQUE reuses thepk_keysmachinery 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,RENAMEas 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 inconn.rs;ROLLBACK TOtombstones pending versions above the mark (reusesStore::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); aCreateIndex-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_VERSIONunchanged. 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
$1and:name(normalize to the existing positional model); identifier quoting[x]/`x`;LIKEvsILIKEsplit (case-sensitive default for PG mode); boolean reported asboolon the wire. - Accept-and-pin
SET TRANSACTION ISOLATION LEVELto SI; accept-and-no-op the common sessionSET/SHOW/PRAGMA/VACUUM/ANALYZEthat ORMs emit on connect, rejecting unknown ones (never silently swallow semantics-bearing statements). EXPLAINreturns 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 numerics —
NUMERIC(p,s)maps tof64. 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 intoText/Blob. - Storage-engine-conflicting statements —
ATTACH/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 acargo build -p twill-engine --release— a greenbun 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
sql.rs → exec.rs → conn.rs) every stage extends.
SQL Reference (docs)The exact surface accepted today — the "Supported" column of this map, kept in sync as stages land.
Server Mode & Wire ProtocolWhere the dialect shims (6E) live — pgwire clients/ORMs connect at this boundary.
Capabilities: Build-in vs ComposeThe deciding rule that keeps OLAP/auth/service surface composed around the core, not in it.
Roadmap & Build SequenceWhere Phase 6 — SQL Surface Completeness slots into the phased plan.
Phase 5 — Vector SearchThe precedent: a SQL capability added additively over the frozen storage seam.