Upgrading to V1
Move from VibORM 0.1.0 or a development checkout to the V1 public API and storage contracts
V1 changes schema declarations, physical storage, migration history, extensions, and raw SQL. Treat this as an application and database upgrade, not only a package-version change.
The published baseline is 0.1.0, the initial development package. The
repository also contained intermediate development APIs that changed before
V1. The retired spellings below identify code to review if you use them; they
are not a claim that every spelling shipped in 0.1.0.
1. Prepare the application and a database copy
Use Node.js 22 or later and TypeScript 5.8 or later, with strict mode. The
package exports ESM. Use a supported driver entry point such as viborm/pg,
viborm/postgres, or viborm/sqlite3 and install its database library
separately. See Installation and
Package entry points.
Keep the old application lockfile, migration artifacts, and a restorable
database backup. Rehearse on a database copy before applying schema changes to
the live database. Compile the application after updating declarations, then
inspect the physical schema difference. Do not use push as an automatic data
converter: several storage changes below require an explicit conversion.
Choose a deployment driver separately from the application driver when needed. Runtime CRUD or batch support does not imply support for live migrations; the migration matrix is the contract.
2. Replace the relation language
There are two factories: s.toOne() for a singular slot and s.toMany() for
a collection. Their target is either a model thunk or a map of named variants.
The old cardinality-specific factories and a separate polymorphic factory are
not compatibility aliases.
The pair of endpoints determines cardinality. Replace declarations according to what each endpoint contains:
| Relationship | V1 endpoints | Storage declaration |
|---|---|---|
| One-to-one | toOne / toOne |
Exactly one endpoint completes .fields(...).references(...); the pair derives its unique constraint |
| One-to-many | toMany / toOne |
The singular endpoint completes .fields(...).references(...) |
| Many-to-many | toMany / toMany |
At most one endpoint configures the physical junction |
| Polymorphic | toOne({ ... }) or toMany({ ... }) |
A map of target variants determines the variant storage |
import { s } from "viborm";
export const user = s.model({
id: s.string().id(),
posts: s.toMany(() => post),
});
export const post = s.model({
id: s.string().id(),
authorId: s.string().nullable(),
author: s.toOne(() => user).fields("authorId").references("id"),
});
export const schema = { user, post };
Both ordinary endpoints must exist. If more than one relation joins the same
models, give the two endpoints the same exact .name(...). An incomplete or
ambiguous pair is a schema error; it is not selected by declaration order.
For ordinary relations, remove relation .optional() and express absence
through nullable foreign-key scalars. A singular inverse without the foreign
key is nullable because the owning row may not exist. A variant toOne({ ... })
still has .optional() for its private discriminator and identifier columns.
Remove relation .unique(); the toOne / toOne pair derives uniqueness.
A declared unique foreign key paired with a collection is a contradiction,
so review any scalar or compound unique constraint left by an old declaration.
Complete every .fields(...) with matching
.references(...); zero-argument .fields() is not allowed. For junctions,
use .source(...) and .target(...) instead of .A() / .B(), with physical
configuration on one endpoint. Do not copy storage declarations onto both
endpoints.
Changing factory names alone does not prove that an existing foreign key, junction name, or polymorphic discriminator is preserved. Compare the physical schema, especially custom junctions and variant values. See Relations, Many-to-many, and Polymorphic relations.
3. Review scalar values and physical storage
Fixed decimals
Declare every decimal with a required descriptor:
import { Decimal, s } from "viborm";
const amount = s.decimal({ precision: 10, scale: 2 });
const openingBalance = new Decimal("12.34");
const increasedBalance = openingBalance.plus("0.20");
Typed writes accept plain decimal strings or VibORM’s exported Decimal.
JavaScript numbers, exponent strings, and decimal.js instances are not inputs.
Assignment refuses values that exceed precision or scale; it does not round
them. Selected values are fresh Decimal instances, so compare with .eq()
rather than object identity.
If your application used the intermediate decimal.js value type, convert its
values with new Decimal(oldValue.toFixed()). Plain toFixed() retains all
digits without exponent notation. Replace decimal.js aliases or configuration:
VibORM exposes plus, minus, times, div, comparison methods and formatting,
but no Decimal.set(). For example, value.isZero() becomes value.eq("0").
| Dialect | Scalar | Decimal list |
|---|---|---|
| PostgreSQL | NUMERIC(p,s) |
Native NUMERIC(p,s)[] |
| MySQL | DECIMAL(p,s) |
JSON array of coefficient strings |
| SQLite family | Checked scaled INTEGER coefficient |
TEXT containing coefficient-string JSON |
At scale 2, logical 12.34 is SQLite coefficient 1234; a JSON-backed list
stores it as "1234". Existing SQLite decimal text or floating-point values
are not interchangeable with these coefficients. Preserve their logical value
in a reviewed conversion; never rename their type and assume the stored value
has changed. Descriptor changes also need review: scale reduction is valid
only when all discarded digits are zero. See
Decimal for provider
limits and refused conversion paths.
Identifier domains
A bare s.string().id() remains a text key with a default ULID generator.
Adding .uuid(), .uuidv7(), .ulid(), .ksuid(), .nanoid(), or .cuid()
declares a domain: every supplied value must match that format and its declared
prefix, including values in filters, cursors, and relation selectors.
UUID/UUIDv7 now use PostgreSQL uuid, MySQL BINARY(16), or SQLite BLOB.
ULID uses 16 payload bytes and KSUID uses 20; PostgreSQL uses bytea for both.
Prefixes are restored on typed reads. NanoID and CUID remain text. Foreign-key
columns derive the referenced identifier’s domain and representation.
For an existing text column, choose explicitly:
- Keep text storage with a supported text-family native type while retaining
domain validation, for example
s.string(PG.STRING.TEXT).uuid()withPGimported fromviborm/schema. - Convert every affected key and reference together, including junction and polymorphic storage columns, using the identifier conversion guide.
Check invalid legacy values, normalization collisions and reference integrity
before conversion. A generated binary alterColumn cannot convert text into
identifier payload bytes and is refused. PostgreSQL’s guarded text-to-UUID cast
is a separate supported case. No batch converter or automatic prefix stripping
is supplied.
contains, startsWith, endsWith, and mode are unavailable for the four
compact formats, even when a text override preserves their storage. Review
queries as well as DDL. See String.
DateTime admission and storage
DateTime accepts valid Date values and timestamps naming a real calendar
date, with a timezone and a UTC instant from 0000-01-01T00:00:00.000Z through
9999-12-31T23:59:59.999Z. Invalid dates, nonexistent calendar days, hour 24,
leap-second spellings, and out-of-range instants are refused. Audit permissive
legacy strings before reading or rewriting them through the typed client.
SQLite DateTime storage follows the declared native type:
import { s } from "viborm";
import { SQLITE } from "viborm/schema";
const timestampText = s.dateTime();
const epochMilliseconds = s.dateTime(SQLITE.DATETIME.INTEGER);
const julianDay = s.dateTime(SQLITE.DATETIME.REAL);
TEXT contains timestamps, INTEGER contains epoch milliseconds, and
REAL contains Julian days. Unix seconds are not milliseconds. Existing
numeric values must agree with the declared representation before a new
client reads them. Changing a declaration is not proof of a correct data
conversion. Raw SQL stays physical and does not apply these codecs. See
DateTime and
Native types.
Creation and update timestamps
.now() is insert-only. Remove its field from update, updateMany, the
update arm of upsert, and nested update payloads, including { set: ... }
and an explicit undefined. Runtime admission rejects the key. A root refusal
does no SQL; a nested refusal keeps the existing transaction and partial-progress
rules for work that preceded that nested input.
Use .updatedAt() for an automatic update timestamp. On non-array DateTime,
Date, and Time fields, an omitted or undefined update value receives the
application’s current UTC value. An explicit data: {} refreshes it too. A
valid explicit value, including { set: value }, wins.
const timestamps = {
createdAt: s.dateTime().now(),
updatedAt: s.dateTime().updatedAt(),
};
The clock is read at input admission, not at commit. One scalar bulk update
shares its admitted value; separately admitted relation-series members can
have different values. A permitted retry reuses already-admitted values. This
is not a monotonic version counter. .updatedAt().default(value) changes the
create default only. The last .now() / .updatedAt() declaration determines
the generator. Temporal arrays do not gain an automatic update timestamp.
Raw SQL, cascades, and external writers do not run this generator. See
Auto-timestamps.
4. Adopt the V1 migration estate
Use createMigrationClient() with createFsStorageWriter():
import { createFsStorageWriter, createMigrationClient } from "viborm/migrations";
import { client } from "./client";
const migrations = createMigrationClient(client, {
storage: createFsStorageWriter("./migrations-v1"),
});
const candidate = await migrations.generate({ name: "v1-baseline", dryRun: true });
Without storage, the client exposes push and log. A reader adds inspection
and application methods; a writer also adds generate and reset.
V1 stores an immutable estate descriptor, hashed state manifests, snapshots,
and exact SQL blobs. The live database has a current-state marker and an
append-only ledger. It does not load old journals, numbered migration programs,
or TypeScript migration files as V1 history. If you used a development
$migrations, createFsStorageDriver, MigrationJournal, pending(), or
squash API, rewrite that integration against the
migration client. Pending work is on status().
For an existing database:
- Preserve the old migration directory and tracking evidence. Create a new V1 estate directory rather than editing hashes or renaming old artifacts.
- Make the V1 schema describe the intended physical database. Review and complete required data conversions on a copy first.
- Generate the structural baseline into the new estate and run
check. Compare the live schema with that state before adoption. - Use
baseline({ to: { name: "v1-baseline" } })only when the live database already matches the state. Baseline requires no existing V1 marker or ledger history, an exact physical match, and a fully structural root path. It does not execute the generated create-table SQL or bypass drift. - Generate subsequent changes, review the stored SQL, then apply them through the admitted deployment driver. Keep the new estate artifacts in version control.
An ordinary first apply() requires an empty managed target; it does not
silently adopt populated tables. A legacy control-table conflict or refused
baseline is a migration decision to resolve, not a reason to delete tracking
evidence. See Baseline
for its exact conditions.
push remains history-free. Review a dry-run preview and its resolver choices;
the returned consent identifies that exact reviewed change. forceReset: true
remains a planning option: preview it with dryRun: true, then execute only
with push({ consent: preview.consent }). It drops and rebuilds managed tables;
it does not convert existing data or bypass consent. down() executes the stored
rollback policy, and resolve() requires proof of the live state; neither is
checksum repair. See Push and
Recovery.
Provider and namespace changes
PostgreSQL migrations bind the driver’s namespace (default public). MySQL
artifacts are database-relative, but live migration work needs a resolved
database namespace. MySQL2 effectful commands also need the explicit
migrationNamespaceAttestation: "non-redirecting" transport assertion; do not
set it without knowing that the transport preserves qualified destinations.
MySQL DDL is stepwise and can leave earlier statements applied after failure.
Neon HTTP, PlanetScale, LibSQL, and D1 refuse effectful V1 migrations, including
live push. Offline generate and check and admitted read-only paths remain.
Dry-run apply and down read the live marker without a lock and need
connectivity. Dry-run reset previews the estate’s root-to-target path without
database reads, after target and read-only capability admission. Locked
verify also needs the effectful capability even though it does not mutate the
schema. D1 additionally has a documented live-introspection limitation. Use a
session-capable PostgreSQL deployment driver for Neon, and the provider’s
deployment system where VibORM has no admitted live route. Hosted Neon,
PlanetScale, and Turso evidence is not implied by local database-family tests.
See Drivers, Namespaces, and
Migration atomicity.
5. Move optional behavior to extensions
Replace createClient cache, instrumentation, and default-omit configuration
with the official extensions. $extends() returns a new client; keep and use
that returned value.
import { createClient } from "viborm/sqlite3";
import { cache } from "viborm/cache";
import { MemoryCache } from "viborm/cache/memory";
import { defaultOmit } from "viborm/client";
import { instrumentation } from "viborm/instrumentation";
import { schema } from "./schema";
const client = createClient({ schema, dataDir: "./app.db" })
.$extends(cache({ driver: new MemoryCache(), version: "v1" }))
.$extends(instrumentation({ logging: { warning: true, error: true } }))
.$extends(defaultOmit<typeof schema>()({ post: { authorId: true } }));
Only the cache-derived client exposes $withCache() and $invalidate().
Custom cache keys are suffixes of a canonical query key, not replacements.
Cache hits return fresh value graphs. Old namespaces are not a cache-migration
format; expect a cold cache. Cached reads bypass raw calls, transactions and
chains with statement transforms.
Apply defaultOmit() before schema-specific query maps or client/model method
factories. Default omit is overridable presentation, not authorization.
Instrumentation SQL and parameter disclosure default to off and are configured
separately for tracing, logging and error diagnostics.
Custom extensions have six capabilities: request, query, statement,
observe, client, and model. A mutation or raw query interceptor must call
proceed() exactly once. Read the lifecycle and continuation rules before
porting hooks. See Extensions,
Caching, Instrumentation,
and Default omit.
6. Update raw SQL calls and their results
$queryRaw and $executeRaw accept tagged templates or one Sql fragment.
They do not accept plain statement strings or a fragment followed by extra
parameters. Use Unsafe methods only for trusted statement text, with parameters
as individual arguments rather than an array.
const minimumAge = 18;
const rows = await client.$queryRaw<{ age: number }>`
SELECT age FROM person WHERE age >= ${minimumAge}
`;
// Trusted SQLite text; use the selected driver's placeholder syntax.
const otherRows = await client.$queryRawUnsafe<{ age: number }>(
"SELECT age FROM person WHERE age >= ?",
minimumAge,
);
const affected = await client.$executeRaw`
UPDATE person SET active = ${false} WHERE age < ${minimumAge}
`;
Query methods return the row array directly; execute methods return an affected
row count. Remove old .rows / .rowCount result-envelope access. The generic
row type is a caller declaration, not validation or model decoding. Identifier
bytes, decimal coefficients, temporal values and GeoPoint carriers remain
physical provider values. Invalid raw Date inputs are refused before
dispatch. See Raw SQL.
7. Check recursive reads and transaction assumptions
Recursive projections are opt-in with recurse on a supported ordinary
self-relation. Existing fixed-depth nested selections do not need to change.
If you adopt recursion, use a deliberate bound: recurse: true means depth
100, not unlimited; recurse: { depth: false } is exhaustive. A numeric cutoff
omits the repeated key, while natural termination returns [] or null.
Filters apply at each hop, and a provider recursion-limit failure is an error,
not a truncated success. See
Recursive relations.
On Neon HTTP and D1, dynamic writes outside an atomic array may use multiple committed segments. A later failure does not roll back earlier segments. Inspect reported progress before retrying; do not replay a committed prefix. The driver behavior table and Transactions describe the exact boundaries.
Finish the rehearsal
Compile the upgraded application and run its existing tests against the candidate. On a database copy, verify representative create/read/update/delete operations, nullable and nested relations, identifier lookups, decimal values, timestamps, raw results, and any cache or extension behavior you use.
Inspect generated SQL, prove the baseline or empty-target apply path, and test the stored rollback or recovery policy before a deployment. Record the exact candidate version, driver, database version, and which hosted paths actually ran. Keep the backup until both the application and database upgrade are verified; installing the old package cannot undo changed physical storage.