VibORM

Runtime Schemas

ArkType schema builders for runtime validation

Runtime Schemas

VibORM uses ArkType for runtime validation. Each model generates schemas that mirror the TypeScript types.

Schema Building Architecture

Two-Phase Building

Schemas are built in two phases to enable reuse:

Phase 1: Core Schemas

const core: CoreSchemas = {
  where: buildWhereSchema(model),
  whereUnique: buildWhereUniqueSchema(model),
  create: buildCreateSchema(model),
  createMany: buildCreateManySchema(model),
  update: buildUpdateSchema(model),
  select: buildSelectSchema(model),
  include: buildIncludeSchema(model),
  orderBy: buildOrderBySchema(model),
  selectNested: buildSelectNestedSchema(model),
  includeNested: buildIncludeNestedSchema(model),
};

Phase 2: Args Schemas

Args schemas receive core and reuse its schemas:

return {
  ...core,
  findMany: buildFindManyArgsSchema(model, core),
  findUnique: buildFindUniqueArgsSchema(core),  // Note: no model needed
  findFirst: buildFindFirstArgsSchema(model, core),
  createArgs: buildCreateArgsSchema(core),
  updateArgs: buildUpdateArgsSchema(core),
  deleteArgs: buildDeleteArgsSchema(core),
  // ...
};

Field Schema Structure

Each field provides schemas for different operations:

Example: String Field Schemas

// src/schema/fields/string/schemas.ts

// Base types
export const stringBase = type.string;
export const stringNullable = stringBase.or("null");

// Filter with shorthand normalization
const stringFilterBase = type({
  equals: stringBase,
  in: stringBase.array(),
  notIn: stringBase.array(),
  contains: stringBase,
  startsWith: stringBase,
  endsWith: stringBase,
  // ...
}).partial();

export const stringFilter = stringFilterBase
  .merge(type({ "not?": stringFilterBase.or(stringNullable) }))
  .or(stringBase.pipe((v) => ({ equals: v })));  // Shorthand normalization

// Update with shorthand normalization
export const stringUpdate = type({ set: stringBase })
  .partial()
  .or(stringBase.pipe((v) => ({ set: v })));

Where Schema Building

export const buildWhereSchema = (model: Model<any>): Type => {
  const shape: Record<string, Type | (() => Type)> = {};

  // Scalar fields
  for (const [name, field] of model["~"].fieldMap) {
    shape[name + "?"] = field["~"].schemas.filter;
  }

  // Relation fields (lazy for circular references)
  for (const [name, relation] of model["~"].relations) {
    const relationType = relation["~"].relationType;
    const getTargetModel = relation["~"].getter;

    if (relationType === "oneToOne" || relationType === "manyToOne") {
      // To-one with shorthand normalization
      shape[name + "?"] = type("object | null").pipe((t) => {
        if (t === null) return { is: null };
        if (isToOneShorthand(t)) return t;
        return { is: t };
      });
    } else {
      // To-many: some/every/none
      shape[name + "?"] = type({
        "some?": () => getTargetModel()["~"].schemas.where,
        "every?": () => getTargetModel()["~"].schemas.where,
        "none?": () => getTargetModel()["~"].schemas.where,
      });
    }
  }

  return type(shape);
};

Lazy Evaluation Pattern

Circular references are handled with thunks:

Thunk Pattern

// Bad - causes infinite recursion
shape["posts?"] = buildWhereSchema(postModel);

// Good - lazy evaluation
shape["posts?"] = type({
  "some?": () => getTargetModel()["~"].schemas.where,
});

Model-Level Caching

Schemas are cached per model to prevent rebuilding:

class Model<State> {
  private _schemas?: TypedModelSchemas;

  get ["~"]() {
    return {
      // ...
      get schemas() {
        if (!this._schemas) {
          this._schemas = buildModelSchemas(this);
        }
        return this._schemas;
      }
    };
  }
}

WhereUnique Schema

Handles single-field and compound unique identifiers:

export const buildWhereUniqueSchema = (model: Model<any>): Type => {
  const shape: Record<string, Type> = {};
  const uniqueFieldNames: string[] = [];
  const compoundKeyNames: string[] = [];

  // Single-field uniques
  for (const [name, field] of model["~"].fieldMap) {
    if (field["~"].state.isId || field["~"].state.isUnique) {
      shape[name + "?"] = field["~"].schemas.base;
      uniqueFieldNames.push(name);
    }
  }

  // Compound ID
  const compoundId = model["~"].compoundId;
  if (compoundId?.fields?.length > 0) {
    const keyName = compoundId.name ?? generateCompoundKeyName(compoundId.fields);
    const compoundShape = {};
    for (const fieldName of compoundId.fields) {
      compoundShape[fieldName] = model["~"].fieldMap.get(fieldName)["~"].schemas.base;
    }
    shape[keyName + "?"] = type(compoundShape);
    compoundKeyNames.push(keyName);
  }

  // Compound uniques (similar pattern)
  // ...

  // Validation: at least one identifier required
  return type(shape).narrow((data, ctx) => {
    const allIdentifiers = [...uniqueFieldNames, ...compoundKeyNames];
    const hasIdentifier = allIdentifiers.some(name => name in data);
    if (!hasIdentifier) {
      return ctx.mustBe(`an object with at least one of: ${allIdentifiers.join(", ")}`);
    }
    return true;
  });
};

Relation Create/Update Schemas

To-One Create

const buildRelationCreateSchema = (relation, getTargetModel) => {
  return type({
    "create?": () => getTargetModel()["~"].schemas.create,
    "connect?": () => getTargetModel()["~"].schemas.whereUnique,
    "connectOrCreate?": type({
      where: () => getTargetModel()["~"].schemas.whereUnique,
      create: () => getTargetModel()["~"].schemas.create,
    }),
  });
};

To-Many Create

// Uses ensureArray helper for single-or-array normalization
const ensureArray = <T>(v: T | T[]): T[] => Array.isArray(v) ? v : [v];

const createSchema = type(() => getTargetModel()["~"].schemas.create);
return type({
  "create?": createSchema.or(createSchema.array()).pipe(ensureArray),
  "connect?": connectSchema.or(connectSchema.array()).pipe(ensureArray),
  // ...
});

To-Many Update (Array-Only Operations)

Some operations only accept arrays due to ArkType limitations:

return type({
  // Single-or-array (normalized)
  "create?": createSchema.or(createSchema.array()).pipe(ensureArray),
  "connect?": connectSchema.or(connectSchema.array()).pipe(ensureArray),
  
  // Array-only (no single-value shorthand)
  "deleteMany?": whereSchema.array(),
  "updateMany?": updateManySchema.array(),
  "upsert?": upsertSchema.array(),
});

Schema Type Exports

ArkType schemas expose their types:

// Get input type (what user provides)
type StringFilterInput = typeof stringFilter.inferIn;

// Get output type (after normalization)
type StringFilterOutput = typeof stringFilter.infer;

For piped schemas, these differ:

  • inferIn: string | { equals?: string, ... }
  • infer: { equals: string } | { contains?: string, ... }

On this page