@rebasepro/server-postgres 0.12.1-canary.g4e7bcbf → 0.12.1-canary.g52d71ee

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 (51) hide show
  1. package/dist/auth/services.d.ts +6 -1
  2. package/dist/backup/backup-service.d.ts +10 -1
  3. package/dist/backup/pg-tools.d.ts +47 -0
  4. package/dist/{backup-service-DLb2drIH.js → backup-service-CD8o_1Sl.js} +136 -10
  5. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  6. package/dist/{ensure-collection-policies-Dv21KDMJ.js → ensure-collection-policies-ViG8XiPn.js} +2 -2
  7. package/dist/{ensure-collection-policies-Dv21KDMJ.js.map → ensure-collection-policies-ViG8XiPn.js.map} +1 -1
  8. package/dist/{ensure-collection-tables-CT6xHB1d.js → ensure-collection-tables-CBQdOETu.js} +2 -2
  9. package/dist/{ensure-collection-tables-CT6xHB1d.js.map → ensure-collection-tables-CBQdOETu.js.map} +1 -1
  10. package/dist/index.es.js +768 -537
  11. package/dist/index.es.js.map +1 -1
  12. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  13. package/dist/schema/introspect-db-logic.d.ts +94 -5
  14. package/dist/schema/introspect-db-queries.d.ts +119 -0
  15. package/dist/schema/introspect-db-structure.d.ts +263 -0
  16. package/dist/schema/introspect-db-types.d.ts +11 -0
  17. package/dist/services/RelationService.d.ts +24 -1
  18. package/dist/services/channel-bus/index.d.ts +1 -7
  19. package/dist/services/collection-helpers.d.ts +36 -2
  20. package/dist/{src-BkdpiQdw.js → src-DlPBctw_.js} +72 -6
  21. package/dist/src-DlPBctw_.js.map +1 -0
  22. package/dist/utils/connection-string.d.ts +29 -0
  23. package/dist/utils/drizzle-conditions.d.ts +5 -4
  24. package/dist/utils/pg-error-utils.d.ts +16 -0
  25. package/package.json +6 -6
  26. package/src/auth/services.ts +6 -3
  27. package/src/backup/backup-cli.ts +41 -2
  28. package/src/backup/backup-service.ts +38 -5
  29. package/src/backup/pg-tools.ts +96 -3
  30. package/src/cli.ts +11 -4
  31. package/src/collections/validate-relations.ts +15 -0
  32. package/src/data-transformer.ts +9 -3
  33. package/src/schema/generate-drizzle-schema-logic.ts +26 -1
  34. package/src/schema/introspect-db-constraints.ts +385 -0
  35. package/src/schema/introspect-db-inference.ts +18 -8
  36. package/src/schema/introspect-db-logic.ts +364 -68
  37. package/src/schema/introspect-db-queries.ts +326 -0
  38. package/src/schema/introspect-db-structure.ts +670 -0
  39. package/src/schema/introspect-db-types.ts +56 -0
  40. package/src/schema/introspect-db.ts +37 -80
  41. package/src/services/BranchService.ts +66 -28
  42. package/src/services/FetchService.ts +14 -0
  43. package/src/services/PersistService.ts +20 -6
  44. package/src/services/RelationService.ts +211 -45
  45. package/src/services/channel-bus/index.ts +0 -9
  46. package/src/services/collection-helpers.ts +69 -3
  47. package/src/utils/connection-string.ts +58 -0
  48. package/src/utils/drizzle-conditions.ts +31 -6
  49. package/src/utils/pg-error-utils.ts +19 -0
  50. package/dist/backup-service-DLb2drIH.js.map +0 -1
  51. package/dist/src-BkdpiQdw.js.map +0 -1
@@ -8,11 +8,25 @@
8
8
  */
9
9
  import { inferPropertyFromData } from "./introspect-db-inference";
10
10
  import { humanize } from "./introspect-db-naming";
11
+ import { mapPgType } from "./introspect-db-types";
12
+ import type { CheckFactsByTable } from "./introspect-db-constraints";
13
+ import type { TableClassification } from "./introspect-db-structure";
14
+ import {
15
+ buildColumnFacts,
16
+ deriveKanbanProperty,
17
+ deriveListProperties,
18
+ deriveSort,
19
+ deriveTitleProperty,
20
+ isDerivedIndexColumn,
21
+ isReadOnlyColumn
22
+ } from "./introspect-db-structure";
11
23
 
12
24
  // ── Typed interfaces for SQL query results ────────────────────────────
13
25
 
14
26
  export interface TableRow {
15
27
  table_name: string;
28
+ /** True for the parent of a partitioned table (`relkind = 'p'`). */
29
+ is_partitioned?: boolean;
16
30
  }
17
31
 
18
32
  export interface TableColumn {
@@ -23,6 +37,18 @@ export interface TableColumn {
23
37
  is_nullable: string;
24
38
  column_default: string | null;
25
39
  atttypmod: number | null;
40
+ /** 1-based position in the table, as declared. */
41
+ ordinal_position?: number;
42
+ /** `"ALWAYS"` for a generated column, `"NEVER"` otherwise. */
43
+ is_generated?: string;
44
+ /** `"YES"` for an identity column. */
45
+ is_identity?: string;
46
+ /** `"ALWAYS"` or `"BY DEFAULT"` on an identity column. */
47
+ identity_generation?: string | null;
48
+ /** The declared `varchar(n)` / `char(n)` bound, if any. */
49
+ character_maximum_length?: number | null;
50
+ numeric_precision?: number | null;
51
+ numeric_scale?: number | null;
26
52
  }
27
53
 
28
54
  export interface EnumValue {
@@ -41,6 +67,57 @@ export interface ForeignKeyRow {
41
67
  column_name: string;
42
68
  foreign_table_name: string;
43
69
  foreign_column_name: string;
70
+ /** Name of the FK constraint — the only way to tell composite keys apart. */
71
+ constraint_name?: string;
72
+ /** 1-based position of this column within its constraint. */
73
+ ordinal?: number;
74
+ /** `"CASCADE"`, `"RESTRICT"`, `"SET NULL"`, `"SET DEFAULT"`, `"NO ACTION"`. */
75
+ delete_rule?: string;
76
+ }
77
+
78
+ /** A unique constraint or unique index, as an ordered column list. */
79
+ export interface UniqueConstraintRow {
80
+ table_name: string;
81
+ constraint_name: string;
82
+ column_names: string[];
83
+ }
84
+
85
+ /** A CHECK constraint, as `pg_get_constraintdef` renders it. */
86
+ export interface CheckConstraintRow {
87
+ table_name: string;
88
+ constraint_name: string;
89
+ definition: string;
90
+ }
91
+
92
+ /** A `COMMENT ON TABLE` (null `column_name`) or `COMMENT ON COLUMN`. */
93
+ export interface CommentRow {
94
+ table_name: string;
95
+ column_name: string | null;
96
+ comment: string;
97
+ }
98
+
99
+ /**
100
+ * Everything one introspection run reads from the database.
101
+ *
102
+ * Passed around as one value so a new signal means a new field here rather than
103
+ * a new parameter on every function between the query and the generator — the
104
+ * shape `generateCollectionFile` had grown to seven positional arguments by.
105
+ */
106
+ export interface SchemaMetadata {
107
+ schema: string;
108
+ tables: TableRow[];
109
+ columns: TableColumn[];
110
+ enumValues: EnumValue[];
111
+ pks: PrimaryKeyRow[];
112
+ fks: ForeignKeyRow[];
113
+ uniques: UniqueConstraintRow[];
114
+ checks: CheckConstraintRow[];
115
+ comments: CommentRow[];
116
+ /**
117
+ * Row counts for the tables that needed one, capped — see `countRowsUpTo`.
118
+ * Absent for every table introspection never had a reason to count.
119
+ */
120
+ rowCounts: Record<string, number>;
44
121
  }
45
122
 
46
123
  export interface TableMeta {
@@ -160,54 +237,7 @@ export function getIconForTable(tableName: string): string {
160
237
  return "Database";
161
238
  }
162
239
 
163
- /**
164
- * Map a PostgreSQL data type to a Rebase property type.
165
- */
166
- export function mapPgType(dataType: string): string {
167
- const dt = dataType.toLowerCase();
168
-
169
- // Interval MUST be checked before numeric ("interval" contains "int")
170
- if (dt === "interval") return "string";
171
-
172
- // Array types MUST be checked before numeric ("_int4" contains "int")
173
- if (dt === "array" || dt.startsWith("_")) return "array";
174
-
175
- // Numeric types
176
- if (
177
- dt.includes("int") || // integer, smallint, bigint
178
- dt.includes("numeric") ||
179
- dt.includes("decimal") ||
180
- dt.includes("serial") || // serial, bigserial
181
- dt === "real" ||
182
- dt === "float4" ||
183
- dt === "float8" ||
184
- dt === "double precision" ||
185
- dt === "money"
186
- ) {
187
- return "number";
188
- }
189
-
190
- // Boolean
191
- if (dt.includes("bool")) return "boolean";
192
-
193
- // Date / Time
194
- if (dt.includes("time") || dt.includes("date")) return "date";
195
-
196
- // JSON
197
- if (dt === "json" || dt === "jsonb") return "map";
198
-
199
- // Binary
200
- if (dt === "bytea") return "binary";
201
-
202
- // Network types
203
- if (dt === "inet" || dt === "cidr" || dt === "macaddr" || dt === "macaddr8") return "string";
204
-
205
- // UUID
206
- if (dt === "uuid") return "string";
207
-
208
- // Text/varchar/char — default to string
209
- return "string";
210
- }
240
+ export { mapPgType };
211
241
 
212
242
  // ── Build the enum map from query results ─────────────────────────────
213
243
 
@@ -246,6 +276,20 @@ export function buildTablesMap(
246
276
 
247
277
  // ── Identify join tables ──────────────────────────────────────────────
248
278
 
279
+ /**
280
+ * Join tables, identified by column name.
281
+ *
282
+ * Superseded for the CLI by `classifyTables` in `./introspect-db-structure`,
283
+ * which asks the database instead: two single-column keys, unique together, no
284
+ * payload column, nothing referencing the table. This rule folds away
285
+ * `northwind.order_details` — which has the key shape and carries unit price,
286
+ * quantity and discount — because it recognises `id`, `created_at` and
287
+ * `updated_at` by name and calls everything else a foreign key.
288
+ *
289
+ * Still used by `./introspect-runtime`, which builds collections in memory from
290
+ * a narrower set of catalog queries and has no unique-constraint or row-count
291
+ * data to reason with.
292
+ */
249
293
  export function identifyJoinTables(tablesMap: Map<string, TableMeta>): Set<string> {
250
294
  const joinTables = new Set<string>();
251
295
  for (const [tableName, meta] of tablesMap.entries()) {
@@ -519,6 +563,83 @@ export interface GeneratedFile {
519
563
  content: string;
520
564
  }
521
565
 
566
+ /**
567
+ * The structural analysis a run can hand the generator.
568
+ *
569
+ * Optional in full, and the generator degrades to exactly its previous output
570
+ * without it. That is not politeness towards old callers: three existing test
571
+ * suites and the `rebase init` scaffold path build a `TableMeta` by hand and
572
+ * have no database to read constraints or row counts from, and they must keep
573
+ * producing a valid collection.
574
+ */
575
+ export interface GenerationContext {
576
+ metadata?: SchemaMetadata;
577
+ classifications?: Map<string, TableClassification>;
578
+ checkFacts?: CheckFactsByTable;
579
+ }
580
+
581
+ /** Adds entries to a property's `validation` block, creating it if absent. */
582
+ function withValidation(extra: string, entries: string[]): string {
583
+ if (entries.length === 0) return extra;
584
+ const block = entries.map((e) => ` ${e}`).join(",\n");
585
+ if (extra.includes("validation: {")) {
586
+ return extra.replace("validation: {", `validation: {\n${block},`);
587
+ }
588
+ return `${extra}\n validation: {\n${block}\n },`;
589
+ }
590
+
591
+ /**
592
+ * Whether a property's generated text already sets `key:` as an object key.
593
+ *
594
+ * A bare `extra.includes("min:")` looks like it answers this and does not:
595
+ * `admin:` ends in `min:`, so every property with an admin block claimed to
596
+ * have a minimum already and silently lost the one the database declared. The
597
+ * leading-delimiter requirement is the whole point — a key is preceded by a
598
+ * newline, a brace or a comma, never by another identifier character.
599
+ */
600
+ function hasGeneratedKey(extra: string, key: string): boolean {
601
+ return new RegExp(`(^|[\\s{,])${key}\\s*:`).test(extra);
602
+ }
603
+
604
+ /** Adds entries to a property's `admin` block, creating it if absent. */
605
+ function withAdminOptions(extra: string, entries: string[]): string {
606
+ if (entries.length === 0) return extra;
607
+ const block = entries.map((e) => ` ${e}`).join(",\n");
608
+ if (extra.includes("admin: {")) {
609
+ return extra.replace("admin: {", `admin: {\n${block},`);
610
+ }
611
+ return `${extra}\n admin: {\n${block}\n },`;
612
+ }
613
+
614
+ /** A TypeScript string literal, escaped. */
615
+ function quote(value: string): string {
616
+ return JSON.stringify(value);
617
+ }
618
+
619
+ /**
620
+ * The first candidate key not already used, or a numbered fallback.
621
+ *
622
+ * The numbered tail exists so this is total: a table can hold two foreign keys
623
+ * whose columns strip to the same name, and a function that returns a key it
624
+ * cannot guarantee is free has just moved the duplicate one line down.
625
+ */
626
+ function firstFreeKey(candidates: string[], taken: ReadonlyMap<string, unknown>): string {
627
+ for (const candidate of candidates) {
628
+ if (!taken.has(candidate)) return candidate;
629
+ }
630
+ const base = candidates[candidates.length - 1];
631
+ for (let suffix = 2; ; suffix++) {
632
+ const candidate = `${base}_${suffix}`;
633
+ if (!taken.has(candidate)) return candidate;
634
+ }
635
+ }
636
+
637
+ /** A property-key array, one key per line, indented for the admin block. */
638
+ function formatKeyList(keys: string[]): string {
639
+ if (keys.length === 0) return "[]";
640
+ return `[\n${keys.map((k) => ` ${quote(k)}`).join(",\n")}\n ]`;
641
+ }
642
+
522
643
  /**
523
644
  * Generate the full TypeScript file content for a single collection.
524
645
  * Pure function — no I/O.
@@ -530,18 +651,53 @@ export function generateCollectionFile(
530
651
  joinTables: Set<string>,
531
652
  tablesMap: Map<string, TableMeta>,
532
653
  enumMap: Map<string, string[]>,
533
- sampleData?: Record<string, unknown>[]
654
+ sampleData?: Record<string, unknown>[],
655
+ context: GenerationContext = {}
534
656
  ): string {
535
657
  const collectionName = humanize(tableName);
536
658
  const singular = singularize(collectionName);
537
659
  const icon = getIconForTable(tableName);
538
660
 
661
+ const classification = context.classifications?.get(tableName);
662
+ const checkFacts: CheckFactsByTable = context.checkFacts ?? new Map();
663
+ const tableChecks = checkFacts.get(tableName);
664
+ const columnComments = new Map<string, string>();
665
+ let tableComment: string | undefined;
666
+ for (const comment of context.metadata?.comments ?? []) {
667
+ if (comment.table_name !== tableName) continue;
668
+ if (comment.column_name === null) tableComment = comment.comment;
669
+ else columnComments.set(comment.column_name, comment.comment);
670
+ }
671
+ const singleColumnUniques = new Set(
672
+ (context.metadata?.uniques ?? [])
673
+ .filter((u) => u.table_name === tableName && u.column_names.length === 1)
674
+ .map((u) => u.column_names[0])
675
+ );
676
+
539
677
  const imports = new Set<string>(['import { PostgresCollectionConfig } from "@rebasepro/types";']);
540
678
 
679
+ /**
680
+ * Imports the collection a relation points at — unless it is this one.
681
+ *
682
+ * A self-referencing key (`employees.reports_to -> employees`, which both
683
+ * northwind and chinook have) otherwise made the file import its own default
684
+ * export under the name it declares three lines later: `TS2440: Import
685
+ * declaration conflicts with local declaration`. The relation target is a
686
+ * thunk, so referring to the local const directly is fine — it is only
687
+ * dereferenced after the module has finished evaluating.
688
+ */
689
+ const importCollection = (otherTable: string): string => {
690
+ const varName = toCollectionVarName(otherTable);
691
+ if (otherTable !== tableName) imports.add(`import ${varName} from "./${otherTable}";`);
692
+ return varName;
693
+ };
694
+
541
695
  let propsOutput = "";
542
696
  let relationsOutput = "";
543
697
  const orderEntries: PropertyOrderEntry[] = [];
544
698
  const propertyBlocks = new Map<string, string>();
699
+ /** Properties the list view will not render, so `listProperties` skips them. */
700
+ const hiddenFromCollection = new Set<string>();
545
701
  let columnIndex = 0;
546
702
 
547
703
  // Detect composite primary keys
@@ -576,20 +732,33 @@ export function generateCollectionFile(
576
732
  if (inferred.extra) inferenceExtra = inferred.extra;
577
733
  }
578
734
 
735
+ const columnChecks = tableChecks?.get(col.column_name);
736
+
579
737
  // Enum values — generate real enum from the PG enum
580
738
  if (isEnumColumn && colEnumValues) {
581
739
  const enumEntries = colEnumValues
582
740
  .map((v) => `{ id: "${v}", label: "${humanize(v)}" }`)
583
741
  .join(", ");
584
742
  extra += `\n enum: [${enumEntries}],`;
743
+ } else if (columnChecks?.enumValues && !inferenceExtra.includes("enum:") && propType === "string") {
744
+ // `CHECK (col IN (…))` is the other way a schema declares a closed
745
+ // set. It is the same statement as a Postgres enum type, made by an
746
+ // author who did not want a type — and until now the form offered a
747
+ // free-text box for it and let the database reject the write.
748
+ const enumEntries = columnChecks.enumValues
749
+ .map((v) => `{ id: ${quote(v)}, label: ${quote(humanize(v))} }`)
750
+ .join(", ");
751
+ extra += `\n enum: [${enumEntries}],`;
585
752
  }
586
753
 
587
754
  // Date auto-value heuristics
588
755
  if (finalPropType === "date") {
589
756
  if (colNameLower === "created_at" || colNameLower === "createdat") {
590
757
  extra += "\n autoValue: \"on_create\",\n admin: {\n readOnly: true,\n hideFromCollection: true\n },";
758
+ hiddenFromCollection.add(col.column_name);
591
759
  } else if (colNameLower === "updated_at" || colNameLower === "updatedat") {
592
760
  extra += "\n autoValue: \"on_update\",\n admin: {\n readOnly: true,\n hideFromCollection: true\n },";
761
+ hiddenFromCollection.add(col.column_name);
593
762
  } else if (col.column_default && (col.column_default.includes("now()") || col.column_default.includes("CURRENT_TIMESTAMP"))) {
594
763
  extra += "\n autoValue: \"on_create\",\n admin: {\n readOnly: true\n },";
595
764
  }
@@ -626,7 +795,12 @@ export function generateCollectionFile(
626
795
  } else if (colNameLower === "description" || colNameLower === "summary" || colNameLower === "excerpt") {
627
796
  extra += "\n admin: {\n multiline: true\n },";
628
797
  } else if (colNameLower === "content" || colNameLower === "body") {
629
- extra += "\n multiline: true,\n markdown: true,";
798
+ // Inside `admin`, because that is where both options live. At the
799
+ // top of the property — where these were — the generated file
800
+ // does not compile: `StringProperty` declares neither. Six of
801
+ // OpenStreetMap's tables have a `body` column, which is how this
802
+ // surfaced.
803
+ extra += "\n admin: {\n multiline: true,\n markdown: true\n },";
630
804
  } else if (col.data_type === "text") {
631
805
  extra += "\n admin: {\n multiline: true\n },";
632
806
  }
@@ -638,6 +812,63 @@ export function generateCollectionFile(
638
812
  if (!extra.endsWith(",")) extra += ",";
639
813
  }
640
814
 
815
+ // ── Rules the database already enforces ──────────────────────────────
816
+ // Everything below is read from the catalog, not guessed from the data
817
+ // or the column's name. Each one is a constraint a write would hit
818
+ // anyway; surfacing it means the form says no before the database does.
819
+ const declaredValidation: string[] = [];
820
+
821
+ // `varchar(n)` — a bound the author wrote down and nothing has read.
822
+ if (finalPropType === "string" &&
823
+ typeof col.character_maximum_length === "number" &&
824
+ col.character_maximum_length > 0 &&
825
+ !hasGeneratedKey(extra, "max")) {
826
+ declaredValidation.push(`max: ${col.character_maximum_length}`);
827
+ }
828
+
829
+ if (columnChecks) {
830
+ if (finalPropType === "number") {
831
+ if (columnChecks.min !== undefined && !hasGeneratedKey(extra, "min")) declaredValidation.push(`min: ${columnChecks.min}`);
832
+ if (columnChecks.max !== undefined && !hasGeneratedKey(extra, "max")) declaredValidation.push(`max: ${columnChecks.max}`);
833
+ if (columnChecks.moreThan !== undefined) declaredValidation.push(`moreThan: ${columnChecks.moreThan}`);
834
+ if (columnChecks.lessThan !== undefined) declaredValidation.push(`lessThan: ${columnChecks.lessThan}`);
835
+ }
836
+ if (finalPropType === "string") {
837
+ if (columnChecks.lengthMin !== undefined && !hasGeneratedKey(extra, "min")) declaredValidation.push(`min: ${columnChecks.lengthMin}`);
838
+ if (columnChecks.lengthMax !== undefined && !declaredValidation.some((v) => v.startsWith("max:")) && !hasGeneratedKey(extra, "max")) {
839
+ declaredValidation.push(`max: ${columnChecks.lengthMax}`);
840
+ }
841
+ }
842
+ }
843
+
844
+ // A single-column unique index is the same promise `validation.unique`
845
+ // makes. Composite uniqueness is not: it constrains the combination, and
846
+ // marking either column unique on its own would reject valid rows.
847
+ if (singleColumnUniques.has(col.column_name) && !meta.pks.includes(col.column_name)) {
848
+ declaredValidation.push("unique: true");
849
+ }
850
+
851
+ extra = withValidation(extra, declaredValidation);
852
+
853
+ // A generated column rejects every write, and a tsvector holds lexeme
854
+ // positions rather than text, so an editable field for either is a field
855
+ // that can only ever produce an error.
856
+ if (isReadOnlyColumn(col) && !hasGeneratedKey(extra, "readOnly")) {
857
+ const options = ["readOnly: true"];
858
+ if (isDerivedIndexColumn(col) && !hasGeneratedKey(extra, "hideFromCollection")) {
859
+ options.push("hideFromCollection: true");
860
+ hiddenFromCollection.add(col.column_name);
861
+ }
862
+ extra = withAdminOptions(extra, options);
863
+ }
864
+
865
+ // `COMMENT ON COLUMN` — documentation the author already wrote, which
866
+ // introspection has never carried across.
867
+ const columnComment = columnComments.get(col.column_name);
868
+ if (columnComment) {
869
+ extra = `\n description: ${quote(columnComment)},${extra}`;
870
+ }
871
+
641
872
  // Identify IDs (unless already inferred as UUID/CUID by inferenceEngine)
642
873
  if (meta.pks.includes(col.column_name)) {
643
874
  if (isCompositePk) {
@@ -656,7 +887,10 @@ export function generateCollectionFile(
656
887
  extra += `\n dimensions: ${dims},`;
657
888
  }
658
889
 
659
- if (col.is_nullable === "NO" && !meta.pks.includes(col.column_name) && !col.column_default) {
890
+ // `required` on a column the user cannot write is a form that cannot be
891
+ // submitted: pagila's `film.fulltext` is NOT NULL and maintained by a
892
+ // trigger, so demanding it of the user blocks every create.
893
+ if (col.is_nullable === "NO" && !meta.pks.includes(col.column_name) && !col.column_default && !isReadOnlyColumn(col)) {
660
894
  if (extra.includes("validation: {")) {
661
895
  extra = extra.replace("validation: {", "validation: {\n required: true,");
662
896
  } else {
@@ -690,12 +924,26 @@ export function generateCollectionFile(
690
924
  for (const fk of meta.fks) {
691
925
  const targetTableName = fk.foreign_table_name;
692
926
  if (!joinTables.has(targetTableName)) {
693
- let relName = fk.column_name.replace(/_id$/, "");
694
- if (meta.pks.includes(fk.column_name) && relName === fk.column_name) {
695
- // If the FK is also the PK and its name doesn't imply a relation (like "id"),
696
- // use the target table name to avoid conflicting with the PK property.
697
- relName = targetTableName;
698
- }
927
+ // The relation gets its own property key, and it must not be one this
928
+ // file has already used — a duplicate key in an object literal is a
929
+ // TypeScript error, so the whole collection stops compiling.
930
+ //
931
+ // The collision needs three things at once and is invisible without
932
+ // all three: a foreign key column that does *not* end in `_id`, that
933
+ // column also being part of the primary key (which is what keeps it
934
+ // as a property of its own rather than folding it into the relation),
935
+ // and the stripped name matching the target table. MusicBrainz names
936
+ // every foreign key after the table it points at — `area_tag (area,
937
+ // tag)` — so 67 of its 339 collections came out with a property
938
+ // declared twice.
939
+ const relName = firstFreeKey(
940
+ [
941
+ fk.column_name.replace(/_id$/, ""),
942
+ targetTableName,
943
+ `${fk.column_name.replace(/_id$/, "")}_relation`
944
+ ],
945
+ propertyBlocks
946
+ );
699
947
  // Push the relation property key, not the FK column name
700
948
  orderEntries.push({
701
949
  key: relName,
@@ -709,8 +957,7 @@ export function generateCollectionFile(
709
957
  }
710
958
  });
711
959
 
712
- const targetCollectionCamel = toCollectionVarName(targetTableName);
713
- imports.add(`import ${targetCollectionCamel} from "./${targetTableName}";`);
960
+ const targetCollectionCamel = importCollection(targetTableName);
714
961
 
715
962
  const relHumanName = humanize(relName);
716
963
 
@@ -734,8 +981,7 @@ export function generateCollectionFile(
734
981
  for (const fk of inverseFks) {
735
982
  const sourceTableName = fk.table_name;
736
983
 
737
- const targetCollectionCamel = toCollectionVarName(sourceTableName);
738
- imports.add(`import ${targetCollectionCamel} from "./${sourceTableName}";`);
984
+ const targetCollectionCamel = importCollection(sourceTableName);
739
985
 
740
986
  relationsOutput += `
741
987
  {
@@ -788,8 +1034,7 @@ export function generateCollectionFile(
788
1034
  if (otherFk) {
789
1035
  const targetTableName = otherFk.foreign_table_name;
790
1036
 
791
- const targetCollectionCamel = toCollectionVarName(targetTableName);
792
- imports.add(`import ${targetCollectionCamel} from "./${targetTableName}";`);
1037
+ const targetCollectionCamel = importCollection(targetTableName);
793
1038
 
794
1039
  // Both sides of a many-to-many are `manyToMany`. There is no owning
795
1040
  // and inverse side to pick between any more, so this no longer
@@ -821,18 +1066,69 @@ export function generateCollectionFile(
821
1066
  propsOutput += propertyBlocks.get(key) || "";
822
1067
  }
823
1068
 
1069
+ // ── The admin block ──────────────────────────────────────────────────
1070
+ // `icon` and `propertiesOrder` used to be emitted at the *top level* of the
1071
+ // config, where they have not belonged since the admin block was split out:
1072
+ // `PostgresCollectionConfig` does not declare them, so every generated file
1073
+ // was a type error, and the panel — which reads the block — never saw them.
1074
+ const adminEntries: string[] = [`icon: "${icon}"`];
1075
+
1076
+ if (classification) {
1077
+ const derivedFacts = context.metadata
1078
+ ? buildColumnFacts(meta, context.metadata, enumMap, checkFacts)
1079
+ : undefined;
1080
+
1081
+ if (classification.role === "owned-child") {
1082
+ // The rows are already reachable: every inbound foreign key renders
1083
+ // as a tab on the parent. A second, top-level entry for them is what
1084
+ // turns a navigation of eight nouns into a list of thirty tables.
1085
+ adminEntries.push("hideFromNavigation: true");
1086
+ } else if (classification.role === "lookup") {
1087
+ adminEntries.push('group: "Reference"');
1088
+ }
1089
+
1090
+ if (derivedFacts) {
1091
+ const titleProperty = deriveTitleProperty(derivedFacts);
1092
+ if (titleProperty) adminEntries.push(`titleProperty: ${quote(titleProperty)}`);
1093
+
1094
+ const kanbanProperty = deriveKanbanProperty(derivedFacts);
1095
+ if (kanbanProperty) adminEntries.push(`kanban: {\n columnProperty: ${quote(kanbanProperty)}\n }`);
1096
+
1097
+ const sort = deriveSort(derivedFacts);
1098
+ if (sort) adminEntries.push(`sort: [${quote(sort[0])}, "desc"]`);
1099
+ }
1100
+
1101
+ const listProperties = deriveListProperties(sortedPropertiesOrder, hiddenFromCollection);
1102
+ if (listProperties) {
1103
+ adminEntries.push(`listProperties: ${formatKeyList(listProperties)}`);
1104
+ }
1105
+ }
1106
+
1107
+ adminEntries.push(`propertiesOrder: ${formatKeyList(sortedPropertiesOrder)}`);
1108
+
1109
+ const adminBlock = `\n admin: {\n ${adminEntries.join(",\n ")}\n }`;
1110
+
1111
+ const descriptionBlock = tableComment
1112
+ ? `\n description: ${quote(tableComment)},`
1113
+ : "";
1114
+
1115
+ // The classification is stated in the file because it is a *decision*, and a
1116
+ // decision the reader may disagree with. Naming the evidence tells them
1117
+ // which line to delete when they do.
1118
+ const classificationNote = classification && classification.role !== "entity"
1119
+ ? `\n// Introspected as a ${classification.role}: ${classification.reason}.\n`
1120
+ : "";
1121
+
824
1122
  const collectionVarName = toCollectionVarName(tableName);
825
1123
  const fileContent = `${Array.from(imports).join("\n")}
826
-
1124
+ ${classificationNote}
827
1125
  const ${collectionVarName}: PostgresCollectionConfig = {
828
1126
  name: "${collectionName}",
829
1127
  singularName: "${singular}",
830
1128
  slug: "${tableName}",
831
- table: "${tableName}",
832
- icon: "${icon}",
1129
+ table: "${tableName}",${descriptionBlock}
833
1130
  properties: {${propsOutput}
834
- },${relationsBlock}
835
- propertiesOrder: ${JSON.stringify(sortedPropertiesOrder, null, 8).replace(/]$/, " ]")}
1131
+ },${relationsBlock}${adminBlock}
836
1132
  };
837
1133
 
838
1134
  export default ${collectionVarName};