@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.
- package/dist/index.es.js +381 -1074
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +312 -1005
- package/dist/index.umd.js.map +1 -1
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +2 -1
- package/dist/server-postgresql/src/schema/introspect-db-inference.d.ts +5 -0
- package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +44 -9
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +9 -0
- package/dist/types/src/controllers/auth.d.ts +8 -2
- package/dist/types/src/controllers/client.d.ts +13 -0
- package/dist/types/src/controllers/navigation.d.ts +18 -6
- package/dist/types/src/controllers/registry.d.ts +9 -1
- package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
- package/dist/types/src/rebase_context.d.ts +17 -0
- package/dist/types/src/types/collections.d.ts +64 -1
- package/dist/types/src/types/component_ref.d.ts +47 -0
- package/dist/types/src/types/entity_views.d.ts +2 -1
- package/dist/types/src/types/index.d.ts +1 -0
- package/dist/types/src/types/properties.d.ts +3 -3
- package/dist/types/src/types/translations.d.ts +8 -0
- package/package.json +5 -5
- package/src/PostgresBackendDriver.ts +23 -6
- package/src/cli.ts +9 -1
- package/src/data-transformer.ts +84 -1
- package/src/schema/generate-drizzle-schema-logic.ts +35 -0
- package/src/schema/introspect-db-inference.ts +238 -0
- package/src/schema/introspect-db-logic.ts +337 -36
- package/src/schema/introspect-db.ts +66 -23
- package/src/services/EntityFetchService.ts +16 -0
- package/src/services/EntityPersistService.ts +88 -12
- package/test/generate-drizzle-schema.test.ts +128 -0
- package/test/introspect-db-generation.test.ts +5 -5
- 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
|
|
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
|
-
|
|
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
|
|
573
|
+
extra += `\n enum: [${enumEntries}],`;
|
|
313
574
|
}
|
|
314
575
|
|
|
315
576
|
// Date auto-value heuristics
|
|
316
|
-
if (
|
|
577
|
+
if (finalPropType === "date") {
|
|
317
578
|
if (colNameLower === "created_at" || colNameLower === "createdat") {
|
|
318
|
-
extra
|
|
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
|
|
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
|
|
583
|
+
extra += `\n autoValue: "on_create",\n ui: {\n readOnly: true\n },`;
|
|
323
584
|
}
|
|
324
585
|
}
|
|
325
586
|
|
|
326
|
-
// Array/Map heuristics
|
|
327
|
-
if (
|
|
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
|
|
336
|
-
} else if (
|
|
337
|
-
extra
|
|
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 (
|
|
341
|
-
if (
|
|
342
|
-
|
|
343
|
-
|
|
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
|
|
609
|
+
extra += `\n multiline: true,`;
|
|
346
610
|
} else if (colNameLower === "content" || colNameLower === "body") {
|
|
347
|
-
extra
|
|
611
|
+
extra += `\n multiline: true,\n markdown: true,`;
|
|
348
612
|
} else if (col.data_type === "text") {
|
|
349
|
-
extra
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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: "${
|
|
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
|
-
|
|
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
|
-
|
|
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 ${
|
|
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(
|
|
816
|
+
propertiesOrder: ${JSON.stringify(sortedPropertiesOrder, null, 8).replace(/]$/, " ]")}
|
|
516
817
|
};
|
|
517
818
|
|
|
518
|
-
export default ${
|
|
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
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
}
|