@rebasepro/server-postgres 0.17.3 → 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 (204) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/BranchService-ByLfM_Tt.js +360 -0
  4. package/dist/BranchService-ByLfM_Tt.js.map +1 -0
  5. package/dist/__vite-browser-external-BnuHet1e.js +46 -0
  6. package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
  7. package/dist/arg-Dni7MzLB.js +87 -0
  8. package/dist/arg-Dni7MzLB.js.map +1 -0
  9. package/dist/auth-users-columns-D2LBFrMH.js +116 -0
  10. package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
  11. package/dist/backup-argv.d.ts +19 -0
  12. package/dist/backup-cli-oM9WQVlV.js +408 -0
  13. package/dist/backup-cli-oM9WQVlV.js.map +1 -0
  14. package/dist/backup-service-Br3tqwsy.js +715 -0
  15. package/dist/backup-service-Br3tqwsy.js.map +1 -0
  16. package/dist/branch-argv.d.ts +28 -0
  17. package/dist/branch-prune.d.ts +77 -0
  18. package/dist/cli-collections-path.d.ts +31 -0
  19. package/dist/cli-errors-DxN4yzdH.js +657 -0
  20. package/dist/cli-errors-DxN4yzdH.js.map +1 -0
  21. package/dist/cli-errors.d.ts +123 -0
  22. package/dist/cli-flags.d.ts +101 -0
  23. package/dist/cli-helpers.d.ts +94 -10
  24. package/dist/cli-output-CNdMql-L.js +47 -0
  25. package/dist/cli-output-CNdMql-L.js.map +1 -0
  26. package/dist/cli.js +2020 -0
  27. package/dist/cli.js.map +1 -0
  28. package/dist/collection-index-BHgT_TKd.js +276 -0
  29. package/dist/collection-index-BHgT_TKd.js.map +1 -0
  30. package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
  31. package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
  32. package/dist/{connection-GOKU3Hu5.js → databasePoolManager-Bj5FbeAs.js} +112 -40
  33. package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
  34. package/dist/databasePoolManager.d.ts +27 -0
  35. package/dist/doctor-D7HWmGzs.js +974 -0
  36. package/dist/doctor-D7HWmGzs.js.map +1 -0
  37. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-2c2NV8zX.js} +7 -27
  38. package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
  39. package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
  40. package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
  41. package/dist/ensure-tables-CIojK-Rx.js +650 -0
  42. package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
  43. package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
  44. package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
  45. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
  46. package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
  47. package/dist/{ensure-collection-tables-DgVixhX3.js → generate-postgres-ddl-logic-CtnpIBVv.js} +126 -984
  48. package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
  49. package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
  50. package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
  51. package/dist/index.d.ts +12 -0
  52. package/dist/index.es.js +7423 -1133
  53. package/dist/index.es.js.map +1 -1
  54. package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
  55. package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
  56. package/dist/policy-drift-kNJ6X2Uj.js +313 -0
  57. package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
  58. package/dist/{backup-service-DCk7KhhL.js → rls-bootstrap-sql-DLDnUjtx.js} +737 -2689
  59. package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
  60. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-DeYEjYLt.js} +65 -190
  61. package/dist/rls-enforcement-DeYEjYLt.js.map +1 -0
  62. package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
  63. package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
  64. package/dist/schema/column-type-drift.d.ts +79 -0
  65. package/dist/schema/doctor-cli.js +129 -0
  66. package/dist/schema/doctor-cli.js.map +1 -0
  67. package/dist/schema/doctor.d.ts +42 -0
  68. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  69. package/dist/schema/generate-drizzle-schema.d.ts +1 -1
  70. package/dist/schema/generate-drizzle-schema.js +24 -0
  71. package/dist/schema/generate-drizzle-schema.js.map +1 -0
  72. package/dist/schema/generate-next-step.d.ts +30 -0
  73. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  74. package/dist/schema/generate-postgres-ddl.d.ts +1 -1
  75. package/dist/schema/generate-postgres-ddl.js +138 -0
  76. package/dist/schema/generate-postgres-ddl.js.map +1 -0
  77. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  78. package/dist/schema/introspect-db-queries.d.ts +34 -0
  79. package/dist/schema/introspect-db.js +866 -0
  80. package/dist/schema/introspect-db.js.map +1 -0
  81. package/dist/security/rls-enforcement.d.ts +63 -2
  82. package/dist/services/BranchService.d.ts +86 -1
  83. package/dist/services/collection-helpers.d.ts +1 -1
  84. package/dist/services/realtimeService.d.ts +30 -2
  85. package/dist/services/row-pipeline.d.ts +12 -1
  86. package/dist/source-Br7L7GOI.js +410 -0
  87. package/dist/source-Br7L7GOI.js.map +1 -0
  88. package/dist/utils/pg-error-utils.d.ts +3 -1
  89. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  90. package/dist/vector-index-DkWxm2de.js +309 -0
  91. package/dist/vector-index-DkWxm2de.js.map +1 -0
  92. package/package.json +32 -27
  93. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  94. package/dist/collection-index-BRUg10H5.js +0 -5690
  95. package/dist/collection-index-BRUg10H5.js.map +0 -1
  96. package/dist/connection-GOKU3Hu5.js.map +0 -1
  97. package/dist/ensure-collection-policies-UCqgv_8c.js.map +0 -1
  98. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  99. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  100. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  101. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  102. package/dist/src-DiB5RP2Z.js +0 -541
  103. package/dist/src-DiB5RP2Z.js.map +0 -1
  104. package/dist/websocket-BZ4H5wUz.js +0 -8198
  105. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  106. package/src/PostgresAdapter.ts +0 -91
  107. package/src/PostgresBackendDriver.ts +0 -1857
  108. package/src/PostgresBootstrapper.ts +0 -1157
  109. package/src/auth/ensure-tables.ts +0 -896
  110. package/src/auth/schema-version.ts +0 -260
  111. package/src/auth/services.ts +0 -1610
  112. package/src/backup/backup-cli.ts +0 -520
  113. package/src/backup/backup-cron.ts +0 -212
  114. package/src/backup/backup-logic.ts +0 -93
  115. package/src/backup/backup-service.ts +0 -485
  116. package/src/backup/index.ts +0 -12
  117. package/src/backup/pg-tools.ts +0 -403
  118. package/src/backup/retention.ts +0 -86
  119. package/src/cli-errors.ts +0 -265
  120. package/src/cli-helpers.ts +0 -569
  121. package/src/cli-output.ts +0 -43
  122. package/src/cli.ts +0 -1339
  123. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  124. package/src/collections/buildRegistry.ts +0 -68
  125. package/src/collections/validate-relations.ts +0 -402
  126. package/src/connection.ts +0 -333
  127. package/src/data-transformer.ts +0 -845
  128. package/src/databasePoolManager.ts +0 -95
  129. package/src/history/HistoryService.ts +0 -254
  130. package/src/history/ensure-history-table.ts +0 -53
  131. package/src/index.ts +0 -19
  132. package/src/interfaces.ts +0 -60
  133. package/src/module-dir.ts +0 -7
  134. package/src/schema/atlas-argv.ts +0 -94
  135. package/src/schema/auth-schema.ts +0 -298
  136. package/src/schema/auth-users-columns.ts +0 -131
  137. package/src/schema/carved-out-migration.ts +0 -404
  138. package/src/schema/classify-change.ts +0 -436
  139. package/src/schema/collection-index.ts +0 -427
  140. package/src/schema/collections-schema-version.ts +0 -103
  141. package/src/schema/destructive-sql.ts +0 -94
  142. package/src/schema/doctor-cli.ts +0 -66
  143. package/src/schema/doctor-policy-checks.ts +0 -105
  144. package/src/schema/doctor.ts +0 -837
  145. package/src/schema/drizzle-ddl.ts +0 -33
  146. package/src/schema/dynamic-tables.test.ts +0 -302
  147. package/src/schema/dynamic-tables.ts +0 -293
  148. package/src/schema/ensure-collection-policies.ts +0 -198
  149. package/src/schema/ensure-collection-tables.test.ts +0 -518
  150. package/src/schema/ensure-collection-tables.ts +0 -1267
  151. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  152. package/src/schema/generate-drizzle-schema.ts +0 -143
  153. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  154. package/src/schema/generate-postgres-ddl.ts +0 -180
  155. package/src/schema/generate-schema-commit.ts +0 -267
  156. package/src/schema/generated-schema-staleness.ts +0 -284
  157. package/src/schema/introspect-db-constraints.ts +0 -385
  158. package/src/schema/introspect-db-inference.ts +0 -255
  159. package/src/schema/introspect-db-logic.ts +0 -1387
  160. package/src/schema/introspect-db-naming.ts +0 -15
  161. package/src/schema/introspect-db-project.ts +0 -78
  162. package/src/schema/introspect-db-queries.ts +0 -326
  163. package/src/schema/introspect-db-structure.ts +0 -670
  164. package/src/schema/introspect-db-types.ts +0 -56
  165. package/src/schema/introspect-db.ts +0 -257
  166. package/src/schema/introspect-runtime.test.ts +0 -260
  167. package/src/schema/introspect-runtime.ts +0 -327
  168. package/src/schema/non-sql-collections.test.ts +0 -131
  169. package/src/schema/rls-bootstrap-sql.ts +0 -288
  170. package/src/schema/search-column.ts +0 -643
  171. package/src/schema/test-schema.ts +0 -11
  172. package/src/schema/vector-index.ts +0 -491
  173. package/src/security/anonymous-grants.test.ts +0 -73
  174. package/src/security/policy-drift.test.ts +0 -435
  175. package/src/security/policy-drift.ts +0 -473
  176. package/src/security/rls-enforcement.ts +0 -505
  177. package/src/services/BranchService.ts +0 -326
  178. package/src/services/FetchService.ts +0 -2115
  179. package/src/services/PersistService.ts +0 -557
  180. package/src/services/RelationService.ts +0 -978
  181. package/src/services/RelationWriteService.ts +0 -653
  182. package/src/services/cdc/CdcListener.ts +0 -103
  183. package/src/services/cdc/junction-tables.ts +0 -91
  184. package/src/services/cdc/trigger-cdc.ts +0 -173
  185. package/src/services/channel-bus/ChannelBus.ts +0 -44
  186. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  187. package/src/services/channel-bus/index.ts +0 -114
  188. package/src/services/channel-history.ts +0 -424
  189. package/src/services/channel-presence.ts +0 -185
  190. package/src/services/collection-helpers.ts +0 -420
  191. package/src/services/dataService.ts +0 -256
  192. package/src/services/index.ts +0 -14
  193. package/src/services/junction-writes.ts +0 -295
  194. package/src/services/nested-path.ts +0 -145
  195. package/src/services/pg-notify-listener.ts +0 -151
  196. package/src/services/realtimeService.ts +0 -2545
  197. package/src/services/row-pipeline.ts +0 -240
  198. package/src/services/write-denial.ts +0 -55
  199. package/src/types.ts +0 -4
  200. package/src/utils/connection-string.ts +0 -58
  201. package/src/utils/drizzle-conditions.ts +0 -2743
  202. package/src/utils/pg-array-null-patch.ts +0 -42
  203. package/src/utils/pg-error-utils.ts +0 -391
  204. package/src/websocket.ts +0 -855
@@ -1,284 +0,0 @@
1
- import { CollectionConfig } from "@rebasepro/types";
2
- import { getTableName, resolveCollectionRelations, relationalCollections } from "@rebasepro/common";
3
- import { generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
4
-
5
- /**
6
- * Notice a generated Drizzle schema that a *library upgrade* invalidated.
7
- *
8
- * `rebase dev` already watches `config/collections` and warns when a collection
9
- * file changes. That covers drift the developer caused. It cannot cover this one,
10
- * because nothing the developer owns changed: 0.13 derives `category_id` where
11
- * 0.12 derived `categorie_id`, from the same unedited collection. The watcher
12
- * never fires, and `backend/src/schema.generated.ts` quietly stops describing the
13
- * schema the runtime expects.
14
- *
15
- * The consequence is not cosmetic. Boot-ensure renames the column in the
16
- * database, then relation validation reads the stale module and refuses to
17
- * start — on that boot and every boot after it, because the rename is already
18
- * applied and will not be attempted again.
19
- *
20
- * Deliberately narrow: this answers "does the generated schema name a foreign key
21
- * the way the previous rule did", not "is this file what we would generate now".
22
- * The wide question would report every whitespace change in the generator as a
23
- * fatal staleness, and a check that cries wolf gets switched off.
24
- */
25
-
26
- /** One column the generated schema names under the pre-0.13 rule. */
27
- export interface LegacyForeignKeyName {
28
- /** Table whose column declaration is stale. */
29
- table: string;
30
- /** The name the generated schema declares. */
31
- legacy: string;
32
- /** The name this release derives, and which the database now carries. */
33
- current: string;
34
- /** `<collection>.<relation>` that derives it, for the message. */
35
- relation: string;
36
- }
37
-
38
- /**
39
- * The slice of the generated source declaring one table.
40
- *
41
- * Scoping matters: two junctions in one file can carry columns of the same name,
42
- * and a whole-file match would attribute a stale column to whichever table the
43
- * reader looks at first. Returns "" when the table is not in the file at all,
44
- * which is not staleness — it is a table the generator has not been asked about.
45
- */
46
- function tableBlock(source: string, table: string): string {
47
- // Both forms the generator emits. A collection with `schema: "rebase"` — the
48
- // auth users collection, in every scaffold — is written as
49
- // `rebaseSchema.table("users", …)`, so matching only `pgTable(` made this
50
- // blind to every table outside `public`: the block came back empty, and an
51
- // empty block reads as "a table the generator has not been asked about".
52
- // The rename check therefore never inspected them.
53
- const literal = table.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
54
- const start = source.search(new RegExp(`(?:pgTable|\\.table)\\(\\s*["']${literal}["']`));
55
- if (start === -1) return "";
56
- // Generated files put every table in its own `export const`, so the next one
57
- // is the end of this block. No brace counting, nothing to get wrong.
58
- const next = source.indexOf("\nexport ", start);
59
- return next === -1 ? source.slice(start) : source.slice(start, next);
60
- }
61
-
62
- /**
63
- * Whether a block *declares* the column, rather than merely mentioning it.
64
- *
65
- * A comment explaining the rename, or a policy expression naming the old column,
66
- * must not read as a declaration — otherwise regenerating the file would not
67
- * clear the finding and the check would be permanently red.
68
- */
69
- function declaresColumn(block: string, column: string): boolean {
70
- // `categorieId: integer("categorie_id")` — the Drizzle column shape. Only
71
- // the string argument is the column; the key beside it is the *wire* name
72
- // and no longer agrees with it (`authorId: integer("author_id")`). Matching
73
- // both, as this did, made every camelCased key look like a column the
74
- // generated file did not declare.
75
- const literal = column.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
76
- return new RegExp(`(^|[\\s,{])[\\w$"']+\\s*:\\s*\\w+\\(\\s*["']${literal}["']`, "m").test(block);
77
- }
78
-
79
- /**
80
- * @param generatedSource contents of `backend/src/schema.generated.ts`
81
- * @param collections the project's collections, as this release reads them
82
- */
83
- export function findLegacyForeignKeyNames(
84
- generatedSource: string,
85
- collections: CollectionConfig[]
86
- ): LegacyForeignKeyName[] {
87
- const found: LegacyForeignKeyName[] = [];
88
- const seen = new Set<string>();
89
-
90
- /**
91
- * Report `wanted` as stale when the generated schema declares what the
92
- * previous rule would have derived from `source` instead.
93
- *
94
- * The `wanted !== current` guard is what honours an explicitly named column.
95
- * An author who pinned `categorie_id` has said so on the relation, so the
96
- * generated file agreeing with them is correct, not stale — and the rename
97
- * note documents exactly that opt-out.
98
- */
99
- const consider = (
100
- table: string,
101
- wanted: string,
102
- sourceName: string | undefined,
103
- relation: string
104
- ): void => {
105
- if (!table || !wanted || !sourceName) return;
106
-
107
- const current = generateForeignKeyName(sourceName);
108
- const legacy = legacyForeignKeyName(sourceName);
109
- if (legacy === current) return; // this name never moved
110
- if (wanted !== current) return; // pinned by the author, or unrelated
111
-
112
- const block = tableBlock(generatedSource, table);
113
- if (!block) return;
114
- if (!declaresColumn(block, legacy)) return;
115
- if (declaresColumn(block, current)) return; // already regenerated
116
-
117
- const key = `${table}.${legacy}`;
118
- if (seen.has(key)) return;
119
- seen.add(key);
120
- found.push({ table, legacy, current, relation });
121
- };
122
-
123
- // Same rule as the generator whose output this reads: a collection that
124
- // gets no table cannot have named a column in it. Inert in practice today —
125
- // the finding also requires the generated file to declare the table — but
126
- // the guard is what keeps that true when a non-SQL collection's slug happens
127
- // to match a SQL one's table.
128
- for (const collection of relationalCollections(collections)) {
129
- const sourceTable = getTableName(collection);
130
-
131
- for (const [name, relation] of Object.entries(resolveCollectionRelations(collection))) {
132
- const at = `${collection.slug}.${name}`;
133
-
134
- let target: CollectionConfig | undefined;
135
- try {
136
- target = relation.target?.();
137
- } catch {
138
- // A throwing target thunk is its own defect, reported at boot by
139
- // `validate-relations`. Nothing to say about its column names.
140
- continue;
141
- }
142
-
143
- switch (relation.kind) {
144
- case "belongsTo":
145
- consider(sourceTable, relation.localKey, relation.relationName, at);
146
- break;
147
-
148
- case "hasOne":
149
- case "hasMany":
150
- if (target) {
151
- consider(getTableName(target), relation.foreignKeyOnTarget, collection.slug, at);
152
- }
153
- break;
154
-
155
- case "manyToMany":
156
- consider(relation.through.table, relation.through.sourceColumn, collection.slug, at);
157
- if (target) {
158
- consider(relation.through.table, relation.through.targetColumn, target.slug, at);
159
- }
160
- break;
161
-
162
- default:
163
- // `via` joins are written by hand — there is no derived name
164
- // for the rule change to have moved.
165
- break;
166
- }
167
- }
168
- }
169
-
170
- return found;
171
- }
172
-
173
- /** One-line summary for a log or a CLI notice. */
174
- export function describeLegacyForeignKeyNames(found: LegacyForeignKeyName[]): string {
175
- return found
176
- .map(f => ` • ${f.table}.${f.legacy} → ${f.current} (${f.relation})`)
177
- .join("\n");
178
- }
179
-
180
- // ── The other way a generated schema goes stale ──────────────────────────────
181
- //
182
- // The rename above is the subtle one: nothing the developer owns changed. The
183
- // ordinary one is the opposite — a collection or a relation was added and the
184
- // generator was not re-run, so the checked-in module simply does not describe
185
- // part of the schema. It fails later and elsewhere: relation validation, or a
186
- // query against a table the module has never heard of.
187
- //
188
- // Still not "is this byte-for-byte what the generator would emit now". This asks
189
- // only about *names*, which is what the rest of the system reads out of the
190
- // file, so reformatting the generator cannot make it red.
191
-
192
- /** Something the collections derive that the generated schema does not declare. */
193
- export interface MissingGeneratedName {
194
- /** The table it belongs to, or is. */
195
- table: string;
196
- /** The column, when a column is what is missing. */
197
- column?: string;
198
- /** `<collection>` or `<collection>.<relation>`, for the message. */
199
- source: string;
200
- }
201
-
202
- /**
203
- * Tables and derived foreign-key columns the collections name and the generated
204
- * schema does not.
205
- *
206
- * @param generatedSource contents of `backend/src/schema.generated.ts`
207
- * @param collections the project's collections, as this release reads them
208
- */
209
- export function findMissingGeneratedNames(
210
- generatedSource: string,
211
- collections: CollectionConfig[]
212
- ): MissingGeneratedName[] {
213
- const missing: MissingGeneratedName[] = [];
214
- const seen = new Set<string>();
215
-
216
- const report = (table: string, source: string, column?: string): void => {
217
- const key = `${table}.${column ?? ""}`;
218
- if (seen.has(key)) return;
219
- seen.add(key);
220
- missing.push({ table, source, ...(column ? { column } : {}) });
221
- };
222
-
223
- const requireColumn = (table: string, column: string | undefined, source: string): void => {
224
- if (!table || !column) return;
225
- const block = tableBlock(generatedSource, table);
226
- // A table that is absent is reported once, as a table. Listing each of
227
- // its columns as separately missing would bury the one fact that
228
- // explains all of them.
229
- if (!block) return report(table, source);
230
- if (!declaresColumn(block, column)) report(table, source, column);
231
- };
232
-
233
- for (const collection of relationalCollections(collections)) {
234
- const sourceTable = getTableName(collection);
235
- if (!tableBlock(generatedSource, sourceTable)) {
236
- report(sourceTable, collection.slug);
237
- continue;
238
- }
239
-
240
- for (const [name, relation] of Object.entries(resolveCollectionRelations(collection))) {
241
- const at = `${collection.slug}.${name}`;
242
-
243
- let target: CollectionConfig | undefined;
244
- try {
245
- target = relation.target?.();
246
- } catch {
247
- // Its own defect, reported at boot by `validate-relations`.
248
- continue;
249
- }
250
-
251
- switch (relation.kind) {
252
- case "belongsTo":
253
- requireColumn(sourceTable, relation.localKey, at);
254
- break;
255
-
256
- case "hasOne":
257
- case "hasMany":
258
- if (target) requireColumn(getTableName(target), relation.foreignKeyOnTarget, at);
259
- break;
260
-
261
- case "manyToMany":
262
- requireColumn(relation.through.table, relation.through.sourceColumn, at);
263
- requireColumn(relation.through.table, relation.through.targetColumn, at);
264
- break;
265
-
266
- default:
267
- // `via` joins are written by hand; there is no derived name
268
- // to be missing.
269
- break;
270
- }
271
- }
272
- }
273
-
274
- return missing;
275
- }
276
-
277
- /** One-line summary for a log or a CLI notice. */
278
- export function describeMissingGeneratedNames(missing: MissingGeneratedName[]): string {
279
- return missing
280
- .map(m => (m.column
281
- ? ` • ${m.table}.${m.column} is not declared (${m.source})`
282
- : ` • table ${m.table} is not declared (${m.source})`))
283
- .join("\n");
284
- }
@@ -1,385 +0,0 @@
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
-
26
- /** What a table's CHECK constraints say about one column. */
27
- export interface ColumnCheckFacts {
28
- /** Allowed values, from `IN (…)` / `= ANY (ARRAY[…])` / `= 'literal'`. */
29
- enumValues?: string[];
30
- /** `x >= n` */
31
- min?: number;
32
- /** `x <= n` */
33
- max?: number;
34
- /** `x > n` */
35
- moreThan?: number;
36
- /** `x < n` */
37
- lessThan?: number;
38
- /** `length(x) >= n` */
39
- lengthMin?: number;
40
- /** `length(x) <= n` */
41
- lengthMax?: number;
42
- }
43
-
44
- /** Per-table, per-column facts. */
45
- export type CheckFactsByTable = Map<string, Map<string, ColumnCheckFacts>>;
46
-
47
- /** The length functions whose argument is the column being constrained. */
48
- const LENGTH_FUNCTIONS = new Set(["length", "char_length", "character_length", "octet_length"]);
49
-
50
- /**
51
- * Splits on a separator that appears at paren depth 0 and outside string
52
- * literals.
53
- *
54
- * Depth tracking is the whole job: `ARRAY['a', 'b']` contains a comma that must
55
- * not split the enclosing argument list, and `'it''s'` contains a quote that
56
- * must not end the literal.
57
- */
58
- function splitTopLevel(input: string, separator: string): string[] {
59
- const parts: string[] = [];
60
- let depth = 0;
61
- let inString = false;
62
- let current = "";
63
-
64
- for (let i = 0; i < input.length; i++) {
65
- const ch = input[i];
66
-
67
- if (inString) {
68
- current += ch;
69
- if (ch === "'") {
70
- // '' inside a literal is an escaped quote, not the end of it.
71
- if (input[i + 1] === "'") {
72
- current += input[++i];
73
- } else {
74
- inString = false;
75
- }
76
- }
77
- continue;
78
- }
79
-
80
- if (ch === "'") {
81
- inString = true;
82
- current += ch;
83
- continue;
84
- }
85
- if (ch === "(" || ch === "[") depth++;
86
- if (ch === ")" || ch === "]") depth--;
87
-
88
- if (depth === 0 && input.startsWith(separator, i)) {
89
- // A word separator must not match inside an identifier: "AND" is a
90
- // separator in `a AND b` but not in `brand = 1`.
91
- const before = input[i - 1];
92
- const after = input[i + separator.length];
93
- const wordish = /[A-Za-z]/.test(separator[0]);
94
- const boundedOk = !wordish || ((before === undefined || !/[A-Za-z0-9_]/.test(before)) &&
95
- (after === undefined || !/[A-Za-z0-9_]/.test(after)));
96
- if (boundedOk) {
97
- parts.push(current);
98
- current = "";
99
- i += separator.length - 1;
100
- continue;
101
- }
102
- }
103
-
104
- current += ch;
105
- }
106
-
107
- parts.push(current);
108
- return parts.map((p) => p.trim()).filter((p) => p.length > 0);
109
- }
110
-
111
- /** Removes one layer of wrapping parentheses, repeatedly, when balanced. */
112
- export function unwrapParens(input: string): string {
113
- let s = input.trim();
114
- while (s.startsWith("(") && s.endsWith(")")) {
115
- let depth = 0;
116
- let balanced = true;
117
- let inString = false;
118
- for (let i = 0; i < s.length; i++) {
119
- const ch = s[i];
120
- if (inString) {
121
- if (ch === "'") {
122
- if (s[i + 1] === "'") i++;
123
- else inString = false;
124
- }
125
- continue;
126
- }
127
- if (ch === "'") { inString = true; continue; }
128
- if (ch === "(") depth++;
129
- else if (ch === ")") {
130
- depth--;
131
- // The opening paren closed before the end, so the outer pair is
132
- // not a wrapper: `(a) AND (b)`.
133
- if (depth === 0 && i < s.length - 1) { balanced = false; break; }
134
- }
135
- }
136
- if (!balanced) break;
137
- s = s.slice(1, -1).trim();
138
- }
139
- return s;
140
- }
141
-
142
- /** Drops trailing `::type` casts, including array and quoted forms. */
143
- export function stripCasts(input: string): string {
144
- let s = unwrapParens(input);
145
- let previous: string;
146
- do {
147
- previous = s;
148
- s = unwrapParens(
149
- s.replace(/::\s*"?[A-Za-z_][A-Za-z0-9_ ]*"?(\s*\(\s*\d+\s*(,\s*\d+\s*)?\))?(\s*\[\s*\])*\s*$/, "").trim()
150
- );
151
- } while (s !== previous);
152
- return s;
153
- }
154
-
155
- /** A bare, unquoted column reference — anything else is an expression. */
156
- function asColumnName(input: string): string | null {
157
- const s = stripCasts(input);
158
- if (/^[a-z_][a-z0-9_$]*$/i.test(s)) return s;
159
- // A quoted identifier keeps its case: "userId".
160
- const quoted = s.match(/^"([^"]+)"$/);
161
- return quoted ? quoted[1] : null;
162
- }
163
-
164
- /** `length(col)` and friends → the column they measure. */
165
- function asLengthOfColumn(input: string): string | null {
166
- const s = stripCasts(input);
167
- const match = s.match(/^([a-z_]+)\s*\((.*)\)$/is);
168
- if (!match || !LENGTH_FUNCTIONS.has(match[1].toLowerCase())) return null;
169
- return asColumnName(match[2]);
170
- }
171
-
172
- /** A numeric literal, after casts are removed. Rejects anything else. */
173
- function asNumber(input: string): number | null {
174
- const s = stripCasts(input).replace(/^'(.*)'$/s, "$1");
175
- if (!/^[+-]?(\d+\.?\d*|\.\d+)$/.test(s)) return null;
176
- const n = Number(s);
177
- return Number.isFinite(n) ? n : null;
178
- }
179
-
180
- /** A single-quoted string literal, with `''` unescaped. */
181
- function asStringLiteral(input: string): string | null {
182
- const s = stripCasts(input);
183
- if (!s.startsWith("'") || !s.endsWith("'") || s.length < 2) return null;
184
- const body = s.slice(1, -1);
185
- // Any unescaped quote inside means this was not one literal.
186
- if (/(^|[^'])'($|[^'])/.test(body)) return null;
187
- return body.replace(/''/g, "'");
188
- }
189
-
190
- /** The element list of `ARRAY[…]`, or of a parenthesised `IN (…)` list. */
191
- function asValueList(input: string): string[] | null {
192
- const s = stripCasts(input);
193
- const arrayMatch = s.match(/^ARRAY\s*\[(.*)\]$/is);
194
- const body = arrayMatch ? arrayMatch[1] : (s.startsWith("(") && s.endsWith(")") ? s.slice(1, -1) : null);
195
- if (body === null) return null;
196
- if (body.trim() === "") return null;
197
-
198
- const values: string[] = [];
199
- for (const element of splitTopLevel(body, ",")) {
200
- const literal = asStringLiteral(element);
201
- if (literal !== null) {
202
- values.push(literal);
203
- continue;
204
- }
205
- const numeric = asNumber(element);
206
- if (numeric !== null) {
207
- values.push(String(numeric));
208
- continue;
209
- }
210
- return null; // A non-literal element means this is not an enumeration.
211
- }
212
- return values;
213
- }
214
-
215
- /** One understood comparison, already resolved to the column it constrains. */
216
- interface CheckTerm {
217
- column: string;
218
- apply: (facts: ColumnCheckFacts) => void;
219
- }
220
-
221
- /**
222
- * Parses one conjunct. Returns null when the shape is not one of the few this
223
- * understands, which is the signal to abandon the whole constraint.
224
- */
225
- function parseTerm(rawTerm: string): CheckTerm | null {
226
- const term = unwrapParens(rawTerm);
227
-
228
- // ── Membership: `x = ANY (ARRAY[…])`, `x IN (…)` ──────────────────
229
- const anyMatch = term.match(/^(.*?)\s*=\s*ANY\s*(\(.*\))$/is);
230
- const inMatch = anyMatch ? null : term.match(/^(.*?)\s+IN\s*(\(.*\))$/is);
231
- const membership = anyMatch ?? inMatch;
232
- if (membership) {
233
- const column = asColumnName(membership[1]);
234
- const values = asValueList(membership[2]);
235
- if (!column || !values || values.length === 0) return null;
236
- return {
237
- column,
238
- apply: (facts) => {
239
- // Two membership checks on one column mean both must hold.
240
- facts.enumValues = facts.enumValues
241
- ? facts.enumValues.filter((v) => values.includes(v))
242
- : values;
243
- }
244
- };
245
- }
246
-
247
- // ── Comparison: `x >= n`, `length(x) <= n`, `x = 'literal'` ───────
248
- // Longest operators first so `>=` is not read as `>`.
249
- for (const operator of [">=", "<=", "<>", "!=", "=", ">", "<"] as const) {
250
- const parts = splitTopLevel(term, operator);
251
- if (parts.length !== 2) continue;
252
- const [rawLeft, rawRight] = parts;
253
-
254
- const lengthColumn = asLengthOfColumn(rawLeft);
255
- if (lengthColumn) {
256
- const bound = asNumber(rawRight);
257
- if (bound === null) return null;
258
- switch (operator) {
259
- case ">=": return { column: lengthColumn, apply: (f) => { f.lengthMin = Math.max(f.lengthMin ?? bound, bound); } };
260
- case ">": return { column: lengthColumn, apply: (f) => { f.lengthMin = Math.max(f.lengthMin ?? bound + 1, bound + 1); } };
261
- case "<=": return { column: lengthColumn, apply: (f) => { f.lengthMax = Math.min(f.lengthMax ?? bound, bound); } };
262
- case "<": return { column: lengthColumn, apply: (f) => { f.lengthMax = Math.min(f.lengthMax ?? bound - 1, bound - 1); } };
263
- case "=": return { column: lengthColumn, apply: (f) => { f.lengthMin = bound; f.lengthMax = bound; } };
264
- default: return null;
265
- }
266
- }
267
-
268
- const column = asColumnName(rawLeft);
269
- if (!column) continue;
270
-
271
- if (operator === "=") {
272
- const literal = asStringLiteral(rawRight);
273
- if (literal !== null) {
274
- return {
275
- column,
276
- apply: (facts) => {
277
- facts.enumValues = facts.enumValues
278
- ? facts.enumValues.filter((v) => v === literal)
279
- : [literal];
280
- }
281
- };
282
- }
283
- // `x = 5` pins a number. Expressed as both bounds rather than as a
284
- // one-value enum, which for a number would render as a dropdown.
285
- const pinned = asNumber(rawRight);
286
- if (pinned === null) return null;
287
- return { column, apply: (f) => { f.min = pinned; f.max = pinned; } };
288
- }
289
-
290
- const bound = asNumber(rawRight);
291
- if (bound === null) return null;
292
- switch (operator) {
293
- case ">=": return { column, apply: (f) => { f.min = Math.max(f.min ?? bound, bound); } };
294
- case ">": return { column, apply: (f) => { f.moreThan = Math.max(f.moreThan ?? bound, bound); } };
295
- case "<=": return { column, apply: (f) => { f.max = Math.min(f.max ?? bound, bound); } };
296
- case "<": return { column, apply: (f) => { f.lessThan = Math.min(f.lessThan ?? bound, bound); } };
297
- default: return null; // `<>` / `!=` exclude rather than bound.
298
- }
299
- }
300
-
301
- return null;
302
- }
303
-
304
- /**
305
- * Reads one constraint definition into per-column facts.
306
- *
307
- * Returns an empty map for anything not understood — including a constraint
308
- * that is understood but spans two columns (`start_date < end_date`), which no
309
- * per-property validation rule can express.
310
- */
311
- export function parseCheckDefinition(definition: string): Map<string, ColumnCheckFacts> {
312
- const result = new Map<string, ColumnCheckFacts>();
313
-
314
- const body = definition
315
- .trim()
316
- .replace(/\s+NOT\s+VALID\s*$/i, "")
317
- .replace(/^CHECK\s*/i, "")
318
- .trim();
319
- if (body === definition.trim()) return result; // Not a CHECK definition.
320
-
321
- const expression = unwrapParens(body);
322
- if (!expression) return result;
323
-
324
- // Only conjunctions. A disjunction (`a OR b`) does not narrow a column: each
325
- // branch may allow what the other forbids, so no single bound follows.
326
- if (splitTopLevel(expression, " OR ").length > 1) return result;
327
-
328
- const terms = splitTopLevel(expression, " AND ");
329
- const parsed: CheckTerm[] = [];
330
- for (const term of terms) {
331
- const one = parseTerm(term);
332
- if (!one) return result; // One unreadable conjunct voids the constraint.
333
- parsed.push(one);
334
- }
335
-
336
- // A CHECK naming two columns constrains their *relationship*; keeping the
337
- // half that mentions one column would assert something the database does not.
338
- const columns = new Set(parsed.map((t) => t.column));
339
- if (columns.size !== 1) return result;
340
-
341
- const facts: ColumnCheckFacts = {};
342
- for (const term of parsed) term.apply(facts);
343
- if (Object.keys(facts).length === 0) return result;
344
-
345
- result.set(parsed[0].column, facts);
346
- return result;
347
- }
348
-
349
- /** Merges every readable CHECK in the schema into per-table, per-column facts. */
350
- export function parseCheckConstraints(rows: CheckConstraintRow[]): CheckFactsByTable {
351
- const byTable: CheckFactsByTable = new Map();
352
-
353
- for (const row of rows) {
354
- const parsed = parseCheckDefinition(row.definition);
355
- if (parsed.size === 0) continue;
356
-
357
- let table = byTable.get(row.table_name);
358
- if (!table) {
359
- table = new Map();
360
- byTable.set(row.table_name, table);
361
- }
362
-
363
- for (const [column, facts] of parsed) {
364
- const existing = table.get(column);
365
- if (!existing) {
366
- table.set(column, facts);
367
- continue;
368
- }
369
- // Separate constraints on the same column all hold at once.
370
- if (facts.enumValues) {
371
- existing.enumValues = existing.enumValues
372
- ? existing.enumValues.filter((v) => facts.enumValues!.includes(v))
373
- : facts.enumValues;
374
- }
375
- if (facts.min !== undefined) existing.min = Math.max(existing.min ?? facts.min, facts.min);
376
- if (facts.max !== undefined) existing.max = Math.min(existing.max ?? facts.max, facts.max);
377
- if (facts.moreThan !== undefined) existing.moreThan = Math.max(existing.moreThan ?? facts.moreThan, facts.moreThan);
378
- if (facts.lessThan !== undefined) existing.lessThan = Math.min(existing.lessThan ?? facts.lessThan, facts.lessThan);
379
- if (facts.lengthMin !== undefined) existing.lengthMin = Math.max(existing.lengthMin ?? facts.lengthMin, facts.lengthMin);
380
- if (facts.lengthMax !== undefined) existing.lengthMax = Math.min(existing.lengthMax ?? facts.lengthMax, facts.lengthMax);
381
- }
382
- }
383
-
384
- return byTable;
385
- }