@rebasepro/server-postgresql 0.0.1-canary.eae7889 → 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 (70) hide show
  1. package/dist/index.es.js +458 -201
  2. package/dist/index.es.js.map +1 -1
  3. package/dist/index.umd.js +458 -201
  4. package/dist/index.umd.js.map +1 -1
  5. package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +8 -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 +117 -0
  8. package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
  9. package/dist/server-postgresql/src/services/EntityPersistService.d.ts +9 -0
  10. package/dist/types/src/controllers/auth.d.ts +8 -2
  11. package/dist/types/src/controllers/client.d.ts +13 -0
  12. package/dist/types/src/controllers/collection_registry.d.ts +2 -1
  13. package/dist/types/src/controllers/data_driver.d.ts +36 -1
  14. package/dist/types/src/controllers/navigation.d.ts +18 -6
  15. package/dist/types/src/controllers/registry.d.ts +9 -1
  16. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -0
  17. package/dist/types/src/rebase_context.d.ts +17 -0
  18. package/dist/types/src/types/backend_hooks.d.ts +187 -0
  19. package/dist/types/src/types/collections.d.ts +31 -11
  20. package/dist/types/src/types/component_ref.d.ts +47 -0
  21. package/dist/types/src/types/cron.d.ts +1 -1
  22. package/dist/types/src/types/entity_views.d.ts +6 -7
  23. package/dist/types/src/types/formex.d.ts +40 -0
  24. package/dist/types/src/types/index.d.ts +3 -0
  25. package/dist/types/src/types/plugins.d.ts +6 -3
  26. package/dist/types/src/types/properties.d.ts +72 -88
  27. package/dist/types/src/types/slots.d.ts +20 -10
  28. package/dist/types/src/types/translations.d.ts +6 -0
  29. package/examples/sdk-demo/node_modules/esbuild/LICENSE.md +21 -0
  30. package/examples/sdk-demo/node_modules/esbuild/README.md +3 -0
  31. package/examples/sdk-demo/node_modules/esbuild/bin/esbuild +223 -0
  32. package/examples/sdk-demo/node_modules/esbuild/install.js +289 -0
  33. package/examples/sdk-demo/node_modules/esbuild/lib/main.d.ts +716 -0
  34. package/examples/sdk-demo/node_modules/esbuild/lib/main.js +2242 -0
  35. package/examples/sdk-demo/node_modules/esbuild/package.json +49 -0
  36. package/package.json +6 -5
  37. package/src/PostgresBackendDriver.ts +32 -6
  38. package/src/cli.ts +68 -2
  39. package/src/data-transformer.ts +84 -1
  40. package/src/schema/doctor.ts +14 -2
  41. package/src/schema/generate-drizzle-schema-logic.ts +59 -30
  42. package/src/schema/introspect-db-inference.ts +238 -0
  43. package/src/schema/introspect-db-logic.ts +896 -0
  44. package/src/schema/introspect-db.ts +254 -0
  45. package/src/services/EntityFetchService.ts +16 -0
  46. package/src/services/EntityPersistService.ts +95 -13
  47. package/test/generate-drizzle-schema.test.ts +342 -0
  48. package/test/introspect-db-generation.test.ts +458 -0
  49. package/test/introspect-db-utils.test.ts +392 -0
  50. package/test/property-ordering.test.ts +395 -0
  51. package/test/relations.test.ts +4 -4
  52. package/test/unmapped-tables-safety.test.ts +345 -0
  53. package/jest-all.log +0 -3128
  54. package/jest.log +0 -49
  55. package/scratch.ts +0 -41
  56. package/test-drizzle-bug.ts +0 -18
  57. package/test-drizzle-out/0000_cultured_freak.sql +0 -7
  58. package/test-drizzle-out/0001_tiresome_professor_monster.sql +0 -1
  59. package/test-drizzle-out/meta/0000_snapshot.json +0 -55
  60. package/test-drizzle-out/meta/0001_snapshot.json +0 -63
  61. package/test-drizzle-out/meta/_journal.json +0 -20
  62. package/test-drizzle-prompt.sh +0 -2
  63. package/test-policy-prompt.sh +0 -3
  64. package/test-programmatic.ts +0 -30
  65. package/test-programmatic2.ts +0 -59
  66. package/test-schema-no-policies.ts +0 -12
  67. package/test_drizzle_mock.js +0 -3
  68. package/test_find_changed.mjs +0 -32
  69. package/test_hash.js +0 -14
  70. package/test_output.txt +0 -3145
@@ -0,0 +1,896 @@
1
+ /**
2
+ * Introspection logic — pure functions and the pipeline that transforms
3
+ * raw PostgreSQL metadata into Rebase collection definition files.
4
+ *
5
+ * This module contains NO side-effects: no fs writes, no pg.Client creation,
6
+ * no process.exit. It is imported by introspect-db.ts (the CLI entry-point)
7
+ * and consumed directly by tests.
8
+ */
9
+ import { inferPropertyFromData } from "./introspect-db-inference";
10
+
11
+ // ── Typed interfaces for SQL query results ────────────────────────────
12
+
13
+ export interface TableRow {
14
+ table_name: string;
15
+ }
16
+
17
+ export interface TableColumn {
18
+ table_name: string;
19
+ column_name: string;
20
+ data_type: string;
21
+ udt_name: string;
22
+ is_nullable: string;
23
+ column_default: string | null;
24
+ }
25
+
26
+ export interface EnumValue {
27
+ enum_name: string;
28
+ enum_value: string;
29
+ sort_order: number;
30
+ }
31
+
32
+ export interface PrimaryKeyRow {
33
+ table_name: string;
34
+ column_name: string;
35
+ }
36
+
37
+ export interface ForeignKeyRow {
38
+ table_name: string;
39
+ column_name: string;
40
+ foreign_table_name: string;
41
+ foreign_column_name: string;
42
+ }
43
+
44
+ export interface TableMeta {
45
+ name: string;
46
+ columns: TableColumn[];
47
+ pks: string[];
48
+ fks: ForeignKeyRow[];
49
+ }
50
+
51
+ // ── Irregular plurals that naive rules can't handle ───────────────────
52
+
53
+ const IRREGULAR_SINGULARS: Record<string, string> = {
54
+ people: "person",
55
+ children: "child",
56
+ men: "man",
57
+ women: "woman",
58
+ mice: "mouse",
59
+ geese: "goose",
60
+ teeth: "tooth",
61
+ feet: "foot",
62
+ data: "datum",
63
+ media: "medium",
64
+ criteria: "criterion",
65
+ phenomena: "phenomenon",
66
+ };
67
+
68
+ /** Words ending in 's' that are already singular. */
69
+ const UNCOUNTABLE = new Set([
70
+ "status", "campus", "virus", "bus", "plus", "census",
71
+ "diagnosis", "analysis", "basis", "crisis", "thesis",
72
+ "synopsis", "parenthesis", "hypothesis", "emphasis",
73
+ "news", "series", "species", "means", "athletics",
74
+ "economics", "electronics", "mathematics", "physics",
75
+ "politics", "statistics",
76
+ ]);
77
+
78
+ export function singularize(word: string): string {
79
+ const lower = word.toLowerCase();
80
+
81
+ // Check irregular forms
82
+ if (IRREGULAR_SINGULARS[lower]) {
83
+ // Preserve the original casing of the first character
84
+ const singular = IRREGULAR_SINGULARS[lower];
85
+ return word[0] === word[0].toUpperCase()
86
+ ? singular.charAt(0).toUpperCase() + singular.slice(1)
87
+ : singular;
88
+ }
89
+
90
+ // Check uncountable
91
+ if (UNCOUNTABLE.has(lower)) return word;
92
+
93
+ // Latin/Greek -es endings (diagnosis -> diagnosis is uncountable, but "addresses" -> "address")
94
+ if (lower.endsWith("ices") && lower.length > 5) {
95
+ // e.g. "indices" -> "index", "vertices" -> "vertex"
96
+ return word.slice(0, -4) + "ex";
97
+ }
98
+ if (lower.endsWith("ies") && lower.length > 3) {
99
+ return word.slice(0, -3) + "y";
100
+ }
101
+ if (lower.endsWith("ves")) {
102
+ // e.g. "wolves" -> "wolf", "leaves" -> "leaf"
103
+ return word.slice(0, -3) + "f";
104
+ }
105
+ if (lower.endsWith("ches") || lower.endsWith("shes") || lower.endsWith("sses") || lower.endsWith("xes") || lower.endsWith("zes")) {
106
+ return word.slice(0, -2);
107
+ }
108
+ if (lower.endsWith("ses") && !lower.endsWith("sses")) {
109
+ // e.g. "responses" -> "response", "databases" -> "database"
110
+ return word.slice(0, -1);
111
+ }
112
+ if (lower.endsWith("s") && !lower.endsWith("ss") && !lower.endsWith("us") && !lower.endsWith("is")) {
113
+ return word.slice(0, -1);
114
+ }
115
+
116
+ return word;
117
+ }
118
+
119
+ /**
120
+ * Convert a snake_case name to a human-readable Title Case label.
121
+ * e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
122
+ */
123
+ export function humanize(snakeName: string): string {
124
+ return snakeName
125
+ .replace(/_/g, " ")
126
+ .replace(/\b\w/g, (c) => c.toUpperCase());
127
+ }
128
+
129
+ /**
130
+ * Convert a snake_case table name to a camelCase + "Collection" variable name.
131
+ * e.g. "company_token" -> "companyTokenCollection"
132
+ */
133
+ export function toCollectionVarName(tableName: string): string {
134
+ return tableName.replace(/_([a-z])/g, (_g, letter: string) => letter.toUpperCase()) + "Collection";
135
+ }
136
+
137
+ export function getIconForTable(tableName: string): string {
138
+ const table = tableName.toLowerCase();
139
+ if (table.includes("user") || table.includes("account") || table.includes("member") || table.includes("customer") || table.includes("client") || table.includes("patient")) return "Users";
140
+ if (table.includes("post") || table.includes("article") || table.includes("blog") || table.includes("page")) return "FileText";
141
+ if (table.includes("product") || table.includes("item")) return "Package";
142
+ if (table.includes("order") || table.includes("cart") || table.includes("purchase") || table.includes("invoice")) return "ShoppingCart";
143
+ if (table.includes("setting") || table.includes("config")) return "Settings";
144
+ if (table.includes("tag") || table.includes("categor")) return "Tag";
145
+ if (table.includes("image") || table.includes("photo") || table.includes("media") || table.includes("asset")) return "Image";
146
+ if (table.includes("notification") || table.includes("message") || table.includes("email")) return "Mail";
147
+ if (table.includes("log") || table.includes("audit") || table.includes("event")) return "Activity";
148
+ if (table.includes("subscription") || table.includes("plan") || table.includes("billing")) return "CreditCard";
149
+ if (table.includes("comment") || table.includes("review") || table.includes("feedback")) return "MessageCircle";
150
+ return "Database";
151
+ }
152
+
153
+ /**
154
+ * Map a PostgreSQL data type to a Rebase property type.
155
+ */
156
+ export function mapPgType(dataType: string): string {
157
+ const dt = dataType.toLowerCase();
158
+
159
+ // Interval MUST be checked before numeric ("interval" contains "int")
160
+ if (dt === "interval") return "string";
161
+
162
+ // Array types MUST be checked before numeric ("_int4" contains "int")
163
+ if (dt === "array" || dt.startsWith("_")) return "array";
164
+
165
+ // Numeric types
166
+ if (
167
+ dt.includes("int") || // integer, smallint, bigint
168
+ dt.includes("numeric") ||
169
+ dt.includes("decimal") ||
170
+ dt.includes("serial") || // serial, bigserial
171
+ dt === "real" ||
172
+ dt === "float4" ||
173
+ dt === "float8" ||
174
+ dt === "double precision" ||
175
+ dt === "money"
176
+ ) {
177
+ return "number";
178
+ }
179
+
180
+ // Boolean
181
+ if (dt.includes("bool")) return "boolean";
182
+
183
+ // Date / Time
184
+ if (dt.includes("time") || dt.includes("date")) return "date";
185
+
186
+ // JSON
187
+ if (dt === "json" || dt === "jsonb") return "map";
188
+
189
+ // Binary
190
+ if (dt === "bytea") return "string";
191
+
192
+ // Network types
193
+ if (dt === "inet" || dt === "cidr" || dt === "macaddr" || dt === "macaddr8") return "string";
194
+
195
+ // UUID
196
+ if (dt === "uuid") return "string";
197
+
198
+ // Text/varchar/char — default to string
199
+ return "string";
200
+ }
201
+
202
+ // ── Build the enum map from query results ─────────────────────────────
203
+
204
+ export function buildEnumMap(enumValues: EnumValue[]): Map<string, string[]> {
205
+ const enumMap = new Map<string, string[]>();
206
+ for (const ev of enumValues) {
207
+ const existing = enumMap.get(ev.enum_name);
208
+ if (existing) {
209
+ existing.push(ev.enum_value);
210
+ } else {
211
+ enumMap.set(ev.enum_name, [ev.enum_value]);
212
+ }
213
+ }
214
+ return enumMap;
215
+ }
216
+
217
+ // ── Build the tables map from raw query results ───────────────────────
218
+
219
+ export function buildTablesMap(
220
+ tables: TableRow[],
221
+ columns: TableColumn[],
222
+ pks: PrimaryKeyRow[],
223
+ fks: ForeignKeyRow[]
224
+ ): Map<string, TableMeta> {
225
+ const tablesMap = new Map<string, TableMeta>();
226
+ for (const t of tables) {
227
+ tablesMap.set(t.table_name, {
228
+ name: t.table_name,
229
+ columns: columns.filter((c) => c.table_name === t.table_name),
230
+ pks: pks.filter((pk) => pk.table_name === t.table_name).map((pk) => pk.column_name),
231
+ fks: fks.filter((fk) => fk.table_name === t.table_name)
232
+ });
233
+ }
234
+ return tablesMap;
235
+ }
236
+
237
+ // ── Identify join tables ──────────────────────────────────────────────
238
+
239
+ export function identifyJoinTables(tablesMap: Map<string, TableMeta>): Set<string> {
240
+ const joinTables = new Set<string>();
241
+ for (const [tableName, meta] of tablesMap.entries()) {
242
+ if (meta.fks.length === 2) {
243
+ const isLikelyJoinTable = meta.columns.every((c) =>
244
+ meta.fks.some((fk) => fk.column_name === c.column_name) ||
245
+ c.column_name === "id" ||
246
+ c.column_name === "created_at" ||
247
+ c.column_name === "updated_at"
248
+ );
249
+
250
+ if (isLikelyJoinTable) {
251
+ joinTables.add(tableName);
252
+ }
253
+ }
254
+ }
255
+ return joinTables;
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
+
504
+ // ── Generate collection file content ──────────────────────────────────
505
+
506
+ export interface GeneratedFile {
507
+ tableName: string;
508
+ fileName: string;
509
+ content: string;
510
+ }
511
+
512
+ /**
513
+ * Generate the full TypeScript file content for a single collection.
514
+ * Pure function — no I/O.
515
+ */
516
+ export function generateCollectionFile(
517
+ tableName: string,
518
+ meta: TableMeta,
519
+ allFks: ForeignKeyRow[],
520
+ joinTables: Set<string>,
521
+ tablesMap: Map<string, TableMeta>,
522
+ enumMap: Map<string, string[]>,
523
+ sampleData?: Record<string, unknown>[],
524
+ ): string {
525
+ const collectionName = humanize(tableName);
526
+ const singular = singularize(collectionName);
527
+ const icon = getIconForTable(tableName);
528
+
529
+ const imports = new Set<string>(['import { PostgresCollection } from "@rebasepro/types";']);
530
+
531
+ let propsOutput = ``;
532
+ let relationsOutput = ``;
533
+ const orderEntries: PropertyOrderEntry[] = [];
534
+ const propertyBlocks = new Map<string, string>();
535
+ let columnIndex = 0;
536
+
537
+ // Detect composite primary keys
538
+ const isCompositePk = meta.pks.length > 1;
539
+
540
+ // Map columns
541
+ for (const col of meta.columns) {
542
+ // Skip foreign keys since we handle them as relations
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;
545
+
546
+ const currentIndex = columnIndex++;
547
+
548
+ // Check if this column uses a PostgreSQL enum type
549
+ const colEnumValues = enumMap.get(col.udt_name);
550
+ const isEnumColumn = col.data_type === "USER-DEFINED" && colEnumValues !== undefined;
551
+
552
+ const propType = isEnumColumn ? "string" : mapPgType(col.data_type);
553
+ let extra = "";
554
+
555
+ const colNameLower = col.column_name.toLowerCase();
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
+
568
+ // Enum values — generate real enum from the PG enum
569
+ if (isEnumColumn && colEnumValues) {
570
+ const enumEntries = colEnumValues
571
+ .map((v) => `{ id: "${v}", label: "${humanize(v)}" }`)
572
+ .join(", ");
573
+ extra += `\n enum: [${enumEntries}],`;
574
+ }
575
+
576
+ // Date auto-value heuristics
577
+ if (finalPropType === "date") {
578
+ if (colNameLower === "created_at" || colNameLower === "createdat") {
579
+ extra += `\n autoValue: "on_create",\n ui: {\n readOnly: true,\n hideFromCollection: true\n },`;
580
+ } else if (colNameLower === "updated_at" || colNameLower === "updatedat") {
581
+ extra += `\n autoValue: "on_update",\n ui: {\n readOnly: true,\n hideFromCollection: true\n },`;
582
+ } else if (col.column_default && (col.column_default.includes("now()") || col.column_default.includes("CURRENT_TIMESTAMP"))) {
583
+ extra += `\n autoValue: "on_create",\n ui: {\n readOnly: true\n },`;
584
+ }
585
+ }
586
+
587
+ // Array/Map heuristics (Fallback if not inferred)
588
+ if (finalPropType === "array" && !inferenceExtra.includes("of: {")) {
589
+ let innerType = "string";
590
+ if (col.udt_name.startsWith("_")) {
591
+ const baseType = col.udt_name.substring(1);
592
+ innerType = mapPgType(baseType);
593
+ }
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,`;
597
+ }
598
+
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 },`;
608
+ } else if (colNameLower === "description" || colNameLower === "summary" || colNameLower === "excerpt") {
609
+ extra += `\n multiline: true,`;
610
+ } else if (colNameLower === "content" || colNameLower === "body") {
611
+ extra += `\n multiline: true,\n markdown: true,`;
612
+ } else if (col.data_type === "text") {
613
+ extra += `\n multiline: true,`;
614
+ }
615
+ }
616
+
617
+ // Append inference results
618
+ if (inferenceExtra) {
619
+ extra += inferenceExtra;
620
+ if (!extra.endsWith(",")) extra += ",";
621
+ }
622
+
623
+ // Identify IDs (unless already inferred as UUID/CUID by inferenceEngine)
624
+ if (meta.pks.includes(col.column_name)) {
625
+ if (isCompositePk) {
626
+ extra += `\n // Part of composite primary key (${meta.pks.join(", ")})`;
627
+ } else if (finalPropType === "number" && !inferenceExtra.includes("isId:")) {
628
+ extra += `\n isId: "increment",`;
629
+ } else if (col.data_type.toLowerCase() === "uuid" && !inferenceExtra.includes("isId:")) {
630
+ extra += `\n isId: "uuid",`;
631
+ } else if (!inferenceExtra.includes("isId:")) {
632
+ extra += `\n isId: "uuid", // Verify if this is a UUID or CUID`;
633
+ }
634
+ }
635
+
636
+ if (col.is_nullable === "NO" && !meta.pks.includes(col.column_name) && !col.column_default) {
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
+ }
642
+ }
643
+
644
+ const humanName = humanize(col.column_name);
645
+
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, `
659
+ ${col.column_name}: {
660
+ name: "${humanName}",
661
+ columnName: "${col.column_name}",
662
+ type: "${finalPropType}",${extra}
663
+ },`);
664
+ }
665
+
666
+ // Map Owning Relations (from this table's FKs to other tables)
667
+ for (const fk of meta.fks) {
668
+ const targetTableName = fk.foreign_table_name;
669
+ if (!joinTables.has(targetTableName)) {
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
+ }
676
+ // Push the relation property key, not the FK column name
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
+ });
688
+
689
+ const targetCollectionCamel = toCollectionVarName(targetTableName);
690
+ imports.add(`import ${targetCollectionCamel} from "./${targetTableName}";`);
691
+
692
+ const relHumanName = humanize(relName);
693
+
694
+ propertyBlocks.set(relName, `
695
+ ${relName}: {
696
+ name: "${relHumanName}",
697
+ type: "relation",
698
+ target: () => ${targetCollectionCamel},
699
+ cardinality: "one",
700
+ direction: "owning",
701
+ localKey: "${fk.column_name}",
702
+ // mapped from foreign key: ${fk.column_name} -> ${targetTableName}(${fk.foreign_column_name})
703
+ },`);
704
+ }
705
+ }
706
+
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.
709
+ const inverseFks = allFks.filter((fk) => fk.foreign_table_name === tableName && !joinTables.has(fk.table_name));
710
+ for (const fk of inverseFks) {
711
+ const sourceTableName = fk.table_name;
712
+
713
+ const targetCollectionCamel = toCollectionVarName(sourceTableName);
714
+ imports.add(`import ${targetCollectionCamel} from "./${sourceTableName}";`);
715
+
716
+ const inverseRelName = fk.column_name.replace(/_id$/, "");
717
+
718
+ relationsOutput += `
719
+ {
720
+ relationName: "${sourceTableName}",
721
+ target: () => ${targetCollectionCamel},
722
+ cardinality: "many",
723
+ direction: "inverse",
724
+ inverseRelationName: "${inverseRelName}",
725
+ foreignKeyOnTarget: "${fk.column_name}"
726
+ },`;
727
+ }
728
+
729
+ // Map Many-to-Many Relations (Join Tables)
730
+ // These also go into the `relations` array so they render as subcollection tabs.
731
+ const relatedJoinTables = Array.from(joinTables).filter((jt) => {
732
+ const jtMeta = tablesMap.get(jt);
733
+ return jtMeta ? jtMeta.fks.some((fk) => fk.foreign_table_name === tableName) : false;
734
+ });
735
+
736
+ for (const jt of relatedJoinTables) {
737
+ const jtMeta = tablesMap.get(jt);
738
+ if (!jtMeta) continue;
739
+
740
+ const joinFks = jtMeta.fks;
741
+
742
+ // Handle self-referencing M2M: both FKs point to the same table
743
+ const selfRefFks = joinFks.filter((fk) => fk.foreign_table_name === tableName);
744
+ if (selfRefFks.length === 2) {
745
+ // Self-referencing M2M — generate a single owning relation
746
+ const thisFk = selfRefFks[0];
747
+ const otherFk = selfRefFks[1];
748
+
749
+ const relPropName = `${tableName}_via_${otherFk.column_name.replace(/_id$/, "")}`;
750
+
751
+ relationsOutput += `
752
+ {
753
+ relationName: "${relPropName}",
754
+ target: () => ${tableName}Collection,
755
+ cardinality: "many",
756
+ direction: "owning",
757
+ through: {
758
+ table: "${jt}",
759
+ sourceColumn: "${thisFk.column_name}",
760
+ targetColumn: "${otherFk.column_name}"
761
+ }
762
+ },`;
763
+ continue;
764
+ }
765
+
766
+ const otherFk = joinFks.find((fk) => fk.foreign_table_name !== tableName);
767
+
768
+ if (otherFk) {
769
+ const targetTableName = otherFk.foreign_table_name;
770
+
771
+ const targetCollectionCamel = toCollectionVarName(targetTableName);
772
+ imports.add(`import ${targetCollectionCamel} from "./${targetTableName}";`);
773
+
774
+ // Determine direction (alphabetically first table is owning)
775
+ const direction = tableName < targetTableName ? "owning" : "inverse";
776
+
777
+ const thisFk = joinFks.find((fk) => fk.foreign_table_name === tableName);
778
+
779
+ let throughCode = "";
780
+ if (direction === "owning" && thisFk) {
781
+ throughCode = `\n through: {\n table: "${jt}",\n sourceColumn: "${thisFk.column_name}",\n targetColumn: "${otherFk.column_name}"\n },`;
782
+ } else if (direction === "inverse") {
783
+ throughCode = `\n // Make sure the target collection configures the 'through' property.`;
784
+ }
785
+
786
+ relationsOutput += `
787
+ {
788
+ relationName: "${targetTableName}",
789
+ target: () => ${targetCollectionCamel},
790
+ cardinality: "many",
791
+ direction: "${direction}",${throughCode}
792
+ },`;
793
+ }
794
+ }
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);
806
+ const fileContent = `${Array.from(imports).join("\n")}
807
+
808
+ const ${collectionVarName}: PostgresCollection = {
809
+ name: "${collectionName}",
810
+ singularName: "${singular}",
811
+ slug: "${tableName}",
812
+ table: "${tableName}",
813
+ icon: "${icon}",
814
+ properties: {${propsOutput}
815
+ },${relationsBlock}
816
+ propertiesOrder: ${JSON.stringify(sortedPropertiesOrder, null, 8).replace(/]$/, " ]")}
817
+ };
818
+
819
+ export default ${collectionVarName};
820
+ `;
821
+
822
+ return fileContent;
823
+ }
824
+
825
+ /**
826
+ * Generate the content for an index.ts file that re-exports all collections.
827
+ */
828
+ export function generateIndexContent(fileNames: string[]): string {
829
+ const sorted = [...fileNames].sort();
830
+ let imports = "";
831
+ let arrayElements = "";
832
+ for (const f of sorted) {
833
+ const varName = toCollectionVarName(f);
834
+ imports += `import ${varName} from "./${f}";\n`;
835
+ arrayElements += ` ${varName},\n`;
836
+ }
837
+ return `${imports}\nexport const collections = [\n${arrayElements}];\n`;
838
+ }
839
+
840
+ /**
841
+ * Merge new exports into existing index.ts content.
842
+ * Returns the merged content string.
843
+ */
844
+ export function mergeIndexContent(existingContent: string, newFileNames: string[]): string {
845
+ const existingImports = new Set(
846
+ [...existingContent.matchAll(/import\s+([a-zA-Z0-9_]+)\s+from\s+"\.\/([^"]+)"/g)].map((m) => m[2])
847
+ );
848
+ const sorted = [...newFileNames].sort();
849
+
850
+ let newImports = "";
851
+ let newElements = "";
852
+
853
+ for (const f of sorted) {
854
+ if (!existingImports.has(f)) {
855
+ const varName = toCollectionVarName(f);
856
+ newImports += `import ${varName} from "./${f}";\n`;
857
+ newElements += ` ${varName},\n`;
858
+ }
859
+ }
860
+
861
+ if (!newImports) return existingContent;
862
+
863
+ // Simple injection logic:
864
+ // Add new imports below the last import or at the top
865
+ const importRegex = /import\s+.*?;/g;
866
+ let lastImportMatch;
867
+ let match;
868
+ while ((match = importRegex.exec(existingContent)) !== null) {
869
+ lastImportMatch = match;
870
+ }
871
+
872
+ let contentWithImports = existingContent;
873
+ if (lastImportMatch) {
874
+ const pos = lastImportMatch.index + lastImportMatch[0].length;
875
+ contentWithImports = existingContent.slice(0, pos) + "\n" + newImports.trimEnd() + existingContent.slice(pos);
876
+ } else {
877
+ contentWithImports = newImports + "\n" + existingContent;
878
+ }
879
+
880
+ // Inject into the `collections = [...]` array
881
+ const arrayRegex = /export\s+const\s+collections\s*=\s*\[([\s\S]*?)\];/;
882
+ return contentWithImports.replace(arrayRegex, (fullMatch, arrayContent) => {
883
+ let mergedArray = arrayContent.trimEnd();
884
+ if (mergedArray && !mergedArray.endsWith(",")) mergedArray += ",";
885
+ if (mergedArray) mergedArray += "\n";
886
+ mergedArray += newElements.trimEnd();
887
+ return `export const collections = [\n ${mergedArray.trim()}\n];`;
888
+ });
889
+ }
890
+
891
+ /**
892
+ * Safely extract the host portion of a database URL for logging.
893
+ */
894
+ export function safeHostFromUrl(url: string): string {
895
+ return url.includes("@") ? url.split("@")[1] : "(local connection)";
896
+ }