@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.
- package/dist/auth/services.d.ts +6 -1
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/{backup-service-DLb2drIH.js → backup-service-CD8o_1Sl.js} +136 -10
- package/dist/backup-service-CD8o_1Sl.js.map +1 -0
- package/dist/{ensure-collection-policies-Dv21KDMJ.js → ensure-collection-policies-ViG8XiPn.js} +2 -2
- package/dist/{ensure-collection-policies-Dv21KDMJ.js.map → ensure-collection-policies-ViG8XiPn.js.map} +1 -1
- package/dist/{ensure-collection-tables-CT6xHB1d.js → ensure-collection-tables-CBQdOETu.js} +2 -2
- package/dist/{ensure-collection-tables-CT6xHB1d.js.map → ensure-collection-tables-CBQdOETu.js.map} +1 -1
- package/dist/index.es.js +768 -537
- package/dist/index.es.js.map +1 -1
- package/dist/schema/introspect-db-constraints.d.ts +57 -0
- package/dist/schema/introspect-db-logic.d.ts +94 -5
- package/dist/schema/introspect-db-queries.d.ts +119 -0
- package/dist/schema/introspect-db-structure.d.ts +263 -0
- package/dist/schema/introspect-db-types.d.ts +11 -0
- package/dist/services/RelationService.d.ts +24 -1
- package/dist/services/channel-bus/index.d.ts +1 -7
- package/dist/services/collection-helpers.d.ts +36 -2
- package/dist/{src-BkdpiQdw.js → src-DlPBctw_.js} +72 -6
- package/dist/src-DlPBctw_.js.map +1 -0
- package/dist/utils/connection-string.d.ts +29 -0
- package/dist/utils/drizzle-conditions.d.ts +5 -4
- package/dist/utils/pg-error-utils.d.ts +16 -0
- package/package.json +6 -6
- package/src/auth/services.ts +6 -3
- package/src/backup/backup-cli.ts +41 -2
- package/src/backup/backup-service.ts +38 -5
- package/src/backup/pg-tools.ts +96 -3
- package/src/cli.ts +11 -4
- package/src/collections/validate-relations.ts +15 -0
- package/src/data-transformer.ts +9 -3
- package/src/schema/generate-drizzle-schema-logic.ts +26 -1
- package/src/schema/introspect-db-constraints.ts +385 -0
- package/src/schema/introspect-db-inference.ts +18 -8
- package/src/schema/introspect-db-logic.ts +364 -68
- package/src/schema/introspect-db-queries.ts +326 -0
- package/src/schema/introspect-db-structure.ts +670 -0
- package/src/schema/introspect-db-types.ts +56 -0
- package/src/schema/introspect-db.ts +37 -80
- package/src/services/BranchService.ts +66 -28
- package/src/services/FetchService.ts +14 -0
- package/src/services/PersistService.ts +20 -6
- package/src/services/RelationService.ts +211 -45
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/collection-helpers.ts +69 -3
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +31 -6
- package/src/utils/pg-error-utils.ts +19 -0
- package/dist/backup-service-DLb2drIH.js.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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};
|