@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/README.md +402 -158
- package/dist/cli.js +361 -99
- package/dist/index.d.ts +75 -6
- package/dist/index.js +153 -8
- package/dist/index.js.map +1 -1
- package/package.json +5 -2
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
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
133
|
+
// visual
|
|
97
134
|
color: () => faker.color.human(),
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
260
|
+
function sortedEntityNames() {
|
|
170
261
|
const names = Object.keys(entities);
|
|
171
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
const
|
|
183
|
-
|
|
184
|
-
...
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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 {
|
|
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
|
|
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 `${
|
|
700
|
+
return `${c2.cyan}${m}${c2.reset}`;
|
|
549
701
|
case "POST":
|
|
550
|
-
return `${
|
|
702
|
+
return `${c2.green}${m}${c2.reset}`;
|
|
551
703
|
case "PUT":
|
|
552
|
-
return `${
|
|
704
|
+
return `${c2.yellow}${m}${c2.reset}`;
|
|
553
705
|
case "PATCH":
|
|
554
|
-
return `${
|
|
706
|
+
return `${c2.yellow}${m}${c2.reset}`;
|
|
555
707
|
case "DELETE":
|
|
556
|
-
return `${
|
|
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 `${
|
|
564
|
-
if (code < 400) return `${
|
|
565
|
-
if (code < 500) return `${
|
|
566
|
-
return `${
|
|
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 = `${
|
|
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
|
|
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(
|
|
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(`${
|
|
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(
|
|
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(`${
|
|
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(`${
|
|
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(`${
|
|
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
|
|
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
|
|
689
|
-
|
|
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
|
|
692
|
-
` :
|
|
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
|
-
${
|
|
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
|
-
${
|
|
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(
|
|
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();
|