Bläddra i källkod

Merge pull request #2 from database-pizza/docs/engine-contracts

docs: engine contracts for compatibility, DDL, JSON, rowid, limits, and TLS
Danilo Fragoso 16 timmar sedan
förälder
incheckning
4522906731

+ 11 - 3
src/content/docs/clients/postgresql.md

@@ -8,14 +8,14 @@ PizzaSQL implements a **partial** PostgreSQL wire protocol, which lets *some* Po
 Before you connect anything, two facts matter:
 
 - **The SQL is SQLite-style, not PostgreSQL.** The dialect differences in [Compatibility](/sql-reference/compatibility/) apply to every client.
-- **TLS is not available.** The proxy currently declines SSL, so your API key and query traffic travel in cleartext over the wire. Use `sslmode=disable`, and do not send sensitive data over an untrusted network.
+- **TLS terminates at the managed proxy when the deployment enables it.** With a certificate installed, the proxy offers TLS by default (mode `optional`): `sslmode=prefer`/`require` clients upgrade automatically and existing `sslmode=disable` clients keep working. The operator can set `PGPROXY_TLS_MODE=required` to reject plaintext with `08P01`; clients pinned to `sslmode=disable` (including `ssl: false` in node-postgres) then fail until they enable TLS. If SSL negotiation is declined, the deployment has not installed a certificate yet: use `sslmode=disable` only over a trusted hop. TLS never covers the proxy-to-engine loopback hop.
 
 The connection parameters are described in [Connect](/getting-started/connect/):
 
 - Host `db.database.pizza`, port `5432`.
 - Database name `acme/production` (slash included).
 - Password is your API key; the user field is ignored.
-- `sslmode=disable` — TLS is currently declined by the proxy.
+- `sslmode=require` when the deployment serves TLS; `sslmode=disable` only when SSL negotiation is declined or the hop is trusted.
 
 ## Connection URI
 
@@ -58,7 +58,7 @@ Some GUI tools connect using the connection type **PostgreSQL**, but support var
 - **Database**: `acme/production`
 - **User**: `u` (any value)
 - **Password**: your API key
-- **SSL**: off / disable
+- **SSL**: require (or disable only on a deployment that declines TLS)
 
 Most GUIs use discrete fields, so enter `acme/production` literally — no `%2F` encoding needed. A GUI that runs introspection beyond the [emulated catalog subset](/internals/postgres-protocol/) may fail; test it against your actual schema before relying on it.
 
@@ -108,6 +108,14 @@ conn.close()
 
 See [Python](/clients/python/) for more.
 
+#### Array parameters are rejected
+
+`WHERE id = ANY($1)` with a text[]/int[] parameter returns SQLSTATE `0A000`. The engine has no array type and does not expand a bound array; rewrite the query with an `IN (?, ?, ...)` list. This is documented in [Data types](/sql-reference/data-types/) and reported by the capability document.
+
+#### Discovery and quota errors
+
+Read the real build from `pizzasql_version()` or the `pizzasql.version` ParameterStatus; `server_version` remains the PostgreSQL compatibility string. `GET /capabilities` (engine HTTP API) serves the machine-readable capability document. When the per-organization connection quota is full, the proxy returns SQLSTATE `53300` with the current/limit numbers and a retry hint; retry after a short backoff or reduce the client pool size. See [Limits & quotas](/guides/limits/#connections-and-idle-disconnects).
+
 ### Go — `pgx` / `lib/pq`
 
 ```go

+ 42 - 0
src/content/docs/engine/concurrency.md

@@ -60,6 +60,48 @@ Implications:
 
 Below the SQL layer, a **connection pool** to the PizzaKV key-value store fans out individual key operations. Pool connections are checked out per operation and returned afterward; a timeout or short read on a pooled connection closes it and replaces it rather than reusing a possibly-corrupt connection. This is internal detail, but it means key-value I/O is safe to issue from many goroutines (e.g. `INSERT ... SELECT` writes rows concurrently).
 
+## Isolation guarantees
+
+The model is **optimistic serialization at commit**, not snapshot isolation:
+
+- A transaction observes rows and table/predicate generations as it reads. At commit, PizzaKV applies the staged writes only if every observed version still matches; otherwise the commit fails with SQLSTATE `40001` and nothing from the batch is written.
+- Concurrent writers to the **same row** cannot both commit: one wins, the other gets `40001` and must retry.
+- Concurrent inserts and deletes that a scanned predicate would have seen bump a predicate generation, so a transaction that decided something from a scan is invalidated by a conflicting insert/delete and also gets `40001`. This covers the common phantom case without MVCC.
+- **Write skew is possible.** Two transactions can read overlapping sets, write disjoint rows, and both commit, producing a state no serial order would allow. There are no predicate locks to prevent this. If a business invariant spans rows that different transactions write, enforce it with a single atomic statement (`UPDATE ... WHERE invariant`, `INSERT ... ON CONFLICT`) or serialize those transactions through one contended row.
+- Long-running reads do not hold a snapshot. Each statement sees the latest committed state plus the transaction's own staged writes, so re-reading a row inside a transaction can return a different value than the first read.
+- DDL is not transactional and is never rolled back by `ROLLBACK`.
+
+## Retry recipe
+
+Treat `40001` as retryable and retry the **whole transaction** with bounded exponential backoff and jitter. Do not retry a single statement inside a transaction: its reads are no longer a valid basis for the commit.
+
+```text
+for attempt in 0..max_attempts:
+    BEGIN
+    ... reads and writes ...
+    COMMIT
+    on SQLSTATE 40001:
+        ROLLBACK (ignore "no transaction in progress")
+        sleep(base * 2^attempt + jitter)
+        continue
+    on success: return
+```
+
+Keep each attempt short, cap the total retry budget, and surface a permanent failure after the cap. HTTP clients see the same class as `409` with `"code": "40001"`; PostgreSQL clients see `40001`.
+
+## Row locks
+
+There are no row locks. `SELECT ... FOR UPDATE`, `FOR SHARE`, `FOR NO KEY UPDATE`, `NOWAIT`, and `SKIP LOCKED` are rejected with SQLSTATE `0A000` instead of being accepted without locking. Do not build a queue or claim loop that depends on `FOR UPDATE SKIP LOCKED`.
+
+### Safe claim patterns
+
+- **Atomic compare-and-set:** `UPDATE jobs SET owner = ?, state = 'claimed' WHERE id = ? AND state = 'pending'`; a `rows_affected` of `1` means this worker owns the job, `0` means another worker won. This is a single statement, so it is atomic without row locks.
+- **Unique-index arbitration:** insert a row into a `UNIQUE` table and treat `23505` as "already claimed". Pair it with `ON CONFLICT DO NOTHING` to make the probe non-throwing.
+- **Optimistic version column:** read `version`, write `WHERE id = ? AND version = ?`, and retry on `rows_affected = 0`. Do not hold a transaction open across user think-time.
+- **Leases:** store `lease_until`; a reclaim is an `UPDATE ... WHERE lease_until < now()`, which is again a single atomic statement. Clock skew makes this approximate; prefer a server-generated timestamp if it matters.
+
+If a workflow genuinely needs to hold a row while calling an external service, redesign it as a state machine with short transactions rather than waiting for row locks.
+
 ## Practical guidance
 
 - Keep transactions short to reduce conflicts and the amount of work discarded by a retry.

+ 4 - 4
src/content/docs/getting-started/connect.md

@@ -18,7 +18,7 @@ Connect to `db.database.pizza` on port `5432` the way you would a PostgreSQL ser
 
 1. **The database name is `acme/production`** — organization and database joined by a slash. It's a single name, not two path segments.
 2. **The password is your API key.** The user field is ignored; you can use any value (the examples use `u`).
-3. **TLS is declined.** The proxy currently answers SSL negotiation with `N`, so connect with `sslmode=disable`. Don't require or verify TLS here — it will fail.
+3. **TLS is deployment-dependent.** The managed proxy supports TLS termination; use `sslmode=require` when it is enabled. If SSL negotiation is answered with `N`, the deployment has no certificate installed: use `sslmode=disable` over a trusted hop.
 
 ### Connection string
 
@@ -64,14 +64,14 @@ The HTTP surface also exposes a batch endpoint, schema introspection, and a heal
 | Database | `acme/production` | path segment `/acme/production` |
 | User | ignored (use `u`) | — |
 | Password / token | API key | `Authorization: Bearer <key>` |
-| SSL | `sslmode=disable` (TLS declined) | HTTPS only |
+| SSL | `sslmode=require` when enabled; `disable` only if negotiation is declined | HTTPS only |
 
 ## Which should I use?
 
 - **PostgreSQL wire protocol** is useful when you have a tested Postgres driver or `psql`-style workflow. It is the only surface that supports transactions (optimistic DML transactions — see [Transactions](/engine/transactions/)). It is **not** a full PostgreSQL surface: the SQL dialect is SQLite-style, and compatibility is partial.
 - **HTTP query API** is best for serverless functions, edge workloads, and environments where a long-lived database connection isn't practical. Transactions are **not** supported over HTTP — use the Postgres protocol when you need them. Do not expose a database API key in browser code.
 
-> **TLS warning.** The PostgreSQL proxy currently **declines SSL**. All wire-protocol connections use `sslmode=disable`, so your API key and query traffic are **sent in cleartext**. Avoid using the wire protocol for sensitive data over an untrusted network; prefer the HTTPS-only HTTP API when confidentiality matters. See [API keys & permissions](/clients/api-keys/).
+> **TLS warning.** TLS terminates at the managed proxy when the deployment has a certificate; `sslmode=require` then protects the client-to-proxy hop. The proxy-to-engine loopback hop remains plaintext, and a deployment without a certificate declines SSL entirely. Prefer the HTTPS-only HTTP API when you need end-to-end transport security. See [API keys & permissions](/clients/api-keys/).
 
 Both surfaces enforce the same API key scopes. See [API keys & permissions](/clients/api-keys/) and [PostgreSQL clients](/clients/postgresql/) for idiomatic client examples.
 
@@ -79,7 +79,7 @@ Both surfaces enforce the same API key scopes. See [API keys & permissions](/cli
 
 - **`invalid_password` / "authentication failed"** — the key is wrong, revoked, or lacks `DB_ACCESS` permission. Check the key and its scopes.
 - **`invalid_catalog_name` / "database must be org/db"** — the database name doesn't contain the `/`. Use `acme/production`.
-- **SSL negotiation fails** — you forced TLS. Use `sslmode=disable`.
+- **SSL negotiation fails** — this deployment declined SSL negotiation, so it has no proxy certificate configured. Use `sslmode=disable` over a trusted hop, or ask the operator to enable TLS.
 - **`invalid api key`** — the key isn't a valid live key, or you pasted it with whitespace.
 
 More error cases are covered in [Errors & troubleshooting](/guides/errors/).

+ 1 - 1
src/content/docs/getting-started/quickstart.md

@@ -74,7 +74,7 @@ PGPASSWORD='pz_live_REPLACE_ME' psql \
 Three details matter here:
 
 - The **database name is `acme/production`** — the slash is part of the name. In a connection URI it must be percent-encoded as `acme%2Fproduction`.
-- **TLS is currently declined** by the proxy, so connect with `sslmode=disable`; your key and queries travel in cleartext over the wire.
+- **TLS depends on the deployment**: use `sslmode=require` when the proxy serves a certificate, and `sslmode=disable` only when SSL negotiation is declined or the hop is trusted. Even with TLS, the proxy-to-engine loopback hop is plaintext.
 - The wire protocol is **partially** PostgreSQL-compatible — `psql` works, but the SQL you send is SQLite-style, not PostgreSQL. See [PostgreSQL clients](/clients/postgresql/) and [Compatibility](/sql-reference/compatibility/).
 
 Full details are in [Connect](/getting-started/connect/) and [PostgreSQL clients](/clients/postgresql/).

+ 1 - 1
src/content/docs/guides/errors.md

@@ -98,7 +98,7 @@ This is by design — the HTTP API is stateless. Use the [PostgreSQL protocol](/
 
 ## Connection troubleshooting
 
-- **SSL negotiation fails** — you're forcing TLS. The proxy declines SSL; connect with `sslmode=disable`.
+- **SSL negotiation fails** — the deployment declined SSL negotiation, so it has no proxy certificate installed. Connect with `sslmode=disable` over a trusted hop, or ask the operator to enable TLS on the proxy.
 - **`database must be "org/db"`** — the database name in your connection string is missing the `/` separator.
 - **URI parse errors** — remember to percent-encode the database name's slash: `acme%2Fproduction`.
 

+ 15 - 3
src/content/docs/guides/limits.md

@@ -16,9 +16,15 @@ Quotas apply per organization and cover four dimensions:
 | Databases | 3 | Active databases in the organization |
 | Storage | 100 MB | Allocated on-disk space for the organization |
 | Queries | 500,000 / week | Queries across all surfaces |
-| Connections | 100 concurrent | Open PostgreSQL connections |
+| Connections | 8 concurrent (compiled fallback) | Open PostgreSQL connections |
 
-Queries and bytes-read are counted per organization per week; concurrent connections are measured at any instant. Usage is visible in the dashboard's **Quota** view.
+Queries and bytes-read are counted per organization per week; concurrent connections are measured at any instant. Usage is visible in the dashboard's **Quota** view and through `GET /api/v1/orgs/{id}/quota`, which returns `usage.active_connections` alongside `quota.max_concurrent_connections`. The dashboard value is authoritative: organizations normally have an explicit quota row, so the compiled fallback of 8 only applies when no override exists.
+
+### Connections and idle disconnects
+
+When the connection quota is exhausted, the proxy returns SQLSTATE `53300` with the current and limit numbers (for example `8/8 connections active`) and a retry hint in the message, plus a `connection_quota` detail. It briefly waits for a slot to free before failing. Treat `53300` as retryable with backoff and check for leaked client-side pool connections.
+
+Neither the proxy nor the engine sets an idle timeout. A connection that goes away after sitting idle is closed by the network path (NAT/firewall state expiry) or by the client pool, not by database.pizza. The proxy enables TCP keepalive on both hops (30s idle, 10s interval) so state stays warm and a dead peer is detected, but clients should still enable keepalive, bound pool `max_lifetime`/`idle_timeout`, and retry a connection that fails its first statement.
 
 When you hit a quota:
 
@@ -53,7 +59,13 @@ Imports of existing SQLite databases skip several constructs: views, triggers, p
 
 ## TLS and connectivity
 
-The PostgreSQL proxy currently declines SSL, so all wire-protocol connections use `sslmode=disable`. The HTTP surface is HTTPS-only. Plan around this for sensitive data — see the security notes in [API keys & permissions](/clients/api-keys/).
+The managed PostgreSQL proxy supports TLS termination with a deployment setting (`PGPROXY_TLS_MODE`):
+
+- `optional` (default when a certificate is installed) offers TLS; `sslmode=prefer`/`require` clients upgrade, while `sslmode=disable` clients keep working.
+- `required` rejects plaintext startup with `08P01`. Clients pinned to `sslmode=disable` or `ssl: false` must be updated first.
+- `off` disables TLS even if certificate paths are configured.
+
+If SSL negotiation is declined, that deployment has no certificate installed and only `sslmode=disable` works; use it only over a trusted hop. The HTTP surface is HTTPS-only. TLS ends at the proxy: the loopback hop to PizzaSQL is plaintext. See the security notes in [API keys & permissions](/clients/api-keys/) and the client impact matrix in [PostgreSQL clients](/clients/postgresql/#discovery-and-quota-errors).
 
 ## Related
 

+ 1 - 1
src/content/docs/index.md

@@ -31,7 +31,7 @@ Every database gives you a managed access surface plus a dashboard:
 
 All programmatic access is authenticated with **API keys**. Keys carry granular scopes — `read`, `write`, `alter_table`, `drop_table` — so you can issue a read-only key to a reporting job and a write key to an ingestion service. See [API keys & permissions](/clients/api-keys/).
 
-> **Before you connect anything:** the PostgreSQL wire protocol is **partial**, and the managed proxy currently **declines TLS** (`sslmode=disable`). Credentials and query traffic over the wire are therefore **not encrypted** on an untrusted network. Verify that your specific client, driver, or ORM works before committing to it. Read [PostgreSQL protocol](/internals/postgres-protocol/) and [Connect](/getting-started/connect/).
+> **Before you connect anything:** the PostgreSQL wire protocol is **partial**, and TLS availability depends on the deployment: use `sslmode=require` when the managed proxy serves a certificate, and `sslmode=disable` only when SSL negotiation is declined or the hop is trusted. Credentials and query traffic are not encrypted end to end even with TLS, because the proxy-to-engine loopback hop is plaintext. Verify that your specific client, driver, or ORM works before committing to it. Read [PostgreSQL protocol](/internals/postgres-protocol/) and [Connect](/getting-started/connect/).
 
 ## A taste
 

+ 3 - 3
src/content/docs/internals/postgres-protocol.md

@@ -10,7 +10,7 @@ PizzaSQL implements a **partial** PostgreSQL wire protocol (v3.0) so that *some*
 ### Startup and auth
 
 - Accepts the standard startup message and negotiates the protocol version.
-- **SSL is not supported**: an SSLRequest is answered with `N` (decline), after which the client should retry without SSL.
+- **TLS is optional**: an SSLRequest is answered with `S` when the proxy has a certificate and the rest of the session runs over TLS; otherwise it is answered with `N` (decline) and the client retries without SSL. `PGPROXY_REQUIRE_TLS` rejects plaintext startup with `08P01`.
 - Authentication is `AuthenticationOk` (no password required) at the engine level.
 - The server advertises these parameter statuses:
   - `server_version` = `14.0 (PizzaSQL)` (so drivers requiring PG 9.x+ are satisfied)
@@ -60,7 +60,7 @@ Message frames are size-capped (`16 MiB` for normal messages, `1 MiB` for startu
 
 On database.pizza, customers do **not** reach the engine directly. A proxy listens at `db.database.pizza:5432` and adds authentication and routing:
 
-1. The client connects and the proxy declines SSL, then requests a **cleartext password**.
+1. The client connects, optionally negotiates TLS, and the proxy requests a **cleartext password** (API key).
 2. The password is your **API key**. The proxy verifies it, checks it belongs to the organization and (if scoped) the database.
 3. The database name in the connection string must be **`org/db`** (e.g. `acme/production`). In a connection URI the slash must be percent-encoded: `acme%2Fproduction`.
 4. On success, the proxy opens a TCP tunnel to the engine's PostgreSQL port with the resolved internal **namespace**, and forwards protocol frames in both directions.
@@ -80,7 +80,7 @@ PGPASSWORD='pz_live_REPLACE_ME' psql \
 
 ## What this means for you
 
-- **TLS is not available.** The proxy declines SSL, so connections use `sslmode=disable` (or your client's equivalent) and your API key and query traffic travel in cleartext. Do not use the wire protocol for sensitive data over an untrusted network; the HTTP API is HTTPS-only.
+- **TLS terminates at the proxy, when enabled.** With a certificate installed, `sslmode=require` protects the client-to-proxy hop. Without one, SSL negotiation is declined and the API key travels in cleartext; use that only over a trusted hop. The proxy-to-engine loopback hop is plaintext in both cases. The HTTP API is HTTPS-only.
 - Your PostgreSQL *password* is the API key; the user is ignored.
 - The database name is `org/db`, not a bare name.
 - Only the catalog tables listed above are available to tools for introspection; anything deeper fails.

+ 36 - 13
src/content/docs/sql-reference/compatibility.md

@@ -17,6 +17,24 @@ This page describes behavior verified in the current source and tests. The SQLit
 | **Unsafe** | Syntax may be accepted, but behavior is incorrect or not enforced. |
 | **Unverified** | Expected or claimed, but not covered by reliable automated evidence. |
 
+## Engine build and capability discovery
+
+Every managed PostgreSQL endpoint reports three separate identities so a client can pin behavior:
+
+| Surface | Value |
+| --- | --- |
+| `server_version` ParameterStatus / `SHOW server_version` / `version()` | PostgreSQL compatibility string (`14.0 (PizzaSQL)`) |
+| `pizzasql.version` and `pizzasql.commit` ParameterStatus | the real engine build and source commit |
+| `SELECT pizzasql_version()` | the engine build, independent of the PostgreSQL compatibility string |
+| `GET /version` on the engine HTTP API | `version`, `commit`, `date`, `postgres_compat`, and `sqlite_compat` |
+| `GET /capabilities` on the engine HTTP API | machine-readable capability document (`schema_version`, constraints, DDL, transactions, types, parameters, JSON, error codes, docs links) |
+
+The capability document is versioned by its own `schema_version` and names the engine `engine_version` it describes. The pg-wire build identity and the engine HTTP document are the sources of truth; use them to delete version-specific workarounds rather than sniffing `server_version`.
+
+Compatibility is versioned with each docs release: a release note records the PizzaSQL build, and the compatibility page and capability document for that build are published together. Behavior that clients can observe follows semantic versioning — removals and semantic changes require a major release, additive capabilities a minor release, and fixes a patch release. A behavior change is announced in the release notes, the capability document is updated in the same release, and the old behavior is deprecated for at least one minor release before removal. Client-visible SQLSTATE codes are part of the contract and are not changed in a patch release.
+
+The engine repository also runs a pChat-style compatibility suite (`make compat`) on every CI run. It covers DDL, upsert idempotency, UNIQUE enforcement, pagination, parameters, transaction state, and error codes; results are published as a CI artifact. Release tags run it in strict mode, where a missing documented capability fails the gate.
+
 ## Positioning
 
 | Dimension | PizzaSQL |
@@ -32,8 +50,11 @@ This page describes behavior verified in the current source and tests. The SQLit
 
 Do not rely on the following behavior yet:
 
-- `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.
+- `CHECK` and foreign-key declarations are rejected at DDL with SQLSTATE `0A000`; they are never accepted and discarded. `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 are rejected at DDL with SQLSTATE `0A000`; they are never silently reduced to the first column.
+- Non-constant column defaults (`DEFAULT CURRENT_TIMESTAMP`, function calls) are rejected at DDL with `0A000`; there is no per-INSERT default evaluation.
+- Array parameters and `ANY`/`ALL` predicates are rejected with `0A000`; rewrite them as `IN (...)` with individual parameters.
+- `SELECT ... FOR UPDATE` and `NOWAIT`/`SKIP LOCKED` are rejected with `0A000`; transactions are optimistic and have no row locks.
 - `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.
@@ -88,26 +109,27 @@ Unsupported syntax normally returns an error. The items above are called out sep
 | --- | --- | --- | --- |
 | `CREATE TABLE` and `DROP TABLE` | Supported | Supported | Supported |
 | `CREATE TABLE IF NOT EXISTS` | Supported | Supported | Supported |
-| `ALTER TABLE ADD/DROP/RENAME` | Partial | Partial | Supported |
+| `ALTER TABLE ADD/DROP/RENAME` | Partial; no backfill on ADD, DROP rewrites rows, RENAME leaves index metadata stale | Partial | Supported |
 | Altering column type, default, or nullability | Unsupported | Limited | Supported |
 | `CREATE INDEX` and `DROP INDEX` | Partial; indexes are rebuilt from stored rows | Supported | Supported |
 | `CREATE INDEX IF NOT EXISTS` | Supported in current source; deployed versions may differ | Supported | Supported |
 | Views | Partial; not persisted across executor restarts | Persistent | Persistent |
 | Triggers | Unsupported | Supported | Supported |
 | Single-column primary keys | Supported | Supported | Supported |
-| Composite primary keys | Unsafe; only the first column is retained | Supported | Supported |
+| Composite primary keys | Rejected with `0A000` | Supported | Supported |
 | `NOT NULL` | Supported | 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 |
-| Expression defaults | Partial | Supported | Supported |
-| `DEFAULT CURRENT_TIMESTAMP` | Unsafe; does not evaluate per inserted row | Supported | Supported |
+| `CHECK` constraints | Rejected with `0A000` | Supported | Supported |
+| Foreign keys | Rejected with `0A000`; cascades are not implemented | Supported when enabled | Supported |
+| Literal defaults | Supported (constant values) | Supported | Supported |
+| Expression defaults | Rejected with `0A000` | Supported | Supported |
+| `DEFAULT CURRENT_TIMESTAMP` | Rejected with `0A000`; no per-row evaluation | Supported | Supported |
+| Array parameters and `ANY`/`ALL` | Rejected with `0A000` | Not applicable | Supported |
 | `AUTOINCREMENT` | Partial; accepted without distinct SQLite semantics | Supported | Not applicable |
 | Identity and serial columns | Unsupported | Not applicable | Supported |
 | Generated columns | Unsupported | Supported | Supported |
-| Implicit `rowid` | Partial | Supported | Unsupported |
+| Implicit `rowid` | Supported; `rowid`/`oid`/`_rowid_` are `BIGINT` over the wire and form a keyset cursor when auto-assigned | Supported | Unsupported |
 
 ## Types and expressions
 
@@ -123,7 +145,7 @@ PizzaSQL accepts many familiar type names but does not implement PostgreSQL's st
 | Strict PostgreSQL types | Unsupported | Unsupported | Supported |
 | `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` |
+| JSON values and functions | Supported as text plus the JSON1 function set; no `JSONB` type or `->`/`->>` operators | Available with SQLite JSON support | Supported with `JSON` and `JSONB` |
 | 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 |
@@ -175,7 +197,7 @@ PostgreSQL wire compatibility allows some PostgreSQL clients to connect. It does
 | Binary parameters | Partial; limited boolean and integer support | Not applicable | Supported |
 | Binary results | Unsupported | 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 TLS | Supported when the managed proxy is configured with a certificate; sslmode contract below | Not applicable | Supported |
 | PostgreSQL SCRAM and MD5 authentication | Unsupported | Not applicable | Supported |
 | `information_schema` | Partial emulation | Unsupported | Supported |
 | `pg_catalog` | Unsupported | Unsupported | Supported |
@@ -194,7 +216,8 @@ Because the wire is PostgreSQL-compatible, drivers may issue introspection queri
 The managed database.pizza proxy layers additional behavior on top of PizzaSQL:
 
 - API keys with `read`, `write`, `alter_table`, and `drop_table` scopes gate statements.
-- PostgreSQL TLS is currently declined. Connections require `sslmode=disable`, so credentials and query traffic are not protected on an untrusted network.
+- PostgreSQL TLS terminates at the managed proxy when `PGPROXY_TLS_CERT`/`PGPROXY_TLS_KEY` are configured. `PGPROXY_TLS_MODE=optional` (the default with a certificate) offers TLS while keeping `sslmode=disable` clients working; `required` rejects plaintext startup with `08P01`; `off` disables TLS. Clients can use `sslmode=require` (or `verify-full` with a trusted, hostname-matching certificate). If a deployment has not installed certificates, `SSLRequest` is declined and only `sslmode=disable` connects; use that only over a trusted hop. TLS ends at the proxy — the loopback hop to PizzaSQL is plaintext.
+- Exhausting the per-organization connection quota returns SQLSTATE `53300` with the current/limit numbers and a retry hint. Quota and idle-connection behavior are documented in [Limits & quotas](/guides/limits/#connections-and-idle-disconnects).
 - The PostgreSQL database name is `org/db`; the slash must be percent-encoded in a URI.
 - Transaction statements are rejected by the managed HTTP query endpoints and forwarded by the PostgreSQL proxy.
 

+ 15 - 11
src/content/docs/sql-reference/constraints.md

@@ -25,6 +25,8 @@ Constraints in PizzaSQL fall into two camps: the ones that are actually implemen
 
 - `DEFAULT expr` is evaluated when the table is created, then stored and applied to inserted rows that omit the column.
 - Because it's evaluated at DDL time, only constant expressions are useful. There is no `DEFAULT (expr)` re-evaluation per row.
+- A non-constant default (`CURRENT_TIMESTAMP`, `datetime('now')`, a scalar function call, a column reference) is **rejected at DDL with SQLSTATE `0A000`**. Accepting it would silently freeze one value for the life of the table. Declare the column without a default and supply a fresh value on each `INSERT`.
+- A constant `DEFAULT` on `ADD COLUMN` applies to later inserts only; existing rows are not backfilled. See [ALTER TABLE](/sql-reference/statements/#alter-table).
 
 ### AUTOINCREMENT
 
@@ -43,6 +45,10 @@ Constraints in PizzaSQL fall into two camps: the ones that are actually implemen
 
 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/).
 
+Outside an explicit transaction a duplicate is rejected by the `INSERT`/`UPDATE` itself. Inside an explicit transaction, writes are buffered and the unique indexes are validated at **`COMMIT`**, so the offending statement can succeed and the `COMMIT` then fail. A failed `COMMIT` has already rolled the transaction back and leaves the session usable (the next statement starts a new implicit transaction).
+
+The wire protocol reports a duplicate as SQLSTATE `23505` with the violated index name in the message. The managed HTTP query API reports HTTP `409` with `"code": "23505"` and the index name in `details.constraint`. `INSERT ... ON CONFLICT` and `INSERT OR IGNORE`/`REPLACE` remain the idempotent write shapes; a plain insert is not retry-safe by itself.
+
 ### 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:
@@ -53,23 +59,21 @@ The implementation is scan-based: a table with a unique index serializes its cor
 
 Do not assume an existing table's uniqueness is enforced just because the engine now supports `UNIQUE`.
 
-## The non-enforced constraints
+## The rejected constraints
 
-These are accepted by the parser and then **silently discarded** — the schema does not store them and no code checks them:
+`CHECK` and `FOREIGN KEY` are **rejected at DDL** with SQLSTATE `0A000` instead of being accepted and discarded:
 
-| Constraint | Parsed? | Stored? | Enforced? |
-| --- | --- | --- | --- |
-| `CHECK (expr)` | yes | no | **no** |
-| `FOREIGN KEY ... REFERENCES ...` | yes | no | **no** |
+| Constraint | Status | What to do instead |
+| --- | --- | --- |
+| `CHECK (expr)` | Rejected at `CREATE TABLE` and `ALTER TABLE ADD COLUMN` | Validate in the application, or enforce through a unique/foreign-key-free design (a partial unique index is not available either). |
+| `FOREIGN KEY ... REFERENCES ...` | Rejected, including inline `REFERENCES`, table-level `FOREIGN KEY`, named constraints, and `ON DELETE`/`ON UPDATE` actions | Enforce referential integrity in the application; cascades are not implemented. |
 
-Consequences to internalize:
+This is deliberate: accepting the clause while never evaluating it made applications believe a constraint existed. A migration that carries `CHECK`/`FOREIGN KEY` from PostgreSQL or SQLite now fails loudly at the DDL statement and must be rewritten. Gogs and Vikunja declare neither clause; GoatCounter's retired schema does, so a fresh GoatCounter bootstrap is not supported on this engine build.
 
-- 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`.
+Constraints that remain accepted and enforced are `PRIMARY KEY` (single column), `NOT NULL`, `UNIQUE`/unique indexes, and constant `DEFAULT`s. Duplicate values are allowed in any other column.
 
 ## Why this matters for migrations
 
-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.
+SQLite- and PostgreSQL-flavoured schemas often lean on `UNIQUE`, `CHECK`, and `FOREIGN KEY` for integrity. `UNIQUE` imports into enforced unique indexes, but `CHECK`/`FOREIGN KEY` are rejected. Rewrite those DDL statements and add application-level checks for anything that must stay referentially consistent before pointing a migrator at PizzaSQL.
 
 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.

+ 9 - 0
src/content/docs/sql-reference/data-types.md

@@ -35,6 +35,10 @@ This is substring-based and case-insensitive. `VARCHAR(255)`, `TEXT`, `NCHAR`, a
 
 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.
 
+`JSON` and `JSONB` are accepted as column type names but have no native JSON semantics. By SQLite's type-name rules they fall through to NUMERIC affinity (see the table above), so declare `TEXT` when the affinity matters; in practice the engine stores the JSON text you insert and reports `TEXT` (OID 25) over the PostgreSQL wire. Use `json_extract`/`json_set` for queries and updates (see [Functions](/sql-reference/functions/#json)); the PostgreSQL `->` and `->>` operators do not exist. Values are not validated on write: store valid JSON text and use `json_valid(...)` when the source is untrusted.
+
+Array types and array parameters are not implemented. `id = ANY($1)`, `ANY(...)`, and `ALL(...)` are rejected with SQLSTATE `0A000`; there is no silently-wrong empty result. Rewrite the predicate as `IN (?, ?, ...)` with individual parameters, or as an explicit temporary table. This also avoids the statement-cache churn of building an `IN` list by string concatenation.
+
 ## How values are actually stored
 
 Rows use a versioned binary encoding (`PZSQLROW`) with a legacy JSON fallback for values the codec cannot represent. Each value carries a one-byte type tag, so it round-trips without the float64 lossiness of the old JSON encoding:
@@ -65,6 +69,11 @@ Every row carries an implicit **`_rowid_`**, even when no `INTEGER PRIMARY KEY`
 - `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`).
+- Over the PostgreSQL wire, the hidden alias is typed **`BIGINT`** (OID 20) rather than `TEXT`. The value is a signed 64-bit integer.
+- `_rowid_` is **monotonic when the engine assigns it**: tables without an `INTEGER PRIMARY KEY` get 1, 2, 3, ... from a durable per-table counter, and the counter survives restarts. `WHERE _rowid_ > $1 ORDER BY _rowid_ LIMIT n` is therefore a correct keyset cursor: concurrent inserts append larger rowids and a page never repeats a row it already returned.
+- On a table with a single `INTEGER PRIMARY KEY`, the inserted key **is** the rowid. Monotonicity then depends on the application: inserting `id = 5` after `id = 30` produces a smaller rowid. Do not use `_rowid_` pagination over client-assigned, non-increasing integer keys.
+- `UPDATE` never rewrites the stored rowid, including an update that changes an `INTEGER PRIMARY KEY`. A cursor remains stable across updates, but on an integer-PK table the rowid can then differ from the current primary-key value.
+- Explicit columns named `rowid`, `oid`, or `_rowid_` shadow the alias, as described above.
 
 ## Type introspection
 

+ 20 - 2
src/content/docs/sql-reference/functions.md

@@ -97,8 +97,26 @@ Date/time functions accept SQLite-style time values and modifiers:
 
 | Function | Description |
 | --- | --- |
-| `pizzasql_version()` | engine build version |
-| `sqlite_version()` | same value, for SQLite compatibility |
+| `pizzasql_version()` | PizzaSQL engine build identity (for example `v0.1.0-3-gabc1234`) |
+| `sqlite_version()` | the documented SQLite compatibility floor, `3.35.0` |
+
+`pizzasql_version()` is deliberately independent of the `server_version`/`version()` PostgreSQL compatibility string and of `sqlite_version()`. See [Engine build and capabilities](/sql-reference/compatibility/#engine-build-and-capability-discovery) for the wire-protocol and HTTP discovery endpoints.
+
+### JSON
+
+JSON lives in ordinary text. These SQLite JSON1 functions are implemented:
+
+| Function | Behavior |
+| --- | --- |
+| `json(text)` | validates and returns the canonical JSON text |
+| `json_valid(text)` | `1` for valid JSON, `0` otherwise (never throws) |
+| `json_type(text)` | `null`, `true`, `false`, `integer`, `real`, `text`, `array`, or `object` |
+| `json_extract(text, path, ...)` | path lookup; returns SQL `NULL` for a missing path |
+| `json_set`, `json_insert`, `json_replace`, `json_remove` | partial document updates |
+| `json_array`, `json_object`, `json_quote` | constructors |
+| `json_group_array` | aggregate that builds a JSON array |
+
+Malformed input to `json`, `json_extract`, or the mutators fails the statement with `malformed JSON`; `json_valid` is the non-throwing probe. There is no binary `JSONB` representation and no `->`/`->>` operator surface; use `json_extract`. Store documents in a `TEXT` column (or a `JSON`-named column, which stores text with NUMERIC affinity and reports `TEXT` on the wire).
 
 ### Session state
 

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

@@ -24,6 +24,8 @@ LIMIT n [OFFSET m];
 - `LIMIT`/`OFFSET` accept numeric expressions.
 - **Set operations**: `UNION`, `UNION ALL`, `INTERSECT`, and `EXCEPT` are supported with standard precedence (`INTERSECT` binds tighter than `UNION`/`EXCEPT`). A compound query's `ORDER BY`/`LIMIT`/`OFFSET` apply to the combined result.
 - **Subqueries** are supported in `FROM` (derived tables require an alias), as scalar expressions, in `IN (SELECT ...)`, and in `EXISTS (SELECT ...)`, including correlated subqueries.
+- **Row-locking clauses are rejected.** `FOR UPDATE`, `FOR SHARE`, `FOR NO KEY UPDATE`, and the `NOWAIT`/`SKIP LOCKED` variants fail with SQLSTATE `0A000`. There are no row locks; use the optimistic transaction model and retry `40001` (see [Concurrency](/engine/concurrency/)).
+- **Array predicates are rejected.** `expr = ANY(...)`, `expr <> ALL(...)`, and array literals fail with `0A000`; rewrite with `IN (?, ?, ...)` and individual parameters.
 
 ### Joins
 
@@ -87,9 +89,10 @@ CREATE TABLE [IF NOT EXISTS] table (
 ```
 
 - `IF NOT EXISTS` silently succeeds if the table already exists.
-- `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.
+- `PRIMARY KEY`, `NOT NULL`, and constant `DEFAULT`s 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 rejected at DDL with SQLSTATE `0A000` (inline and table-level, including named constraints and referential actions). They are not silently discarded; enforce those rules in the application.
 - A table-level composite `PRIMARY KEY (a, b)` is rejected at `CREATE TABLE` (SQLSTATE `0A000`) instead of being reduced to the first column.
-- `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.
+- `DEFAULT expr` is evaluated when the table is created, so it must be a constant expression and is stored as the evaluated value. Signed numeric literals (`DEFAULT -1`, `DEFAULT +5`, `DEFAULT (-7)`), strings, blobs, `NULL`, and boolean literals are accepted. A non-constant default such as `DEFAULT CURRENT_TIMESTAMP`, `DEFAULT datetime('now')`, or `DEFAULT lower('X')` is rejected at DDL with SQLSTATE `0A000` rather than being frozen at table-creation time. There is no per-row default evaluation; supply the value on `INSERT` instead.
 - 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`.
 
@@ -102,9 +105,12 @@ ALTER TABLE table RENAME TO new_name;
 ALTER TABLE table RENAME COLUMN old TO new;
 ```
 
-- `ADD COLUMN` appends a nullable column; `ADD COLUMN IF NOT EXISTS column ...` is also accepted.
-- You cannot drop the primary-key column.
-- Renaming is schema-only; index definitions referencing renamed columns are **not** updated automatically.
+- `ADD COLUMN` appends schema metadata only. **Existing rows are not rewritten or backfilled**: they read `NULL` for the new column even when it declares `NOT NULL` or a `DEFAULT`. The declared default applies only to later `INSERT`s that omit the column. If you need a value on existing rows, run an explicit `UPDATE`.
+- `ADD COLUMN IF NOT EXISTS column ...` is idempotent and is the supported shape for boot-time migrations. The check and schema append are serialized within one engine process, so concurrent runners on the same process do not duplicate the column. Separate engine processes do not coordinate; run schema migrations from a single runner.
+- `DROP COLUMN` rewrites **every remaining row** to remove the field, so its cost is proportional to table size and it holds the table gate for the duration. It also leaves index metadata that references the dropped column untouched; drop those indexes first or recreate them afterwards. The primary-key column cannot be dropped.
+- `RENAME COLUMN` rewrites every row key and does not update index metadata, expression indexes, views, or generated-column SQL that mention the old name. Drop and recreate affected indexes after a rename. `RENAME TO` is schema-only.
+- `ALTER TABLE ADD COLUMN ... DEFAULT expr` follows the same constant-only rule as `CREATE TABLE`: a non-constant default is rejected with `0A000`, and even a constant default does not backfill existing rows.
+- DDL is not transactional and is not rolled back by `ROLLBACK`; a failed multi-step migration must be repaired explicitly.
 
 ## CREATE / DROP INDEX and VIEW