@rebasepro/server-postgres 0.12.0 → 0.12.1-canary.g06f263c
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/PostgresBootstrapper.d.ts +25 -1
- package/dist/auth/services.d.ts +16 -0
- package/dist/backup-service-vAKWJkYL.js +8867 -0
- package/dist/backup-service-vAKWJkYL.js.map +1 -0
- package/dist/cli-helpers.d.ts +24 -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-BjSwj0FM.js +57 -0
- package/dist/ensure-collection-policies-BjSwj0FM.js.map +1 -0
- package/dist/ensure-collection-tables-D6-XhqnQ.js +590 -0
- package/dist/ensure-collection-tables-D6-XhqnQ.js.map +1 -0
- package/dist/index.es.js +545 -9629
- 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 +24 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +116 -1
- package/dist/{src-BbFOPJ1S.js → src-C_NHNVW2.js} +94 -153
- package/dist/src-C_NHNVW2.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-DB7TbFPT.js +529 -0
- package/dist/websocket-DB7TbFPT.js.map +1 -0
- package/package.json +14 -14
- package/src/PostgresAdapter.ts +14 -0
- package/src/PostgresBootstrapper.ts +192 -33
- package/src/auth/ensure-tables.ts +164 -9
- package/src/auth/services.ts +21 -2
- package/src/cli-helpers.ts +44 -0
- package/src/cli.ts +31 -3
- package/src/connection.ts +73 -0
- 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 +142 -25
- package/src/schema/generate-drizzle-schema-logic.ts +16 -5
- 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/services/RelationService.ts +38 -3
- package/src/services/realtimeService.ts +3 -3
- package/src/utils/pg-error-utils.ts +46 -0
- package/src/websocket.ts +3 -3
- 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,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;
|
|
@@ -18,6 +18,39 @@ import { PostgresCollectionRegistry } from "../collections/PostgresCollectionReg
|
|
|
18
18
|
import { logger } from "@rebasepro/server";
|
|
19
19
|
import type { NestedPathHop } from "./nested-path";
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The ids in a to-many relation write, whatever shape the caller sent.
|
|
23
|
+
*
|
|
24
|
+
* A membership list is written as either the related rows (`[{ id: 1 }]`, what
|
|
25
|
+
* the admin UI sends back after reading them) or as bare keys (`[1]`, `["t-1"]`,
|
|
26
|
+
* what anyone writing the API by hand sends). Only the first was read, via a
|
|
27
|
+
* blind `.map(rel => rel.id)`, and a bare key therefore became `undefined`:
|
|
28
|
+
* on a numeric-keyed target that surfaced as `Invalid numeric ID: undefined`,
|
|
29
|
+
* and on a string-keyed one it did not surface at all — `String(undefined)`
|
|
30
|
+
* wrote a junction row pointing at the literal `"undefined"`, which no read
|
|
31
|
+
* would ever match. Both shapes are accepted here, in one place, because both
|
|
32
|
+
* call sites had the same assumption.
|
|
33
|
+
*
|
|
34
|
+
* An element that carries no key is refused rather than skipped: dropping it
|
|
35
|
+
* would silently write a shorter membership list than the caller asked for.
|
|
36
|
+
*/
|
|
37
|
+
function relationTargetIds(value: unknown, relationName: string, collectionSlug: string): (string | number)[] {
|
|
38
|
+
if (!Array.isArray(value)) return [];
|
|
39
|
+
|
|
40
|
+
return value.map((element, index) => {
|
|
41
|
+
if (typeof element === "string" || typeof element === "number") return element;
|
|
42
|
+
if (element && typeof element === "object") {
|
|
43
|
+
const id = (element as { id?: unknown }).id;
|
|
44
|
+
if (typeof id === "string" || typeof id === "number") return id;
|
|
45
|
+
}
|
|
46
|
+
throw new Error(
|
|
47
|
+
`Cannot write relation "${relationName}" on "${collectionSlug}": element ${index} carries no id. ` +
|
|
48
|
+
"Pass either the related rows (`[{ id: … }]`) or their keys (`[1, 2]`), not " +
|
|
49
|
+
`${element === null ? "null" : typeof element}.`
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
21
54
|
/**
|
|
22
55
|
* Typed wrapper for Drizzle dynamic query innerJoin.
|
|
23
56
|
* Drizzle's `$dynamic()` queries lose the `innerJoin` method from
|
|
@@ -840,7 +873,7 @@ export class RelationService {
|
|
|
840
873
|
const relation = findRelation(resolvedRelations, key);
|
|
841
874
|
if (!relation || relation.cardinality !== "many") continue;
|
|
842
875
|
|
|
843
|
-
const targetEntityIds = (value
|
|
876
|
+
const targetEntityIds = relationTargetIds(value, key, collection.slug);
|
|
844
877
|
const targetCollection = relation.target();
|
|
845
878
|
|
|
846
879
|
// Use joinPath if available
|
|
@@ -1166,7 +1199,9 @@ export class RelationService {
|
|
|
1166
1199
|
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
1167
1200
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1168
1201
|
const targetIdInfo = targetPks[0];
|
|
1169
|
-
|
|
1202
|
+
// This path already read both shapes; the other two did not.
|
|
1203
|
+
// Same helper now, so the three cannot drift again.
|
|
1204
|
+
const targetEntityIds = relationTargetIds(newValue, relation.relationName, sourceCollection.slug);
|
|
1170
1205
|
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
1171
1206
|
|
|
1172
1207
|
const newLinks = parsedTargetIds.map(targetId => ({
|
|
@@ -1238,7 +1273,7 @@ export class RelationService {
|
|
|
1238
1273
|
if (newValue && Array.isArray(newValue) && newValue.length > 0) {
|
|
1239
1274
|
const targetPks = requirePrimaryKeys(targetCollection, this.registry);
|
|
1240
1275
|
const targetIdInfo = targetPks[0];
|
|
1241
|
-
const targetEntityIds = (newValue
|
|
1276
|
+
const targetEntityIds = relationTargetIds(newValue, relation.relationName, sourceCollection.slug);
|
|
1242
1277
|
const parsedTargetIds = targetEntityIds.map(id => parseIdValues(id, targetPks)[targetIdInfo.fieldName]);
|
|
1243
1278
|
|
|
1244
1279
|
const newLinks = parsedTargetIds.map(targetId => ({
|
|
@@ -4,7 +4,7 @@ import { Client as PgClient } from "pg";
|
|
|
4
4
|
import { randomUUID } from "crypto";
|
|
5
5
|
import { DataService } from "./dataService";
|
|
6
6
|
|
|
7
|
-
import { FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
|
|
7
|
+
import { ANONYMOUS_USER_ID, FetchCollectionProps, ListenCollectionProps, ListenOneProps, DataDriver, CollectionUpdateMessage, SingleUpdateMessage, CollectionPatchMessage, WebSocketMessage, FilterValues, CollectionConfig, RebaseCallContext, resolveClientListLimit } from "@rebasepro/types";
|
|
8
8
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
9
9
|
import { sql as drizzleSql } from "drizzle-orm";
|
|
10
10
|
import { RealtimeProvider, CollectionSubscriptionConfig, SingleSubscriptionConfig } from "../interfaces";
|
|
@@ -751,7 +751,7 @@ export class RealtimeService extends EventEmitter implements RealtimeProvider {
|
|
|
751
751
|
// Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
|
|
752
752
|
// Refetches are reads: apply the same GUCs + reader-role downgrade as the
|
|
753
753
|
// driver's read path, so realtime cannot leak rows the initial fetch hid.
|
|
754
|
-
const activeAuth = authContext || { uid:
|
|
754
|
+
const activeAuth = authContext || { uid: ANONYMOUS_USER_ID,
|
|
755
755
|
roles: ["anon"] };
|
|
756
756
|
return await this.db.transaction(async (tx) => {
|
|
757
757
|
await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);
|
|
@@ -931,7 +931,7 @@ roles: activeAuth.roles },
|
|
|
931
931
|
|
|
932
932
|
// Always wrap in a transaction with session vars, defaulting to anonymous context if missing.
|
|
933
933
|
// Same read isolation as collection refetches: GUCs + reader-role downgrade.
|
|
934
|
-
const activeAuth = authContext || { uid:
|
|
934
|
+
const activeAuth = authContext || { uid: ANONYMOUS_USER_ID,
|
|
935
935
|
roles: ["anon"] };
|
|
936
936
|
return await this.db.transaction(async (tx) => {
|
|
937
937
|
await applyAuthContext(tx, { uid: activeAuth.uid, roles: activeAuth.roles }, this.rlsUserRole);
|