@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.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/dist/PostgresBackendDriver.d.ts +1 -1
- package/dist/PostgresBootstrapper.d.ts +25 -1
- package/dist/auth/services.d.ts +21 -0
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/backup-service-CD8o_1Sl.js +8999 -0
- package/dist/backup-service-CD8o_1Sl.js.map +1 -0
- package/dist/cli-helpers.d.ts +39 -0
- package/dist/connection-BuZ97wsr.js +250 -0
- package/dist/connection-BuZ97wsr.js.map +1 -0
- package/dist/connection.d.ts +42 -0
- package/dist/ensure-collection-policies-ViG8XiPn.js +57 -0
- package/dist/ensure-collection-policies-ViG8XiPn.js.map +1 -0
- package/dist/ensure-collection-tables-CBQdOETu.js +650 -0
- package/dist/ensure-collection-tables-CBQdOETu.js.map +1 -0
- package/dist/index.es.js +1231 -10031
- package/dist/index.es.js.map +1 -1
- package/dist/policy-CeA1JcxP.js +105 -0
- package/dist/policy-CeA1JcxP.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +83 -144
- package/dist/schema/ensure-collection-policies.d.ts +60 -0
- package/dist/schema/ensure-collection-tables.d.ts +44 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +135 -1
- package/dist/schema/introspect-db-constraints.d.ts +57 -0
- package/dist/schema/introspect-db-logic.d.ts +94 -5
- package/dist/schema/introspect-db-queries.d.ts +119 -0
- package/dist/schema/introspect-db-structure.d.ts +263 -0
- package/dist/schema/introspect-db-types.d.ts +11 -0
- package/dist/services/FetchService.d.ts +4 -1
- package/dist/services/RelationService.d.ts +24 -1
- package/dist/services/channel-bus/index.d.ts +1 -7
- package/dist/services/collection-helpers.d.ts +36 -2
- package/dist/services/dataService.d.ts +3 -1
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-BbFOPJ1S.js → src-DlPBctw_.js} +299 -173
- package/dist/src-DlPBctw_.js.map +1 -0
- package/dist/{src-Zqwaw3P5.js → src-DoU9yPqq.js} +3 -159
- package/dist/src-DoU9yPqq.js.map +1 -0
- package/dist/utils/connection-string.d.ts +29 -0
- package/dist/utils/drizzle-conditions.d.ts +5 -4
- package/dist/utils/pg-error-utils.d.ts +35 -0
- package/dist/websocket-B2LsrINK.js +530 -0
- package/dist/websocket-B2LsrINK.js.map +1 -0
- package/package.json +14 -14
- package/src/PostgresAdapter.ts +21 -2
- package/src/PostgresBackendDriver.ts +4 -0
- package/src/PostgresBootstrapper.ts +192 -33
- package/src/auth/ensure-tables.ts +164 -9
- package/src/auth/services.ts +24 -2
- package/src/backup/backup-cli.ts +41 -2
- package/src/backup/backup-service.ts +38 -5
- package/src/backup/pg-tools.ts +96 -3
- package/src/cli-helpers.ts +70 -0
- package/src/cli.ts +44 -26
- package/src/collections/validate-relations.ts +15 -0
- package/src/connection.ts +73 -0
- package/src/data-transformer.ts +9 -3
- package/src/databasePoolManager.ts +5 -2
- package/src/schema/auth-schema.ts +30 -19
- package/src/schema/ensure-collection-policies.ts +105 -0
- package/src/schema/ensure-collection-tables.test.ts +105 -9
- package/src/schema/ensure-collection-tables.ts +220 -32
- package/src/schema/generate-drizzle-schema-logic.ts +42 -6
- package/src/schema/generate-postgres-ddl-logic.ts +382 -19
- package/src/schema/introspect-db-constraints.ts +385 -0
- package/src/schema/introspect-db-inference.ts +18 -8
- package/src/schema/introspect-db-logic.ts +385 -71
- package/src/schema/introspect-db-queries.ts +326 -0
- package/src/schema/introspect-db-structure.ts +670 -0
- package/src/schema/introspect-db-types.ts +56 -0
- package/src/schema/introspect-db.ts +37 -80
- package/src/schema/introspect-runtime.test.ts +56 -8
- package/src/schema/introspect-runtime.ts +31 -9
- package/src/security/policy-drift.test.ts +11 -3
- package/src/services/BranchService.ts +66 -28
- package/src/services/FetchService.ts +90 -14
- package/src/services/PersistService.ts +20 -6
- package/src/services/RelationService.ts +249 -48
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/collection-helpers.ts +69 -3
- package/src/services/dataService.ts +3 -1
- package/src/services/realtimeService.ts +3 -3
- package/src/services/row-pipeline.ts +1 -1
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +31 -6
- package/src/utils/pg-error-utils.ts +65 -0
- package/src/websocket.ts +18 -9
- package/dist/chunk-DSJWtz9O.js +0 -40
- package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
- package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
- package/dist/src-BbFOPJ1S.js.map +0 -1
- package/dist/src-Zqwaw3P5.js.map +0 -1
|
@@ -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 } from "@rebasepro/types";
|
|
2
|
-
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
|
|
3
|
-
import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
|
|
2
|
+
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
|
|
3
|
+
import { toSnakeCase, getPolicyNamesForRule, generateForeignKeyName, legacyForeignKeyName } from "@rebasepro/utils";
|
|
4
4
|
|
|
5
5
|
// --- Helper Functions ---
|
|
6
6
|
|
|
@@ -11,7 +11,7 @@ export const resolveColumnName = (propName: string, prop?: Property | null): str
|
|
|
11
11
|
return toSnakeCase(propName);
|
|
12
12
|
};
|
|
13
13
|
|
|
14
|
-
const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type: "string" | "number", isUuid: boolean } => {
|
|
14
|
+
export const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type: "string" | "number", isUuid: boolean } => {
|
|
15
15
|
if (collection.properties) {
|
|
16
16
|
const idPropEntry = Object.entries(collection.properties).find(([_, prop]) => "isId" in (prop as unknown as object) && Boolean((prop as unknown as Record<string, unknown>).isId));
|
|
17
17
|
if (idPropEntry) {
|
|
@@ -28,14 +28,18 @@ const getPrimaryKeyProp = (collection: CollectionConfig): { name: string, type:
|
|
|
28
28
|
return { name: "id", type: "string", isUuid: isUuid ?? false };
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
const isNumericId = (collection: CollectionConfig): boolean => {
|
|
31
|
+
export const isNumericId = (collection: CollectionConfig): boolean => {
|
|
32
32
|
return getPrimaryKeyProp(collection).type === "number";
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
const getPrimaryKeyName = (collection: CollectionConfig): string => {
|
|
35
|
+
export const getPrimaryKeyName = (collection: CollectionConfig): string => {
|
|
36
36
|
return getPrimaryKeyProp(collection).name;
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
/** The column type a junction holds for one endpoint's primary key. */
|
|
40
|
+
const junctionKeyType = (collection: CollectionConfig): string =>
|
|
41
|
+
isNumericId(collection) ? "INTEGER" : (getPrimaryKeyProp(collection).isUuid ? "UUID" : "TEXT");
|
|
42
|
+
|
|
39
43
|
export const isIdProperty = (propName: string, prop: Property, collection: CollectionConfig): boolean => {
|
|
40
44
|
if ("isId" in prop && Boolean(prop.isId)) return true;
|
|
41
45
|
const hasExplicitId = Object.values(collection.properties ?? {}).some(p => "isId" in (p as unknown as object) && Boolean((p as unknown as Record<string, unknown>).isId));
|
|
@@ -45,7 +49,28 @@ export const isIdProperty = (propName: string, prop: Property, collection: Colle
|
|
|
45
49
|
|
|
46
50
|
type ResolveCollection = (slug: string) => CollectionConfig | undefined;
|
|
47
51
|
|
|
48
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Render statements produced by {@link generatePolicyStatements} back into the
|
|
54
|
+
* exact string the DDL/policies files have always carried: each statement on
|
|
55
|
+
* its own line, terminated by a newline. Keeping the string form derived from
|
|
56
|
+
* the statement array means the two can never drift — the boot-time applier and
|
|
57
|
+
* the generated `policies.sql` emit the same SQL, from the same source.
|
|
58
|
+
*/
|
|
59
|
+
const statementsToDdl = (statements: string[]): string => statements.map(s => `${s}\n`).join("");
|
|
60
|
+
|
|
61
|
+
const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string =>
|
|
62
|
+
statementsToDdl(generatePolicyStatements(collection, rule, resolveCollection));
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The individual SQL statements a single security rule compiles to: a
|
|
66
|
+
* `DROP POLICY IF EXISTS` / `CREATE POLICY` pair per operation, each a complete
|
|
67
|
+
* statement (terminated by `;`, no trailing newline).
|
|
68
|
+
*
|
|
69
|
+
* This is the primitive the boot-time RLS applier runs one statement at a time
|
|
70
|
+
* (the runtime's DB handle speaks the extended query protocol, which forbids
|
|
71
|
+
* multiple commands in one execute), while `db push` writes the joined string.
|
|
72
|
+
*/
|
|
73
|
+
export const generatePolicyStatements = (collection: CollectionConfig, rule: SecurityRule, resolveCollection: ResolveCollection): string[] => {
|
|
49
74
|
const tableName = getTableName(collection);
|
|
50
75
|
const ops: readonly SecurityOperation[] = rule.operations && rule.operations.length > 0
|
|
51
76
|
? rule.operations
|
|
@@ -53,12 +78,12 @@ const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, res
|
|
|
53
78
|
|
|
54
79
|
const policyNames = getPolicyNamesForRule(rule, tableName);
|
|
55
80
|
|
|
56
|
-
return ops.
|
|
57
|
-
return
|
|
58
|
-
})
|
|
81
|
+
return ops.flatMap((op, opIdx) => {
|
|
82
|
+
return generateSinglePolicyStatements(collection, rule, op, policyNames[opIdx], resolveCollection);
|
|
83
|
+
});
|
|
59
84
|
};
|
|
60
85
|
|
|
61
|
-
const
|
|
86
|
+
const generateSinglePolicyStatements = (collection: CollectionConfig, rule: SecurityRule, operation: SecurityOperation, policyName: string, resolveCollection: ResolveCollection): string[] => {
|
|
62
87
|
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
63
88
|
const tableName = getTableName(collection);
|
|
64
89
|
const mode = (rule.mode ?? "permissive").toUpperCase();
|
|
@@ -83,13 +108,24 @@ const generateSinglePolicyDdl = (collection: CollectionConfig, rule: SecurityRul
|
|
|
83
108
|
withCheckClause = "false";
|
|
84
109
|
}
|
|
85
110
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (usingClause)
|
|
89
|
-
if (withCheckClause)
|
|
90
|
-
|
|
111
|
+
const drop = `DROP POLICY IF EXISTS "${policyName}" ON "${schema}"."${tableName}";`;
|
|
112
|
+
let create = `CREATE POLICY "${policyName}" ON "${schema}"."${tableName}" AS ${mode} FOR ${operationUpper} TO ${pgRoles.map(r => `"${r}"`).join(", ")}`;
|
|
113
|
+
if (usingClause) create += ` USING (${usingClause})`;
|
|
114
|
+
if (withCheckClause) create += ` WITH CHECK (${withCheckClause})`;
|
|
115
|
+
create += ";";
|
|
116
|
+
return [drop, create];
|
|
91
117
|
};
|
|
92
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Single-quote escaping for a SQL string literal (PostgreSQL doubles the
|
|
121
|
+
* quote). Enum labels come straight from user-authored collection config, so a
|
|
122
|
+
* label like `it's` closes the literal early and the whole generated file stops
|
|
123
|
+
* parsing at the `CREATE TYPE`. Lives here rather than next to its other caller
|
|
124
|
+
* because ensure-collection-tables already imports from this module — the
|
|
125
|
+
* reverse would be a cycle.
|
|
126
|
+
*/
|
|
127
|
+
export const quoteSqlLiteral = (value: string): string => `'${value.replace(/'/g, "''")}'`;
|
|
128
|
+
|
|
93
129
|
export const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
|
|
94
130
|
switch (prop.type) {
|
|
95
131
|
case "string": {
|
|
@@ -103,11 +139,15 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
|
|
|
103
139
|
if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") {
|
|
104
140
|
return "UUID";
|
|
105
141
|
}
|
|
142
|
+
// Width comes from `validation.max` when the property states one.
|
|
143
|
+
// It used to be a hardcoded 255 here and *absent* on the Drizzle
|
|
144
|
+
// path, so the same property produced a bounded column down one
|
|
145
|
+
// generator and an unbounded one down the other.
|
|
106
146
|
if (stringProp.columnType === "char") {
|
|
107
|
-
return
|
|
147
|
+
return `CHAR(${resolveStringColumnLength(stringProp)})`;
|
|
108
148
|
}
|
|
109
149
|
if (stringProp.columnType === "varchar") {
|
|
110
|
-
return
|
|
150
|
+
return `VARCHAR(${resolveStringColumnLength(stringProp)})`;
|
|
111
151
|
}
|
|
112
152
|
// `text` is the default. The two generators disagreed here before:
|
|
113
153
|
// this one emitted VARCHAR(255) while the drizzle path emitted a bare
|
|
@@ -212,6 +252,13 @@ export const generatePostgresDdl = async (
|
|
|
212
252
|
if (uniqueSchemas.length > 0) ddl += "\n";
|
|
213
253
|
|
|
214
254
|
// 2. Generate Enums
|
|
255
|
+
//
|
|
256
|
+
// The enum type name is derived from table + column, so two collections
|
|
257
|
+
// mapped onto the same table — or two properties whose `columnName`
|
|
258
|
+
// resolves to the same column — land on the same name. `CREATE TYPE` has no
|
|
259
|
+
// IF NOT EXISTS, so emitting it twice aborts the whole file on the second
|
|
260
|
+
// statement. Dedupe by name, matching planCollectionSchemaEnsure.
|
|
261
|
+
const emittedEnums = new Set<string>();
|
|
215
262
|
collections.forEach(collection => {
|
|
216
263
|
const collectionTable = getTableName(collection);
|
|
217
264
|
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
@@ -223,8 +270,9 @@ export const generatePostgresDdl = async (
|
|
|
223
270
|
String(typeof v === "object" && v !== null && "id" in v ? v.id : v)
|
|
224
271
|
)
|
|
225
272
|
: Object.keys(prop.enum);
|
|
226
|
-
if (values.length > 0) {
|
|
227
|
-
|
|
273
|
+
if (values.length > 0 && !emittedEnums.has(`${schema}.${enumDbName}`)) {
|
|
274
|
+
emittedEnums.add(`${schema}.${enumDbName}`);
|
|
275
|
+
ddl += `CREATE TYPE "${schema}"."${enumDbName}" AS ENUM (${values.map(quoteSqlLiteral).join(", ")});\n`;
|
|
228
276
|
}
|
|
229
277
|
}
|
|
230
278
|
});
|
|
@@ -470,6 +518,321 @@ export const generatePostgresDdl = async (
|
|
|
470
518
|
return ddl;
|
|
471
519
|
};
|
|
472
520
|
|
|
521
|
+
/** The RLS statements one declared collection's table needs, ready to run. */
|
|
522
|
+
/**
|
|
523
|
+
* A foreign key, as both its parts and the statement that creates it.
|
|
524
|
+
*
|
|
525
|
+
* `ALTER TABLE … ADD CONSTRAINT` has no `IF NOT EXISTS`, so a caller applying
|
|
526
|
+
* these has to skip by name — hence the name is a field and not only a substring
|
|
527
|
+
* of the SQL.
|
|
528
|
+
*/
|
|
529
|
+
export interface ForeignKeyPlan {
|
|
530
|
+
constraintName: string;
|
|
531
|
+
schema: string;
|
|
532
|
+
/** Bare table name, no schema prefix. */
|
|
533
|
+
table: string;
|
|
534
|
+
column: string;
|
|
535
|
+
targetSchema: string;
|
|
536
|
+
targetTable: string;
|
|
537
|
+
targetColumn: string;
|
|
538
|
+
sql: string;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** A column a `relation` or `reference` property owns on its own table. */
|
|
542
|
+
export interface RelationalColumnPlan {
|
|
543
|
+
schema: string;
|
|
544
|
+
/** Bare table name, no schema prefix. */
|
|
545
|
+
table: string;
|
|
546
|
+
column: string;
|
|
547
|
+
/** Postgres type, exactly as the DDL generator declares it. */
|
|
548
|
+
type: string;
|
|
549
|
+
/** Absent when the target collection is not part of this bundle. */
|
|
550
|
+
foreignKey?: ForeignKeyPlan;
|
|
551
|
+
/**
|
|
552
|
+
* What this column would have been called before `generateForeignKeyName`
|
|
553
|
+
* learned to singularize — set only when the two differ and the column name
|
|
554
|
+
* is the derived default rather than one the author wrote.
|
|
555
|
+
*
|
|
556
|
+
* Carried so the boot-time ensure can notice a database provisioned under
|
|
557
|
+
* the old rule. It is never used to name anything.
|
|
558
|
+
*/
|
|
559
|
+
legacyColumn?: string;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** The table behind a many-to-many `through` relation. */
|
|
563
|
+
export interface JunctionTablePlan {
|
|
564
|
+
schema: string;
|
|
565
|
+
/** Bare table name, no schema prefix. */
|
|
566
|
+
table: string;
|
|
567
|
+
columns: { name: string; type: string; legacyName?: string }[];
|
|
568
|
+
/** Both endpoint columns plus the composite primary key. */
|
|
569
|
+
createTable: string;
|
|
570
|
+
foreignKeys: ForeignKeyPlan[];
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const schemaOfCollection = (collection: CollectionConfig): string =>
|
|
574
|
+
isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
575
|
+
|
|
576
|
+
const bareTableName = (name: string): string => (name.includes(".") ? name.split(".").pop()! : name);
|
|
577
|
+
|
|
578
|
+
const foreignKeyPlan = (
|
|
579
|
+
args: Omit<ForeignKeyPlan, "constraintName" | "sql"> & { onDelete: string; onUpdate?: string }
|
|
580
|
+
): ForeignKeyPlan => {
|
|
581
|
+
const constraintName = `${args.table}_${args.column}_fkey`;
|
|
582
|
+
const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
|
|
583
|
+
return {
|
|
584
|
+
constraintName,
|
|
585
|
+
schema: args.schema,
|
|
586
|
+
table: args.table,
|
|
587
|
+
column: args.column,
|
|
588
|
+
targetSchema: args.targetSchema,
|
|
589
|
+
targetTable: args.targetTable,
|
|
590
|
+
targetColumn: args.targetColumn,
|
|
591
|
+
sql:
|
|
592
|
+
`ALTER TABLE "${args.schema}"."${args.table}" ADD CONSTRAINT "${constraintName}" ` +
|
|
593
|
+
`FOREIGN KEY ("${args.column}") REFERENCES "${args.targetSchema}"."${args.targetTable}" ` +
|
|
594
|
+
`("${args.targetColumn}") ON DELETE ${args.onDelete.toUpperCase()}${onUpdate};`
|
|
595
|
+
};
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* The FK columns the declared collections own — one entry per `relation`
|
|
600
|
+
* (`belongsTo` side) or `reference` property.
|
|
601
|
+
*
|
|
602
|
+
* Split out of {@link generatePostgresDdl} so the boot-time schema ensure can
|
|
603
|
+
* create the same columns with the same names, types and constraints. Before
|
|
604
|
+
* this it skipped them outright, which was survivable only because `db push`
|
|
605
|
+
* always followed; on a managed tenant nothing follows, so a table arrived
|
|
606
|
+
* without the column its own collection reads and wrote 400 on every insert.
|
|
607
|
+
*
|
|
608
|
+
* A relation whose target is not in the bundle yields no column at all (the
|
|
609
|
+
* generator returns early on an unresolvable target); a `reference` whose target
|
|
610
|
+
* is unknown yields the column without a constraint. Both mirror the generator
|
|
611
|
+
* exactly — a divergence here is a schema fork between boot and `db push`.
|
|
612
|
+
*/
|
|
613
|
+
export const planRelationalColumns = (collections: CollectionConfig[]): RelationalColumnPlan[] => {
|
|
614
|
+
const plans: RelationalColumnPlan[] = [];
|
|
615
|
+
|
|
616
|
+
for (const collection of collections) {
|
|
617
|
+
const tableName = getTableName(collection);
|
|
618
|
+
if (!tableName) continue;
|
|
619
|
+
const schema = schemaOfCollection(collection);
|
|
620
|
+
const table = bareTableName(tableName);
|
|
621
|
+
|
|
622
|
+
for (const [propName, rawProp] of Object.entries(collection.properties ?? {})) {
|
|
623
|
+
const prop = rawProp as Property;
|
|
624
|
+
|
|
625
|
+
if (prop.type === "relation") {
|
|
626
|
+
const refProp = prop as RelationProperty;
|
|
627
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
628
|
+
const relInfo = findRelation(resolvedRelations, refProp.relation?.relationName ?? propName);
|
|
629
|
+
if (relInfo?.kind !== "belongsTo") continue;
|
|
630
|
+
// The relation and an explicit FK property can both be declared;
|
|
631
|
+
// the explicit one owns the column.
|
|
632
|
+
if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) continue;
|
|
633
|
+
|
|
634
|
+
let targetCollection: CollectionConfig;
|
|
635
|
+
try {
|
|
636
|
+
targetCollection = relInfo.target();
|
|
637
|
+
} catch {
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
if (!targetCollection) continue;
|
|
641
|
+
|
|
642
|
+
const required = prop.validation?.required;
|
|
643
|
+
// Only a *derived* column name can be affected by the change to
|
|
644
|
+
// `generateForeignKeyName`; one the author wrote is theirs.
|
|
645
|
+
const relationName = refProp.relation?.relationName ?? propName;
|
|
646
|
+
const legacyKey = legacyForeignKeyName(relationName);
|
|
647
|
+
const derived = relInfo.localKey === generateForeignKeyName(relationName);
|
|
648
|
+
plans.push({
|
|
649
|
+
schema,
|
|
650
|
+
table,
|
|
651
|
+
column: relInfo.localKey,
|
|
652
|
+
legacyColumn: derived && legacyKey !== relInfo.localKey ? legacyKey : undefined,
|
|
653
|
+
type: getSqlColumnType(propName, prop, collection, collections),
|
|
654
|
+
foreignKey: foreignKeyPlan({
|
|
655
|
+
schema,
|
|
656
|
+
table,
|
|
657
|
+
column: relInfo.localKey,
|
|
658
|
+
targetSchema: schemaOfCollection(targetCollection),
|
|
659
|
+
targetTable: bareTableName(getTableName(targetCollection)),
|
|
660
|
+
targetColumn: getPrimaryKeyName(targetCollection),
|
|
661
|
+
onDelete: relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL"),
|
|
662
|
+
onUpdate: relInfo.onUpdate
|
|
663
|
+
})
|
|
664
|
+
});
|
|
665
|
+
} else if (prop.type === "reference") {
|
|
666
|
+
const refProp = prop as ReferenceProperty;
|
|
667
|
+
const targetCollection = collections.find(
|
|
668
|
+
c => c.slug === refProp.path || getTableName(c) === refProp.path
|
|
669
|
+
);
|
|
670
|
+
const column = resolveColumnName(propName, prop);
|
|
671
|
+
const type = getSqlColumnType(propName, prop, collection, collections);
|
|
672
|
+
const required = prop.validation?.required;
|
|
673
|
+
|
|
674
|
+
plans.push({
|
|
675
|
+
schema,
|
|
676
|
+
table,
|
|
677
|
+
column,
|
|
678
|
+
type,
|
|
679
|
+
foreignKey: targetCollection
|
|
680
|
+
? foreignKeyPlan({
|
|
681
|
+
schema,
|
|
682
|
+
table,
|
|
683
|
+
column,
|
|
684
|
+
targetSchema: schemaOfCollection(targetCollection),
|
|
685
|
+
targetTable: bareTableName(getTableName(targetCollection)),
|
|
686
|
+
targetColumn: getPrimaryKeyName(targetCollection),
|
|
687
|
+
onDelete: required ? "CASCADE" : "SET NULL"
|
|
688
|
+
})
|
|
689
|
+
: undefined
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return plans;
|
|
696
|
+
};
|
|
697
|
+
|
|
698
|
+
/**
|
|
699
|
+
* The junction tables a bundle's many-to-many relations imply.
|
|
700
|
+
*
|
|
701
|
+
* Derived from {@link resolveJunctionSpecs}, the same source the junction RLS
|
|
702
|
+
* comes from, so a table created here always has policies planned for it — a
|
|
703
|
+
* junction with row-level security left off is readable and writable by every
|
|
704
|
+
* signed-in user, which is why the two must ship together.
|
|
705
|
+
*/
|
|
706
|
+
export const planJunctionTables = (collections: CollectionConfig[]): JunctionTablePlan[] => {
|
|
707
|
+
const plans: JunctionTablePlan[] = [];
|
|
708
|
+
|
|
709
|
+
for (const spec of resolveJunctionSpecs(collections).values()) {
|
|
710
|
+
const [source, target] = spec.endpoints;
|
|
711
|
+
// A junction column's default name is derived from the endpoint
|
|
712
|
+
// collection's slug — which is normally plural, so this is where the
|
|
713
|
+
// singularization change actually lands: a `categories` endpoint used
|
|
714
|
+
// to give `categorie_id` and now gives `category_id`. Recorded, not
|
|
715
|
+
// used, so boot-ensure can recognise a table built under the old rule.
|
|
716
|
+
const legacyFor = (endpoint: typeof source): string | undefined => {
|
|
717
|
+
const slug = toSnakeCase(endpoint.collection.slug ?? endpoint.collection.name ?? "");
|
|
718
|
+
const legacy = legacyForeignKeyName(slug);
|
|
719
|
+
const derived = endpoint.junctionColumn === generateForeignKeyName(slug);
|
|
720
|
+
return derived && legacy !== endpoint.junctionColumn ? legacy : undefined;
|
|
721
|
+
};
|
|
722
|
+
const columns = [
|
|
723
|
+
{ name: source.junctionColumn, type: junctionKeyType(source.collection), legacyName: legacyFor(source) },
|
|
724
|
+
{ name: target.junctionColumn, type: junctionKeyType(target.collection), legacyName: legacyFor(target) }
|
|
725
|
+
];
|
|
726
|
+
// Every declaring side agrees on the edge's lifetime; the first one wins,
|
|
727
|
+
// as it does in the generator's walk.
|
|
728
|
+
const onDelete = spec.declaringSides[0]?.relation.onDelete ?? "CASCADE";
|
|
729
|
+
|
|
730
|
+
plans.push({
|
|
731
|
+
schema: spec.schema,
|
|
732
|
+
table: spec.table,
|
|
733
|
+
columns,
|
|
734
|
+
createTable:
|
|
735
|
+
`CREATE TABLE IF NOT EXISTS "${spec.schema}"."${spec.table}" (` +
|
|
736
|
+
columns.map(c => `"${c.name}" ${c.type} NOT NULL`).join(", ") +
|
|
737
|
+
`, PRIMARY KEY (${columns.map(c => `"${c.name}"`).join(", ")}));`,
|
|
738
|
+
foreignKeys: [source, target].map((endpoint, i) =>
|
|
739
|
+
foreignKeyPlan({
|
|
740
|
+
schema: spec.schema,
|
|
741
|
+
table: spec.table,
|
|
742
|
+
column: columns[i].name,
|
|
743
|
+
targetSchema: schemaOfCollection(endpoint.collection),
|
|
744
|
+
targetTable: bareTableName(getTableName(endpoint.collection)),
|
|
745
|
+
targetColumn: getPrimaryKeyName(endpoint.collection),
|
|
746
|
+
onDelete
|
|
747
|
+
})
|
|
748
|
+
)
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
return plans;
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
export interface CollectionPolicyPlan {
|
|
756
|
+
/** The table's schema (e.g. `public`, `rebase`). */
|
|
757
|
+
schema: string;
|
|
758
|
+
/** The bare table name, no schema prefix. */
|
|
759
|
+
table: string;
|
|
760
|
+
/** `schema.table` — matches the keys `readExistingSchema` returns. */
|
|
761
|
+
qualified: string;
|
|
762
|
+
/** `ALTER TABLE … ENABLE ROW LEVEL SECURITY;` — locked by default. */
|
|
763
|
+
enableRls: string;
|
|
764
|
+
/** `DROP POLICY IF EXISTS` / `CREATE POLICY` statements, in order. */
|
|
765
|
+
policyStatements: string[];
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* The per-table RLS plan for the *declared* collections, as executable
|
|
770
|
+
* statements — what the managed runtime applies at boot so a freshly
|
|
771
|
+
* provisioned tenant database serves data instead of 401ing every read.
|
|
772
|
+
*
|
|
773
|
+
* Mirrors {@link generatePostgresPoliciesDdl} exactly (same
|
|
774
|
+
* `generatePolicyStatements`, same enable-RLS, same effective rules, same
|
|
775
|
+
* derived junction rules), so boot and `db push` produce identical policies from
|
|
776
|
+
* identical collections.
|
|
777
|
+
*
|
|
778
|
+
* Junction tables are included, and have to be: boot creates them now
|
|
779
|
+
* ({@link planJunctionTables}), and a junction with RLS left off is readable and
|
|
780
|
+
* writable by every signed-in user. A junction whose table is still absent is
|
|
781
|
+
* skipped by the applier, not planned away here.
|
|
782
|
+
*/
|
|
783
|
+
export const planCollectionPolicies = (collections: CollectionConfig[]): CollectionPolicyPlan[] => {
|
|
784
|
+
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
785
|
+
const plans: CollectionPolicyPlan[] = [];
|
|
786
|
+
const seen = new Set<string>();
|
|
787
|
+
|
|
788
|
+
for (const collection of collections) {
|
|
789
|
+
const tableName = getTableName(collection);
|
|
790
|
+
if (!tableName) continue;
|
|
791
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
792
|
+
const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
|
|
793
|
+
const qualified = `${schema}.${baseTableName}`;
|
|
794
|
+
if (seen.has(qualified)) continue;
|
|
795
|
+
seen.add(qualified);
|
|
796
|
+
|
|
797
|
+
const policyStatements: string[] = [];
|
|
798
|
+
for (const rule of getEffectiveSecurityRules(collection)) {
|
|
799
|
+
policyStatements.push(...generatePolicyStatements(collection, rule, resolveCollection));
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
plans.push({
|
|
803
|
+
schema,
|
|
804
|
+
table: baseTableName,
|
|
805
|
+
qualified,
|
|
806
|
+
enableRls: `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;`,
|
|
807
|
+
policyStatements
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// Junctions are derived from `through` relations rather than declared, so
|
|
812
|
+
// the walk above never sees them.
|
|
813
|
+
for (const spec of resolveJunctionSpecs(collections).values()) {
|
|
814
|
+
const qualified = `${spec.schema}.${spec.table}`;
|
|
815
|
+
if (seen.has(qualified)) continue;
|
|
816
|
+
seen.add(qualified);
|
|
817
|
+
|
|
818
|
+
const junctionCollection = getJunctionCollectionConfig(spec);
|
|
819
|
+
const policyStatements: string[] = [];
|
|
820
|
+
for (const rule of getJunctionSecurityRules(spec)) {
|
|
821
|
+
policyStatements.push(...generatePolicyStatements(junctionCollection, rule, resolveCollection));
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
plans.push({
|
|
825
|
+
schema: spec.schema,
|
|
826
|
+
table: spec.table,
|
|
827
|
+
qualified,
|
|
828
|
+
enableRls: `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;`,
|
|
829
|
+
policyStatements
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
return plans;
|
|
834
|
+
};
|
|
835
|
+
|
|
473
836
|
export const generatePostgresPoliciesDdl = (collections: CollectionConfig[]): string => {
|
|
474
837
|
let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
|
|
475
838
|
|