@yrest/cli 0.9.0 → 0.11.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/index.js CHANGED
@@ -56,37 +56,136 @@ function parseRelations(raw) {
56
56
  }
57
57
  function normaliseRelationDef(key, value) {
58
58
  if (typeof value === "string") {
59
- return { type: "many2one", target: value };
59
+ return value.includes(":") ? parseDslString(value) : { type: "many2one", target: value };
60
60
  }
61
61
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
62
62
  const v = value;
63
- const type = v["type"];
64
- const nested = v["nested"] === true ? true : void 0;
63
+ const type = v["_type"];
64
+ const nested = v["_nested"] === true ? true : void 0;
65
+ const carDirect = typeof v["_car-direct"] === "string" ? v["_car-direct"] : void 0;
66
+ const carInverse = typeof v["_car-inverse"] === "string" ? v["_car-inverse"] : void 0;
65
67
  if (type === "many2one" || type === void 0) {
66
- const target = v["target"];
68
+ const target = v["_target"];
67
69
  if (typeof target !== "string") return null;
68
- return nested ? { type: "many2one", target, nested } : { type: "many2one", target };
70
+ const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
71
+ return compact({ type: "many2one", target, foreignKey, nested, carDirect, carInverse });
69
72
  }
70
73
  if (type === "one2one") {
71
- const target = v["target"];
74
+ const target = v["_target"];
72
75
  if (typeof target !== "string") return null;
73
- return nested ? { type: "one2one", target, nested } : { type: "one2one", target };
76
+ const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
77
+ return compact({ type: "one2one", target, foreignKey, nested, carDirect, carInverse });
74
78
  }
75
79
  if (type === "many2many") {
76
- const target = typeof v["target"] === "string" ? v["target"] : key;
77
- const through = v["through"];
78
- const foreignKey = v["foreignKey"];
79
- const otherKey = v["otherKey"];
80
+ const target = typeof v["_target"] === "string" ? v["_target"] : key;
81
+ const through = v["_through"];
82
+ const foreignKey = v["_foreignKey"];
83
+ const otherKey = v["_otherKey"];
80
84
  if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
81
85
  return null;
82
- return nested ? { type: "many2many", target, through, foreignKey, otherKey, nested } : { type: "many2many", target, through, foreignKey, otherKey };
86
+ return compact({
87
+ type: "many2many",
88
+ target,
89
+ through,
90
+ foreignKey,
91
+ otherKey,
92
+ nested,
93
+ carDirect,
94
+ carInverse
95
+ });
83
96
  }
84
97
  return null;
85
98
  }
99
+ function parseDslString(raw) {
100
+ const nested = raw.endsWith("+nested") ? true : void 0;
101
+ let s = nested ? raw.slice(0, -7) : raw;
102
+ let carDirect;
103
+ let carInverse;
104
+ const carMatch = s.match(/\[([^[\]]+)->([^[\]]+)\]$/);
105
+ if (carMatch) {
106
+ carDirect = carMatch[1];
107
+ carInverse = carMatch[2];
108
+ s = s.slice(0, s.length - carMatch[0].length);
109
+ }
110
+ const colonIdx = s.indexOf(":");
111
+ if (colonIdx === -1) return null;
112
+ const typeAlias = s.slice(0, colonIdx);
113
+ const body = s.slice(colonIdx + 1);
114
+ const type = TYPE_ALIASES[typeAlias];
115
+ if (!type) return null;
116
+ if (type === "many2many") {
117
+ const m = body.match(/^([^@()[\]]+)@([^@()[\]]+)\(([^,)]+),\s*([^,)]+)\)$/);
118
+ if (!m) return null;
119
+ const [, target2, through, foreignKey2, otherKey] = m;
120
+ return compact({
121
+ type: "many2many",
122
+ target: target2,
123
+ through,
124
+ foreignKey: foreignKey2,
125
+ otherKey,
126
+ nested,
127
+ carDirect,
128
+ carInverse
129
+ });
130
+ }
131
+ const atIdx = body.indexOf("@");
132
+ const target = atIdx === -1 ? body : body.slice(0, atIdx);
133
+ const foreignKey = atIdx === -1 ? void 0 : body.slice(atIdx + 1);
134
+ if (!target) return null;
135
+ return compact({ type, target, foreignKey, nested, carDirect, carInverse });
136
+ }
137
+ function compact(obj) {
138
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
139
+ }
140
+ var TYPE_ALIASES;
86
141
  var init_parseRelations = __esm({
87
142
  "src/storage/parseRelations.ts"() {
88
143
  "use strict";
89
144
  init_cjs_shims();
145
+ TYPE_ALIASES = {
146
+ many2one: "many2one",
147
+ m2o: "many2one",
148
+ one2one: "one2one",
149
+ o2o: "one2one",
150
+ many2many: "many2many",
151
+ m2m: "many2many"
152
+ };
153
+ }
154
+ });
155
+
156
+ // src/storage/parseSchema.ts
157
+ function parseSchema(raw) {
158
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
159
+ const result = {};
160
+ for (const [collection, fields] of Object.entries(raw)) {
161
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
162
+ result[collection] = {};
163
+ for (const [field, value] of Object.entries(fields)) {
164
+ const def = normaliseFieldDef(value);
165
+ if (def) result[collection][field] = def;
166
+ }
167
+ }
168
+ return result;
169
+ }
170
+ function normaliseFieldDef(value) {
171
+ if (value === "required") return { required: true };
172
+ if (value === "optional") return { required: false };
173
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
174
+ const v = value;
175
+ const def = {};
176
+ if (v["_required"] === true || v["_required"] === false) def.required = v["_required"];
177
+ if (typeof v["_type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["_type"]))
178
+ def.type = v["_type"];
179
+ if (typeof v["_format"] === "string") def.format = v["_format"];
180
+ if (Array.isArray(v["_enum"])) def.enum = v["_enum"];
181
+ if (typeof v["_description"] === "string") def.description = v["_description"];
182
+ if (v["_default"] !== void 0) def.default = v["_default"];
183
+ return def;
184
+ }
185
+ var init_parseSchema = __esm({
186
+ "src/storage/parseSchema.ts"() {
187
+ "use strict";
188
+ init_cjs_shims();
90
189
  }
91
190
  });
92
191
 
@@ -103,18 +202,128 @@ var init_deepCopy = __esm({
103
202
  }
104
203
  });
105
204
 
205
+ // src/storage/parseRoutes.ts
206
+ function parseRoutes(raw) {
207
+ if (!Array.isArray(raw)) return [];
208
+ return raw.map(normaliseRoute).filter((r) => r !== null);
209
+ }
210
+ function normaliseRoute(raw) {
211
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
212
+ const v = raw;
213
+ const method = v["_method"];
214
+ const path = v["_path"];
215
+ if (typeof method !== "string" || typeof path !== "string") return null;
216
+ const route = { method, path };
217
+ if (typeof v["_handler"] === "string") route.handler = v["_handler"];
218
+ if (typeof v["_delay"] === "number") route.delay = v["_delay"];
219
+ if (typeof v["_error"] === "number") route.error = v["_error"];
220
+ if (v["_errorBody"] !== void 0) route.errorBody = v["_errorBody"];
221
+ const response = normaliseResponse(v["_response"]);
222
+ if (response) route.response = response;
223
+ const otherwise = normaliseResponse(v["_otherwise"]);
224
+ if (otherwise) route.otherwise = otherwise;
225
+ const scenarios = normaliseScenarios(v["_scenarios"]);
226
+ if (scenarios.length > 0) route.scenarios = scenarios;
227
+ return route;
228
+ }
229
+ function normaliseResponse(raw) {
230
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
231
+ const v = raw;
232
+ const response = {};
233
+ if (typeof v["_status"] === "number") response.status = v["_status"];
234
+ if (v["_body"] !== void 0) response.body = v["_body"];
235
+ if (v["_headers"] && typeof v["_headers"] === "object" && !Array.isArray(v["_headers"]))
236
+ response.headers = v["_headers"];
237
+ return Object.keys(response).length > 0 ? response : void 0;
238
+ }
239
+ function normaliseScenarios(raw) {
240
+ if (!Array.isArray(raw)) return [];
241
+ return raw.map(normaliseScenario).filter((s) => s !== null);
242
+ }
243
+ function normaliseScenario(raw) {
244
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
245
+ const v = raw;
246
+ const when = v["_when"];
247
+ if (!when || typeof when !== "object") return null;
248
+ const response = normaliseResponse(v["_response"]);
249
+ if (!response) return null;
250
+ return { when, response };
251
+ }
252
+ var init_parseRoutes = __esm({
253
+ "src/storage/parseRoutes.ts"() {
254
+ "use strict";
255
+ init_cjs_shims();
256
+ }
257
+ });
258
+
106
259
  // src/storage/yrestStorage.ts
107
260
  var yrestStorage_exports = {};
108
261
  __export(yrestStorage_exports, {
109
262
  createYrestStorage: () => createYrestStorage
110
263
  });
264
+ function serializeRelations(relations) {
265
+ const out = {};
266
+ for (const [col, fields] of Object.entries(relations)) {
267
+ out[col] = {};
268
+ for (const [key, def] of Object.entries(fields)) {
269
+ out[col][key] = serializeRelationDef(def);
270
+ }
271
+ }
272
+ return out;
273
+ }
274
+ function serializeRelationDef(def) {
275
+ if (def.type === "many2many") {
276
+ const entry2 = {
277
+ _type: def.type,
278
+ _target: def.target,
279
+ _through: def.through,
280
+ _foreignKey: def.foreignKey,
281
+ _otherKey: def.otherKey
282
+ };
283
+ if (def.nested) entry2._nested = true;
284
+ if (def.carDirect) entry2["_car-direct"] = def.carDirect;
285
+ if (def.carInverse) entry2["_car-inverse"] = def.carInverse;
286
+ return entry2;
287
+ }
288
+ const entry = { _type: def.type, _target: def.target };
289
+ if (def.foreignKey) entry._foreignKey = def.foreignKey;
290
+ if (def.nested) entry._nested = true;
291
+ if (def.carDirect) entry["_car-direct"] = def.carDirect;
292
+ if (def.carInverse) entry["_car-inverse"] = def.carInverse;
293
+ return entry;
294
+ }
295
+ function serializeRoutes(routes) {
296
+ return routes.map((r) => {
297
+ const entry = { _method: r.method, _path: r.path };
298
+ if (r.handler) entry._handler = r.handler;
299
+ if (r.delay !== void 0) entry._delay = r.delay;
300
+ if (r.error !== void 0) entry._error = r.error;
301
+ if (r.errorBody !== void 0) entry._errorBody = r.errorBody;
302
+ if (r.response) entry._response = serializeResponse(r.response);
303
+ if (r.otherwise) entry._otherwise = serializeResponse(r.otherwise);
304
+ if (r.scenarios?.length) entry._scenarios = r.scenarios.map(serializeScenario);
305
+ return entry;
306
+ });
307
+ }
308
+ function serializeResponse(res) {
309
+ const out = {};
310
+ if (res.status !== void 0) out._status = res.status;
311
+ if (res.body !== void 0) out._body = res.body;
312
+ if (res.headers) out._headers = res.headers;
313
+ return out;
314
+ }
315
+ function serializeScenario(s) {
316
+ return { _when: s.when, _response: serializeResponse(s.response) };
317
+ }
111
318
  function createYrestStorage(filePath) {
112
319
  const absPath = (0, import_node_path2.resolve)(filePath);
113
- const raw = (0, import_yaml2.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
320
+ const raw = (0, import_yaml3.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
321
+ const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
114
322
  const relations = parseRelations(raw["_rel"]);
115
- const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
323
+ const routes = parseRoutes(raw["_routes"]);
324
+ const schema = parseSchema(raw["_schema"]);
116
325
  const data = Object.fromEntries(
117
- Object.entries(raw).filter(([key]) => key !== "_rel" && key !== "_routes")
326
+ Object.entries(raw).filter(([key]) => !RESERVED.has(key))
118
327
  );
119
328
  let snapshot = {
120
329
  data: deepCopyData(data),
@@ -128,6 +337,9 @@ function createYrestStorage(filePath) {
128
337
  getRelations() {
129
338
  return relations;
130
339
  },
340
+ getSchema() {
341
+ return schema;
342
+ },
131
343
  getRoutes() {
132
344
  return routes;
133
345
  },
@@ -139,18 +351,18 @@ function createYrestStorage(filePath) {
139
351
  },
140
352
  persist() {
141
353
  const payload = {};
142
- if (Object.keys(relations).length > 0) payload._rel = relations;
143
- if (routes.length > 0) payload._routes = routes;
354
+ if (Object.keys(relations).length > 0) payload._rel = serializeRelations(relations);
355
+ if (routes.length > 0) payload._routes = serializeRoutes(routes);
144
356
  Object.assign(payload, data);
145
357
  const tmp = (0, import_node_path2.resolve)((0, import_node_path2.dirname)(absPath), `.yrest-${(0, import_node_crypto2.randomUUID)()}.tmp`);
146
- (0, import_node_fs3.writeFileSync)(tmp, (0, import_yaml2.stringify)(payload), "utf8");
358
+ (0, import_node_fs3.writeFileSync)(tmp, (0, import_yaml3.stringify)(payload), "utf8");
147
359
  (0, import_node_fs3.renameSync)(tmp, absPath);
148
360
  },
149
361
  reload() {
150
- const fresh = (0, import_yaml2.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
362
+ const fresh = (0, import_yaml3.parse)((0, import_node_fs3.readFileSync)(absPath, "utf8")) ?? {};
151
363
  const freshRelations = parseRelations(fresh["_rel"]);
152
364
  const freshData = Object.fromEntries(
153
- Object.entries(fresh).filter(([key]) => key !== "_rel" && key !== "_routes")
365
+ Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
154
366
  );
155
367
  for (const key of Object.keys(data)) delete data[key];
156
368
  Object.assign(data, freshData);
@@ -177,7 +389,7 @@ function createYrestStorage(filePath) {
177
389
  }
178
390
  };
179
391
  }
180
- var import_node_fs3, import_node_path2, import_node_crypto2, import_yaml2;
392
+ var import_node_fs3, import_node_path2, import_node_crypto2, import_yaml3;
181
393
  var init_yrestStorage = __esm({
182
394
  "src/storage/yrestStorage.ts"() {
183
395
  "use strict";
@@ -185,8 +397,10 @@ var init_yrestStorage = __esm({
185
397
  import_node_fs3 = require("fs");
186
398
  import_node_path2 = require("path");
187
399
  import_node_crypto2 = require("crypto");
188
- import_yaml2 = require("yaml");
400
+ import_yaml3 = require("yaml");
189
401
  init_parseRelations();
402
+ init_parseRoutes();
403
+ init_parseSchema();
190
404
  init_deepCopy();
191
405
  }
192
406
  });
@@ -250,6 +464,7 @@ function dedent(str) {
250
464
  init_cjs_shims();
251
465
  var import_node_path3 = require("path");
252
466
  init_parseRelations();
467
+ init_parseSchema();
253
468
 
254
469
  // src/utils/handlers.ts
255
470
  init_cjs_shims();
@@ -382,7 +597,7 @@ function endpointRow(method, path, desc) {
382
597
  }
383
598
  function resourceAccordion(name, base, isOpen) {
384
599
  const p = `${base}/${name}`;
385
- const singular = name.endsWith("s") ? name.slice(0, -1) : name;
600
+ const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
386
601
  const rows = [
387
602
  endpointRow(
388
603
  "GET",
@@ -392,20 +607,20 @@ function resourceAccordion(name, base, isOpen) {
392
607
  endpointRow(
393
608
  "POST",
394
609
  p,
395
- `Create a new ${singular}. Auto-assigns <code>id</code> if not provided.`
610
+ `Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
396
611
  ),
397
- endpointRow("GET", `${p}/:id`, `Get a single ${singular} by id.`),
612
+ endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
398
613
  endpointRow(
399
614
  "PUT",
400
615
  `${p}/:id`,
401
- `Fully replace a ${singular}. Original <code>id</code> is always preserved.`
616
+ `Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
402
617
  ),
403
618
  endpointRow(
404
619
  "PATCH",
405
620
  `${p}/:id`,
406
- `Partially update a ${singular} \u2014 only provided fields change.`
621
+ `Partially update a ${singular2} \u2014 only provided fields change.`
407
622
  ),
408
- endpointRow("DELETE", `${p}/:id`, `Delete a ${singular} and return it as confirmation.`)
623
+ endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
409
624
  ].join("");
410
625
  return `
411
626
  <details class="resource-card" ${isOpen ? "open" : ""}>
@@ -424,13 +639,13 @@ function nestedRoutesAccordion(relations, base) {
424
639
  for (const [key, def] of Object.entries(fields)) {
425
640
  const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
426
641
  if (def.type === "many2many") {
427
- const singular = source.endsWith("s") ? source.slice(0, -1) : source;
642
+ const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
428
643
  const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
429
644
  rows.push(
430
645
  endpointRow(
431
646
  "GET",
432
647
  `${base}/${source}/:id/${key}`,
433
- `List ${def.target} linked to a ${singular} via ${def.through}. ${m2mBadge}${nestedBadge}`
648
+ `List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
434
649
  )
435
650
  );
436
651
  const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
@@ -556,7 +771,7 @@ function examplesBlock(collections, relations, base, host, options, firstCustomR
556
771
  const firstCol = collections[0];
557
772
  if (firstCol) {
558
773
  const p = `${host}${base}/${firstCol}`;
559
- const singular = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
774
+ const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
560
775
  examples.push(
561
776
  `# List all ${firstCol}
562
777
  curl ${p}`,
@@ -564,17 +779,17 @@ curl ${p}`,
564
779
  curl "${p}?name=value"`,
565
780
  `# Sort and paginate
566
781
  curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
567
- `# Get single ${singular}
782
+ `# Get single ${singular2}
568
783
  curl ${p}/1`,
569
- `# Create ${singular}
784
+ `# Create ${singular2}
570
785
  curl -X POST ${p} \\
571
786
  -H "Content-Type: application/json" \\
572
787
  -d '{"name":"example"}'`,
573
- `# Partially update ${singular}
788
+ `# Partially update ${singular2}
574
789
  curl -X PATCH ${p}/1 \\
575
790
  -H "Content-Type: application/json" \\
576
791
  -d '{"name":"updated"}'`,
577
- `# Delete ${singular}
792
+ `# Delete ${singular2}
578
793
  curl -X DELETE ${p}/1`
579
794
  );
580
795
  }
@@ -637,12 +852,510 @@ curl ${curlFlag}${fullPath}`);
637
852
  return `<pre>${highlighted}</pre>`;
638
853
  }
639
854
 
855
+ // src/router/templates/about.diagram.ts
856
+ init_cjs_shims();
857
+ function inferType(value) {
858
+ if (value === null || value === void 0) return "string";
859
+ if (typeof value === "boolean") return "boolean";
860
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
861
+ if (Array.isArray(value)) return "array";
862
+ if (typeof value === "object") return "object";
863
+ return "string";
864
+ }
865
+ function generateERData(storage) {
866
+ const data = storage.getData();
867
+ const relations = storage.getRelations();
868
+ const schema = storage.getSchema();
869
+ const pivotTables = /* @__PURE__ */ new Set();
870
+ for (const fields of Object.values(relations)) {
871
+ for (const def of Object.values(fields)) {
872
+ if (def.type === "many2many") pivotTables.add(def.through);
873
+ }
874
+ }
875
+ const fkFields = /* @__PURE__ */ new Map();
876
+ for (const [source, fields] of Object.entries(relations)) {
877
+ for (const [key, def] of Object.entries(fields)) {
878
+ if (def.type !== "many2many") {
879
+ if (!fkFields.has(source)) fkFields.set(source, /* @__PURE__ */ new Set());
880
+ fkFields.get(source).add(key);
881
+ }
882
+ }
883
+ }
884
+ const entities = Object.entries(data).map(([name, items]) => {
885
+ const sample = items[0] ?? {};
886
+ const schemaFields = schema[name] ?? {};
887
+ const allFields = /* @__PURE__ */ new Set([...Object.keys(sample), ...Object.keys(schemaFields)]);
888
+ const fields = [...allFields].map((fieldName) => ({
889
+ name: fieldName,
890
+ type: schemaFields[fieldName]?.type ?? inferType(sample[fieldName]),
891
+ pk: fieldName === "id",
892
+ fk: fkFields.get(name)?.has(fieldName) ?? false,
893
+ required: schemaFields[fieldName]?.required ?? false
894
+ }));
895
+ fields.sort((a, b) => {
896
+ if (a.pk) return -1;
897
+ if (b.pk) return 1;
898
+ if (a.fk && !b.fk) return 1;
899
+ if (!a.fk && b.fk) return -1;
900
+ return 0;
901
+ });
902
+ return { name, fields, isPivot: pivotTables.has(name) };
903
+ });
904
+ const erRelations = [];
905
+ for (const [source, fields] of Object.entries(relations)) {
906
+ for (const [key, def] of Object.entries(fields)) {
907
+ if (def.type === "many2many") {
908
+ erRelations.push({
909
+ source,
910
+ target: def.target,
911
+ type: "many2many",
912
+ label: def.through,
913
+ through: def.through,
914
+ foreignKey: def.foreignKey,
915
+ otherKey: def.otherKey
916
+ });
917
+ } else {
918
+ erRelations.push({
919
+ source,
920
+ target: def.target,
921
+ type: def.type,
922
+ label: key,
923
+ carDirect: def.carDirect,
924
+ carInverse: def.carInverse
925
+ });
926
+ }
927
+ }
928
+ }
929
+ return { entities, relations: erRelations };
930
+ }
931
+
932
+ // src/router/templates/about.diagram.canvas.ts
933
+ init_cjs_shims();
934
+ function erDiagramSection(data) {
935
+ if (!data.entities.length) return "";
936
+ const json = JSON.stringify(data);
937
+ return (
938
+ /*html*/
939
+ `
940
+ <h2>Data Model</h2>
941
+
942
+ <div class="card" style="padding:0;overflow:hidden;position:relative;">
943
+ <canvas id="er-canvas" style="width:100%;display:block;cursor:grab;"></canvas>
944
+ <button id="er-dl" title="Download PNG" style="position:absolute;top:10px;right:14px;background:#161b22;border:1px solid #30363d;color:#7d8590;padding:4px 10px;border-radius:6px;font-size:11px;cursor:pointer;line-height:1.4;">\u2193 PNG</button>
945
+ </div>
946
+
947
+ <script>${/*js*/
948
+ `
949
+ (function(){
950
+
951
+ var D=${json};
952
+
953
+ // \u2500\u2500 Constants \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
954
+ var NW=178,HH=34,FH=22,FP=10,ROW_GAP=44,COL_GAP=100,BR=8,BO=14,LGH=56;
955
+
956
+ var C={bg:'#0d1117',nb:'#161b22',nh:'#1c2128',pb:'#0d1117',ph:'#161b22',
957
+ bo:'#30363d',bs:'#58a6ff',tx:'#e6edf3',mu:'#7d8590',
958
+ pk:'#e3b341',fk:'#79c0ff',m2o:'#58a6ff',o2o:'#34d399',m2m:'#818cf8'};
959
+
960
+ // \u2500\u2500 Canvas \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
961
+ var cv = document.getElementById('er-canvas');
962
+ var dpr = window.devicePixelRatio||1;
963
+ var ctx = cv.getContext('2d');
964
+
965
+ function resize(h){
966
+ if(h)cv.style.height = h + 'px';
967
+ var r = cv.getBoundingClientRect();
968
+ cv.width = Math.round(r.width*dpr);
969
+ cv.height = Math.round(r.height*dpr);
970
+ }
971
+
972
+ // \u2500\u2500 Nodes & adjacency \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
973
+ var nodes = D.entities.map(function(e){
974
+ return{e:e,x:0,y:0,w:NW,h:HH+e.fields.length*FH+6};
975
+ });
976
+
977
+ var nIdx={};D.entities.forEach(function(e,i){nIdx[e.name]=i;});
978
+
979
+ var adj=nodes.map(function(){return[];}); // forward
980
+ var radj=nodes.map(function(){return[];}); // reverse
981
+
982
+ D.relations.forEach(function(r){
983
+ if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
984
+ var s=nIdx[r.source],p=nIdx[r.through],t=nIdx[r.target];
985
+ if(s!=null&&p!=null&&s!==p){adj[s].push(p);radj[p].push(s);}
986
+ if(t!=null&&p!=null&&t!==p){adj[t].push(p);radj[p].push(t);}
987
+ }else{
988
+ var s=nIdx[r.source],t=nIdx[r.target];
989
+ if(s==null||t==null||s===t)return;
990
+ adj[s].push(t);radj[t].push(s);
991
+ }
992
+ });
993
+
994
+ // \u2500\u2500 Layered layout (simplified Sugiyama) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
995
+ var groups=[];
996
+
997
+ function median(arr){
998
+ if(!arr.length)return 0;
999
+ var s=[].concat(arr).sort(function(a,b){return a-b;});
1000
+ return s[Math.floor(s.length/2)];
1001
+ }
1002
+
1003
+ function computeLayout(W,H){
1004
+ var layer=nodes.map(function(){return 0;});
1005
+ var inDeg=nodes.map(function(){return 0;});
1006
+ adj.forEach(function(ts){ts.forEach(function(t){inDeg[t]++;});});
1007
+ var q=[],qi=0;
1008
+ for(var i=0;i<nodes.length;i++)if(inDeg[i]===0)q.push(i);
1009
+ if(!q.length)for(var i=0;i<nodes.length;i++)q.push(i);
1010
+ while(qi<q.length){
1011
+ var u=q[qi++];
1012
+ adj[u].forEach(function(v){if(layer[v]<=layer[u]){layer[v]=layer[u]+1;q.push(v);}});
1013
+ }
1014
+ var maxL=layer.reduce(function(m,l){return Math.max(m,l);},0);
1015
+ groups=[];
1016
+ for(var l=0;l<=maxL;l++)groups.push([]);
1017
+ for(var i=0;i<nodes.length;i++)groups[layer[i]].push(i);
1018
+ for(var l=1;l<=maxL;l++){
1019
+ var pp={};groups[l-1].forEach(function(i,p){pp[i]=p;});
1020
+ groups[l].sort(function(a,b){
1021
+ return median(radj[a].map(function(x){return pp[x]!=null?pp[x]:0;}))-
1022
+ median(radj[b].map(function(x){return pp[x]!=null?pp[x]:0;}));
1023
+ });
1024
+ }
1025
+ for(var l=maxL-1;l>=0;l--){
1026
+ var np={};groups[l+1].forEach(function(i,p){np[i]=p;});
1027
+ groups[l].sort(function(a,b){
1028
+ return median(adj[a].map(function(x){return np[x]!=null?np[x]:0;}))-
1029
+ median(adj[b].map(function(x){return np[x]!=null?np[x]:0;}));
1030
+ });
1031
+ }
1032
+ var colW=NW+COL_GAP;
1033
+ var totalW=(maxL+1)*colW-COL_GAP;
1034
+ var startX=Math.max(20,(W-totalW)/2);
1035
+ for(var l=0;l<=maxL;l++){
1036
+ var g=groups[l];
1037
+ var totalH=g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP;
1038
+ var y=Math.max(20,(H-totalH)/2);
1039
+ g.forEach(function(i){nodes[i].x=startX+l*colW;nodes[i].y=y;y+=nodes[i].h+ROW_GAP;});
1040
+ }
1041
+ }
1042
+
1043
+ function requiredH(){
1044
+ return Math.max(340,Math.min(720,groups.reduce(function(m,g){
1045
+ return Math.max(m,g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP+LGH+70);
1046
+ },300)));
1047
+ }
1048
+
1049
+ // \u2500\u2500 View \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1050
+ var pan={x:0,y:0},zm=1;
1051
+ var origPos=[];
1052
+
1053
+ function savePos(){origPos=nodes.map(function(n){return{x:n.x,y:n.y};});}
1054
+
1055
+ function fitView(W,H){
1056
+ var x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;
1057
+ nodes.forEach(function(n){x0=Math.min(x0,n.x);y0=Math.min(y0,n.y);x1=Math.max(x1,n.x+n.w);y1=Math.max(y1,n.y+n.h);});
1058
+ var pad=28,gW=x1-x0+pad*2,gH=y1-y0+pad*2;
1059
+ zm=Math.min(W/gW,(H-LGH)/gH,1);
1060
+ pan.x=(W-gW*zm)/2+pad*zm-x0*zm;
1061
+ pan.y=((H-LGH)-gH*zm)/2+pad*zm-y0*zm;
1062
+ }
1063
+
1064
+ // \u2500\u2500 Rendering \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1065
+ function rr(x,y,w,h,r){
1066
+ if(ctx.roundRect){ctx.beginPath();ctx.roundRect(x,y,w,h,r);return;}
1067
+ ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);
1068
+ ctx.arcTo(x+w,y+h,x,y+h,r);ctx.arcTo(x,y+h,x,y,r);ctx.arcTo(x,y,x+w,y,r);ctx.closePath();
1069
+ }
1070
+
1071
+ function edgePt(n,tx,ty){
1072
+ var cx=n.x+n.w/2,cy=n.y+n.h/2,dx=tx-cx,dy=ty-cy;
1073
+ if(!dx&&!dy)return{x:cx,y:cy};
1074
+ var s=Math.min((n.w/2)/Math.abs(dx||.001),(n.h/2)/Math.abs(dy||.001));
1075
+ return{x:cx+dx*s,y:cy+dy*s};
1076
+ }
1077
+
1078
+ var badges=[];
1079
+
1080
+ function pushBadge(x,y,ux,uy,many,col){
1081
+ badges.push({x:x,y:y,ux:ux,uy:uy,many:many,col:col});
1082
+ }
1083
+
1084
+ // Badge: solid colored circle; crow's foot (many) or double-bar (one) in dark ink inside.
1085
+ // ux,uy points outward from the node \u2014 badge is offset BO px into the gap.
1086
+ // Crow's foot tines open toward the node (-x in rotated frame), tip toward line (+x).
1087
+ function drawBadge(b){
1088
+ var bx=b.x+b.ux*BO,by=b.y+b.uy*BO;
1089
+ ctx.fillStyle=b.col;ctx.strokeStyle=b.col;ctx.lineWidth=1;
1090
+ ctx.beginPath();ctx.arc(bx,by,BR,0,Math.PI*2);ctx.fill();ctx.stroke();
1091
+ ctx.save();ctx.translate(bx,by);ctx.rotate(Math.atan2(b.uy,b.ux));
1092
+ ctx.strokeStyle=C.bg;ctx.lineWidth=1.4;ctx.lineCap='round';
1093
+ ctx.beginPath();
1094
+ if(b.many){
1095
+ ctx.moveTo(-5,-4);ctx.lineTo(5,0); // upper tine \u2192 tip
1096
+ ctx.moveTo(-5,4);ctx.lineTo(5,0); // lower tine \u2192 tip
1097
+ ctx.moveTo(-5,0);ctx.lineTo(5,0); // center line
1098
+ ctx.moveTo(-5,-4);ctx.lineTo(-5,4); // base bar (near node)
1099
+ }else{
1100
+ ctx.moveTo(-3,-5);ctx.lineTo(-3,5); // near bar
1101
+ ctx.moveTo(2,-5);ctx.lineTo(2,5); // far bar
1102
+ }
1103
+ ctx.stroke();
1104
+ ctx.restore();
1105
+ }
1106
+
1107
+ function ecol(t){return t==='one2one'?C.o2o:t==='many2many'?C.m2m:C.m2o;}
1108
+
1109
+ // offset: pixels to shift perpendicular to the edge direction (for parallel edges)
1110
+ function drawEdgeLine(a,b,col,dashed,label,manyA,manyB,off){
1111
+ var p1=edgePt(a,b.x+b.w/2,b.y+b.h/2);
1112
+ var p2=edgePt(b,a.x+a.w/2,a.y+a.h/2);
1113
+ var dx=p2.x-p1.x,dy=p2.y-p1.y,d=Math.sqrt(dx*dx+dy*dy)||1;
1114
+ var ux=dx/d,uy=dy/d;
1115
+ var px=-uy*off,py=ux*off; // perpendicular shift
1116
+ var q1x=p1.x+px,q1y=p1.y+py,q2x=p2.x+px,q2y=p2.y+py;
1117
+ ctx.strokeStyle=col+'55';ctx.lineWidth=1.5;
1118
+ ctx.setLineDash(dashed?[5,4]:[]);
1119
+ ctx.beginPath();ctx.moveTo(q1x,q1y);ctx.lineTo(q2x,q2y);ctx.stroke();
1120
+ ctx.setLineDash([]);
1121
+ pushBadge(q1x,q1y,ux,uy,manyA,col);
1122
+ pushBadge(q2x,q2y,-ux,-uy,manyB,col);
1123
+ if(label){
1124
+ var mx=(q1x+q2x)/2,my=(q1y+q2y)/2;
1125
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='center';ctx.textBaseline='middle';
1126
+ ctx.fillText(label,mx-uy*14,my+ux*14);
1127
+ ctx.textBaseline='alphabetic';
1128
+ }
1129
+ }
1130
+
1131
+ // canonical key for a node pair regardless of direction
1132
+ function pairKey(a,b){
1133
+ var ai=nodes.indexOf(a),bi=nodes.indexOf(b);
1134
+ return ai<bi?ai+'-'+bi:bi+'-'+ai;
1135
+ }
1136
+
1137
+ function collectEdges(){
1138
+ var edges=[];
1139
+ D.relations.forEach(function(r){
1140
+ var col=ecol(r.type);
1141
+ if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
1142
+ var sn=nodes[nIdx[r.source]],pn=nodes[nIdx[r.through]],tn=nodes[nIdx[r.target]];
1143
+ if(sn&&pn)edges.push({a:sn,b:pn,col:col,dashed:true,label:r.foreignKey||'',manyA:false,manyB:true,off:0});
1144
+ if(tn&&pn)edges.push({a:tn,b:pn,col:col,dashed:true,label:r.otherKey||'',manyA:false,manyB:true,off:0});
1145
+ }else{
1146
+ var sn=nodes[nIdx[r.source]],tn=nodes[nIdx[r.target]];
1147
+ if(sn&&tn&&sn!==tn){
1148
+ var manyA=r.carInverse?r.carInverse.endsWith('n'):r.type!=='one2one';
1149
+ var manyB=r.carDirect?r.carDirect.endsWith('n'):r.type==='many2many';
1150
+ edges.push({a:sn,b:tn,col:col,dashed:false,label:r.label,manyA:manyA,manyB:manyB,off:0});
1151
+ }
1152
+ }
1153
+ });
1154
+ // assign perpendicular offsets to parallel edges (same node pair)
1155
+ var cnt={},idx={};
1156
+ edges.forEach(function(e){var k=pairKey(e.a,e.b);cnt[k]=(cnt[k]||0)+1;});
1157
+ edges.forEach(function(e){
1158
+ var k=pairKey(e.a,e.b),n=cnt[k];
1159
+ var i=idx[k]=(idx[k]||0);
1160
+ e.off=n>1?(i-(n-1)/2)*16:0;
1161
+ idx[k]++;
1162
+ });
1163
+ return edges;
1164
+ }
1165
+
1166
+ function drawNode(n){
1167
+ var x=n.x,y=n.y,w=n.w,h=n.h,e=n.e;
1168
+ var bg=e.isPivot?C.pb:C.nb,hd=e.isPivot?C.ph:C.nh;
1169
+ ctx.save();ctx.shadowColor='rgba(0,0,0,0.45)';ctx.shadowBlur=14;ctx.shadowOffsetY=4;
1170
+ ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();ctx.restore();
1171
+ ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();
1172
+ ctx.strokeStyle=n===drag?C.bs:C.bo;ctx.lineWidth=1.2;rr(x,y,w,h,7);ctx.stroke();
1173
+ ctx.fillStyle=hd;rr(x,y,w,HH,7);ctx.fill();ctx.fillRect(x,y+HH-7,w,7);
1174
+ ctx.strokeStyle=C.bo;ctx.lineWidth=1;
1175
+ ctx.beginPath();ctx.moveTo(x,y+HH);ctx.lineTo(x+w,y+HH);ctx.stroke();
1176
+ ctx.fillStyle=e.isPivot?C.mu:C.tx;
1177
+ ctx.font='600 12px -apple-system,BlinkMacSystemFont,sans-serif';
1178
+ ctx.textAlign='left';ctx.textBaseline='middle';
1179
+ ctx.fillText(e.name,x+FP,y+HH/2);
1180
+ if(e.isPivot){
1181
+ ctx.fillStyle='#3d444d';ctx.font='9px monospace';ctx.textAlign='right';
1182
+ ctx.fillText('pivot',x+w-FP,y+HH/2);
1183
+ }
1184
+ e.fields.forEach(function(f,fi){
1185
+ var fy=y+HH+fi*FH+FH/2+3;
1186
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
1187
+ ctx.fillText(f.type,x+w-FP-(f.required&&!f.pk?14:0),fy);
1188
+ if(f.pk||f.fk){
1189
+ var tag=f.pk?'PK':'FK',tc=f.pk?C.pk:C.fk;
1190
+ ctx.fillStyle=tc+'22';ctx.strokeStyle=tc+'66';ctx.lineWidth=1;
1191
+ rr(x+FP,fy-6,12,12,2);ctx.fill();ctx.stroke();
1192
+ ctx.fillStyle=tc;ctx.font='7px monospace';ctx.textAlign='center';
1193
+ ctx.fillText(tag,x+FP+6,fy+0.5);
1194
+ }
1195
+ ctx.fillStyle=f.pk?C.pk:f.fk?C.fk:C.tx;
1196
+ ctx.font=(f.required&&!f.pk?'600 ':'')+'11px monospace';
1197
+ ctx.textAlign='left';ctx.textBaseline='middle';
1198
+ ctx.fillText(f.name,x+FP+(f.pk||f.fk?16:0),fy);
1199
+ if(f.required&&!f.pk){
1200
+ ctx.fillStyle='#f85149';ctx.beginPath();
1201
+ ctx.arc(x+w-FP-6,fy,2.5,0,Math.PI*2);ctx.fill();
1202
+ }
1203
+ });
1204
+ ctx.textBaseline='alphabetic';
1205
+ }
1206
+
1207
+ function drawLegend(){
1208
+ var cvW=cv.width/dpr,cvH=cv.height/dpr;
1209
+ var y0=cvH-LGH,y1=cvH-LGH+18,y2=cvH-LGH+38;
1210
+
1211
+ ctx.fillStyle='#161b22';
1212
+ ctx.fillRect(0,y0,cvW,LGH);
1213
+ ctx.strokeStyle='#30363d';ctx.lineWidth=1;
1214
+ ctx.beginPath();ctx.moveTo(0,y0);ctx.lineTo(cvW,y0);ctx.stroke();
1215
+
1216
+ // Row 1: relation line types
1217
+ var types=[
1218
+ {c:C.m2o,l:'many2one',d:false},
1219
+ {c:C.o2o,l:'one2one',d:false},
1220
+ {c:C.m2m,l:'many2many',d:true}
1221
+ ];
1222
+ ctx.textAlign='left';ctx.textBaseline='middle';ctx.font='10px monospace';
1223
+ var x=14;
1224
+ types.forEach(function(it){
1225
+ ctx.strokeStyle=it.c;ctx.lineWidth=1.5;ctx.setLineDash(it.d?[4,3]:[]);
1226
+ ctx.beginPath();ctx.moveTo(x,y1);ctx.lineTo(x+14,y1);ctx.stroke();
1227
+ ctx.setLineDash([]);
1228
+ ctx.fillStyle=C.mu;
1229
+ ctx.fillText(it.l,x+18,y1);
1230
+ x+=18+ctx.measureText(it.l).width+22;
1231
+ });
1232
+
1233
+ // Row 2: cardinality symbols
1234
+ function badge2(cx,cy,many,col){
1235
+ ctx.fillStyle=col;ctx.strokeStyle=col;ctx.lineWidth=1;
1236
+ ctx.beginPath();ctx.arc(cx,cy,6,0,Math.PI*2);ctx.fill();
1237
+ ctx.save();ctx.translate(cx,cy);
1238
+ ctx.strokeStyle=C.bg;ctx.lineWidth=1.2;ctx.lineCap='round';
1239
+ ctx.beginPath();
1240
+ if(many){
1241
+ ctx.moveTo(-4,-3);ctx.lineTo(4,0);ctx.moveTo(-4,3);ctx.lineTo(4,0);
1242
+ ctx.moveTo(-4,0);ctx.lineTo(4,0);ctx.moveTo(-4,-3);ctx.lineTo(-4,3);
1243
+ }else{
1244
+ ctx.moveTo(-2,-3);ctx.lineTo(-2,3);ctx.moveTo(2,-3);ctx.lineTo(2,3);
1245
+ }
1246
+ ctx.stroke();ctx.restore();
1247
+ }
1248
+
1249
+ ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
1250
+ x=14;
1251
+ var pairs=[
1252
+ {a:false,b:true,col:C.m2o,l:'many2one (N:1)'},
1253
+ {a:false,b:false,col:C.o2o,l:'one2one (1:1)'},
1254
+ {a:false,b:true,col:C.m2m,l:'pivot edge (1:N)'}
1255
+ ];
1256
+ pairs.forEach(function(p){
1257
+ ctx.strokeStyle=p.col+'55';ctx.lineWidth=1.2;ctx.setLineDash(p.col===C.m2m?[4,3]:[]);
1258
+ ctx.beginPath();ctx.moveTo(x+8,y2);ctx.lineTo(x+56,y2);ctx.stroke();
1259
+ ctx.setLineDash([]);
1260
+ badge2(x+8,y2,p.a,p.col);
1261
+ badge2(x+56,y2,p.b,p.col);
1262
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
1263
+ ctx.fillText(p.l,x+66,y2);
1264
+ x+=66+ctx.measureText(p.l).width+24;
1265
+ });
1266
+
1267
+ ctx.fillStyle='#3d444d';ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
1268
+ ctx.fillText('drag \xB7 scroll to zoom \xB7 dbl-click to reset',cvW-14,y2);
1269
+ ctx.textBaseline='alphabetic';
1270
+ }
1271
+
1272
+ function draw(){
1273
+ var W=cv.width/dpr,H=cv.height/dpr;
1274
+ ctx.setTransform(dpr,0,0,dpr,0,0);
1275
+ ctx.clearRect(0,0,W,H);
1276
+ ctx.fillStyle=C.bg;ctx.fillRect(0,0,W,H);
1277
+ ctx.save();ctx.translate(pan.x,pan.y);ctx.scale(zm,zm);
1278
+ badges=[];
1279
+ var edges=collectEdges();
1280
+ edges.forEach(function(e){drawEdgeLine(e.a,e.b,e.col,e.dashed,e.label,e.manyA,e.manyB,e.off);});
1281
+ nodes.forEach(function(n){drawNode(n);});
1282
+ badges.forEach(function(b){drawBadge(b);});
1283
+ ctx.restore();
1284
+ drawLegend();
1285
+ }
1286
+
1287
+ // \u2500\u2500 Interaction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1288
+ var drag=null,dox=0,doy=0,isPan=false,ps={x:0,y:0};
1289
+
1290
+ function toW(mx,my){return{x:(mx-pan.x)/zm,y:(my-pan.y)/zm};}
1291
+ function hitTest(wx,wy){
1292
+ for(var i=nodes.length-1;i>=0;i--){var n=nodes[i];if(wx>=n.x&&wx<=n.x+n.w&&wy>=n.y&&wy<=n.y+n.h)return n;}
1293
+ return null;
1294
+ }
1295
+
1296
+ cv.addEventListener('mousedown',function(ev){
1297
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1298
+ var w=toW(mx,my),h=hitTest(w.x,w.y);
1299
+ if(h){drag=h;dox=w.x-h.x;doy=w.y-h.y;}else{isPan=true;ps={x:mx-pan.x,y:my-pan.y};}
1300
+ cv.style.cursor='grabbing';
1301
+ });
1302
+ window.addEventListener('mousemove',function(ev){
1303
+ if(!drag&&!isPan)return;
1304
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1305
+ if(drag){var w=toW(mx,my);drag.x=w.x-dox;drag.y=w.y-doy;}
1306
+ else{pan.x=mx-ps.x;pan.y=my-ps.y;}
1307
+ draw();
1308
+ });
1309
+ window.addEventListener('mouseup',function(){drag=null;isPan=false;cv.style.cursor='grab';});
1310
+ cv.addEventListener('wheel',function(ev){
1311
+ ev.preventDefault();
1312
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1313
+ var d=ev.deltaY<0?1.1:0.91,nz=Math.max(0.25,Math.min(3,zm*d));
1314
+ pan.x=mx-(mx-pan.x)*(nz/zm);pan.y=my-(my-pan.y)*(nz/zm);zm=nz;draw();
1315
+ },{passive:false});
1316
+ cv.addEventListener('dblclick',function(){
1317
+ nodes.forEach(function(n,i){n.x=origPos[i].x;n.y=origPos[i].y;});
1318
+ var r=cv.getBoundingClientRect();fitView(r.width,r.height);draw();
1319
+ });
1320
+ window.addEventListener('resize',function(){
1321
+ resize();var r=cv.getBoundingClientRect();
1322
+ computeLayout(r.width,r.height);savePos();fitView(r.width,r.height);draw();
1323
+ });
1324
+
1325
+ document.getElementById('er-dl').addEventListener('click',function(){
1326
+ var SCALE=3;
1327
+ var cw=cv.width/dpr,ch=cv.height/dpr;
1328
+ var oc=document.createElement('canvas');
1329
+ oc.width=Math.round(cw*SCALE);oc.height=Math.round(ch*SCALE);
1330
+ var sav={cv:cv,ctx:ctx,dpr:dpr};
1331
+ cv=oc;ctx=oc.getContext('2d');dpr=SCALE;
1332
+ draw();
1333
+ cv=sav.cv;ctx=sav.ctx;dpr=sav.dpr;
1334
+ var a=document.createElement('a');
1335
+ a.download='er-diagram.png';a.href=oc.toDataURL('image/png');a.click();
1336
+ });
1337
+
1338
+ // \u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1339
+ requestAnimationFrame(function(){
1340
+ resize();
1341
+ var r=cv.getBoundingClientRect();
1342
+ computeLayout(r.width,r.height);
1343
+ var h=requiredH();
1344
+ resize(h);r=cv.getBoundingClientRect();
1345
+ computeLayout(r.width,r.height);
1346
+ savePos();fitView(r.width,r.height);draw();
1347
+ });
1348
+ })();
1349
+ `}</script>`
1350
+ );
1351
+ }
1352
+
640
1353
  // src/router/templates/about.template.ts
641
1354
  var _dir = (0, import_node_path.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
642
1355
  var LOGO_SRC = (() => {
643
1356
  try {
644
- const buf = (0, import_node_fs2.readFileSync)((0, import_node_path.join)(_dir, "../../assets/logo-color.png"));
645
- return `data:image/png;base64,${buf.toString("base64")}`;
1357
+ const buf = (0, import_node_fs2.readFileSync)((0, import_node_path.join)(_dir, "../../assets/yrest-lockup.svg"));
1358
+ return `data:image/svg+xml;base64,${buf.toString("base64")}`;
646
1359
  } catch {
647
1360
  return "";
648
1361
  }
@@ -653,6 +1366,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
653
1366
  const customRoutes = storage.getRoutes();
654
1367
  const base = options.base;
655
1368
  const host = `http://${options.host}:${options.port}`;
1369
+ const swaggerUrl = `https://petstore.swagger.io/?url=${encodeURIComponent(`${host}/_openapi.json`)}`;
656
1370
  const modes = [];
657
1371
  if (options.watch) modes.push(badge("watch", "#38bdf8", "#38bdf818"));
658
1372
  if (options.readonly) modes.push(badge("readonly", "#94a3b8", "#94a3b818"));
@@ -663,9 +1377,12 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
663
1377
  if (handlers.size > 0) modes.push(badge(`handlers \xB7 ${handlers.size}`, "#f0883e", "#f0883e18"));
664
1378
  if (options.idStrategy !== "increment")
665
1379
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
1380
+ const erDiagram = erDiagramSection(generateERData(storage));
666
1381
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
667
1382
  const paginationDesc = options.pageable.enabled ? `Pageable mode active \u2014 default limit <code>${options.pageable.limit}</code>. Response wrapped in <code>{ data, pagination }</code>.` : `Returns the requested slice. <code>X-Total-Count</code> header reflects the total before pagination.`;
668
- return `<!DOCTYPE html>
1383
+ return (
1384
+ /*html*/
1385
+ `<!DOCTYPE html>
669
1386
  <html lang="en">
670
1387
  <head>
671
1388
  <meta charset="UTF-8">
@@ -692,20 +1409,30 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
692
1409
  width: 100%;
693
1410
  background: linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a2332 100%);
694
1411
  border-bottom: 1px solid var(--border);
695
- padding: 48px 32px 40px;
1412
+ padding: 20px 32px 16px;
696
1413
  }
697
- .banner-inner { max-width: 1100px; margin: 0 auto; }
698
- .banner h1 { font-size: clamp(36px, 6vw, 60px); font-weight: 800; letter-spacing: -2px; line-height: 1; }
1414
+ .banner-inner { margin: 0 auto; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
1415
+ .banner-left { flex:1; min-width:0; }
1416
+ .banner h1 { font-size: clamp(24px, 4vw, 40px); font-weight: 800; letter-spacing: -1.5px; line-height: 1; }
699
1417
  .banner h1 .y { color: var(--text); }
700
1418
  .banner h1 .rest { color: var(--accent); }
701
- .banner p { color: var(--text-muted); margin-top: 10px; font-size: 15px; }
702
- .banner-meta { display: flex; gap: 24px; margin-top: 20px; flex-wrap: wrap; }
703
- .banner-meta span { color: var(--text-muted); font-size: 13px; }
1419
+ .banner p { color: var(--text-muted); margin-top: 6px; font-size: 14px; }
1420
+ .banner-meta { display: flex; gap: 20px; margin-top: 10px; flex-wrap: wrap; }
1421
+ .banner-meta span { color: var(--text-muted); font-size: 12px; }
704
1422
  .banner-meta span strong { color: var(--text); font-family: monospace; }
1423
+ .api-links { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
1424
+ .btn-api { display:inline-block; padding:5px 12px; border-radius:6px; border:1px solid var(--border); color:var(--text-muted); text-decoration:none; font-size:12px; font-weight:500; background:var(--bg-card); white-space:nowrap; transition:border-color .15s,color .15s; }
1425
+ .btn-api:hover { border-color:var(--border-hi); color:var(--text); }
1426
+ .btn-swagger { border-color:var(--accent); color:var(--accent); }
1427
+ .btn-swagger:hover { background:#58a6ff18; }
705
1428
 
706
1429
  /* \u2500\u2500 Layout \u2500\u2500 */
707
- .wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 48px; }
1430
+ .wrap { padding: 24px 32px 48px; }
708
1431
  h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin: 32px 0 12px; }
1432
+ .about-grid { display:grid; grid-template-columns:5fr 7fr; gap:28px; align-items:start; }
1433
+ @media(max-width:960px) { .about-grid { grid-template-columns:1fr; } }
1434
+ .col-left, .col-right { min-width:0; }
1435
+ .col-left > h2:first-child, .col-right > h2:first-child { margin-top:0; }
709
1436
 
710
1437
  /* \u2500\u2500 Cards \u2500\u2500 */
711
1438
  .card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px; }
@@ -721,10 +1448,8 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
721
1448
  .modes { display: flex; gap: 8px; flex-wrap: wrap; min-height: 28px; align-items: center; }
722
1449
  .badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; white-space: nowrap; }
723
1450
 
724
- /* \u2500\u2500 Endpoints grid (2 cols on wide, 1 col on narrow) \u2500\u2500 */
725
- .endpoints-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
726
- @media (max-width: 860px) { .endpoints-grid { grid-template-columns: 1fr; } }
727
- .nested-card { grid-column: 1 / -1; }
1451
+ /* \u2500\u2500 Endpoints list \u2500\u2500 */
1452
+ .endpoints-grid { display: flex; flex-direction: column; gap: 10px; }
728
1453
 
729
1454
  /* \u2500\u2500 Accordion (details/summary) \u2500\u2500 */
730
1455
  .resource-card {
@@ -789,56 +1514,71 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
789
1514
 
790
1515
  <div class="banner">
791
1516
  <div class="banner-inner">
792
- ${LOGO_SRC ? `<img src="${LOGO_SRC}" alt="yRest" height="68" style="display:block;margin-bottom:0px" />` : `<h1><span class="y">y</span><span class="rest">Rest</span></h1>`}
793
- <p>Zero-config REST API mock server</p>
794
- <div class="banner-meta">
795
- <span>URL <strong>${host}</strong></span>
796
- <span>Base <strong>${base || "/"}</strong></span>
797
- <span>File <strong>${options.file}</strong></span>
798
- <span>Collections <strong>${collections.length}</strong></span>
1517
+ <div class="banner-left">
1518
+ ${LOGO_SRC ? `<img src="${LOGO_SRC}" alt="yRest" height="36" style="display:block;margin-bottom:4px" />` : `<h1><span class="y">y</span><span class="rest">Rest</span></h1>`}
1519
+ <p>Zero-config REST API mock server</p>
1520
+ <div class="banner-meta">
1521
+ <span>URL <strong>${host}</strong></span>
1522
+ <span>Base <strong>${base || "/"}</strong></span>
1523
+ <span>File <strong>${options.file}</strong></span>
1524
+ <span>Collections <strong>${collections.length}</strong></span>
1525
+ </div>
1526
+ </div>
1527
+ <div class="api-links">
1528
+ <a href="/_openapi" target="_blank" class="btn-api">OpenAPI YAML</a>
1529
+ <a href="/_openapi.json" target="_blank" class="btn-api">OpenAPI JSON</a>
1530
+ <a href="${swaggerUrl}" target="_blank" class="btn-api btn-swagger">Swagger UI \u2197</a>
799
1531
  </div>
800
1532
  </div>
801
1533
  </div>
802
1534
 
803
1535
  <div class="wrap">
1536
+ <div class="about-grid">
804
1537
 
805
- <h2>Active Modes</h2>
806
- <div class="card">
807
- <div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
808
- ${options.watch ? `<div class="warn">\u26A0 <strong>Watch mode:</strong> data changes in existing collections reload automatically. Adding or removing entire collections requires a server restart.</div>` : ""}
809
- </div>
1538
+ <div class="col-left">
1539
+ <h2>Active Modes</h2>
1540
+ <div class="card">
1541
+ <div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
1542
+ ${options.watch ? `<div class="warn">\u26A0 <strong>Watch mode:</strong> data changes in existing collections reload automatically. Adding or removing entire collections requires a server restart.</div>` : ""}
1543
+ </div>
810
1544
 
811
- <h2>Endpoints</h2>
812
- <div class="endpoints-grid">
813
- ${accordions}
814
- ${nestedRoutesAccordion(relations, base)}
815
- ${options.snapshot ? snapshotAccordion() : ""}
816
- ${customRoutesAccordion(customRoutes, base, handlers)}
817
- ${handlersAccordion(handlers, customRoutes, base)}
818
- </div>
1545
+ <h2>Endpoints</h2>
1546
+ <div class="endpoints-grid">
1547
+ ${accordions}
1548
+ ${nestedRoutesAccordion(relations, base)}
1549
+ ${options.snapshot ? snapshotAccordion() : ""}
1550
+ ${customRoutesAccordion(customRoutes, base, handlers)}
1551
+ ${handlersAccordion(handlers, customRoutes, base)}
1552
+ </div>
819
1553
 
820
- <h2>Query Parameters</h2>
821
- <div class="card">
822
- <table class="param-table">
823
- <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
824
- <tbody>
825
- <tr><td><code>?field=value</code></td><td><code>?name=Ana&amp;role=admin</code></td><td>Filter by any field. Multiple params are ANDed.</td></tr>
826
- <tr><td><code>?field_gte / _lte</code></td><td><code>?price_gte=10&amp;price_lte=50</code></td><td>Numeric or lexicographic range. Works with any comparable field.</td></tr>
827
- <tr><td><code>?field_ne</code></td><td><code>?status_ne=inactive</code></td><td>Exclude items where the field equals the value.</td></tr>
828
- <tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
829
- <tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
830
- <tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
831
- <tr><td><code>?_q</code></td><td><code>?_q=ana</code></td><td>Full-text search across all scalar fields (case-insensitive substring).</td></tr>
832
- <tr><td><code>?_sort &amp; ?_order</code></td><td><code>?_sort=name&amp;_order=desc</code></td><td>Sort by field. <code>_order</code>: <code>asc</code> (default) or <code>desc</code>.</td></tr>
833
- <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
834
- <tr><td><code>?_expand</code></td><td><code>?_expand=user</code></td><td>Embed a related parent object inline. Requires <code>_rel</code> in the YAML file.</td></tr>
835
- <tr><td><code>?_embed</code></td><td><code>?_embed=posts</code></td><td>Embed child collections into each parent item. Requires <code>_rel</code> in the YAML file.</td></tr>
836
- <tr><td><code>?_fields</code></td><td><code>?_fields=id,name</code></td><td>Return only the specified fields. Applied last \u2014 can include embedded/expanded keys.</td></tr>
837
- </tbody>
838
- </table>
839
- </div>
1554
+ <h2>Query Parameters</h2>
1555
+ <div class="card">
1556
+ <table class="param-table">
1557
+ <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
1558
+ <tbody>
1559
+ <tr><td><code>?field=value</code></td><td><code>?name=Ana&amp;role=admin</code></td><td>Filter by any field. Multiple params are ANDed.</td></tr>
1560
+ <tr><td><code>?field_gte / _lte</code></td><td><code>?price_gte=10&amp;price_lte=50</code></td><td>Numeric or lexicographic range. Works with any comparable field.</td></tr>
1561
+ <tr><td><code>?field_ne</code></td><td><code>?status_ne=inactive</code></td><td>Exclude items where the field equals the value.</td></tr>
1562
+ <tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
1563
+ <tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
1564
+ <tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
1565
+ <tr><td><code>?_q</code></td><td><code>?_q=ana</code></td><td>Full-text search across all scalar fields (case-insensitive substring).</td></tr>
1566
+ <tr><td><code>?_sort &amp; ?_order</code></td><td><code>?_sort=name&amp;_order=desc</code></td><td>Sort by field. <code>_order</code>: <code>asc</code> (default) or <code>desc</code>.</td></tr>
1567
+ <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
1568
+ <tr><td><code>?_expand</code></td><td><code>?_expand=user</code></td><td>Embed a related parent object inline. Requires <code>_rel</code> in the YAML file.</td></tr>
1569
+ <tr><td><code>?_embed</code></td><td><code>?_embed=posts</code></td><td>Embed child collections into each parent item. Requires <code>_rel</code> in the YAML file.</td></tr>
1570
+ <tr><td><code>?_fields</code></td><td><code>?_fields=id,name</code></td><td>Return only the specified fields. Applied last \u2014 can include embedded/expanded keys.</td></tr>
1571
+ </tbody>
1572
+ </table>
1573
+ </div>
1574
+ </div>
1575
+
1576
+ <div class="col-right">
1577
+ ${erDiagram}
1578
+ ${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
1579
+ </div>
840
1580
 
841
- ${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
1581
+ </div>
842
1582
 
843
1583
  <footer>
844
1584
  Powered by <a href="https://github.com/aggiovato/yRest" target="_blank">@yrest/cli</a> &nbsp;\xB7&nbsp; <a href="/_about">/_about</a>
@@ -846,7 +1586,8 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
846
1586
 
847
1587
  </div>
848
1588
  </body>
849
- </html>`;
1589
+ </html>`
1590
+ );
850
1591
  }
851
1592
 
852
1593
  // src/router/routes/about.routes.ts
@@ -1447,12 +2188,23 @@ var NestedRouteCommand = class {
1447
2188
  relations;
1448
2189
  base;
1449
2190
  register(server) {
2191
+ const registered = /* @__PURE__ */ new Set();
1450
2192
  for (const [source, fields] of Object.entries(this.relations)) {
1451
2193
  for (const [key, def] of Object.entries(fields)) {
1452
2194
  if (def.type === "many2many") {
1453
- this.registerMany2Many(server, source, key, def);
2195
+ const fwd = `${this.base}/${source}/:id/${key}`;
2196
+ const inv = `${this.base}/${def.target}/:id/${source}`;
2197
+ if (!registered.has(fwd) && !registered.has(inv)) {
2198
+ registered.add(fwd);
2199
+ registered.add(inv);
2200
+ this.registerMany2Many(server, source, key, def);
2201
+ }
1454
2202
  } else {
1455
- this.registerFkRelation(server, source, key, def.target, def.type);
2203
+ const collPath = `${this.base}/${def.target}/:id/${source}`;
2204
+ if (!registered.has(collPath)) {
2205
+ registered.add(collPath);
2206
+ this.registerFkRelation(server, source, key, def.target, def.type);
2207
+ }
1456
2208
  }
1457
2209
  }
1458
2210
  }
@@ -1511,6 +2263,379 @@ var NestedRouteCommand = class {
1511
2263
  }
1512
2264
  };
1513
2265
 
2266
+ // src/router/routes/openapi.routes.ts
2267
+ init_cjs_shims();
2268
+
2269
+ // src/openapi/generateOpenApi.ts
2270
+ init_cjs_shims();
2271
+
2272
+ // src/openapi/inferSchema.ts
2273
+ init_cjs_shims();
2274
+ function buildCollectionSchema(items, fieldDefs = {}) {
2275
+ const sample = items.slice(0, 10);
2276
+ const inferredTypes = /* @__PURE__ */ new Map();
2277
+ for (const item of sample) {
2278
+ for (const [key, value] of Object.entries(item)) {
2279
+ if (!inferredTypes.has(key)) {
2280
+ inferredTypes.set(key, jsToOpenApiType(value));
2281
+ }
2282
+ }
2283
+ }
2284
+ const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
2285
+ const properties = {};
2286
+ const required = [];
2287
+ for (const field of allFields) {
2288
+ const def = fieldDefs[field];
2289
+ const inferred = inferredTypes.get(field) ?? "string";
2290
+ const prop = {
2291
+ type: def?.type ?? inferred
2292
+ };
2293
+ if (def?.format) prop.format = def.format;
2294
+ if (def?.description) prop.description = def.description;
2295
+ if (def?.enum) prop.enum = def.enum;
2296
+ if (def?.default !== void 0) prop.default = def.default;
2297
+ properties[field] = prop;
2298
+ if (def?.required === true) required.push(field);
2299
+ }
2300
+ const schema = { type: "object", properties };
2301
+ if (required.length > 0) schema.required = required;
2302
+ return schema;
2303
+ }
2304
+ function jsToOpenApiType(value) {
2305
+ if (value === null || value === void 0) return "string";
2306
+ if (typeof value === "boolean") return "boolean";
2307
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
2308
+ if (Array.isArray(value)) return "array";
2309
+ if (typeof value === "object") return "object";
2310
+ return "string";
2311
+ }
2312
+
2313
+ // src/openapi/buildPaths.ts
2314
+ init_cjs_shims();
2315
+ var COLLECTION_QUERY_PARAMS = [
2316
+ { name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
2317
+ { name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
2318
+ { name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
2319
+ {
2320
+ name: "_order",
2321
+ in: "query",
2322
+ schema: { type: "string", enum: ["asc", "desc"] },
2323
+ description: "Sort direction"
2324
+ },
2325
+ {
2326
+ name: "_q",
2327
+ in: "query",
2328
+ schema: { type: "string" },
2329
+ description: "Full-text search across all scalar fields (case-insensitive)"
2330
+ },
2331
+ {
2332
+ name: "_expand",
2333
+ in: "query",
2334
+ schema: { type: "string" },
2335
+ description: "Embed related parent object inline (e.g. ?_expand=user)"
2336
+ },
2337
+ {
2338
+ name: "_embed",
2339
+ in: "query",
2340
+ schema: { type: "string" },
2341
+ description: "Embed child collection into each item (e.g. ?_embed=posts)"
2342
+ },
2343
+ {
2344
+ name: "_fields",
2345
+ in: "query",
2346
+ schema: { type: "string" },
2347
+ description: "Comma-separated field projection (e.g. ?_fields=id,name)"
2348
+ }
2349
+ ];
2350
+ var ID_PATH_PARAM = {
2351
+ name: "id",
2352
+ in: "path",
2353
+ required: true,
2354
+ schema: { type: "string" },
2355
+ description: "Item id"
2356
+ };
2357
+ function toOpenApiPath(fastifyPath) {
2358
+ return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
2359
+ }
2360
+ function extractPathParams(fastifyPath) {
2361
+ const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
2362
+ return matches.map((m) => ({
2363
+ name: m.slice(1),
2364
+ in: "path",
2365
+ required: true,
2366
+ schema: { type: "string" }
2367
+ }));
2368
+ }
2369
+ function singular(name) {
2370
+ return name.endsWith("s") ? name.slice(0, -1) : name;
2371
+ }
2372
+ function schemaRef(name) {
2373
+ return { $ref: `#/components/schemas/${name}` };
2374
+ }
2375
+ function jsonContent(schema) {
2376
+ return { "application/json": { schema } };
2377
+ }
2378
+ function ok(schema, description = "OK") {
2379
+ return { description, content: jsonContent(schema) };
2380
+ }
2381
+ function buildCrudPaths(collection, base, schemaName) {
2382
+ const ref = schemaRef(schemaName);
2383
+ const tag = collection;
2384
+ const sing = singular(collection);
2385
+ const collPath = `${base}/${collection}`;
2386
+ const itemPath = `${base}/${collection}/{id}`;
2387
+ return {
2388
+ [collPath]: {
2389
+ get: {
2390
+ summary: `List ${collection}`,
2391
+ tags: [tag],
2392
+ parameters: COLLECTION_QUERY_PARAMS,
2393
+ responses: {
2394
+ "200": {
2395
+ description: "OK",
2396
+ content: jsonContent({ type: "array", items: ref }),
2397
+ headers: {
2398
+ "X-Total-Count": {
2399
+ description: "Total items (when using ?_page / ?_limit)",
2400
+ schema: { type: "integer" }
2401
+ }
2402
+ }
2403
+ }
2404
+ }
2405
+ },
2406
+ post: {
2407
+ summary: `Create ${sing}`,
2408
+ tags: [tag],
2409
+ requestBody: { required: true, content: jsonContent(ref) },
2410
+ responses: { "201": ok(ref, "Created") }
2411
+ }
2412
+ },
2413
+ [itemPath]: {
2414
+ get: {
2415
+ summary: `Get ${sing}`,
2416
+ tags: [tag],
2417
+ parameters: [
2418
+ ID_PATH_PARAM,
2419
+ ...COLLECTION_QUERY_PARAMS.filter(
2420
+ (p) => ["_expand", "_embed", "_fields"].includes(p.name)
2421
+ )
2422
+ ],
2423
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
2424
+ },
2425
+ put: {
2426
+ summary: `Replace ${sing}`,
2427
+ tags: [tag],
2428
+ parameters: [ID_PATH_PARAM],
2429
+ requestBody: { required: true, content: jsonContent(ref) },
2430
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
2431
+ },
2432
+ patch: {
2433
+ summary: `Update ${sing}`,
2434
+ tags: [tag],
2435
+ parameters: [ID_PATH_PARAM],
2436
+ requestBody: { required: false, content: jsonContent(ref) },
2437
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
2438
+ },
2439
+ delete: {
2440
+ summary: `Delete ${sing}`,
2441
+ tags: [tag],
2442
+ parameters: [ID_PATH_PARAM],
2443
+ responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
2444
+ }
2445
+ }
2446
+ };
2447
+ }
2448
+ function buildRelationPaths(relations, base) {
2449
+ const paths = {};
2450
+ for (const [source, fields] of Object.entries(relations)) {
2451
+ for (const [key, def] of Object.entries(fields)) {
2452
+ if (def.type === "many2many") {
2453
+ const forwardPath = `${base}/${source}/{id}/${key}`;
2454
+ const inversePath = `${base}/${def.target}/{id}/${source}`;
2455
+ paths[forwardPath] = {
2456
+ get: {
2457
+ summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
2458
+ tags: [source],
2459
+ parameters: [ID_PATH_PARAM],
2460
+ responses: {
2461
+ "200": {
2462
+ description: "OK",
2463
+ content: jsonContent({ type: "array", items: { type: "object" } })
2464
+ },
2465
+ "404": { description: `${singular(source)} not found` }
2466
+ }
2467
+ }
2468
+ };
2469
+ paths[inversePath] = {
2470
+ get: {
2471
+ summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
2472
+ tags: [def.target],
2473
+ parameters: [ID_PATH_PARAM],
2474
+ responses: {
2475
+ "200": {
2476
+ description: "OK",
2477
+ content: jsonContent({ type: "array", items: { type: "object" } })
2478
+ },
2479
+ "404": { description: `${singular(def.target)} not found` }
2480
+ }
2481
+ }
2482
+ };
2483
+ } else {
2484
+ const parentSing = singular(def.target);
2485
+ const collPath = `${base}/${def.target}/{id}/${source}`;
2486
+ const isOne2One = def.type === "one2one";
2487
+ const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
2488
+ paths[collPath] = {
2489
+ get: {
2490
+ summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
2491
+ tags: [def.target],
2492
+ parameters: [ID_PATH_PARAM],
2493
+ responses: {
2494
+ "200": { description: "OK", content: jsonContent(responseSchema) },
2495
+ "404": { description: `${parentSing} not found` }
2496
+ }
2497
+ }
2498
+ };
2499
+ if (!isOne2One) {
2500
+ const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
2501
+ paths[itemPath] = {
2502
+ get: {
2503
+ summary: `Get single ${singular(source)} scoped to ${parentSing}`,
2504
+ tags: [def.target],
2505
+ parameters: [
2506
+ ID_PATH_PARAM,
2507
+ { name: "childId", in: "path", required: true, schema: { type: "string" } }
2508
+ ],
2509
+ responses: {
2510
+ "200": { description: "OK", content: jsonContent({ type: "object" }) },
2511
+ "404": { description: "Not found" }
2512
+ }
2513
+ }
2514
+ };
2515
+ }
2516
+ }
2517
+ }
2518
+ }
2519
+ return paths;
2520
+ }
2521
+ function buildCustomRoutePaths(routes, base) {
2522
+ const paths = {};
2523
+ for (const route of routes) {
2524
+ const openApiPath = toOpenApiPath(`${base}${route.path}`);
2525
+ const method = route.method.toLowerCase();
2526
+ const pathParams = extractPathParams(route.path);
2527
+ const responses = {};
2528
+ if (route.error) {
2529
+ responses[String(route.error)] = { description: `Forced error ${route.error}` };
2530
+ } else {
2531
+ const statuses = /* @__PURE__ */ new Set();
2532
+ for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
2533
+ if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
2534
+ if (route.response) statuses.add(route.response.status ?? 200);
2535
+ if (statuses.size === 0) statuses.add(200);
2536
+ for (const status of statuses) {
2537
+ const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
2538
+ responses[String(status)] = {
2539
+ description: status < 400 ? "OK" : "Error",
2540
+ ...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
2541
+ };
2542
+ }
2543
+ }
2544
+ const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
2545
+ const operation = {
2546
+ summary: `${route.method.toUpperCase()} ${route.path}`,
2547
+ description: desc,
2548
+ tags: ["custom"],
2549
+ ...pathParams.length > 0 ? { parameters: pathParams } : {},
2550
+ responses
2551
+ };
2552
+ if (!paths[openApiPath]) paths[openApiPath] = {};
2553
+ paths[openApiPath][method] = operation;
2554
+ }
2555
+ return paths;
2556
+ }
2557
+ function inferResponseSchema(body) {
2558
+ if (body === null || body === void 0) return {};
2559
+ if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
2560
+ const properties = {};
2561
+ for (const [key, value] of Object.entries(body)) {
2562
+ properties[key] = { type: jsToOpenApiType2(value) };
2563
+ }
2564
+ return { type: "object", properties };
2565
+ }
2566
+ function jsToOpenApiType2(value) {
2567
+ if (value === null || value === void 0) return "string";
2568
+ if (typeof value === "boolean") return "boolean";
2569
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
2570
+ if (Array.isArray(value)) return "array";
2571
+ if (typeof value === "object") return "object";
2572
+ return "string";
2573
+ }
2574
+
2575
+ // src/openapi/generateOpenApi.ts
2576
+ function generateOpenApi(storage, options, title = "yRest API") {
2577
+ const collections = Object.keys(storage.getData());
2578
+ const relations = storage.getRelations();
2579
+ const schemaBlock = storage.getSchema();
2580
+ const customRoutes = storage.getRoutes();
2581
+ const base = options.base ?? "";
2582
+ const schemas = {};
2583
+ for (const collection of collections) {
2584
+ const items = storage.getCollection(collection) ?? [];
2585
+ const fieldDefs = schemaBlock[collection] ?? {};
2586
+ const schemaName = toSchemaName(collection);
2587
+ schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
2588
+ }
2589
+ const paths = {};
2590
+ for (const collection of collections) {
2591
+ const schemaName = toSchemaName(collection);
2592
+ Object.assign(paths, buildCrudPaths(collection, base, schemaName));
2593
+ }
2594
+ Object.assign(paths, buildRelationPaths(relations, base));
2595
+ Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
2596
+ return {
2597
+ openapi: "3.0.3",
2598
+ info: {
2599
+ title,
2600
+ version: "1.0.0",
2601
+ description: "Generated by yRest from db.yml"
2602
+ },
2603
+ servers: [
2604
+ {
2605
+ url: `http://${options.host}:${options.port}${base}`,
2606
+ description: "yRest mock server"
2607
+ }
2608
+ ],
2609
+ paths,
2610
+ components: { schemas }
2611
+ };
2612
+ }
2613
+ function toSchemaName(collection) {
2614
+ const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
2615
+ return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
2616
+ }
2617
+
2618
+ // src/router/routes/openapi.routes.ts
2619
+ var import_yaml2 = require("yaml");
2620
+ var OpenApiRouteCommand = class {
2621
+ constructor(storage, options) {
2622
+ this.storage = storage;
2623
+ this.options = options;
2624
+ }
2625
+ storage;
2626
+ options;
2627
+ register(server) {
2628
+ server.get("/_openapi", (_req, reply) => {
2629
+ const doc = generateOpenApi(this.storage, this.options);
2630
+ reply.header("Content-Type", "text/yaml; charset=utf-8");
2631
+ return reply.send((0, import_yaml2.stringify)(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
2632
+ });
2633
+ server.get("/_openapi.json", (_req, reply) => {
2634
+ return reply.send(generateOpenApi(this.storage, this.options));
2635
+ });
2636
+ }
2637
+ };
2638
+
1514
2639
  // src/router/routes/snapshot.routes.ts
1515
2640
  init_cjs_shims();
1516
2641
  var SnapshotRouteCommand = class {
@@ -1592,6 +2717,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
1592
2717
  }
1593
2718
  const commands = [
1594
2719
  new AboutRouteCommand(storage, options, handlers),
2720
+ new OpenApiRouteCommand(storage, options),
1595
2721
  ...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
1596
2722
  new CustomRouteCommand(storage, options.base, handlers),
1597
2723
  ...buildResourceRouteCommands(storage, options)
@@ -1671,10 +2797,12 @@ function buildOptions(opts) {
1671
2797
  }
1672
2798
  function createInMemoryStorage(data) {
1673
2799
  const raw = data;
2800
+ const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
1674
2801
  const relations = parseRelations(raw["_rel"]);
1675
2802
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
2803
+ const schema = parseSchema(raw["_schema"]);
1676
2804
  const collections = Object.fromEntries(
1677
- Object.entries(raw).filter(([k]) => k !== "_rel" && k !== "_routes")
2805
+ Object.entries(raw).filter(([k]) => !RESERVED.has(k))
1678
2806
  );
1679
2807
  let snapshot = {
1680
2808
  data: deepCopyData(collections),
@@ -1684,6 +2812,7 @@ function createInMemoryStorage(data) {
1684
2812
  return {
1685
2813
  getData: () => collections,
1686
2814
  getRelations: () => relations,
2815
+ getSchema: () => schema,
1687
2816
  getRoutes: () => routes,
1688
2817
  getCollection: (name) => collections[name],
1689
2818
  setCollection: (name, items) => {