Records API

REST endpoints for listing, creating, reading, updating, and deleting records with filtering, sorting, and pagination.

#Records API

Records are data rows within a collection. All record endpoints are scoped to a specific collection and require collections/{name} permissions.

Base path: /api/v1/collections/:name/records


#List records

GET /api/v1/collections/:name/records

Query records with filtering, sorting, pagination, search, and relation expansion.

Permission required: collections/{name}:read

#Query parameters

Parameter Type Default Description
page integer 1 Page number (1-indexed)
limit integer 25 Records per page (max: 100)
sortBy string created_at Field to sort by. Must be a valid field or system column. A - prefix (-created_at) is shorthand for descending and overrides order
order asc|desc desc Sort direction
filter string JSON-encoded filter (see filtering)
search string Full-text search across text-like fields
expand string Comma-separated relation fields to expand inline
fields string Comma-separated field projection. System columns id, created_at, updated_at are always included
locale string project default For translatable fields: resolve values to this BCP-47 locale (with fallback), or * for the full { locale: value } map. Also selects the locale that sortBy and filter compare against on a translatable field (by that locale's value, with no fallback). On a single-record read, meta=i18n additionally returns per-locale status.
cursor string Opaque cursor for cursor-based pagination
include_deleted boolean false Include soft-deleted records. Requires the view_deleted permission
path string JSON path projection (returns { id, path, value } per record)

#Request

#HTTP

GET /api/v1/collections/products/records?filter={"status":"published"}&sortBy=price&order=asc&limit=10&expand=category
Authorization: Bearer sk-your-api-key

#SDK

import { EmuView } from '@emuview/sdk';

const sdk = new EmuView({
	url: 'https://your-api.example.com',
	auth: { apiKey: 'sk-your-api-key' }
});

const results = await sdk.collection('products').list({
	filter: { status: 'published' },
	sortBy: 'price',
	order: 'asc',
	limit: 10,
	expand: 'category'
});

#Response

200 OK

{
	"data": [
		{
			"id": "b10b01964f804a55b140be7dad738c8f",
			"title": "Widget Pro",
			"price": 29.99,
			"status": "published",
			"category": {
				"id": "cat_electronics",
				"name": "Electronics"
			},
			"created_at": 1718900000,
			"updated_at": 1718905000,
			"created_by": "usr_01HXK5M9ABCDEF",
			"updated_by": "usr_01HXK5M9ABCDEF"
		}
	],
	"total": 142,
	"page": 1,
	"limit": 10,
	"hasMore": true
}

#Cursor-based pagination

For large datasets, pass a cursor parameter instead of page. This uses keyset pagination, which avoids expensive COUNT queries:

GET /api/v1/collections/products/records?cursor=eyJ2IjoxNzE4OTA1MDAwLCJpZCI6ImFiYyJ9&limit=20
Authorization: Bearer sk-your-api-key

The response includes cursor tokens:

{
	"data": [],
	"total": -1,
	"page": 1,
	"limit": 20,
	"hasMore": true,
	"cursors": {
		"next": "eyJ2IjoxNzE4OTA1MDAwLCJpZCI6ImExYjJjM2Q0In0=",
		"previous": "eyJ2IjoxNzE4OTEwMDAwLCJpZCI6InoweXh3dnV0In0="
	}
}

Pass cursors.next as the cursor parameter to fetch the next page.


#Get record

GET /api/v1/collections/:name/records/:id

Retrieve a single record by ID.

Permission required: collections/{name}:read

#Path parameters

Parameter Type Description
name string Collection name
id string Record identifier

#Query parameters

Parameter Type Default Description
expand string Comma-separated relation fields to expand
include_deleted boolean false Return the record even if soft-deleted

#Request

#HTTP

GET /api/v1/collections/products/records/b10b01964f804a55b140be7dad738c8f?expand=category
Authorization: Bearer sk-your-api-key

#SDK

const product = await sdk.collection('products').get('b10b01964f804a55b140be7dad738c8f', {
	expand: 'category'
});

#Response

200 OK

{
	"data": {
		"id": "b10b01964f804a55b140be7dad738c8f",
		"title": "Widget Pro",
		"price": 29.99,
		"status": "published",
		"category": {
			"id": "cat_electronics",
			"name": "Electronics"
		},
		"created_at": 1718900000,
		"updated_at": 1718905000,
		"created_by": "usr_01HXK5M9ABCDEF",
		"updated_by": "usr_01HXK5M9ABCDEF"
	}
}

#Errors

Status Code Description
401 unauthorized Missing or invalid authentication
403 forbidden User's role lacks read permission on this collection
404 record_not_found No record with this ID exists (or is hidden by row-level security)

#Create record

POST /api/v1/collections/:name/records

Create a new record. System fields id, created_at, updated_at, created_by, and updated_by are set automatically.

owned_by is a system column too, but it is not set on create. It stays NULL, and NULL means "nobody has moved this record", which reads as created_by. It becomes a real value only when somebody hands the record over — see Transfer ownership below. The column is added to a collection lazily, so it appears in responses only once that collection has used owner scoping or had a record transferred.

Migrations & imports: a privileged caller (super_admin, or a role with admin access) may include explicit id, created_at, and/or updated_at (unix seconds) and they are persisted, preserving record identity across a migration. A duplicate id returns 409. For all other callers these keys are ignored and stripped — the response always shows the values that were actually stored. For large loads use the NDJSON import endpoint (POST /collections/:name/records/import-ndjson, SDK importNdjson()).

Permission required: collections/{name}:create

#Request

#HTTP

POST /api/v1/collections/products/records
Content-Type: application/json
Authorization: Bearer sk-your-api-key

{
  "title": "Widget Pro",
  "price": 29.99,
  "status": "published"
}

#SDK

const product = await sdk.collection('products').create({
	title: 'Widget Pro',
	price: 29.99,
	status: 'published'
});
// product.id → "a1b2c3d4..."

#Response

201 Created

{
	"id": "a1b2c3d4e5f6a7b8c9d0e1f2",
	"title": "Widget Pro",
	"price": 29.99,
	"status": "published",
	"created_at": 1718900000,
	"updated_at": 1718900000,
	"created_by": "usr_01HXK5M9ABCDEF",
	"updated_by": "usr_01HXK5M9ABCDEF"
}

#Errors

Status Code Description
400 missing_field A required field is missing from the request body
400 invalid_field A field value does not match the expected type
422 validation_error One or more fields failed validation rules
409 already_exists Unique constraint violation

#Update record

PATCH /api/v1/collections/:name/records/:id

Update specific fields on a record. Supports two modes: field-level updates and operator-based JSON mutations.

Permission required: collections/{name}:update

#Field-level update

Send the fields you want to change:

#HTTP

PATCH /api/v1/collections/products/records/b10b01964f804a55b140be7dad738c8f
Content-Type: application/json
Authorization: Bearer sk-your-api-key

{
  "title": "Widget Pro Max",
  "price": 39.99
}

#SDK

const updated = await sdk.collection('products').update('b10b01964f804a55b140be7dad738c8f', {
	title: 'Widget Pro Max',
	price: 39.99
});

#Operator-based update (JSON fields)

Use MongoDB-style operators for fine-grained mutations on JSON fields:

PATCH /api/v1/collections/products/records/b10b01964f804a55b140be7dad738c8f
Content-Type: application/json
Authorization: Bearer sk-your-api-key

{
  "$set": {
    "metadata.category": "electronics",
    "metadata.tags": ["sale", "featured"]
  },
  "$push": {
    "metadata.history": { "action": "price_change", "date": "2026-01-15" }
  },
  "$pull": {
    "metadata.tags": "clearance"
  },
  "$unset": {
    "metadata.legacy_field": true
  }
}

#Update operators

Operator Description
$set Set a value at a JSON path
$push Append a value to an array at a JSON path
$pull Remove a value from an array at a JSON path
$unset Delete a key at a JSON path

#Optimistic concurrency control

To prevent lost updates, send the record's _version back with your update.

_version is a counter the server increments on every write. It is returned on create, read and update responses. Do not use updated_at — it has second precision and was the version source in an older design; sending it now produces a permanent conflict.

Which channel you use decides the status code on a mismatch:

Method Example Status on mismatch
Header If-Match: 4 412 Precondition Failed
Query param ?version=4 409 Conflict
Body field "_version": 4 409 Conflict

OCC is opt-in: an update that sends no version is applied unconditionally.

A supplied version is re-checked as part of the write itself, so when several clients hold the same version exactly one update commits and the rest are told. A rejected update changes nothing.

# Read the current version, then update conditionally
VERSION=$(curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/api/v1/collections/products/records/rec_123" | jq -r '.data._version')

curl -X PATCH "$BASE/api/v1/collections/products/records/rec_123" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d "{\"price\": 29.99, \"_version\": $VERSION}"

On a 409/412: re-read the record, re-apply your change to the fresh copy, and retry with the new _version.

#Response

200 OK — returns the full updated record.

#Errors

Status Code Description
404 record_not_found Record does not exist
409 conflict Version mismatch, where the version came from the body or ?version=
412 precondition_failed Version mismatch, where the version came from If-Match
422 validation_error Updated values fail field validation rules

#Delete record

DELETE /api/v1/collections/:name/records/:id

Delete a record. Defaults to soft delete (sets deleted_at), with an option for permanent deletion.

Deleting an id that does not exist, is hidden by row-level security, or was already soft-deleted returns 404 record_not_found (not a silent 200), so deletes are safely idempotency-checkable. A permanent delete may still purge an already-soft-deleted row.

Permission required: collections/{name}:delete

#Query parameters

Parameter Type Default Description
permanent boolean false If true, permanently removes the record and any associated R2 files

#Request

#HTTP

DELETE /api/v1/collections/products/records/b10b01964f804a55b140be7dad738c8f
Authorization: Bearer sk-your-api-key

#SDK

// Soft delete (default)
await sdk.collection('products').delete('b10b01964f804a55b140be7dad738c8f');

// Permanent delete
await sdk.collection('products').delete('b10b01964f804a55b140be7dad738c8f', {
	permanent: true
});

#Response

200 OK

{ "success": true, "permanent": false }

#Restore record

POST /api/v1/collections/:name/records/:id/restore

Restore a soft-deleted record, clearing deleted_at so it reappears in normal reads and lists.

Restore is an explicit route, not a deleted_at write on update — writing deleted_at in a PATCH body is silently ignored. This keeps restore behind the same permission as the action that deleted the record, rather than granting it to everyone holding update.

Restoring an id that does not exist, is hidden by row-level security, or is not soft-deleted returns 404 — the three cases are indistinguishable, so the route reveals nothing about hidden records.

Permission required: collections/{name}:delete

#Request

#HTTP

POST /api/v1/collections/products/records/b10b01964f804a55b140be7dad738c8f/restore
Authorization: Bearer sk-your-api-key

#Response

200 OK

{ "success": true }

#Bulk delete

DELETE /api/v1/collections/:name/records/bulk

Delete multiple records in a single request.

Permission required: collections/{name}:delete

#Request

#HTTP

DELETE /api/v1/collections/products/records/bulk
Content-Type: application/json
Authorization: Bearer sk-your-api-key

{
  "ids": ["id_001", "id_002", "id_003"]
}

#Response

200 OK

{ "deleted": 3 }

Constraints:

  • Maximum 200 records per bulk delete
  • Respects row-level security (RBAC item filters)

#Export records

GET /api/v1/collections/:name/records/export

Export records as CSV or JSON.

Permission required: collections/{name}:read

#Query parameters

Parameter Type Default Description
format csv|json csv Export format
ids string Comma-separated record IDs (exports all if omitted)

#Request

GET /api/v1/collections/products/records/export?format=json
Authorization: Bearer sk-your-api-key

#Response

File download with appropriate Content-Disposition header.


#Import records

POST /api/v1/collections/:name/records/import-ndjson

Bulk-load records from newline-delimited JSON — one JSON object per line. This is the endpoint for migrations and large loads; POST /records one row at a time is far slower.

Important

Do not confuse this with POST /collections/:name/records/import, which is the geospatial

importer (GeoJSON, CSV, KML, GPX). Sending NDJSON there fails with a GeoJSON parse error.

Permission required: collections/{name}:create

#Query parameters

Parameter Type Default Description
dryRun boolean false Validate and report without writing anything
jobId string Attribute this chunk to an import job (see below)

A single request accepts at most 1000 records. For a larger load, send sequential chunks under one import job.

#Request

#HTTP

POST /api/v1/collections/posts/records/import-ndjson
Content-Type: application/x-ndjson
Authorization: Bearer sk-your-api-key

{"title":"First post","body":"…"}
{"title":"Second post","body":"…"}

#SDK

await sdk.collection('posts').importNdjson(rows);

#Response

{
	"imported": 2,
	"failed": 0,
	"total": 2,
	"truncated": false,
	"records": [
		/* the stored rows */
	],
	"errors": [],
	"staleSearchIndexes": ["forum_posts"]
}

staleSearchIndexes names every compiled search index whose sources include this collection. They are marked stale automatically — see Search indexes after an import.


#Import jobs

For a load larger than 1000 records, an import job gives you one handle and aggregate progress across all its chunks, instead of tracking counts client-side.

const { jobId } = await sdk.collection('posts').startImportJob({ total: rows.length });

for (const chunk of chunks(rows, 1000)) {
	await sdk.collection('posts').importNdjson(chunk, { jobId });
}

const job = await sdk.collection('posts').finalizeImportJob(jobId, { rebuild: true });
console.log(`${job.imported}/${job.total} imported`);
Note

Progress is a read-modify-write on a single job record, so drive a job's chunks sequentially.

Parallel chunks against one jobId can lose counts.

#Create a job

POST /api/v1/collections/:name/records/import-jobs

Body takes an optional { "total": <number> } — the row count you expect, used only to report progress against. Returns { "jobId", "status": "pending", "total" }.

#Read progress

GET /api/v1/collections/:name/records/import-jobs/:jobId

{
	"jobId": "4c4e6c7d-…",
	"collection": "posts",
	"status": "pending",
	"total": 438000,
	"imported": 12000,
	"failed": 3,
	"chunks": 12,
	"errors": [
		/* capped sample */
	],
	"createdAt": "2026-07-27T03:27:36.856Z",
	"updatedAt": "2026-07-27T03:29:01.980Z"
}

Counts accrue even when individual rows fail; errors holds a capped sample rather than every failure. Job records expire after a TTL, so read progress while the load is running.

#Finalize

POST /api/v1/collections/:name/records/import-jobs/:jobId/finalize

Marks the job complete and returns the final tallies. Pass ?rebuild=true to also kick off the search-index rebuild described below; the response then carries rebuildingSearchIndexes.


#Transfer ownership

POST /api/v1/collections/:name/records/:id/transfer

Hand a record to a new owner. created_by is deliberately untouched — who ADDED a record is history and never changes; who OWNS it is an access fact and can move. See Ownership for the model.

curl -X POST https://your-api.example.com/api/v1/collections/photos/records/abc123/transfer \
  -H "Authorization: Bearer sk-your-api-key" \
  -H "Content-Type: application/json" \
  -d '{ "userEmail": "ana@example.com" }'

Body

Field Type Notes
userId string The new owner. Exactly one of userId or userEmail.
userEmail string The same, by address. Matched case-insensitively.
keepAccess boolean Default true — leave the previous owner an edit share.

Response

{
	"success": true,
	"changed": true,
	"owner": "usr_ana",
	"previousOwner": "usr_priya",
	"previousOwnerKeptAccess": true
}

changed is false when the record already belonged to that person — a retry is safe and is not an error. previousOwnerKeptAccess is false when you asked for a clean break, when there was no previous owner, or when the collection has sharing turned off, in which case no demotion share could be written.

Who may call it

The record's effective owner, or a role granted transfer on the collection (manage includes it). update deliberately does not — it is the permission handed out most freely, and a handover riding inside it would let every editor reassign anybody's records.

The caller must also be able to READ the record: the row lookup composes the caller's row filter, so a record outside their scope answers 404 exactly as a missing one does.

Errors

Status When
400 Both userId and userEmail, or neither.
403 Not the owner, and no transfer grant.
404 No such record — or none the caller can reach.
404 Person not found. — deliberately the same message either way, so this cannot be used to test whether somebody has an account here.

#Search indexes after an import

Bulk-loading data invalidates any compiled search index built from that collection. EmuView handles this in two steps, deliberately kept separate:

Step When What happens
Mark stale Automatically, on every import Affected indexes are flagged; the response lists them in staleSearchIndexes
Rebuild Only when you ask finalize?rebuild=true, or POST /search-indexes/:name/rebuild

Rebuilds are not triggered per chunk on purpose. A 438k-row migration sent as 1000-row chunks would otherwise queue several hundred rebuilds of the same index. Load everything, then rebuild once at finalize.


#Filtering

Filters are passed as the filter query parameter (JSON-encoded string). EmuView supports two filter formats.

#MongoDB-style object filter

A nested object with operator keys:

{
	"status": { "_eq": "published" },
	"price": { "_gte": 10, "_lte": 100 }
}

Shorthand equality — { "status": "published" } is equivalent to { "status": { "_eq": "published" } }.

#Filter operators

Operator SQL equivalent Description
_eq = Equal
_neq != Not equal
_gt > Greater than
_gte >= Greater than or equal
_lt < Less than
_lte <= Less than or equal
_in IN (...) Value in array
_nin NOT IN (...) Value not in array
_contains LIKE '%val%' String contains
_ncontains NOT LIKE '%val%' String does not contain
_starts_with LIKE 'val%' String starts with
_nstarts_with NOT LIKE 'val%' String does not start with
_ends_with LIKE '%val' String ends with
_nends_with NOT LIKE '%val' String does not end with
_null IS NULL Field is null (pass true)
_nnull IS NOT NULL Field is not null (pass true)

#UserFilter array format

A flat array of filter objects — an alternative to the object format:

[
	{ "field": "status", "op": "eq", "value": "published" },
	{ "field": "price", "op": "gte", "value": 10 }
]
Operator Description
eq Equal
neq Not equal
gt, gte Greater than / or equal
lt, lte Less than / or equal
contains String contains
starts_with String starts with
ends_with String ends with
between Value between range (use value and valueTo)
in Value in comma-separated list
empty Field is NULL or empty string
not_empty Field is NOT NULL and not empty

#Logical combinators

Use _and and _or to build complex filters:

{
	"_or": [
		{ "status": { "_eq": "published" } },
		{
			"_and": [{ "status": { "_eq": "draft" } }, { "created_by": { "_eq": "$CURRENT_USER" } }]
		}
	]
}

#Dynamic variables

These variables are resolved server-side in filter values:

Variable Resolves to
$CURRENT_USER Authenticated user's ID
$CURRENT_ROLE Authenticated user's role name
$PROJECT_ID Current project ID
$NOW Current Unix timestamp (seconds)
$NOW_DATE Current date as ISO string (YYYY-MM-DD)

#Expand (relations)

The expand parameter resolves relation fields from foreign keys to full records.

#Single expansion

GET /api/v1/collections/posts/records?expand=author

Without expand, author returns the raw ID: "usr_abc123". With expand, it returns the full record:

{
	"author": {
		"id": "usr_abc123",
		"name": "Jane Doe",
		"email": "jane@example.com"
	}
}

#Multiple expansions

Comma-separate field names:

GET /api/v1/collections/posts/records?expand=author,category

#Nested expansions

Use dot notation:

GET /api/v1/collections/posts/records?expand=author,author.organization

#Relation types

Type Storage Expanded value
belongsTo Single ID string Single object or null
hasMany JSON array of IDs Array of objects
Note

Expansion respects RBAC permissions. If you lack read permission on the target collection, the expanded field returns null (belongsTo) or [] (hasMany).