cogsbox-shape
Version:
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
614 lines (460 loc) • 23.2 kB
Markdown
# 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
```bash
npm install cogsbox-shape
# or
yarn add cogsbox-shape
# or
pnpm add cogsbox-shape
```
## State Plugin
Shape can be used with `cogsbox-state` through the `cogsbox-shape/state` export:
```typescript
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](./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:
```typescript
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:
```typescript
s.sqlite({ type: "text" });
s.postgres({ type: "varchar", length: 255 });
s.mysql({ type: "varchar", length: 255 });
```
Enums are real SQL column configs:
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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:
```typescript
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.
```typescript
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.
```typescript
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.
```typescript
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:
- `forClient` computes client-only fields during `generateDefaults()` and `toClient()`.
- `forDb` computes DB-backed fields during `toDb()`, `parseForDb()`, and ORM writes. Use `sqlOnly: true` when the computed column should stay hidden from the client.
```typescript
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.
```typescript
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:
- `undefined` or `null` — 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:
```typescript
box.events.refineInfo;
// { groups: RefineEntry[], fieldToGroup: Record<string, number[]> }
```
**Chaining**: `refine()` can be chained after `derive()`:
```typescript
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`** — plain `ZodObject` shapes. Always composable with `.pick()`, `.omit()`, `.partial()`, etc. Use these for form field extraction, type inference, and partial validation.
- **`validators`** — schema + refinements. These are `ZodEffects` when `.refine()` is used, otherwise the same `ZodObject`. Use these for full validation that enforces cross-field rules.
```typescript
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.server
```
Why 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:
```typescript
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:
```typescript
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
```typescript
import { s, schema, createSchemaBox, addViews } 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:
```typescript
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:
```typescript
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.
```typescript
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 automatically
```
### 5. Expose Views on the Box with `addViews`
Relations are **excluded from base schemas** — `box.journals` doesn't have `imports` or `tradeEvents` on it. To access those joined shapes, you create a view. But calling `box.journals.createView(...)` every time is repetitive, and you often want views as stable, named entries you can pass around.
`addViews` takes a box and merges named views as top-level keys on a new extended box:
```typescript
import { addViews } from "cogsbox-shape";
const box = createSchemaBox(
{ journals, importBatches, tradeEvents, tradingRulesForm },
{
journals: {
imports: { fromKey: "id", toKey: importBatches.journalId },
tradeEvents: { fromKey: "id", toKey: tradeEvents.journalId },
tradingRules: { fromKey: "id", toKey: tradingRulesForm.journalId },
},
},
);
const extendedBox = addViews(box, {
journalSummary: box.journals.createView({
imports: true,
tradeEvents: true,
}),
journalFull: box.journals.createView({
imports: true,
tradeEvents: true,
}),
});
// Views are now first-class entries on the box
extendedBox.journalSummary; // full view entry with schemas, transforms, defaults, etc.
extendedBox.journalFull; // same shape, different view selection
// Use with the state plugin — views are normalized automatically
const plugin = createShapePlugin(extendedBox);
// Connect to the ORM — views hydrate their relations automatically
const db = createSqliteDb("app.sqlite");
const bx = connect(extendedBox, db);
const journals = await bx.journalSummary.findMany({ where: { userId } });
// Each journal already has imports[] and tradeEvents[] hydrated from SQL
```
**Why is this important?** Without `addViews`, views are local variables — you'd call `box.journals.createView(...)` at each call site. `addViews` gives views named keys on the box so they can be:
- Used as keys in state management (e.g., `shapePlugin.initialState.journalSummary`)
- Queried via the ORM with automatic relation hydration (`bx.journalSummary.findMany(...)`)
- Passed as a single box object to `createShapePlugin()` and `connect()` instead of managing loose views
`addViews` is how you turn relations from invisible schema definitions into usable, named shape entries.
When a box with views is connected to the ORM, view reads hydrate the selected relation tree before parsing:
```typescript
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 shape
```
Cloudflare D1 uses the same SQLite schema dialect with a D1 connection helper:
```typescript
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());
},
};
```
For atomic multi-query writes on D1, pass Kysely query builders to the
adapter's `batch()` method. D1 rolls back the whole batch when one statement
fails:
```typescript
await db.batch([
db.insertInto("users").values(firstUser),
db.insertInto("users").values(secondUser),
]);
```
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.
### 6. 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:
```typescript
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.