@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.
Files changed (91) hide show
  1. package/dist/PostgresBackendDriver.d.ts +1 -1
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/backup/backup-service.d.ts +10 -1
  5. package/dist/backup/pg-tools.d.ts +47 -0
  6. package/dist/backup-service-CD8o_1Sl.js +8999 -0
  7. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  8. package/dist/cli-helpers.d.ts +39 -0
  9. package/dist/connection-BuZ97wsr.js +250 -0
  10. package/dist/connection-BuZ97wsr.js.map +1 -0
  11. package/dist/connection.d.ts +42 -0
  12. package/dist/ensure-collection-policies-BrUVgjz3.js +57 -0
  13. package/dist/ensure-collection-policies-BrUVgjz3.js.map +1 -0
  14. package/dist/ensure-collection-tables-Da2oGkX2.js +650 -0
  15. package/dist/ensure-collection-tables-Da2oGkX2.js.map +1 -0
  16. package/dist/index.es.js +816 -9679
  17. package/dist/index.es.js.map +1 -1
  18. package/dist/policy-CeA1JcxP.js +105 -0
  19. package/dist/policy-CeA1JcxP.js.map +1 -0
  20. package/dist/schema/auth-schema.d.ts +83 -144
  21. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  22. package/dist/schema/ensure-collection-tables.d.ts +44 -2
  23. package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
  24. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  25. package/dist/schema/introspect-db-logic.d.ts +94 -5
  26. package/dist/schema/introspect-db-queries.d.ts +119 -0
  27. package/dist/schema/introspect-db-structure.d.ts +263 -0
  28. package/dist/schema/introspect-db-types.d.ts +11 -0
  29. package/dist/services/FetchService.d.ts +4 -1
  30. package/dist/services/RelationService.d.ts +24 -1
  31. package/dist/services/channel-bus/index.d.ts +1 -7
  32. package/dist/services/collection-helpers.d.ts +24 -1
  33. package/dist/services/dataService.d.ts +3 -1
  34. package/dist/services/row-pipeline.d.ts +1 -1
  35. package/dist/{src-BbFOPJ1S.js → src-CzbghKwf.js} +271 -173
  36. package/dist/src-CzbghKwf.js.map +1 -0
  37. package/dist/{src-Zqwaw3P5.js → src-DoU9yPqq.js} +3 -159
  38. package/dist/src-DoU9yPqq.js.map +1 -0
  39. package/dist/utils/connection-string.d.ts +29 -0
  40. package/dist/utils/drizzle-conditions.d.ts +5 -4
  41. package/dist/utils/pg-error-utils.d.ts +19 -0
  42. package/dist/websocket-B2LsrINK.js +530 -0
  43. package/dist/websocket-B2LsrINK.js.map +1 -0
  44. package/package.json +14 -14
  45. package/src/PostgresAdapter.ts +21 -2
  46. package/src/PostgresBackendDriver.ts +4 -0
  47. package/src/PostgresBootstrapper.ts +192 -33
  48. package/src/auth/ensure-tables.ts +164 -9
  49. package/src/auth/services.ts +24 -2
  50. package/src/backup/backup-cli.ts +41 -2
  51. package/src/backup/backup-service.ts +38 -5
  52. package/src/backup/pg-tools.ts +96 -3
  53. package/src/cli-helpers.ts +70 -0
  54. package/src/cli.ts +44 -26
  55. package/src/collections/validate-relations.ts +15 -0
  56. package/src/connection.ts +73 -0
  57. package/src/data-transformer.ts +9 -3
  58. package/src/databasePoolManager.ts +5 -2
  59. package/src/schema/auth-schema.ts +30 -19
  60. package/src/schema/ensure-collection-policies.ts +105 -0
  61. package/src/schema/ensure-collection-tables.test.ts +105 -9
  62. package/src/schema/ensure-collection-tables.ts +220 -32
  63. package/src/schema/generate-drizzle-schema-logic.ts +23 -6
  64. package/src/schema/generate-postgres-ddl-logic.ts +382 -19
  65. package/src/schema/introspect-db-constraints.ts +385 -0
  66. package/src/schema/introspect-db-inference.ts +18 -8
  67. package/src/schema/introspect-db-logic.ts +385 -71
  68. package/src/schema/introspect-db-queries.ts +326 -0
  69. package/src/schema/introspect-db-structure.ts +670 -0
  70. package/src/schema/introspect-db-types.ts +56 -0
  71. package/src/schema/introspect-db.ts +37 -80
  72. package/src/schema/introspect-runtime.test.ts +56 -8
  73. package/src/schema/introspect-runtime.ts +31 -9
  74. package/src/security/policy-drift.test.ts +11 -3
  75. package/src/services/FetchService.ts +76 -14
  76. package/src/services/PersistService.ts +20 -6
  77. package/src/services/RelationService.ts +249 -48
  78. package/src/services/channel-bus/index.ts +0 -9
  79. package/src/services/collection-helpers.ts +40 -1
  80. package/src/services/dataService.ts +3 -1
  81. package/src/services/realtimeService.ts +3 -3
  82. package/src/services/row-pipeline.ts +1 -1
  83. package/src/utils/connection-string.ts +58 -0
  84. package/src/utils/drizzle-conditions.ts +31 -6
  85. package/src/utils/pg-error-utils.ts +46 -0
  86. package/src/websocket.ts +18 -9
  87. package/dist/chunk-DSJWtz9O.js +0 -40
  88. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  89. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  90. package/dist/src-BbFOPJ1S.js.map +0 -1
  91. 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
+ }