@rebasepro/server-postgres 0.12.0 → 0.12.1-canary.g35be8cb
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/auth/services.d.ts +16 -0
- package/dist/backup-service-DH9kPg-E.js +8866 -0
- package/dist/backup-service-DH9kPg-E.js.map +1 -0
- package/dist/connection-B5Wndbr1.js +196 -0
- package/dist/connection-B5Wndbr1.js.map +1 -0
- package/dist/ensure-collection-policies-Vl3Cv1Q9.js +57 -0
- package/dist/ensure-collection-policies-Vl3Cv1Q9.js.map +1 -0
- package/dist/ensure-collection-tables-DsDsNl6o.js +590 -0
- package/dist/ensure-collection-tables-DsDsNl6o.js.map +1 -0
- package/dist/index.es.js +452 -9609
- package/dist/index.es.js.map +1 -1
- 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 +24 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +116 -1
- package/dist/{src-BbFOPJ1S.js → src-DihrDFuP.js} +160 -150
- package/dist/src-DihrDFuP.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/pg-error-utils.d.ts +19 -0
- package/dist/websocket-BKcGvILX.js +528 -0
- package/dist/websocket-BKcGvILX.js.map +1 -0
- package/package.json +14 -14
- package/src/PostgresAdapter.ts +14 -0
- package/src/PostgresBootstrapper.ts +111 -13
- package/src/auth/ensure-tables.ts +164 -9
- package/src/auth/services.ts +21 -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 +142 -25
- package/src/schema/generate-drizzle-schema-logic.ts +7 -3
- package/src/schema/generate-postgres-ddl-logic.ts +335 -16
- package/src/schema/introspect-runtime.test.ts +56 -8
- package/src/schema/introspect-runtime.ts +31 -9
- package/src/utils/pg-error-utils.ts +46 -0
- 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, 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 } from "@rebasepro/common";
|
|
3
|
+
import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
|
|
4
4
|
import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
|
|
5
5
|
import { logger } from "@rebasepro/server";
|
|
6
6
|
// --- Helper Functions ---
|
|
@@ -98,9 +98,13 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
|
|
|
98
98
|
} else if (stringProp.columnType === "uuid") {
|
|
99
99
|
columnDefinition = `uuid("${colName}")`;
|
|
100
100
|
} else if (stringProp.columnType === "char") {
|
|
101
|
-
columnDefinition = `char("${colName}")`;
|
|
101
|
+
columnDefinition = `char("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
102
102
|
} else if (stringProp.columnType === "varchar") {
|
|
103
|
-
|
|
103
|
+
// The length is not optional decoration: `varchar("col")` with
|
|
104
|
+
// no length is an UNBOUNDED varchar in Postgres, which is what
|
|
105
|
+
// this emitted while the DDL generator emitted VARCHAR(255) for
|
|
106
|
+
// the very same property.
|
|
107
|
+
columnDefinition = `varchar("${colName}", { length: ${resolveStringColumnLength(stringProp)} })`;
|
|
104
108
|
} else {
|
|
105
109
|
// `text` is the default, and the only length-unbounded choice.
|
|
106
110
|
// Ask for `varchar` explicitly if you want the length constraint.
|
|
@@ -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 } from "@rebasepro/common";
|
|
2
|
+
import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig, resolveStringColumnLength } from "@rebasepro/common";
|
|
3
3
|
import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
|
|
4
4
|
|
|
5
5
|
// --- Helper Functions ---
|
|
@@ -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,11 +108,12 @@ 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
|
|
|
93
119
|
export const getSqlColumnType = (propName: string, prop: Property, collection: CollectionConfig, collections: CollectionConfig[]): string => {
|
|
@@ -103,11 +129,15 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
|
|
|
103
129
|
if (stringProp.isId === "uuid" || stringProp.columnType === "uuid") {
|
|
104
130
|
return "UUID";
|
|
105
131
|
}
|
|
132
|
+
// Width comes from `validation.max` when the property states one.
|
|
133
|
+
// It used to be a hardcoded 255 here and *absent* on the Drizzle
|
|
134
|
+
// path, so the same property produced a bounded column down one
|
|
135
|
+
// generator and an unbounded one down the other.
|
|
106
136
|
if (stringProp.columnType === "char") {
|
|
107
|
-
return
|
|
137
|
+
return `CHAR(${resolveStringColumnLength(stringProp)})`;
|
|
108
138
|
}
|
|
109
139
|
if (stringProp.columnType === "varchar") {
|
|
110
|
-
return
|
|
140
|
+
return `VARCHAR(${resolveStringColumnLength(stringProp)})`;
|
|
111
141
|
}
|
|
112
142
|
// `text` is the default. The two generators disagreed here before:
|
|
113
143
|
// this one emitted VARCHAR(255) while the drizzle path emitted a bare
|
|
@@ -470,6 +500,295 @@ export const generatePostgresDdl = async (
|
|
|
470
500
|
return ddl;
|
|
471
501
|
};
|
|
472
502
|
|
|
503
|
+
/** The RLS statements one declared collection's table needs, ready to run. */
|
|
504
|
+
/**
|
|
505
|
+
* A foreign key, as both its parts and the statement that creates it.
|
|
506
|
+
*
|
|
507
|
+
* `ALTER TABLE … ADD CONSTRAINT` has no `IF NOT EXISTS`, so a caller applying
|
|
508
|
+
* these has to skip by name — hence the name is a field and not only a substring
|
|
509
|
+
* of the SQL.
|
|
510
|
+
*/
|
|
511
|
+
export interface ForeignKeyPlan {
|
|
512
|
+
constraintName: string;
|
|
513
|
+
schema: string;
|
|
514
|
+
/** Bare table name, no schema prefix. */
|
|
515
|
+
table: string;
|
|
516
|
+
column: string;
|
|
517
|
+
targetSchema: string;
|
|
518
|
+
targetTable: string;
|
|
519
|
+
targetColumn: string;
|
|
520
|
+
sql: string;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/** A column a `relation` or `reference` property owns on its own table. */
|
|
524
|
+
export interface RelationalColumnPlan {
|
|
525
|
+
schema: string;
|
|
526
|
+
/** Bare table name, no schema prefix. */
|
|
527
|
+
table: string;
|
|
528
|
+
column: string;
|
|
529
|
+
/** Postgres type, exactly as the DDL generator declares it. */
|
|
530
|
+
type: string;
|
|
531
|
+
/** Absent when the target collection is not part of this bundle. */
|
|
532
|
+
foreignKey?: ForeignKeyPlan;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** The table behind a many-to-many `through` relation. */
|
|
536
|
+
export interface JunctionTablePlan {
|
|
537
|
+
schema: string;
|
|
538
|
+
/** Bare table name, no schema prefix. */
|
|
539
|
+
table: string;
|
|
540
|
+
columns: { name: string; type: string }[];
|
|
541
|
+
/** Both endpoint columns plus the composite primary key. */
|
|
542
|
+
createTable: string;
|
|
543
|
+
foreignKeys: ForeignKeyPlan[];
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const schemaOfCollection = (collection: CollectionConfig): string =>
|
|
547
|
+
isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
548
|
+
|
|
549
|
+
const bareTableName = (name: string): string => (name.includes(".") ? name.split(".").pop()! : name);
|
|
550
|
+
|
|
551
|
+
const foreignKeyPlan = (
|
|
552
|
+
args: Omit<ForeignKeyPlan, "constraintName" | "sql"> & { onDelete: string; onUpdate?: string }
|
|
553
|
+
): ForeignKeyPlan => {
|
|
554
|
+
const constraintName = `${args.table}_${args.column}_fkey`;
|
|
555
|
+
const onUpdate = args.onUpdate ? ` ON UPDATE ${args.onUpdate.toUpperCase()}` : "";
|
|
556
|
+
return {
|
|
557
|
+
constraintName,
|
|
558
|
+
schema: args.schema,
|
|
559
|
+
table: args.table,
|
|
560
|
+
column: args.column,
|
|
561
|
+
targetSchema: args.targetSchema,
|
|
562
|
+
targetTable: args.targetTable,
|
|
563
|
+
targetColumn: args.targetColumn,
|
|
564
|
+
sql:
|
|
565
|
+
`ALTER TABLE "${args.schema}"."${args.table}" ADD CONSTRAINT "${constraintName}" ` +
|
|
566
|
+
`FOREIGN KEY ("${args.column}") REFERENCES "${args.targetSchema}"."${args.targetTable}" ` +
|
|
567
|
+
`("${args.targetColumn}") ON DELETE ${args.onDelete.toUpperCase()}${onUpdate};`
|
|
568
|
+
};
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* The FK columns the declared collections own — one entry per `relation`
|
|
573
|
+
* (`belongsTo` side) or `reference` property.
|
|
574
|
+
*
|
|
575
|
+
* Split out of {@link generatePostgresDdl} so the boot-time schema ensure can
|
|
576
|
+
* create the same columns with the same names, types and constraints. Before
|
|
577
|
+
* this it skipped them outright, which was survivable only because `db push`
|
|
578
|
+
* always followed; on a managed tenant nothing follows, so a table arrived
|
|
579
|
+
* without the column its own collection reads and wrote 400 on every insert.
|
|
580
|
+
*
|
|
581
|
+
* A relation whose target is not in the bundle yields no column at all (the
|
|
582
|
+
* generator returns early on an unresolvable target); a `reference` whose target
|
|
583
|
+
* is unknown yields the column without a constraint. Both mirror the generator
|
|
584
|
+
* exactly — a divergence here is a schema fork between boot and `db push`.
|
|
585
|
+
*/
|
|
586
|
+
export const planRelationalColumns = (collections: CollectionConfig[]): RelationalColumnPlan[] => {
|
|
587
|
+
const plans: RelationalColumnPlan[] = [];
|
|
588
|
+
|
|
589
|
+
for (const collection of collections) {
|
|
590
|
+
const tableName = getTableName(collection);
|
|
591
|
+
if (!tableName) continue;
|
|
592
|
+
const schema = schemaOfCollection(collection);
|
|
593
|
+
const table = bareTableName(tableName);
|
|
594
|
+
|
|
595
|
+
for (const [propName, rawProp] of Object.entries(collection.properties ?? {})) {
|
|
596
|
+
const prop = rawProp as Property;
|
|
597
|
+
|
|
598
|
+
if (prop.type === "relation") {
|
|
599
|
+
const refProp = prop as RelationProperty;
|
|
600
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
601
|
+
const relInfo = findRelation(resolvedRelations, refProp.relation?.relationName ?? propName);
|
|
602
|
+
if (relInfo?.kind !== "belongsTo") continue;
|
|
603
|
+
// The relation and an explicit FK property can both be declared;
|
|
604
|
+
// the explicit one owns the column.
|
|
605
|
+
if (collection.properties[relInfo.localKey] && propName !== relInfo.localKey) continue;
|
|
606
|
+
|
|
607
|
+
let targetCollection: CollectionConfig;
|
|
608
|
+
try {
|
|
609
|
+
targetCollection = relInfo.target();
|
|
610
|
+
} catch {
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
if (!targetCollection) continue;
|
|
614
|
+
|
|
615
|
+
const required = prop.validation?.required;
|
|
616
|
+
plans.push({
|
|
617
|
+
schema,
|
|
618
|
+
table,
|
|
619
|
+
column: relInfo.localKey,
|
|
620
|
+
type: getSqlColumnType(propName, prop, collection, collections),
|
|
621
|
+
foreignKey: foreignKeyPlan({
|
|
622
|
+
schema,
|
|
623
|
+
table,
|
|
624
|
+
column: relInfo.localKey,
|
|
625
|
+
targetSchema: schemaOfCollection(targetCollection),
|
|
626
|
+
targetTable: bareTableName(getTableName(targetCollection)),
|
|
627
|
+
targetColumn: getPrimaryKeyName(targetCollection),
|
|
628
|
+
onDelete: relInfo.onDelete ?? (required ? "CASCADE" : "SET NULL"),
|
|
629
|
+
onUpdate: relInfo.onUpdate
|
|
630
|
+
})
|
|
631
|
+
});
|
|
632
|
+
} else if (prop.type === "reference") {
|
|
633
|
+
const refProp = prop as ReferenceProperty;
|
|
634
|
+
const targetCollection = collections.find(
|
|
635
|
+
c => c.slug === refProp.path || getTableName(c) === refProp.path
|
|
636
|
+
);
|
|
637
|
+
const column = resolveColumnName(propName, prop);
|
|
638
|
+
const type = getSqlColumnType(propName, prop, collection, collections);
|
|
639
|
+
const required = prop.validation?.required;
|
|
640
|
+
|
|
641
|
+
plans.push({
|
|
642
|
+
schema,
|
|
643
|
+
table,
|
|
644
|
+
column,
|
|
645
|
+
type,
|
|
646
|
+
foreignKey: targetCollection
|
|
647
|
+
? foreignKeyPlan({
|
|
648
|
+
schema,
|
|
649
|
+
table,
|
|
650
|
+
column,
|
|
651
|
+
targetSchema: schemaOfCollection(targetCollection),
|
|
652
|
+
targetTable: bareTableName(getTableName(targetCollection)),
|
|
653
|
+
targetColumn: getPrimaryKeyName(targetCollection),
|
|
654
|
+
onDelete: required ? "CASCADE" : "SET NULL"
|
|
655
|
+
})
|
|
656
|
+
: undefined
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
return plans;
|
|
663
|
+
};
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* The junction tables a bundle's many-to-many relations imply.
|
|
667
|
+
*
|
|
668
|
+
* Derived from {@link resolveJunctionSpecs}, the same source the junction RLS
|
|
669
|
+
* comes from, so a table created here always has policies planned for it — a
|
|
670
|
+
* junction with row-level security left off is readable and writable by every
|
|
671
|
+
* signed-in user, which is why the two must ship together.
|
|
672
|
+
*/
|
|
673
|
+
export const planJunctionTables = (collections: CollectionConfig[]): JunctionTablePlan[] => {
|
|
674
|
+
const plans: JunctionTablePlan[] = [];
|
|
675
|
+
|
|
676
|
+
for (const spec of resolveJunctionSpecs(collections).values()) {
|
|
677
|
+
const [source, target] = spec.endpoints;
|
|
678
|
+
const columns = [
|
|
679
|
+
{ name: source.junctionColumn, type: junctionKeyType(source.collection) },
|
|
680
|
+
{ name: target.junctionColumn, type: junctionKeyType(target.collection) }
|
|
681
|
+
];
|
|
682
|
+
// Every declaring side agrees on the edge's lifetime; the first one wins,
|
|
683
|
+
// as it does in the generator's walk.
|
|
684
|
+
const onDelete = spec.declaringSides[0]?.relation.onDelete ?? "CASCADE";
|
|
685
|
+
|
|
686
|
+
plans.push({
|
|
687
|
+
schema: spec.schema,
|
|
688
|
+
table: spec.table,
|
|
689
|
+
columns,
|
|
690
|
+
createTable:
|
|
691
|
+
`CREATE TABLE IF NOT EXISTS "${spec.schema}"."${spec.table}" (` +
|
|
692
|
+
columns.map(c => `"${c.name}" ${c.type} NOT NULL`).join(", ") +
|
|
693
|
+
`, PRIMARY KEY (${columns.map(c => `"${c.name}"`).join(", ")}));`,
|
|
694
|
+
foreignKeys: [source, target].map((endpoint, i) =>
|
|
695
|
+
foreignKeyPlan({
|
|
696
|
+
schema: spec.schema,
|
|
697
|
+
table: spec.table,
|
|
698
|
+
column: columns[i].name,
|
|
699
|
+
targetSchema: schemaOfCollection(endpoint.collection),
|
|
700
|
+
targetTable: bareTableName(getTableName(endpoint.collection)),
|
|
701
|
+
targetColumn: getPrimaryKeyName(endpoint.collection),
|
|
702
|
+
onDelete
|
|
703
|
+
})
|
|
704
|
+
)
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
return plans;
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
export interface CollectionPolicyPlan {
|
|
712
|
+
/** The table's schema (e.g. `public`, `rebase`). */
|
|
713
|
+
schema: string;
|
|
714
|
+
/** The bare table name, no schema prefix. */
|
|
715
|
+
table: string;
|
|
716
|
+
/** `schema.table` — matches the keys `readExistingSchema` returns. */
|
|
717
|
+
qualified: string;
|
|
718
|
+
/** `ALTER TABLE … ENABLE ROW LEVEL SECURITY;` — locked by default. */
|
|
719
|
+
enableRls: string;
|
|
720
|
+
/** `DROP POLICY IF EXISTS` / `CREATE POLICY` statements, in order. */
|
|
721
|
+
policyStatements: string[];
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
* The per-table RLS plan for the *declared* collections, as executable
|
|
726
|
+
* statements — what the managed runtime applies at boot so a freshly
|
|
727
|
+
* provisioned tenant database serves data instead of 401ing every read.
|
|
728
|
+
*
|
|
729
|
+
* Mirrors {@link generatePostgresPoliciesDdl} exactly (same
|
|
730
|
+
* `generatePolicyStatements`, same enable-RLS, same effective rules, same
|
|
731
|
+
* derived junction rules), so boot and `db push` produce identical policies from
|
|
732
|
+
* identical collections.
|
|
733
|
+
*
|
|
734
|
+
* Junction tables are included, and have to be: boot creates them now
|
|
735
|
+
* ({@link planJunctionTables}), and a junction with RLS left off is readable and
|
|
736
|
+
* writable by every signed-in user. A junction whose table is still absent is
|
|
737
|
+
* skipped by the applier, not planned away here.
|
|
738
|
+
*/
|
|
739
|
+
export const planCollectionPolicies = (collections: CollectionConfig[]): CollectionPolicyPlan[] => {
|
|
740
|
+
const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
|
|
741
|
+
const plans: CollectionPolicyPlan[] = [];
|
|
742
|
+
const seen = new Set<string>();
|
|
743
|
+
|
|
744
|
+
for (const collection of collections) {
|
|
745
|
+
const tableName = getTableName(collection);
|
|
746
|
+
if (!tableName) continue;
|
|
747
|
+
const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
|
|
748
|
+
const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
|
|
749
|
+
const qualified = `${schema}.${baseTableName}`;
|
|
750
|
+
if (seen.has(qualified)) continue;
|
|
751
|
+
seen.add(qualified);
|
|
752
|
+
|
|
753
|
+
const policyStatements: string[] = [];
|
|
754
|
+
for (const rule of getEffectiveSecurityRules(collection)) {
|
|
755
|
+
policyStatements.push(...generatePolicyStatements(collection, rule, resolveCollection));
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
plans.push({
|
|
759
|
+
schema,
|
|
760
|
+
table: baseTableName,
|
|
761
|
+
qualified,
|
|
762
|
+
enableRls: `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;`,
|
|
763
|
+
policyStatements
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Junctions are derived from `through` relations rather than declared, so
|
|
768
|
+
// the walk above never sees them.
|
|
769
|
+
for (const spec of resolveJunctionSpecs(collections).values()) {
|
|
770
|
+
const qualified = `${spec.schema}.${spec.table}`;
|
|
771
|
+
if (seen.has(qualified)) continue;
|
|
772
|
+
seen.add(qualified);
|
|
773
|
+
|
|
774
|
+
const junctionCollection = getJunctionCollectionConfig(spec);
|
|
775
|
+
const policyStatements: string[] = [];
|
|
776
|
+
for (const rule of getJunctionSecurityRules(spec)) {
|
|
777
|
+
policyStatements.push(...generatePolicyStatements(junctionCollection, rule, resolveCollection));
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
plans.push({
|
|
781
|
+
schema: spec.schema,
|
|
782
|
+
table: spec.table,
|
|
783
|
+
qualified,
|
|
784
|
+
enableRls: `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;`,
|
|
785
|
+
policyStatements
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
return plans;
|
|
790
|
+
};
|
|
791
|
+
|
|
473
792
|
export const generatePostgresPoliciesDdl = (collections: CollectionConfig[]): string => {
|
|
474
793
|
let ddl = "-- This file contains RLS policies generated by Rebase. Applied separately from migrations.\n\n";
|
|
475
794
|
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "@jest/globals";
|
|
2
|
+
import { resolveCollectionRelations } from "@rebasepro/common";
|
|
3
|
+
import type { CollectionConfig } from "@rebasepro/types";
|
|
2
4
|
|
|
3
5
|
import { buildCollectionsFromSchema, introspectSchema, readRlsStatus, IntrospectedSchema, Queryable } from "./introspect-runtime";
|
|
4
6
|
import { buildTablesMap, identifyJoinTables, TableColumn, ForeignKeyRow, PrimaryKeyRow } from "./introspect-db-logic";
|
|
@@ -121,14 +123,60 @@ describe("buildCollectionsFromSchema", () => {
|
|
|
121
123
|
const props = posts.properties as any;
|
|
122
124
|
|
|
123
125
|
expect(props.author_id).toBeUndefined();
|
|
124
|
-
expect(props.author).
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
126
|
+
expect(props.author.name).toBe("Author");
|
|
127
|
+
expect(props.author.type).toBe("relation");
|
|
128
|
+
// The descriptor is nested under `relation`, carries `kind`, and its
|
|
129
|
+
// `target` is a thunk — see the resolvability test below for why none of
|
|
130
|
+
// those three are stylistic.
|
|
131
|
+
expect(props.author.relation).toMatchObject({ kind: "belongsTo", localKey: "author_id" });
|
|
132
|
+
expect(typeof props.author.relation.target).toBe("function");
|
|
133
|
+
expect(props.author.relation.target().slug).toBe("authors");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The shape has to be one the resolver actually reads.
|
|
138
|
+
*
|
|
139
|
+
* This path emitted `cardinality` / `direction` / a bare-string `target`
|
|
140
|
+
* flat on the property — the shape `Relation` stopped accepting, and the
|
|
141
|
+
* same drift `introspect-emits-valid-relations.test.ts` caught in the
|
|
142
|
+
* generated-file path and this one was missed by. Nothing threw:
|
|
143
|
+
* `resolveCollectionRelations` reads `property.relation`, found none, and
|
|
144
|
+
* reported a collection with no relations at all.
|
|
145
|
+
*
|
|
146
|
+
* The visible cost was writes. `assertKnownWriteFields` learns an owning
|
|
147
|
+
* relation's FK column from the *resolved* relation's `localKey`, and the FK
|
|
148
|
+
* column is deliberately absent from `properties` ("surfaces as a
|
|
149
|
+
* relation"), so with nothing resolving, `POST /api/data/orders` with
|
|
150
|
+
* `product_id` came back 400 `has no field 'product_id'` — the column was
|
|
151
|
+
* simultaneously the only way to set the relation and not a known field.
|
|
152
|
+
*
|
|
153
|
+
* Asserting the literal object is what let it drift, so this asserts
|
|
154
|
+
* through the resolver instead: a future reshape has to keep it resolvable,
|
|
155
|
+
* not merely keep the keys someone once wrote down.
|
|
156
|
+
*/
|
|
157
|
+
it("emits a relation the resolver can read, so the fk column is writable", () => {
|
|
158
|
+
const schema = schemaOf(
|
|
159
|
+
[
|
|
160
|
+
column({ table_name: "products", column_name: "id", data_type: "integer", udt_name: "int4", is_nullable: "NO" }),
|
|
161
|
+
column({ table_name: "orders", column_name: "id", data_type: "integer", udt_name: "int4", is_nullable: "NO" }),
|
|
162
|
+
column({ table_name: "orders", column_name: "product_id", data_type: "integer", udt_name: "int4" })
|
|
163
|
+
],
|
|
164
|
+
[
|
|
165
|
+
{ table_name: "products", column_name: "id" },
|
|
166
|
+
{ table_name: "orders", column_name: "id" }
|
|
167
|
+
],
|
|
168
|
+
[{ table_name: "orders", column_name: "product_id", foreign_table_name: "products", foreign_column_name: "id" }]
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const orders = buildCollectionsFromSchema(schema, "public").find((c) => c.slug === "orders")!;
|
|
172
|
+
const resolved = resolveCollectionRelations(orders as unknown as CollectionConfig);
|
|
173
|
+
|
|
174
|
+
expect(Object.keys(resolved)).toEqual(["product"]);
|
|
175
|
+
expect(resolved.product.kind).toBe("belongsTo");
|
|
176
|
+
// `localKey` is the field `assertKnownWriteFields` adds to the known set,
|
|
177
|
+
// which is what makes `product_id` writable. The write itself is covered
|
|
178
|
+
// end-to-end by `scripts/smoke-baas.ts` against a real database.
|
|
179
|
+
expect((resolved.product as { localKey: string }).localKey).toBe("product_id");
|
|
132
180
|
});
|
|
133
181
|
|
|
134
182
|
it("skips join tables — they are an edge between collections, not a collection", () => {
|
|
@@ -207,13 +207,30 @@ function buildProperties(
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
/**
|
|
210
|
-
* Owning relations, derived from this table's foreign keys
|
|
211
|
-
* `generateCollectionFile`
|
|
212
|
-
*
|
|
210
|
+
* Owning relations, derived from this table's foreign keys — the same shape
|
|
211
|
+
* `generateCollectionFile` writes into a collection file: a `relation` property
|
|
212
|
+
* whose nested descriptor carries `kind`, a `target` thunk and the `localKey`.
|
|
213
|
+
*
|
|
214
|
+
* The shape is load-bearing, not cosmetic. `resolveCollectionRelations` reads
|
|
215
|
+
* relations from `property.relation` and `resolveRelation` requires `target` to
|
|
216
|
+
* be a thunk; this used to emit `target`/`cardinality`/`localKey` flat on the
|
|
217
|
+
* property with the slug as a bare string, which satisfies neither. Nothing
|
|
218
|
+
* threw — the resolver simply skipped every such property and reported that the
|
|
219
|
+
* collection had no relations. So an introspected BaaS collection had its FK
|
|
220
|
+
* columns removed from `properties` (they "surface as relations") and then no
|
|
221
|
+
* resolvable relation to surface as, which is why writing the FK column
|
|
222
|
+
* directly came back as `has no field 'product_id'`: `assertKnownWriteFields`
|
|
223
|
+
* learns that column from the resolved relation's `localKey`.
|
|
224
|
+
*
|
|
225
|
+
* The thunk closes over the collections being built in this same pass rather
|
|
226
|
+
* than importing a module, which is what a runtime introspection has instead of
|
|
227
|
+
* generated files. It is called lazily, after the map is fully populated, so a
|
|
228
|
+
* table may reference one introspected later.
|
|
213
229
|
*/
|
|
214
230
|
function buildRelations(
|
|
215
231
|
meta: TableMeta,
|
|
216
|
-
slugByTable: Map<string, string
|
|
232
|
+
slugByTable: Map<string, string>,
|
|
233
|
+
collectionBySlug: Map<string, PostgresCollectionConfig>
|
|
217
234
|
): Record<string, Record<string, unknown>> {
|
|
218
235
|
const relations: Record<string, Record<string, unknown>> = {};
|
|
219
236
|
|
|
@@ -233,10 +250,11 @@ function buildRelations(
|
|
|
233
250
|
relations[key] = {
|
|
234
251
|
name: humanize(key),
|
|
235
252
|
type: "relation",
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
253
|
+
relation: {
|
|
254
|
+
kind: "belongsTo",
|
|
255
|
+
target: () => collectionBySlug.get(targetSlug),
|
|
256
|
+
localKey: fk.column_name
|
|
257
|
+
}
|
|
240
258
|
};
|
|
241
259
|
}
|
|
242
260
|
|
|
@@ -259,6 +277,9 @@ export function buildCollectionsFromSchema(
|
|
|
259
277
|
}
|
|
260
278
|
|
|
261
279
|
const collections: PostgresCollectionConfig[] = [];
|
|
280
|
+
// Filled as we go; the relation thunks read it lazily, so a table may point
|
|
281
|
+
// at one that has not been built yet at the moment its relation is created.
|
|
282
|
+
const collectionBySlug = new Map<string, PostgresCollectionConfig>();
|
|
262
283
|
|
|
263
284
|
for (const [tableName, meta] of tablesMap) {
|
|
264
285
|
if (joinTables.has(tableName)) continue;
|
|
@@ -273,11 +294,12 @@ export function buildCollectionsFromSchema(
|
|
|
273
294
|
icon: getIconForTable(tableName),
|
|
274
295
|
properties: {
|
|
275
296
|
...buildProperties(meta, enumMap),
|
|
276
|
-
...buildRelations(meta, slugByTable)
|
|
297
|
+
...buildRelations(meta, slugByTable, collectionBySlug)
|
|
277
298
|
}
|
|
278
299
|
} as unknown as PostgresCollectionConfig;
|
|
279
300
|
|
|
280
301
|
collections.push(collection);
|
|
302
|
+
collectionBySlug.set(tableName, collection);
|
|
281
303
|
}
|
|
282
304
|
|
|
283
305
|
return collections;
|
|
@@ -97,6 +97,52 @@ export function extractCauseMessage(error: unknown): string | null {
|
|
|
97
97
|
return null;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Codes that mean "this connection will never work as configured".
|
|
102
|
+
*
|
|
103
|
+
* A wrong password or a database that does not exist is a settled fact about
|
|
104
|
+
* the connection string, not a transient fault — retrying produces the same
|
|
105
|
+
* answer forever.
|
|
106
|
+
*/
|
|
107
|
+
const UNRECOVERABLE_CONNECT_CODES = new Set([
|
|
108
|
+
"28P01", // invalid_password
|
|
109
|
+
"28000", // invalid_authorization_specification
|
|
110
|
+
"3D000", // invalid_catalog_name — the database does not exist
|
|
111
|
+
"42501" // insufficient_privilege
|
|
112
|
+
]);
|
|
113
|
+
|
|
114
|
+
export interface ConnectFailure {
|
|
115
|
+
/** True when retrying cannot help: the connection string itself is wrong. */
|
|
116
|
+
fatal: boolean;
|
|
117
|
+
/** The deepest message available — the Postgres one where there is one. */
|
|
118
|
+
reason: string;
|
|
119
|
+
/** The `SQLSTATE`, when the failure came from Postgres rather than the socket. */
|
|
120
|
+
code?: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Describe a failed connection attempt in terms a developer can act on.
|
|
125
|
+
*
|
|
126
|
+
* The error a caller catches is Drizzle's wrapper: its message is
|
|
127
|
+
* `Failed query: SELECT 1` and its stack runs through drizzle internals, while
|
|
128
|
+
* the sentence that says what is actually wrong — "password authentication
|
|
129
|
+
* failed for user …", "database … does not exist" — sits in `.cause`. Logging
|
|
130
|
+
* the wrapper, as the bootstrapper used to, tells a developer with a typo in
|
|
131
|
+
* their `DATABASE_URL` nothing at all.
|
|
132
|
+
*/
|
|
133
|
+
export function classifyConnectFailure(error: unknown): ConnectFailure {
|
|
134
|
+
const pgError = extractPgError(error);
|
|
135
|
+
const reason =
|
|
136
|
+
pgError?.message ??
|
|
137
|
+
extractCauseMessage(error) ??
|
|
138
|
+
(error instanceof Error ? error.message : String(error));
|
|
139
|
+
return {
|
|
140
|
+
fatal: Boolean(pgError?.code && UNRECOVERABLE_CONNECT_CODES.has(pgError.code)),
|
|
141
|
+
reason,
|
|
142
|
+
code: pgError?.code
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
100
146
|
/**
|
|
101
147
|
* Detect whether an error is specifically a role-switching permission failure
|
|
102
148
|
* (e.g. "permission denied to set role" or "must be member of role"),
|