cogsbox-shape
v0.5.235
Published
A TypeScript library for creating type-safe database schemas with Zod validation, SQL type definitions, and automatic client/server transformations. Unifies client, server, and database types through a single schema definition, with built-in support for r
Maintainers
Readme
cogsbox-shape
[!CAUTION] This library is under active development and the API is rapidly changing. Do not use in production.
Breaking changes are expected between any release. The library is currently in an experimental phase as we work towards a stable v1.0 release.
A TypeScript-first schema declaration and validation library for full-stack applications. Define your database schema once and get type-safe schemas for your database, client, and validation layers with automatic transformations.
Installation
npm install cogsbox-shape
# or
yarn add cogsbox-shape
# or
pnpm add cogsbox-shapeState Plugin
Shape can be used with cogsbox-state through the cogsbox-shape/state export:
import { createShapePlugin } from "cogsbox-shape/state";
const shapePlugin = createShapePlugin(schemaBox);The state plugin wires Shape defaults and validation into state, and adds persistence methods such as $save(), $load(), $revert(), and $status() for server-backed state.
See cogsbox-shape-state/README.md for the plugin API, server adapters, cache keys, dirty tracking, and status behaviour.
The Problem
In full-stack applications, data flows through multiple layers:
- Database stores data in SQL types (integers, varchars, etc.)
- Client needs different types for UI work (booleans instead of 0/1, temp string IDs instead of auto-increment integers)
- Validation rules differ between client and server boundaries
- Forms need typed default values that match the client representation
Traditional approaches require defining these layers separately, leading to type mismatches and duplicated logic.
The Shape Flow
Define a field by chaining methods. Each step is optional — use only what you need.
s.sqlite()/s.postgres()/s.mysql() → .client() → .clientCheck() → .server() → .transform()| Method | Purpose |
| --------------------------------- | -------------------------------------------------------------- |
| s.sqlite/postgres/mysql({ type, sqlOnly }) | Database column type. sqlOnly excludes from client layer. |
| .client({ value, schema }) | Client-side input schema and default value for new records. |
| .clientCheck(fn) | Client-side validation on the final client union type. |
| .server(fn) | Server-side validation. Stricter rules before database writes. |
| .transform({ toClient, toDb }) | Converts between database and client representations. |
Note: .derive() and .refine() are schema-level methods, not chainable on individual fields.
1. SQL — Define Your Database Schema
Start with your database reality:
import { s, schema } from "cogsbox-shape";
const userSchema = schema({
_tableName: "users",
id: s.sqlite({ type: "int", pk: true }),
email: s.sqlite({ type: "varchar", length: 255 }),
createdAt: s.sqlite({ type: "datetime", default: "CURRENT_TIMESTAMP" }),
});This generates a Zod schema matching your SQL types exactly.
Use the SQL engine function that matches the database this schema targets:
s.sqlite({ type: "text" });
s.postgres({ type: "varchar", length: 255 });
s.mysql({ type: "varchar", length: 255 });Enums are real SQL column configs:
s.sqlite({ type: "enum", values: ["draft", "published"] });
// SQL: TEXT CHECK (...)
s.postgres({
type: "enum",
name: "post_status",
values: ["draft", "published"],
});
// SQL: CREATE TYPE post_status AS ENUM (...), then column uses post_status
s.mysql({ type: "enum", values: ["draft", "published"] });
// SQL: ENUM('draft', 'published')2. Client — Defaults and Client-Side Validation
.client() sets the default value and client-side validation type for new records.
const userSchema = schema({
_tableName: "users",
// DB stores auto-increment integers, but new records need a temp string ID
id: s.sqlite({ type: "int", pk: true }).client({
value: () => crypto.randomUUID(),
schema: z.string(),
}),
// client type: string (just the user's schema)
// Default value: a generated UUID string
// Simple default without type override
name: s.sqlite({ type: "varchar" }).client({ value: "Anonymous" }),
// client type: string (inherits from SQL)
// Default value: "Anonymous"
// Type-only override; default is inferred from the client schema
count: s.sqlite({ type: "int" }).client(() => z.number().min(0)),
// client type: number (with min validation)
// Default value: inferred from the client schema (0 for number)
});Note: The final client schema is a union of sql | client types, representing the complete app state after transforms.
generateDefaults() uses the client schema to pick a default when no explicit value is provided. toClient() stays for DB-shaped data.
3. Client Check — Client-Side Validation
.clientCheck() adds validation rules to the final client schema (the union of sql | client). Use it for client-side validation that operates on the complete client type.
name: s.sqlite({ type: "varchar" })
.client({ value: "" })
.clientCheck((tools) => tools.client.min(3, "Too short"))
.server((tools) => tools.client.min(5, "Must be at least 5 chars")),The .clientCheck() callback receives tools with sql, client, and clientCheck schemas.
4. Server — Server-Side Validation
.server() adds validation rules that run at the server boundary before database writes. It builds on the client schema, adding stricter constraints.
const userSchema = schema({
_tableName: "users",
email: s
.sqlite({ type: "varchar", length: 255 })
.server(({ sql }) => sql.email("Invalid email")),
age: s
.sqlite({ type: "int" })
.server(({ sql }) => sql.min(18, "Must be 18+").max(120)),
});The callback receives the previous schema in the chain so you can refine it:
name: s
.sqlite({ type: "varchar" })
.client(() => z.string().trim())
.server(({ client }) => client.min(2, "Too short")),5. Transform — Convert Between Layers
.transform() defines bidirectional conversion functions. These run on the server when reading from or writing to the database.
status: s
.sqlite({ type: "int" }) // DB: 0 or 1
.client(() => z.enum(["active", "inactive"])) // Client input: string enum
.transform({
toClient: (dbValue) => dbValue === 1 ? "active" : "inactive",
toDb: (clientValue) => clientValue === "active" ? 1 : 0,
}),Transforms are optional — only needed when the client type differs from the SQL type.
6. Layer Separation: DB-Only, Client-Only, and Derived Fields
cogsbox-shape lets you explicitly define fields that only exist in specific layers, or dynamically compute them.
DB-Only Fields (sqlOnly: true)
Use sqlOnly: true to define fields that belong to the database exclusively (like internal tokens). They are saved in the DB, but dropped before data reaches the client.
const userSchema = schema({
_tableName: "users",
id: s.sqlite({ type: "int", pk: true }),
email: s.sqlite({ type: "varchar" }),
internalToken: s.sqlite({ type: "varchar", sqlOnly: true }),
});
// DB reads/writes: { id, email, internalToken }
// Client sees: { id, email }Client-Only Fields
By skipping s.sqlite() entirely and just using s.client(), you can define fields that exist purely on the client (like a temporary UI state or computed field) and will not be sent to the database.
const products = schema({
_tableName: "products",
price: s.sqlite({ type: "int" }),
formattedPrice: s.client(""), // Client-only field!
});Derived Fields (.derive())
.derive() populates existing fields dynamically. Define the target field first, then choose where the derivation runs:
forClientcomputes client-only fields duringgenerateDefaults()andtoClient().forDbcomputes DB-backed fields duringtoDb(),parseForDb(), and ORM writes. UsesqlOnly: truewhen the computed column should stay hidden from the client.
const users = schema({
_tableName: "users",
firstName: s.sqlite({ type: "varchar" }).client({ value: "John" }),
lastName: s.sqlite({ type: "varchar" }).client({ value: "Doe" }),
// Virtual field. It exists in app/view state, not SQL.
fullName: s.client(""),
// Hidden DB column. It is written to SQL, but not sent to the client.
searchIndex: s.sqlite({ type: "varchar", sqlOnly: true }),
}).derive({
forClient: {
fullName: (row) => `${row.firstName} ${row.lastName}`,
},
forDb: {
searchIndex: (row) => `${row.firstName} ${row.lastName}`.toLowerCase(),
},
});During partial ORM updates, DB-backed derivations fetch only missing dependency fields they actually read, then recompute the affected forDb fields. Client-only derived fields are ignored by SQL writes.
7. Refinement (.refine())
.refine() adds cross-field validation rules that the entire row must satisfy. Unlike .clientCheck()/.server() which validate individual fields, refine can check relationships between fields.
const events = schema({
_tableName: "events",
id: s.sqlite({ type: "int", pk: true }),
startDate: s.sqlite({ type: "varchar" }).client({ value: "" }),
endDate: s.sqlite({ type: "varchar" }).client({ value: "" }),
content: s.sqlite({ type: "varchar", nullable: true }).client({
value: null,
schema: z.string().nullable(),
}),
isPublished: s.sqlite({ type: "boolean" }).client({ value: false }),
}).refine((r) => [
r("server", (row) => {
const errors: { path: string[]; message: string }[] = [];
if (row.startDate && row.endDate && row.startDate > row.endDate) {
errors.push({ path: ["endDate"], message: "End date must be after start date" });
}
if (row.isPublished && !row.content) {
errors.push({ path: ["content"], message: "Published events must have content" });
}
return errors.length > 0 ? errors : undefined;
}),
]);
const box = createSchemaBox({ events }, { events: {} });
// Server refinement runs on parseForDb (before DB writes)
box.events.transforms.parseForDb({
id: 1, startDate: "2024-12-31", endDate: "2024-01-01",
content: null, isPublished: false,
});
// Throws: "End date must be after start date"The refine() method takes a callback that receives an r helper function. Each call to r(layer, check, deps?) creates a refine entry:
| Layer | Applies to | Purpose |
|-------|-----------|---------|
| "server" | parseForDb(), server schema | Cross-field validation before DB writes |
| "clientCheck" | clientCheck schema | Cross-field validation on client output |
| "client" | client schema | Cross-field validation on raw client input |
| "sql" | parseFromDb(), sql schema | Cross-field validation on DB reads |
| "all" | all of the above | Universal cross-field validation |
| string[] | specified layers | Apply to multiple layers at once |
The check function receives the full row and returns:
undefinedornull— validation passes- A single
{ path: string[]; message: string }— one error - An array of
{ path: string[]; message: string }— multiple errors
Optional third argument deps specifies explicit dependency fields as a string or string array. If omitted, the library uses proxy-based tracking (same caveat as derive() — conditional branches with falsy defaults can hide dependencies).
Dependency tracking: Dependencies are exposed as refineInfo on the box entry:
box.events.refineInfo;
// { groups: RefineEntry[], fieldToGroup: Record<string, number[]> }Chaining: refine() can be chained after derive():
schema({ ... })
.derive({ forDb: { fullName: (row) => `${row.firstName} ${row.lastName}` } })
.refine((r) => [r("server", (row) => { ... })]);Note: parsePatchForDb uses the base schema (without refinement) since partial data may not satisfy cross-field rules.
Schemas vs Validators
Each box entry exposes two sets of Zod schemas:
schemas— plainZodObjectshapes. Always composable with.pick(),.omit(),.partial(), etc. Use these for form field extraction, type inference, and partial validation.validators— schema + refinements. These areZodEffectswhen.refine()is used, otherwise the sameZodObject. Use these for full validation that enforces cross-field rules.
const box = createSchemaBox({ events }, { events: {} });
// Base schema — always a ZodObject, always composable
box.events.schemas.client.pick({ startDate: true, endDate: true }); // works!
// Validator — enforces refine rules
box.events.validators.client.safeParse(data); // runs cross-field checks
// Internal transforms use validators automatically
box.events.transforms.parseForDb(data); // uses validator.serverWhy the split? After .refine(), Zod wraps the schema in ZodEffects, which loses .shape, .pick(), .omit(), and .partial(). By keeping the base schema separate from refinements, you can always compose the shape while still enforcing cross-field rules when needed.
Schema Object Structure
The returned schema object has a clear separation of concerns:
const schema = createSchema(mySchema);
schema.schemas; // { sql, client, clientChecked, server } — ZodObject shapes (composable)
schema.validators; // { sql, client, clientChecked, server } — with refinements enforced
schema.transforms; // { toClient, toDb, parseForDb, parseFromDb } — transformations
schema.defaults; // Default values for forms
schema.generateDefaults; // Function to generate fresh client defaults (executes randomizers)
schema.pk; // Primary key field names
schema.clientPk; // Client-side primary key field names
schema.isClientRecord; // Function to check if a record is client-created
schema.deriveDependencies; // Derive function dependencies ({ [field]: string[] })
schema.refineInfo; // Refinement info ({ groups: RefineEntry[], fieldToGroup: Record<string, number[]> })Using Schemas
Single Schema with createSchema
For standalone schemas without relationships:
import { s, schema, createSchema } from "cogsbox-shape";
const contactSchema = schema({
_tableName: "contacts",
id: s.sqlite({ type: "int", pk: true }).client({
value: () => `new_${crypto.randomUUID().slice(0, 8)}`,
schema: z.string(),
}),
name: s.sqlite({ type: "varchar" }).server(({ sql }) => sql.min(2)),
email: s.sqlite({ type: "varchar" }).server(({ sql }) => sql.email()),
isActive: s
.sqlite({ type: "boolean", default: true })
.client(() => z.boolean())
.transform({
toClient: (val) => Boolean(val),
toDb: (val) => (val ? 1 : 0),
}),
});
const schema = createSchema(contactSchema);
// Access schemas directly
const { sql, client, clientChecked, server } = schema.schemas;
const { defaults, generateDefaults } = schema;
// Transforms for converting between layers
const { toClient, toDb, parseForDb, parseFromDb } = schema.transforms;
// Use in a form
const [data, setData] = useState(generateDefaults());
// { id: "new_a1b2c3d4", name: "", email: "", isActive: true }
// Validate explicitly (use validators for refinement enforcement)
const result = schema.validators.server.safeParse(data);
// Or use the base schema for shape operations
const pickedSchema = schema.schemas.server.pick({ email: true, age: true });
// Or handle validation & transformation in a single step!
const safeDbRow = parseForDb(data);
// Validates using server schema, outputs { isActive: 1, ... }Relationships and Views
For schemas with relationships, use createSchemaBox.
1. Define Schemas with Placeholders
import { s, schema, createSchemaBox } from "cogsbox-shape";
const users = schema({
_tableName: "users",
id: s.sqlite({ type: "int", pk: true }),
name: s.sqlite({ type: "varchar" }),
posts: s.hasMany(), // Placeholder — resolved later
});
const posts = schema({
_tableName: "posts",
id: s.sqlite({ type: "int", pk: true }),
title: s.sqlite({ type: "varchar" }),
authorId: s.reference(() => users.id), // Foreign key
});2. Create the Registry
The createSchemaBox function resolves relationships and gives you a type-safe API:
const box = createSchemaBox(
{ users, posts },
{
users: {
posts: { fromKey: "id", toKey: posts.authorId },
},
},
);3. Access Base Schemas
Base schemas exclude relations by default, preventing circular dependencies:
const { schemas, defaults, transforms, pk, clientPk } = box.users;
type UserClient = z.infer<typeof schemas.client>;
// { id: number; name: string; }
// No 'posts' — relations are excluded from base schemas
// Convert data between layers
const dbRow = transforms.toDb(clientData);
const clientData = transforms.toClient(dbRow);
// Validate and convert in one step
const dbRow = transforms.parseForDb(appData);
const clientData = transforms.parseFromDb(dbRow);4. Create Views to Include Relations
Explicitly select which relations to include. The resulting views automatically apply nested transforms and deep schema validations.
const userWithPosts = box.users.createView({
posts: true,
});
type UserWithPosts = z.infer<typeof userWithPosts.schemas.client>;
// {
// id: number;
// name: string;
// posts: { id: number; title: string; authorId: number; }[]
// }
// Views also have transforms for the selected fields
const { defaults, transforms } = userWithPosts;
// transforms.toClient() handles nested relation transforms automaticallyWhen a box is connected to the ORM, view reads hydrate the selected relation tree before parsing:
import { connect } from "cogsbox-shape/db";
import { createSqliteDb } from "cogsbox-shape/db/sqlite";
const db = createSqliteDb("app.sqlite");
const bx = connect(box, db);
const userView = bx.users.createView({
posts: true,
});
const user = await userView.findById(1);
// user.posts is loaded and validated as part of the view shapeCloudflare D1 uses the same SQLite schema dialect with a D1 connection helper:
import { connect } from "cogsbox-shape/db";
import { createD1Db } from "cogsbox-shape/db/cloudflare-d1";
export default {
async fetch(_request, env) {
const db = createD1Db(env.DB);
const bx = connect(box, db);
return Response.json(await bx.users.findMany());
},
};Use insert(data).ids() when you only need the database identity, or insert(data).full() when you want optimistic client IDs reconciled back into the submitted client object. create() is kept as an alias for older code; prefer insert() in new code.
5. Nested Defaults and Form Definitions (defaultsDefinition)
When working with forms and nested array relations (like hasMany), you often need the default state for a single new item to add to a form array.
While view.defaults gives you the actual runtime defaults (e.g., an array of 2 default posts if you defined count: 2), view.defaultsDefinition provides an easy way to grab the structure of a single element using the __def__relationName key:
const userView = box.users.createView({
posts: { user: true },
});
// Actual runtime defaults (an array)
console.log(userView.defaults.posts);
// => [{ title: "Default Post", user: { ... } }, { title: "Default Post", user: { ... } }]
// Structural definition of a single item for adding to forms
console.log(userView.defaultsDefinition.__def__posts);
// => { title: "Default Post", user: { ... } }This makes it incredibly simple to implement "Add Item" buttons in complex nested forms without having to manually construct or guess the default object shape.
