@rebasepro/server-postgresql 0.0.1-canary.892f711 → 0.0.1-canary.a6becfb

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 (33) hide show
  1. package/dist/index.es.js +381 -1074
  2. package/dist/index.es.js.map +1 -1
  3. package/dist/index.umd.js +312 -1005
  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 +64 -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 +3 -3
  20. package/dist/types/src/types/translations.d.ts +8 -0
  21. package/package.json +5 -5
  22. package/src/PostgresBackendDriver.ts +23 -6
  23. package/src/cli.ts +9 -1
  24. package/src/data-transformer.ts +84 -1
  25. package/src/schema/generate-drizzle-schema-logic.ts +35 -0
  26. package/src/schema/introspect-db-inference.ts +238 -0
  27. package/src/schema/introspect-db-logic.ts +337 -36
  28. package/src/schema/introspect-db.ts +66 -23
  29. package/src/services/EntityFetchService.ts +16 -0
  30. package/src/services/EntityPersistService.ts +88 -12
  31. package/test/generate-drizzle-schema.test.ts +128 -0
  32. package/test/introspect-db-generation.test.ts +5 -5
  33. package/test/property-ordering.test.ts +395 -0
@@ -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);
@@ -282,7 +530,9 @@ export function generateCollectionFile(
282
530
 
283
531
  let propsOutput = ``;
284
532
  let relationsOutput = ``;
285
- const propertiesOrder: string[] = [];
533
+ const orderEntries: PropertyOrderEntry[] = [];
534
+ const propertyBlocks = new Map<string, string>();
535
+ let columnIndex = 0;
286
536
 
287
537
  // Detect composite primary keys
288
538
  const isCompositePk = meta.pks.length > 1;
@@ -293,7 +543,7 @@ export function generateCollectionFile(
293
543
  // Exception: Do not skip if it's part of the primary key!
294
544
  if (meta.fks.some((fk) => fk.column_name === col.column_name) && !meta.pks.includes(col.column_name)) continue;
295
545
 
296
- propertiesOrder.push(col.column_name);
546
+ const currentIndex = columnIndex++;
297
547
 
298
548
  // Check if this column uses a PostgreSQL enum type
299
549
  const colEnumValues = enumMap.get(col.udt_name);
@@ -304,77 +554,113 @@ export function generateCollectionFile(
304
554
 
305
555
  const colNameLower = col.column_name.toLowerCase();
306
556
 
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
+
307
568
  // Enum values — generate real enum from the PG enum
308
569
  if (isEnumColumn && colEnumValues) {
309
570
  const enumEntries = colEnumValues
310
571
  .map((v) => `{ id: "${v}", label: "${humanize(v)}" }`)
311
572
  .join(", ");
312
- extra = `\n enum: [${enumEntries}],`;
573
+ extra += `\n enum: [${enumEntries}],`;
313
574
  }
314
575
 
315
576
  // Date auto-value heuristics
316
- if (propType === "date") {
577
+ if (finalPropType === "date") {
317
578
  if (colNameLower === "created_at" || colNameLower === "createdat") {
318
- extra = `\n autoValue: "on_create",\n ui: {\n readOnly: true,\n hideFromCollection: true\n },`;
579
+ extra += `\n autoValue: "on_create",\n ui: {\n readOnly: true,\n hideFromCollection: true\n },`;
319
580
  } else if (colNameLower === "updated_at" || colNameLower === "updatedat") {
320
- extra = `\n autoValue: "on_update",\n ui: {\n readOnly: true,\n hideFromCollection: true\n },`;
581
+ extra += `\n autoValue: "on_update",\n ui: {\n readOnly: true,\n hideFromCollection: true\n },`;
321
582
  } else if (col.column_default && (col.column_default.includes("now()") || col.column_default.includes("CURRENT_TIMESTAMP"))) {
322
- extra = `\n autoValue: "on_create",\n ui: {\n readOnly: true\n },`;
583
+ extra += `\n autoValue: "on_create",\n ui: {\n readOnly: true\n },`;
323
584
  }
324
585
  }
325
586
 
326
- // Array/Map heuristics
327
- if (propType === "array") {
587
+ // Array/Map heuristics (Fallback if not inferred)
588
+ if (finalPropType === "array" && !inferenceExtra.includes("of: {")) {
328
589
  let innerType = "string";
329
590
  if (col.udt_name.startsWith("_")) {
330
591
  const baseType = col.udt_name.substring(1);
331
- // Simple recursive check or hardcoded for inner type:
332
- // We'll just call mapPgType on the baseType
333
592
  innerType = mapPgType(baseType);
334
593
  }
335
- extra = `\n of: { name: "${humanize(col.column_name)} Item", type: "${innerType}" },`;
336
- } else if (propType === "map") {
337
- 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,`;
338
597
  }
339
598
 
340
- // String sub-type heuristics (skip if already handled as enum)
341
- if (propType === "string" && !isEnumColumn) {
342
- if (colNameLower.includes("image") || colNameLower.includes("avatar") || colNameLower.includes("photo") || colNameLower.includes("logo") || colNameLower.includes("cover")) {
343
- 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 },`;
344
608
  } else if (colNameLower === "description" || colNameLower === "summary" || colNameLower === "excerpt") {
345
- extra = `\n multiline: true,`;
609
+ extra += `\n multiline: true,`;
346
610
  } else if (colNameLower === "content" || colNameLower === "body") {
347
- extra = `\n multiline: true,\n markdown: true,`;
611
+ extra += `\n multiline: true,\n markdown: true,`;
348
612
  } else if (col.data_type === "text") {
349
- extra = `\n multiline: true,`;
613
+ extra += `\n multiline: true,`;
350
614
  }
351
615
  }
616
+
617
+ // Append inference results
618
+ if (inferenceExtra) {
619
+ extra += inferenceExtra;
620
+ if (!extra.endsWith(",")) extra += ",";
621
+ }
352
622
 
353
- // Identify IDs
623
+ // Identify IDs (unless already inferred as UUID/CUID by inferenceEngine)
354
624
  if (meta.pks.includes(col.column_name)) {
355
625
  if (isCompositePk) {
356
626
  extra += `\n // Part of composite primary key (${meta.pks.join(", ")})`;
357
- } else if (propType === "number") {
627
+ } else if (finalPropType === "number" && !inferenceExtra.includes("isId:")) {
358
628
  extra += `\n isId: "increment",`;
359
- } else if (col.data_type.toLowerCase() === "uuid") {
629
+ } else if (col.data_type.toLowerCase() === "uuid" && !inferenceExtra.includes("isId:")) {
360
630
  extra += `\n isId: "uuid",`;
361
- } else {
631
+ } else if (!inferenceExtra.includes("isId:")) {
362
632
  extra += `\n isId: "uuid", // Verify if this is a UUID or CUID`;
363
633
  }
364
634
  }
365
635
 
366
636
  if (col.is_nullable === "NO" && !meta.pks.includes(col.column_name) && !col.column_default) {
367
- 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
+ }
368
642
  }
369
643
 
370
644
  const humanName = humanize(col.column_name);
371
645
 
372
- 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, `
373
659
  ${col.column_name}: {
374
660
  name: "${humanName}",
375
661
  columnName: "${col.column_name}",
376
- type: "${propType}",${extra}
377
- },`;
662
+ type: "${finalPropType}",${extra}
663
+ },`);
378
664
  }
379
665
 
380
666
  // Map Owning Relations (from this table's FKs to other tables)
@@ -388,14 +674,24 @@ export function generateCollectionFile(
388
674
  relName = targetTableName;
389
675
  }
390
676
  // Push the relation property key, not the FK column name
391
- 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
+ });
392
688
 
393
689
  const targetCollectionCamel = toCollectionVarName(targetTableName);
394
690
  imports.add(`import ${targetCollectionCamel} from "./${targetTableName}";`);
395
691
 
396
692
  const relHumanName = humanize(relName);
397
693
 
398
- propsOutput += `
694
+ propertyBlocks.set(relName, `
399
695
  ${relName}: {
400
696
  name: "${relHumanName}",
401
697
  type: "relation",
@@ -404,7 +700,7 @@ export function generateCollectionFile(
404
700
  direction: "owning",
405
701
  localKey: "${fk.column_name}",
406
702
  // mapped from foreign key: ${fk.column_name} -> ${targetTableName}(${fk.foreign_column_name})
407
- },`;
703
+ },`);
408
704
  }
409
705
  }
410
706
 
@@ -501,21 +797,26 @@ export function generateCollectionFile(
501
797
  ? `\n relations: [${relationsOutput}\n ],`
502
798
  : "";
503
799
 
800
+ const sortedPropertiesOrder = sortPropertiesOrder(orderEntries);
801
+ for (const key of sortedPropertiesOrder) {
802
+ propsOutput += propertyBlocks.get(key) || "";
803
+ }
804
+
805
+ const collectionVarName = toCollectionVarName(tableName);
504
806
  const fileContent = `${Array.from(imports).join("\n")}
505
807
 
506
- const ${tableName}Collection: PostgresCollection = {
808
+ const ${collectionVarName}: PostgresCollection = {
507
809
  name: "${collectionName}",
508
810
  singularName: "${singular}",
509
811
  slug: "${tableName}",
510
812
  table: "${tableName}",
511
813
  icon: "${icon}",
512
- group: "App",
513
814
  properties: {${propsOutput}
514
815
  },${relationsBlock}
515
- propertiesOrder: ${JSON.stringify(propertiesOrder, null, 8).replace(/]$/, " ]")}
816
+ propertiesOrder: ${JSON.stringify(sortedPropertiesOrder, null, 8).replace(/]$/, " ]")}
516
817
  };
517
818
 
518
- export default ${tableName}Collection;
819
+ export default ${collectionVarName};
519
820
  `;
520
821
 
521
822
  return fileContent;
@@ -4,6 +4,7 @@ import path from "path";
4
4
  import pg from "pg";
5
5
  import arg from "arg";
6
6
  import * as dotenv from "dotenv";
7
+ import readline from "readline";
7
8
 
8
9
  import {
9
10
  TableRow,
@@ -24,15 +25,24 @@ async function main() {
24
25
  const args = arg(
25
26
  {
26
27
  "--output": String,
28
+ "--collections": String,
27
29
  "--force": Boolean,
28
30
  "--schema": String,
31
+ "--data-inference": Boolean,
29
32
  "-o": "--output",
33
+ "-c": "--collections",
30
34
  "-f": "--force",
31
35
  },
32
36
  { permissive: true }
33
37
  );
34
38
 
35
- const outDir = args["--output"] || path.resolve(process.cwd(), "config", "collections");
39
+ const cwd = process.cwd();
40
+ const isBackendDir = path.basename(cwd) === "backend";
41
+ const defaultOutDir = isBackendDir
42
+ ? path.resolve(cwd, "..", "config", "collections")
43
+ : path.resolve(cwd, "config", "collections");
44
+
45
+ const outDir = args["--output"] || args["--collections"] || defaultOutDir;
36
46
  const force = args["--force"] || false;
37
47
  const pgSchema = args["--schema"] || "public";
38
48
 
@@ -143,32 +153,65 @@ async function main() {
143
153
 
144
154
  console.log(chalk.blue(`Found ${tablesMap.size} tables (including ${joinTables.size} detected join tables).`));
145
155
 
156
+ let runDataInference = false;
157
+ if (args["--data-inference"] !== undefined) {
158
+ runDataInference = args["--data-inference"];
159
+ } else {
160
+ const rl = readline.createInterface({
161
+ input: process.stdin,
162
+ output: process.stdout
163
+ });
164
+ const answer = await new Promise<string>((resolve) => rl.question(chalk.yellow("? Do you want to run comprehensive data inference on sampled rows to auto-detect types, formats, constraints, and UI configurations? (y/N) "), resolve));
165
+ runDataInference = answer.trim().toLowerCase() === 'y';
166
+ rl.close();
167
+ }
168
+
169
+ if (runDataInference) {
170
+ console.log(chalk.gray(`Sampling database rows for data inference...`));
171
+ }
172
+
146
173
  // Generate Collections
147
174
  const generatedFiles: string[] = [];
148
175
  const skippedFiles: string[] = [];
149
176
 
150
- for (const [tableName, meta] of tablesMap.entries()) {
151
- if (joinTables.has(tableName)) continue; // We don't generate base collections for pure join tables
152
-
153
- // ── File overwrite protection ──────────────────────────────
154
- const filePath = path.join(outDir, `${tableName}.ts`);
155
- if (fs.existsSync(filePath) && !force) {
156
- skippedFiles.push(tableName);
157
- continue;
158
- }
159
-
160
- const fileContent = generateCollectionFile(
161
- tableName,
162
- meta,
163
- fks,
164
- joinTables,
165
- tablesMap,
166
- enumMap,
167
- );
168
-
169
- fs.writeFileSync(filePath, fileContent, "utf-8");
170
- generatedFiles.push(tableName);
171
- console.log(chalk.green(` ✓ ${filePath}`));
177
+ const tablesToProcess = Array.from(tablesMap.entries()).filter(([tableName]) => !joinTables.has(tableName));
178
+
179
+ const BATCH_SIZE = 10;
180
+ for (let i = 0; i < tablesToProcess.length; i += BATCH_SIZE) {
181
+ const batch = tablesToProcess.slice(i, i + BATCH_SIZE);
182
+
183
+ await Promise.all(batch.map(async ([tableName, meta]) => {
184
+ // ── File overwrite protection ──────────────────────────────
185
+ const filePath = path.join(outDir, `${tableName}.ts`);
186
+ if (fs.existsSync(filePath) && !force) {
187
+ skippedFiles.push(tableName);
188
+ return;
189
+ }
190
+
191
+ let sampleData: Record<string, unknown>[] | undefined = undefined;
192
+ if (runDataInference) {
193
+ try {
194
+ const { rows } = await client.query(`SELECT * FROM "${pgSchema}"."${tableName}" LIMIT 100`);
195
+ sampleData = rows;
196
+ } catch (err) {
197
+ console.error(chalk.yellow(`⚠ Failed to sample data for table ${tableName}: ${err instanceof Error ? err.message : String(err)}`));
198
+ }
199
+ }
200
+
201
+ const fileContent = generateCollectionFile(
202
+ tableName,
203
+ meta,
204
+ fks,
205
+ joinTables,
206
+ tablesMap,
207
+ enumMap,
208
+ sampleData,
209
+ );
210
+
211
+ fs.writeFileSync(filePath, fileContent, "utf-8");
212
+ generatedFiles.push(tableName);
213
+ console.log(chalk.green(` ✓ ${filePath}`));
214
+ }));
172
215
  }
173
216
 
174
217
  // Generate index.ts (sorted alphabetically for deterministic output)
@@ -617,6 +617,10 @@ export class EntityFetchService {
617
617
 
618
618
  return entity;
619
619
  } catch (e) {
620
+ if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
621
+ console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
622
+ console.error(`Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.`);
623
+ }
620
624
  console.warn(`[EntityFetchService] db.query.findFirst failed for ${collectionPath}, falling back to db.select:`, e);
621
625
  }
622
626
  }
@@ -733,6 +737,10 @@ export class EntityFetchService {
733
737
 
734
738
  return entities;
735
739
  } catch (e) {
740
+ if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
741
+ console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
742
+ console.error(`Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.`);
743
+ }
736
744
  console.warn(`[EntityFetchService] db.query.findMany failed for ${collectionPath}, falling back to db.select:`, e);
737
745
  }
738
746
  }
@@ -1195,6 +1203,10 @@ export class EntityFetchService {
1195
1203
 
1196
1204
  return restRows;
1197
1205
  } catch (e) {
1206
+ if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
1207
+ console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
1208
+ console.error(`Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.`);
1209
+ }
1198
1210
  console.warn(`[fetchCollectionForRest] db.query.findMany failed for ${collectionPath}, falling back:`, e);
1199
1211
  }
1200
1212
  }
@@ -1305,6 +1317,10 @@ export class EntityFetchService {
1305
1317
 
1306
1318
  return restRow;
1307
1319
  } catch (e) {
1320
+ if (e instanceof Error && e.message.includes("not enough information to infer relation")) {
1321
+ console.error(`[EntityFetchService] Relation inference error for collection '${collectionPath}': ${e.message}`);
1322
+ console.error(`Hint: This usually means a relation in your drizzle schema is missing a reciprocal 'one()' or 'many()' definition. Run 'rebase schema generate' to fix this.`);
1323
+ }
1308
1324
  console.warn(`[fetchEntityForRest] db.query.findFirst failed for ${collectionPath}, falling back:`, e);
1309
1325
  }
1310
1326
  }