@rebasepro/server-postgres 0.18.0 → 0.18.1

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 (70) hide show
  1. package/dist/BranchService-ByLfM_Tt.js +360 -0
  2. package/dist/BranchService-ByLfM_Tt.js.map +1 -0
  3. package/dist/__vite-browser-external-BnuHet1e.js +46 -0
  4. package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
  5. package/dist/arg-Dni7MzLB.js +87 -0
  6. package/dist/arg-Dni7MzLB.js.map +1 -0
  7. package/dist/auth-users-columns-D2LBFrMH.js +116 -0
  8. package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
  9. package/dist/backup-cli-oM9WQVlV.js +408 -0
  10. package/dist/backup-cli-oM9WQVlV.js.map +1 -0
  11. package/dist/backup-service-Br3tqwsy.js +715 -0
  12. package/dist/backup-service-Br3tqwsy.js.map +1 -0
  13. package/dist/cli-errors-DxN4yzdH.js +657 -0
  14. package/dist/cli-errors-DxN4yzdH.js.map +1 -0
  15. package/dist/cli-output-CNdMql-L.js +47 -0
  16. package/dist/cli-output-CNdMql-L.js.map +1 -0
  17. package/dist/cli.js +2020 -0
  18. package/dist/cli.js.map +1 -0
  19. package/dist/{collection-index-C7LopMyX.js → collection-index-BHgT_TKd.js} +32 -3
  20. package/dist/{collection-index-C7LopMyX.js.map → collection-index-BHgT_TKd.js.map} +1 -1
  21. package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
  22. package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
  23. package/dist/databasePoolManager-Bj5FbeAs.js +349 -0
  24. package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
  25. package/dist/doctor-D7HWmGzs.js +974 -0
  26. package/dist/doctor-D7HWmGzs.js.map +1 -0
  27. package/dist/{ensure-collection-policies-CagOzEPS.js → ensure-collection-policies-2c2NV8zX.js} +5 -25
  28. package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
  29. package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
  30. package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
  31. package/dist/ensure-tables-CIojK-Rx.js +650 -0
  32. package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
  33. package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
  34. package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
  35. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
  36. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
  37. package/dist/generate-postgres-ddl-logic-CtnpIBVv.js +1312 -0
  38. package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
  39. package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
  40. package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
  41. package/dist/index.es.js +849 -10807
  42. package/dist/index.es.js.map +1 -1
  43. package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
  44. package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
  45. package/dist/policy-drift-kNJ6X2Uj.js +313 -0
  46. package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
  47. package/dist/rls-bootstrap-sql-DLDnUjtx.js +7092 -0
  48. package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
  49. package/dist/{rls-enforcement-ClJoTboT.js → rls-enforcement-DeYEjYLt.js} +14 -3
  50. package/dist/{rls-enforcement-ClJoTboT.js.map → rls-enforcement-DeYEjYLt.js.map} +1 -1
  51. package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
  52. package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
  53. package/dist/schema/doctor-cli.js +129 -0
  54. package/dist/schema/doctor-cli.js.map +1 -0
  55. package/dist/schema/generate-drizzle-schema.d.ts +1 -1
  56. package/dist/schema/generate-drizzle-schema.js +24 -0
  57. package/dist/schema/generate-drizzle-schema.js.map +1 -0
  58. package/dist/schema/generate-postgres-ddl.d.ts +1 -1
  59. package/dist/schema/generate-postgres-ddl.js +138 -0
  60. package/dist/schema/generate-postgres-ddl.js.map +1 -0
  61. package/dist/schema/introspect-db.js +866 -0
  62. package/dist/schema/introspect-db.js.map +1 -0
  63. package/dist/source-Br7L7GOI.js +410 -0
  64. package/dist/source-Br7L7GOI.js.map +1 -0
  65. package/dist/vector-index-DkWxm2de.js +309 -0
  66. package/dist/vector-index-DkWxm2de.js.map +1 -0
  67. package/package.json +7 -7
  68. package/dist/ensure-collection-policies-CagOzEPS.js.map +0 -1
  69. package/dist/ensure-collection-tables-DbAhrjnG.js +0 -2732
  70. package/dist/ensure-collection-tables-DbAhrjnG.js.map +0 -1
@@ -0,0 +1,866 @@
1
+ import { createRequire as __createRequire } from "module";
2
+ __createRequire(import.meta.url);
3
+ import { a as __toESM, t as require___vite_browser_external } from "../__vite-browser-external-BnuHet1e.js";
4
+ import { n as outError, r as outWarn, t as out } from "../cli-output-CNdMql-L.js";
5
+ import { t as chalk } from "../source-Br7L7GOI.js";
6
+ import { a as generateCollectionFile, f as classifyTables, i as buildTablesMap, l as mergeIndexContent, o as generateIndexContent, p as lookupCandidates, r as buildEnumMap, u as safeHostFromUrl } from "../introspect-db-logic-C6LQdTxj.js";
7
+ import { t as require_arg } from "../arg-Dni7MzLB.js";
8
+ import pg from "pg";
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import * as dotenv from "dotenv";
12
+ //#region src/schema/introspect-db-project.ts
13
+ var import___vite_browser_external = /* @__PURE__ */ __toESM(require___vite_browser_external(), 1);
14
+ var import_arg = /* @__PURE__ */ __toESM(require_arg(), 1);
15
+ /**
16
+ * What the project being introspected into can actually import.
17
+ *
18
+ * The generator is pure — `introspect-db-logic.ts` reads no files — so the one
19
+ * question it cannot answer for itself lives here: which `defineCollection`, if
20
+ * any, will resolve from the directory the collections are written to.
21
+ */
22
+ /** How far up to look before giving up, if no project root announces itself. */
23
+ var MAX_LEVELS = 8;
24
+ /** Every dependency name a manifest declares, in any of the three fields. */
25
+ function declaredDependencies(manifestPath) {
26
+ const names = /* @__PURE__ */ new Set();
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
30
+ } catch {
31
+ return names;
32
+ }
33
+ if (typeof parsed !== "object" || parsed === null) return names;
34
+ const manifest = parsed;
35
+ for (const field of [
36
+ "dependencies",
37
+ "devDependencies",
38
+ "peerDependencies"
39
+ ]) {
40
+ const block = manifest[field];
41
+ if (typeof block === "object" && block !== null) for (const name of Object.keys(block)) names.add(name);
42
+ }
43
+ return names;
44
+ }
45
+ /**
46
+ * Which `defineCollection` to generate against, for collections written to `outDir`.
47
+ *
48
+ * **The detection is the package manifests above the output directory**, unioned from
49
+ * `outDir` up to and including the project root — the first ancestor holding a
50
+ * `rebase.json`, or `MAX_LEVELS` up if there is none.
51
+ *
52
+ * That is the rule Node itself applies: a file in `config/collections` resolves a bare
53
+ * specifier through `config/node_modules`, then `<project>/node_modules`, and so on up.
54
+ * Reading the manifests along that same path answers the only question that matters —
55
+ * *will this import resolve in the project I am writing into* — from the state on disk
56
+ * at the moment of generation. The alternatives are all proxies for it: `rebase.json`'s
57
+ * `apps` block says a CMS scaffold declared an admin app, and a `frontend/` directory
58
+ * says one was scaffolded, but neither is what the compiler consults, and either can be
59
+ * true of a project whose `config` package does not depend on `@rebasepro/cms-types`.
60
+ *
61
+ * Ambiguity resolves towards the admin panel: a project that declares both packages has
62
+ * a panel, and `@rebasepro/cms-types` is the flavour that keeps the `admin` block.
63
+ */
64
+ function detectCollectionBuilder(outDir) {
65
+ let dir = path.resolve(outDir);
66
+ const declared = /* @__PURE__ */ new Set();
67
+ for (let level = 0; level < MAX_LEVELS; level++) {
68
+ const manifest = path.join(dir, "package.json");
69
+ if (fs.existsSync(manifest)) for (const name of declaredDependencies(manifest)) declared.add(name);
70
+ if (fs.existsSync(path.join(dir, "rebase.json"))) break;
71
+ const parent = path.dirname(dir);
72
+ if (parent === dir) break;
73
+ dir = parent;
74
+ }
75
+ if (declared.has("@rebasepro/cms-types")) return "admin-types";
76
+ if (declared.has("@rebasepro/common")) return "common";
77
+ return "annotation";
78
+ }
79
+ //#endregion
80
+ //#region src/schema/introspect-db-queries.ts
81
+ /**
82
+ * A Postgres identifier, quoted so that whatever is inside it stays inside it.
83
+ *
84
+ * Introspection reads table and schema names out of `pg_class` and puts them
85
+ * straight back into SQL, and wrapping a name in double quotes is not the same
86
+ * as escaping it: a table named `x" ; DROP TABLE users; --` closes the quote
87
+ * and then the statement. Postgres's own escape for a quote inside a quoted
88
+ * identifier is to double it, which is what this does.
89
+ *
90
+ * "The catalogue is trusted" is true of your own database, and is exactly the
91
+ * assumption this tool cannot make. Introspection exists to be pointed at
92
+ * databases somebody else built, and it runs with the privileges of whoever
93
+ * ran it — which is usually a superuser, because that is who has a connection
94
+ * string to a database they are adopting.
95
+ */
96
+ function quoteIdentifier(name) {
97
+ return `"${name.replace(/"/g, "\"\"")}"`;
98
+ }
99
+ /** `"schema"."table"`, both halves quoted. */
100
+ function quoteRelation(schema, table) {
101
+ return `${quoteIdentifier(schema)}.${quoteIdentifier(table)}`;
102
+ }
103
+ /**
104
+ * Can this table's name be the name of the file describing it?
105
+ *
106
+ * Introspection writes `<collectionsDir>/<tableName>.ts`, and the table name
107
+ * came from a database this tool did not create. Postgres will hold a table
108
+ * called `../../../etc/cron.d/x`, and `path.join` resolves it — so without this
109
+ * the generator writes a file wherever the name points, as whoever ran the
110
+ * command.
111
+ *
112
+ * Deliberately narrow, and deliberately not a blocklist of `..` and `/`: the
113
+ * question is "is this a plain file name?", and every answer that is not
114
+ * obviously yes is a table better written by hand. NUL and the Windows
115
+ * separator are in scope because a collections directory is checked out on
116
+ * whatever machine the developer has.
117
+ */
118
+ function isUsableFileName(name) {
119
+ if (name.length === 0 || name.length > 200) return false;
120
+ if (name === "." || name === "..") return false;
121
+ if (/[/\\:\u0000-\u001f]/.test(name)) return false;
122
+ return !name.startsWith(".") && !name.endsWith(".");
123
+ }
124
+ /**
125
+ * Base tables, excluding partitions.
126
+ *
127
+ * `relispartition` is the reason this reads `pg_class` rather than
128
+ * `information_schema.tables`, which reports every partition as a base table of
129
+ * its own. Pagila partitions `payment` by month; introspected through
130
+ * information_schema it yields `payment` plus 26 near-identical
131
+ * `payment_p2022_*` collections, each with its own nav entry.
132
+ *
133
+ * `relkind = 'p'` (the partitioned parent) is included and `'r'` partitions are
134
+ * dropped, which is the right way round: the parent is the queryable table.
135
+ */
136
+ var TABLES_QUERY = `
137
+ SELECT c.relname AS table_name,
138
+ c.relkind = 'p' AS is_partitioned
139
+ FROM pg_class c
140
+ JOIN pg_namespace n ON n.oid = c.relnamespace
141
+ WHERE n.nspname = $1
142
+ AND c.relkind IN ('r', 'p')
143
+ AND NOT c.relispartition
144
+ AND c.relname NOT LIKE 'drizzle_%'
145
+ AND c.relname NOT LIKE 'rebase_%'
146
+ ORDER BY c.relname
147
+ `;
148
+ /**
149
+ * Columns, with the catalog facts that say a column is not the user's to edit.
150
+ *
151
+ * `is_generated`/`is_identity` are read for that reason: a generated column
152
+ * rejects any write, so a form that offers it is offering a field that always
153
+ * errors. `character_maximum_length` is the declared `varchar(n)` bound —
154
+ * already enforced by the database, and free to surface as validation.
155
+ */
156
+ var COLUMNS_QUERY = `
157
+ SELECT
158
+ c.table_name,
159
+ c.column_name,
160
+ c.data_type,
161
+ c.udt_name,
162
+ c.is_nullable,
163
+ c.column_default,
164
+ c.ordinal_position,
165
+ c.is_generated,
166
+ c.is_identity,
167
+ c.identity_generation,
168
+ c.character_maximum_length,
169
+ c.numeric_precision,
170
+ c.numeric_scale,
171
+ (SELECT a.atttypmod FROM pg_attribute a
172
+ JOIN pg_class pc ON a.attrelid = pc.oid
173
+ WHERE pc.relname = c.table_name
174
+ AND a.attname = c.column_name
175
+ AND pc.relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = c.table_schema)) as atttypmod
176
+ FROM information_schema.columns c
177
+ WHERE c.table_schema = $1
178
+ ORDER BY c.table_name, c.ordinal_position
179
+ `;
180
+ var ENUMS_QUERY = `
181
+ SELECT t.typname AS enum_name,
182
+ e.enumlabel AS enum_value,
183
+ e.enumsortorder AS sort_order
184
+ FROM pg_type t
185
+ JOIN pg_enum e ON t.oid = e.enumtypid
186
+ JOIN pg_namespace n ON t.typnamespace = n.oid
187
+ WHERE n.nspname = $1
188
+ ORDER BY t.typname, e.enumsortorder
189
+ `;
190
+ /**
191
+ * Primary key columns, in key order.
192
+ *
193
+ * `ORDER BY k.ord` matters for composite keys: the position of a column inside
194
+ * the key is what tells a two-column PK made of two foreign keys apart from an
195
+ * ordinary composite key, and `= ANY(i.indkey)` (the shape this replaced)
196
+ * returns catalog order instead.
197
+ */
198
+ var PRIMARY_KEYS_QUERY = `
199
+ SELECT t.relname AS table_name,
200
+ a.attname AS column_name
201
+ FROM pg_index i
202
+ JOIN pg_class t ON t.oid = i.indrelid
203
+ JOIN pg_namespace n ON n.oid = t.relnamespace
204
+ CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
205
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
206
+ WHERE i.indisprimary AND n.nspname = $1
207
+ ORDER BY t.relname, k.ord
208
+ `;
209
+ /**
210
+ * Foreign keys, one row per referencing column, with the delete rule.
211
+ *
212
+ * Read from `pg_constraint` rather than through
213
+ * `information_schema.constraint_column_usage`, which does not preserve the
214
+ * pairing on a composite foreign key: it reports the cross product of the
215
+ * constraint's columns, so a two-column FK comes back as four rows, two of them
216
+ * pairing the wrong columns. `unnest(...) WITH ORDINALITY` on both sides joined
217
+ * on the ordinal keeps each referencing column with the column it references.
218
+ *
219
+ * `confdeltype` is carried because `ON DELETE CASCADE` is the schema author
220
+ * saying, in the database, that the child cannot outlive the parent — the one
221
+ * unambiguous declaration of ownership a schema contains.
222
+ *
223
+ * Constraints declared on a *partition* are attributed to the partition root,
224
+ * because that is the table this run generates a collection for. Pagila declares
225
+ * `payment`'s three foreign keys on each monthly partition and none on the
226
+ * parent, so reading `pg_constraint` at face value gives the `payment`
227
+ * collection no relations at all. `pick = 1` then keeps one row per logical key,
228
+ * preferring the root's own constraint when it has one, so a schema that
229
+ * declares the key on the parent *and* inherits it onto 26 partitions does not
230
+ * yield 27 copies of the same relation.
231
+ */
232
+ var FOREIGN_KEYS_QUERY = `
233
+ WITH fk AS (
234
+ SELECT con.oid,
235
+ con.conname,
236
+ con.conrelid,
237
+ con.confrelid,
238
+ con.conkey,
239
+ con.confkey,
240
+ con.confdeltype,
241
+ COALESCE(pg_partition_root(con.conrelid), con.conrelid) AS root_oid,
242
+ row_number() OVER (
243
+ PARTITION BY COALESCE(pg_partition_root(con.conrelid), con.conrelid),
244
+ con.confrelid, con.conkey, con.confkey
245
+ ORDER BY (con.conrelid = COALESCE(pg_partition_root(con.conrelid), con.conrelid)) DESC,
246
+ con.oid
247
+ ) AS pick
248
+ FROM pg_constraint con
249
+ JOIN pg_namespace n ON n.oid = con.connamespace
250
+ WHERE con.contype = 'f' AND n.nspname = $1
251
+ )
252
+ SELECT root.relname AS table_name,
253
+ sa.attname AS column_name,
254
+ tgt.relname AS foreign_table_name,
255
+ ta.attname AS foreign_column_name,
256
+ fk.conname AS constraint_name,
257
+ s.ord::int AS ordinal,
258
+ CASE fk.confdeltype
259
+ WHEN 'a' THEN 'NO ACTION'
260
+ WHEN 'r' THEN 'RESTRICT'
261
+ WHEN 'c' THEN 'CASCADE'
262
+ WHEN 'n' THEN 'SET NULL'
263
+ WHEN 'd' THEN 'SET DEFAULT'
264
+ END AS delete_rule
265
+ FROM fk
266
+ JOIN pg_class root ON root.oid = fk.root_oid
267
+ JOIN pg_class tgt ON tgt.oid = fk.confrelid
268
+ CROSS JOIN LATERAL unnest(fk.conkey) WITH ORDINALITY AS s(attnum, ord)
269
+ JOIN pg_attribute sa ON sa.attrelid = fk.conrelid AND sa.attnum = s.attnum
270
+ CROSS JOIN LATERAL unnest(fk.confkey) WITH ORDINALITY AS f(attnum, ord)
271
+ JOIN pg_attribute ta ON ta.attrelid = fk.confrelid AND ta.attnum = f.attnum
272
+ WHERE fk.pick = 1 AND s.ord = f.ord
273
+ ORDER BY root.relname, fk.conname, s.ord
274
+ `;
275
+ /**
276
+ * Unique constraints and unique indexes, as ordered column lists.
277
+ *
278
+ * Indexes rather than constraints alone, because `CREATE UNIQUE INDEX` and
279
+ * `ADD CONSTRAINT ... UNIQUE` produce the same guarantee and schemas use both.
280
+ * Partial indexes (`indpred IS NOT NULL`) are excluded: they promise uniqueness
281
+ * only over the rows matching their predicate, which is not the promise a
282
+ * `validation: { unique: true }` field makes.
283
+ */
284
+ var UNIQUE_CONSTRAINTS_QUERY = `
285
+ SELECT t.relname AS table_name,
286
+ ic.relname AS constraint_name,
287
+ -- ::text, because the driver has no parser for an array of \`name\`
288
+ -- and hands back the raw \`{a,b}\` literal as a string. Every consumer
289
+ -- here indexes and compares it as an array, and a string of that
290
+ -- shape fails those silently rather than loudly.
291
+ array_agg(a.attname::text ORDER BY k.ord) AS column_names
292
+ FROM pg_index i
293
+ JOIN pg_class t ON t.oid = i.indrelid
294
+ JOIN pg_class ic ON ic.oid = i.indexrelid
295
+ JOIN pg_namespace n ON n.oid = t.relnamespace
296
+ CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS k(attnum, ord)
297
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum
298
+ WHERE i.indisunique
299
+ AND NOT i.indisprimary
300
+ AND i.indpred IS NULL
301
+ AND n.nspname = $1
302
+ AND NOT t.relispartition
303
+ GROUP BY t.relname, ic.relname
304
+ ORDER BY t.relname, ic.relname
305
+ `;
306
+ /**
307
+ * CHECK constraints, as the source text Postgres reproduces them from.
308
+ *
309
+ * `pg_get_constraintdef` output is normalized by the server — the parser in
310
+ * {@link ./introspect-db-constraints.ts} reads that normalized form, not
311
+ * whatever the author typed.
312
+ */
313
+ var CHECK_CONSTRAINTS_QUERY = `
314
+ SELECT DISTINCT
315
+ root.relname AS table_name,
316
+ con.conname AS constraint_name,
317
+ pg_get_constraintdef(con.oid) AS definition
318
+ FROM pg_constraint con
319
+ JOIN pg_namespace n ON n.oid = con.connamespace
320
+ JOIN pg_class root ON root.oid = COALESCE(pg_partition_root(con.conrelid), con.conrelid)
321
+ WHERE con.contype = 'c'
322
+ AND n.nspname = $1
323
+ ORDER BY root.relname, con.conname
324
+ `;
325
+ /**
326
+ * `COMMENT ON TABLE` / `COMMENT ON COLUMN`, which is documentation the author
327
+ * already wrote and which nothing downstream has ever read.
328
+ */
329
+ var COMMENTS_QUERY = `
330
+ SELECT t.relname AS table_name,
331
+ a.attname AS column_name,
332
+ d.description AS comment
333
+ FROM pg_description d
334
+ JOIN pg_class t ON t.oid = d.objoid
335
+ JOIN pg_namespace n ON n.oid = t.relnamespace
336
+ LEFT JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = d.objsubid
337
+ WHERE n.nspname = $1
338
+ AND t.relkind IN ('r', 'p')
339
+ AND (d.objsubid = 0 OR a.attname IS NOT NULL)
340
+ ORDER BY t.relname, d.objsubid
341
+ `;
342
+ /**
343
+ * Counts a table's rows, stopping once the answer can only be "more than
344
+ * `limit`".
345
+ *
346
+ * Introspection asks this to tell a small reference table from a table that
347
+ * merely looks like one (see `classifyTables`). `reltuples` would answer for
348
+ * free but lies on a table that has never been analyzed — which is every table
349
+ * in a database restored from a dump, i.e. exactly the case introspection meets
350
+ * — and reports -1 there rather than an error, so the lie is silent.
351
+ *
352
+ * The subquery's LIMIT caps the work at `limit + 1` rows however large the
353
+ * table is, so this stays cheap on a table with a billion rows.
354
+ */
355
+ async function countRowsUpTo(client, schema, table, limit) {
356
+ const { rows } = await client.query(`SELECT count(*)::text AS count FROM (SELECT 1 FROM ${quoteRelation(schema, table)} LIMIT ${limit + 1}) probe`);
357
+ return Number(rows[0]?.count ?? 0);
358
+ }
359
+ /**
360
+ * Reads everything the generator needs, in one pass.
361
+ *
362
+ * `rowCounts` is deliberately absent here: it costs a query per table and only
363
+ * a handful of tables can possibly need it. The caller fills it in for the
364
+ * candidates `lookupCandidates()` names.
365
+ */
366
+ async function readSchemaMetadata(client, schema) {
367
+ const tables = await client.query(TABLES_QUERY, [schema]);
368
+ const columns = await client.query(COLUMNS_QUERY, [schema]);
369
+ const enumValues = await client.query(ENUMS_QUERY, [schema]);
370
+ const pks = await client.query(PRIMARY_KEYS_QUERY, [schema]);
371
+ const fks = await client.query(FOREIGN_KEYS_QUERY, [schema]);
372
+ const uniques = await client.query(UNIQUE_CONSTRAINTS_QUERY, [schema]);
373
+ const checks = await client.query(CHECK_CONSTRAINTS_QUERY, [schema]);
374
+ const comments = await client.query(COMMENTS_QUERY, [schema]);
375
+ const known = new Set(tables.rows.map((t) => t.table_name));
376
+ const scoped = (rows) => rows.filter((r) => known.has(r.table_name));
377
+ return {
378
+ schema,
379
+ tables: tables.rows,
380
+ columns: scoped(columns.rows),
381
+ enumValues: enumValues.rows,
382
+ pks: scoped(pks.rows),
383
+ fks: scoped(fks.rows).filter((fk) => known.has(fk.foreign_table_name)),
384
+ uniques: scoped(uniques.rows),
385
+ checks: scoped(checks.rows),
386
+ comments: scoped(comments.rows),
387
+ rowCounts: {}
388
+ };
389
+ }
390
+ //#endregion
391
+ //#region src/schema/introspect-db-constraints.ts
392
+ /** The length functions whose argument is the column being constrained. */
393
+ var LENGTH_FUNCTIONS = /* @__PURE__ */ new Set([
394
+ "length",
395
+ "char_length",
396
+ "character_length",
397
+ "octet_length"
398
+ ]);
399
+ /**
400
+ * Splits on a separator that appears at paren depth 0 and outside string
401
+ * literals.
402
+ *
403
+ * Depth tracking is the whole job: `ARRAY['a', 'b']` contains a comma that must
404
+ * not split the enclosing argument list, and `'it''s'` contains a quote that
405
+ * must not end the literal.
406
+ */
407
+ function splitTopLevel(input, separator) {
408
+ const parts = [];
409
+ let depth = 0;
410
+ let inString = false;
411
+ let current = "";
412
+ for (let i = 0; i < input.length; i++) {
413
+ const ch = input[i];
414
+ if (inString) {
415
+ current += ch;
416
+ if (ch === "'") if (input[i + 1] === "'") current += input[++i];
417
+ else inString = false;
418
+ continue;
419
+ }
420
+ if (ch === "'") {
421
+ inString = true;
422
+ current += ch;
423
+ continue;
424
+ }
425
+ if (ch === "(" || ch === "[") depth++;
426
+ if (ch === ")" || ch === "]") depth--;
427
+ if (depth === 0 && input.startsWith(separator, i)) {
428
+ const before = input[i - 1];
429
+ const after = input[i + separator.length];
430
+ if (!/[A-Za-z]/.test(separator[0]) || (before === void 0 || !/[A-Za-z0-9_]/.test(before)) && (after === void 0 || !/[A-Za-z0-9_]/.test(after))) {
431
+ parts.push(current);
432
+ current = "";
433
+ i += separator.length - 1;
434
+ continue;
435
+ }
436
+ }
437
+ current += ch;
438
+ }
439
+ parts.push(current);
440
+ return parts.map((p) => p.trim()).filter((p) => p.length > 0);
441
+ }
442
+ /** Removes one layer of wrapping parentheses, repeatedly, when balanced. */
443
+ function unwrapParens(input) {
444
+ let s = input.trim();
445
+ while (s.startsWith("(") && s.endsWith(")")) {
446
+ let depth = 0;
447
+ let balanced = true;
448
+ let inString = false;
449
+ for (let i = 0; i < s.length; i++) {
450
+ const ch = s[i];
451
+ if (inString) {
452
+ if (ch === "'") if (s[i + 1] === "'") i++;
453
+ else inString = false;
454
+ continue;
455
+ }
456
+ if (ch === "'") {
457
+ inString = true;
458
+ continue;
459
+ }
460
+ if (ch === "(") depth++;
461
+ else if (ch === ")") {
462
+ depth--;
463
+ if (depth === 0 && i < s.length - 1) {
464
+ balanced = false;
465
+ break;
466
+ }
467
+ }
468
+ }
469
+ if (!balanced) break;
470
+ s = s.slice(1, -1).trim();
471
+ }
472
+ return s;
473
+ }
474
+ /** Drops trailing `::type` casts, including array and quoted forms. */
475
+ function stripCasts(input) {
476
+ let s = unwrapParens(input);
477
+ let previous;
478
+ do {
479
+ previous = s;
480
+ s = unwrapParens(s.replace(/::\s*"?[A-Za-z_][A-Za-z0-9_ ]*"?(\s*\(\s*\d+\s*(,\s*\d+\s*)?\))?(\s*\[\s*\])*\s*$/, "").trim());
481
+ } while (s !== previous);
482
+ return s;
483
+ }
484
+ /** A bare, unquoted column reference — anything else is an expression. */
485
+ function asColumnName(input) {
486
+ const s = stripCasts(input);
487
+ if (/^[a-z_][a-z0-9_$]*$/i.test(s)) return s;
488
+ const quoted = s.match(/^"([^"]+)"$/);
489
+ return quoted ? quoted[1] : null;
490
+ }
491
+ /** `length(col)` and friends → the column they measure. */
492
+ function asLengthOfColumn(input) {
493
+ const match = stripCasts(input).match(/^([a-z_]+)\s*\((.*)\)$/is);
494
+ if (!match || !LENGTH_FUNCTIONS.has(match[1].toLowerCase())) return null;
495
+ return asColumnName(match[2]);
496
+ }
497
+ /** A numeric literal, after casts are removed. Rejects anything else. */
498
+ function asNumber(input) {
499
+ const s = stripCasts(input).replace(/^'(.*)'$/s, "$1");
500
+ if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(s)) return null;
501
+ const n = Number(s);
502
+ return Number.isFinite(n) ? n : null;
503
+ }
504
+ /** A single-quoted string literal, with `''` unescaped. */
505
+ function asStringLiteral(input) {
506
+ const s = stripCasts(input);
507
+ if (!s.startsWith("'") || !s.endsWith("'") || s.length < 2) return null;
508
+ const body = s.slice(1, -1);
509
+ if (/(^|[^'])'($|[^'])/.test(body)) return null;
510
+ return body.replace(/''/g, "'");
511
+ }
512
+ /** The element list of `ARRAY[…]`, or of a parenthesised `IN (…)` list. */
513
+ function asValueList(input) {
514
+ const s = stripCasts(input);
515
+ const arrayMatch = s.match(/^ARRAY\s*\[(.*)\]$/is);
516
+ const body = arrayMatch ? arrayMatch[1] : s.startsWith("(") && s.endsWith(")") ? s.slice(1, -1) : null;
517
+ if (body === null) return null;
518
+ if (body.trim() === "") return null;
519
+ const values = [];
520
+ for (const element of splitTopLevel(body, ",")) {
521
+ const literal = asStringLiteral(element);
522
+ if (literal !== null) {
523
+ values.push(literal);
524
+ continue;
525
+ }
526
+ const numeric = asNumber(element);
527
+ if (numeric !== null) {
528
+ values.push(String(numeric));
529
+ continue;
530
+ }
531
+ return null;
532
+ }
533
+ return values;
534
+ }
535
+ /**
536
+ * Parses one conjunct. Returns null when the shape is not one of the few this
537
+ * understands, which is the signal to abandon the whole constraint.
538
+ */
539
+ function parseTerm(rawTerm) {
540
+ const term = unwrapParens(rawTerm);
541
+ const anyMatch = term.match(/^(.*?)\s*=\s*ANY\s*(\(.*\))$/is);
542
+ const inMatch = anyMatch ? null : term.match(/^(.*?)\s+IN\s*(\(.*\))$/is);
543
+ const membership = anyMatch ?? inMatch;
544
+ if (membership) {
545
+ const column = asColumnName(membership[1]);
546
+ const values = asValueList(membership[2]);
547
+ if (!column || !values || values.length === 0) return null;
548
+ return {
549
+ column,
550
+ apply: (facts) => {
551
+ facts.enumValues = facts.enumValues ? facts.enumValues.filter((v) => values.includes(v)) : values;
552
+ }
553
+ };
554
+ }
555
+ for (const operator of [
556
+ ">=",
557
+ "<=",
558
+ "<>",
559
+ "!=",
560
+ "=",
561
+ ">",
562
+ "<"
563
+ ]) {
564
+ const parts = splitTopLevel(term, operator);
565
+ if (parts.length !== 2) continue;
566
+ const [rawLeft, rawRight] = parts;
567
+ const lengthColumn = asLengthOfColumn(rawLeft);
568
+ if (lengthColumn) {
569
+ const bound = asNumber(rawRight);
570
+ if (bound === null) return null;
571
+ switch (operator) {
572
+ case ">=": return {
573
+ column: lengthColumn,
574
+ apply: (f) => {
575
+ f.lengthMin = Math.max(f.lengthMin ?? bound, bound);
576
+ }
577
+ };
578
+ case ">": return {
579
+ column: lengthColumn,
580
+ apply: (f) => {
581
+ f.lengthMin = Math.max(f.lengthMin ?? bound + 1, bound + 1);
582
+ }
583
+ };
584
+ case "<=": return {
585
+ column: lengthColumn,
586
+ apply: (f) => {
587
+ f.lengthMax = Math.min(f.lengthMax ?? bound, bound);
588
+ }
589
+ };
590
+ case "<": return {
591
+ column: lengthColumn,
592
+ apply: (f) => {
593
+ f.lengthMax = Math.min(f.lengthMax ?? bound - 1, bound - 1);
594
+ }
595
+ };
596
+ case "=": return {
597
+ column: lengthColumn,
598
+ apply: (f) => {
599
+ f.lengthMin = bound;
600
+ f.lengthMax = bound;
601
+ }
602
+ };
603
+ default: return null;
604
+ }
605
+ }
606
+ const column = asColumnName(rawLeft);
607
+ if (!column) continue;
608
+ if (operator === "=") {
609
+ const literal = asStringLiteral(rawRight);
610
+ if (literal !== null) return {
611
+ column,
612
+ apply: (facts) => {
613
+ facts.enumValues = facts.enumValues ? facts.enumValues.filter((v) => v === literal) : [literal];
614
+ }
615
+ };
616
+ const pinned = asNumber(rawRight);
617
+ if (pinned === null) return null;
618
+ return {
619
+ column,
620
+ apply: (f) => {
621
+ f.min = pinned;
622
+ f.max = pinned;
623
+ }
624
+ };
625
+ }
626
+ const bound = asNumber(rawRight);
627
+ if (bound === null) return null;
628
+ switch (operator) {
629
+ case ">=": return {
630
+ column,
631
+ apply: (f) => {
632
+ f.min = Math.max(f.min ?? bound, bound);
633
+ }
634
+ };
635
+ case ">": return {
636
+ column,
637
+ apply: (f) => {
638
+ f.moreThan = Math.max(f.moreThan ?? bound, bound);
639
+ }
640
+ };
641
+ case "<=": return {
642
+ column,
643
+ apply: (f) => {
644
+ f.max = Math.min(f.max ?? bound, bound);
645
+ }
646
+ };
647
+ case "<": return {
648
+ column,
649
+ apply: (f) => {
650
+ f.lessThan = Math.min(f.lessThan ?? bound, bound);
651
+ }
652
+ };
653
+ default: return null;
654
+ }
655
+ }
656
+ return null;
657
+ }
658
+ /**
659
+ * Reads one constraint definition into per-column facts.
660
+ *
661
+ * Returns an empty map for anything not understood — including a constraint
662
+ * that is understood but spans two columns (`start_date < end_date`), which no
663
+ * per-property validation rule can express.
664
+ */
665
+ function parseCheckDefinition(definition) {
666
+ const result = /* @__PURE__ */ new Map();
667
+ const body = definition.trim().replace(/\s+NOT\s+VALID\s*$/i, "").replace(/^CHECK\s*/i, "").trim();
668
+ if (body === definition.trim()) return result;
669
+ const expression = unwrapParens(body);
670
+ if (!expression) return result;
671
+ if (splitTopLevel(expression, " OR ").length > 1) return result;
672
+ const terms = splitTopLevel(expression, " AND ");
673
+ const parsed = [];
674
+ for (const term of terms) {
675
+ const one = parseTerm(term);
676
+ if (!one) return result;
677
+ parsed.push(one);
678
+ }
679
+ if (new Set(parsed.map((t) => t.column)).size !== 1) return result;
680
+ const facts = {};
681
+ for (const term of parsed) term.apply(facts);
682
+ if (Object.keys(facts).length === 0) return result;
683
+ result.set(parsed[0].column, facts);
684
+ return result;
685
+ }
686
+ /** Merges every readable CHECK in the schema into per-table, per-column facts. */
687
+ function parseCheckConstraints(rows) {
688
+ const byTable = /* @__PURE__ */ new Map();
689
+ for (const row of rows) {
690
+ const parsed = parseCheckDefinition(row.definition);
691
+ if (parsed.size === 0) continue;
692
+ let table = byTable.get(row.table_name);
693
+ if (!table) {
694
+ table = /* @__PURE__ */ new Map();
695
+ byTable.set(row.table_name, table);
696
+ }
697
+ for (const [column, facts] of parsed) {
698
+ const existing = table.get(column);
699
+ if (!existing) {
700
+ table.set(column, facts);
701
+ continue;
702
+ }
703
+ if (facts.enumValues) existing.enumValues = existing.enumValues ? existing.enumValues.filter((v) => facts.enumValues.includes(v)) : facts.enumValues;
704
+ if (facts.min !== void 0) existing.min = Math.max(existing.min ?? facts.min, facts.min);
705
+ if (facts.max !== void 0) existing.max = Math.min(existing.max ?? facts.max, facts.max);
706
+ if (facts.moreThan !== void 0) existing.moreThan = Math.max(existing.moreThan ?? facts.moreThan, facts.moreThan);
707
+ if (facts.lessThan !== void 0) existing.lessThan = Math.min(existing.lessThan ?? facts.lessThan, facts.lessThan);
708
+ if (facts.lengthMin !== void 0) existing.lengthMin = Math.max(existing.lengthMin ?? facts.lengthMin, facts.lengthMin);
709
+ if (facts.lengthMax !== void 0) existing.lengthMax = Math.min(existing.lengthMax ?? facts.lengthMax, facts.lengthMax);
710
+ }
711
+ }
712
+ return byTable;
713
+ }
714
+ //#endregion
715
+ //#region src/schema/introspect-db.ts
716
+ async function main() {
717
+ const args = (0, import_arg.default)({
718
+ "--output": String,
719
+ "--collections": String,
720
+ "--force": Boolean,
721
+ "--schema": String,
722
+ "--data-inference": Boolean,
723
+ "--no-data-inference": Boolean,
724
+ "-o": "--output",
725
+ "-c": "--collections",
726
+ "-f": "--force"
727
+ }, { permissive: true });
728
+ const cwd = process.cwd();
729
+ const defaultOutDir = path.basename(cwd) === "backend" ? path.resolve(cwd, "..", "config", "collections") : path.resolve(cwd, "config", "collections");
730
+ const outDir = args["--output"] || args["--collections"] || defaultOutDir;
731
+ const force = args["--force"] || false;
732
+ const pgSchema = args["--schema"] || "public";
733
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
734
+ const envPaths = [
735
+ process.env.DOTENV_CONFIG_PATH,
736
+ path.resolve(process.cwd(), ".env"),
737
+ path.resolve(process.cwd(), "../.env"),
738
+ path.resolve(process.cwd(), "../../.env")
739
+ ].filter(Boolean);
740
+ for (const p of envPaths) if (fs.existsSync(p)) {
741
+ dotenv.config({
742
+ path: p,
743
+ quiet: true
744
+ });
745
+ break;
746
+ }
747
+ const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
748
+ if (!databaseUrl) {
749
+ outError(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
750
+ process.exit(1);
751
+ }
752
+ const client = new pg.Client({ connectionString: databaseUrl });
753
+ try {
754
+ await client.connect();
755
+ } catch (err) {
756
+ outError(chalk.red(`✗ Failed to connect to database: ${err instanceof Error ? err.message : String(err)}`));
757
+ outError(chalk.gray(" Check your DATABASE_URL and ensure the database is reachable."));
758
+ process.exit(1);
759
+ }
760
+ const hostPart = safeHostFromUrl(databaseUrl);
761
+ out(chalk.gray(`Connected to database: ${hostPart}`));
762
+ out(chalk.gray(`Introspecting schema '${pgSchema}'...`));
763
+ try {
764
+ const metadata = await readSchemaMetadata(client, pgSchema);
765
+ const enumMap = buildEnumMap(metadata.enumValues);
766
+ const tablesMap = buildTablesMap(metadata.tables, metadata.columns, metadata.pks, metadata.fks);
767
+ const fks = metadata.fks;
768
+ for (const table of lookupCandidates(metadata, tablesMap)) try {
769
+ metadata.rowCounts[table] = await countRowsUpTo(client, pgSchema, table, 50);
770
+ } catch (err) {
771
+ out(chalk.gray(` (skipped row count for ${table}: ${err instanceof Error ? err.message : String(err)})`));
772
+ }
773
+ const classifications = classifyTables(metadata, tablesMap);
774
+ const checkFacts = parseCheckConstraints(metadata.checks);
775
+ const builder = detectCollectionBuilder(outDir);
776
+ if (builder === "annotation") {
777
+ outWarn(chalk.yellow(`⚠ Neither @rebasepro/cms-types nor @rebasepro/common is declared above ${outDir}.`));
778
+ outWarn(chalk.gray(" Generating plain type annotations, which widen the property keys to `string`: `propertiesOrder` and friends will not be checked. Add @rebasepro/common to the config package to turn that checking on."));
779
+ }
780
+ const joinTables = new Set(Array.from(classifications.values()).filter((c) => c.role === "junction").map((c) => c.table));
781
+ const roleCount = (role) => Array.from(classifications.values()).filter((c) => c.role === role).length;
782
+ out(chalk.blue(`Found ${tablesMap.size} tables.`));
783
+ out(chalk.gray(` ${roleCount("entity")} entities, ${joinTables.size} join tables (folded into relations), ${roleCount("lookup")} code lists, ${roleCount("owned-child")} owned by another table (hidden from navigation).`));
784
+ let runDataInference = false;
785
+ if (args["--no-data-inference"]) runDataInference = false;
786
+ else if (args["--data-inference"] !== void 0) runDataInference = args["--data-inference"];
787
+ else if (!process.stdin.isTTY) out(chalk.gray("Skipping data inference (non-interactive run; pass --data-inference to enable)."));
788
+ else {
789
+ const rl = import___vite_browser_external.default.createInterface({
790
+ input: process.stdin,
791
+ output: process.stdout
792
+ });
793
+ runDataInference = (await new Promise((resolve) => rl.question(chalk.yellow("? Do you want to run comprehensive data inference on sampled rows to auto-detect types, formats, constraints, and UI configurations? (y/N) "), resolve))).trim().toLowerCase() === "y";
794
+ rl.close();
795
+ }
796
+ if (runDataInference) out(chalk.gray("Sampling database rows for data inference..."));
797
+ const generatedFiles = [];
798
+ const skippedFiles = [];
799
+ const tablesToProcess = Array.from(tablesMap.entries()).filter(([tableName]) => !joinTables.has(tableName));
800
+ const BATCH_SIZE = 10;
801
+ for (let i = 0; i < tablesToProcess.length; i += BATCH_SIZE) {
802
+ const batch = tablesToProcess.slice(i, i + BATCH_SIZE);
803
+ await Promise.all(batch.map(async ([tableName, meta]) => {
804
+ if (!isUsableFileName(tableName)) {
805
+ outWarn(chalk.yellow(`⚠ Skipping table ${JSON.stringify(tableName)}: its name cannot be used as a filename. Rename the table, or write this collection by hand.`));
806
+ skippedFiles.push(tableName);
807
+ return;
808
+ }
809
+ const filePath = path.join(outDir, `${tableName}.ts`);
810
+ if (fs.existsSync(filePath) && !force) {
811
+ skippedFiles.push(tableName);
812
+ return;
813
+ }
814
+ let sampleData = void 0;
815
+ if (runDataInference) try {
816
+ const { rows } = await client.query(`SELECT * FROM ${quoteRelation(pgSchema, tableName)} LIMIT 100`);
817
+ sampleData = rows;
818
+ } catch (err) {
819
+ outError(chalk.yellow(`⚠ Failed to sample data for table ${tableName}: ${err instanceof Error ? err.message : String(err)}`));
820
+ }
821
+ const fileContent = generateCollectionFile(tableName, meta, fks, joinTables, tablesMap, enumMap, sampleData, {
822
+ metadata,
823
+ classifications,
824
+ checkFacts,
825
+ builder
826
+ });
827
+ fs.writeFileSync(filePath, fileContent, "utf-8");
828
+ generatedFiles.push(tableName);
829
+ out(chalk.green(` ✓ ${filePath}`));
830
+ }));
831
+ }
832
+ if (generatedFiles.length > 0) {
833
+ const indexPath = path.join(outDir, "index.ts");
834
+ if (fs.existsSync(indexPath) && !force) {
835
+ const merged = mergeIndexContent(fs.readFileSync(indexPath, "utf-8"), generatedFiles);
836
+ fs.writeFileSync(indexPath, merged, "utf-8");
837
+ } else {
838
+ const siblings = fs.readdirSync(outDir).filter((f) => f.endsWith(".ts") && f !== "index.ts").map((f) => f.replace(/\.ts$/, ""));
839
+ const indexContent = generateIndexContent([.../* @__PURE__ */ new Set([...generatedFiles, ...siblings])]);
840
+ fs.writeFileSync(indexPath, indexContent, "utf-8");
841
+ }
842
+ out(chalk.green(` ✓ ${indexPath}`));
843
+ }
844
+ out("");
845
+ if (skippedFiles.length > 0) {
846
+ out(chalk.yellow(`⚠ Skipped ${skippedFiles.length} existing file(s): ${skippedFiles.join(", ")}`));
847
+ out(chalk.gray(" Use --force to overwrite existing files."));
848
+ out("");
849
+ }
850
+ out(chalk.bold.green(`✓ Introspected ${tablesMap.size} tables — generated ${generatedFiles.length} collection(s).`));
851
+ out(chalk.gray(` Review the generated files in ${outDir} and customize properties as needed.`));
852
+ out("");
853
+ } catch (e) {
854
+ outError(chalk.red(`✗ Error introspecting database: ${e instanceof Error ? e.message : String(e)}`));
855
+ process.exit(1);
856
+ } finally {
857
+ await client.end();
858
+ }
859
+ }
860
+ main().catch((err) => {
861
+ outError(String(err));
862
+ process.exit(1);
863
+ });
864
+ //#endregion
865
+
866
+ //# sourceMappingURL=introspect-db.js.map