@rebasepro/server-postgres 0.17.3 → 0.18.1
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/LICENSE +0 -1
- package/README.md +31 -0
- package/dist/BranchService-ByLfM_Tt.js +360 -0
- package/dist/BranchService-ByLfM_Tt.js.map +1 -0
- package/dist/__vite-browser-external-BnuHet1e.js +46 -0
- package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
- package/dist/arg-Dni7MzLB.js +87 -0
- package/dist/arg-Dni7MzLB.js.map +1 -0
- package/dist/auth-users-columns-D2LBFrMH.js +116 -0
- package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
- package/dist/backup-argv.d.ts +19 -0
- package/dist/backup-cli-oM9WQVlV.js +408 -0
- package/dist/backup-cli-oM9WQVlV.js.map +1 -0
- package/dist/backup-service-Br3tqwsy.js +715 -0
- package/dist/backup-service-Br3tqwsy.js.map +1 -0
- package/dist/branch-argv.d.ts +28 -0
- package/dist/branch-prune.d.ts +77 -0
- package/dist/cli-collections-path.d.ts +31 -0
- package/dist/cli-errors-DxN4yzdH.js +657 -0
- package/dist/cli-errors-DxN4yzdH.js.map +1 -0
- package/dist/cli-errors.d.ts +123 -0
- package/dist/cli-flags.d.ts +101 -0
- package/dist/cli-helpers.d.ts +94 -10
- package/dist/cli-output-CNdMql-L.js +47 -0
- package/dist/cli-output-CNdMql-L.js.map +1 -0
- package/dist/cli.js +2020 -0
- package/dist/cli.js.map +1 -0
- package/dist/collection-index-BHgT_TKd.js +276 -0
- package/dist/collection-index-BHgT_TKd.js.map +1 -0
- package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
- package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
- package/dist/{connection-GOKU3Hu5.js → databasePoolManager-Bj5FbeAs.js} +112 -40
- package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
- package/dist/databasePoolManager.d.ts +27 -0
- package/dist/doctor-D7HWmGzs.js +974 -0
- package/dist/doctor-D7HWmGzs.js.map +1 -0
- package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-2c2NV8zX.js} +7 -27
- package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
- package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
- package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
- package/dist/ensure-tables-CIojK-Rx.js +650 -0
- package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
- package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
- package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
- package/dist/{ensure-collection-tables-DgVixhX3.js → generate-postgres-ddl-logic-CtnpIBVv.js} +126 -984
- package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
- package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
- package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +7423 -1133
- package/dist/index.es.js.map +1 -1
- package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
- package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
- package/dist/policy-drift-kNJ6X2Uj.js +313 -0
- package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
- package/dist/{backup-service-DCk7KhhL.js → rls-bootstrap-sql-DLDnUjtx.js} +737 -2689
- package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
- package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-DeYEjYLt.js} +65 -190
- package/dist/rls-enforcement-DeYEjYLt.js.map +1 -0
- package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/column-type-drift.d.ts +79 -0
- package/dist/schema/doctor-cli.js +129 -0
- package/dist/schema/doctor-cli.js.map +1 -0
- package/dist/schema/doctor.d.ts +42 -0
- package/dist/schema/ensure-collection-tables.d.ts +90 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -1
- package/dist/schema/generate-drizzle-schema.js +24 -0
- package/dist/schema/generate-drizzle-schema.js.map +1 -0
- package/dist/schema/generate-next-step.d.ts +30 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl.js +138 -0
- package/dist/schema/generate-postgres-ddl.js.map +1 -0
- package/dist/schema/generated-schema-staleness.d.ts +37 -0
- package/dist/schema/introspect-db-queries.d.ts +34 -0
- package/dist/schema/introspect-db.js +866 -0
- package/dist/schema/introspect-db.js.map +1 -0
- package/dist/security/rls-enforcement.d.ts +63 -2
- package/dist/services/BranchService.d.ts +86 -1
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +30 -2
- package/dist/services/row-pipeline.d.ts +12 -1
- package/dist/source-Br7L7GOI.js +410 -0
- package/dist/source-Br7L7GOI.js.map +1 -0
- package/dist/utils/pg-error-utils.d.ts +3 -1
- package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
- package/dist/vector-index-DkWxm2de.js +309 -0
- package/dist/vector-index-DkWxm2de.js.map +1 -0
- package/package.json +32 -27
- package/dist/backup-service-DCk7KhhL.js.map +0 -1
- package/dist/collection-index-BRUg10H5.js +0 -5690
- package/dist/collection-index-BRUg10H5.js.map +0 -1
- package/dist/connection-GOKU3Hu5.js.map +0 -1
- package/dist/ensure-collection-policies-UCqgv_8c.js.map +0 -1
- package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
- package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
- package/dist/src-DiB5RP2Z.js +0 -541
- package/dist/src-DiB5RP2Z.js.map +0 -1
- package/dist/websocket-BZ4H5wUz.js +0 -8198
- package/dist/websocket-BZ4H5wUz.js.map +0 -1
- package/src/PostgresAdapter.ts +0 -91
- package/src/PostgresBackendDriver.ts +0 -1857
- package/src/PostgresBootstrapper.ts +0 -1157
- package/src/auth/ensure-tables.ts +0 -896
- package/src/auth/schema-version.ts +0 -260
- package/src/auth/services.ts +0 -1610
- package/src/backup/backup-cli.ts +0 -520
- package/src/backup/backup-cron.ts +0 -212
- package/src/backup/backup-logic.ts +0 -93
- package/src/backup/backup-service.ts +0 -485
- package/src/backup/index.ts +0 -12
- package/src/backup/pg-tools.ts +0 -403
- package/src/backup/retention.ts +0 -86
- package/src/cli-errors.ts +0 -265
- package/src/cli-helpers.ts +0 -569
- package/src/cli-output.ts +0 -43
- package/src/cli.ts +0 -1339
- package/src/collections/PostgresCollectionRegistry.ts +0 -108
- package/src/collections/buildRegistry.ts +0 -68
- package/src/collections/validate-relations.ts +0 -402
- package/src/connection.ts +0 -333
- package/src/data-transformer.ts +0 -845
- package/src/databasePoolManager.ts +0 -95
- package/src/history/HistoryService.ts +0 -254
- package/src/history/ensure-history-table.ts +0 -53
- package/src/index.ts +0 -19
- package/src/interfaces.ts +0 -60
- package/src/module-dir.ts +0 -7
- package/src/schema/atlas-argv.ts +0 -94
- package/src/schema/auth-schema.ts +0 -298
- package/src/schema/auth-users-columns.ts +0 -131
- package/src/schema/carved-out-migration.ts +0 -404
- package/src/schema/classify-change.ts +0 -436
- package/src/schema/collection-index.ts +0 -427
- package/src/schema/collections-schema-version.ts +0 -103
- package/src/schema/destructive-sql.ts +0 -94
- package/src/schema/doctor-cli.ts +0 -66
- package/src/schema/doctor-policy-checks.ts +0 -105
- package/src/schema/doctor.ts +0 -837
- package/src/schema/drizzle-ddl.ts +0 -33
- package/src/schema/dynamic-tables.test.ts +0 -302
- package/src/schema/dynamic-tables.ts +0 -293
- package/src/schema/ensure-collection-policies.ts +0 -198
- package/src/schema/ensure-collection-tables.test.ts +0 -518
- package/src/schema/ensure-collection-tables.ts +0 -1267
- package/src/schema/generate-drizzle-schema-logic.ts +0 -957
- package/src/schema/generate-drizzle-schema.ts +0 -143
- package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
- package/src/schema/generate-postgres-ddl.ts +0 -180
- package/src/schema/generate-schema-commit.ts +0 -267
- package/src/schema/generated-schema-staleness.ts +0 -284
- package/src/schema/introspect-db-constraints.ts +0 -385
- package/src/schema/introspect-db-inference.ts +0 -255
- package/src/schema/introspect-db-logic.ts +0 -1387
- package/src/schema/introspect-db-naming.ts +0 -15
- package/src/schema/introspect-db-project.ts +0 -78
- package/src/schema/introspect-db-queries.ts +0 -326
- package/src/schema/introspect-db-structure.ts +0 -670
- package/src/schema/introspect-db-types.ts +0 -56
- package/src/schema/introspect-db.ts +0 -257
- package/src/schema/introspect-runtime.test.ts +0 -260
- package/src/schema/introspect-runtime.ts +0 -327
- package/src/schema/non-sql-collections.test.ts +0 -131
- package/src/schema/rls-bootstrap-sql.ts +0 -288
- package/src/schema/search-column.ts +0 -643
- package/src/schema/test-schema.ts +0 -11
- package/src/schema/vector-index.ts +0 -491
- package/src/security/anonymous-grants.test.ts +0 -73
- package/src/security/policy-drift.test.ts +0 -435
- package/src/security/policy-drift.ts +0 -473
- package/src/security/rls-enforcement.ts +0 -505
- package/src/services/BranchService.ts +0 -326
- package/src/services/FetchService.ts +0 -2115
- package/src/services/PersistService.ts +0 -557
- package/src/services/RelationService.ts +0 -978
- package/src/services/RelationWriteService.ts +0 -653
- package/src/services/cdc/CdcListener.ts +0 -103
- package/src/services/cdc/junction-tables.ts +0 -91
- package/src/services/cdc/trigger-cdc.ts +0 -173
- package/src/services/channel-bus/ChannelBus.ts +0 -44
- package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
- package/src/services/channel-bus/index.ts +0 -114
- package/src/services/channel-history.ts +0 -424
- package/src/services/channel-presence.ts +0 -185
- package/src/services/collection-helpers.ts +0 -420
- package/src/services/dataService.ts +0 -256
- package/src/services/index.ts +0 -14
- package/src/services/junction-writes.ts +0 -295
- package/src/services/nested-path.ts +0 -145
- package/src/services/pg-notify-listener.ts +0 -151
- package/src/services/realtimeService.ts +0 -2545
- package/src/services/row-pipeline.ts +0 -240
- package/src/services/write-denial.ts +0 -55
- package/src/types.ts +0 -4
- package/src/utils/connection-string.ts +0 -58
- package/src/utils/drizzle-conditions.ts +0 -2743
- package/src/utils/pg-array-null-patch.ts +0 -42
- package/src/utils/pg-error-utils.ts +0 -391
- package/src/websocket.ts +0 -855
|
@@ -0,0 +1,1378 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from "module";
|
|
2
|
+
__createRequire(import.meta.url);
|
|
3
|
+
import { firstFreeKey, toWireKey } from "@rebasepro/utils";
|
|
4
|
+
//#region src/schema/introspect-db-naming.ts
|
|
5
|
+
/**
|
|
6
|
+
* Naming helpers shared by the introspection modules. These live apart from
|
|
7
|
+
* `introspect-db-logic.ts` because the inference pass needs them too, and
|
|
8
|
+
* importing them from there would close a cycle back through this module.
|
|
9
|
+
*/
|
|
10
|
+
/**
|
|
11
|
+
* Convert a snake_case name to a human-readable Title Case label.
|
|
12
|
+
* e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
|
|
13
|
+
*/
|
|
14
|
+
function humanize(snakeName) {
|
|
15
|
+
return snakeName.replace(/_/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
16
|
+
}
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/schema/introspect-db-inference.ts
|
|
19
|
+
var ISO_8601_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
20
|
+
var UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
21
|
+
var CUID_REGEX = /^c[^\s-]{7,}$/i;
|
|
22
|
+
function inferPropertyFromData(columnName, pgDataType, currentPropType, sampleValues, isPk, emitAdmin = true) {
|
|
23
|
+
const result = {};
|
|
24
|
+
const extraLines = [];
|
|
25
|
+
const adminOptions = [];
|
|
26
|
+
const validValues = sampleValues.filter((v) => v !== null && v !== void 0 && v !== "");
|
|
27
|
+
if (validValues.length === 0) return result;
|
|
28
|
+
const colNameLower = columnName.toLowerCase();
|
|
29
|
+
if (currentPropType === "number") {
|
|
30
|
+
if (validValues.every((v) => v === 0 || v === 1 || v === "0" || v === "1")) {
|
|
31
|
+
result.propType = "boolean";
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
const numValues = validValues.map((v) => Number(v)).filter((v) => !isNaN(v));
|
|
35
|
+
if (numValues.length === validValues.length) {
|
|
36
|
+
const min = Math.min(...numValues);
|
|
37
|
+
const max = Math.max(...numValues);
|
|
38
|
+
if (min >= 0 && max <= 100 && (colNameLower.includes("percent") || colNameLower.includes("rate") || colNameLower.includes("score"))) extraLines.push(" validation: {\n min: 0,\n max: 100\n }");
|
|
39
|
+
else if (min >= 0 && (colNameLower.includes("count") || colNameLower.includes("total") || colNameLower.includes("amount"))) extraLines.push(" validation: {\n min: 0\n }");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (currentPropType === "map" || pgDataType.includes("json")) {
|
|
43
|
+
let allArrays = true;
|
|
44
|
+
let allObjects = true;
|
|
45
|
+
for (const v of validValues) {
|
|
46
|
+
let parsed = v;
|
|
47
|
+
if (typeof v === "string") try {
|
|
48
|
+
parsed = JSON.parse(v);
|
|
49
|
+
} catch (e) {
|
|
50
|
+
allArrays = false;
|
|
51
|
+
allObjects = false;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
if (Array.isArray(parsed)) allObjects = false;
|
|
55
|
+
else if (typeof parsed === "object" && parsed !== null) allArrays = false;
|
|
56
|
+
else {
|
|
57
|
+
allArrays = false;
|
|
58
|
+
allObjects = false;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (allArrays && !allObjects) {
|
|
62
|
+
result.propType = "array";
|
|
63
|
+
let allNumbers = true;
|
|
64
|
+
let allStrings = true;
|
|
65
|
+
for (const v of validValues) {
|
|
66
|
+
const parsed = typeof v === "string" ? JSON.parse(v) : v;
|
|
67
|
+
for (const item of parsed) {
|
|
68
|
+
if (typeof item !== "number") allNumbers = false;
|
|
69
|
+
if (typeof item !== "string") allStrings = false;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const innerType = allNumbers ? "number" : allStrings ? "string" : "map";
|
|
73
|
+
extraLines.push(` of: { name: "${humanize(columnName)} Item", type: "${innerType}" }`);
|
|
74
|
+
} else {
|
|
75
|
+
result.propType = "map";
|
|
76
|
+
if (allObjects && validValues.length > 0) {
|
|
77
|
+
const schema = {};
|
|
78
|
+
for (const v of validValues) {
|
|
79
|
+
const parsed = typeof v === "string" ? JSON.parse(v) : v;
|
|
80
|
+
for (const [k, val] of Object.entries(parsed)) {
|
|
81
|
+
if (val === null || val === void 0) continue;
|
|
82
|
+
const type = typeof val;
|
|
83
|
+
if (type === "string" || type === "number" || type === "boolean") {
|
|
84
|
+
if (!schema[k]) schema[k] = type;
|
|
85
|
+
else if (schema[k] !== type) schema[k] = "mixed";
|
|
86
|
+
} else if (Array.isArray(val)) {
|
|
87
|
+
if (!schema[k]) schema[k] = "array";
|
|
88
|
+
else if (schema[k] !== "array") schema[k] = "mixed";
|
|
89
|
+
} else if (type === "object") {
|
|
90
|
+
if (!schema[k]) schema[k] = "map";
|
|
91
|
+
else if (schema[k] !== "map") schema[k] = "mixed";
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const keys = Object.keys(schema).filter((k) => schema[k] !== "mixed");
|
|
96
|
+
if (keys.length > 0) {
|
|
97
|
+
const props = keys.map((k) => {
|
|
98
|
+
return `\n ${k}: { name: "${humanize(k)}", type: "${schema[k]}" }`;
|
|
99
|
+
}).join(",");
|
|
100
|
+
extraLines.push(` properties: {${props}\n }`);
|
|
101
|
+
} else extraLines.push(" keyValue: true");
|
|
102
|
+
} else extraLines.push(" keyValue: true");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (currentPropType === "string") {
|
|
106
|
+
if (validValues.every((v) => typeof v === "string" && ISO_8601_REGEX.test(v))) {
|
|
107
|
+
result.propType = "date";
|
|
108
|
+
return result;
|
|
109
|
+
}
|
|
110
|
+
const uniqueValues = new Set(validValues);
|
|
111
|
+
let maxEnumLength = 0;
|
|
112
|
+
for (const v of uniqueValues) if (typeof v === "string" && v.length > maxEnumLength) maxEnumLength = v.length;
|
|
113
|
+
if (uniqueValues.size > 0 && uniqueValues.size <= 5 && maxEnumLength <= 50 && validValues.length > uniqueValues.size && !uniqueValues.has("")) {
|
|
114
|
+
if (!(isPk || colNameLower.endsWith("_id"))) {
|
|
115
|
+
const enumEntries = Array.from(uniqueValues).map((v) => `{ id: ${JSON.stringify(v)}, label: ${JSON.stringify(humanize(v))} }`).join(", ");
|
|
116
|
+
extraLines.push(` enum: [${enumEntries}]`);
|
|
117
|
+
result.extra = extraLines.length > 0 ? "\n" + extraLines.join(",\n") + "," : "";
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
const allUuid = validValues.every((v) => typeof v === "string" && UUID_REGEX.test(v));
|
|
122
|
+
const allCuid = validValues.every((v) => typeof v === "string" && CUID_REGEX.test(v));
|
|
123
|
+
if (allUuid) {
|
|
124
|
+
if (isPk) extraLines.push(" isId: \"uuid\"");
|
|
125
|
+
} else if (allCuid) {
|
|
126
|
+
if (isPk) extraLines.push(" isId: \"cuid\"");
|
|
127
|
+
}
|
|
128
|
+
let maxLength = 0;
|
|
129
|
+
let hasNewlines = false;
|
|
130
|
+
let hasMarkdown = false;
|
|
131
|
+
for (const v of validValues) if (typeof v === "string") {
|
|
132
|
+
if (v.length > maxLength) maxLength = v.length;
|
|
133
|
+
if (v.includes("\n")) hasNewlines = true;
|
|
134
|
+
if (/^#{1,6}\s+.+/m.test(v) || /\*\*.+\*\*/.test(v) || /\[.+\]\(.+\)/.test(v) || /^\s*[-*]\s+.+/m.test(v)) hasMarkdown = true;
|
|
135
|
+
}
|
|
136
|
+
if (hasMarkdown) adminOptions.push("multiline: true", "markdown: true");
|
|
137
|
+
else if (hasNewlines || maxLength > 100) adminOptions.push("multiline: true");
|
|
138
|
+
if (maxLength > 0 && maxLength < 1e4) {
|
|
139
|
+
const paddedMax = Math.ceil(maxLength * 1.5 / 10) * 10;
|
|
140
|
+
if (paddedMax < 255) extraLines.push(` validation: {\n max: ${paddedMax}\n }`);
|
|
141
|
+
}
|
|
142
|
+
const isUrl = colNameLower.endsWith("_url") || colNameLower.endsWith("_uri") || colNameLower.endsWith("_link");
|
|
143
|
+
const isMedia = colNameLower.includes("image") || colNameLower.includes("avatar") || colNameLower.includes("photo") || colNameLower.includes("logo") || colNameLower.includes("cover");
|
|
144
|
+
if (validValues.every((v) => typeof v === "string" && (v.startsWith("http://") || v.startsWith("https://")))) if (validValues.some((v) => typeof v === "string" && v.match(/\.(jpeg|jpg|gif|png|webp|svg)/i)) || isMedia) {
|
|
145
|
+
extraLines.push(" url: true");
|
|
146
|
+
adminOptions.push("urlPreview: \"image\"");
|
|
147
|
+
} else extraLines.push(" url: true");
|
|
148
|
+
else if (validValues.some((v) => typeof v === "string" && v.match(/\.[a-zA-Z0-9]+$/))) {
|
|
149
|
+
const firstVal = validValues[0];
|
|
150
|
+
const lastSlash = firstVal.lastIndexOf("/");
|
|
151
|
+
const inferredStoragePath = lastSlash > 0 ? firstVal.substring(0, lastSlash) : "files";
|
|
152
|
+
extraLines.push(` storage: {\n storagePath: "${inferredStoragePath}"\n }`);
|
|
153
|
+
} else if (isUrl) {
|
|
154
|
+
extraLines.push(" url: true");
|
|
155
|
+
if (isMedia) adminOptions.push("urlPreview: \"image\"");
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (emitAdmin && adminOptions.length > 0) extraLines.push(` admin: {\n${adminOptions.map((o) => ` ${o}`).join(",\n")}\n }`);
|
|
159
|
+
result.extra = extraLines.length > 0 ? "\n" + extraLines.join(",\n") + "," : "";
|
|
160
|
+
return result;
|
|
161
|
+
}
|
|
162
|
+
//#endregion
|
|
163
|
+
//#region src/schema/introspect-db-types.ts
|
|
164
|
+
/**
|
|
165
|
+
* The PostgreSQL type → Rebase property type mapping.
|
|
166
|
+
*
|
|
167
|
+
* Split out of `introspect-db-logic` so that the structural analysis can use it
|
|
168
|
+
* without importing the generator, which imports the analysis. Re-exported from
|
|
169
|
+
* `introspect-db-logic` so existing callers keep their import path.
|
|
170
|
+
*/
|
|
171
|
+
/**
|
|
172
|
+
* Map a PostgreSQL data type to a Rebase property type.
|
|
173
|
+
*/
|
|
174
|
+
function mapPgType(dataType) {
|
|
175
|
+
const dt = dataType.toLowerCase();
|
|
176
|
+
if (dt === "interval") return "string";
|
|
177
|
+
if (dt === "array" || dt.startsWith("_")) return "array";
|
|
178
|
+
if (dt.includes("int") || dt.includes("numeric") || dt.includes("decimal") || dt.includes("serial") || dt === "real" || dt === "float4" || dt === "float8" || dt === "double precision" || dt === "money") return "number";
|
|
179
|
+
if (dt.includes("bool")) return "boolean";
|
|
180
|
+
if (dt.includes("time") || dt.includes("date")) return "date";
|
|
181
|
+
if (dt === "json" || dt === "jsonb") return "map";
|
|
182
|
+
if (dt === "bytea") return "binary";
|
|
183
|
+
if (dt === "inet" || dt === "cidr" || dt === "macaddr" || dt === "macaddr8") return "string";
|
|
184
|
+
if (dt === "uuid") return "string";
|
|
185
|
+
return "string";
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* A timestamp the database maintains: a temporal column defaulting to the
|
|
189
|
+
* transaction clock.
|
|
190
|
+
*
|
|
191
|
+
* This is the structural stand-in for the `created_at`/`updated_at` name check.
|
|
192
|
+
* It is strictly better than the name: it catches `fecha_creacion` and
|
|
193
|
+
* `last_update` (pagila's spelling, which the name list misses), and it does not
|
|
194
|
+
* fire on a user-editable `created_at date` column that has no default and which
|
|
195
|
+
* the name check would wrongly make read-only.
|
|
196
|
+
*/
|
|
197
|
+
function isAutoTimestamp(column) {
|
|
198
|
+
if (mapPgType(column.data_type) !== "date") return false;
|
|
199
|
+
const columnDefault = (column.column_default ?? "").toLowerCase();
|
|
200
|
+
if (!columnDefault) return false;
|
|
201
|
+
return /\b(now\(\)|current_timestamp|current_date|current_time|localtimestamp|localtime|transaction_timestamp\(\)|statement_timestamp\(\)|clock_timestamp\(\))/.test(columnDefault);
|
|
202
|
+
}
|
|
203
|
+
/** A column Postgres computes; writing to it is an error. */
|
|
204
|
+
function isGeneratedColumn(column) {
|
|
205
|
+
return column.is_generated === "ALWAYS";
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Types that exist to be searched or indexed, never to be typed into.
|
|
209
|
+
*
|
|
210
|
+
* A `tsvector` column is a derived search index — maintained by a trigger, a
|
|
211
|
+
* generated expression, or an application job — and its contents are lexeme
|
|
212
|
+
* positions, not text. Pagila's `film.fulltext` is one, and introspection used
|
|
213
|
+
* to emit it as an ordinary required string: a mandatory form field whose
|
|
214
|
+
* correct value no user can produce, on the sixth column of the list view.
|
|
215
|
+
*/
|
|
216
|
+
function isDerivedIndexColumn(column) {
|
|
217
|
+
return column.udt_name === "tsvector" || column.udt_name === "tsquery";
|
|
218
|
+
}
|
|
219
|
+
/** Anything the user cannot meaningfully edit, whatever the reason. */
|
|
220
|
+
function isReadOnlyColumn(column) {
|
|
221
|
+
return isGeneratedColumn(column) || isDerivedIndexColumn(column);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* A string column with a declared maximum length.
|
|
225
|
+
*
|
|
226
|
+
* `varchar(50)` and `text` are the same type to an application but not to the
|
|
227
|
+
* author: choosing a bound is a statement that the value is short and
|
|
228
|
+
* label-like, which is what makes this usable for picking a display column.
|
|
229
|
+
*/
|
|
230
|
+
function isBoundedString(column) {
|
|
231
|
+
return mapPgType(column.data_type) === "string" && typeof column.character_maximum_length === "number" && column.character_maximum_length > 0;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* A column carrying data rather than structure: not a key, not a foreign key,
|
|
235
|
+
* not a database-maintained timestamp, not computed.
|
|
236
|
+
*
|
|
237
|
+
* The count of these is what tells a pure join table from an association that
|
|
238
|
+
* carries its own attributes — `northwind.order_details` has the key shape of a
|
|
239
|
+
* junction and three payload columns, so it is not one.
|
|
240
|
+
*/
|
|
241
|
+
function isPayloadColumn(column, pks, fkColumns) {
|
|
242
|
+
if (pks.includes(column.column_name)) return false;
|
|
243
|
+
if (fkColumns.has(column.column_name)) return false;
|
|
244
|
+
if (isAutoTimestamp(column)) return false;
|
|
245
|
+
if (isGeneratedColumn(column)) return false;
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Groups per-column foreign key rows back into constraints.
|
|
250
|
+
*
|
|
251
|
+
* Rows arrive one per referencing column. A composite key looks exactly like two
|
|
252
|
+
* separate keys until they are grouped by constraint name, and the difference
|
|
253
|
+
* matters: two single-column keys to two tables can be a junction, one
|
|
254
|
+
* two-column key never is.
|
|
255
|
+
*/
|
|
256
|
+
function groupForeignKeys(fks) {
|
|
257
|
+
const byName = /* @__PURE__ */ new Map();
|
|
258
|
+
for (const fk of fks) {
|
|
259
|
+
const key = `${fk.table_name}::${fk.constraint_name ?? `${fk.column_name}->${fk.foreign_table_name}`}`;
|
|
260
|
+
const existing = byName.get(key);
|
|
261
|
+
if (existing) {
|
|
262
|
+
existing.columns.push(fk.column_name);
|
|
263
|
+
existing.foreignColumns.push(fk.foreign_column_name);
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
byName.set(key, {
|
|
267
|
+
name: fk.constraint_name ?? `${fk.table_name}_${fk.column_name}_fkey`,
|
|
268
|
+
table: fk.table_name,
|
|
269
|
+
columns: [fk.column_name],
|
|
270
|
+
foreignTable: fk.foreign_table_name,
|
|
271
|
+
foreignColumns: [fk.foreign_column_name],
|
|
272
|
+
deleteRule: fk.delete_rule
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
return Array.from(byName.values());
|
|
276
|
+
}
|
|
277
|
+
/** Column sets that are unique in a table: its primary key and every unique index. */
|
|
278
|
+
function uniqueColumnSets(table, pks, uniques) {
|
|
279
|
+
const sets = [];
|
|
280
|
+
if (pks.length > 0) sets.push(pks);
|
|
281
|
+
for (const unique of uniques) if (unique.table_name === table) sets.push(unique.column_names);
|
|
282
|
+
return sets;
|
|
283
|
+
}
|
|
284
|
+
function sameColumnSet(a, b) {
|
|
285
|
+
if (a.length !== b.length) return false;
|
|
286
|
+
const left = [...a].sort();
|
|
287
|
+
const right = [...b].sort();
|
|
288
|
+
return left.every((value, index) => value === right[index]);
|
|
289
|
+
}
|
|
290
|
+
function buildContext(metadata, tables) {
|
|
291
|
+
const allConstraints = groupForeignKeys(metadata.fks);
|
|
292
|
+
const constraintsByTable = /* @__PURE__ */ new Map();
|
|
293
|
+
const inboundByTable = /* @__PURE__ */ new Map();
|
|
294
|
+
for (const constraint of allConstraints) {
|
|
295
|
+
const outbound = constraintsByTable.get(constraint.table) ?? [];
|
|
296
|
+
outbound.push(constraint);
|
|
297
|
+
constraintsByTable.set(constraint.table, outbound);
|
|
298
|
+
const inbound = inboundByTable.get(constraint.foreignTable) ?? [];
|
|
299
|
+
inbound.push(constraint);
|
|
300
|
+
inboundByTable.set(constraint.foreignTable, inbound);
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
tables,
|
|
304
|
+
constraintsByTable,
|
|
305
|
+
inboundByTable,
|
|
306
|
+
uniques: metadata.uniques,
|
|
307
|
+
rowCounts: metadata.rowCounts
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* A table that exists only to relate two others.
|
|
312
|
+
*
|
|
313
|
+
* Requires all of:
|
|
314
|
+
* - exactly two single-column foreign keys, to two distinct columns;
|
|
315
|
+
* - that pair is unique — it is the primary key, or a unique index covers it.
|
|
316
|
+
* Without this the table can hold the same pair twice, which is a list of
|
|
317
|
+
* events between two things, not a set membership;
|
|
318
|
+
* - no payload columns. A join row that carries a quantity is an association
|
|
319
|
+
* with attributes, and collapsing it into a many-to-many silently drops those
|
|
320
|
+
* attributes from the UI entirely;
|
|
321
|
+
* - nothing references it. A junction with its own dependents is something rows
|
|
322
|
+
* point *at*, so it needs an identity of its own.
|
|
323
|
+
*/
|
|
324
|
+
function classifyJunction(table, context) {
|
|
325
|
+
const meta = context.tables.get(table);
|
|
326
|
+
if (!meta) return null;
|
|
327
|
+
const constraints = context.constraintsByTable.get(table) ?? [];
|
|
328
|
+
if (constraints.length !== 2) return null;
|
|
329
|
+
if (constraints.some((c) => c.columns.length !== 1)) return null;
|
|
330
|
+
const [first, second] = constraints;
|
|
331
|
+
if (first.columns[0] === second.columns[0]) return null;
|
|
332
|
+
if ((context.inboundByTable.get(table) ?? []).length > 0) return null;
|
|
333
|
+
const pair = [first.columns[0], second.columns[0]];
|
|
334
|
+
if (!uniqueColumnSets(table, meta.pks, context.uniques).some((set) => sameColumnSet(set, pair))) return null;
|
|
335
|
+
const fkColumns = new Set(pair);
|
|
336
|
+
if (meta.columns.filter((column) => isPayloadColumn(column, meta.pks, fkColumns)).length > 0) return null;
|
|
337
|
+
return {
|
|
338
|
+
table,
|
|
339
|
+
role: "junction",
|
|
340
|
+
reason: `only relates ${first.foreignTable} to ${second.foreignTable}: two foreign keys, unique together, no other data`,
|
|
341
|
+
junction: {
|
|
342
|
+
sourceTable: first.foreignTable,
|
|
343
|
+
sourceColumn: first.columns[0],
|
|
344
|
+
targetTable: second.foreignTable,
|
|
345
|
+
targetColumn: second.columns[0]
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* A small, referenced, self-contained code list.
|
|
351
|
+
*
|
|
352
|
+
* Requires: something references it, it references nothing, it carries few and
|
|
353
|
+
* simple payload columns, and it is small — where "small" is a real count, not
|
|
354
|
+
* an estimate. `reltuples` is -1 on any table that has never been analyzed,
|
|
355
|
+
* which is every table in a freshly restored dump, so a count that is merely
|
|
356
|
+
* absent must not read as "small".
|
|
357
|
+
*/
|
|
358
|
+
function classifyLookup(table, context) {
|
|
359
|
+
if (!isLookupCandidate(table, context)) return null;
|
|
360
|
+
const rowCount = context.rowCounts[table];
|
|
361
|
+
if (typeof rowCount !== "number") return null;
|
|
362
|
+
if (rowCount < 1 || rowCount > 50) return null;
|
|
363
|
+
return {
|
|
364
|
+
table,
|
|
365
|
+
role: "lookup",
|
|
366
|
+
reason: `a ${rowCount}-row code list: referenced by ${(context.inboundByTable.get(table) ?? []).length} table(s), references none, 3 or fewer simple columns`
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* The structural half of the lookup test — everything except the row count.
|
|
371
|
+
*
|
|
372
|
+
* Separate because the count costs a query per table, and the caller only wants
|
|
373
|
+
* to pay it for tables that could possibly qualify.
|
|
374
|
+
*/
|
|
375
|
+
function isLookupCandidate(table, context) {
|
|
376
|
+
const meta = context.tables.get(table);
|
|
377
|
+
if (!meta) return false;
|
|
378
|
+
if ((context.constraintsByTable.get(table) ?? []).length > 0) return false;
|
|
379
|
+
if ((context.inboundByTable.get(table) ?? []).length === 0) return false;
|
|
380
|
+
const payload = meta.columns.filter((column) => isPayloadColumn(column, meta.pks, /* @__PURE__ */ new Set()));
|
|
381
|
+
if (payload.length > 3) return false;
|
|
382
|
+
return payload.every((column) => {
|
|
383
|
+
const type = mapPgType(column.data_type);
|
|
384
|
+
return type === "boolean" || type === "number" || type === "string";
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Names the tables whose classification depends on a row count.
|
|
389
|
+
*
|
|
390
|
+
* The caller counts these — and only these — before calling
|
|
391
|
+
* {@link classifyTables}. On a schema of any size this is a handful of tables,
|
|
392
|
+
* and the count itself is capped (see `countRowsUpTo`), so the whole extra cost
|
|
393
|
+
* is bounded regardless of how much data the database holds.
|
|
394
|
+
*/
|
|
395
|
+
function lookupCandidates(metadata, tables) {
|
|
396
|
+
const context = buildContext(metadata, tables);
|
|
397
|
+
return Array.from(tables.keys()).filter((table) => isLookupCandidate(table, context));
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Rows that belong to one parent row and are reached through it.
|
|
401
|
+
*
|
|
402
|
+
* Requires nothing to reference the table — a table others point at has an
|
|
403
|
+
* identity of its own and belongs in the navigation — and an *unambiguous*
|
|
404
|
+
* owner among its foreign keys. The evidence ladder runs strongest first; each
|
|
405
|
+
* rung is only taken when exactly one key satisfies it, so a table with two
|
|
406
|
+
* equally plausible parents stays an entity rather than being filed under a
|
|
407
|
+
* coin-flip.
|
|
408
|
+
*/
|
|
409
|
+
function classifyOwnedChild(table, context) {
|
|
410
|
+
const meta = context.tables.get(table);
|
|
411
|
+
if (!meta) return null;
|
|
412
|
+
const constraints = context.constraintsByTable.get(table) ?? [];
|
|
413
|
+
if (constraints.length === 0) return null;
|
|
414
|
+
if ((context.inboundByTable.get(table) ?? []).length > 0) return null;
|
|
415
|
+
const owner = pickOwner(meta, constraints);
|
|
416
|
+
if (!owner) return null;
|
|
417
|
+
return {
|
|
418
|
+
table,
|
|
419
|
+
role: "owned-child",
|
|
420
|
+
reason: `belongs to ${owner.constraint.foreignTable} (${describeEvidence(owner.evidence)}) and nothing else references it`,
|
|
421
|
+
owner: {
|
|
422
|
+
table: owner.constraint.foreignTable,
|
|
423
|
+
column: owner.constraint.columns[0],
|
|
424
|
+
evidence: owner.evidence
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
function describeEvidence(evidence) {
|
|
429
|
+
switch (evidence) {
|
|
430
|
+
case "cascade-delete": return "its only ON DELETE CASCADE foreign key";
|
|
431
|
+
case "identifying-key": return "its only foreign key inside the primary key";
|
|
432
|
+
case "sole-required-key": return "its only NOT NULL foreign key";
|
|
433
|
+
case "leading-key-column": return "the leading column of a primary key made only of foreign keys";
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
function pickOwner(meta, constraints) {
|
|
437
|
+
const singleColumn = constraints.filter((c) => c.columns.length === 1);
|
|
438
|
+
if (singleColumn.length === 0) return null;
|
|
439
|
+
const nullability = new Map(meta.columns.map((c) => [c.column_name, c.is_nullable === "NO"]));
|
|
440
|
+
const cascading = singleColumn.filter((c) => c.deleteRule === "CASCADE");
|
|
441
|
+
if (cascading.length === 1) return {
|
|
442
|
+
constraint: cascading[0],
|
|
443
|
+
evidence: "cascade-delete"
|
|
444
|
+
};
|
|
445
|
+
const identifying = singleColumn.filter((c) => meta.pks.includes(c.columns[0]));
|
|
446
|
+
if (identifying.length === 1) return {
|
|
447
|
+
constraint: identifying[0],
|
|
448
|
+
evidence: "identifying-key"
|
|
449
|
+
};
|
|
450
|
+
const required = singleColumn.filter((c) => nullability.get(c.columns[0]) === true);
|
|
451
|
+
if (required.length === 1) return {
|
|
452
|
+
constraint: required[0],
|
|
453
|
+
evidence: "sole-required-key"
|
|
454
|
+
};
|
|
455
|
+
if (identifying.length === meta.pks.length && meta.pks.length > 1) {
|
|
456
|
+
const leading = identifying.find((c) => c.columns[0] === meta.pks[0]);
|
|
457
|
+
if (leading) return {
|
|
458
|
+
constraint: leading,
|
|
459
|
+
evidence: "leading-key-column"
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Classifies every table in the schema.
|
|
466
|
+
*
|
|
467
|
+
* Order matters: junction is the most specific and most consequential (the
|
|
468
|
+
* table disappears), so it is tested first; then lookup, which needs no
|
|
469
|
+
* ownership reasoning; then ownership. Anything unmatched is an entity, which
|
|
470
|
+
* is also what every rule falls back to when its evidence is ambiguous.
|
|
471
|
+
*/
|
|
472
|
+
function classifyTables(metadata, tables) {
|
|
473
|
+
const context = buildContext(metadata, tables);
|
|
474
|
+
const result = /* @__PURE__ */ new Map();
|
|
475
|
+
for (const table of tables.keys()) {
|
|
476
|
+
const classification = classifyJunction(table, context) ?? classifyLookup(table, context) ?? classifyOwnedChild(table, context) ?? {
|
|
477
|
+
table,
|
|
478
|
+
role: "entity",
|
|
479
|
+
reason: "no structural evidence that it is a join table, a code list, or owned by another table"
|
|
480
|
+
};
|
|
481
|
+
result.set(table, classification);
|
|
482
|
+
}
|
|
483
|
+
return result;
|
|
484
|
+
}
|
|
485
|
+
function buildColumnFacts(meta, metadata, enumMap, checkFacts) {
|
|
486
|
+
const fkColumns = new Set(meta.fks.map((fk) => fk.column_name));
|
|
487
|
+
const singleColumnUniques = new Set(metadata.uniques.filter((u) => u.table_name === meta.name && u.column_names.length === 1).map((u) => u.column_names[0]));
|
|
488
|
+
const tableChecks = checkFacts.get(meta.name);
|
|
489
|
+
const facts = /* @__PURE__ */ new Map();
|
|
490
|
+
for (const column of meta.columns) {
|
|
491
|
+
const pgEnum = column.data_type === "USER-DEFINED" ? enumMap.get(column.udt_name) : void 0;
|
|
492
|
+
const checkEnum = tableChecks?.get(column.column_name)?.enumValues;
|
|
493
|
+
facts.set(column.column_name, {
|
|
494
|
+
column,
|
|
495
|
+
isPk: meta.pks.includes(column.column_name),
|
|
496
|
+
isFk: fkColumns.has(column.column_name),
|
|
497
|
+
isUniqueAlone: singleColumnUniques.has(column.column_name),
|
|
498
|
+
isAutoTimestamp: isAutoTimestamp(column),
|
|
499
|
+
isGenerated: isGeneratedColumn(column),
|
|
500
|
+
enumValues: pgEnum ?? checkEnum,
|
|
501
|
+
propType: pgEnum ? "string" : mapPgType(column.data_type)
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return facts;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* The column that identifies a row to a human.
|
|
508
|
+
*
|
|
509
|
+
* Structural, in three rungs, strongest first:
|
|
510
|
+
*
|
|
511
|
+
* 1. A single-column unique constraint on a required string. This is as close
|
|
512
|
+
* as a schema comes to declaring "this is what a row is called": it is the
|
|
513
|
+
* column a person looks a row up by, and the database guarantees it picks
|
|
514
|
+
* out one row.
|
|
515
|
+
* 2. The first required string that declares a length, when the table also has
|
|
516
|
+
* strings that do not. Choosing `varchar(n)` for one column and `text` for
|
|
517
|
+
* another is the author distinguishing a label from prose.
|
|
518
|
+
* 3. The first required string in declaration order. Weak, but it is the same
|
|
519
|
+
* rung the panel's own fallback stands on, and column order carries real
|
|
520
|
+
* information — the identifying column of a table is written near the top of
|
|
521
|
+
* it, in every schema, in every language.
|
|
522
|
+
*
|
|
523
|
+
* Deliberately not: a column called `name`, or `title`. That works on English
|
|
524
|
+
* schemas written by someone who read the same tutorial. This picks
|
|
525
|
+
* `film.title`, `actor.first_name` and `category.name` out of pagila without
|
|
526
|
+
* knowing what any of those words mean.
|
|
527
|
+
*/
|
|
528
|
+
function deriveTitleProperty(facts) {
|
|
529
|
+
const candidates = Array.from(facts.values()).filter((f) => !f.isPk && !f.isFk && !f.isGenerated && f.propType === "string" && !f.enumValues && f.column.is_nullable === "NO");
|
|
530
|
+
if (candidates.length === 0) return void 0;
|
|
531
|
+
const unique = candidates.find((f) => f.isUniqueAlone);
|
|
532
|
+
if (unique) return unique.column.column_name;
|
|
533
|
+
const bounded = candidates.filter((f) => isBoundedString(f.column));
|
|
534
|
+
if (bounded.length > 0 && bounded.length < candidates.length) return bounded[0].column.column_name;
|
|
535
|
+
return candidates[0].column.column_name;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* The enum column a board should have as its columns.
|
|
539
|
+
*
|
|
540
|
+
* A board needs a small, closed, always-present set of states. `NOT NULL` is
|
|
541
|
+
* required because a null has no column to sit in; the bounds keep out
|
|
542
|
+
* two-state flags (a filter, not a board) and long code lists (a scrolling
|
|
543
|
+
* table). The first qualifying column in declaration order wins, so the output
|
|
544
|
+
* is stable across runs.
|
|
545
|
+
*/
|
|
546
|
+
function deriveKanbanProperty(facts) {
|
|
547
|
+
for (const fact of facts.values()) {
|
|
548
|
+
if (fact.isPk || fact.isFk || fact.isGenerated) continue;
|
|
549
|
+
if (!fact.enumValues) continue;
|
|
550
|
+
if (fact.column.is_nullable !== "NO") continue;
|
|
551
|
+
if (fact.enumValues.length < 2 || fact.enumValues.length > 12) continue;
|
|
552
|
+
return fact.column.column_name;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* The column a list should be sorted by, newest first.
|
|
557
|
+
*
|
|
558
|
+
* Only when the table has exactly one database-maintained timestamp. With two —
|
|
559
|
+
* a created and an updated stamp — the two orderings differ and the schema does
|
|
560
|
+
* not say which the user means, so neither is chosen.
|
|
561
|
+
*/
|
|
562
|
+
function deriveSort(facts) {
|
|
563
|
+
const stamps = Array.from(facts.values()).filter((f) => f.isAutoTimestamp);
|
|
564
|
+
if (stamps.length !== 1) return void 0;
|
|
565
|
+
return [stamps[0].column.column_name, "desc"];
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* The first `LIST_PROPERTIES_CAP` visible properties, or nothing.
|
|
569
|
+
*
|
|
570
|
+
* Returning nothing when the table is already narrow matters: `listProperties`
|
|
571
|
+
* that restates every column is config the reader has to check against the
|
|
572
|
+
* property list to discover it does nothing, and it silently stops new columns
|
|
573
|
+
* from appearing in the list view when someone adds one later.
|
|
574
|
+
*
|
|
575
|
+
* `hidden` names the properties already marked `hideFromCollection` — spending
|
|
576
|
+
* one of six columns on a value the list does not render is worse than not
|
|
577
|
+
* capping at all.
|
|
578
|
+
*/
|
|
579
|
+
function deriveListProperties(propertiesOrder, hidden = /* @__PURE__ */ new Set()) {
|
|
580
|
+
const visible = propertiesOrder.filter((key) => !hidden.has(key));
|
|
581
|
+
if (visible.length <= 6) return void 0;
|
|
582
|
+
return visible.slice(0, 6);
|
|
583
|
+
}
|
|
584
|
+
//#endregion
|
|
585
|
+
//#region src/schema/introspect-db-logic.ts
|
|
586
|
+
/**
|
|
587
|
+
* Introspection logic — pure functions and the pipeline that transforms
|
|
588
|
+
* raw PostgreSQL metadata into Rebase collection definition files.
|
|
589
|
+
*
|
|
590
|
+
* This module contains NO side-effects: no fs writes, no pg.Client creation,
|
|
591
|
+
* no process.exit. It is imported by introspect-db.ts (the CLI entry-point)
|
|
592
|
+
* and consumed directly by tests.
|
|
593
|
+
*/
|
|
594
|
+
var IRREGULAR_SINGULARS = {
|
|
595
|
+
people: "person",
|
|
596
|
+
children: "child",
|
|
597
|
+
men: "man",
|
|
598
|
+
women: "woman",
|
|
599
|
+
mice: "mouse",
|
|
600
|
+
geese: "goose",
|
|
601
|
+
teeth: "tooth",
|
|
602
|
+
feet: "foot",
|
|
603
|
+
data: "datum",
|
|
604
|
+
media: "medium",
|
|
605
|
+
criteria: "criterion",
|
|
606
|
+
phenomena: "phenomenon"
|
|
607
|
+
};
|
|
608
|
+
/**
|
|
609
|
+
* Plurals in "-ves" whose singular really ends in f/fe, and which of the two.
|
|
610
|
+
*
|
|
611
|
+
* A blanket "-ves" -> "-f" rule gets `knives` -> `knif`, and mangles every
|
|
612
|
+
* ordinary "-ive" noun that happens to be plural along with it: `archives` ->
|
|
613
|
+
* `archif`, `objectives` -> `objectif`. The set of English words that genuinely
|
|
614
|
+
* swap f/fe for ves is small and closed, so it is listed rather than guessed —
|
|
615
|
+
* anything else ending in "ves" drops the trailing 's' like any other plural.
|
|
616
|
+
*
|
|
617
|
+
* Matched on the whole word, not on the suffix: `olives` ends in `lives`.
|
|
618
|
+
*/
|
|
619
|
+
var VES_SINGULAR_ENDINGS = {
|
|
620
|
+
calves: "f",
|
|
621
|
+
dwarves: "f",
|
|
622
|
+
elves: "f",
|
|
623
|
+
halves: "f",
|
|
624
|
+
hooves: "f",
|
|
625
|
+
leaves: "f",
|
|
626
|
+
loaves: "f",
|
|
627
|
+
scarves: "f",
|
|
628
|
+
selves: "f",
|
|
629
|
+
sheaves: "f",
|
|
630
|
+
shelves: "f",
|
|
631
|
+
thieves: "f",
|
|
632
|
+
wharves: "f",
|
|
633
|
+
wolves: "f",
|
|
634
|
+
knives: "fe",
|
|
635
|
+
lives: "fe",
|
|
636
|
+
wives: "fe"
|
|
637
|
+
};
|
|
638
|
+
/** Words ending in 's' that are already singular. */
|
|
639
|
+
var UNCOUNTABLE = /* @__PURE__ */ new Set([
|
|
640
|
+
"status",
|
|
641
|
+
"campus",
|
|
642
|
+
"virus",
|
|
643
|
+
"bus",
|
|
644
|
+
"plus",
|
|
645
|
+
"census",
|
|
646
|
+
"diagnosis",
|
|
647
|
+
"analysis",
|
|
648
|
+
"basis",
|
|
649
|
+
"crisis",
|
|
650
|
+
"thesis",
|
|
651
|
+
"synopsis",
|
|
652
|
+
"parenthesis",
|
|
653
|
+
"hypothesis",
|
|
654
|
+
"emphasis",
|
|
655
|
+
"news",
|
|
656
|
+
"series",
|
|
657
|
+
"species",
|
|
658
|
+
"means",
|
|
659
|
+
"athletics",
|
|
660
|
+
"economics",
|
|
661
|
+
"electronics",
|
|
662
|
+
"mathematics",
|
|
663
|
+
"physics",
|
|
664
|
+
"politics",
|
|
665
|
+
"statistics"
|
|
666
|
+
]);
|
|
667
|
+
function singularize(word) {
|
|
668
|
+
const lower = word.toLowerCase();
|
|
669
|
+
if (IRREGULAR_SINGULARS[lower]) {
|
|
670
|
+
const singular = IRREGULAR_SINGULARS[lower];
|
|
671
|
+
return word[0] === word[0].toUpperCase() ? singular.charAt(0).toUpperCase() + singular.slice(1) : singular;
|
|
672
|
+
}
|
|
673
|
+
if (UNCOUNTABLE.has(lower)) return word;
|
|
674
|
+
if (lower.endsWith("ices") && lower.length > 5) return word.slice(0, -4) + "ex";
|
|
675
|
+
if (lower.endsWith("ies") && lower.length > 3) return word.slice(0, -3) + "y";
|
|
676
|
+
if (VES_SINGULAR_ENDINGS[lower]) return word.slice(0, -3) + VES_SINGULAR_ENDINGS[lower];
|
|
677
|
+
if (lower.endsWith("ches") || lower.endsWith("shes") || lower.endsWith("sses") || lower.endsWith("xes") || lower.endsWith("zes")) return word.slice(0, -2);
|
|
678
|
+
if (lower.endsWith("ses") && !lower.endsWith("sses")) return word.slice(0, -1);
|
|
679
|
+
if (lower.endsWith("s") && !lower.endsWith("ss") && !lower.endsWith("us") && !lower.endsWith("is")) return word.slice(0, -1);
|
|
680
|
+
return word;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* Convert a snake_case table name to a camelCase + "Collection" variable name.
|
|
684
|
+
* e.g. "company_token" -> "companyTokenCollection"
|
|
685
|
+
*/
|
|
686
|
+
function toCollectionVarName(tableName) {
|
|
687
|
+
const camel = tableName.replace(/_([a-z])/g, (_g, letter) => letter.toUpperCase()) + "Collection";
|
|
688
|
+
if (JS_IDENTIFIER.test(camel)) return camel;
|
|
689
|
+
const sanitized = camel.replace(/[^A-Za-z0-9_$]/g, "_");
|
|
690
|
+
return /^[0-9]/.test(sanitized) ? `_${sanitized}` : sanitized;
|
|
691
|
+
}
|
|
692
|
+
function getIconForTable(tableName) {
|
|
693
|
+
const table = tableName.toLowerCase();
|
|
694
|
+
if (table.includes("user") || table.includes("account") || table.includes("member") || table.includes("customer") || table.includes("client") || table.includes("patient")) return "Users";
|
|
695
|
+
if (table.includes("post") || table.includes("article") || table.includes("blog") || table.includes("page")) return "FileText";
|
|
696
|
+
if (table.includes("product") || table.includes("item")) return "Package";
|
|
697
|
+
if (table.includes("order") || table.includes("cart") || table.includes("purchase") || table.includes("invoice")) return "ShoppingCart";
|
|
698
|
+
if (table.includes("setting") || table.includes("config")) return "Settings";
|
|
699
|
+
if (table.includes("tag") || table.includes("categor")) return "Tag";
|
|
700
|
+
if (table.includes("image") || table.includes("photo") || table.includes("media") || table.includes("asset")) return "Image";
|
|
701
|
+
if (table.includes("notification") || table.includes("message") || table.includes("email")) return "Mail";
|
|
702
|
+
if (table.includes("log") || table.includes("audit") || table.includes("event")) return "Activity";
|
|
703
|
+
if (table.includes("subscription") || table.includes("plan") || table.includes("billing")) return "CreditCard";
|
|
704
|
+
if (table.includes("comment") || table.includes("review") || table.includes("feedback")) return "MessageCircle";
|
|
705
|
+
return "Database";
|
|
706
|
+
}
|
|
707
|
+
function buildEnumMap(enumValues) {
|
|
708
|
+
const enumMap = /* @__PURE__ */ new Map();
|
|
709
|
+
for (const ev of enumValues) {
|
|
710
|
+
const existing = enumMap.get(ev.enum_name);
|
|
711
|
+
if (existing) existing.push(ev.enum_value);
|
|
712
|
+
else enumMap.set(ev.enum_name, [ev.enum_value]);
|
|
713
|
+
}
|
|
714
|
+
return enumMap;
|
|
715
|
+
}
|
|
716
|
+
function buildTablesMap(tables, columns, pks, fks) {
|
|
717
|
+
const tablesMap = /* @__PURE__ */ new Map();
|
|
718
|
+
for (const t of tables) tablesMap.set(t.table_name, {
|
|
719
|
+
name: t.table_name,
|
|
720
|
+
columns: columns.filter((c) => c.table_name === t.table_name),
|
|
721
|
+
pks: pks.filter((pk) => pk.table_name === t.table_name).map((pk) => pk.column_name),
|
|
722
|
+
fks: fks.filter((fk) => fk.table_name === t.table_name)
|
|
723
|
+
});
|
|
724
|
+
return tablesMap;
|
|
725
|
+
}
|
|
726
|
+
/**
|
|
727
|
+
* Join tables, identified by column name.
|
|
728
|
+
*
|
|
729
|
+
* Superseded for the CLI by `classifyTables` in `./introspect-db-structure`,
|
|
730
|
+
* which asks the database instead: two single-column keys, unique together, no
|
|
731
|
+
* payload column, nothing referencing the table. This rule folds away
|
|
732
|
+
* `northwind.order_details` — which has the key shape and carries unit price,
|
|
733
|
+
* quantity and discount — because it recognises `id`, `created_at` and
|
|
734
|
+
* `updated_at` by name and calls everything else a foreign key.
|
|
735
|
+
*
|
|
736
|
+
* Still used by `./introspect-runtime`, which builds collections in memory from
|
|
737
|
+
* a narrower set of catalog queries and has no unique-constraint or row-count
|
|
738
|
+
* data to reason with.
|
|
739
|
+
*/
|
|
740
|
+
function identifyJoinTables(tablesMap) {
|
|
741
|
+
const joinTables = /* @__PURE__ */ new Set();
|
|
742
|
+
for (const [tableName, meta] of tablesMap.entries()) if (meta.fks.length === 2) {
|
|
743
|
+
if (meta.columns.every((c) => meta.fks.some((fk) => fk.column_name === c.column_name) || c.column_name === "id" || c.column_name === "created_at" || c.column_name === "updated_at")) joinTables.add(tableName);
|
|
744
|
+
}
|
|
745
|
+
return joinTables;
|
|
746
|
+
}
|
|
747
|
+
var IDENTITY_EXACT = {
|
|
748
|
+
id: 0,
|
|
749
|
+
uuid: 1,
|
|
750
|
+
_id: 2
|
|
751
|
+
};
|
|
752
|
+
var TITLE_EXACT = {
|
|
753
|
+
name: 10,
|
|
754
|
+
title: 11,
|
|
755
|
+
label: 12,
|
|
756
|
+
display_name: 13,
|
|
757
|
+
displayname: 13,
|
|
758
|
+
headline: 14,
|
|
759
|
+
subject: 15,
|
|
760
|
+
heading: 16
|
|
761
|
+
};
|
|
762
|
+
var HUMAN_IDENTITY_EXACT = {
|
|
763
|
+
first_name: 20,
|
|
764
|
+
firstname: 20,
|
|
765
|
+
last_name: 21,
|
|
766
|
+
lastname: 21,
|
|
767
|
+
full_name: 22,
|
|
768
|
+
fullname: 22,
|
|
769
|
+
given_name: 22,
|
|
770
|
+
family_name: 23,
|
|
771
|
+
middle_name: 24,
|
|
772
|
+
username: 25,
|
|
773
|
+
user_name: 25,
|
|
774
|
+
email: 26,
|
|
775
|
+
email_address: 26,
|
|
776
|
+
phone: 27,
|
|
777
|
+
phone_number: 27,
|
|
778
|
+
mobile: 27
|
|
779
|
+
};
|
|
780
|
+
var DESCRIPTOR_EXACT = {
|
|
781
|
+
slug: 30,
|
|
782
|
+
code: 31,
|
|
783
|
+
sku: 32,
|
|
784
|
+
reference: 33,
|
|
785
|
+
ref: 33,
|
|
786
|
+
type: 34,
|
|
787
|
+
kind: 34,
|
|
788
|
+
status: 35,
|
|
789
|
+
state: 35,
|
|
790
|
+
role: 36,
|
|
791
|
+
category: 37,
|
|
792
|
+
group: 38,
|
|
793
|
+
priority: 39,
|
|
794
|
+
order: 39,
|
|
795
|
+
sort_order: 39,
|
|
796
|
+
position: 39
|
|
797
|
+
};
|
|
798
|
+
var SYSTEM_TIMESTAMP_EXACT = {
|
|
799
|
+
created_at: 120,
|
|
800
|
+
createdat: 120,
|
|
801
|
+
creation_date: 120,
|
|
802
|
+
inserted_at: 121,
|
|
803
|
+
updated_at: 122,
|
|
804
|
+
updatedat: 122,
|
|
805
|
+
modified_at: 122,
|
|
806
|
+
last_modified: 122,
|
|
807
|
+
deleted_at: 123,
|
|
808
|
+
deletedat: 123,
|
|
809
|
+
archived_at: 124
|
|
810
|
+
};
|
|
811
|
+
var TITLE_PATTERNS = [
|
|
812
|
+
"name",
|
|
813
|
+
"title",
|
|
814
|
+
"label"
|
|
815
|
+
];
|
|
816
|
+
var LONG_TEXT_NAMES = /* @__PURE__ */ new Set([
|
|
817
|
+
"description",
|
|
818
|
+
"summary",
|
|
819
|
+
"excerpt",
|
|
820
|
+
"abstract",
|
|
821
|
+
"overview",
|
|
822
|
+
"bio",
|
|
823
|
+
"biography",
|
|
824
|
+
"about"
|
|
825
|
+
]);
|
|
826
|
+
var RICH_CONTENT_NAMES = /* @__PURE__ */ new Set([
|
|
827
|
+
"content",
|
|
828
|
+
"body",
|
|
829
|
+
"html",
|
|
830
|
+
"markup",
|
|
831
|
+
"text",
|
|
832
|
+
"article_body",
|
|
833
|
+
"post_body"
|
|
834
|
+
]);
|
|
835
|
+
var MEDIA_PATTERNS = [
|
|
836
|
+
"image",
|
|
837
|
+
"avatar",
|
|
838
|
+
"photo",
|
|
839
|
+
"logo",
|
|
840
|
+
"cover",
|
|
841
|
+
"thumbnail",
|
|
842
|
+
"banner",
|
|
843
|
+
"icon",
|
|
844
|
+
"picture",
|
|
845
|
+
"poster"
|
|
846
|
+
];
|
|
847
|
+
var JSON_MAP_NAMES = /* @__PURE__ */ new Set([
|
|
848
|
+
"metadata",
|
|
849
|
+
"meta",
|
|
850
|
+
"config",
|
|
851
|
+
"configuration",
|
|
852
|
+
"settings",
|
|
853
|
+
"options",
|
|
854
|
+
"preferences",
|
|
855
|
+
"data",
|
|
856
|
+
"payload",
|
|
857
|
+
"attributes",
|
|
858
|
+
"extra",
|
|
859
|
+
"additional_info"
|
|
860
|
+
]);
|
|
861
|
+
/**
|
|
862
|
+
* Compute a numeric priority score for a property.
|
|
863
|
+
* Lower scores appear first in the generated `propertiesOrder` array.
|
|
864
|
+
*
|
|
865
|
+
* The system uses 14 tiers (0–139), with the original column index
|
|
866
|
+
* added as a fractional tiebreaker (originalIndex / 10000) to
|
|
867
|
+
* guarantee stable ordering within the same tier.
|
|
868
|
+
*
|
|
869
|
+
* Pure function — no side effects.
|
|
870
|
+
*/
|
|
871
|
+
function computePropertyPriority(columnName, ctx) {
|
|
872
|
+
const col = columnName.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
|
|
873
|
+
const tiebreaker = ctx.originalIndex / 1e4;
|
|
874
|
+
if (ctx.isPk) return (IDENTITY_EXACT[col] ?? 5) + tiebreaker;
|
|
875
|
+
const systemTs = SYSTEM_TIMESTAMP_EXACT[col];
|
|
876
|
+
if (systemTs !== void 0) return systemTs + tiebreaker;
|
|
877
|
+
const titleExact = TITLE_EXACT[col];
|
|
878
|
+
if (titleExact !== void 0) return titleExact + tiebreaker;
|
|
879
|
+
const humanExact = HUMAN_IDENTITY_EXACT[col];
|
|
880
|
+
if (humanExact !== void 0) return humanExact + tiebreaker;
|
|
881
|
+
const descriptorExact = DESCRIPTOR_EXACT[col];
|
|
882
|
+
if (descriptorExact !== void 0) return descriptorExact + tiebreaker;
|
|
883
|
+
for (const pattern of TITLE_PATTERNS) if (col.includes(pattern) && col !== pattern) return 17 + tiebreaker;
|
|
884
|
+
if (ctx.isStorage) return 90 + tiebreaker;
|
|
885
|
+
for (const pattern of MEDIA_PATTERNS) if (col.includes(pattern)) return 91 + tiebreaker;
|
|
886
|
+
if (col.endsWith("_url") || col.endsWith("_uri") || col.endsWith("_link")) return 92 + tiebreaker;
|
|
887
|
+
if (LONG_TEXT_NAMES.has(col)) return 70 + tiebreaker;
|
|
888
|
+
if (RICH_CONTENT_NAMES.has(col)) return 80 + tiebreaker;
|
|
889
|
+
if (ctx.propType === "map") return JSON_MAP_NAMES.has(col) ? 100 + tiebreaker : 105 + tiebreaker;
|
|
890
|
+
if (ctx.propType === "array") return 110 + tiebreaker;
|
|
891
|
+
if (ctx.propType === "relation") return 60 + tiebreaker;
|
|
892
|
+
if (ctx.isEnum) return 40 + tiebreaker;
|
|
893
|
+
if (ctx.propType === "boolean") return 45 + tiebreaker;
|
|
894
|
+
if (ctx.propType === "string" && ctx.pgDataType !== "text") return 42 + tiebreaker;
|
|
895
|
+
if (ctx.propType === "number") return 50 + tiebreaker;
|
|
896
|
+
if (ctx.propType === "date") return 55 + tiebreaker;
|
|
897
|
+
if (ctx.propType === "string" && ctx.pgDataType === "text") return 75 + tiebreaker;
|
|
898
|
+
return 130 + tiebreaker;
|
|
899
|
+
}
|
|
900
|
+
function sortPropertiesOrder(entries) {
|
|
901
|
+
return [...entries].sort((a, b) => computePropertyPriority(a.key, a.ctx) - computePropertyPriority(b.key, b.ctx)).map((e) => e.key);
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* The package specifiers the generated files name, spelled once.
|
|
905
|
+
*
|
|
906
|
+
* Written as constants rather than inline in the import templates below because
|
|
907
|
+
* `scripts/headless-guard/check-types.mjs` scans core sources for `from
|
|
908
|
+
* "@rebasepro/cms-types"` and cannot tell a real import from one this module
|
|
909
|
+
* *writes*. It is right to be that blunt — the guard's whole value is that it
|
|
910
|
+
* cannot be reasoned around — so the string simply never appears in that shape
|
|
911
|
+
* here. Inlining them back into the templates re-breaks `check:types-headless`.
|
|
912
|
+
*/
|
|
913
|
+
var ADMIN_TYPES_PACKAGE = "@rebasepro/cms-types";
|
|
914
|
+
var COMMON_PACKAGE = "@rebasepro/common";
|
|
915
|
+
var TYPES_PACKAGE = "@rebasepro/types";
|
|
916
|
+
/** Adds entries to a property's `validation` block, creating it if absent. */
|
|
917
|
+
function withValidation(extra, entries) {
|
|
918
|
+
if (entries.length === 0) return extra;
|
|
919
|
+
const block = entries.map((e) => ` ${e}`).join(",\n");
|
|
920
|
+
if (extra.includes("validation: {")) return extra.replace("validation: {", `validation: {\n${block},`);
|
|
921
|
+
return `${extra}\n validation: {\n${block}\n },`;
|
|
922
|
+
}
|
|
923
|
+
/**
|
|
924
|
+
* Whether a property's generated text already sets `key:` as an object key.
|
|
925
|
+
*
|
|
926
|
+
* A bare `extra.includes("min:")` looks like it answers this and does not:
|
|
927
|
+
* `admin:` ends in `min:`, so every property with an admin block claimed to
|
|
928
|
+
* have a minimum already and silently lost the one the database declared. The
|
|
929
|
+
* leading-delimiter requirement is the whole point — a key is preceded by a
|
|
930
|
+
* newline, a brace or a comma, never by another identifier character.
|
|
931
|
+
*/
|
|
932
|
+
function hasGeneratedKey(extra, key) {
|
|
933
|
+
return new RegExp(`(^|[\\s{,])${key}\\s*:`).test(extra);
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Adds entries to a property's `admin` block, creating it if absent.
|
|
937
|
+
*
|
|
938
|
+
* `emitAdmin` is false for the headless flavours, where `BaseProperty` has no
|
|
939
|
+
* `admin` field to put them in — see {@link CollectionBuilder}. The options are
|
|
940
|
+
* dropped rather than relocated: every one of them (`readOnly`, `multiline`,
|
|
941
|
+
* `hideFromCollection`, `urlPreview`) describes a form widget, and there is no
|
|
942
|
+
* form.
|
|
943
|
+
*/
|
|
944
|
+
function withAdminOptions(extra, entries, emitAdmin = true) {
|
|
945
|
+
if (!emitAdmin) return extra;
|
|
946
|
+
if (entries.length === 0) return extra;
|
|
947
|
+
const block = entries.map((e) => ` ${e}`).join(",\n");
|
|
948
|
+
if (extra.includes("admin: {")) return extra.replace("admin: {", `admin: {\n${block},`);
|
|
949
|
+
return `${extra}\n admin: {\n${block}\n },`;
|
|
950
|
+
}
|
|
951
|
+
/** A TypeScript string literal, escaped. */
|
|
952
|
+
function quote(value) {
|
|
953
|
+
return JSON.stringify(value);
|
|
954
|
+
}
|
|
955
|
+
var JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
956
|
+
/**
|
|
957
|
+
* An object key for the generated file: verbatim when the name is a JavaScript
|
|
958
|
+
* identifier, quoted otherwise.
|
|
959
|
+
*
|
|
960
|
+
* Postgres constrains an identifier only by quoting, so `order`, `full name`
|
|
961
|
+
* and `2fa_enabled` are all ordinary column names — and all three produced a
|
|
962
|
+
* file that did not parse when written as a bare key.
|
|
963
|
+
*
|
|
964
|
+
* Still needed now that keys are camel-cased rather than copied from the
|
|
965
|
+
* column: `toWireKey` splits on separators and joins, which fixes `full name`
|
|
966
|
+
* but cannot fix a name that is not an identifier for some other reason —
|
|
967
|
+
* `2fa_enabled` becomes `2faEnabled`, still leading with a digit, and `order`
|
|
968
|
+
* was never a separator problem at all.
|
|
969
|
+
*/
|
|
970
|
+
function propKey(name) {
|
|
971
|
+
return JS_IDENTIFIER.test(name) ? name : quote(name);
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Text safe to put after `//`.
|
|
975
|
+
*
|
|
976
|
+
* A table comment or a classification reason is free text out of the database.
|
|
977
|
+
* A newline in one ended the comment and let the rest of the value continue as
|
|
978
|
+
* code.
|
|
979
|
+
*/
|
|
980
|
+
function commentText(value) {
|
|
981
|
+
return value.replace(/\s*[\r\n]+\s*/g, " ");
|
|
982
|
+
}
|
|
983
|
+
/** A property-key array, one key per line, indented for the admin block. */
|
|
984
|
+
function formatKeyList(keys) {
|
|
985
|
+
if (keys.length === 0) return "[]";
|
|
986
|
+
return `[\n${keys.map((k) => ` ${quote(k)}`).join(",\n")}\n ]`;
|
|
987
|
+
}
|
|
988
|
+
/**
|
|
989
|
+
* Generate the full TypeScript file content for a single collection.
|
|
990
|
+
* Pure function — no I/O.
|
|
991
|
+
*/
|
|
992
|
+
function generateCollectionFile(tableName, meta, allFks, joinTables, tablesMap, enumMap, sampleData, context = {}) {
|
|
993
|
+
const collectionName = humanize(tableName);
|
|
994
|
+
const singular = singularize(collectionName);
|
|
995
|
+
const icon = getIconForTable(tableName);
|
|
996
|
+
const classification = context.classifications?.get(tableName);
|
|
997
|
+
const checkFacts = context.checkFacts ?? /* @__PURE__ */ new Map();
|
|
998
|
+
const tableChecks = checkFacts.get(tableName);
|
|
999
|
+
const columnComments = /* @__PURE__ */ new Map();
|
|
1000
|
+
let tableComment;
|
|
1001
|
+
for (const comment of context.metadata?.comments ?? []) {
|
|
1002
|
+
if (comment.table_name !== tableName) continue;
|
|
1003
|
+
if (comment.column_name === null) tableComment = comment.comment;
|
|
1004
|
+
else columnComments.set(comment.column_name, comment.comment);
|
|
1005
|
+
}
|
|
1006
|
+
const singleColumnUniques = new Set((context.metadata?.uniques ?? []).filter((u) => u.table_name === tableName && u.column_names.length === 1).map((u) => u.column_names[0]));
|
|
1007
|
+
const builder = context.builder ?? "admin-types";
|
|
1008
|
+
/** Whether the target project has an `admin` field to write into at all. */
|
|
1009
|
+
const emitAdmin = builder === "admin-types";
|
|
1010
|
+
const BUILDER_IMPORT = {
|
|
1011
|
+
"admin-types": `import { defineCollection } from ${quote(ADMIN_TYPES_PACKAGE)};`,
|
|
1012
|
+
common: `import { defineCollection } from ${quote(COMMON_PACKAGE)};`,
|
|
1013
|
+
annotation: `import { PostgresCollectionConfig } from ${quote(TYPES_PACKAGE)};`
|
|
1014
|
+
};
|
|
1015
|
+
const imports = /* @__PURE__ */ new Set([BUILDER_IMPORT[builder]]);
|
|
1016
|
+
/**
|
|
1017
|
+
* Imports the collection a relation points at — unless it is this one.
|
|
1018
|
+
*
|
|
1019
|
+
* A self-referencing key (`employees.reports_to -> employees`, which both
|
|
1020
|
+
* northwind and chinook have) otherwise made the file import its own default
|
|
1021
|
+
* export under the name it declares three lines later: `TS2440: Import
|
|
1022
|
+
* declaration conflicts with local declaration`. The relation target is a
|
|
1023
|
+
* thunk, so referring to the local const directly is fine — it is only
|
|
1024
|
+
* dereferenced after the module has finished evaluating.
|
|
1025
|
+
*/
|
|
1026
|
+
const importCollection = (otherTable) => {
|
|
1027
|
+
const varName = toCollectionVarName(otherTable);
|
|
1028
|
+
if (otherTable !== tableName) imports.add(`import ${varName} from ${quote(`./${otherTable}`)};`);
|
|
1029
|
+
return varName;
|
|
1030
|
+
};
|
|
1031
|
+
/**
|
|
1032
|
+
* A relation's `target` thunk, with its return type spelled out.
|
|
1033
|
+
*
|
|
1034
|
+
* The annotation is what makes `defineCollection` survive a relational schema.
|
|
1035
|
+
* Without an explicit type on the const, the collection's type is *inferred*,
|
|
1036
|
+
* and a relation cycle — `posts` belongs to `authors`, `authors` has many
|
|
1037
|
+
* `posts`; or `employees.reports_to -> employees`, which northwind, chinook and
|
|
1038
|
+
* musicbrainz all have — makes that inference circular: `TS7022: implicitly has
|
|
1039
|
+
* type 'any' because it is referenced directly or indirectly in its own
|
|
1040
|
+
* initializer`, plus `TS7023` on the thunk and `TS2303` on the import alias.
|
|
1041
|
+
* Naming the return type lets the checker type the thunk without resolving the
|
|
1042
|
+
* collection it points at, which breaks the cycle. Nothing else is given up:
|
|
1043
|
+
* the inference that matters runs over `properties`, not over `relations`.
|
|
1044
|
+
*
|
|
1045
|
+
* The annotated flavour has no cycle to break — its const is already typed — so
|
|
1046
|
+
* it keeps the plainer thunk it has always emitted.
|
|
1047
|
+
*/
|
|
1048
|
+
const relationTarget = (targetVarName) => {
|
|
1049
|
+
if (builder === "annotation") return `() => ${targetVarName}`;
|
|
1050
|
+
imports.add(`import type { AnyCollectionConfig } from ${quote(TYPES_PACKAGE)};`);
|
|
1051
|
+
return `(): AnyCollectionConfig => ${targetVarName}`;
|
|
1052
|
+
};
|
|
1053
|
+
let propsOutput = "";
|
|
1054
|
+
let relationsOutput = "";
|
|
1055
|
+
const orderEntries = [];
|
|
1056
|
+
const propertyBlocks = /* @__PURE__ */ new Map();
|
|
1057
|
+
/**
|
|
1058
|
+
* Column → the property key it was generated under.
|
|
1059
|
+
*
|
|
1060
|
+
* Needed because the structural helpers below (`deriveTitleProperty`,
|
|
1061
|
+
* `deriveKanbanProperty`, `deriveSort`) answer in *columns* — they read
|
|
1062
|
+
* `pg_attribute` — while `display.title`, `kanban.columnProperty` and
|
|
1063
|
+
* `sort` name **properties**. The two used to be the same string, so
|
|
1064
|
+
* nothing carried the translation; now `full_name` is generated as
|
|
1065
|
+
* `fullName` and a title pointing at `full_name` points at nothing.
|
|
1066
|
+
*/
|
|
1067
|
+
const keyByColumn = /* @__PURE__ */ new Map();
|
|
1068
|
+
/** Properties the list view will not render, so `listProperties` skips them. */
|
|
1069
|
+
const hiddenFromCollection = /* @__PURE__ */ new Set();
|
|
1070
|
+
let columnIndex = 0;
|
|
1071
|
+
const isCompositePk = meta.pks.length > 1;
|
|
1072
|
+
for (const col of meta.columns) {
|
|
1073
|
+
if (meta.fks.some((fk) => fk.column_name === col.column_name) && !meta.pks.includes(col.column_name)) continue;
|
|
1074
|
+
const currentIndex = columnIndex++;
|
|
1075
|
+
const propertyKey = firstFreeKey([toWireKey(col.column_name), col.column_name], propertyBlocks);
|
|
1076
|
+
keyByColumn.set(col.column_name, propertyKey);
|
|
1077
|
+
const colEnumValues = enumMap.get(col.udt_name);
|
|
1078
|
+
const isEnumColumn = col.data_type === "USER-DEFINED" && colEnumValues !== void 0;
|
|
1079
|
+
const isVectorColumn = col.udt_name === "vector";
|
|
1080
|
+
const propType = isEnumColumn ? "string" : isVectorColumn ? "vector" : mapPgType(col.data_type);
|
|
1081
|
+
let extra = "";
|
|
1082
|
+
const colNameLower = col.column_name.toLowerCase();
|
|
1083
|
+
let finalPropType = propType;
|
|
1084
|
+
let inferenceExtra = "";
|
|
1085
|
+
if (!isEnumColumn && sampleData && sampleData.length > 0) {
|
|
1086
|
+
const values = sampleData.map((r) => r[col.column_name]);
|
|
1087
|
+
const inferred = inferPropertyFromData(col.column_name, col.data_type, propType, values, meta.pks.includes(col.column_name), emitAdmin);
|
|
1088
|
+
if (inferred.propType) finalPropType = inferred.propType;
|
|
1089
|
+
if (inferred.extra) inferenceExtra = inferred.extra;
|
|
1090
|
+
}
|
|
1091
|
+
const columnChecks = tableChecks?.get(col.column_name);
|
|
1092
|
+
if (isEnumColumn && colEnumValues) {
|
|
1093
|
+
const enumEntries = colEnumValues.map((v) => `{ id: ${quote(v)}, label: ${quote(humanize(v))} }`).join(", ");
|
|
1094
|
+
extra += `\n enum: [${enumEntries}],`;
|
|
1095
|
+
} else if (columnChecks?.enumValues && !inferenceExtra.includes("enum:") && propType === "string") {
|
|
1096
|
+
const enumEntries = columnChecks.enumValues.map((v) => `{ id: ${quote(v)}, label: ${quote(humanize(v))} }`).join(", ");
|
|
1097
|
+
extra += `\n enum: [${enumEntries}],`;
|
|
1098
|
+
}
|
|
1099
|
+
if (finalPropType === "date") {
|
|
1100
|
+
if (colNameLower === "created_at" || colNameLower === "createdat") {
|
|
1101
|
+
extra += "\n autoValue: \"on_create\",";
|
|
1102
|
+
extra = withAdminOptions(extra, ["readOnly: true", "hideFromCollection: true"], emitAdmin);
|
|
1103
|
+
hiddenFromCollection.add(propertyKey);
|
|
1104
|
+
} else if (colNameLower === "updated_at" || colNameLower === "updatedat") {
|
|
1105
|
+
extra += "\n autoValue: \"on_update\",";
|
|
1106
|
+
extra = withAdminOptions(extra, ["readOnly: true", "hideFromCollection: true"], emitAdmin);
|
|
1107
|
+
hiddenFromCollection.add(propertyKey);
|
|
1108
|
+
} else if (col.column_default && (col.column_default.includes("now()") || col.column_default.includes("CURRENT_TIMESTAMP"))) {
|
|
1109
|
+
extra += "\n autoValue: \"on_create\",";
|
|
1110
|
+
extra = withAdminOptions(extra, ["readOnly: true"], emitAdmin);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
if (finalPropType === "array" && !inferenceExtra.includes("of: {")) {
|
|
1114
|
+
let innerType = "string";
|
|
1115
|
+
let colType = "";
|
|
1116
|
+
if (col.udt_name.startsWith("_")) {
|
|
1117
|
+
innerType = mapPgType(col.udt_name.substring(1));
|
|
1118
|
+
if (innerType === "string") colType = "text[]";
|
|
1119
|
+
else if (innerType === "number") colType = col.udt_name === "_numeric" ? "numeric[]" : "integer[]";
|
|
1120
|
+
else if (innerType === "boolean") colType = "boolean[]";
|
|
1121
|
+
}
|
|
1122
|
+
if (colType) extra += `\n columnType: ${quote(colType)},`;
|
|
1123
|
+
extra += `\n of: { name: ${quote(`${humanize(col.column_name)} Item`)}, type: ${quote(innerType)} },`;
|
|
1124
|
+
} else if (finalPropType === "map" && !inferenceExtra.includes("keyValue: true") && !inferenceExtra.includes("properties: {")) extra += "\n keyValue: true,";
|
|
1125
|
+
if (finalPropType === "string" && !isEnumColumn && !inferenceExtra) {
|
|
1126
|
+
const isUrl = colNameLower.endsWith("_url") || colNameLower.endsWith("_uri") || colNameLower.endsWith("_link");
|
|
1127
|
+
if (colNameLower.includes("image") || colNameLower.includes("avatar") || colNameLower.includes("photo") || colNameLower.includes("logo") || colNameLower.includes("cover")) extra += `\n storage: {\n storagePath: ${quote(`${tableName}/${col.column_name}`)}\n },`;
|
|
1128
|
+
else if (isUrl) extra += "\n url: true,";
|
|
1129
|
+
else if (colNameLower === "description" || colNameLower === "summary" || colNameLower === "excerpt") extra = withAdminOptions(extra, ["multiline: true"], emitAdmin);
|
|
1130
|
+
else if (colNameLower === "content" || colNameLower === "body") extra = withAdminOptions(extra, ["multiline: true", "markdown: true"], emitAdmin);
|
|
1131
|
+
else if (col.data_type === "text") extra = withAdminOptions(extra, ["multiline: true"], emitAdmin);
|
|
1132
|
+
}
|
|
1133
|
+
if (inferenceExtra) {
|
|
1134
|
+
extra += inferenceExtra;
|
|
1135
|
+
if (!extra.endsWith(",")) extra += ",";
|
|
1136
|
+
}
|
|
1137
|
+
const declaredValidation = [];
|
|
1138
|
+
if (finalPropType === "string" && typeof col.character_maximum_length === "number" && col.character_maximum_length > 0 && !hasGeneratedKey(extra, "max")) declaredValidation.push(`max: ${col.character_maximum_length}`);
|
|
1139
|
+
if (columnChecks) {
|
|
1140
|
+
if (finalPropType === "number") {
|
|
1141
|
+
if (columnChecks.min !== void 0 && !hasGeneratedKey(extra, "min")) declaredValidation.push(`min: ${columnChecks.min}`);
|
|
1142
|
+
if (columnChecks.max !== void 0 && !hasGeneratedKey(extra, "max")) declaredValidation.push(`max: ${columnChecks.max}`);
|
|
1143
|
+
if (columnChecks.moreThan !== void 0) declaredValidation.push(`moreThan: ${columnChecks.moreThan}`);
|
|
1144
|
+
if (columnChecks.lessThan !== void 0) declaredValidation.push(`lessThan: ${columnChecks.lessThan}`);
|
|
1145
|
+
}
|
|
1146
|
+
if (finalPropType === "string") {
|
|
1147
|
+
if (columnChecks.lengthMin !== void 0 && !hasGeneratedKey(extra, "min")) declaredValidation.push(`min: ${columnChecks.lengthMin}`);
|
|
1148
|
+
if (columnChecks.lengthMax !== void 0 && !declaredValidation.some((v) => v.startsWith("max:")) && !hasGeneratedKey(extra, "max")) declaredValidation.push(`max: ${columnChecks.lengthMax}`);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
if (singleColumnUniques.has(col.column_name) && !meta.pks.includes(col.column_name)) declaredValidation.push("unique: true");
|
|
1152
|
+
extra = withValidation(extra, declaredValidation);
|
|
1153
|
+
if (isReadOnlyColumn(col) && !hasGeneratedKey(extra, "readOnly")) {
|
|
1154
|
+
const options = ["readOnly: true"];
|
|
1155
|
+
if (isDerivedIndexColumn(col) && !hasGeneratedKey(extra, "hideFromCollection")) {
|
|
1156
|
+
options.push("hideFromCollection: true");
|
|
1157
|
+
hiddenFromCollection.add(propertyKey);
|
|
1158
|
+
}
|
|
1159
|
+
extra = withAdminOptions(extra, options, emitAdmin);
|
|
1160
|
+
}
|
|
1161
|
+
const columnComment = columnComments.get(col.column_name);
|
|
1162
|
+
if (columnComment) extra = `\n description: ${quote(columnComment)},${extra}`;
|
|
1163
|
+
if (meta.pks.includes(col.column_name)) {
|
|
1164
|
+
if (isCompositePk) extra += `\n // Part of composite primary key (${commentText(meta.pks.join(", "))})`;
|
|
1165
|
+
else if (finalPropType === "number" && !inferenceExtra.includes("isId:")) extra += "\n isId: \"increment\",";
|
|
1166
|
+
else if (col.data_type.toLowerCase() === "uuid" && !inferenceExtra.includes("isId:")) extra += "\n isId: \"uuid\",";
|
|
1167
|
+
else if (!inferenceExtra.includes("isId:")) extra += "\n isId: \"uuid\", // Verify if this is a UUID or CUID";
|
|
1168
|
+
}
|
|
1169
|
+
if (finalPropType === "vector") {
|
|
1170
|
+
const dims = col.atttypmod && col.atttypmod > 0 ? col.atttypmod : 1536;
|
|
1171
|
+
extra += `\n dimensions: ${dims},`;
|
|
1172
|
+
}
|
|
1173
|
+
if (col.is_nullable === "NO" && !meta.pks.includes(col.column_name) && !col.column_default && !isReadOnlyColumn(col)) if (extra.includes("validation: {")) extra = extra.replace("validation: {", "validation: {\n required: true,");
|
|
1174
|
+
else extra += "\n validation: {\n required: true\n },";
|
|
1175
|
+
const humanName = humanize(col.column_name);
|
|
1176
|
+
orderEntries.push({
|
|
1177
|
+
key: propertyKey,
|
|
1178
|
+
ctx: {
|
|
1179
|
+
propType: finalPropType,
|
|
1180
|
+
isPk: meta.pks.includes(col.column_name),
|
|
1181
|
+
isEnum: isEnumColumn,
|
|
1182
|
+
isStorage: extra.includes("storage: {") || inferenceExtra.includes("storage: {"),
|
|
1183
|
+
pgDataType: col.data_type,
|
|
1184
|
+
originalIndex: currentIndex
|
|
1185
|
+
}
|
|
1186
|
+
});
|
|
1187
|
+
propertyBlocks.set(propertyKey, `
|
|
1188
|
+
${propKey(propertyKey)}: {
|
|
1189
|
+
name: ${quote(humanName)},
|
|
1190
|
+
columnName: ${quote(col.column_name)},
|
|
1191
|
+
type: ${quote(finalPropType)},${extra}
|
|
1192
|
+
},`);
|
|
1193
|
+
}
|
|
1194
|
+
for (const fk of meta.fks) {
|
|
1195
|
+
const targetTableName = fk.foreign_table_name;
|
|
1196
|
+
if (!joinTables.has(targetTableName)) {
|
|
1197
|
+
const stripped = toWireKey(fk.column_name.replace(/_id$/, ""));
|
|
1198
|
+
const relName = firstFreeKey([
|
|
1199
|
+
stripped,
|
|
1200
|
+
toWireKey(targetTableName),
|
|
1201
|
+
`${stripped}Relation`
|
|
1202
|
+
], propertyBlocks);
|
|
1203
|
+
orderEntries.push({
|
|
1204
|
+
key: relName,
|
|
1205
|
+
ctx: {
|
|
1206
|
+
propType: "relation",
|
|
1207
|
+
isPk: false,
|
|
1208
|
+
isEnum: false,
|
|
1209
|
+
isStorage: false,
|
|
1210
|
+
pgDataType: "",
|
|
1211
|
+
originalIndex: columnIndex++
|
|
1212
|
+
}
|
|
1213
|
+
});
|
|
1214
|
+
const targetCollectionCamel = importCollection(targetTableName);
|
|
1215
|
+
const relHumanName = humanize(relName);
|
|
1216
|
+
propertyBlocks.set(relName, `
|
|
1217
|
+
${propKey(relName)}: {
|
|
1218
|
+
name: ${quote(relHumanName)},
|
|
1219
|
+
type: "relation",
|
|
1220
|
+
// mapped from foreign key: ${commentText(fk.column_name)} -> ${commentText(targetTableName)}(${commentText(fk.foreign_column_name)})
|
|
1221
|
+
relation: {
|
|
1222
|
+
kind: "belongsTo",
|
|
1223
|
+
target: ${relationTarget(targetCollectionCamel)},
|
|
1224
|
+
localKey: ${quote(fk.column_name)}
|
|
1225
|
+
}
|
|
1226
|
+
},`);
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
const inverseFks = allFks.filter((fk) => fk.foreign_table_name === tableName && !joinTables.has(fk.table_name));
|
|
1230
|
+
for (const fk of inverseFks) {
|
|
1231
|
+
const sourceTableName = fk.table_name;
|
|
1232
|
+
const targetCollectionCamel = importCollection(sourceTableName);
|
|
1233
|
+
relationsOutput += `
|
|
1234
|
+
{
|
|
1235
|
+
kind: "hasMany",
|
|
1236
|
+
relationName: ${quote(sourceTableName)},
|
|
1237
|
+
target: ${relationTarget(targetCollectionCamel)},
|
|
1238
|
+
// the ${commentText(sourceTableName)}.${commentText(fk.column_name)} FK points back here
|
|
1239
|
+
foreignKeyOnTarget: ${quote(fk.column_name)}
|
|
1240
|
+
},`;
|
|
1241
|
+
}
|
|
1242
|
+
const relatedJoinTables = Array.from(joinTables).filter((jt) => {
|
|
1243
|
+
const jtMeta = tablesMap.get(jt);
|
|
1244
|
+
return jtMeta ? jtMeta.fks.some((fk) => fk.foreign_table_name === tableName) : false;
|
|
1245
|
+
});
|
|
1246
|
+
for (const jt of relatedJoinTables) {
|
|
1247
|
+
const jtMeta = tablesMap.get(jt);
|
|
1248
|
+
if (!jtMeta) continue;
|
|
1249
|
+
const joinFks = jtMeta.fks;
|
|
1250
|
+
const selfRefFks = joinFks.filter((fk) => fk.foreign_table_name === tableName);
|
|
1251
|
+
if (selfRefFks.length === 2) {
|
|
1252
|
+
const thisFk = selfRefFks[0];
|
|
1253
|
+
const otherFk = selfRefFks[1];
|
|
1254
|
+
const relPropName = `${tableName}_via_${otherFk.column_name.replace(/_id$/, "")}`;
|
|
1255
|
+
relationsOutput += `
|
|
1256
|
+
{
|
|
1257
|
+
kind: "manyToMany",
|
|
1258
|
+
relationName: ${quote(relPropName)},
|
|
1259
|
+
target: ${relationTarget(toCollectionVarName(tableName))},
|
|
1260
|
+
through: {
|
|
1261
|
+
table: ${quote(jt)},
|
|
1262
|
+
sourceColumn: ${quote(thisFk.column_name)},
|
|
1263
|
+
targetColumn: ${quote(otherFk.column_name)}
|
|
1264
|
+
}
|
|
1265
|
+
},`;
|
|
1266
|
+
continue;
|
|
1267
|
+
}
|
|
1268
|
+
const otherFk = joinFks.find((fk) => fk.foreign_table_name !== tableName);
|
|
1269
|
+
if (otherFk) {
|
|
1270
|
+
const targetTableName = otherFk.foreign_table_name;
|
|
1271
|
+
const targetCollectionCamel = importCollection(targetTableName);
|
|
1272
|
+
const thisFk = joinFks.find((fk) => fk.foreign_table_name === tableName);
|
|
1273
|
+
const throughCode = thisFk ? `\n through: {\n table: ${quote(jt)},\n sourceColumn: ${quote(thisFk.column_name)},\n targetColumn: ${quote(otherFk.column_name)}\n }` : "";
|
|
1274
|
+
relationsOutput += `
|
|
1275
|
+
{
|
|
1276
|
+
kind: "manyToMany",
|
|
1277
|
+
relationName: ${quote(targetTableName)},
|
|
1278
|
+
target: ${relationTarget(targetCollectionCamel)},${throughCode}
|
|
1279
|
+
},`;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
const relationsBlock = relationsOutput ? `\n relations: [${relationsOutput}\n ],` : "";
|
|
1283
|
+
const sortedPropertiesOrder = sortPropertiesOrder(orderEntries);
|
|
1284
|
+
for (const key of sortedPropertiesOrder) propsOutput += propertyBlocks.get(key) || "";
|
|
1285
|
+
const adminEntries = [`icon: ${quote(icon)}`];
|
|
1286
|
+
if (classification) {
|
|
1287
|
+
const derivedFacts = context.metadata ? buildColumnFacts(meta, context.metadata, enumMap, checkFacts) : void 0;
|
|
1288
|
+
if (classification.role === "owned-child") adminEntries.push("hideFromNavigation: true");
|
|
1289
|
+
else if (classification.role === "lookup") adminEntries.push("group: \"Reference\"");
|
|
1290
|
+
if (derivedFacts) {
|
|
1291
|
+
const asProperty = (column) => keyByColumn.get(column) ?? toWireKey(column);
|
|
1292
|
+
const titleProperty = deriveTitleProperty(derivedFacts);
|
|
1293
|
+
if (titleProperty) adminEntries.push(`display: { title: ${quote(asProperty(titleProperty))} }`);
|
|
1294
|
+
const kanbanProperty = deriveKanbanProperty(derivedFacts);
|
|
1295
|
+
if (kanbanProperty) adminEntries.push(`kanban: {\n columnProperty: ${quote(asProperty(kanbanProperty))}\n }`);
|
|
1296
|
+
const sort = deriveSort(derivedFacts);
|
|
1297
|
+
if (sort) adminEntries.push(`sort: [${quote(asProperty(sort[0]))}, "desc"]`);
|
|
1298
|
+
}
|
|
1299
|
+
const listProperties = deriveListProperties(sortedPropertiesOrder, hiddenFromCollection);
|
|
1300
|
+
if (listProperties) adminEntries.push(`listProperties: ${formatKeyList(listProperties)}`);
|
|
1301
|
+
}
|
|
1302
|
+
adminEntries.push(`propertiesOrder: ${formatKeyList(sortedPropertiesOrder)}`);
|
|
1303
|
+
const adminBlock = emitAdmin ? `\n admin: {\n ${adminEntries.join(",\n ")}\n }` : "";
|
|
1304
|
+
const descriptionBlock = tableComment ? `\n description: ${quote(tableComment)},` : "";
|
|
1305
|
+
const classificationNote = classification && classification.role !== "entity" ? `\n// Introspected as a ${commentText(classification.role)}: ${commentText(classification.reason)}.\n` : "";
|
|
1306
|
+
const collectionVarName = toCollectionVarName(tableName);
|
|
1307
|
+
const [open, close] = builder === "annotation" ? [`const ${collectionVarName}: PostgresCollectionConfig = {`, "};"] : [`const ${collectionVarName} = defineCollection({`, "});"];
|
|
1308
|
+
const importLines = Array.from(imports);
|
|
1309
|
+
return `${[...importLines.filter((line) => !line.includes("from \"./")), ...importLines.filter((line) => line.includes("from \"./"))].join("\n")}
|
|
1310
|
+
${classificationNote}
|
|
1311
|
+
${open}
|
|
1312
|
+
name: ${quote(collectionName)},
|
|
1313
|
+
singularName: ${quote(singular)},
|
|
1314
|
+
slug: ${quote(tableName)},
|
|
1315
|
+
table: ${quote(tableName)},${descriptionBlock}
|
|
1316
|
+
properties: {${propsOutput}
|
|
1317
|
+
},${relationsBlock}${adminBlock}
|
|
1318
|
+
${close}
|
|
1319
|
+
|
|
1320
|
+
export default ${collectionVarName};
|
|
1321
|
+
`;
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Generate the content for an index.ts file that re-exports all collections.
|
|
1325
|
+
*/
|
|
1326
|
+
function generateIndexContent(fileNames) {
|
|
1327
|
+
const sorted = [...fileNames].sort();
|
|
1328
|
+
let imports = "";
|
|
1329
|
+
let arrayElements = "";
|
|
1330
|
+
for (const f of sorted) {
|
|
1331
|
+
const varName = toCollectionVarName(f);
|
|
1332
|
+
imports += `import ${varName} from ${quote(`./${f}`)};\n`;
|
|
1333
|
+
arrayElements += ` ${varName},\n`;
|
|
1334
|
+
}
|
|
1335
|
+
return `${imports}\nexport const collections = [\n${arrayElements}];\n`;
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Merge new exports into existing index.ts content.
|
|
1339
|
+
* Returns the merged content string.
|
|
1340
|
+
*/
|
|
1341
|
+
function mergeIndexContent(existingContent, newFileNames) {
|
|
1342
|
+
const existingImports = new Set([...existingContent.matchAll(/import\s+([a-zA-Z0-9_]+)\s+from\s+"\.\/([^"]+)"/g)].map((m) => m[2]));
|
|
1343
|
+
const sorted = [...newFileNames].sort();
|
|
1344
|
+
let newImports = "";
|
|
1345
|
+
let newElements = "";
|
|
1346
|
+
for (const f of sorted) if (!existingImports.has(f)) {
|
|
1347
|
+
const varName = toCollectionVarName(f);
|
|
1348
|
+
newImports += `import ${varName} from ${quote(`./${f}`)};\n`;
|
|
1349
|
+
newElements += ` ${varName},\n`;
|
|
1350
|
+
}
|
|
1351
|
+
if (!newImports) return existingContent;
|
|
1352
|
+
const importRegex = /import\s+.*?;/g;
|
|
1353
|
+
let lastImportMatch;
|
|
1354
|
+
let match;
|
|
1355
|
+
while ((match = importRegex.exec(existingContent)) !== null) lastImportMatch = match;
|
|
1356
|
+
let contentWithImports = existingContent;
|
|
1357
|
+
if (lastImportMatch) {
|
|
1358
|
+
const pos = lastImportMatch.index + lastImportMatch[0].length;
|
|
1359
|
+
contentWithImports = existingContent.slice(0, pos) + "\n" + newImports.trimEnd() + existingContent.slice(pos);
|
|
1360
|
+
} else contentWithImports = newImports + "\n" + existingContent;
|
|
1361
|
+
return contentWithImports.replace(/export\s+const\s+collections\s*=\s*\[([\s\S]*?)\];/, (fullMatch, arrayContent) => {
|
|
1362
|
+
let mergedArray = arrayContent.trimEnd();
|
|
1363
|
+
if (mergedArray && !mergedArray.endsWith(",")) mergedArray += ",";
|
|
1364
|
+
if (mergedArray) mergedArray += "\n";
|
|
1365
|
+
mergedArray += newElements.trimEnd();
|
|
1366
|
+
return `export const collections = [\n ${mergedArray.trim()}\n];`;
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* Safely extract the host portion of a database URL for logging.
|
|
1371
|
+
*/
|
|
1372
|
+
function safeHostFromUrl(url) {
|
|
1373
|
+
return url.includes("@") ? url.split("@")[1] : "(local connection)";
|
|
1374
|
+
}
|
|
1375
|
+
//#endregion
|
|
1376
|
+
export { generateCollectionFile as a, identifyJoinTables as c, singularize as d, classifyTables as f, humanize as h, buildTablesMap as i, mergeIndexContent as l, mapPgType as m, COMMON_PACKAGE as n, generateIndexContent as o, lookupCandidates as p, buildEnumMap as r, getIconForTable as s, ADMIN_TYPES_PACKAGE as t, safeHostFromUrl as u };
|
|
1377
|
+
|
|
1378
|
+
//# sourceMappingURL=introspect-db-logic-C6LQdTxj.js.map
|