@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g394d868
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/dist/PostgresBackendDriver.d.ts +48 -1
- package/dist/backup-service-CD8o_1Sl.js.map +1 -1
- package/dist/cli-helpers.d.ts +1 -1
- package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-BTdgHBKV.js} +2 -2
- package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-BTdgHBKV.js.map} +1 -1
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-CdRsuy33.js} +33 -7
- package/dist/ensure-collection-tables-CdRsuy33.js.map +1 -0
- package/dist/index.es.js +231 -94
- package/dist/index.es.js.map +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +1 -1
- package/dist/schema/generate-drizzle-schema-logic.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/services/FetchService.d.ts +10 -7
- package/dist/services/dataService.d.ts +2 -0
- package/dist/services/realtimeService.d.ts +25 -21
- package/dist/{src-DlPBctw_.js → src-B0PGnKU3.js} +174 -16
- package/dist/src-B0PGnKU3.js.map +1 -0
- package/dist/src-DoU9yPqq.js.map +1 -1
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +165 -3
- package/src/cli-helpers.ts +8 -2
- package/src/cli.ts +79 -0
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +13 -3
- package/src/schema/doctor.ts +7 -5
- package/src/schema/ensure-collection-tables.ts +8 -2
- package/src/schema/generate-drizzle-schema-logic.ts +8 -2
- package/src/schema/generate-postgres-ddl-logic.ts +47 -11
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-logic.ts +1 -1
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/services/FetchService.ts +10 -93
- package/src/services/PersistService.ts +14 -1
- package/src/services/dataService.ts +2 -0
- package/src/services/realtimeService.ts +36 -33
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- package/dist/src-DlPBctw_.js.map +0 -1
|
@@ -2,6 +2,7 @@ import { getTableColumns } from "drizzle-orm";
|
|
|
2
2
|
import { PgTable } from "drizzle-orm/pg-core";
|
|
3
3
|
import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
|
|
4
4
|
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
5
|
+
import { generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
|
|
5
6
|
|
|
6
7
|
import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
|
|
7
8
|
|
|
@@ -58,6 +59,68 @@ const quote = (xs: Iterable<string>) => Array.from(xs).map(s => `\`${s}\``).join
|
|
|
58
59
|
/** `on.from` / `on.to` accept a single column or a composite tuple. */
|
|
59
60
|
const asColumns = (value: string | string[]): string[] => Array.isArray(value) ? value : [value];
|
|
60
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Distinguish "this column name is wrong" from "the generated schema is old".
|
|
64
|
+
*
|
|
65
|
+
* They present identically here — a relation asks for a column the registered
|
|
66
|
+
* table does not have — but they are opposite problems with opposite fixes, and
|
|
67
|
+
* getting them the wrong way round is how the 0.12 → 0.13 upgrade bricked
|
|
68
|
+
* projects.
|
|
69
|
+
*
|
|
70
|
+
* The registered table is not the database. It comes from the project's
|
|
71
|
+
* checked-in `backend/src/schema.generated.ts`, and 0.13 changed the rule that
|
|
72
|
+
* derives foreign-key names: `categories` yields `category_id` where it used to
|
|
73
|
+
* yield `categorie_id`. Boot-ensure renames the database column to match, so by
|
|
74
|
+
* the time this runs the *database* is correct and the *generated module* is the
|
|
75
|
+
* stale one. Reporting "not a column" then points at the wrong artifact, and the
|
|
76
|
+
* generic fix — "set `through.targetColumn` to one of: …", listing the legacy
|
|
77
|
+
* name because that is what the stale module still has — talks the reader into
|
|
78
|
+
* pinning a column that no longer exists.
|
|
79
|
+
*
|
|
80
|
+
* So when the wanted name is what the current rule derives, and the table
|
|
81
|
+
* carries what the *previous* rule would have derived from the same source, say
|
|
82
|
+
* that instead.
|
|
83
|
+
*
|
|
84
|
+
* @param wanted the column the relation asks for
|
|
85
|
+
* @param available every column the registered table has
|
|
86
|
+
* @param sources names the default could have been derived from (a slug, a
|
|
87
|
+
* relation name) — checking against these rather than guessing
|
|
88
|
+
* backwards from `wanted` keeps the match exact
|
|
89
|
+
*/
|
|
90
|
+
function staleCodegenRename(
|
|
91
|
+
wanted: string,
|
|
92
|
+
available: Set<string>,
|
|
93
|
+
sources: string[]
|
|
94
|
+
): { legacy: string; current: string } | null {
|
|
95
|
+
for (const source of sources) {
|
|
96
|
+
if (!source) continue;
|
|
97
|
+
const current = generateForeignKeyName(source);
|
|
98
|
+
const legacy = legacyForeignKeyName(source);
|
|
99
|
+
// Only a name that actually moved, and only when the table still has the
|
|
100
|
+
// old spelling and not the new one.
|
|
101
|
+
if (current !== wanted || legacy === current) continue;
|
|
102
|
+
if (available.has(legacy) && !available.has(current)) return { legacy, current };
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The shared explanation, so every relation kind reports it identically. */
|
|
108
|
+
function staleCodegenDefect(
|
|
109
|
+
table: string,
|
|
110
|
+
{ legacy, current }: { legacy: string; current: string }
|
|
111
|
+
): Pick<RelationDefect, "problem" | "fix"> {
|
|
112
|
+
return {
|
|
113
|
+
problem:
|
|
114
|
+
`the generated Drizzle schema still declares \`${legacy}\` on \`${table}\`, but this ` +
|
|
115
|
+
`release derives \`${current}\` — the generated schema predates the foreign-key ` +
|
|
116
|
+
"naming fix and no longer describes the database",
|
|
117
|
+
fix:
|
|
118
|
+
"regenerate it with `rebase schema generate` (or `pnpm run schema:generate`). The " +
|
|
119
|
+
"database column has already been renamed for you at boot, so nothing else is needed. " +
|
|
120
|
+
`To keep \`${legacy}\` instead, name it explicitly on the relation and regenerate.`
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
61
124
|
/**
|
|
62
125
|
* Relations whose names do not resolve against the registered schema.
|
|
63
126
|
*
|
|
@@ -117,11 +180,20 @@ kind: relation.kind };
|
|
|
117
180
|
switch (relation.kind) {
|
|
118
181
|
case "belongsTo": {
|
|
119
182
|
if (!sourceColumns.has(relation.localKey)) {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
183
|
+
// `localKey` defaults to the relation name run through
|
|
184
|
+
// the foreign-key rule, so it moves with that rule.
|
|
185
|
+
const stale = staleCodegenRename(
|
|
186
|
+
relation.localKey,
|
|
187
|
+
sourceColumns,
|
|
188
|
+
[relation.relationName, targetCollection.slug]
|
|
189
|
+
);
|
|
190
|
+
defects.push(stale
|
|
191
|
+
? { ...at, ...staleCodegenDefect(sourceTableName, stale) }
|
|
192
|
+
: {
|
|
193
|
+
...at,
|
|
194
|
+
problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
|
|
195
|
+
fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
|
|
196
|
+
});
|
|
125
197
|
}
|
|
126
198
|
break;
|
|
127
199
|
}
|
|
@@ -129,11 +201,20 @@ kind: relation.kind };
|
|
|
129
201
|
case "hasOne":
|
|
130
202
|
case "hasMany": {
|
|
131
203
|
if (!targetColumns.has(relation.foreignKeyOnTarget)) {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
204
|
+
// The default is derived from *this* collection's slug —
|
|
205
|
+
// the column on the target that points back here.
|
|
206
|
+
const stale = staleCodegenRename(
|
|
207
|
+
relation.foreignKeyOnTarget,
|
|
208
|
+
targetColumns,
|
|
209
|
+
[collection.slug]
|
|
210
|
+
);
|
|
211
|
+
defects.push(stale
|
|
212
|
+
? { ...at, ...staleCodegenDefect(targetTableName, stale) }
|
|
213
|
+
: {
|
|
214
|
+
...at,
|
|
215
|
+
problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
|
|
216
|
+
fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
|
|
217
|
+
});
|
|
137
218
|
}
|
|
138
219
|
// `sourceKey` is the easiest of the two to put on the wrong
|
|
139
220
|
// side — it is the only column in a `hasMany` that lives
|
|
@@ -167,14 +248,24 @@ kind: relation.kind };
|
|
|
167
248
|
break;
|
|
168
249
|
}
|
|
169
250
|
const junctionColumns = columnNames(junction);
|
|
251
|
+
// Junction columns are the ones that actually moved in 0.13:
|
|
252
|
+
// each defaults to its endpoint collection's *slug* run
|
|
253
|
+
// through the foreign-key rule, and slugs are plural.
|
|
254
|
+
const derivedFrom = {
|
|
255
|
+
sourceColumn: [collection.slug],
|
|
256
|
+
targetColumn: [targetCollection.slug]
|
|
257
|
+
} as const;
|
|
170
258
|
for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]] as const) {
|
|
171
259
|
if (!junctionColumns.has(column)) {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
260
|
+
const stale = staleCodegenRename(column, junctionColumns, [...derivedFrom[label]]);
|
|
261
|
+
defects.push(stale
|
|
262
|
+
? { ...at, ...staleCodegenDefect(table, stale) }
|
|
263
|
+
: {
|
|
264
|
+
...at,
|
|
265
|
+
problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
|
|
266
|
+
fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` +
|
|
267
|
+
(label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
|
|
268
|
+
});
|
|
178
269
|
}
|
|
179
270
|
}
|
|
180
271
|
break;
|
|
@@ -287,9 +378,25 @@ export function assertRelationsResolve(
|
|
|
287
378
|
);
|
|
288
379
|
|
|
289
380
|
throw new Error(
|
|
290
|
-
`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against
|
|
381
|
+
`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against ` +
|
|
382
|
+
"`backend/src/schema.generated.ts`.\n\n" +
|
|
291
383
|
"Each of these would return no rows at query time rather than reporting an error, " +
|
|
292
384
|
"so they are fatal at boot instead.\n\n" +
|
|
385
|
+
// This reads the *generated file*, not the database, and the difference
|
|
386
|
+
// is the whole diagnosis after an upgrade. Boot-ensure renames columns
|
|
387
|
+
// in the database — a 0.12 → 0.13 upgrade singularises a junction key,
|
|
388
|
+
// `categorie_id` → `category_id` — and the checked-in file still
|
|
389
|
+
// declares the old name. The config is then correct and the file is
|
|
390
|
+
// stale, so the per-defect advice below, which lists the columns this
|
|
391
|
+
// file has, names a column that no longer exists in the database.
|
|
392
|
+
// Following it turns a recoverable state into a broken config.
|
|
393
|
+
//
|
|
394
|
+
// Hence the ordering: regenerate first, and only then consider that the
|
|
395
|
+
// collection might be the thing that is wrong.
|
|
396
|
+
"If the database was migrated recently — an upgrade, a `db push`, a restore — this file is\n" +
|
|
397
|
+
"probably older than the schema it describes. Regenerate it before changing anything else:\n\n" +
|
|
398
|
+
" rebase schema generate\n\n" +
|
|
399
|
+
"If it is already current, then the collection is what disagrees with it:\n\n" +
|
|
293
400
|
lines.join("\n\n") + "\n"
|
|
294
401
|
);
|
|
295
402
|
}
|
package/src/data-transformer.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { AnyPgColumn } from "drizzle-orm/pg-core";
|
|
|
3
3
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
4
|
import { CollectionConfig, Properties, Property, ResolvedRelation, RelationProperty, Vector, BinaryProperty, hasForeignKeyOnTarget, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, type ResolvedVia } from "@rebasepro/types";
|
|
5
5
|
import { getTableName, resolveCollectionRelations, findRelation, createRelationRef, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "@rebasepro/common";
|
|
6
|
+
import { isPrototypePollutingKey } from "@rebasepro/utils";
|
|
6
7
|
import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
|
|
7
8
|
import { DrizzleConditionBuilder } from "./utils/drizzle-conditions";
|
|
8
9
|
import { getPrimaryKeys, buildCompositeId } from "./services/collection-helpers";
|
|
@@ -68,9 +69,15 @@ export function sanitizeAndConvertDates(obj: unknown): unknown {
|
|
|
68
69
|
if (typeof obj === "object") {
|
|
69
70
|
const newObj: Record<string, unknown> = {};
|
|
70
71
|
for (const key in obj) {
|
|
71
|
-
if (Object.prototype.hasOwnProperty.call(obj, key))
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
if (!Object.prototype.hasOwnProperty.call(obj, key)) continue;
|
|
73
|
+
// `JSON.parse` creates `__proto__` as an *own* property, so it gets
|
|
74
|
+
// past the check above — and `newObj[key] = …` then invokes the
|
|
75
|
+
// prototype setter instead of creating a property. The row's
|
|
76
|
+
// prototype becomes whatever the request body supplied, so
|
|
77
|
+
// `row.isAdmin` answers `true` while `Object.keys(row)` shows
|
|
78
|
+
// nothing of the sort. No column can be reached by these names.
|
|
79
|
+
if (isPrototypePollutingKey(key)) continue;
|
|
80
|
+
newObj[key] = sanitizeAndConvertDates((obj as Record<string, unknown>)[key]);
|
|
74
81
|
}
|
|
75
82
|
return newObj;
|
|
76
83
|
}
|
|
@@ -126,6 +133,9 @@ joinPathRelationUpdates: [] };
|
|
|
126
133
|
});
|
|
127
134
|
|
|
128
135
|
for (const [key, value] of Object.entries(row)) {
|
|
136
|
+
// Same reasoning as `sanitizeAndConvertDates`: these keys come from the
|
|
137
|
+
// request body, and no column answers to them.
|
|
138
|
+
if (isPrototypePollutingKey(key)) continue;
|
|
129
139
|
const property = properties[key as keyof M] as Property;
|
|
130
140
|
|
|
131
141
|
// Coerce empty strings to null for any field that acts as a foreign key
|
package/src/schema/doctor.ts
CHANGED
|
@@ -15,7 +15,7 @@ import chalk from "chalk";
|
|
|
15
15
|
import { CollectionConfig, isPostgresCollectionConfig, Property, NumberProperty, StringProperty, DateProperty, ArrayProperty, MapProperty, RelationProperty, type ResolvedManyToMany, type ResolvedBelongsTo, isManyToMany } from "@rebasepro/types";
|
|
16
16
|
import { generateSchema } from "./generate-drizzle-schema-logic";
|
|
17
17
|
import { generateTypedefs } from "@rebasepro/codegen";
|
|
18
|
-
import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
18
|
+
import { getTableName, resolveCollectionRelations, findRelation, relationalCollections } from "@rebasepro/common";
|
|
19
19
|
import { toSnakeCase } from "@rebasepro/utils";
|
|
20
20
|
import { logger, loadCollectionsFromDirectory } from "@rebasepro/server";
|
|
21
21
|
|
|
@@ -164,8 +164,10 @@ export async function checkCollectionsVsSchema(
|
|
|
164
164
|
issues };
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
// Re-generate schema in-memory and compare with file on disk
|
|
168
|
-
|
|
167
|
+
// Re-generate schema in-memory and compare with file on disk. Only the
|
|
168
|
+
// collections the generator itself will emit — a Firestore collection has
|
|
169
|
+
// no table in the generated file and must not be reported as missing one.
|
|
170
|
+
const postgresCollections = relationalCollections(collections);
|
|
169
171
|
if (postgresCollections.length === 0) {
|
|
170
172
|
return { passed: true,
|
|
171
173
|
issues };
|
|
@@ -292,7 +294,7 @@ export async function checkCollectionsVsDatabase(
|
|
|
292
294
|
const schemas = Array.from(new Set([
|
|
293
295
|
"public",
|
|
294
296
|
"rebase",
|
|
295
|
-
...collections
|
|
297
|
+
...relationalCollections(collections)
|
|
296
298
|
.filter(isPostgresCollectionConfig)
|
|
297
299
|
.map(c => c.schema)
|
|
298
300
|
.filter((s): s is string => !!s)
|
|
@@ -383,7 +385,7 @@ export async function checkCollectionsVsDatabase(
|
|
|
383
385
|
|
|
384
386
|
// ── Compare each collection against the database ─────────────────
|
|
385
387
|
|
|
386
|
-
const postgresCollections = collections
|
|
388
|
+
const postgresCollections = relationalCollections(collections);
|
|
387
389
|
|
|
388
390
|
for (const collection of postgresCollections) {
|
|
389
391
|
const tableName = getTableName(collection);
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* no-op.
|
|
27
27
|
*/
|
|
28
28
|
import { type CollectionConfig, type Property, isPostgresCollectionConfig } from "@rebasepro/types";
|
|
29
|
-
import { getTableName } from "@rebasepro/common";
|
|
29
|
+
import { getTableName, relationalCollections } from "@rebasepro/common";
|
|
30
30
|
import { logger } from "@rebasepro/server";
|
|
31
31
|
import {
|
|
32
32
|
getSqlColumnType,
|
|
@@ -167,9 +167,15 @@ function requiredEnums(collection: CollectionConfig): { name: string; values: st
|
|
|
167
167
|
* table may be the target of a relation), and nothing is emitted twice.
|
|
168
168
|
*/
|
|
169
169
|
export function planCollectionSchemaEnsure(
|
|
170
|
-
|
|
170
|
+
allCollections: CollectionConfig[],
|
|
171
171
|
existing: ExistingSchema
|
|
172
172
|
): EnsurePlan {
|
|
173
|
+
// Boot receives every collection the bundle declares, including the ones
|
|
174
|
+
// served by another engine entirely. Creating a Postgres table for a
|
|
175
|
+
// Firestore collection is not a harmless extra: the app keeps reading
|
|
176
|
+
// documents from Firestore while an empty table with the same name accretes
|
|
177
|
+
// policies and shows up in every drift report.
|
|
178
|
+
const collections = relationalCollections(allCollections);
|
|
173
179
|
const actions: EnsureAction[] = [];
|
|
174
180
|
const plannedEnums = new Set<string>();
|
|
175
181
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, hasForeignKeyOnTarget } from "@rebasepro/types";
|
|
2
2
|
import { getPrimaryKeys } from "../services/collection-helpers";
|
|
3
|
-
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
|
|
3
|
+
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength, relationalCollections } from "@rebasepro/common";
|
|
4
4
|
import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
|
|
5
5
|
import { logger } from "@rebasepro/server";
|
|
6
6
|
// --- Helper Functions ---
|
|
@@ -451,7 +451,13 @@ const computeSharedRelationName = (
|
|
|
451
451
|
};
|
|
452
452
|
|
|
453
453
|
// --- Main Schema Generation Logic ---
|
|
454
|
-
export const generateSchema = async (
|
|
454
|
+
export const generateSchema = async (allCollections: CollectionConfig[], stripPolicies = false): Promise<string> => {
|
|
455
|
+
// A Firestore or MongoDB collection has no table to generate, and generating
|
|
456
|
+
// one for it is not merely wasted output: `db push` would create it, and
|
|
457
|
+
// `rebase doctor` would then report the store the collection actually reads
|
|
458
|
+
// from as drift. Non-SQL collections leave the toolchain here, once, rather
|
|
459
|
+
// than being filtered again in each stage below.
|
|
460
|
+
const collections = relationalCollections(allCollections);
|
|
455
461
|
let schemaContent = "// This file is auto-generated by the Rebase Drizzle generator. Do not edit manually.\n\n";
|
|
456
462
|
|
|
457
463
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo } from "@rebasepro/types";
|
|
2
|
-
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
|
|
2
|
+
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength, relationalCollections } from "@rebasepro/common";
|
|
3
3
|
import { toSnakeCase, getPolicyNamesForRule, generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
|
|
4
4
|
|
|
5
5
|
// --- Helper Functions ---
|
|
@@ -236,9 +236,11 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
|
|
|
236
236
|
};
|
|
237
237
|
|
|
238
238
|
export const generatePostgresDdl = async (
|
|
239
|
-
|
|
239
|
+
allCollections: CollectionConfig[],
|
|
240
240
|
options: { includePolicies?: boolean } = { includePolicies: true }
|
|
241
241
|
): Promise<string> => {
|
|
242
|
+
// Only the collections this engine stores. See `relationalCollections`.
|
|
243
|
+
const collections = relationalCollections(allCollections);
|
|
242
244
|
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n\n";
|
|
243
245
|
|
|
244
246
|
// 1. Create custom schemas
|
|
@@ -355,8 +357,8 @@ export const generatePostgresDdl = async (
|
|
|
355
357
|
ddl += ` PRIMARY KEY ("${sourceColumn}", "${targetColumn}")\n`;
|
|
356
358
|
ddl += `);\n\n`;
|
|
357
359
|
|
|
358
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${sourceColumn}_fkey" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
359
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${targetColumn}_fkey" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
360
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${sourceColumn}_fkey`)}" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
361
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${targetColumn}_fkey`)}" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
360
362
|
|
|
361
363
|
if (options.includePolicies) {
|
|
362
364
|
// Junction tables are generated tables like any other: locked by
|
|
@@ -414,7 +416,7 @@ export const generatePostgresDdl = async (
|
|
|
414
416
|
if (required) colDef += " NOT NULL";
|
|
415
417
|
columns.push(colDef);
|
|
416
418
|
|
|
417
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${relInfo.localKey}_fkey" FOREIGN KEY ("${relInfo.localKey}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDeleteVal.toUpperCase()}${onUpdate};`);
|
|
419
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${relInfo.localKey}_fkey`)}" FOREIGN KEY ("${relInfo.localKey}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDeleteVal.toUpperCase()}${onUpdate};`);
|
|
418
420
|
} else if (prop.type === "reference") {
|
|
419
421
|
const refProp = prop as ReferenceProperty;
|
|
420
422
|
const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
@@ -436,7 +438,7 @@ export const generatePostgresDdl = async (
|
|
|
436
438
|
if (required) colDef += " NOT NULL";
|
|
437
439
|
columns.push(colDef);
|
|
438
440
|
|
|
439
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${colName}_fkey" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
441
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${colName}_fkey`)}" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
440
442
|
}
|
|
441
443
|
} else {
|
|
442
444
|
const colName = resolveColumnName(propName, prop);
|
|
@@ -575,10 +577,35 @@ const schemaOfCollection = (collection: CollectionConfig): string =>
|
|
|
575
577
|
|
|
576
578
|
const bareTableName = (name: string): string => (name.includes(".") ? name.split(".").pop()! : name);
|
|
577
579
|
|
|
580
|
+
/**
|
|
581
|
+
* Truncate a derived identifier the way Postgres does: to 63 bytes, silently.
|
|
582
|
+
*
|
|
583
|
+
* This is not cosmetic, and not really a naming choice at all — it is agreeing
|
|
584
|
+
* with the name the database ALREADY stored. `ADD CONSTRAINT` on a longer name
|
|
585
|
+
* succeeds and records the truncated form, so the untruncated name this used to
|
|
586
|
+
* derive matched nothing in the catalogue. Boot-ensure compares its planned
|
|
587
|
+
* constraints against `readExistingSchema`, which reads catalogue names, so the
|
|
588
|
+
* comparison could never hit: every boot re-issued `ADD CONSTRAINT` for the same
|
|
589
|
+
* constraint, forever, and got "already exists" every time. Non-fatal (foreign
|
|
590
|
+
* keys are the one action allowed to fail) and therefore permanent — an error in
|
|
591
|
+
* the log on every restart of a project whose table and column names happened to
|
|
592
|
+
* be long.
|
|
593
|
+
*
|
|
594
|
+
* Byte length, not string length: NAMEDATALEN is 64 bytes, and a multi-byte
|
|
595
|
+
* character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.
|
|
596
|
+
*/
|
|
597
|
+
const toPostgresIdentifier = (name: string): string => {
|
|
598
|
+
const bytes = Buffer.from(name, "utf8");
|
|
599
|
+
if (bytes.byteLength <= 63) return name;
|
|
600
|
+
// `toString` on a slice that ends mid-character yields U+FFFD; dropping it
|
|
601
|
+
// lands on the last whole character that fits, which is what Postgres does.
|
|
602
|
+
return bytes.subarray(0, 63).toString("utf8").replace(/�+$/, "");
|
|
603
|
+
};
|
|
604
|
+
|
|
578
605
|
const foreignKeyPlan = (
|
|
579
606
|
args: Omit<ForeignKeyPlan, "constraintName" | "sql"> & { onDelete: string; onUpdate?: string }
|
|
580
607
|
): ForeignKeyPlan => {
|
|
581
|
-
const constraintName = `${args.table}_${args.column}_fkey
|
|
608
|
+
const constraintName = toPostgresIdentifier(`${args.table}_${args.column}_fkey`);
|
|
582
609
|
const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
|
|
583
610
|
return {
|
|
584
611
|
constraintName,
|
|
@@ -610,7 +637,8 @@ const foreignKeyPlan = (
|
|
|
610
637
|
* is unknown yields the column without a constraint. Both mirror the generator
|
|
611
638
|
* exactly — a divergence here is a schema fork between boot and `db push`.
|
|
612
639
|
*/
|
|
613
|
-
export const planRelationalColumns = (
|
|
640
|
+
export const planRelationalColumns = (allCollections: CollectionConfig[]): RelationalColumnPlan[] => {
|
|
641
|
+
const collections = relationalCollections(allCollections);
|
|
614
642
|
const plans: RelationalColumnPlan[] = [];
|
|
615
643
|
|
|
616
644
|
for (const collection of collections) {
|
|
@@ -703,7 +731,8 @@ export const planRelationalColumns = (collections: CollectionConfig[]): Relation
|
|
|
703
731
|
* junction with row-level security left off is readable and writable by every
|
|
704
732
|
* signed-in user, which is why the two must ship together.
|
|
705
733
|
*/
|
|
706
|
-
export const planJunctionTables = (
|
|
734
|
+
export const planJunctionTables = (allCollections: CollectionConfig[]): JunctionTablePlan[] => {
|
|
735
|
+
const collections = relationalCollections(allCollections);
|
|
707
736
|
const plans: JunctionTablePlan[] = [];
|
|
708
737
|
|
|
709
738
|
for (const spec of resolveJunctionSpecs(collections).values()) {
|
|
@@ -780,7 +809,10 @@ export interface CollectionPolicyPlan {
|
|
|
780
809
|
* writable by every signed-in user. A junction whose table is still absent is
|
|
781
810
|
* skipped by the applier, not planned away here.
|
|
782
811
|
*/
|
|
783
|
-
export const planCollectionPolicies = (
|
|
812
|
+
export const planCollectionPolicies = (allCollections: CollectionConfig[]): CollectionPolicyPlan[] => {
|
|
813
|
+
// A store with no RLS gets no policies planned for it — `supportsRLS` is
|
|
814
|
+
// false for exactly the engines `relationalCollections` filters out.
|
|
815
|
+
const collections = relationalCollections(allCollections);
|
|
784
816
|
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
785
817
|
const plans: CollectionPolicyPlan[] = [];
|
|
786
818
|
const seen = new Set<string>();
|
|
@@ -833,7 +865,11 @@ export const planCollectionPolicies = (collections: CollectionConfig[]): Collect
|
|
|
833
865
|
return plans;
|
|
834
866
|
};
|
|
835
867
|
|
|
836
|
-
export const generatePostgresPoliciesDdl = (
|
|
868
|
+
export const generatePostgresPoliciesDdl = (allCollections: CollectionConfig[]): string => {
|
|
869
|
+
// Also the expectation `checkPolicyDrift` reconciles the database against,
|
|
870
|
+
// so a non-SQL collection filtered out here is one the drift report cannot
|
|
871
|
+
// invent a missing policy for.
|
|
872
|
+
const collections = relationalCollections(allCollections);
|
|
837
873
|
let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
|
|
838
874
|
|
|
839
875
|
const allTablesToGenerate = new Map<string, {
|
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
const start = source.indexOf(`pgTable("${table}"`);
|
|
48
|
+
if (start === -1) return "";
|
|
49
|
+
// Generated files put every table in its own `export const`, so the next one
|
|
50
|
+
// is the end of this block. No brace counting, nothing to get wrong.
|
|
51
|
+
const next = source.indexOf("\nexport ", start);
|
|
52
|
+
return next === -1 ? source.slice(start) : source.slice(start, next);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether a block *declares* the column, rather than merely mentioning it.
|
|
57
|
+
*
|
|
58
|
+
* A comment explaining the rename, or a policy expression naming the old column,
|
|
59
|
+
* must not read as a declaration — otherwise regenerating the file would not
|
|
60
|
+
* clear the finding and the check would be permanently red.
|
|
61
|
+
*/
|
|
62
|
+
function declaresColumn(block: string, column: string): boolean {
|
|
63
|
+
// `categorie_id: integer("categorie_id")` — the Drizzle column shape. The key
|
|
64
|
+
// and the string argument agree in generated output, so requiring both is
|
|
65
|
+
// both precise and cheap.
|
|
66
|
+
const key = column.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
67
|
+
return new RegExp(`(^|[\\s,{])${key}\\s*:\\s*\\w+\\(\\s*["']${key}["']`, "m").test(block);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param generatedSource contents of `backend/src/schema.generated.ts`
|
|
72
|
+
* @param collections the project's collections, as this release reads them
|
|
73
|
+
*/
|
|
74
|
+
export function findLegacyForeignKeyNames(
|
|
75
|
+
generatedSource: string,
|
|
76
|
+
collections: CollectionConfig[]
|
|
77
|
+
): LegacyForeignKeyName[] {
|
|
78
|
+
const found: LegacyForeignKeyName[] = [];
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Report `wanted` as stale when the generated schema declares what the
|
|
83
|
+
* previous rule would have derived from `source` instead.
|
|
84
|
+
*
|
|
85
|
+
* The `wanted !== current` guard is what honours an explicitly named column.
|
|
86
|
+
* An author who pinned `categorie_id` has said so on the relation, so the
|
|
87
|
+
* generated file agreeing with them is correct, not stale — and the rename
|
|
88
|
+
* note documents exactly that opt-out.
|
|
89
|
+
*/
|
|
90
|
+
const consider = (
|
|
91
|
+
table: string,
|
|
92
|
+
wanted: string,
|
|
93
|
+
sourceName: string | undefined,
|
|
94
|
+
relation: string
|
|
95
|
+
): void => {
|
|
96
|
+
if (!table || !wanted || !sourceName) return;
|
|
97
|
+
|
|
98
|
+
const current = generateForeignKeyName(sourceName);
|
|
99
|
+
const legacy = legacyForeignKeyName(sourceName);
|
|
100
|
+
if (legacy === current) return; // this name never moved
|
|
101
|
+
if (wanted !== current) return; // pinned by the author, or unrelated
|
|
102
|
+
|
|
103
|
+
const block = tableBlock(generatedSource, table);
|
|
104
|
+
if (!block) return;
|
|
105
|
+
if (!declaresColumn(block, legacy)) return;
|
|
106
|
+
if (declaresColumn(block, current)) return; // already regenerated
|
|
107
|
+
|
|
108
|
+
const key = `${table}.${legacy}`;
|
|
109
|
+
if (seen.has(key)) return;
|
|
110
|
+
seen.add(key);
|
|
111
|
+
found.push({ table, legacy, current, relation });
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Same rule as the generator whose output this reads: a collection that
|
|
115
|
+
// gets no table cannot have named a column in it. Inert in practice today —
|
|
116
|
+
// the finding also requires the generated file to declare the table — but
|
|
117
|
+
// the guard is what keeps that true when a non-SQL collection's slug happens
|
|
118
|
+
// to match a SQL one's table.
|
|
119
|
+
for (const collection of relationalCollections(collections)) {
|
|
120
|
+
const sourceTable = getTableName(collection);
|
|
121
|
+
|
|
122
|
+
for (const [name, relation] of Object.entries(resolveCollectionRelations(collection))) {
|
|
123
|
+
const at = `${collection.slug}.${name}`;
|
|
124
|
+
|
|
125
|
+
let target: CollectionConfig | undefined;
|
|
126
|
+
try {
|
|
127
|
+
target = relation.target?.();
|
|
128
|
+
} catch {
|
|
129
|
+
// A throwing target thunk is its own defect, reported at boot by
|
|
130
|
+
// `validate-relations`. Nothing to say about its column names.
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
switch (relation.kind) {
|
|
135
|
+
case "belongsTo":
|
|
136
|
+
consider(sourceTable, relation.localKey, relation.relationName, at);
|
|
137
|
+
break;
|
|
138
|
+
|
|
139
|
+
case "hasOne":
|
|
140
|
+
case "hasMany":
|
|
141
|
+
if (target) {
|
|
142
|
+
consider(getTableName(target), relation.foreignKeyOnTarget, collection.slug, at);
|
|
143
|
+
}
|
|
144
|
+
break;
|
|
145
|
+
|
|
146
|
+
case "manyToMany":
|
|
147
|
+
consider(relation.through.table, relation.through.sourceColumn, collection.slug, at);
|
|
148
|
+
if (target) {
|
|
149
|
+
consider(relation.through.table, relation.through.targetColumn, target.slug, at);
|
|
150
|
+
}
|
|
151
|
+
break;
|
|
152
|
+
|
|
153
|
+
default:
|
|
154
|
+
// `via` joins are written by hand — there is no derived name
|
|
155
|
+
// for the rule change to have moved.
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return found;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** One-line summary for a log or a CLI notice. */
|
|
165
|
+
export function describeLegacyForeignKeyNames(found: LegacyForeignKeyName[]): string {
|
|
166
|
+
return found
|
|
167
|
+
.map(f => ` • ${f.table}.${f.legacy} → ${f.current} (${f.relation})`)
|
|
168
|
+
.join("\n");
|
|
169
|
+
}
|
|
@@ -1089,7 +1089,7 @@ export function generateCollectionFile(
|
|
|
1089
1089
|
|
|
1090
1090
|
if (derivedFacts) {
|
|
1091
1091
|
const titleProperty = deriveTitleProperty(derivedFacts);
|
|
1092
|
-
if (titleProperty) adminEntries.push(`
|
|
1092
|
+
if (titleProperty) adminEntries.push(`display: { title: ${quote(titleProperty)} }`);
|
|
1093
1093
|
|
|
1094
1094
|
const kanbanProperty = deriveKanbanProperty(derivedFacts);
|
|
1095
1095
|
if (kanbanProperty) adminEntries.push(`kanban: {\n columnProperty: ${quote(kanbanProperty)}\n }`);
|