remult
- Repo stars 3,199
- License MIT
- Author repo remult
Remult
Remult is a full-stack TypeScript framework: define an entity once, get a typed REST API, a typed client, validation, and permissions everywhere.
Need more docs? Fetch https://remult.dev/llms.txt - it's the curated index of every doc page. Pull the specific page you need from there.
repo() Usage
Always call repo(Entity) inline - never store in a variable. It's cheap, context-aware (frontend/backend), and per-request scoped.
await repo(Task).find(...)
Entity ID Field
Prefer @Fields.id() (UUID, no DB autoincrement coupling). Use @Fields.autoIncrement() only if a numeric DB-side sequence is required.
@Fields.id()
id!: string
Entity Declaration
import { Entity, Fields, Allow } from 'remult'
@Entity<Task>('tasks', {
allowApiCrud: Allow.authenticated,
allowApiDelete: 'admin',
})
export class Task {
@Fields.id() id!: string
@Fields.string() title = ''
@Fields.boolean() completed = false
}
Permissions: allowApi* vs. apiPrefilter
Two layers, applied in order:
allowApi*- entity-level gate. Acceptsboolean | Allow.* | Role | Role[] | (item, remult) => boolean. Decides whether a caller can hit this entity for read/insert/update/delete at all. Keys:allowApiCrud,allowApiRead,allowApiInsert,allowApiUpdate,allowApiDelete.apiPrefilter- row-level filter. Returns anEntityFilterthat's automatically AND-ed into every API query (find,count, updates, deletes). Decides which rows the caller can see/touch.
@Entity<Task>('tasks', {
allowApiRead: Allow.authenticated,
apiPrefilter: () =>
remult.isAllowed('admin')
? {} // admins see all
: { ownerId: remult.user!.id }, // everyone else: own rows only
})
Use apiPrefilter for row-level security - never recreate the WHERE clause on the client. The prefilter runs server-side regardless of what the client sends.
Lifecycle Hooks
Order on save: saving -> save -> saved. On delete: deleting -> delete -> deleted. Each hook receives (entity, event); event.isNew distinguishes insert from update.
@Entity<Post>('posts', {
saving(entity, event) {
if (event.isNew && remult.user) entity.userId = remult.user.id
},
async saved(entity, event) {
if (event.isNew) {
// server-only side effect
}
},
})
Server-only Code in Hooks / BackendMethods
For Node-only deps (sharp, fs, ...), wrap the server section in if (import.meta.env.SSR) { ... } and dynamically import() inside the block. Vite drops the entire branch from the client bundle.
@BackendMethod({ allowed: true })
static async log(msg: string) {
if (import.meta.env.SSR) {
const { appendFileSync } = await import('fs')
appendFileSync('./logs/log.txt', `${new Date().toISOString()} ${msg}\n`)
}
}
Same pattern in hooks:
@Entity<Post>('posts', {
saved: async (post) => {
if (import.meta.env.SSR) {
const { appendFileSync } = await import('fs')
appendFileSync('./logs/log.txt', `${new Date().toISOString()} saved ${post.id}\n`)
}
},
})
Do not use if (!import.meta.env.SSR) return as an early-return - it does NOT strip the Node-only deps from the client bundle. Always wrap the server-only section in if (import.meta.env.SSR) { ... }.
For more (abstract-the-call, bundler exclusion), see https://remult.dev/docs/using-server-only-packages.
Entity-first vs. BackendMethod
Remult's main lever: put logic in entity hooks and let clients call repo(X).insert/update/delete. Reach for BackendMethod only for genuinely cross-entity or client-invisible flows.
| Entity hook (preferred) | BackendMethod (when needed) |
|---|---|
| Default a field on insert | Multi-entity transactions |
| Per-row validation | Aggregations across many repos |
| Single-row side effects | Cross-entity bulk/clone ops |
| Image optimization on save | Reads from entities not exposed to clients |
Repository Methods - upsert
upsert matches by where, updates with set if found, inserts if not. Idempotent and works in single or batch form.
// single
await repo(Task).upsert({
where: { slug: 'hello' },
set: { title: 'Hello' },
})
// batch
await repo(Task).upsert([
{ where: { slug: 'a' }, set: { title: 'A' } },
{ where: { slug: 'b' }, set: { title: 'B' } },
])
Use it instead of hand-rolling find-then-insert-or-update.
ValueList Enums
Use over @Fields.enum / @Fields.literal it's more future-proof, supports extra properties (label, color, etc.) and behavior on each value, and is easier to maintain as the list grows.
import { ValueListFieldType, getValueList, ValueListInfo } from 'remult'
@ValueListFieldType()
export class TaskStatus {
static Open = new TaskStatus('open', 'Open', '#22c55e')
static Done = new TaskStatus('done', 'Done', '#94a3b8')
constructor(
public id: string,
public caption: string,
public color: string,
) {}
}
for (const s of getValueList(TaskStatus)) {
// s.id, s.caption, s.color
}
getValueList(EnumClass | fieldRef | fieldMetadata) returns the values (use to populate dropdowns). id is what's stored in the DB and sent over the API; caption is what you display.
Binding to <select> / query string
Cross any string boundary (HTML inputs, URLs, FormData, localStorage) via ValueListInfo.get(EnumClass):
toInput(instance)-> theidas a stringfromInput(idString)-> the instance
const info = ValueListInfo.get(TaskStatus)
// React <select>
<select
value={info.toInput(task.status)}
onChange={(e) => setTask({ ...task, status: info.fromInput(e.target.value) })}
>
{getValueList(TaskStatus).map((s) => (
<option key={s.id} value={info.toInput(s)} style={{ color: s.color }}>
{s.caption}
</option>
))}
</select>
// URL <-> instance
const params = new URLSearchParams(location.search)
const status = info.fromInput(params.get('status') ?? '')
params.set('status', String(info.toInput(TaskStatus.Open)))
In Svelte/Vue/Angular template syntax with two-way binds on objects, you bind the instance directly and skip toInput/fromInput - reach for them only when something forces strings.
Field Metadata in the UI
Don't hard-code field labels, types, or validators in templates - read them from repo(X).metadata.fields.<field> so a single source of truth drives the UI.
@Entity('tasks', { allowApiCrud: true })
export class Task {
@Fields.string({ label: 'Task title' })
title = ''
}
const f = repo(Task).metadata.fields.title
f.label // 'Task title'
f.key // 'title' (the field name)
f.valueType // String
f.options // raw FieldOptions (validate, includeInApi, ...)
<label htmlFor={f.key}>{f.label}</label>
<input id={f.key} placeholder={f.label} value={task.title} />
If a field doesn't set label, remult auto-generates one from the key (firstName -> First Name). Same auto-titleising applies to ValueList items.
Permission Checks in UI
Use entity metadata - never duplicate permission logic in components.
const canEdit = repo(Post).metadata.apiUpdateAllowed(post)
const canDelete = repo(Post).metadata.apiDeleteAllowed(post)
const canInsert = repo(Post).metadata.apiInsertAllowed
These re-evaluate with the current remult.user and (where relevant) the row, mirroring the server-side check exactly.
Relations - Typed Includes
@Fields.string()
authorId = ''
@Relations.toOne(() => User, { field: 'authorId' })
author?: User
Read with include:
await repo(Post).find({ include: { author: true } })
For one-to-many: @Relations.toMany(() => Comment, 'postId').
Reusable Filters - Filter.createCustom
Encapsulate complex/computed WHERE logic so it's reusable, type-safe, and runs server-side.
import { Entity, Fields, Filter, repo } from 'remult'
@Entity('orders')
export class Order {
@Fields.id() id!: string
@Fields.string() status = ''
@Fields.createdAt() createdAt = new Date()
static activeIn = Filter.createCustom<Order, { year: number }>(
async ({ year }) => ({
status: { $in: ['created', 'pending', 'confirmed'] },
createdAt: { $gte: new Date(year, 0, 1), $lt: new Date(year + 1, 0, 1) },
}),
)
}
await repo(Order).find({ where: Order.activeIn({ year: 2024 }) })
The arg generic types the call site; the filter body always runs on the server.
Computed Fields - sqlExpression
Fields backed by a SQL expression instead of a physical column. Filterable and sortable in one round-trip, no row loading.
@Entity('tasks')
export class Task {
@Fields.id() id!: string
@Fields.string() title = ''
@Fields.integer({ sqlExpression: () => 'length(title)' })
titleLength = 0
}
await repo(Task).find({ where: { titleLength: { $gt: 10 } } })
The function form receives (entity, args?, command?) so you can build dynamic expressions; pair with dbNamesOf(Entity) for safe identifiers.
SQL-Driven Relations / Derived Joins
Inline a related value via subquery using sqlExpression + dbNamesOf. Flat shape, single query, fully queryable from the API.
import { dbNamesOf, Entity, Fields, repo } from 'remult'
@Entity('orders')
export class Order {
@Fields.id() id!: string
@Fields.string() customerId = ''
@Fields.string<Order>({
sqlExpression: async () => {
const cust = await dbNamesOf(Customer)
const ord = await dbNamesOf(Order)
return `(select ${cust.city} from ${cust} where ${cust.id} = ${ord.customerId})`
},
})
customerCity = ''
}
await repo(Order).find({ where: { customerCity: 'London' } })
Use this when you want a derived column queryable by the API client without exposing the related entity or doing N+1.
Sharing Shape Across Entities
Prefer class extends class for shared fields + hooks. Concrete entities extend a base class.
abstract class Auditable {
@Fields.id() id!: string
@Fields.createdAt() createdAt = new Date()
@Fields.string() createdBy = ''
}
@Entity<Post>('posts', { allowApiCrud: Allow.authenticated })
export class Post extends Auditable {
@Fields.string() title = ''
@Fields.string() body = ''
}
@Entity<Comment>('comments', { allowApiCrud: Allow.authenticated })
export class Comment extends Auditable {
@Fields.string() postId = ''
@Fields.string() text = ''
}
For shared options (permissions, hooks), extract a typed helper returning EntityOptions<T> and spread it into each @Entity({...}).
Module Pattern
Bundle related entities + init logic into a module so apps register them in one line. See https://remult.dev/docs/modules.
Quick References
- Entity options: https://remult.dev/docs/ref_entity
- Field options: https://remult.dev/docs/ref_field
- Repository API: https://remult.dev/docs/ref_repository
- EntityFilter: https://remult.dev/docs/entityFilter
- Custom filters: https://remult.dev/docs/custom-filter
- Access control: https://remult.dev/docs/access-control
- BackendMethod: https://remult.dev/docs/ref_backendmethod
- Validators: https://remult.dev/docs/ref_validators
- Migrations: https://remult.dev/docs/migrations
- Admin UI: https://remult.dev/docs/admin-ui
- Full doc index: https://remult.dev/llms.txt
- Fluxly category
- Documentation
- Author-declared agents
- No explicit declaration found; this is not inferred or tested compatibility
- Static check
- 94 / 100 · heuristic scan, not runtime safety proof
- Author / version / license
- @remult · MIT
- Fluxly token estimate
- Lean
- Fluxly setup estimate
- Guided setup
- External API key
- No requirement detected
- Detected OS requirements
- macOS · Linux · Windows
- Runtime requirements
- Node.js
- Detected file/system behavior
-
- Read-only
- Write / modify
- Env read
- Detected network behavior
- External requests
- Install commands
- None (reference only)
Profile is derived at build time from SKILL.md and install vectors. Subject to drift from author intent.
Heads up: 未限定 allowed-tools,默认拥有全部工具权限。
The current SKILL.md does not define a fixed output example. For Node-only deps (sharp, fs, ...), wrap the server section in if (import.meta.env.SSR) { ... } and dynamically import() inside the block. Vite drops the entire branch from the client bundle. Same pattern in hooks: Do not use if (!import.meta.env.SSR) return…
Remult's main lever: put logic in entity hooks and let clients call repo(X).insert/update/delete. Reach for BackendMethod only for genuinely cross-entity or client-invisible flows. Entity hook (preferred) · BackendMethod (when needed)
upsert matches by where, updates with set if found, inserts if not. Idempotent and works in single or batch form. Use it instead of hand-rolling find-then-insert-or-update.
# Remult
[Remult](https://remult.dev) is a full-stack TypeScript framework: define an entity once, get a typed REST API, a typed client, validation, and permissions everywhere.
**Need more docs? Fetch <https://remult.dev/llms.txt>** - it's the curated index of every doc page. Pull the specific page you need from there.
## `repo()` Usage
Always call `repo(Entity)` inline - never store in a variable. It's cheap, context-aware (frontend/backend), and per-request scoped.
```ts
await repo(Task).find(...)
```
## Entity ID Field
Prefer `@Fields.id()` (UUID, no DB autoincrement coupling). Use `@Fields.autoIncrement()` only if a numeric DB-side sequence is required.
```ts
@Fields.id()
id!: string
```
## Entity Declaration
```ts
import { Entity, Fields, Allow } from 'remult'
@Entity<Task>('tasks', {
allowApiCrud: Allow.authenticated,
allowApiDelete: 'admin',
})
export class Task {
@Fields.id() id!: string
@Fields.string() title = ''
@Fields.boolean() completed = false
}
```
## Permissions: `allowApi*` vs. `apiPrefilter`
Two layers, applied in order:
1. **`allowApi*`** - entity-level gate. Accepts `boolean | Allow.* | Role | Role[] | (item, remult) => boolean`. Decides _whether_ a caller can hit this entity for read/insert/update/delete at all. Keys: `allowApiCrud`, `allowApiRead`, `allowApiInsert`, `allowApiUpdate`, `allowApiDelete`.
2. **`apiPrefilter`** - row-level filter. Returns an `EntityFilter` that's automatically AND-ed into every API query (`find`, `count`, updates, deletes). Decides _which rows_ the caller can see/touch.
```ts
@Entity<Task>('tasks', {
allowApiRead: Allow.authenticated,
apiPrefilter: () =>
remult.isAllowed('admin')
? {} // admins see all
… Author text anchors workflow facts; Fluxly only indexes current sections, terms, files, and commands.
sections -> repo() Usage → Entity ID Field → Entity Declaration → Permissions: allowApi vs. apiPrefilter → Lifecycle Hooks → Server-only Code in Hooks / BackendMethods
terms -> Need more docs? Fetch <https://remult.dev/llms.txt> · apiPrefilter · Do not use if (!import.meta.env.SSR) return as an early-return · instance · class extends class
files/cmd -> repo() · repo(Entity) · @Fields.id() · @Fields.autoIncrement() · allowApi · apiPrefilter · boolean | Allow. | Role | Role[] | (item, remult) => boolean · allowApiCrud
body sha256 -> e3b7a77b4734
Decide Fit First
Design Intent
How To Use It
Boundaries And Review