@yrest/cli 0.10.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/cli/index.js CHANGED
@@ -124,11 +124,11 @@ _rel:
124
124
  userId: users
125
125
  # many2many \u2014 posts can have multiple tags via the post_tags pivot
126
126
  tags:
127
- type: many2many
128
- target: tags
129
- through: post_tags
130
- foreignKey: postId
131
- otherKey: tagId
127
+ _type: many2many
128
+ _target: tags
129
+ _through: post_tags
130
+ _foreignKey: postId
131
+ _otherKey: tagId
132
132
  comments:
133
133
  postId: posts
134
134
  userId: users
@@ -136,8 +136,8 @@ _rel:
136
136
  # one2one \u2014 each user has exactly one profile
137
137
  profiles:
138
138
  userId:
139
- type: one2one
140
- target: users
139
+ _type: one2one
140
+ _target: users
141
141
 
142
142
  users:
143
143
  - id: 1
@@ -286,52 +286,52 @@ _rel:
286
286
  # many2many \u2014 products belong to multiple categories via pivot
287
287
  products:
288
288
  categories:
289
- type: many2many
290
- target: categories
291
- through: product_categories
292
- foreignKey: productId
293
- otherKey: categoryId
289
+ _type: many2many
290
+ _target: categories
291
+ _through: product_categories
292
+ _foreignKey: productId
293
+ _otherKey: categoryId
294
294
 
295
295
  _routes:
296
296
  # Login with conditional scenarios
297
- - method: POST
298
- path: /auth/login
299
- scenarios:
300
- - when:
297
+ - _method: POST
298
+ _path: /auth/login
299
+ _scenarios:
300
+ - _when:
301
301
  body.email: admin@example.com
302
302
  body.password: secret
303
- response:
304
- status: 200
305
- body:
303
+ _response:
304
+ _status: 200
305
+ _body:
306
306
  token: tok-admin-abc123
307
307
  role: admin
308
308
  userId: 1
309
- - when:
309
+ - _when:
310
310
  body.email: user@example.com
311
311
  body.password: secret
312
- response:
313
- status: 200
314
- body:
312
+ _response:
313
+ _status: 200
314
+ _body:
315
315
  token: tok-user-xyz789
316
316
  role: user
317
317
  userId: 2
318
- otherwise:
319
- status: 401
320
- body:
318
+ _otherwise:
319
+ _status: 401
320
+ _body:
321
321
  error: Invalid credentials
322
322
 
323
323
  # Logout \u2014 always 204
324
- - method: POST
325
- path: /auth/logout
326
- response:
327
- status: 204
324
+ - _method: POST
325
+ _path: /auth/logout
326
+ _response:
327
+ _status: 204
328
328
 
329
329
  # Static featured products list
330
- - method: GET
331
- path: /store/featured
332
- response:
333
- status: 200
334
- body:
330
+ - _method: GET
331
+ _path: /store/featured
332
+ _response:
333
+ _status: 200
334
+ _body:
335
335
  - id: 1
336
336
  name: Laptop Pro 15
337
337
  price: 1299.99
@@ -346,31 +346,31 @@ _routes:
346
346
  badge: On Sale
347
347
 
348
348
  # Template variables \u2014 echoes the requested product id
349
- - method: GET
350
- path: /products/:id/summary
351
- response:
352
- status: 200
353
- body:
349
+ - _method: GET
350
+ _path: /products/:id/summary
351
+ _response:
352
+ _status: 200
353
+ _body:
354
354
  productId: "{{params.id}}"
355
355
  requestedAt: "{{now}}"
356
356
  source: mock
357
357
 
358
358
  # Cancel order with simulated latency and template vars
359
- - method: POST
360
- path: /orders/:id/cancel
361
- delay: 500
362
- response:
363
- status: 200
364
- body:
359
+ - _method: POST
360
+ _path: /orders/:id/cancel
361
+ _delay: 500
362
+ _response:
363
+ _status: 200
364
+ _body:
365
365
  orderId: "{{params.id}}"
366
366
  status: cancelled
367
367
  cancelledAt: "{{now}}"
368
368
 
369
369
  # Simulate a service outage for testing error handling
370
- - method: GET
371
- path: /store/inventory/sync
372
- error: 503
373
- errorBody:
370
+ - _method: GET
371
+ _path: /store/inventory/sync
372
+ _error: 503
373
+ _errorBody:
374
374
  message: Inventory service temporarily unavailable
375
375
  retryAfter: 30
376
376
 
@@ -565,33 +565,143 @@ function parseRelations(raw) {
565
565
  }
566
566
  function normaliseRelationDef(key, value) {
567
567
  if (typeof value === "string") {
568
- return { type: "many2one", target: value };
568
+ return value.includes(":") ? parseDslString(value) : { type: "many2one", target: value };
569
569
  }
570
570
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
571
571
  const v = value;
572
- const type = v["type"];
573
- const nested = v["nested"] === true ? true : void 0;
572
+ const type = v["_type"];
573
+ const nested = v["_nested"] === true ? true : void 0;
574
+ const carDirect = typeof v["_car-direct"] === "string" ? v["_car-direct"] : void 0;
575
+ const carInverse = typeof v["_car-inverse"] === "string" ? v["_car-inverse"] : void 0;
574
576
  if (type === "many2one" || type === void 0) {
575
- const target = v["target"];
577
+ const target = v["_target"];
576
578
  if (typeof target !== "string") return null;
577
- return nested ? { type: "many2one", target, nested } : { type: "many2one", target };
579
+ const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
580
+ return compact({ type: "many2one", target, foreignKey, nested, carDirect, carInverse });
578
581
  }
579
582
  if (type === "one2one") {
580
- const target = v["target"];
583
+ const target = v["_target"];
581
584
  if (typeof target !== "string") return null;
582
- return nested ? { type: "one2one", target, nested } : { type: "one2one", target };
585
+ const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
586
+ return compact({ type: "one2one", target, foreignKey, nested, carDirect, carInverse });
583
587
  }
584
588
  if (type === "many2many") {
585
- const target = typeof v["target"] === "string" ? v["target"] : key;
586
- const through = v["through"];
587
- const foreignKey = v["foreignKey"];
588
- const otherKey = v["otherKey"];
589
+ const target = typeof v["_target"] === "string" ? v["_target"] : key;
590
+ const through = v["_through"];
591
+ const foreignKey = v["_foreignKey"];
592
+ const otherKey = v["_otherKey"];
589
593
  if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
590
594
  return null;
591
- return nested ? { type: "many2many", target, through, foreignKey, otherKey, nested } : { type: "many2many", target, through, foreignKey, otherKey };
595
+ return compact({
596
+ type: "many2many",
597
+ target,
598
+ through,
599
+ foreignKey,
600
+ otherKey,
601
+ nested,
602
+ carDirect,
603
+ carInverse
604
+ });
592
605
  }
593
606
  return null;
594
607
  }
608
+ var TYPE_ALIASES = {
609
+ many2one: "many2one",
610
+ m2o: "many2one",
611
+ one2one: "one2one",
612
+ o2o: "one2one",
613
+ many2many: "many2many",
614
+ m2m: "many2many"
615
+ };
616
+ function parseDslString(raw) {
617
+ const nested = raw.endsWith("+nested") ? true : void 0;
618
+ let s = nested ? raw.slice(0, -7) : raw;
619
+ let carDirect;
620
+ let carInverse;
621
+ const carMatch = s.match(/\[([^[\]]+)->([^[\]]+)\]$/);
622
+ if (carMatch) {
623
+ carDirect = carMatch[1];
624
+ carInverse = carMatch[2];
625
+ s = s.slice(0, s.length - carMatch[0].length);
626
+ }
627
+ const colonIdx = s.indexOf(":");
628
+ if (colonIdx === -1) return null;
629
+ const typeAlias = s.slice(0, colonIdx);
630
+ const body = s.slice(colonIdx + 1);
631
+ const type = TYPE_ALIASES[typeAlias];
632
+ if (!type) return null;
633
+ if (type === "many2many") {
634
+ const m = body.match(/^([^@()[\]]+)@([^@()[\]]+)\(([^,)]+),\s*([^,)]+)\)$/);
635
+ if (!m) return null;
636
+ const [, target2, through, foreignKey2, otherKey] = m;
637
+ return compact({
638
+ type: "many2many",
639
+ target: target2,
640
+ through,
641
+ foreignKey: foreignKey2,
642
+ otherKey,
643
+ nested,
644
+ carDirect,
645
+ carInverse
646
+ });
647
+ }
648
+ const atIdx = body.indexOf("@");
649
+ const target = atIdx === -1 ? body : body.slice(0, atIdx);
650
+ const foreignKey = atIdx === -1 ? void 0 : body.slice(atIdx + 1);
651
+ if (!target) return null;
652
+ return compact({ type, target, foreignKey, nested, carDirect, carInverse });
653
+ }
654
+ function compact(obj) {
655
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
656
+ }
657
+
658
+ // src/storage/parseRoutes.ts
659
+ function parseRoutes(raw) {
660
+ if (!Array.isArray(raw)) return [];
661
+ return raw.map(normaliseRoute).filter((r) => r !== null);
662
+ }
663
+ function normaliseRoute(raw) {
664
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
665
+ const v = raw;
666
+ const method = v["_method"];
667
+ const path = v["_path"];
668
+ if (typeof method !== "string" || typeof path !== "string") return null;
669
+ const route = { method, path };
670
+ if (typeof v["_handler"] === "string") route.handler = v["_handler"];
671
+ if (typeof v["_delay"] === "number") route.delay = v["_delay"];
672
+ if (typeof v["_error"] === "number") route.error = v["_error"];
673
+ if (v["_errorBody"] !== void 0) route.errorBody = v["_errorBody"];
674
+ const response = normaliseResponse(v["_response"]);
675
+ if (response) route.response = response;
676
+ const otherwise = normaliseResponse(v["_otherwise"]);
677
+ if (otherwise) route.otherwise = otherwise;
678
+ const scenarios = normaliseScenarios(v["_scenarios"]);
679
+ if (scenarios.length > 0) route.scenarios = scenarios;
680
+ return route;
681
+ }
682
+ function normaliseResponse(raw) {
683
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
684
+ const v = raw;
685
+ const response = {};
686
+ if (typeof v["_status"] === "number") response.status = v["_status"];
687
+ if (v["_body"] !== void 0) response.body = v["_body"];
688
+ if (v["_headers"] && typeof v["_headers"] === "object" && !Array.isArray(v["_headers"]))
689
+ response.headers = v["_headers"];
690
+ return Object.keys(response).length > 0 ? response : void 0;
691
+ }
692
+ function normaliseScenarios(raw) {
693
+ if (!Array.isArray(raw)) return [];
694
+ return raw.map(normaliseScenario).filter((s) => s !== null);
695
+ }
696
+ function normaliseScenario(raw) {
697
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
698
+ const v = raw;
699
+ const when = v["_when"];
700
+ if (!when || typeof when !== "object") return null;
701
+ const response = normaliseResponse(v["_response"]);
702
+ if (!response) return null;
703
+ return { when, response };
704
+ }
595
705
 
596
706
  // src/storage/parseSchema.ts
597
707
  function parseSchema(raw) {
@@ -613,13 +723,13 @@ function normaliseFieldDef(value) {
613
723
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
614
724
  const v = value;
615
725
  const def = {};
616
- if (v["required"] === true || v["required"] === false) def.required = v["required"];
617
- if (typeof v["type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["type"]))
618
- def.type = v["type"];
619
- if (typeof v["format"] === "string") def.format = v["format"];
620
- if (Array.isArray(v["enum"])) def.enum = v["enum"];
621
- if (typeof v["description"] === "string") def.description = v["description"];
622
- if (v["default"] !== void 0) def.default = v["default"];
726
+ if (v["_required"] === true || v["_required"] === false) def.required = v["_required"];
727
+ if (typeof v["_type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["_type"]))
728
+ def.type = v["_type"];
729
+ if (typeof v["_format"] === "string") def.format = v["_format"];
730
+ if (Array.isArray(v["_enum"])) def.enum = v["_enum"];
731
+ if (typeof v["_description"] === "string") def.description = v["_description"];
732
+ if (v["_default"] !== void 0) def.default = v["_default"];
623
733
  return def;
624
734
  }
625
735
 
@@ -631,12 +741,66 @@ function deepCopyData(source) {
631
741
  }
632
742
 
633
743
  // src/storage/yrestStorage.ts
744
+ function serializeRelations(relations) {
745
+ const out = {};
746
+ for (const [col, fields] of Object.entries(relations)) {
747
+ out[col] = {};
748
+ for (const [key, def] of Object.entries(fields)) {
749
+ out[col][key] = serializeRelationDef(def);
750
+ }
751
+ }
752
+ return out;
753
+ }
754
+ function serializeRelationDef(def) {
755
+ if (def.type === "many2many") {
756
+ const entry2 = {
757
+ _type: def.type,
758
+ _target: def.target,
759
+ _through: def.through,
760
+ _foreignKey: def.foreignKey,
761
+ _otherKey: def.otherKey
762
+ };
763
+ if (def.nested) entry2._nested = true;
764
+ if (def.carDirect) entry2["_car-direct"] = def.carDirect;
765
+ if (def.carInverse) entry2["_car-inverse"] = def.carInverse;
766
+ return entry2;
767
+ }
768
+ const entry = { _type: def.type, _target: def.target };
769
+ if (def.foreignKey) entry._foreignKey = def.foreignKey;
770
+ if (def.nested) entry._nested = true;
771
+ if (def.carDirect) entry["_car-direct"] = def.carDirect;
772
+ if (def.carInverse) entry["_car-inverse"] = def.carInverse;
773
+ return entry;
774
+ }
775
+ function serializeRoutes(routes) {
776
+ return routes.map((r) => {
777
+ const entry = { _method: r.method, _path: r.path };
778
+ if (r.handler) entry._handler = r.handler;
779
+ if (r.delay !== void 0) entry._delay = r.delay;
780
+ if (r.error !== void 0) entry._error = r.error;
781
+ if (r.errorBody !== void 0) entry._errorBody = r.errorBody;
782
+ if (r.response) entry._response = serializeResponse(r.response);
783
+ if (r.otherwise) entry._otherwise = serializeResponse(r.otherwise);
784
+ if (r.scenarios?.length) entry._scenarios = r.scenarios.map(serializeScenario);
785
+ return entry;
786
+ });
787
+ }
788
+ function serializeResponse(res) {
789
+ const out = {};
790
+ if (res.status !== void 0) out._status = res.status;
791
+ if (res.body !== void 0) out._body = res.body;
792
+ if (res.headers) out._headers = res.headers;
793
+ return out;
794
+ }
795
+ function serializeScenario(s) {
796
+ return { _when: s.when, _response: serializeResponse(s.response) };
797
+ }
634
798
  function createYrestStorage(filePath) {
635
799
  const absPath = (0, import_node_path2.resolve)(filePath);
636
800
  const raw = (0, import_yaml.parse)((0, import_node_fs2.readFileSync)(absPath, "utf8")) ?? {};
637
801
  const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
638
802
  const relations = parseRelations(raw["_rel"]);
639
- const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
803
+ const routes = parseRoutes(raw["_routes"]);
640
804
  const schema = parseSchema(raw["_schema"]);
641
805
  const data = Object.fromEntries(
642
806
  Object.entries(raw).filter(([key]) => !RESERVED.has(key))
@@ -667,8 +831,8 @@ function createYrestStorage(filePath) {
667
831
  },
668
832
  persist() {
669
833
  const payload = {};
670
- if (Object.keys(relations).length > 0) payload._rel = relations;
671
- if (routes.length > 0) payload._routes = routes;
834
+ if (Object.keys(relations).length > 0) payload._rel = serializeRelations(relations);
835
+ if (routes.length > 0) payload._routes = serializeRoutes(routes);
672
836
  Object.assign(payload, data);
673
837
  const tmp = (0, import_node_path2.resolve)((0, import_node_path2.dirname)(absPath), `.yrest-${(0, import_node_crypto.randomUUID)()}.tmp`);
674
838
  (0, import_node_fs2.writeFileSync)(tmp, (0, import_yaml.stringify)(payload), "utf8");
@@ -1045,12 +1209,508 @@ curl ${curlFlag}${fullPath}`);
1045
1209
  return `<pre>${highlighted}</pre>`;
1046
1210
  }
1047
1211
 
1212
+ // src/router/templates/about.diagram.ts
1213
+ function inferType(value) {
1214
+ if (value === null || value === void 0) return "string";
1215
+ if (typeof value === "boolean") return "boolean";
1216
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
1217
+ if (Array.isArray(value)) return "array";
1218
+ if (typeof value === "object") return "object";
1219
+ return "string";
1220
+ }
1221
+ function generateERData(storage) {
1222
+ const data = storage.getData();
1223
+ const relations = storage.getRelations();
1224
+ const schema = storage.getSchema();
1225
+ const pivotTables = /* @__PURE__ */ new Set();
1226
+ for (const fields of Object.values(relations)) {
1227
+ for (const def of Object.values(fields)) {
1228
+ if (def.type === "many2many") pivotTables.add(def.through);
1229
+ }
1230
+ }
1231
+ const fkFields = /* @__PURE__ */ new Map();
1232
+ for (const [source, fields] of Object.entries(relations)) {
1233
+ for (const [key, def] of Object.entries(fields)) {
1234
+ if (def.type !== "many2many") {
1235
+ if (!fkFields.has(source)) fkFields.set(source, /* @__PURE__ */ new Set());
1236
+ fkFields.get(source).add(key);
1237
+ }
1238
+ }
1239
+ }
1240
+ const entities = Object.entries(data).map(([name, items]) => {
1241
+ const sample = items[0] ?? {};
1242
+ const schemaFields = schema[name] ?? {};
1243
+ const allFields = /* @__PURE__ */ new Set([...Object.keys(sample), ...Object.keys(schemaFields)]);
1244
+ const fields = [...allFields].map((fieldName) => ({
1245
+ name: fieldName,
1246
+ type: schemaFields[fieldName]?.type ?? inferType(sample[fieldName]),
1247
+ pk: fieldName === "id",
1248
+ fk: fkFields.get(name)?.has(fieldName) ?? false,
1249
+ required: schemaFields[fieldName]?.required ?? false
1250
+ }));
1251
+ fields.sort((a, b) => {
1252
+ if (a.pk) return -1;
1253
+ if (b.pk) return 1;
1254
+ if (a.fk && !b.fk) return 1;
1255
+ if (!a.fk && b.fk) return -1;
1256
+ return 0;
1257
+ });
1258
+ return { name, fields, isPivot: pivotTables.has(name) };
1259
+ });
1260
+ const erRelations = [];
1261
+ for (const [source, fields] of Object.entries(relations)) {
1262
+ for (const [key, def] of Object.entries(fields)) {
1263
+ if (def.type === "many2many") {
1264
+ erRelations.push({
1265
+ source,
1266
+ target: def.target,
1267
+ type: "many2many",
1268
+ label: def.through,
1269
+ through: def.through,
1270
+ foreignKey: def.foreignKey,
1271
+ otherKey: def.otherKey
1272
+ });
1273
+ } else {
1274
+ erRelations.push({
1275
+ source,
1276
+ target: def.target,
1277
+ type: def.type,
1278
+ label: key,
1279
+ carDirect: def.carDirect,
1280
+ carInverse: def.carInverse
1281
+ });
1282
+ }
1283
+ }
1284
+ }
1285
+ return { entities, relations: erRelations };
1286
+ }
1287
+
1288
+ // src/router/templates/about.diagram.canvas.ts
1289
+ function erDiagramSection(data) {
1290
+ if (!data.entities.length) return "";
1291
+ const json = JSON.stringify(data);
1292
+ return (
1293
+ /*html*/
1294
+ `
1295
+ <h2>Data Model</h2>
1296
+
1297
+ <div class="card" style="padding:0;overflow:hidden;position:relative;">
1298
+ <canvas id="er-canvas" style="width:100%;display:block;cursor:grab;"></canvas>
1299
+ <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>
1300
+ </div>
1301
+
1302
+ <script>${/*js*/
1303
+ `
1304
+ (function(){
1305
+
1306
+ var D=${json};
1307
+
1308
+ // \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
1309
+ var NW=178,HH=34,FH=22,FP=10,ROW_GAP=44,COL_GAP=100,BR=8,BO=14,LGH=56;
1310
+
1311
+ var C={bg:'#0d1117',nb:'#161b22',nh:'#1c2128',pb:'#0d1117',ph:'#161b22',
1312
+ bo:'#30363d',bs:'#58a6ff',tx:'#e6edf3',mu:'#7d8590',
1313
+ pk:'#e3b341',fk:'#79c0ff',m2o:'#58a6ff',o2o:'#34d399',m2m:'#818cf8'};
1314
+
1315
+ // \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
1316
+ var cv = document.getElementById('er-canvas');
1317
+ var dpr = window.devicePixelRatio||1;
1318
+ var ctx = cv.getContext('2d');
1319
+
1320
+ function resize(h){
1321
+ if(h)cv.style.height = h + 'px';
1322
+ var r = cv.getBoundingClientRect();
1323
+ cv.width = Math.round(r.width*dpr);
1324
+ cv.height = Math.round(r.height*dpr);
1325
+ }
1326
+
1327
+ // \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
1328
+ var nodes = D.entities.map(function(e){
1329
+ return{e:e,x:0,y:0,w:NW,h:HH+e.fields.length*FH+6};
1330
+ });
1331
+
1332
+ var nIdx={};D.entities.forEach(function(e,i){nIdx[e.name]=i;});
1333
+
1334
+ var adj=nodes.map(function(){return[];}); // forward
1335
+ var radj=nodes.map(function(){return[];}); // reverse
1336
+
1337
+ D.relations.forEach(function(r){
1338
+ if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
1339
+ var s=nIdx[r.source],p=nIdx[r.through],t=nIdx[r.target];
1340
+ if(s!=null&&p!=null&&s!==p){adj[s].push(p);radj[p].push(s);}
1341
+ if(t!=null&&p!=null&&t!==p){adj[t].push(p);radj[p].push(t);}
1342
+ }else{
1343
+ var s=nIdx[r.source],t=nIdx[r.target];
1344
+ if(s==null||t==null||s===t)return;
1345
+ adj[s].push(t);radj[t].push(s);
1346
+ }
1347
+ });
1348
+
1349
+ // \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
1350
+ var groups=[];
1351
+
1352
+ function median(arr){
1353
+ if(!arr.length)return 0;
1354
+ var s=[].concat(arr).sort(function(a,b){return a-b;});
1355
+ return s[Math.floor(s.length/2)];
1356
+ }
1357
+
1358
+ function computeLayout(W,H){
1359
+ var layer=nodes.map(function(){return 0;});
1360
+ var inDeg=nodes.map(function(){return 0;});
1361
+ adj.forEach(function(ts){ts.forEach(function(t){inDeg[t]++;});});
1362
+ var q=[],qi=0;
1363
+ for(var i=0;i<nodes.length;i++)if(inDeg[i]===0)q.push(i);
1364
+ if(!q.length)for(var i=0;i<nodes.length;i++)q.push(i);
1365
+ while(qi<q.length){
1366
+ var u=q[qi++];
1367
+ adj[u].forEach(function(v){if(layer[v]<=layer[u]){layer[v]=layer[u]+1;q.push(v);}});
1368
+ }
1369
+ var maxL=layer.reduce(function(m,l){return Math.max(m,l);},0);
1370
+ groups=[];
1371
+ for(var l=0;l<=maxL;l++)groups.push([]);
1372
+ for(var i=0;i<nodes.length;i++)groups[layer[i]].push(i);
1373
+ for(var l=1;l<=maxL;l++){
1374
+ var pp={};groups[l-1].forEach(function(i,p){pp[i]=p;});
1375
+ groups[l].sort(function(a,b){
1376
+ return median(radj[a].map(function(x){return pp[x]!=null?pp[x]:0;}))-
1377
+ median(radj[b].map(function(x){return pp[x]!=null?pp[x]:0;}));
1378
+ });
1379
+ }
1380
+ for(var l=maxL-1;l>=0;l--){
1381
+ var np={};groups[l+1].forEach(function(i,p){np[i]=p;});
1382
+ groups[l].sort(function(a,b){
1383
+ return median(adj[a].map(function(x){return np[x]!=null?np[x]:0;}))-
1384
+ median(adj[b].map(function(x){return np[x]!=null?np[x]:0;}));
1385
+ });
1386
+ }
1387
+ var colW=NW+COL_GAP;
1388
+ var totalW=(maxL+1)*colW-COL_GAP;
1389
+ var startX=Math.max(20,(W-totalW)/2);
1390
+ for(var l=0;l<=maxL;l++){
1391
+ var g=groups[l];
1392
+ var totalH=g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP;
1393
+ var y=Math.max(20,(H-totalH)/2);
1394
+ g.forEach(function(i){nodes[i].x=startX+l*colW;nodes[i].y=y;y+=nodes[i].h+ROW_GAP;});
1395
+ }
1396
+ }
1397
+
1398
+ function requiredH(){
1399
+ return Math.max(340,Math.min(720,groups.reduce(function(m,g){
1400
+ return Math.max(m,g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP+LGH+70);
1401
+ },300)));
1402
+ }
1403
+
1404
+ // \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
1405
+ var pan={x:0,y:0},zm=1;
1406
+ var origPos=[];
1407
+
1408
+ function savePos(){origPos=nodes.map(function(n){return{x:n.x,y:n.y};});}
1409
+
1410
+ function fitView(W,H){
1411
+ var x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;
1412
+ 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);});
1413
+ var pad=28,gW=x1-x0+pad*2,gH=y1-y0+pad*2;
1414
+ zm=Math.min(W/gW,(H-LGH)/gH,1);
1415
+ pan.x=(W-gW*zm)/2+pad*zm-x0*zm;
1416
+ pan.y=((H-LGH)-gH*zm)/2+pad*zm-y0*zm;
1417
+ }
1418
+
1419
+ // \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
1420
+ function rr(x,y,w,h,r){
1421
+ if(ctx.roundRect){ctx.beginPath();ctx.roundRect(x,y,w,h,r);return;}
1422
+ ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);
1423
+ 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();
1424
+ }
1425
+
1426
+ function edgePt(n,tx,ty){
1427
+ var cx=n.x+n.w/2,cy=n.y+n.h/2,dx=tx-cx,dy=ty-cy;
1428
+ if(!dx&&!dy)return{x:cx,y:cy};
1429
+ var s=Math.min((n.w/2)/Math.abs(dx||.001),(n.h/2)/Math.abs(dy||.001));
1430
+ return{x:cx+dx*s,y:cy+dy*s};
1431
+ }
1432
+
1433
+ var badges=[];
1434
+
1435
+ function pushBadge(x,y,ux,uy,many,col){
1436
+ badges.push({x:x,y:y,ux:ux,uy:uy,many:many,col:col});
1437
+ }
1438
+
1439
+ // Badge: solid colored circle; crow's foot (many) or double-bar (one) in dark ink inside.
1440
+ // ux,uy points outward from the node \u2014 badge is offset BO px into the gap.
1441
+ // Crow's foot tines open toward the node (-x in rotated frame), tip toward line (+x).
1442
+ function drawBadge(b){
1443
+ var bx=b.x+b.ux*BO,by=b.y+b.uy*BO;
1444
+ ctx.fillStyle=b.col;ctx.strokeStyle=b.col;ctx.lineWidth=1;
1445
+ ctx.beginPath();ctx.arc(bx,by,BR,0,Math.PI*2);ctx.fill();ctx.stroke();
1446
+ ctx.save();ctx.translate(bx,by);ctx.rotate(Math.atan2(b.uy,b.ux));
1447
+ ctx.strokeStyle=C.bg;ctx.lineWidth=1.4;ctx.lineCap='round';
1448
+ ctx.beginPath();
1449
+ if(b.many){
1450
+ ctx.moveTo(-5,-4);ctx.lineTo(5,0); // upper tine \u2192 tip
1451
+ ctx.moveTo(-5,4);ctx.lineTo(5,0); // lower tine \u2192 tip
1452
+ ctx.moveTo(-5,0);ctx.lineTo(5,0); // center line
1453
+ ctx.moveTo(-5,-4);ctx.lineTo(-5,4); // base bar (near node)
1454
+ }else{
1455
+ ctx.moveTo(-3,-5);ctx.lineTo(-3,5); // near bar
1456
+ ctx.moveTo(2,-5);ctx.lineTo(2,5); // far bar
1457
+ }
1458
+ ctx.stroke();
1459
+ ctx.restore();
1460
+ }
1461
+
1462
+ function ecol(t){return t==='one2one'?C.o2o:t==='many2many'?C.m2m:C.m2o;}
1463
+
1464
+ // offset: pixels to shift perpendicular to the edge direction (for parallel edges)
1465
+ function drawEdgeLine(a,b,col,dashed,label,manyA,manyB,off){
1466
+ var p1=edgePt(a,b.x+b.w/2,b.y+b.h/2);
1467
+ var p2=edgePt(b,a.x+a.w/2,a.y+a.h/2);
1468
+ var dx=p2.x-p1.x,dy=p2.y-p1.y,d=Math.sqrt(dx*dx+dy*dy)||1;
1469
+ var ux=dx/d,uy=dy/d;
1470
+ var px=-uy*off,py=ux*off; // perpendicular shift
1471
+ var q1x=p1.x+px,q1y=p1.y+py,q2x=p2.x+px,q2y=p2.y+py;
1472
+ ctx.strokeStyle=col+'55';ctx.lineWidth=1.5;
1473
+ ctx.setLineDash(dashed?[5,4]:[]);
1474
+ ctx.beginPath();ctx.moveTo(q1x,q1y);ctx.lineTo(q2x,q2y);ctx.stroke();
1475
+ ctx.setLineDash([]);
1476
+ pushBadge(q1x,q1y,ux,uy,manyA,col);
1477
+ pushBadge(q2x,q2y,-ux,-uy,manyB,col);
1478
+ if(label){
1479
+ var mx=(q1x+q2x)/2,my=(q1y+q2y)/2;
1480
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='center';ctx.textBaseline='middle';
1481
+ ctx.fillText(label,mx-uy*14,my+ux*14);
1482
+ ctx.textBaseline='alphabetic';
1483
+ }
1484
+ }
1485
+
1486
+ // canonical key for a node pair regardless of direction
1487
+ function pairKey(a,b){
1488
+ var ai=nodes.indexOf(a),bi=nodes.indexOf(b);
1489
+ return ai<bi?ai+'-'+bi:bi+'-'+ai;
1490
+ }
1491
+
1492
+ function collectEdges(){
1493
+ var edges=[];
1494
+ D.relations.forEach(function(r){
1495
+ var col=ecol(r.type);
1496
+ if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
1497
+ var sn=nodes[nIdx[r.source]],pn=nodes[nIdx[r.through]],tn=nodes[nIdx[r.target]];
1498
+ if(sn&&pn)edges.push({a:sn,b:pn,col:col,dashed:true,label:r.foreignKey||'',manyA:false,manyB:true,off:0});
1499
+ if(tn&&pn)edges.push({a:tn,b:pn,col:col,dashed:true,label:r.otherKey||'',manyA:false,manyB:true,off:0});
1500
+ }else{
1501
+ var sn=nodes[nIdx[r.source]],tn=nodes[nIdx[r.target]];
1502
+ if(sn&&tn&&sn!==tn){
1503
+ var manyA=r.carInverse?r.carInverse.endsWith('n'):r.type!=='one2one';
1504
+ var manyB=r.carDirect?r.carDirect.endsWith('n'):r.type==='many2many';
1505
+ edges.push({a:sn,b:tn,col:col,dashed:false,label:r.label,manyA:manyA,manyB:manyB,off:0});
1506
+ }
1507
+ }
1508
+ });
1509
+ // assign perpendicular offsets to parallel edges (same node pair)
1510
+ var cnt={},idx={};
1511
+ edges.forEach(function(e){var k=pairKey(e.a,e.b);cnt[k]=(cnt[k]||0)+1;});
1512
+ edges.forEach(function(e){
1513
+ var k=pairKey(e.a,e.b),n=cnt[k];
1514
+ var i=idx[k]=(idx[k]||0);
1515
+ e.off=n>1?(i-(n-1)/2)*16:0;
1516
+ idx[k]++;
1517
+ });
1518
+ return edges;
1519
+ }
1520
+
1521
+ function drawNode(n){
1522
+ var x=n.x,y=n.y,w=n.w,h=n.h,e=n.e;
1523
+ var bg=e.isPivot?C.pb:C.nb,hd=e.isPivot?C.ph:C.nh;
1524
+ ctx.save();ctx.shadowColor='rgba(0,0,0,0.45)';ctx.shadowBlur=14;ctx.shadowOffsetY=4;
1525
+ ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();ctx.restore();
1526
+ ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();
1527
+ ctx.strokeStyle=n===drag?C.bs:C.bo;ctx.lineWidth=1.2;rr(x,y,w,h,7);ctx.stroke();
1528
+ ctx.fillStyle=hd;rr(x,y,w,HH,7);ctx.fill();ctx.fillRect(x,y+HH-7,w,7);
1529
+ ctx.strokeStyle=C.bo;ctx.lineWidth=1;
1530
+ ctx.beginPath();ctx.moveTo(x,y+HH);ctx.lineTo(x+w,y+HH);ctx.stroke();
1531
+ ctx.fillStyle=e.isPivot?C.mu:C.tx;
1532
+ ctx.font='600 12px -apple-system,BlinkMacSystemFont,sans-serif';
1533
+ ctx.textAlign='left';ctx.textBaseline='middle';
1534
+ ctx.fillText(e.name,x+FP,y+HH/2);
1535
+ if(e.isPivot){
1536
+ ctx.fillStyle='#3d444d';ctx.font='9px monospace';ctx.textAlign='right';
1537
+ ctx.fillText('pivot',x+w-FP,y+HH/2);
1538
+ }
1539
+ e.fields.forEach(function(f,fi){
1540
+ var fy=y+HH+fi*FH+FH/2+3;
1541
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
1542
+ ctx.fillText(f.type,x+w-FP-(f.required&&!f.pk?14:0),fy);
1543
+ if(f.pk||f.fk){
1544
+ var tag=f.pk?'PK':'FK',tc=f.pk?C.pk:C.fk;
1545
+ ctx.fillStyle=tc+'22';ctx.strokeStyle=tc+'66';ctx.lineWidth=1;
1546
+ rr(x+FP,fy-6,12,12,2);ctx.fill();ctx.stroke();
1547
+ ctx.fillStyle=tc;ctx.font='7px monospace';ctx.textAlign='center';
1548
+ ctx.fillText(tag,x+FP+6,fy+0.5);
1549
+ }
1550
+ ctx.fillStyle=f.pk?C.pk:f.fk?C.fk:C.tx;
1551
+ ctx.font=(f.required&&!f.pk?'600 ':'')+'11px monospace';
1552
+ ctx.textAlign='left';ctx.textBaseline='middle';
1553
+ ctx.fillText(f.name,x+FP+(f.pk||f.fk?16:0),fy);
1554
+ if(f.required&&!f.pk){
1555
+ ctx.fillStyle='#f85149';ctx.beginPath();
1556
+ ctx.arc(x+w-FP-6,fy,2.5,0,Math.PI*2);ctx.fill();
1557
+ }
1558
+ });
1559
+ ctx.textBaseline='alphabetic';
1560
+ }
1561
+
1562
+ function drawLegend(){
1563
+ var cvW=cv.width/dpr,cvH=cv.height/dpr;
1564
+ var y0=cvH-LGH,y1=cvH-LGH+18,y2=cvH-LGH+38;
1565
+
1566
+ ctx.fillStyle='#161b22';
1567
+ ctx.fillRect(0,y0,cvW,LGH);
1568
+ ctx.strokeStyle='#30363d';ctx.lineWidth=1;
1569
+ ctx.beginPath();ctx.moveTo(0,y0);ctx.lineTo(cvW,y0);ctx.stroke();
1570
+
1571
+ // Row 1: relation line types
1572
+ var types=[
1573
+ {c:C.m2o,l:'many2one',d:false},
1574
+ {c:C.o2o,l:'one2one',d:false},
1575
+ {c:C.m2m,l:'many2many',d:true}
1576
+ ];
1577
+ ctx.textAlign='left';ctx.textBaseline='middle';ctx.font='10px monospace';
1578
+ var x=14;
1579
+ types.forEach(function(it){
1580
+ ctx.strokeStyle=it.c;ctx.lineWidth=1.5;ctx.setLineDash(it.d?[4,3]:[]);
1581
+ ctx.beginPath();ctx.moveTo(x,y1);ctx.lineTo(x+14,y1);ctx.stroke();
1582
+ ctx.setLineDash([]);
1583
+ ctx.fillStyle=C.mu;
1584
+ ctx.fillText(it.l,x+18,y1);
1585
+ x+=18+ctx.measureText(it.l).width+22;
1586
+ });
1587
+
1588
+ // Row 2: cardinality symbols
1589
+ function badge2(cx,cy,many,col){
1590
+ ctx.fillStyle=col;ctx.strokeStyle=col;ctx.lineWidth=1;
1591
+ ctx.beginPath();ctx.arc(cx,cy,6,0,Math.PI*2);ctx.fill();
1592
+ ctx.save();ctx.translate(cx,cy);
1593
+ ctx.strokeStyle=C.bg;ctx.lineWidth=1.2;ctx.lineCap='round';
1594
+ ctx.beginPath();
1595
+ if(many){
1596
+ ctx.moveTo(-4,-3);ctx.lineTo(4,0);ctx.moveTo(-4,3);ctx.lineTo(4,0);
1597
+ ctx.moveTo(-4,0);ctx.lineTo(4,0);ctx.moveTo(-4,-3);ctx.lineTo(-4,3);
1598
+ }else{
1599
+ ctx.moveTo(-2,-3);ctx.lineTo(-2,3);ctx.moveTo(2,-3);ctx.lineTo(2,3);
1600
+ }
1601
+ ctx.stroke();ctx.restore();
1602
+ }
1603
+
1604
+ ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
1605
+ x=14;
1606
+ var pairs=[
1607
+ {a:false,b:true,col:C.m2o,l:'many2one (N:1)'},
1608
+ {a:false,b:false,col:C.o2o,l:'one2one (1:1)'},
1609
+ {a:false,b:true,col:C.m2m,l:'pivot edge (1:N)'}
1610
+ ];
1611
+ pairs.forEach(function(p){
1612
+ ctx.strokeStyle=p.col+'55';ctx.lineWidth=1.2;ctx.setLineDash(p.col===C.m2m?[4,3]:[]);
1613
+ ctx.beginPath();ctx.moveTo(x+8,y2);ctx.lineTo(x+56,y2);ctx.stroke();
1614
+ ctx.setLineDash([]);
1615
+ badge2(x+8,y2,p.a,p.col);
1616
+ badge2(x+56,y2,p.b,p.col);
1617
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
1618
+ ctx.fillText(p.l,x+66,y2);
1619
+ x+=66+ctx.measureText(p.l).width+24;
1620
+ });
1621
+
1622
+ ctx.fillStyle='#3d444d';ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
1623
+ ctx.fillText('drag \xB7 scroll to zoom \xB7 dbl-click to reset',cvW-14,y2);
1624
+ ctx.textBaseline='alphabetic';
1625
+ }
1626
+
1627
+ function draw(){
1628
+ var W=cv.width/dpr,H=cv.height/dpr;
1629
+ ctx.setTransform(dpr,0,0,dpr,0,0);
1630
+ ctx.clearRect(0,0,W,H);
1631
+ ctx.fillStyle=C.bg;ctx.fillRect(0,0,W,H);
1632
+ ctx.save();ctx.translate(pan.x,pan.y);ctx.scale(zm,zm);
1633
+ badges=[];
1634
+ var edges=collectEdges();
1635
+ edges.forEach(function(e){drawEdgeLine(e.a,e.b,e.col,e.dashed,e.label,e.manyA,e.manyB,e.off);});
1636
+ nodes.forEach(function(n){drawNode(n);});
1637
+ badges.forEach(function(b){drawBadge(b);});
1638
+ ctx.restore();
1639
+ drawLegend();
1640
+ }
1641
+
1642
+ // \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
1643
+ var drag=null,dox=0,doy=0,isPan=false,ps={x:0,y:0};
1644
+
1645
+ function toW(mx,my){return{x:(mx-pan.x)/zm,y:(my-pan.y)/zm};}
1646
+ function hitTest(wx,wy){
1647
+ 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;}
1648
+ return null;
1649
+ }
1650
+
1651
+ cv.addEventListener('mousedown',function(ev){
1652
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1653
+ var w=toW(mx,my),h=hitTest(w.x,w.y);
1654
+ 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};}
1655
+ cv.style.cursor='grabbing';
1656
+ });
1657
+ window.addEventListener('mousemove',function(ev){
1658
+ if(!drag&&!isPan)return;
1659
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1660
+ if(drag){var w=toW(mx,my);drag.x=w.x-dox;drag.y=w.y-doy;}
1661
+ else{pan.x=mx-ps.x;pan.y=my-ps.y;}
1662
+ draw();
1663
+ });
1664
+ window.addEventListener('mouseup',function(){drag=null;isPan=false;cv.style.cursor='grab';});
1665
+ cv.addEventListener('wheel',function(ev){
1666
+ ev.preventDefault();
1667
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1668
+ var d=ev.deltaY<0?1.1:0.91,nz=Math.max(0.25,Math.min(3,zm*d));
1669
+ pan.x=mx-(mx-pan.x)*(nz/zm);pan.y=my-(my-pan.y)*(nz/zm);zm=nz;draw();
1670
+ },{passive:false});
1671
+ cv.addEventListener('dblclick',function(){
1672
+ nodes.forEach(function(n,i){n.x=origPos[i].x;n.y=origPos[i].y;});
1673
+ var r=cv.getBoundingClientRect();fitView(r.width,r.height);draw();
1674
+ });
1675
+ window.addEventListener('resize',function(){
1676
+ resize();var r=cv.getBoundingClientRect();
1677
+ computeLayout(r.width,r.height);savePos();fitView(r.width,r.height);draw();
1678
+ });
1679
+
1680
+ document.getElementById('er-dl').addEventListener('click',function(){
1681
+ var SCALE=3;
1682
+ var cw=cv.width/dpr,ch=cv.height/dpr;
1683
+ var oc=document.createElement('canvas');
1684
+ oc.width=Math.round(cw*SCALE);oc.height=Math.round(ch*SCALE);
1685
+ var sav={cv:cv,ctx:ctx,dpr:dpr};
1686
+ cv=oc;ctx=oc.getContext('2d');dpr=SCALE;
1687
+ draw();
1688
+ cv=sav.cv;ctx=sav.ctx;dpr=sav.dpr;
1689
+ var a=document.createElement('a');
1690
+ a.download='er-diagram.png';a.href=oc.toDataURL('image/png');a.click();
1691
+ });
1692
+
1693
+ // \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
1694
+ requestAnimationFrame(function(){
1695
+ resize();
1696
+ var r=cv.getBoundingClientRect();
1697
+ computeLayout(r.width,r.height);
1698
+ var h=requiredH();
1699
+ resize(h);r=cv.getBoundingClientRect();
1700
+ computeLayout(r.width,r.height);
1701
+ savePos();fitView(r.width,r.height);draw();
1702
+ });
1703
+ })();
1704
+ `}</script>`
1705
+ );
1706
+ }
1707
+
1048
1708
  // src/router/templates/about.template.ts
1049
1709
  var _dir = (0, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
1050
1710
  var LOGO_SRC = (() => {
1051
1711
  try {
1052
- const buf = (0, import_node_fs3.readFileSync)((0, import_node_path3.join)(_dir, "../../assets/logo-color.png"));
1053
- return `data:image/png;base64,${buf.toString("base64")}`;
1712
+ const buf = (0, import_node_fs3.readFileSync)((0, import_node_path3.join)(_dir, "../../assets/yrest-lockup.svg"));
1713
+ return `data:image/svg+xml;base64,${buf.toString("base64")}`;
1054
1714
  } catch {
1055
1715
  return "";
1056
1716
  }
@@ -1061,6 +1721,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
1061
1721
  const customRoutes = storage.getRoutes();
1062
1722
  const base = options.base;
1063
1723
  const host = `http://${options.host}:${options.port}`;
1724
+ const swaggerUrl = `https://petstore.swagger.io/?url=${encodeURIComponent(`${host}/_openapi.json`)}`;
1064
1725
  const modes = [];
1065
1726
  if (options.watch) modes.push(badge("watch", "#38bdf8", "#38bdf818"));
1066
1727
  if (options.readonly) modes.push(badge("readonly", "#94a3b8", "#94a3b818"));
@@ -1071,9 +1732,12 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
1071
1732
  if (handlers.size > 0) modes.push(badge(`handlers \xB7 ${handlers.size}`, "#f0883e", "#f0883e18"));
1072
1733
  if (options.idStrategy !== "increment")
1073
1734
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
1735
+ const erDiagram = erDiagramSection(generateERData(storage));
1074
1736
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
1075
1737
  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.`;
1076
- return `<!DOCTYPE html>
1738
+ return (
1739
+ /*html*/
1740
+ `<!DOCTYPE html>
1077
1741
  <html lang="en">
1078
1742
  <head>
1079
1743
  <meta charset="UTF-8">
@@ -1100,20 +1764,30 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
1100
1764
  width: 100%;
1101
1765
  background: linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a2332 100%);
1102
1766
  border-bottom: 1px solid var(--border);
1103
- padding: 48px 32px 40px;
1767
+ padding: 20px 32px 16px;
1104
1768
  }
1105
- .banner-inner { max-width: 1100px; margin: 0 auto; }
1106
- .banner h1 { font-size: clamp(36px, 6vw, 60px); font-weight: 800; letter-spacing: -2px; line-height: 1; }
1769
+ .banner-inner { margin: 0 auto; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
1770
+ .banner-left { flex:1; min-width:0; }
1771
+ .banner h1 { font-size: clamp(24px, 4vw, 40px); font-weight: 800; letter-spacing: -1.5px; line-height: 1; }
1107
1772
  .banner h1 .y { color: var(--text); }
1108
1773
  .banner h1 .rest { color: var(--accent); }
1109
- .banner p { color: var(--text-muted); margin-top: 10px; font-size: 15px; }
1110
- .banner-meta { display: flex; gap: 24px; margin-top: 20px; flex-wrap: wrap; }
1111
- .banner-meta span { color: var(--text-muted); font-size: 13px; }
1774
+ .banner p { color: var(--text-muted); margin-top: 6px; font-size: 14px; }
1775
+ .banner-meta { display: flex; gap: 20px; margin-top: 10px; flex-wrap: wrap; }
1776
+ .banner-meta span { color: var(--text-muted); font-size: 12px; }
1112
1777
  .banner-meta span strong { color: var(--text); font-family: monospace; }
1778
+ .api-links { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
1779
+ .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; }
1780
+ .btn-api:hover { border-color:var(--border-hi); color:var(--text); }
1781
+ .btn-swagger { border-color:var(--accent); color:var(--accent); }
1782
+ .btn-swagger:hover { background:#58a6ff18; }
1113
1783
 
1114
1784
  /* \u2500\u2500 Layout \u2500\u2500 */
1115
- .wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 48px; }
1785
+ .wrap { padding: 24px 32px 48px; }
1116
1786
  h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin: 32px 0 12px; }
1787
+ .about-grid { display:grid; grid-template-columns:5fr 7fr; gap:28px; align-items:start; }
1788
+ @media(max-width:960px) { .about-grid { grid-template-columns:1fr; } }
1789
+ .col-left, .col-right { min-width:0; }
1790
+ .col-left > h2:first-child, .col-right > h2:first-child { margin-top:0; }
1117
1791
 
1118
1792
  /* \u2500\u2500 Cards \u2500\u2500 */
1119
1793
  .card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px; }
@@ -1129,10 +1803,8 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
1129
1803
  .modes { display: flex; gap: 8px; flex-wrap: wrap; min-height: 28px; align-items: center; }
1130
1804
  .badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; white-space: nowrap; }
1131
1805
 
1132
- /* \u2500\u2500 Endpoints grid (2 cols on wide, 1 col on narrow) \u2500\u2500 */
1133
- .endpoints-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
1134
- @media (max-width: 860px) { .endpoints-grid { grid-template-columns: 1fr; } }
1135
- .nested-card { grid-column: 1 / -1; }
1806
+ /* \u2500\u2500 Endpoints list \u2500\u2500 */
1807
+ .endpoints-grid { display: flex; flex-direction: column; gap: 10px; }
1136
1808
 
1137
1809
  /* \u2500\u2500 Accordion (details/summary) \u2500\u2500 */
1138
1810
  .resource-card {
@@ -1197,64 +1869,80 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
1197
1869
 
1198
1870
  <div class="banner">
1199
1871
  <div class="banner-inner">
1200
- ${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>`}
1201
- <p>Zero-config REST API mock server</p>
1202
- <div class="banner-meta">
1203
- <span>URL <strong>${host}</strong></span>
1204
- <span>Base <strong>${base || "/"}</strong></span>
1205
- <span>File <strong>${options.file}</strong></span>
1206
- <span>Collections <strong>${collections.length}</strong></span>
1872
+ <div class="banner-left">
1873
+ ${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>`}
1874
+ <p>Zero-config REST API mock server</p>
1875
+ <div class="banner-meta">
1876
+ <span>URL <strong>${host}</strong></span>
1877
+ <span>Base <strong>${base || "/"}</strong></span>
1878
+ <span>File <strong>${options.file}</strong></span>
1879
+ <span>Collections <strong>${collections.length}</strong></span>
1880
+ </div>
1881
+ </div>
1882
+ <div class="api-links">
1883
+ <a href="/_openapi" target="_blank" class="btn-api">OpenAPI YAML</a>
1884
+ <a href="/_openapi.json" target="_blank" class="btn-api">OpenAPI JSON</a>
1885
+ <a href="${swaggerUrl}" target="_blank" class="btn-api btn-swagger">Swagger UI \u2197</a>
1207
1886
  </div>
1208
1887
  </div>
1209
1888
  </div>
1210
1889
 
1211
1890
  <div class="wrap">
1891
+ <div class="about-grid">
1892
+
1893
+ <div class="col-left">
1894
+ <h2>Active Modes</h2>
1895
+ <div class="card">
1896
+ <div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
1897
+ ${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>` : ""}
1898
+ </div>
1899
+
1900
+ <h2>Endpoints</h2>
1901
+ <div class="endpoints-grid">
1902
+ ${accordions}
1903
+ ${nestedRoutesAccordion(relations, base)}
1904
+ ${options.snapshot ? snapshotAccordion() : ""}
1905
+ ${customRoutesAccordion(customRoutes, base, handlers)}
1906
+ ${handlersAccordion(handlers, customRoutes, base)}
1907
+ </div>
1908
+
1909
+ <h2>Query Parameters</h2>
1910
+ <div class="card">
1911
+ <table class="param-table">
1912
+ <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
1913
+ <tbody>
1914
+ <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>
1915
+ <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>
1916
+ <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>
1917
+ <tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
1918
+ <tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
1919
+ <tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
1920
+ <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>
1921
+ <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>
1922
+ <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
1923
+ <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>
1924
+ <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>
1925
+ <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>
1926
+ </tbody>
1927
+ </table>
1928
+ </div>
1929
+ </div>
1212
1930
 
1213
- <h2>Active Modes</h2>
1214
- <div class="card">
1215
- <div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
1216
- ${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>` : ""}
1217
- </div>
1218
-
1219
- <h2>Endpoints</h2>
1220
- <div class="endpoints-grid">
1221
- ${accordions}
1222
- ${nestedRoutesAccordion(relations, base)}
1223
- ${options.snapshot ? snapshotAccordion() : ""}
1224
- ${customRoutesAccordion(customRoutes, base, handlers)}
1225
- ${handlersAccordion(handlers, customRoutes, base)}
1226
- </div>
1931
+ <div class="col-right">
1932
+ ${erDiagram}
1933
+ ${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
1934
+ </div>
1227
1935
 
1228
- <h2>Query Parameters</h2>
1229
- <div class="card">
1230
- <table class="param-table">
1231
- <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
1232
- <tbody>
1233
- <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>
1234
- <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>
1235
- <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>
1236
- <tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
1237
- <tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
1238
- <tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
1239
- <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>
1240
- <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>
1241
- <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
1242
- <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>
1243
- <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>
1244
- <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>
1245
- </tbody>
1246
- </table>
1247
1936
  </div>
1248
1937
 
1249
- ${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
1250
-
1251
1938
  <footer>
1252
1939
  Powered by <a href="https://github.com/aggiovato/yRest" target="_blank">@yrest/cli</a> &nbsp;\xB7&nbsp; <a href="/_about">/_about</a>
1253
1940
  </footer>
1254
1941
 
1255
1942
  </div>
1256
1943
  </body>
1257
- </html>`;
1944
+ </html>`
1945
+ );
1258
1946
  }
1259
1947
 
1260
1948
  // src/router/routes/about.routes.ts
@@ -1842,12 +2530,23 @@ var NestedRouteCommand = class {
1842
2530
  relations;
1843
2531
  base;
1844
2532
  register(server) {
2533
+ const registered = /* @__PURE__ */ new Set();
1845
2534
  for (const [source, fields] of Object.entries(this.relations)) {
1846
2535
  for (const [key, def] of Object.entries(fields)) {
1847
2536
  if (def.type === "many2many") {
1848
- this.registerMany2Many(server, source, key, def);
2537
+ const fwd = `${this.base}/${source}/:id/${key}`;
2538
+ const inv = `${this.base}/${def.target}/:id/${source}`;
2539
+ if (!registered.has(fwd) && !registered.has(inv)) {
2540
+ registered.add(fwd);
2541
+ registered.add(inv);
2542
+ this.registerMany2Many(server, source, key, def);
2543
+ }
1849
2544
  } else {
1850
- this.registerFkRelation(server, source, key, def.target, def.type);
2545
+ const collPath = `${this.base}/${def.target}/:id/${source}`;
2546
+ if (!registered.has(collPath)) {
2547
+ registered.add(collPath);
2548
+ this.registerFkRelation(server, source, key, def.target, def.type);
2549
+ }
1851
2550
  }
1852
2551
  }
1853
2552
  }