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
Pass JavaScript values to json() column writes, not JSON.stringify(value).
A string such as 'null', '42' or '{"a":1}' remains that exact string;
it is not interpreted as a pre-encoded null, number or object. The store encodes
the value at the SQL boundary. Keep explicit serialization for text() columns
and raw SQL parameters. Existing stored JSON is not rewritten; a value previously
stored as the wrong type cannot be reconstructed automatically.
SQLite-family and SQL Server stores apply the same schema decoding to write return values and change images as to queries. Upsert callbacks receive decoded column values; do not parse JSON columns again in a callback or event consumer.
SQL Server schema inspection distinguishes JSON-validity checks from enum
membership: literals used to validate JSON are not reported as oneOf values.
Low-level ColumnSnapshot.jsonChecks lists recognized JSON checks with their
catalog name, validation (value or container), enabled and trusted
state. A re-enabled check is not necessarily trusted: SQL Server can enable
future-write checking without validating existing rows. Application-specific
predicates are not included in this list.
jsonValidation summarizes the proven domain (value, container or
unverified) separately from catalog names. An active container-only check
still prevents scalar writes even if a value check exists alongside it.
For existing SQL Server tables, run voltro db plan and voltro db apply
before deploying. reconcile-json-check replaces recognized older or
unverified checks with the required validation. The database validates existing
rows within the migration transaction; invalid JSON refuses the migration and
restores the preceding checks. No values are rewritten. Application-specific
checks remain in force and can impose additional restrictions. voltro serve
does not apply this migration.
If rollback itself fails, the migration error retains both the original DDL
failure and the rollback failure; do not infer successful rollback from that
error alone. Inspect the database state before retrying.
The rolling-deploy assessment treats a verified container-to-value widening as
safe. Adding validity enforcement or narrowing to containers requires writers
to satisfy the new constraint first.
On MySQL and MariaDB, mysql2 already decodes native JSON (including MariaDB's extended JSON metadata). Voltro preserves these decoded values instead of parsing strings a second time. Fallback array columns still have their own text decoder.
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, additionalPatch?) 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, and
SQLite-family JSON functions. Root objects merge shallowly; nested values
replace their target. Missing/non-object parents become objects. Numeric path
segments are object keys, not array indices. JSON null stays present.
The optional companion patch writes other columns in the same UPDATE:
await ctx.store.patchJson('notes', id, 'prefs.theme', 'dark', { title: 'Updated note' })Companions cannot replace primary-key columns or the JSON column itself;
undefined is omitted and explicit null is preserved. Request-scoped
.onUpdate() and audit values ride in the same assignment, through validation
and encryption. The JSON target counts as an explicit write, so its own
.onUpdate() cannot replace the document. A failed companion constraint
rolls back the JSON change too. No whole-document application snapshot is used.
Escaped SQLite-family object keys use bound comparisons with an in-statement engine probe, so keys are decoded exactly once. Literal backslash-n and a newline remain different keys. Unrelated escaped siblings are preserved too.
SQL Server limitation: the current JSON-column constraint allows object/array roots, not scalar roots (nested scalar values are supported). Ordinary-object tests do not prove scalar-root support.
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.