@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.mjs CHANGED
@@ -33,37 +33,136 @@ function parseRelations(raw) {
33
33
  }
34
34
  function normaliseRelationDef(key, value) {
35
35
  if (typeof value === "string") {
36
- return { type: "many2one", target: value };
36
+ return value.includes(":") ? parseDslString(value) : { type: "many2one", target: value };
37
37
  }
38
38
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
39
39
  const v = value;
40
- const type = v["type"];
41
- const nested = v["nested"] === true ? true : void 0;
40
+ const type = v["_type"];
41
+ const nested = v["_nested"] === true ? true : void 0;
42
+ const carDirect = typeof v["_car-direct"] === "string" ? v["_car-direct"] : void 0;
43
+ const carInverse = typeof v["_car-inverse"] === "string" ? v["_car-inverse"] : void 0;
42
44
  if (type === "many2one" || type === void 0) {
43
- const target = v["target"];
45
+ const target = v["_target"];
44
46
  if (typeof target !== "string") return null;
45
- return nested ? { type: "many2one", target, nested } : { type: "many2one", target };
47
+ const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
48
+ return compact({ type: "many2one", target, foreignKey, nested, carDirect, carInverse });
46
49
  }
47
50
  if (type === "one2one") {
48
- const target = v["target"];
51
+ const target = v["_target"];
49
52
  if (typeof target !== "string") return null;
50
- return nested ? { type: "one2one", target, nested } : { type: "one2one", target };
53
+ const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
54
+ return compact({ type: "one2one", target, foreignKey, nested, carDirect, carInverse });
51
55
  }
52
56
  if (type === "many2many") {
53
- const target = typeof v["target"] === "string" ? v["target"] : key;
54
- const through = v["through"];
55
- const foreignKey = v["foreignKey"];
56
- const otherKey = v["otherKey"];
57
+ const target = typeof v["_target"] === "string" ? v["_target"] : key;
58
+ const through = v["_through"];
59
+ const foreignKey = v["_foreignKey"];
60
+ const otherKey = v["_otherKey"];
57
61
  if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
58
62
  return null;
59
- return nested ? { type: "many2many", target, through, foreignKey, otherKey, nested } : { type: "many2many", target, through, foreignKey, otherKey };
63
+ return compact({
64
+ type: "many2many",
65
+ target,
66
+ through,
67
+ foreignKey,
68
+ otherKey,
69
+ nested,
70
+ carDirect,
71
+ carInverse
72
+ });
60
73
  }
61
74
  return null;
62
75
  }
76
+ function parseDslString(raw) {
77
+ const nested = raw.endsWith("+nested") ? true : void 0;
78
+ let s = nested ? raw.slice(0, -7) : raw;
79
+ let carDirect;
80
+ let carInverse;
81
+ const carMatch = s.match(/\[([^[\]]+)->([^[\]]+)\]$/);
82
+ if (carMatch) {
83
+ carDirect = carMatch[1];
84
+ carInverse = carMatch[2];
85
+ s = s.slice(0, s.length - carMatch[0].length);
86
+ }
87
+ const colonIdx = s.indexOf(":");
88
+ if (colonIdx === -1) return null;
89
+ const typeAlias = s.slice(0, colonIdx);
90
+ const body = s.slice(colonIdx + 1);
91
+ const type = TYPE_ALIASES[typeAlias];
92
+ if (!type) return null;
93
+ if (type === "many2many") {
94
+ const m = body.match(/^([^@()[\]]+)@([^@()[\]]+)\(([^,)]+),\s*([^,)]+)\)$/);
95
+ if (!m) return null;
96
+ const [, target2, through, foreignKey2, otherKey] = m;
97
+ return compact({
98
+ type: "many2many",
99
+ target: target2,
100
+ through,
101
+ foreignKey: foreignKey2,
102
+ otherKey,
103
+ nested,
104
+ carDirect,
105
+ carInverse
106
+ });
107
+ }
108
+ const atIdx = body.indexOf("@");
109
+ const target = atIdx === -1 ? body : body.slice(0, atIdx);
110
+ const foreignKey = atIdx === -1 ? void 0 : body.slice(atIdx + 1);
111
+ if (!target) return null;
112
+ return compact({ type, target, foreignKey, nested, carDirect, carInverse });
113
+ }
114
+ function compact(obj) {
115
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
116
+ }
117
+ var TYPE_ALIASES;
63
118
  var init_parseRelations = __esm({
64
119
  "src/storage/parseRelations.ts"() {
65
120
  "use strict";
66
121
  init_esm_shims();
122
+ TYPE_ALIASES = {
123
+ many2one: "many2one",
124
+ m2o: "many2one",
125
+ one2one: "one2one",
126
+ o2o: "one2one",
127
+ many2many: "many2many",
128
+ m2m: "many2many"
129
+ };
130
+ }
131
+ });
132
+
133
+ // src/storage/parseSchema.ts
134
+ function parseSchema(raw) {
135
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
136
+ const result = {};
137
+ for (const [collection, fields] of Object.entries(raw)) {
138
+ if (!fields || typeof fields !== "object" || Array.isArray(fields)) continue;
139
+ result[collection] = {};
140
+ for (const [field, value] of Object.entries(fields)) {
141
+ const def = normaliseFieldDef(value);
142
+ if (def) result[collection][field] = def;
143
+ }
144
+ }
145
+ return result;
146
+ }
147
+ function normaliseFieldDef(value) {
148
+ if (value === "required") return { required: true };
149
+ if (value === "optional") return { required: false };
150
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
151
+ const v = value;
152
+ const def = {};
153
+ if (v["_required"] === true || v["_required"] === false) def.required = v["_required"];
154
+ if (typeof v["_type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["_type"]))
155
+ def.type = v["_type"];
156
+ if (typeof v["_format"] === "string") def.format = v["_format"];
157
+ if (Array.isArray(v["_enum"])) def.enum = v["_enum"];
158
+ if (typeof v["_description"] === "string") def.description = v["_description"];
159
+ if (v["_default"] !== void 0) def.default = v["_default"];
160
+ return def;
161
+ }
162
+ var init_parseSchema = __esm({
163
+ "src/storage/parseSchema.ts"() {
164
+ "use strict";
165
+ init_esm_shims();
67
166
  }
68
167
  });
69
168
 
@@ -80,6 +179,60 @@ var init_deepCopy = __esm({
80
179
  }
81
180
  });
82
181
 
182
+ // src/storage/parseRoutes.ts
183
+ function parseRoutes(raw) {
184
+ if (!Array.isArray(raw)) return [];
185
+ return raw.map(normaliseRoute).filter((r) => r !== null);
186
+ }
187
+ function normaliseRoute(raw) {
188
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
189
+ const v = raw;
190
+ const method = v["_method"];
191
+ const path2 = v["_path"];
192
+ if (typeof method !== "string" || typeof path2 !== "string") return null;
193
+ const route = { method, path: path2 };
194
+ if (typeof v["_handler"] === "string") route.handler = v["_handler"];
195
+ if (typeof v["_delay"] === "number") route.delay = v["_delay"];
196
+ if (typeof v["_error"] === "number") route.error = v["_error"];
197
+ if (v["_errorBody"] !== void 0) route.errorBody = v["_errorBody"];
198
+ const response = normaliseResponse(v["_response"]);
199
+ if (response) route.response = response;
200
+ const otherwise = normaliseResponse(v["_otherwise"]);
201
+ if (otherwise) route.otherwise = otherwise;
202
+ const scenarios = normaliseScenarios(v["_scenarios"]);
203
+ if (scenarios.length > 0) route.scenarios = scenarios;
204
+ return route;
205
+ }
206
+ function normaliseResponse(raw) {
207
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
208
+ const v = raw;
209
+ const response = {};
210
+ if (typeof v["_status"] === "number") response.status = v["_status"];
211
+ if (v["_body"] !== void 0) response.body = v["_body"];
212
+ if (v["_headers"] && typeof v["_headers"] === "object" && !Array.isArray(v["_headers"]))
213
+ response.headers = v["_headers"];
214
+ return Object.keys(response).length > 0 ? response : void 0;
215
+ }
216
+ function normaliseScenarios(raw) {
217
+ if (!Array.isArray(raw)) return [];
218
+ return raw.map(normaliseScenario).filter((s) => s !== null);
219
+ }
220
+ function normaliseScenario(raw) {
221
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
222
+ const v = raw;
223
+ const when = v["_when"];
224
+ if (!when || typeof when !== "object") return null;
225
+ const response = normaliseResponse(v["_response"]);
226
+ if (!response) return null;
227
+ return { when, response };
228
+ }
229
+ var init_parseRoutes = __esm({
230
+ "src/storage/parseRoutes.ts"() {
231
+ "use strict";
232
+ init_esm_shims();
233
+ }
234
+ });
235
+
83
236
  // src/storage/yrestStorage.ts
84
237
  var yrestStorage_exports = {};
85
238
  __export(yrestStorage_exports, {
@@ -88,14 +241,70 @@ __export(yrestStorage_exports, {
88
241
  import { readFileSync as readFileSync2, writeFileSync, renameSync } from "fs";
89
242
  import { resolve, dirname as dirname2 } from "path";
90
243
  import { randomUUID as randomUUID2 } from "crypto";
91
- import { parse as parse2, stringify } from "yaml";
244
+ import { parse as parse2, stringify as stringify2 } from "yaml";
245
+ function serializeRelations(relations) {
246
+ const out = {};
247
+ for (const [col, fields] of Object.entries(relations)) {
248
+ out[col] = {};
249
+ for (const [key, def] of Object.entries(fields)) {
250
+ out[col][key] = serializeRelationDef(def);
251
+ }
252
+ }
253
+ return out;
254
+ }
255
+ function serializeRelationDef(def) {
256
+ if (def.type === "many2many") {
257
+ const entry2 = {
258
+ _type: def.type,
259
+ _target: def.target,
260
+ _through: def.through,
261
+ _foreignKey: def.foreignKey,
262
+ _otherKey: def.otherKey
263
+ };
264
+ if (def.nested) entry2._nested = true;
265
+ if (def.carDirect) entry2["_car-direct"] = def.carDirect;
266
+ if (def.carInverse) entry2["_car-inverse"] = def.carInverse;
267
+ return entry2;
268
+ }
269
+ const entry = { _type: def.type, _target: def.target };
270
+ if (def.foreignKey) entry._foreignKey = def.foreignKey;
271
+ if (def.nested) entry._nested = true;
272
+ if (def.carDirect) entry["_car-direct"] = def.carDirect;
273
+ if (def.carInverse) entry["_car-inverse"] = def.carInverse;
274
+ return entry;
275
+ }
276
+ function serializeRoutes(routes) {
277
+ return routes.map((r) => {
278
+ const entry = { _method: r.method, _path: r.path };
279
+ if (r.handler) entry._handler = r.handler;
280
+ if (r.delay !== void 0) entry._delay = r.delay;
281
+ if (r.error !== void 0) entry._error = r.error;
282
+ if (r.errorBody !== void 0) entry._errorBody = r.errorBody;
283
+ if (r.response) entry._response = serializeResponse(r.response);
284
+ if (r.otherwise) entry._otherwise = serializeResponse(r.otherwise);
285
+ if (r.scenarios?.length) entry._scenarios = r.scenarios.map(serializeScenario);
286
+ return entry;
287
+ });
288
+ }
289
+ function serializeResponse(res) {
290
+ const out = {};
291
+ if (res.status !== void 0) out._status = res.status;
292
+ if (res.body !== void 0) out._body = res.body;
293
+ if (res.headers) out._headers = res.headers;
294
+ return out;
295
+ }
296
+ function serializeScenario(s) {
297
+ return { _when: s.when, _response: serializeResponse(s.response) };
298
+ }
92
299
  function createYrestStorage(filePath) {
93
300
  const absPath = resolve(filePath);
94
301
  const raw = parse2(readFileSync2(absPath, "utf8")) ?? {};
302
+ const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
95
303
  const relations = parseRelations(raw["_rel"]);
96
- const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
304
+ const routes = parseRoutes(raw["_routes"]);
305
+ const schema = parseSchema(raw["_schema"]);
97
306
  const data = Object.fromEntries(
98
- Object.entries(raw).filter(([key]) => key !== "_rel" && key !== "_routes")
307
+ Object.entries(raw).filter(([key]) => !RESERVED.has(key))
99
308
  );
100
309
  let snapshot = {
101
310
  data: deepCopyData(data),
@@ -109,6 +318,9 @@ function createYrestStorage(filePath) {
109
318
  getRelations() {
110
319
  return relations;
111
320
  },
321
+ getSchema() {
322
+ return schema;
323
+ },
112
324
  getRoutes() {
113
325
  return routes;
114
326
  },
@@ -120,18 +332,18 @@ function createYrestStorage(filePath) {
120
332
  },
121
333
  persist() {
122
334
  const payload = {};
123
- if (Object.keys(relations).length > 0) payload._rel = relations;
124
- if (routes.length > 0) payload._routes = routes;
335
+ if (Object.keys(relations).length > 0) payload._rel = serializeRelations(relations);
336
+ if (routes.length > 0) payload._routes = serializeRoutes(routes);
125
337
  Object.assign(payload, data);
126
338
  const tmp = resolve(dirname2(absPath), `.yrest-${randomUUID2()}.tmp`);
127
- writeFileSync(tmp, stringify(payload), "utf8");
339
+ writeFileSync(tmp, stringify2(payload), "utf8");
128
340
  renameSync(tmp, absPath);
129
341
  },
130
342
  reload() {
131
343
  const fresh = parse2(readFileSync2(absPath, "utf8")) ?? {};
132
344
  const freshRelations = parseRelations(fresh["_rel"]);
133
345
  const freshData = Object.fromEntries(
134
- Object.entries(fresh).filter(([key]) => key !== "_rel" && key !== "_routes")
346
+ Object.entries(fresh).filter(([key]) => !RESERVED.has(key))
135
347
  );
136
348
  for (const key of Object.keys(data)) delete data[key];
137
349
  Object.assign(data, freshData);
@@ -163,6 +375,8 @@ var init_yrestStorage = __esm({
163
375
  "use strict";
164
376
  init_esm_shims();
165
377
  init_parseRelations();
378
+ init_parseRoutes();
379
+ init_parseSchema();
166
380
  init_deepCopy();
167
381
  }
168
382
  });
@@ -215,6 +429,7 @@ function dedent(str) {
215
429
  // src/api/yrestServer.ts
216
430
  init_esm_shims();
217
431
  init_parseRelations();
432
+ init_parseSchema();
218
433
  import { resolve as resolve2 } from "path";
219
434
 
220
435
  // src/utils/handlers.ts
@@ -348,7 +563,7 @@ function endpointRow(method, path2, desc) {
348
563
  }
349
564
  function resourceAccordion(name, base, isOpen) {
350
565
  const p = `${base}/${name}`;
351
- const singular = name.endsWith("s") ? name.slice(0, -1) : name;
566
+ const singular2 = name.endsWith("s") ? name.slice(0, -1) : name;
352
567
  const rows = [
353
568
  endpointRow(
354
569
  "GET",
@@ -358,20 +573,20 @@ function resourceAccordion(name, base, isOpen) {
358
573
  endpointRow(
359
574
  "POST",
360
575
  p,
361
- `Create a new ${singular}. Auto-assigns <code>id</code> if not provided.`
576
+ `Create a new ${singular2}. Auto-assigns <code>id</code> if not provided.`
362
577
  ),
363
- endpointRow("GET", `${p}/:id`, `Get a single ${singular} by id.`),
578
+ endpointRow("GET", `${p}/:id`, `Get a single ${singular2} by id.`),
364
579
  endpointRow(
365
580
  "PUT",
366
581
  `${p}/:id`,
367
- `Fully replace a ${singular}. Original <code>id</code> is always preserved.`
582
+ `Fully replace a ${singular2}. Original <code>id</code> is always preserved.`
368
583
  ),
369
584
  endpointRow(
370
585
  "PATCH",
371
586
  `${p}/:id`,
372
- `Partially update a ${singular} \u2014 only provided fields change.`
587
+ `Partially update a ${singular2} \u2014 only provided fields change.`
373
588
  ),
374
- endpointRow("DELETE", `${p}/:id`, `Delete a ${singular} and return it as confirmation.`)
589
+ endpointRow("DELETE", `${p}/:id`, `Delete a ${singular2} and return it as confirmation.`)
375
590
  ].join("");
376
591
  return `
377
592
  <details class="resource-card" ${isOpen ? "open" : ""}>
@@ -390,13 +605,13 @@ function nestedRoutesAccordion(relations, base) {
390
605
  for (const [key, def] of Object.entries(fields)) {
391
606
  const nestedBadge = def.nested ? ` ${badge("nested", "#facc15", "#facc1518")}` : "";
392
607
  if (def.type === "many2many") {
393
- const singular = source.endsWith("s") ? source.slice(0, -1) : source;
608
+ const singular2 = source.endsWith("s") ? source.slice(0, -1) : source;
394
609
  const m2mBadge = badge("many2many", "#818cf8", "#818cf818");
395
610
  rows.push(
396
611
  endpointRow(
397
612
  "GET",
398
613
  `${base}/${source}/:id/${key}`,
399
- `List ${def.target} linked to a ${singular} via ${def.through}. ${m2mBadge}${nestedBadge}`
614
+ `List ${def.target} linked to a ${singular2} via ${def.through}. ${m2mBadge}${nestedBadge}`
400
615
  )
401
616
  );
402
617
  const targetSingular = def.target.endsWith("s") ? def.target.slice(0, -1) : def.target;
@@ -522,7 +737,7 @@ function examplesBlock(collections, relations, base, host, options, firstCustomR
522
737
  const firstCol = collections[0];
523
738
  if (firstCol) {
524
739
  const p = `${host}${base}/${firstCol}`;
525
- const singular = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
740
+ const singular2 = firstCol.endsWith("s") ? firstCol.slice(0, -1) : firstCol;
526
741
  examples.push(
527
742
  `# List all ${firstCol}
528
743
  curl ${p}`,
@@ -530,17 +745,17 @@ curl ${p}`,
530
745
  curl "${p}?name=value"`,
531
746
  `# Sort and paginate
532
747
  curl "${p}?_sort=id&_order=desc&_page=1&_limit=5"`,
533
- `# Get single ${singular}
748
+ `# Get single ${singular2}
534
749
  curl ${p}/1`,
535
- `# Create ${singular}
750
+ `# Create ${singular2}
536
751
  curl -X POST ${p} \\
537
752
  -H "Content-Type: application/json" \\
538
753
  -d '{"name":"example"}'`,
539
- `# Partially update ${singular}
754
+ `# Partially update ${singular2}
540
755
  curl -X PATCH ${p}/1 \\
541
756
  -H "Content-Type: application/json" \\
542
757
  -d '{"name":"updated"}'`,
543
- `# Delete ${singular}
758
+ `# Delete ${singular2}
544
759
  curl -X DELETE ${p}/1`
545
760
  );
546
761
  }
@@ -603,12 +818,510 @@ curl ${curlFlag}${fullPath}`);
603
818
  return `<pre>${highlighted}</pre>`;
604
819
  }
605
820
 
821
+ // src/router/templates/about.diagram.ts
822
+ init_esm_shims();
823
+ function inferType(value) {
824
+ if (value === null || value === void 0) return "string";
825
+ if (typeof value === "boolean") return "boolean";
826
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
827
+ if (Array.isArray(value)) return "array";
828
+ if (typeof value === "object") return "object";
829
+ return "string";
830
+ }
831
+ function generateERData(storage) {
832
+ const data = storage.getData();
833
+ const relations = storage.getRelations();
834
+ const schema = storage.getSchema();
835
+ const pivotTables = /* @__PURE__ */ new Set();
836
+ for (const fields of Object.values(relations)) {
837
+ for (const def of Object.values(fields)) {
838
+ if (def.type === "many2many") pivotTables.add(def.through);
839
+ }
840
+ }
841
+ const fkFields = /* @__PURE__ */ new Map();
842
+ for (const [source, fields] of Object.entries(relations)) {
843
+ for (const [key, def] of Object.entries(fields)) {
844
+ if (def.type !== "many2many") {
845
+ if (!fkFields.has(source)) fkFields.set(source, /* @__PURE__ */ new Set());
846
+ fkFields.get(source).add(key);
847
+ }
848
+ }
849
+ }
850
+ const entities = Object.entries(data).map(([name, items]) => {
851
+ const sample = items[0] ?? {};
852
+ const schemaFields = schema[name] ?? {};
853
+ const allFields = /* @__PURE__ */ new Set([...Object.keys(sample), ...Object.keys(schemaFields)]);
854
+ const fields = [...allFields].map((fieldName) => ({
855
+ name: fieldName,
856
+ type: schemaFields[fieldName]?.type ?? inferType(sample[fieldName]),
857
+ pk: fieldName === "id",
858
+ fk: fkFields.get(name)?.has(fieldName) ?? false,
859
+ required: schemaFields[fieldName]?.required ?? false
860
+ }));
861
+ fields.sort((a, b) => {
862
+ if (a.pk) return -1;
863
+ if (b.pk) return 1;
864
+ if (a.fk && !b.fk) return 1;
865
+ if (!a.fk && b.fk) return -1;
866
+ return 0;
867
+ });
868
+ return { name, fields, isPivot: pivotTables.has(name) };
869
+ });
870
+ const erRelations = [];
871
+ for (const [source, fields] of Object.entries(relations)) {
872
+ for (const [key, def] of Object.entries(fields)) {
873
+ if (def.type === "many2many") {
874
+ erRelations.push({
875
+ source,
876
+ target: def.target,
877
+ type: "many2many",
878
+ label: def.through,
879
+ through: def.through,
880
+ foreignKey: def.foreignKey,
881
+ otherKey: def.otherKey
882
+ });
883
+ } else {
884
+ erRelations.push({
885
+ source,
886
+ target: def.target,
887
+ type: def.type,
888
+ label: key,
889
+ carDirect: def.carDirect,
890
+ carInverse: def.carInverse
891
+ });
892
+ }
893
+ }
894
+ }
895
+ return { entities, relations: erRelations };
896
+ }
897
+
898
+ // src/router/templates/about.diagram.canvas.ts
899
+ init_esm_shims();
900
+ function erDiagramSection(data) {
901
+ if (!data.entities.length) return "";
902
+ const json = JSON.stringify(data);
903
+ return (
904
+ /*html*/
905
+ `
906
+ <h2>Data Model</h2>
907
+
908
+ <div class="card" style="padding:0;overflow:hidden;position:relative;">
909
+ <canvas id="er-canvas" style="width:100%;display:block;cursor:grab;"></canvas>
910
+ <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>
911
+ </div>
912
+
913
+ <script>${/*js*/
914
+ `
915
+ (function(){
916
+
917
+ var D=${json};
918
+
919
+ // \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
920
+ var NW=178,HH=34,FH=22,FP=10,ROW_GAP=44,COL_GAP=100,BR=8,BO=14,LGH=56;
921
+
922
+ var C={bg:'#0d1117',nb:'#161b22',nh:'#1c2128',pb:'#0d1117',ph:'#161b22',
923
+ bo:'#30363d',bs:'#58a6ff',tx:'#e6edf3',mu:'#7d8590',
924
+ pk:'#e3b341',fk:'#79c0ff',m2o:'#58a6ff',o2o:'#34d399',m2m:'#818cf8'};
925
+
926
+ // \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
927
+ var cv = document.getElementById('er-canvas');
928
+ var dpr = window.devicePixelRatio||1;
929
+ var ctx = cv.getContext('2d');
930
+
931
+ function resize(h){
932
+ if(h)cv.style.height = h + 'px';
933
+ var r = cv.getBoundingClientRect();
934
+ cv.width = Math.round(r.width*dpr);
935
+ cv.height = Math.round(r.height*dpr);
936
+ }
937
+
938
+ // \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
939
+ var nodes = D.entities.map(function(e){
940
+ return{e:e,x:0,y:0,w:NW,h:HH+e.fields.length*FH+6};
941
+ });
942
+
943
+ var nIdx={};D.entities.forEach(function(e,i){nIdx[e.name]=i;});
944
+
945
+ var adj=nodes.map(function(){return[];}); // forward
946
+ var radj=nodes.map(function(){return[];}); // reverse
947
+
948
+ D.relations.forEach(function(r){
949
+ if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
950
+ var s=nIdx[r.source],p=nIdx[r.through],t=nIdx[r.target];
951
+ if(s!=null&&p!=null&&s!==p){adj[s].push(p);radj[p].push(s);}
952
+ if(t!=null&&p!=null&&t!==p){adj[t].push(p);radj[p].push(t);}
953
+ }else{
954
+ var s=nIdx[r.source],t=nIdx[r.target];
955
+ if(s==null||t==null||s===t)return;
956
+ adj[s].push(t);radj[t].push(s);
957
+ }
958
+ });
959
+
960
+ // \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
961
+ var groups=[];
962
+
963
+ function median(arr){
964
+ if(!arr.length)return 0;
965
+ var s=[].concat(arr).sort(function(a,b){return a-b;});
966
+ return s[Math.floor(s.length/2)];
967
+ }
968
+
969
+ function computeLayout(W,H){
970
+ var layer=nodes.map(function(){return 0;});
971
+ var inDeg=nodes.map(function(){return 0;});
972
+ adj.forEach(function(ts){ts.forEach(function(t){inDeg[t]++;});});
973
+ var q=[],qi=0;
974
+ for(var i=0;i<nodes.length;i++)if(inDeg[i]===0)q.push(i);
975
+ if(!q.length)for(var i=0;i<nodes.length;i++)q.push(i);
976
+ while(qi<q.length){
977
+ var u=q[qi++];
978
+ adj[u].forEach(function(v){if(layer[v]<=layer[u]){layer[v]=layer[u]+1;q.push(v);}});
979
+ }
980
+ var maxL=layer.reduce(function(m,l){return Math.max(m,l);},0);
981
+ groups=[];
982
+ for(var l=0;l<=maxL;l++)groups.push([]);
983
+ for(var i=0;i<nodes.length;i++)groups[layer[i]].push(i);
984
+ for(var l=1;l<=maxL;l++){
985
+ var pp={};groups[l-1].forEach(function(i,p){pp[i]=p;});
986
+ groups[l].sort(function(a,b){
987
+ return median(radj[a].map(function(x){return pp[x]!=null?pp[x]:0;}))-
988
+ median(radj[b].map(function(x){return pp[x]!=null?pp[x]:0;}));
989
+ });
990
+ }
991
+ for(var l=maxL-1;l>=0;l--){
992
+ var np={};groups[l+1].forEach(function(i,p){np[i]=p;});
993
+ groups[l].sort(function(a,b){
994
+ return median(adj[a].map(function(x){return np[x]!=null?np[x]:0;}))-
995
+ median(adj[b].map(function(x){return np[x]!=null?np[x]:0;}));
996
+ });
997
+ }
998
+ var colW=NW+COL_GAP;
999
+ var totalW=(maxL+1)*colW-COL_GAP;
1000
+ var startX=Math.max(20,(W-totalW)/2);
1001
+ for(var l=0;l<=maxL;l++){
1002
+ var g=groups[l];
1003
+ var totalH=g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP;
1004
+ var y=Math.max(20,(H-totalH)/2);
1005
+ g.forEach(function(i){nodes[i].x=startX+l*colW;nodes[i].y=y;y+=nodes[i].h+ROW_GAP;});
1006
+ }
1007
+ }
1008
+
1009
+ function requiredH(){
1010
+ return Math.max(340,Math.min(720,groups.reduce(function(m,g){
1011
+ return Math.max(m,g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP+LGH+70);
1012
+ },300)));
1013
+ }
1014
+
1015
+ // \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
1016
+ var pan={x:0,y:0},zm=1;
1017
+ var origPos=[];
1018
+
1019
+ function savePos(){origPos=nodes.map(function(n){return{x:n.x,y:n.y};});}
1020
+
1021
+ function fitView(W,H){
1022
+ var x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;
1023
+ 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);});
1024
+ var pad=28,gW=x1-x0+pad*2,gH=y1-y0+pad*2;
1025
+ zm=Math.min(W/gW,(H-LGH)/gH,1);
1026
+ pan.x=(W-gW*zm)/2+pad*zm-x0*zm;
1027
+ pan.y=((H-LGH)-gH*zm)/2+pad*zm-y0*zm;
1028
+ }
1029
+
1030
+ // \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
1031
+ function rr(x,y,w,h,r){
1032
+ if(ctx.roundRect){ctx.beginPath();ctx.roundRect(x,y,w,h,r);return;}
1033
+ ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);
1034
+ 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();
1035
+ }
1036
+
1037
+ function edgePt(n,tx,ty){
1038
+ var cx=n.x+n.w/2,cy=n.y+n.h/2,dx=tx-cx,dy=ty-cy;
1039
+ if(!dx&&!dy)return{x:cx,y:cy};
1040
+ var s=Math.min((n.w/2)/Math.abs(dx||.001),(n.h/2)/Math.abs(dy||.001));
1041
+ return{x:cx+dx*s,y:cy+dy*s};
1042
+ }
1043
+
1044
+ var badges=[];
1045
+
1046
+ function pushBadge(x,y,ux,uy,many,col){
1047
+ badges.push({x:x,y:y,ux:ux,uy:uy,many:many,col:col});
1048
+ }
1049
+
1050
+ // Badge: solid colored circle; crow's foot (many) or double-bar (one) in dark ink inside.
1051
+ // ux,uy points outward from the node \u2014 badge is offset BO px into the gap.
1052
+ // Crow's foot tines open toward the node (-x in rotated frame), tip toward line (+x).
1053
+ function drawBadge(b){
1054
+ var bx=b.x+b.ux*BO,by=b.y+b.uy*BO;
1055
+ ctx.fillStyle=b.col;ctx.strokeStyle=b.col;ctx.lineWidth=1;
1056
+ ctx.beginPath();ctx.arc(bx,by,BR,0,Math.PI*2);ctx.fill();ctx.stroke();
1057
+ ctx.save();ctx.translate(bx,by);ctx.rotate(Math.atan2(b.uy,b.ux));
1058
+ ctx.strokeStyle=C.bg;ctx.lineWidth=1.4;ctx.lineCap='round';
1059
+ ctx.beginPath();
1060
+ if(b.many){
1061
+ ctx.moveTo(-5,-4);ctx.lineTo(5,0); // upper tine \u2192 tip
1062
+ ctx.moveTo(-5,4);ctx.lineTo(5,0); // lower tine \u2192 tip
1063
+ ctx.moveTo(-5,0);ctx.lineTo(5,0); // center line
1064
+ ctx.moveTo(-5,-4);ctx.lineTo(-5,4); // base bar (near node)
1065
+ }else{
1066
+ ctx.moveTo(-3,-5);ctx.lineTo(-3,5); // near bar
1067
+ ctx.moveTo(2,-5);ctx.lineTo(2,5); // far bar
1068
+ }
1069
+ ctx.stroke();
1070
+ ctx.restore();
1071
+ }
1072
+
1073
+ function ecol(t){return t==='one2one'?C.o2o:t==='many2many'?C.m2m:C.m2o;}
1074
+
1075
+ // offset: pixels to shift perpendicular to the edge direction (for parallel edges)
1076
+ function drawEdgeLine(a,b,col,dashed,label,manyA,manyB,off){
1077
+ var p1=edgePt(a,b.x+b.w/2,b.y+b.h/2);
1078
+ var p2=edgePt(b,a.x+a.w/2,a.y+a.h/2);
1079
+ var dx=p2.x-p1.x,dy=p2.y-p1.y,d=Math.sqrt(dx*dx+dy*dy)||1;
1080
+ var ux=dx/d,uy=dy/d;
1081
+ var px=-uy*off,py=ux*off; // perpendicular shift
1082
+ var q1x=p1.x+px,q1y=p1.y+py,q2x=p2.x+px,q2y=p2.y+py;
1083
+ ctx.strokeStyle=col+'55';ctx.lineWidth=1.5;
1084
+ ctx.setLineDash(dashed?[5,4]:[]);
1085
+ ctx.beginPath();ctx.moveTo(q1x,q1y);ctx.lineTo(q2x,q2y);ctx.stroke();
1086
+ ctx.setLineDash([]);
1087
+ pushBadge(q1x,q1y,ux,uy,manyA,col);
1088
+ pushBadge(q2x,q2y,-ux,-uy,manyB,col);
1089
+ if(label){
1090
+ var mx=(q1x+q2x)/2,my=(q1y+q2y)/2;
1091
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='center';ctx.textBaseline='middle';
1092
+ ctx.fillText(label,mx-uy*14,my+ux*14);
1093
+ ctx.textBaseline='alphabetic';
1094
+ }
1095
+ }
1096
+
1097
+ // canonical key for a node pair regardless of direction
1098
+ function pairKey(a,b){
1099
+ var ai=nodes.indexOf(a),bi=nodes.indexOf(b);
1100
+ return ai<bi?ai+'-'+bi:bi+'-'+ai;
1101
+ }
1102
+
1103
+ function collectEdges(){
1104
+ var edges=[];
1105
+ D.relations.forEach(function(r){
1106
+ var col=ecol(r.type);
1107
+ if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
1108
+ var sn=nodes[nIdx[r.source]],pn=nodes[nIdx[r.through]],tn=nodes[nIdx[r.target]];
1109
+ if(sn&&pn)edges.push({a:sn,b:pn,col:col,dashed:true,label:r.foreignKey||'',manyA:false,manyB:true,off:0});
1110
+ if(tn&&pn)edges.push({a:tn,b:pn,col:col,dashed:true,label:r.otherKey||'',manyA:false,manyB:true,off:0});
1111
+ }else{
1112
+ var sn=nodes[nIdx[r.source]],tn=nodes[nIdx[r.target]];
1113
+ if(sn&&tn&&sn!==tn){
1114
+ var manyA=r.carInverse?r.carInverse.endsWith('n'):r.type!=='one2one';
1115
+ var manyB=r.carDirect?r.carDirect.endsWith('n'):r.type==='many2many';
1116
+ edges.push({a:sn,b:tn,col:col,dashed:false,label:r.label,manyA:manyA,manyB:manyB,off:0});
1117
+ }
1118
+ }
1119
+ });
1120
+ // assign perpendicular offsets to parallel edges (same node pair)
1121
+ var cnt={},idx={};
1122
+ edges.forEach(function(e){var k=pairKey(e.a,e.b);cnt[k]=(cnt[k]||0)+1;});
1123
+ edges.forEach(function(e){
1124
+ var k=pairKey(e.a,e.b),n=cnt[k];
1125
+ var i=idx[k]=(idx[k]||0);
1126
+ e.off=n>1?(i-(n-1)/2)*16:0;
1127
+ idx[k]++;
1128
+ });
1129
+ return edges;
1130
+ }
1131
+
1132
+ function drawNode(n){
1133
+ var x=n.x,y=n.y,w=n.w,h=n.h,e=n.e;
1134
+ var bg=e.isPivot?C.pb:C.nb,hd=e.isPivot?C.ph:C.nh;
1135
+ ctx.save();ctx.shadowColor='rgba(0,0,0,0.45)';ctx.shadowBlur=14;ctx.shadowOffsetY=4;
1136
+ ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();ctx.restore();
1137
+ ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();
1138
+ ctx.strokeStyle=n===drag?C.bs:C.bo;ctx.lineWidth=1.2;rr(x,y,w,h,7);ctx.stroke();
1139
+ ctx.fillStyle=hd;rr(x,y,w,HH,7);ctx.fill();ctx.fillRect(x,y+HH-7,w,7);
1140
+ ctx.strokeStyle=C.bo;ctx.lineWidth=1;
1141
+ ctx.beginPath();ctx.moveTo(x,y+HH);ctx.lineTo(x+w,y+HH);ctx.stroke();
1142
+ ctx.fillStyle=e.isPivot?C.mu:C.tx;
1143
+ ctx.font='600 12px -apple-system,BlinkMacSystemFont,sans-serif';
1144
+ ctx.textAlign='left';ctx.textBaseline='middle';
1145
+ ctx.fillText(e.name,x+FP,y+HH/2);
1146
+ if(e.isPivot){
1147
+ ctx.fillStyle='#3d444d';ctx.font='9px monospace';ctx.textAlign='right';
1148
+ ctx.fillText('pivot',x+w-FP,y+HH/2);
1149
+ }
1150
+ e.fields.forEach(function(f,fi){
1151
+ var fy=y+HH+fi*FH+FH/2+3;
1152
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
1153
+ ctx.fillText(f.type,x+w-FP-(f.required&&!f.pk?14:0),fy);
1154
+ if(f.pk||f.fk){
1155
+ var tag=f.pk?'PK':'FK',tc=f.pk?C.pk:C.fk;
1156
+ ctx.fillStyle=tc+'22';ctx.strokeStyle=tc+'66';ctx.lineWidth=1;
1157
+ rr(x+FP,fy-6,12,12,2);ctx.fill();ctx.stroke();
1158
+ ctx.fillStyle=tc;ctx.font='7px monospace';ctx.textAlign='center';
1159
+ ctx.fillText(tag,x+FP+6,fy+0.5);
1160
+ }
1161
+ ctx.fillStyle=f.pk?C.pk:f.fk?C.fk:C.tx;
1162
+ ctx.font=(f.required&&!f.pk?'600 ':'')+'11px monospace';
1163
+ ctx.textAlign='left';ctx.textBaseline='middle';
1164
+ ctx.fillText(f.name,x+FP+(f.pk||f.fk?16:0),fy);
1165
+ if(f.required&&!f.pk){
1166
+ ctx.fillStyle='#f85149';ctx.beginPath();
1167
+ ctx.arc(x+w-FP-6,fy,2.5,0,Math.PI*2);ctx.fill();
1168
+ }
1169
+ });
1170
+ ctx.textBaseline='alphabetic';
1171
+ }
1172
+
1173
+ function drawLegend(){
1174
+ var cvW=cv.width/dpr,cvH=cv.height/dpr;
1175
+ var y0=cvH-LGH,y1=cvH-LGH+18,y2=cvH-LGH+38;
1176
+
1177
+ ctx.fillStyle='#161b22';
1178
+ ctx.fillRect(0,y0,cvW,LGH);
1179
+ ctx.strokeStyle='#30363d';ctx.lineWidth=1;
1180
+ ctx.beginPath();ctx.moveTo(0,y0);ctx.lineTo(cvW,y0);ctx.stroke();
1181
+
1182
+ // Row 1: relation line types
1183
+ var types=[
1184
+ {c:C.m2o,l:'many2one',d:false},
1185
+ {c:C.o2o,l:'one2one',d:false},
1186
+ {c:C.m2m,l:'many2many',d:true}
1187
+ ];
1188
+ ctx.textAlign='left';ctx.textBaseline='middle';ctx.font='10px monospace';
1189
+ var x=14;
1190
+ types.forEach(function(it){
1191
+ ctx.strokeStyle=it.c;ctx.lineWidth=1.5;ctx.setLineDash(it.d?[4,3]:[]);
1192
+ ctx.beginPath();ctx.moveTo(x,y1);ctx.lineTo(x+14,y1);ctx.stroke();
1193
+ ctx.setLineDash([]);
1194
+ ctx.fillStyle=C.mu;
1195
+ ctx.fillText(it.l,x+18,y1);
1196
+ x+=18+ctx.measureText(it.l).width+22;
1197
+ });
1198
+
1199
+ // Row 2: cardinality symbols
1200
+ function badge2(cx,cy,many,col){
1201
+ ctx.fillStyle=col;ctx.strokeStyle=col;ctx.lineWidth=1;
1202
+ ctx.beginPath();ctx.arc(cx,cy,6,0,Math.PI*2);ctx.fill();
1203
+ ctx.save();ctx.translate(cx,cy);
1204
+ ctx.strokeStyle=C.bg;ctx.lineWidth=1.2;ctx.lineCap='round';
1205
+ ctx.beginPath();
1206
+ if(many){
1207
+ ctx.moveTo(-4,-3);ctx.lineTo(4,0);ctx.moveTo(-4,3);ctx.lineTo(4,0);
1208
+ ctx.moveTo(-4,0);ctx.lineTo(4,0);ctx.moveTo(-4,-3);ctx.lineTo(-4,3);
1209
+ }else{
1210
+ ctx.moveTo(-2,-3);ctx.lineTo(-2,3);ctx.moveTo(2,-3);ctx.lineTo(2,3);
1211
+ }
1212
+ ctx.stroke();ctx.restore();
1213
+ }
1214
+
1215
+ ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
1216
+ x=14;
1217
+ var pairs=[
1218
+ {a:false,b:true,col:C.m2o,l:'many2one (N:1)'},
1219
+ {a:false,b:false,col:C.o2o,l:'one2one (1:1)'},
1220
+ {a:false,b:true,col:C.m2m,l:'pivot edge (1:N)'}
1221
+ ];
1222
+ pairs.forEach(function(p){
1223
+ ctx.strokeStyle=p.col+'55';ctx.lineWidth=1.2;ctx.setLineDash(p.col===C.m2m?[4,3]:[]);
1224
+ ctx.beginPath();ctx.moveTo(x+8,y2);ctx.lineTo(x+56,y2);ctx.stroke();
1225
+ ctx.setLineDash([]);
1226
+ badge2(x+8,y2,p.a,p.col);
1227
+ badge2(x+56,y2,p.b,p.col);
1228
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
1229
+ ctx.fillText(p.l,x+66,y2);
1230
+ x+=66+ctx.measureText(p.l).width+24;
1231
+ });
1232
+
1233
+ ctx.fillStyle='#3d444d';ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
1234
+ ctx.fillText('drag \xB7 scroll to zoom \xB7 dbl-click to reset',cvW-14,y2);
1235
+ ctx.textBaseline='alphabetic';
1236
+ }
1237
+
1238
+ function draw(){
1239
+ var W=cv.width/dpr,H=cv.height/dpr;
1240
+ ctx.setTransform(dpr,0,0,dpr,0,0);
1241
+ ctx.clearRect(0,0,W,H);
1242
+ ctx.fillStyle=C.bg;ctx.fillRect(0,0,W,H);
1243
+ ctx.save();ctx.translate(pan.x,pan.y);ctx.scale(zm,zm);
1244
+ badges=[];
1245
+ var edges=collectEdges();
1246
+ edges.forEach(function(e){drawEdgeLine(e.a,e.b,e.col,e.dashed,e.label,e.manyA,e.manyB,e.off);});
1247
+ nodes.forEach(function(n){drawNode(n);});
1248
+ badges.forEach(function(b){drawBadge(b);});
1249
+ ctx.restore();
1250
+ drawLegend();
1251
+ }
1252
+
1253
+ // \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
1254
+ var drag=null,dox=0,doy=0,isPan=false,ps={x:0,y:0};
1255
+
1256
+ function toW(mx,my){return{x:(mx-pan.x)/zm,y:(my-pan.y)/zm};}
1257
+ function hitTest(wx,wy){
1258
+ 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;}
1259
+ return null;
1260
+ }
1261
+
1262
+ cv.addEventListener('mousedown',function(ev){
1263
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1264
+ var w=toW(mx,my),h=hitTest(w.x,w.y);
1265
+ 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};}
1266
+ cv.style.cursor='grabbing';
1267
+ });
1268
+ window.addEventListener('mousemove',function(ev){
1269
+ if(!drag&&!isPan)return;
1270
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1271
+ if(drag){var w=toW(mx,my);drag.x=w.x-dox;drag.y=w.y-doy;}
1272
+ else{pan.x=mx-ps.x;pan.y=my-ps.y;}
1273
+ draw();
1274
+ });
1275
+ window.addEventListener('mouseup',function(){drag=null;isPan=false;cv.style.cursor='grab';});
1276
+ cv.addEventListener('wheel',function(ev){
1277
+ ev.preventDefault();
1278
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1279
+ var d=ev.deltaY<0?1.1:0.91,nz=Math.max(0.25,Math.min(3,zm*d));
1280
+ pan.x=mx-(mx-pan.x)*(nz/zm);pan.y=my-(my-pan.y)*(nz/zm);zm=nz;draw();
1281
+ },{passive:false});
1282
+ cv.addEventListener('dblclick',function(){
1283
+ nodes.forEach(function(n,i){n.x=origPos[i].x;n.y=origPos[i].y;});
1284
+ var r=cv.getBoundingClientRect();fitView(r.width,r.height);draw();
1285
+ });
1286
+ window.addEventListener('resize',function(){
1287
+ resize();var r=cv.getBoundingClientRect();
1288
+ computeLayout(r.width,r.height);savePos();fitView(r.width,r.height);draw();
1289
+ });
1290
+
1291
+ document.getElementById('er-dl').addEventListener('click',function(){
1292
+ var SCALE=3;
1293
+ var cw=cv.width/dpr,ch=cv.height/dpr;
1294
+ var oc=document.createElement('canvas');
1295
+ oc.width=Math.round(cw*SCALE);oc.height=Math.round(ch*SCALE);
1296
+ var sav={cv:cv,ctx:ctx,dpr:dpr};
1297
+ cv=oc;ctx=oc.getContext('2d');dpr=SCALE;
1298
+ draw();
1299
+ cv=sav.cv;ctx=sav.ctx;dpr=sav.dpr;
1300
+ var a=document.createElement('a');
1301
+ a.download='er-diagram.png';a.href=oc.toDataURL('image/png');a.click();
1302
+ });
1303
+
1304
+ // \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
1305
+ requestAnimationFrame(function(){
1306
+ resize();
1307
+ var r=cv.getBoundingClientRect();
1308
+ computeLayout(r.width,r.height);
1309
+ var h=requiredH();
1310
+ resize(h);r=cv.getBoundingClientRect();
1311
+ computeLayout(r.width,r.height);
1312
+ savePos();fitView(r.width,r.height);draw();
1313
+ });
1314
+ })();
1315
+ `}</script>`
1316
+ );
1317
+ }
1318
+
606
1319
  // src/router/templates/about.template.ts
607
1320
  var _dir = dirname(fileURLToPath2(import.meta.url));
608
1321
  var LOGO_SRC = (() => {
609
1322
  try {
610
- const buf = readFileSync(join(_dir, "../../assets/logo-color.png"));
611
- return `data:image/png;base64,${buf.toString("base64")}`;
1323
+ const buf = readFileSync(join(_dir, "../../assets/yrest-lockup.svg"));
1324
+ return `data:image/svg+xml;base64,${buf.toString("base64")}`;
612
1325
  } catch {
613
1326
  return "";
614
1327
  }
@@ -619,6 +1332,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
619
1332
  const customRoutes = storage.getRoutes();
620
1333
  const base = options.base;
621
1334
  const host = `http://${options.host}:${options.port}`;
1335
+ const swaggerUrl = `https://petstore.swagger.io/?url=${encodeURIComponent(`${host}/_openapi.json`)}`;
622
1336
  const modes = [];
623
1337
  if (options.watch) modes.push(badge("watch", "#38bdf8", "#38bdf818"));
624
1338
  if (options.readonly) modes.push(badge("readonly", "#94a3b8", "#94a3b818"));
@@ -629,9 +1343,12 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
629
1343
  if (handlers.size > 0) modes.push(badge(`handlers \xB7 ${handlers.size}`, "#f0883e", "#f0883e18"));
630
1344
  if (options.idStrategy !== "increment")
631
1345
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
1346
+ const erDiagram = erDiagramSection(generateERData(storage));
632
1347
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
633
1348
  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.`;
634
- return `<!DOCTYPE html>
1349
+ return (
1350
+ /*html*/
1351
+ `<!DOCTYPE html>
635
1352
  <html lang="en">
636
1353
  <head>
637
1354
  <meta charset="UTF-8">
@@ -658,20 +1375,30 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
658
1375
  width: 100%;
659
1376
  background: linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a2332 100%);
660
1377
  border-bottom: 1px solid var(--border);
661
- padding: 48px 32px 40px;
1378
+ padding: 20px 32px 16px;
662
1379
  }
663
- .banner-inner { max-width: 1100px; margin: 0 auto; }
664
- .banner h1 { font-size: clamp(36px, 6vw, 60px); font-weight: 800; letter-spacing: -2px; line-height: 1; }
1380
+ .banner-inner { margin: 0 auto; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
1381
+ .banner-left { flex:1; min-width:0; }
1382
+ .banner h1 { font-size: clamp(24px, 4vw, 40px); font-weight: 800; letter-spacing: -1.5px; line-height: 1; }
665
1383
  .banner h1 .y { color: var(--text); }
666
1384
  .banner h1 .rest { color: var(--accent); }
667
- .banner p { color: var(--text-muted); margin-top: 10px; font-size: 15px; }
668
- .banner-meta { display: flex; gap: 24px; margin-top: 20px; flex-wrap: wrap; }
669
- .banner-meta span { color: var(--text-muted); font-size: 13px; }
1385
+ .banner p { color: var(--text-muted); margin-top: 6px; font-size: 14px; }
1386
+ .banner-meta { display: flex; gap: 20px; margin-top: 10px; flex-wrap: wrap; }
1387
+ .banner-meta span { color: var(--text-muted); font-size: 12px; }
670
1388
  .banner-meta span strong { color: var(--text); font-family: monospace; }
1389
+ .api-links { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
1390
+ .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; }
1391
+ .btn-api:hover { border-color:var(--border-hi); color:var(--text); }
1392
+ .btn-swagger { border-color:var(--accent); color:var(--accent); }
1393
+ .btn-swagger:hover { background:#58a6ff18; }
671
1394
 
672
1395
  /* \u2500\u2500 Layout \u2500\u2500 */
673
- .wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 48px; }
1396
+ .wrap { padding: 24px 32px 48px; }
674
1397
  h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin: 32px 0 12px; }
1398
+ .about-grid { display:grid; grid-template-columns:5fr 7fr; gap:28px; align-items:start; }
1399
+ @media(max-width:960px) { .about-grid { grid-template-columns:1fr; } }
1400
+ .col-left, .col-right { min-width:0; }
1401
+ .col-left > h2:first-child, .col-right > h2:first-child { margin-top:0; }
675
1402
 
676
1403
  /* \u2500\u2500 Cards \u2500\u2500 */
677
1404
  .card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px; }
@@ -687,10 +1414,8 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
687
1414
  .modes { display: flex; gap: 8px; flex-wrap: wrap; min-height: 28px; align-items: center; }
688
1415
  .badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; white-space: nowrap; }
689
1416
 
690
- /* \u2500\u2500 Endpoints grid (2 cols on wide, 1 col on narrow) \u2500\u2500 */
691
- .endpoints-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
692
- @media (max-width: 860px) { .endpoints-grid { grid-template-columns: 1fr; } }
693
- .nested-card { grid-column: 1 / -1; }
1417
+ /* \u2500\u2500 Endpoints list \u2500\u2500 */
1418
+ .endpoints-grid { display: flex; flex-direction: column; gap: 10px; }
694
1419
 
695
1420
  /* \u2500\u2500 Accordion (details/summary) \u2500\u2500 */
696
1421
  .resource-card {
@@ -755,56 +1480,71 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
755
1480
 
756
1481
  <div class="banner">
757
1482
  <div class="banner-inner">
758
- ${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>`}
759
- <p>Zero-config REST API mock server</p>
760
- <div class="banner-meta">
761
- <span>URL <strong>${host}</strong></span>
762
- <span>Base <strong>${base || "/"}</strong></span>
763
- <span>File <strong>${options.file}</strong></span>
764
- <span>Collections <strong>${collections.length}</strong></span>
1483
+ <div class="banner-left">
1484
+ ${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>`}
1485
+ <p>Zero-config REST API mock server</p>
1486
+ <div class="banner-meta">
1487
+ <span>URL <strong>${host}</strong></span>
1488
+ <span>Base <strong>${base || "/"}</strong></span>
1489
+ <span>File <strong>${options.file}</strong></span>
1490
+ <span>Collections <strong>${collections.length}</strong></span>
1491
+ </div>
1492
+ </div>
1493
+ <div class="api-links">
1494
+ <a href="/_openapi" target="_blank" class="btn-api">OpenAPI YAML</a>
1495
+ <a href="/_openapi.json" target="_blank" class="btn-api">OpenAPI JSON</a>
1496
+ <a href="${swaggerUrl}" target="_blank" class="btn-api btn-swagger">Swagger UI \u2197</a>
765
1497
  </div>
766
1498
  </div>
767
1499
  </div>
768
1500
 
769
1501
  <div class="wrap">
1502
+ <div class="about-grid">
770
1503
 
771
- <h2>Active Modes</h2>
772
- <div class="card">
773
- <div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
774
- ${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>` : ""}
775
- </div>
1504
+ <div class="col-left">
1505
+ <h2>Active Modes</h2>
1506
+ <div class="card">
1507
+ <div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
1508
+ ${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>` : ""}
1509
+ </div>
776
1510
 
777
- <h2>Endpoints</h2>
778
- <div class="endpoints-grid">
779
- ${accordions}
780
- ${nestedRoutesAccordion(relations, base)}
781
- ${options.snapshot ? snapshotAccordion() : ""}
782
- ${customRoutesAccordion(customRoutes, base, handlers)}
783
- ${handlersAccordion(handlers, customRoutes, base)}
784
- </div>
1511
+ <h2>Endpoints</h2>
1512
+ <div class="endpoints-grid">
1513
+ ${accordions}
1514
+ ${nestedRoutesAccordion(relations, base)}
1515
+ ${options.snapshot ? snapshotAccordion() : ""}
1516
+ ${customRoutesAccordion(customRoutes, base, handlers)}
1517
+ ${handlersAccordion(handlers, customRoutes, base)}
1518
+ </div>
785
1519
 
786
- <h2>Query Parameters</h2>
787
- <div class="card">
788
- <table class="param-table">
789
- <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
790
- <tbody>
791
- <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>
792
- <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>
793
- <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>
794
- <tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
795
- <tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
796
- <tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
797
- <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>
798
- <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>
799
- <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
800
- <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>
801
- <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>
802
- <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>
803
- </tbody>
804
- </table>
805
- </div>
1520
+ <h2>Query Parameters</h2>
1521
+ <div class="card">
1522
+ <table class="param-table">
1523
+ <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
1524
+ <tbody>
1525
+ <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>
1526
+ <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>
1527
+ <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>
1528
+ <tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
1529
+ <tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
1530
+ <tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
1531
+ <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>
1532
+ <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>
1533
+ <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
1534
+ <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>
1535
+ <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>
1536
+ <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>
1537
+ </tbody>
1538
+ </table>
1539
+ </div>
1540
+ </div>
1541
+
1542
+ <div class="col-right">
1543
+ ${erDiagram}
1544
+ ${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
1545
+ </div>
806
1546
 
807
- ${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
1547
+ </div>
808
1548
 
809
1549
  <footer>
810
1550
  Powered by <a href="https://github.com/aggiovato/yRest" target="_blank">@yrest/cli</a> &nbsp;\xB7&nbsp; <a href="/_about">/_about</a>
@@ -812,7 +1552,8 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
812
1552
 
813
1553
  </div>
814
1554
  </body>
815
- </html>`;
1555
+ </html>`
1556
+ );
816
1557
  }
817
1558
 
818
1559
  // src/router/routes/about.routes.ts
@@ -1413,12 +2154,23 @@ var NestedRouteCommand = class {
1413
2154
  relations;
1414
2155
  base;
1415
2156
  register(server) {
2157
+ const registered = /* @__PURE__ */ new Set();
1416
2158
  for (const [source, fields] of Object.entries(this.relations)) {
1417
2159
  for (const [key, def] of Object.entries(fields)) {
1418
2160
  if (def.type === "many2many") {
1419
- this.registerMany2Many(server, source, key, def);
2161
+ const fwd = `${this.base}/${source}/:id/${key}`;
2162
+ const inv = `${this.base}/${def.target}/:id/${source}`;
2163
+ if (!registered.has(fwd) && !registered.has(inv)) {
2164
+ registered.add(fwd);
2165
+ registered.add(inv);
2166
+ this.registerMany2Many(server, source, key, def);
2167
+ }
1420
2168
  } else {
1421
- this.registerFkRelation(server, source, key, def.target, def.type);
2169
+ const collPath = `${this.base}/${def.target}/:id/${source}`;
2170
+ if (!registered.has(collPath)) {
2171
+ registered.add(collPath);
2172
+ this.registerFkRelation(server, source, key, def.target, def.type);
2173
+ }
1422
2174
  }
1423
2175
  }
1424
2176
  }
@@ -1477,6 +2229,379 @@ var NestedRouteCommand = class {
1477
2229
  }
1478
2230
  };
1479
2231
 
2232
+ // src/router/routes/openapi.routes.ts
2233
+ init_esm_shims();
2234
+
2235
+ // src/openapi/generateOpenApi.ts
2236
+ init_esm_shims();
2237
+
2238
+ // src/openapi/inferSchema.ts
2239
+ init_esm_shims();
2240
+ function buildCollectionSchema(items, fieldDefs = {}) {
2241
+ const sample = items.slice(0, 10);
2242
+ const inferredTypes = /* @__PURE__ */ new Map();
2243
+ for (const item of sample) {
2244
+ for (const [key, value] of Object.entries(item)) {
2245
+ if (!inferredTypes.has(key)) {
2246
+ inferredTypes.set(key, jsToOpenApiType(value));
2247
+ }
2248
+ }
2249
+ }
2250
+ const allFields = /* @__PURE__ */ new Set([...inferredTypes.keys(), ...Object.keys(fieldDefs)]);
2251
+ const properties = {};
2252
+ const required = [];
2253
+ for (const field of allFields) {
2254
+ const def = fieldDefs[field];
2255
+ const inferred = inferredTypes.get(field) ?? "string";
2256
+ const prop = {
2257
+ type: def?.type ?? inferred
2258
+ };
2259
+ if (def?.format) prop.format = def.format;
2260
+ if (def?.description) prop.description = def.description;
2261
+ if (def?.enum) prop.enum = def.enum;
2262
+ if (def?.default !== void 0) prop.default = def.default;
2263
+ properties[field] = prop;
2264
+ if (def?.required === true) required.push(field);
2265
+ }
2266
+ const schema = { type: "object", properties };
2267
+ if (required.length > 0) schema.required = required;
2268
+ return schema;
2269
+ }
2270
+ function jsToOpenApiType(value) {
2271
+ if (value === null || value === void 0) return "string";
2272
+ if (typeof value === "boolean") return "boolean";
2273
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
2274
+ if (Array.isArray(value)) return "array";
2275
+ if (typeof value === "object") return "object";
2276
+ return "string";
2277
+ }
2278
+
2279
+ // src/openapi/buildPaths.ts
2280
+ init_esm_shims();
2281
+ var COLLECTION_QUERY_PARAMS = [
2282
+ { name: "_page", in: "query", schema: { type: "integer" }, description: "Page number (1-based)" },
2283
+ { name: "_limit", in: "query", schema: { type: "integer" }, description: "Items per page" },
2284
+ { name: "_sort", in: "query", schema: { type: "string" }, description: "Field name to sort by" },
2285
+ {
2286
+ name: "_order",
2287
+ in: "query",
2288
+ schema: { type: "string", enum: ["asc", "desc"] },
2289
+ description: "Sort direction"
2290
+ },
2291
+ {
2292
+ name: "_q",
2293
+ in: "query",
2294
+ schema: { type: "string" },
2295
+ description: "Full-text search across all scalar fields (case-insensitive)"
2296
+ },
2297
+ {
2298
+ name: "_expand",
2299
+ in: "query",
2300
+ schema: { type: "string" },
2301
+ description: "Embed related parent object inline (e.g. ?_expand=user)"
2302
+ },
2303
+ {
2304
+ name: "_embed",
2305
+ in: "query",
2306
+ schema: { type: "string" },
2307
+ description: "Embed child collection into each item (e.g. ?_embed=posts)"
2308
+ },
2309
+ {
2310
+ name: "_fields",
2311
+ in: "query",
2312
+ schema: { type: "string" },
2313
+ description: "Comma-separated field projection (e.g. ?_fields=id,name)"
2314
+ }
2315
+ ];
2316
+ var ID_PATH_PARAM = {
2317
+ name: "id",
2318
+ in: "path",
2319
+ required: true,
2320
+ schema: { type: "string" },
2321
+ description: "Item id"
2322
+ };
2323
+ function toOpenApiPath(fastifyPath) {
2324
+ return fastifyPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, "{$1}");
2325
+ }
2326
+ function extractPathParams(fastifyPath) {
2327
+ const matches = fastifyPath.match(/:([a-zA-Z_][a-zA-Z0-9_]*)/g) ?? [];
2328
+ return matches.map((m) => ({
2329
+ name: m.slice(1),
2330
+ in: "path",
2331
+ required: true,
2332
+ schema: { type: "string" }
2333
+ }));
2334
+ }
2335
+ function singular(name) {
2336
+ return name.endsWith("s") ? name.slice(0, -1) : name;
2337
+ }
2338
+ function schemaRef(name) {
2339
+ return { $ref: `#/components/schemas/${name}` };
2340
+ }
2341
+ function jsonContent(schema) {
2342
+ return { "application/json": { schema } };
2343
+ }
2344
+ function ok(schema, description = "OK") {
2345
+ return { description, content: jsonContent(schema) };
2346
+ }
2347
+ function buildCrudPaths(collection, base, schemaName) {
2348
+ const ref = schemaRef(schemaName);
2349
+ const tag = collection;
2350
+ const sing = singular(collection);
2351
+ const collPath = `${base}/${collection}`;
2352
+ const itemPath = `${base}/${collection}/{id}`;
2353
+ return {
2354
+ [collPath]: {
2355
+ get: {
2356
+ summary: `List ${collection}`,
2357
+ tags: [tag],
2358
+ parameters: COLLECTION_QUERY_PARAMS,
2359
+ responses: {
2360
+ "200": {
2361
+ description: "OK",
2362
+ content: jsonContent({ type: "array", items: ref }),
2363
+ headers: {
2364
+ "X-Total-Count": {
2365
+ description: "Total items (when using ?_page / ?_limit)",
2366
+ schema: { type: "integer" }
2367
+ }
2368
+ }
2369
+ }
2370
+ }
2371
+ },
2372
+ post: {
2373
+ summary: `Create ${sing}`,
2374
+ tags: [tag],
2375
+ requestBody: { required: true, content: jsonContent(ref) },
2376
+ responses: { "201": ok(ref, "Created") }
2377
+ }
2378
+ },
2379
+ [itemPath]: {
2380
+ get: {
2381
+ summary: `Get ${sing}`,
2382
+ tags: [tag],
2383
+ parameters: [
2384
+ ID_PATH_PARAM,
2385
+ ...COLLECTION_QUERY_PARAMS.filter(
2386
+ (p) => ["_expand", "_embed", "_fields"].includes(p.name)
2387
+ )
2388
+ ],
2389
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
2390
+ },
2391
+ put: {
2392
+ summary: `Replace ${sing}`,
2393
+ tags: [tag],
2394
+ parameters: [ID_PATH_PARAM],
2395
+ requestBody: { required: true, content: jsonContent(ref) },
2396
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
2397
+ },
2398
+ patch: {
2399
+ summary: `Update ${sing}`,
2400
+ tags: [tag],
2401
+ parameters: [ID_PATH_PARAM],
2402
+ requestBody: { required: false, content: jsonContent(ref) },
2403
+ responses: { "200": ok(ref), "404": { description: "Not found" } }
2404
+ },
2405
+ delete: {
2406
+ summary: `Delete ${sing}`,
2407
+ tags: [tag],
2408
+ parameters: [ID_PATH_PARAM],
2409
+ responses: { "200": ok(ref, "Deleted item returned"), "404": { description: "Not found" } }
2410
+ }
2411
+ }
2412
+ };
2413
+ }
2414
+ function buildRelationPaths(relations, base) {
2415
+ const paths = {};
2416
+ for (const [source, fields] of Object.entries(relations)) {
2417
+ for (const [key, def] of Object.entries(fields)) {
2418
+ if (def.type === "many2many") {
2419
+ const forwardPath = `${base}/${source}/{id}/${key}`;
2420
+ const inversePath = `${base}/${def.target}/{id}/${source}`;
2421
+ paths[forwardPath] = {
2422
+ get: {
2423
+ summary: `List ${def.target} linked to ${singular(source)} via ${def.through}`,
2424
+ tags: [source],
2425
+ parameters: [ID_PATH_PARAM],
2426
+ responses: {
2427
+ "200": {
2428
+ description: "OK",
2429
+ content: jsonContent({ type: "array", items: { type: "object" } })
2430
+ },
2431
+ "404": { description: `${singular(source)} not found` }
2432
+ }
2433
+ }
2434
+ };
2435
+ paths[inversePath] = {
2436
+ get: {
2437
+ summary: `List ${source} linked to ${singular(def.target)} via ${def.through} (inverse)`,
2438
+ tags: [def.target],
2439
+ parameters: [ID_PATH_PARAM],
2440
+ responses: {
2441
+ "200": {
2442
+ description: "OK",
2443
+ content: jsonContent({ type: "array", items: { type: "object" } })
2444
+ },
2445
+ "404": { description: `${singular(def.target)} not found` }
2446
+ }
2447
+ }
2448
+ };
2449
+ } else {
2450
+ const parentSing = singular(def.target);
2451
+ const collPath = `${base}/${def.target}/{id}/${source}`;
2452
+ const isOne2One = def.type === "one2one";
2453
+ const responseSchema = isOne2One ? { type: "object" } : { type: "array", items: { type: "object" } };
2454
+ paths[collPath] = {
2455
+ get: {
2456
+ summary: isOne2One ? `Get ${singular(source)} belonging to ${parentSing}` : `List ${source} belonging to ${parentSing}`,
2457
+ tags: [def.target],
2458
+ parameters: [ID_PATH_PARAM],
2459
+ responses: {
2460
+ "200": { description: "OK", content: jsonContent(responseSchema) },
2461
+ "404": { description: `${parentSing} not found` }
2462
+ }
2463
+ }
2464
+ };
2465
+ if (!isOne2One) {
2466
+ const itemPath = `${base}/${def.target}/{id}/${source}/{childId}`;
2467
+ paths[itemPath] = {
2468
+ get: {
2469
+ summary: `Get single ${singular(source)} scoped to ${parentSing}`,
2470
+ tags: [def.target],
2471
+ parameters: [
2472
+ ID_PATH_PARAM,
2473
+ { name: "childId", in: "path", required: true, schema: { type: "string" } }
2474
+ ],
2475
+ responses: {
2476
+ "200": { description: "OK", content: jsonContent({ type: "object" }) },
2477
+ "404": { description: "Not found" }
2478
+ }
2479
+ }
2480
+ };
2481
+ }
2482
+ }
2483
+ }
2484
+ }
2485
+ return paths;
2486
+ }
2487
+ function buildCustomRoutePaths(routes, base) {
2488
+ const paths = {};
2489
+ for (const route of routes) {
2490
+ const openApiPath = toOpenApiPath(`${base}${route.path}`);
2491
+ const method = route.method.toLowerCase();
2492
+ const pathParams = extractPathParams(route.path);
2493
+ const responses = {};
2494
+ if (route.error) {
2495
+ responses[String(route.error)] = { description: `Forced error ${route.error}` };
2496
+ } else {
2497
+ const statuses = /* @__PURE__ */ new Set();
2498
+ for (const s of route.scenarios ?? []) statuses.add(s.response.status ?? 200);
2499
+ if (route.otherwise) statuses.add(route.otherwise.status ?? 200);
2500
+ if (route.response) statuses.add(route.response.status ?? 200);
2501
+ if (statuses.size === 0) statuses.add(200);
2502
+ for (const status of statuses) {
2503
+ const bodySource = (route.scenarios ?? []).find((s) => (s.response.status ?? 200) === status)?.response.body ?? (route.otherwise?.status ?? 200) === status ? route.otherwise?.body : route.response?.body;
2504
+ responses[String(status)] = {
2505
+ description: status < 400 ? "OK" : "Error",
2506
+ ...bodySource != null ? { content: jsonContent(inferResponseSchema(bodySource)) } : {}
2507
+ };
2508
+ }
2509
+ }
2510
+ const desc = route.handler ? `Handler: ${route.handler}()` : route.scenarios?.length ? `Conditional scenarios (${route.scenarios.length})` : "Custom static route";
2511
+ const operation = {
2512
+ summary: `${route.method.toUpperCase()} ${route.path}`,
2513
+ description: desc,
2514
+ tags: ["custom"],
2515
+ ...pathParams.length > 0 ? { parameters: pathParams } : {},
2516
+ responses
2517
+ };
2518
+ if (!paths[openApiPath]) paths[openApiPath] = {};
2519
+ paths[openApiPath][method] = operation;
2520
+ }
2521
+ return paths;
2522
+ }
2523
+ function inferResponseSchema(body) {
2524
+ if (body === null || body === void 0) return {};
2525
+ if (typeof body !== "object" || Array.isArray(body)) return { type: "object" };
2526
+ const properties = {};
2527
+ for (const [key, value] of Object.entries(body)) {
2528
+ properties[key] = { type: jsToOpenApiType2(value) };
2529
+ }
2530
+ return { type: "object", properties };
2531
+ }
2532
+ function jsToOpenApiType2(value) {
2533
+ if (value === null || value === void 0) return "string";
2534
+ if (typeof value === "boolean") return "boolean";
2535
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
2536
+ if (Array.isArray(value)) return "array";
2537
+ if (typeof value === "object") return "object";
2538
+ return "string";
2539
+ }
2540
+
2541
+ // src/openapi/generateOpenApi.ts
2542
+ function generateOpenApi(storage, options, title = "yRest API") {
2543
+ const collections = Object.keys(storage.getData());
2544
+ const relations = storage.getRelations();
2545
+ const schemaBlock = storage.getSchema();
2546
+ const customRoutes = storage.getRoutes();
2547
+ const base = options.base ?? "";
2548
+ const schemas = {};
2549
+ for (const collection of collections) {
2550
+ const items = storage.getCollection(collection) ?? [];
2551
+ const fieldDefs = schemaBlock[collection] ?? {};
2552
+ const schemaName = toSchemaName(collection);
2553
+ schemas[schemaName] = buildCollectionSchema(items, fieldDefs);
2554
+ }
2555
+ const paths = {};
2556
+ for (const collection of collections) {
2557
+ const schemaName = toSchemaName(collection);
2558
+ Object.assign(paths, buildCrudPaths(collection, base, schemaName));
2559
+ }
2560
+ Object.assign(paths, buildRelationPaths(relations, base));
2561
+ Object.assign(paths, buildCustomRoutePaths(customRoutes, base));
2562
+ return {
2563
+ openapi: "3.0.3",
2564
+ info: {
2565
+ title,
2566
+ version: "1.0.0",
2567
+ description: "Generated by yRest from db.yml"
2568
+ },
2569
+ servers: [
2570
+ {
2571
+ url: `http://${options.host}:${options.port}${base}`,
2572
+ description: "yRest mock server"
2573
+ }
2574
+ ],
2575
+ paths,
2576
+ components: { schemas }
2577
+ };
2578
+ }
2579
+ function toSchemaName(collection) {
2580
+ const withoutS = collection.endsWith("s") ? collection.slice(0, -1) : collection;
2581
+ return withoutS.charAt(0).toUpperCase() + withoutS.slice(1);
2582
+ }
2583
+
2584
+ // src/router/routes/openapi.routes.ts
2585
+ import { stringify } from "yaml";
2586
+ var OpenApiRouteCommand = class {
2587
+ constructor(storage, options) {
2588
+ this.storage = storage;
2589
+ this.options = options;
2590
+ }
2591
+ storage;
2592
+ options;
2593
+ register(server) {
2594
+ server.get("/_openapi", (_req, reply) => {
2595
+ const doc = generateOpenApi(this.storage, this.options);
2596
+ reply.header("Content-Type", "text/yaml; charset=utf-8");
2597
+ return reply.send(stringify(doc, { lineWidth: 0, aliasDuplicateObjects: false }));
2598
+ });
2599
+ server.get("/_openapi.json", (_req, reply) => {
2600
+ return reply.send(generateOpenApi(this.storage, this.options));
2601
+ });
2602
+ }
2603
+ };
2604
+
1480
2605
  // src/router/routes/snapshot.routes.ts
1481
2606
  init_esm_shims();
1482
2607
  var SnapshotRouteCommand = class {
@@ -1558,6 +2683,7 @@ async function createServer(storage, options, handlers = /* @__PURE__ */ new Map
1558
2683
  }
1559
2684
  const commands = [
1560
2685
  new AboutRouteCommand(storage, options, handlers),
2686
+ new OpenApiRouteCommand(storage, options),
1561
2687
  ...options.snapshot ? [new SnapshotRouteCommand(storage)] : [],
1562
2688
  new CustomRouteCommand(storage, options.base, handlers),
1563
2689
  ...buildResourceRouteCommands(storage, options)
@@ -1637,10 +2763,12 @@ function buildOptions(opts) {
1637
2763
  }
1638
2764
  function createInMemoryStorage(data) {
1639
2765
  const raw = data;
2766
+ const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
1640
2767
  const relations = parseRelations(raw["_rel"]);
1641
2768
  const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
2769
+ const schema = parseSchema(raw["_schema"]);
1642
2770
  const collections = Object.fromEntries(
1643
- Object.entries(raw).filter(([k]) => k !== "_rel" && k !== "_routes")
2771
+ Object.entries(raw).filter(([k]) => !RESERVED.has(k))
1644
2772
  );
1645
2773
  let snapshot = {
1646
2774
  data: deepCopyData(collections),
@@ -1650,6 +2778,7 @@ function createInMemoryStorage(data) {
1650
2778
  return {
1651
2779
  getData: () => collections,
1652
2780
  getRelations: () => relations,
2781
+ getSchema: () => schema,
1653
2782
  getRoutes: () => routes,
1654
2783
  getCollection: (name) => collections[name],
1655
2784
  setCollection: (name, items) => {