@rebasepro/server-postgresql 0.0.1-canary.f81da60 → 0.1.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.
Files changed (59) hide show
  1. package/dist/index.es.js +287 -21
  2. package/dist/index.es.js.map +1 -1
  3. package/dist/index.umd.js +287 -21
  4. package/dist/index.umd.js.map +1 -1
  5. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +2 -1
  6. package/dist/server-postgresql/src/schema/introspect-db-inference.d.ts +5 -0
  7. package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +44 -9
  8. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +9 -0
  9. package/dist/types/src/controllers/auth.d.ts +8 -2
  10. package/dist/types/src/controllers/client.d.ts +13 -0
  11. package/dist/types/src/controllers/navigation.d.ts +18 -6
  12. package/dist/types/src/controllers/registry.d.ts +9 -1
  13. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
  14. package/dist/types/src/rebase_context.d.ts +17 -0
  15. package/dist/types/src/types/collections.d.ts +20 -1
  16. package/dist/types/src/types/component_ref.d.ts +47 -0
  17. package/dist/types/src/types/entity_views.d.ts +2 -1
  18. package/dist/types/src/types/index.d.ts +1 -0
  19. package/dist/types/src/types/properties.d.ts +15 -3
  20. package/dist/types/src/types/translations.d.ts +2 -0
  21. package/examples/sdk-demo/node_modules/esbuild/LICENSE.md +21 -0
  22. package/examples/sdk-demo/node_modules/esbuild/README.md +3 -0
  23. package/examples/sdk-demo/node_modules/esbuild/bin/esbuild +223 -0
  24. package/examples/sdk-demo/node_modules/esbuild/install.js +289 -0
  25. package/examples/sdk-demo/node_modules/esbuild/lib/main.d.ts +716 -0
  26. package/examples/sdk-demo/node_modules/esbuild/lib/main.js +2242 -0
  27. package/examples/sdk-demo/node_modules/esbuild/package.json +49 -0
  28. package/package.json +5 -5
  29. package/src/PostgresBackendDriver.ts +23 -6
  30. package/src/cli.ts +10 -2
  31. package/src/data-transformer.ts +84 -1
  32. package/src/schema/doctor.ts +14 -2
  33. package/src/schema/generate-drizzle-schema-logic.ts +52 -5
  34. package/src/schema/introspect-db-inference.ts +238 -0
  35. package/src/schema/introspect-db-logic.ts +365 -61
  36. package/src/schema/introspect-db.ts +66 -23
  37. package/src/services/EntityFetchService.ts +16 -0
  38. package/src/services/EntityPersistService.ts +88 -12
  39. package/test/generate-drizzle-schema.test.ts +295 -0
  40. package/test/introspect-db-generation.test.ts +32 -10
  41. package/test/property-ordering.test.ts +395 -0
  42. package/jest-all.log +0 -3128
  43. package/jest.log +0 -49
  44. package/scratch.ts +0 -41
  45. package/test-drizzle-bug.ts +0 -18
  46. package/test-drizzle-out/0000_cultured_freak.sql +0 -7
  47. package/test-drizzle-out/0001_tiresome_professor_monster.sql +0 -1
  48. package/test-drizzle-out/meta/0000_snapshot.json +0 -55
  49. package/test-drizzle-out/meta/0001_snapshot.json +0 -63
  50. package/test-drizzle-out/meta/_journal.json +0 -20
  51. package/test-drizzle-prompt.sh +0 -2
  52. package/test-policy-prompt.sh +0 -3
  53. package/test-programmatic.ts +0 -30
  54. package/test-programmatic2.ts +0 -59
  55. package/test-schema-no-policies.ts +0 -12
  56. package/test_drizzle_mock.js +0 -3
  57. package/test_find_changed.mjs +0 -32
  58. package/test_hash.js +0 -14
  59. package/test_output.txt +0 -3145
@@ -6,6 +6,7 @@
6
6
  * no process.exit. It is imported by introspect-db.ts (the CLI entry-point)
7
7
  * and consumed directly by tests.
8
8
  */
9
+ import { inferPropertyFromData } from "./introspect-db-inference";
9
10
 
10
11
  // ── Typed interfaces for SQL query results ────────────────────────────
11
12
 
@@ -254,6 +255,252 @@ export function identifyJoinTables(tablesMap: Map<string, TableMeta>): Set<strin
254
255
  return joinTables;
255
256
  }
256
257
 
258
+ // ── Property ordering heuristics ──────────────────────────────────────
259
+
260
+ /**
261
+ * Property metadata used to compute display priority.
262
+ * Keeps computePropertyPriority free of any TableMeta coupling.
263
+ */
264
+ export interface PropertyOrderingContext {
265
+ /** The resolved Rebase property type (e.g. "string", "number", "date", "relation"). */
266
+ propType: string;
267
+ /** Whether this column is a primary key. */
268
+ isPk: boolean;
269
+ /** Whether this column is an enum (USER-DEFINED with matching values). */
270
+ isEnum: boolean;
271
+ /** Whether this is a storage/file-upload field (detected from column name). */
272
+ isStorage: boolean;
273
+ /** The PostgreSQL data_type (e.g. "text", "character varying", "jsonb"). */
274
+ pgDataType: string;
275
+ /** The original column index in PostgreSQL (for stable tiebreaking). */
276
+ originalIndex: number;
277
+ }
278
+
279
+ // — Tier 0: Identity (0–9) ————————————————————————————————————————————
280
+ const IDENTITY_EXACT: Record<string, number> = {
281
+ id: 0,
282
+ uuid: 1,
283
+ _id: 2,
284
+ };
285
+
286
+ // — Tier 1: Title / Name — the "display column" (10–19) ———————————————
287
+ const TITLE_EXACT: Record<string, number> = {
288
+ name: 10,
289
+ title: 11,
290
+ label: 12,
291
+ display_name: 13,
292
+ displayname: 13,
293
+ headline: 14,
294
+ subject: 15,
295
+ heading: 16,
296
+ };
297
+
298
+ // — Tier 2: Human identity fields (20–29) —————————————————————————————
299
+ const HUMAN_IDENTITY_EXACT: Record<string, number> = {
300
+ first_name: 20,
301
+ firstname: 20,
302
+ last_name: 21,
303
+ lastname: 21,
304
+ full_name: 22,
305
+ fullname: 22,
306
+ given_name: 22,
307
+ family_name: 23,
308
+ middle_name: 24,
309
+ username: 25,
310
+ user_name: 25,
311
+ email: 26,
312
+ email_address: 26,
313
+ phone: 27,
314
+ phone_number: 27,
315
+ mobile: 27,
316
+ };
317
+
318
+ // — Tier 3: Core descriptors (30–39) ——————————————————————————————————
319
+ const DESCRIPTOR_EXACT: Record<string, number> = {
320
+ slug: 30,
321
+ code: 31,
322
+ sku: 32,
323
+ reference: 33,
324
+ ref: 33,
325
+ type: 34,
326
+ kind: 34,
327
+ status: 35,
328
+ state: 35,
329
+ role: 36,
330
+ category: 37,
331
+ group: 38,
332
+ priority: 39,
333
+ order: 39,
334
+ sort_order: 39,
335
+ position: 39,
336
+ };
337
+
338
+ // — Tier 12: System timestamps (120–129) ——————————————————————————————
339
+ const SYSTEM_TIMESTAMP_EXACT: Record<string, number> = {
340
+ created_at: 120,
341
+ createdat: 120,
342
+ creation_date: 120,
343
+ inserted_at: 121,
344
+ updated_at: 122,
345
+ updatedat: 122,
346
+ modified_at: 122,
347
+ last_modified: 122,
348
+ deleted_at: 123,
349
+ deletedat: 123,
350
+ archived_at: 124,
351
+ };
352
+
353
+ // — Pattern-based rules for partial matches ———————————————————————————
354
+ const TITLE_PATTERNS = ["name", "title", "label"];
355
+ const LONG_TEXT_NAMES = new Set(["description", "summary", "excerpt", "abstract", "overview", "bio", "biography", "about"]);
356
+ const RICH_CONTENT_NAMES = new Set(["content", "body", "html", "markup", "text", "article_body", "post_body"]);
357
+ const MEDIA_PATTERNS = ["image", "avatar", "photo", "logo", "cover", "thumbnail", "banner", "icon", "picture", "poster"];
358
+ const JSON_MAP_NAMES = new Set(["metadata", "meta", "config", "configuration", "settings", "options", "preferences", "data", "payload", "attributes", "extra", "additional_info"]);
359
+
360
+ /**
361
+ * Compute a numeric priority score for a property.
362
+ * Lower scores appear first in the generated `propertiesOrder` array.
363
+ *
364
+ * The system uses 14 tiers (0–139), with the original column index
365
+ * added as a fractional tiebreaker (originalIndex / 10000) to
366
+ * guarantee stable ordering within the same tier.
367
+ *
368
+ * Pure function — no side effects.
369
+ */
370
+ export function computePropertyPriority(
371
+ columnName: string,
372
+ ctx: PropertyOrderingContext,
373
+ ): number {
374
+ // Normalize camelCase/PascalCase to snake_case, then lowercase
375
+ const col = columnName.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
376
+ const tiebreaker = ctx.originalIndex / 10000;
377
+
378
+ // ── Tier 0: Primary key identity fields
379
+ if (ctx.isPk) {
380
+ const exactScore = IDENTITY_EXACT[col];
381
+ return (exactScore ?? 5) + tiebreaker;
382
+ }
383
+
384
+ // ── Tier 12: System timestamps (check early to prevent false matches)
385
+ const systemTs = SYSTEM_TIMESTAMP_EXACT[col];
386
+ if (systemTs !== undefined) {
387
+ return systemTs + tiebreaker;
388
+ }
389
+
390
+ // ── Tier 1: Title / Name exact matches
391
+ const titleExact = TITLE_EXACT[col];
392
+ if (titleExact !== undefined) {
393
+ return titleExact + tiebreaker;
394
+ }
395
+
396
+ // ── Tier 2: Human identity exact matches
397
+ const humanExact = HUMAN_IDENTITY_EXACT[col];
398
+ if (humanExact !== undefined) {
399
+ return humanExact + tiebreaker;
400
+ }
401
+
402
+ // ── Tier 3: Core descriptor exact matches
403
+ const descriptorExact = DESCRIPTOR_EXACT[col];
404
+ if (descriptorExact !== undefined) {
405
+ return descriptorExact + tiebreaker;
406
+ }
407
+
408
+ // ── Tier 1b: Title-like partial matches (e.g. "product_name", "page_title")
409
+ // Score 17–19 so they rank after exact matches but still in tier 1.
410
+ for (const pattern of TITLE_PATTERNS) {
411
+ if (col.includes(pattern) && col !== pattern) {
412
+ return 17 + tiebreaker;
413
+ }
414
+ }
415
+
416
+ // ── Tier 9: Media / file upload fields (check before general strings)
417
+ if (ctx.isStorage) {
418
+ return 90 + tiebreaker;
419
+ }
420
+ for (const pattern of MEDIA_PATTERNS) {
421
+ if (col.includes(pattern)) {
422
+ return 91 + tiebreaker;
423
+ }
424
+ }
425
+ if (col.endsWith("_url") || col.endsWith("_uri") || col.endsWith("_link")) {
426
+ return 92 + tiebreaker;
427
+ }
428
+
429
+ // ── Tier 7: Long text fields
430
+ if (LONG_TEXT_NAMES.has(col)) {
431
+ return 70 + tiebreaker;
432
+ }
433
+
434
+ // ── Tier 8: Rich content fields
435
+ if (RICH_CONTENT_NAMES.has(col)) {
436
+ return 80 + tiebreaker;
437
+ }
438
+
439
+ // ── Tier 10: JSON / Map types
440
+ if (ctx.propType === "map") {
441
+ return JSON_MAP_NAMES.has(col) ? 100 + tiebreaker : 105 + tiebreaker;
442
+ }
443
+
444
+ // ── Tier 11: Array types
445
+ if (ctx.propType === "array") {
446
+ return 110 + tiebreaker;
447
+ }
448
+
449
+ // ── Tier 6: Owning relations
450
+ if (ctx.propType === "relation") {
451
+ return 60 + tiebreaker;
452
+ }
453
+
454
+ // ── Tier 4: Short text, enums, booleans — "quick glance" fields
455
+ if (ctx.isEnum) {
456
+ return 40 + tiebreaker;
457
+ }
458
+ if (ctx.propType === "boolean") {
459
+ return 45 + tiebreaker;
460
+ }
461
+ if (ctx.propType === "string" && ctx.pgDataType !== "text") {
462
+ // Short string (varchar, char, uuid that's not a PK)
463
+ return 42 + tiebreaker;
464
+ }
465
+
466
+ // ── Tier 5: Numbers & user-facing dates
467
+ if (ctx.propType === "number") {
468
+ return 50 + tiebreaker;
469
+ }
470
+ if (ctx.propType === "date") {
471
+ // A date that isn't a system timestamp (already handled above)
472
+ return 55 + tiebreaker;
473
+ }
474
+
475
+ // ── Tier 7b: text data_type that didn't match long-text names
476
+ if (ctx.propType === "string" && ctx.pgDataType === "text") {
477
+ return 75 + tiebreaker;
478
+ }
479
+
480
+ // ── Tier 13: Fallback / unknown
481
+ return 130 + tiebreaker;
482
+ }
483
+
484
+ /**
485
+ * Sort a `propertiesOrder` array using the priority heuristic.
486
+ * Returns a new sorted array; does not mutate the input.
487
+ *
488
+ * @param entries - Array of { key, columnName, propType, ... } objects
489
+ * carrying the information needed to compute priority.
490
+ */
491
+ export interface PropertyOrderEntry {
492
+ /** The property key in the generated collection (may differ from columnName for relations). */
493
+ key: string;
494
+ /** The ordering context for this property. */
495
+ ctx: PropertyOrderingContext;
496
+ }
497
+
498
+ export function sortPropertiesOrder(entries: PropertyOrderEntry[]): string[] {
499
+ return [...entries]
500
+ .sort((a, b) => computePropertyPriority(a.key, a.ctx) - computePropertyPriority(b.key, b.ctx))
501
+ .map((e) => e.key);
502
+ }
503
+
257
504
  // ── Generate collection file content ──────────────────────────────────
258
505
 
259
506
  export interface GeneratedFile {
@@ -273,6 +520,7 @@ export function generateCollectionFile(
273
520
  joinTables: Set<string>,
274
521
  tablesMap: Map<string, TableMeta>,
275
522
  enumMap: Map<string, string[]>,
523
+ sampleData?: Record<string, unknown>[],
276
524
  ): string {
277
525
  const collectionName = humanize(tableName);
278
526
  const singular = singularize(collectionName);
@@ -281,7 +529,10 @@ export function generateCollectionFile(
281
529
  const imports = new Set<string>(['import { PostgresCollection } from "@rebasepro/types";']);
282
530
 
283
531
  let propsOutput = ``;
284
- const propertiesOrder: string[] = [];
532
+ let relationsOutput = ``;
533
+ const orderEntries: PropertyOrderEntry[] = [];
534
+ const propertyBlocks = new Map<string, string>();
535
+ let columnIndex = 0;
285
536
 
286
537
  // Detect composite primary keys
287
538
  const isCompositePk = meta.pks.length > 1;
@@ -289,9 +540,10 @@ export function generateCollectionFile(
289
540
  // Map columns
290
541
  for (const col of meta.columns) {
291
542
  // Skip foreign keys since we handle them as relations
292
- if (meta.fks.some((fk) => fk.column_name === col.column_name)) continue;
543
+ // Exception: Do not skip if it's part of the primary key!
544
+ if (meta.fks.some((fk) => fk.column_name === col.column_name) && !meta.pks.includes(col.column_name)) continue;
293
545
 
294
- propertiesOrder.push(col.column_name);
546
+ const currentIndex = columnIndex++;
295
547
 
296
548
  // Check if this column uses a PostgreSQL enum type
297
549
  const colEnumValues = enumMap.get(col.udt_name);
@@ -302,92 +554,144 @@ export function generateCollectionFile(
302
554
 
303
555
  const colNameLower = col.column_name.toLowerCase();
304
556
 
305
- // Enum values generate real enumValues from the PG enum
557
+ // ── Data Inference Engine ────────────────────────────────────────────
558
+ let finalPropType = propType;
559
+ let inferenceExtra = "";
560
+
561
+ if (!isEnumColumn && sampleData && sampleData.length > 0) {
562
+ const values = sampleData.map(r => r[col.column_name]);
563
+ const inferred = inferPropertyFromData(col.column_name, col.data_type, propType, values, meta.pks.includes(col.column_name));
564
+ if (inferred.propType) finalPropType = inferred.propType;
565
+ if (inferred.extra) inferenceExtra = inferred.extra;
566
+ }
567
+
568
+ // Enum values — generate real enum from the PG enum
306
569
  if (isEnumColumn && colEnumValues) {
307
570
  const enumEntries = colEnumValues
308
571
  .map((v) => `{ id: "${v}", label: "${humanize(v)}" }`)
309
572
  .join(", ");
310
- extra = `\n enumValues: [${enumEntries}],`;
573
+ extra += `\n enum: [${enumEntries}],`;
311
574
  }
312
575
 
313
576
  // Date auto-value heuristics
314
- if (propType === "date") {
577
+ if (finalPropType === "date") {
315
578
  if (colNameLower === "created_at" || colNameLower === "createdat") {
316
- extra = `\n autoValue: "on_create",\n readOnly: true,\n hideFromCollection: true,`;
579
+ extra += `\n autoValue: "on_create",\n ui: {\n readOnly: true,\n hideFromCollection: true\n },`;
317
580
  } else if (colNameLower === "updated_at" || colNameLower === "updatedat") {
318
- extra = `\n autoValue: "on_update",\n readOnly: true,\n hideFromCollection: true,`;
581
+ extra += `\n autoValue: "on_update",\n ui: {\n readOnly: true,\n hideFromCollection: true\n },`;
319
582
  } else if (col.column_default && (col.column_default.includes("now()") || col.column_default.includes("CURRENT_TIMESTAMP"))) {
320
- extra = `\n autoValue: "on_create",\n readOnly: true,`;
583
+ extra += `\n autoValue: "on_create",\n ui: {\n readOnly: true\n },`;
321
584
  }
322
585
  }
323
586
 
324
- // Array/Map heuristics
325
- if (propType === "array") {
587
+ // Array/Map heuristics (Fallback if not inferred)
588
+ if (finalPropType === "array" && !inferenceExtra.includes("of: {")) {
326
589
  let innerType = "string";
327
590
  if (col.udt_name.startsWith("_")) {
328
591
  const baseType = col.udt_name.substring(1);
329
- // Simple recursive check or hardcoded for inner type:
330
- // We'll just call mapPgType on the baseType
331
592
  innerType = mapPgType(baseType);
332
593
  }
333
- extra = `\n of: { type: "${innerType}" },`;
334
- } else if (propType === "map") {
335
- extra = `\n keyValue: true,`;
594
+ extra += `\n of: { name: "${humanize(col.column_name)} Item", type: "${innerType}" },`;
595
+ } else if (finalPropType === "map" && !inferenceExtra.includes("keyValue: true") && !inferenceExtra.includes("properties: {")) {
596
+ extra += `\n keyValue: true,`;
336
597
  }
337
598
 
338
- // String sub-type heuristics (skip if already handled as enum)
339
- if (propType === "string" && !isEnumColumn) {
340
- if (colNameLower.includes("image") || colNameLower.includes("avatar") || colNameLower.includes("photo") || colNameLower.includes("logo") || colNameLower.includes("cover")) {
341
- extra = `\n storage: {\n storagePath: "${tableName}/${col.column_name}"\n },`;
599
+ // String sub-type heuristics (Fallback if not handled by inference or enum)
600
+ if (finalPropType === "string" && !isEnumColumn && !inferenceExtra) {
601
+ const isUrl = colNameLower.endsWith("_url") || colNameLower.endsWith("_uri") || colNameLower.endsWith("_link");
602
+ const isMedia = colNameLower.includes("image") || colNameLower.includes("avatar") || colNameLower.includes("photo") || colNameLower.includes("logo") || colNameLower.includes("cover");
603
+
604
+ if (isMedia) {
605
+ extra += `\n storage: {\n storagePath: "${tableName}/${col.column_name}"\n },`;
606
+ } else if (isUrl) {
607
+ extra += `\n ui: {\n url: true\n },`;
342
608
  } else if (colNameLower === "description" || colNameLower === "summary" || colNameLower === "excerpt") {
343
- extra = `\n multiline: true,`;
609
+ extra += `\n multiline: true,`;
344
610
  } else if (colNameLower === "content" || colNameLower === "body") {
345
- extra = `\n multiline: true,\n markdown: true,`;
611
+ extra += `\n multiline: true,\n markdown: true,`;
346
612
  } else if (col.data_type === "text") {
347
- extra = `\n multiline: true,`;
613
+ extra += `\n multiline: true,`;
348
614
  }
349
615
  }
616
+
617
+ // Append inference results
618
+ if (inferenceExtra) {
619
+ extra += inferenceExtra;
620
+ if (!extra.endsWith(",")) extra += ",";
621
+ }
350
622
 
351
- // Identify IDs
623
+ // Identify IDs (unless already inferred as UUID/CUID by inferenceEngine)
352
624
  if (meta.pks.includes(col.column_name)) {
353
625
  if (isCompositePk) {
354
626
  extra += `\n // Part of composite primary key (${meta.pks.join(", ")})`;
355
- } else if (propType === "number") {
627
+ } else if (finalPropType === "number" && !inferenceExtra.includes("isId:")) {
356
628
  extra += `\n isId: "increment",`;
357
- } else if (col.data_type.toLowerCase() === "uuid") {
629
+ } else if (col.data_type.toLowerCase() === "uuid" && !inferenceExtra.includes("isId:")) {
358
630
  extra += `\n isId: "uuid",`;
359
- } else {
631
+ } else if (!inferenceExtra.includes("isId:")) {
360
632
  extra += `\n isId: "uuid", // Verify if this is a UUID or CUID`;
361
633
  }
362
634
  }
363
635
 
364
636
  if (col.is_nullable === "NO" && !meta.pks.includes(col.column_name) && !col.column_default) {
365
- extra += `\n validation: {\n required: true\n },`;
637
+ if (extra.includes("validation: {")) {
638
+ extra = extra.replace("validation: {", "validation: {\n required: true,");
639
+ } else {
640
+ extra += `\n validation: {\n required: true\n },`;
641
+ }
366
642
  }
367
643
 
368
644
  const humanName = humanize(col.column_name);
369
645
 
370
- propsOutput += `
646
+ orderEntries.push({
647
+ key: col.column_name,
648
+ ctx: {
649
+ propType: finalPropType,
650
+ isPk: meta.pks.includes(col.column_name),
651
+ isEnum: isEnumColumn,
652
+ isStorage: extra.includes("storage: {") || inferenceExtra.includes("storage: {"),
653
+ pgDataType: col.data_type,
654
+ originalIndex: currentIndex,
655
+ },
656
+ });
657
+
658
+ propertyBlocks.set(col.column_name, `
371
659
  ${col.column_name}: {
372
660
  name: "${humanName}",
373
- type: "${propType}",${extra}
374
- },`;
661
+ columnName: "${col.column_name}",
662
+ type: "${finalPropType}",${extra}
663
+ },`);
375
664
  }
376
665
 
377
666
  // Map Owning Relations (from this table's FKs to other tables)
378
667
  for (const fk of meta.fks) {
379
668
  const targetTableName = fk.foreign_table_name;
380
669
  if (!joinTables.has(targetTableName)) {
381
- const relName = fk.column_name.replace(/_id$/, "");
670
+ let relName = fk.column_name.replace(/_id$/, "");
671
+ if (meta.pks.includes(fk.column_name) && relName === fk.column_name) {
672
+ // If the FK is also the PK and its name doesn't imply a relation (like "id"),
673
+ // use the target table name to avoid conflicting with the PK property.
674
+ relName = targetTableName;
675
+ }
382
676
  // Push the relation property key, not the FK column name
383
- propertiesOrder.push(relName);
677
+ orderEntries.push({
678
+ key: relName,
679
+ ctx: {
680
+ propType: "relation",
681
+ isPk: false,
682
+ isEnum: false,
683
+ isStorage: false,
684
+ pgDataType: "",
685
+ originalIndex: columnIndex++,
686
+ },
687
+ });
384
688
 
385
689
  const targetCollectionCamel = toCollectionVarName(targetTableName);
386
690
  imports.add(`import ${targetCollectionCamel} from "./${targetTableName}";`);
387
691
 
388
692
  const relHumanName = humanize(relName);
389
693
 
390
- propsOutput += `
694
+ propertyBlocks.set(relName, `
391
695
  ${relName}: {
392
696
  name: "${relHumanName}",
393
697
  type: "relation",
@@ -396,26 +700,24 @@ export function generateCollectionFile(
396
700
  direction: "owning",
397
701
  localKey: "${fk.column_name}",
398
702
  // mapped from foreign key: ${fk.column_name} -> ${targetTableName}(${fk.foreign_column_name})
399
- },`;
703
+ },`);
400
704
  }
401
705
  }
402
706
 
403
707
  // Map Inverse Relations (1-to-many where OTHER table points to THIS table)
708
+ // These go into the `relations` array so they render as subcollection tabs.
404
709
  const inverseFks = allFks.filter((fk) => fk.foreign_table_name === tableName && !joinTables.has(fk.table_name));
405
710
  for (const fk of inverseFks) {
406
711
  const sourceTableName = fk.table_name;
407
- propertiesOrder.push(sourceTableName);
408
712
 
409
713
  const targetCollectionCamel = toCollectionVarName(sourceTableName);
410
714
  imports.add(`import ${targetCollectionCamel} from "./${sourceTableName}";`);
411
715
 
412
716
  const inverseRelName = fk.column_name.replace(/_id$/, "");
413
- const relHumanName = humanize(sourceTableName);
414
717
 
415
- propsOutput += `
416
- ${sourceTableName}: {
417
- name: "${relHumanName}",
418
- type: "relation",
718
+ relationsOutput += `
719
+ {
720
+ relationName: "${sourceTableName}",
419
721
  target: () => ${targetCollectionCamel},
420
722
  cardinality: "many",
421
723
  direction: "inverse",
@@ -425,6 +727,7 @@ export function generateCollectionFile(
425
727
  }
426
728
 
427
729
  // Map Many-to-Many Relations (Join Tables)
730
+ // These also go into the `relations` array so they render as subcollection tabs.
428
731
  const relatedJoinTables = Array.from(joinTables).filter((jt) => {
429
732
  const jtMeta = tablesMap.get(jt);
430
733
  return jtMeta ? jtMeta.fks.some((fk) => fk.foreign_table_name === tableName) : false;
@@ -444,15 +747,10 @@ export function generateCollectionFile(
444
747
  const otherFk = selfRefFks[1];
445
748
 
446
749
  const relPropName = `${tableName}_via_${otherFk.column_name.replace(/_id$/, "")}`;
447
- propertiesOrder.push(relPropName);
448
750
 
449
- // Self-ref: import is the same collection (use a lazy reference)
450
- const relHumanName = humanize(otherFk.column_name.replace(/_id$/, ""));
451
-
452
- propsOutput += `
453
- ${relPropName}: {
454
- name: "${relHumanName}",
455
- type: "relation",
751
+ relationsOutput += `
752
+ {
753
+ relationName: "${relPropName}",
456
754
  target: () => ${tableName}Collection,
457
755
  cardinality: "many",
458
756
  direction: "owning",
@@ -469,7 +767,6 @@ export function generateCollectionFile(
469
767
 
470
768
  if (otherFk) {
471
769
  const targetTableName = otherFk.foreign_table_name;
472
- propertiesOrder.push(targetTableName);
473
770
 
474
771
  const targetCollectionCamel = toCollectionVarName(targetTableName);
475
772
  imports.add(`import ${targetCollectionCamel} from "./${targetTableName}";`);
@@ -478,19 +775,17 @@ export function generateCollectionFile(
478
775
  const direction = tableName < targetTableName ? "owning" : "inverse";
479
776
 
480
777
  const thisFk = joinFks.find((fk) => fk.foreign_table_name === tableName);
481
- const relHumanName = humanize(targetTableName);
482
778
 
483
779
  let throughCode = "";
484
780
  if (direction === "owning" && thisFk) {
485
- throughCode = `\n through: {\n table: "${jt}",\n sourceColumn: "${thisFk.column_name}",\n targetColumn: "${otherFk.column_name}"\n }`;
781
+ throughCode = `\n through: {\n table: "${jt}",\n sourceColumn: "${thisFk.column_name}",\n targetColumn: "${otherFk.column_name}"\n },`;
486
782
  } else if (direction === "inverse") {
487
783
  throughCode = `\n // Make sure the target collection configures the 'through' property.`;
488
784
  }
489
785
 
490
- propsOutput += `
491
- ${targetTableName}: {
492
- name: "${relHumanName}",
493
- type: "relation",
786
+ relationsOutput += `
787
+ {
788
+ relationName: "${targetTableName}",
494
789
  target: () => ${targetCollectionCamel},
495
790
  cardinality: "many",
496
791
  direction: "${direction}",${throughCode}
@@ -498,21 +793,30 @@ export function generateCollectionFile(
498
793
  }
499
794
  }
500
795
 
796
+ const relationsBlock = relationsOutput
797
+ ? `\n relations: [${relationsOutput}\n ],`
798
+ : "";
799
+
800
+ const sortedPropertiesOrder = sortPropertiesOrder(orderEntries);
801
+ for (const key of sortedPropertiesOrder) {
802
+ propsOutput += propertyBlocks.get(key) || "";
803
+ }
804
+
805
+ const collectionVarName = toCollectionVarName(tableName);
501
806
  const fileContent = `${Array.from(imports).join("\n")}
502
807
 
503
- const ${tableName}Collection: PostgresCollection = {
808
+ const ${collectionVarName}: PostgresCollection = {
504
809
  name: "${collectionName}",
505
810
  singularName: "${singular}",
506
811
  slug: "${tableName}",
507
812
  table: "${tableName}",
508
813
  icon: "${icon}",
509
- group: "App",
510
814
  properties: {${propsOutput}
511
- },
512
- propertiesOrder: ${JSON.stringify(propertiesOrder, null, 8).replace(/]$/, " ]")}
815
+ },${relationsBlock}
816
+ propertiesOrder: ${JSON.stringify(sortedPropertiesOrder, null, 8).replace(/]$/, " ]")}
513
817
  };
514
818
 
515
- export default ${tableName}Collection;
819
+ export default ${collectionVarName};
516
820
  `;
517
821
 
518
822
  return fileContent;