JSON columns
Typed jsonb columns, path queries, GIN indexing, and when to denormalise into real columns.
json<T>() declares a jsonb column whose JSON shape is typed by T. The runtime decodes on read + validates on write — typos in your object literals fail the TypeScript check, not at runtime.
Declaring a typed JSON column
import { table, id, json } from '@voltro/database'
interface NotePrefs {
readonly fontSize: 'sm' | 'md' | 'lg'
readonly collapsed: ReadonlyArray<string>
readonly autoSave?: boolean
}
const notes = table('notes', {
id: id(),
prefs: json<NotePrefs>().default({ fontSize: 'md', collapsed: [] }),
})ctx.store.select('notes') returns prefs: NotePrefs — autocomplete works in your IDE, mutation inputs are checked too.
Storage + validation per dialect
json() maps to each engine's native JSON type, and Voltro enforces JSON validity on write, on every dialect — invalid JSON is rejected by the database, not only by TypeScript:
| Dialect | Column type | Validated on write |
|---|---|---|
| postgres | JSONB (binary, GIN-indexable) |
yes (native) |
| mysql / mariadb | JSON |
yes (mariadb's JSON is LONGTEXT + an auto json_valid CHECK) |
| mssql | NVARCHAR(MAX) + CHECK (ISJSON(col)=1) |
yes |
| sqlite | TEXT + CHECK (json_valid(col)) |
yes |
On postgres the binary JSONB form is what makes path queries + GIN indexing fast; the other engines store JSON as text (mariadb's information_schema reports the JSON column as longtext — that is what the JSON type is there). Either way, the jsonField(...) filters below and write-validation behave identically across all of them, and reads always come back as parsed objects/arrays — never raw strings.
On postgres, prefer
jsonboverjson(Voltro always emitsjsonb): binary storage, GIN-indexable, faster. Plainjsononly preserves exact byte / whitespace / key-order — never what you want for app data.
Path filters
Filter on a value inside a json() column with jsonField(column, ...path):
import { jsonField } from '@voltro/database'
database.users.where(jsonField('preferences', 'theme').eq('dark'))
database.events.where(jsonField('payload', 'amount').gt(1000)) // numeric, not lexical
database.docs.where(jsonField('meta', 'tags', 0).eq('urgent')) // nested key + array index
database.users.where(jsonField('preferences', 'locale').inSet(['de', 'en']))
database.users.where(jsonField('preferences', 'theme').isNotNull())Path segments are object keys (string) or array indices (number) → $.theme / $.tags[0].
| Operators | Compare the extracted value as |
|---|---|
eq neq inSet notInSet contains isNull isNotNull |
text |
gt gte lt lte |
number (numeric ordering, not lexical) |
The same jsonField(...) expression is portable across every backend — it lowers to each dialect's json accessor: postgres #>>, mysql/mariadb JSON_EXTRACT (+ JSON_UNQUOTE), mssql JSON_VALUE, sqlite / turso json_extract.
Reactive subscriptions filtered by a JSON path stay live, but the matcher treats the leaf as non-indexable: it's re-checked on every change to the table (the same way contains is). For a high-traffic filter, denormalise into a real indexed column — or index the specific path (see Indexing JSON columns below).
Scope. JSON-path filtering is a server-side query-DSL feature (query / mutation handlers). Clients can't yet send JSON-path filters over rpc — that's a deliberate later phase (it needs path allow-listing + validation). For arbitrary expressions the DSL doesn't model, drop to the
store.rawescape hatch (see the query builder).
Indexing JSON columns
GIN index — broad coverage
A GIN index covers arbitrary containment queries on the whole jsonb
document. Declare it with the table-level .index(name, [col], { kind: 'gin' }):
import { table, id, json } from '@voltro/database'
const events = table('events', {
id: id(),
payload: json<unknown>(),
})
.index('events_payload', ['payload'], { kind: 'gin' })On postgres this emits CREATE INDEX "events_payload" ON "events" USING GIN ("payload"),
so any containment query on payload is indexed.
GIN is postgres-only. On mysql / mariadb there is no GIN access method and a btree on a JSON column can't serve containment — the migrator skips the index and warns, pointing you at the single-path approach below. On mssql / sqlite it falls back to a plain btree + a warning. GIN is also large (often 30-50% of the table size for wide JSON); use the next option when you only filter on one specific path.
Index a JSON path — jsonIndex
When 99% of your queries look like jsonField('payload', 'kind').eq('X'),
index just that path with jsonIndex(column, ...path) inside
.expressionIndex(...). It mirrors jsonField exactly — same column, same
segments, lowered to the same per-dialect accessor — so a filter on that
path can use the index:
import { table, id, json, jsonIndex } from '@voltro/database'
const events = table('events', { id: id(), payload: json<unknown>() })
.expressionIndex('events_kind', [jsonIndex('payload', 'kind')])
.expressionIndex('events_amount', [jsonIndex('payload', 'amount').numeric()])Text extraction (the default) backs eq / neq / inSet / contains;
.numeric() backs the range ops (gt / gte / lt / lte). Because the
index expression is byte-identical to the WHERE expression jsonField(...)
compiles to, the optimiser actually picks it up.
Dialect support is uneven — this is a hard engine limit, not a Voltro choice. Only some engines can index an expression directly:
Dialect jsonIndexNotes postgres ✓ expression index ((col #>> '{path}'::text[]))mysql (8.0.13+) ✓ functional index ((JSON_UNQUOTE(JSON_EXTRACT(...))))sqlite ✓ expression index (json_extract(col, '$.path'))mariadb — skipped + warned no expression-index support mssql — skipped + warned no expression-index support On mariadb / mssql the migrator skips a
jsonIndexand warns rather than emit DDL the engine rejects. The idiomatic indexed-JSON path there is a generated / computed column (which Voltro already supports), then filter on that column instead ofjsonField(...):table('events', { id: id(), payload: json<unknown>(), // materialise the path into a real, indexable column kind: text().generatedAs(`JSON_UNQUOTE(JSON_EXTRACT(\`payload\`, '$.kind'))`), }).index('events_kind', ['kind']) // query: ctx.store.select('events').where('kind', 'click').all()
For an arbitrary expression the jsonIndex shorthand doesn't model, drop to
the raw .expressionIndex(...) form with an { expr } entry:
table('events', { id: id(), payload: json<unknown>() })
.expressionIndex('events_kind', [{ expr: `(payload->>'kind')` }])There you own per-dialect correctness (the expr string is emitted
verbatim; the postgres ->> form is shown).
Writing JSON
ctx.store.insert('notes', {
prefs: { fontSize: 'lg', collapsed: ['archive'] },
})A plain update writes the WHOLE JSON value. To change one field
without rewriting the rest, use patchJson — a server-side in-place
JSON merge (no read-modify-write round-trip):
// Merge an object over the top level of the column:
await ctx.store.patchJson('notes', id, 'prefs', { autoSave: true })
// Set a nested path (dot-separated; the column is the first segment):
await ctx.store.patchJson('notes', id, 'prefs.theme', 'dark')patchJson(table, pk, path, value) returns the post-image (or null
when the row doesn't exist) and emits an update ChangeEvent so reactive
subscribers see the change. The merge is server-side on every dialect —
postgres jsonb_set / ||, mysql + mariadb JSON_SET, mssql
JSON_MODIFY, sqlite json_set / json_patch.
When you need a full read-modify-write (e.g. computing the new value from the old in JS), read the row, change the object, and write it back inside the mutation's transaction:
const note = await ctx.store.select('notes').where('id', id).one()
await ctx.store.update('notes', id, {
prefs: { ...note.prefs, autoSave: true },
})Validating JSON shape
Two layers of validation apply:
- JSON validity — that the stored bytes are well-formed JSON — is enforced automatically by the database on every dialect (see Storage + validation per dialect). You don't declare anything.
- JSON shape — that the value matches your expected structure — is up to you: enforce it at the table level with
table().validate(Schema):
import { Schema } from 'effect'
table('notes', { id: id(), prefs: json<NotePrefs>() })
.validate(Schema.Struct({
prefs: Schema.Struct({ fontSize: Schema.Literal('sm', 'md', 'lg') }),
}))Decode failure throws a typed TableValidationFailed before the INSERT runs. There is no .check() modifier on a JSON column.
When JSON is the wrong choice
JSON is great for:
- Free-form user-configurable data (preferences, layout configs)
- Sparse extensions (every row has different shape)
- Foreign-system payloads (Stripe webhook bodies, Slack message JSON)
JSON is a footgun for:
- Anything you filter on heavily. Denormalise into real columns — they're cheaper to query, easier to index, simpler to constrain.
- Anything with strict schema. A real column with a NOT NULL + CHECK is stronger than a JSON path constraint.
- Joining / relating to other tables. You can't FK from a JSON path.
Rule of thumb: if you'd write a migration to add a new field, it's a real column. If users add fields without your code changing, it's JSON.
Anti-patterns
json<any>()everywhere. Defeats the type-safety. Be specific.- Putting a foreign key inside JSON. No FK constraint, no cascade, no clean join. Use a real
reference(() => table)column. - Storing big binary as JSON. Use the storage plugin (
@voltro/plugin-storage) for blobs > a few KB.