@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.
- package/dist/PostgresBackendDriver.d.ts +1 -1
- package/dist/PostgresBootstrapper.d.ts +25 -1
- package/dist/auth/services.d.ts +21 -0
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/backup-service-CD8o_1Sl.js +8999 -0
- package/dist/backup-service-CD8o_1Sl.js.map +1 -0
- package/dist/cli-helpers.d.ts +39 -0
- package/dist/connection-BuZ97wsr.js +250 -0
- package/dist/connection-BuZ97wsr.js.map +1 -0
- package/dist/connection.d.ts +42 -0
- package/dist/ensure-collection-policies-ViG8XiPn.js +57 -0
- package/dist/ensure-collection-policies-ViG8XiPn.js.map +1 -0
- package/dist/ensure-collection-tables-CBQdOETu.js +650 -0
- package/dist/ensure-collection-tables-CBQdOETu.js.map +1 -0
- package/dist/index.es.js +1231 -10031
- package/dist/index.es.js.map +1 -1
- package/dist/policy-CeA1JcxP.js +105 -0
- package/dist/policy-CeA1JcxP.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +83 -144
- package/dist/schema/ensure-collection-policies.d.ts +60 -0
- package/dist/schema/ensure-collection-tables.d.ts +44 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -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/FetchService.d.ts +4 -1
- 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/services/dataService.d.ts +3 -1
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-BbFOPJ1S.js → src-DlPBctw_.js} +299 -173
- package/dist/src-DlPBctw_.js.map +1 -0
- package/dist/{src-Zqwaw3P5.js → src-DoU9yPqq.js} +3 -159
- package/dist/src-DoU9yPqq.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 +35 -0
- package/dist/websocket-B2LsrINK.js +530 -0
- package/dist/websocket-B2LsrINK.js.map +1 -0
- package/package.json +14 -14
- package/src/PostgresAdapter.ts +21 -2
- package/src/PostgresBackendDriver.ts +4 -0
- package/src/PostgresBootstrapper.ts +192 -33
- package/src/auth/ensure-tables.ts +164 -9
- package/src/auth/services.ts +24 -2
- 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-helpers.ts +70 -0
- package/src/cli.ts +44 -26
- package/src/collections/validate-relations.ts +15 -0
- package/src/connection.ts +73 -0
- package/src/data-transformer.ts +9 -3
- package/src/databasePoolManager.ts +5 -2
- package/src/schema/auth-schema.ts +30 -19
- package/src/schema/ensure-collection-policies.ts +105 -0
- package/src/schema/ensure-collection-tables.test.ts +105 -9
- package/src/schema/ensure-collection-tables.ts +220 -32
- package/src/schema/generate-drizzle-schema-logic.ts +42 -6
- package/src/schema/generate-postgres-ddl-logic.ts +382 -19
- 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 +385 -71
- 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/schema/introspect-runtime.test.ts +56 -8
- package/src/schema/introspect-runtime.ts +31 -9
- package/src/security/policy-drift.test.ts +11 -3
- package/src/services/BranchService.ts +66 -28
- package/src/services/FetchService.ts +90 -14
- package/src/services/PersistService.ts +20 -6
- package/src/services/RelationService.ts +249 -48
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/collection-helpers.ts +69 -3
- package/src/services/dataService.ts +3 -1
- package/src/services/realtimeService.ts +3 -3
- package/src/services/row-pipeline.ts +1 -1
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +31 -6
- package/src/utils/pg-error-utils.ts +65 -0
- package/src/websocket.ts +18 -9
- package/dist/chunk-DSJWtz9O.js +0 -40
- package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
- package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
- package/dist/src-BbFOPJ1S.js.map +0 -1
- 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
|
|
104
|
-
// e.g. "wolves" -> "wolf", "
|
|
105
|
-
return word.slice(0, -3) +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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};
|