@rebasepro/server-postgres 0.12.1-canary.g4e7bcbf → 0.12.1-canary.g52d71ee

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/auth/services.d.ts +6 -1
  2. package/dist/backup/backup-service.d.ts +10 -1
  3. package/dist/backup/pg-tools.d.ts +47 -0
  4. package/dist/{backup-service-DLb2drIH.js → backup-service-CD8o_1Sl.js} +136 -10
  5. package/dist/backup-service-CD8o_1Sl.js.map +1 -0
  6. package/dist/{ensure-collection-policies-Dv21KDMJ.js → ensure-collection-policies-ViG8XiPn.js} +2 -2
  7. package/dist/{ensure-collection-policies-Dv21KDMJ.js.map → ensure-collection-policies-ViG8XiPn.js.map} +1 -1
  8. package/dist/{ensure-collection-tables-CT6xHB1d.js → ensure-collection-tables-CBQdOETu.js} +2 -2
  9. package/dist/{ensure-collection-tables-CT6xHB1d.js.map → ensure-collection-tables-CBQdOETu.js.map} +1 -1
  10. package/dist/index.es.js +768 -537
  11. package/dist/index.es.js.map +1 -1
  12. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  13. package/dist/schema/introspect-db-logic.d.ts +94 -5
  14. package/dist/schema/introspect-db-queries.d.ts +119 -0
  15. package/dist/schema/introspect-db-structure.d.ts +263 -0
  16. package/dist/schema/introspect-db-types.d.ts +11 -0
  17. package/dist/services/RelationService.d.ts +24 -1
  18. package/dist/services/channel-bus/index.d.ts +1 -7
  19. package/dist/services/collection-helpers.d.ts +36 -2
  20. package/dist/{src-BkdpiQdw.js → src-DlPBctw_.js} +72 -6
  21. package/dist/src-DlPBctw_.js.map +1 -0
  22. package/dist/utils/connection-string.d.ts +29 -0
  23. package/dist/utils/drizzle-conditions.d.ts +5 -4
  24. package/dist/utils/pg-error-utils.d.ts +16 -0
  25. package/package.json +6 -6
  26. package/src/auth/services.ts +6 -3
  27. package/src/backup/backup-cli.ts +41 -2
  28. package/src/backup/backup-service.ts +38 -5
  29. package/src/backup/pg-tools.ts +96 -3
  30. package/src/cli.ts +11 -4
  31. package/src/collections/validate-relations.ts +15 -0
  32. package/src/data-transformer.ts +9 -3
  33. package/src/schema/generate-drizzle-schema-logic.ts +26 -1
  34. package/src/schema/introspect-db-constraints.ts +385 -0
  35. package/src/schema/introspect-db-inference.ts +18 -8
  36. package/src/schema/introspect-db-logic.ts +364 -68
  37. package/src/schema/introspect-db-queries.ts +326 -0
  38. package/src/schema/introspect-db-structure.ts +670 -0
  39. package/src/schema/introspect-db-types.ts +56 -0
  40. package/src/schema/introspect-db.ts +37 -80
  41. package/src/services/BranchService.ts +66 -28
  42. package/src/services/FetchService.ts +14 -0
  43. package/src/services/PersistService.ts +20 -6
  44. package/src/services/RelationService.ts +211 -45
  45. package/src/services/channel-bus/index.ts +0 -9
  46. package/src/services/collection-helpers.ts +69 -3
  47. package/src/utils/connection-string.ts +58 -0
  48. package/src/utils/drizzle-conditions.ts +31 -6
  49. package/src/utils/pg-error-utils.ts +19 -0
  50. package/dist/backup-service-DLb2drIH.js.map +0 -1
  51. package/dist/src-BkdpiQdw.js.map +0 -1
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Reading validation rules out of CHECK constraints.
3
+ *
4
+ * A CHECK constraint is the schema author stating a rule the database already
5
+ * enforces. Introspection has never read them, so a generated form let the user
6
+ * type a value the database was always going to reject — the rule was written
7
+ * down, in the schema, and the UI asked anyway.
8
+ *
9
+ * Everything here parses the *normalized* text `pg_get_constraintdef` produces,
10
+ * not what the author typed: Postgres re-renders the expression from its parse
11
+ * tree, so `CHECK (price > 0)` on a `numeric` column always comes back as
12
+ * `CHECK ((price > (0)::numeric))`. That normalization is what makes a
13
+ * string-level parser tractable — the input is generated, and the shapes are
14
+ * few.
15
+ *
16
+ * Constraints this cannot read are skipped in full. A partially-understood
17
+ * constraint is worse than an unread one: it would produce validation that
18
+ * *narrows* differently from the database, so a value the UI accepts still
19
+ * fails on write, or — worse — a value the database allows is refused in the
20
+ * form with no way to see why.
21
+ *
22
+ * Pure module: no I/O, no logging.
23
+ */
24
+ import type { CheckConstraintRow } from "./introspect-db-logic";
25
+ /** What a table's CHECK constraints say about one column. */
26
+ export interface ColumnCheckFacts {
27
+ /** Allowed values, from `IN (…)` / `= ANY (ARRAY[…])` / `= 'literal'`. */
28
+ enumValues?: string[];
29
+ /** `x >= n` */
30
+ min?: number;
31
+ /** `x <= n` */
32
+ max?: number;
33
+ /** `x > n` */
34
+ moreThan?: number;
35
+ /** `x < n` */
36
+ lessThan?: number;
37
+ /** `length(x) >= n` */
38
+ lengthMin?: number;
39
+ /** `length(x) <= n` */
40
+ lengthMax?: number;
41
+ }
42
+ /** Per-table, per-column facts. */
43
+ export type CheckFactsByTable = Map<string, Map<string, ColumnCheckFacts>>;
44
+ /** Removes one layer of wrapping parentheses, repeatedly, when balanced. */
45
+ export declare function unwrapParens(input: string): string;
46
+ /** Drops trailing `::type` casts, including array and quoted forms. */
47
+ export declare function stripCasts(input: string): string;
48
+ /**
49
+ * Reads one constraint definition into per-column facts.
50
+ *
51
+ * Returns an empty map for anything not understood — including a constraint
52
+ * that is understood but spans two columns (`start_date < end_date`), which no
53
+ * per-property validation rule can express.
54
+ */
55
+ export declare function parseCheckDefinition(definition: string): Map<string, ColumnCheckFacts>;
56
+ /** Merges every readable CHECK in the schema into per-table, per-column facts. */
57
+ export declare function parseCheckConstraints(rows: CheckConstraintRow[]): CheckFactsByTable;
@@ -1,5 +1,10 @@
1
+ import { mapPgType } from "./introspect-db-types";
2
+ import type { CheckFactsByTable } from "./introspect-db-constraints";
3
+ import type { TableClassification } from "./introspect-db-structure";
1
4
  export interface TableRow {
2
5
  table_name: string;
6
+ /** True for the parent of a partitioned table (`relkind = 'p'`). */
7
+ is_partitioned?: boolean;
3
8
  }
4
9
  export interface TableColumn {
5
10
  table_name: string;
@@ -9,6 +14,18 @@ export interface TableColumn {
9
14
  is_nullable: string;
10
15
  column_default: string | null;
11
16
  atttypmod: number | null;
17
+ /** 1-based position in the table, as declared. */
18
+ ordinal_position?: number;
19
+ /** `"ALWAYS"` for a generated column, `"NEVER"` otherwise. */
20
+ is_generated?: string;
21
+ /** `"YES"` for an identity column. */
22
+ is_identity?: string;
23
+ /** `"ALWAYS"` or `"BY DEFAULT"` on an identity column. */
24
+ identity_generation?: string | null;
25
+ /** The declared `varchar(n)` / `char(n)` bound, if any. */
26
+ character_maximum_length?: number | null;
27
+ numeric_precision?: number | null;
28
+ numeric_scale?: number | null;
12
29
  }
13
30
  export interface EnumValue {
14
31
  enum_name: string;
@@ -24,6 +41,53 @@ export interface ForeignKeyRow {
24
41
  column_name: string;
25
42
  foreign_table_name: string;
26
43
  foreign_column_name: string;
44
+ /** Name of the FK constraint — the only way to tell composite keys apart. */
45
+ constraint_name?: string;
46
+ /** 1-based position of this column within its constraint. */
47
+ ordinal?: number;
48
+ /** `"CASCADE"`, `"RESTRICT"`, `"SET NULL"`, `"SET DEFAULT"`, `"NO ACTION"`. */
49
+ delete_rule?: string;
50
+ }
51
+ /** A unique constraint or unique index, as an ordered column list. */
52
+ export interface UniqueConstraintRow {
53
+ table_name: string;
54
+ constraint_name: string;
55
+ column_names: string[];
56
+ }
57
+ /** A CHECK constraint, as `pg_get_constraintdef` renders it. */
58
+ export interface CheckConstraintRow {
59
+ table_name: string;
60
+ constraint_name: string;
61
+ definition: string;
62
+ }
63
+ /** A `COMMENT ON TABLE` (null `column_name`) or `COMMENT ON COLUMN`. */
64
+ export interface CommentRow {
65
+ table_name: string;
66
+ column_name: string | null;
67
+ comment: string;
68
+ }
69
+ /**
70
+ * Everything one introspection run reads from the database.
71
+ *
72
+ * Passed around as one value so a new signal means a new field here rather than
73
+ * a new parameter on every function between the query and the generator — the
74
+ * shape `generateCollectionFile` had grown to seven positional arguments by.
75
+ */
76
+ export interface SchemaMetadata {
77
+ schema: string;
78
+ tables: TableRow[];
79
+ columns: TableColumn[];
80
+ enumValues: EnumValue[];
81
+ pks: PrimaryKeyRow[];
82
+ fks: ForeignKeyRow[];
83
+ uniques: UniqueConstraintRow[];
84
+ checks: CheckConstraintRow[];
85
+ comments: CommentRow[];
86
+ /**
87
+ * Row counts for the tables that needed one, capped — see `countRowsUpTo`.
88
+ * Absent for every table introspection never had a reason to count.
89
+ */
90
+ rowCounts: Record<string, number>;
27
91
  }
28
92
  export interface TableMeta {
29
93
  name: string;
@@ -38,12 +102,23 @@ export declare function singularize(word: string): string;
38
102
  */
39
103
  export declare function toCollectionVarName(tableName: string): string;
40
104
  export declare function getIconForTable(tableName: string): string;
41
- /**
42
- * Map a PostgreSQL data type to a Rebase property type.
43
- */
44
- export declare function mapPgType(dataType: string): string;
105
+ export { mapPgType };
45
106
  export declare function buildEnumMap(enumValues: EnumValue[]): Map<string, string[]>;
46
107
  export declare function buildTablesMap(tables: TableRow[], columns: TableColumn[], pks: PrimaryKeyRow[], fks: ForeignKeyRow[]): Map<string, TableMeta>;
108
+ /**
109
+ * Join tables, identified by column name.
110
+ *
111
+ * Superseded for the CLI by `classifyTables` in `./introspect-db-structure`,
112
+ * which asks the database instead: two single-column keys, unique together, no
113
+ * payload column, nothing referencing the table. This rule folds away
114
+ * `northwind.order_details` — which has the key shape and carries unit price,
115
+ * quantity and discount — because it recognises `id`, `created_at` and
116
+ * `updated_at` by name and calls everything else a foreign key.
117
+ *
118
+ * Still used by `./introspect-runtime`, which builds collections in memory from
119
+ * a narrower set of catalog queries and has no unique-constraint or row-count
120
+ * data to reason with.
121
+ */
47
122
  export declare function identifyJoinTables(tablesMap: Map<string, TableMeta>): Set<string>;
48
123
  /**
49
124
  * Property metadata used to compute display priority.
@@ -93,11 +168,25 @@ export interface GeneratedFile {
93
168
  fileName: string;
94
169
  content: string;
95
170
  }
171
+ /**
172
+ * The structural analysis a run can hand the generator.
173
+ *
174
+ * Optional in full, and the generator degrades to exactly its previous output
175
+ * without it. That is not politeness towards old callers: three existing test
176
+ * suites and the `rebase init` scaffold path build a `TableMeta` by hand and
177
+ * have no database to read constraints or row counts from, and they must keep
178
+ * producing a valid collection.
179
+ */
180
+ export interface GenerationContext {
181
+ metadata?: SchemaMetadata;
182
+ classifications?: Map<string, TableClassification>;
183
+ checkFacts?: CheckFactsByTable;
184
+ }
96
185
  /**
97
186
  * Generate the full TypeScript file content for a single collection.
98
187
  * Pure function — no I/O.
99
188
  */
100
- export declare function generateCollectionFile(tableName: string, meta: TableMeta, allFks: ForeignKeyRow[], joinTables: Set<string>, tablesMap: Map<string, TableMeta>, enumMap: Map<string, string[]>, sampleData?: Record<string, unknown>[]): string;
189
+ export declare function generateCollectionFile(tableName: string, meta: TableMeta, allFks: ForeignKeyRow[], joinTables: Set<string>, tablesMap: Map<string, TableMeta>, enumMap: Map<string, string[]>, sampleData?: Record<string, unknown>[], context?: GenerationContext): string;
101
190
  /**
102
191
  * Generate the content for an index.ts file that re-exports all collections.
103
192
  */
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The catalog queries introspection runs, and the shape they produce.
3
+ *
4
+ * These live apart from {@link ./introspect-db.ts} (the CLI entry point) on
5
+ * purpose: the fixture-capture script and the live e2e read a database through
6
+ * this module too, so what the tests see is what the CLI sees. A query that
7
+ * only exists inside the CLI's `main()` can only be tested by running the CLI.
8
+ *
9
+ * No side effects beyond `SELECT`. Introspection reads a database it does not
10
+ * own — it must never `ANALYZE`, create a temp table, or otherwise write.
11
+ */
12
+ import type { SchemaMetadata } from "./introspect-db-logic";
13
+ /** The subset of `pg.Client` this module needs, so tests can pass a fake. */
14
+ export interface QueryableClient {
15
+ query<R = Record<string, unknown>>(text: string, values?: unknown[]): Promise<{
16
+ rows: R[];
17
+ }>;
18
+ }
19
+ /**
20
+ * Base tables, excluding partitions.
21
+ *
22
+ * `relispartition` is the reason this reads `pg_class` rather than
23
+ * `information_schema.tables`, which reports every partition as a base table of
24
+ * its own. Pagila partitions `payment` by month; introspected through
25
+ * information_schema it yields `payment` plus 26 near-identical
26
+ * `payment_p2022_*` collections, each with its own nav entry.
27
+ *
28
+ * `relkind = 'p'` (the partitioned parent) is included and `'r'` partitions are
29
+ * dropped, which is the right way round: the parent is the queryable table.
30
+ */
31
+ export declare const TABLES_QUERY = "\n SELECT c.relname AS table_name,\n c.relkind = 'p' AS is_partitioned\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE n.nspname = $1\n AND c.relkind IN ('r', 'p')\n AND NOT c.relispartition\n AND c.relname NOT LIKE 'drizzle_%'\n AND c.relname NOT LIKE 'rebase_%'\n ORDER BY c.relname\n";
32
+ /**
33
+ * Columns, with the catalog facts that say a column is not the user's to edit.
34
+ *
35
+ * `is_generated`/`is_identity` are read for that reason: a generated column
36
+ * rejects any write, so a form that offers it is offering a field that always
37
+ * errors. `character_maximum_length` is the declared `varchar(n)` bound —
38
+ * already enforced by the database, and free to surface as validation.
39
+ */
40
+ export declare const COLUMNS_QUERY = "\n SELECT\n c.table_name,\n c.column_name,\n c.data_type,\n c.udt_name,\n c.is_nullable,\n c.column_default,\n c.ordinal_position,\n c.is_generated,\n c.is_identity,\n c.identity_generation,\n c.character_maximum_length,\n c.numeric_precision,\n c.numeric_scale,\n (SELECT a.atttypmod FROM pg_attribute a\n JOIN pg_class pc ON a.attrelid = pc.oid\n WHERE pc.relname = c.table_name\n AND a.attname = c.column_name\n AND pc.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema)) as atttypmod\n FROM information_schema.columns c\n WHERE c.table_schema = $1\n ORDER BY c.table_name, c.ordinal_position\n";
41
+ export declare const ENUMS_QUERY = "\n SELECT t.typname AS enum_name,\n e.enumlabel AS enum_value,\n e.enumsortorder AS sort_order\n FROM pg_type t\n JOIN pg_enum e ON t.oid = e.enumtypid\n JOIN pg_namespace n ON t.typnamespace = n.oid\n WHERE n.nspname = $1\n ORDER BY t.typname, e.enumsortorder\n";
42
+ /**
43
+ * Primary key columns, in key order.
44
+ *
45
+ * `ORDER BY k.ord` matters for composite keys: the position of a column inside
46
+ * the key is what tells a two-column PK made of two foreign keys apart from an
47
+ * ordinary composite key, and `= ANY(i.indkey)` (the shape this replaced)
48
+ * returns catalog order instead.
49
+ */
50
+ export declare const PRIMARY_KEYS_QUERY = "\n SELECT t.relname AS table_name,\n a.attname AS column_name\n FROM pg_index i\n JOIN pg_class t ON t.oid = i.indrelid\n JOIN pg_namespace n ON n.oid = t.relnamespace\n CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)\n JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum\n WHERE i.indisprimary AND n.nspname = $1\n ORDER BY t.relname, k.ord\n";
51
+ /**
52
+ * Foreign keys, one row per referencing column, with the delete rule.
53
+ *
54
+ * Read from `pg_constraint` rather than through
55
+ * `information_schema.constraint_column_usage`, which does not preserve the
56
+ * pairing on a composite foreign key: it reports the cross product of the
57
+ * constraint's columns, so a two-column FK comes back as four rows, two of them
58
+ * pairing the wrong columns. `unnest(...) WITH ORDINALITY` on both sides joined
59
+ * on the ordinal keeps each referencing column with the column it references.
60
+ *
61
+ * `confdeltype` is carried because `ON DELETE CASCADE` is the schema author
62
+ * saying, in the database, that the child cannot outlive the parent — the one
63
+ * unambiguous declaration of ownership a schema contains.
64
+ *
65
+ * Constraints declared on a *partition* are attributed to the partition root,
66
+ * because that is the table this run generates a collection for. Pagila declares
67
+ * `payment`'s three foreign keys on each monthly partition and none on the
68
+ * parent, so reading `pg_constraint` at face value gives the `payment`
69
+ * collection no relations at all. `pick = 1` then keeps one row per logical key,
70
+ * preferring the root's own constraint when it has one, so a schema that
71
+ * declares the key on the parent *and* inherits it onto 26 partitions does not
72
+ * yield 27 copies of the same relation.
73
+ */
74
+ export declare const FOREIGN_KEYS_QUERY = "\n WITH fk AS (\n SELECT con.oid,\n con.conname,\n con.conrelid,\n con.confrelid,\n con.conkey,\n con.confkey,\n con.confdeltype,\n COALESCE(pg_partition_root(con.conrelid), con.conrelid) AS root_oid,\n row_number() OVER (\n PARTITION BY COALESCE(pg_partition_root(con.conrelid), con.conrelid),\n con.confrelid, con.conkey, con.confkey\n ORDER BY (con.conrelid = COALESCE(pg_partition_root(con.conrelid), con.conrelid)) DESC,\n con.oid\n ) AS pick\n FROM pg_constraint con\n JOIN pg_namespace n ON n.oid = con.connamespace\n WHERE con.contype = 'f' AND n.nspname = $1\n )\n SELECT root.relname AS table_name,\n sa.attname AS column_name,\n tgt.relname AS foreign_table_name,\n ta.attname AS foreign_column_name,\n fk.conname AS constraint_name,\n s.ord::int AS ordinal,\n CASE fk.confdeltype\n WHEN 'a' THEN 'NO ACTION'\n WHEN 'r' THEN 'RESTRICT'\n WHEN 'c' THEN 'CASCADE'\n WHEN 'n' THEN 'SET NULL'\n WHEN 'd' THEN 'SET DEFAULT'\n END AS delete_rule\n FROM fk\n JOIN pg_class root ON root.oid = fk.root_oid\n JOIN pg_class tgt ON tgt.oid = fk.confrelid\n CROSS JOIN LATERAL unnest(fk.conkey) WITH ORDINALITY AS s(attnum, ord)\n JOIN pg_attribute sa ON sa.attrelid = fk.conrelid AND sa.attnum = s.attnum\n CROSS JOIN LATERAL unnest(fk.confkey) WITH ORDINALITY AS f(attnum, ord)\n JOIN pg_attribute ta ON ta.attrelid = fk.confrelid AND ta.attnum = f.attnum\n WHERE fk.pick = 1 AND s.ord = f.ord\n ORDER BY root.relname, fk.conname, s.ord\n";
75
+ /**
76
+ * Unique constraints and unique indexes, as ordered column lists.
77
+ *
78
+ * Indexes rather than constraints alone, because `CREATE UNIQUE INDEX` and
79
+ * `ADD CONSTRAINT ... UNIQUE` produce the same guarantee and schemas use both.
80
+ * Partial indexes (`indpred IS NOT NULL`) are excluded: they promise uniqueness
81
+ * only over the rows matching their predicate, which is not the promise a
82
+ * `validation: { unique: true }` field makes.
83
+ */
84
+ export declare const UNIQUE_CONSTRAINTS_QUERY = "\n SELECT t.relname AS table_name,\n ic.relname AS constraint_name,\n -- ::text, because the driver has no parser for an array of `name`\n -- and hands back the raw `{a,b}` literal as a string. Every consumer\n -- here indexes and compares it as an array, and a string of that\n -- shape fails those silently rather than loudly.\n array_agg(a.attname::text ORDER BY k.ord) AS column_names\n FROM pg_index i\n JOIN pg_class t ON t.oid = i.indrelid\n JOIN pg_class ic ON ic.oid = i.indexrelid\n JOIN pg_namespace n ON n.oid = t.relnamespace\n CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)\n JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum\n WHERE i.indisunique\n AND NOT i.indisprimary\n AND i.indpred IS NULL\n AND n.nspname = $1\n AND NOT t.relispartition\n GROUP BY t.relname, ic.relname\n ORDER BY t.relname, ic.relname\n";
85
+ /**
86
+ * CHECK constraints, as the source text Postgres reproduces them from.
87
+ *
88
+ * `pg_get_constraintdef` output is normalized by the server — the parser in
89
+ * {@link ./introspect-db-constraints.ts} reads that normalized form, not
90
+ * whatever the author typed.
91
+ */
92
+ export declare const CHECK_CONSTRAINTS_QUERY = "\n SELECT DISTINCT\n root.relname AS table_name,\n con.conname AS constraint_name,\n pg_get_constraintdef(con.oid) AS definition\n FROM pg_constraint con\n JOIN pg_namespace n ON n.oid = con.connamespace\n JOIN pg_class root ON root.oid = COALESCE(pg_partition_root(con.conrelid), con.conrelid)\n WHERE con.contype = 'c'\n AND n.nspname = $1\n ORDER BY root.relname, con.conname\n";
93
+ /**
94
+ * `COMMENT ON TABLE` / `COMMENT ON COLUMN`, which is documentation the author
95
+ * already wrote and which nothing downstream has ever read.
96
+ */
97
+ export declare const COMMENTS_QUERY = "\n SELECT t.relname AS table_name,\n a.attname AS column_name,\n d.description AS comment\n FROM pg_description d\n JOIN pg_class t ON t.oid = d.objoid\n JOIN pg_namespace n ON n.oid = t.relnamespace\n LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.objsubid\n WHERE n.nspname = $1\n AND t.relkind IN ('r', 'p')\n AND (d.objsubid = 0 OR a.attname IS NOT NULL)\n ORDER BY t.relname, d.objsubid\n";
98
+ /**
99
+ * Counts a table's rows, stopping once the answer can only be "more than
100
+ * `limit`".
101
+ *
102
+ * Introspection asks this to tell a small reference table from a table that
103
+ * merely looks like one (see `classifyTables`). `reltuples` would answer for
104
+ * free but lies on a table that has never been analyzed — which is every table
105
+ * in a database restored from a dump, i.e. exactly the case introspection meets
106
+ * — and reports -1 there rather than an error, so the lie is silent.
107
+ *
108
+ * The subquery's LIMIT caps the work at `limit + 1` rows however large the
109
+ * table is, so this stays cheap on a table with a billion rows.
110
+ */
111
+ export declare function countRowsUpTo(client: QueryableClient, schema: string, table: string, limit: number): Promise<number>;
112
+ /**
113
+ * Reads everything the generator needs, in one pass.
114
+ *
115
+ * `rowCounts` is deliberately absent here: it costs a query per table and only
116
+ * a handful of tables can possibly need it. The caller fills it in for the
117
+ * candidates `lookupCandidates()` names.
118
+ */
119
+ export declare function readSchemaMetadata(client: QueryableClient, schema: string): Promise<SchemaMetadata>;
@@ -0,0 +1,263 @@
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 { ForeignKeyRow, SchemaMetadata, TableColumn, TableMeta } from "./introspect-db-logic";
37
+ import type { CheckFactsByTable } from "./introspect-db-constraints";
38
+ /**
39
+ * The row count above which a referenced table is a real entity rather than a
40
+ * code list. Deliberately low: `pagila.category` has 16 rows and `language` 6,
41
+ * while `actor` has 200 and `country` 109 — the gap between "a fixed set
42
+ * somebody typed once" and "data the app accumulates" is wide, and picking a
43
+ * number in the middle of it costs nothing.
44
+ */
45
+ export declare const LOOKUP_MAX_ROWS = 50;
46
+ /**
47
+ * The most payload columns a code list may carry. A code list is an id, a
48
+ * label, and perhaps a sort key or a flag; past that it is a table with
49
+ * attributes, which is an entity.
50
+ */
51
+ export declare const LOOKUP_MAX_PAYLOAD_COLUMNS = 3;
52
+ /**
53
+ * The most enum values a board can usefully have as columns. A kanban with
54
+ * thirty columns is a horizontally scrolling table.
55
+ */
56
+ export declare const KANBAN_MAX_VALUES = 12;
57
+ /** Below this, a "board" is one or two columns — a filter, not a board. */
58
+ export declare const KANBAN_MIN_VALUES = 2;
59
+ /**
60
+ * How many columns a generated list view shows before it stops being readable.
61
+ * Only applied when a table has more properties than this; a six-column table
62
+ * gets no `listProperties` at all rather than a restatement of its own columns.
63
+ */
64
+ export declare const LIST_PROPERTIES_CAP = 6;
65
+ /**
66
+ * What a table *is*, structurally.
67
+ *
68
+ * - `entity` — a thing the app is about. Gets a collection and a nav entry.
69
+ * - `junction` — exists only to relate two other tables. Gets no collection at
70
+ * all; it becomes a many-to-many relation on both sides.
71
+ * - `lookup` — a small, referenced, self-contained code list. Gets a collection,
72
+ * grouped away from the entities rather than listed beside them.
73
+ * - `owned-child` — rows that belong to exactly one parent row and are reached
74
+ * through it. Gets a collection (it is a real table with real rows, and the
75
+ * API still serves it) but no nav entry: it already renders as a tab on its
76
+ * parent.
77
+ */
78
+ export type TableRole = "entity" | "junction" | "lookup" | "owned-child";
79
+ /**
80
+ * Why a table was called someone's child, weakest last.
81
+ *
82
+ * Carried into the generated file as a comment. A reader who disagrees with the
83
+ * classification needs to see what it was based on to know which line to change.
84
+ */
85
+ export type OwnershipEvidence =
86
+ /** The only foreign key declared `ON DELETE CASCADE`. */
87
+ "cascade-delete"
88
+ /** The only foreign key that is part of the table's primary key. */
89
+ | "identifying-key"
90
+ /** The only foreign key that is `NOT NULL`. */
91
+ | "sole-required-key"
92
+ /** First column of a composite primary key made entirely of foreign keys. */
93
+ | "leading-key-column";
94
+ export interface JunctionShape {
95
+ sourceTable: string;
96
+ sourceColumn: string;
97
+ targetTable: string;
98
+ targetColumn: string;
99
+ }
100
+ export interface TableClassification {
101
+ table: string;
102
+ role: TableRole;
103
+ /** One line, in prose, for the generated file. */
104
+ reason: string;
105
+ /** Set when `role === "owned-child"`. */
106
+ owner?: {
107
+ table: string;
108
+ column: string;
109
+ evidence: OwnershipEvidence;
110
+ };
111
+ /** Set when `role === "junction"`. */
112
+ junction?: JunctionShape;
113
+ }
114
+ /**
115
+ * A timestamp the database maintains: a temporal column defaulting to the
116
+ * transaction clock.
117
+ *
118
+ * This is the structural stand-in for the `created_at`/`updated_at` name check.
119
+ * It is strictly better than the name: it catches `fecha_creacion` and
120
+ * `last_update` (pagila's spelling, which the name list misses), and it does not
121
+ * fire on a user-editable `created_at date` column that has no default and which
122
+ * the name check would wrongly make read-only.
123
+ */
124
+ export declare function isAutoTimestamp(column: TableColumn): boolean;
125
+ /** A key the database fills in: identity, serial, or a uuid-generating default. */
126
+ export declare function isGeneratedKey(column: TableColumn): boolean;
127
+ /** A column Postgres computes; writing to it is an error. */
128
+ export declare function isGeneratedColumn(column: TableColumn): boolean;
129
+ /**
130
+ * Types that exist to be searched or indexed, never to be typed into.
131
+ *
132
+ * A `tsvector` column is a derived search index — maintained by a trigger, a
133
+ * generated expression, or an application job — and its contents are lexeme
134
+ * positions, not text. Pagila's `film.fulltext` is one, and introspection used
135
+ * to emit it as an ordinary required string: a mandatory form field whose
136
+ * correct value no user can produce, on the sixth column of the list view.
137
+ */
138
+ export declare function isDerivedIndexColumn(column: TableColumn): boolean;
139
+ /** Anything the user cannot meaningfully edit, whatever the reason. */
140
+ export declare function isReadOnlyColumn(column: TableColumn): boolean;
141
+ /**
142
+ * A string column with a declared maximum length.
143
+ *
144
+ * `varchar(50)` and `text` are the same type to an application but not to the
145
+ * author: choosing a bound is a statement that the value is short and
146
+ * label-like, which is what makes this usable for picking a display column.
147
+ */
148
+ export declare function isBoundedString(column: TableColumn): boolean;
149
+ /**
150
+ * A column carrying data rather than structure: not a key, not a foreign key,
151
+ * not a database-maintained timestamp, not computed.
152
+ *
153
+ * The count of these is what tells a pure join table from an association that
154
+ * carries its own attributes — `northwind.order_details` has the key shape of a
155
+ * junction and three payload columns, so it is not one.
156
+ */
157
+ export declare function isPayloadColumn(column: TableColumn, pks: string[], fkColumns: Set<string>): boolean;
158
+ /** One foreign key, with its columns grouped back together. */
159
+ export interface ForeignKeyConstraint {
160
+ name: string;
161
+ table: string;
162
+ columns: string[];
163
+ foreignTable: string;
164
+ foreignColumns: string[];
165
+ deleteRule?: string;
166
+ }
167
+ /**
168
+ * Groups per-column foreign key rows back into constraints.
169
+ *
170
+ * Rows arrive one per referencing column. A composite key looks exactly like two
171
+ * separate keys until they are grouped by constraint name, and the difference
172
+ * matters: two single-column keys to two tables can be a junction, one
173
+ * two-column key never is.
174
+ */
175
+ export declare function groupForeignKeys(fks: ForeignKeyRow[]): ForeignKeyConstraint[];
176
+ /**
177
+ * Names the tables whose classification depends on a row count.
178
+ *
179
+ * The caller counts these — and only these — before calling
180
+ * {@link classifyTables}. On a schema of any size this is a handful of tables,
181
+ * and the count itself is capped (see `countRowsUpTo`), so the whole extra cost
182
+ * is bounded regardless of how much data the database holds.
183
+ */
184
+ export declare function lookupCandidates(metadata: SchemaMetadata, tables: Map<string, TableMeta>): string[];
185
+ /**
186
+ * Classifies every table in the schema.
187
+ *
188
+ * Order matters: junction is the most specific and most consequential (the
189
+ * table disappears), so it is tested first; then lookup, which needs no
190
+ * ownership reasoning; then ownership. Anything unmatched is an entity, which
191
+ * is also what every rule falls back to when its evidence is ambiguous.
192
+ */
193
+ export declare function classifyTables(metadata: SchemaMetadata, tables: Map<string, TableMeta>): Map<string, TableClassification>;
194
+ /**
195
+ * The columns a property-level derivation needs, resolved once.
196
+ */
197
+ export interface ColumnFacts {
198
+ column: TableColumn;
199
+ isPk: boolean;
200
+ isFk: boolean;
201
+ /** Covered by a single-column unique constraint or unique index. */
202
+ isUniqueAlone: boolean;
203
+ isAutoTimestamp: boolean;
204
+ isGenerated: boolean;
205
+ /** Allowed values, from a Postgres enum type or a readable CHECK. */
206
+ enumValues?: string[];
207
+ propType: string;
208
+ }
209
+ export declare function buildColumnFacts(meta: TableMeta, metadata: SchemaMetadata, enumMap: Map<string, string[]>, checkFacts: CheckFactsByTable): Map<string, ColumnFacts>;
210
+ /**
211
+ * The column that identifies a row to a human.
212
+ *
213
+ * Structural, in three rungs, strongest first:
214
+ *
215
+ * 1. A single-column unique constraint on a required string. This is as close
216
+ * as a schema comes to declaring "this is what a row is called": it is the
217
+ * column a person looks a row up by, and the database guarantees it picks
218
+ * out one row.
219
+ * 2. The first required string that declares a length, when the table also has
220
+ * strings that do not. Choosing `varchar(n)` for one column and `text` for
221
+ * another is the author distinguishing a label from prose.
222
+ * 3. The first required string in declaration order. Weak, but it is the same
223
+ * rung the panel's own fallback stands on, and column order carries real
224
+ * information — the identifying column of a table is written near the top of
225
+ * it, in every schema, in every language.
226
+ *
227
+ * Deliberately not: a column called `name`, or `title`. That works on English
228
+ * schemas written by someone who read the same tutorial. This picks
229
+ * `film.title`, `actor.first_name` and `category.name` out of pagila without
230
+ * knowing what any of those words mean.
231
+ */
232
+ export declare function deriveTitleProperty(facts: Map<string, ColumnFacts>): string | undefined;
233
+ /**
234
+ * The enum column a board should have as its columns.
235
+ *
236
+ * A board needs a small, closed, always-present set of states. `NOT NULL` is
237
+ * required because a null has no column to sit in; the bounds keep out
238
+ * two-state flags (a filter, not a board) and long code lists (a scrolling
239
+ * table). The first qualifying column in declaration order wins, so the output
240
+ * is stable across runs.
241
+ */
242
+ export declare function deriveKanbanProperty(facts: Map<string, ColumnFacts>): string | undefined;
243
+ /**
244
+ * The column a list should be sorted by, newest first.
245
+ *
246
+ * Only when the table has exactly one database-maintained timestamp. With two —
247
+ * a created and an updated stamp — the two orderings differ and the schema does
248
+ * not say which the user means, so neither is chosen.
249
+ */
250
+ export declare function deriveSort(facts: Map<string, ColumnFacts>): [string, "desc"] | undefined;
251
+ /**
252
+ * The first `LIST_PROPERTIES_CAP` visible properties, or nothing.
253
+ *
254
+ * Returning nothing when the table is already narrow matters: `listProperties`
255
+ * that restates every column is config the reader has to check against the
256
+ * property list to discover it does nothing, and it silently stops new columns
257
+ * from appearing in the list view when someone adds one later.
258
+ *
259
+ * `hidden` names the properties already marked `hideFromCollection` — spending
260
+ * one of six columns on a value the list does not render is worse than not
261
+ * capping at all.
262
+ */
263
+ export declare function deriveListProperties(propertiesOrder: string[], hidden?: ReadonlySet<string>): string[] | undefined;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The PostgreSQL type → Rebase property type mapping.
3
+ *
4
+ * Split out of `introspect-db-logic` so that the structural analysis can use it
5
+ * without importing the generator, which imports the analysis. Re-exported from
6
+ * `introspect-db-logic` so existing callers keep their import path.
7
+ */
8
+ /**
9
+ * Map a PostgreSQL data type to a Rebase property type.
10
+ */
11
+ export declare function mapPgType(dataType: string): string;
@@ -1,5 +1,5 @@
1
1
  import { DrizzleClient } from "../interfaces";
2
- import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany } from "@rebasepro/types";
2
+ import { CollectionConfig, FilterValues, ResolvedRelation, ResolvedManyToMany, ResolvedHasMany, ResolvedHasOne } from "@rebasepro/types";
3
3
  import { type ResolvedVia } from "@rebasepro/types";
4
4
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
5
5
  import type { NestedPathHop } from "./nested-path";
@@ -56,6 +56,29 @@ export declare class RelationService {
56
56
  * hand a tenant's rows to its neighbour — say so instead.
57
57
  */
58
58
  private assertSingleKeyAddressable;
59
+ /**
60
+ * What the target's foreign key holds, for each of these parent rows.
61
+ *
62
+ * Ordinarily the parent's id, and then this is free. When the relation
63
+ * declares a `sourceKey` the two are different values, and the mapping
64
+ * between them lives in the source table — so it costs one SELECT, issued
65
+ * once for the whole batch rather than per parent.
66
+ *
67
+ * Both directions come back because both are needed and deriving one from
68
+ * the other by hand is how a batch loader ends up attributing a child to the
69
+ * wrong parent: reads translate id → key to build the WHERE, and then
70
+ * translate key → id to attribute each row that comes back.
71
+ */
72
+ /**
73
+ * The value a related row's foreign key must hold to belong to this parent.
74
+ *
75
+ * `undefined` when the parent's source key is null — which is not an error
76
+ * here, only in the callers that were about to write it. Exposed for
77
+ * {@link PersistService}, which stamps this onto a child created under a
78
+ * nested path and would otherwise write the id and lose the row.
79
+ */
80
+ parentKeyValue(parentCollection: CollectionConfig, relation: ResolvedHasOne | ResolvedHasMany, parentId: string | number, db?: DrizzleClient): Promise<string | number | undefined>;
81
+ private resolveSourceKeys;
59
82
  /**
60
83
  * Fetch rows related to a parent row through a specific relation
61
84
  */
@@ -15,7 +15,7 @@
15
15
  * `types/channel_bus.ts` for the contract such a package implements.
16
16
  */
17
17
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
18
- import { type ChannelBus, type ChannelBusConfig, type ChannelBusSetting } from "@rebasepro/types";
18
+ import { type ChannelBus, type ChannelBusSetting } from "@rebasepro/types";
19
19
  export * from "./ChannelBus";
20
20
  export { PostgresChannelBus, CHANNEL_BUS_NOTIFY_CHANNEL, PG_NOTIFY_MAX_PAYLOAD_BYTES, DEFAULT_BATCH_WINDOW_MS, parseChannelBusFrame, parseChannelBusPayload } from "./PostgresChannelBus";
21
21
  export interface ChannelBusDeps {
@@ -37,12 +37,6 @@ export interface ChannelBusDeps {
37
37
  * mean silently discarding the object the application handed us.
38
38
  */
39
39
  export declare function resolveChannelBusSetting(configured?: ChannelBusSetting): ChannelBusSetting;
40
- /**
41
- * @deprecated Use {@link resolveChannelBusSetting}, which also accepts a
42
- * supplied {@link ChannelBus} instance. Kept as a narrow alias so existing
43
- * config-only callers keep their exact types.
44
- */
45
- export declare function resolveChannelBusConfig(configured?: ChannelBusConfig): ChannelBusConfig;
46
40
  /**
47
41
  * Produce the bus a setting asks for.
48
42
  *