@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g18cfeb7
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/{src-DlPBctw_.js → auth-users-columns-Do9mw5Y5.js} +318 -42
- package/dist/auth-users-columns-Do9mw5Y5.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
- package/dist/cli-helpers.d.ts +1 -1
- package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-DMUdRQdM.js} +2 -2
- package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-DMUdRQdM.js.map} +1 -1
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DSIxvLLD.js} +59 -15
- package/dist/ensure-collection-tables-DSIxvLLD.js.map +1 -0
- package/dist/index.es.js +534 -200
- package/dist/index.es.js.map +1 -1
- package/dist/{policy-CeA1JcxP.js → policy-CPkCqVTz.js} +4 -4
- package/dist/policy-CPkCqVTz.js.map +1 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- 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/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/security/rls-enforcement.d.ts +53 -2
- 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-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/{websocket-B2LsrINK.js → websocket-D0TBU3ia.js} +3 -3
- package/dist/{websocket-B2LsrINK.js.map → websocket-D0TBU3ia.js.map} +1 -1
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +165 -3
- package/src/PostgresBootstrapper.ts +41 -2
- package/src/auth/ensure-tables.ts +185 -86
- package/src/cli-helpers.ts +22 -9
- package/src/cli.ts +175 -30
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +13 -3
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor.ts +7 -5
- package/src/schema/ensure-collection-tables.ts +88 -19
- package/src/schema/generate-drizzle-schema-logic.ts +8 -2
- package/src/schema/generate-postgres-ddl-logic.ts +97 -13
- 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/schema/rls-bootstrap-sql.ts +288 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/rls-enforcement.ts +141 -3
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +10 -93
- package/src/services/PersistService.ts +14 -1
- package/src/services/channel-history.ts +8 -0
- package/src/services/channel-presence.ts +6 -0
- 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/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-DlPBctw_.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
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,
|
|
@@ -36,6 +36,12 @@ import {
|
|
|
36
36
|
planJunctionTables,
|
|
37
37
|
quoteSqlLiteral
|
|
38
38
|
} from "./generate-postgres-ddl-logic";
|
|
39
|
+
import {
|
|
40
|
+
AUTH_USERS_COLUMNS,
|
|
41
|
+
authUsersColumnDefinition,
|
|
42
|
+
authUsersColumnSql,
|
|
43
|
+
isAuthCollection
|
|
44
|
+
} from "./auth-users-columns";
|
|
39
45
|
|
|
40
46
|
/**
|
|
41
47
|
* The subset of a database handle this needs: run a statement, get rows back.
|
|
@@ -167,9 +173,15 @@ function requiredEnums(collection: CollectionConfig): { name: string; values: st
|
|
|
167
173
|
* table may be the target of a relation), and nothing is emitted twice.
|
|
168
174
|
*/
|
|
169
175
|
export function planCollectionSchemaEnsure(
|
|
170
|
-
|
|
176
|
+
allCollections: CollectionConfig[],
|
|
171
177
|
existing: ExistingSchema
|
|
172
178
|
): EnsurePlan {
|
|
179
|
+
// Boot receives every collection the bundle declares, including the ones
|
|
180
|
+
// served by another engine entirely. Creating a Postgres table for a
|
|
181
|
+
// Firestore collection is not a harmless extra: the app keeps reading
|
|
182
|
+
// documents from Firestore while an empty table with the same name accretes
|
|
183
|
+
// policies and shows up in every drift report.
|
|
184
|
+
const collections = relationalCollections(allCollections);
|
|
173
185
|
const actions: EnsureAction[] = [];
|
|
174
186
|
const plannedEnums = new Set<string>();
|
|
175
187
|
|
|
@@ -204,17 +216,22 @@ export function planCollectionSchemaEnsure(
|
|
|
204
216
|
);
|
|
205
217
|
const idName = idEntry ? resolveColumnName(idEntry[0], idEntry[1] as Property) : "id";
|
|
206
218
|
const idProp = idEntry?.[1] as Property | undefined;
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
219
|
+
// Derived through the generator's own type mapping, not re-decided here.
|
|
220
|
+
// This branch used to emit BIGSERIAL for a numeric id while `db push`
|
|
221
|
+
// emitted INTEGER GENERATED BY DEFAULT AS IDENTITY, so the same project
|
|
222
|
+
// got an int8 key when the runtime brought the schema up and an int4 key
|
|
223
|
+
// when a human pushed it. Two consequences, both real: node-postgres
|
|
224
|
+
// hands back int8 as a *string*, so a collection declaring
|
|
225
|
+
// `type: "number"` served `"1"` instead of `1` on the managed path only;
|
|
226
|
+
// and every foreign key and junction column pointing at it stayed
|
|
227
|
+
// INTEGER on both paths, which is a truncation waiting for the sequence
|
|
228
|
+
// to pass 2^31. The agreement test now pins this.
|
|
229
|
+
const idType = idProp
|
|
230
|
+
? getSqlColumnType(idEntry![0], idProp, collection, collections)
|
|
231
|
+
: "TEXT";
|
|
232
|
+
let idDef = `"${idName}" ${idType} PRIMARY KEY`;
|
|
233
|
+
if (idProp?.type === "string" && (idProp as { isId?: unknown }).isId === "uuid") {
|
|
234
|
+
idDef += " DEFAULT gen_random_uuid()";
|
|
218
235
|
}
|
|
219
236
|
actions.push({
|
|
220
237
|
kind: "create-table",
|
|
@@ -276,17 +293,14 @@ export function planCollectionSchemaEnsure(
|
|
|
276
293
|
schema: string,
|
|
277
294
|
table: string,
|
|
278
295
|
column: string,
|
|
279
|
-
|
|
296
|
+
definition: string
|
|
280
297
|
): void => {
|
|
281
298
|
const present = existing.tables.get(key);
|
|
282
299
|
if (present?.has(column)) return;
|
|
283
300
|
actions.push({
|
|
284
301
|
kind: "add-column",
|
|
285
302
|
target: `${key}.${column}`,
|
|
286
|
-
|
|
287
|
-
// non-null column without a default, and inventing one would be
|
|
288
|
-
// guessing at the customer's data.
|
|
289
|
-
sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${type};`
|
|
303
|
+
sql: `ALTER TABLE "${schema}"."${table}" ADD COLUMN IF NOT EXISTS "${column}" ${definition};`
|
|
290
304
|
});
|
|
291
305
|
};
|
|
292
306
|
|
|
@@ -294,6 +308,16 @@ export function planCollectionSchemaEnsure(
|
|
|
294
308
|
const key = qualified(collection);
|
|
295
309
|
const schema = schemaOf(collection);
|
|
296
310
|
const table = getTableName(collection);
|
|
311
|
+
// A table this run is creating has no rows yet, so the constraints
|
|
312
|
+
// `db push` writes are free to apply. On a table that already exists
|
|
313
|
+
// they are not: `SET NOT NULL` is checked against live rows and a UNIQUE
|
|
314
|
+
// would fail on existing duplicates, and this module runs unattended
|
|
315
|
+
// against customer data with nobody reading a diff. So the constraints
|
|
316
|
+
// are emitted for the fresh case — which is the whole managed-runtime
|
|
317
|
+
// path, and the one that diverged from `db push` — and withheld for the
|
|
318
|
+
// adopted one. `rebase db push` remains how an existing table gets them.
|
|
319
|
+
const fresh = created.has(key);
|
|
320
|
+
const auth = isAuthCollection(collection);
|
|
297
321
|
for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
|
|
298
322
|
const p = prop as Property;
|
|
299
323
|
if (isIdProperty(propName, p, collection)) continue;
|
|
@@ -302,7 +326,52 @@ export function planCollectionSchemaEnsure(
|
|
|
302
326
|
// foreign key the same way `db push` does. Deriving them here as
|
|
303
327
|
// plain columns is what once produced a column with no constraint.
|
|
304
328
|
if (p.type === "reference" || p.type === "relation") continue;
|
|
305
|
-
|
|
329
|
+
|
|
330
|
+
const column = resolveColumnName(propName, p);
|
|
331
|
+
// On an auth collection, the columns auth itself reads and writes
|
|
332
|
+
// have exactly one definition, wherever the table is created from —
|
|
333
|
+
// see `auth-users-columns`. Anything else on that collection is an
|
|
334
|
+
// ordinary user-declared field and is generated like any other.
|
|
335
|
+
const authDefinition = auth ? authUsersColumnDefinition(column) : undefined;
|
|
336
|
+
if (authDefinition) {
|
|
337
|
+
addColumn(key, schema, table, column, authDefinition);
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// Assembled in the generator's order — type, UNIQUE, DEFAULT,
|
|
342
|
+
// NOT NULL — so the two produce byte-identical column definitions
|
|
343
|
+
// and the agreement test can compare them directly instead of
|
|
344
|
+
// checking that a column merely exists, which is how BIGSERIAL-vs-
|
|
345
|
+
// INTEGER and every missing constraint went unnoticed.
|
|
346
|
+
let definition = getSqlColumnType(propName, p, collection, collections);
|
|
347
|
+
if (fresh && p.validation?.unique) definition += " UNIQUE";
|
|
348
|
+
// Not gated on `fresh`: a default binds future writes only, so it is
|
|
349
|
+
// safe on a live table, and a column added without it would take the
|
|
350
|
+
// value the application forgot to send rather than `now()`.
|
|
351
|
+
const autoValue = (p as { autoValue?: string }).autoValue;
|
|
352
|
+
if (p.type === "date" && (autoValue === "on_create" || autoValue === "on_update")) {
|
|
353
|
+
definition += " DEFAULT now()";
|
|
354
|
+
}
|
|
355
|
+
if (fresh && p.validation?.required) definition += " NOT NULL";
|
|
356
|
+
addColumn(key, schema, table, column, definition);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The auth columns the collection never mentions. The scaffold's users
|
|
360
|
+
// collection describes 12 of the 14 auth reads and writes, so planning
|
|
361
|
+
// only from properties left `is_anonymous` and `tokens_valid_after` to
|
|
362
|
+
// `ensureAuthTablesExist` — which does create them, but only because
|
|
363
|
+
// that function happens to run later in the same boot. Planning them
|
|
364
|
+
// here makes this path self-contained and identical to `db push`, so
|
|
365
|
+
// neither depends on the other having run.
|
|
366
|
+
if (auth) {
|
|
367
|
+
const declared = new Set(
|
|
368
|
+
Object.entries(collection.properties ?? {})
|
|
369
|
+
.map(([name, prop]) => resolveColumnName(name, prop as Property))
|
|
370
|
+
);
|
|
371
|
+
for (const spec of AUTH_USERS_COLUMNS) {
|
|
372
|
+
if (declared.has(spec.column)) continue;
|
|
373
|
+
addColumn(key, schema, table, spec.column, authUsersColumnSql(spec));
|
|
374
|
+
}
|
|
306
375
|
}
|
|
307
376
|
}
|
|
308
377
|
|
|
@@ -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,6 +1,8 @@
|
|
|
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
|
+
import { AUTH_USERS_COLUMNS, authUsersColumnDefinition, authUsersColumnSql, isAuthCollection } from "./auth-users-columns";
|
|
5
|
+
import { REBASE_SCHEMA } from "@rebasepro/types";
|
|
4
6
|
|
|
5
7
|
// --- Helper Functions ---
|
|
6
8
|
|
|
@@ -236,14 +238,24 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
|
|
|
236
238
|
};
|
|
237
239
|
|
|
238
240
|
export const generatePostgresDdl = async (
|
|
239
|
-
|
|
241
|
+
allCollections: CollectionConfig[],
|
|
240
242
|
options: { includePolicies?: boolean } = { includePolicies: true }
|
|
241
243
|
): Promise<string> => {
|
|
244
|
+
// Only the collections this engine stores. See `relationalCollections`.
|
|
245
|
+
const collections = relationalCollections(allCollections);
|
|
242
246
|
let ddl = "-- This file is auto-generated by the Rebase DDL generator. Do not edit manually.\n\n";
|
|
243
247
|
|
|
244
|
-
// 1. Create custom schemas
|
|
248
|
+
// 1. Create custom schemas.
|
|
249
|
+
//
|
|
250
|
+
// `rebase` is unconditional and load-bearing. The RLS helper functions live
|
|
251
|
+
// in it, so the migration preamble creates it — which puts it in Atlas's
|
|
252
|
+
// replayed state, and anything in that state but absent from this desired
|
|
253
|
+
// schema gets a `DROP SCHEMA … CASCADE` planned against it. That would take
|
|
254
|
+
// the auth tables. It used to appear here only when some collection
|
|
255
|
+
// happened to declare `schema: "rebase"`; the scaffold's users collection
|
|
256
|
+
// does, which is why nobody hit it, but nothing guaranteed it.
|
|
245
257
|
const uniqueSchemas = Array.from(new Set([
|
|
246
|
-
|
|
258
|
+
REBASE_SCHEMA,
|
|
247
259
|
...collections.map(c => isPostgresCollectionConfig(c) ? c.schema : undefined).filter(Boolean)
|
|
248
260
|
]));
|
|
249
261
|
uniqueSchemas.forEach(schema => {
|
|
@@ -355,8 +367,8 @@ export const generatePostgresDdl = async (
|
|
|
355
367
|
ddl += ` PRIMARY KEY ("${sourceColumn}", "${targetColumn}")\n`;
|
|
356
368
|
ddl += `);\n\n`;
|
|
357
369
|
|
|
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()};`);
|
|
370
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${sourceColumn}_fkey`)}" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
371
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${targetColumn}_fkey`)}" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
360
372
|
|
|
361
373
|
if (options.includePolicies) {
|
|
362
374
|
// Junction tables are generated tables like any other: locked by
|
|
@@ -414,7 +426,7 @@ export const generatePostgresDdl = async (
|
|
|
414
426
|
if (required) colDef += " NOT NULL";
|
|
415
427
|
columns.push(colDef);
|
|
416
428
|
|
|
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};`);
|
|
429
|
+
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
430
|
} else if (prop.type === "reference") {
|
|
419
431
|
const refProp = prop as ReferenceProperty;
|
|
420
432
|
const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
|
|
@@ -436,10 +448,27 @@ export const generatePostgresDdl = async (
|
|
|
436
448
|
if (required) colDef += " NOT NULL";
|
|
437
449
|
columns.push(colDef);
|
|
438
450
|
|
|
439
|
-
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${colName}_fkey" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
451
|
+
fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${toPostgresIdentifier(`${baseTableName}_${colName}_fkey`)}" FOREIGN KEY ("${colName}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
|
|
440
452
|
}
|
|
441
453
|
} else {
|
|
442
454
|
const colName = resolveColumnName(propName, prop);
|
|
455
|
+
|
|
456
|
+
// On an auth collection, the columns auth reads and writes
|
|
457
|
+
// are defined once, in `auth-users-columns`, and every
|
|
458
|
+
// creator of this table emits that definition. Before this,
|
|
459
|
+
// the desired state here was built from the collection file
|
|
460
|
+
// alone — which knows nothing of `is_anonymous` or
|
|
461
|
+
// `tokens_valid_after` — so `db push` and the runtime
|
|
462
|
+
// disagreed about `email`'s nullability and about which
|
|
463
|
+
// columns exist at all.
|
|
464
|
+
const authDefinition = isAuthCollection(collection)
|
|
465
|
+
? authUsersColumnDefinition(colName)
|
|
466
|
+
: undefined;
|
|
467
|
+
if (authDefinition && !isIdProperty(propName, prop, collection)) {
|
|
468
|
+
columns.push(` "${colName}" ${authDefinition}`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
443
472
|
const colType = getSqlColumnType(propName, prop, collection, collections);
|
|
444
473
|
let colDef = ` "${colName}" ${colType}`;
|
|
445
474
|
|
|
@@ -476,6 +505,27 @@ export const generatePostgresDdl = async (
|
|
|
476
505
|
}
|
|
477
506
|
});
|
|
478
507
|
|
|
508
|
+
// ── Auth columns the collection file never mentions ──────────────
|
|
509
|
+
// `db push` is declarative: Atlas diffs the database against exactly
|
|
510
|
+
// what is emitted here and drops anything else. The scaffold's users
|
|
511
|
+
// collection describes 12 columns while auth needs 14, so
|
|
512
|
+
// `is_anonymous` and `tokens_valid_after` — created at boot by
|
|
513
|
+
// `ensureAuthTablesExist` — read as unmanaged drift, and a push run
|
|
514
|
+
// after the server had started once planned to DROP them. The
|
|
515
|
+
// destructive gate catches it, which makes it a data-loss prompt on
|
|
516
|
+
// the auth table rather than silent damage; either way the desired
|
|
517
|
+
// state was wrong. Emit the full contract so the two agree.
|
|
518
|
+
if (isAuthCollection(collection)) {
|
|
519
|
+
const declared = new Set(
|
|
520
|
+
Object.entries(collection.properties ?? {})
|
|
521
|
+
.map(([name, prop]) => resolveColumnName(name, prop))
|
|
522
|
+
);
|
|
523
|
+
for (const spec of AUTH_USERS_COLUMNS) {
|
|
524
|
+
if (declared.has(spec.column)) continue;
|
|
525
|
+
columns.push(` "${spec.column}" ${authUsersColumnSql(spec)}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
479
529
|
// Backwards compatibility: add default id primary key if missing
|
|
480
530
|
const hasPk = columns.some(c => c.includes("PRIMARY KEY"));
|
|
481
531
|
if (!hasPk) {
|
|
@@ -575,10 +625,35 @@ const schemaOfCollection = (collection: CollectionConfig): string =>
|
|
|
575
625
|
|
|
576
626
|
const bareTableName = (name: string): string => (name.includes(".") ? name.split(".").pop()! : name);
|
|
577
627
|
|
|
628
|
+
/**
|
|
629
|
+
* Truncate a derived identifier the way Postgres does: to 63 bytes, silently.
|
|
630
|
+
*
|
|
631
|
+
* This is not cosmetic, and not really a naming choice at all — it is agreeing
|
|
632
|
+
* with the name the database ALREADY stored. `ADD CONSTRAINT` on a longer name
|
|
633
|
+
* succeeds and records the truncated form, so the untruncated name this used to
|
|
634
|
+
* derive matched nothing in the catalogue. Boot-ensure compares its planned
|
|
635
|
+
* constraints against `readExistingSchema`, which reads catalogue names, so the
|
|
636
|
+
* comparison could never hit: every boot re-issued `ADD CONSTRAINT` for the same
|
|
637
|
+
* constraint, forever, and got "already exists" every time. Non-fatal (foreign
|
|
638
|
+
* keys are the one action allowed to fail) and therefore permanent — an error in
|
|
639
|
+
* the log on every restart of a project whose table and column names happened to
|
|
640
|
+
* be long.
|
|
641
|
+
*
|
|
642
|
+
* Byte length, not string length: NAMEDATALEN is 64 bytes, and a multi-byte
|
|
643
|
+
* character straddling the boundary would be cut mid-sequence by `slice(0, 63)`.
|
|
644
|
+
*/
|
|
645
|
+
const toPostgresIdentifier = (name: string): string => {
|
|
646
|
+
const bytes = Buffer.from(name, "utf8");
|
|
647
|
+
if (bytes.byteLength <= 63) return name;
|
|
648
|
+
// `toString` on a slice that ends mid-character yields U+FFFD; dropping it
|
|
649
|
+
// lands on the last whole character that fits, which is what Postgres does.
|
|
650
|
+
return bytes.subarray(0, 63).toString("utf8").replace(/�+$/, "");
|
|
651
|
+
};
|
|
652
|
+
|
|
578
653
|
const foreignKeyPlan = (
|
|
579
654
|
args: Omit<ForeignKeyPlan, "constraintName" | "sql"> & { onDelete: string; onUpdate?: string }
|
|
580
655
|
): ForeignKeyPlan => {
|
|
581
|
-
const constraintName = `${args.table}_${args.column}_fkey
|
|
656
|
+
const constraintName = toPostgresIdentifier(`${args.table}_${args.column}_fkey`);
|
|
582
657
|
const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
|
|
583
658
|
return {
|
|
584
659
|
constraintName,
|
|
@@ -610,7 +685,8 @@ const foreignKeyPlan = (
|
|
|
610
685
|
* is unknown yields the column without a constraint. Both mirror the generator
|
|
611
686
|
* exactly — a divergence here is a schema fork between boot and `db push`.
|
|
612
687
|
*/
|
|
613
|
-
export const planRelationalColumns = (
|
|
688
|
+
export const planRelationalColumns = (allCollections: CollectionConfig[]): RelationalColumnPlan[] => {
|
|
689
|
+
const collections = relationalCollections(allCollections);
|
|
614
690
|
const plans: RelationalColumnPlan[] = [];
|
|
615
691
|
|
|
616
692
|
for (const collection of collections) {
|
|
@@ -703,7 +779,8 @@ export const planRelationalColumns = (collections: CollectionConfig[]): Relation
|
|
|
703
779
|
* junction with row-level security left off is readable and writable by every
|
|
704
780
|
* signed-in user, which is why the two must ship together.
|
|
705
781
|
*/
|
|
706
|
-
export const planJunctionTables = (
|
|
782
|
+
export const planJunctionTables = (allCollections: CollectionConfig[]): JunctionTablePlan[] => {
|
|
783
|
+
const collections = relationalCollections(allCollections);
|
|
707
784
|
const plans: JunctionTablePlan[] = [];
|
|
708
785
|
|
|
709
786
|
for (const spec of resolveJunctionSpecs(collections).values()) {
|
|
@@ -780,7 +857,10 @@ export interface CollectionPolicyPlan {
|
|
|
780
857
|
* writable by every signed-in user. A junction whose table is still absent is
|
|
781
858
|
* skipped by the applier, not planned away here.
|
|
782
859
|
*/
|
|
783
|
-
export const planCollectionPolicies = (
|
|
860
|
+
export const planCollectionPolicies = (allCollections: CollectionConfig[]): CollectionPolicyPlan[] => {
|
|
861
|
+
// A store with no RLS gets no policies planned for it — `supportsRLS` is
|
|
862
|
+
// false for exactly the engines `relationalCollections` filters out.
|
|
863
|
+
const collections = relationalCollections(allCollections);
|
|
784
864
|
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
785
865
|
const plans: CollectionPolicyPlan[] = [];
|
|
786
866
|
const seen = new Set<string>();
|
|
@@ -833,7 +913,11 @@ export const planCollectionPolicies = (collections: CollectionConfig[]): Collect
|
|
|
833
913
|
return plans;
|
|
834
914
|
};
|
|
835
915
|
|
|
836
|
-
export const generatePostgresPoliciesDdl = (
|
|
916
|
+
export const generatePostgresPoliciesDdl = (allCollections: CollectionConfig[]): string => {
|
|
917
|
+
// Also the expectation `checkPolicyDrift` reconciles the database against,
|
|
918
|
+
// so a non-SQL collection filtered out here is one the drift report cannot
|
|
919
|
+
// invent a missing policy for.
|
|
920
|
+
const collections = relationalCollections(allCollections);
|
|
837
921
|
let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
|
|
838
922
|
|
|
839
923
|
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 }`);
|