@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.
- package/dist/index.es.js +458 -201
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +458 -201
- package/dist/index.umd.js.map +1 -1
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +8 -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 +117 -0
- package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
- 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/collection_registry.d.ts +2 -1
- package/dist/types/src/controllers/data_driver.d.ts +36 -1
- 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/backend_hooks.d.ts +187 -0
- package/dist/types/src/types/collections.d.ts +31 -11
- package/dist/types/src/types/component_ref.d.ts +47 -0
- package/dist/types/src/types/cron.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +6 -7
- package/dist/types/src/types/formex.d.ts +40 -0
- package/dist/types/src/types/index.d.ts +3 -0
- package/dist/types/src/types/plugins.d.ts +6 -3
- package/dist/types/src/types/properties.d.ts +72 -88
- package/dist/types/src/types/slots.d.ts +20 -10
- package/dist/types/src/types/translations.d.ts +6 -0
- package/examples/sdk-demo/node_modules/esbuild/LICENSE.md +21 -0
- package/examples/sdk-demo/node_modules/esbuild/README.md +3 -0
- package/examples/sdk-demo/node_modules/esbuild/bin/esbuild +223 -0
- package/examples/sdk-demo/node_modules/esbuild/install.js +289 -0
- package/examples/sdk-demo/node_modules/esbuild/lib/main.d.ts +716 -0
- package/examples/sdk-demo/node_modules/esbuild/lib/main.js +2242 -0
- package/examples/sdk-demo/node_modules/esbuild/package.json +49 -0
- package/package.json +6 -5
- package/src/PostgresBackendDriver.ts +32 -6
- package/src/cli.ts +68 -2
- package/src/data-transformer.ts +84 -1
- package/src/schema/doctor.ts +14 -2
- package/src/schema/generate-drizzle-schema-logic.ts +59 -30
- package/src/schema/introspect-db-inference.ts +238 -0
- package/src/schema/introspect-db-logic.ts +896 -0
- package/src/schema/introspect-db.ts +254 -0
- package/src/services/EntityFetchService.ts +16 -0
- package/src/services/EntityPersistService.ts +95 -13
- package/test/generate-drizzle-schema.test.ts +342 -0
- package/test/introspect-db-generation.test.ts +458 -0
- package/test/introspect-db-utils.test.ts +392 -0
- package/test/property-ordering.test.ts +395 -0
- package/test/relations.test.ts +4 -4
- package/test/unmapped-tables-safety.test.ts +345 -0
- package/jest-all.log +0 -3128
- package/jest.log +0 -49
- package/scratch.ts +0 -41
- package/test-drizzle-bug.ts +0 -18
- package/test-drizzle-out/0000_cultured_freak.sql +0 -7
- package/test-drizzle-out/0001_tiresome_professor_monster.sql +0 -1
- package/test-drizzle-out/meta/0000_snapshot.json +0 -55
- package/test-drizzle-out/meta/0001_snapshot.json +0 -63
- package/test-drizzle-out/meta/_journal.json +0 -20
- package/test-drizzle-prompt.sh +0 -2
- package/test-policy-prompt.sh +0 -3
- package/test-programmatic.ts +0 -30
- package/test-programmatic2.ts +0 -59
- package/test-schema-no-policies.ts +0 -12
- package/test_drizzle_mock.js +0 -3
- package/test_find_changed.mjs +0 -32
- package/test_hash.js +0 -14
- 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
|
+
}
|