@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/backup/backup-service.d.ts +10 -1
  5. package/dist/backup/pg-tools.d.ts +47 -0
  6. package/dist/backup-service-CD8o_1Sl.js +8999 -0
  7. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  8. package/dist/cli-helpers.d.ts +39 -0
  9. package/dist/connection-BuZ97wsr.js +250 -0
  10. package/dist/connection-BuZ97wsr.js.map +1 -0
  11. package/dist/connection.d.ts +42 -0
  12. package/dist/ensure-collection-policies-ViG8XiPn.js +57 -0
  13. package/dist/ensure-collection-policies-ViG8XiPn.js.map +1 -0
  14. package/dist/ensure-collection-tables-CBQdOETu.js +650 -0
  15. package/dist/ensure-collection-tables-CBQdOETu.js.map +1 -0
  16. package/dist/index.es.js +1231 -10031
  17. package/dist/index.es.js.map +1 -1
  18. package/dist/policy-CeA1JcxP.js +105 -0
  19. package/dist/policy-CeA1JcxP.js.map +1 -0
  20. package/dist/schema/auth-schema.d.ts +83 -144
  21. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  22. package/dist/schema/ensure-collection-tables.d.ts +44 -2
  23. package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
  24. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  25. package/dist/schema/introspect-db-logic.d.ts +94 -5
  26. package/dist/schema/introspect-db-queries.d.ts +119 -0
  27. package/dist/schema/introspect-db-structure.d.ts +263 -0
  28. package/dist/schema/introspect-db-types.d.ts +11 -0
  29. package/dist/services/FetchService.d.ts +4 -1
  30. package/dist/services/RelationService.d.ts +24 -1
  31. package/dist/services/channel-bus/index.d.ts +1 -7
  32. package/dist/services/collection-helpers.d.ts +36 -2
  33. package/dist/services/dataService.d.ts +3 -1
  34. package/dist/services/row-pipeline.d.ts +1 -1
  35. package/dist/{src-BbFOPJ1S.js → src-DlPBctw_.js} +299 -173
  36. package/dist/src-DlPBctw_.js.map +1 -0
  37. package/dist/{src-Zqwaw3P5.js → src-DoU9yPqq.js} +3 -159
  38. package/dist/src-DoU9yPqq.js.map +1 -0
  39. package/dist/utils/connection-string.d.ts +29 -0
  40. package/dist/utils/drizzle-conditions.d.ts +5 -4
  41. package/dist/utils/pg-error-utils.d.ts +35 -0
  42. package/dist/websocket-B2LsrINK.js +530 -0
  43. package/dist/websocket-B2LsrINK.js.map +1 -0
  44. package/package.json +14 -14
  45. package/src/PostgresAdapter.ts +21 -2
  46. package/src/PostgresBackendDriver.ts +4 -0
  47. package/src/PostgresBootstrapper.ts +192 -33
  48. package/src/auth/ensure-tables.ts +164 -9
  49. package/src/auth/services.ts +24 -2
  50. package/src/backup/backup-cli.ts +41 -2
  51. package/src/backup/backup-service.ts +38 -5
  52. package/src/backup/pg-tools.ts +96 -3
  53. package/src/cli-helpers.ts +70 -0
  54. package/src/cli.ts +44 -26
  55. package/src/collections/validate-relations.ts +15 -0
  56. package/src/connection.ts +73 -0
  57. package/src/data-transformer.ts +9 -3
  58. package/src/databasePoolManager.ts +5 -2
  59. package/src/schema/auth-schema.ts +30 -19
  60. package/src/schema/ensure-collection-policies.ts +105 -0
  61. package/src/schema/ensure-collection-tables.test.ts +105 -9
  62. package/src/schema/ensure-collection-tables.ts +220 -32
  63. package/src/schema/generate-drizzle-schema-logic.ts +42 -6
  64. package/src/schema/generate-postgres-ddl-logic.ts +382 -19
  65. package/src/schema/introspect-db-constraints.ts +385 -0
  66. package/src/schema/introspect-db-inference.ts +18 -8
  67. package/src/schema/introspect-db-logic.ts +385 -71
  68. package/src/schema/introspect-db-queries.ts +326 -0
  69. package/src/schema/introspect-db-structure.ts +670 -0
  70. package/src/schema/introspect-db-types.ts +56 -0
  71. package/src/schema/introspect-db.ts +37 -80
  72. package/src/schema/introspect-runtime.test.ts +56 -8
  73. package/src/schema/introspect-runtime.ts +31 -9
  74. package/src/security/policy-drift.test.ts +11 -3
  75. package/src/services/BranchService.ts +66 -28
  76. package/src/services/FetchService.ts +90 -14
  77. package/src/services/PersistService.ts +20 -6
  78. package/src/services/RelationService.ts +249 -48
  79. package/src/services/channel-bus/index.ts +0 -9
  80. package/src/services/collection-helpers.ts +69 -3
  81. package/src/services/dataService.ts +3 -1
  82. package/src/services/realtimeService.ts +3 -3
  83. package/src/services/row-pipeline.ts +1 -1
  84. package/src/utils/connection-string.ts +58 -0
  85. package/src/utils/drizzle-conditions.ts +31 -6
  86. package/src/utils/pg-error-utils.ts +65 -0
  87. package/src/websocket.ts +18 -9
  88. package/dist/chunk-DSJWtz9O.js +0 -40
  89. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  90. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  91. package/dist/src-BbFOPJ1S.js.map +0 -1
  92. package/dist/src-Zqwaw3P5.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 {
@@ -67,6 +144,24 @@ const IRREGULAR_SINGULARS: Record<string, string> = {
67
144
  phenomena: "phenomenon"
68
145
  };
69
146
 
147
+ /**
148
+ * Plurals in "-ves" whose singular really ends in f/fe, and which of the two.
149
+ *
150
+ * A blanket "-ves" -> "-f" rule gets `knives` -> `knif`, and mangles every
151
+ * ordinary "-ive" noun that happens to be plural along with it: `archives` ->
152
+ * `archif`, `objectives` -> `objectif`. The set of English words that genuinely
153
+ * swap f/fe for ves is small and closed, so it is listed rather than guessed —
154
+ * anything else ending in "ves" drops the trailing 's' like any other plural.
155
+ *
156
+ * Matched on the whole word, not on the suffix: `olives` ends in `lives`.
157
+ */
158
+ const VES_SINGULAR_ENDINGS: Record<string, "f" | "fe"> = {
159
+ calves: "f", dwarves: "f", elves: "f", halves: "f", hooves: "f",
160
+ leaves: "f", loaves: "f", scarves: "f", selves: "f", sheaves: "f",
161
+ shelves: "f", thieves: "f", wharves: "f", wolves: "f",
162
+ knives: "fe", lives: "fe", wives: "fe"
163
+ };
164
+
70
165
  /** Words ending in 's' that are already singular. */
71
166
  const UNCOUNTABLE = new Set([
72
167
  "status", "campus", "virus", "bus", "plus", "census",
@@ -100,9 +195,9 @@ export function singularize(word: string): string {
100
195
  if (lower.endsWith("ies") && lower.length > 3) {
101
196
  return word.slice(0, -3) + "y";
102
197
  }
103
- if (lower.endsWith("ves")) {
104
- // e.g. "wolves" -> "wolf", "leaves" -> "leaf"
105
- return word.slice(0, -3) + "f";
198
+ if (VES_SINGULAR_ENDINGS[lower]) {
199
+ // e.g. "wolves" -> "wolf", "knives" -> "knife"
200
+ return word.slice(0, -3) + VES_SINGULAR_ENDINGS[lower];
106
201
  }
107
202
  if (lower.endsWith("ches") || lower.endsWith("shes") || lower.endsWith("sses") || lower.endsWith("xes") || lower.endsWith("zes")) {
108
203
  return word.slice(0, -2);
@@ -142,54 +237,7 @@ export function getIconForTable(tableName: string): string {
142
237
  return "Database";
143
238
  }
144
239
 
145
- /**
146
- * Map a PostgreSQL data type to a Rebase property type.
147
- */
148
- export function mapPgType(dataType: string): string {
149
- const dt = dataType.toLowerCase();
150
-
151
- // Interval MUST be checked before numeric ("interval" contains "int")
152
- if (dt === "interval") return "string";
153
-
154
- // Array types MUST be checked before numeric ("_int4" contains "int")
155
- if (dt === "array" || dt.startsWith("_")) return "array";
156
-
157
- // Numeric types
158
- if (
159
- dt.includes("int") || // integer, smallint, bigint
160
- dt.includes("numeric") ||
161
- dt.includes("decimal") ||
162
- dt.includes("serial") || // serial, bigserial
163
- dt === "real" ||
164
- dt === "float4" ||
165
- dt === "float8" ||
166
- dt === "double precision" ||
167
- dt === "money"
168
- ) {
169
- return "number";
170
- }
171
-
172
- // Boolean
173
- if (dt.includes("bool")) return "boolean";
174
-
175
- // Date / Time
176
- if (dt.includes("time") || dt.includes("date")) return "date";
177
-
178
- // JSON
179
- if (dt === "json" || dt === "jsonb") return "map";
180
-
181
- // Binary
182
- if (dt === "bytea") return "binary";
183
-
184
- // Network types
185
- if (dt === "inet" || dt === "cidr" || dt === "macaddr" || dt === "macaddr8") return "string";
186
-
187
- // UUID
188
- if (dt === "uuid") return "string";
189
-
190
- // Text/varchar/char — default to string
191
- return "string";
192
- }
240
+ export { mapPgType };
193
241
 
194
242
  // ── Build the enum map from query results ─────────────────────────────
195
243
 
@@ -228,6 +276,20 @@ export function buildTablesMap(
228
276
 
229
277
  // ── Identify join tables ──────────────────────────────────────────────
230
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
+ */
231
293
  export function identifyJoinTables(tablesMap: Map<string, TableMeta>): Set<string> {
232
294
  const joinTables = new Set<string>();
233
295
  for (const [tableName, meta] of tablesMap.entries()) {
@@ -501,6 +563,83 @@ export interface GeneratedFile {
501
563
  content: string;
502
564
  }
503
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
+
504
643
  /**
505
644
  * Generate the full TypeScript file content for a single collection.
506
645
  * Pure function — no I/O.
@@ -512,18 +651,53 @@ export function generateCollectionFile(
512
651
  joinTables: Set<string>,
513
652
  tablesMap: Map<string, TableMeta>,
514
653
  enumMap: Map<string, string[]>,
515
- sampleData?: Record<string, unknown>[]
654
+ sampleData?: Record<string, unknown>[],
655
+ context: GenerationContext = {}
516
656
  ): string {
517
657
  const collectionName = humanize(tableName);
518
658
  const singular = singularize(collectionName);
519
659
  const icon = getIconForTable(tableName);
520
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
+
521
677
  const imports = new Set<string>(['import { PostgresCollectionConfig } from "@rebasepro/types";']);
522
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
+
523
695
  let propsOutput = "";
524
696
  let relationsOutput = "";
525
697
  const orderEntries: PropertyOrderEntry[] = [];
526
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>();
527
701
  let columnIndex = 0;
528
702
 
529
703
  // Detect composite primary keys
@@ -558,20 +732,33 @@ export function generateCollectionFile(
558
732
  if (inferred.extra) inferenceExtra = inferred.extra;
559
733
  }
560
734
 
735
+ const columnChecks = tableChecks?.get(col.column_name);
736
+
561
737
  // Enum values — generate real enum from the PG enum
562
738
  if (isEnumColumn && colEnumValues) {
563
739
  const enumEntries = colEnumValues
564
740
  .map((v) => `{ id: "${v}", label: "${humanize(v)}" }`)
565
741
  .join(", ");
566
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}],`;
567
752
  }
568
753
 
569
754
  // Date auto-value heuristics
570
755
  if (finalPropType === "date") {
571
756
  if (colNameLower === "created_at" || colNameLower === "createdat") {
572
757
  extra += "\n autoValue: \"on_create\",\n admin: {\n readOnly: true,\n hideFromCollection: true\n },";
758
+ hiddenFromCollection.add(col.column_name);
573
759
  } else if (colNameLower === "updated_at" || colNameLower === "updatedat") {
574
760
  extra += "\n autoValue: \"on_update\",\n admin: {\n readOnly: true,\n hideFromCollection: true\n },";
761
+ hiddenFromCollection.add(col.column_name);
575
762
  } else if (col.column_default && (col.column_default.includes("now()") || col.column_default.includes("CURRENT_TIMESTAMP"))) {
576
763
  extra += "\n autoValue: \"on_create\",\n admin: {\n readOnly: true\n },";
577
764
  }
@@ -608,7 +795,12 @@ export function generateCollectionFile(
608
795
  } else if (colNameLower === "description" || colNameLower === "summary" || colNameLower === "excerpt") {
609
796
  extra += "\n admin: {\n multiline: true\n },";
610
797
  } else if (colNameLower === "content" || colNameLower === "body") {
611
- 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 },";
612
804
  } else if (col.data_type === "text") {
613
805
  extra += "\n admin: {\n multiline: true\n },";
614
806
  }
@@ -620,6 +812,63 @@ export function generateCollectionFile(
620
812
  if (!extra.endsWith(",")) extra += ",";
621
813
  }
622
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
+
623
872
  // Identify IDs (unless already inferred as UUID/CUID by inferenceEngine)
624
873
  if (meta.pks.includes(col.column_name)) {
625
874
  if (isCompositePk) {
@@ -638,7 +887,10 @@ export function generateCollectionFile(
638
887
  extra += `\n dimensions: ${dims},`;
639
888
  }
640
889
 
641
- 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)) {
642
894
  if (extra.includes("validation: {")) {
643
895
  extra = extra.replace("validation: {", "validation: {\n required: true,");
644
896
  } else {
@@ -672,12 +924,26 @@ export function generateCollectionFile(
672
924
  for (const fk of meta.fks) {
673
925
  const targetTableName = fk.foreign_table_name;
674
926
  if (!joinTables.has(targetTableName)) {
675
- let relName = fk.column_name.replace(/_id$/, "");
676
- if (meta.pks.includes(fk.column_name) && relName === fk.column_name) {
677
- // If the FK is also the PK and its name doesn't imply a relation (like "id"),
678
- // use the target table name to avoid conflicting with the PK property.
679
- relName = targetTableName;
680
- }
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
+ );
681
947
  // Push the relation property key, not the FK column name
682
948
  orderEntries.push({
683
949
  key: relName,
@@ -691,8 +957,7 @@ export function generateCollectionFile(
691
957
  }
692
958
  });
693
959
 
694
- const targetCollectionCamel = toCollectionVarName(targetTableName);
695
- imports.add(`import ${targetCollectionCamel} from "./${targetTableName}";`);
960
+ const targetCollectionCamel = importCollection(targetTableName);
696
961
 
697
962
  const relHumanName = humanize(relName);
698
963
 
@@ -716,8 +981,7 @@ export function generateCollectionFile(
716
981
  for (const fk of inverseFks) {
717
982
  const sourceTableName = fk.table_name;
718
983
 
719
- const targetCollectionCamel = toCollectionVarName(sourceTableName);
720
- imports.add(`import ${targetCollectionCamel} from "./${sourceTableName}";`);
984
+ const targetCollectionCamel = importCollection(sourceTableName);
721
985
 
722
986
  relationsOutput += `
723
987
  {
@@ -770,8 +1034,7 @@ export function generateCollectionFile(
770
1034
  if (otherFk) {
771
1035
  const targetTableName = otherFk.foreign_table_name;
772
1036
 
773
- const targetCollectionCamel = toCollectionVarName(targetTableName);
774
- imports.add(`import ${targetCollectionCamel} from "./${targetTableName}";`);
1037
+ const targetCollectionCamel = importCollection(targetTableName);
775
1038
 
776
1039
  // Both sides of a many-to-many are `manyToMany`. There is no owning
777
1040
  // and inverse side to pick between any more, so this no longer
@@ -803,18 +1066,69 @@ export function generateCollectionFile(
803
1066
  propsOutput += propertyBlocks.get(key) || "";
804
1067
  }
805
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
+
806
1122
  const collectionVarName = toCollectionVarName(tableName);
807
1123
  const fileContent = `${Array.from(imports).join("\n")}
808
-
1124
+ ${classificationNote}
809
1125
  const ${collectionVarName}: PostgresCollectionConfig = {
810
1126
  name: "${collectionName}",
811
1127
  singularName: "${singular}",
812
1128
  slug: "${tableName}",
813
- table: "${tableName}",
814
- icon: "${icon}",
1129
+ table: "${tableName}",${descriptionBlock}
815
1130
  properties: {${propsOutput}
816
- },${relationsBlock}
817
- propertiesOrder: ${JSON.stringify(sortedPropertiesOrder, null, 8).replace(/]$/, " ]")}
1131
+ },${relationsBlock}${adminBlock}
818
1132
  };
819
1133
 
820
1134
  export default ${collectionVarName};