# RepoGrid — Complete Platform Reference for AI Agents

> This file is meant to be read by an AI (or an engineer) that has been handed a
> RepoGrid project and needs to use it correctly. It documents **every feature**
> that the platform exposes through its public HTTP API, and separately every
> feature that exists **only inside the dashboard**. Read it once, then follow
> the integration blueprint in section "Using RepoGrid from a project".
>
> If you are an AI agent: before you write any code that touches RepoGrid, read
> the "Hard rules" section. It lists the failure modes that are easy to get
> wrong (they are all verified against the real server).

---

## 1. What RepoGrid is

RepoGrid is a serverless database platform. Each database has two halves:

- **A backend engine**: either **relational** (SQLite, executed in-process via
  sql.js) or **JSON** (schemaless documents in named collections).
- **A durable store**: one **private GitHub repository per database**, owned by
  the user's own GitHub account.

Every schema change, insert, update, delete and restore is written as a **Git
commit**. That means:

- **Version history is the commit history.** `GET /versions` returns commits,
  newest first. Any read endpoint can be pinned to a past commit with
  `?version=<sha>`.
- **Rollback is restore-to-a-commit.** Restoring writes a NEW commit carrying
  an old tree on top of the current HEAD — it never rewinds or deletes history,
  so a restore is reversible by restoring forward.
- **Data is portable.** The repository on GitHub contains the schema and data
  as ordinary files; a user can clone it, grep it, or delete it at any time.

There are exactly two kinds of databases. A database is one or the other,
never both:

| Type | What it stores | Write path |
|---|---|---|
| `relational` | tables with typed columns, indexes, foreign keys | `POST /sql` (SQLite dialect) or structured `POST /tables` |
| `json` | collections of JSON documents with `_id`, filters, indexes | `POST /docs`, `PATCH /docs/:id`, `DELETE /docs/:id` |

---

## 2. The three values every client needs

A client needs exactly three values. They map to environment variables used
throughout this document:

| Env var | What it is | How you get it |
|---|---|---|
| `REPOGRID_URL` | the API base URL | shown on the dashboard's Settings page (e.g. `https://api-repogrid.vercel.app`) |
| `REPOGRID_DB` | the database id (looks like `db_1a2b3c4d5e6f7890`) | shown under the database name in the dashboard, with a copy button |
| `REPOGRID_KEY` | an API key (looks like `db_...`, shown **once** at creation) | created in the dashboard's API Keys page |

Requests and responses are JSON. There is no SDK; plain HTTPS with a Bearer
token is the whole integration.

---

## 3. Authentication model (four labels)

Every endpoint is labelled by the **weakest credential** that works. Use these
labels as the source of truth:

| Label | Meaning |
|---|---|
| `none` | no authentication required (health probe) |
| `any` | a **dashboard session cookie** **or** an **API key Bearer token** both work |
| `apikey` | **only** a valid API key works; a dashboard session is *rejected with 401* |
| `session` | **only** a signed-in dashboard session works; an API key is rejected |

Bearer header format: `Authorization: Bearer <key>`.

**Three rules that surprise people (all verified):**

1. **JSON writes are API-key-only.** Inserting, updating, deleting documents and
   creating/dropping JSON collections requires a real API key. The dashboard
   itself cannot do these — it is why the dashboard has no JSON write UI.
2. **Relational writes accept a session or a key.** `POST /sql`, creating and
   dropping tables are labelled `any`, so the dashboard's SQL console works.
3. **Management is session-only.** Creating/deleting databases, renaming,
   blocking/unblocking API access, restoring, and managing API keys require the
   owner's dashboard session. An API key can never do any of these — by design,
   so that a leaked key cannot destroy or roll back your data.

API keys are scoped to the whole account (all databases the account owns), are
shown in full exactly once at creation, and can be revoked. A revoked key
stops working immediately.

---

## 4. The API (everything you can do programmatically)

All paths are relative to `REPOGRID_URL` and start with `/api`.

### 4.1 Health probe — `none`

**`GET /api/health`** → `200`

```json
{ "ok": true, "service": "repogrid", "time": "2026-01-01T00:00:00.000Z", "types": ["relational", "json"] }
```

### 4.2 Discovery — `any` (session or key)

**`GET /api/dbs/:id/versions`**

Commit history, newest first. Every element is a point-in-time snapshot:

```json
{
  "database": "db_1a2b3c4d5e6f7890",
  "versions": [
    { "sha": "59a79644f767482370da9cc7a256c45a16af3498", "shortSha": "59a7964",
      "message": "update 1 record(s) in chunk-000001 of orders", "author": "ada",
      "authorEmail": "ada@example.com", "date": "2026-01-01T00:00:00Z" }
  ]
}
```

The `sha` is a **full 40-character commit SHA** and is the value to use in any
`?version=` parameter.

**`GET /api/dbs/:id/schema?version=<full-sha>`**

Relational databases return `{ database, type: "relational", tables: {...}, version }`
where `tables` is a map of table name → column/index definitions. JSON
databases return `{ database, type: "json", collections: [...], version }`
where `collections` is an array of `{ name, indexes, createdAt, updatedAt }`.
Omit `version` (or use a full SHA) to read the schema at HEAD or at a past
commit.

**`GET /api/dbs/:id/stats`**

```json
{
  "database": "db_...",
  "type": "relational",
  "versions": { "count": 18, "latest": { "sha": "...", "message": "..." } },
  "repo": { },            // repository footprint, best-effort
  "rateLimit": { },       // GitHub API telemetry, best-effort
  "cache": { },           // cache hit/miss telemetry
  "counts": {             // relational
    "tables": { "customers": 3, "orders": 6 }, "records": 9
  }
}
```

For JSON databases `counts` is `{ collections: { name: { count, sizeBytes,
chunkCount, ... } }, records, sizeBytes, chunks }`.

**`GET /api/stats`** — global cache telemetry for the whole service.

### 4.3 Relational data — `any` (session or key)

**`GET /api/dbs/:id/tables`** → `{ database, tables: ["customers", "orders"] }`

**`POST /api/dbs/:id/tables`** — create a table structurally (one commit):

```json
{
  "name": "users",
  "columns": [
    { "name": "id", "type": "INTEGER", "primaryKey": true, "autoIncrement": true },
    { "name": "email", "type": "TEXT", "notNull": true },
    { "name": "created_at", "type": "TEXT", "default": "CURRENT_TIMESTAMP" }
  ],
  "indexes": [ { "name": "users_email", "columns": ["email"], "unique": true } ]
}
```

`type` defaults to `TEXT`. Index `name` and `columns` are required. → `201`

**`GET /api/dbs/:id/tables/:name/rows?limit=&offset=&version=<full-sha>`**

Response is row-positional, not objects:

```json
{
  "database": "db_...", "table": "orders",
  "fields": ["id", "customer_id", "total", "status"],
  "rows": [ [1, 1, 120.5, "paid"] ],
  "total": 6, "limit": 50, "offset": 0
}
```

`fields` is an ordered list of column names; each row in `rows` is a value
array aligned to `fields`. Rows are ordered by `rowid` (or the first column
when the table is `WITHOUT ROWID`). `total` is the full table row count, so
paginate with `offset`. Default `limit` is **50**, max **1000**, `offset` ≥ 0.

**`POST /api/dbs/:id/sql`** — the escape hatch. SQLite dialect, **one statement
per request** (multi-statement SQL is rejected with `QUERY_ERROR`):

```json
{ "sql": "UPDATE orders SET status = ? WHERE id = ?", "params": ["paid", 1] }
```

`params` is optional. Use **bound parameters** (`?` positional or `:name` /
`$name` named) instead of string interpolation; string interpolation of user
input is the one thing the platform explicitly expects you to avoid.

Response:

```json
{
  "database": "db_...",
  "result": {
    "kind": "dml",                      // "select" | "dml" | "ddl" | "other"
    "fields": [{ "name": "status", "declaredType": "TEXT" }],   // [] for DML
    "rows": [],                          // objects keyed by column for reads
    "changes": 1,                        // rows modified (dml/ddl)
    "ddlChanged": false,                 // true when schema changed
    "lastInsertRowid": 7,                // only for dml
    "durationMs": 3
  }
}
```

`kind` is `select` for reads, `dml` for INSERT/UPDATE/DELETE, `ddl` when the
schema changed (CREATE/ALTER/DROP), `other` for statements that fit none of
these. Any dml/ddl statement commits automatically. Use `ddlChanged` as the
signal to re-fetch the schema.

**`DELETE /api/dbs/:id/tables/:name`** → `{ database, dropped: true }`

### 4.4 JSON documents — reads `any`, writes/drop `apikey`

**`GET /api/dbs/:id/collections`** → `{ database, collections: [...] }`

**`POST /api/dbs/:id/collections`** (*apikey*) — create a collection, optionally
with indexes, in one commit:

```json
{ "name": "customers", "indexes": [ { "fields": ["email"], "unique": true } ] }
```

Index def: `fields` (1–4 dot-paths, required), `unique` (bool), `name`
(optional; auto-generated like `by_<field>` if omitted).

**`DELETE /api/dbs/:id/collections/:name`** (*apikey*) — drop collection and
all documents.

**`GET /api/dbs/:id/collections/:name/docs?filter=&sort=&fields=&limit=&offset=&page=&pageSize=&version=<full-sha>`** — query documents:

- `filter` — URL-encoded JSON object (see filter operators below).
- `sort` — URL-encoded JSON, e.g. `{"createdAt":-1}` or
  `[{"createdAt":-1},{"age":1}]` (see sort forms below).
- `fields` — comma-separated or JSON array of dot-paths to project (the `_id`
  is always included).
- `limit`/`offset`, or `page`/`pageSize` (1-based). Default `limit` is **100**,
  max **1000**.

```json
{
  "database": "db_...", "collection": "customers",
  "docs": [{ "_id": "ada@example.com", "name": "Ada", "tier": "pro" }],
  "total": 42, "count": 25, "limit": 25, "offset": 0
}
```

`total` is the number of documents matching the filter; `count` is
`docs.length`.

**`POST /api/dbs/:id/collections/:name/docs`** (*apikey*) — insert.

Single document (the raw object is the body). It is assigned an id you can
control in either of these ways:

```json
{ "_id": "ada@example.com", "name": "Ada", "tier": "pro" }
{ "id": "ada@example.com", "name": "Ada", "tier": "pro" }
```

If neither `_id` nor `id` is present, an id is generated: `rec_<16 hex chars>`.
Auto-generated for a batch when `ids` is omitted. Response for a single insert:

```json
{ "database": "db_...", "collection": "customers", "_id": "ada@example.com", "rev": 1, "inserted": true }
```

Batch (a raw array, or `{ "docs": [...], "ids": [...] }` — ids list must align
with the docs array, 1–1000 documents, all in one commit):

```json
[ { "email": "b@example.com" }, { "email": "c@example.com" } ]
```

```json
{ "database": "db_...", "collection": "customers", "inserted": true, "ids": ["rec_...", "rec_..."] }
```

**`GET /api/dbs/:id/collections/:name/docs/:docId?version=<full-sha>`** — one
document. `404 NOT_FOUND` when missing.

**`PATCH /api/dbs/:id/collections/:name/docs/:docId`** (*apikey*) — update.

Three body forms, chosen by the shape of what you send:

- **Full replacement** — body is a plain object with no `$`-prefixed keys. The
  whole document (except `_id`/`rev`) is replaced:
  `{ "tier": "enterprise" }`
- **Partial operators** — body is `{ "$set": {...}, "$unset": [...], "$inc": {...} }`:
  - `$set` — `{ "field.path": value }`
  - `$unset` — array of field paths **or** a single string path. A boolean
    `true` is a no-op; don't use it.
  - `$inc` — `{ "field.path": number }` (missing fields start from 0)
- **Update-wrapper with upsert** — `{ "update": <operators or body>, "upsert": true }`.
  When the document is missing and `upsert` is true, it is created from the
  expression (`$set`/`$inc` only — `$unset` is rejected for upserts). When it
  is missing and `upsert` is false/omitted, you get `404`.

```json
{ "update": { "$set": { "tier": "enterprise" }, "$inc": { "logins": 1 } }, "upsert": true }
```

Response:

```json
{ "database": "db_...", "collection": "customers", "_id": "ada@example.com",
  "rev": 2, "matched": true, "updated": true, "replaced": false }
```

`replaced: true` when a full replacement happened.

**`DELETE /api/dbs/:id/collections/:name/docs/:docId`** (*apikey*) →
`{ database, collection, deleted: true, _id }`. Returns `deleted: false`
(HTTP 200) if the document was already gone.

### 4.5 Versioned reads (work everywhere)

Any read that accepts data — `schema`, `tables/:name/rows`, `collections/
:name/docs`, `collections/:name/docs/:docId` — also accepts
`?version=<full 40-char sha>`. Steps:

1. `GET /api/dbs/:id/versions` and pick a `sha`.
2. Add `?version=<that sha>` to any read.
3. The response echoes the `version` it served.

Rules: historical reads are **read-only** (writes always land on HEAD); the
`sha` must be the **full 40-character** value from `/versions` **or** a
`rev-<n>` reference on non-GitHub (memory) instances — a short SHA like
`59a7964` or the word `latest` is **rejected with `400 VALIDATION_ERROR`**;
omitting `version` reads HEAD.

### 4.6 Sessions and keys — `session` (management)

These back the dashboard and are the only way to do management:

- `GET /api/auth/login?returnTo=` → redirect to GitHub OAuth.
- `GET /api/auth/callback?code=&state=` → sets the session cookie, redirects to
  the dashboard with a one-time exchange grant.
- `POST /api/auth/exchange { "code" }` → `{ token, user }` (landing page /
  non-cookie clients).
- `POST /api/auth/logout` → clears the session.
- `GET /api/auth/me` (session) → `{ user: { id, github: { login, ... }, createdAt } }`.
- `GET /api/dbs` (session) → list your databases.
- `POST /api/dbs { "name", "type" }` (session) → create (provisions the repo).
- `GET /api/dbs/:id` (session) → one database entry.
- `PATCH /api/dbs/:id { "name"?, "blocked"? }` (session) → rename and/or
  block/unblock API access. Both are applied if both are present.
- `DELETE /api/dbs/:id?mode=archive|delete` (session) → remove the database.
  `archive` (default) keeps it recoverable; `delete` removes the repository.
- `POST /api/dbs/:id/restore { "version" }` (session) → restore to a past
  version (never loses newer history; see section 6).
- `GET /api/keys` / `POST /api/keys { label? }` / `DELETE /api/keys/:id`
  (session) → list / create (full key returned once) / revoke.

---

## 5. Query language (JSON documents)

### 5.1 Filters

A `filter` is a plain JSON object. A key whose value is an object of
`$`-prefixed keys is an operator expression; anything else is an equality
match (deep equality, so nested objects/arrays work).

**Operators:** `$eq` `$ne` `$gt` `$gte` `$lt` `$lte` `$in` `$nin` `$exists`
`$regex` `$not`

**Combinators:** `$and` `$or` `$nor` `$not` at the top level, and
`$not` as an operator expression.

Examples:

```json
{ "tier": "pro" }
{ "active": { "$ne": false } }
{ "total": { "$gte": 100 } }
{ "email": { "$in": ["a@example.com", "b@example.com"] } }
{ "name": { "$regex": "^ada" } }
{ "$or": [ { "tier": "pro" }, { "tier": "enterprise" } ] }
{ "phone": { "$exists": false } }
```

Semantics that matter (verified):

- A **missing field** never matches `$gt/$gte/$lt/$lte` and never matches
  equality against a value. Use `$exists`.
- `$eq: null` matches **missing** fields as well as explicit nulls.
- `$regex` value must be a **string**; it is compiled as a JavaScript
  `RegExp` **without flags** — there is **no `$options` support** (a sibling
  `$options` key is silently ignored). An invalid regex simply never matches.
  A missing field is tested against the empty string.
- Indexes declared on a collection are maintained on write and used for unique
  enforcement, but query results are **always** computed by evaluating the
  filter against the live documents — a stale index can never return a wrong
  answer.

### 5.2 Sort

`sort` is URL-encoded JSON. Accepted shapes (any of these):

```json
{"createdAt": -1}
{"createdAt": "desc"}
{"field": "createdAt", "dir": "desc"}
{"field": "createdAt", "dir": -1}
{"field": "createdAt", "order": "asc"}
[{"createdAt": -1}, {"age": 1}]
```

Direction values: `"asc"`/`1` or `"desc"`/`-1`. Multi-key sorts are arrays of
clauses. **Plain text like `-createdAt` is not JSON and is rejected** — do not
use the `-` prefix style.

### 5.3 Fields projection

`fields` is a comma-separated list or a JSON array of dot-paths:
`fields=name,tier` or `fields=["name","tier"]`. `_id` is always returned.

---

## 6. Restore behaviour (dashboard + session API)

`POST /api/dbs/:id/restore` with `{ "version": "<full sha>" }` (session only):

- Creates a **new commit** whose tree is exactly the old commit's tree,
  parented on the current HEAD. Nothing is discarded: the "bad" commit and
  everything after it stay in the history and can be restored back to.
- Reuses the historical tree object, so restoring a huge database uploads no
  file contents (same cost small or large).
- Uses an expected-head check so a concurrent write cannot be silently
  overwritten (409 `CONCURRENT_MODIFICATION` if the branch moved).
- **Does not change the block state or registry entry.** A blocked database
  stays blocked; a blocked database's API stays unreachable after the restore
  until you unblock it (the response includes a `warning` field when blocked).
- Returns `{ database, restored: true, sha, restoredFrom, previousHead }`.

---

## 7. Limits worth knowing (all verified)

| Limit | Value |
|---|---|
| Request body size | 4 MB (`413 PAYLOAD_TOO_LARGE`) |
| Single document size | ~4 MB (default `maxDocBytes`) |
| Data chunk size | 50 MB (default `MAX_CHUNK_BYTES`) |
| Row/document page | `limit` 1–1000; relational default `50`, JSON default `100` |
| `offset` | non-negative integer |
| `page`/`pageSize` | `pageSize` 1–1000, `page` ≥ 1 (1-based) |
| Doc batch insert | 1–1000 documents per request, all in one commit |
| SQL statements per request | **1** — a trailing `;` is fine, multiple statements are rejected |
| Table/column name | 1–63 chars, `[A-Za-z0-9_]` |
| Collection name | 1–64 chars, lowercase `[a-z0-9_-]` |
| Document `_id` | 1–128 chars, `[A-Za-z0-9_.:-]`, must start with a letter or digit |
| Index fields | 1–4 dot-paths |
| Read cache | ~60 s TTL, namespaced per database, invalidated on every write/restore |

Every mutation is one GitHub commit, so GitHub's API rate-limit budget is the
shared ceiling for all accounts on the platform — large bulk imports should be
batched and paced.

---

## 8. Errors, retries and codes

Every failure uses the same envelope. Messages are for humans; **branch on the
code**, never on the message:

```json
{ "error": { "code": "QUERY_ERROR", "message": "no such table: userz" } }
```

The full stable catalogue:

| Status | Codes | Action |
|---|---|---|
| 400 | `VALIDATION_ERROR`, `QUERY_ERROR`, `SCHEMA_ERROR`, `DOCUMENT_ERROR`, `INVALID_REQUEST`, `DATABASE_TYPE_MISMATCH`, `DATABASE_VERSION_CURRENT` | fix the request, do not retry |
| 401 | `UNAUTHORIZED`, `INVALID_API_KEY`, `GITHUB_AUTH_EXPIRED` | missing/invalid/revoked key or expired session |
| 403 | `FORBIDDEN`, `DATABASE_BLOCKED` | account/permission or the database's API kill-switch is on |
| 404 | `NOT_FOUND`, `DATABASE_NOT_FOUND`, `DATABASE_VERSION_NOT_FOUND` | wrong id, table, collection, document, or commit sha |
| 409 | `CONCURRENT_MODIFICATION` | someone else wrote first; re-read and retry (the write never clobbers) |
| 413 | `PAYLOAD_TOO_LARGE` | body over 4 MB; batch or shrink |
| 429 | `RATE_LIMITED`, `GITHUB_RATE_LIMIT` | too many requests / GitHub budget; back off and retry (exponential) |
| 500 | `INTERNAL_ERROR`, `STORAGE_ERROR`, `CHUNK_ERROR`, `INDEX_ERROR` | retry; if persistent, file a bug |
| 502 | `GITHUB_API_ERROR`, `GITHUB_UPSTREAM_UNAVAILABLE` | upstream GitHub hiccup; retry with backoff |

Error responses never include stack traces or secrets.

---

## 9. Dashboard-only features

Everything below exists **only in the dashboard** (an API key cannot do any of
it; the corresponding HTTP endpoints, where they exist, are `session`-only).

- **GitHub sign-in and session.** The dashboard is the only surface that
  authenticates with GitHub OAuth and holds a session. It is also where you
  approve what the platform may do with your repositories.
- **Create a database** with a friendly name and a type (relational or JSON);
  this provisions the private repository and returns the database id.
- **List / open databases**, and copy the id for use in code.
- **Rename a database** — the underlying GitHub repository is renamed too.
- **Archive or delete a database** (archive keeps it recoverable; delete
  removes the repository).
- **Block / unblock API access (kill switch).** A blocked database returns
  `403 DATABASE_BLOCKED` on every data endpoint for API keys and sessions,
  while the dashboard remains usable. Unblocking happens here and only here.
- **Restore to any past version** with a confirmation dialog and a success
  banner. This is the primary user-facing restore UI on top of the session
  restore endpoint.
- **Version history browser:** time-travel through every commit and *preview*
  the schema, a table's rows or a collection's documents *as of that commit*
  before deciding to restore.
- **SQL console** (relational): write and run SQLite statements interactively;
  this is the only dashboard surface that writes data (relational writes are
  `any`, so the session is accepted).
- **Table browser** (relational): view table schemas and paginate rows; the
  dashboard row browser returns the same position-ordered shape as the rows API
  and supports version-pinned reads.
- **Document browser** (JSON): view collections and documents and their
  indexes, and page through them — **read-only**. The dashboard has no JSON
  write UI because JSON writes require an API key (`apikey`). To insert or
  update JSON documents, use the API with a key.
- **Usage / stats panel** per database: version count, per-table/collection
  record counts, repository footprint, cache telemetry, and GitHub API
  rate-limit headroom.
- **API Keys page:** generate a key (full value shown exactly once), label it,
  list keys with their prefix and timestamps, and revoke them.
- **Settings:** connected GitHub account, the API base URL (with copy button),
  and a live connection/health check.
- **API Reference page:** the human-readable version of this document, with
  copy-paste examples.

---

## 10. Using RepoGrid from a project (blueprint for an AI)

When an AI is asked to build something on RepoGrid, it should:

1. **Ask for / read the three values** — `REPOGRID_URL`, `REPOGRID_DB`,
   `REPOGRID_KEY`. If only some are given, the schema and versions endpoints
   (`any`) still work without a key, but writes do not.
2. **Discover the shape of the database first.** Call `GET .../schema` and
   `GET .../versions` and inspect the response before writing anything. For
   relational databases, list tables and columns; for JSON, list collections
   and note the index definitions so unique-constraint errors are expected.
3. **Choose the write path by type.** Relational → `POST /sql` (or structured
   `POST /tables` for DDL). JSON → `POST /docs` (single or batch), `PATCH
   /docs/:id`, `DELETE /docs/:id` — always with an API key.
4. **Always bind parameters in SQL.** Never interpolate user input into the
   SQL string. `params` accepts positional (`?`) or named (`:name`) bindings.
5. **URL-encode query params.** `filter`, `sort` and `fields` are JSON that
   must be URL-encoded on GET requests. `sort=-createdAt` is not JSON and
   fails.
6. **Use full SHAs for version reads.** Short SHAs and the string `latest` are
   rejected. To read "today's state" at a fixed point, list versions, grab a
   `sha`, and pin reads to it.
7. **Handle errors by code.** Retry `429`/`502` with backoff and
   `409` by re-reading then re-applying. Treat anything in the 400s as a bug in
   the request.
8. **Respect the operation model.** No "bulk import" endpoint exists; each
   write is one commit. Batch document inserts up to 1000 per request to keep
   the commit count down.
9. **Remember the dashboard boundary.** If a request needs to create a
   database, rename it, block it, restore it, or mint/revoke a key, the answer
   for the user is "open the dashboard" — the API will correctly refuse with
   `401` if you try with a key.

### Minimal examples

```bash
# relational read (no key needed for reads)
curl -G "$REPOGRID_URL/api/dbs/$REPOGRID_DB/tables/users/rows" \
  --data-urlencode "limit=20" --data-urlencode "offset=0"

# relational write (SQLite, one statement, bound params)
curl -X POST "$REPOGRID_URL/api/dbs/$REPOGRID_DB/sql" \
  -H "Authorization: Bearer $REPOGRID_KEY" -H "Content-Type: application/json" \
  -d '{"sql":"INSERT INTO users (email, tier) VALUES (?, ?)","params":["ada@example.com","pro"]}'

# json write (API key required)
curl -X POST "$REPOGRID_URL/api/dbs/$REPOGRID_DB/collections/customers/docs" \
  -H "Authorization: Bearer $REPOGRID_KEY" -H "Content-Type: application/json" \
  -d '{"_id":"ada@example.com","tier":"pro"}'

# json query with filter + sort (URL-encoded JSON)
curl -G "$REPOGRID_URL/api/dbs/$REPOGRID_DB/collections/customers/docs" \
  -H "Authorization: Bearer $REPOGRID_KEY" \
  --data-urlencode 'filter={"tier":"pro","active":{"$ne":false}}' \
  --data-urlencode 'sort={"createdAt":-1}' \
  --data-urlencode 'fields=["email","tier"]' \
  --data-urlencode "limit=25"
```

---

## 11. Hard rules (the ones that commonly bite)

1. JSON document writes and collection create/drop are **API-key-only**
   (`apikey`). A session will get `401`.
2. Management, restore, and key management are **session-only**. An API key
   will get `401`.
3. **One SQL statement per request.** A trailing semicolon is allowed;
   multiple statements return `QUERY_ERROR`.
4. `sort` and `filter` must be **valid JSON**, URL-encoded. The `-field`
   style does not work.
5. `?version=` needs a **full 40-character SHA** (from `/versions`) or a
   `rev-<n>` ref on non-GitHub instances. Short SHAs and `latest` are
   rejected with `400`.
6. `/tables/:name/rows` returns **positional value arrays**, not objects; the
   column order is in `fields`. `POST /sql` returns rows as objects.
7. Relational page default `limit` is 50; JSON docs default is 100; max is
   1000 for both. `total` reflects the full (filtered) result.
8. Every write is one Git commit, so writes are visible (and versionable)
   immediately but consume GitHub API rate-limit budget.
9. A database that has been **blocked** in the dashboard returns
   `403 DATABASE_BLOCKED` on all data endpoints until unblocked.
10. Field names prefixed `__qdb_` are reserved by the engine; user documents
    cannot use them. Generated doc ids look like `rec_<16 hex>`.