@koltakov/ffa-core 0.8.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,19 +1,24 @@
1
1
  # ffa-core
2
2
 
3
- **F**rontend **F**irst **A**PI — instant mock REST API for frontend development.
3
+ **Frontend First API** — instant mock REST API for frontend development.
4
4
 
5
- Stop waiting for the backend. Describe your data, run one command, get a working API.
5
+ Stop waiting for the backend. Describe your data in one config file, run one command, get a fully working REST API.
6
+
7
+ ```bash
8
+ npx @koltakov/ffa-core init
9
+ npx @koltakov/ffa-core dev
10
+ ```
6
11
 
7
12
  ---
8
13
 
9
14
  ## Why ffa?
10
15
 
11
- When building a frontend you need a real API to work against — but the backend isn't ready yet. `ffa` generates a fully functional REST API from a simple config file. No JSON files to maintain, no manual routes, no extra services.
12
-
13
16
  - **Zero boilerplate** — one config file, one command
14
- - **Auto CRUD** — all 6 routes per entity out of the box
17
+ - **Auto CRUD** — 6 routes per entity, out of the box
15
18
  - **Smart fake data** — field names drive generation (`email`, `price`, `avatar`, ...)
16
- - **Zod validation** — request bodies validated against your schema
19
+ - **Typed DSL** — `string().email().required()` with full TypeScript autocomplete
20
+ - **Zod validation** — POST/PUT/PATCH bodies validated against your schema
21
+ - **Relations** — `belongsTo` / `hasMany` with inline join via `?include=`
17
22
  - **Swagger UI** — auto-generated docs at `/docs`
18
23
  - **Delay simulation** — test loading states with artificial latency
19
24
  - **TypeScript or JSON** — pick your config format
@@ -23,101 +28,94 @@ When building a frontend you need a real API to work against — but the backend
23
28
  ## Quick Start
24
29
 
25
30
  ```bash
26
- # 1. Install
27
31
  npm install @koltakov/ffa-core
28
32
 
29
- # 2. Scaffold a config
30
- npx ffa init
31
-
32
- # 3. Start
33
- npx ffa dev
33
+ npx ffa init # scaffold ffa.config.ts
34
+ npx ffa dev # start server
34
35
  ```
35
36
 
36
- Or skip `init` and write `ffa.config.ts` manually (see below).
37
-
38
37
  ---
39
38
 
40
- ## Config: TypeScript
39
+ ## Config
41
40
 
42
41
  ```ts
43
- import { defineConfig, entity, string, number, boolean, enumField, belongsTo } from '@koltakov/ffa-core'
42
+ // ffa.config.ts
43
+ import {
44
+ defineConfig, entity,
45
+ string, number, boolean, enumField,
46
+ belongsTo, hasMany, object, array,
47
+ } from '@koltakov/ffa-core'
44
48
 
45
49
  export default defineConfig({
46
50
  server: {
47
51
  port: 3333,
48
- delay: [200, 600], // artificial latency (ms or [min, max])
52
+ delay: [200, 600], // artificial latency in ms (number or [min, max])
49
53
  persist: true, // save data to ffa-data.json between restarts
54
+ errorRate: 0.05, // 5% of requests return 500 (for error state testing)
50
55
  },
51
56
 
52
57
  entities: {
53
58
  User: entity({
54
- name: string().required(),
55
- email: string().required(),
56
- role: enumField(['admin', 'editor', 'viewer']).required(),
57
- avatar: string().optional(),
58
- }, { count: 20 }),
59
+ name: string().fullName().required(),
60
+ email: string().email().required(),
61
+ role: enumField(['admin', 'editor', 'viewer']).required(),
62
+ avatar: string().avatar().optional(),
63
+ address: object({
64
+ city: string().city(),
65
+ country: string().country(),
66
+ zip: string().zip(),
67
+ }),
68
+ phones: array(string().phone()),
69
+ }, {
70
+ count: 20,
71
+ // Guaranteed records — appear first, rest are generated up to count
72
+ seed: [
73
+ { name: 'Admin User', email: 'admin@example.com', role: 'admin' },
74
+ ],
75
+ }),
59
76
 
60
77
  Post: entity({
61
- title: string().required(),
62
- body: string().required(),
63
- status: enumField(['draft', 'published', 'archived']).required(),
64
- price: number().price().required(),
78
+ title: string().sentence().required(),
79
+ body: string().paragraph().required(),
80
+ status: enumField(['draft', 'published', 'archived']).required(),
81
+ price: number().price().required(),
82
+ rating: number().rating().optional(),
65
83
  authorId: belongsTo('User'),
84
+ tagIds: hasMany('Tag'),
66
85
  }, {
67
- count: 30,
86
+ count: 50,
68
87
  meta: {
69
- // Static values — always present in meta regardless of filters
70
- currency: 'USD',
71
- supportedStatuses: ['draft', 'published', 'archived'],
72
-
73
- // Functions — receive filtered items (before pagination), return computed value
74
- total: (items) => items.length,
75
- avgPrice: (items) => +(items.reduce((s, i) => s + (i.price as number), 0) / items.length).toFixed(2),
76
- byStatus: (items) => Object.fromEntries(
77
- ['draft', 'published', 'archived'].map(s => [s, items.filter(i => i.status === s).length])
78
- ),
88
+ currency: 'USD', // static value
89
+ total: (items) => items.length, // computed from filtered items
90
+ avgPrice: (items) => +(
91
+ items.reduce((s, i) => s + (i.price as number), 0) / items.length
92
+ ).toFixed(2),
79
93
  },
80
94
  }),
95
+
96
+ Tag: entity({
97
+ name: string().word().required(),
98
+ color: string().hexColor().optional(),
99
+ }, { count: 15 }),
81
100
  },
82
101
  })
83
102
  ```
84
103
 
85
- ## Config: JSON (no TypeScript needed)
86
-
87
- ```json
88
- {
89
- "server": { "port": 3333, "delay": 300 },
90
- "entities": {
91
- "Product": {
92
- "count": 20,
93
- "fields": {
94
- "title": "string!",
95
- "price": "number",
96
- "status": ["draft", "published", "archived"],
97
- "inStock": "boolean",
98
- "createdAt": "datetime"
99
- }
100
- }
101
- }
102
- }
103
- ```
104
-
105
- Save as `ffa.config.json`. Type suffix `!` means required (`"string!"` → required string). An array is shorthand for `enumField`.
106
-
107
104
  ---
108
105
 
109
106
  ## CLI
110
107
 
111
- ```bash
112
- ffa dev # start server
113
- ffa dev --port 4000 # override port
114
- ffa dev --watch # restart on ffa.config.ts changes
115
- ffa dev --open # open Swagger UI in browser on start
116
- ffa dev -p 4000 -w -o # all flags combined
117
-
118
- ffa init # create ffa.config.ts in current directory
119
- ffa reset # delete ffa-data.json (clears persisted data)
120
- ```
108
+ | Command | Description |
109
+ |---------|-------------|
110
+ | `ffa dev` | Start the dev server |
111
+ | `ffa dev -p 4000` | Override port |
112
+ | `ffa dev -w` | Watch mode restart on config changes |
113
+ | `ffa dev -o` | Open Swagger UI in browser on start |
114
+ | `ffa init` | Scaffold `ffa.config.ts` in current directory |
115
+ | `ffa inspect` | Show config structure without starting the server |
116
+ | `ffa snapshot` | Export current in-memory DB to JSON (server must be running) |
117
+ | `ffa snapshot -o dump.json` | Custom output path |
118
+ | `ffa reset` | Delete `ffa-data.json` (clears persisted data) |
121
119
 
122
120
  ---
123
121
 
@@ -125,339 +123,399 @@ ffa reset # delete ffa-data.json (clears persisted data)
125
123
 
126
124
  For every entity `Foo` ffa creates six routes:
127
125
 
128
- | Method | Path | Description | Status |
129
- |-----------|--------------|--------------------------------------|--------------|
130
- | `GET` | `/foos` | List all records | 200 |
131
- | `GET` | `/foos/:id` | Get single record | 200, 404 |
132
- | `POST` | `/foos` | Create a record | 201, 422 |
133
- | `PUT` | `/foos/:id` | Full replace (all fields required) | 200, 404, 422|
134
- | `PATCH` | `/foos/:id` | Partial update (all fields optional) | 200, 404, 422|
135
- | `DELETE` | `/foos/:id` | Delete a record | 200, 404 |
126
+ | Method | Path | Description | Status |
127
+ |--------|------|-------------|--------|
128
+ | `GET` | `/foos` | List all records | 200 |
129
+ | `GET` | `/foos/:id` | Get single record | 200, 404 |
130
+ | `POST` | `/foos` | Create a record | 201, 422 |
131
+ | `PUT` | `/foos/:id` | Full replace | 200, 404, 422 |
132
+ | `PATCH` | `/foos/:id` | Partial update | 200, 404, 422 |
133
+ | `DELETE` | `/foos/:id` | Delete a record | 200, 404 |
136
134
 
137
135
  > Names are auto-pluralized: `Product` → `/products`, `Category` → `/categories`.
138
136
 
137
+ ### System endpoints
138
+
139
+ | Method | Path | Description |
140
+ |--------|------|-------------|
141
+ | `POST` | `/__reset` | Regenerate all data |
142
+ | `GET` | `/__snapshot` | Dump current DB to JSON |
143
+ | `GET` | `/docs` | Swagger UI |
144
+ | `GET` | `/openapi.json` | OpenAPI 3.0 spec |
145
+
139
146
  ---
140
147
 
141
- ## Query Params on GET /list
148
+ ## Query Params (GET /list)
142
149
 
143
- | Param | Example | Description |
144
- |---------------|------------------------------|-------------------------------------|
145
- | `page` | `?page=2` | Page number (default: 1) |
146
- | `limit` | `?limit=10` | Records per page |
147
- | `sort` | `?sort=price` | Sort by field |
148
- | `order` | `?order=desc` | `asc` (default) or `desc` |
149
- | `search` | `?search=apple` | Full-text search across string fields|
150
- | `{field}` | `?status=published` | Exact field filter |
150
+ ### Pagination, sorting, search
151
151
 
152
- All params are combinable:
153
152
  ```
154
- GET /posts?search=react&status=published&sort=price&order=asc&page=1&limit=20
153
+ ?page=2&limit=10
154
+ ?sort=price&order=desc
155
+ ?search=apple full-text search across all string fields
156
+ ?status=published exact match filter
155
157
  ```
156
158
 
157
- **Response envelope:**
158
- ```json
159
- {
160
- "data": [ { "id": "...", "title": "...", ... } ],
161
- "meta": {
162
- "pagination": { "total": 47, "page": 1, "limit": 10, "pages": 5 }
163
- }
164
- }
159
+ ### Filter operators
160
+
161
+ ```
162
+ ?price_gte=100 price >= 100
163
+ ?price_lte=500 price <= 500
164
+ ?price_gt=0 price > 0
165
+ ?price_lt=1000 price < 1000
166
+ ?price_ne=0 price != 0
167
+ ?status_in=draft,published status in ['draft', 'published']
168
+ ?title_contains=hello title contains 'hello' (case-insensitive)
165
169
  ```
166
170
 
167
- With custom meta fields (see [Custom Meta](#custom-meta) below):
171
+ All params are combinable:
172
+ ```
173
+ GET /posts?search=react&status_in=draft,published&price_gte=10&sort=price&order=asc&page=1&limit=20
174
+ ```
175
+
176
+ ### Response envelope
177
+
168
178
  ```json
169
179
  {
170
- "data": [ ... ],
180
+ "data": [{ "id": "...", "title": "...", "price": 49.99 }],
171
181
  "meta": {
172
- "pagination": { "total": 23, "page": 1, "limit": 10, "pages": 3 },
182
+ "pagination": { "total": 47, "page": 1, "limit": 10, "pages": 5 },
173
183
  "currency": "USD",
174
- "avgPrice": 149.99,
175
- "byStatus": { "draft": 5, "published": 15, "archived": 3 }
184
+ "avgPrice": 124.50
176
185
  }
177
186
  }
178
187
  ```
179
188
 
180
- Also sets `X-Total-Count: 47` header.
189
+ Header: `X-Total-Count: 47`
181
190
 
182
191
  ---
183
192
 
184
- ## Custom Meta
193
+ ## Field Types
185
194
 
186
- Define `meta` on any entity to enrich the list response. Meta values can be **static** (always included as-is) or **functions** (computed on the current filtered dataset, before pagination).
195
+ ### Primitives
187
196
 
188
197
  ```ts
189
- import type { MetaFn } from '@koltakov/ffa-core'
190
-
191
- Product: entity({
192
- price: number().price().required(),
193
- status: enumField(['active', 'sale', 'soldout']).required(),
194
- rating: number().rating().required(),
195
- }, {
196
- count: 50,
197
- meta: {
198
- // Static — always the same regardless of filters
199
- currency: 'USD',
200
- apiVersion: 2,
201
- sortOptions: ['price', 'rating', 'title'],
202
-
203
- // Functions — receive items after search/filter, before pagination
204
- total: (items) => items.length,
205
- avgPrice: (items) => +(items.reduce((s, i) => s + (i.price as number), 0) / items.length).toFixed(2),
206
- avgRating: (items) => +(items.reduce((s, i) => s + (i.rating as number), 0) / items.length).toFixed(1),
207
- priceRange: (items) => ({
208
- min: Math.min(...items.map(i => i.price as number)),
209
- max: Math.max(...items.map(i => i.price as number)),
210
- }),
211
- byStatus: (items) => Object.fromEntries(
212
- ['active', 'sale', 'soldout'].map(s => [s, items.filter(i => i.status === s).length])
213
- ),
214
- },
215
- })
216
- ```
217
-
218
- Response when filtering with `?status=active&page=1&limit=10`:
219
- ```json
220
- {
221
- "data": [ ... ],
222
- "meta": {
223
- "pagination": { "total": 31, "page": 1, "limit": 10, "pages": 4 },
224
- "currency": "USD",
225
- "apiVersion": 2,
226
- "sortOptions": ["price", "rating", "title"],
227
- "total": 31,
228
- "avgPrice": 124.50,
229
- "avgRating": 4.1,
230
- "priceRange": { "min": 9.99, "max": 599.99 },
231
- "byStatus": { "active": 31, "sale": 0, "soldout": 0 }
232
- }
233
- }
198
+ string() // chainable hints see below
199
+ number() // chainable hints — see below
200
+ boolean()
201
+ uuid()
202
+ datetime()
234
203
  ```
235
204
 
236
- > Meta functions receive only the filtered items — so `avgPrice` and `byStatus` always reflect the current search/filter result, not the full dataset.
237
-
238
- ---
239
-
240
- ## Relations
205
+ ### Enum
241
206
 
242
207
  ```ts
243
- import { belongsTo, hasMany } from '@koltakov/ffa-core'
208
+ enumField(['draft', 'published', 'archived'])
209
+ ```
244
210
 
245
- entities: {
246
- User: entity({ name: string().required() }),
211
+ ### Relations
247
212
 
248
- Post: entity({
249
- title: string().required(),
250
- authorId: belongsTo('User'), // stores a random User id
251
- tagIds: hasMany('Tag'), // stores 1–3 random Tag ids
252
- }),
253
- }
213
+ ```ts
214
+ belongsTo('User') // stores a random User id
215
+ hasMany('Tag') // stores an array of 1–3 Tag ids
254
216
  ```
255
217
 
256
- **Inline join** on GET by id:
218
+ **Inline join** on `GET /:id`:
257
219
  ```
258
220
  GET /posts/abc?include=authorId,tagIds
259
221
  ```
260
- Returns the related objects inline instead of just ids.
261
222
 
262
- ---
223
+ ### Nested structures (v0.12.0)
263
224
 
264
- ## Field Types
225
+ ```ts
226
+ // Nested object — generates each field recursively
227
+ address: object({
228
+ city: string().city(),
229
+ country: string().country(),
230
+ zip: string().zip(),
231
+ })
265
232
 
266
- | Factory | Faker output | Notes |
267
- |-----------------------|----------------------------------|--------------------------------|
268
- | `string()` | Smart by field name or word | Chainable hints — see below |
269
- | `number()` | Integer 0–1000 | Chainable hints — see below |
270
- | `boolean()` | `true` / `false` | |
271
- | `uuid()` | UUID v4 | |
272
- | `datetime()` | ISO 8601 string | |
273
- | `enumField([...])` | Random value from array | Also validates on write |
274
- | `belongsTo('Entity')` | Random id from that entity | |
275
- | `hasMany('Entity')` | Array of 1–3 ids from that entity| |
233
+ // Array of typed items
234
+ tags: array(string().word())
235
+ phones: array(string().phone(), [1, 4]) // [min, max] items
236
+ scores: array(number().rating())
237
+ ```
276
238
 
277
239
  ---
278
240
 
279
- ## DSL Fake Hints
241
+ ## String Fake Hints
280
242
 
281
- When field name alone isn't enough, chain a hint method to control exactly what gets generated.
282
- Hints take priority over smart field-name detection.
283
-
284
- ### `string()` hints
243
+ Chain a method on `string()` to control what gets generated:
285
244
 
286
245
  ```ts
287
246
  // Internet
288
- string().email() // "user@example.com"
289
- string().url() // "https://example.com"
290
- string().domain() // "example.com"
291
- string().ip() // "192.168.1.1"
292
- string().username() // "john_doe"
247
+ string().email() // "user@example.com"
248
+ string().url() // "https://example.com"
249
+ string().domain() // "example.com"
250
+ string().ip() // "192.168.1.1"
251
+ string().username() // "john_doe"
293
252
 
294
253
  // Media
295
- string().image() // "https://loremflickr.com/640/480"
296
- string().avatar() // "https://avatars.githubusercontent.com/..."
254
+ string().image() // "https://loremflickr.com/640/480"
255
+ string().avatar() // avatar URL
297
256
 
298
257
  // Person
299
- string().firstName() // "Alice"
300
- string().lastName() // "Johnson"
301
- string().fullName() // "Alice Johnson"
302
- string().phone() // "+1-555-234-5678"
258
+ string().firstName() // "Alice"
259
+ string().lastName() // "Johnson"
260
+ string().fullName() // "Alice Johnson"
261
+ string().phone() // "+1-555-234-5678"
303
262
 
304
263
  // Location
305
- string().city() // "Berlin"
306
- string().country() // "Germany"
307
- string().address() // "12 Oak Street"
308
- string().zip() // "10115"
309
- string().locale() // "DE"
264
+ string().city() // "Berlin"
265
+ string().country() // "Germany"
266
+ string().address() // "12 Oak Street"
267
+ string().zip() // "10115"
268
+ string().locale() // "DE"
310
269
 
311
270
  // Business
312
- string().company() // "Acme Corp"
313
- string().jobTitle() // "Senior Engineer"
314
- string().department() // "Electronics"
315
- string().currency() // "EUR"
271
+ string().company() // "Acme Corp"
272
+ string().jobTitle() // "Senior Engineer"
273
+ string().department() // "Electronics"
274
+ string().currency() // "EUR"
316
275
 
317
276
  // Text
318
- string().word() // "matrix"
319
- string().slug() // "hello-world"
320
- string().sentence() // "The quick brown fox."
321
- string().paragraph() // "Lorem ipsum dolor..."
322
- string().bio() // "Lorem ipsum dolor..."
277
+ string().word() // "matrix"
278
+ string().slug() // "hello-world"
279
+ string().sentence() // "The quick brown fox."
280
+ string().paragraph() // "Lorem ipsum dolor..."
281
+ string().bio() // "Lorem ipsum dolor..."
323
282
 
324
283
  // Visual
325
- string().color() // "azure"
326
- string().hexColor() // "#A3F5C2"
284
+ string().color() // "azure"
285
+ string().hexColor() // "#A3F5C2"
327
286
 
328
287
  // Id
329
- string().uuid() // "a3f7c2b1-..."
288
+ string().uuid() // UUID v4
330
289
  ```
331
290
 
332
- ### `number()` hints
291
+ ## Number Fake Hints
333
292
 
334
293
  ```ts
335
- number().price() // 19.99
336
- number().age() // 34
337
- number().rating() // 4
338
- number().percent() // 72
339
- number().lat() // 51.5074
340
- number().lng() // -0.1278
341
- number().year() // 2021
294
+ number().price() // 19.99
295
+ number().age() // 34
296
+ number().rating() // 4
297
+ number().percent() // 72
298
+ number().lat() // 51.5074
299
+ number().lng() // -0.1278
300
+ number().year() // 2021
342
301
  ```
343
302
 
344
- ### Escape hatch
303
+ ## Smart Field-Name Detection
304
+
305
+ When no hint is set, ffa infers the right value from the field name automatically:
306
+
307
+ | Field name pattern | Generated value |
308
+ |--------------------|----------------|
309
+ | `email`, `mail` | Email address |
310
+ | `name`, `firstName` | First name |
311
+ | `lastName`, `surname` | Last name |
312
+ | `phone`, `tel`, `mobile` | Phone number |
313
+ | `city`, `country`, `address` | Location values |
314
+ | `url`, `website`, `link` | URL |
315
+ | `avatar`, `photo` | Avatar URL |
316
+ | `image` | Image URL |
317
+ | `company` | Company name |
318
+ | `title`, `heading` | Short sentence |
319
+ | `description`, `bio`, `text` | Paragraph |
320
+ | `price`, `cost`, `amount` | Price |
321
+ | `color` | Color name |
345
322
 
346
- For any case not covered by the methods above, use `.fake(hint)` directly with full TypeScript autocomplete:
323
+ ---
324
+
325
+ ## Field Rules
326
+
327
+ All builders share these chainable rules:
328
+
329
+ | Method | Effect |
330
+ |--------|--------|
331
+ | `.required()` | Field required in POST/PUT |
332
+ | `.optional()` | Field can be omitted (default) |
333
+ | `.min(n)` | Min string length / min number value |
334
+ | `.max(n)` | Max string length / max number value |
335
+ | `.readonly()` | Excluded from create/update validation |
336
+ | `.default(val)` | Default value |
337
+ | `.fake(hint)` | Explicit faker hint |
338
+
339
+ ---
340
+
341
+ ## Seed Data (v0.9.0)
342
+
343
+ Guarantee specific records always exist in your entity:
347
344
 
348
345
  ```ts
349
- string().fake('hexColor')
350
- number().fake('rating')
346
+ User: entity({
347
+ name: string().fullName().required(),
348
+ role: enumField(['admin', 'editor', 'viewer']).required(),
349
+ }, {
350
+ count: 20,
351
+ seed: [
352
+ { id: 'admin-1', name: 'Admin User', role: 'admin' },
353
+ { name: 'Guest User', role: 'viewer' }, // id auto-generated if omitted
354
+ ],
355
+ })
351
356
  ```
352
357
 
353
- ### Example config
358
+ Seed records appear first. The remaining `count - seed.length` records are generated.
359
+
360
+ ---
361
+
362
+ ## Custom Meta (v0.8.0)
363
+
364
+ Attach extra fields to the list response `meta`. Values can be static or computed from the current filtered dataset (before pagination):
354
365
 
355
366
  ```ts
356
- User: entity({
357
- name: string().fullName().required(),
358
- email: string().email().required(),
359
- avatar: string().avatar().optional(),
360
- bio: string().paragraph().optional(),
361
- role: enumField(['admin', 'editor', 'viewer']).required(),
362
- }),
363
-
364
- Product: entity({
365
- title: string().sentence().required(),
366
- image: string().image().required(),
367
- price: number().price().required(),
368
- rating: number().rating().required(),
369
- company: string().company().required(),
370
- color: string().color().optional(),
371
- lat: number().lat().required(),
372
- lng: number().lng().required(),
373
- }),
374
- ```
375
-
376
- ### Smart Faker by Field Name (auto, no hint needed)
377
-
378
- If the field name matches a known keyword, ffa generates the right value automatically — no hint required:
379
-
380
- | Field name pattern | Generated value |
381
- |-----------------------------|-------------------------|
382
- | `email`, `mail` | Email address |
383
- | `name`, `firstName` | First name |
384
- | `lastName`, `surname` | Last name |
385
- | `phone`, `tel`, `mobile` | Phone number |
386
- | `city`, `country`, `address`| Location values |
387
- | `url`, `website`, `link` | URL |
388
- | `avatar`, `photo` | Avatar URL |
389
- | `image` | Image URL |
390
- | `company` | Company name |
391
- | `title`, `heading` | Short sentence |
392
- | `description`, `bio`, `text`| Paragraph |
393
- | `price`, `cost`, `amount` | Price |
394
- | `color` | Color name |
367
+ Product: entity({ price: number().price(), status: enumField(['active', 'sale']) }, {
368
+ count: 50,
369
+ meta: {
370
+ currency: 'USD', // static
371
+ apiVersion: 2, // static
372
+ total: (items) => items.length, // computed
373
+ avgPrice: (items) => +(items.reduce((s, i) => s + (i.price as number), 0) / items.length).toFixed(2),
374
+ byStatus: (items) => Object.fromEntries(
375
+ ['active', 'sale'].map(s => [s, items.filter(i => i.status === s).length])
376
+ ),
377
+ },
378
+ })
379
+ ```
380
+
381
+ Meta functions receive items **after** search/filter but **before** pagination — so aggregates always reflect the current query.
395
382
 
396
383
  ---
397
384
 
398
- ## Field Rules
385
+ ## Seed Data Export (v0.15.0)
386
+
387
+ Export the current in-memory DB while the server is running — useful for generating fixture files:
399
388
 
400
- | Rule | Applies to | Effect |
401
- |---------------|--------------------|-------------------------------------------------|
402
- | `.required()` | all | Field required in POST/PUT requests |
403
- | `.optional()` | all | Field can be omitted |
404
- | `.min(n)` | `string`, `number` | Min length / min value |
405
- | `.max(n)` | `string`, `number` | Max length / max value |
406
- | `.readonly()` | all | Excluded from create/update validation |
407
- | `.fake(hint)` | `string`, `number` | Explicit faker hint (see DSL hints above) |
389
+ ```bash
390
+ ffa snapshot # → ffa-snapshot.json
391
+ ffa snapshot -o fixtures.json
392
+ ffa snapshot -p 4000 # specify port if not in config
393
+ ```
394
+
395
+ Or call directly:
396
+ ```
397
+ GET /__snapshot → { "User": [...], "Post": [...] }
398
+ ```
408
399
 
409
400
  ---
410
401
 
411
- ## Server Config
402
+ ## Inspect Config (v0.16.0)
403
+
404
+ Print your config structure without starting the server:
405
+
406
+ ```bash
407
+ ffa inspect
408
+ ```
409
+
410
+ ```
411
+ FFA inspect port 3333
412
+
413
+ User 20 records +1 seed
414
+ * name string [fullName]
415
+ * email string [email]
416
+ * role enum (admin | editor | viewer)
417
+ avatar string [avatar]
418
+ address object { city, country, zip }
419
+ phones array of string
420
+
421
+ Post 50 records
422
+ * title string [sentence]
423
+ * body string [paragraph]
424
+ * status enum (draft | published | archived)
425
+ * price number [price]
426
+ authorId belongsTo → User
427
+ tagIds hasMany → Tag
428
+ meta: currency, total, avgPrice
429
+
430
+ delay 200–600ms · persist ffa-data.json · errorRate 5%
431
+ ```
432
+
433
+ ---
434
+
435
+ ## Config Validation (v0.11.0)
436
+
437
+ On startup, ffa checks your config and prints warnings:
438
+
439
+ ```
440
+ ⚠ Post.authorId (belongsTo) references 'Author' which is not defined
441
+ ⚠ Tag.type is enum but has no values
442
+ ⚠ User: seed has 5 records but count is 3 — seed will be truncated
443
+ ```
444
+
445
+ ---
446
+
447
+ ## Error Simulation (v0.14.0)
448
+
449
+ Test your frontend error handling without mocking:
412
450
 
413
451
  ```ts
414
452
  server: {
415
- port: 3333, // default: 3000
416
- delay: 400, // fixed delay in ms
417
- delay: [200, 800], // random delay between min and max ms
418
- persist: true, // save to ./ffa-data.json
419
- persist: './data.json', // custom file path
453
+ errorRate: 0.1 // 10% of requests return 500
420
454
  }
421
455
  ```
422
456
 
423
- If `delay` is set, system routes (`/__reset`, `/docs`) are not delayed.
424
-
425
- If the configured port is busy, ffa automatically tries the next one.
457
+ System routes (`/__reset`, `/__snapshot`, `/docs`) are never affected.
426
458
 
427
459
  ---
428
460
 
429
- ## System Endpoints
430
-
431
- | Method | Path | Description |
432
- |--------|----------------|------------------------------------|
433
- | `POST` | `/__reset` | Regenerate all seed data in memory |
434
- | `GET` | `/docs` | Swagger UI |
435
- | `GET` | `/openapi.json`| Raw OpenAPI 3.0 spec |
461
+ ## Persistence
436
462
 
437
463
  ```ts
438
- // Reset data from frontend code (e.g. in test setup)
439
- await fetch('http://localhost:3333/__reset', { method: 'POST' })
464
+ server: {
465
+ persist: true, // save to ./ffa-data.json
466
+ persist: './data/db.json', // custom path
467
+ }
440
468
  ```
441
469
 
470
+ Data is saved on every write (POST/PUT/PATCH/DELETE) and restored on restart.
471
+
442
472
  ---
443
473
 
444
- ## Terminal Output
474
+ ## JSON Config (no TypeScript)
445
475
 
446
- Every request is logged with method, URL, status code and response time:
476
+ ```json
477
+ {
478
+ "server": { "port": 3333, "delay": 300 },
479
+ "entities": {
480
+ "Product": {
481
+ "count": 20,
482
+ "fields": {
483
+ "title": "string!",
484
+ "price": "number",
485
+ "status": ["draft", "published", "archived"],
486
+ "inStock": "boolean",
487
+ "createdAt": "datetime"
488
+ }
489
+ }
490
+ }
491
+ }
492
+ ```
493
+
494
+ Save as `ffa.config.json`. `"string!"` = required, `"string"` = optional. Arrays are shorthand for `enumField`.
495
+
496
+ ---
497
+
498
+ ## Terminal Output
447
499
 
448
500
  ```
449
- FFA dev server v0.6.0
501
+ FFA dev server v0.16.0
502
+
503
+ Entity Count Route
504
+ ──────────────────────────────────────────────────────────
505
+ User 20 GET POST http://localhost:3333/users
506
+ Post 50 GET POST http://localhost:3333/posts
507
+ Tag 15 GET POST http://localhost:3333/tags
450
508
 
451
- → Product 20 records http://localhost:3333/products
452
- User 10 records http://localhost:3333/users
509
+ delay 200–600ms
510
+ errorRate 5% chance of 500
453
511
 
454
- delay: 200–600ms
455
- Swagger UI http://localhost:3333/docs
512
+ docs http://localhost:3333/docs
513
+ reset POST http://localhost:3333/__reset
456
514
 
457
- GET /products 200 312ms
458
- POST /products 201 287ms
459
- GET /products/abc-123 404 201ms
460
- PATCH /products/def-456 200 344ms
515
+ GET /users 200 14ms
516
+ POST /posts 201 312ms
517
+ GET /posts/abc-123 404 8ms
518
+ PATCH /posts/def-456 200 267ms
461
519
  ```
462
520
 
463
521
  ---