Collections
Manage collection schemas — create a typed table, list and describe collections, alter columns and indexes, drop a collection, and read migration history.
Collections are schema-first, relational tables in your project's own managed Postgres database. These admin routes manage the schema — create a collection, add or drop columns and indexes, and inspect migration history. End-user row reads and writes (with automatic per-user scoping) are covered in the Collections SDK guide.
Schema changes are applied asynchronously through a managed migration; the
create / alter / delete routes wait for the result and return the migration
version and status.
Paths below are shown relative to the API version base. The canonical external
prefix is https://api.amba.dev/v1, so /admin/projects/:projectId/... below
means /v1/admin/projects/:projectId/... on the wire.
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /admin/projects/:projectId/collections | Create a collection. |
| GET | /admin/projects/:projectId/collections | List collections (paginated). |
| GET | /admin/projects/:projectId/collections/:name | Describe one (columns, indexes, latest migration). |
| PATCH | /admin/projects/:projectId/collections/:name | Alter (add column / add index / drop column / relax user_id). |
| DELETE | /admin/projects/:projectId/collections/:name?confirm=:name | Drop a collection. |
| DELETE | /admin/projects/:projectId/collections/:name/data | Reset: empty all rows but keep the collection (schema stays). |
| GET | /admin/projects/:projectId/collections/:name/migrations | Migration history. |
| POST | /admin/projects/:projectId/collections/transaction | Atomic multi-collection write transaction. |
| POST | /admin/projects/:projectId/collections/:name/rows | Insert one row (admin — bypasses user-scope). |
| POST | /admin/projects/:projectId/collections/:name/rows:batch | Bulk-insert up to 500 rows. Canonical seeding path. |
| GET | /admin/projects/:projectId/collections/:name/rows | Find rows (query DSL). Excludes soft-deleted by default. |
| GET | /admin/projects/:projectId/collections/:name/rows/count | Count rows. Excludes soft-deleted by default. |
| GET | /admin/projects/:projectId/collections/:name/rows/:id | Fetch one row by id. |
| POST | /admin/projects/:projectId/collections/:name/rows/aggregate | Group-by aggregation (count/sum/avg/min/max). |
| PATCH | /admin/projects/:projectId/collections/:name/rows/:id | Update one row by id. |
| PATCH | /admin/projects/:projectId/collections/:name/rows | Bulk update rows matching where. |
| DELETE | /admin/projects/:projectId/collections/:name/rows/:id | Soft-delete one row by id (?hard=true hard-deletes). |
| DELETE | /admin/projects/:projectId/collections/:name/rows | Bulk delete rows matching where with ?confirm=<count>. |
Collection names must match ^[a-z][a-z0-9_]*$. Reserved prefixes
(amba_, coll_amba_) are rejected with 400 RESERVED_NAME.
POST /admin/projects/:projectId/collections
Create a collection. You supply the columns; Amba adds id, user_id,
created_at, updated_at, and deleted_at automatically, plus a primary
key and a user_id index.
Request (CollectionSchema)
| Field | Type | Required | Description |
|---|---|---|---|
name | string | yes | ^[a-z][a-z0-9_]*$. |
columns | array | yes | Column definitions (see below). |
indexes | array | no | Extra indexes beyond the defaults. |
shared | boolean | no | true makes user_id nullable so rows aren't owned by any one user. See below. |
owner_scope | "user" | "group" | no | Ownership model. user (default) = each row owned by its user_id. group = rows owned by a group_id (team); see group ownership. |
read_policy | "owner" | "public" | "member" | no | Who can read rows. owner (default) = your own rows; public = every signed-in user; member = any member of the owning group (member:<role> adds a role floor; requires owner_scope: "group"). |
write_policy | "owner" | "authenticated" | "member" | no | Who can write rows. owner (default); authenticated = any signed-in user; member / member:admin = group members at that role floor (requires owner_scope: "group"). |
Each column is { name, type, nullable?, references?, dimension? }.
type is one of text, integer, bigint, numeric, boolean,
timestamptz, date, uuid, jsonb, vector, or a native array type:
text[], integer[], bigint[], numeric[], boolean[], uuid[].
vector columns require a dimension (1–4096).
Array columns hold a flat, uniformly-typed list (tags, tropes, category
slugs, related ids) and unlock the contains / containedBy / overlaps
query operators. Prefer an
array column over jsonb for any flat list you filter on; keep jsonb for
nested documents and mixed-shape objects.
Shared (global) collections
By default every row carries a non-null user_id — collections are
per-user. For developer-seeded GLOBAL content that no single user owns
(question banks, content libraries, lookup tables, reference data), create
the collection with "shared": true. That drops the user_id NOT NULL
constraint so you can insert rows without a user_id.
Insert into a per-user collection without a user_id (e.g. seeding from a
function or the admin API) returns 400 NOT_NULL_VIOLATION. The fix is
either "shared": true at create time, or "relax_user_id": true on an
existing collection (see PATCH below). The end-user client SDK can never set
user_id — the server injects it from the session — so shared/global data
must be seeded through the admin API or MCP tools.
Access policies (public catalogs)
By default a collection is owner-scoped: client reads and writes are
auto-scoped to the signed-in user's own rows (plus any shared null-owner
rows on a shared collection). Two optional policies relax that per
collection:
read_policy: "public"— every signed-in user can read all rows. This is how you build a public catalog (a shared product list, a global leaderboard table, community posts) that everyone reads but only the owner edits.write_policy: "authenticated"— any signed-in user can write/update rows, not just the owner.
Policies are independent — a common pattern is read_policy: "public" with
the default write_policy: "owner" (everyone reads, each user only edits
their own). Omitting both keeps the historical owner/owner behavior. Set or
change a policy at create time, or later via set_policy on PATCH (below);
Group-owned collections (teams)
For data owned by a team rather than one user, create the collection with
owner_scope: "group" plus a member policy. Rows are owned by a group_id
(a group); access is resolved from the caller's membership in that
group, gated by role rank (owner > admin > member > viewer):
The collection carries group_id as its owner column (and a nullable user_id
for the creator). Clients act within a group via X-Group-Context: <groupId>
(or amba.asUser(uid).asGroup(gid) in the Node SDK), verified server-side
(403 NOT_A_GROUP_MEMBER if the caller isn't a member). Inserts require a group
context (400 GROUP_CONTEXT_REQUIRED) at the write role floor
(403 GROUP_ROLE_FORBIDDEN), and the owning group must be private
(400 GROUP_NOT_PRIVATE for an open-join group). owner_scope is fixed at
create time; set_policy can change the member role floors but not the scope.
See team-owned collections for the
full guide.
a bare re-create that omits the policy fields never clobbers a policy you set earlier.
Each index is { columns, name?, unique?, using? }, where using is
one of btree (default), hash, gin, gist, brin, hnsw, ivfflat.
Response 201
A DDL error returns 400 with error.code COLLECTION_MIGRATION_FAILED,
INVALID_COLLECTION_SCHEMA, or INVALID_IDENTIFIER.
Try it:
/admin/projects/%7B%7BprojectId%7D%7D/collectionscurl -X POST 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/collections'GET /admin/projects/:projectId/collections
List collections. Offset/limit pagination — limit defaults to 50 (max
200), offset defaults to 0.
Response 200
Try it:
/admin/projects/%7B%7BprojectId%7D%7D/collectionscurl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/collections'GET /admin/projects/:projectId/collections/:name
Describe a collection: its live columns and indexes (read from the database catalog) plus the most recent migration record.
Response 200
404 NOT_FOUND if the collection doesn't exist.
Try it:
/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7Dcurl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D'PATCH /admin/projects/:projectId/collections/:name
Alter a collection. Exactly one operation per call: add_column,
add_index, change_type, rename_column, drop_column, or
relax_user_id. Chain multiple PATCHes for multiple changes.
| Field | Type | Description |
|---|---|---|
add_column | object | A column definition (same shape as create). |
add_index | object | An index definition. |
change_type | object | Change an existing column's type. { column, to_type, from_type?, using? }. See cast safety below. |
rename_column | object | Rename a column. { from, to }. Metadata-only and fast, but breaks code referencing the old name. |
drop_column | string | Column name to drop. Destructive — requires ?confirm=<column>. |
relax_user_id | boolean | Drop the user_id NOT NULL so the collection can hold shared/global rows. Post-hoc equivalent of shared. |
set_policy | object | Change the access policy: { read_policy?, write_policy? }. See Access policies. |
change_type
Migrates a column to a new type in place — no drop-and-re-add, so existing data is preserved.
change_type rewrites the entire table and holds an exclusive lock for the duration, so the
collection is briefly unreadable and unwritable while it runs. On large collections, schedule it
during a low-traffic window.
Cast safety. Only a subset of type pairs convert automatically. A
using cast expression (e.g. "score::integer") is required for any
cast that isn't trivially safe (such as text → integer); the request is
rejected up front with 400 INVALID_COLLECTION_SCHEMA rather than failing
mid-migration. Casts that are always safe don't need using: any type →
text, and (when you pass from_type) the widening casts integer → bigint,
integer → numeric, and bigint → numeric. If a using cast still fails on
real row data at apply time, the call returns 400 COLLECTION_MIGRATION_FAILED
and the collection is left unchanged.
rename_column
A rename is a fast metadata-only change — no table rewrite. It breaks any
app code that still references the old column name, so re-run amba types generate and update your call sites afterward. The new name can't collide
with a server-managed column (id, user_id, created_at, updated_at,
deleted_at) or a reserved query keyword (and, or, not).
Response 200
400 INVALID_PATCH if not exactly one operation; 400 CONFIRMATION_REQUIRED
if drop_column is set without a matching ?confirm=.
Try it:
/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7Dcurl -X PATCH 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D'DELETE /admin/projects/:projectId/collections/:name
Drop a collection. Destructive — requires ?confirm=<name>.
Try it:
/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7Dcurl -X DELETE 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D?confirm=%7B%7BcollectionName%7D%7D'DELETE /admin/projects/:projectId/collections/:name/data
Reset a collection's data — empty every row but keep the collection, its
schema, indexes, and config. The collection-scoped analogue of a per-user
reset; ideal for wiping a fixture table between test runs without rebuilding
it. To delete the collection itself, use the DELETE …/collections/:name
route above.
hard by default — the rows are gone, not tombstoned. Pass ?hard=false to
soft-delete instead (stamps deleted_at on currently-live rows so they stay
recoverable). No ?confirm= is required: the path already names the exact
collection and this is a Bearer-authed admin-only surface.
Response 200
Errors
404 COLLECTION_NOT_FOUND— the collection doesn't exist.500 RESET_FAILED.
Try it:
/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D/datacurl -X DELETE 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D/data'GET /admin/projects/:projectId/collections/:name/migrations
Migration history for the project, newest first (limit default 50, max
500). Each row carries the version, status, applied_at, sql_text,
and error.
Response 200
Try it:
/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D/migrationscurl -X GET 'https://api.amba.dev/v1/admin/projects/%7B%7BprojectId%7D%7D/collections/%7B%7BcollectionName%7D%7D/migrations'Rows (admin)
These routes read and write rows as the developer/agent. Unlike the
end-user client SDK, they bypass per-user row scoping: an admin insert can
attribute a row to any user_id (pass it in the body) or leave it NULL on a
shared collection.
POST /admin/projects/:projectId/collections/:name/rows
Insert one row. The body is a flat column → value map (no wrapper).
Server-managed columns (id, created_at, updated_at, deleted_at) are
stripped; user_id is honored if present. Two control fields are recognised
on the body and are not treated as columns: as_system and
idempotency_key.
Returns 201 { "data": { ...row } }.
Shared (system) rows (as_system)
Pass "as_system": true to write a global, null-owner row (user_id = NULL) — the developer-seeded-content shape. It's mutually exclusive with an
explicit user_id (sending both is 400). The collection must allow a null
owner: create it with "shared": true or PATCH "relax_user_id": true,
otherwise an as_system insert returns 409 NOT_SHARED_COLLECTION.
Idempotency (Idempotency-Key)
To make a retried or double-submitted insert safe, supply an idempotency key
— either as the Idempotency-Key request header or as an idempotency_key
field on the body (if you send both, they must match). The first insert with a
given key creates the row (201); any replay with the same key returns the
original row unchanged (200) instead of a duplicate. The key is scoped
to (collection, key). If the original row was since soft-deleted, the replay
resolves to data: null.
Return shape (return=minimal)
By default the insert echoes the full row (RETURNING *). For agent-driven
bulk migrations — rows with large JSONB columns (word timings, embeddings,
chunk blobs) — that echo can exhaust an agent's token budget after a couple
of inserts. Pass ?return=minimal (or the idiomatic header
Prefer: return=minimal) to trim the response to the row id only. The row is
still inserted in full; only the response is trimmed.
The same option applies to the batch endpoint
below, where it matters most — a 500-row batch echoes rows: [{ "id": "…" }, …]
instead of megabytes of inserted rows.
A required column left unset returns
400 NOT_NULL_VIOLATION — most often user_id on a per-user collection;
create the collection with "shared": true (or PATCH "relax_user_id": true) if the rows are global/shared. 400 INVALID_COLUMN for an unknown
column; 404 COLLECTION_NOT_FOUND if the collection doesn't exist. A row that
collides with a unique index returns 409 CONFLICT (not an opaque 500) —
pass on_conflict / conflict_target query params to turn the insert into an
upsert instead.
Upsert (on_conflict / conflict_target)
The single-row insert accepts the same upsert query params as the client
route: ?on_conflict=error|ignore|update (default error) plus
?conflict_target=col1,col2 naming the unique-index columns. ignore returns
the existing row, update merges the supplied columns; both require
conflict_target, and a target not covered by a unique index (or absent from
the row) returns 400 INVALID_CONFLICT_TARGET. See the
client collections reference
for the full status-code matrix (admin inserts aren't user-scoped, so the
"owned by another user" 409 case doesn't apply).
POST /admin/projects/:projectId/collections/:name/rows:batch
Bulk-insert up to 500 rows in a single atomic statement — the canonical path for seeding and migrations (question banks, content libraries, reference data). Far faster than calling the single-row insert in a loop.
| Field | Type | Required | Description |
|---|---|---|---|
rows | array | yes | 1–500 column → value maps. Rows may be sparse — a column absent from a row is inserted NULL. |
on_conflict | string | no | "error" (default) aborts the whole batch on any conflict; "skip" ignores conflicting rows. |
Returns 201 { "data": { "inserted": <n>, "rows": [...] } }; with
"on_conflict": "skip" the response also carries "skipped": <n>. Pass
?return=minimal (or Prefer: return=minimal) to trim rows to [{ "id": "…" }, …]
— essential when seeding rows with large JSONB columns so the response doesn't exhaust
an agent's token budget. The rows are still inserted in full.
Errors: 400 BATCH_TOO_LARGE (more than 500 rows — split into multiple
calls), 400 NOT_NULL_VIOLATION / INVALID_COLUMN, 404 COLLECTION_NOT_FOUND, 409 CONFLICT (when on_conflict is "error").
GET /admin/projects/:projectId/collections/:name/rows
Find rows with the full query DSL — pass the [FindQuery] as a JSON body or
via ?query=<json> (and the friendly ?where= / ?limit= / ?order=
shortcuts). Unlike the client route, admin reads are not user-scoped, so
you see every user's rows.
Behavior change. includeDeleted now defaults to false on the admin row reads —
soft-deleted rows are excluded by default. (It previously defaulted to true.) Pass
"includeDeleted": true in the body (or ?includeDeleted=true) to include soft-deleted rows.
Returns 200 { "data": [...], "next_cursor": ... }.
where syntax
where is a column-keyed JSON object, not the Node SDK's
{ column, op, value } filter shape. On GET routes, URL-encode it in the
query string (?where=<json>), or put it inside the full ?query=<FindQuery>
object. Bulk write routes take the same object directly in the JSON body.
Supported field operators: eq, ne, gt, gte, lt, lte, in,
notIn, like, ilike, isNull, isNotNull, contains, containedBy,
and overlaps. Operators are not $-prefixed. The null checks must be
literal true ({ "deleted_at": { "isNull": true } }).
If you are translating from the Node SDK filter shape, map not_in →
notIn, is_null → isNull, is_not_null → isNotNull, and
contained_by → containedBy.
GET /admin/projects/:projectId/collections/:name/rows/count
Exact row count. Honors where + includeDeleted only (pagination /
projection params are rejected). Same default as the find route —
includeDeleted defaults to false, so soft-deleted rows are not
counted unless you pass ?includeDeleted=true.
Returns 200 { "data": { "count": <n> } }.
GET /admin/projects/:projectId/collections/:name/rows/:id
Fetch one row by id.
Returns 200 { "data": { ...row } }, 404 NOT_FOUND if no row exists, or
400 INVALID_UUID for a malformed id.
POST /admin/projects/:projectId/collections/:name/rows/aggregate
Group-by aggregation across all rows (no user-scope). Run a count / sum /
avg / min / max server-side instead of paging through rows.
| Field | Type | Required | Description |
|---|---|---|---|
select | array | yes | One or more { fn, column?, as? }. fn ∈ count/sum/avg/min/max. |
group_by | string | string[] | no | Column(s) to group by. Omit for a whole-collection aggregate. |
where | object | no | Filter the ROWS before aggregating. |
having | array | no | { fn, column?, op, value } conditions on the GROUPED result. op ∈ eq/ne/gt/gte/lt/lte. |
order_by | string | array | no | Order groups by a group_by column or select alias — "revenue" or { by, direction }. |
limit | number | no | Cap the number of returned groups (max 10000). |
includeDeleted | boolean | no | Include soft-deleted rows (default false; include_deleted also accepted). |
count with no column is COUNT(*); pass a column for COUNT(col).
sum/avg/min/max require a column. as sets the output alias.
having rebuilds its aggregate from fn/column, so it can filter on an
aggregate the select doesn't return. Like the row reads above,
soft-deleted rows are excluded by default — pass "includeDeleted": true
to include them.
Returns one row per group, each merging the group columns with the aggregate aliases:
POST /admin/projects/:projectId/collections/transaction
Run multiple collection writes atomically inside one project tenant database.
This is the admin equivalent of POST /v1/client/collections/transaction:
all operations commit or all roll back. Admin transactions bypass user row
scoping and can read/write any row in the project.
Each operation has collection plus op: "insert" | "update" | "delete".
Inserts take row and optional on_conflict / conflict_target. Updates
take id, non-empty set, optional expected, optional where, and optional
objects: "merge" | "replace" for JSONB object update behavior. Deletes take
id, optional expected, optional where, and optional hard: true for an
admin hard delete.
expected is a compare-and-set guard: the write applies only if the current
row still matches the supplied column values. A mismatch returns 409 PRECONDITION_FAILED; a missing row returns 404 NOT_FOUND. On transaction
failure, the response includes details.opIndex naming the failing operation.
Transactions are project-local; they do not span multiple Amba projects.
INSERT vs PATCH body shapes
The write verbs deliberately take different body shapes — this is a contract, not an accident:
| Verb | Body shape | Why |
|---|---|---|
POST …/rows (insert) | flat { "col": value, … } | An insert body is only column values — nothing to disambiguate. |
PATCH …/rows/:id (single row) | { "set": { … } } — flat also works | set is the canonical envelope (symmetry with bulk update); a flat body is accepted as an alias since it's unambiguous. |
PATCH …/rows (bulk) | { "where": { … }, "set": { … } } | set and where are siblings — the set wrapper is what disambiguates the update payload from the filter. |
Side by side:
A PATCH body with no usable set (an empty body, or a flat body on the
bulk route, where the wrapper is required) returns 400 INVALID_BODY
with a pointed hint naming the top-level keys you actually sent:
PATCH /admin/projects/:projectId/collections/:name/rows/:id
Update one row by id. The canonical body is { "set": { ...columns } }. A
flat { "col": value } body (the same shape INSERT takes) is also
accepted on this single-row route as an alias — there's no where here, so
the shape is unambiguous.
Two edge cases of the flat alias: a column literally named set holding an
object resolves to the wrapper interpretation — use the explicit { "set": … }
wrapper if you have one. And expected (compare-and-set, below) is a
top-level control key — it's stripped on the flat path, while inside a
{ "set": { … } } envelope a real column named expected is written
normally.
Compare-and-set (expected)
Optional optimistic concurrency, same contract as the
client PATCH:
include a top-level expected map of column → value and the update applies
only if the row's current values match — otherwise 409 PRECONDITION_FAILED
(vs 404 NOT_FOUND when the row doesn't exist at all). Null-safe:
"expected": { "col": null } matches a NULL column. Admin CAS is not
user-scoped — it can target any owner's row.
JSON object columns merge (merge-patch)
A set value that is a JSON object targeting a jsonb column merges
into the stored object (shallow, top-level key union, atomic in one
statement) instead of replacing it — same contract as the
client PATCH.
Arrays, scalars and null replace the column value, as does any patch whose
stored value isn't a JSON object. Opt out per request with
?objects=replace to overwrite the whole stored object.
PATCH /admin/projects/:projectId/collections/:name/rows
Bulk update rows matching a filter. Body is { "where": {...}, "set": {...} }.
Unlike the single-row route, the set wrapper is required here — set
and where are siblings, and the wrapper is what tells the update payload
apart from the filter; a flat body returns 400 INVALID_BODY with the
top-level-keys hint shown above. where is required and must produce at
least one predicate (400 WHERE_REQUIRED otherwise — an empty {} doesn't
count) so you can't accidentally rewrite the whole collection; to genuinely
touch every row, use an always-true predicate such as
{ "id": { "isNotNull": true } }. JSON object values targeting jsonb
columns merge exactly like the single-row PATCH above (?objects=replace
opts out).
DELETE /admin/projects/:projectId/collections/:name/rows/:id
Soft-delete one row by id. Pass ?hard=true only when you intentionally need
to remove the row from the table.
Returns 200 { "data": { "id": "...", "deleted": true, "mode": "soft" } }.
If a soft-delete targets an already soft-deleted row, the route returns
deleted: false with already_deleted: true. Missing rows return
404 NOT_FOUND.
DELETE /admin/projects/:projectId/collections/:name/rows
Bulk delete rows matching where. You must pass ?confirm=<count> with the
exact number of rows that should be deleted; preview that value with the
rows/count endpoint first. The default is a soft delete; ?hard=true
permanently deletes matching rows.
MCP tools
amba_collections_create, amba_collections_list, amba_collections_get,
amba_collections_alter, and amba_collections_delete wrap the schema
routes for agentic use — pass your pat and project_id. The destructive
operations set their confirm guard automatically. For rows,
amba_admin_insert_row (one row), amba_admin_insert_rows (bulk, up to 500
— the seeding/migration path), amba_admin_list_rows, and
amba_admin_aggregate_rows (group-by count/sum/avg/min/max) wrap the
row routes. amba_admin_insert_row takes on_conflict + conflict_target for
upserts, plus as_system to write a shared null-owner row.
amba_collections_create takes shared (and read_policy / write_policy),
and amba_collections_alter takes relax_user_id, for shared/global
collections. amba_collections_reset_data empties a collection's rows while
keeping its schema (the reset path above).