CollectionDb (v2)
CollectionDb is the v2 storage engine โ the "type": "collection" table. It's
a schema-flexible, columnar store: you can POST raw JSON and let Aito infer the
schema, evolve that schema as your data grows, and edit it declaratively. This
guide is the reference for what a collection exposes.
Collection vs table
collection (rep2) | table (rep1) | |
|---|---|---|
| Engine | CollectionDb (new, columnar) | TableDb (production) |
| Schema | flexible โ infer from JSON, evolve incrementally | fixed at creation |
| Storage | compact columnar, memory-mapped reads | row-oriented |
| Use when | you want the v2 engine, JSON ingest, schema evolution | you need v1 parity today |
table is still the default; create a collection with "type": "collection", or
let /import create one for you.
The v2 engine is built for collections. A rep1 table can be reached through
/api/v2 for basic filtering and predict, but its v2 support is deliberately
minimal (v1 is being retired) โ e.g. the text matchers $match / $search are
collection-only. To use v2 fully on data in a v1 table, migrate it to a
collection or branch it into an env to trial v2 โ see
Which Query Type? โ v2 works on v2 collections.
Ingesting data
| Endpoint | What it does |
|---|---|
POST /api/v2/data/{table}/import | Schema-flexible import. POST a JSON array, or a CSV with a header row (content-type: text/csv); Aito infers column types, creates the table if absent, and appends if present. ?type=table makes a rep1 table instead. |
POST /api/v2/data/{table} | Insert one document. |
POST /api/v2/data/{table}/batch | Insert an array of documents. Add ?on_conflict=update|ignore to upsert/skip against a declared primaryKey. |
POST /api/v2/data/{table}/stream | Insert NDJSON (one object per line). |
POST /api/v2/schema/_infer | Infer a schema from example rows without creating anything (a dry run). |
POST /api/v2/data/events/import
[
{ "user": "ann", "action": "view", "ts": "2026-06-01" },
{ "user": "bob", "action": "buy", "ts": "2026-06-02", "amount": 19.0 }
]
Type inference maps JSON scalars to Int/Long/Decimal/Boolean/String,
dates to Date, and nested/irregular values to Json. When two rows disagree on
a field's type, the column widens to Json (lossless) rather than failing.
A string column that reads like free text โ most values several words, over
15 characters on average, and many different values โ is inferred as Text,
so its words become evidence; short codes, labels and ids stay String. The
response's inferred shows the type each column got.
Available since: v2.11.3 for Text inference, text/csv, and
the warning below. Up to v2.11.2 every string column was inferred as String.
Loading a CSV
Post the file as it is, with content-type: text/csv:
curl -X POST "$AITO_URL/api/v2/data/invoices/import" \
-H "x-api-key: $AITO_KEY" -H 'content-type: text/csv' --data-binary @invoices.csv
The header row names the columns. A CSV cell is always text, so each column's
type is read from all its cells: all integers โ a number, all numbers โ Decimal,
all true/false โ Boolean, otherwise a string โ and a free-text column (an
invoice description) โ Text, as above. A column with a leading zero (00100)
stays a string, so postal codes and ids keep their digits. A malformed CSV โ a row
with more or fewer cells than the header, an empty or duplicate header column โ
answers 400 with the code import.csv_invalid, naming the row or column.
Why Text matters. A String is one opaque value, so the individual words
of a description never become evidence: a prediction that should lean on it
comes back flat, with every candidate at about the same probability. On 200
invoices, predicting the category from the description scored 0.25 with the
description as a String and 1.00 as inferred Text (5 categories).
If the table already exists with a free-text column declared String, /import
keeps the declared type and adds an import.string_looks_like_text warning to
the response naming the column โ re-declare it as Text.
To choose the analyzer (e.g. english stemming), or to control every type,
declare the schema first and insert with /batch:
import csv, os, requests
AITO_URL = 'http://127.0.0.1:9005' # a hosted instance: https://your-instance.aito.ai/db/your-db
HEADERS = {'x-api-key': os.environ['AITO_KEY'], 'content-type': 'application/json'}
# 1. Declare the schema. The free-text column is Text, so its words become evidence.
schema = {
'type': 'collection',
'columns': {
'vendor': {'type': 'String'},
'description': {'type': 'Text', 'analyzer': 'english'},
'amount': {'type': 'Decimal'},
'gl_account': {'type': 'String'},
},
}
requests.put(f'{AITO_URL}/api/v2/schema/invoices', json=schema, headers=HEADERS).raise_for_status()
# 2. Read the CSV. Every CSV value is a string, so convert the numeric columns.
with open('invoices.csv', newline='') as f:
rows = [dict(row, amount=float(row['amount'])) for row in csv.DictReader(f)]
# 3. Insert in batches.
for i in range(0, len(rows), 1000):
requests.post(f'{AITO_URL}/api/v2/data/invoices/batch',
json=rows[i:i + 1000], headers=HEADERS).raise_for_status()
On eight coded invoices, the same prediction
("where": { "description": "annual software subscription" }) gave the software
account 0.46 with description declared as Text, and a flat 0.25 across
all four accounts when /import had inferred it as String. GET /api/v2/schema/{table} shows which type a column got; see
Schema Design for the types.
De-duplicating a batch โ ?on_conflict
By default a collection is an observation log: batch appends every row, so
re-uploading a row you already sent stores a duplicate. When a table has a
declared primaryKey, the
?on_conflict query parameter on POST /api/v2/data/{table}/batch chooses how
an incoming row whose key already exists is handled:
?on_conflict= | Behaviour | SQL analogue |
|---|---|---|
error (default) | A colliding key fails loud โ the batch is rejected. | INSERT (unique-constraint violation) |
update | Upsert โ the incoming row replaces the existing one (the old row is tombstoned, the new one appended). A brand-new key is a plain insert. | ON CONFLICT (pk) DO UPDATE |
ignore | The incoming row is dropped; the existing row is kept. | ON CONFLICT DO NOTHING |
POST /api/v2/data/customers/batch?on_conflict=update
[
{ "email": "ann@example.com", "name": "Ann (updated)" },
{ "email": "cid@example.com", "name": "Cid" }
]
Notes:
updateandignoreare meaningful only against a declaredprimaryKey. Sending either to a keyless collection is rejected (on_conflict=update/ignore requires a declared primaryKey) rather than silently appending โ Aito never dedups on an ad-hoc column.- A batch that carries the same key twice within one request always errors, in every mode โ a single request naming a key twice is a client mistake, not a self-upsert.
- Statistics stay correct across an upsert: the replaced row is masked from reads
immediately, so inference over live rows reflects the new value at once;
optimizecompacts the tombstones later.
Schema evolution
A collection's schema isn't frozen:
- New fields appear automatically on
/importโ importing rows with a field the table doesn't have extends the schema; existing rows treat it as null. - Heterogeneous-schema merge โ segments written with different schemas merge transparently (the read side takes the union), so cross-insert evolution just works.
- Declarative changes โ describe the schema you want and apply the diff (see
Schema Migration ยง plan & apply):
POST .../{table}/_planclassifies each column (add / rebuild / drop / rejected / no-op);_applyconverges in one commit (refuses lossy changes; data-loss drops need?confirm=true; idempotent). - Single-column edits โ
PUT /api/v2/schema/{table}/{column}adds a column (optionalvaluebackfill) or alters an existing one's type/analyzer in place (the engine re-indexes; durable acrossoptimize; incompatible alters rejected).
Known limitation. Automatic new-column inference currently fires on bulk
/import only โ not on incremental single/batch inserts, which validate
against the existing schema. To add a field outside import, use _apply or
PUT .../{column}.
Maintaining a collection
| Endpoint | What it does |
|---|---|
POST /api/v2/data/{table}/optimize | Consolidate segments into an optimized state (faster queries). |
POST /api/v2/data/_modify | Atomic by-key ops: update / upsert / delete a document, plus maintenance (optimize, copy, warm the linkage cache). |
POST /api/v2/data/_delete | Delete rows matching a where-clause. |
What _delete deletes. Exactly the rows a _query with the same where
returns. The where is a field value (equality), a field condition with $eq,
$ne, $lt, $lte, $gt, $gte, $in or $not, and $and / $or / $not
around those:
{ "from": "qv", "where": { "generation": { "$not": "g1" } } }
deletes the rows whose generation is set to something other than g1 โ like a
query's $not (and SQL's NOT), a row with generation unset is not deleted. An
unknown operator or column is a 400 naming it, never a silent {"total": 0}.
Available since: v2.11.2. Up to v2.11.1 a $not delete on a
collection was refused (Unknown operator on v2, a garbled invalid escape
message on v1), and a top-level {"$not": โฆ} or an unknown column deleted
nothing and answered {"total": 0}.
Writes land as segments and are queryable immediately; optimize merges them for
steady-state query speed (it's the rep2 analogue of a compaction). See
Schema Migration & Maintenance for worked examples of bulk
backfill, plan/apply, and atomic _modify operations.
Nullable & sparse columns
Scalar nullable columns carry an honest defined-mask โ a real null that does
not collide with 0, "", or false โ and round-trip through persistence and
merge. Query them with $exists. (Non-scalar nullable
inners โ nullable arrays/Json โ are still sentinel-based; that's a tracked
follow-up.)
Related
- Schema Design (v2) โ types, links, the
quantityflag, nullable modelling. - Schema Migration & Maintenance (v2) โ evolve a schema in place: plan/apply, add/alter, backfill, optimize.
- Inference (v2) โ predict / recommend / relate /
$why. - Query Operator Reference (v2).
- v2 vs v1 โ by the numbers โ memory, disk, speed, accuracy.