浏览代码

document SQLite catalog and unique-index behavior

Update the compatibility matrix, statement, function, and protocol references for the SQLite catalog emulation, session state functions, result type OIDs, and unique-index enforcement added for the Gogs backend. Add the git.database.pizza Caddy route.
Danilo Fragoso 1 天之前
父节点
当前提交
0e493e67e4

+ 7 - 0
deploy/Caddyfile

@@ -63,3 +63,10 @@ docs.database.pizza {
 
 import /home/pizzadatabase/testmarket/Caddyfile
 import /home/pizzadatabase/pizza-admin/Caddyfile
+
+git.database.pizza {
+	tls internal
+	encode zstd gzip
+	header X-Content-Type-Options nosniff
+	reverse_proxy 127.0.0.1:3001
+}

+ 2 - 0
src/content/docs/clients/postgresql.md

@@ -119,6 +119,8 @@ conn, err := pgx.Connect(ctx,
     "postgresql://u:pz_live_REPLACE_ME@db.database.pizza:5432/acme%2Fproduction?sslmode=disable")
 ```
 
+pgx decodes the type metadata advertised for single-table direct column selects: `BIGINT` arrives as `int64` and `DATETIME`/`TIMESTAMP` columns arrive as `time.Time` (UTC, via `timestamptz`). Expressions and joins still report `TEXT`. An experimental Gogs fork uses a `pgx`-backed `database/sql` driver against a local PizzaSQL source build only — it is not a deployed configuration (see [Compatibility](/sql-reference/compatibility/)).
+
 ## Placeholders
 
 PizzaSQL accepts both `?` (SQLite style) and `$1`, `$2` (PostgreSQL style) placeholders. Prefer the style your driver parameterizes natively — most Postgres drivers use `$1`, `$2`.

+ 1 - 1
src/content/docs/engine/indexes.md

@@ -52,7 +52,7 @@ If no index applies, the engine performs a **full table scan** (it reads every r
 
 ## UNIQUE indexes
 
-`CREATE UNIQUE INDEX` stores the `unique` flag in the definition (it appears in `pg_indexes` introspection), but **uniqueness is not enforced**. Duplicate values are allowed. See [Constraints](/sql-reference/constraints/).
+`CREATE UNIQUE INDEX` stores the `unique` flag in the definition (it appears in `pg_indexes` introspection) **and enforces it** on the core write paths via validating scans; creating one against a table with existing duplicate non-`NULL` values is rejected. `NULL` values are exempt, matching SQLite. See [Constraints](/sql-reference/constraints/).
 
 ## Interaction with the row cache
 

+ 1 - 1
src/content/docs/internals/catalog.md

@@ -41,7 +41,7 @@ This means concurrent DDL and DML from different connections eventually converge
 
 The catalog is deliberately minimal:
 
-- It does **not** store `UNIQUE`, `CHECK`, or `FOREIGN KEY` constraints — those are discarded at `CREATE TABLE` (see [Constraints](/sql-reference/constraints/)).
+- It does **not** store `CHECK` or `FOREIGN KEY` constraints — those are discarded at `CREATE TABLE`. `UNIQUE` constraints are materialized into unique index definitions (see [Constraints](/sql-reference/constraints/)).
 - It does not store index definitions (those live in the `SchemaManager`/storage, not the analyzer catalog).
 - It does not store statistics, histograms, or anything a cost-based planner would use — there is no planner.
 

+ 6 - 2
src/content/docs/internals/postgres-protocol.md

@@ -41,12 +41,16 @@ Many drivers run introspection queries on connect. PizzaSQL intercepts and emula
 - `SHOW server_version` / `server_encoding` / `client_encoding`
 - `SELECT ... FROM information_schema.tables / columns / table_constraints / key_column_usage`
 - `SELECT ... FROM pg_tables / pg_indexes`
+- `SELECT ... FROM sqlite_master` / `sqlite_schema` — read-only synthesized rows for SQLite migrators
+- `PRAGMA index_list` / `index_info` / `table_xinfo` — answered from the durable schema
 
-These are generated from the PizzaSQL schema, not a real PostgreSQL catalog. Filtering (`WHERE table_name = 'x'`, `WHERE schemaname = ...`) is recognized for simple equality patterns; other clauses (joins, subqueries against catalog tables) are not supported. The catalog is a **read-only compatibility shim**, not a queryable schema.
+These are generated from the PizzaSQL schema, not a real PostgreSQL or SQLite catalog. Filtering (`WHERE table_name = 'x'`, `WHERE schemaname = ...`) is recognized for simple equality patterns; other clauses (joins, subqueries against catalog tables) are not supported. The catalog is a **read-only compatibility shim**, not a queryable schema.
 
 ### Type mapping on results
 
-Result columns are advertised with a small OID mapping: `INTEGER`/`INT` → `int4` (23), `TEXT`/`VARCHAR`/`CHAR` → `text` (25), `REAL`/`FLOAT` → `float4` (700), `DOUBLE` → `float8` (701), `BOOLEAN` → `bool` (16), `BLOB` → `bytea` (17), anything else → `text`. Values are sent in text format.
+Result columns are advertised with a small OID mapping: `INTEGER`/`INT` → `int4` (23), `BIGINT` → `int8` (20), `TEXT`/`VARCHAR`/`CHAR`/`UUID` → `text` (25), `REAL`/`FLOAT` → `float4` (700), `DOUBLE` → `float8` (701), `BOOLEAN` → `bool` (16), `BLOB` → `bytea` (17), `DATETIME`/`TIMESTAMP` → `timestamptz` (1184), anything else → `text`. Values are sent in text format; datetime values are exchanged as UTC RFC3339 so `timestamptz` decodes directly.
+
+Type metadata is populated only for **single-table direct column projections** (a column reference, an alias, or `SELECT *`), derived from schema metadata rather than row values so it holds even for empty results. Expressions, joins, multi-table queries, and unknown columns still report `TEXT` (25). A `UUID` column is stored as text and reports `text` (25), not the native PostgreSQL `uuid` OID. The advertised OID reflects the **declared** column type (`INTEGER` stays `int4`, for example) and only guides client decoding — it does not make values strictly typed: storage remains SQLite-style dynamic affinity.
 
 ### Protocol hardening
 

+ 21 - 8
src/content/docs/sql-reference/compatibility.md

@@ -32,12 +32,12 @@ This page describes behavior verified in the current source and tests. The SQLit
 
 Do not rely on the following behavior yet:
 
-- `UNIQUE`, `CHECK`, and foreign-key declarations may be accepted but are not enforced.
+- `CHECK` and foreign-key declarations may be accepted but are not enforced. `UNIQUE` constraints and unique indexes are enforced on the core write paths, but only through validating scans — this is not a durable uniqueness claim and does not imply full SQLite or PostgreSQL concurrency semantics. `UNIQUE` is materialized only when a table is created; tables created before this support are not retroactively enforced after an upgrade.
 - Composite primary keys retain only their first column.
 - `RIGHT JOIN`, `FULL JOIN`, `JOIN ... USING`, and `NATURAL JOIN` may parse but do not execute correctly.
 - DDL changes such as `CREATE TABLE` are not rolled back by `ROLLBACK`.
 - `GROUP_CONCAT` and `TOTAL` are not safe to execute.
-- PostgreSQL result columns may be reported as `TEXT` regardless of their SQL type.
+- Result column type metadata is only reported for single-table direct column projections; expressions, joins, and unknown columns are still reported as `TEXT`.
 
 Unsupported syntax normally returns an error. The items above are called out separately because accepting syntax without enforcing its semantics can cause incorrect application behavior.
 
@@ -96,8 +96,8 @@ Unsupported syntax normally returns an error. The items above are called out sep
 | Single-column primary keys | Supported | Supported | Supported |
 | Composite primary keys | Unsafe; only the first column is retained | Supported | Supported |
 | `NOT NULL` | Supported | Supported | Supported |
-| `UNIQUE` constraints | Unsafe; parsed but not enforced | Supported | Supported |
-| Unique indexes | Unsafe; uniqueness is not enforced | Supported | Supported |
+| `UNIQUE` constraints | Partial; enforced on core write paths via validating scans | Supported | Supported |
+| Unique indexes | Partial; enforced on core write paths via validating scans | Supported | Supported |
 | `CHECK` constraints | Unsafe; parsed but not enforced | Supported | Supported |
 | Foreign keys | Unsafe; parsed but not enforced | Supported when enabled | Supported |
 | Literal defaults | Partial | Supported | Supported |
@@ -123,7 +123,7 @@ PizzaSQL accepts many familiar type names but does not implement PostgreSQL's st
 | `NUMERIC(p,s)` precision enforcement | Unsupported | Unsupported | Supported |
 | Native date and time types | Unsupported; values remain dynamically typed | Unsupported | Supported |
 | JSON values, functions, and operators | Unsupported; `JSON` names have no JSON semantics | Available with SQLite JSON support | Supported with `JSON` and `JSONB` |
-| Arrays, UUIDs, enums, and intervals | Unsupported | Unsupported as native types | Supported |
+| Arrays, UUIDs, enums, and intervals | Partial; `UUID` is accepted as a text alias with no native PostgreSQL UUID semantics | Unsupported as native types | Supported |
 | `CAST` to integer, real, and text | Supported | Supported | Supported |
 | Other `CAST` targets | Partial; many targets are no-ops | Affinity based | Strictly typed |
 | PostgreSQL `::` casts | Unsupported | Unsupported | Supported |
@@ -144,6 +144,7 @@ PizzaSQL accepts many familiar type names but does not implement PostgreSQL's st
 | Common math functions | Partial | Supported | Supported |
 | `CEIL`, `FLOOR`, and `MOD` | Unsafe; declared but currently return `NULL` | Supported | Supported |
 | SQLite date and time functions | Supported | Supported | Different function set |
+| `last_insert_rowid()`, `changes()`, `total_changes()` | Supported; session-local | Supported | Unsupported |
 | PostgreSQL date and time functions | Unsupported | Unsupported | Supported |
 | JSON functions | Unsupported | Available with SQLite JSON support | Supported |
 | Window functions | Unsupported | Supported | Supported |
@@ -172,7 +173,7 @@ PostgreSQL wire compatibility allows some PostgreSQL clients to connect. It does
 | Text parameters and results | Supported | Not applicable | Supported |
 | Binary parameters | Partial; limited boolean and integer support | Not applicable | Supported |
 | Binary results | Unsupported | Not applicable | Supported |
-| Correct result type OIDs | Unsafe; normal query columns may be reported as `TEXT` | Not applicable | Supported |
+| Correct result type OIDs | Partial; single-table direct column projections report typed OIDs, expressions and joins report `TEXT` | Not applicable | Supported |
 | PostgreSQL TLS | Unsupported | Not applicable | Supported |
 | PostgreSQL SCRAM and MD5 authentication | Unsupported | Not applicable | Supported |
 | `information_schema` | Partial emulation | Unsupported | Supported |
@@ -185,7 +186,7 @@ PostgreSQL wire compatibility allows some PostgreSQL clients to connect. It does
 | ORMs and migration frameworks | Unverified | Varies | Supported |
 | `psql` schema introspection commands | Partial | Not applicable | Supported |
 
-Because the wire is PostgreSQL-compatible, drivers may issue introspection queries such as `information_schema`, `pg_catalog`, `SELECT version()`, or `SHOW server_version` on connection. PizzaSQL emulates a small subset of these so basic clients can start. See [PostgreSQL protocol](/internals/postgres-protocol/).
+Because the wire is PostgreSQL-compatible, drivers may issue introspection queries such as `information_schema`, `pg_catalog`, `SELECT version()`, or `SHOW server_version` on connection. PizzaSQL emulates a small subset of these so basic clients can start. SQLite-style migrators are also accommodated with a read-only `sqlite_master`/`sqlite_schema` emulation and the `index_list`, `index_info`, and `table_xinfo` pragmas. See [PostgreSQL protocol](/internals/postgres-protocol/).
 
 ## Managed service differences
 
@@ -207,10 +208,22 @@ The PizzaSQL repository includes the SQLite SQLLogicTest corpus and a custom run
 
 Compatibility claims should therefore be tied to explicit automated tests, not only to the presence or size of the corpus.
 
+## Experimental Gogs backend evidence
+
+An experimental Gogs fork drives PizzaSQL through a custom `pgx`-backed `database/sql` driver that emits SQLite-dialect SQL over the PostgreSQL wire. This is a **local source-build exercise, not a deployed or production configuration**, and transport TLS remains unavailable. Its opt-in smoke test passes end to end against local engine binaries, covering:
+
+- Full fresh-start schema installation and table creation through GORM and XORM, migrations seeding, and XORM `Sync2`.
+- The install flow end to end: `GET /install` returns `200`, `POST /install` redirects with `302`, and the admin user is inserted and reachable through `/user/login`.
+- User, repository, access-token, and permission writes through the public stores, cross-ORM reads, uniqueness rejection, and transaction rollback.
+- Pool close/reopen and full engine-process plus PizzaKV restart against the same `.pkvdb` file, confirming installation and persistence.
+- Core LFS object metadata: multiple objects per repository, the same OID in a different repository kept distinct, duplicate `(repo_id, oid)` rejection, and reads back with a real `time.Time` `created_at`.
+
+This is **not** evidence of complete Gogs compatibility or of the Git LFS transfer path — actual Git LFS upload, SSH, and push have not been tested yet, and the full Gogs test suite has not passed against PizzaSQL.
+
 ## Advice for porting
 
 1. Use explicit `INNER`, `LEFT`, and `CROSS` joins with `ON` conditions.
-2. Enforce uniqueness, checks, and referential integrity in the application until engine enforcement lands.
+2. Enforce checks and referential integrity in the application. Uniqueness is enforced on the core write paths, but keep application-level checks for anything with stricter concurrency or DDL needs.
 3. Use a single-column primary key.
 4. Avoid `AUTOINCREMENT`, expression defaults, and `DEFAULT CURRENT_TIMESTAMP` for semantic behavior.
 5. Use `COUNT`, `SUM`, `AVG`, `MIN`, and `MAX`; verify other functions against the [function reference](/sql-reference/functions/).

+ 25 - 7
src/content/docs/sql-reference/constraints.md

@@ -3,7 +3,7 @@ title: Constraints
 description: What PRIMARY KEY, NOT NULL, DEFAULT, UNIQUE, CHECK, and FOREIGN KEY actually do in PizzaSQL — and which ones are not enforced.
 ---
 
-Constraints in PizzaSQL fall into two camps: the ones that are actually implemented, and the ones that merely parse. This page is deliberately blunt about the difference, because silently assuming a `UNIQUE` or `FOREIGN KEY` constraint is enforced will corrupt data.
+Constraints in PizzaSQL fall into two camps: the ones that are actually implemented, and the ones that merely parse. This page is deliberately blunt about the difference, because silently assuming a `CHECK` or `FOREIGN KEY` constraint is enforced will corrupt data. `UNIQUE` is now enforced on the core write paths, with caveats described below.
 
 ## The enforced constraints
 
@@ -31,27 +31,45 @@ Constraints in PizzaSQL fall into two camps: the ones that are actually implemen
 - `AUTOINCREMENT` **parses but has no behaviour**. It does not prevent rowid reuse, does not maintain a separate sequence, and behaves exactly like a plain `INTEGER PRIMARY KEY`.
 - It is retained in the schema JSON for compatibility but is never read.
 
+## UNIQUE
+
+`UNIQUE` is enforced, but through validating scans rather than a durable uniqueness claim:
+
+- `CREATE UNIQUE INDEX` and inline/table-level `UNIQUE` constraints both materialize into a unique index definition.
+- The core write paths (`INSERT`, `UPDATE`, `DELETE`, and their `ON CONFLICT`/`OR` variants) validate non-`NULL` values against the table's unique indexes before persisting, rejecting duplicates with `UNIQUE constraint failed`.
+- `CREATE UNIQUE INDEX` on a table that already contains duplicate non-`NULL` values is rejected and the index is not registered.
+- `NULL` values are exempt — multiple `NULL`s are allowed, matching SQLite.
+- Composite unique indexes are enforced, and integral numerics are canonicalized so `1` (integer) and `1.0` (real) collide the way SQLite's affinity does.
+
+The implementation is scan-based: a table with a unique index serializes its core writes on the table gate so the validating scan cannot race another writer, and multi-row DML against such a table runs in an implicit transaction for statement-level atomicity. This is **not** full SQLite or PostgreSQL concurrency semantics, and DDL remains non-transactional. See [Indexes](/engine/indexes/) and [Compatibility](/sql-reference/compatibility/).
+
+### Fresh schemas vs. existing tables
+
+`UNIQUE` constraints are materialized into indexes **only when the table is created**. The effect applies to newly created tables, not retroactively:
+
+- A table created now with `UNIQUE` (inline, table-level, or `CREATE UNIQUE INDEX`) is enforced from the start.
+- A table created by an older engine build, whose `UNIQUE` declarations were silently discarded, is **not** magically enforced after an upgrade — nothing re-parses the old `CREATE TABLE` statement, so the constraint was never retained.
+- After upgrading, re-issue `CREATE UNIQUE INDEX` for any column that must be unique on a pre-existing table; that path validates existing rows and rejects the index if duplicates are already present.
+
+Do not assume an existing table's uniqueness is enforced just because the engine now supports `UNIQUE`.
+
 ## The non-enforced constraints
 
 These are accepted by the parser and then **silently discarded** — the schema does not store them and no code checks them:
 
 | Constraint | Parsed? | Stored? | Enforced? |
 | --- | --- | --- | --- |
-| `UNIQUE (col)` | yes | no | **no** |
-| `UNIQUE` column constraint | yes | no | **no** |
 | `CHECK (expr)` | yes | no | **no** |
 | `FOREIGN KEY ... REFERENCES ...` | yes | no | **no** |
-| `CREATE UNIQUE INDEX` | yes | definition stored | **no** |
 
 Consequences to internalize:
 
-- Duplicate values are allowed in any column except the primary key. If you need uniqueness, enforce it in your application.
+- Duplicate values are allowed in any column except the primary key and non-`NULL` unique-indexed columns.
 - Foreign-key relationships are not validated; deleting a parent row does not cascade, restrict, or set null on children.
 - `CHECK` constraints never run. `CREATE TABLE t (age INTEGER CHECK (age > 0))` happily accepts `-5`.
-- `CREATE UNIQUE INDEX` records an index marked `unique` in the catalog (it shows up in `pg_indexes`), but the uniqueness flag is cosmetic — the index still only accelerates lookups and does not reject duplicates.
 
 ## Why this matters for migrations
 
-SQLite-flavoured schemas often lean on `UNIQUE` and `FOREIGN KEY` for integrity. When importing such a schema (SQL dump or SQLite file), those constraints are **dropped silently** rather than erroring. Inspect the imported schema afterwards and add application-level checks for anything that must stay unique or referentially consistent.
+SQLite-flavoured schemas often lean on `UNIQUE` and `FOREIGN KEY` for integrity. `UNIQUE` constraints now import into enforced unique indexes, but `FOREIGN KEY` is still **dropped silently** rather than erroring. Inspect the imported schema afterwards and add application-level checks for anything that must stay referentially consistent.
 
 See [Compatibility](/sql-reference/compatibility/) for the wider gap list, and [Indexes](/engine/indexes/) for how `CREATE INDEX`/`UNIQUE INDEX` actually behave at the storage layer.

+ 2 - 1
src/content/docs/sql-reference/data-types.md

@@ -33,7 +33,7 @@ This is substring-based and case-insensitive. `VARCHAR(255)`, `TEXT`, `NCHAR`, a
 | `BLOB` | BLOB | stored as a string in practice |
 | `BOOLEAN`, `BOOL` | BOOLEAN | stored as 1/0 |
 
-There is no `ARRAY`, `JSONB`-specific, `UUID`, `SERIAL`, or `ENUM` type. There are no schemas or user-defined types.
+There is no `ARRAY`, `JSONB`-specific, `SERIAL`, or `ENUM` type. There are no schemas or user-defined types. `UUID` is accepted as a type name but is stored with **TEXT affinity** — it is a plain text column with no native PostgreSQL UUID semantics.
 
 ## How values are actually stored
 
@@ -61,6 +61,7 @@ Values are coerced on use, not on insert. The key rules:
 Every row carries an implicit **`_rowid_`**, even when no `INTEGER PRIMARY KEY` is declared:
 
 - `rowid`, `oid`, and `_rowid_` all refer to the same value.
+- An **explicit column** named `rowid`, `oid`, or `_rowid_` takes precedence over the hidden rowid alias, matching SQLite — so a table with a real `oid TEXT` column resolves `oid` to that text column, not the integer rowid.
 - `INTEGER PRIMARY KEY` (exactly an integer type, single column) **aliases the rowid** — the primary-key value *is* the rowid, and inserting without a value auto-assigns the next one.
 - Any other primary key (e.g. `TEXT PRIMARY KEY`, or a table-level PK) is a normal column; the rowid is a separate, invisible counter maintained in parallel.
 - `_rowid_` is not included in `SELECT *`; reference it explicitly (`SELECT rowid, * FROM t`).

+ 15 - 3
src/content/docs/sql-reference/functions.md

@@ -100,6 +100,21 @@ Date/time functions accept SQLite-style time values and modifiers:
 | `pizzasql_version()` | engine build version |
 | `sqlite_version()` | same value, for SQLite compatibility |
 
+### Session state
+
+These mirror SQLite's connection-local counters and are tracked per connection:
+
+| Function | Description |
+| --- | --- |
+| `last_insert_rowid()` | rowid of the most recent successful `INSERT` on this connection; `0` before any insert |
+| `changes()` | number of rows changed by the most recent `INSERT`/`UPDATE`/`DELETE` |
+| `total_changes()` | total rows changed since this connection opened; monotonic, never decremented by `ROLLBACK` |
+
+Notes:
+
+- `last_insert_rowid()` reflects the actual generated rowid (not a naive counter or `MAX`), including for multi-row `INSERT` and `INSERT ... SELECT`, and holds across `ROLLBACK`.
+- `total_changes()` is incremented by every completed DML statement even when the change is later undone, matching SQLite.
+
 ## Declared but not implemented
 
 These names are recognized by the parser/analyzer but **return `NULL` when called**. Treat them as unsupported:
@@ -110,11 +125,8 @@ These names are recognized by the parser/analyzer but **return `NULL` when calle
 - `iif`
 - `quote`
 - `total`, `group_concat`
-- `last_insert_rowid`, `changes`, `total_changes`
 - `randomblob` is implemented in the executor but not registered with the analyzer, so calls are currently rejected as an unknown function.
 
-The `last_insert_rowid` gap matters in practice: after an `INSERT` with an auto-generated key, there is no built-in function to retrieve the generated id. If you need it, insert an explicit value instead of relying on auto-generation.
-
 ## Caveats
 
 - `round` uses `int64(v*mult + 0.5)` and only behaves correctly for non-negative decimal counts; treat it as approximate for edge cases.

+ 14 - 5
src/content/docs/sql-reference/statements.md

@@ -60,7 +60,7 @@ INSERT INTO table ... ON CONFLICT [(pk)] DO UPDATE SET col = expr, ...;
   - `INSERT OR REPLACE` deletes the conflicting row and inserts the new one.
   - `INSERT OR FAIL`/`OR ABORT` abort on the first duplicate.
   - `ON CONFLICT (target) DO NOTHING` / `DO UPDATE SET ...` work only when the conflict is on the primary key; the `(target)` list must name the PK column (or be omitted).
-- Auto-generated integer primary keys (and the implicit rowid) are assigned when no PK value is supplied. There is **no `RETURNING`** and no way to read back the generated id in the same statement; use `SELECT last_insert_rowid()`-style patterns with caution (see [Functions](/sql-reference/functions/) — the rowid functions are not implemented).
+- Auto-generated integer primary keys (and the implicit rowid) are assigned when no PK value is supplied. There is **no `RETURNING`**, but `SELECT last_insert_rowid()` returns the rowid of the most recent successful `INSERT` on this connection (see [Functions](/sql-reference/functions/)).
 
 ## UPDATE and DELETE
 
@@ -87,9 +87,10 @@ CREATE TABLE [IF NOT EXISTS] table (
 ```
 
 - `IF NOT EXISTS` silently succeeds if the table already exists.
-- Only `PRIMARY KEY`, `NOT NULL`, and `DEFAULT` have an effect. `AUTOINCREMENT` is accepted but adds no behavior; `UNIQUE`, `CHECK`, and `FOREIGN KEY` are parsed and discarded — see [Constraints](/sql-reference/constraints/).
+- `PRIMARY KEY`, `NOT NULL`, and `DEFAULT` have an effect; `AUTOINCREMENT` is accepted but adds no behavior. Inline and table-level `UNIQUE` constraints are materialized into unique indexes and enforced on the core write paths — see [Constraints](/sql-reference/constraints/). `CHECK` and `FOREIGN KEY` are still parsed and discarded.
 - A table-level `PRIMARY KEY (a, b)` uses the first named column as the primary key; composite keys are not truly supported.
-- `DEFAULT expr` is evaluated when the table is created, so it must be a constant expression.
+- `DEFAULT expr` is evaluated when the table is created, so it must be a constant expression. Signed numeric literals (`DEFAULT -1`, `DEFAULT +5`, `DEFAULT (-7)`) are accepted.
+- A bare `NULL` column specifier is accepted as a no-op (columns are nullable by default); `NOT NULL` still applies whenever it appears.
 - `AUTOINCREMENT` is accepted but has no extra behaviour over a plain `INTEGER PRIMARY KEY`.
 
 ## ALTER TABLE
@@ -116,7 +117,7 @@ DROP VIEW [IF EXISTS] view;
 ```
 
 - Index definitions persist, but index entries are rebuilt in memory and only used for single-column equality lookups — see [Indexes](/engine/indexes/).
-- `UNIQUE` indexes are recorded as unique in the catalog but **uniqueness is not enforced**.
+- `UNIQUE` indexes are recorded as unique in the catalog **and enforced** on the core write paths via validating scans; `CREATE UNIQUE INDEX` rejects a table that already contains duplicate non-`NULL` values.
 - Views are **connection-local and in-memory**: they exist only within the connection that created them and are not persisted or shared. See [Catalog & schema](/internals/catalog/).
 
 ## Transactions
@@ -138,6 +139,9 @@ ROLLBACK TO name;
 
 ```sql
 PRAGMA table_info(t);      -- column list for table t
+PRAGMA table_xinfo(t);     -- table_info columns plus a trailing hidden flag
+PRAGMA index_list(t);      -- indexes on table t (seq, name, unique, origin, partial)
+PRAGMA index_info(idx);    -- columns of index idx (seqno, cid, name)
 PRAGMA table_list;         -- all tables
 PRAGMA database_list;      -- attached databases
 PRAGMA version;            -- engine version
@@ -146,9 +150,14 @@ EXPLAIN stmt;              -- simplified opcode listing
 EXPLAIN QUERY PLAN stmt;   -- SCAN/FILTER/SORT/LIMIT description
 ```
 
-- Only the four `PRAGMA` forms above are implemented; any other pragma name errors.
+- `table_xinfo`, `index_list`, and `index_info` exist to satisfy SQLite migrators (GORM/XORM); they are answered from the durable schema, not a real SQLite file.
+- Only the `PRAGMA` forms above are implemented; any other pragma name errors.
 - `EXPLAIN` output is **illustrative only** — it does not reflect the real execution engine (there is no cost-based planner; see [Query lifecycle](/internals/query-lifecycle/)).
 
+## SQLite catalog tables
+
+`sqlite_master` and `sqlite_schema` are emulated as **read-only** virtual tables whose rows are synthesized from the durable schema (`type`, `name`, `tbl_name`, `rootpage`, `sql`). Single-table `SELECT` queries against them work for migrator introspection; they are not real writable SQLite catalog tables and are not persisted. See [PostgreSQL protocol](/internals/postgres-protocol/).
+
 ## ATTACH / DETACH DATABASE
 
 ```sql