Collections
SDK-facing row CRUD over your collections — insert, upsert, batch, find, find-nearest, count, aggregate, get, update, compare-and-set, and delete. Every read and write is auto-scoped to the signed-in user.
These are the row read/write routes the client SDK calls. Schema management (create a collection, add columns/indexes) lives in the admin collections reference; the SDK-level usage of everything below is covered in the Collections feature guide.
By default every route here is auto-scoped to the signed-in user. Reads
filter by user_id = <session user> AND deleted_at IS NULL; writes stamp
user_id from the session token and reject any client-supplied user_id — a
client can never set another user's id.
A collection can opt into broader access via its
access policy:
read_policy: "public" lets every signed-in user read all rows (public
catalogs), and write_policy: "authenticated" lets any signed-in user write.
The default policy (owner/owner) keeps the strict per-user scoping described
above.
A collection can also be group-owned (owner_scope: "group"): rows belong
to a group and access is resolved from the caller's membership, gated by role
(read_policy: "member", write_policy: "member:admin"; rank
owner > admin > member > viewer). Reads return the rows of every group the
signed-in user belongs to at the read role; to act within one specific group —
required when inserting, so Amba knows which group owns the new row — send
X-Group-Context: <groupId> alongside the session token (the Node SDK exposes
this as amba.asUser(uid).asGroup(gid)). Membership is verified server-side:
a group the caller isn't a member of is rejected with 403 NOT_A_GROUP_MEMBER,
an insert with no group context returns 400 GROUP_CONTEXT_REQUIRED, a role
below the write floor returns 403 GROUP_ROLE_FORBIDDEN, and only a private
group may own rows (400 GROUP_NOT_PRIVATE). See
team-owned collections.
All routes require both the client API key and a session token:
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /client/collections/:name | Insert one row. Optional upsert via query params. |
| POST | /client/collections/:name/batch | Bulk-insert up to 500 rows (atomic). |
| POST | /client/collections/:name/find | Find rows (SDK filter shape). |
| GET | /client/collections/:name | Find rows (query-string / ?query= shape). |
| POST | /client/collections/:name/find-nearest | Vector nearest-neighbor search. |
| GET | /client/collections/:name/count | Count rows matching a filter. |
| POST | /client/collections/:name/aggregate | Group-by aggregation (count/sum/avg/min/max). |
| GET | /client/collections/:name/:id | Get one row by id. |
| PATCH | /client/collections/:name/:id | Update one row by id. Optional compare-and-set. |
| DELETE | /client/collections/:name/:id | Soft-delete one row by id. |
Collection names must match ^[a-z][a-z0-9_]*$; reserved prefixes are
rejected with 400 RESERVED_NAME. Hitting a collection that doesn't exist
returns 404 COLLECTION_NOT_FOUND.
POST /client/collections/:name
Insert one row. The body is a flat column → value map. Server-managed
columns (id, created_at, updated_at, deleted_at) and any
client-supplied user_id are stripped; user_id is injected from the
session.
Request
Response 201
Upsert (on_conflict / conflict_target)
Upsert requires a unique index to arbitrate on. You declare unique
(and composite) indexes when you create the collection — pass them in the
indexes array with unique: true. See the
admin collections reference.
By default a plain insert that collides with a unique index returns 409 CONFLICT. To turn the insert into an upsert, pass two query params:
| Query param | Values | Description |
|---|---|---|
on_conflict | error (default), ignore, update | What to do when the row collides with a unique index. |
conflict_target | col1,col2 | The columns forming the unique index to arbitrate on. Comma-separated. |
error(default) — a plain insert. A unique-index clash returns 409 CONFLICT (not a 500).ignore— ON-CONFLICT-do-nothing. If the row already exists, the existing row is returned with 200; if it actually inserted, 201.update— merges the columns you provided into the existing row. 201 on a first insert, 200 on a merge.
conflict_target is required when on_conflict is ignore or update.
Every column you name must be present in the row and covered by a unique
index, or the request is rejected:
- A
conflict_targetcolumn missing from the row → 400 INVALID_CONFLICT_TARGET. - A
conflict_targetthat doesn't match any unique index on the collection → 400 INVALID_CONFLICT_TARGET.
Upsert is auto-RLS scoped: update can only merge into a row the caller owns. If the unique key
is already held by another user, the call returns 409 CONFLICT — a client can never take
over another user's row.
(user_id is part of the unique index server-side but is injected from the
session, so you only name the columns you actually supply — here kind.)
Errors
400 INVALID_BODY/INVALID_JSON— body isn't a JSON object.400 INVALID_COLUMN— unknown column or unsafe identifier.400 INVALID_CONFLICT_TARGET— see above.400 NOT_NULL_VIOLATION— a required column (oftenuser_idon a per-user collection) was left unset.409 CONFLICT— unique-index clash on a plain insert, or anupdateupsert blocked because the key belongs to another user.
Try it:
/client/collections/%7B%7BcollectionName%7D%7Dcurl -X POST 'https://api.amba.dev/v1/client/collections/%7B%7BcollectionName%7D%7D' \
-H 'Content-Type: application/json' \
-d '{
"title": "Hello amba",
"body": "My first post."
}'POST /client/collections/:name/batch
Bulk-insert up to 500 rows in a single atomic statement — the whole
batch commits or none of it does. user_id is injected on every row.
Request
Rows may be sparse — a column absent from one row is inserted as NULL.
Response 201
An empty rows array returns 201 { "data": [] }.
Errors
400 BATCH_TOO_LARGE— more than 500 rows. Split into multiple calls.400 INVALID_BODY— body isn't{ "rows": [...] }, or a row isn't an object.400 INVALID_COLUMN/NOT_NULL_VIOLATION.409 CONFLICT— any row collides with a unique index (the whole batch aborts).
Try it:
/client/collections/%7B%7BcollectionName%7D%7D/batchcurl -X POST 'https://api.amba.dev/v1/client/collections/%7B%7BcollectionName%7D%7D/batch' \
-H 'Content-Type: application/json' \
-d '{
"rows": [
{
"title": "One"
},
{
"title": "Two"
}
]
}'POST /client/collections/:name/find
Find rows using the SDK filter shape. This is the route the SDK's
find() calls.
Request (FindOptions)
| Field | Type | Description |
|---|---|---|
filter | object | Column-tagged filter leaves ({ column, op, value }) and and/or/not composition. |
search | object | Server-side keyword / fuzzy text search across columns. See Text search. |
order | OrderBy[] | { column, direction }. Single-column order required when paginating with cursor. |
limit | number | Page size (default 50, max 1000). |
offset | number | Offset pagination. |
cursor | string | Cursor for stable pagination (preferred over offset). |
select | string[] | Project specific columns. |
include_deleted | boolean | Include soft-deleted rows (default false). |
include | array | Eager-load related rows from other collections. See Eager-loading. |
Each filter leaf is { "column": "...", "op": "...", "value": ... }. op is
one of:
| Operators | Semantics |
|---|---|
eq ne gt gte lt lte | Comparison (=, <>, >, >=, <, <=). |
in / not_in | value is an array — membership test. |
like / ilike | SQL pattern match (%/_ wildcards); ilike is case-insensitive. |
is_null / is_not_null | Null test (omit value). |
contains / contained_by / overlaps | Array-column set operators (@>, <@, &&) — value is an array. See Array columns. |
Response 200
next_cursor is null when there are no more pages (only present on the
cursor path).
Array columns
Collections support native array column types (text[], integer[],
bigint[], numeric[], boolean[], uuid[]) — declare them at
create time.
The three set operators filter on them:
contains(@>) — the column has all of the given values.overlaps(&&) — the column shares at least one value.contained_by(<@) — every column element is in the given set.
On the raw where shape (the GET ?where= form,
admin row reads, and include.where) the operators are keys on the column —
{ "tags": { "contains": ["enemies-to-lovers"] } } — and contained_by is
spelled containedBy there. Prefer an array column over jsonb for any flat,
typed list you filter on; jsonb is for nested documents and can't use these
operators.
Text search
You don't need to ship rows to the client and filter them yourself, and you don't need a separate search service — find does keyword search server-side. Add a search object alongside (or instead of) filter:
| Field | Type | Description |
|---|---|---|
q | string | The query string (non-empty). |
columns | string[] | One or more columns to match against (non-empty). |
fuzzy | boolean | false (default) → case-insensitive substring match. true → typo-tolerant trigram similarity match. |
threshold | number | Fuzzy only: similarity cutoff in [0,1] (default 0.3). Higher = stricter. |
- Substring (default): a case-insensitive
containsmatch across the named columns —q: "ocean"matches"The Ocean at the End". Works on any collection. Literal%/_in the query are matched literally, not as wildcards. - Fuzzy: typo-tolerant word-similarity matching — the query matches when it's close to a word or phrase inside the column, so a short query still matches a long title and a near-miss (
q: "oceaen") still hits.threshold(default0.3) sets how close is close enough: raise it toward1for stricter matching, lower it for recall. When fuzzy is on and you haven't supplied an explicitorderorcursor, results are ranked best-match-first automatically (a row ranks by its best-matching column).
search AND's with filter, respects the collection's read scoping, and works with limit / cursor like any other find. The same search clause works on GET .../count via ?query=.
A catalog search — misspelled trope, still finds the right novels, best match first:
Which text tool when? search for user-typed queries (substring by
default, fuzzy: true for typo tolerance + ranking); the like / ilike
filter operators when you control the
pattern (prefix/suffix matches); find-nearest
for semantic similarity over a vector column — meaning, not keywords.
Eager-load related rows (include)
Fetch related rows from other collections in the same find call — one
batched query per relation instead of an N+1 fetch per row.
| Field | Type | Description |
|---|---|---|
collection | string | The related collection to load from. |
localKey | string | belongs-to: the FK column on the base row pointing at the target. |
foreignKey | string | Join column on the target. belongs-to: defaults to id. has-many: required — the FK pointing back at the base id. |
as | string | Result key the nested data lands under on each base row. |
single | boolean | belongs-to only: nest one object (row | null) instead of an array. |
limit | number | has-many only: cap related rows per base row (default 100, max 1000). |
where | object | Filter the related rows (same where shape as the base query). |
select | string[] | Project the related rows' columns. |
include | array | Nested include — load relations OF the related rows. Max depth 3; max 10 clauses total. |
Each novel row comes back with chapters: [...], and each chapter with
comments: [...] — three collections, three queries, regardless of row
count. The included collection's own read policy and soft-delete filter are
honored independently (an owner-scoped include only returns the caller's
related rows). At most 5 include clauses per level.
Try it:
/client/collections/%7B%7BcollectionName%7D%7D/findcurl -X POST 'https://api.amba.dev/v1/client/collections/%7B%7BcollectionName%7D%7D/find' \
-H 'Content-Type: application/json' \
-d '{
"filter": {
"column": "status",
"op": "eq",
"value": "published"
},
"limit": 50
}'GET /client/collections/:name
The query-string variant of find. Pass ?query=<JSON FindQuery> for the full
DSL, or the friendly shortcuts (?where=, ?limit=, ?order=,
?offset=, ?cursor=, ?select=, ?includeDeleted=). The two can't be
combined — a friendly shortcut alongside ?query= returns
400 INVALID_QUERY_PARAM. Unknown query params are rejected loudly with the
same code.
Response 200
Same shape as POST .../find.
Try it:
/client/collections/%7B%7BcollectionName%7D%7Dcurl -X GET 'https://api.amba.dev/v1/client/collections/%7B%7BcollectionName%7D%7D'POST /client/collections/:name/find-nearest
Vector nearest-neighbor search over a vector column. Supply either a
to_vector (a number array) or a to_text string (the server fetches the
embedding for you).
Request
| Field | Type | Description |
|---|---|---|
to_vector | number[] | Query vector. Mutually exclusive with to_text. |
to_text | string | Text to embed server-side. Mutually exclusive with to_vector. |
embedding_model | string | Model for to_text (default text-embedding-3-small). |
column | string | Vector column name (default embedding). |
k | number | Result count (default 10, max 100). |
distance | cosine | l2 | inner_product | Distance metric — must match the index's opclass (default cosine). |
where | object | Additional filter applied on top of the nearest-neighbor scan. |
include_deleted | boolean | Include soft-deleted rows (default false). |
Exactly one of to_vector / to_text is required. Each returned row carries
a synthesized _distance column.
Response 200
Errors
400 INVALID_NEAREST_INPUT— neither or both ofto_vector/to_text.400 INVALID_DISTANCE—distancenot one of the three metrics.400 INVALID_VECTOR—to_vectorisn't an array of finite numbers.
Try it:
/client/collections/%7B%7BcollectionName%7D%7D/find-nearestcurl -X POST 'https://api.amba.dev/v1/client/collections/%7B%7BcollectionName%7D%7D/find-nearest' \
-H 'Content-Type: application/json' \
-d '{
"to_text": "a calm beach at sunset",
"k": 5
}'GET /client/collections/:name/count
Exact server-side row count, scoped to the caller. Honors where and
includeDeleted only (pagination/projection params are rejected with
400 INVALID_QUERY_PARAM).
Response 200
Try it:
/client/collections/%7B%7BcollectionName%7D%7D/countcurl -X GET 'https://api.amba.dev/v1/client/collections/%7B%7BcollectionName%7D%7D/count'POST /client/collections/:name/aggregate
Group-by aggregation, scoped to the caller.
Request
| Field | Type | Description |
|---|---|---|
select | array | One or more { fn, column?, as? }. fn ∈ count/sum/avg/min/max. |
group_by | string | string[] | Column(s) to group by. Omit for a whole-collection aggregate. |
filter | object | SDK filter shape (or pass a raw where). Filters ROWS before aggregation. |
having | array | { fn, column?, op, value } conditions on the GROUPED result. op ∈ eq/ne/gt/gte/lt/lte. |
order_by | string | array | Order groups by a group_by column or select alias — "total" or { by, direction }. |
limit | number | Cap the number of returned groups (max 10000). |
include_deleted | boolean | Include soft-deleted rows (default false). |
count without a column is COUNT(*); pass a column for COUNT(col).
as sets the output alias (defaults to count, count_<col>, or
<fn>_<col>). sum/avg/min/max require a column. having rebuilds
its aggregate from fn/column, so it can filter on an aggregate the
select doesn't return.
Request example
"Top 5 statuses by revenue, ignoring statuses with fewer than 3 orders":
Response 200
One row per group, each merging the group columns with the aggregate aliases:
Try it:
/client/collections/%7B%7BcollectionName%7D%7D/aggregatecurl -X POST 'https://api.amba.dev/v1/client/collections/%7B%7BcollectionName%7D%7D/aggregate' \
-H 'Content-Type: application/json' \
-d '{
"select": [
{
"fn": "count",
"as": "total"
}
],
"group_by": "status"
}'GET /client/collections/:name/:id
Get one row by id. Returns 404 NOT_FOUND if the row doesn't exist or
doesn't belong to the caller. 400 INVALID_ID if id isn't a UUID.
Response 200
Try it:
/client/collections/%7B%7BcollectionName%7D%7D/%7B%7BrowId%7D%7Dcurl -X GET 'https://api.amba.dev/v1/client/collections/%7B%7BcollectionName%7D%7D/%7B%7BrowId%7D%7D'PATCH /client/collections/:name/:id
Update one row by id. The canonical body puts the columns to change inside a
set envelope; a flat { "col": value } body (the same shape INSERT takes)
is also accepted on this single-row route as an alias. A body with no usable
columns returns 400 INVALID_BODY naming the top-level keys you sent.
user_id and server-managed columns can't be updated. (The bulk update
route still requires the set wrapper — there it disambiguates the update
payload from the sibling where filter.)
Request
or, equivalently, flat:
A column literally named set holding an object resolves to the wrapper
interpretation — use the explicit { "set": … } envelope if you have one.
JSON object columns merge (merge-patch)
When a set value is a JSON object targeting a jsonb column, the keys
are merged into the stored object server-side instead of replacing it — two
devices each patching their own key both survive:
The merge is shallow (top-level key union, applied atomically in one
statement): a nested object replaces that key wholesale, and a key set to
null stores JSON null (it does not delete the key). Arrays, scalars and
null always replace the whole column value, and if the stored value isn't a
JSON object the patch object replaces it too. To overwrite the entire stored
object, opt out per request with ?objects=replace:
The same semantics (and the same ?objects=replace opt-out) apply to the
bulk update routes; on a transaction update operation, pass
"objects": "replace" on the op instead.
Compare-and-set (expected)
Add an expected map to make the update conditional — optimistic
concurrency without a transaction. The update applies only if the row still
matches every column in expected; otherwise it returns
409 PRECONDITION_FAILED and nothing changes.
expected is null-safe: "expected": { "assignee": null } matches a row
whose assignee is currently NULL. A common pattern is read → modify →
write-back with expected set to the values you read, so a concurrent writer
can't silently clobber your change.
Response 200
The updated row.
Errors
400 INVALID_BODY— no columns to update (empty body / emptyset), orexpectedisn't an object.400 INVALID_COLUMN— attempt to writeuser_idor a server-managed column.404 NOT_FOUND— row doesn't exist or isn't the caller's.409 PRECONDITION_FAILED— the row no longer matchesexpected.
Try it:
/client/collections/%7B%7BcollectionName%7D%7D/%7B%7BrowId%7D%7Dcurl -X PATCH 'https://api.amba.dev/v1/client/collections/%7B%7BcollectionName%7D%7D/%7B%7BrowId%7D%7D' \
-H 'Content-Type: application/json' \
-d '{
"set": {
"title": "New title"
}
}'DELETE /client/collections/:name/:id
Soft-delete one row by id — sets deleted_at and excludes the row from
subsequent reads. Hard delete is admin-only.
Response 200
Returns 404 NOT_FOUND if the row doesn't exist or isn't the caller's.
Try it:
/client/collections/%7B%7BcollectionName%7D%7D/%7B%7BrowId%7D%7Dcurl -X DELETE 'https://api.amba.dev/v1/client/collections/%7B%7BcollectionName%7D%7D/%7B%7BrowId%7D%7D'MCP tools
For agentic row writes, amba_client_insert_row takes on_conflict and
conflict_target for upserts, and amba_client_update_row takes expected
for compare-and-set. Schema-side row tooling (admin) is documented in the
admin collections reference.