@risklight/models
v0.4.1
Published
Framework-agnostic Model/Collection with Proxy-based reactivity and Zod validation
Maintainers
Readme
@risklight/models
Docs: https://risklight.github.io/models/
Framework-agnostic Model/Collection library with Proxy-based reactivity, a pluggable HTTP layer, Zod validation, and first-class TypeScript generics. Works standalone, with Vue 3, React, or Nuxt.
Drop-in replacement for vue-mc — same API, no framework lock-in.
Install
npm install @risklight/modelsQuick start
TypeScript
import { Model, Collection } from '@risklight/models'
import { required, email, between } from '@risklight/models'
interface UserAttrs {
id: number
name: string
email: string
age: number
}
class User extends Model<UserAttrs> {
defaults(): Partial<UserAttrs> {
return { id: 0, name: '', email: '', age: 0 }
}
routes() {
return {
fetch: '/api/users/{id}',
save: '/api/users',
}
}
validation() {
return {
name: required,
email: required.and(email),
age: required.and(between(18, 120)),
}
}
}
class Users extends Collection<User> {
model() { return User }
routes() { return { fetch: '/api/users' } }
}JavaScript
import { Model, Collection, required, email, between } from '@risklight/models'
class User extends Model {
defaults() {
return { id: 0, name: '', email: '', age: 0 }
}
routes() {
return {
fetch: '/api/users/{id}',
save: '/api/users',
}
}
validation() {
return {
name: required,
email: required.and(email),
age: required.and(between(18, 120)),
}
}
}
class Users extends Collection {
model() { return User }
routes() { return { fetch: '/api/users' } }
}Attribute access
Models use a Proxy — read/write attributes with dot notation:
const user = new User({ name: 'John', email: '[email protected]' })
user.name // 'John'
user.name = 'Jane' // sets attribute, emits 'change' event
user.get('name') // typed: string (with generics)
user.set('name', 'Bob')
user.set({ name: 'Bob', email: '[email protected]' })Attribute access is typed from the generic: with class User extends Model<UserAttrs>, user.name is string, user.age is number, and assigning the wrong type is a compile error. A model declared without a generic keeps Record<string, any> attributes.
Attribute names may start with an underscore as long as they are declared in defaults() (Mongo's _id is the usual case). Undeclared _-prefixed keys are treated as private instance fields and never reach the attribute store.
State management
user.sync() // mark current state as "saved"
user.name = 'changed'
user.changed() // ['name']
user.saved('name') // 'Bob' (last synced value)
user.reset() // revert to synced state
user.changed() // falseNested models
Cast attributes to model instances via mutations:
interface AddressAttrs {
street: string
city: string
zip: string
}
class Address extends Model<AddressAttrs> {
defaults() { return { street: '', city: '', zip: '' } }
}
class User extends Model {
defaults() {
return { name: '', address: {} }
}
mutations() {
return {
address: (v: any) => new Address(v),
}
}
}
const user = new User({ name: 'John', address: { street: '123 Main', city: 'Berlin', zip: '10115' } })
user.address.city // 'Berlin'
user.address.city = 'Munich'Validation
With rules
import { required, email, min, max, length, match, integer } from '@risklight/models'
class User extends Model {
validation() {
return {
name: required.and(length(2, 50)),
email: required.and(email),
age: required.and(integer).and(min(18)),
phone: match(/^\+\d{10,15}$/),
}
}
}
const user = new User()
const errors = await user.validate()
// { name: 'Required', email: 'Required', age: 'Required' }With Zod
Zod schemas take priority over validation() rules:
import { z } from 'zod'
class User extends Model {
defaults() { return { name: '', email: '' } }
schema() {
return z.object({
name: z.string().min(2),
email: z.string().email(),
})
}
}Nested attributes
Errors for nested fields are keyed by the full dotted path ('basic_info.title', 'genres.1'), so each message can sit next to its own input. validation() rules accept the same dotted keys.
Rule combinators
// AND — both must pass
required.and(email)
// OR — at least one must pass
required.or(email)
// Custom message
required.format('This field cannot be empty')Available rules
| Rule | Description |
|---|---|
| required | Not null/undefined/empty |
| email | Valid email |
| url | Valid URL |
| uuid | Valid UUID |
| integer | Integer number |
| numeric | Number or numeric string |
| boolean | Boolean value |
| string | String value |
| array | Array value |
| object | Plain object |
| min(n) | Number >= n |
| max(n) | Number <= n |
| between(a, b) | Number between a and b |
| gt(n), gte(n), lt(n), lte(n) | Comparisons |
| length(min, max?) | String/array length |
| match(regex) | Matches pattern |
| equals(value) | Strictly equal |
| date | Valid date |
| after(date), before(date) | Date comparisons |
| alpha, alphanumeric | Letter/number only |
| ip, iso8601, base64, ascii | Format checks |
| creditcard | Luhn algorithm check |
| json | Valid JSON string |
| positive, negative | Sign checks |
| not(...values) | Exclusion |
| same(attr) | Same as another attribute |
Localization
import { register, locale } from '@risklight/models'
register('de', {
required: 'Pflichtfeld',
email: 'Ungueltige E-Mail',
})
locale('de')HTTP (CRUD)
HTTP goes through createRequest(): axios by default, or the transport configured with configureTransport() when you use ResourceModel/ResourceCollection (see REST resources). Override createRequest() on a model or collection to plug in anything else.
Model
class User extends Model {
defaults() { return { id: null, name: '', email: '' } }
routes() {
return {
fetch: '/api/users/{id}',
save: '/api/users',
delete: '/api/users/{id}',
}
}
}
// Create
const user = new User({ name: 'John', email: '[email protected]' })
await user.save() // POST /api/users
console.log(user.id) // server-assigned ID
// Read
const user2 = new User({ id: 5 })
await user2.fetch() // GET /api/users/5
// Update
user.name = 'Jane'
await user.save() // PUT /api/users/5
// Delete
await user.delete() // DELETE /api/users/5Collection
const users = new Users()
await users.fetch() // GET /api/users
users.models // User[]
users.size() // numberPagination
const users = new Users()
users.page(1)
await users.fetch() // GET /api/users?page=1
users.page(2)
await users.fetch() // GET /api/users?page=2
users.isLastPage() // true/falseFile upload
class Avatar extends Model {
defaults() { return { id: null, filename: '' } }
routes() { return { save: '/api/upload' } }
}
const model = new Avatar()
await model.upload({
data: { file: fileInput.files[0], field: 'avatar' }
})Backend validation (422)
When the server returns 422, errors are automatically parsed into model.errors:
try {
await user.save()
} catch (e) {
console.log(user.errors)
// { name: ['Name is required'], email: ['Invalid email'] }
}Bulk operations
// Bulk save — saves all changed models in one request
for (const user of users.models) {
user.name = user.name.toUpperCase()
}
await users.save()
// Bulk delete — mark models then delete
users.models[0].deleting = true
users.models[2].deleting = true
await users.delete()REST resources
ResourceModel and ResourceCollection bind a model to a REST resource by a static route and route HTTP through a transport you configure once. They work in any framework: plain JS, Vue, React or Nuxt. Without configureTransport() they fall back to the default axios request.
import { ResourceModel, ResourceCollection, configureTransport } from '@risklight/models'
import type { Identified } from '@risklight/models'
configureTransport({
fetcher: async ({ url, method, data, params, headers }) => {
const res = await fetch(url + '?' + new URLSearchParams(params as any), { method, body: data ? JSON.stringify(data) : undefined, headers })
return { data: await res.json(), status: res.status }
},
// unwrap: (payload) => payload.data // default: unwraps { data } envelopes
// identifier: '_id' // default
})
interface GenreAttrs extends Identified { name: string; description?: string }
class Genre extends ResourceModel<GenreAttrs> {
static route = '/api/genre'
defaults(): Partial<GenreAttrs> { return { _id: '', name: '', description: '' } }
}
class Genres extends ResourceCollection<Genre> {
model() { return Genre }
}
const genre = new Genre()
await genre.fetchOne('a1') // GET /api/genre/a1
genre.name = 'Landscape'
await genre.save() // PUT /api/genre/a1 (POST /api/genre when new, without the identifier)
await genre.delete() // DELETE /api/genre/a1
const genres = new Genres()
await genres.fetchAll() // GET /api/genre → genres.models / genres.items (plain objects)
await Genre.fetchAll() // { data: GenreAttrs[] } without instantiating modelsRoutes: fetch/update/delete use {route}/{identifier}, save on a new model posts to {route}. The identifier defaults to _id; pass identifier to configureTransport() for id or anything else.
Options
class User extends Model {
options() {
return {
identifier: 'id', // primary key attribute
patch: false, // use PATCH instead of PUT for updates
saveUnchanged: true, // send unchanged models to server
useFirstErrorOnly: false, // single error string vs array per field
validateOnChange: false, // auto-validate on attribute change
validateRecursively: true, // validate nested models
mutateOnChange: false, // apply mutations on change
mutateBeforeSync: true, // mutate before syncing state
mutateBeforeSave: true, // mutate before save request
}
}
}Undeclared attribute protection
Writing to an attribute not declared in defaults() triggers a console warning:
class User extends Model {
defaults() { return { name: '', email: '' } }
}
const user = new User()
user.phone = '123' // ⚠ [models] Undeclared "phone" on UserThe attribute is still stored — but the warning helps catch typos and unintended properties. Control this via the debug option:
class User extends Model {
options() {
return {
debug: true, // console.warn (default)
// debug: 'strict', // throw Error instead
// debug: false, // silent
}
}
}Events
const user = new User({ name: 'John' })
user.sync()
// Attribute changes
user.on('change', (ctx) => {
console.log(ctx.attribute, ctx.value, ctx.previous)
})
user.on('change:name', (ctx) => {
console.log('name changed to', ctx.value)
})
// Lifecycle
user.on('save', () => console.log('save started'))
user.on('save.success', () => console.log('saved'))
user.on('save.failure', () => console.log('save failed'))
user.on('fetch', () => console.log('fetched'))
user.on('delete', () => console.log('deleted'))
user.on('sync', () => console.log('synced'))
user.on('reset', () => console.log('reset'))
// Property signals
user.on('name', (value, previous) => {
console.log(`name: ${previous} -> ${value}`)
})
// Wildcard
user.on('*', (key, value, previous) => {
console.log(`${key} changed`)
})
// Unsubscribe
const handler = () => {}
user.on('change', handler)
user.off('change', handler)Collection API
const users = new Users()
// Add/remove
users.add({ name: 'John', email: '[email protected]' })
users.add(new User({ name: 'Jane' }))
users.remove(user)
users.clear()
// Query
users.find(u => u.name === 'John') // User | undefined
users.where(u => u.age > 18) // User[]
users.filter(u => u.age > 18) // new Collection<User>
users.has(user) // boolean
// Array-like
users.models // User[]
users.size() // number
users.isEmpty() // boolean
users.first() // User | undefined
users.last() // User | undefined
users.map(u => u.name)
users.each(u => console.log(u.name))
users.sort('name')
// Iteration
for (const user of users) {
console.log(user.name)
}
// Serialization
users.toJSON() // plain object array
users.clone() // new collection with cloned modelsHTTP customization
Override methods for custom behavior:
class User extends Model {
// Custom headers
getDefaultHeaders() {
return { Authorization: `Bearer ${getToken()}` }
}
// Custom query params
getFetchQuery() {
return { include: 'posts,comments' }
}
// Custom save data (e.g. wrap in root key)
getSaveData() {
return { user: this.attributes }
}
// Custom URL logic
getFetchURL() {
return `/api/v2/users/${this.id}`
}
// PATCH mode
options() {
return { patch: true }
}
}Custom query string serializer
For nested params (e.g. with qs):
import qs from 'qs'
class User extends Model {
options() {
return {
paramsSerializer: (params) => qs.stringify(params, { arrayFormat: 'brackets' }),
}
}
}Lifecycle hooks
Override these to customize request behavior:
class User extends Model {
async onSave() {
// Return REQUEST_CONTINUE, REQUEST_SKIP, or REQUEST_REDUNDANT
if (this.loading) return Model.REQUEST_SKIP
return super.onSave()
}
onSaveSuccess(response) {
super.onSaveSuccess(response)
console.log('Saved!', response.getData())
}
onSaveFailure(error) {
super.onSaveFailure(error)
notify('Save failed')
}
onFetchSuccess(response) {
super.onFetchSuccess(response)
// Transform response data
}
}Framework adapters
Vue 3
Import the adapter once in your main.ts — all Model instances become Vue-reactive automatically:
// main.ts
import '@risklight/models/vue'<script setup>
import { ref } from 'vue'
import { User } from './models/User'
const user = ref(new User({ name: 'John' }))
// user.value.name is reactive in templates
</script>
<template>
<input v-model="user.name" />
<p>{{ user.name }}</p>
</template>Validation errors and the loading, saving, deleting and fatal flags are reactive too, whether the model sits in ref(), reactive() or a plain variable, so a template can show the message for a field as soon as validate() or save() fills it:
<template>
<input v-model="genre.name" />
<p v-if="genre.errors.name">{{ genre.errors.name[0] }}</p>
<button :disabled="genre.saving" @click="genre.save()">Save</button>
</template>React
Use the useModelState hook for reactive re-renders:
import { useMemo } from 'react'
import { useModelState } from '@risklight/models/react'
import { User } from './models/User'
function UserForm() {
const user = useMemo(() => new User({ name: 'John' }), [])
const state = useModelState(user)
return (
<>
<input
value={state.name}
onChange={e => user.name = e.target.value}
/>
<p>{state.name}</p>
</>
)
}useModelState returns a reactive snapshot of model attributes. It re-renders on:
- attribute changes (including nested models)
sync,reset,fetch,save.success,delete
Nuxt
@risklight/models/nuxt is ResourceModel/ResourceCollection plus the Vue adapter, exported under Nuxt names (NuxtModel, NuxtCollection, configureNuxtModels). The only Nuxt-specific piece is the fetcher, which lives in your app because it needs $fetch and useNuxtApp():
// plugins/models.ts
import { configureNuxtModels } from '@risklight/models/nuxt'
export default defineNuxtPlugin(() => {
configureNuxtModels({
fetcher: async ({ url, method, data, params, headers }) => {
const fetcher = import.meta.server ? $fetch : (useNuxtApp().$csrfFetch as typeof $fetch)
const res = await fetcher.raw(url, { method: method as any, body: data as any, query: params, headers })
return { data: res._data, status: res.status, headers: Object.fromEntries(res.headers.entries()) }
}
})
})import { NuxtModel, NuxtCollection } from '@risklight/models/nuxt'
import type { Identified } from '@risklight/models'
class Genre extends NuxtModel<GenreAttrs> {
static route = '/api/genre'
defaults(): Partial<GenreAttrs> { return { _id: '', name: '', description: '' } }
}SSR goes through $fetch, the client through $csrfFetch with cookies and CSRF headers. See REST resources for the full API.
Vanilla JS / Node.js
Works without any adapter:
import { Model } from '@risklight/models'
class User extends Model {
defaults() { return { name: '' } }
routes() { return { save: '/api/users' } }
}
const user = new User({ name: 'John' })
user.on('change', (ctx) => console.log(ctx.attribute, ctx.value))
user.name = 'Jane' // logs: 'name' 'Jane'
await user.save()Exported types
import type {
Routes,
Options,
RequestOptions,
Listener,
Mutation,
RouteResolver,
HttpMethod,
ResponseData,
RequestSuccessCallback,
RequestFailureCallback,
} from '@risklight/models'Base classes and constructors
Model and ResourceModel are typed constructors that return ModelBase<A> & A / ResourceModelBase<A> & A, which is what gives dot access its types. The underlying classes and constructor types are exported for tooling and generic constraints:
import type { ModelBase, ModelConstructor } from '@risklight/models'
import type { ResourceModelBase, ResourceModelConstructor } from '@risklight/models'
function describe<M extends ModelBase<any>>(model: M) { return model.toJSON() }Transport and resource types
import type { Fetcher, RequestConfig, RawResponse, TransportOptions, Identified, Attributes } from '@risklight/models'
import type { ResourceModelConstructor } from '@risklight/models'
const fetcher: Fetcher = async (config: RequestConfig): Promise<RawResponse> => { /* ... */ }
interface GenreAttrs extends Identified { name: string } // Identified is { _id?: string }
type GenreRow = Attributes<Genre> // toJSON() shape of any model; what ResourceCollection#items yieldsError handling
import { ValidationError, RequestError, ResponseError } from '@risklight/models'
try {
await user.save()
} catch (e) {
if (e instanceof ValidationError) {
// Client-side validation failed
console.log(e.getValidationErrors())
}
if (e instanceof ResponseError) {
// Server returned error (e.g. 422)
console.log(e.getResponse()?.getStatus())
}
if (e instanceof RequestError) {
// Network/request error
console.log(e.getError())
}
}License
MIT
