@rebasepro/server-postgres 0.17.3-canary.gdd23447 → 0.18.0

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 (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. 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
- }