Collections API
REST endpoints for creating, reading, updating, and deleting collection schemas in EmuView.
#Collections API
Collections are the core data model in EmuView. Each collection has a schema defining its fields, a physical D1 database table, and auto-generated CRUD endpoints for its records.
All collection schema endpoints use the /api/v1/collections base path.
#List collections
GET /api/v1/collections
Returns all collections in the current project.
#Request
#HTTP
GET /api/v1/collections
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 collections = await sdk.collections.list();
#Response
200 OK
[
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2",
"projectId": "proj_abc",
"name": "products",
"storageType": "d1",
"databaseId": "db_xyz",
"schemaVersion": 3,
"fields": [
{
"name": "title",
"type": "text",
"required": true,
"indexed": true
},
{
"name": "price",
"type": "decimal",
"required": true,
"defaultValue": 0
}
],
"accessControl": null,
"createdAt": 1718900000,
"updatedAt": 1718905000
}
]
#Field options
GET /api/v1/collections/:name/fields/:field/options
GET /api/v1/collections/:name/options
The values a field is allowed to hold — everything a client needs to render a picker, and nothing else.
Prefer these over reading the schema when all you want is choices: the response is small, safe to hand an app client (values and labels only — never validation rules or enforcement settings), respects the caller's column permissions, and is cacheable.
One shape answers for every kind of constrained field:
- an
enumcolumn returns one set at path$ - a
jsonfield with a structure returns one set per constrained location, addressed by path and flaggedmultiplewhere the location holds a list - a field that constrains nothing returns no sets, and is omitted from the collection-wide response
#Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Collection name (URL path) |
field |
string |
Yes | Field name (URL path) — field-scoped form only |
fields |
string |
No | Comma-separated field names to narrow to — collection-scoped only |
locale |
string |
No | BCP-47 tag selecting localised labels |
#Request
#HTTP
GET /api/v1/collections/trails/fields/tag/options?locale=en
Authorization: Bearer sk-your-api-key
#SDK
const { optionSets } = await sdk.collection('trails').fieldOptions('tag');
// Or every constrained field in one call:
const { fields } = await sdk.collection('trails').options({ locale: 'en' });
#Response
{
"field": "tag",
"type": "json",
"optionSets": [
{
"path": "$.biome[*]",
"label": "Biome",
"multiple": true,
"values": [
{ "value": "grassland", "label": "Grassland" },
{ "value": "alpine", "label": "Alpine" }
]
}
]
}
The collection-scoped form wraps the same objects:
{ "collection": "trails", "fields": [{ "field": "tag", "type": "json", "optionSets": [] }] }
#Errors
| Status | Code | Meaning |
|---|---|---|
404 |
COLLECTION_NOT_FOUND |
No such collection, or the caller may not read it (deliberately indistinguishable) |
404 |
NOT_FOUND |
No such field, or the caller may not read that column |
#Check records against a field's structure
GET /api/v1/collections/:name/fields/:field/scan
Reports which stored records do not match a JSON field's structure, grouped by
cause rather than listed by row. Use it before switching a structure's
enforcement to strict.
Most callers want the job form below instead. This endpoint hands you one batch and a cursor, so YOU own the loop and the retries. It exists for consumers that genuinely want that control; the dashboard does not use it.
Requires a builder grant (system/roles:read), not merely read on the
collection: the report spans every row regardless of any row-level access rule.
Scans one bounded batch per request and returns a cursor, so a collection of any
size can be checked without a request that outlives a Worker invocation. Follow
nextCursor until it is null and merge the groups.
#Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Collection name (URL path) |
field |
string |
Yes | Field name (URL path) |
cursor |
string |
No | nextCursor from the previous batch |
limit |
number |
No | Rows per batch (default 200, maximum 1000) |
#Request
GET /api/v1/collections/trails/fields/tag/scan?limit=200
Authorization: Bearer sk-your-api-key
#Response
{
"checked": 12,
"invalid": 12,
"nextCursor": null,
"estimate": { "total": 500000, "upperBound": 17526, "narrowed": true },
"groups": [
{
"code": "enum",
"path": "biome[]",
"detail": "\"grasslands\" is not an allowed value. Did you mean \"grassland\"?",
"count": 9,
"suggestion": "grassland",
"sampleIds": ["01JA...", "01JB..."]
}
]
}
List indices are collapsed (biome[0] and biome[7] both report as biome[]),
and one record contributes at most once to each group — so count is a number
of records to fix, not a number of bad values. Soft-deleted records are skipped.
checked is not the collection size. Where the structure allows it, the
scan uses a SQL predicate to read only the rows that might be wrong, so a clean
collection of half a million records reports checked: 0. Use estimate.total
as the denominator in any report.
estimate appears on the first batch only (the request with no cursor):
| Field | Meaning |
|---|---|
total |
Records in the collection, excluding soft-deleted |
upperBound |
A ceiling, not a count. Always at least the true number, often more. |
narrowed |
false when no predicate could be built, so upperBound is simply total |
Present upperBound as "up to N records may need attention". It comes from a
deliberately generous predicate — it can only ever overstate, and the exact
figure arrives when the walk finishes. Batches are capped by BYTES as well as by
limit, so a page shorter than limit does not by itself mean the scan is
finished; follow nextCursor until it is null.
#Errors
| Status | Code | Meaning |
|---|---|---|
403 |
FORBIDDEN |
The caller lacks the builder grant |
404 |
NOT_FOUND |
No such field |
422 |
VALIDATION_ERROR |
The field has no structure to check records against |
#Check records as a job
POST /api/v1/collections/:name/fields/:field/scan-jobs
GET /api/v1/collections/:name/scan-jobs/:jobId
DELETE /api/v1/collections/:name/scan-jobs/:jobId
The same check, run server-side. You start it and poll it; you never drive the
loop, and nothing has to stay connected for it to finish. Requires the same
builder grant (system/roles:read) as the cursor form.
POST returns 202 with the job's initial state. Every one of the three
endpoints returns the same object:
{
"jobId": "01JC...",
"kind": "scan",
"collection": "trails",
"field": "tag",
"status": "running",
"checked": 400,
"invalid": 12,
"estimate": { "total": 500000, "upperBound": 17526, "narrowed": true },
"groups": [
{
"code": "enum",
"path": "biome[]",
"detail": "\"grasslands\" is not an allowed value. Did you mean \"grassland\"?",
"count": 9,
"suggestion": "grassland",
"sampleIds": ["01JA...", "01JB..."]
}
],
"cursor": "01JB...",
"startedAt": 1718900000000,
"updatedAt": 1718900004000
}
status |
Meaning |
|---|---|
running |
Still walking. checked, invalid and groups grow as it goes |
complete |
The whole collection was checked; the figures are final |
cancelled |
Stopped by DELETE. The partial report is kept and is accurate as far as it got |
superseded |
The field's structure changed while it ran, so the results answer an old question |
failed |
See error |
checked, estimate and groups mean exactly what they mean on the cursor
endpoint, including upperBound being a ceiling you should present as "up to N
records may need attention".
The collection is not locked while a job runs. Editing the structure is
allowed at any time; the job notices and stops itself as superseded rather
than reporting against rules that no longer exist. Re-run it when you are happy
with the shape.
DELETE is the kill switch. It stops the job immediately and keeps whatever
it found. A cancelled job does not satisfy the dashboard's requirement for
switching a field to strict — only a complete job with invalid: 0 does.
#SDK
const job = await sdk.collection('trails').startStructureScan('tag');
const now = await sdk.collection('trails').getStructureScan(job.jobId);
await sdk.collection('trails').cancelStructureScan(job.jobId);
#Errors
| Status | Code | Meaning |
|---|---|---|
403 |
FORBIDDEN |
The caller lacks the builder grant |
404 |
NOT_FOUND |
No such field, or no such job on this collection |
422 |
VALIDATION_ERROR |
The field has no structure to check records against |
#Repair records against a structure
POST /api/v1/collections/:name/fields/:field/repair-jobs
GET /api/v1/collections/:name/repair-jobs/:jobId
DELETE /api/v1/collections/:name/repair-jobs/:jobId
Applies the check's suggested corrections across the collection. Same job shape
as a check, same builder grant, and kind is repair.
#Request
{
"fixes": [{ "kind": "enum", "path": "biome[]", "from": "grasslands", "to": "grassland" }]
}
Build each fix from a group in the check's report: kind is the group's code,
path is its path, from is its from, and to is its suggestion. A group
carrying both from and suggestion is repairable; anything else is not, and
the endpoint returns 422 rather than guessing.
kind |
What it corrects |
|---|---|
enum |
A value that should be a different allowed value |
unknown-key |
An unrecognised key that should be a declared entry |
key |
A map key that should be a different allowed name |
A type mismatch ("2.5h" where a number belongs) has no single right answer and
cannot be repaired in bulk. At most 50 fixes per job.
#Response
The job state, plus a repair block:
{
"jobId": "01JD...",
"kind": "repair",
"status": "complete",
"checked": 12500,
"repair": {
"fixes": [{ "kind": "enum", "path": "biome[]", "from": "grasslands", "to": "grassland" }],
"repaired": 9900,
"skipped": 0,
"failed": 2600,
"failures": [{ "id": "01JA...", "reason": "Biome must be a list." }]
}
}
Read all four numbers, not just repaired. skipped counts rows the fixes no
longer applied to — each fix is recomputed from the row's CURRENT value, so a
report from an hour ago cannot clobber an edit made since, and an already-correct
row is not written at all. failed counts rows the write path refused, usually
because they have a second problem nobody asked to fix; failures samples the
reasons.
#What to know before calling it
- Every repair is an ordinary record update. 12,500 repairs fire 12,500
record.updatewebhooks and Automate triggers. Tell whoever owns those flows before starting, not after. - Writes go out as the caller. Row-level access rules apply exactly as they would to a hand edit: records the caller may not edit are reported as failures, not repaired.
- Nothing is deleted. A fix rewrites a value or renames a key. It will not remove an entry to make a record valid, and it refuses a rename onto a key that already holds something.
- Cancelling stops the work, not the writes already made. A repair is a sequence of ordinary edits; there is nothing to roll back to.
#SDK
const fixes = report.groups
.filter((g) => g.from && g.suggestion)
.map((g) => ({ kind: g.code, path: g.path, from: g.from, to: g.suggestion }));
const job = await sdk.collection('trails').startStructureRepair('tag', fixes);
const now = await sdk.collection('trails').getStructureRepair(job.jobId);
await sdk.collection('trails').cancelStructureRepair(job.jobId);
#Errors
| Status | Code | Meaning |
|---|---|---|
403 |
FORBIDDEN |
The caller lacks the builder grant |
404 |
NOT_FOUND |
No such field, or no such job on this collection |
422 |
VALIDATION_ERROR |
No fixes given, more than 50, or a fix that is not a fix |
#Get collection
GET /api/v1/collections/:name
Retrieve a single collection's schema, including field definitions, access control policy, and column status.
#Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Collection name (URL path) |
#Request
#HTTP
GET /api/v1/collections/products
Authorization: Bearer sk-your-api-key
#SDK
const collection = await sdk.collections.get('products');
#Response
200 OK
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2",
"projectId": "proj_abc",
"name": "products",
"storageType": "d1",
"fields": [
{
"name": "title",
"type": "text",
"required": true,
"indexed": true
},
{
"name": "price",
"type": "decimal",
"required": true,
"defaultValue": 0
},
{
"name": "status",
"type": "enum",
"required": true,
"enumValues": ["draft", "published", "archived"]
}
],
"accessControl": null,
"columnStatuses": [{ "name": "created_by", "exists": true, "type": "TEXT REFERENCES user(id)" }],
"policyKey": "policy:collection:products",
"createdAt": 1718900000,
"updatedAt": 1718905000
}
#Errors
| Status | Code | Description |
|---|---|---|
404 |
collection_not_found |
No collection with this name exists in the project |
#Create collection
POST /api/v1/collections
Create a new collection with a defined schema. EmuView creates the underlying D1 table and registers the schema.
Permission required: schema/*:create
#Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Collection name. Must match /^[a-z][a-z0-9_]{0,49}$/ |
storageType |
string |
No | Storage backend. Default: d1. Options: d1, r2_sql, kv, remote, durable_object |
databaseId |
string |
No | D1 database binding ID (defaults to project database) |
fields |
FieldDef[] |
Yes | Array of field definitions |
#Field definition
Each field in the fields array accepts:
| Property | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Field name. Must match /^[a-z][a-z0-9_]{0,49}$/ |
type |
string |
Yes | One of 21 supported field types |
required |
boolean |
Yes | Whether the field is required (adds NOT NULL constraint) |
unique |
boolean |
No | Add UNIQUE constraint |
indexed |
boolean |
No | Create a database index for faster queries |
label |
string |
No | Human-readable label |
enumValues |
string[] |
No | Allowed values (required for enum type) |
defaultValue |
any |
No | Default value when not provided |
relationConfig |
object |
No | Relation configuration (required for relation type) |
validationRules |
object |
No | Advanced validation constraints |
#Request
#HTTP
POST /api/v1/collections
Content-Type: application/json
Authorization: Bearer sk-your-api-key
{
"name": "products",
"storageType": "d1",
"fields": [
{
"name": "title",
"type": "text",
"required": true,
"indexed": true,
"validationRules": { "minLength": 1, "maxLength": 200 }
},
{
"name": "price",
"type": "decimal",
"required": true,
"defaultValue": 0,
"validationRules": { "min": 0, "max": 999999.99 }
},
{
"name": "status",
"type": "enum",
"required": true,
"enumValues": ["draft", "published", "archived"]
},
{
"name": "category",
"type": "relation",
"required": false,
"relationConfig": {
"targetCollection": "categories",
"displayField": "name",
"relationType": "belongsTo",
"onDelete": "set-null"
}
}
]
}
#SDK
import { EmuView } from '@emuview/sdk';
const sdk = new EmuView({
url: 'https://your-api.example.com',
auth: { apiKey: 'sk-your-api-key' }
});
const collection = await sdk.collections.create({
name: 'products',
storageType: 'd1',
fields: [
{ name: 'title', type: 'text', required: true, indexed: true },
{ name: 'price', type: 'decimal', required: true, defaultValue: 0 },
{ name: 'status', type: 'enum', required: true, enumValues: ['draft', 'published', 'archived'] }
]
});
#Response
201 Created
{
"id": "a1b2c3d4e5f6a7b8c9d0e1f2",
"name": "products",
"storageType": "d1",
"databaseId": "db_xyz",
"fields": [
{ "name": "title", "type": "text", "required": true, "indexed": true },
{ "name": "price", "type": "decimal", "required": true, "defaultValue": 0 },
{
"name": "status",
"type": "enum",
"required": true,
"enumValues": ["draft", "published", "archived"]
}
],
"accessControl": null,
"schemaVersion": 1,
"createdAt": 1718900000
}
#Errors
| Status | Code | Description |
|---|---|---|
400 |
invalid_name |
Name contains invalid characters or exceeds 50 characters |
400 |
reserved_name |
Name conflicts with a reserved name |
400 |
reserved_field |
A field name collides with a system column |
400 |
duplicate_field |
Two fields share the same name |
400 |
invalid_field |
Field type is not supported or enum type is missing enumValues |
409 |
already_exists |
A collection with this name already exists in the project |
#Update collection
PATCH /api/v1/collections/:name
Add new fields or toggle indexes on existing fields.
Permission required: schema/*:update
Due to SQLite limitations, you can only add new fields or toggle indexes. Removing or renaming fields is not supported. Send the complete fields array — new fields are detected and added via ALTER TABLE.
#Parameters
| Parameter | Type | Required | Description |
|---|---|---|---|
name |
string |
Yes | Collection name (URL path) |
fields |
FieldDef[] |
Yes | Complete fields array including existing and new fields |
#Request
#HTTP
PATCH /api/v1/collections/products
Content-Type: application/json
Authorization: Bearer sk-your-api-key
{
"fields": [
{ "name": "title", "type": "text", "required": true, "indexed": true },
{ "name": "price", "type": "decimal", "required": true },
{ "name": "status", "type": "enum", "required": true, "enumValues": ["draft", "published", "archived"] },
{ "name": "description", "type": "richtext", "required": false }
]
}
#SDK
await sdk.collections.update('products', {
fields: [
{ name: 'title', type: 'text', required: true, indexed: true },
{ name: 'price', type: 'decimal', required: true },
{
name: 'status',
type: 'enum',
required: true,
enumValues: ['draft', 'published', 'archived']
},
{ name: 'description', type: 'richtext', required: false } // new field
]
});
#Response
200 OK
{
"name": "products",
"fields": [
{ "name": "title", "type": "text", "required": true, "indexed": true },
{ "name": "price", "type": "decimal", "required": true },
{
"name": "status",
"type": "enum",
"required": true,
"enumValues": ["draft", "published", "archived"]
},
{ "name": "description", "type": "richtext", "required": false }
],
"updatedAt": 1718906000
}
#Errors
| Status | Code | Description |
|---|---|---|
400 |
invalid_field |
New field has an invalid type or configuration |
404 |
collection_not_found |
Collection does not exist |
500 |
ddl_error |
Database schema migration failed |
#Delete collection
DELETE /api/v1/collections/:name
Drop the physical database table and remove the schema registry entry. This deletes all data in the collection permanently.
Permission required: schema/*:delete
Deleting a collection removes all its data permanently. This cannot be undone.
#Request
#HTTP
DELETE /api/v1/collections/products
Authorization: Bearer sk-your-api-key
#SDK
await sdk.collections.delete('products');
#Response
200 OK
{ "success": true }
#Errors
| Status | Code | Description |
|---|---|---|
404 |
collection_not_found |
Collection does not exist |
#Delete a database (and its collections)
DELETE /api/v1/databases/:id
Removing a database that still has collections attached is refused:
{
"error": "conflict",
"message": "Cannot delete database because it has 1 active collection(s) attached. Export and delete those collections first, or pass ?cascade=true to drop them with the database."
}
That default protects a real app's data. Pass ?cascade=true to drop the attached collections
along with the database — intended for disposable databases, such as a per-run end-to-end test
target you create and destroy around a suite.
Permission required: system/databases:manage
#Request
DELETE /api/v1/databases/db_xyz?cascade=true
Authorization: Bearer sk-your-api-key
#Response
200 OK
{
"success": true,
"cleanupLog": ["Dropped attached collection \"posts\"", "Removed database from project registry"],
"manualCleanupRequired": false
}
Each attached collection has its registry row and all derived state removed — permissions, file rows and their R2 objects, search-index sources, saved views, hooks, published maps, webhook attachments, and caches. The physical tables go with the database itself.
manualCleanupRequired is true when some derived state could not be removed; cleanupLog says
what was and wasn't done.
?cascade=true destroys every collection on the database and all their data. There is no undo.
Without the flag the
409stands.
#Errors
| Status | Code | Description |
|---|---|---|
404 |
not_found |
No database with this ID |
409 |
conflict |
Collections are still attached and ?cascade=true was not passed |
#Truncate collection
POST /api/v1/collections/:name/truncate
Delete all records in the collection — including soft-deleted rows — while keeping the schema, permissions, saved views, hooks, and files intact. This is the sanctioned way to reset sample, demo, or staging data (the lightweight alternative to dropping and re-provisioning a database), and it is safe to run on a schedule for rehearsal loads.
Permission required: collections/:name:hard_delete
Search indexes that draw from the collection are marked stale (the compiled index still serves the old rows until you rebuild it — same policy as collection delete); their names are returned in staleSearchIndexes. List caches, ETags, and cached counts are invalidated in the same request.
Truncation is permanent — there is no recycle bin for truncated rows. To delete only soft-deleted rows, use purge_deleted instead.
#Request
#HTTP
POST /api/v1/collections/posts/truncate
Authorization: Bearer sk-your-api-key
#SDK
const result = await sdk.collection('posts').truncate();
console.log(`Removed ${result.deleted} records`);
#Response
200 OK
{ "success": true, "deleted": 1204, "staleSearchIndexes": ["forum_search"] }
#Errors
| Status | Code | Description |
|---|---|---|
403 |
forbidden |
Caller lacks the hard_delete permission |
404 |
collection_not_found |
Collection does not exist |
409 |
collection_migrating |
Collection is migrating between databases |
#Reserved collection names
The following names cannot be used for collections:
auth, users, roles, permissions, databases, files, settings, plugins, api-keys, modules, system, schema, health, metrics, hooks, webhooks
#System columns
Every collection table includes these columns automatically. They cannot be used as field names:
| Column | Type | Description |
|---|---|---|
id |
TEXT PRIMARY KEY |
Unique record identifier (UUID without hyphens) |
created_at |
INTEGER NOT NULL |
Unix timestamp when the record was created |
updated_at |
INTEGER NOT NULL |
Unix timestamp of the last update |
created_by |
TEXT |
User ID of the creator |
updated_by |
TEXT |
User ID of the last updater |
deleted_at |
INTEGER |
Soft-delete timestamp (NULL = active) |