@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.
- package/LICENSE +0 -1
- package/README.md +31 -0
- package/dist/backup-argv.d.ts +19 -0
- package/dist/branch-argv.d.ts +28 -0
- package/dist/branch-prune.d.ts +77 -0
- package/dist/cli-collections-path.d.ts +31 -0
- package/dist/cli-errors.d.ts +123 -0
- package/dist/cli-flags.d.ts +101 -0
- package/dist/cli-helpers.d.ts +94 -10
- package/dist/collection-index-C7LopMyX.js +247 -0
- package/dist/collection-index-C7LopMyX.js.map +1 -0
- package/dist/databasePoolManager.d.ts +27 -0
- package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
- package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
- package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
- package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.es.js +19307 -3059
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
- package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
- package/dist/schema/column-type-drift.d.ts +79 -0
- package/dist/schema/doctor.d.ts +42 -0
- package/dist/schema/ensure-collection-tables.d.ts +90 -0
- package/dist/schema/generate-next-step.d.ts +30 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
- package/dist/schema/generated-schema-staleness.d.ts +37 -0
- package/dist/schema/introspect-db-queries.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +63 -2
- package/dist/services/BranchService.d.ts +86 -1
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +30 -2
- package/dist/services/row-pipeline.d.ts +12 -1
- package/dist/utils/pg-error-utils.d.ts +3 -1
- package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
- package/package.json +32 -27
- package/dist/backup-service-DCk7KhhL.js +0 -9044
- package/dist/backup-service-DCk7KhhL.js.map +0 -1
- package/dist/collection-index-BRUg10H5.js +0 -5690
- package/dist/collection-index-BRUg10H5.js.map +0 -1
- package/dist/connection-GOKU3Hu5.js +0 -277
- package/dist/connection-GOKU3Hu5.js.map +0 -1
- package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
- package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
- package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
- package/dist/src-DiB5RP2Z.js +0 -541
- package/dist/src-DiB5RP2Z.js.map +0 -1
- package/dist/websocket-BZ4H5wUz.js +0 -8198
- package/dist/websocket-BZ4H5wUz.js.map +0 -1
- package/src/PostgresAdapter.ts +0 -91
- package/src/PostgresBackendDriver.ts +0 -1857
- package/src/PostgresBootstrapper.ts +0 -1157
- package/src/auth/ensure-tables.ts +0 -896
- package/src/auth/schema-version.ts +0 -260
- package/src/auth/services.ts +0 -1610
- package/src/backup/backup-cli.ts +0 -520
- package/src/backup/backup-cron.ts +0 -212
- package/src/backup/backup-logic.ts +0 -93
- package/src/backup/backup-service.ts +0 -485
- package/src/backup/index.ts +0 -12
- package/src/backup/pg-tools.ts +0 -403
- package/src/backup/retention.ts +0 -86
- package/src/cli-errors.ts +0 -265
- package/src/cli-helpers.ts +0 -569
- package/src/cli-output.ts +0 -43
- package/src/cli.ts +0 -1339
- package/src/collections/PostgresCollectionRegistry.ts +0 -108
- package/src/collections/buildRegistry.ts +0 -68
- package/src/collections/validate-relations.ts +0 -402
- package/src/connection.ts +0 -333
- package/src/data-transformer.ts +0 -845
- package/src/databasePoolManager.ts +0 -95
- package/src/history/HistoryService.ts +0 -254
- package/src/history/ensure-history-table.ts +0 -53
- package/src/index.ts +0 -19
- package/src/interfaces.ts +0 -60
- package/src/module-dir.ts +0 -7
- package/src/schema/atlas-argv.ts +0 -94
- package/src/schema/auth-schema.ts +0 -298
- package/src/schema/auth-users-columns.ts +0 -131
- package/src/schema/carved-out-migration.ts +0 -404
- package/src/schema/classify-change.ts +0 -436
- package/src/schema/collection-index.ts +0 -427
- package/src/schema/collections-schema-version.ts +0 -103
- package/src/schema/destructive-sql.ts +0 -94
- package/src/schema/doctor-cli.ts +0 -66
- package/src/schema/doctor-policy-checks.ts +0 -105
- package/src/schema/doctor.ts +0 -837
- package/src/schema/drizzle-ddl.ts +0 -33
- package/src/schema/dynamic-tables.test.ts +0 -302
- package/src/schema/dynamic-tables.ts +0 -293
- package/src/schema/ensure-collection-policies.ts +0 -198
- package/src/schema/ensure-collection-tables.test.ts +0 -518
- package/src/schema/ensure-collection-tables.ts +0 -1267
- package/src/schema/generate-drizzle-schema-logic.ts +0 -957
- package/src/schema/generate-drizzle-schema.ts +0 -143
- package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
- package/src/schema/generate-postgres-ddl.ts +0 -180
- package/src/schema/generate-schema-commit.ts +0 -267
- package/src/schema/generated-schema-staleness.ts +0 -284
- package/src/schema/introspect-db-constraints.ts +0 -385
- package/src/schema/introspect-db-inference.ts +0 -255
- package/src/schema/introspect-db-logic.ts +0 -1387
- package/src/schema/introspect-db-naming.ts +0 -15
- package/src/schema/introspect-db-project.ts +0 -78
- package/src/schema/introspect-db-queries.ts +0 -326
- package/src/schema/introspect-db-structure.ts +0 -670
- package/src/schema/introspect-db-types.ts +0 -56
- package/src/schema/introspect-db.ts +0 -257
- package/src/schema/introspect-runtime.test.ts +0 -260
- package/src/schema/introspect-runtime.ts +0 -327
- package/src/schema/non-sql-collections.test.ts +0 -131
- package/src/schema/rls-bootstrap-sql.ts +0 -288
- package/src/schema/search-column.ts +0 -643
- package/src/schema/test-schema.ts +0 -11
- package/src/schema/vector-index.ts +0 -491
- package/src/security/anonymous-grants.test.ts +0 -73
- package/src/security/policy-drift.test.ts +0 -435
- package/src/security/policy-drift.ts +0 -473
- package/src/security/rls-enforcement.ts +0 -505
- package/src/services/BranchService.ts +0 -326
- package/src/services/FetchService.ts +0 -2115
- package/src/services/PersistService.ts +0 -557
- package/src/services/RelationService.ts +0 -978
- package/src/services/RelationWriteService.ts +0 -653
- package/src/services/cdc/CdcListener.ts +0 -103
- package/src/services/cdc/junction-tables.ts +0 -91
- package/src/services/cdc/trigger-cdc.ts +0 -173
- package/src/services/channel-bus/ChannelBus.ts +0 -44
- package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
- package/src/services/channel-bus/index.ts +0 -114
- package/src/services/channel-history.ts +0 -424
- package/src/services/channel-presence.ts +0 -185
- package/src/services/collection-helpers.ts +0 -420
- package/src/services/dataService.ts +0 -256
- package/src/services/index.ts +0 -14
- package/src/services/junction-writes.ts +0 -295
- package/src/services/nested-path.ts +0 -145
- package/src/services/pg-notify-listener.ts +0 -151
- package/src/services/realtimeService.ts +0 -2545
- package/src/services/row-pipeline.ts +0 -240
- package/src/services/write-denial.ts +0 -55
- package/src/types.ts +0 -4
- package/src/utils/connection-string.ts +0 -58
- package/src/utils/drizzle-conditions.ts +0 -2743
- package/src/utils/pg-array-null-patch.ts +0 -42
- package/src/utils/pg-error-utils.ts +0 -391
- 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
|
-
}
|