Data types
The value types a Lunora document can hold, how each is stored, and which ones need the wire codec to survive JSON.
Last updated:
A Lunora document is a JSON-shaped object stored as a row in SQLite. The
validators in v.* define which value types a
column may hold; this page is the reference for what those types actually are.
The type table
| Validator | TypeScript type | Notes |
|---|---|---|
v.string() | string | |
v.number() | number | IEEE-754 double |
v.boolean() | boolean | |
v.null() | null | The explicit "no value" — distinct from absent |
v.bigint() | bigint | Needs the wire codec (see below) |
v.bytes() | ArrayBuffer | Needs the wire codec |
v.id("table") | Id<"table"> | A reference — see Document IDs |
v.timestamp() | number | Epoch milliseconds |
v.date() | number | Calendar date, also stored as epoch ms |
v.geoPoint() | { lat, lng } | WGS84 decimal degrees |
v.storage(bucket?) | string | An R2 object key |
v.array(inner) | T[] | |
v.object({ … }) | { … } | Nested object with a declared shape |
v.record(key, value) | Record<K, V> | Open-ended keys, uniform values |
v.union(a, b, …) | A | B | … | |
v.literal(value) | the literal | string, number, boolean, bigint, or null |
v.optional(inner) | T | undefined | The field may be absent |
v.any() | unknown | Unchecked — the escape hatch |
v.from(schema) | inferred | Adapt a Standard Schema validator (Zod, Valibot, ArkType) |
Every document additionally carries the framework-managed _id and
_creationTime, described in Document IDs.
Numbers, bigints, and dates
v.number() is a JavaScript double, so integers above 2^53 lose precision. When
you need exact large integers — ledger amounts, external 64-bit ids — use
v.bigint().
v.timestamp() and v.date() both store a finite epoch-millisecond number;
they differ only in intent, and both pair with .defaultNow() to stamp the
insert time:
// lunora/schema.ts
import { defineSchema, defineTable, v } from "lunorash/server";
export default defineSchema({
invoices: defineTable({
amountCents: v.bigint(),
issuedAt: v.timestamp().defaultNow(),
dueOn: v.date(),
}),
});Storing times as numbers keeps them sortable and range-queryable through an ordinary index — no date parsing on the read path.
Optional versus null
These are different, and the difference is load-bearing:
v.optional(v.string())— the field may be absent. Reading it givesundefined.v.union(v.string(), v.null())— the field is present and explicitly empty.
That distinction shows up when you write. patch rejects an explicit
undefined: to clear a nullable field set it to null, and to leave a field
untouched omit the key entirely. Setting a field to undefined is an error
rather than a silent no-op, because the two intents are too easy to confuse.
Values that JSON cannot carry
The RPC and WebSocket transport is JSON, and JSON has no bigint and no
ArrayBuffer — JSON.stringify(1n) throws, and an ArrayBuffer silently
stringifies to {}. Lunora therefore runs a wire codec over every payload
that tags exactly those leaves so they survive the round trip.
Encoded by the codec: bigint, ArrayBuffer and typed-array views
(Uint8Array, Float32Array, …), Date, URL, Map, Set, Error,
NaN / ±Infinity, and undefined in array positions (where JSON would
turn it into null).
Rejected outright: cyclic graphs, functions, and non-plain objects such as
RegExp or a class instance. These have no own enumerable keys, so instead of
silently encoding to {} they throw a TypeError. Nesting deeper than 64
levels throws a RangeError.
A value with no special leaves encodes to a byte-identical JSON tree, so the
codec costs nothing on ordinary payloads. See
Wire protocol for the encoding itself and how
non-TypeScript clients (the Python SDK's WireBigInt, WireBytes, …) express
these types.
Size limits
Values live in Durable Object SQLite, so the platform's storage envelope applies:
a single storage operation is capped at 128 KB, and a Durable Object's SQLite
tops out at 10 GB. Large binary payloads belong in R2 via
file storage with a v.storage() column holding
the key, not inline in a v.bytes() field. See Limits for the
full table.
Types outside the schema
v.from(schema) adapts any Standard Schema
validator (Zod, Valibot, ArkType), for function arguments and table columns.
It exists for the case where a shape is already defined in another library — an
AI SDK that demands Zod types, a validator shared with a non-Lunora service — so
you do not have to maintain the same shape twice, in two languages, and hope they
stay in step.
Codegen recovers the wrapped schema's type from ~standard.types.output, so the
generated Doc_* and api surface show the real shape rather than unknown.
Validation must be synchronous; a validator returning a Promise throws.
What a v.from() column costs
Reach for a concrete v.* type when you can. v.from() is opaque to everything
that reads the schema rather than the value:
- Storage follows the runtime type. A scalar is written verbatim (a
v.from(z.string())column holds a barehello, not"hello"); an object or array is JSON-encoded. That is the same rulev.unionandv.anyfollow, and it is why the column cannot be a Postgres/MySQLJSONcolumn. One consequence: a stored string that itself looks like JSON ('{"a":1}') is ambiguous on read and decodes to the parsed object. - Indexes. Declare the column with a concrete type if you need a comparison index on it — the storage form above is what an index would sort.
- Seeding.
@lunora/seedcannot introspect an external schema to invent a conforming value, so it refuses the column by name. Supply one throughoverrides, or skip the table withonly. - JSON Schema. The OpenAPI/OpenRPC surface documents the column as unconstrained, because there is nothing for it to read.