@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.
@@ -97,11 +97,11 @@ _rel:
97
97
  userId: users
98
98
  # many2many \u2014 posts can have multiple tags via the post_tags pivot
99
99
  tags:
100
- type: many2many
101
- target: tags
102
- through: post_tags
103
- foreignKey: postId
104
- otherKey: tagId
100
+ _type: many2many
101
+ _target: tags
102
+ _through: post_tags
103
+ _foreignKey: postId
104
+ _otherKey: tagId
105
105
  comments:
106
106
  postId: posts
107
107
  userId: users
@@ -109,8 +109,8 @@ _rel:
109
109
  # one2one \u2014 each user has exactly one profile
110
110
  profiles:
111
111
  userId:
112
- type: one2one
113
- target: users
112
+ _type: one2one
113
+ _target: users
114
114
 
115
115
  users:
116
116
  - id: 1
@@ -259,52 +259,52 @@ _rel:
259
259
  # many2many \u2014 products belong to multiple categories via pivot
260
260
  products:
261
261
  categories:
262
- type: many2many
263
- target: categories
264
- through: product_categories
265
- foreignKey: productId
266
- otherKey: categoryId
262
+ _type: many2many
263
+ _target: categories
264
+ _through: product_categories
265
+ _foreignKey: productId
266
+ _otherKey: categoryId
267
267
 
268
268
  _routes:
269
269
  # Login with conditional scenarios
270
- - method: POST
271
- path: /auth/login
272
- scenarios:
273
- - when:
270
+ - _method: POST
271
+ _path: /auth/login
272
+ _scenarios:
273
+ - _when:
274
274
  body.email: admin@example.com
275
275
  body.password: secret
276
- response:
277
- status: 200
278
- body:
276
+ _response:
277
+ _status: 200
278
+ _body:
279
279
  token: tok-admin-abc123
280
280
  role: admin
281
281
  userId: 1
282
- - when:
282
+ - _when:
283
283
  body.email: user@example.com
284
284
  body.password: secret
285
- response:
286
- status: 200
287
- body:
285
+ _response:
286
+ _status: 200
287
+ _body:
288
288
  token: tok-user-xyz789
289
289
  role: user
290
290
  userId: 2
291
- otherwise:
292
- status: 401
293
- body:
291
+ _otherwise:
292
+ _status: 401
293
+ _body:
294
294
  error: Invalid credentials
295
295
 
296
296
  # Logout \u2014 always 204
297
- - method: POST
298
- path: /auth/logout
299
- response:
300
- status: 204
297
+ - _method: POST
298
+ _path: /auth/logout
299
+ _response:
300
+ _status: 204
301
301
 
302
302
  # Static featured products list
303
- - method: GET
304
- path: /store/featured
305
- response:
306
- status: 200
307
- body:
303
+ - _method: GET
304
+ _path: /store/featured
305
+ _response:
306
+ _status: 200
307
+ _body:
308
308
  - id: 1
309
309
  name: Laptop Pro 15
310
310
  price: 1299.99
@@ -319,31 +319,31 @@ _routes:
319
319
  badge: On Sale
320
320
 
321
321
  # Template variables \u2014 echoes the requested product id
322
- - method: GET
323
- path: /products/:id/summary
324
- response:
325
- status: 200
326
- body:
322
+ - _method: GET
323
+ _path: /products/:id/summary
324
+ _response:
325
+ _status: 200
326
+ _body:
327
327
  productId: "{{params.id}}"
328
328
  requestedAt: "{{now}}"
329
329
  source: mock
330
330
 
331
331
  # Cancel order with simulated latency and template vars
332
- - method: POST
333
- path: /orders/:id/cancel
334
- delay: 500
335
- response:
336
- status: 200
337
- body:
332
+ - _method: POST
333
+ _path: /orders/:id/cancel
334
+ _delay: 500
335
+ _response:
336
+ _status: 200
337
+ _body:
338
338
  orderId: "{{params.id}}"
339
339
  status: cancelled
340
340
  cancelledAt: "{{now}}"
341
341
 
342
342
  # Simulate a service outage for testing error handling
343
- - method: GET
344
- path: /store/inventory/sync
345
- error: 503
346
- errorBody:
343
+ - _method: GET
344
+ _path: /store/inventory/sync
345
+ _error: 503
346
+ _errorBody:
347
347
  message: Inventory service temporarily unavailable
348
348
  retryAfter: 30
349
349
 
@@ -538,33 +538,143 @@ function parseRelations(raw) {
538
538
  }
539
539
  function normaliseRelationDef(key, value) {
540
540
  if (typeof value === "string") {
541
- return { type: "many2one", target: value };
541
+ return value.includes(":") ? parseDslString(value) : { type: "many2one", target: value };
542
542
  }
543
543
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
544
544
  const v = value;
545
- const type = v["type"];
546
- const nested = v["nested"] === true ? true : void 0;
545
+ const type = v["_type"];
546
+ const nested = v["_nested"] === true ? true : void 0;
547
+ const carDirect = typeof v["_car-direct"] === "string" ? v["_car-direct"] : void 0;
548
+ const carInverse = typeof v["_car-inverse"] === "string" ? v["_car-inverse"] : void 0;
547
549
  if (type === "many2one" || type === void 0) {
548
- const target = v["target"];
550
+ const target = v["_target"];
549
551
  if (typeof target !== "string") return null;
550
- return nested ? { type: "many2one", target, nested } : { type: "many2one", target };
552
+ const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
553
+ return compact({ type: "many2one", target, foreignKey, nested, carDirect, carInverse });
551
554
  }
552
555
  if (type === "one2one") {
553
- const target = v["target"];
556
+ const target = v["_target"];
554
557
  if (typeof target !== "string") return null;
555
- return nested ? { type: "one2one", target, nested } : { type: "one2one", target };
558
+ const foreignKey = typeof v["_foreignKey"] === "string" ? v["_foreignKey"] : void 0;
559
+ return compact({ type: "one2one", target, foreignKey, nested, carDirect, carInverse });
556
560
  }
557
561
  if (type === "many2many") {
558
- const target = typeof v["target"] === "string" ? v["target"] : key;
559
- const through = v["through"];
560
- const foreignKey = v["foreignKey"];
561
- const otherKey = v["otherKey"];
562
+ const target = typeof v["_target"] === "string" ? v["_target"] : key;
563
+ const through = v["_through"];
564
+ const foreignKey = v["_foreignKey"];
565
+ const otherKey = v["_otherKey"];
562
566
  if (typeof through !== "string" || typeof foreignKey !== "string" || typeof otherKey !== "string")
563
567
  return null;
564
- return nested ? { type: "many2many", target, through, foreignKey, otherKey, nested } : { type: "many2many", target, through, foreignKey, otherKey };
568
+ return compact({
569
+ type: "many2many",
570
+ target,
571
+ through,
572
+ foreignKey,
573
+ otherKey,
574
+ nested,
575
+ carDirect,
576
+ carInverse
577
+ });
565
578
  }
566
579
  return null;
567
580
  }
581
+ var TYPE_ALIASES = {
582
+ many2one: "many2one",
583
+ m2o: "many2one",
584
+ one2one: "one2one",
585
+ o2o: "one2one",
586
+ many2many: "many2many",
587
+ m2m: "many2many"
588
+ };
589
+ function parseDslString(raw) {
590
+ const nested = raw.endsWith("+nested") ? true : void 0;
591
+ let s = nested ? raw.slice(0, -7) : raw;
592
+ let carDirect;
593
+ let carInverse;
594
+ const carMatch = s.match(/\[([^[\]]+)->([^[\]]+)\]$/);
595
+ if (carMatch) {
596
+ carDirect = carMatch[1];
597
+ carInverse = carMatch[2];
598
+ s = s.slice(0, s.length - carMatch[0].length);
599
+ }
600
+ const colonIdx = s.indexOf(":");
601
+ if (colonIdx === -1) return null;
602
+ const typeAlias = s.slice(0, colonIdx);
603
+ const body = s.slice(colonIdx + 1);
604
+ const type = TYPE_ALIASES[typeAlias];
605
+ if (!type) return null;
606
+ if (type === "many2many") {
607
+ const m = body.match(/^([^@()[\]]+)@([^@()[\]]+)\(([^,)]+),\s*([^,)]+)\)$/);
608
+ if (!m) return null;
609
+ const [, target2, through, foreignKey2, otherKey] = m;
610
+ return compact({
611
+ type: "many2many",
612
+ target: target2,
613
+ through,
614
+ foreignKey: foreignKey2,
615
+ otherKey,
616
+ nested,
617
+ carDirect,
618
+ carInverse
619
+ });
620
+ }
621
+ const atIdx = body.indexOf("@");
622
+ const target = atIdx === -1 ? body : body.slice(0, atIdx);
623
+ const foreignKey = atIdx === -1 ? void 0 : body.slice(atIdx + 1);
624
+ if (!target) return null;
625
+ return compact({ type, target, foreignKey, nested, carDirect, carInverse });
626
+ }
627
+ function compact(obj) {
628
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
629
+ }
630
+
631
+ // src/storage/parseRoutes.ts
632
+ function parseRoutes(raw) {
633
+ if (!Array.isArray(raw)) return [];
634
+ return raw.map(normaliseRoute).filter((r) => r !== null);
635
+ }
636
+ function normaliseRoute(raw) {
637
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
638
+ const v = raw;
639
+ const method = v["_method"];
640
+ const path = v["_path"];
641
+ if (typeof method !== "string" || typeof path !== "string") return null;
642
+ const route = { method, path };
643
+ if (typeof v["_handler"] === "string") route.handler = v["_handler"];
644
+ if (typeof v["_delay"] === "number") route.delay = v["_delay"];
645
+ if (typeof v["_error"] === "number") route.error = v["_error"];
646
+ if (v["_errorBody"] !== void 0) route.errorBody = v["_errorBody"];
647
+ const response = normaliseResponse(v["_response"]);
648
+ if (response) route.response = response;
649
+ const otherwise = normaliseResponse(v["_otherwise"]);
650
+ if (otherwise) route.otherwise = otherwise;
651
+ const scenarios = normaliseScenarios(v["_scenarios"]);
652
+ if (scenarios.length > 0) route.scenarios = scenarios;
653
+ return route;
654
+ }
655
+ function normaliseResponse(raw) {
656
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return void 0;
657
+ const v = raw;
658
+ const response = {};
659
+ if (typeof v["_status"] === "number") response.status = v["_status"];
660
+ if (v["_body"] !== void 0) response.body = v["_body"];
661
+ if (v["_headers"] && typeof v["_headers"] === "object" && !Array.isArray(v["_headers"]))
662
+ response.headers = v["_headers"];
663
+ return Object.keys(response).length > 0 ? response : void 0;
664
+ }
665
+ function normaliseScenarios(raw) {
666
+ if (!Array.isArray(raw)) return [];
667
+ return raw.map(normaliseScenario).filter((s) => s !== null);
668
+ }
669
+ function normaliseScenario(raw) {
670
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
671
+ const v = raw;
672
+ const when = v["_when"];
673
+ if (!when || typeof when !== "object") return null;
674
+ const response = normaliseResponse(v["_response"]);
675
+ if (!response) return null;
676
+ return { when, response };
677
+ }
568
678
 
569
679
  // src/storage/parseSchema.ts
570
680
  function parseSchema(raw) {
@@ -586,13 +696,13 @@ function normaliseFieldDef(value) {
586
696
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
587
697
  const v = value;
588
698
  const def = {};
589
- if (v["required"] === true || v["required"] === false) def.required = v["required"];
590
- if (typeof v["type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["type"]))
591
- def.type = v["type"];
592
- if (typeof v["format"] === "string") def.format = v["format"];
593
- if (Array.isArray(v["enum"])) def.enum = v["enum"];
594
- if (typeof v["description"] === "string") def.description = v["description"];
595
- if (v["default"] !== void 0) def.default = v["default"];
699
+ if (v["_required"] === true || v["_required"] === false) def.required = v["_required"];
700
+ if (typeof v["_type"] === "string" && ["string", "integer", "number", "boolean", "object", "array"].includes(v["_type"]))
701
+ def.type = v["_type"];
702
+ if (typeof v["_format"] === "string") def.format = v["_format"];
703
+ if (Array.isArray(v["_enum"])) def.enum = v["_enum"];
704
+ if (typeof v["_description"] === "string") def.description = v["_description"];
705
+ if (v["_default"] !== void 0) def.default = v["_default"];
596
706
  return def;
597
707
  }
598
708
 
@@ -604,12 +714,66 @@ function deepCopyData(source) {
604
714
  }
605
715
 
606
716
  // src/storage/yrestStorage.ts
717
+ function serializeRelations(relations) {
718
+ const out = {};
719
+ for (const [col, fields] of Object.entries(relations)) {
720
+ out[col] = {};
721
+ for (const [key, def] of Object.entries(fields)) {
722
+ out[col][key] = serializeRelationDef(def);
723
+ }
724
+ }
725
+ return out;
726
+ }
727
+ function serializeRelationDef(def) {
728
+ if (def.type === "many2many") {
729
+ const entry2 = {
730
+ _type: def.type,
731
+ _target: def.target,
732
+ _through: def.through,
733
+ _foreignKey: def.foreignKey,
734
+ _otherKey: def.otherKey
735
+ };
736
+ if (def.nested) entry2._nested = true;
737
+ if (def.carDirect) entry2["_car-direct"] = def.carDirect;
738
+ if (def.carInverse) entry2["_car-inverse"] = def.carInverse;
739
+ return entry2;
740
+ }
741
+ const entry = { _type: def.type, _target: def.target };
742
+ if (def.foreignKey) entry._foreignKey = def.foreignKey;
743
+ if (def.nested) entry._nested = true;
744
+ if (def.carDirect) entry["_car-direct"] = def.carDirect;
745
+ if (def.carInverse) entry["_car-inverse"] = def.carInverse;
746
+ return entry;
747
+ }
748
+ function serializeRoutes(routes) {
749
+ return routes.map((r) => {
750
+ const entry = { _method: r.method, _path: r.path };
751
+ if (r.handler) entry._handler = r.handler;
752
+ if (r.delay !== void 0) entry._delay = r.delay;
753
+ if (r.error !== void 0) entry._error = r.error;
754
+ if (r.errorBody !== void 0) entry._errorBody = r.errorBody;
755
+ if (r.response) entry._response = serializeResponse(r.response);
756
+ if (r.otherwise) entry._otherwise = serializeResponse(r.otherwise);
757
+ if (r.scenarios?.length) entry._scenarios = r.scenarios.map(serializeScenario);
758
+ return entry;
759
+ });
760
+ }
761
+ function serializeResponse(res) {
762
+ const out = {};
763
+ if (res.status !== void 0) out._status = res.status;
764
+ if (res.body !== void 0) out._body = res.body;
765
+ if (res.headers) out._headers = res.headers;
766
+ return out;
767
+ }
768
+ function serializeScenario(s) {
769
+ return { _when: s.when, _response: serializeResponse(s.response) };
770
+ }
607
771
  function createYrestStorage(filePath) {
608
772
  const absPath = resolve2(filePath);
609
773
  const raw = parse(readFileSync(absPath, "utf8")) ?? {};
610
774
  const RESERVED = /* @__PURE__ */ new Set(["_rel", "_routes", "_schema"]);
611
775
  const relations = parseRelations(raw["_rel"]);
612
- const routes = Array.isArray(raw["_routes"]) ? raw["_routes"] : [];
776
+ const routes = parseRoutes(raw["_routes"]);
613
777
  const schema = parseSchema(raw["_schema"]);
614
778
  const data = Object.fromEntries(
615
779
  Object.entries(raw).filter(([key]) => !RESERVED.has(key))
@@ -640,8 +804,8 @@ function createYrestStorage(filePath) {
640
804
  },
641
805
  persist() {
642
806
  const payload = {};
643
- if (Object.keys(relations).length > 0) payload._rel = relations;
644
- if (routes.length > 0) payload._routes = routes;
807
+ if (Object.keys(relations).length > 0) payload._rel = serializeRelations(relations);
808
+ if (routes.length > 0) payload._routes = serializeRoutes(routes);
645
809
  Object.assign(payload, data);
646
810
  const tmp = resolve2(dirname(absPath), `.yrest-${randomUUID()}.tmp`);
647
811
  writeFileSync2(tmp, stringify(payload), "utf8");
@@ -1018,12 +1182,508 @@ curl ${curlFlag}${fullPath}`);
1018
1182
  return `<pre>${highlighted}</pre>`;
1019
1183
  }
1020
1184
 
1185
+ // src/router/templates/about.diagram.ts
1186
+ function inferType(value) {
1187
+ if (value === null || value === void 0) return "string";
1188
+ if (typeof value === "boolean") return "boolean";
1189
+ if (typeof value === "number") return Number.isInteger(value) ? "integer" : "number";
1190
+ if (Array.isArray(value)) return "array";
1191
+ if (typeof value === "object") return "object";
1192
+ return "string";
1193
+ }
1194
+ function generateERData(storage) {
1195
+ const data = storage.getData();
1196
+ const relations = storage.getRelations();
1197
+ const schema = storage.getSchema();
1198
+ const pivotTables = /* @__PURE__ */ new Set();
1199
+ for (const fields of Object.values(relations)) {
1200
+ for (const def of Object.values(fields)) {
1201
+ if (def.type === "many2many") pivotTables.add(def.through);
1202
+ }
1203
+ }
1204
+ const fkFields = /* @__PURE__ */ new Map();
1205
+ for (const [source, fields] of Object.entries(relations)) {
1206
+ for (const [key, def] of Object.entries(fields)) {
1207
+ if (def.type !== "many2many") {
1208
+ if (!fkFields.has(source)) fkFields.set(source, /* @__PURE__ */ new Set());
1209
+ fkFields.get(source).add(key);
1210
+ }
1211
+ }
1212
+ }
1213
+ const entities = Object.entries(data).map(([name, items]) => {
1214
+ const sample = items[0] ?? {};
1215
+ const schemaFields = schema[name] ?? {};
1216
+ const allFields = /* @__PURE__ */ new Set([...Object.keys(sample), ...Object.keys(schemaFields)]);
1217
+ const fields = [...allFields].map((fieldName) => ({
1218
+ name: fieldName,
1219
+ type: schemaFields[fieldName]?.type ?? inferType(sample[fieldName]),
1220
+ pk: fieldName === "id",
1221
+ fk: fkFields.get(name)?.has(fieldName) ?? false,
1222
+ required: schemaFields[fieldName]?.required ?? false
1223
+ }));
1224
+ fields.sort((a, b) => {
1225
+ if (a.pk) return -1;
1226
+ if (b.pk) return 1;
1227
+ if (a.fk && !b.fk) return 1;
1228
+ if (!a.fk && b.fk) return -1;
1229
+ return 0;
1230
+ });
1231
+ return { name, fields, isPivot: pivotTables.has(name) };
1232
+ });
1233
+ const erRelations = [];
1234
+ for (const [source, fields] of Object.entries(relations)) {
1235
+ for (const [key, def] of Object.entries(fields)) {
1236
+ if (def.type === "many2many") {
1237
+ erRelations.push({
1238
+ source,
1239
+ target: def.target,
1240
+ type: "many2many",
1241
+ label: def.through,
1242
+ through: def.through,
1243
+ foreignKey: def.foreignKey,
1244
+ otherKey: def.otherKey
1245
+ });
1246
+ } else {
1247
+ erRelations.push({
1248
+ source,
1249
+ target: def.target,
1250
+ type: def.type,
1251
+ label: key,
1252
+ carDirect: def.carDirect,
1253
+ carInverse: def.carInverse
1254
+ });
1255
+ }
1256
+ }
1257
+ }
1258
+ return { entities, relations: erRelations };
1259
+ }
1260
+
1261
+ // src/router/templates/about.diagram.canvas.ts
1262
+ function erDiagramSection(data) {
1263
+ if (!data.entities.length) return "";
1264
+ const json = JSON.stringify(data);
1265
+ return (
1266
+ /*html*/
1267
+ `
1268
+ <h2>Data Model</h2>
1269
+
1270
+ <div class="card" style="padding:0;overflow:hidden;position:relative;">
1271
+ <canvas id="er-canvas" style="width:100%;display:block;cursor:grab;"></canvas>
1272
+ <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>
1273
+ </div>
1274
+
1275
+ <script>${/*js*/
1276
+ `
1277
+ (function(){
1278
+
1279
+ var D=${json};
1280
+
1281
+ // \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
1282
+ var NW=178,HH=34,FH=22,FP=10,ROW_GAP=44,COL_GAP=100,BR=8,BO=14,LGH=56;
1283
+
1284
+ var C={bg:'#0d1117',nb:'#161b22',nh:'#1c2128',pb:'#0d1117',ph:'#161b22',
1285
+ bo:'#30363d',bs:'#58a6ff',tx:'#e6edf3',mu:'#7d8590',
1286
+ pk:'#e3b341',fk:'#79c0ff',m2o:'#58a6ff',o2o:'#34d399',m2m:'#818cf8'};
1287
+
1288
+ // \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
1289
+ var cv = document.getElementById('er-canvas');
1290
+ var dpr = window.devicePixelRatio||1;
1291
+ var ctx = cv.getContext('2d');
1292
+
1293
+ function resize(h){
1294
+ if(h)cv.style.height = h + 'px';
1295
+ var r = cv.getBoundingClientRect();
1296
+ cv.width = Math.round(r.width*dpr);
1297
+ cv.height = Math.round(r.height*dpr);
1298
+ }
1299
+
1300
+ // \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
1301
+ var nodes = D.entities.map(function(e){
1302
+ return{e:e,x:0,y:0,w:NW,h:HH+e.fields.length*FH+6};
1303
+ });
1304
+
1305
+ var nIdx={};D.entities.forEach(function(e,i){nIdx[e.name]=i;});
1306
+
1307
+ var adj=nodes.map(function(){return[];}); // forward
1308
+ var radj=nodes.map(function(){return[];}); // reverse
1309
+
1310
+ D.relations.forEach(function(r){
1311
+ if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
1312
+ var s=nIdx[r.source],p=nIdx[r.through],t=nIdx[r.target];
1313
+ if(s!=null&&p!=null&&s!==p){adj[s].push(p);radj[p].push(s);}
1314
+ if(t!=null&&p!=null&&t!==p){adj[t].push(p);radj[p].push(t);}
1315
+ }else{
1316
+ var s=nIdx[r.source],t=nIdx[r.target];
1317
+ if(s==null||t==null||s===t)return;
1318
+ adj[s].push(t);radj[t].push(s);
1319
+ }
1320
+ });
1321
+
1322
+ // \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
1323
+ var groups=[];
1324
+
1325
+ function median(arr){
1326
+ if(!arr.length)return 0;
1327
+ var s=[].concat(arr).sort(function(a,b){return a-b;});
1328
+ return s[Math.floor(s.length/2)];
1329
+ }
1330
+
1331
+ function computeLayout(W,H){
1332
+ var layer=nodes.map(function(){return 0;});
1333
+ var inDeg=nodes.map(function(){return 0;});
1334
+ adj.forEach(function(ts){ts.forEach(function(t){inDeg[t]++;});});
1335
+ var q=[],qi=0;
1336
+ for(var i=0;i<nodes.length;i++)if(inDeg[i]===0)q.push(i);
1337
+ if(!q.length)for(var i=0;i<nodes.length;i++)q.push(i);
1338
+ while(qi<q.length){
1339
+ var u=q[qi++];
1340
+ adj[u].forEach(function(v){if(layer[v]<=layer[u]){layer[v]=layer[u]+1;q.push(v);}});
1341
+ }
1342
+ var maxL=layer.reduce(function(m,l){return Math.max(m,l);},0);
1343
+ groups=[];
1344
+ for(var l=0;l<=maxL;l++)groups.push([]);
1345
+ for(var i=0;i<nodes.length;i++)groups[layer[i]].push(i);
1346
+ for(var l=1;l<=maxL;l++){
1347
+ var pp={};groups[l-1].forEach(function(i,p){pp[i]=p;});
1348
+ groups[l].sort(function(a,b){
1349
+ return median(radj[a].map(function(x){return pp[x]!=null?pp[x]:0;}))-
1350
+ median(radj[b].map(function(x){return pp[x]!=null?pp[x]:0;}));
1351
+ });
1352
+ }
1353
+ for(var l=maxL-1;l>=0;l--){
1354
+ var np={};groups[l+1].forEach(function(i,p){np[i]=p;});
1355
+ groups[l].sort(function(a,b){
1356
+ return median(adj[a].map(function(x){return np[x]!=null?np[x]:0;}))-
1357
+ median(adj[b].map(function(x){return np[x]!=null?np[x]:0;}));
1358
+ });
1359
+ }
1360
+ var colW=NW+COL_GAP;
1361
+ var totalW=(maxL+1)*colW-COL_GAP;
1362
+ var startX=Math.max(20,(W-totalW)/2);
1363
+ for(var l=0;l<=maxL;l++){
1364
+ var g=groups[l];
1365
+ var totalH=g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP;
1366
+ var y=Math.max(20,(H-totalH)/2);
1367
+ g.forEach(function(i){nodes[i].x=startX+l*colW;nodes[i].y=y;y+=nodes[i].h+ROW_GAP;});
1368
+ }
1369
+ }
1370
+
1371
+ function requiredH(){
1372
+ return Math.max(340,Math.min(720,groups.reduce(function(m,g){
1373
+ return Math.max(m,g.reduce(function(s,i){return s+nodes[i].h;},0)+(g.length-1)*ROW_GAP+LGH+70);
1374
+ },300)));
1375
+ }
1376
+
1377
+ // \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
1378
+ var pan={x:0,y:0},zm=1;
1379
+ var origPos=[];
1380
+
1381
+ function savePos(){origPos=nodes.map(function(n){return{x:n.x,y:n.y};});}
1382
+
1383
+ function fitView(W,H){
1384
+ var x0=1e9,y0=1e9,x1=-1e9,y1=-1e9;
1385
+ 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);});
1386
+ var pad=28,gW=x1-x0+pad*2,gH=y1-y0+pad*2;
1387
+ zm=Math.min(W/gW,(H-LGH)/gH,1);
1388
+ pan.x=(W-gW*zm)/2+pad*zm-x0*zm;
1389
+ pan.y=((H-LGH)-gH*zm)/2+pad*zm-y0*zm;
1390
+ }
1391
+
1392
+ // \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
1393
+ function rr(x,y,w,h,r){
1394
+ if(ctx.roundRect){ctx.beginPath();ctx.roundRect(x,y,w,h,r);return;}
1395
+ ctx.beginPath();ctx.moveTo(x+r,y);ctx.arcTo(x+w,y,x+w,y+h,r);
1396
+ 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();
1397
+ }
1398
+
1399
+ function edgePt(n,tx,ty){
1400
+ var cx=n.x+n.w/2,cy=n.y+n.h/2,dx=tx-cx,dy=ty-cy;
1401
+ if(!dx&&!dy)return{x:cx,y:cy};
1402
+ var s=Math.min((n.w/2)/Math.abs(dx||.001),(n.h/2)/Math.abs(dy||.001));
1403
+ return{x:cx+dx*s,y:cy+dy*s};
1404
+ }
1405
+
1406
+ var badges=[];
1407
+
1408
+ function pushBadge(x,y,ux,uy,many,col){
1409
+ badges.push({x:x,y:y,ux:ux,uy:uy,many:many,col:col});
1410
+ }
1411
+
1412
+ // Badge: solid colored circle; crow's foot (many) or double-bar (one) in dark ink inside.
1413
+ // ux,uy points outward from the node \u2014 badge is offset BO px into the gap.
1414
+ // Crow's foot tines open toward the node (-x in rotated frame), tip toward line (+x).
1415
+ function drawBadge(b){
1416
+ var bx=b.x+b.ux*BO,by=b.y+b.uy*BO;
1417
+ ctx.fillStyle=b.col;ctx.strokeStyle=b.col;ctx.lineWidth=1;
1418
+ ctx.beginPath();ctx.arc(bx,by,BR,0,Math.PI*2);ctx.fill();ctx.stroke();
1419
+ ctx.save();ctx.translate(bx,by);ctx.rotate(Math.atan2(b.uy,b.ux));
1420
+ ctx.strokeStyle=C.bg;ctx.lineWidth=1.4;ctx.lineCap='round';
1421
+ ctx.beginPath();
1422
+ if(b.many){
1423
+ ctx.moveTo(-5,-4);ctx.lineTo(5,0); // upper tine \u2192 tip
1424
+ ctx.moveTo(-5,4);ctx.lineTo(5,0); // lower tine \u2192 tip
1425
+ ctx.moveTo(-5,0);ctx.lineTo(5,0); // center line
1426
+ ctx.moveTo(-5,-4);ctx.lineTo(-5,4); // base bar (near node)
1427
+ }else{
1428
+ ctx.moveTo(-3,-5);ctx.lineTo(-3,5); // near bar
1429
+ ctx.moveTo(2,-5);ctx.lineTo(2,5); // far bar
1430
+ }
1431
+ ctx.stroke();
1432
+ ctx.restore();
1433
+ }
1434
+
1435
+ function ecol(t){return t==='one2one'?C.o2o:t==='many2many'?C.m2m:C.m2o;}
1436
+
1437
+ // offset: pixels to shift perpendicular to the edge direction (for parallel edges)
1438
+ function drawEdgeLine(a,b,col,dashed,label,manyA,manyB,off){
1439
+ var p1=edgePt(a,b.x+b.w/2,b.y+b.h/2);
1440
+ var p2=edgePt(b,a.x+a.w/2,a.y+a.h/2);
1441
+ var dx=p2.x-p1.x,dy=p2.y-p1.y,d=Math.sqrt(dx*dx+dy*dy)||1;
1442
+ var ux=dx/d,uy=dy/d;
1443
+ var px=-uy*off,py=ux*off; // perpendicular shift
1444
+ var q1x=p1.x+px,q1y=p1.y+py,q2x=p2.x+px,q2y=p2.y+py;
1445
+ ctx.strokeStyle=col+'55';ctx.lineWidth=1.5;
1446
+ ctx.setLineDash(dashed?[5,4]:[]);
1447
+ ctx.beginPath();ctx.moveTo(q1x,q1y);ctx.lineTo(q2x,q2y);ctx.stroke();
1448
+ ctx.setLineDash([]);
1449
+ pushBadge(q1x,q1y,ux,uy,manyA,col);
1450
+ pushBadge(q2x,q2y,-ux,-uy,manyB,col);
1451
+ if(label){
1452
+ var mx=(q1x+q2x)/2,my=(q1y+q2y)/2;
1453
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='center';ctx.textBaseline='middle';
1454
+ ctx.fillText(label,mx-uy*14,my+ux*14);
1455
+ ctx.textBaseline='alphabetic';
1456
+ }
1457
+ }
1458
+
1459
+ // canonical key for a node pair regardless of direction
1460
+ function pairKey(a,b){
1461
+ var ai=nodes.indexOf(a),bi=nodes.indexOf(b);
1462
+ return ai<bi?ai+'-'+bi:bi+'-'+ai;
1463
+ }
1464
+
1465
+ function collectEdges(){
1466
+ var edges=[];
1467
+ D.relations.forEach(function(r){
1468
+ var col=ecol(r.type);
1469
+ if(r.type==='many2many'&&r.through&&nIdx[r.through]!=null){
1470
+ var sn=nodes[nIdx[r.source]],pn=nodes[nIdx[r.through]],tn=nodes[nIdx[r.target]];
1471
+ if(sn&&pn)edges.push({a:sn,b:pn,col:col,dashed:true,label:r.foreignKey||'',manyA:false,manyB:true,off:0});
1472
+ if(tn&&pn)edges.push({a:tn,b:pn,col:col,dashed:true,label:r.otherKey||'',manyA:false,manyB:true,off:0});
1473
+ }else{
1474
+ var sn=nodes[nIdx[r.source]],tn=nodes[nIdx[r.target]];
1475
+ if(sn&&tn&&sn!==tn){
1476
+ var manyA=r.carInverse?r.carInverse.endsWith('n'):r.type!=='one2one';
1477
+ var manyB=r.carDirect?r.carDirect.endsWith('n'):r.type==='many2many';
1478
+ edges.push({a:sn,b:tn,col:col,dashed:false,label:r.label,manyA:manyA,manyB:manyB,off:0});
1479
+ }
1480
+ }
1481
+ });
1482
+ // assign perpendicular offsets to parallel edges (same node pair)
1483
+ var cnt={},idx={};
1484
+ edges.forEach(function(e){var k=pairKey(e.a,e.b);cnt[k]=(cnt[k]||0)+1;});
1485
+ edges.forEach(function(e){
1486
+ var k=pairKey(e.a,e.b),n=cnt[k];
1487
+ var i=idx[k]=(idx[k]||0);
1488
+ e.off=n>1?(i-(n-1)/2)*16:0;
1489
+ idx[k]++;
1490
+ });
1491
+ return edges;
1492
+ }
1493
+
1494
+ function drawNode(n){
1495
+ var x=n.x,y=n.y,w=n.w,h=n.h,e=n.e;
1496
+ var bg=e.isPivot?C.pb:C.nb,hd=e.isPivot?C.ph:C.nh;
1497
+ ctx.save();ctx.shadowColor='rgba(0,0,0,0.45)';ctx.shadowBlur=14;ctx.shadowOffsetY=4;
1498
+ ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();ctx.restore();
1499
+ ctx.fillStyle=bg;rr(x,y,w,h,7);ctx.fill();
1500
+ ctx.strokeStyle=n===drag?C.bs:C.bo;ctx.lineWidth=1.2;rr(x,y,w,h,7);ctx.stroke();
1501
+ ctx.fillStyle=hd;rr(x,y,w,HH,7);ctx.fill();ctx.fillRect(x,y+HH-7,w,7);
1502
+ ctx.strokeStyle=C.bo;ctx.lineWidth=1;
1503
+ ctx.beginPath();ctx.moveTo(x,y+HH);ctx.lineTo(x+w,y+HH);ctx.stroke();
1504
+ ctx.fillStyle=e.isPivot?C.mu:C.tx;
1505
+ ctx.font='600 12px -apple-system,BlinkMacSystemFont,sans-serif';
1506
+ ctx.textAlign='left';ctx.textBaseline='middle';
1507
+ ctx.fillText(e.name,x+FP,y+HH/2);
1508
+ if(e.isPivot){
1509
+ ctx.fillStyle='#3d444d';ctx.font='9px monospace';ctx.textAlign='right';
1510
+ ctx.fillText('pivot',x+w-FP,y+HH/2);
1511
+ }
1512
+ e.fields.forEach(function(f,fi){
1513
+ var fy=y+HH+fi*FH+FH/2+3;
1514
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
1515
+ ctx.fillText(f.type,x+w-FP-(f.required&&!f.pk?14:0),fy);
1516
+ if(f.pk||f.fk){
1517
+ var tag=f.pk?'PK':'FK',tc=f.pk?C.pk:C.fk;
1518
+ ctx.fillStyle=tc+'22';ctx.strokeStyle=tc+'66';ctx.lineWidth=1;
1519
+ rr(x+FP,fy-6,12,12,2);ctx.fill();ctx.stroke();
1520
+ ctx.fillStyle=tc;ctx.font='7px monospace';ctx.textAlign='center';
1521
+ ctx.fillText(tag,x+FP+6,fy+0.5);
1522
+ }
1523
+ ctx.fillStyle=f.pk?C.pk:f.fk?C.fk:C.tx;
1524
+ ctx.font=(f.required&&!f.pk?'600 ':'')+'11px monospace';
1525
+ ctx.textAlign='left';ctx.textBaseline='middle';
1526
+ ctx.fillText(f.name,x+FP+(f.pk||f.fk?16:0),fy);
1527
+ if(f.required&&!f.pk){
1528
+ ctx.fillStyle='#f85149';ctx.beginPath();
1529
+ ctx.arc(x+w-FP-6,fy,2.5,0,Math.PI*2);ctx.fill();
1530
+ }
1531
+ });
1532
+ ctx.textBaseline='alphabetic';
1533
+ }
1534
+
1535
+ function drawLegend(){
1536
+ var cvW=cv.width/dpr,cvH=cv.height/dpr;
1537
+ var y0=cvH-LGH,y1=cvH-LGH+18,y2=cvH-LGH+38;
1538
+
1539
+ ctx.fillStyle='#161b22';
1540
+ ctx.fillRect(0,y0,cvW,LGH);
1541
+ ctx.strokeStyle='#30363d';ctx.lineWidth=1;
1542
+ ctx.beginPath();ctx.moveTo(0,y0);ctx.lineTo(cvW,y0);ctx.stroke();
1543
+
1544
+ // Row 1: relation line types
1545
+ var types=[
1546
+ {c:C.m2o,l:'many2one',d:false},
1547
+ {c:C.o2o,l:'one2one',d:false},
1548
+ {c:C.m2m,l:'many2many',d:true}
1549
+ ];
1550
+ ctx.textAlign='left';ctx.textBaseline='middle';ctx.font='10px monospace';
1551
+ var x=14;
1552
+ types.forEach(function(it){
1553
+ ctx.strokeStyle=it.c;ctx.lineWidth=1.5;ctx.setLineDash(it.d?[4,3]:[]);
1554
+ ctx.beginPath();ctx.moveTo(x,y1);ctx.lineTo(x+14,y1);ctx.stroke();
1555
+ ctx.setLineDash([]);
1556
+ ctx.fillStyle=C.mu;
1557
+ ctx.fillText(it.l,x+18,y1);
1558
+ x+=18+ctx.measureText(it.l).width+22;
1559
+ });
1560
+
1561
+ // Row 2: cardinality symbols
1562
+ function badge2(cx,cy,many,col){
1563
+ ctx.fillStyle=col;ctx.strokeStyle=col;ctx.lineWidth=1;
1564
+ ctx.beginPath();ctx.arc(cx,cy,6,0,Math.PI*2);ctx.fill();
1565
+ ctx.save();ctx.translate(cx,cy);
1566
+ ctx.strokeStyle=C.bg;ctx.lineWidth=1.2;ctx.lineCap='round';
1567
+ ctx.beginPath();
1568
+ if(many){
1569
+ ctx.moveTo(-4,-3);ctx.lineTo(4,0);ctx.moveTo(-4,3);ctx.lineTo(4,0);
1570
+ ctx.moveTo(-4,0);ctx.lineTo(4,0);ctx.moveTo(-4,-3);ctx.lineTo(-4,3);
1571
+ }else{
1572
+ ctx.moveTo(-2,-3);ctx.lineTo(-2,3);ctx.moveTo(2,-3);ctx.lineTo(2,3);
1573
+ }
1574
+ ctx.stroke();ctx.restore();
1575
+ }
1576
+
1577
+ ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
1578
+ x=14;
1579
+ var pairs=[
1580
+ {a:false,b:true,col:C.m2o,l:'many2one (N:1)'},
1581
+ {a:false,b:false,col:C.o2o,l:'one2one (1:1)'},
1582
+ {a:false,b:true,col:C.m2m,l:'pivot edge (1:N)'}
1583
+ ];
1584
+ pairs.forEach(function(p){
1585
+ ctx.strokeStyle=p.col+'55';ctx.lineWidth=1.2;ctx.setLineDash(p.col===C.m2m?[4,3]:[]);
1586
+ ctx.beginPath();ctx.moveTo(x+8,y2);ctx.lineTo(x+56,y2);ctx.stroke();
1587
+ ctx.setLineDash([]);
1588
+ badge2(x+8,y2,p.a,p.col);
1589
+ badge2(x+56,y2,p.b,p.col);
1590
+ ctx.fillStyle=C.mu;ctx.font='10px monospace';ctx.textAlign='left';ctx.textBaseline='middle';
1591
+ ctx.fillText(p.l,x+66,y2);
1592
+ x+=66+ctx.measureText(p.l).width+24;
1593
+ });
1594
+
1595
+ ctx.fillStyle='#3d444d';ctx.font='10px monospace';ctx.textAlign='right';ctx.textBaseline='middle';
1596
+ ctx.fillText('drag \xB7 scroll to zoom \xB7 dbl-click to reset',cvW-14,y2);
1597
+ ctx.textBaseline='alphabetic';
1598
+ }
1599
+
1600
+ function draw(){
1601
+ var W=cv.width/dpr,H=cv.height/dpr;
1602
+ ctx.setTransform(dpr,0,0,dpr,0,0);
1603
+ ctx.clearRect(0,0,W,H);
1604
+ ctx.fillStyle=C.bg;ctx.fillRect(0,0,W,H);
1605
+ ctx.save();ctx.translate(pan.x,pan.y);ctx.scale(zm,zm);
1606
+ badges=[];
1607
+ var edges=collectEdges();
1608
+ edges.forEach(function(e){drawEdgeLine(e.a,e.b,e.col,e.dashed,e.label,e.manyA,e.manyB,e.off);});
1609
+ nodes.forEach(function(n){drawNode(n);});
1610
+ badges.forEach(function(b){drawBadge(b);});
1611
+ ctx.restore();
1612
+ drawLegend();
1613
+ }
1614
+
1615
+ // \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
1616
+ var drag=null,dox=0,doy=0,isPan=false,ps={x:0,y:0};
1617
+
1618
+ function toW(mx,my){return{x:(mx-pan.x)/zm,y:(my-pan.y)/zm};}
1619
+ function hitTest(wx,wy){
1620
+ 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;}
1621
+ return null;
1622
+ }
1623
+
1624
+ cv.addEventListener('mousedown',function(ev){
1625
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1626
+ var w=toW(mx,my),h=hitTest(w.x,w.y);
1627
+ 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};}
1628
+ cv.style.cursor='grabbing';
1629
+ });
1630
+ window.addEventListener('mousemove',function(ev){
1631
+ if(!drag&&!isPan)return;
1632
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1633
+ if(drag){var w=toW(mx,my);drag.x=w.x-dox;drag.y=w.y-doy;}
1634
+ else{pan.x=mx-ps.x;pan.y=my-ps.y;}
1635
+ draw();
1636
+ });
1637
+ window.addEventListener('mouseup',function(){drag=null;isPan=false;cv.style.cursor='grab';});
1638
+ cv.addEventListener('wheel',function(ev){
1639
+ ev.preventDefault();
1640
+ var r=cv.getBoundingClientRect(),mx=ev.clientX-r.left,my=ev.clientY-r.top;
1641
+ var d=ev.deltaY<0?1.1:0.91,nz=Math.max(0.25,Math.min(3,zm*d));
1642
+ pan.x=mx-(mx-pan.x)*(nz/zm);pan.y=my-(my-pan.y)*(nz/zm);zm=nz;draw();
1643
+ },{passive:false});
1644
+ cv.addEventListener('dblclick',function(){
1645
+ nodes.forEach(function(n,i){n.x=origPos[i].x;n.y=origPos[i].y;});
1646
+ var r=cv.getBoundingClientRect();fitView(r.width,r.height);draw();
1647
+ });
1648
+ window.addEventListener('resize',function(){
1649
+ resize();var r=cv.getBoundingClientRect();
1650
+ computeLayout(r.width,r.height);savePos();fitView(r.width,r.height);draw();
1651
+ });
1652
+
1653
+ document.getElementById('er-dl').addEventListener('click',function(){
1654
+ var SCALE=3;
1655
+ var cw=cv.width/dpr,ch=cv.height/dpr;
1656
+ var oc=document.createElement('canvas');
1657
+ oc.width=Math.round(cw*SCALE);oc.height=Math.round(ch*SCALE);
1658
+ var sav={cv:cv,ctx:ctx,dpr:dpr};
1659
+ cv=oc;ctx=oc.getContext('2d');dpr=SCALE;
1660
+ draw();
1661
+ cv=sav.cv;ctx=sav.ctx;dpr=sav.dpr;
1662
+ var a=document.createElement('a');
1663
+ a.download='er-diagram.png';a.href=oc.toDataURL('image/png');a.click();
1664
+ });
1665
+
1666
+ // \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
1667
+ requestAnimationFrame(function(){
1668
+ resize();
1669
+ var r=cv.getBoundingClientRect();
1670
+ computeLayout(r.width,r.height);
1671
+ var h=requiredH();
1672
+ resize(h);r=cv.getBoundingClientRect();
1673
+ computeLayout(r.width,r.height);
1674
+ savePos();fitView(r.width,r.height);draw();
1675
+ });
1676
+ })();
1677
+ `}</script>`
1678
+ );
1679
+ }
1680
+
1021
1681
  // src/router/templates/about.template.ts
1022
1682
  var _dir = dirname2(fileURLToPath(import.meta.url));
1023
1683
  var LOGO_SRC = (() => {
1024
1684
  try {
1025
- const buf = readFileSync2(join(_dir, "../../assets/logo-color.png"));
1026
- return `data:image/png;base64,${buf.toString("base64")}`;
1685
+ const buf = readFileSync2(join(_dir, "../../assets/yrest-lockup.svg"));
1686
+ return `data:image/svg+xml;base64,${buf.toString("base64")}`;
1027
1687
  } catch {
1028
1688
  return "";
1029
1689
  }
@@ -1034,6 +1694,7 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
1034
1694
  const customRoutes = storage.getRoutes();
1035
1695
  const base = options.base;
1036
1696
  const host = `http://${options.host}:${options.port}`;
1697
+ const swaggerUrl = `https://petstore.swagger.io/?url=${encodeURIComponent(`${host}/_openapi.json`)}`;
1037
1698
  const modes = [];
1038
1699
  if (options.watch) modes.push(badge("watch", "#38bdf8", "#38bdf818"));
1039
1700
  if (options.readonly) modes.push(badge("readonly", "#94a3b8", "#94a3b818"));
@@ -1044,9 +1705,12 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
1044
1705
  if (handlers.size > 0) modes.push(badge(`handlers \xB7 ${handlers.size}`, "#f0883e", "#f0883e18"));
1045
1706
  if (options.idStrategy !== "increment")
1046
1707
  modes.push(badge(`id \xB7 ${options.idStrategy}`, "#a371f7", "#a371f718"));
1708
+ const erDiagram = erDiagramSection(generateERData(storage));
1047
1709
  const accordions = collections.map((col, i) => resourceAccordion(col, base, i === 0)).join("");
1048
1710
  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.`;
1049
- return `<!DOCTYPE html>
1711
+ return (
1712
+ /*html*/
1713
+ `<!DOCTYPE html>
1050
1714
  <html lang="en">
1051
1715
  <head>
1052
1716
  <meta charset="UTF-8">
@@ -1073,20 +1737,30 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
1073
1737
  width: 100%;
1074
1738
  background: linear-gradient(135deg, #0d1117 0%, #161b22 40%, #1a2332 100%);
1075
1739
  border-bottom: 1px solid var(--border);
1076
- padding: 48px 32px 40px;
1740
+ padding: 20px 32px 16px;
1077
1741
  }
1078
- .banner-inner { max-width: 1100px; margin: 0 auto; }
1079
- .banner h1 { font-size: clamp(36px, 6vw, 60px); font-weight: 800; letter-spacing: -2px; line-height: 1; }
1742
+ .banner-inner { margin: 0 auto; display:flex; align-items:center; justify-content:space-between; gap:16px; flex-wrap:wrap; }
1743
+ .banner-left { flex:1; min-width:0; }
1744
+ .banner h1 { font-size: clamp(24px, 4vw, 40px); font-weight: 800; letter-spacing: -1.5px; line-height: 1; }
1080
1745
  .banner h1 .y { color: var(--text); }
1081
1746
  .banner h1 .rest { color: var(--accent); }
1082
- .banner p { color: var(--text-muted); margin-top: 10px; font-size: 15px; }
1083
- .banner-meta { display: flex; gap: 24px; margin-top: 20px; flex-wrap: wrap; }
1084
- .banner-meta span { color: var(--text-muted); font-size: 13px; }
1747
+ .banner p { color: var(--text-muted); margin-top: 6px; font-size: 14px; }
1748
+ .banner-meta { display: flex; gap: 20px; margin-top: 10px; flex-wrap: wrap; }
1749
+ .banner-meta span { color: var(--text-muted); font-size: 12px; }
1085
1750
  .banner-meta span strong { color: var(--text); font-family: monospace; }
1751
+ .api-links { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
1752
+ .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; }
1753
+ .btn-api:hover { border-color:var(--border-hi); color:var(--text); }
1754
+ .btn-swagger { border-color:var(--accent); color:var(--accent); }
1755
+ .btn-swagger:hover { background:#58a6ff18; }
1086
1756
 
1087
1757
  /* \u2500\u2500 Layout \u2500\u2500 */
1088
- .wrap { max-width: 1100px; margin: 0 auto; padding: 32px 24px 48px; }
1758
+ .wrap { padding: 24px 32px 48px; }
1089
1759
  h2 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: var(--text-muted); margin: 32px 0 12px; }
1760
+ .about-grid { display:grid; grid-template-columns:5fr 7fr; gap:28px; align-items:start; }
1761
+ @media(max-width:960px) { .about-grid { grid-template-columns:1fr; } }
1762
+ .col-left, .col-right { min-width:0; }
1763
+ .col-left > h2:first-child, .col-right > h2:first-child { margin-top:0; }
1090
1764
 
1091
1765
  /* \u2500\u2500 Cards \u2500\u2500 */
1092
1766
  .card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius); padding: 20px 24px; }
@@ -1102,10 +1776,8 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
1102
1776
  .modes { display: flex; gap: 8px; flex-wrap: wrap; min-height: 28px; align-items: center; }
1103
1777
  .badge { display: inline-block; padding: 3px 10px; border-radius: 20px; font-size: 12px; font-weight: 600; white-space: nowrap; }
1104
1778
 
1105
- /* \u2500\u2500 Endpoints grid (2 cols on wide, 1 col on narrow) \u2500\u2500 */
1106
- .endpoints-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
1107
- @media (max-width: 860px) { .endpoints-grid { grid-template-columns: 1fr; } }
1108
- .nested-card { grid-column: 1 / -1; }
1779
+ /* \u2500\u2500 Endpoints list \u2500\u2500 */
1780
+ .endpoints-grid { display: flex; flex-direction: column; gap: 10px; }
1109
1781
 
1110
1782
  /* \u2500\u2500 Accordion (details/summary) \u2500\u2500 */
1111
1783
  .resource-card {
@@ -1170,64 +1842,80 @@ function generateAboutHtml(storage, options, handlers = /* @__PURE__ */ new Map(
1170
1842
 
1171
1843
  <div class="banner">
1172
1844
  <div class="banner-inner">
1173
- ${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>`}
1174
- <p>Zero-config REST API mock server</p>
1175
- <div class="banner-meta">
1176
- <span>URL <strong>${host}</strong></span>
1177
- <span>Base <strong>${base || "/"}</strong></span>
1178
- <span>File <strong>${options.file}</strong></span>
1179
- <span>Collections <strong>${collections.length}</strong></span>
1845
+ <div class="banner-left">
1846
+ ${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>`}
1847
+ <p>Zero-config REST API mock server</p>
1848
+ <div class="banner-meta">
1849
+ <span>URL <strong>${host}</strong></span>
1850
+ <span>Base <strong>${base || "/"}</strong></span>
1851
+ <span>File <strong>${options.file}</strong></span>
1852
+ <span>Collections <strong>${collections.length}</strong></span>
1853
+ </div>
1854
+ </div>
1855
+ <div class="api-links">
1856
+ <a href="/_openapi" target="_blank" class="btn-api">OpenAPI YAML</a>
1857
+ <a href="/_openapi.json" target="_blank" class="btn-api">OpenAPI JSON</a>
1858
+ <a href="${swaggerUrl}" target="_blank" class="btn-api btn-swagger">Swagger UI \u2197</a>
1180
1859
  </div>
1181
1860
  </div>
1182
1861
  </div>
1183
1862
 
1184
1863
  <div class="wrap">
1864
+ <div class="about-grid">
1865
+
1866
+ <div class="col-left">
1867
+ <h2>Active Modes</h2>
1868
+ <div class="card">
1869
+ <div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
1870
+ ${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>` : ""}
1871
+ </div>
1872
+
1873
+ <h2>Endpoints</h2>
1874
+ <div class="endpoints-grid">
1875
+ ${accordions}
1876
+ ${nestedRoutesAccordion(relations, base)}
1877
+ ${options.snapshot ? snapshotAccordion() : ""}
1878
+ ${customRoutesAccordion(customRoutes, base, handlers)}
1879
+ ${handlersAccordion(handlers, customRoutes, base)}
1880
+ </div>
1881
+
1882
+ <h2>Query Parameters</h2>
1883
+ <div class="card">
1884
+ <table class="param-table">
1885
+ <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
1886
+ <tbody>
1887
+ <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>
1888
+ <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>
1889
+ <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>
1890
+ <tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
1891
+ <tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
1892
+ <tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
1893
+ <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>
1894
+ <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>
1895
+ <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
1896
+ <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>
1897
+ <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>
1898
+ <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>
1899
+ </tbody>
1900
+ </table>
1901
+ </div>
1902
+ </div>
1185
1903
 
1186
- <h2>Active Modes</h2>
1187
- <div class="card">
1188
- <div class="modes">${modes.length ? modes.join(" ") : `<span style="color:var(--text-muted);font-size:13px">none</span>`}</div>
1189
- ${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>` : ""}
1190
- </div>
1191
-
1192
- <h2>Endpoints</h2>
1193
- <div class="endpoints-grid">
1194
- ${accordions}
1195
- ${nestedRoutesAccordion(relations, base)}
1196
- ${options.snapshot ? snapshotAccordion() : ""}
1197
- ${customRoutesAccordion(customRoutes, base, handlers)}
1198
- ${handlersAccordion(handlers, customRoutes, base)}
1199
- </div>
1904
+ <div class="col-right">
1905
+ ${erDiagram}
1906
+ ${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
1907
+ </div>
1200
1908
 
1201
- <h2>Query Parameters</h2>
1202
- <div class="card">
1203
- <table class="param-table">
1204
- <thead><tr><th>Param</th><th>Example</th><th>Description</th></tr></thead>
1205
- <tbody>
1206
- <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>
1207
- <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>
1208
- <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>
1209
- <tr><td><code>?field_like</code></td><td><code>?name_like=ana</code></td><td>Case-insensitive substring match.</td></tr>
1210
- <tr><td><code>?field_start</code></td><td><code>?name_start=A</code></td><td>Case-insensitive prefix match.</td></tr>
1211
- <tr><td><code>?field_regex</code></td><td><code>?email_regex=gmail</code></td><td>Case-insensitive regular expression match.</td></tr>
1212
- <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>
1213
- <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>
1214
- <tr><td><code>?_page &amp; ?_limit</code></td><td><code>?_page=2&amp;_limit=10</code></td><td>${paginationDesc}</td></tr>
1215
- <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>
1216
- <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>
1217
- <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>
1218
- </tbody>
1219
- </table>
1220
1909
  </div>
1221
1910
 
1222
- ${collections.length ? `<h2>Examples</h2><div class="card">${examplesBlock(collections, relations, base, host, options, customRoutes[0])}</div>` : ""}
1223
-
1224
1911
  <footer>
1225
1912
  Powered by <a href="https://github.com/aggiovato/yRest" target="_blank">@yrest/cli</a> &nbsp;\xB7&nbsp; <a href="/_about">/_about</a>
1226
1913
  </footer>
1227
1914
 
1228
1915
  </div>
1229
1916
  </body>
1230
- </html>`;
1917
+ </html>`
1918
+ );
1231
1919
  }
1232
1920
 
1233
1921
  // src/router/routes/about.routes.ts
@@ -1815,12 +2503,23 @@ var NestedRouteCommand = class {
1815
2503
  relations;
1816
2504
  base;
1817
2505
  register(server) {
2506
+ const registered = /* @__PURE__ */ new Set();
1818
2507
  for (const [source, fields] of Object.entries(this.relations)) {
1819
2508
  for (const [key, def] of Object.entries(fields)) {
1820
2509
  if (def.type === "many2many") {
1821
- this.registerMany2Many(server, source, key, def);
2510
+ const fwd = `${this.base}/${source}/:id/${key}`;
2511
+ const inv = `${this.base}/${def.target}/:id/${source}`;
2512
+ if (!registered.has(fwd) && !registered.has(inv)) {
2513
+ registered.add(fwd);
2514
+ registered.add(inv);
2515
+ this.registerMany2Many(server, source, key, def);
2516
+ }
1822
2517
  } else {
1823
- this.registerFkRelation(server, source, key, def.target, def.type);
2518
+ const collPath = `${this.base}/${def.target}/:id/${source}`;
2519
+ if (!registered.has(collPath)) {
2520
+ registered.add(collPath);
2521
+ this.registerFkRelation(server, source, key, def.target, def.type);
2522
+ }
1824
2523
  }
1825
2524
  }
1826
2525
  }