@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.
Files changed (204) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/BranchService-ByLfM_Tt.js +360 -0
  4. package/dist/BranchService-ByLfM_Tt.js.map +1 -0
  5. package/dist/__vite-browser-external-BnuHet1e.js +46 -0
  6. package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
  7. package/dist/arg-Dni7MzLB.js +87 -0
  8. package/dist/arg-Dni7MzLB.js.map +1 -0
  9. package/dist/auth-users-columns-D2LBFrMH.js +116 -0
  10. package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
  11. package/dist/backup-argv.d.ts +19 -0
  12. package/dist/backup-cli-oM9WQVlV.js +408 -0
  13. package/dist/backup-cli-oM9WQVlV.js.map +1 -0
  14. package/dist/backup-service-Br3tqwsy.js +715 -0
  15. package/dist/backup-service-Br3tqwsy.js.map +1 -0
  16. package/dist/branch-argv.d.ts +28 -0
  17. package/dist/branch-prune.d.ts +77 -0
  18. package/dist/cli-collections-path.d.ts +31 -0
  19. package/dist/cli-errors-DxN4yzdH.js +657 -0
  20. package/dist/cli-errors-DxN4yzdH.js.map +1 -0
  21. package/dist/cli-errors.d.ts +123 -0
  22. package/dist/cli-flags.d.ts +101 -0
  23. package/dist/cli-helpers.d.ts +94 -10
  24. package/dist/cli-output-CNdMql-L.js +47 -0
  25. package/dist/cli-output-CNdMql-L.js.map +1 -0
  26. package/dist/cli.js +2020 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/collection-index-BHgT_TKd.js +276 -0
  29. package/dist/collection-index-BHgT_TKd.js.map +1 -0
  30. package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
  31. package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
  32. package/dist/{connection-GOKU3Hu5.js → databasePoolManager-Bj5FbeAs.js} +112 -40
  33. package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
  34. package/dist/databasePoolManager.d.ts +27 -0
  35. package/dist/doctor-D7HWmGzs.js +974 -0
  36. package/dist/doctor-D7HWmGzs.js.map +1 -0
  37. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-2c2NV8zX.js} +7 -27
  38. package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
  39. package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
  40. package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
  41. package/dist/ensure-tables-CIojK-Rx.js +650 -0
  42. package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
  43. package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
  44. package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
  45. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
  46. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
  47. package/dist/{ensure-collection-tables-DgVixhX3.js → generate-postgres-ddl-logic-CtnpIBVv.js} +126 -984
  48. package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
  49. package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
  50. package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
  51. package/dist/index.d.ts +12 -0
  52. package/dist/index.es.js +7423 -1133
  53. package/dist/index.es.js.map +1 -1
  54. package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
  55. package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
  56. package/dist/policy-drift-kNJ6X2Uj.js +313 -0
  57. package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
  58. package/dist/{backup-service-DCk7KhhL.js → rls-bootstrap-sql-DLDnUjtx.js} +737 -2689
  59. package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
  60. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-DeYEjYLt.js} +65 -190
  61. package/dist/rls-enforcement-DeYEjYLt.js.map +1 -0
  62. package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
  63. package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
  64. package/dist/schema/column-type-drift.d.ts +79 -0
  65. package/dist/schema/doctor-cli.js +129 -0
  66. package/dist/schema/doctor-cli.js.map +1 -0
  67. package/dist/schema/doctor.d.ts +42 -0
  68. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  69. package/dist/schema/generate-drizzle-schema.d.ts +1 -1
  70. package/dist/schema/generate-drizzle-schema.js +24 -0
  71. package/dist/schema/generate-drizzle-schema.js.map +1 -0
  72. package/dist/schema/generate-next-step.d.ts +30 -0
  73. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  74. package/dist/schema/generate-postgres-ddl.d.ts +1 -1
  75. package/dist/schema/generate-postgres-ddl.js +138 -0
  76. package/dist/schema/generate-postgres-ddl.js.map +1 -0
  77. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  78. package/dist/schema/introspect-db-queries.d.ts +34 -0
  79. package/dist/schema/introspect-db.js +866 -0
  80. package/dist/schema/introspect-db.js.map +1 -0
  81. package/dist/security/rls-enforcement.d.ts +63 -2
  82. package/dist/services/BranchService.d.ts +86 -1
  83. package/dist/services/collection-helpers.d.ts +1 -1
  84. package/dist/services/realtimeService.d.ts +30 -2
  85. package/dist/services/row-pipeline.d.ts +12 -1
  86. package/dist/source-Br7L7GOI.js +410 -0
  87. package/dist/source-Br7L7GOI.js.map +1 -0
  88. package/dist/utils/pg-error-utils.d.ts +3 -1
  89. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  90. package/dist/vector-index-DkWxm2de.js +309 -0
  91. package/dist/vector-index-DkWxm2de.js.map +1 -0
  92. package/package.json +32 -27
  93. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  94. package/dist/collection-index-BRUg10H5.js +0 -5690
  95. package/dist/collection-index-BRUg10H5.js.map +0 -1
  96. package/dist/connection-GOKU3Hu5.js.map +0 -1
  97. package/dist/ensure-collection-policies-UCqgv_8c.js.map +0 -1
  98. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  99. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  100. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  101. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  102. package/dist/src-DiB5RP2Z.js +0 -541
  103. package/dist/src-DiB5RP2Z.js.map +0 -1
  104. package/dist/websocket-BZ4H5wUz.js +0 -8198
  105. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  106. package/src/PostgresAdapter.ts +0 -91
  107. package/src/PostgresBackendDriver.ts +0 -1857
  108. package/src/PostgresBootstrapper.ts +0 -1157
  109. package/src/auth/ensure-tables.ts +0 -896
  110. package/src/auth/schema-version.ts +0 -260
  111. package/src/auth/services.ts +0 -1610
  112. package/src/backup/backup-cli.ts +0 -520
  113. package/src/backup/backup-cron.ts +0 -212
  114. package/src/backup/backup-logic.ts +0 -93
  115. package/src/backup/backup-service.ts +0 -485
  116. package/src/backup/index.ts +0 -12
  117. package/src/backup/pg-tools.ts +0 -403
  118. package/src/backup/retention.ts +0 -86
  119. package/src/cli-errors.ts +0 -265
  120. package/src/cli-helpers.ts +0 -569
  121. package/src/cli-output.ts +0 -43
  122. package/src/cli.ts +0 -1339
  123. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  124. package/src/collections/buildRegistry.ts +0 -68
  125. package/src/collections/validate-relations.ts +0 -402
  126. package/src/connection.ts +0 -333
  127. package/src/data-transformer.ts +0 -845
  128. package/src/databasePoolManager.ts +0 -95
  129. package/src/history/HistoryService.ts +0 -254
  130. package/src/history/ensure-history-table.ts +0 -53
  131. package/src/index.ts +0 -19
  132. package/src/interfaces.ts +0 -60
  133. package/src/module-dir.ts +0 -7
  134. package/src/schema/atlas-argv.ts +0 -94
  135. package/src/schema/auth-schema.ts +0 -298
  136. package/src/schema/auth-users-columns.ts +0 -131
  137. package/src/schema/carved-out-migration.ts +0 -404
  138. package/src/schema/classify-change.ts +0 -436
  139. package/src/schema/collection-index.ts +0 -427
  140. package/src/schema/collections-schema-version.ts +0 -103
  141. package/src/schema/destructive-sql.ts +0 -94
  142. package/src/schema/doctor-cli.ts +0 -66
  143. package/src/schema/doctor-policy-checks.ts +0 -105
  144. package/src/schema/doctor.ts +0 -837
  145. package/src/schema/drizzle-ddl.ts +0 -33
  146. package/src/schema/dynamic-tables.test.ts +0 -302
  147. package/src/schema/dynamic-tables.ts +0 -293
  148. package/src/schema/ensure-collection-policies.ts +0 -198
  149. package/src/schema/ensure-collection-tables.test.ts +0 -518
  150. package/src/schema/ensure-collection-tables.ts +0 -1267
  151. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  152. package/src/schema/generate-drizzle-schema.ts +0 -143
  153. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  154. package/src/schema/generate-postgres-ddl.ts +0 -180
  155. package/src/schema/generate-schema-commit.ts +0 -267
  156. package/src/schema/generated-schema-staleness.ts +0 -284
  157. package/src/schema/introspect-db-constraints.ts +0 -385
  158. package/src/schema/introspect-db-inference.ts +0 -255
  159. package/src/schema/introspect-db-logic.ts +0 -1387
  160. package/src/schema/introspect-db-naming.ts +0 -15
  161. package/src/schema/introspect-db-project.ts +0 -78
  162. package/src/schema/introspect-db-queries.ts +0 -326
  163. package/src/schema/introspect-db-structure.ts +0 -670
  164. package/src/schema/introspect-db-types.ts +0 -56
  165. package/src/schema/introspect-db.ts +0 -257
  166. package/src/schema/introspect-runtime.test.ts +0 -260
  167. package/src/schema/introspect-runtime.ts +0 -327
  168. package/src/schema/non-sql-collections.test.ts +0 -131
  169. package/src/schema/rls-bootstrap-sql.ts +0 -288
  170. package/src/schema/search-column.ts +0 -643
  171. package/src/schema/test-schema.ts +0 -11
  172. package/src/schema/vector-index.ts +0 -491
  173. package/src/security/anonymous-grants.test.ts +0 -73
  174. package/src/security/policy-drift.test.ts +0 -435
  175. package/src/security/policy-drift.ts +0 -473
  176. package/src/security/rls-enforcement.ts +0 -505
  177. package/src/services/BranchService.ts +0 -326
  178. package/src/services/FetchService.ts +0 -2115
  179. package/src/services/PersistService.ts +0 -557
  180. package/src/services/RelationService.ts +0 -978
  181. package/src/services/RelationWriteService.ts +0 -653
  182. package/src/services/cdc/CdcListener.ts +0 -103
  183. package/src/services/cdc/junction-tables.ts +0 -91
  184. package/src/services/cdc/trigger-cdc.ts +0 -173
  185. package/src/services/channel-bus/ChannelBus.ts +0 -44
  186. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  187. package/src/services/channel-bus/index.ts +0 -114
  188. package/src/services/channel-history.ts +0 -424
  189. package/src/services/channel-presence.ts +0 -185
  190. package/src/services/collection-helpers.ts +0 -420
  191. package/src/services/dataService.ts +0 -256
  192. package/src/services/index.ts +0 -14
  193. package/src/services/junction-writes.ts +0 -295
  194. package/src/services/nested-path.ts +0 -145
  195. package/src/services/pg-notify-listener.ts +0 -151
  196. package/src/services/realtimeService.ts +0 -2545
  197. package/src/services/row-pipeline.ts +0 -240
  198. package/src/services/write-denial.ts +0 -55
  199. package/src/types.ts +0 -4
  200. package/src/utils/connection-string.ts +0 -58
  201. package/src/utils/drizzle-conditions.ts +0 -2743
  202. package/src/utils/pg-array-null-patch.ts +0 -42
  203. package/src/utils/pg-error-utils.ts +0 -391
  204. package/src/websocket.ts +0 -855
@@ -1,1387 +0,0 @@
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 { firstFreeKey, toWireKey } from "@rebasepro/utils";
10
- import { inferPropertyFromData } from "./introspect-db-inference";
11
- import { humanize } from "./introspect-db-naming";
12
- import { mapPgType } from "./introspect-db-types";
13
- import type { CheckFactsByTable } from "./introspect-db-constraints";
14
- import type { TableClassification } from "./introspect-db-structure";
15
- import {
16
- buildColumnFacts,
17
- deriveKanbanProperty,
18
- deriveListProperties,
19
- deriveSort,
20
- deriveTitleProperty,
21
- isDerivedIndexColumn,
22
- isReadOnlyColumn
23
- } from "./introspect-db-structure";
24
-
25
- // ── Typed interfaces for SQL query results ────────────────────────────
26
-
27
- export interface TableRow {
28
- table_name: string;
29
- /** True for the parent of a partitioned table (`relkind = 'p'`). */
30
- is_partitioned?: boolean;
31
- }
32
-
33
- export interface TableColumn {
34
- table_name: string;
35
- column_name: string;
36
- data_type: string;
37
- udt_name: string;
38
- is_nullable: string;
39
- column_default: string | null;
40
- atttypmod: number | null;
41
- /** 1-based position in the table, as declared. */
42
- ordinal_position?: number;
43
- /** `"ALWAYS"` for a generated column, `"NEVER"` otherwise. */
44
- is_generated?: string;
45
- /** `"YES"` for an identity column. */
46
- is_identity?: string;
47
- /** `"ALWAYS"` or `"BY DEFAULT"` on an identity column. */
48
- identity_generation?: string | null;
49
- /** The declared `varchar(n)` / `char(n)` bound, if any. */
50
- character_maximum_length?: number | null;
51
- numeric_precision?: number | null;
52
- numeric_scale?: number | null;
53
- }
54
-
55
- export interface EnumValue {
56
- enum_name: string;
57
- enum_value: string;
58
- sort_order: number;
59
- }
60
-
61
- export interface PrimaryKeyRow {
62
- table_name: string;
63
- column_name: string;
64
- }
65
-
66
- export interface ForeignKeyRow {
67
- table_name: string;
68
- column_name: string;
69
- foreign_table_name: string;
70
- foreign_column_name: string;
71
- /** Name of the FK constraint — the only way to tell composite keys apart. */
72
- constraint_name?: string;
73
- /** 1-based position of this column within its constraint. */
74
- ordinal?: number;
75
- /** `"CASCADE"`, `"RESTRICT"`, `"SET NULL"`, `"SET DEFAULT"`, `"NO ACTION"`. */
76
- delete_rule?: string;
77
- }
78
-
79
- /** A unique constraint or unique index, as an ordered column list. */
80
- export interface UniqueConstraintRow {
81
- table_name: string;
82
- constraint_name: string;
83
- column_names: string[];
84
- }
85
-
86
- /** A CHECK constraint, as `pg_get_constraintdef` renders it. */
87
- export interface CheckConstraintRow {
88
- table_name: string;
89
- constraint_name: string;
90
- definition: string;
91
- }
92
-
93
- /** A `COMMENT ON TABLE` (null `column_name`) or `COMMENT ON COLUMN`. */
94
- export interface CommentRow {
95
- table_name: string;
96
- column_name: string | null;
97
- comment: string;
98
- }
99
-
100
- /**
101
- * Everything one introspection run reads from the database.
102
- *
103
- * Passed around as one value so a new signal means a new field here rather than
104
- * a new parameter on every function between the query and the generator — the
105
- * shape `generateCollectionFile` had grown to seven positional arguments by.
106
- */
107
- export interface SchemaMetadata {
108
- schema: string;
109
- tables: TableRow[];
110
- columns: TableColumn[];
111
- enumValues: EnumValue[];
112
- pks: PrimaryKeyRow[];
113
- fks: ForeignKeyRow[];
114
- uniques: UniqueConstraintRow[];
115
- checks: CheckConstraintRow[];
116
- comments: CommentRow[];
117
- /**
118
- * Row counts for the tables that needed one, capped — see `countRowsUpTo`.
119
- * Absent for every table introspection never had a reason to count.
120
- */
121
- rowCounts: Record<string, number>;
122
- }
123
-
124
- export interface TableMeta {
125
- name: string;
126
- columns: TableColumn[];
127
- pks: string[];
128
- fks: ForeignKeyRow[];
129
- }
130
-
131
- // ── Irregular plurals that naive rules can't handle ───────────────────
132
-
133
- const IRREGULAR_SINGULARS: Record<string, string> = {
134
- people: "person",
135
- children: "child",
136
- men: "man",
137
- women: "woman",
138
- mice: "mouse",
139
- geese: "goose",
140
- teeth: "tooth",
141
- feet: "foot",
142
- data: "datum",
143
- media: "medium",
144
- criteria: "criterion",
145
- phenomena: "phenomenon"
146
- };
147
-
148
- /**
149
- * Plurals in "-ves" whose singular really ends in f/fe, and which of the two.
150
- *
151
- * A blanket "-ves" -> "-f" rule gets `knives` -> `knif`, and mangles every
152
- * ordinary "-ive" noun that happens to be plural along with it: `archives` ->
153
- * `archif`, `objectives` -> `objectif`. The set of English words that genuinely
154
- * swap f/fe for ves is small and closed, so it is listed rather than guessed —
155
- * anything else ending in "ves" drops the trailing 's' like any other plural.
156
- *
157
- * Matched on the whole word, not on the suffix: `olives` ends in `lives`.
158
- */
159
- const VES_SINGULAR_ENDINGS: Record<string, "f" | "fe"> = {
160
- calves: "f", dwarves: "f", elves: "f", halves: "f", hooves: "f",
161
- leaves: "f", loaves: "f", scarves: "f", selves: "f", sheaves: "f",
162
- shelves: "f", thieves: "f", wharves: "f", wolves: "f",
163
- knives: "fe", lives: "fe", wives: "fe"
164
- };
165
-
166
- /** Words ending in 's' that are already singular. */
167
- const UNCOUNTABLE = new Set([
168
- "status", "campus", "virus", "bus", "plus", "census",
169
- "diagnosis", "analysis", "basis", "crisis", "thesis",
170
- "synopsis", "parenthesis", "hypothesis", "emphasis",
171
- "news", "series", "species", "means", "athletics",
172
- "economics", "electronics", "mathematics", "physics",
173
- "politics", "statistics"
174
- ]);
175
-
176
- export function singularize(word: string): string {
177
- const lower = word.toLowerCase();
178
-
179
- // Check irregular forms
180
- if (IRREGULAR_SINGULARS[lower]) {
181
- // Preserve the original casing of the first character
182
- const singular = IRREGULAR_SINGULARS[lower];
183
- return word[0] === word[0].toUpperCase()
184
- ? singular.charAt(0).toUpperCase() + singular.slice(1)
185
- : singular;
186
- }
187
-
188
- // Check uncountable
189
- if (UNCOUNTABLE.has(lower)) return word;
190
-
191
- // Latin/Greek -es endings (diagnosis -> diagnosis is uncountable, but "addresses" -> "address")
192
- if (lower.endsWith("ices") && lower.length > 5) {
193
- // e.g. "indices" -> "index", "vertices" -> "vertex"
194
- return word.slice(0, -4) + "ex";
195
- }
196
- if (lower.endsWith("ies") && lower.length > 3) {
197
- return word.slice(0, -3) + "y";
198
- }
199
- if (VES_SINGULAR_ENDINGS[lower]) {
200
- // e.g. "wolves" -> "wolf", "knives" -> "knife"
201
- return word.slice(0, -3) + VES_SINGULAR_ENDINGS[lower];
202
- }
203
- if (lower.endsWith("ches") || lower.endsWith("shes") || lower.endsWith("sses") || lower.endsWith("xes") || lower.endsWith("zes")) {
204
- return word.slice(0, -2);
205
- }
206
- if (lower.endsWith("ses") && !lower.endsWith("sses")) {
207
- // e.g. "responses" -> "response", "databases" -> "database"
208
- return word.slice(0, -1);
209
- }
210
- if (lower.endsWith("s") && !lower.endsWith("ss") && !lower.endsWith("us") && !lower.endsWith("is")) {
211
- return word.slice(0, -1);
212
- }
213
-
214
- return word;
215
- }
216
-
217
- /**
218
- * Convert a snake_case table name to a camelCase + "Collection" variable name.
219
- * e.g. "company_token" -> "companyTokenCollection"
220
- */
221
- export function toCollectionVarName(tableName: string): string {
222
- const camel = tableName.replace(/_([a-z])/g, (_g, letter: string) => letter.toUpperCase()) + "Collection";
223
- // Only reshapes names that are not identifiers already, so every table that
224
- // generated a working file keeps the exact variable name it had. A table
225
- // called `2024 archive` used to emit `const 2024 archiveCollection`, which
226
- // is three syntax errors rather than a declaration.
227
- if (JS_IDENTIFIER.test(camel)) return camel;
228
- const sanitized = camel.replace(/[^A-Za-z0-9_$]/g, "_");
229
- return /^[0-9]/.test(sanitized) ? `_${sanitized}` : sanitized;
230
- }
231
-
232
- export function getIconForTable(tableName: string): string {
233
- const table = tableName.toLowerCase();
234
- if (table.includes("user") || table.includes("account") || table.includes("member") || table.includes("customer") || table.includes("client") || table.includes("patient")) return "Users";
235
- if (table.includes("post") || table.includes("article") || table.includes("blog") || table.includes("page")) return "FileText";
236
- if (table.includes("product") || table.includes("item")) return "Package";
237
- if (table.includes("order") || table.includes("cart") || table.includes("purchase") || table.includes("invoice")) return "ShoppingCart";
238
- if (table.includes("setting") || table.includes("config")) return "Settings";
239
- if (table.includes("tag") || table.includes("categor")) return "Tag";
240
- if (table.includes("image") || table.includes("photo") || table.includes("media") || table.includes("asset")) return "Image";
241
- if (table.includes("notification") || table.includes("message") || table.includes("email")) return "Mail";
242
- if (table.includes("log") || table.includes("audit") || table.includes("event")) return "Activity";
243
- if (table.includes("subscription") || table.includes("plan") || table.includes("billing")) return "CreditCard";
244
- if (table.includes("comment") || table.includes("review") || table.includes("feedback")) return "MessageCircle";
245
- return "Database";
246
- }
247
-
248
- export { mapPgType };
249
-
250
- // ── Build the enum map from query results ─────────────────────────────
251
-
252
- export function buildEnumMap(enumValues: EnumValue[]): Map<string, string[]> {
253
- const enumMap = new Map<string, string[]>();
254
- for (const ev of enumValues) {
255
- const existing = enumMap.get(ev.enum_name);
256
- if (existing) {
257
- existing.push(ev.enum_value);
258
- } else {
259
- enumMap.set(ev.enum_name, [ev.enum_value]);
260
- }
261
- }
262
- return enumMap;
263
- }
264
-
265
- // ── Build the tables map from raw query results ───────────────────────
266
-
267
- export function buildTablesMap(
268
- tables: TableRow[],
269
- columns: TableColumn[],
270
- pks: PrimaryKeyRow[],
271
- fks: ForeignKeyRow[]
272
- ): Map<string, TableMeta> {
273
- const tablesMap = new Map<string, TableMeta>();
274
- for (const t of tables) {
275
- tablesMap.set(t.table_name, {
276
- name: t.table_name,
277
- columns: columns.filter((c) => c.table_name === t.table_name),
278
- pks: pks.filter((pk) => pk.table_name === t.table_name).map((pk) => pk.column_name),
279
- fks: fks.filter((fk) => fk.table_name === t.table_name)
280
- });
281
- }
282
- return tablesMap;
283
- }
284
-
285
- // ── Identify join tables ──────────────────────────────────────────────
286
-
287
- /**
288
- * Join tables, identified by column name.
289
- *
290
- * Superseded for the CLI by `classifyTables` in `./introspect-db-structure`,
291
- * which asks the database instead: two single-column keys, unique together, no
292
- * payload column, nothing referencing the table. This rule folds away
293
- * `northwind.order_details` — which has the key shape and carries unit price,
294
- * quantity and discount — because it recognises `id`, `created_at` and
295
- * `updated_at` by name and calls everything else a foreign key.
296
- *
297
- * Still used by `./introspect-runtime`, which builds collections in memory from
298
- * a narrower set of catalog queries and has no unique-constraint or row-count
299
- * data to reason with.
300
- */
301
- export function identifyJoinTables(tablesMap: Map<string, TableMeta>): Set<string> {
302
- const joinTables = new Set<string>();
303
- for (const [tableName, meta] of tablesMap.entries()) {
304
- if (meta.fks.length === 2) {
305
- const isLikelyJoinTable = meta.columns.every((c) =>
306
- meta.fks.some((fk) => fk.column_name === c.column_name) ||
307
- c.column_name === "id" ||
308
- c.column_name === "created_at" ||
309
- c.column_name === "updated_at"
310
- );
311
-
312
- if (isLikelyJoinTable) {
313
- joinTables.add(tableName);
314
- }
315
- }
316
- }
317
- return joinTables;
318
- }
319
-
320
- // ── Property ordering heuristics ──────────────────────────────────────
321
-
322
- /**
323
- * Property metadata used to compute display priority.
324
- * Keeps computePropertyPriority free of any TableMeta coupling.
325
- */
326
- export interface PropertyOrderingContext {
327
- /** The resolved Rebase property type (e.g. "string", "number", "date", "relation"). */
328
- propType: string;
329
- /** Whether this column is a primary key. */
330
- isPk: boolean;
331
- /** Whether this column is an enum (USER-DEFINED with matching values). */
332
- isEnum: boolean;
333
- /** Whether this is a storage/file-upload field (detected from column name). */
334
- isStorage: boolean;
335
- /** The PostgreSQL data_type (e.g. "text", "character varying", "jsonb"). */
336
- pgDataType: string;
337
- /** The original column index in PostgreSQL (for stable tiebreaking). */
338
- originalIndex: number;
339
- }
340
-
341
- // — Tier 0: Identity (0–9) ————————————————————————————————————————————
342
- const IDENTITY_EXACT: Record<string, number> = {
343
- id: 0,
344
- uuid: 1,
345
- _id: 2
346
- };
347
-
348
- // — Tier 1: Title / Name — the "display column" (10–19) ———————————————
349
- const TITLE_EXACT: Record<string, number> = {
350
- name: 10,
351
- title: 11,
352
- label: 12,
353
- display_name: 13,
354
- displayname: 13,
355
- headline: 14,
356
- subject: 15,
357
- heading: 16
358
- };
359
-
360
- // — Tier 2: Human identity fields (20–29) —————————————————————————————
361
- const HUMAN_IDENTITY_EXACT: Record<string, number> = {
362
- first_name: 20,
363
- firstname: 20,
364
- last_name: 21,
365
- lastname: 21,
366
- full_name: 22,
367
- fullname: 22,
368
- given_name: 22,
369
- family_name: 23,
370
- middle_name: 24,
371
- username: 25,
372
- user_name: 25,
373
- email: 26,
374
- email_address: 26,
375
- phone: 27,
376
- phone_number: 27,
377
- mobile: 27
378
- };
379
-
380
- // — Tier 3: Core descriptors (30–39) ——————————————————————————————————
381
- const DESCRIPTOR_EXACT: Record<string, number> = {
382
- slug: 30,
383
- code: 31,
384
- sku: 32,
385
- reference: 33,
386
- ref: 33,
387
- type: 34,
388
- kind: 34,
389
- status: 35,
390
- state: 35,
391
- role: 36,
392
- category: 37,
393
- group: 38,
394
- priority: 39,
395
- order: 39,
396
- sort_order: 39,
397
- position: 39
398
- };
399
-
400
- // — Tier 12: System timestamps (120–129) ——————————————————————————————
401
- const SYSTEM_TIMESTAMP_EXACT: Record<string, number> = {
402
- created_at: 120,
403
- createdat: 120,
404
- creation_date: 120,
405
- inserted_at: 121,
406
- updated_at: 122,
407
- updatedat: 122,
408
- modified_at: 122,
409
- last_modified: 122,
410
- deleted_at: 123,
411
- deletedat: 123,
412
- archived_at: 124
413
- };
414
-
415
- // — Pattern-based rules for partial matches ———————————————————————————
416
- const TITLE_PATTERNS = ["name", "title", "label"];
417
- const LONG_TEXT_NAMES = new Set(["description", "summary", "excerpt", "abstract", "overview", "bio", "biography", "about"]);
418
- const RICH_CONTENT_NAMES = new Set(["content", "body", "html", "markup", "text", "article_body", "post_body"]);
419
- const MEDIA_PATTERNS = ["image", "avatar", "photo", "logo", "cover", "thumbnail", "banner", "icon", "picture", "poster"];
420
- const JSON_MAP_NAMES = new Set(["metadata", "meta", "config", "configuration", "settings", "options", "preferences", "data", "payload", "attributes", "extra", "additional_info"]);
421
-
422
- /**
423
- * Compute a numeric priority score for a property.
424
- * Lower scores appear first in the generated `propertiesOrder` array.
425
- *
426
- * The system uses 14 tiers (0–139), with the original column index
427
- * added as a fractional tiebreaker (originalIndex / 10000) to
428
- * guarantee stable ordering within the same tier.
429
- *
430
- * Pure function — no side effects.
431
- */
432
- export function computePropertyPriority(
433
- columnName: string,
434
- ctx: PropertyOrderingContext
435
- ): number {
436
- // Normalize camelCase/PascalCase to snake_case, then lowercase
437
- const col = columnName.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toLowerCase();
438
- const tiebreaker = ctx.originalIndex / 10000;
439
-
440
- // ── Tier 0: Primary key identity fields
441
- if (ctx.isPk) {
442
- const exactScore = IDENTITY_EXACT[col];
443
- return (exactScore ?? 5) + tiebreaker;
444
- }
445
-
446
- // ── Tier 12: System timestamps (check early to prevent false matches)
447
- const systemTs = SYSTEM_TIMESTAMP_EXACT[col];
448
- if (systemTs !== undefined) {
449
- return systemTs + tiebreaker;
450
- }
451
-
452
- // ── Tier 1: Title / Name exact matches
453
- const titleExact = TITLE_EXACT[col];
454
- if (titleExact !== undefined) {
455
- return titleExact + tiebreaker;
456
- }
457
-
458
- // ── Tier 2: Human identity exact matches
459
- const humanExact = HUMAN_IDENTITY_EXACT[col];
460
- if (humanExact !== undefined) {
461
- return humanExact + tiebreaker;
462
- }
463
-
464
- // ── Tier 3: Core descriptor exact matches
465
- const descriptorExact = DESCRIPTOR_EXACT[col];
466
- if (descriptorExact !== undefined) {
467
- return descriptorExact + tiebreaker;
468
- }
469
-
470
- // ── Tier 1b: Title-like partial matches (e.g. "product_name", "page_title")
471
- // Score 17–19 so they rank after exact matches but still in tier 1.
472
- for (const pattern of TITLE_PATTERNS) {
473
- if (col.includes(pattern) && col !== pattern) {
474
- return 17 + tiebreaker;
475
- }
476
- }
477
-
478
- // ── Tier 9: Media / file upload fields (check before general strings)
479
- if (ctx.isStorage) {
480
- return 90 + tiebreaker;
481
- }
482
- for (const pattern of MEDIA_PATTERNS) {
483
- if (col.includes(pattern)) {
484
- return 91 + tiebreaker;
485
- }
486
- }
487
- if (col.endsWith("_url") || col.endsWith("_uri") || col.endsWith("_link")) {
488
- return 92 + tiebreaker;
489
- }
490
-
491
- // ── Tier 7: Long text fields
492
- if (LONG_TEXT_NAMES.has(col)) {
493
- return 70 + tiebreaker;
494
- }
495
-
496
- // ── Tier 8: Rich content fields
497
- if (RICH_CONTENT_NAMES.has(col)) {
498
- return 80 + tiebreaker;
499
- }
500
-
501
- // ── Tier 10: JSON / Map types
502
- if (ctx.propType === "map") {
503
- return JSON_MAP_NAMES.has(col) ? 100 + tiebreaker : 105 + tiebreaker;
504
- }
505
-
506
- // ── Tier 11: Array types
507
- if (ctx.propType === "array") {
508
- return 110 + tiebreaker;
509
- }
510
-
511
- // ── Tier 6: Owning relations
512
- if (ctx.propType === "relation") {
513
- return 60 + tiebreaker;
514
- }
515
-
516
- // ── Tier 4: Short text, enums, booleans — "quick glance" fields
517
- if (ctx.isEnum) {
518
- return 40 + tiebreaker;
519
- }
520
- if (ctx.propType === "boolean") {
521
- return 45 + tiebreaker;
522
- }
523
- if (ctx.propType === "string" && ctx.pgDataType !== "text") {
524
- // Short string (varchar, char, uuid that's not a PK)
525
- return 42 + tiebreaker;
526
- }
527
-
528
- // ── Tier 5: Numbers & user-facing dates
529
- if (ctx.propType === "number") {
530
- return 50 + tiebreaker;
531
- }
532
- if (ctx.propType === "date") {
533
- // A date that isn't a system timestamp (already handled above)
534
- return 55 + tiebreaker;
535
- }
536
-
537
- // ── Tier 7b: text data_type that didn't match long-text names
538
- if (ctx.propType === "string" && ctx.pgDataType === "text") {
539
- return 75 + tiebreaker;
540
- }
541
-
542
- // ── Tier 13: Fallback / unknown
543
- return 130 + tiebreaker;
544
- }
545
-
546
- /**
547
- * Sort a `propertiesOrder` array using the priority heuristic.
548
- * Returns a new sorted array; does not mutate the input.
549
- *
550
- * @param entries - Array of { key, columnName, propType, ... } objects
551
- * carrying the information needed to compute priority.
552
- */
553
- export interface PropertyOrderEntry {
554
- /** The property key in the generated collection (may differ from columnName for relations). */
555
- key: string;
556
- /** The ordering context for this property. */
557
- ctx: PropertyOrderingContext;
558
- }
559
-
560
- export function sortPropertiesOrder(entries: PropertyOrderEntry[]): string[] {
561
- return [...entries]
562
- .sort((a, b) => computePropertyPriority(a.key, a.ctx) - computePropertyPriority(b.key, b.ctx))
563
- .map((e) => e.key);
564
- }
565
-
566
- // ── Generate collection file content ──────────────────────────────────
567
-
568
- export interface GeneratedFile {
569
- tableName: string;
570
- fileName: string;
571
- content: string;
572
- }
573
-
574
- /**
575
- * The structural analysis a run can hand the generator.
576
- *
577
- * Optional in full, and the generator degrades to exactly its previous output
578
- * without it. That is not politeness towards old callers: three existing test
579
- * suites and the `rebase init` scaffold path build a `TableMeta` by hand and
580
- * have no database to read constraints or row counts from, and they must keep
581
- * producing a valid collection.
582
- */
583
- /**
584
- * Which `defineCollection` — if any — the project being generated into can import.
585
- *
586
- * A bare `const x: PostgresCollectionConfig = { … }` annotation widens `properties`
587
- * to `Record<string, …>`, and every key-shaped field in the admin block —
588
- * `titleProperty`, `sort`, `propertiesOrder`, `listProperties`, `fixedFilter` — is
589
- * derived from those keys. Annotated, they accept any string: introspection was
590
- * emitting a `propertiesOrder` array that nothing checked, so renaming a column and
591
- * re-introspecting left a stale key that compiled silently. `defineCollection` is
592
- * the identity function whose `const P` type parameter keeps the keys literal, which
593
- * is what turns that checking on.
594
- *
595
- * There are two of them and they are not interchangeable:
596
- *
597
- * - `admin-types` — `@rebasepro/cms-types`. Its index side-effect-imports
598
- * `augment.ts`, so importing it is also what *declares* the `admin` block. Only a
599
- * project that depends on the package can resolve it.
600
- * - `common` — `@rebasepro/common`. Same key inference, no admin surface, no React
601
- * anywhere in its graph (`scripts/headless-guard` lists it as core). This is the
602
- * headless flavour.
603
- * - `annotation` — neither package is declared, so neither import would resolve and
604
- * the old annotation is the only honest thing to emit. Projects scaffolded before
605
- * `@rebasepro/common` joined the headless config package land here.
606
- *
607
- * The last two emit **no admin block, on the collection or on any property**. That is
608
- * not a downgrade: `@rebasepro/types` declares no `admin` field at all, so the block
609
- * introspection used to emit was a type error in every headless project it was
610
- * written into. See `packages/cms-types/src/augment.ts`.
611
- */
612
- export type CollectionBuilder = "admin-types" | "common" | "annotation";
613
-
614
- /**
615
- * The package specifiers the generated files name, spelled once.
616
- *
617
- * Written as constants rather than inline in the import templates below because
618
- * `scripts/headless-guard/check-types.mjs` scans core sources for `from
619
- * "@rebasepro/cms-types"` and cannot tell a real import from one this module
620
- * *writes*. It is right to be that blunt — the guard's whole value is that it
621
- * cannot be reasoned around — so the string simply never appears in that shape
622
- * here. Inlining them back into the templates re-breaks `check:types-headless`.
623
- */
624
- export const ADMIN_TYPES_PACKAGE = "@rebasepro/cms-types";
625
- export const COMMON_PACKAGE = "@rebasepro/common";
626
- export const TYPES_PACKAGE = "@rebasepro/types";
627
-
628
- export interface GenerationContext {
629
- metadata?: SchemaMetadata;
630
- classifications?: Map<string, TableClassification>;
631
- checkFacts?: CheckFactsByTable;
632
- /**
633
- * Defaults to `admin-types`, which is what the generator has always emitted.
634
- * The CLI never relies on the default — `introspect-db.ts` detects the flavour
635
- * from the target project and passes it. See `detectCollectionBuilder`.
636
- */
637
- builder?: CollectionBuilder;
638
- }
639
-
640
- /** Adds entries to a property's `validation` block, creating it if absent. */
641
- function withValidation(extra: string, entries: string[]): string {
642
- if (entries.length === 0) return extra;
643
- const block = entries.map((e) => ` ${e}`).join(",\n");
644
- if (extra.includes("validation: {")) {
645
- return extra.replace("validation: {", `validation: {\n${block},`);
646
- }
647
- return `${extra}\n validation: {\n${block}\n },`;
648
- }
649
-
650
- /**
651
- * Whether a property's generated text already sets `key:` as an object key.
652
- *
653
- * A bare `extra.includes("min:")` looks like it answers this and does not:
654
- * `admin:` ends in `min:`, so every property with an admin block claimed to
655
- * have a minimum already and silently lost the one the database declared. The
656
- * leading-delimiter requirement is the whole point — a key is preceded by a
657
- * newline, a brace or a comma, never by another identifier character.
658
- */
659
- function hasGeneratedKey(extra: string, key: string): boolean {
660
- return new RegExp(`(^|[\\s{,])${key}\\s*:`).test(extra);
661
- }
662
-
663
- /**
664
- * Adds entries to a property's `admin` block, creating it if absent.
665
- *
666
- * `emitAdmin` is false for the headless flavours, where `BaseProperty` has no
667
- * `admin` field to put them in — see {@link CollectionBuilder}. The options are
668
- * dropped rather than relocated: every one of them (`readOnly`, `multiline`,
669
- * `hideFromCollection`, `urlPreview`) describes a form widget, and there is no
670
- * form.
671
- */
672
- function withAdminOptions(extra: string, entries: string[], emitAdmin = true): string {
673
- if (!emitAdmin) return extra;
674
- if (entries.length === 0) return extra;
675
- const block = entries.map((e) => ` ${e}`).join(",\n");
676
- if (extra.includes("admin: {")) {
677
- return extra.replace("admin: {", `admin: {\n${block},`);
678
- }
679
- return `${extra}\n admin: {\n${block}\n },`;
680
- }
681
-
682
- /** A TypeScript string literal, escaped. */
683
- function quote(value: string): string {
684
- return JSON.stringify(value);
685
- }
686
-
687
- const JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
688
-
689
- /**
690
- * An object key for the generated file: verbatim when the name is a JavaScript
691
- * identifier, quoted otherwise.
692
- *
693
- * Postgres constrains an identifier only by quoting, so `order`, `full name`
694
- * and `2fa_enabled` are all ordinary column names — and all three produced a
695
- * file that did not parse when written as a bare key.
696
- *
697
- * Still needed now that keys are camel-cased rather than copied from the
698
- * column: `toWireKey` splits on separators and joins, which fixes `full name`
699
- * but cannot fix a name that is not an identifier for some other reason —
700
- * `2fa_enabled` becomes `2faEnabled`, still leading with a digit, and `order`
701
- * was never a separator problem at all.
702
- */
703
- function propKey(name: string): string {
704
- return JS_IDENTIFIER.test(name) ? name : quote(name);
705
- }
706
-
707
- /**
708
- * Text safe to put after `//`.
709
- *
710
- * A table comment or a classification reason is free text out of the database.
711
- * A newline in one ended the comment and let the rest of the value continue as
712
- * code.
713
- */
714
- function commentText(value: string): string {
715
- return value.replace(/\s*[\r\n]+\s*/g, " ");
716
- }
717
-
718
- /** A property-key array, one key per line, indented for the admin block. */
719
- function formatKeyList(keys: string[]): string {
720
- if (keys.length === 0) return "[]";
721
- return `[\n${keys.map((k) => ` ${quote(k)}`).join(",\n")}\n ]`;
722
- }
723
-
724
- /**
725
- * Generate the full TypeScript file content for a single collection.
726
- * Pure function — no I/O.
727
- */
728
- export function generateCollectionFile(
729
- tableName: string,
730
- meta: TableMeta,
731
- allFks: ForeignKeyRow[],
732
- joinTables: Set<string>,
733
- tablesMap: Map<string, TableMeta>,
734
- enumMap: Map<string, string[]>,
735
- sampleData?: Record<string, unknown>[],
736
- context: GenerationContext = {}
737
- ): string {
738
- const collectionName = humanize(tableName);
739
- const singular = singularize(collectionName);
740
- const icon = getIconForTable(tableName);
741
-
742
- const classification = context.classifications?.get(tableName);
743
- const checkFacts: CheckFactsByTable = context.checkFacts ?? new Map();
744
- const tableChecks = checkFacts.get(tableName);
745
- const columnComments = new Map<string, string>();
746
- let tableComment: string | undefined;
747
- for (const comment of context.metadata?.comments ?? []) {
748
- if (comment.table_name !== tableName) continue;
749
- if (comment.column_name === null) tableComment = comment.comment;
750
- else columnComments.set(comment.column_name, comment.comment);
751
- }
752
- const singleColumnUniques = new Set(
753
- (context.metadata?.uniques ?? [])
754
- .filter((u) => u.table_name === tableName && u.column_names.length === 1)
755
- .map((u) => u.column_names[0])
756
- );
757
-
758
- const builder: CollectionBuilder = context.builder ?? "admin-types";
759
- /** Whether the target project has an `admin` field to write into at all. */
760
- const emitAdmin = builder === "admin-types";
761
-
762
- const BUILDER_IMPORT: Record<CollectionBuilder, string> = {
763
- "admin-types": `import { defineCollection } from ${quote(ADMIN_TYPES_PACKAGE)};`,
764
- common: `import { defineCollection } from ${quote(COMMON_PACKAGE)};`,
765
- annotation: `import { PostgresCollectionConfig } from ${quote(TYPES_PACKAGE)};`
766
- };
767
- const imports = new Set<string>([BUILDER_IMPORT[builder]]);
768
-
769
- /**
770
- * Imports the collection a relation points at — unless it is this one.
771
- *
772
- * A self-referencing key (`employees.reports_to -> employees`, which both
773
- * northwind and chinook have) otherwise made the file import its own default
774
- * export under the name it declares three lines later: `TS2440: Import
775
- * declaration conflicts with local declaration`. The relation target is a
776
- * thunk, so referring to the local const directly is fine — it is only
777
- * dereferenced after the module has finished evaluating.
778
- */
779
- const importCollection = (otherTable: string): string => {
780
- const varName = toCollectionVarName(otherTable);
781
- if (otherTable !== tableName) imports.add(`import ${varName} from ${quote(`./${otherTable}`)};`);
782
- return varName;
783
- };
784
-
785
- /**
786
- * A relation's `target` thunk, with its return type spelled out.
787
- *
788
- * The annotation is what makes `defineCollection` survive a relational schema.
789
- * Without an explicit type on the const, the collection's type is *inferred*,
790
- * and a relation cycle — `posts` belongs to `authors`, `authors` has many
791
- * `posts`; or `employees.reports_to -> employees`, which northwind, chinook and
792
- * musicbrainz all have — makes that inference circular: `TS7022: implicitly has
793
- * type 'any' because it is referenced directly or indirectly in its own
794
- * initializer`, plus `TS7023` on the thunk and `TS2303` on the import alias.
795
- * Naming the return type lets the checker type the thunk without resolving the
796
- * collection it points at, which breaks the cycle. Nothing else is given up:
797
- * the inference that matters runs over `properties`, not over `relations`.
798
- *
799
- * The annotated flavour has no cycle to break — its const is already typed — so
800
- * it keeps the plainer thunk it has always emitted.
801
- */
802
- const relationTarget = (targetVarName: string): string => {
803
- if (builder === "annotation") return `() => ${targetVarName}`;
804
- imports.add(`import type { AnyCollectionConfig } from ${quote(TYPES_PACKAGE)};`);
805
- return `(): AnyCollectionConfig => ${targetVarName}`;
806
- };
807
-
808
- let propsOutput = "";
809
- let relationsOutput = "";
810
- const orderEntries: PropertyOrderEntry[] = [];
811
- const propertyBlocks = new Map<string, string>();
812
- /**
813
- * Column → the property key it was generated under.
814
- *
815
- * Needed because the structural helpers below (`deriveTitleProperty`,
816
- * `deriveKanbanProperty`, `deriveSort`) answer in *columns* — they read
817
- * `pg_attribute` — while `display.title`, `kanban.columnProperty` and
818
- * `sort` name **properties**. The two used to be the same string, so
819
- * nothing carried the translation; now `full_name` is generated as
820
- * `fullName` and a title pointing at `full_name` points at nothing.
821
- */
822
- const keyByColumn = new Map<string, string>();
823
- /** Properties the list view will not render, so `listProperties` skips them. */
824
- const hiddenFromCollection = new Set<string>();
825
- let columnIndex = 0;
826
-
827
- // Detect composite primary keys
828
- const isCompositePk = meta.pks.length > 1;
829
-
830
- // Map columns
831
- for (const col of meta.columns) {
832
- // Skip foreign keys since we handle them as relations
833
- // Exception: Do not skip if it's part of the primary key!
834
- if (meta.fks.some((fk) => fk.column_name === col.column_name) && !meta.pks.includes(col.column_name)) continue;
835
-
836
- const currentIndex = columnIndex++;
837
-
838
- // The key this column is generated under — its *wire* name, which is
839
- // not its column name. `columnName` below carries the column, so the
840
- // two never have to agree and the API stops carrying `user_id` next to
841
- // `displayName`.
842
- //
843
- // Camel-casing makes collisions possible where none existed: `user_id`
844
- // and `userId` are two columns and one key, which is a duplicate key in
845
- // an object literal — a TypeScript error that stops the whole generated
846
- // collection compiling. Resolved the same way the foreign-key loop
847
- // below resolves its own: first free candidate, then a numbered tail,
848
- // so no column is ever dropped. The raw column name is the second
849
- // candidate, so the loser of a collision still gets a name that means
850
- // something. Deterministic for a given database: the columns arrive in
851
- // ordinal order, so the same schema always yields the same keys.
852
- const propertyKey = firstFreeKey([toWireKey(col.column_name), col.column_name], propertyBlocks);
853
- keyByColumn.set(col.column_name, propertyKey);
854
-
855
- // Check if this column uses a PostgreSQL enum type
856
- const colEnumValues = enumMap.get(col.udt_name);
857
- const isEnumColumn = col.data_type === "USER-DEFINED" && colEnumValues !== undefined;
858
- const isVectorColumn = col.udt_name === "vector";
859
-
860
- const propType = isEnumColumn ? "string" : (isVectorColumn ? "vector" : mapPgType(col.data_type));
861
- let extra = "";
862
-
863
- const colNameLower = col.column_name.toLowerCase();
864
-
865
- // ── Data Inference Engine ────────────────────────────────────────────
866
- let finalPropType = propType;
867
- let inferenceExtra = "";
868
-
869
- if (!isEnumColumn && sampleData && sampleData.length > 0) {
870
- const values = sampleData.map(r => r[col.column_name]);
871
- const inferred = inferPropertyFromData(col.column_name, col.data_type, propType, values, meta.pks.includes(col.column_name), emitAdmin);
872
- if (inferred.propType) finalPropType = inferred.propType;
873
- if (inferred.extra) inferenceExtra = inferred.extra;
874
- }
875
-
876
- const columnChecks = tableChecks?.get(col.column_name);
877
-
878
- // Enum values — generate real enum from the PG enum
879
- if (isEnumColumn && colEnumValues) {
880
- const enumEntries = colEnumValues
881
- .map((v) => `{ id: ${quote(v)}, label: ${quote(humanize(v))} }`)
882
- .join(", ");
883
- extra += `\n enum: [${enumEntries}],`;
884
- } else if (columnChecks?.enumValues && !inferenceExtra.includes("enum:") && propType === "string") {
885
- // `CHECK (col IN (…))` is the other way a schema declares a closed
886
- // set. It is the same statement as a Postgres enum type, made by an
887
- // author who did not want a type — and until now the form offered a
888
- // free-text box for it and let the database reject the write.
889
- const enumEntries = columnChecks.enumValues
890
- .map((v) => `{ id: ${quote(v)}, label: ${quote(humanize(v))} }`)
891
- .join(", ");
892
- extra += `\n enum: [${enumEntries}],`;
893
- }
894
-
895
- // Date auto-value heuristics
896
- if (finalPropType === "date") {
897
- if (colNameLower === "created_at" || colNameLower === "createdat") {
898
- extra += "\n autoValue: \"on_create\",";
899
- extra = withAdminOptions(extra, ["readOnly: true", "hideFromCollection: true"], emitAdmin);
900
- hiddenFromCollection.add(propertyKey);
901
- } else if (colNameLower === "updated_at" || colNameLower === "updatedat") {
902
- extra += "\n autoValue: \"on_update\",";
903
- extra = withAdminOptions(extra, ["readOnly: true", "hideFromCollection: true"], emitAdmin);
904
- hiddenFromCollection.add(propertyKey);
905
- } else if (col.column_default && (col.column_default.includes("now()") || col.column_default.includes("CURRENT_TIMESTAMP"))) {
906
- extra += "\n autoValue: \"on_create\",";
907
- extra = withAdminOptions(extra, ["readOnly: true"], emitAdmin);
908
- }
909
- }
910
-
911
- // Array/Map heuristics (Fallback if not inferred)
912
- if (finalPropType === "array" && !inferenceExtra.includes("of: {")) {
913
- let innerType = "string";
914
- let colType = "";
915
- if (col.udt_name.startsWith("_")) {
916
- const baseType = col.udt_name.substring(1);
917
- innerType = mapPgType(baseType);
918
- if (innerType === "string") colType = "text[]";
919
- else if (innerType === "number") colType = col.udt_name === "_numeric" ? "numeric[]" : "integer[]";
920
- else if (innerType === "boolean") colType = "boolean[]";
921
- }
922
- if (colType) {
923
- extra += `\n columnType: ${quote(colType)},`;
924
- }
925
- extra += `\n of: { name: ${quote(`${humanize(col.column_name)} Item`)}, type: ${quote(innerType)} },`;
926
- } else if (finalPropType === "map" && !inferenceExtra.includes("keyValue: true") && !inferenceExtra.includes("properties: {")) {
927
- extra += "\n keyValue: true,";
928
- }
929
-
930
- // String sub-type heuristics (Fallback if not handled by inference or enum)
931
- if (finalPropType === "string" && !isEnumColumn && !inferenceExtra) {
932
- const isUrl = colNameLower.endsWith("_url") || colNameLower.endsWith("_uri") || colNameLower.endsWith("_link");
933
- const isMedia = colNameLower.includes("image") || colNameLower.includes("avatar") || colNameLower.includes("photo") || colNameLower.includes("logo") || colNameLower.includes("cover");
934
-
935
- if (isMedia) {
936
- extra += `\n storage: {\n storagePath: ${quote(`${tableName}/${col.column_name}`)}\n },`;
937
- } else if (isUrl) {
938
- extra += "\n url: true,";
939
- } else if (colNameLower === "description" || colNameLower === "summary" || colNameLower === "excerpt") {
940
- extra = withAdminOptions(extra, ["multiline: true"], emitAdmin);
941
- } else if (colNameLower === "content" || colNameLower === "body") {
942
- // Inside `admin`, because that is where both options live. At the
943
- // top of the property — where these were — the generated file
944
- // does not compile: `StringProperty` declares neither. Six of
945
- // OpenStreetMap's tables have a `body` column, which is how this
946
- // surfaced.
947
- extra = withAdminOptions(extra, ["multiline: true", "markdown: true"], emitAdmin);
948
- } else if (col.data_type === "text") {
949
- extra = withAdminOptions(extra, ["multiline: true"], emitAdmin);
950
- }
951
- }
952
-
953
- // Append inference results
954
- if (inferenceExtra) {
955
- extra += inferenceExtra;
956
- if (!extra.endsWith(",")) extra += ",";
957
- }
958
-
959
- // ── Rules the database already enforces ──────────────────────────────
960
- // Everything below is read from the catalog, not guessed from the data
961
- // or the column's name. Each one is a constraint a write would hit
962
- // anyway; surfacing it means the form says no before the database does.
963
- const declaredValidation: string[] = [];
964
-
965
- // `varchar(n)` — a bound the author wrote down and nothing has read.
966
- if (finalPropType === "string" &&
967
- typeof col.character_maximum_length === "number" &&
968
- col.character_maximum_length > 0 &&
969
- !hasGeneratedKey(extra, "max")) {
970
- declaredValidation.push(`max: ${col.character_maximum_length}`);
971
- }
972
-
973
- if (columnChecks) {
974
- if (finalPropType === "number") {
975
- if (columnChecks.min !== undefined && !hasGeneratedKey(extra, "min")) declaredValidation.push(`min: ${columnChecks.min}`);
976
- if (columnChecks.max !== undefined && !hasGeneratedKey(extra, "max")) declaredValidation.push(`max: ${columnChecks.max}`);
977
- if (columnChecks.moreThan !== undefined) declaredValidation.push(`moreThan: ${columnChecks.moreThan}`);
978
- if (columnChecks.lessThan !== undefined) declaredValidation.push(`lessThan: ${columnChecks.lessThan}`);
979
- }
980
- if (finalPropType === "string") {
981
- if (columnChecks.lengthMin !== undefined && !hasGeneratedKey(extra, "min")) declaredValidation.push(`min: ${columnChecks.lengthMin}`);
982
- if (columnChecks.lengthMax !== undefined && !declaredValidation.some((v) => v.startsWith("max:")) && !hasGeneratedKey(extra, "max")) {
983
- declaredValidation.push(`max: ${columnChecks.lengthMax}`);
984
- }
985
- }
986
- }
987
-
988
- // A single-column unique index is the same promise `validation.unique`
989
- // makes. Composite uniqueness is not: it constrains the combination, and
990
- // marking either column unique on its own would reject valid rows.
991
- if (singleColumnUniques.has(col.column_name) && !meta.pks.includes(col.column_name)) {
992
- declaredValidation.push("unique: true");
993
- }
994
-
995
- extra = withValidation(extra, declaredValidation);
996
-
997
- // A generated column rejects every write, and a tsvector holds lexeme
998
- // positions rather than text, so an editable field for either is a field
999
- // that can only ever produce an error.
1000
- if (isReadOnlyColumn(col) && !hasGeneratedKey(extra, "readOnly")) {
1001
- const options = ["readOnly: true"];
1002
- if (isDerivedIndexColumn(col) && !hasGeneratedKey(extra, "hideFromCollection")) {
1003
- options.push("hideFromCollection: true");
1004
- hiddenFromCollection.add(propertyKey);
1005
- }
1006
- extra = withAdminOptions(extra, options, emitAdmin);
1007
- }
1008
-
1009
- // `COMMENT ON COLUMN` — documentation the author already wrote, which
1010
- // introspection has never carried across.
1011
- const columnComment = columnComments.get(col.column_name);
1012
- if (columnComment) {
1013
- extra = `\n description: ${quote(columnComment)},${extra}`;
1014
- }
1015
-
1016
- // Identify IDs (unless already inferred as UUID/CUID by inferenceEngine)
1017
- if (meta.pks.includes(col.column_name)) {
1018
- if (isCompositePk) {
1019
- extra += `\n // Part of composite primary key (${commentText(meta.pks.join(", "))})`;
1020
- } else if (finalPropType === "number" && !inferenceExtra.includes("isId:")) {
1021
- extra += "\n isId: \"increment\",";
1022
- } else if (col.data_type.toLowerCase() === "uuid" && !inferenceExtra.includes("isId:")) {
1023
- extra += "\n isId: \"uuid\",";
1024
- } else if (!inferenceExtra.includes("isId:")) {
1025
- extra += "\n isId: \"uuid\", // Verify if this is a UUID or CUID";
1026
- }
1027
- }
1028
-
1029
- if (finalPropType === "vector") {
1030
- const dims = col.atttypmod && col.atttypmod > 0 ? col.atttypmod : 1536;
1031
- extra += `\n dimensions: ${dims},`;
1032
- }
1033
-
1034
- // `required` on a column the user cannot write is a form that cannot be
1035
- // submitted: pagila's `film.fulltext` is NOT NULL and maintained by a
1036
- // trigger, so demanding it of the user blocks every create.
1037
- if (col.is_nullable === "NO" && !meta.pks.includes(col.column_name) && !col.column_default && !isReadOnlyColumn(col)) {
1038
- if (extra.includes("validation: {")) {
1039
- extra = extra.replace("validation: {", "validation: {\n required: true,");
1040
- } else {
1041
- extra += "\n validation: {\n required: true\n },";
1042
- }
1043
- }
1044
-
1045
- const humanName = humanize(col.column_name);
1046
-
1047
- orderEntries.push({
1048
- key: propertyKey,
1049
- ctx: {
1050
- propType: finalPropType,
1051
- isPk: meta.pks.includes(col.column_name),
1052
- isEnum: isEnumColumn,
1053
- isStorage: extra.includes("storage: {") || inferenceExtra.includes("storage: {"),
1054
- pgDataType: col.data_type,
1055
- originalIndex: currentIndex
1056
- }
1057
- });
1058
-
1059
- propertyBlocks.set(propertyKey, `
1060
- ${propKey(propertyKey)}: {
1061
- name: ${quote(humanName)},
1062
- columnName: ${quote(col.column_name)},
1063
- type: ${quote(finalPropType)},${extra}
1064
- },`);
1065
- }
1066
-
1067
- // Map Owning Relations (from this table's FKs to other tables)
1068
- for (const fk of meta.fks) {
1069
- const targetTableName = fk.foreign_table_name;
1070
- if (!joinTables.has(targetTableName)) {
1071
- // The relation gets its own property key, and it must not be one this
1072
- // file has already used — a duplicate key in an object literal is a
1073
- // TypeScript error, so the whole collection stops compiling.
1074
- //
1075
- // The collision needs three things at once and is invisible without
1076
- // all three: a foreign key column that does *not* end in `_id`, that
1077
- // column also being part of the primary key (which is what keeps it
1078
- // as a property of its own rather than folding it into the relation),
1079
- // and the stripped name matching the target table. MusicBrainz names
1080
- // every foreign key after the table it points at — `area_tag (area,
1081
- // tag)` — so 67 of its 339 collections came out with a property
1082
- // declared twice.
1083
- //
1084
- // Camel-cased for the same reason the columns above are: this is a
1085
- // property key, it sits in the same object literal, and a
1086
- // `blog_author` beside a `publishedAt` is the two-conventions
1087
- // defect reproduced inside a single collection.
1088
- const stripped = toWireKey(fk.column_name.replace(/_id$/, ""));
1089
- const relName = firstFreeKey(
1090
- [
1091
- stripped,
1092
- toWireKey(targetTableName),
1093
- `${stripped}Relation`
1094
- ],
1095
- propertyBlocks
1096
- );
1097
- // Push the relation property key, not the FK column name
1098
- orderEntries.push({
1099
- key: relName,
1100
- ctx: {
1101
- propType: "relation",
1102
- isPk: false,
1103
- isEnum: false,
1104
- isStorage: false,
1105
- pgDataType: "",
1106
- originalIndex: columnIndex++
1107
- }
1108
- });
1109
-
1110
- const targetCollectionCamel = importCollection(targetTableName);
1111
-
1112
- const relHumanName = humanize(relName);
1113
-
1114
- propertyBlocks.set(relName, `
1115
- ${propKey(relName)}: {
1116
- name: ${quote(relHumanName)},
1117
- type: "relation",
1118
- // mapped from foreign key: ${commentText(fk.column_name)} -> ${commentText(targetTableName)}(${commentText(fk.foreign_column_name)})
1119
- relation: {
1120
- kind: "belongsTo",
1121
- target: ${relationTarget(targetCollectionCamel)},
1122
- localKey: ${quote(fk.column_name)}
1123
- }
1124
- },`);
1125
- }
1126
- }
1127
-
1128
- // Map Inverse Relations (1-to-many where OTHER table points to THIS table)
1129
- // These go into the `relations` array so they render as subcollection tabs.
1130
- const inverseFks = allFks.filter((fk) => fk.foreign_table_name === tableName && !joinTables.has(fk.table_name));
1131
- for (const fk of inverseFks) {
1132
- const sourceTableName = fk.table_name;
1133
-
1134
- const targetCollectionCamel = importCollection(sourceTableName);
1135
-
1136
- relationsOutput += `
1137
- {
1138
- kind: "hasMany",
1139
- relationName: ${quote(sourceTableName)},
1140
- target: ${relationTarget(targetCollectionCamel)},
1141
- // the ${commentText(sourceTableName)}.${commentText(fk.column_name)} FK points back here
1142
- foreignKeyOnTarget: ${quote(fk.column_name)}
1143
- },`;
1144
- }
1145
-
1146
- // Map Many-to-Many Relations (Join Tables)
1147
- // These also go into the `relations` array so they render as subcollection tabs.
1148
- const relatedJoinTables = Array.from(joinTables).filter((jt) => {
1149
- const jtMeta = tablesMap.get(jt);
1150
- return jtMeta ? jtMeta.fks.some((fk) => fk.foreign_table_name === tableName) : false;
1151
- });
1152
-
1153
- for (const jt of relatedJoinTables) {
1154
- const jtMeta = tablesMap.get(jt);
1155
- if (!jtMeta) continue;
1156
-
1157
- const joinFks = jtMeta.fks;
1158
-
1159
- // Handle self-referencing M2M: both FKs point to the same table
1160
- const selfRefFks = joinFks.filter((fk) => fk.foreign_table_name === tableName);
1161
- if (selfRefFks.length === 2) {
1162
- // Self-referencing M2M — generate a single owning relation
1163
- const thisFk = selfRefFks[0];
1164
- const otherFk = selfRefFks[1];
1165
-
1166
- const relPropName = `${tableName}_via_${otherFk.column_name.replace(/_id$/, "")}`;
1167
-
1168
- relationsOutput += `
1169
- {
1170
- kind: "manyToMany",
1171
- relationName: ${quote(relPropName)},
1172
- target: ${relationTarget(toCollectionVarName(tableName))},
1173
- through: {
1174
- table: ${quote(jt)},
1175
- sourceColumn: ${quote(thisFk.column_name)},
1176
- targetColumn: ${quote(otherFk.column_name)}
1177
- }
1178
- },`;
1179
- continue;
1180
- }
1181
-
1182
- const otherFk = joinFks.find((fk) => fk.foreign_table_name !== tableName);
1183
-
1184
- if (otherFk) {
1185
- const targetTableName = otherFk.foreign_table_name;
1186
-
1187
- const targetCollectionCamel = importCollection(targetTableName);
1188
-
1189
- // Both sides of a many-to-many are `manyToMany`. There is no owning
1190
- // and inverse side to pick between any more, so this no longer
1191
- // guesses one from table-name ordering and no longer emits a
1192
- // half-configured relation on the losing side with a comment asking
1193
- // the reader to finish it by hand. Introspection already knows both
1194
- // junction columns; each side just names them from its own end.
1195
- const thisFk = joinFks.find((fk) => fk.foreign_table_name === tableName);
1196
-
1197
- const throughCode = thisFk
1198
- ? `\n through: {\n table: ${quote(jt)},\n sourceColumn: ${quote(thisFk.column_name)},\n targetColumn: ${quote(otherFk.column_name)}\n }`
1199
- : "";
1200
-
1201
- relationsOutput += `
1202
- {
1203
- kind: "manyToMany",
1204
- relationName: ${quote(targetTableName)},
1205
- target: ${relationTarget(targetCollectionCamel)},${throughCode}
1206
- },`;
1207
- }
1208
- }
1209
-
1210
- const relationsBlock = relationsOutput
1211
- ? `\n relations: [${relationsOutput}\n ],`
1212
- : "";
1213
-
1214
- const sortedPropertiesOrder = sortPropertiesOrder(orderEntries);
1215
- for (const key of sortedPropertiesOrder) {
1216
- propsOutput += propertyBlocks.get(key) || "";
1217
- }
1218
-
1219
- // ── The admin block ──────────────────────────────────────────────────
1220
- // `icon` and `propertiesOrder` used to be emitted at the *top level* of the
1221
- // config, where they have not belonged since the admin block was split out:
1222
- // `PostgresCollectionConfig` does not declare them, so every generated file
1223
- // was a type error, and the panel — which reads the block — never saw them.
1224
- const adminEntries: string[] = [`icon: ${quote(icon)}`];
1225
-
1226
- if (classification) {
1227
- const derivedFacts = context.metadata
1228
- ? buildColumnFacts(meta, context.metadata, enumMap, checkFacts)
1229
- : undefined;
1230
-
1231
- if (classification.role === "owned-child") {
1232
- // The rows are already reachable: every inbound foreign key renders
1233
- // as a tab on the parent. A second, top-level entry for them is what
1234
- // turns a navigation of eight nouns into a list of thirty tables.
1235
- adminEntries.push("hideFromNavigation: true");
1236
- } else if (classification.role === "lookup") {
1237
- adminEntries.push('group: "Reference"');
1238
- }
1239
-
1240
- if (derivedFacts) {
1241
- // Each of these comes back as a column and is emitted as a property.
1242
- const asProperty = (column: string): string => keyByColumn.get(column) ?? toWireKey(column);
1243
-
1244
- const titleProperty = deriveTitleProperty(derivedFacts);
1245
- if (titleProperty) adminEntries.push(`display: { title: ${quote(asProperty(titleProperty))} }`);
1246
-
1247
- const kanbanProperty = deriveKanbanProperty(derivedFacts);
1248
- if (kanbanProperty) adminEntries.push(`kanban: {\n columnProperty: ${quote(asProperty(kanbanProperty))}\n }`);
1249
-
1250
- const sort = deriveSort(derivedFacts);
1251
- if (sort) adminEntries.push(`sort: [${quote(asProperty(sort[0]))}, "desc"]`);
1252
- }
1253
-
1254
- const listProperties = deriveListProperties(sortedPropertiesOrder, hiddenFromCollection);
1255
- if (listProperties) {
1256
- adminEntries.push(`listProperties: ${formatKeyList(listProperties)}`);
1257
- }
1258
- }
1259
-
1260
- adminEntries.push(`propertiesOrder: ${formatKeyList(sortedPropertiesOrder)}`);
1261
-
1262
- // Every entry above names a property key, and with `defineCollection` those
1263
- // keys are now checked against `properties` — a stale `propertiesOrder` entry
1264
- // left behind by a renamed column is a compile error rather than a silent
1265
- // no-op. Which is also why the block cannot be emitted where the field is not
1266
- // declared: see {@link CollectionBuilder}.
1267
- const adminBlock = emitAdmin
1268
- ? `\n admin: {\n ${adminEntries.join(",\n ")}\n }`
1269
- : "";
1270
-
1271
- const descriptionBlock = tableComment
1272
- ? `\n description: ${quote(tableComment)},`
1273
- : "";
1274
-
1275
- // The classification is stated in the file because it is a *decision*, and a
1276
- // decision the reader may disagree with. Naming the evidence tells them
1277
- // which line to delete when they do.
1278
- const classificationNote = classification && classification.role !== "entity"
1279
- ? `\n// Introspected as a ${commentText(classification.role)}: ${commentText(classification.reason)}.\n`
1280
- : "";
1281
-
1282
- const collectionVarName = toCollectionVarName(tableName);
1283
- // `const x = defineCollection({ … })` is also the shape the ts-morph schema
1284
- // editor in `@rebasepro/server` expects — `COLLECTION_FACTORIES` — so an
1285
- // introspected collection is now editable from the panel the way a scaffolded
1286
- // one is.
1287
- const [open, close] = builder === "annotation"
1288
- ? [`const ${collectionVarName}: PostgresCollectionConfig = {`, "};"]
1289
- : [`const ${collectionVarName} = defineCollection({`, "});"];
1290
- // Package imports first, then siblings. `AnyCollectionConfig` is added the
1291
- // moment the first relation needs it — which is after the sibling collections
1292
- // it points at have already been added — and only when a relation needs it, so
1293
- // a project with `noUnusedLocals` never sees an import it does not use.
1294
- const importLines = Array.from(imports);
1295
- const orderedImports = [
1296
- ...importLines.filter((line) => !line.includes('from "./')),
1297
- ...importLines.filter((line) => line.includes('from "./'))
1298
- ];
1299
- const fileContent = `${orderedImports.join("\n")}
1300
- ${classificationNote}
1301
- ${open}
1302
- name: ${quote(collectionName)},
1303
- singularName: ${quote(singular)},
1304
- slug: ${quote(tableName)},
1305
- table: ${quote(tableName)},${descriptionBlock}
1306
- properties: {${propsOutput}
1307
- },${relationsBlock}${adminBlock}
1308
- ${close}
1309
-
1310
- export default ${collectionVarName};
1311
- `;
1312
-
1313
- return fileContent;
1314
- }
1315
-
1316
- /**
1317
- * Generate the content for an index.ts file that re-exports all collections.
1318
- */
1319
- export function generateIndexContent(fileNames: string[]): string {
1320
- const sorted = [...fileNames].sort();
1321
- let imports = "";
1322
- let arrayElements = "";
1323
- for (const f of sorted) {
1324
- const varName = toCollectionVarName(f);
1325
- imports += `import ${varName} from ${quote(`./${f}`)};\n`;
1326
- arrayElements += ` ${varName},\n`;
1327
- }
1328
- return `${imports}\nexport const collections = [\n${arrayElements}];\n`;
1329
- }
1330
-
1331
- /**
1332
- * Merge new exports into existing index.ts content.
1333
- * Returns the merged content string.
1334
- */
1335
- export function mergeIndexContent(existingContent: string, newFileNames: string[]): string {
1336
- const existingImports = new Set(
1337
- [...existingContent.matchAll(/import\s+([a-zA-Z0-9_]+)\s+from\s+"\.\/([^"]+)"/g)].map((m) => m[2])
1338
- );
1339
- const sorted = [...newFileNames].sort();
1340
-
1341
- let newImports = "";
1342
- let newElements = "";
1343
-
1344
- for (const f of sorted) {
1345
- if (!existingImports.has(f)) {
1346
- const varName = toCollectionVarName(f);
1347
- newImports += `import ${varName} from ${quote(`./${f}`)};\n`;
1348
- newElements += ` ${varName},\n`;
1349
- }
1350
- }
1351
-
1352
- if (!newImports) return existingContent;
1353
-
1354
- // Simple injection logic:
1355
- // Add new imports below the last import or at the top
1356
- const importRegex = /import\s+.*?;/g;
1357
- let lastImportMatch;
1358
- let match;
1359
- while ((match = importRegex.exec(existingContent)) !== null) {
1360
- lastImportMatch = match;
1361
- }
1362
-
1363
- let contentWithImports = existingContent;
1364
- if (lastImportMatch) {
1365
- const pos = lastImportMatch.index + lastImportMatch[0].length;
1366
- contentWithImports = existingContent.slice(0, pos) + "\n" + newImports.trimEnd() + existingContent.slice(pos);
1367
- } else {
1368
- contentWithImports = newImports + "\n" + existingContent;
1369
- }
1370
-
1371
- // Inject into the `collections = [...]` array
1372
- const arrayRegex = /export\s+const\s+collections\s*=\s*\[([\s\S]*?)\];/;
1373
- return contentWithImports.replace(arrayRegex, (fullMatch, arrayContent) => {
1374
- let mergedArray = arrayContent.trimEnd();
1375
- if (mergedArray && !mergedArray.endsWith(",")) mergedArray += ",";
1376
- if (mergedArray) mergedArray += "\n";
1377
- mergedArray += newElements.trimEnd();
1378
- return `export const collections = [\n ${mergedArray.trim()}\n];`;
1379
- });
1380
- }
1381
-
1382
- /**
1383
- * Safely extract the host portion of a database URL for logging.
1384
- */
1385
- export function safeHostFromUrl(url: string): string {
1386
- return url.includes("@") ? url.split("@")[1] : "(local connection)";
1387
- }