Răsfoiți Sursa

feat(db): adopt smartbotic-database 2.11.1 features (0.2.0)

Adds secondary indexes (including unique with duplicate reporting), facet
values, document TTL, relations with referential integrity, an admin
read-only lock, and runtime client-library version reporting; plus a web UI
index panel and full API documentation.

This unblocks the production database upgrade from 2.4.0 to 2.11.1. The
upgrade cannot be DB-only: both versions ship SONAME
libsmartbotic-db-client.so.2, so the linker silently binds an old binary to
the new library, and a public struct changed size in v2.7.1 - inside the
range being jumped. vectorapi must therefore be rebuilt against 2.11.1,
which this release does while also using the new API surface (41 client
symbols imported, up from 23).

Three semantics were established empirically and are documented because no
upstream header states them: relation names are project-qualified;
relations_enforced is read on the parent collection being deleted from, not
the child holding the reference; and ttl_seconds=0 on PATCH clears an expiry
while omitting it leaves the expiry untouched.
fszontagh 1 lună în urmă
părinte
comite
4ed51c2805

+ 1 - 1
VERSION

@@ -1 +1 @@
-0.1.7
+0.2.0

+ 32 - 7
api/llms.txt

@@ -65,17 +65,37 @@ Collection names may not start with `_` or the reserved prefix `vectorapi_`.
 
 
 Arbitrary JSON CRUD under a `kind=json` collection.
 Arbitrary JSON CRUD under a `kind=json` collection.
 
 
-- `POST  /api/v1/projects/{project}/collections/{name}/documents` `{data:{…}}` — Insert a document. Returns `{id}`.
-- `GET   /api/v1/projects/{project}/collections/{name}/documents/{id}` — Fetch a document by ID.
-- `PUT   /api/v1/projects/{project}/collections/{name}/documents/{id}` — Replace a document (full upsert).
-- `PATCH /api/v1/projects/{project}/collections/{name}/documents/{id}` — Partially update a document (merge fields).
-- `DELETE /api/v1/projects/{project}/collections/{name}/documents/{id}` — Delete a document.
-- `GET   /api/v1/projects/{project}/collections/{name}/documents` — Find documents.
+- `POST  /api/v1/projects/{project}/collections/{name}/documents` `{data:{…}}` - Insert a document. Returns `{id}`. Optional `?ttl_seconds=N` query param sets an expiry; absent means no expiry.
+- `GET   /api/v1/projects/{project}/collections/{name}/documents/{id}` - Fetch a document by ID.
+- `PUT   /api/v1/projects/{project}/collections/{name}/documents/{id}` - Replace a document (full upsert).
+- `PATCH /api/v1/projects/{project}/collections/{name}/documents/{id}` - Partially update a document (merge fields). Optional `?ttl_seconds=N`: **omitted leaves the existing expiry untouched**; `ttl_seconds=0` **clears** it (document becomes permanent) - do not assume 0 means "no change".
+- `DELETE /api/v1/projects/{project}/collections/{name}/documents/{id}` - Delete a document. Returns **409** `relation_restricted` (with `error.details.impacts`) if a restrict relation has children referencing it; see delete-impact below to preview this first.
+- `GET   /api/v1/projects/{project}/collections/{name}/documents` - Find documents.
+- `GET   /api/v1/projects/{project}/collections/{name}/documents/{id}/delete-impact` - Preview what deleting this document would affect, without deleting it: `{would_be_blocked, impacts:[{relation, child_collection, child_field, on_delete, child_count, sample_child_ids, blocks}]}`. Read-only; follows the collection's read grant (a scoped key with a read rule may call it).
 
 
 **Filter grammar**: `?filter=field:op:value` (repeatable; ANDed). Supported ops: `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `contains`. Example: `?filter=age:gte:30&filter=name:eq:Alice`.
 **Filter grammar**: `?filter=field:op:value` (repeatable; ANDed). Supported ops: `eq`, `ne`, `lt`, `lte`, `gt`, `gte`, `contains`. Example: `?filter=age:gte:30&filter=name:eq:Alice`.
 
 
 Additional query params: `limit` (default 20), `offset` (default 0), `sort` (field name), `desc` (bool).
 Additional query params: `limit` (default 20), `offset` (default 0), `sort` (field name), `desc` (bool).
 
 
+## Indexes and facets
+
+Project-scoped, under a collection. Index create/drop require a full (non-scoped) key with project access - scoped keys cannot manage indexes.
+
+- `POST   /api/v1/projects/{project}/collections/{name}/indexes` `{field, unique?}` - Declare an index on `field`, backfilling existing rows so it's usable immediately. Idempotent: re-declaring reports `already_existed: true`. With `unique: true`, refuses with **409** `duplicate_values` (`error.details.duplicate_examples`, up to five colliding document ids) if the field already holds duplicate values.
+- `GET    /api/v1/projects/{project}/collections/{name}/indexes` - List indexes: `{indexes:[{field, distinct_values, entries, unique}]}`.
+- `DELETE /api/v1/projects/{project}/collections/{name}/indexes/{field}` - Drop an index.
+- `GET    /api/v1/projects/{project}/collections/{name}/indexes/{field}/values` - Distinct values with counts: `{values:[{value, count}]}`. Index-dependent by design (not an error): returns an empty list for a field with no index and for array-valued fields, whose index keys are elements rather than values. Query params: `limit` (1-1000, default 100), `order` (`asc`|`desc`, default `asc`; use `order=desc&limit=1` for the maximum). Follows the collection's read grant.
+
+## Relations (admin)
+
+Relation names are per-project and **unqualified**: requests and responses always use the bare name; the internal `project:` prefix is never exposed. All relation endpoints require an admin key.
+
+- `POST   /api/v1/projects/{project}/relations` `{name, child, child_field, parent, on_delete?, validate_on_write?}` - Create a relation: deleting a `parent` document affects `child` documents whose `child_field` references it. `on_delete` is one of `restrict` (default), `cascade`, `set_null`, `no_action`; anything else is 422.
+- `GET    /api/v1/projects/{project}/relations` - List relations in the project.
+- `GET    /api/v1/projects/{project}/relations/{name}` - Get a relation by name.
+- `DELETE /api/v1/projects/{project}/relations/{name}` - Drop a relation by name.
+- `PUT    /api/v1/projects/{project}/collections/{name}/relations-enforced` `{enforced}` - Enable/disable relation enforcement on a collection. **`relations_enforced` is read on the collection you are deleting FROM (the PARENT side of the relation), not on the child collection holding the reference.** Disabling it on the child has no effect on deletes issued against the parent; set it on the parent to let its deletes bypass restrict relations.
+
 ## RAG vectors
 ## RAG vectors
 
 
 Requires a `kind=vector` collection. Vectors are stored with cosine-similarity indexing.
 Requires a `kind=vector` collection. Vectors are stored with cosine-similarity indexing.
@@ -97,7 +117,12 @@ All settings endpoints require an admin key.
 ## Stats
 ## Stats
 
 
 - `GET /api/v1/projects/{project}/stats` — Per-project stats (collections, document counts). Accessible to any key granted the project.
 - `GET /api/v1/projects/{project}/stats` — Per-project stats (collections, document counts). Accessible to any key granted the project.
-- `GET /api/v1/stats` — Server-wide stats including `memory_pressure_level` and an `embedding` object with cache telemetry (`cache_size`, `cache_capacity`, `cache_hits`, `cache_misses`, `cache_hit_ratio`, `cache_bytes`). Admin only.
+- `GET /api/v1/stats` - Server-wide stats including `memory_pressure_level`, an `embedding` object with cache telemetry (`cache_size`, `cache_capacity`, `cache_hits`, `cache_misses`, `cache_hit_ratio`, `cache_bytes`), and `db_client_version`/`db_client_commit` (the smartbotic-database client library the dynamic linker actually bound at process startup, not necessarily what the binary was compiled against (2.4.x and 2.11.x share a SONAME, so a mismatched library can load silently and only crash on a later restart)). Admin only.
+
+## Admin
+
+- `GET /api/v1/admin/readonly` - Get the server-wide (not per-project) read-only lock status: `{readonly, scope:"server", reason?}`. Reflects the database's own authoritative state - it can enter read-only by itself after a degraded/failed recovery, not only via the PUT below, so `readonly:true` does not necessarily mean an operator locked it; `reason` (DB-defined free text) is present only when non-empty. While locked, writes are rejected by the database and surfaced by vectorapi as **503**. Admin only.
+- `PUT /api/v1/admin/readonly` `{readonly}` - Set the server-wide read-only lock. Admin only.
 
 
 ## Ops
 ## Ops
 
 

+ 611 - 10
api/openapi.json

@@ -84,6 +84,65 @@
           "created_at": { "type": "integer" },
           "created_at": { "type": "integer" },
           "scope": { "$ref": "#/components/schemas/KeyScope", "description": "Present only when the key carries a capability scope" }
           "scope": { "$ref": "#/components/schemas/KeyScope", "description": "Present only when the key carries a capability scope" }
         }
         }
+      },
+      "RelationDefinition": {
+        "type": "object",
+        "description": "Relation names are per-project and unqualified: the API always takes and returns bare names. The internal `project:` prefix used to store the relation is never exposed to clients.",
+        "properties": {
+          "name": { "type": "string" },
+          "child": { "type": "string", "description": "Child collection name - holds the foreign reference" },
+          "child_field": { "type": "string", "description": "Field on the child document that stores the parent's id" },
+          "parent": { "type": "string", "description": "Parent collection name" },
+          "on_delete": { "type": "string", "enum": ["restrict", "cascade", "set_null", "no_action"] },
+          "validate_on_write": { "type": "boolean" },
+          "created_at": { "type": "integer" },
+          "updated_at": { "type": "integer" }
+        },
+        "required": ["name", "child", "child_field", "parent", "on_delete"]
+      }
+    },
+    "responses": {
+      "Conflict": {
+        "description": "Conflict. `error.code` distinguishes the two cases vectorapi returns 409 for: `duplicate_values` (a unique index was refused because the field already holds colliding values) and `relation_restricted` (a document delete was blocked by a restrict relation).",
+        "content": {
+          "application/json": {
+            "schema": {
+              "type": "object",
+              "properties": {
+                "error": {
+                  "type": "object",
+                  "properties": {
+                    "code": { "type": "string", "enum": ["duplicate_values", "relation_restricted"] },
+                    "message": { "type": "string" },
+                    "details": {
+                      "type": "object",
+                      "description": "Machine-readable payload. For `duplicate_values`: `duplicate_examples` (array of up to five document ids, one per colliding value). For `relation_restricted`: `impacts` (array of the same shape returned by GET .../documents/{id}/delete-impact).",
+                      "properties": {
+                        "duplicate_examples": { "type": "array", "items": { "type": "string" } },
+                        "impacts": {
+                          "type": "array",
+                          "items": {
+                            "type": "object",
+                            "properties": {
+                              "relation": { "type": "string" },
+                              "child_collection": { "type": "string" },
+                              "child_field": { "type": "string" },
+                              "on_delete": { "type": "string" },
+                              "child_count": { "type": "integer" },
+                              "sample_child_ids": { "type": "array", "items": { "type": "string" } },
+                              "blocks": { "type": "boolean" }
+                            }
+                          }
+                        }
+                      }
+                    }
+                  },
+                  "required": ["code", "message"]
+                }
+              }
+            }
+          }
+        }
       }
       }
     }
     }
   },
   },
@@ -151,7 +210,7 @@
         "responses": {
         "responses": {
           "201": { "description": "Project created" },
           "201": { "description": "Project created" },
           "401": { "description": "Unauthorized" },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" },
+          "403": { "description": "Forbidden - admin required" },
           "422": { "description": "Validation error" }
           "422": { "description": "Validation error" }
         }
         }
       }
       }
@@ -196,7 +255,7 @@
         "responses": {
         "responses": {
           "200": { "description": "Project deleted" },
           "200": { "description": "Project deleted" },
           "401": { "description": "Unauthorized" },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required or cannot drop default" },
+          "403": { "description": "Forbidden - admin required or cannot drop default" },
           "404": { "description": "Project not found" }
           "404": { "description": "Project not found" }
         }
         }
       }
       }
@@ -223,7 +282,7 @@
             }
             }
           },
           },
           "401": { "description": "Unauthorized" },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" }
+          "403": { "description": "Forbidden - admin required" }
         }
         }
       },
       },
       "post": {
       "post": {
@@ -270,7 +329,7 @@
             }
             }
           },
           },
           "401": { "description": "Unauthorized" },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" },
+          "403": { "description": "Forbidden - admin required" },
           "422": { "description": "Validation error" }
           "422": { "description": "Validation error" }
         }
         }
       }
       }
@@ -307,7 +366,7 @@
         "responses": {
         "responses": {
           "200": { "description": "Key updated" },
           "200": { "description": "Key updated" },
           "401": { "description": "Unauthorized" },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" },
+          "403": { "description": "Forbidden - admin required" },
           "404": { "description": "Key not found" }
           "404": { "description": "Key not found" }
         }
         }
       },
       },
@@ -317,7 +376,7 @@
         "responses": {
         "responses": {
           "200": { "description": "Key revoked" },
           "200": { "description": "Key revoked" },
           "401": { "description": "Unauthorized" },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" },
+          "403": { "description": "Forbidden - admin required" },
           "404": { "description": "Key not found" }
           "404": { "description": "Key not found" }
         }
         }
       }
       }
@@ -535,6 +594,13 @@
             "required": false,
             "required": false,
             "schema": { "type": "string" },
             "schema": { "type": "string" },
             "description": "CAPTCHA response token. Required when the scoped key rule has require_human_token=true; verified server-side via the configured captcha provider."
             "description": "CAPTCHA response token. Required when the scoped key rule has require_human_token=true; verified server-side via the configured captcha provider."
+          },
+          {
+            "name": "ttl_seconds",
+            "in": "query",
+            "required": false,
+            "schema": { "type": "integer", "minimum": 0 },
+            "description": "Optional expiry, in seconds from now. Absent means the document has no expiry."
           }
           }
         ],
         ],
         "requestBody": {
         "requestBody": {
@@ -640,6 +706,15 @@
       "patch": {
       "patch": {
         "summary": "Partially update a document (merge fields)",
         "summary": "Partially update a document (merge fields)",
         "operationId": "patchDocument",
         "operationId": "patchDocument",
+        "parameters": [
+          {
+            "name": "ttl_seconds",
+            "in": "query",
+            "required": false,
+            "schema": { "type": "integer", "minimum": 0 },
+            "description": "Sets the document's expiry, in seconds from now. This parameter is asymmetric by design: **omitting it leaves the document's current expiry untouched**; sending `ttl_seconds=0` **clears** the expiry, making the document permanent. Do not assume 0 means \"no TTL requested\": it actively wipes an existing one."
+          }
+        ],
         "requestBody": {
         "requestBody": {
           "required": true,
           "required": true,
           "content": {
           "content": {
@@ -659,16 +734,346 @@
       },
       },
       "delete": {
       "delete": {
         "summary": "Delete a document by ID",
         "summary": "Delete a document by ID",
+        "description": "Fails with 409 relation_restricted when a restrict relation has children referencing this document (see GET .../delete-impact to preview this beforehand without deleting).",
         "operationId": "deleteDocument",
         "operationId": "deleteDocument",
         "responses": {
         "responses": {
           "200": { "description": "Document deleted" },
           "200": { "description": "Document deleted" },
           "401": { "description": "Unauthorized" },
           "401": { "description": "Unauthorized" },
           "403": { "description": "Forbidden" },
           "403": { "description": "Forbidden" },
           "404": { "description": "Document not found" },
           "404": { "description": "Document not found" },
+          "409": { "$ref": "#/components/responses/Conflict" },
+          "429": { "description": "Too Many Requests (rate-limited scoped key)" }
+        }
+      }
+    },
+    "/api/v1/projects/{project}/collections/{name}/documents/{id}/delete-impact": {
+      "parameters": [
+        {
+          "name": "project",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Project name"
+        },
+        {
+          "name": "name",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Collection name"
+        },
+        {
+          "name": "id",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Document ID"
+        }
+      ],
+      "get": {
+        "summary": "Preview what deleting this document would affect, without deleting it",
+        "description": "Read-only: changes nothing. Reports whether a delete would be blocked and, for every relation where this collection is the parent, the child collection, the count of referencing child documents, up to a handful of sample child ids, and whether that particular relation blocks the delete. Follows the collection's read grant, so a scoped key with a read rule on this collection may call it (index/relation management routes are admin/full-key only, but this one is not).",
+        "operationId": "describeDelete",
+        "responses": {
+          "200": {
+            "description": "Delete impact preview",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "would_be_blocked": { "type": "boolean" },
+                    "impacts": {
+                      "type": "array",
+                      "items": {
+                        "type": "object",
+                        "properties": {
+                          "relation": { "type": "string" },
+                          "child_collection": { "type": "string" },
+                          "child_field": { "type": "string" },
+                          "on_delete": { "type": "string", "enum": ["restrict", "cascade", "set_null", "no_action"] },
+                          "child_count": { "type": "integer" },
+                          "sample_child_ids": { "type": "array", "items": { "type": "string" } },
+                          "blocks": { "type": "boolean" }
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden" },
+          "404": { "description": "Document not found" },
           "429": { "description": "Too Many Requests (rate-limited scoped key)" }
           "429": { "description": "Too Many Requests (rate-limited scoped key)" }
         }
         }
       }
       }
     },
     },
+    "/api/v1/projects/{project}/collections/{name}/indexes": {
+      "parameters": [
+        {
+          "name": "project",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Project name"
+        },
+        {
+          "name": "name",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Collection name"
+        }
+      ],
+      "post": {
+        "summary": "Declare a field index, optionally unique; backfills existing rows",
+        "description": "Backfills existing rows as part of the call, so the index is usable immediately once this returns. Idempotent: re-declaring an index that already exists on `field` reports already_existed:true instead of erroring. Requires a full (non-scoped) key with project access; scoped keys cannot manage indexes.",
+        "operationId": "createIndex",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": {
+                  "field": { "type": "string" },
+                  "unique": {
+                    "type": "boolean",
+                    "default": false,
+                    "description": "When true, enforces a uniqueness constraint. If the field already holds duplicate values across existing documents, the call fails with 409 duplicate_values instead of creating the index."
+                  }
+                },
+                "required": ["field"]
+              }
+            }
+          }
+        },
+        "responses": {
+          "201": {
+            "description": "Index declared",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "field": { "type": "string" },
+                    "unique": { "type": "boolean" },
+                    "rows_indexed": { "type": "integer" },
+                    "already_existed": { "type": "boolean" }
+                  }
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - project access required; scoped keys cannot manage indexes" },
+          "404": { "description": "Collection not found" },
+          "409": { "$ref": "#/components/responses/Conflict" },
+          "422": { "description": "Validation error (missing field)" }
+        }
+      },
+      "get": {
+        "summary": "List indexes declared on a collection",
+        "operationId": "listIndexes",
+        "responses": {
+          "200": {
+            "description": "Index list",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "indexes": {
+                      "type": "array",
+                      "items": {
+                        "type": "object",
+                        "properties": {
+                          "field": { "type": "string" },
+                          "distinct_values": { "type": "integer" },
+                          "entries": { "type": "integer" },
+                          "unique": { "type": "boolean" }
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden" },
+          "404": { "description": "Collection not found" }
+        }
+      }
+    },
+    "/api/v1/projects/{project}/collections/{name}/indexes/{field}": {
+      "parameters": [
+        {
+          "name": "project",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Project name"
+        },
+        {
+          "name": "name",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Collection name"
+        },
+        {
+          "name": "field",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Indexed field name"
+        }
+      ],
+      "delete": {
+        "summary": "Drop an index",
+        "operationId": "dropIndex",
+        "description": "Requires a full (non-scoped) key with project access; scoped keys cannot manage indexes.",
+        "responses": {
+          "200": {
+            "description": "Index dropped",
+            "content": {
+              "application/json": {
+                "schema": { "type": "object", "properties": { "dropped": { "type": "string" } } }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - project access required; scoped keys cannot manage indexes" },
+          "404": { "description": "Index not found" }
+        }
+      }
+    },
+    "/api/v1/projects/{project}/collections/{name}/indexes/{field}/values": {
+      "parameters": [
+        {
+          "name": "project",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Project name"
+        },
+        {
+          "name": "name",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Collection name"
+        },
+        {
+          "name": "field",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Indexed field name"
+        }
+      ],
+      "get": {
+        "summary": "Distinct values of an indexed field, with per-value counts",
+        "description": "Index-dependent by design, not an error: returns an empty values list with HTTP 200 when `field` has no index, and also for array-valued fields, whose index keys are the individual array elements rather than the field's own value. Follows the collection's read grant, so a scoped key with a read rule on this collection may call it. Use order=desc&limit=1 to get the maximum value.",
+        "operationId": "indexFieldValues",
+        "parameters": [
+          {
+            "name": "limit",
+            "in": "query",
+            "schema": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 100 },
+            "description": "Maximum number of distinct values to return, 1-1000."
+          },
+          {
+            "name": "order",
+            "in": "query",
+            "schema": { "type": "string", "enum": ["asc", "desc"], "default": "asc" },
+            "description": "Sort order of the returned values."
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "Distinct values with counts (possibly empty; see description)",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "values": {
+                      "type": "array",
+                      "items": {
+                        "type": "object",
+                        "properties": {
+                          "value": {},
+                          "count": { "type": "integer" }
+                        }
+                      }
+                    }
+                  }
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden" },
+          "422": { "description": "Validation error (limit out of 1..1000 range or non-numeric)" },
+          "429": { "description": "Too Many Requests (rate-limited scoped key)" }
+        }
+      }
+    },
+    "/api/v1/projects/{project}/collections/{name}/relations-enforced": {
+      "parameters": [
+        {
+          "name": "project",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Project name"
+        },
+        {
+          "name": "name",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Collection name"
+        }
+      ],
+      "put": {
+        "summary": "Enable or disable relation enforcement on a collection (admin only)",
+        "description": "IMPORTANT: relations_enforced is read on the collection you are deleting FROM, i.e. the PARENT side of the relation, not on the child collection that holds the foreign reference. Disabling it on the child has no effect on deletes issued against the parent. To allow deletes on a parent collection to bypass restrict relations, set this flag on the parent, not the child.",
+        "operationId": "setRelationsEnforced",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": { "enforced": { "type": "boolean" } },
+                "required": ["enforced"]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Enforcement flag updated",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": { "collection": { "type": "string" }, "enforced": { "type": "boolean" } }
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - admin required" },
+          "422": { "description": "Validation error (enforced missing or not a boolean)" }
+        }
+      }
+    },
     "/api/v1/projects/{project}/collections/{name}/vectors": {
     "/api/v1/projects/{project}/collections/{name}/vectors": {
       "parameters": [
       "parameters": [
         {
         {
@@ -873,6 +1278,128 @@
         }
         }
       }
       }
     },
     },
+    "/api/v1/projects/{project}/relations": {
+      "parameters": [
+        {
+          "name": "project",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Project name"
+        }
+      ],
+      "post": {
+        "summary": "Create a relation between two collections in a project (admin only)",
+        "description": "Relation names are per-project and unqualified: the API takes and returns bare names - the internal project: prefix used to store the relation is never exposed. name may not start with _ or the reserved vectorapi_ prefix.",
+        "operationId": "createRelation",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": {
+                  "name": { "type": "string", "description": "Unqualified relation name" },
+                  "child": { "type": "string", "description": "Child collection name - holds the foreign reference" },
+                  "child_field": { "type": "string", "description": "Field on the child document that stores the parent's id" },
+                  "parent": { "type": "string", "description": "Parent collection name" },
+                  "on_delete": {
+                    "type": "string",
+                    "enum": ["restrict", "cascade", "set_null", "no_action"],
+                    "default": "restrict",
+                    "description": "Any value outside this set is rejected with 422."
+                  },
+                  "validate_on_write": {
+                    "type": "boolean",
+                    "default": false,
+                    "description": "When true, writes to the child are validated against an existing parent."
+                  }
+                },
+                "required": ["name", "child", "child_field", "parent"]
+              }
+            }
+          }
+        },
+        "responses": {
+          "201": {
+            "description": "Relation created",
+            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RelationDefinition" } } }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - admin required" },
+          "422": { "description": "Validation error (missing field, or on_delete not one of restrict/cascade/set_null/no_action)" }
+        }
+      },
+      "get": {
+        "summary": "List relations defined in a project (admin only)",
+        "operationId": "listRelations",
+        "responses": {
+          "200": {
+            "description": "Relation list",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "relations": { "type": "array", "items": { "$ref": "#/components/schemas/RelationDefinition" } }
+                  }
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - admin required" }
+        }
+      }
+    },
+    "/api/v1/projects/{project}/relations/{name}": {
+      "parameters": [
+        {
+          "name": "project",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Project name"
+        },
+        {
+          "name": "name",
+          "in": "path",
+          "required": true,
+          "schema": { "type": "string" },
+          "description": "Unqualified relation name"
+        }
+      ],
+      "get": {
+        "summary": "Get a relation by name (admin only)",
+        "operationId": "getRelation",
+        "responses": {
+          "200": {
+            "description": "Relation",
+            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/RelationDefinition" } } }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - admin required" },
+          "404": { "description": "Relation not found" }
+        }
+      },
+      "delete": {
+        "summary": "Drop a relation by name (admin only)",
+        "operationId": "dropRelation",
+        "responses": {
+          "200": {
+            "description": "Relation dropped",
+            "content": {
+              "application/json": {
+                "schema": { "type": "object", "properties": { "dropped": { "type": "string" } } }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - admin required" },
+          "404": { "description": "Relation not found" }
+        }
+      }
+    },
     "/api/v1/settings": {
     "/api/v1/settings": {
       "get": {
       "get": {
         "summary": "Get current settings (admin only); openai_api_key is masked to a boolean openai_api_key_set",
         "summary": "Get current settings (admin only); openai_api_key is masked to a boolean openai_api_key_set",
@@ -908,7 +1435,7 @@
             }
             }
           },
           },
           "401": { "description": "Unauthorized" },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" }
+          "403": { "description": "Forbidden - admin required" }
         }
         }
       },
       },
       "put": {
       "put": {
@@ -946,7 +1473,7 @@
         "responses": {
         "responses": {
           "200": { "description": "Settings updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } } } } },
           "200": { "description": "Settings updated", "content": { "application/json": { "schema": { "type": "object", "properties": { "ok": { "type": "boolean" } } } } } },
           "401": { "description": "Unauthorized" },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" }
+          "403": { "description": "Forbidden - admin required" }
         }
         }
       }
       }
     },
     },
@@ -966,14 +1493,88 @@
                     "memory_pressure_level": { "type": "integer" },
                     "memory_pressure_level": { "type": "integer" },
                     "collections": { "type": "integer" },
                     "collections": { "type": "integer" },
                     "documents": { "type": "integer" },
                     "documents": { "type": "integer" },
-                    "embedding": { "type": "object", "properties": { "cache_size": { "type": "integer" }, "cache_capacity": { "type": "integer" }, "cache_hits": { "type": "integer" }, "cache_misses": { "type": "integer" }, "cache_hit_ratio": { "type": "number" }, "cache_bytes": { "type": "integer" } } }
+                    "embedding": { "type": "object", "properties": { "cache_size": { "type": "integer" }, "cache_capacity": { "type": "integer" }, "cache_hits": { "type": "integer" }, "cache_misses": { "type": "integer" }, "cache_hit_ratio": { "type": "number" }, "cache_bytes": { "type": "integer" } } },
+                    "db_client_version": {
+                      "type": "string",
+                      "description": "Version of the smartbotic-database client library actually bound by the dynamic linker at process startup - not necessarily the version this binary was compiled against. 2.4.x and 2.11.x share a SONAME, so a mismatched shared library can load silently at runtime and only surface as a crash on a later restart; this field lets an operator catch that before it does."
+                    },
+                    "db_client_commit": {
+                      "type": "string",
+                      "description": "Commit hash of the loaded smartbotic-database client library, for the same reason as db_client_version."
+                    }
                   }
                   }
                 }
                 }
               }
               }
             }
             }
           },
           },
           "401": { "description": "Unauthorized" },
           "401": { "description": "Unauthorized" },
-          "403": { "description": "Forbidden — admin required" }
+          "403": { "description": "Forbidden - admin required" }
+        }
+      }
+    },
+    "/api/v1/admin/readonly": {
+      "get": {
+        "summary": "Get the server-wide read-only lock status (admin only)",
+        "description": "Server-global, not per project: a single lock state applies to the entire database backing every project. Reflects the database's own authoritative state, obtained live from it on every call - the database can also enter read-only by itself after a degraded or failed recovery, not only via an operator's PUT to this endpoint, so readonly:true does not necessarily mean an operator locked it. While locked, the database refuses writes (observed error: \"Insert rejected: database is in read-only mode. manually locked via SetReadOnly RPC\") and vectorapi surfaces that as HTTP 503.",
+        "operationId": "getReadOnly",
+        "responses": {
+          "200": {
+            "description": "Read-only status",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "readonly": { "type": "boolean" },
+                    "scope": { "type": "string", "enum": ["server"], "description": "Always \"server\" - the lock is not scoped to a project." },
+                    "reason": { "type": "string", "description": "DB-defined free text explaining why the database is read-only. Present only when non-empty; its absence does not imply the state was operator-set." }
+                  },
+                  "required": ["readonly", "scope"]
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - admin required" }
+        }
+      },
+      "put": {
+        "summary": "Set the server-wide read-only lock (admin only)",
+        "description": "Server-global, not per project. Locking blocks writes across every project on this server.",
+        "operationId": "setReadOnly",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object",
+                "properties": { "readonly": { "type": "boolean" } },
+                "required": ["readonly"]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Read-only status after the change",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "readonly": { "type": "boolean" },
+                    "scope": { "type": "string", "enum": ["server"] },
+                    "reason": { "type": "string", "description": "Present only when non-empty" }
+                  },
+                  "required": ["readonly", "scope"]
+                }
+              }
+            }
+          },
+          "401": { "description": "Unauthorized" },
+          "403": { "description": "Forbidden - admin required" },
+          "422": { "description": "Validation error (readonly missing or not a boolean)" },
+          "503": { "description": "Database rejected the request (e.g. failed to change read-only mode)" }
         }
         }
       }
       }
     }
     }

+ 2 - 0
src/CMakeLists.txt

@@ -20,10 +20,12 @@ add_library(vectorapi_core STATIC
     handlers/projects.cpp
     handlers/projects.cpp
     handlers/keys.cpp
     handlers/keys.cpp
     handlers/collections.cpp
     handlers/collections.cpp
+    handlers/indexes.cpp
     handlers/documents.cpp
     handlers/documents.cpp
     handlers/vectors.cpp
     handlers/vectors.cpp
     handlers/stats.cpp
     handlers/stats.cpp
     handlers/settings.cpp
     handlers/settings.cpp
+    handlers/relations.cpp
 )
 )
 target_include_directories(vectorapi_core PUBLIC
 target_include_directories(vectorapi_core PUBLIC
     ${CMAKE_CURRENT_SOURCE_DIR}
     ${CMAKE_CURRENT_SOURCE_DIR}

+ 6 - 2
src/errors.cpp

@@ -8,6 +8,7 @@ int httpStatus(ErrCode code) {
         case ErrCode::Unauthorized:  return 401;
         case ErrCode::Unauthorized:  return 401;
         case ErrCode::Forbidden:     return 403;
         case ErrCode::Forbidden:     return 403;
         case ErrCode::NotFound:      return 404;
         case ErrCode::NotFound:      return 404;
+        case ErrCode::Conflict:      return 409;
         case ErrCode::Unprocessable:    return 422;
         case ErrCode::Unprocessable:    return 422;
         case ErrCode::TooManyRequests:  return 429;
         case ErrCode::TooManyRequests:  return 429;
         case ErrCode::Unavailable:      return 503;
         case ErrCode::Unavailable:      return 503;
@@ -16,8 +17,11 @@ int httpStatus(ErrCode code) {
     return 500;
     return 500;
 }
 }
 
 
-nlohmann::json errorBody(const std::string& code, const std::string& message) {
-    return nlohmann::json{{"error", {{"code", code}, {"message", message}}}};
+nlohmann::json errorBody(const std::string& code, const std::string& message,
+                         const nlohmann::json& details) {
+    nlohmann::json err{{"code", code}, {"message", message}};
+    if (!details.is_null() && !details.empty()) err["details"] = details;
+    return nlohmann::json{{"error", err}};
 }
 }
 
 
 } // namespace svapi
 } // namespace svapi

+ 12 - 4
src/errors.hpp

@@ -5,16 +5,24 @@
 
 
 namespace svapi {
 namespace svapi {
 
 
-enum class ErrCode { BadRequest, Unauthorized, Forbidden, NotFound, Unprocessable, TooManyRequests, Unavailable, Internal };
+enum class ErrCode { BadRequest, Unauthorized, Forbidden, NotFound, Conflict,
+                     Unprocessable, TooManyRequests, Unavailable, Internal };
 
 
 int httpStatus(ErrCode code);
 int httpStatus(ErrCode code);
-nlohmann::json errorBody(const std::string& code, const std::string& message);
+
+/// `details` is merged into the error object as `error.details` when non-empty.
+/// Used for machine-readable payloads (colliding ids, relation impacts) that
+/// would otherwise be flattened into the message string.
+nlohmann::json errorBody(const std::string& code, const std::string& message,
+                         const nlohmann::json& details = nlohmann::json::object());
 
 
 struct ApiError : std::runtime_error {
 struct ApiError : std::runtime_error {
     ErrCode code;
     ErrCode code;
     std::string slug;
     std::string slug;
-    ApiError(ErrCode c, std::string slug_, const std::string& msg)
-        : std::runtime_error(msg), code(c), slug(std::move(slug_)) {}
+    nlohmann::json details;   // optional structured payload; empty object = omitted
+    ApiError(ErrCode c, std::string slug_, const std::string& msg,
+             nlohmann::json det = nlohmann::json::object())
+        : std::runtime_error(msg), code(c), slug(std::move(slug_)), details(std::move(det)) {}
 };
 };
 
 
 } // namespace svapi
 } // namespace svapi

+ 34 - 3
src/handlers/documents.cpp

@@ -3,6 +3,9 @@
 #include "filters.hpp"
 #include "filters.hpp"
 #include "json_http.hpp"
 #include "json_http.hpp"
 #include "server.hpp"
 #include "server.hpp"
+#include <cstdlib>
+#include <limits>
+#include <optional>
 namespace svapi {
 namespace svapi {
 namespace {
 namespace {
 // Authorize + verify the collection exists; returns the qualified collection name.
 // Authorize + verify the collection exists; returns the qualified collection name.
@@ -13,6 +16,19 @@ std::string scoped(ServerDeps* d, const httplib::Request& req, int projIdx, int
     if (!d->registry.get(project, name)) throw ApiError(ErrCode::NotFound, "not_found", "no such collection");
     if (!d->registry.get(project, name)) throw ApiError(ErrCode::NotFound, "not_found", "no such collection");
     return qualify(project, name);
     return qualify(project, name);
 }
 }
+
+// Parses ?ttl_seconds=. Returns nullopt when absent - the caller must
+// distinguish "not supplied" (leave expiry alone) from 0 (clear the expiry).
+std::optional<uint32_t> ttlParam(const httplib::Request& req) {
+    if (!req.has_param("ttl_seconds")) return std::nullopt;
+    const std::string raw = req.get_param_value("ttl_seconds");
+    if (raw.empty() || raw.find_first_not_of("0123456789") != std::string::npos)
+        throw ApiError(ErrCode::Unprocessable, "validation", "ttl_seconds must be a non-negative integer");
+    unsigned long long wide = std::strtoull(raw.c_str(), nullptr, 10);
+    if (wide > std::numeric_limits<uint32_t>::max())
+        throw ApiError(ErrCode::Unprocessable, "validation", "ttl_seconds must be a non-negative integer");
+    return (uint32_t)wide;
+}
 }
 }
 void registerDocumentRoutes(ApiServer& s) {
 void registerDocumentRoutes(ApiServer& s) {
     auto& svr = s.raw(); ServerDeps* d = &s.deps();
     auto& svr = s.raw(); ServerDeps* d = &s.deps();
@@ -23,7 +39,8 @@ void registerDocumentRoutes(ApiServer& s) {
         std::string id = body.value("id", "");
         std::string id = body.value("id", "");
         nlohmann::json data = body.contains("data") ? body["data"] : body;
         nlohmann::json data = body.contains("data") ? body["data"] : body;
         if (data.is_object() && data.contains("id")) data.erase("id");
         if (data.is_object() && data.contains("id")) data.erase("id");
-        std::string newId = d->db.client().insert(c, data, id);
+        const uint32_t ttl = ttlParam(req).value_or(0);
+        std::string newId = d->db.client().insert(c, data, id, ttl);
         if (newId.empty()) throw ApiError(ErrCode::Unavailable, "db_error", "insert failed");
         if (newId.empty()) throw ApiError(ErrCode::Unavailable, "db_error", "insert failed");
         sendJson(res, 201, {{"id", newId}});
         sendJson(res, 201, {{"id", newId}});
     });
     });
@@ -55,7 +72,10 @@ void registerDocumentRoutes(ApiServer& s) {
     svr.Patch(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
     svr.Patch(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
         std::string c = scoped(d, req, 1, 2, KeyOp::Update); std::string id = req.matches[3];
         std::string c = scoped(d, req, 1, 2, KeyOp::Update); std::string id = req.matches[3];
         if (!d->db.client().exists(c, id)) throw ApiError(ErrCode::NotFound, "not_found", "no such document");
         if (!d->db.client().exists(c, id)) throw ApiError(ErrCode::NotFound, "not_found", "no such document");
-        uint64_t v = d->db.client().patch(c, id, bodyJson(req));
+        nlohmann::json patchBody = bodyJson(req);
+        const auto ttl = ttlParam(req);
+        uint64_t v = ttl ? d->db.client().patchWithTtl(c, id, patchBody, *ttl)
+                          : d->db.client().patch(c, id, patchBody);
         if (v == 0) throw ApiError(ErrCode::Unavailable, "db_error", "patch failed");
         if (v == 0) throw ApiError(ErrCode::Unavailable, "db_error", "patch failed");
         sendJson(res, 200, {{"id", id}, {"version", v}});
         sendJson(res, 200, {{"id", id}, {"version", v}});
     });
     });
@@ -69,7 +89,18 @@ void registerDocumentRoutes(ApiServer& s) {
 
 
     svr.Delete(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
     svr.Delete(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
         std::string c = scoped(d, req, 1, 2, KeyOp::Delete); std::string id = req.matches[3];
         std::string c = scoped(d, req, 1, 2, KeyOp::Delete); std::string id = req.matches[3];
-        if (!d->db.client().remove(c, id)) throw ApiError(ErrCode::NotFound, "not_found", "no such document");
+        std::string project = req.matches[1];
+        std::string err;
+        if (!d->db.client().remove(c, id, err)) {
+            // A restrict relation is the one refusal a caller can act on, so it
+            // gets 409 plus the impact list rather than a generic failure.
+            auto why = d->db.client().describeDelete(c, id);
+            if (why.success && why.wouldBeBlocked)
+                throw ApiError(ErrCode::Conflict, "relation_restricted",
+                               "delete blocked by a relation",
+                               {{"impacts", impactsJson(project, why)}});
+            throw ApiError(ErrCode::NotFound, "not_found", "no such document");
+        }
         sendJson(res, 200, {{"deleted", id}});
         sendJson(res, 200, {{"deleted", id}});
     });
     });
 }
 }

+ 124 - 0
src/handlers/indexes.cpp

@@ -0,0 +1,124 @@
+#include <cstdlib>
+
+#include "errors.hpp"
+#include "json_http.hpp"
+#include "server.hpp"
+
+namespace svapi {
+
+void registerIndexRoutes(ApiServer& s) {
+    auto& svr = s.raw(); ServerDeps* d = &s.deps();
+
+    // Declare an index. Backfills existing rows, so it is usable immediately.
+    // Idempotent: re-declaring an existing index reports already_existed.
+    svr.Post(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/indexes)",
+             [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], coll = req.matches[2];
+        requireProjectManage(k, project);
+        auto body = bodyJson(req);
+        // A request body of literal `null` (or any non-object JSON) parses fine but
+        // is not an object; guard before .value() so it 422s as "missing field"
+        // rather than 500ing on a type_error.
+        std::string field = body.is_object() ? body.value("field", "") : std::string();
+        if (field.empty()) throw ApiError(ErrCode::Unprocessable, "validation", "field is required");
+
+        const std::string qc = qualify(project, coll);
+        if (!d->db.client().getCollectionInfo(qc))
+            throw ApiError(ErrCode::NotFound, "not_found", "collection not found");
+
+        const bool unique = body.value("unique", false);
+        if (unique) {
+            auto r = d->db.client().createUniqueIndex(qc, field);
+            if (!r.success) {
+                if (!r.duplicateExamples.empty()) {
+                    nlohmann::json ex = nlohmann::json::array();
+                    for (const auto& id : r.duplicateExamples) ex.push_back(id);
+                    throw ApiError(ErrCode::Conflict, "duplicate_values",
+                                   "field '" + field + "' already holds duplicate values",
+                                   {{"duplicate_examples", ex}});
+                }
+                throw ApiError(ErrCode::Unavailable, "db_error",
+                               r.error.empty() ? "failed to create unique index" : r.error);
+            }
+            sendJson(res, 201, {{"field", field}, {"unique", true},
+                                {"rows_indexed", r.rowsIndexed},
+                                {"already_existed", r.alreadyExisted}});
+            return;
+        }
+
+        // createIndex() is idempotent on the DB side but does not itself report
+        // whether the index already existed, so check the current list first.
+        bool alreadyExisted = false;
+        {
+            auto existing = d->db.client().listIndexes(qc);
+            for (const auto& def : existing) if (def.field == field) { alreadyExisted = true; break; }
+        }
+
+        uint64_t rows = 0;
+        if (!d->db.client().createIndex(qc, field, rows))
+            throw ApiError(ErrCode::Unavailable, "db_error", "failed to create index");
+        sendJson(res, 201, {{"field", field}, {"unique", false},
+                            {"rows_indexed", rows}, {"already_existed", alreadyExisted}});
+    });
+
+    svr.Get(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/indexes)",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], coll = req.matches[2];
+        requireProjectAccess(k, project);
+
+        const std::string qc = qualify(project, coll);
+        if (!d->db.client().getCollectionInfo(qc))
+            throw ApiError(ErrCode::NotFound, "not_found", "collection not found");
+
+        std::vector<bool> uniqueFlags;
+        auto defs = d->db.client().listIndexes(qc, uniqueFlags);
+        nlohmann::json out = nlohmann::json::array();
+        for (size_t i = 0; i < defs.size(); ++i) {
+            const auto& def = defs[i];
+            const bool uniq = i < uniqueFlags.size() && uniqueFlags[i];
+            out.push_back({{"field", def.field},
+                           {"distinct_values", def.distinctValues},
+                           {"entries", def.entries},
+                           {"unique", uniq}});
+        }
+        sendJson(res, 200, {{"indexes", out}});
+    });
+
+    // Distinct values of an indexed field, with counts. Index-dependent by
+    // design: returns an empty list for an unindexed field and for array-valued
+    // fields, whose index keys are elements rather than values.
+    svr.Get(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/indexes/([^/]+)/values)",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], coll = req.matches[2], field = req.matches[3];
+        requireCapability(*d, k, req, project, coll, KeyOp::Read);
+
+        uint32_t limit = 100;
+        if (req.has_param("limit")) {
+            unsigned long wide = std::strtoul(req.get_param_value("limit").c_str(), nullptr, 10);
+            if (wide == 0 || wide > 1000)
+                throw ApiError(ErrCode::Unprocessable, "validation", "limit must be 1..1000");
+            limit = (uint32_t)wide;
+        }
+        const bool ascending = !(req.has_param("order") && req.get_param_value("order") == "desc");
+
+        auto vals = d->db.client().indexValues(qualify(project, coll), field, limit, ascending);
+        nlohmann::json out = nlohmann::json::array();
+        for (const auto& v : vals) out.push_back({{"value", v.value}, {"count", v.count}});
+        sendJson(res, 200, {{"values", out}});
+    });
+
+    svr.Delete(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/indexes/([^/]+))",
+               [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], coll = req.matches[2], field = req.matches[3];
+        requireProjectManage(k, project);
+        if (!d->db.client().dropIndex(qualify(project, coll), field))
+            throw ApiError(ErrCode::NotFound, "not_found", "index not found");
+        sendJson(res, 200, {{"dropped", field}});
+    });
+}
+
+} // namespace svapi

+ 139 - 0
src/handlers/relations.cpp

@@ -0,0 +1,139 @@
+#include "errors.hpp"
+#include "json_http.hpp"
+#include "server.hpp"
+
+namespace svapi {
+
+// Strip a leading "project:" so the API never leaks qualified names outward.
+std::string unqualify(const std::string& project, const std::string& name) {
+    const std::string pfx = project + ":";
+    return name.rfind(pfx, 0) == 0 ? name.substr(pfx.size()) : name;
+}
+
+namespace {
+
+nlohmann::json relJson(const std::string& project,
+                       const smartbotic::database::Client::RelationDefinition& r) {
+    return {{"name",        unqualify(project, r.name)},
+            {"child",       unqualify(project, r.child)},
+            {"child_field", r.childField},
+            {"parent",      unqualify(project, r.parent)},
+            {"on_delete",   r.onDelete},
+            {"validate_on_write", r.validateOnWrite},
+            {"created_at",  r.createdAt},
+            {"updated_at",  r.updatedAt}};
+}
+
+} // namespace
+
+// Shared with handlers/documents.cpp: renders describeDelete impacts as JSON.
+nlohmann::json impactsJson(const std::string& project,
+                           const smartbotic::database::Client::DescribeDeleteResult& r) {
+    nlohmann::json arr = nlohmann::json::array();
+    for (const auto& i : r.impacts) {
+        nlohmann::json ids = nlohmann::json::array();
+        for (const auto& s : i.sampleChildIds) ids.push_back(s);
+        arr.push_back({{"relation",         unqualify(project, i.relation)},
+                       {"child_collection", unqualify(project, i.childCollection)},
+                       {"child_field",      i.childField},
+                       {"on_delete",        i.onDelete},
+                       {"child_count",      i.childCount},
+                       {"sample_child_ids", ids},
+                       {"blocks",           i.blocks}});
+    }
+    return arr;
+}
+
+void registerRelationRoutes(ApiServer& s) {
+    auto& svr = s.raw(); ServerDeps* d = &s.deps();
+
+    svr.Post(R"(/api/v1/projects/([^/]+)/relations)",
+             [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req); std::string project = req.matches[1];
+        requireAdmin(k); requireProjectAccess(k, project);
+        auto body = bodyJson(req);
+        std::string name  = body.is_object() ? body.value("name", "") : std::string(),
+                    child  = body.is_object() ? body.value("child", "") : std::string(),
+                    field  = body.is_object() ? body.value("child_field", "") : std::string(),
+                    parent = body.is_object() ? body.value("parent", "") : std::string(),
+                    onDelete = body.is_object() ? body.value("on_delete", "restrict") : std::string("restrict");
+        if (name.empty() || child.empty() || field.empty() || parent.empty())
+            throw ApiError(ErrCode::Unprocessable, "validation",
+                           "name, child, child_field and parent are required");
+        if (name.rfind('_', 0) == 0 || name.rfind("vectorapi_", 0) == 0)
+            throw ApiError(ErrCode::Unprocessable, "validation",
+                           "name may not start with '_' or the reserved 'vectorapi_' prefix");
+        if (onDelete != "restrict" && onDelete != "cascade" &&
+            onDelete != "set_null" && onDelete != "no_action")
+            throw ApiError(ErrCode::Unprocessable, "validation",
+                           "on_delete must be restrict, cascade, set_null or no_action");
+        const bool validateOnWrite = body.is_object() ? body.value("validate_on_write", false) : false;
+
+        // The relation NAME is project-qualified exactly like the collections.
+        if (!d->db.client().createRelation(qualify(project, name), qualify(project, child), field,
+                                           qualify(project, parent), onDelete, validateOnWrite))
+            throw ApiError(ErrCode::Unavailable, "db_error", "failed to create relation");
+        sendJson(res, 201, {{"name", name}, {"child", child}, {"child_field", field},
+                            {"parent", parent}, {"on_delete", onDelete},
+                            {"validate_on_write", validateOnWrite}});
+    });
+
+    svr.Get(R"(/api/v1/projects/([^/]+)/relations)",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req); std::string project = req.matches[1];
+        requireAdmin(k); requireProjectAccess(k, project);
+        auto defs = d->db.client().listRelations(project);
+        nlohmann::json out = nlohmann::json::array();
+        for (const auto& r : defs) out.push_back(relJson(project, r));
+        sendJson(res, 200, {{"relations", out}});
+    });
+
+    svr.Get(R"(/api/v1/projects/([^/]+)/relations/([^/]+))",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], name = req.matches[2];
+        requireAdmin(k); requireProjectAccess(k, project);
+        auto info = d->db.client().getRelationInfo(qualify(project, name));
+        if (!info) throw ApiError(ErrCode::NotFound, "not_found", "relation not found");
+        sendJson(res, 200, relJson(project, *info));
+    });
+
+    svr.Delete(R"(/api/v1/projects/([^/]+)/relations/([^/]+))",
+               [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], name = req.matches[2];
+        requireAdmin(k); requireProjectAccess(k, project);
+        if (!d->db.client().dropRelation(qualify(project, name)))
+            throw ApiError(ErrCode::NotFound, "not_found", "relation not found");
+        sendJson(res, 200, {{"dropped", name}});
+    });
+
+    svr.Get(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents/([^/]+)/delete-impact)",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], coll = req.matches[2], id = req.matches[3];
+        // Read-only: changes nothing, but child counts and sample ids are facts
+        // about data, so it follows the collection's read grant.
+        requireCapability(*d, k, req, project, coll, KeyOp::Read);
+        auto r = d->db.client().describeDelete(qualify(project, coll), id);
+        if (!r.success) throw ApiError(ErrCode::NotFound, "not_found", "document not found");
+        sendJson(res, 200, {{"would_be_blocked", r.wouldBeBlocked},
+                            {"impacts", impactsJson(project, r)}});
+    });
+
+    svr.Put(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/relations-enforced)",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], coll = req.matches[2];
+        requireAdmin(k); requireProjectAccess(k, project);
+        auto body = bodyJson(req);
+        if (!body.is_object() || !body.contains("enforced") || !body["enforced"].is_boolean())
+            throw ApiError(ErrCode::Unprocessable, "validation", "enforced (boolean) is required");
+        const bool enforced = body["enforced"].get<bool>();
+        if (!d->db.client().setRelationsEnforced(qualify(project, coll), enforced))
+            throw ApiError(ErrCode::Unavailable, "db_error", "failed to set relations_enforced");
+        sendJson(res, 200, {{"collection", coll}, {"enforced", enforced}});
+    });
+}
+
+} // namespace svapi

+ 26 - 0
src/handlers/settings.cpp

@@ -66,5 +66,31 @@ void registerSettingsRoutes(ApiServer& s) {
         d->settings.save(updated);
         d->settings.save(updated);
         sendJson(res, 200, {{"ok", true}});
         sendJson(res, 200, {{"ok", true}});
     });
     });
+
+    // Server-global read-only lock (not per project). GET reflects the DB's own
+    // authoritative state - the DB can also enter read-only by itself after a
+    // failed/degraded recovery, not only via an operator's PUT here, so `reason`
+    // (when non-empty) tells the caller which it was.
+    svr.Get(R"(/api/v1/admin/readonly)", [d](const httplib::Request& req, httplib::Response& res) {
+        requireAdmin(requireKey(*d, req));
+        auto st = d->db.client().getReadOnlyStatus();
+        nlohmann::json j{{"readonly", st.readOnly}, {"scope", "server"}};
+        if (!st.reason.empty()) j["reason"] = st.reason;
+        sendJson(res, 200, j);
+    });
+
+    svr.Put(R"(/api/v1/admin/readonly)", [d](const httplib::Request& req, httplib::Response& res) {
+        requireAdmin(requireKey(*d, req));
+        auto body = bodyJson(req);
+        if (!body.is_object() || !body.contains("readonly") || !body["readonly"].is_boolean())
+            throw ApiError(ErrCode::Unprocessable, "validation", "readonly (boolean) is required");
+        const bool ro = body["readonly"].get<bool>();
+        if (!d->db.client().setReadOnly(ro))
+            throw ApiError(ErrCode::Unavailable, "db_error", "failed to set read-only mode");
+        auto st = d->db.client().getReadOnlyStatus();
+        nlohmann::json j{{"readonly", st.readOnly}, {"scope", "server"}};
+        if (!st.reason.empty()) j["reason"] = st.reason;
+        sendJson(res, 200, j);
+    });
 }
 }
 } // namespace svapi
 } // namespace svapi

+ 4 - 0
src/handlers/stats.cpp

@@ -43,6 +43,10 @@ void registerStatsRoutes(ApiServer& s) {
             {"cache_hit_ratio", total > 0 ? cs.hits / total : 0.0},
             {"cache_hit_ratio", total > 0 ? cs.hits / total : 0.0},
             {"cache_bytes",     cs.bytes}
             {"cache_bytes",     cs.bytes}
         };
         };
+        // What the dynamic linker actually bound, not what we compiled against:
+        // a stale .so under an unchanged soname is otherwise invisible.
+        out["db_client_version"] = smartbotic::database::clientLibraryVersion();
+        out["db_client_commit"]  = smartbotic::database::clientLibraryCommit();
         sendJson(res, 200, out);
         sendJson(res, 200, out);
     });
     });
 }
 }

+ 3 - 1
src/server.cpp

@@ -73,7 +73,7 @@ void ApiServer::registerRoutes() {
         try { std::rethrow_exception(ep); }
         try { std::rethrow_exception(ep); }
         catch (const ApiError& e) {
         catch (const ApiError& e) {
             if (e.code == ErrCode::TooManyRequests) res.set_header("Retry-After", "60");
             if (e.code == ErrCode::TooManyRequests) res.set_header("Retry-After", "60");
-            sendJson(res, httpStatus(e.code), errorBody(e.slug, e.what()));
+            sendJson(res, httpStatus(e.code), errorBody(e.slug, e.what(), e.details));
         }
         }
         catch (const std::exception& e) { sendJson(res, 500, errorBody("internal", e.what())); }
         catch (const std::exception& e) { sendJson(res, 500, errorBody("internal", e.what())); }
     });
     });
@@ -134,10 +134,12 @@ void ApiServer::registerRoutes() {
     registerProjectRoutes(*this);
     registerProjectRoutes(*this);
     registerKeyRoutes(*this);
     registerKeyRoutes(*this);
     registerCollectionRoutes(*this);
     registerCollectionRoutes(*this);
+    registerIndexRoutes(*this);
     registerDocumentRoutes(*this);
     registerDocumentRoutes(*this);
     registerVectorRoutes(*this);
     registerVectorRoutes(*this);
     registerStatsRoutes(*this);
     registerStatsRoutes(*this);
     registerSettingsRoutes(*this);
     registerSettingsRoutes(*this);
+    registerRelationRoutes(*this);
 
 
     if (!d_.shareDir.empty()) svr_.set_mount_point("/docs", d_.shareDir + "/docs");
     if (!d_.shareDir.empty()) svr_.set_mount_point("/docs", d_.shareDir + "/docs");
     if (!d_.webuiDir.empty()) svr_.set_mount_point("/", d_.webuiDir);
     if (!d_.webuiDir.empty()) svr_.set_mount_point("/", d_.webuiDir);

+ 8 - 0
src/server.hpp

@@ -48,15 +48,23 @@ void   requireProjectManage(const ApiKey& k, const std::string& project);  // th
 void   requireCapability(ServerDeps& d, const ApiKey& k, const httplib::Request& req,
 void   requireCapability(ServerDeps& d, const ApiKey& k, const httplib::Request& req,
                          const std::string& project, const std::string& collection, KeyOp op);
                          const std::string& project, const std::string& collection, KeyOp op);
 
 
+// Shared with handlers/relations.cpp; documents.cpp needs them for the 409 payload
+// on a relation-blocked delete.
+std::string unqualify(const std::string& project, const std::string& name);
+nlohmann::json impactsJson(const std::string& project,
+                           const smartbotic::database::Client::DescribeDeleteResult& r);
+
 // Route registration (handlers/*.cpp).
 // Route registration (handlers/*.cpp).
 void registerMetaRoutes(ApiServer&);
 void registerMetaRoutes(ApiServer&);
 void registerWebuiAuthRoutes(ApiServer&);
 void registerWebuiAuthRoutes(ApiServer&);
 void registerProjectRoutes(ApiServer&);
 void registerProjectRoutes(ApiServer&);
 void registerKeyRoutes(ApiServer&);
 void registerKeyRoutes(ApiServer&);
 void registerCollectionRoutes(ApiServer&);
 void registerCollectionRoutes(ApiServer&);
+void registerIndexRoutes(ApiServer&);
 void registerDocumentRoutes(ApiServer&);
 void registerDocumentRoutes(ApiServer&);
 void registerVectorRoutes(ApiServer&);
 void registerVectorRoutes(ApiServer&);
 void registerStatsRoutes(ApiServer&);
 void registerStatsRoutes(ApiServer&);
 void registerSettingsRoutes(ApiServer&);
 void registerSettingsRoutes(ApiServer&);
+void registerRelationRoutes(ApiServer&);
 
 
 } // namespace svapi
 } // namespace svapi

+ 363 - 0
tests/test_api_integration.cpp

@@ -166,6 +166,17 @@ TEST_F(ApiFixture, ProjectStatsAndGlobalStats) {
     EXPECT_TRUE(gJson.contains("memory_pressure_level"));
     EXPECT_TRUE(gJson.contains("memory_pressure_level"));
 }
 }
 
 
+TEST_F(ApiFixture, StatsReportsLoadedClientLibraryVersion) {
+    auto r = admin().Get("/api/v1/stats");
+    ASSERT_TRUE(r); ASSERT_EQ(r->status, 200);
+    auto body = nlohmann::json::parse(r->body);
+    ASSERT_TRUE(body.contains("db_client_version"));
+    ASSERT_TRUE(body.contains("db_client_commit"));
+    // Reports the LOADED library, so it must be non-empty and 2.x.
+    EXPECT_FALSE(body["db_client_version"].get<std::string>().empty());
+    EXPECT_EQ(body["db_client_version"].get<std::string>().rfind("2.", 0), 0u);
+}
+
 TEST_F(ApiFixture, OpenApiDescribesProjectRoutes) {
 TEST_F(ApiFixture, OpenApiDescribesProjectRoutes) {
     auto r = noAuth().Get("/openapi.json");
     auto r = noAuth().Get("/openapi.json");
     ASSERT_EQ(r->status, 200);
     ASSERT_EQ(r->status, 200);
@@ -893,3 +904,355 @@ TEST_F(ApiFixture, CaptchaGatedInsert) {
     s.captchaVerifyUrl = "";
     s.captchaVerifyUrl = "";
     settings_->save(s);
     settings_->save(s);
 }
 }
+
+TEST_F(ApiFixture, IndexCreateListDrop) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","idxc"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string idx = base + "/idxc/indexes";
+
+    auto made = c.Post(idx.c_str(), nlohmann::json{{"field","status"}}.dump(), "application/json");
+    ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
+    auto madeJson = nlohmann::json::parse(made->body);
+    EXPECT_EQ(madeJson["field"], "status");
+    EXPECT_FALSE(madeJson["unique"].get<bool>());
+
+    auto list = c.Get(idx.c_str());
+    ASSERT_EQ(list->status, 200);
+    auto listJson = nlohmann::json::parse(list->body);
+    bool found = false;
+    for (auto& e : listJson["indexes"]) if (e["field"] == "status") found = true;
+    EXPECT_TRUE(found);
+
+    EXPECT_EQ(c.Delete((idx + "/status").c_str())->status, 200);
+    auto after = c.Get(idx.c_str());
+    auto afterJson = nlohmann::json::parse(after->body);
+    for (auto& e : afterJson["indexes"]) EXPECT_NE(e["field"], "status");
+}
+
+TEST_F(ApiFixture, IndexCreateRequiresField) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","idxv"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    auto bad = c.Post((base + "/idxv/indexes").c_str(), nlohmann::json{}.dump(), "application/json");
+    ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
+}
+
+TEST_F(ApiFixture, IndexValuesReturnsFacetCounts) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","facets"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string docs = base + "/facets/documents";
+    for (const char* st : {"open", "open", "closed"})
+        ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"status", st}}.dump(), "application/json")->status, 201);
+    ASSERT_EQ(c.Post((base + "/facets/indexes").c_str(),
+                     nlohmann::json{{"field","status"}}.dump(), "application/json")->status, 201);
+
+    auto r = c.Get((base + "/facets/indexes/status/values").c_str());
+    ASSERT_TRUE(r); EXPECT_EQ(r->status, 200);
+    auto body = nlohmann::json::parse(r->body);
+    uint64_t open = 0, closed = 0;
+    for (auto& e : body["values"]) {
+        if (e["value"] == "open")   open   = e["count"].get<uint64_t>();
+        if (e["value"] == "closed") closed = e["count"].get<uint64_t>();
+    }
+    EXPECT_EQ(open, 2u);
+    EXPECT_EQ(closed, 1u);
+}
+
+TEST_F(ApiFixture, IndexValuesEmptyForUnindexedField) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","nofacet"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    auto r = c.Get((base + "/nofacet/indexes/whatever/values").c_str());
+    ASSERT_TRUE(r); EXPECT_EQ(r->status, 200);
+    EXPECT_TRUE(nlohmann::json::parse(r->body)["values"].empty());
+}
+
+TEST_F(ApiFixture, IndexValuesRejectsOutOfRangeLimit) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","limitrange"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    ASSERT_EQ(c.Post((base + "/limitrange/indexes").c_str(),
+                     nlohmann::json{{"field","status"}}.dump(), "application/json")->status, 201);
+
+    // 2^32 + 100: fits cleanly in the unsigned long strtoul returns, but is far
+    // out of the uint32_t range once narrowed. Must be rejected, not silently
+    // truncated down to a small in-range value.
+    auto overflow = c.Get((base + "/limitrange/indexes/status/values?limit=4294967396").c_str());
+    ASSERT_TRUE(overflow); EXPECT_EQ(overflow->status, 422);
+
+    // Existing behavior that must be preserved: non-numeric and empty-string
+    // limits still 422 (strtoul yields 0, caught by the ==0 check), and the
+    // default of 100 applies when the parameter is absent.
+    auto nonNumeric = c.Get((base + "/limitrange/indexes/status/values?limit=abc").c_str());
+    ASSERT_TRUE(nonNumeric); EXPECT_EQ(nonNumeric->status, 422);
+
+    auto empty = c.Get((base + "/limitrange/indexes/status/values?limit=").c_str());
+    ASSERT_TRUE(empty); EXPECT_EQ(empty->status, 422);
+
+    auto absent = c.Get((base + "/limitrange/indexes/status/values").c_str());
+    ASSERT_TRUE(absent); EXPECT_EQ(absent->status, 200);
+}
+
+TEST_F(ApiFixture, UniqueIndexRejectsExistingDuplicates) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","uq"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string docs = base + "/uq/documents";
+    ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","A1"}}.dump(), "application/json")->status, 201);
+    ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","A1"}}.dump(), "application/json")->status, 201);
+
+    auto conflict = c.Post((base + "/uq/indexes").c_str(),
+                           nlohmann::json{{"field","sku"},{"unique",true}}.dump(), "application/json");
+    ASSERT_TRUE(conflict); EXPECT_EQ(conflict->status, 409);
+    auto body = nlohmann::json::parse(conflict->body);
+    EXPECT_EQ(body["error"]["code"], "duplicate_values");
+    ASSERT_TRUE(body["error"]["details"].contains("duplicate_examples"));
+    EXPECT_GE(body["error"]["details"]["duplicate_examples"].size(), 1u);
+}
+
+TEST_F(ApiFixture, UniqueIndexOnCleanFieldSucceeds) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","uq2"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string docs = base + "/uq2/documents";
+    ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","B1"}}.dump(), "application/json")->status, 201);
+    ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","B2"}}.dump(), "application/json")->status, 201);
+
+    auto made = c.Post((base + "/uq2/indexes").c_str(),
+                       nlohmann::json{{"field","sku"},{"unique",true}}.dump(), "application/json");
+    ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
+    EXPECT_TRUE(nlohmann::json::parse(made->body)["unique"].get<bool>());
+}
+
+TEST_F(ApiFixture, DocumentTtlAcceptedOnInsertAndPatch) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","ttlc"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string docs = base + "/ttlc/documents";
+
+    auto made = c.Post((docs + "?ttl_seconds=3600").c_str(),
+                       nlohmann::json{{"v",1}}.dump(), "application/json");
+    ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
+    std::string id = nlohmann::json::parse(made->body)["id"];
+
+    // Document is readable while the TTL is in the future.
+    EXPECT_EQ(c.Get((docs + "/" + id).c_str())->status, 200);
+
+    // ttl_seconds=0 on PATCH clears the expiry (does not delete the document).
+    auto cleared = c.Patch((docs + "/" + id + "?ttl_seconds=0").c_str(),
+                           nlohmann::json{{"v",2}}.dump(), "application/json");
+    ASSERT_TRUE(cleared); EXPECT_EQ(cleared->status, 200);
+    auto got = c.Get((docs + "/" + id).c_str());
+    ASSERT_EQ(got->status, 200);
+    EXPECT_EQ(nlohmann::json::parse(got->body)["v"], 2);
+}
+
+TEST_F(ApiFixture, DocumentTtlRejectsNonNumeric) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","ttlbad"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    auto bad = c.Post((base + "/ttlbad/documents?ttl_seconds=soon").c_str(),
+                      nlohmann::json{{"v",1}}.dump(), "application/json");
+    ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
+}
+
+TEST_F(ApiFixture, RelationCrud) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","orders"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","customers"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+
+    std::string rel = "/api/v1/projects/" + project_ + "/relations";
+    auto made = c.Post(rel.c_str(), nlohmann::json{
+        {"name","order_customer"}, {"child","orders"}, {"child_field","customer_id"},
+        {"parent","customers"}, {"on_delete","restrict"}}.dump(), "application/json");
+    ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
+
+    auto list = c.Get(rel.c_str());
+    ASSERT_EQ(list->status, 200);
+    auto listJson = nlohmann::json::parse(list->body);
+    bool found = false;
+    for (auto& e : listJson["relations"])
+        if (e["name"] == "order_customer") { found = true; EXPECT_EQ(e["child"], "orders"); }
+    EXPECT_TRUE(found);
+
+    auto one = c.Get((rel + "/order_customer").c_str());
+    ASSERT_EQ(one->status, 200);
+    EXPECT_EQ(nlohmann::json::parse(one->body)["parent"], "customers");
+
+    EXPECT_EQ(c.Delete((rel + "/order_customer").c_str())->status, 200);
+    EXPECT_EQ(c.Get((rel + "/order_customer").c_str())->status, 404);
+}
+
+TEST_F(ApiFixture, RelationRejectsBadOnDelete) {
+    auto c = admin();
+    std::string rel = "/api/v1/projects/" + project_ + "/relations";
+    auto bad = c.Post(rel.c_str(), nlohmann::json{
+        {"name","r"}, {"child","a"}, {"child_field","b"},
+        {"parent","c"}, {"on_delete","explode"}}.dump(), "application/json");
+    ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
+}
+
+TEST_F(ApiFixture, DeleteBlockedByRelationReturns409WithImpacts) {
+    auto c = admin();
+    std::string relUrl = "/api/v1/projects/" + project_ + "/relations/inv_cust";
+
+    // RAII guard: this test creates a restrict relation that must not survive the
+    // test, since tmpName()'s project name is deterministic and dropProject does
+    // not purge collection data (see db_test_util.hpp). Runs on early ASSERT_*
+    // returns too. A missing relation on cleanup is fine (idempotent delete).
+    struct RelationGuard {
+        httplib::Client& c;
+        std::string url;
+        ~RelationGuard() { c.Delete(url.c_str()); }
+    } relGuard{c, relUrl};
+
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","inv"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","cust"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string rel = "/api/v1/projects/" + project_ + "/relations";
+    ASSERT_EQ(c.Post(rel.c_str(), nlohmann::json{
+        {"name","inv_cust"}, {"child","inv"}, {"child_field","cust_id"},
+        {"parent","cust"}, {"on_delete","restrict"}}.dump(), "application/json")->status, 201);
+
+    auto p = c.Post((base + "/cust/documents").c_str(),
+                    nlohmann::json{{"n","acme"}}.dump(), "application/json");
+    ASSERT_EQ(p->status, 201);
+    std::string pid = nlohmann::json::parse(p->body)["id"];
+    ASSERT_EQ(c.Post((base + "/inv/documents").c_str(),
+                     nlohmann::json{{"cust_id", pid}}.dump(), "application/json")->status, 201);
+
+    auto impact = c.Get((base + "/cust/documents/" + pid + "/delete-impact").c_str());
+    ASSERT_TRUE(impact); EXPECT_EQ(impact->status, 200);
+    auto impactJson = nlohmann::json::parse(impact->body);
+    EXPECT_TRUE(impactJson["would_be_blocked"].get<bool>());
+    EXPECT_GE(impactJson["impacts"].size(), 1u);
+
+    auto del = c.Delete((base + "/cust/documents/" + pid).c_str());
+    ASSERT_TRUE(del); EXPECT_EQ(del->status, 409);
+    auto delJson = nlohmann::json::parse(del->body);
+    EXPECT_EQ(delJson["error"]["code"], "relation_restricted");
+    EXPECT_GE(delJson["error"]["details"]["impacts"].size(), 1u);
+}
+
+// relations_enforced is read on the collection being deleted FROM (the
+// PARENT side of the relation) - Delete() checks
+// config_manager_.configFor(request->collection()), i.e. the parent's own
+// flag. Disabling it on the CHILD collection has no effect on deletes of
+// the parent's documents: the child's relations_enforced only governs the
+// child's own reverse-index arming / validate_on_write, a separate
+// mechanism. So the toggle here targets "cust2" (the parent), not "inv2"
+// (the child) - this is deliberately non-obvious and every API consumer
+// will trip on it otherwise (see task-8-report.md for the source trace).
+TEST_F(ApiFixture, RelationsEnforcedOnParentPermitsDelete) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    std::string relUrl = "/api/v1/projects/" + project_ + "/relations/inv2_cust2";
+    std::string enforcedUrl = base + "/cust2/relations-enforced";
+
+    // RAII guard: this test creates a restrict relation and flips relations_enforced
+    // to false on cust2 - both must be undone unconditionally, since tmpName()'s
+    // project name is deterministic and dropProject does not purge collection data
+    // (see db_test_util.hpp). Runs on early ASSERT_* returns too. A missing relation
+    // on cleanup is fine (idempotent delete); restoring enforced=true is idempotent too.
+    struct RelationEnforcedGuard {
+        httplib::Client& c;
+        std::string relUrl;
+        std::string enforcedUrl;
+        ~RelationEnforcedGuard() {
+            c.Delete(relUrl.c_str());
+            c.Put(enforcedUrl.c_str(), nlohmann::json{{"enforced", true}}.dump(),
+                 "application/json");
+        }
+    } relGuard{c, relUrl, enforcedUrl};
+
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","inv2"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","cust2"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string rel = "/api/v1/projects/" + project_ + "/relations";
+    ASSERT_EQ(c.Post(rel.c_str(), nlohmann::json{
+        {"name","inv2_cust2"}, {"child","inv2"}, {"child_field","cust_id"},
+        {"parent","cust2"}, {"on_delete","restrict"}}.dump(), "application/json")->status, 201);
+
+    auto p = c.Post((base + "/cust2/documents").c_str(),
+                    nlohmann::json{{"n","x"}}.dump(), "application/json");
+    std::string pid = nlohmann::json::parse(p->body)["id"];
+    ASSERT_EQ(c.Post((base + "/inv2/documents").c_str(),
+                     nlohmann::json{{"cust_id", pid}}.dump(), "application/json")->status, 201);
+
+    auto off = c.Put((base + "/cust2/relations-enforced").c_str(),
+                     nlohmann::json{{"enforced", false}}.dump(), "application/json");
+    ASSERT_TRUE(off); EXPECT_EQ(off->status, 200);
+    EXPECT_EQ(c.Delete((base + "/cust2/documents/" + pid).c_str())->status, 200);
+}
+
+TEST_F(ApiFixture, AdminReadonlyLockBlocksWritesThenReleases) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","rolock"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string docs = base + "/rolock/documents";
+
+    // RAII guard: unconditionally releases the server-global lock when the test
+    // function returns (including on an early ASSERT_* failure), so a mid-test
+    // assertion can never leave the DB locked for every later test in the suite.
+    struct UnlockGuard {
+        httplib::Client& c;
+        ~UnlockGuard() {
+            c.Put("/api/v1/admin/readonly", nlohmann::json{{"readonly", false}}.dump(),
+                 "application/json");
+        }
+    } unlockGuard{c};
+
+    auto on = c.Put("/api/v1/admin/readonly", nlohmann::json{{"readonly", true}}.dump(),
+                    "application/json");
+    ASSERT_TRUE(on); EXPECT_EQ(on->status, 200);
+    // GET is now authoritative (backed by the DB's own getReadOnlyStatus()), not a
+    // locally-tracked flag -- assert it reflects what the server itself reports.
+    auto st1 = c.Get("/api/v1/admin/readonly");
+    ASSERT_TRUE(st1); EXPECT_EQ(st1->status, 200);
+    auto st1Json = nlohmann::json::parse(st1->body);
+    EXPECT_TRUE(st1Json["readonly"].get<bool>());
+    EXPECT_EQ(st1Json["scope"], "server");
+
+    auto blocked = c.Post(docs.c_str(), nlohmann::json{{"v",1}}.dump(), "application/json");
+    ASSERT_TRUE(blocked); EXPECT_NE(blocked->status, 201);
+
+    auto off = c.Put("/api/v1/admin/readonly", nlohmann::json{{"readonly", false}}.dump(),
+                     "application/json");
+    ASSERT_TRUE(off); EXPECT_EQ(off->status, 200);
+    auto st2 = c.Get("/api/v1/admin/readonly");
+    ASSERT_TRUE(st2); EXPECT_EQ(st2->status, 200);
+    EXPECT_FALSE(nlohmann::json::parse(st2->body)["readonly"].get<bool>());
+    EXPECT_EQ(c.Post(docs.c_str(), nlohmann::json{{"v",2}}.dump(), "application/json")->status, 201);
+}
+
+TEST_F(ApiFixture, ReadonlyRequiresAdmin) {
+    // The fixture's scoped-key helper is used by the existing scoped-key tests;
+    // reuse the same construction to assert a non-admin key is refused.
+    auto c = admin();
+    auto made = c.Post("/api/v1/keys", nlohmann::json{
+        {"label","ro-nonadmin"}, {"projects", nlohmann::json::array({project_})},
+        {"admin", false}}.dump(), "application/json");
+    ASSERT_EQ(made->status, 201);
+    std::string nonAdmin = nlohmann::json::parse(made->body)["key"];
+    auto r = request("PUT", "/api/v1/admin/readonly",
+                     nlohmann::json{{"readonly", true}}.dump(), "application/json", nonAdmin);
+    ASSERT_TRUE(r); EXPECT_EQ(r->status, 403);
+}

+ 18 - 0
tests/test_errors.cpp

@@ -30,3 +30,21 @@ TEST(Errors, ForbiddenIs403) { EXPECT_EQ(httpStatus(svapi::ErrCode::Forbidden),
 TEST(Errors, TooManyRequestsMapsTo429) {
 TEST(Errors, TooManyRequestsMapsTo429) {
     EXPECT_EQ(svapi::httpStatus(svapi::ErrCode::TooManyRequests), 429);
     EXPECT_EQ(svapi::httpStatus(svapi::ErrCode::TooManyRequests), 429);
 }
 }
+
+TEST(Errors, ConflictMapsTo409) {
+    EXPECT_EQ(svapi::httpStatus(svapi::ErrCode::Conflict), 409);
+}
+
+TEST(Errors, ErrorBodyCarriesDetails) {
+    nlohmann::json det = {{"duplicate_examples", nlohmann::json::array({"a", "b"})}};
+    auto body = svapi::errorBody("duplicate_values", "field holds duplicates", det);
+    EXPECT_EQ(body["error"]["code"], "duplicate_values");
+    EXPECT_EQ(body["error"]["message"], "field holds duplicates");
+    ASSERT_TRUE(body["error"].contains("details"));
+    EXPECT_EQ(body["error"]["details"]["duplicate_examples"].size(), 2u);
+}
+
+TEST(Errors, ErrorBodyOmitsEmptyDetails) {
+    auto body = svapi::errorBody("validation", "bad");
+    EXPECT_FALSE(body["error"].contains("details"));
+}

+ 15 - 2
webui/src/api/client.ts

@@ -1,7 +1,7 @@
 import type {
 import type {
   LoginResponse, CollectionMeta, ApiKeyPublic, ApiKeyCreated,
   LoginResponse, CollectionMeta, ApiKeyPublic, ApiKeyCreated,
   SearchResult, ProjectStats, GlobalStats, FindResult, SettingsView,
   SearchResult, ProjectStats, GlobalStats, FindResult, SettingsView,
-  KeyScope,
+  KeyScope, IndexDef, IndexValue, CreateIndexResult,
 } from '@/types'
 } from '@/types'
 import { ApiError } from '@/types'
 import { ApiError } from '@/types'
 import { serverUrl } from '@/api/base'
 import { serverUrl } from '@/api/base'
@@ -23,7 +23,7 @@ async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
   }
   }
   const text = await res.text()
   const text = await res.text()
   const body = text ? JSON.parse(text) : {}
   const body = text ? JSON.parse(text) : {}
-  if (!res.ok) throw new ApiError(res.status, body?.error?.message || res.statusText)
+  if (!res.ok) throw new ApiError(res.status, body?.error?.message || res.statusText, body?.error?.details)
   return body as T
   return body as T
 }
 }
 
 
@@ -62,6 +62,19 @@ export const api = {
   deleteCollection: (project: string, name: string) =>
   deleteCollection: (project: string, name: string) =>
     request<{ deleted: string }>(`${P(project)}/collections/${enc(name)}`, { method: 'DELETE' }),
     request<{ deleted: string }>(`${P(project)}/collections/${enc(name)}`, { method: 'DELETE' }),
 
 
+  // indexes
+  listIndexes: (project: string, coll: string) =>
+    request<{ indexes: IndexDef[] }>(`${P(project)}/collections/${enc(coll)}/indexes`),
+  createIndex: (project: string, coll: string, field: string, unique: boolean) =>
+    request<CreateIndexResult>(`${P(project)}/collections/${enc(coll)}/indexes`,
+      { method: 'POST', body: JSON.stringify({ field, unique }) }),
+  dropIndex: (project: string, coll: string, field: string) =>
+    request<{ dropped: string }>(`${P(project)}/collections/${enc(coll)}/indexes/${enc(field)}`,
+      { method: 'DELETE' }),
+  indexValues: (project: string, coll: string, field: string, limit = 20, order?: 'asc' | 'desc') =>
+    request<{ values: IndexValue[] }>(
+      `${P(project)}/collections/${enc(coll)}/indexes/${enc(field)}/values?limit=${limit}${order ? `&order=${order}` : ''}`),
+
   // documents
   // documents
   findDocuments: (project: string, coll: string, qs = '') =>
   findDocuments: (project: string, coll: string, qs = '') =>
     request<FindResult>(`${P(project)}/collections/${enc(coll)}/documents${qs}`),
     request<FindResult>(`${P(project)}/collections/${enc(coll)}/documents${qs}`),

+ 225 - 0
webui/src/components/IndexPanel.tsx

@@ -0,0 +1,225 @@
+import { Fragment, useState } from 'react'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
+import { Plus, Trash2, ChevronDown, ChevronRight, Eye } from 'lucide-react'
+import { api } from '@/api/client'
+import { ApiError } from '@/types'
+import { useToastStore } from '@/stores/toastStore'
+
+interface IndexPanelProps {
+  project: string
+  collection: string
+}
+
+// ─── values sub-panel (expand-to-load) ─────────────────────────────────────────
+
+function IndexValues({ project, collection, field }: { project: string; collection: string; field: string }) {
+  const { data, isLoading, isError } = useQuery({
+    queryKey: ['index-values', project, collection, field],
+    queryFn: () => api.indexValues(project, collection, field, 20),
+  })
+
+  if (isLoading) return <p className="text-xs text-slate-500 py-2">Loading values…</p>
+  if (isError) return <p className="text-xs text-red-400 py-2">Failed to load values.</p>
+
+  const values = data?.values ?? []
+  if (values.length === 0) {
+    return (
+      <p className="text-xs text-slate-500 py-2">
+        No values to show (field is not indexed, or holds array values).
+      </p>
+    )
+  }
+
+  return (
+    <ul className="divide-y divide-slate-700/30 py-1">
+      {values.map((v, i) => (
+        <li key={i} className="flex items-center justify-between px-2 py-1 text-xs">
+          <span className="font-mono text-slate-300 truncate">{JSON.stringify(v.value)}</span>
+          <span className="text-slate-500">{v.count.toLocaleString()}</span>
+        </li>
+      ))}
+    </ul>
+  )
+}
+
+// ─── panel ──────────────────────────────────────────────────────────────────────
+
+export function IndexPanel({ project, collection }: IndexPanelProps) {
+  const qc = useQueryClient()
+  const push = useToastStore((s) => s.push)
+
+  const [field, setField] = useState('')
+  const [unique, setUnique] = useState(false)
+  const [duplicates, setDuplicates] = useState<string[]>([])
+  const [expanded, setExpanded] = useState<string | null>(null)
+
+  const key = ['indexes', project, collection]
+  const { data, isLoading } = useQuery({
+    queryKey: key,
+    queryFn: () => api.listIndexes(project, collection),
+  })
+
+  const create = useMutation({
+    mutationFn: () => api.createIndex(project, collection, field.trim(), unique),
+    onSuccess: (res) => {
+      setField('')
+      setUnique(false)
+      setDuplicates([])
+      void qc.invalidateQueries({ queryKey: key })
+      push(
+        res.already_existed
+          ? `Index on "${res.field}" already existed.`
+          : `Index created on "${res.field}" (${res.rows_indexed.toLocaleString()} rows indexed).`,
+        'success',
+      )
+    },
+    onError: (err: unknown) => {
+      if (err instanceof ApiError && err.status === 409) {
+        const det = err.details
+        const examples =
+          det && typeof det === 'object' && 'duplicate_examples' in det
+            ? (det as { duplicate_examples: unknown }).duplicate_examples
+            : undefined
+        setDuplicates(Array.isArray(examples) ? examples.map((v) => String(v)) : [])
+        return
+      }
+      setDuplicates([])
+      const msg = err instanceof ApiError ? err.message : String(err)
+      push(`Failed to create index: ${msg}`, 'error')
+    },
+  })
+
+  const drop = useMutation({
+    mutationFn: (f: string) => api.dropIndex(project, collection, f),
+    onSuccess: (res) => {
+      void qc.invalidateQueries({ queryKey: key })
+      if (expanded === res.dropped) setExpanded(null)
+      push(`Index on "${res.dropped}" dropped.`, 'success')
+    },
+    onError: (err: unknown) => {
+      const msg = err instanceof ApiError ? err.message : String(err)
+      push(`Failed to drop index: ${msg}`, 'error')
+    },
+  })
+
+  const indexes = data?.indexes ?? []
+
+  return (
+    <div className="rounded-lg border border-slate-700/40 bg-slate-800/30 p-4">
+      <h3 className="text-sm font-semibold text-slate-200 mb-3">Indexes</h3>
+
+      {isLoading ? (
+        <p className="text-sm text-slate-500">Loading…</p>
+      ) : (
+        <div className="rounded-lg border border-slate-700/40 overflow-hidden mb-4">
+          <table className="w-full text-sm">
+            <thead>
+              <tr className="border-b border-slate-700/40 bg-slate-800/60 text-left text-xs font-semibold text-slate-400 uppercase tracking-wider">
+                <th className="px-3 py-2" />
+                <th className="px-3 py-2">Field</th>
+                <th className="px-3 py-2">Unique</th>
+                <th className="px-3 py-2">Distinct</th>
+                <th className="px-3 py-2">Entries</th>
+                <th className="px-3 py-2 text-right">Actions</th>
+              </tr>
+            </thead>
+            <tbody>
+              {indexes.map((idx) => (
+                <Fragment key={idx.field}>
+                  <tr className="border-t border-slate-700/20">
+                    <td className="px-3 py-2">
+                      <button
+                        onClick={() => setExpanded((e) => (e === idx.field ? null : idx.field))}
+                        className="text-slate-500 hover:text-slate-300"
+                        aria-label={`Toggle values for ${idx.field}`}
+                      >
+                        {expanded === idx.field ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
+                      </button>
+                    </td>
+                    <td className="px-3 py-2 font-mono text-slate-200 text-xs">{idx.field}</td>
+                    <td className="px-3 py-2 text-slate-300">{idx.unique ? 'yes' : 'no'}</td>
+                    <td className="px-3 py-2 text-slate-300">{idx.distinct_values.toLocaleString()}</td>
+                    <td className="px-3 py-2 text-slate-300">{idx.entries.toLocaleString()}</td>
+                    <td className="px-3 py-2 text-right">
+                      <div className="flex items-center justify-end gap-1">
+                        <button
+                          onClick={() => setExpanded((e) => (e === idx.field ? null : idx.field))}
+                          className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-700/50 transition"
+                        >
+                          <Eye size={12} />
+                          Values
+                        </button>
+                        <button
+                          onClick={() => drop.mutate(idx.field)}
+                          disabled={drop.isPending}
+                          className="inline-flex items-center gap-1 px-2 py-1 rounded text-xs font-medium text-red-400 hover:text-red-300 hover:bg-red-500/10 transition disabled:opacity-50"
+                        >
+                          <Trash2 size={12} />
+                          Drop
+                        </button>
+                      </div>
+                    </td>
+                  </tr>
+                  {expanded === idx.field && (
+                    <tr className="border-t border-slate-700/10 bg-slate-900/30">
+                      <td />
+                      <td colSpan={5} className="px-3">
+                        <IndexValues project={project} collection={collection} field={idx.field} />
+                      </td>
+                    </tr>
+                  )}
+                </Fragment>
+              ))}
+              {indexes.length === 0 && (
+                <tr>
+                  <td colSpan={6} className="px-3 py-4 text-center text-slate-500">
+                    No indexes declared.
+                  </td>
+                </tr>
+              )}
+            </tbody>
+          </table>
+        </div>
+      )}
+
+      <div className="flex items-center gap-2 flex-wrap">
+        <input
+          value={field}
+          onChange={(e) => setField(e.target.value)}
+          placeholder="field name"
+          className="rounded-md bg-slate-800 border border-slate-600 text-slate-100 px-3 py-1.5 text-sm placeholder-slate-500 focus:outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500/40"
+        />
+        <label className="flex items-center gap-1.5 text-sm text-slate-300">
+          <input
+            type="checkbox"
+            checked={unique}
+            onChange={(e) => setUnique(e.target.checked)}
+            className="accent-indigo-500"
+          />
+          unique
+        </label>
+        <button
+          disabled={!field.trim() || create.isPending}
+          onClick={() => create.mutate()}
+          className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-500 transition disabled:opacity-50"
+        >
+          <Plus size={14} />
+          Create index
+        </button>
+      </div>
+
+      {duplicates.length > 0 && (
+        <div className="mt-3 rounded-md border border-red-500/30 bg-red-500/5 p-3 text-sm">
+          <p className="text-red-400">
+            Cannot make this field unique - these documents hold duplicate values:
+          </p>
+          <ul className="mt-1.5 list-disc pl-5 font-mono text-xs text-red-300 space-y-0.5">
+            {duplicates.map((id) => (
+              <li key={id}>{id}</li>
+            ))}
+          </ul>
+        </div>
+      )}
+    </div>
+  )
+}

+ 4 - 0
webui/src/pages/Collections.tsx

@@ -10,6 +10,7 @@ import { useAuthStore } from '@/stores/authStore'
 import { useToastStore } from '@/stores/toastStore'
 import { useToastStore } from '@/stores/toastStore'
 import { Modal } from '@/components/Modal'
 import { Modal } from '@/components/Modal'
 import { ConfirmDialog } from '@/components/ConfirmDialog'
 import { ConfirmDialog } from '@/components/ConfirmDialog'
+import { IndexPanel } from '@/components/IndexPanel'
 import { ApiError } from '@/types'
 import { ApiError } from '@/types'
 import type { CollectionMeta } from '@/types'
 import type { CollectionMeta } from '@/types'
 
 
@@ -332,6 +333,9 @@ function CollectionDetailDrawer({ project, name, onClose }: DetailDrawerProps) {
             <DetailRow label="Created" value={formatDate(data.created_at)} />
             <DetailRow label="Created" value={formatDate(data.created_at)} />
           </dl>
           </dl>
 
 
+          {/* Indexes */}
+          <IndexPanel project={project} collection={data.name} />
+
           {/* Quick actions */}
           {/* Quick actions */}
           <div className="flex items-center justify-end gap-3 pt-1">
           <div className="flex items-center justify-end gap-3 pt-1">
             {data.kind === 'vector' && (
             {data.kind === 'vector' && (

+ 19 - 1
webui/src/types/index.ts

@@ -47,9 +47,27 @@ export interface SettingsView {
 }
 }
 export class ApiError extends Error {
 export class ApiError extends Error {
   status: number
   status: number
-  constructor(status: number, message: string) {
+  details?: unknown
+  constructor(status: number, message: string, details?: unknown) {
     super(message)
     super(message)
     this.name = 'ApiError'
     this.name = 'ApiError'
     this.status = status
     this.status = status
+    this.details = details
   }
   }
 }
 }
+
+export type IndexDef = {
+  field: string
+  distinct_values: number
+  entries: number
+  unique: boolean
+}
+
+export type IndexValue = { value: unknown; count: number }
+
+export type CreateIndexResult = {
+  field: string
+  unique: boolean
+  rows_indexed: number
+  already_existed: boolean
+}