Schema DSL
Every generator type an entity's data block can use
Each table is one JSON file at mock/tables/<table>.json (a table holds data;
its API surface is described separately — see Endpoints):
{
"amount": 1000,
"data": { "fieldName": "<DSL string>" },
"fixtures": [{ "fieldName": "an exact literal value" }],
"literal": [{ "fieldName": "a whole curated record" }]
}Legacy layout
mock/api/<entity>/schema.json (a table plus an implicit CRUD REST surface,
with an optional table-level bypass) still works; npx mpug migrate converts
it to the tables/ + routes/ layout. bypass now lives on the
endpoint, not the table.
amount: how many records to generate for this entity. Required, non-negative.data: one DSL string per field, or a{when,then,else}object for conditional generation. Required.fixtures(optional): exact, literal field values applied positionally as a patch onto an otherwise schema-generated record. See Fixtures below.literal(optional): exact, caller-provided whole records placed at the head of the set, bypassing generation entirely. See Literal records below. Fields present in every literal record but absent fromdataare inferred into the schema (typed by value).
A field's value can also be a plain nested object or a
correlated ref — see below.
Every DSL string below is parsed by core's parseFieldType(). The exact
same function backs the CLI, mockingpug/next, the Vite plugin, and the
manual-import path for mockingpug/react, so behavior never diverges
between transports.
Scalars
| DSL | Type | Notes |
|---|---|---|
uuid | string | Seeded UUID v4, not crypto.randomUUID(). Deterministic from the seed, and dependency-free in a browser bundle. |
number | number | Random integer in [0, 1_000_000] by default. |
number.<min>-<max> | number | Random integer in [min, max], e.g. number.1-100. Negative bounds are allowed: number.-50-50. |
number.float.<min>-<max>.<precision> | number | Random float in [min, max], rounded to precision decimal places, e.g. number.float.4-5.1 for a one-decimal rating like 4.8. Replaces the old trick of generating number.35-50 and dividing by 10 in the API layer. |
number.increment | number | Auto-incrementing counter, starting at 1, scoped per entity+field. Continues past the existing max when more records are appended to an entity later (not restarted at 1). |
username.FS | string | "First Last": a real first + last name pair. |
username.NN | string | "AdjectiveNoun123": an adjective + noun + number nickname. |
email | string | local.1234@<random-domain>. |
email[<domain>] | string | Same, with a fixed domain: email[gmail.com] → [email protected]. |
hash / hash.md5 / hash.sha256 | string | A hex string shaped like a digest (32/32/64 hex chars). Not a real cryptographic hash: there's nothing to verify it against, and a real one would require Node's node:crypto (unavailable in a browser bundle) or the async Web Crypto API. |
lorem | string | 6 to 24 random lorem-ipsum words. |
lorem.<N> | string | Lorem text truncated/padded to exactly N characters. |
date / date.past / date.future | string (ISO) | A timestamp within one year of a fixed reference date, before/after/around it depending on the suffix. |
boolean | boolean | 50/50 by default. |
boolean.<p> | boolean | true with probability p (0 to 1), e.g. boolean.0.9. |
enum[a,b,c] | literal | Uniformly random pick. The value type is preserved: enum[3600,86400] → number, enum[true,false] → boolean, enum[ADMIN,USER] → string. |
Arrays
array[<inner type>].<count> generates a fixed-length array, recursing into
the inner type for every element. The inner type can be any scalar above,
including another array[...] (nested arrays):
{ "tags": "array[lorem.8].5" }A crossRef inner type is supported for the field-level form:
{ "relatedProductIds": "array[data.product.id].3" }Each element is an independent pick (its own RNG stream per array
position, not shared across the array), so a count > 1 doesn't just
repeat the same product three times. Generation order applies exactly like
a plain field-level ref: product must be generated before order here,
enforced automatically.
A bare or multi-pick crossRef can't be an array item
array[data.category].3 (bare, no field) and
array[data.product.[id,name]].3 (multi-pick) both reject at parse time
with MP-SCHEMA-022 — neither produces one concrete stored value per
array slot the way a field-level pick does. Use
array[data.category.id].3 instead. A nested array of crossRef items
(array[array[data.x.id].2].3) isn't supported either, and still fails at
generation time with MP-GEN-001.
count is checked against mock.config.js's limits.maxArrayDepth by
mpug doctor; see Reference → mock.config.js.
Custom dictionaries
A bare word that isn't one of the built-in types (role, department, …)
is looked up in mock/data/<name>.json, a JSON array of weighted entries:
[
{ "value": "ADMIN", "max": 5 },
{ "value": "USER", "chance": 0.9 },
{ "value": "MODER", "chance": 0.2 }
]value: the literal value to emit (any JSON type, not just strings).max(optional): hard cap on how many times this value can appear across the current generation run's new records. Once the cap is hit, the entry drops out of the pool for the rest of that run.chance(optional): relative weight in[0, 1]among the entries that still have room under theirmax. Entries with nochanceshare the remaining probability mass evenly.
Fixtures
Every scalar and dictionary type above picks a value randomly (seed-stable,
but still randomly assigned per record). That's the wrong tool when
specific rows are load-bearing: a category tree where slug: "fb" is
hardcoded into icon paths, cross-links, and navigation elsewhere in an
app, for instance. Regenerating that as random data would scramble the
names/slugs every time, breaking anything that matched on them by string.
fixtures fixes that: an array of literal record patches, applied
positionally. fixtures[0] always becomes record index 0,
fixtures[1] index 1, and so on, on every mpug generate run, regardless
of seed:
{
"amount": 200,
"data": { "id": "uuid", "name": "lorem", "slug": "lorem", "icon": "lorem" },
"fixtures": [
{ "name": "Facebook", "slug": "fb" },
{ "name": "Steam", "slug": "steam-keys" }
]
}A fixture only needs to list the fields that must stay fixed. Every field
it doesn't mention (id, icon above) is still schema-generated
normally. Records beyond fixtures.length (here, indices 2 through 199)
are entirely schema-generated, same as without fixtures at all.
Fixture values always win: even if slug's generator type changes later,
or an unrelated field on the schema changes, fixtures[0].slug still
comes out as "fb" on the next generate. amount must be at least
fixtures.length (mpug doctor/parsing rejects it otherwise, MP-SCHEMA-014).
Literal records
fixtures patches individual fields onto an otherwise schema-generated
record; sometimes the whole record is curated data with no schema-generated
part at all — a curated catalog tree with exact slugs, or one "sample
product" with specific real-looking values throughout. literal covers
that: an array of whole records, placed verbatim at the head of the
set, the same positional semantics as fixtures (literal[0] is always
record index 0, and so on):
{
"amount": 200,
"data": { "id": "number.increment", "name": "lorem", "slug": "lorem" },
"literal": [
{ "id": 1, "name": "VKontakte", "slug": "vk" },
{ "id": 2, "name": "Steam", "slug": "steam-keys" }
]
}Unlike a fixture, a literal record is never passed through the generator at
all — it's inserted exactly as written, so it must carry every field the
schema declares (mpug doctor warns, but doesn't hard-fail, if a literal
record is missing a field or has the wrong type for it — see
Doctor). id/increment-style fields set by a
literal record are respected immediately: the generator seeds its increment
counters from literal values first, so a schema-generated record never
collides with a literal-assigned id, even on the very first generate.
Records beyond literal.length are entirely schema-generated, same as
without literal at all, and a literal-covered record is a perfectly
valid crossRef target for other entities, same as a generated one.
amount must be at least literal.length (mpug doctor/parsing rejects it
otherwise, MP-SCHEMA-019). If literal shrinks on a later edit, the
positions that are no longer covered are regenerated fresh rather than
keeping stale literal content around.
Slugify
slugify[<field>,<separator>] derives a field from another field on the
same record: it reads that field's already-generated value, transliterates
any Cyrillic characters and Latin diacritics to plain ASCII, lowercases it,
and collapses everything that isn't a-z0-9 into <separator>:
{
"amount": 1000,
"data": {
"id": "uuid",
"title": "lorem.32",
"slug": "slugify[title,-]"
}
}"Привет, Мир!" → "privet-mir". An empty separator (slugify[title,])
concatenates words instead of separating them.
The source field (title above) must be declared earlier in data:
generation follows declaration order, so a slugify field can only read a
value that was already produced. A missing/unknown source field fails at
parse time (MP-SCHEMA-016); a source field declared after the slugify
field, or a self-reference, also fails at parse time (MP-SCHEMA-017).
Unlike a fixture, a slugify field is still generated fresh
from its source on every reconciliation that touches it (field added, or the
source field's own type changes) — it derives a value, it doesn't fix one.
Nested objects
A field's value can be a plain object of sub-fields — a value object, not a
relation. It's generated recursively, and a leaf is addressed by a dotted
path in an endpoint's select/filterable (options.is_top):
{
"amount": 100,
"data": {
"id": "number.increment",
"options": {
"is_top": "boolean.0.15",
"is_new": "boolean.0.05"
}
}
}This removes the need for a separate one-to-one table per value object. It's
distinguished from a conditional by the absence of a
when key. (Sub-fields are leaf generators — a crossRef/slugify/conditional
inside a nested object isn't resolved.)
Correlated ref
A bare multi-pick (data.product.[id,slug]) writes the target's fields under
their source names, which collide with the record's own fields. A ref
maps them to explicit output names, all from one guaranteed-same target record:
"product": {
"kind": "ref",
"entity": "product",
"fields": { "product_id": "id", "product_slug": "slug" }
}Here product_id / product_slug come from the same picked product. See
Relations & Generation for the plain
and multi-pick forms.
Unique references
A field-level relation with a trailing !unique — data.user.id!unique —
maps record i to target i (a stable 1:1 pairing) instead of a random pick,
so no two records share the same referenced row.
Conditional generation
Every field above is a DSL string. A field can also be a JSON
object of the shape { "when": {...}, "then": ..., "else": ... },
which picks between two possible generators based on another field's
already-generated value on the same record:
{
"amount": 1000,
"data": {
"status": "enum[scheduled,published]",
"publishedAt": { "when": { "status": "scheduled" }, "then": null, "else": "date.past" }
}
}Records with status: "scheduled" get publishedAt: null; every other
status gets a real past date. "when" compares against the already-
generated value of one or more sibling fields — all listed keys must match
(AND) for "then" to fire:
{ "when": { "status": "a", "priority": "high" }, "then": "...", "else": "..." }then/else can be:
- a DSL string, parsed exactly like any other field value (
"date.past","data.user.id","lorem.32", ...); - a JSON literal —
null, a boolean, or a number — stored verbatim, no generator involved; - another
{when,then,else}object, nested, for more than two outcomes:
{
"when": { "status": "archived" },
"then": "lorem.16",
"else": { "when": { "status": "scheduled" }, "then": true, "else": null }
}Rules, mirroring slugify's:
- Every field referenced in
"when"(including inside a nested branch) must be declared earlier indata, so it's already generated by the time the conditional runs. An unknown field fails at parse time (MP-SCHEMA-026); a field declared at the same position or later (including a self-reference) also fails at parse time (MP-SCHEMA-027) — the same "declaration order = generation order" rule as everywhere else, checked explicitly instead of silently readingundefined. - A branch can resolve to a field-level
crossRef("data.user.id") or even a nested conditional, but not a bare relation or a multi-pick — neither produces one concrete value for a single branch, and both reject at parse time (MP-SCHEMA-025). - A
number.incrementnested inside a branch still continues correctly across reconciliation passes (increment counters look past the conditional wrapper when seeding from existing records). - Editing the conditional itself (the
when/then/elseshape) is a change like any other field's generator type changing:doctor/generatere-evaluate it on every existing record via the schema's fingerprint.
Cross-entity relations
data.<entity>, data.<entity>.<field>, and data.<entity>.[field,...]
(a correlated multi-field pick — several fields from one guaranteed-same
related record) are the three relation forms. They behave very
differently, and have their own dedicated page:
Relations & Generation Order.
Typo detection
An unrecognized type (emial[gmail.com], bool, usernme.FS) fails with a
SchemaError (MP-SCHEMA-001) that includes a Levenshtein-distance "did
you mean" suggestion against both the built-in type list and your project's
own custom dictionary names. The comparison is done on the type's base word
(the part before any [...]/. suffix), so a bracket parameter never
throws off the match:
unknown generator type "emial[gmail.com]"
did you mean "email"?Run npx mpug doctor any time to surface these before they ever
reach generation.