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)
EngineCollectionDb (new, columnar)TableDb (production)
Schemaflexible โ€” infer from JSON, evolve incrementallyfixed at creation
Storagecompact columnar, memory-mapped readsrow-oriented
Use whenyou want the v2 engine, JSON ingest, schema evolutionyou 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

EndpointWhat it does
POST /api/v2/data/{table}/importSchema-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}/batchInsert an array of documents. Add ?on_conflict=update|ignore to upsert/skip against a declared primaryKey.
POST /api/v2/data/{table}/streamInsert NDJSON (one object per line).
POST /api/v2/schema/_inferInfer 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=BehaviourSQL analogue
error (default)A colliding key fails loud โ€” the batch is rejected.INSERT (unique-constraint violation)
updateUpsert โ€” 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
ignoreThe 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:

  • update and ignore are meaningful only against a declared primaryKey. 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; optimize compacts 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}/_plan classifies each column (add / rebuild / drop / rejected / no-op); _apply converges 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 (optional value backfill) or alters an existing one's type/analyzer in place (the engine re-indexes; durable across optimize; 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

EndpointWhat it does
POST /api/v2/data/{table}/optimizeConsolidate segments into an optimized state (faster queries).
POST /api/v2/data/_modifyAtomic by-key ops: update / upsert / delete a document, plus maintenance (optimize, copy, warm the linkage cache).
POST /api/v2/data/_deleteDelete 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.)