@koltakov/ffa-core 0.8.0 → 0.16.1

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,341 +123,421 @@ 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
- })
198
+ string() // chainable hints see below
199
+ number() // chainable hints — see below
200
+ boolean()
201
+ uuid()
202
+ datetime()
216
203
  ```
217
204
 
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
- }
234
- ```
235
-
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
280
-
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.
241
+ ## String Fake Hints
283
242
 
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://picsum.photos/seed/xxx/1280/720" (default 16:9)
255
+ string().image(300, 450) // "https://picsum.photos/seed/xxx/300/450" (portrait 2:3)
256
+ string().image(1920, 1080) // "https://picsum.photos/seed/xxx/1920/1080" (Full HD)
257
+ string().avatar() // avatar URL
297
258
 
298
259
  // Person
299
- string().firstName() // "Alice"
300
- string().lastName() // "Johnson"
301
- string().fullName() // "Alice Johnson"
302
- string().phone() // "+1-555-234-5678"
260
+ string().firstName() // "Alice"
261
+ string().lastName() // "Johnson"
262
+ string().fullName() // "Alice Johnson"
263
+ string().phone() // "+1-555-234-5678"
303
264
 
304
265
  // Location
305
- string().city() // "Berlin"
306
- string().country() // "Germany"
307
- string().address() // "12 Oak Street"
308
- string().zip() // "10115"
309
- string().locale() // "DE"
266
+ string().city() // "Berlin"
267
+ string().country() // "Germany"
268
+ string().address() // "12 Oak Street"
269
+ string().zip() // "10115"
270
+ string().locale() // "DE"
310
271
 
311
272
  // Business
312
- string().company() // "Acme Corp"
313
- string().jobTitle() // "Senior Engineer"
314
- string().department() // "Electronics"
315
- string().currency() // "EUR"
273
+ string().company() // "Acme Corp"
274
+ string().jobTitle() // "Senior Engineer"
275
+ string().department() // "Electronics"
276
+ string().currency() // "EUR"
316
277
 
317
278
  // 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..."
279
+ string().word() // "matrix"
280
+ string().slug() // "hello-world"
281
+ string().sentence() // "The quick brown fox."
282
+ string().paragraph() // "Lorem ipsum dolor..."
283
+ string().bio() // "Lorem ipsum dolor..."
323
284
 
324
285
  // Visual
325
- string().color() // "azure"
326
- string().hexColor() // "#A3F5C2"
286
+ string().color() // "azure"
287
+ string().hexColor() // "#A3F5C2"
327
288
 
328
289
  // Id
329
- string().uuid() // "a3f7c2b1-..."
290
+ string().uuid() // UUID v4
330
291
  ```
331
292
 
332
- ### `number()` hints
293
+ ## Number Fake Hints
333
294
 
334
295
  ```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
296
+ number().price() // 19.99
297
+ number().age() // 34
298
+ number().rating() // 4
299
+ number().percent() // 72
300
+ number().lat() // 51.5074
301
+ number().lng() // -0.1278
302
+ number().year() // 2021
342
303
  ```
343
304
 
344
- ### Escape hatch
305
+ ## Smart Field-Name Detection
306
+
307
+ When no hint is set, ffa infers the right value from the field name automatically:
308
+
309
+ | Field name pattern | Generated value |
310
+ |--------------------|----------------|
311
+ | `email`, `mail` | Email address |
312
+ | `name`, `firstName` | First name |
313
+ | `lastName`, `surname` | Last name |
314
+ | `phone`, `tel`, `mobile` | Phone number |
315
+ | `city`, `country`, `address` | Location values |
316
+ | `url`, `website`, `link` | URL |
317
+ | `avatar`, `photo` | Avatar URL |
318
+ | `image` | Image URL |
319
+ | `company` | Company name |
320
+ | `title`, `heading` | Short sentence |
321
+ | `description`, `bio`, `text` | Paragraph |
322
+ | `price`, `cost`, `amount` | Price |
323
+ | `color` | Color name |
324
+
325
+ ---
326
+
327
+ ## Field Rules
345
328
 
346
- For any case not covered by the methods above, use `.fake(hint)` directly with full TypeScript autocomplete:
329
+ All builders share these chainable rules:
330
+
331
+ | Method | Effect |
332
+ |--------|--------|
333
+ | `.required()` | Field required in POST/PUT |
334
+ | `.optional()` | Field can be omitted (default) |
335
+ | `.min(n)` | Min string length / min number value |
336
+ | `.max(n)` | Max string length / max number value |
337
+ | `.readonly()` | Excluded from create/update validation |
338
+ | `.default(val)` | Default value |
339
+ | `.fake(hint)` | Explicit faker hint |
340
+ | `.image(w, h)` | Image dimensions (only for `string().image()`) |
341
+
342
+ ---
343
+
344
+ ## Seed Data (v0.9.0)
345
+
346
+ Guarantee specific records always exist in your entity:
347
347
 
348
348
  ```ts
349
- string().fake('hexColor')
350
- number().fake('rating')
349
+ User: entity({
350
+ name: string().fullName().required(),
351
+ role: enumField(['admin', 'editor', 'viewer']).required(),
352
+ }, {
353
+ count: 20,
354
+ seed: [
355
+ { id: 'admin-1', name: 'Admin User', role: 'admin' },
356
+ { name: 'Guest User', role: 'viewer' }, // id auto-generated if omitted
357
+ ],
358
+ })
351
359
  ```
352
360
 
353
- ### Example config
361
+ Seed records appear first. The remaining `count - seed.length` records are generated.
362
+
363
+ ---
364
+
365
+ ## Custom Meta (v0.8.0)
366
+
367
+ Attach extra fields to the list response `meta`. Values can be static or computed from the current filtered dataset (before pagination):
354
368
 
355
369
  ```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 |
370
+ Product: entity({ price: number().price(), status: enumField(['active', 'sale']) }, {
371
+ count: 50,
372
+ meta: {
373
+ currency: 'USD', // static
374
+ apiVersion: 2, // static
375
+ total: (items) => items.length, // computed
376
+ avgPrice: (items) => +(items.reduce((s, i) => s + (i.price as number), 0) / items.length).toFixed(2),
377
+ byStatus: (items) => Object.fromEntries(
378
+ ['active', 'sale'].map(s => [s, items.filter(i => i.status === s).length])
379
+ ),
380
+ },
381
+ })
382
+ ```
383
+
384
+ Meta functions receive items **after** search/filter but **before** pagination — so aggregates always reflect the current query.
395
385
 
396
386
  ---
397
387
 
398
- ## Field Rules
388
+ ## Seed Data Export (v0.15.0)
389
+
390
+ Export the current in-memory DB while the server is running — useful for generating fixture files:
391
+
392
+ ```bash
393
+ ffa snapshot # → ffa-snapshot.json
394
+ ffa snapshot -o fixtures.json
395
+ ffa snapshot -p 4000 # specify port if not in config
396
+ ```
397
+
398
+ Or call directly:
399
+ ```
400
+ GET /__snapshot → { "User": [...], "Post": [...] }
401
+ ```
402
+
403
+ ---
404
+
405
+ ## Inspect Config (v0.16.0)
406
+
407
+ Print your config structure without starting the server:
408
+
409
+ ```bash
410
+ ffa inspect
411
+ ```
412
+
413
+ ```
414
+ FFA inspect port 3333
415
+
416
+ User 20 records +1 seed
417
+ * name string [fullName]
418
+ * email string [email]
419
+ * role enum (admin | editor | viewer)
420
+ avatar string [avatar]
421
+ address object { city, country, zip }
422
+ phones array of string
423
+
424
+ Post 50 records
425
+ * title string [sentence]
426
+ * body string [paragraph]
427
+ * status enum (draft | published | archived)
428
+ * price number [price]
429
+ authorId belongsTo → User
430
+ tagIds hasMany → Tag
431
+ meta: currency, total, avgPrice
432
+
433
+ delay 200–600ms · persist ffa-data.json · errorRate 5%
434
+ ```
435
+
436
+ ---
437
+
438
+ ## Config Validation (v0.11.0)
439
+
440
+ On startup, ffa checks your config and prints warnings:
399
441
 
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) |
442
+ ```
443
+ ⚠ Post.authorId (belongsTo) references 'Author' which is not defined
444
+ ⚠ Tag.type is enum but has no values
445
+ ⚠ User: seed has 5 records but count is 3 — seed will be truncated
446
+ ```
408
447
 
409
448
  ---
410
449
 
411
- ## Server Config
450
+ ## Error Simulation (v0.14.0)
451
+
452
+ Test your frontend error handling without mocking:
412
453
 
413
454
  ```ts
414
455
  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
456
+ errorRate: 0.1 // 10% of requests return 500
420
457
  }
421
458
  ```
422
459
 
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.
460
+ System routes (`/__reset`, `/__snapshot`, `/docs`) are never affected.
426
461
 
427
462
  ---
428
463
 
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 |
464
+ ## Persistence
436
465
 
437
466
  ```ts
438
- // Reset data from frontend code (e.g. in test setup)
439
- await fetch('http://localhost:3333/__reset', { method: 'POST' })
467
+ server: {
468
+ persist: true, // save to ./ffa-data.json
469
+ persist: './data/db.json', // custom path
470
+ }
440
471
  ```
441
472
 
473
+ Data is saved on every write (POST/PUT/PATCH/DELETE) and restored on restart.
474
+
475
+ ---
476
+
477
+ ## JSON Config (no TypeScript)
478
+
479
+ ```json
480
+ {
481
+ "server": { "port": 3333, "delay": 300 },
482
+ "entities": {
483
+ "Product": {
484
+ "count": 20,
485
+ "fields": {
486
+ "title": "string!",
487
+ "price": "number",
488
+ "status": ["draft", "published", "archived"],
489
+ "inStock": "boolean",
490
+ "createdAt": "datetime"
491
+ }
492
+ }
493
+ }
494
+ }
495
+ ```
496
+
497
+ Save as `ffa.config.json`. `"string!"` = required, `"string"` = optional. Arrays are shorthand for `enumField`.
498
+
442
499
  ---
443
500
 
444
501
  ## Terminal Output
445
502
 
446
- Every request is logged with method, URL, status code and response time:
503
+ ```
504
+ FFA dev server v0.16.0
505
+
506
+ Entity Count Route
507
+ ──────────────────────────────────────────────────────────
508
+ User 20 GET POST http://localhost:3333/users
509
+ Post 50 GET POST http://localhost:3333/posts
510
+ Tag 15 GET POST http://localhost:3333/tags
511
+
512
+ delay 200–600ms
513
+ errorRate 5% chance of 500
514
+
515
+ docs http://localhost:3333/docs
516
+ reset POST http://localhost:3333/__reset
447
517
 
518
+ GET /users 200 14ms
519
+ POST /posts 201 312ms
520
+ GET /posts/abc-123 404 8ms
521
+ PATCH /posts/def-456 200 267ms
448
522
  ```
449
- FFA dev server v0.6.0
450
523
 
451
- → Product 20 records http://localhost:3333/products
452
- → User 10 records http://localhost:3333/users
524
+ ---
453
525
 
454
- delay: 200–600ms
455
- → Swagger UI http://localhost:3333/docs
526
+ ## Testing
456
527
 
457
- GET /products 200 312ms
458
- POST /products 201 287ms
459
- GET /products/abc-123 404 201ms
460
- PATCH /products/def-456 200 344ms
528
+ ```bash
529
+ npm test # run all tests once
530
+ npm run test:watch # watch mode
461
531
  ```
462
532
 
533
+ Tests live in `tests/` and are excluded from the build. 144 tests covering:
534
+
535
+ - DSL builders and all fake hints (`string()`, `number()`, `object()`, `array()`, ...)
536
+ - Zod schema generation for all field types
537
+ - `entity()` helper
538
+ - `createMemoryDB` — CRUD, pagination, sorting, search, filter operators, seed, meta, relations, image dimensions
539
+ - `validateConfig` — all warning scenarios
540
+
463
541
  ---
464
542
 
465
543
  ## License