@rebasepro/server-postgres 0.12.0 → 0.12.1-canary.g009ed95
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-BrUVgjz3.js +57 -0
- package/dist/ensure-collection-policies-BrUVgjz3.js.map +1 -0
- package/dist/ensure-collection-tables-Da2oGkX2.js +650 -0
- package/dist/ensure-collection-tables-Da2oGkX2.js.map +1 -0
- package/dist/index.es.js +816 -9679
- 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 +24 -1
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-BbFOPJ1S.js → src-CzbghKwf.js} +271 -173
- package/dist/src-CzbghKwf.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 +19 -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 +23 -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/FetchService.ts +76 -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 +40 -1
- 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 +46 -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
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a schema's *structure* says about the app on top of it.
|
|
3
|
+
*
|
|
4
|
+
* Introspection has always been a table mirror: one table in, one collection
|
|
5
|
+
* out, one nav entry each, every column a form field. A schema of thirty tables
|
|
6
|
+
* produces thirty sidebar entries, and a panel whose navigation is a list of
|
|
7
|
+
* table names reads as a database browser however good the fields are — which
|
|
8
|
+
* is the actual complaint about generated admin panels, and is structural, not
|
|
9
|
+
* cosmetic.
|
|
10
|
+
*
|
|
11
|
+
* Most of what separates the eight nouns a user navigates by from the thirty
|
|
12
|
+
* tables underneath them is written down in the schema already: which tables
|
|
13
|
+
* only exist to join two others, which are small referenced code lists, which
|
|
14
|
+
* rows cannot outlive a parent row. This module reads that.
|
|
15
|
+
*
|
|
16
|
+
* ## Structure only
|
|
17
|
+
*
|
|
18
|
+
* Nothing here looks at a column or table *name*. Name heuristics — `status`,
|
|
19
|
+
* `*_url`, `image`, `created_at` — are wrong exactly when a schema is not in
|
|
20
|
+
* English, or is domain-specific, or spells things differently, and they are
|
|
21
|
+
* wrong silently. Every rule below is a fact the database enforces: key
|
|
22
|
+
* composition, foreign-key direction and delete rule, uniqueness, nullability,
|
|
23
|
+
* declared type and length, generated-ness, row count.
|
|
24
|
+
*
|
|
25
|
+
* That constraint has a cost, and it is worth stating: a schema that declares
|
|
26
|
+
* nothing beyond `NOT NULL` gives this module very little to work with, and it
|
|
27
|
+
* returns `entity` for everything rather than guessing. Under-classifying is
|
|
28
|
+
* the intended failure mode. A table wrongly hidden from the navigation is a
|
|
29
|
+
* table the user cannot find; a table wrongly left in it is merely the status
|
|
30
|
+
* quo.
|
|
31
|
+
*
|
|
32
|
+
* Pure module: no I/O. Row counts come in on {@link SchemaMetadata.rowCounts},
|
|
33
|
+
* which the caller fills from {@link ./introspect-db-queries.countRowsUpTo} for
|
|
34
|
+
* the tables {@link lookupCandidates} names.
|
|
35
|
+
*/
|
|
36
|
+
import type {
|
|
37
|
+
ForeignKeyRow,
|
|
38
|
+
SchemaMetadata,
|
|
39
|
+
TableColumn,
|
|
40
|
+
TableMeta,
|
|
41
|
+
UniqueConstraintRow
|
|
42
|
+
} from "./introspect-db-logic";
|
|
43
|
+
import { mapPgType } from "./introspect-db-types";
|
|
44
|
+
import type { CheckFactsByTable } from "./introspect-db-constraints";
|
|
45
|
+
|
|
46
|
+
// ── Thresholds ────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The row count above which a referenced table is a real entity rather than a
|
|
50
|
+
* code list. Deliberately low: `pagila.category` has 16 rows and `language` 6,
|
|
51
|
+
* while `actor` has 200 and `country` 109 — the gap between "a fixed set
|
|
52
|
+
* somebody typed once" and "data the app accumulates" is wide, and picking a
|
|
53
|
+
* number in the middle of it costs nothing.
|
|
54
|
+
*/
|
|
55
|
+
export const LOOKUP_MAX_ROWS = 50;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The most payload columns a code list may carry. A code list is an id, a
|
|
59
|
+
* label, and perhaps a sort key or a flag; past that it is a table with
|
|
60
|
+
* attributes, which is an entity.
|
|
61
|
+
*/
|
|
62
|
+
export const LOOKUP_MAX_PAYLOAD_COLUMNS = 3;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The most enum values a board can usefully have as columns. A kanban with
|
|
66
|
+
* thirty columns is a horizontally scrolling table.
|
|
67
|
+
*/
|
|
68
|
+
export const KANBAN_MAX_VALUES = 12;
|
|
69
|
+
|
|
70
|
+
/** Below this, a "board" is one or two columns — a filter, not a board. */
|
|
71
|
+
export const KANBAN_MIN_VALUES = 2;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* How many columns a generated list view shows before it stops being readable.
|
|
75
|
+
* Only applied when a table has more properties than this; a six-column table
|
|
76
|
+
* gets no `listProperties` at all rather than a restatement of its own columns.
|
|
77
|
+
*/
|
|
78
|
+
export const LIST_PROPERTIES_CAP = 6;
|
|
79
|
+
|
|
80
|
+
// ── Roles ─────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What a table *is*, structurally.
|
|
84
|
+
*
|
|
85
|
+
* - `entity` — a thing the app is about. Gets a collection and a nav entry.
|
|
86
|
+
* - `junction` — exists only to relate two other tables. Gets no collection at
|
|
87
|
+
* all; it becomes a many-to-many relation on both sides.
|
|
88
|
+
* - `lookup` — a small, referenced, self-contained code list. Gets a collection,
|
|
89
|
+
* grouped away from the entities rather than listed beside them.
|
|
90
|
+
* - `owned-child` — rows that belong to exactly one parent row and are reached
|
|
91
|
+
* through it. Gets a collection (it is a real table with real rows, and the
|
|
92
|
+
* API still serves it) but no nav entry: it already renders as a tab on its
|
|
93
|
+
* parent.
|
|
94
|
+
*/
|
|
95
|
+
export type TableRole = "entity" | "junction" | "lookup" | "owned-child";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Why a table was called someone's child, weakest last.
|
|
99
|
+
*
|
|
100
|
+
* Carried into the generated file as a comment. A reader who disagrees with the
|
|
101
|
+
* classification needs to see what it was based on to know which line to change.
|
|
102
|
+
*/
|
|
103
|
+
export type OwnershipEvidence =
|
|
104
|
+
/** The only foreign key declared `ON DELETE CASCADE`. */
|
|
105
|
+
| "cascade-delete"
|
|
106
|
+
/** The only foreign key that is part of the table's primary key. */
|
|
107
|
+
| "identifying-key"
|
|
108
|
+
/** The only foreign key that is `NOT NULL`. */
|
|
109
|
+
| "sole-required-key"
|
|
110
|
+
/** First column of a composite primary key made entirely of foreign keys. */
|
|
111
|
+
| "leading-key-column";
|
|
112
|
+
|
|
113
|
+
export interface JunctionShape {
|
|
114
|
+
sourceTable: string;
|
|
115
|
+
sourceColumn: string;
|
|
116
|
+
targetTable: string;
|
|
117
|
+
targetColumn: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface TableClassification {
|
|
121
|
+
table: string;
|
|
122
|
+
role: TableRole;
|
|
123
|
+
/** One line, in prose, for the generated file. */
|
|
124
|
+
reason: string;
|
|
125
|
+
/** Set when `role === "owned-child"`. */
|
|
126
|
+
owner?: { table: string; column: string; evidence: OwnershipEvidence };
|
|
127
|
+
/** Set when `role === "junction"`. */
|
|
128
|
+
junction?: JunctionShape;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── Column-level structural predicates ────────────────────────────────
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* A timestamp the database maintains: a temporal column defaulting to the
|
|
135
|
+
* transaction clock.
|
|
136
|
+
*
|
|
137
|
+
* This is the structural stand-in for the `created_at`/`updated_at` name check.
|
|
138
|
+
* It is strictly better than the name: it catches `fecha_creacion` and
|
|
139
|
+
* `last_update` (pagila's spelling, which the name list misses), and it does not
|
|
140
|
+
* fire on a user-editable `created_at date` column that has no default and which
|
|
141
|
+
* the name check would wrongly make read-only.
|
|
142
|
+
*/
|
|
143
|
+
export function isAutoTimestamp(column: TableColumn): boolean {
|
|
144
|
+
if (mapPgType(column.data_type) !== "date") return false;
|
|
145
|
+
const columnDefault = (column.column_default ?? "").toLowerCase();
|
|
146
|
+
if (!columnDefault) return false;
|
|
147
|
+
return /\b(now\(\)|current_timestamp|current_date|current_time|localtimestamp|localtime|transaction_timestamp\(\)|statement_timestamp\(\)|clock_timestamp\(\))/.test(columnDefault);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A key the database fills in: identity, serial, or a uuid-generating default. */
|
|
151
|
+
export function isGeneratedKey(column: TableColumn): boolean {
|
|
152
|
+
if (column.is_identity === "YES") return true;
|
|
153
|
+
const columnDefault = (column.column_default ?? "").toLowerCase();
|
|
154
|
+
if (!columnDefault) return false;
|
|
155
|
+
return columnDefault.includes("nextval(") ||
|
|
156
|
+
columnDefault.includes("gen_random_uuid") ||
|
|
157
|
+
columnDefault.includes("uuid_generate");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A column Postgres computes; writing to it is an error. */
|
|
161
|
+
export function isGeneratedColumn(column: TableColumn): boolean {
|
|
162
|
+
return column.is_generated === "ALWAYS";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Types that exist to be searched or indexed, never to be typed into.
|
|
167
|
+
*
|
|
168
|
+
* A `tsvector` column is a derived search index — maintained by a trigger, a
|
|
169
|
+
* generated expression, or an application job — and its contents are lexeme
|
|
170
|
+
* positions, not text. Pagila's `film.fulltext` is one, and introspection used
|
|
171
|
+
* to emit it as an ordinary required string: a mandatory form field whose
|
|
172
|
+
* correct value no user can produce, on the sixth column of the list view.
|
|
173
|
+
*/
|
|
174
|
+
export function isDerivedIndexColumn(column: TableColumn): boolean {
|
|
175
|
+
return column.udt_name === "tsvector" || column.udt_name === "tsquery";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Anything the user cannot meaningfully edit, whatever the reason. */
|
|
179
|
+
export function isReadOnlyColumn(column: TableColumn): boolean {
|
|
180
|
+
return isGeneratedColumn(column) || isDerivedIndexColumn(column);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* A string column with a declared maximum length.
|
|
185
|
+
*
|
|
186
|
+
* `varchar(50)` and `text` are the same type to an application but not to the
|
|
187
|
+
* author: choosing a bound is a statement that the value is short and
|
|
188
|
+
* label-like, which is what makes this usable for picking a display column.
|
|
189
|
+
*/
|
|
190
|
+
export function isBoundedString(column: TableColumn): boolean {
|
|
191
|
+
return mapPgType(column.data_type) === "string" &&
|
|
192
|
+
typeof column.character_maximum_length === "number" &&
|
|
193
|
+
column.character_maximum_length > 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* A column carrying data rather than structure: not a key, not a foreign key,
|
|
198
|
+
* not a database-maintained timestamp, not computed.
|
|
199
|
+
*
|
|
200
|
+
* The count of these is what tells a pure join table from an association that
|
|
201
|
+
* carries its own attributes — `northwind.order_details` has the key shape of a
|
|
202
|
+
* junction and three payload columns, so it is not one.
|
|
203
|
+
*/
|
|
204
|
+
export function isPayloadColumn(column: TableColumn, pks: string[], fkColumns: Set<string>): boolean {
|
|
205
|
+
if (pks.includes(column.column_name)) return false;
|
|
206
|
+
if (fkColumns.has(column.column_name)) return false;
|
|
207
|
+
if (isAutoTimestamp(column)) return false;
|
|
208
|
+
if (isGeneratedColumn(column)) return false;
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Foreign-key topology ──────────────────────────────────────────────
|
|
213
|
+
|
|
214
|
+
/** One foreign key, with its columns grouped back together. */
|
|
215
|
+
export interface ForeignKeyConstraint {
|
|
216
|
+
name: string;
|
|
217
|
+
table: string;
|
|
218
|
+
columns: string[];
|
|
219
|
+
foreignTable: string;
|
|
220
|
+
foreignColumns: string[];
|
|
221
|
+
deleteRule?: string;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Groups per-column foreign key rows back into constraints.
|
|
226
|
+
*
|
|
227
|
+
* Rows arrive one per referencing column. A composite key looks exactly like two
|
|
228
|
+
* separate keys until they are grouped by constraint name, and the difference
|
|
229
|
+
* matters: two single-column keys to two tables can be a junction, one
|
|
230
|
+
* two-column key never is.
|
|
231
|
+
*/
|
|
232
|
+
export function groupForeignKeys(fks: ForeignKeyRow[]): ForeignKeyConstraint[] {
|
|
233
|
+
const byName = new Map<string, ForeignKeyConstraint>();
|
|
234
|
+
for (const fk of fks) {
|
|
235
|
+
// Rows from older callers carry no constraint name. Falling back to a
|
|
236
|
+
// per-column key treats each as its own single-column constraint, which
|
|
237
|
+
// is what the generator assumed before names were available.
|
|
238
|
+
const key = `${fk.table_name}::${fk.constraint_name ?? `${fk.column_name}->${fk.foreign_table_name}`}`;
|
|
239
|
+
const existing = byName.get(key);
|
|
240
|
+
if (existing) {
|
|
241
|
+
existing.columns.push(fk.column_name);
|
|
242
|
+
existing.foreignColumns.push(fk.foreign_column_name);
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
byName.set(key, {
|
|
246
|
+
name: fk.constraint_name ?? `${fk.table_name}_${fk.column_name}_fkey`,
|
|
247
|
+
table: fk.table_name,
|
|
248
|
+
columns: [fk.column_name],
|
|
249
|
+
foreignTable: fk.foreign_table_name,
|
|
250
|
+
foreignColumns: [fk.foreign_column_name],
|
|
251
|
+
deleteRule: fk.delete_rule
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
return Array.from(byName.values());
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Column sets that are unique in a table: its primary key and every unique index. */
|
|
258
|
+
function uniqueColumnSets(table: string, pks: string[], uniques: UniqueConstraintRow[]): string[][] {
|
|
259
|
+
const sets: string[][] = [];
|
|
260
|
+
if (pks.length > 0) sets.push(pks);
|
|
261
|
+
for (const unique of uniques) {
|
|
262
|
+
if (unique.table_name === table) sets.push(unique.column_names);
|
|
263
|
+
}
|
|
264
|
+
return sets;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function sameColumnSet(a: string[], b: string[]): boolean {
|
|
268
|
+
if (a.length !== b.length) return false;
|
|
269
|
+
const left = [...a].sort();
|
|
270
|
+
const right = [...b].sort();
|
|
271
|
+
return left.every((value, index) => value === right[index]);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ── Classification ────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/** Everything classification needs, assembled once per run. */
|
|
277
|
+
interface AnalysisContext {
|
|
278
|
+
tables: Map<string, TableMeta>;
|
|
279
|
+
constraintsByTable: Map<string, ForeignKeyConstraint[]>;
|
|
280
|
+
inboundByTable: Map<string, ForeignKeyConstraint[]>;
|
|
281
|
+
uniques: UniqueConstraintRow[];
|
|
282
|
+
rowCounts: Record<string, number>;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function buildContext(metadata: SchemaMetadata, tables: Map<string, TableMeta>): AnalysisContext {
|
|
286
|
+
const allConstraints = groupForeignKeys(metadata.fks);
|
|
287
|
+
const constraintsByTable = new Map<string, ForeignKeyConstraint[]>();
|
|
288
|
+
const inboundByTable = new Map<string, ForeignKeyConstraint[]>();
|
|
289
|
+
|
|
290
|
+
for (const constraint of allConstraints) {
|
|
291
|
+
const outbound = constraintsByTable.get(constraint.table) ?? [];
|
|
292
|
+
outbound.push(constraint);
|
|
293
|
+
constraintsByTable.set(constraint.table, outbound);
|
|
294
|
+
|
|
295
|
+
const inbound = inboundByTable.get(constraint.foreignTable) ?? [];
|
|
296
|
+
inbound.push(constraint);
|
|
297
|
+
inboundByTable.set(constraint.foreignTable, inbound);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
tables,
|
|
302
|
+
constraintsByTable,
|
|
303
|
+
inboundByTable,
|
|
304
|
+
uniques: metadata.uniques,
|
|
305
|
+
rowCounts: metadata.rowCounts
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* A table that exists only to relate two others.
|
|
311
|
+
*
|
|
312
|
+
* Requires all of:
|
|
313
|
+
* - exactly two single-column foreign keys, to two distinct columns;
|
|
314
|
+
* - that pair is unique — it is the primary key, or a unique index covers it.
|
|
315
|
+
* Without this the table can hold the same pair twice, which is a list of
|
|
316
|
+
* events between two things, not a set membership;
|
|
317
|
+
* - no payload columns. A join row that carries a quantity is an association
|
|
318
|
+
* with attributes, and collapsing it into a many-to-many silently drops those
|
|
319
|
+
* attributes from the UI entirely;
|
|
320
|
+
* - nothing references it. A junction with its own dependents is something rows
|
|
321
|
+
* point *at*, so it needs an identity of its own.
|
|
322
|
+
*/
|
|
323
|
+
function classifyJunction(table: string, context: AnalysisContext): TableClassification | null {
|
|
324
|
+
const meta = context.tables.get(table);
|
|
325
|
+
if (!meta) return null;
|
|
326
|
+
|
|
327
|
+
const constraints = context.constraintsByTable.get(table) ?? [];
|
|
328
|
+
if (constraints.length !== 2) return null;
|
|
329
|
+
if (constraints.some((c) => c.columns.length !== 1)) return null;
|
|
330
|
+
|
|
331
|
+
const [first, second] = constraints;
|
|
332
|
+
if (first.columns[0] === second.columns[0]) return null;
|
|
333
|
+
|
|
334
|
+
if ((context.inboundByTable.get(table) ?? []).length > 0) return null;
|
|
335
|
+
|
|
336
|
+
const pair = [first.columns[0], second.columns[0]];
|
|
337
|
+
const uniqueSets = uniqueColumnSets(table, meta.pks, context.uniques);
|
|
338
|
+
if (!uniqueSets.some((set) => sameColumnSet(set, pair))) return null;
|
|
339
|
+
|
|
340
|
+
const fkColumns = new Set(pair);
|
|
341
|
+
const payload = meta.columns.filter((column) => isPayloadColumn(column, meta.pks, fkColumns));
|
|
342
|
+
if (payload.length > 0) return null;
|
|
343
|
+
|
|
344
|
+
return {
|
|
345
|
+
table,
|
|
346
|
+
role: "junction",
|
|
347
|
+
reason: `only relates ${first.foreignTable} to ${second.foreignTable}: two foreign keys, unique together, no other data`,
|
|
348
|
+
junction: {
|
|
349
|
+
sourceTable: first.foreignTable,
|
|
350
|
+
sourceColumn: first.columns[0],
|
|
351
|
+
targetTable: second.foreignTable,
|
|
352
|
+
targetColumn: second.columns[0]
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* A small, referenced, self-contained code list.
|
|
359
|
+
*
|
|
360
|
+
* Requires: something references it, it references nothing, it carries few and
|
|
361
|
+
* simple payload columns, and it is small — where "small" is a real count, not
|
|
362
|
+
* an estimate. `reltuples` is -1 on any table that has never been analyzed,
|
|
363
|
+
* which is every table in a freshly restored dump, so a count that is merely
|
|
364
|
+
* absent must not read as "small".
|
|
365
|
+
*/
|
|
366
|
+
function classifyLookup(table: string, context: AnalysisContext): TableClassification | null {
|
|
367
|
+
if (!isLookupCandidate(table, context)) return null;
|
|
368
|
+
|
|
369
|
+
const rowCount = context.rowCounts[table];
|
|
370
|
+
if (typeof rowCount !== "number") return null;
|
|
371
|
+
// An empty table is not a code list — there is nothing to look up, and a
|
|
372
|
+
// schema-only restore would otherwise classify half the database this way.
|
|
373
|
+
if (rowCount < 1 || rowCount > LOOKUP_MAX_ROWS) return null;
|
|
374
|
+
|
|
375
|
+
const referencedBy = (context.inboundByTable.get(table) ?? []).length;
|
|
376
|
+
return {
|
|
377
|
+
table,
|
|
378
|
+
role: "lookup",
|
|
379
|
+
reason: `a ${rowCount}-row code list: referenced by ${referencedBy} table(s), references none, ${LOOKUP_MAX_PAYLOAD_COLUMNS} or fewer simple columns`
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* The structural half of the lookup test — everything except the row count.
|
|
385
|
+
*
|
|
386
|
+
* Separate because the count costs a query per table, and the caller only wants
|
|
387
|
+
* to pay it for tables that could possibly qualify.
|
|
388
|
+
*/
|
|
389
|
+
function isLookupCandidate(table: string, context: AnalysisContext): boolean {
|
|
390
|
+
const meta = context.tables.get(table);
|
|
391
|
+
if (!meta) return false;
|
|
392
|
+
|
|
393
|
+
if ((context.constraintsByTable.get(table) ?? []).length > 0) return false;
|
|
394
|
+
if ((context.inboundByTable.get(table) ?? []).length === 0) return false;
|
|
395
|
+
|
|
396
|
+
const payload = meta.columns.filter((column) => isPayloadColumn(column, meta.pks, new Set()));
|
|
397
|
+
if (payload.length > LOOKUP_MAX_PAYLOAD_COLUMNS) return false;
|
|
398
|
+
|
|
399
|
+
// A code list holds labels and flags. Documents, arrays, files and dates are
|
|
400
|
+
// content, and content means the rows are worth browsing in their own right.
|
|
401
|
+
//
|
|
402
|
+
// Strings are allowed whether or not they declare a length. Requiring
|
|
403
|
+
// `varchar(n)` looked like a way to say "label, not prose", and it is —
|
|
404
|
+
// except that a schema written any time recently uses `text` for everything,
|
|
405
|
+
// which is the advice Postgres itself gives. Modern pagila is all `text`, so
|
|
406
|
+
// that rule classified nothing at all on it.
|
|
407
|
+
return payload.every((column) => {
|
|
408
|
+
const type = mapPgType(column.data_type);
|
|
409
|
+
return type === "boolean" || type === "number" || type === "string";
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Names the tables whose classification depends on a row count.
|
|
415
|
+
*
|
|
416
|
+
* The caller counts these — and only these — before calling
|
|
417
|
+
* {@link classifyTables}. On a schema of any size this is a handful of tables,
|
|
418
|
+
* and the count itself is capped (see `countRowsUpTo`), so the whole extra cost
|
|
419
|
+
* is bounded regardless of how much data the database holds.
|
|
420
|
+
*/
|
|
421
|
+
export function lookupCandidates(metadata: SchemaMetadata, tables: Map<string, TableMeta>): string[] {
|
|
422
|
+
const context = buildContext(metadata, tables);
|
|
423
|
+
return Array.from(tables.keys()).filter((table) => isLookupCandidate(table, context));
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Rows that belong to one parent row and are reached through it.
|
|
428
|
+
*
|
|
429
|
+
* Requires nothing to reference the table — a table others point at has an
|
|
430
|
+
* identity of its own and belongs in the navigation — and an *unambiguous*
|
|
431
|
+
* owner among its foreign keys. The evidence ladder runs strongest first; each
|
|
432
|
+
* rung is only taken when exactly one key satisfies it, so a table with two
|
|
433
|
+
* equally plausible parents stays an entity rather than being filed under a
|
|
434
|
+
* coin-flip.
|
|
435
|
+
*/
|
|
436
|
+
function classifyOwnedChild(table: string, context: AnalysisContext): TableClassification | null {
|
|
437
|
+
const meta = context.tables.get(table);
|
|
438
|
+
if (!meta) return null;
|
|
439
|
+
|
|
440
|
+
const constraints = context.constraintsByTable.get(table) ?? [];
|
|
441
|
+
if (constraints.length === 0) return null;
|
|
442
|
+
if ((context.inboundByTable.get(table) ?? []).length > 0) return null;
|
|
443
|
+
|
|
444
|
+
const owner = pickOwner(meta, constraints);
|
|
445
|
+
if (!owner) return null;
|
|
446
|
+
|
|
447
|
+
return {
|
|
448
|
+
table,
|
|
449
|
+
role: "owned-child",
|
|
450
|
+
reason: `belongs to ${owner.constraint.foreignTable} (${describeEvidence(owner.evidence)}) and nothing else references it`,
|
|
451
|
+
owner: {
|
|
452
|
+
table: owner.constraint.foreignTable,
|
|
453
|
+
column: owner.constraint.columns[0],
|
|
454
|
+
evidence: owner.evidence
|
|
455
|
+
}
|
|
456
|
+
};
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function describeEvidence(evidence: OwnershipEvidence): string {
|
|
460
|
+
switch (evidence) {
|
|
461
|
+
case "cascade-delete": return "its only ON DELETE CASCADE foreign key";
|
|
462
|
+
case "identifying-key": return "its only foreign key inside the primary key";
|
|
463
|
+
case "sole-required-key": return "its only NOT NULL foreign key";
|
|
464
|
+
case "leading-key-column": return "the leading column of a primary key made only of foreign keys";
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function pickOwner(
|
|
469
|
+
meta: TableMeta,
|
|
470
|
+
constraints: ForeignKeyConstraint[]
|
|
471
|
+
): { constraint: ForeignKeyConstraint; evidence: OwnershipEvidence } | null {
|
|
472
|
+
const singleColumn = constraints.filter((c) => c.columns.length === 1);
|
|
473
|
+
if (singleColumn.length === 0) return null;
|
|
474
|
+
|
|
475
|
+
const nullability = new Map(meta.columns.map((c) => [c.column_name, c.is_nullable === "NO"]));
|
|
476
|
+
|
|
477
|
+
const cascading = singleColumn.filter((c) => c.deleteRule === "CASCADE");
|
|
478
|
+
if (cascading.length === 1) return { constraint: cascading[0], evidence: "cascade-delete" };
|
|
479
|
+
|
|
480
|
+
const identifying = singleColumn.filter((c) => meta.pks.includes(c.columns[0]));
|
|
481
|
+
if (identifying.length === 1) return { constraint: identifying[0], evidence: "identifying-key" };
|
|
482
|
+
|
|
483
|
+
const required = singleColumn.filter((c) => nullability.get(c.columns[0]) === true);
|
|
484
|
+
if (required.length === 1) return { constraint: required[0], evidence: "sole-required-key" };
|
|
485
|
+
|
|
486
|
+
// Last resort, and the only rung that leans on an ordering convention rather
|
|
487
|
+
// than a guarantee: in a table whose whole primary key is foreign keys, the
|
|
488
|
+
// first key column is the parent the rows are filed under.
|
|
489
|
+
// `northwind.order_details` is keyed `(order_id, product_id)` — the rows are
|
|
490
|
+
// lines of an order, which is exactly what that ordering says.
|
|
491
|
+
if (identifying.length === meta.pks.length && meta.pks.length > 1) {
|
|
492
|
+
const leading = identifying.find((c) => c.columns[0] === meta.pks[0]);
|
|
493
|
+
if (leading) return { constraint: leading, evidence: "leading-key-column" };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/**
|
|
500
|
+
* Classifies every table in the schema.
|
|
501
|
+
*
|
|
502
|
+
* Order matters: junction is the most specific and most consequential (the
|
|
503
|
+
* table disappears), so it is tested first; then lookup, which needs no
|
|
504
|
+
* ownership reasoning; then ownership. Anything unmatched is an entity, which
|
|
505
|
+
* is also what every rule falls back to when its evidence is ambiguous.
|
|
506
|
+
*/
|
|
507
|
+
export function classifyTables(
|
|
508
|
+
metadata: SchemaMetadata,
|
|
509
|
+
tables: Map<string, TableMeta>
|
|
510
|
+
): Map<string, TableClassification> {
|
|
511
|
+
const context = buildContext(metadata, tables);
|
|
512
|
+
const result = new Map<string, TableClassification>();
|
|
513
|
+
|
|
514
|
+
for (const table of tables.keys()) {
|
|
515
|
+
const classification =
|
|
516
|
+
classifyJunction(table, context) ??
|
|
517
|
+
classifyLookup(table, context) ??
|
|
518
|
+
classifyOwnedChild(table, context) ?? {
|
|
519
|
+
table,
|
|
520
|
+
role: "entity" as const,
|
|
521
|
+
reason: "no structural evidence that it is a join table, a code list, or owned by another table"
|
|
522
|
+
};
|
|
523
|
+
result.set(table, classification);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
return result;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// ── Presentation derived from structure ───────────────────────────────
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The columns a property-level derivation needs, resolved once.
|
|
533
|
+
*/
|
|
534
|
+
export interface ColumnFacts {
|
|
535
|
+
column: TableColumn;
|
|
536
|
+
isPk: boolean;
|
|
537
|
+
isFk: boolean;
|
|
538
|
+
/** Covered by a single-column unique constraint or unique index. */
|
|
539
|
+
isUniqueAlone: boolean;
|
|
540
|
+
isAutoTimestamp: boolean;
|
|
541
|
+
isGenerated: boolean;
|
|
542
|
+
/** Allowed values, from a Postgres enum type or a readable CHECK. */
|
|
543
|
+
enumValues?: string[];
|
|
544
|
+
propType: string;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export function buildColumnFacts(
|
|
548
|
+
meta: TableMeta,
|
|
549
|
+
metadata: SchemaMetadata,
|
|
550
|
+
enumMap: Map<string, string[]>,
|
|
551
|
+
checkFacts: CheckFactsByTable
|
|
552
|
+
): Map<string, ColumnFacts> {
|
|
553
|
+
const fkColumns = new Set(meta.fks.map((fk) => fk.column_name));
|
|
554
|
+
const singleColumnUniques = new Set(
|
|
555
|
+
metadata.uniques
|
|
556
|
+
.filter((u) => u.table_name === meta.name && u.column_names.length === 1)
|
|
557
|
+
.map((u) => u.column_names[0])
|
|
558
|
+
);
|
|
559
|
+
const tableChecks = checkFacts.get(meta.name);
|
|
560
|
+
|
|
561
|
+
const facts = new Map<string, ColumnFacts>();
|
|
562
|
+
for (const column of meta.columns) {
|
|
563
|
+
const pgEnum = column.data_type === "USER-DEFINED" ? enumMap.get(column.udt_name) : undefined;
|
|
564
|
+
const checkEnum = tableChecks?.get(column.column_name)?.enumValues;
|
|
565
|
+
facts.set(column.column_name, {
|
|
566
|
+
column,
|
|
567
|
+
isPk: meta.pks.includes(column.column_name),
|
|
568
|
+
isFk: fkColumns.has(column.column_name),
|
|
569
|
+
isUniqueAlone: singleColumnUniques.has(column.column_name),
|
|
570
|
+
isAutoTimestamp: isAutoTimestamp(column),
|
|
571
|
+
isGenerated: isGeneratedColumn(column),
|
|
572
|
+
enumValues: pgEnum ?? checkEnum,
|
|
573
|
+
propType: pgEnum ? "string" : mapPgType(column.data_type)
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
return facts;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* The column that identifies a row to a human.
|
|
581
|
+
*
|
|
582
|
+
* Structural, in three rungs, strongest first:
|
|
583
|
+
*
|
|
584
|
+
* 1. A single-column unique constraint on a required string. This is as close
|
|
585
|
+
* as a schema comes to declaring "this is what a row is called": it is the
|
|
586
|
+
* column a person looks a row up by, and the database guarantees it picks
|
|
587
|
+
* out one row.
|
|
588
|
+
* 2. The first required string that declares a length, when the table also has
|
|
589
|
+
* strings that do not. Choosing `varchar(n)` for one column and `text` for
|
|
590
|
+
* another is the author distinguishing a label from prose.
|
|
591
|
+
* 3. The first required string in declaration order. Weak, but it is the same
|
|
592
|
+
* rung the panel's own fallback stands on, and column order carries real
|
|
593
|
+
* information — the identifying column of a table is written near the top of
|
|
594
|
+
* it, in every schema, in every language.
|
|
595
|
+
*
|
|
596
|
+
* Deliberately not: a column called `name`, or `title`. That works on English
|
|
597
|
+
* schemas written by someone who read the same tutorial. This picks
|
|
598
|
+
* `film.title`, `actor.first_name` and `category.name` out of pagila without
|
|
599
|
+
* knowing what any of those words mean.
|
|
600
|
+
*/
|
|
601
|
+
export function deriveTitleProperty(facts: Map<string, ColumnFacts>): string | undefined {
|
|
602
|
+
const candidates = Array.from(facts.values()).filter((f) =>
|
|
603
|
+
!f.isPk && !f.isFk && !f.isGenerated &&
|
|
604
|
+
f.propType === "string" &&
|
|
605
|
+
!f.enumValues &&
|
|
606
|
+
f.column.is_nullable === "NO"
|
|
607
|
+
);
|
|
608
|
+
if (candidates.length === 0) return undefined;
|
|
609
|
+
|
|
610
|
+
const unique = candidates.find((f) => f.isUniqueAlone);
|
|
611
|
+
if (unique) return unique.column.column_name;
|
|
612
|
+
|
|
613
|
+
const bounded = candidates.filter((f) => isBoundedString(f.column));
|
|
614
|
+
if (bounded.length > 0 && bounded.length < candidates.length) {
|
|
615
|
+
return bounded[0].column.column_name;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
return candidates[0].column.column_name;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* The enum column a board should have as its columns.
|
|
623
|
+
*
|
|
624
|
+
* A board needs a small, closed, always-present set of states. `NOT NULL` is
|
|
625
|
+
* required because a null has no column to sit in; the bounds keep out
|
|
626
|
+
* two-state flags (a filter, not a board) and long code lists (a scrolling
|
|
627
|
+
* table). The first qualifying column in declaration order wins, so the output
|
|
628
|
+
* is stable across runs.
|
|
629
|
+
*/
|
|
630
|
+
export function deriveKanbanProperty(facts: Map<string, ColumnFacts>): string | undefined {
|
|
631
|
+
for (const fact of facts.values()) {
|
|
632
|
+
if (fact.isPk || fact.isFk || fact.isGenerated) continue;
|
|
633
|
+
if (!fact.enumValues) continue;
|
|
634
|
+
if (fact.column.is_nullable !== "NO") continue;
|
|
635
|
+
if (fact.enumValues.length < KANBAN_MIN_VALUES || fact.enumValues.length > KANBAN_MAX_VALUES) continue;
|
|
636
|
+
return fact.column.column_name;
|
|
637
|
+
}
|
|
638
|
+
return undefined;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* The column a list should be sorted by, newest first.
|
|
643
|
+
*
|
|
644
|
+
* Only when the table has exactly one database-maintained timestamp. With two —
|
|
645
|
+
* a created and an updated stamp — the two orderings differ and the schema does
|
|
646
|
+
* not say which the user means, so neither is chosen.
|
|
647
|
+
*/
|
|
648
|
+
export function deriveSort(facts: Map<string, ColumnFacts>): [string, "desc"] | undefined {
|
|
649
|
+
const stamps = Array.from(facts.values()).filter((f) => f.isAutoTimestamp);
|
|
650
|
+
if (stamps.length !== 1) return undefined;
|
|
651
|
+
return [stamps[0].column.column_name, "desc"];
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* The first `LIST_PROPERTIES_CAP` visible properties, or nothing.
|
|
656
|
+
*
|
|
657
|
+
* Returning nothing when the table is already narrow matters: `listProperties`
|
|
658
|
+
* that restates every column is config the reader has to check against the
|
|
659
|
+
* property list to discover it does nothing, and it silently stops new columns
|
|
660
|
+
* from appearing in the list view when someone adds one later.
|
|
661
|
+
*
|
|
662
|
+
* `hidden` names the properties already marked `hideFromCollection` — spending
|
|
663
|
+
* one of six columns on a value the list does not render is worse than not
|
|
664
|
+
* capping at all.
|
|
665
|
+
*/
|
|
666
|
+
export function deriveListProperties(propertiesOrder: string[], hidden: ReadonlySet<string> = new Set()): string[] | undefined {
|
|
667
|
+
const visible = propertiesOrder.filter((key) => !hidden.has(key));
|
|
668
|
+
if (visible.length <= LIST_PROPERTIES_CAP) return undefined;
|
|
669
|
+
return visible.slice(0, LIST_PROPERTIES_CAP);
|
|
670
|
+
}
|