mockingpug
Concepts

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 from data are 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

DSLTypeNotes
uuidstringSeeded UUID v4, not crypto.randomUUID(). Deterministic from the seed, and dependency-free in a browser bundle.
numbernumberRandom integer in [0, 1_000_000] by default.
number.<min>-<max>numberRandom integer in [min, max], e.g. number.1-100. Negative bounds are allowed: number.-50-50.
number.float.<min>-<max>.<precision>numberRandom 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.incrementnumberAuto-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.FSstring"First Last": a real first + last name pair.
username.NNstring"AdjectiveNoun123": an adjective + noun + number nickname.
emailstringlocal.1234@<random-domain>.
email[<domain>]stringSame, with a fixed domain: email[gmail.com] → [email protected].
hash / hash.md5 / hash.sha256stringA 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.
loremstring6 to 24 random lorem-ipsum words.
lorem.<N>stringLorem text truncated/padded to exactly N characters.
date / date.past / date.futurestring (ISO)A timestamp within one year of a fixed reference date, before/after/around it depending on the suffix.
booleanboolean50/50 by default.
boolean.<p>booleantrue with probability p (0 to 1), e.g. boolean.0.9.
enum[a,b,c]literalUniformly 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:

mock/data/role.json
[
  { "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 their max. Entries with no chance share 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:

mock/api/category/schema.json
{
  "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):

mock/api/category/schema.json
{
  "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):

mock/tables/product.json
{
  "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 in data, 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 reading undefined.
  • 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.increment nested 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/else shape) is a change like any other field's generator type changing: doctor/ generate re-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.

On this page