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 enum column returns one set at path $
  • a json field with a structure returns one set per constrained location, addressed by path and flagged multiple where 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.update webhooks 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

Important

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

Warning

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.

Warning

?cascade=true destroys every collection on the database and all their data. There is no undo.

Without the flag the 409 stands.

#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.

Warning

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)