@koltakov/ffa-core 0.6.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/dist/cli.js CHANGED
@@ -63,77 +63,170 @@ async function loadConfig() {
63
63
  // src/memory-db.ts
64
64
  import { faker } from "@faker-js/faker";
65
65
  import { readFileSync as readFileSync2, writeFileSync, existsSync as existsSync2 } from "fs";
66
- var FIELD_NAME_MAP = {
66
+ var OP_SUFFIXES = ["_gte", "_lte", "_gt", "_lt", "_in", "_contains", "_ne"];
67
+ function parseFilter(filter) {
68
+ return Object.entries(filter).map(([key, value]) => {
69
+ for (const op of OP_SUFFIXES) {
70
+ if (key.endsWith(op)) {
71
+ return { field: key.slice(0, -op.length), op, value };
72
+ }
73
+ }
74
+ return { field: key, op: null, value };
75
+ });
76
+ }
77
+ function applyFilter(item, field, op, value) {
78
+ const raw = item[field];
79
+ if (op === null) return String(raw) === value;
80
+ const num = Number(value);
81
+ switch (op) {
82
+ case "_gte":
83
+ return Number(raw) >= num;
84
+ case "_lte":
85
+ return Number(raw) <= num;
86
+ case "_gt":
87
+ return Number(raw) > num;
88
+ case "_lt":
89
+ return Number(raw) < num;
90
+ case "_ne":
91
+ return String(raw) !== value;
92
+ case "_in":
93
+ return value.split(",").map((v) => v.trim()).includes(String(raw));
94
+ case "_contains":
95
+ return String(raw).toLowerCase().includes(value.toLowerCase());
96
+ default:
97
+ return true;
98
+ }
99
+ }
100
+ var HINT_MAP = {
101
+ // internet
67
102
  email: () => faker.internet.email(),
68
- mail: () => faker.internet.email(),
69
- name: () => faker.person.firstName(),
70
- firstname: () => faker.person.firstName(),
71
- first_name: () => faker.person.firstName(),
72
- lastname: () => faker.person.lastName(),
73
- last_name: () => faker.person.lastName(),
74
- surname: () => faker.person.lastName(),
75
- fullname: () => faker.person.fullName(),
76
- username: () => faker.person.fullName(),
103
+ url: () => faker.internet.url(),
104
+ domain: () => faker.internet.domainName(),
105
+ ip: () => faker.internet.ip(),
106
+ username: () => faker.internet.username(),
107
+ // media
108
+ image: () => faker.image.url(),
109
+ avatar: () => faker.image.avatar(),
110
+ // person
111
+ firstName: () => faker.person.firstName(),
112
+ lastName: () => faker.person.lastName(),
113
+ fullName: () => faker.person.fullName(),
114
+ // contact
77
115
  phone: () => faker.phone.number(),
78
- tel: () => faker.phone.number(),
79
- mobile: () => faker.phone.number(),
116
+ // location
80
117
  city: () => faker.location.city(),
81
118
  country: () => faker.location.country(),
82
119
  address: () => faker.location.streetAddress(),
83
120
  zip: () => faker.location.zipCode(),
84
- postal: () => faker.location.zipCode(),
85
- url: () => faker.internet.url(),
86
- website: () => faker.internet.url(),
87
- link: () => faker.internet.url(),
88
- avatar: () => faker.image.avatar(),
89
- photo: () => faker.image.avatar(),
90
- image: () => faker.image.avatar(),
121
+ locale: () => faker.location.countryCode("alpha-2"),
122
+ // business
91
123
  company: () => faker.company.name(),
92
- title: () => faker.lorem.sentence(3),
93
- heading: () => faker.lorem.sentence(3),
94
- description: () => faker.lorem.paragraph(),
124
+ jobTitle: () => faker.person.jobTitle(),
125
+ department: () => faker.commerce.department(),
126
+ currency: () => faker.finance.currencyCode(),
127
+ // text
128
+ word: () => faker.lorem.word(),
129
+ slug: () => faker.helpers.slugify(faker.lorem.words(2)),
130
+ sentence: () => faker.lorem.sentence(),
131
+ paragraph: () => faker.lorem.paragraph(),
95
132
  bio: () => faker.lorem.paragraph(),
96
- text: () => faker.lorem.paragraph(),
133
+ // visual
97
134
  color: () => faker.color.human(),
98
- price: () => faker.commerce.price(),
99
- cost: () => faker.commerce.price(),
100
- amount: () => faker.commerce.price()
135
+ hexColor: () => faker.color.rgb({ format: "hex" }),
136
+ // id
137
+ uuid: () => faker.string.uuid(),
138
+ // number hints
139
+ price: () => parseFloat(faker.commerce.price()),
140
+ age: () => faker.number.int({ min: 18, max: 80 }),
141
+ rating: () => faker.number.int({ min: 1, max: 5 }),
142
+ percent: () => faker.number.int({ min: 0, max: 100 }),
143
+ lat: () => parseFloat(faker.location.latitude().toString()),
144
+ lng: () => parseFloat(faker.location.longitude().toString()),
145
+ year: () => faker.number.int({ min: 1990, max: (/* @__PURE__ */ new Date()).getFullYear() })
146
+ };
147
+ var FIELD_NAME_MAP = {
148
+ email: HINT_MAP.email,
149
+ mail: HINT_MAP.email,
150
+ name: HINT_MAP.firstName,
151
+ firstname: HINT_MAP.firstName,
152
+ first_name: HINT_MAP.firstName,
153
+ lastname: HINT_MAP.lastName,
154
+ last_name: HINT_MAP.lastName,
155
+ surname: HINT_MAP.lastName,
156
+ fullname: HINT_MAP.fullName,
157
+ username: HINT_MAP.username,
158
+ phone: HINT_MAP.phone,
159
+ tel: HINT_MAP.phone,
160
+ mobile: HINT_MAP.phone,
161
+ city: HINT_MAP.city,
162
+ country: HINT_MAP.country,
163
+ address: HINT_MAP.address,
164
+ zip: HINT_MAP.zip,
165
+ postal: HINT_MAP.zip,
166
+ url: HINT_MAP.url,
167
+ website: HINT_MAP.url,
168
+ link: HINT_MAP.url,
169
+ avatar: HINT_MAP.avatar,
170
+ photo: HINT_MAP.avatar,
171
+ image: HINT_MAP.image,
172
+ company: HINT_MAP.company,
173
+ title: HINT_MAP.sentence,
174
+ heading: HINT_MAP.sentence,
175
+ description: HINT_MAP.paragraph,
176
+ bio: HINT_MAP.bio,
177
+ text: HINT_MAP.paragraph,
178
+ color: HINT_MAP.color,
179
+ price: HINT_MAP.price,
180
+ cost: HINT_MAP.price,
181
+ amount: HINT_MAP.price
101
182
  };
102
183
  function generateFakeValue(def, fieldName, db) {
103
- if (def.type === "enum") {
104
- if (def.rules.enumValues?.length) return faker.helpers.arrayElement(def.rules.enumValues);
105
- return faker.lorem.word();
106
- }
184
+ if (def.rules.fakeHint) return HINT_MAP[def.rules.fakeHint]();
107
185
  if (def.type === "belongsTo") {
108
186
  const target = def.rules.entity;
109
187
  if (target && db[target]?.length) {
110
- const items = db[target];
111
- return items[Math.floor(Math.random() * items.length)].id;
188
+ return db[target][Math.floor(Math.random() * db[target].length)].id;
112
189
  }
113
190
  return faker.string.uuid();
114
191
  }
115
192
  if (def.type === "hasMany") {
116
193
  const target = def.rules.entity;
117
194
  if (target && db[target]?.length) {
118
- const items = db[target];
119
- const count = faker.number.int({ min: 1, max: Math.min(3, items.length) });
120
- return [...items].sort(() => Math.random() - 0.5).slice(0, count).map((i) => i.id);
195
+ const count = faker.number.int({ min: 1, max: Math.min(3, db[target].length) });
196
+ return [...db[target]].sort(() => Math.random() - 0.5).slice(0, count).map((i) => i.id);
121
197
  }
122
198
  return [];
123
199
  }
200
+ if (def.type === "enum") {
201
+ if (def.rules.enumValues?.length) return faker.helpers.arrayElement(def.rules.enumValues);
202
+ return faker.lorem.word();
203
+ }
204
+ if (def.type === "object") {
205
+ if (!def.rules.objectFields) return {};
206
+ return Object.fromEntries(
207
+ Object.entries(def.rules.objectFields).map(([k, fieldDef]) => [
208
+ k,
209
+ generateFakeValue(fieldDef, k, db)
210
+ ])
211
+ );
212
+ }
213
+ if (def.type === "array") {
214
+ if (!def.rules.arrayItem) return [];
215
+ const [min, max] = def.rules.arrayCount ?? [1, 3];
216
+ const count = faker.number.int({ min, max });
217
+ return Array.from({ length: count }).map(() => generateFakeValue(def.rules.arrayItem, "item", db));
218
+ }
124
219
  const normalized = fieldName.toLowerCase().replace(/[-\s]/g, "_");
125
- const mapFn = FIELD_NAME_MAP[normalized];
126
- if (mapFn) return mapFn();
220
+ if (FIELD_NAME_MAP[normalized]) return FIELD_NAME_MAP[normalized]();
127
221
  for (const key of Object.keys(FIELD_NAME_MAP)) {
128
222
  if (normalized.includes(key)) return FIELD_NAME_MAP[key]();
129
223
  }
130
- const { type, rules } = def;
131
- switch (type) {
224
+ switch (def.type) {
132
225
  case "string":
133
226
  return faker.lorem.word();
134
227
  case "number": {
135
- const min = rules.min ?? 0;
136
- const max = rules.max ?? 1e3;
228
+ const min = def.rules.min ?? 0;
229
+ const max = def.rules.max ?? 1e3;
137
230
  return faker.number.int({ min, max });
138
231
  }
139
232
  case "boolean":
@@ -160,41 +253,35 @@ function createMemoryDB(entities, persistPath) {
160
253
  try {
161
254
  const raw = readFileSync2(persistPath, "utf-8");
162
255
  const saved = JSON.parse(raw);
163
- for (const name in entities) {
164
- db[name] = saved[name] ?? [];
165
- }
256
+ for (const name in entities) db[name] = saved[name] ?? [];
166
257
  } catch {
167
258
  }
168
259
  }
169
- function seedAll() {
260
+ function sortedEntityNames() {
170
261
  const names = Object.keys(entities);
171
- const sorted = [
262
+ return [
172
263
  ...names.filter((n) => !Object.values(entities[n].fields).some((f) => f.type === "belongsTo" || f.type === "hasMany")),
173
264
  ...names.filter((n) => Object.values(entities[n].fields).some((f) => f.type === "belongsTo" || f.type === "hasMany"))
174
265
  ];
175
- for (const name of sorted) {
176
- const { fields, count = 10 } = entities[name];
177
- db[name] = Array.from({ length: count }).map(() => generateRecord(fields, db));
178
- }
179
266
  }
180
- const needsSeed = Object.keys(entities).filter((n) => !db[n]);
181
- if (needsSeed.length > 0) {
182
- const names = Object.keys(entities);
183
- const sorted = [
184
- ...names.filter((n) => !Object.values(entities[n].fields).some((f) => f.type === "belongsTo" || f.type === "hasMany")),
185
- ...names.filter((n) => Object.values(entities[n].fields).some((f) => f.type === "belongsTo" || f.type === "hasMany"))
186
- ];
187
- for (const name of sorted) {
188
- if (!db[name]) {
189
- const { fields, count = 10 } = entities[name];
190
- db[name] = Array.from({ length: count }).map(() => generateRecord(fields, db));
191
- }
192
- }
267
+ function seedEntity(name) {
268
+ const { fields, count = 10, seed } = entities[name];
269
+ const seedItems = (seed ?? []).map((record) => ({
270
+ id: faker.string.uuid(),
271
+ ...record
272
+ }));
273
+ const generateCount = Math.max(0, count - seedItems.length);
274
+ const generated = Array.from({ length: generateCount }).map(() => generateRecord(fields, db));
275
+ db[name] = [...seedItems, ...generated];
276
+ }
277
+ function seedAll() {
278
+ for (const name of sortedEntityNames()) seedEntity(name);
279
+ }
280
+ for (const name of sortedEntityNames()) {
281
+ if (!db[name]) seedEntity(name);
193
282
  }
194
283
  function persist() {
195
- if (persistPath) {
196
- writeFileSync(persistPath, JSON.stringify(db, null, 2), "utf-8");
197
- }
284
+ if (persistPath) writeFileSync(persistPath, JSON.stringify(db, null, 2), "utf-8");
198
285
  }
199
286
  return {
200
287
  list(entity, options = {}) {
@@ -206,29 +293,42 @@ function createMemoryDB(entities, persistPath) {
206
293
  );
207
294
  }
208
295
  if (options.filter) {
209
- for (const [key, value] of Object.entries(options.filter)) {
210
- items = items.filter((item) => String(item[key]) === value);
211
- }
296
+ const conditions = parseFilter(options.filter);
297
+ items = items.filter(
298
+ (item) => conditions.every(({ field, op, value }) => applyFilter(item, field, op, value))
299
+ );
212
300
  }
213
301
  if (options.sort) {
214
302
  const sortKey = options.sort;
215
303
  const order = options.order ?? "asc";
216
304
  items.sort((a, b) => {
217
- const aVal = a[sortKey];
218
- const bVal = b[sortKey];
305
+ const aVal = a[sortKey], bVal = b[sortKey];
219
306
  if (aVal == null) return 1;
220
307
  if (bVal == null) return -1;
221
308
  const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0;
222
309
  return order === "desc" ? -cmp : cmp;
223
310
  });
224
311
  }
312
+ const entityMeta = entities[entity]?.meta;
313
+ const customMeta = {};
314
+ if (entityMeta) {
315
+ for (const [key, value] of Object.entries(entityMeta)) {
316
+ customMeta[key] = typeof value === "function" ? value(items) : value;
317
+ }
318
+ }
225
319
  const total = items.length;
226
320
  const page = options.page ?? 1;
227
321
  const limit = options.limit ?? total;
228
322
  const pages = limit > 0 ? Math.ceil(total / limit) : 1;
229
323
  const offset = (page - 1) * limit;
230
324
  const data = options.limit ? items.slice(offset, offset + limit) : items;
231
- return { data, meta: { total, page, limit, pages } };
325
+ return {
326
+ data,
327
+ meta: {
328
+ pagination: { total, page, limit, pages },
329
+ ...customMeta
330
+ }
331
+ };
232
332
  },
233
333
  get(entity, id) {
234
334
  return db[entity]?.find((item) => item.id === id);
@@ -260,6 +360,9 @@ function createMemoryDB(entities, persistPath) {
260
360
  },
261
361
  count(entity) {
262
362
  return db[entity]?.length ?? 0;
363
+ },
364
+ snapshot() {
365
+ return Object.fromEntries(Object.entries(db).map(([k, v]) => [k, v.map((item) => ({ ...item }))]));
263
366
  }
264
367
  };
265
368
  }
@@ -308,6 +411,22 @@ function fieldToZod(def) {
308
411
  schema = z.array(z.string());
309
412
  break;
310
413
  }
414
+ case "object": {
415
+ if (!def.rules.objectFields) {
416
+ schema = z.object({});
417
+ break;
418
+ }
419
+ const shape = {};
420
+ for (const [k, fieldDef] of Object.entries(def.rules.objectFields)) {
421
+ shape[k] = fieldToZod(fieldDef);
422
+ }
423
+ schema = z.object(shape);
424
+ break;
425
+ }
426
+ case "array": {
427
+ schema = def.rules.arrayItem ? z.array(fieldToZod(def.rules.arrayItem)) : z.array(z.unknown());
428
+ break;
429
+ }
311
430
  default: {
312
431
  schema = z.unknown();
313
432
  }
@@ -504,6 +623,39 @@ function generateOpenApiSpec(config) {
504
623
  };
505
624
  }
506
625
 
626
+ // src/validate-config.ts
627
+ var c = {
628
+ yellow: "\x1B[33m",
629
+ reset: "\x1B[0m",
630
+ bold: "\x1B[1m"
631
+ };
632
+ function validateConfig(entities) {
633
+ const names = new Set(Object.keys(entities));
634
+ const warnings = [];
635
+ for (const [entityName, def] of Object.entries(entities)) {
636
+ for (const [fieldName, field] of Object.entries(def.fields)) {
637
+ if ((field.type === "belongsTo" || field.type === "hasMany") && field.rules.entity) {
638
+ if (!names.has(field.rules.entity)) {
639
+ warnings.push(
640
+ `${entityName}.${fieldName} (${field.type}) references '${field.rules.entity}' which is not defined`
641
+ );
642
+ }
643
+ }
644
+ if (field.type === "enum" && (!field.rules.enumValues || field.rules.enumValues.length === 0)) {
645
+ warnings.push(`${entityName}.${fieldName} is enum but has no values`);
646
+ }
647
+ }
648
+ if ((def.count ?? 10) < (def.seed?.length ?? 0)) {
649
+ warnings.push(
650
+ `${entityName}: seed has ${def.seed.length} records but count is ${def.count} \u2014 seed will be truncated`
651
+ );
652
+ }
653
+ }
654
+ for (const w of warnings) {
655
+ console.warn(` ${c.yellow}${c.bold}\u26A0${c.reset} ${w}`);
656
+ }
657
+ }
658
+
507
659
  // src/server.ts
508
660
  function pluralize2(word) {
509
661
  const lower = word.toLowerCase();
@@ -530,7 +682,7 @@ async function findPort(start) {
530
682
  }
531
683
  return port;
532
684
  }
533
- var c = {
685
+ var c2 = {
534
686
  reset: "\x1B[0m",
535
687
  bold: "\x1B[1m",
536
688
  dim: "\x1B[2m",
@@ -545,29 +697,30 @@ function colorMethod(method) {
545
697
  const m = method.toUpperCase().padEnd(6);
546
698
  switch (method.toUpperCase()) {
547
699
  case "GET":
548
- return `${c.cyan}${m}${c.reset}`;
700
+ return `${c2.cyan}${m}${c2.reset}`;
549
701
  case "POST":
550
- return `${c.green}${m}${c.reset}`;
702
+ return `${c2.green}${m}${c2.reset}`;
551
703
  case "PUT":
552
- return `${c.yellow}${m}${c.reset}`;
704
+ return `${c2.yellow}${m}${c2.reset}`;
553
705
  case "PATCH":
554
- return `${c.yellow}${m}${c.reset}`;
706
+ return `${c2.yellow}${m}${c2.reset}`;
555
707
  case "DELETE":
556
- return `${c.red}${m}${c.reset}`;
708
+ return `${c2.red}${m}${c2.reset}`;
557
709
  default:
558
710
  return m;
559
711
  }
560
712
  }
561
713
  function colorStatus(code) {
562
714
  const s = String(code);
563
- if (code < 300) return `${c.green}${s}${c.reset}`;
564
- if (code < 400) return `${c.cyan}${s}${c.reset}`;
565
- if (code < 500) return `${c.yellow}${s}${c.reset}`;
566
- return `${c.red}${s}${c.reset}`;
715
+ if (code < 300) return `${c2.green}${s}${c2.reset}`;
716
+ if (code < 400) return `${c2.cyan}${s}${c2.reset}`;
717
+ if (code < 500) return `${c2.yellow}${s}${c2.reset}`;
718
+ return `${c2.red}${s}${c2.reset}`;
567
719
  }
568
720
  var currentApp = null;
569
721
  async function startServer(portOverride) {
570
722
  const config = await loadConfig();
723
+ validateConfig(config.entities);
571
724
  const port = await findPort(portOverride ?? config.server.port);
572
725
  const openApiSpec = generateOpenApiSpec(config);
573
726
  const app = Fastify({ logger: false });
@@ -588,6 +741,16 @@ async function startServer(portOverride) {
588
741
  await new Promise((r) => setTimeout(r, ms));
589
742
  });
590
743
  }
744
+ const errorRate = config.server.errorRate;
745
+ if (errorRate !== void 0 && errorRate > 0) {
746
+ const SYSTEM_ROUTES_ERR = /* @__PURE__ */ new Set(["/__reset", "/docs", "/openapi.json", "/__snapshot"]);
747
+ app.addHook("preHandler", async (req, reply) => {
748
+ if (SYSTEM_ROUTES_ERR.has(req.url) || req.url.startsWith("/docs")) return;
749
+ if (Math.random() < errorRate) {
750
+ reply.code(500).send({ error: "Simulated server error", hint: "errorRate is enabled in config" });
751
+ }
752
+ });
753
+ }
591
754
  app.addHook("onResponse", (req, reply, done) => {
592
755
  if (req.url.startsWith("/docs/static") || req.url === "/favicon.ico") {
593
756
  done();
@@ -596,7 +759,7 @@ async function startServer(portOverride) {
596
759
  const ms = Date.now() - (req._ffaStart ?? Date.now());
597
760
  const method = colorMethod(req.method);
598
761
  const status = colorStatus(reply.statusCode);
599
- const time = `${c.gray}${ms}ms${c.reset}`;
762
+ const time = `${c2.gray}${ms}ms${c2.reset}`;
600
763
  console.log(` ${method} ${req.url.padEnd(35)} ${status} ${time}`);
601
764
  done();
602
765
  });
@@ -606,10 +769,10 @@ async function startServer(portOverride) {
606
769
  }
607
770
  const db = createMemoryDB(config.entities, persistPath);
608
771
  for (const entityName in config.entities) {
609
- const base = `/${pluralize2(entityName)}`;
772
+ const base2 = `/${pluralize2(entityName)}`;
610
773
  const zodSchema = entityToZod(config.entities[entityName].fields);
611
774
  const entityFields = config.entities[entityName].fields;
612
- app.get(base, (req, reply) => {
775
+ app.get(base2, (req, reply) => {
613
776
  const query = req.query;
614
777
  const page = query.page ? Number(query.page) : void 0;
615
778
  const limit = query.limit ? Number(query.limit) : void 0;
@@ -632,7 +795,7 @@ async function startServer(portOverride) {
632
795
  reply.header("X-Total-Count", String(result.meta.total));
633
796
  return result;
634
797
  });
635
- app.get(`${base}/:id`, (req, reply) => {
798
+ app.get(`${base2}/:id`, (req, reply) => {
636
799
  const item = db.get(entityName, req.params.id);
637
800
  if (!item) return reply.code(404).send({ error: "Not found" });
638
801
  const include = req.query.include;
@@ -649,13 +812,13 @@ async function startServer(portOverride) {
649
812
  }
650
813
  return result;
651
814
  });
652
- app.post(base, (req, reply) => {
815
+ app.post(base2, (req, reply) => {
653
816
  const result = zodSchema.safeParse(req.body);
654
817
  if (!result.success)
655
818
  return reply.code(422).send({ error: "Validation failed", issues: result.error.issues });
656
819
  return reply.code(201).send(db.create(entityName, result.data));
657
820
  });
658
- app.put(`${base}/:id`, (req, reply) => {
821
+ app.put(`${base2}/:id`, (req, reply) => {
659
822
  const result = zodSchema.safeParse(req.body);
660
823
  if (!result.success)
661
824
  return reply.code(422).send({ error: "Validation failed", issues: result.error.issues });
@@ -663,7 +826,7 @@ async function startServer(portOverride) {
663
826
  if (!item) return reply.code(404).send({ error: "Not found" });
664
827
  return item;
665
828
  });
666
- app.patch(`${base}/:id`, (req, reply) => {
829
+ app.patch(`${base2}/:id`, (req, reply) => {
667
830
  const result = zodSchema.partial().safeParse(req.body);
668
831
  if (!result.success)
669
832
  return reply.code(422).send({ error: "Validation failed", issues: result.error.issues });
@@ -671,7 +834,7 @@ async function startServer(portOverride) {
671
834
  if (!item) return reply.code(404).send({ error: "Not found" });
672
835
  return item;
673
836
  });
674
- app.delete(`${base}/:id`, (req, reply) => {
837
+ app.delete(`${base2}/:id`, (req, reply) => {
675
838
  const ok = db.remove(entityName, req.params.id);
676
839
  if (!ok) return reply.code(404).send({ error: "Not found" });
677
840
  return { success: true };
@@ -681,23 +844,36 @@ async function startServer(portOverride) {
681
844
  db.reset();
682
845
  return { success: true, message: "Data reset to seed state" };
683
846
  });
847
+ app.get("/__snapshot", () => db.snapshot());
684
848
  app.get("/openapi.json", () => openApiSpec);
685
849
  await app.listen({ port, host: "0.0.0.0" });
686
- const entityRows = Object.keys(config.entities).map((name) => {
850
+ const VERSION = "0.16.0";
851
+ const base = `http://localhost:${port}`;
852
+ const entityNames = Object.keys(config.entities);
853
+ const colEntity = Math.max(6, ...entityNames.map((n) => n.length));
854
+ const colRoute = Math.max(5, ...entityNames.map((n) => `/${pluralize2(n)}`.length));
855
+ const header = ` ${"Entity".padEnd(colEntity)} ${"Count".padStart(5)} Route`;
856
+ const divider = ` ${"\u2500".repeat(colEntity)} ${"\u2500".repeat(5)} ${"\u2500".repeat(colRoute + 18)}`;
857
+ const entityRows = entityNames.map((name) => {
687
858
  const count = db.count(name);
688
- const url = `http://localhost:${port}/${pluralize2(name)}`;
689
- return ` ${c.gray}\u2192${c.reset} ${name.padEnd(16)} ${c.gray}${String(count).padStart(3)} records${c.reset} ${c.cyan}${url}${c.reset}`;
859
+ const route = `/${pluralize2(name)}`;
860
+ const methods = `${c2.cyan}GET${c2.reset} ${c2.green}POST${c2.reset}`;
861
+ return ` ${c2.bold}${name.padEnd(colEntity)}${c2.reset} ${c2.gray}${String(count).padStart(5)}${c2.reset} ${methods} ${c2.cyan}${base}${route}${c2.reset}`;
690
862
  }).join("\n");
691
- const delayInfo = delayConfig !== void 0 ? Array.isArray(delayConfig) ? ` ${c.gray}delay: ${delayConfig[0]}\u2013${delayConfig[1]}ms${c.reset}
692
- ` : ` ${c.gray}delay: ${delayConfig}ms${c.reset}
863
+ const delayLine = delayConfig !== void 0 ? ` ${c2.gray}delay ${Array.isArray(delayConfig) ? `${delayConfig[0]}\u2013${delayConfig[1]}ms` : `${delayConfig}ms`}${c2.reset}
864
+ ` : "";
865
+ const errorLine = errorRate !== void 0 && errorRate > 0 ? ` ${c2.yellow}errorRate ${(errorRate * 100).toFixed(0)}% chance of 500${c2.reset}
693
866
  ` : "";
694
867
  console.log(
695
868
  `
696
- ${c.bold}FFA dev server${c.reset} ${c.gray}v0.6.0${c.reset}
869
+ ${c2.bold}${c2.cyan}FFA${c2.reset}${c2.bold} dev server${c2.reset} ${c2.gray}v${VERSION}${c2.reset}
697
870
 
871
+ ${c2.gray}${header}
872
+ ${divider}${c2.reset}
698
873
  ${entityRows}
699
874
 
700
- ${delayInfo} ${c.gray}\u2192${c.reset} Swagger UI ${c.cyan}http://localhost:${port}/docs${c.reset}
875
+ ${delayLine}${errorLine} ${c2.gray}docs ${c2.reset}${c2.cyan}${base}/docs${c2.reset}
876
+ ${c2.gray}reset ${c2.reset}${c2.cyan}POST ${base}/__reset${c2.reset}
701
877
  `
702
878
  );
703
879
  return { app, port };
@@ -741,7 +917,7 @@ program.command("init").description("Create ffa.config.ts in the current directo
741
917
  console.log(" ffa.config.ts already exists!");
742
918
  process.exit(1);
743
919
  }
744
- const template = `import { defineConfig, entity, string, number, boolean, enumField } from 'ffa-core'
920
+ const template = `import { defineConfig, entity, string, number, boolean, enumField } from '@koltakov/ffa-core'
745
921
 
746
922
  export default defineConfig({
747
923
  server: { port: 3333 },
@@ -758,6 +934,30 @@ export default defineConfig({
758
934
  writeFileSync2(tsPath, template, "utf-8");
759
935
  console.log(" ffa.config.ts created! Run: ffa dev");
760
936
  });
937
+ program.command("snapshot").description("Export current DB state from running server to a JSON file").option("-o, --output <file>", "output file path", "ffa-snapshot.json").option("-p, --port <port>", "server port (reads from config by default)").action(async (opts) => {
938
+ let port = opts.port ? Number(opts.port) : void 0;
939
+ if (!port) {
940
+ try {
941
+ const cfg = await loadConfig();
942
+ port = cfg.server.port;
943
+ } catch {
944
+ port = 3e3;
945
+ }
946
+ }
947
+ try {
948
+ const res = await fetch(`http://localhost:${port}/__snapshot`);
949
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
950
+ const data = await res.json();
951
+ const outPath = resolve(process.cwd(), opts.output);
952
+ writeFileSync2(outPath, JSON.stringify(data, null, 2), "utf-8");
953
+ const counts = Object.entries(data).map(([k, v]) => `${k}: ${v.length}`).join(", ");
954
+ console.log(` Snapshot saved \u2192 ${outPath}`);
955
+ console.log(` ${counts}`);
956
+ } catch {
957
+ console.error(` Failed to snapshot. Is the server running on port ${port}?`);
958
+ process.exit(1);
959
+ }
960
+ });
761
961
  program.command("reset").description("Delete persisted data file (ffa-data.json)").action(() => {
762
962
  const dataPath = resolve(process.cwd(), "ffa-data.json");
763
963
  if (!existsSync3(dataPath)) {
@@ -765,6 +965,68 @@ program.command("reset").description("Delete persisted data file (ffa-data.json)
765
965
  return;
766
966
  }
767
967
  unlinkSync(dataPath);
768
- console.log(" ffa-data.json deleted. Restart the server to regenerate seed data.");
968
+ console.log(
969
+ " ffa-data.json deleted. Restart the server to regenerate seed data."
970
+ );
971
+ });
972
+ program.command("inspect").description("Show config structure without starting the server").action(async () => {
973
+ const c3 = {
974
+ reset: "\x1B[0m",
975
+ bold: "\x1B[1m",
976
+ dim: "\x1B[2m",
977
+ cyan: "\x1B[36m",
978
+ green: "\x1B[32m",
979
+ yellow: "\x1B[33m",
980
+ blue: "\x1B[34m",
981
+ gray: "\x1B[90m"
982
+ };
983
+ let config;
984
+ try {
985
+ config = await loadConfig();
986
+ } catch (err) {
987
+ console.error(` Error: ${err.message}`);
988
+ process.exit(1);
989
+ }
990
+ const entityNames = Object.keys(config.entities);
991
+ console.log(`
992
+ ${c3.bold}${c3.cyan}FFA${c3.reset}${c3.bold} inspect${c3.reset} ${c3.gray}port ${config.server.port}${c3.reset}
993
+ `);
994
+ for (const name of entityNames) {
995
+ const def = config.entities[name];
996
+ const count = def.count ?? 10;
997
+ const seedCount = def.seed?.length ?? 0;
998
+ const seedNote = seedCount > 0 ? ` ${c3.green}+${seedCount} seed${c3.reset}` : "";
999
+ console.log(` ${c3.bold}${name}${c3.reset} ${c3.gray}${count} records${c3.reset}${seedNote}`);
1000
+ const fieldEntries = Object.entries(def.fields);
1001
+ const colName = Math.max(...fieldEntries.map(([k]) => k.length));
1002
+ for (const [fieldName, field] of fieldEntries) {
1003
+ const req = field.rules.required ? `${c3.yellow}*${c3.reset}` : " ";
1004
+ const ro = field.rules.readonly ? ` ${c3.gray}readonly${c3.reset}` : "";
1005
+ const hint = field.rules.fakeHint ? ` ${c3.dim}[${field.rules.fakeHint}]${c3.reset}` : "";
1006
+ const rel = field.rules.entity ? ` ${c3.blue}\u2192 ${field.rules.entity}${c3.reset}` : "";
1007
+ const enums = field.rules.enumValues?.length ? ` ${c3.gray}(${field.rules.enumValues.join(" | ")})${c3.reset}` : "";
1008
+ const nested = field.rules.objectFields ? ` ${c3.gray}{ ${Object.keys(field.rules.objectFields).join(", ")} }${c3.reset}` : "";
1009
+ const arrOf = field.rules.arrayItem ? ` ${c3.gray}of ${field.rules.arrayItem.type}${c3.reset}` : "";
1010
+ console.log(
1011
+ ` ${req} ${fieldName.padEnd(colName)} ${c3.cyan}${field.type}${c3.reset}${hint}${rel}${enums}${nested}${arrOf}${ro}`
1012
+ );
1013
+ }
1014
+ if (def.meta && Object.keys(def.meta).length > 0) {
1015
+ const metaKeys = Object.keys(def.meta);
1016
+ console.log(` ${c3.gray}meta: ${metaKeys.join(", ")}${c3.reset}`);
1017
+ }
1018
+ console.log();
1019
+ }
1020
+ const { delay, persist, errorRate } = config.server;
1021
+ const info = [];
1022
+ if (delay !== void 0)
1023
+ info.push(`delay ${Array.isArray(delay) ? `${delay[0]}\u2013${delay[1]}ms` : `${delay}ms`}`);
1024
+ if (persist)
1025
+ info.push(`persist ${typeof persist === "string" ? persist : "ffa-data.json"}`);
1026
+ if (errorRate !== void 0 && errorRate > 0)
1027
+ info.push(`errorRate ${(errorRate * 100).toFixed(0)}%`);
1028
+ if (info.length > 0)
1029
+ console.log(` ${c3.gray}${info.join(" \xB7 ")}${c3.reset}
1030
+ `);
769
1031
  });
770
1032
  program.parse();