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

ValidatorTypeScript typeNotes
v.string()string
v.number()numberIEEE-754 double
v.boolean()boolean
v.null()nullThe explicit "no value" — distinct from absent
v.bigint()bigintNeeds the wire codec (see below)
v.bytes()ArrayBufferNeeds the wire codec
v.id("table")Id<"table">A reference — see Document IDs
v.timestamp()numberEpoch milliseconds
v.date()numberCalendar date, also stored as epoch ms
v.geoPoint(){ lat, lng }WGS84 decimal degrees
v.storage(bucket?)stringAn 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 literalstring, number, boolean, bigint, or null
v.optional(inner)T | undefinedThe field may be absent
v.any()unknownUnchecked — the escape hatch
v.from(schema)inferredAdapt 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 gives undefined.
  • 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 ArrayBufferJSON.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 bare hello, not "hello"); an object or array is JSON-encoded. That is the same rule v.union and v.any follow, and it is why the column cannot be a Postgres/MySQL JSON column. 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/seed cannot introspect an external schema to invent a conforming value, so it refuses the column by name. Supply one through overrides, or skip the table with only.
  • JSON Schema. The OpenAPI/OpenRPC surface documents the column as unconstrained, because there is nothing for it to read.

See also