Skip to main content

remult

Remult patterns - entities, fields, repo() usage, lifecycle hooks, permissions (allowApi + apiPrefilter), upsert, customFilter, sqlExpression, ValueList enums, relations, BackendMethod, and keeping server-only / Node-only code (fs, sharp, ...) out of the client bundle with `import.meta.env.SSR`. Use whenever writing or modifying Remult entities, configuring API permissions, working with `repo()`, writing a BackendMethod, guarding server-only code, or building CRUD flows in any Remult-powered app (React, Angular, Vue, SvelteKit, Next.js, SolidStart, Nuxt).

Zur Installation springen

Quellinformationen

Repository
remult/remult
Letzte Quellaktivität
30. Juni 2026 um 07:45
Erkannte Sprache von SKILL.md
Englisch
Sterne
3.210
Forks
158

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
remult
description
Remult patterns - entities, fields, repo() usage, lifecycle hooks, permissions (allowApi + apiPrefilter), upsert, customFilter, sqlExpression, ValueList enums, relations, BackendMethod, and keeping server-only / Node-only code (fs, sharp, ...) out of the client bundle with `import.meta.env.SSR`. Use whenever writing or modifying Remult entities, configuring API permissions, working with `repo()`, writing a BackendMethod, guarding server-only code, or building CRUD flows in any Remult-powered app (React, Angular, Vue, SvelteKit, Next.js, SolidStart, Nuxt).
# 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 : { 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. ```ts @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. ```ts @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`) return { status: 'ok' } } // Unreachable on the server (SSR is build-time true). When the method returns a // value, `return` inside the block and `throw` here to keep the return type clean // (no `| undefined`). throw new Error('server-only') } ``` Same pattern in hooks: ```ts @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`) } }, }) ``` **The shape is mandatory (this is THE thing people get wrong):** `import.meta.env.SSR` is a build-time constant Vite replaces + tree-shakes, so it can only drop a **wrapped block**. The server-only `import()` MUST be inside `if (import.meta.env.SSR) { ... }` (return inside; `throw` after for a returning method). An early `if (!import.meta.env.SSR) return` leaves the import statically reachable -> it stays in the client bundle -> `Module not found`. 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. ```ts // 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. ```ts 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)` -> the `id` as a string - `fromInput(idString)` -> the instance ```tsx 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. ```ts @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, ...) ``` ```tsx <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. ```ts 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 ```ts @Fields.string() authorId = '' @Relations.toOne(() => User, { field: 'authorId' }) author?: User ``` Read with `include`: ```ts 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. ```ts 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. ```ts @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. ```ts 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. ```ts abstract class Auditable { @Fields.id() id!: string
Auf GitHub ansehen
Diese SKILL.md ist sehr gross, daher zeigt SkillsMP hier nur den ersten Abschnitt. Auf GitHub ansehen