@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g06dbe5b
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +48 -1
- package/dist/auth/services.d.ts +19 -0
- package/dist/{src-DlPBctw_.js → auth-users-columns-CBEOeYqa.js} +835 -63
- package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
- package/dist/cli-helpers.d.ts +57 -1
- package/dist/data-transformer.d.ts +7 -2
- package/dist/data_driver-ULAyJEi9.js +193 -0
- package/dist/data_driver-ULAyJEi9.js.map +1 -0
- package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-B_JMGa5K.js} +2 -2
- package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-B_JMGa5K.js.map} +1 -1
- package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DzeTEvMv.js} +170 -20
- package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
- package/dist/index.es.js +1365 -338
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
- package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
- package/dist/schema/auth-schema.d.ts +102 -0
- package/dist/schema/auth-users-columns.d.ts +97 -0
- package/dist/schema/doctor-policy-checks.d.ts +28 -0
- package/dist/schema/doctor.d.ts +23 -25
- package/dist/schema/ensure-collection-tables.d.ts +61 -7
- package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +34 -7
- package/dist/services/RelationService.d.ts +30 -0
- package/dist/services/collection-helpers.d.ts +26 -0
- package/dist/services/dataService.d.ts +5 -0
- package/dist/services/realtimeService.d.ts +131 -21
- package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-D1qbmLZ2.js} +75 -18
- package/dist/websocket-D1qbmLZ2.js.map +1 -0
- package/package.json +9 -8
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +55 -6
- package/src/auth/ensure-tables.ts +207 -86
- package/src/auth/services.ts +69 -5
- package/src/cli-helpers.ts +129 -10
- package/src/cli.ts +232 -30
- package/src/collections/validate-relations.ts +124 -17
- package/src/data-transformer.ts +120 -17
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +12 -63
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +135 -76
- package/src/schema/ensure-collection-tables.ts +374 -32
- package/src/schema/generate-drizzle-schema-logic.ts +132 -42
- package/src/schema/generate-postgres-ddl-logic.ts +294 -16
- package/src/schema/generate-postgres-ddl.ts +25 -2
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-logic.ts +66 -34
- package/src/schema/non-sql-collections.test.ts +131 -0
- package/src/schema/rls-bootstrap-sql.ts +288 -0
- package/src/schema/search-column.ts +643 -0
- package/src/security/anonymous-grants.test.ts +4 -2
- package/src/security/rls-enforcement.ts +149 -6
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +175 -108
- package/src/services/PersistService.ts +38 -2
- package/src/services/RelationService.ts +110 -67
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +54 -1
- package/src/services/dataService.ts +5 -0
- package/src/services/realtimeService.ts +344 -79
- package/src/utils/drizzle-conditions.ts +365 -23
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
- package/dist/policy-CeA1JcxP.js +0 -105
- package/dist/policy-CeA1JcxP.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-DlPBctw_.js.map +0 -1
- package/dist/src-DoU9yPqq.js.map +0 -1
- package/dist/websocket-B2LsrINK.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
|
@@ -218,7 +218,14 @@ export function singularize(word: string): string {
|
|
|
218
218
|
* e.g. "company_token" -> "companyTokenCollection"
|
|
219
219
|
*/
|
|
220
220
|
export function toCollectionVarName(tableName: string): string {
|
|
221
|
-
|
|
221
|
+
const camel = tableName.replace(/_([a-z])/g, (_g, letter: string) => letter.toUpperCase()) + "Collection";
|
|
222
|
+
// Only reshapes names that are not identifiers already, so every table that
|
|
223
|
+
// generated a working file keeps the exact variable name it had. A table
|
|
224
|
+
// called `2024 archive` used to emit `const 2024 archiveCollection`, which
|
|
225
|
+
// is three syntax errors rather than a declaration.
|
|
226
|
+
if (JS_IDENTIFIER.test(camel)) return camel;
|
|
227
|
+
const sanitized = camel.replace(/[^A-Za-z0-9_$]/g, "_");
|
|
228
|
+
return /^[0-9]/.test(sanitized) ? `_${sanitized}` : sanitized;
|
|
222
229
|
}
|
|
223
230
|
|
|
224
231
|
export function getIconForTable(tableName: string): string {
|
|
@@ -616,6 +623,31 @@ function quote(value: string): string {
|
|
|
616
623
|
return JSON.stringify(value);
|
|
617
624
|
}
|
|
618
625
|
|
|
626
|
+
const JS_IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* An object key for the generated file: verbatim when the name is a JavaScript
|
|
630
|
+
* identifier, quoted otherwise.
|
|
631
|
+
*
|
|
632
|
+
* Postgres constrains an identifier only by quoting, so `order`, `full name`
|
|
633
|
+
* and `2fa_enabled` are all ordinary column names — and all three produced a
|
|
634
|
+
* file that did not parse when written as a bare key.
|
|
635
|
+
*/
|
|
636
|
+
function propKey(name: string): string {
|
|
637
|
+
return JS_IDENTIFIER.test(name) ? name : quote(name);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Text safe to put after `//`.
|
|
642
|
+
*
|
|
643
|
+
* A table comment or a classification reason is free text out of the database.
|
|
644
|
+
* A newline in one ended the comment and let the rest of the value continue as
|
|
645
|
+
* code.
|
|
646
|
+
*/
|
|
647
|
+
function commentText(value: string): string {
|
|
648
|
+
return value.replace(/\s*[\r\n]+\s*/g, " ");
|
|
649
|
+
}
|
|
650
|
+
|
|
619
651
|
/**
|
|
620
652
|
* The first candidate key not already used, or a numbered fallback.
|
|
621
653
|
*
|
|
@@ -688,7 +720,7 @@ export function generateCollectionFile(
|
|
|
688
720
|
*/
|
|
689
721
|
const importCollection = (otherTable: string): string => {
|
|
690
722
|
const varName = toCollectionVarName(otherTable);
|
|
691
|
-
if (otherTable !== tableName) imports.add(`import ${varName} from
|
|
723
|
+
if (otherTable !== tableName) imports.add(`import ${varName} from ${quote(`./${otherTable}`)};`);
|
|
692
724
|
return varName;
|
|
693
725
|
};
|
|
694
726
|
|
|
@@ -737,7 +769,7 @@ export function generateCollectionFile(
|
|
|
737
769
|
// Enum values — generate real enum from the PG enum
|
|
738
770
|
if (isEnumColumn && colEnumValues) {
|
|
739
771
|
const enumEntries = colEnumValues
|
|
740
|
-
.map((v) => `{ id:
|
|
772
|
+
.map((v) => `{ id: ${quote(v)}, label: ${quote(humanize(v))} }`)
|
|
741
773
|
.join(", ");
|
|
742
774
|
extra += `\n enum: [${enumEntries}],`;
|
|
743
775
|
} else if (columnChecks?.enumValues && !inferenceExtra.includes("enum:") && propType === "string") {
|
|
@@ -776,9 +808,9 @@ export function generateCollectionFile(
|
|
|
776
808
|
else if (innerType === "boolean") colType = "boolean[]";
|
|
777
809
|
}
|
|
778
810
|
if (colType) {
|
|
779
|
-
extra += `\n columnType:
|
|
811
|
+
extra += `\n columnType: ${quote(colType)},`;
|
|
780
812
|
}
|
|
781
|
-
extra += `\n of: { name:
|
|
813
|
+
extra += `\n of: { name: ${quote(`${humanize(col.column_name)} Item`)}, type: ${quote(innerType)} },`;
|
|
782
814
|
} else if (finalPropType === "map" && !inferenceExtra.includes("keyValue: true") && !inferenceExtra.includes("properties: {")) {
|
|
783
815
|
extra += "\n keyValue: true,";
|
|
784
816
|
}
|
|
@@ -789,7 +821,7 @@ export function generateCollectionFile(
|
|
|
789
821
|
const isMedia = colNameLower.includes("image") || colNameLower.includes("avatar") || colNameLower.includes("photo") || colNameLower.includes("logo") || colNameLower.includes("cover");
|
|
790
822
|
|
|
791
823
|
if (isMedia) {
|
|
792
|
-
extra += `\n storage: {\n storagePath:
|
|
824
|
+
extra += `\n storage: {\n storagePath: ${quote(`${tableName}/${col.column_name}`)}\n },`;
|
|
793
825
|
} else if (isUrl) {
|
|
794
826
|
extra += "\n url: true,";
|
|
795
827
|
} else if (colNameLower === "description" || colNameLower === "summary" || colNameLower === "excerpt") {
|
|
@@ -872,7 +904,7 @@ export function generateCollectionFile(
|
|
|
872
904
|
// Identify IDs (unless already inferred as UUID/CUID by inferenceEngine)
|
|
873
905
|
if (meta.pks.includes(col.column_name)) {
|
|
874
906
|
if (isCompositePk) {
|
|
875
|
-
extra += `\n // Part of composite primary key (${meta.pks.join(", ")})`;
|
|
907
|
+
extra += `\n // Part of composite primary key (${commentText(meta.pks.join(", "))})`;
|
|
876
908
|
} else if (finalPropType === "number" && !inferenceExtra.includes("isId:")) {
|
|
877
909
|
extra += "\n isId: \"increment\",";
|
|
878
910
|
} else if (col.data_type.toLowerCase() === "uuid" && !inferenceExtra.includes("isId:")) {
|
|
@@ -913,10 +945,10 @@ export function generateCollectionFile(
|
|
|
913
945
|
});
|
|
914
946
|
|
|
915
947
|
propertyBlocks.set(col.column_name, `
|
|
916
|
-
${col.column_name}: {
|
|
917
|
-
name:
|
|
918
|
-
columnName:
|
|
919
|
-
type:
|
|
948
|
+
${propKey(col.column_name)}: {
|
|
949
|
+
name: ${quote(humanName)},
|
|
950
|
+
columnName: ${quote(col.column_name)},
|
|
951
|
+
type: ${quote(finalPropType)},${extra}
|
|
920
952
|
},`);
|
|
921
953
|
}
|
|
922
954
|
|
|
@@ -962,14 +994,14 @@ export function generateCollectionFile(
|
|
|
962
994
|
const relHumanName = humanize(relName);
|
|
963
995
|
|
|
964
996
|
propertyBlocks.set(relName, `
|
|
965
|
-
${relName}: {
|
|
966
|
-
name:
|
|
997
|
+
${propKey(relName)}: {
|
|
998
|
+
name: ${quote(relHumanName)},
|
|
967
999
|
type: "relation",
|
|
968
|
-
// mapped from foreign key: ${fk.column_name} -> ${targetTableName}(${fk.foreign_column_name})
|
|
1000
|
+
// mapped from foreign key: ${commentText(fk.column_name)} -> ${commentText(targetTableName)}(${commentText(fk.foreign_column_name)})
|
|
969
1001
|
relation: {
|
|
970
1002
|
kind: "belongsTo",
|
|
971
1003
|
target: () => ${targetCollectionCamel},
|
|
972
|
-
localKey:
|
|
1004
|
+
localKey: ${quote(fk.column_name)}
|
|
973
1005
|
}
|
|
974
1006
|
},`);
|
|
975
1007
|
}
|
|
@@ -986,10 +1018,10 @@ export function generateCollectionFile(
|
|
|
986
1018
|
relationsOutput += `
|
|
987
1019
|
{
|
|
988
1020
|
kind: "hasMany",
|
|
989
|
-
relationName:
|
|
1021
|
+
relationName: ${quote(sourceTableName)},
|
|
990
1022
|
target: () => ${targetCollectionCamel},
|
|
991
|
-
// the ${sourceTableName}.${fk.column_name} FK points back here
|
|
992
|
-
foreignKeyOnTarget:
|
|
1023
|
+
// the ${commentText(sourceTableName)}.${commentText(fk.column_name)} FK points back here
|
|
1024
|
+
foreignKeyOnTarget: ${quote(fk.column_name)}
|
|
993
1025
|
},`;
|
|
994
1026
|
}
|
|
995
1027
|
|
|
@@ -1018,12 +1050,12 @@ export function generateCollectionFile(
|
|
|
1018
1050
|
relationsOutput += `
|
|
1019
1051
|
{
|
|
1020
1052
|
kind: "manyToMany",
|
|
1021
|
-
relationName:
|
|
1022
|
-
target: () => ${tableName}
|
|
1053
|
+
relationName: ${quote(relPropName)},
|
|
1054
|
+
target: () => ${toCollectionVarName(tableName)},
|
|
1023
1055
|
through: {
|
|
1024
|
-
table:
|
|
1025
|
-
sourceColumn:
|
|
1026
|
-
targetColumn:
|
|
1056
|
+
table: ${quote(jt)},
|
|
1057
|
+
sourceColumn: ${quote(thisFk.column_name)},
|
|
1058
|
+
targetColumn: ${quote(otherFk.column_name)}
|
|
1027
1059
|
}
|
|
1028
1060
|
},`;
|
|
1029
1061
|
continue;
|
|
@@ -1045,13 +1077,13 @@ export function generateCollectionFile(
|
|
|
1045
1077
|
const thisFk = joinFks.find((fk) => fk.foreign_table_name === tableName);
|
|
1046
1078
|
|
|
1047
1079
|
const throughCode = thisFk
|
|
1048
|
-
? `\n through: {\n table:
|
|
1080
|
+
? `\n through: {\n table: ${quote(jt)},\n sourceColumn: ${quote(thisFk.column_name)},\n targetColumn: ${quote(otherFk.column_name)}\n }`
|
|
1049
1081
|
: "";
|
|
1050
1082
|
|
|
1051
1083
|
relationsOutput += `
|
|
1052
1084
|
{
|
|
1053
1085
|
kind: "manyToMany",
|
|
1054
|
-
relationName:
|
|
1086
|
+
relationName: ${quote(targetTableName)},
|
|
1055
1087
|
target: () => ${targetCollectionCamel},${throughCode}
|
|
1056
1088
|
},`;
|
|
1057
1089
|
}
|
|
@@ -1071,7 +1103,7 @@ export function generateCollectionFile(
|
|
|
1071
1103
|
// config, where they have not belonged since the admin block was split out:
|
|
1072
1104
|
// `PostgresCollectionConfig` does not declare them, so every generated file
|
|
1073
1105
|
// was a type error, and the panel — which reads the block — never saw them.
|
|
1074
|
-
const adminEntries: string[] = [`icon:
|
|
1106
|
+
const adminEntries: string[] = [`icon: ${quote(icon)}`];
|
|
1075
1107
|
|
|
1076
1108
|
if (classification) {
|
|
1077
1109
|
const derivedFacts = context.metadata
|
|
@@ -1089,7 +1121,7 @@ export function generateCollectionFile(
|
|
|
1089
1121
|
|
|
1090
1122
|
if (derivedFacts) {
|
|
1091
1123
|
const titleProperty = deriveTitleProperty(derivedFacts);
|
|
1092
|
-
if (titleProperty) adminEntries.push(`
|
|
1124
|
+
if (titleProperty) adminEntries.push(`display: { title: ${quote(titleProperty)} }`);
|
|
1093
1125
|
|
|
1094
1126
|
const kanbanProperty = deriveKanbanProperty(derivedFacts);
|
|
1095
1127
|
if (kanbanProperty) adminEntries.push(`kanban: {\n columnProperty: ${quote(kanbanProperty)}\n }`);
|
|
@@ -1116,17 +1148,17 @@ export function generateCollectionFile(
|
|
|
1116
1148
|
// decision the reader may disagree with. Naming the evidence tells them
|
|
1117
1149
|
// which line to delete when they do.
|
|
1118
1150
|
const classificationNote = classification && classification.role !== "entity"
|
|
1119
|
-
? `\n// Introspected as a ${classification.role}: ${classification.reason}.\n`
|
|
1151
|
+
? `\n// Introspected as a ${commentText(classification.role)}: ${commentText(classification.reason)}.\n`
|
|
1120
1152
|
: "";
|
|
1121
1153
|
|
|
1122
1154
|
const collectionVarName = toCollectionVarName(tableName);
|
|
1123
1155
|
const fileContent = `${Array.from(imports).join("\n")}
|
|
1124
1156
|
${classificationNote}
|
|
1125
1157
|
const ${collectionVarName}: PostgresCollectionConfig = {
|
|
1126
|
-
name:
|
|
1127
|
-
singularName:
|
|
1128
|
-
slug:
|
|
1129
|
-
table:
|
|
1158
|
+
name: ${quote(collectionName)},
|
|
1159
|
+
singularName: ${quote(singular)},
|
|
1160
|
+
slug: ${quote(tableName)},
|
|
1161
|
+
table: ${quote(tableName)},${descriptionBlock}
|
|
1130
1162
|
properties: {${propsOutput}
|
|
1131
1163
|
},${relationsBlock}${adminBlock}
|
|
1132
1164
|
};
|
|
@@ -1146,7 +1178,7 @@ export function generateIndexContent(fileNames: string[]): string {
|
|
|
1146
1178
|
let arrayElements = "";
|
|
1147
1179
|
for (const f of sorted) {
|
|
1148
1180
|
const varName = toCollectionVarName(f);
|
|
1149
|
-
imports += `import ${varName} from
|
|
1181
|
+
imports += `import ${varName} from ${quote(`./${f}`)};\n`;
|
|
1150
1182
|
arrayElements += ` ${varName},\n`;
|
|
1151
1183
|
}
|
|
1152
1184
|
return `${imports}\nexport const collections = [\n${arrayElements}];\n`;
|
|
@@ -1168,7 +1200,7 @@ export function mergeIndexContent(existingContent: string, newFileNames: string[
|
|
|
1168
1200
|
for (const f of sorted) {
|
|
1169
1201
|
if (!existingImports.has(f)) {
|
|
1170
1202
|
const varName = toCollectionVarName(f);
|
|
1171
|
-
newImports += `import ${varName} from
|
|
1203
|
+
newImports += `import ${varName} from ${quote(`./${f}`)};\n`;
|
|
1172
1204
|
newElements += ` ${varName},\n`;
|
|
1173
1205
|
}
|
|
1174
1206
|
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A collection served by another engine is not this engine's to provision.
|
|
3
|
+
*
|
|
4
|
+
* Firestore and MongoDB collections are declared in the same
|
|
5
|
+
* `config/collections` directory as the Postgres ones — that is what
|
|
6
|
+
* `dataSource` routing is for — and every stage of the SQL toolchain used to
|
|
7
|
+
* read "the collections" as all of them. So a Firestore collection got a
|
|
8
|
+
* `pgTable` in the generated schema, a `CREATE TABLE` at boot, RLS policies,
|
|
9
|
+
* and an entry in the `db push` include list, while the app went on reading its
|
|
10
|
+
* documents from Firestore.
|
|
11
|
+
*
|
|
12
|
+
* The include list is the one that can lose data: a name on it is a name Atlas
|
|
13
|
+
* is *allowed to drop*, so a Firestore collection called `exercises` removed a
|
|
14
|
+
* real, unrelated `public.exercises` table's protection from the next
|
|
15
|
+
* auto-approved `db push`.
|
|
16
|
+
*/
|
|
17
|
+
import { describe, expect, it } from "@jest/globals";
|
|
18
|
+
import type { CollectionConfig } from "@rebasepro/types";
|
|
19
|
+
|
|
20
|
+
import { generateSchema } from "./generate-drizzle-schema-logic";
|
|
21
|
+
import {
|
|
22
|
+
generatePostgresDdl,
|
|
23
|
+
generatePostgresPoliciesDdl,
|
|
24
|
+
planCollectionPolicies
|
|
25
|
+
} from "./generate-postgres-ddl-logic";
|
|
26
|
+
import { planCollectionSchemaEnsure, type ExistingSchema } from "./ensure-collection-tables";
|
|
27
|
+
import { getTableIncludesFromCollections } from "../cli-helpers";
|
|
28
|
+
|
|
29
|
+
const sqlCollection = {
|
|
30
|
+
name: "Customers",
|
|
31
|
+
slug: "customers",
|
|
32
|
+
properties: {
|
|
33
|
+
id: { name: "ID", type: "string", isId: "uuid" },
|
|
34
|
+
email: { name: "Email", type: "string" }
|
|
35
|
+
},
|
|
36
|
+
securityRules: [{ operation: "read", policy: "true" }]
|
|
37
|
+
} as unknown as CollectionConfig;
|
|
38
|
+
|
|
39
|
+
const firestoreCollection = {
|
|
40
|
+
name: "Exercises",
|
|
41
|
+
slug: "exercises",
|
|
42
|
+
engine: "firestore",
|
|
43
|
+
dataSource: "firestore",
|
|
44
|
+
properties: {
|
|
45
|
+
id: { name: "ID", type: "string" },
|
|
46
|
+
title: { name: "Title", type: "string" },
|
|
47
|
+
difficulty: {
|
|
48
|
+
name: "Difficulty",
|
|
49
|
+
type: "string",
|
|
50
|
+
enum: [{ id: "easy", label: "Easy" }, { id: "hard", label: "Hard" }]
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} as unknown as CollectionConfig;
|
|
54
|
+
|
|
55
|
+
const mongoCollection = {
|
|
56
|
+
name: "Events",
|
|
57
|
+
slug: "events",
|
|
58
|
+
engine: "mongodb",
|
|
59
|
+
properties: { id: { name: "ID", type: "string" } }
|
|
60
|
+
} as unknown as CollectionConfig;
|
|
61
|
+
|
|
62
|
+
/** Routed by `dataSource` alone — no `engine` field to read. */
|
|
63
|
+
const dataSourceOnlyCollection = {
|
|
64
|
+
name: "Articles",
|
|
65
|
+
slug: "articles",
|
|
66
|
+
dataSource: "firestore",
|
|
67
|
+
properties: { id: { name: "ID", type: "string" } }
|
|
68
|
+
} as unknown as CollectionConfig;
|
|
69
|
+
|
|
70
|
+
const all = [sqlCollection, firestoreCollection, mongoCollection, dataSourceOnlyCollection];
|
|
71
|
+
|
|
72
|
+
describe("the Drizzle schema generator", () => {
|
|
73
|
+
it("emits a table for the Postgres collection only", async () => {
|
|
74
|
+
const schema = await generateSchema(all);
|
|
75
|
+
|
|
76
|
+
expect(schema).toContain("customers");
|
|
77
|
+
expect(schema).not.toContain("exercises");
|
|
78
|
+
expect(schema).not.toContain("events");
|
|
79
|
+
expect(schema).not.toContain("articles");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("does not emit enum types for a document store's fields", async () => {
|
|
83
|
+
const schema = await generateSchema(all);
|
|
84
|
+
expect(schema).not.toContain("difficulty");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("the Postgres DDL generator", () => {
|
|
89
|
+
it("creates no table for a non-SQL collection", async () => {
|
|
90
|
+
const ddl = await generatePostgresDdl(all, { includePolicies: false });
|
|
91
|
+
|
|
92
|
+
expect(ddl).toContain('CREATE TABLE "public"."customers"');
|
|
93
|
+
expect(ddl).not.toContain("exercises");
|
|
94
|
+
expect(ddl).not.toContain("events");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("plans no RLS for a store that has none", () => {
|
|
98
|
+
const policies = generatePostgresPoliciesDdl(all);
|
|
99
|
+
expect(policies).not.toContain("exercises");
|
|
100
|
+
|
|
101
|
+
const plans = planCollectionPolicies(all);
|
|
102
|
+
expect(plans.map(p => p.qualified)).toEqual(["public.customers"]);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("the boot-time schema ensure", () => {
|
|
107
|
+
const empty = (): ExistingSchema => ({ tables: new Map(), enums: new Set() });
|
|
108
|
+
|
|
109
|
+
it("creates nothing for collections another engine stores", () => {
|
|
110
|
+
const plan = planCollectionSchemaEnsure(all, empty());
|
|
111
|
+
|
|
112
|
+
expect(plan.actions.map(a => a.target)).toEqual(
|
|
113
|
+
expect.arrayContaining(["public.customers"])
|
|
114
|
+
);
|
|
115
|
+
for (const action of plan.actions) {
|
|
116
|
+
expect(action.target).not.toContain("exercises");
|
|
117
|
+
expect(action.target).not.toContain("events");
|
|
118
|
+
expect(action.target).not.toContain("articles");
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
describe("the db push include list", () => {
|
|
124
|
+
it("does not claim a table for a Firestore collection", async () => {
|
|
125
|
+
// Anything absent from this list is *excluded* from the declarative
|
|
126
|
+
// apply, which is what protects it from being dropped.
|
|
127
|
+
const includes = await getTableIncludesFromCollections(all);
|
|
128
|
+
|
|
129
|
+
expect(includes).toEqual(["public.customers"]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { LEGACY_RLS_SCHEMA, REBASE_SCHEMA } from "@rebasepro/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canonical SQL bootstrap for the RLS helper functions.
|
|
5
|
+
*
|
|
6
|
+
* Generated RLS policies reference `rebase.uid()` / `rebase.roles()` /
|
|
7
|
+
* `rebase.jwt()`, so any SQL stream that can contain policies must be
|
|
8
|
+
* self-contained: it has to (re)create these helpers first. This matters for
|
|
9
|
+
* the migration directory in particular — Atlas replays migrations against a
|
|
10
|
+
* clean dev database where no out-of-band bootstrap has ever run, so a
|
|
11
|
+
* migration carrying policies without this preamble fails with
|
|
12
|
+
* "function rebase.uid() does not exist".
|
|
13
|
+
*
|
|
14
|
+
* Idempotent (`IF NOT EXISTS` / `OR REPLACE`) so it can be prepended to every
|
|
15
|
+
* policies block and re-applied freely. The runtime boot path
|
|
16
|
+
* (`auth/ensure-tables.ts`) creates the same functions under an advisory lock
|
|
17
|
+
* for HMR-safety; keep the definitions in sync.
|
|
18
|
+
*
|
|
19
|
+
* ## Creating the `rebase` schema here is now safe, and required
|
|
20
|
+
*
|
|
21
|
+
* It deliberately did not, once. These functions lived in a schema called
|
|
22
|
+
* `auth`, and the note here read: creating `rebase` would leak it into Atlas's
|
|
23
|
+
* replayed migration state, and — absent from the desired `schema.sql` — Atlas
|
|
24
|
+
* would then plan `DROP SCHEMA "rebase" CASCADE`, taking the auth tables with
|
|
25
|
+
* it. That reasoning still holds; what changed is the second half of it. The
|
|
26
|
+
* DDL generator now emits `CREATE SCHEMA IF NOT EXISTS "rebase"`
|
|
27
|
+
* unconditionally, so the schema is always in the desired state and the diff is
|
|
28
|
+
* empty. (It used to appear only when some collection happened to declare
|
|
29
|
+
* `schema: "rebase"` — true for the scaffold's users collection, and not a
|
|
30
|
+
* property anything guaranteed.) `db push` additionally excludes the whole
|
|
31
|
+
* schema from the declarative apply.
|
|
32
|
+
*
|
|
33
|
+
* See `@rebasepro/types`' `rls-functions` for why the functions moved out of
|
|
34
|
+
* `auth` at all: the short version is that the name was Supabase's, and
|
|
35
|
+
* `CREATE OR REPLACE FUNCTION auth.uid() RETURNS text` cannot be applied over
|
|
36
|
+
* Supabase's `RETURNS uuid` — Postgres refuses, and the refusal used to be
|
|
37
|
+
* swallowed.
|
|
38
|
+
*/
|
|
39
|
+
/**
|
|
40
|
+
* The bootstrap as individual statements.
|
|
41
|
+
*
|
|
42
|
+
* Kept as an array because the two consumers need different shapes and only one
|
|
43
|
+
* of them can take a multi-command string: the migration preamble is written to
|
|
44
|
+
* a file and replayed by Atlas, but the boot path runs through drizzle, whose
|
|
45
|
+
* node-postgres handle speaks the extended query protocol and rejects more than
|
|
46
|
+
* one command per call. Splitting a joined string back apart on `$$;` would be
|
|
47
|
+
* a parser for a problem that does not need one.
|
|
48
|
+
*/
|
|
49
|
+
export const RLS_BOOTSTRAP_STATEMENTS: readonly string[] = [
|
|
50
|
+
`CREATE SCHEMA IF NOT EXISTS ${REBASE_SCHEMA}`,
|
|
51
|
+
|
|
52
|
+
// Falls back to the pre-rename `app.user_id` so a database that has taken
|
|
53
|
+
// the new schema but is still served by an older backend keeps resolving
|
|
54
|
+
// the principal. Drop the COALESCE once no such deployment remains.
|
|
55
|
+
`CREATE OR REPLACE FUNCTION ${REBASE_SCHEMA}.uid() RETURNS text AS $$
|
|
56
|
+
SELECT COALESCE(
|
|
57
|
+
NULLIF(current_setting('app.uid', true), ''),
|
|
58
|
+
NULLIF(current_setting('app.user_id', true), '')
|
|
59
|
+
);
|
|
60
|
+
$$ LANGUAGE sql STABLE`,
|
|
61
|
+
|
|
62
|
+
`CREATE OR REPLACE FUNCTION ${REBASE_SCHEMA}.jwt() RETURNS jsonb AS $$
|
|
63
|
+
SELECT COALESCE(
|
|
64
|
+
NULLIF(current_setting('app.jwt', true), ''),
|
|
65
|
+
'{}'
|
|
66
|
+
)::jsonb;
|
|
67
|
+
$$ LANGUAGE sql STABLE`,
|
|
68
|
+
|
|
69
|
+
`CREATE OR REPLACE FUNCTION ${REBASE_SCHEMA}.roles() RETURNS text AS $$
|
|
70
|
+
SELECT COALESCE(NULLIF(current_setting('app.user_roles', true), ''), '');
|
|
71
|
+
$$ LANGUAGE sql STABLE`
|
|
72
|
+
];
|
|
73
|
+
|
|
74
|
+
/** The same statements as one script, for migration files and raw clients. */
|
|
75
|
+
export const RLS_BOOTSTRAP_SQL =
|
|
76
|
+
"-- Rebase schema + RLS helper functions (required by the policies below)\n" +
|
|
77
|
+
RLS_BOOTSTRAP_STATEMENTS.map(s => `${s};`).join("\n\n") + "\n";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Removes the pre-1.0 `auth` schema, but only when Rebase is what put it there.
|
|
81
|
+
*
|
|
82
|
+
* ## Why this is safe against a Supabase database
|
|
83
|
+
*
|
|
84
|
+
* Two independent guards, and both have to pass:
|
|
85
|
+
*
|
|
86
|
+
* 1. **Each function is identified before it is dropped.** Ours returns `text`
|
|
87
|
+
* and reads the `app.uid` GUC; Supabase's returns `uuid` and reads
|
|
88
|
+
* `request.jwt.claims`. Nothing is dropped on a signature we did not write,
|
|
89
|
+
* so a Supabase database — where our `CREATE OR REPLACE` could never have
|
|
90
|
+
* succeeded in the first place, Postgres refusing to change a return type —
|
|
91
|
+
* matches nothing and this is a no-op.
|
|
92
|
+
* 2. **`DROP SCHEMA … RESTRICT`**, never CASCADE. If anything else at all still
|
|
93
|
+
* lives in `auth` (Supabase's `users` table, its other helpers), the drop
|
|
94
|
+
* fails and the schema stays. CASCADE here would be unrecoverable.
|
|
95
|
+
*
|
|
96
|
+
* ## Why it cannot run too early
|
|
97
|
+
*
|
|
98
|
+
* Postgres records a dependency from every RLS policy to the functions its body
|
|
99
|
+
* calls, so `DROP FUNCTION auth.uid()` fails for as long as a single policy
|
|
100
|
+
* still references it. That is the interlock, and it is load-bearing: the drop
|
|
101
|
+
* can only succeed once every policy has been recompiled to \`rebase.uid()\`.
|
|
102
|
+
* Callers therefore run this *after* applying policies, and treat a failure as
|
|
103
|
+
* "not yet — try again next boot" rather than as an error.
|
|
104
|
+
*/
|
|
105
|
+
export const DROP_LEGACY_AUTH_SCHEMA_SQL = `
|
|
106
|
+
DO $rebase_drop_legacy$
|
|
107
|
+
DECLARE
|
|
108
|
+
dropped_any boolean := false;
|
|
109
|
+
BEGIN
|
|
110
|
+
-- Each function is matched on its own result type and body, so a schema
|
|
111
|
+
-- that merely shares the name keeps everything it has.
|
|
112
|
+
IF EXISTS (
|
|
113
|
+
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
114
|
+
WHERE n.nspname = 'auth' AND p.proname = 'uid'
|
|
115
|
+
AND pg_get_function_result(p.oid) = 'text'
|
|
116
|
+
AND p.prosrc LIKE '%app.uid%'
|
|
117
|
+
) THEN
|
|
118
|
+
DROP FUNCTION auth.uid();
|
|
119
|
+
dropped_any := true;
|
|
120
|
+
END IF;
|
|
121
|
+
|
|
122
|
+
IF EXISTS (
|
|
123
|
+
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
124
|
+
WHERE n.nspname = 'auth' AND p.proname = 'jwt'
|
|
125
|
+
AND pg_get_function_result(p.oid) = 'jsonb'
|
|
126
|
+
AND p.prosrc LIKE '%app.jwt%'
|
|
127
|
+
) THEN
|
|
128
|
+
DROP FUNCTION auth.jwt();
|
|
129
|
+
dropped_any := true;
|
|
130
|
+
END IF;
|
|
131
|
+
|
|
132
|
+
IF EXISTS (
|
|
133
|
+
SELECT 1 FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
134
|
+
WHERE n.nspname = 'auth' AND p.proname = 'roles'
|
|
135
|
+
AND pg_get_function_result(p.oid) = 'text'
|
|
136
|
+
AND p.prosrc LIKE '%app.user_roles%'
|
|
137
|
+
) THEN
|
|
138
|
+
DROP FUNCTION auth.roles();
|
|
139
|
+
dropped_any := true;
|
|
140
|
+
END IF;
|
|
141
|
+
|
|
142
|
+
-- RESTRICT: only an empty schema goes. Anything else in there — including a
|
|
143
|
+
-- Supabase installation left untouched above — keeps it.
|
|
144
|
+
IF dropped_any THEN
|
|
145
|
+
BEGIN
|
|
146
|
+
EXECUTE 'DROP SCHEMA auth RESTRICT';
|
|
147
|
+
EXCEPTION WHEN OTHERS THEN
|
|
148
|
+
NULL;
|
|
149
|
+
END;
|
|
150
|
+
END IF;
|
|
151
|
+
END
|
|
152
|
+
$rebase_drop_legacy$;
|
|
153
|
+
`;
|
|
154
|
+
|
|
155
|
+
/** Somebody's policy that still calls a pre-1.0 helper. */
|
|
156
|
+
export interface LegacyRlsDependent {
|
|
157
|
+
schema: string;
|
|
158
|
+
table: string;
|
|
159
|
+
policy: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Policies whose body still calls `auth.uid()` / `auth.roles()` / `auth.jwt()`.
|
|
164
|
+
*
|
|
165
|
+
* Postgres will not drop a function a policy depends on, so this is exactly the
|
|
166
|
+
* set standing between a database and losing the legacy schema. Rebase's own
|
|
167
|
+
* policies leave the list on the next push or boot, when they are recompiled —
|
|
168
|
+
* anything still here afterwards is hand-written, will never be recompiled by
|
|
169
|
+
* anybody, and is the reason the drop keeps being skipped. Silence there would
|
|
170
|
+
* leave an operator staring at a schema the release notes said would go.
|
|
171
|
+
*/
|
|
172
|
+
export const LEGACY_RLS_DEPENDENTS_SQL = `
|
|
173
|
+
SELECT n.nspname AS schema, c.relname AS "table", p.polname AS policy
|
|
174
|
+
FROM pg_policy p
|
|
175
|
+
JOIN pg_class c ON c.oid = p.polrelid
|
|
176
|
+
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
177
|
+
WHERE pg_get_expr(p.polqual, p.polrelid) ~* '\\m${LEGACY_RLS_SCHEMA}\\.(uid|jwt|roles)\\s*\\('
|
|
178
|
+
OR pg_get_expr(p.polwithcheck, p.polrelid) ~* '\\m${LEGACY_RLS_SCHEMA}\\.(uid|jwt|roles)\\s*\\('
|
|
179
|
+
ORDER BY 1, 2, 3
|
|
180
|
+
`;
|
|
181
|
+
|
|
182
|
+
/** Somebody's *function* that still calls a pre-1.0 helper from its own body. */
|
|
183
|
+
export interface LegacyRlsFunctionDependent {
|
|
184
|
+
schema: string;
|
|
185
|
+
function: string;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Functions whose body calls `auth.uid()` / `auth.roles()` / `auth.jwt()`.
|
|
190
|
+
*
|
|
191
|
+
* This is the half `DROP FUNCTION ... RESTRICT` cannot see, and the reason it
|
|
192
|
+
* needs its own query. Postgres records a dependency for a *policy* that calls a
|
|
193
|
+
* function, which is why the drop is safe against the policies above — but a
|
|
194
|
+
* `LANGUAGE sql` function whose body is a **string literal** is not parsed when
|
|
195
|
+
* it is created, so nothing is recorded and `RESTRICT` has nothing to refuse on.
|
|
196
|
+
* The drop succeeds and the caller is left pointing at a function that no longer
|
|
197
|
+
* exists, which fails at *query* time rather than at boot.
|
|
198
|
+
*
|
|
199
|
+
* A downstream project building on these helpers is not hypothetical: the Rebase
|
|
200
|
+
* control plane defines `auth.is_org_member(uuid)` and `auth.is_org_admin(uuid)`
|
|
201
|
+
* in this very schema, each calling `auth.uid()` in a string body, and eleven of
|
|
202
|
+
* its row-level-security policies go through them. Every one of those would have
|
|
203
|
+
* started failing the first time a recompile left no policy referencing
|
|
204
|
+
* `auth.uid()` directly — the drop's own precondition.
|
|
205
|
+
*
|
|
206
|
+
* Matching on the body text is the only option available, and it is deliberately
|
|
207
|
+
* broad: a false positive costs a schema that stays one release longer and says
|
|
208
|
+
* why, while a false negative costs somebody their policies.
|
|
209
|
+
*/
|
|
210
|
+
export const LEGACY_RLS_FUNCTION_DEPENDENTS_SQL = `
|
|
211
|
+
SELECT n.nspname AS schema, p.proname AS function
|
|
212
|
+
FROM pg_proc p
|
|
213
|
+
JOIN pg_namespace n ON n.oid = p.pronamespace
|
|
214
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
215
|
+
AND NOT (n.nspname = '${LEGACY_RLS_SCHEMA}' AND p.proname IN ('uid', 'jwt', 'roles'))
|
|
216
|
+
AND p.prosrc ~* '\\m${LEGACY_RLS_SCHEMA}\\.(uid|jwt|roles)\\s*\\('
|
|
217
|
+
ORDER BY 1, 2
|
|
218
|
+
`;
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Retire the pre-1.0 `auth` schema, reporting what is holding it back.
|
|
222
|
+
*
|
|
223
|
+
* The shared implementation behind the CLI's post-push step and the runtime's
|
|
224
|
+
* post-policy step. Both used to just fire {@link DROP_LEGACY_AUTH_SCHEMA_SQL}
|
|
225
|
+
* and swallow whatever came back, which is right for the ordinary case — a
|
|
226
|
+
* table not recompiled *yet* — and wrong for the one that never resolves: a
|
|
227
|
+
* hand-written policy nothing will ever rewrite. Then the schema stays forever
|
|
228
|
+
* and nothing ever says why.
|
|
229
|
+
*/
|
|
230
|
+
export async function dropLegacyAuthSchema(
|
|
231
|
+
run: (sql: string) => Promise<Record<string, unknown>[]>,
|
|
232
|
+
report: { info: (m: string) => void; warn: (m: string) => void }
|
|
233
|
+
): Promise<void> {
|
|
234
|
+
let blockers: LegacyRlsDependent[];
|
|
235
|
+
try {
|
|
236
|
+
blockers = (await run(LEGACY_RLS_DEPENDENTS_SQL)) as unknown as LegacyRlsDependent[];
|
|
237
|
+
} catch {
|
|
238
|
+
return; // No catalogue access; nothing here is worth failing a boot for.
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (blockers.length > 0) {
|
|
242
|
+
report.warn(
|
|
243
|
+
`The pre-1.0 \`${LEGACY_RLS_SCHEMA}\` schema cannot be removed yet: ${blockers.length} ` +
|
|
244
|
+
`${blockers.length === 1 ? "policy still calls" : "policies still call"} ` +
|
|
245
|
+
`\`${LEGACY_RLS_SCHEMA}.uid()\` and friends, and Postgres will not drop a function a policy ` +
|
|
246
|
+
`depends on. Rebase's own policies are ` +
|
|
247
|
+
`rewritten automatically — anything listed here is hand-written SQL that has to be updated to ` +
|
|
248
|
+
`\`${REBASE_SCHEMA}.uid()\` by hand, after which the schema goes on its own:\n` +
|
|
249
|
+
blockers.map(b => ` • ${b.schema}.${b.table} → "${b.policy}"`).join("\n")
|
|
250
|
+
);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// The half Postgres will not refuse on. See LEGACY_RLS_FUNCTION_DEPENDENTS_SQL:
|
|
255
|
+
// a string-literal SQL body records no dependency, so `DROP FUNCTION` drops
|
|
256
|
+
// out from under a caller that is still using it and nothing complains until
|
|
257
|
+
// a query runs.
|
|
258
|
+
let borrowers: LegacyRlsFunctionDependent[];
|
|
259
|
+
try {
|
|
260
|
+
borrowers = (await run(LEGACY_RLS_FUNCTION_DEPENDENTS_SQL)) as unknown as LegacyRlsFunctionDependent[];
|
|
261
|
+
} catch {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (borrowers.length > 0) {
|
|
266
|
+
report.warn(
|
|
267
|
+
`The pre-1.0 \`${LEGACY_RLS_SCHEMA}\` schema cannot be removed yet: ${borrowers.length} ` +
|
|
268
|
+
`${borrowers.length === 1 ? "function calls" : "functions call"} ` +
|
|
269
|
+
`\`${LEGACY_RLS_SCHEMA}.uid()\` and friends from its own body. Postgres records no dependency ` +
|
|
270
|
+
`for that — a SQL body written as a string literal is never parsed — so dropping the helpers ` +
|
|
271
|
+
`would leave these pointing at nothing, and they would fail when a query reached them rather ` +
|
|
272
|
+
`than now. Repoint them at \`${REBASE_SCHEMA}.uid()\`, after which the schema goes on its own:\n` +
|
|
273
|
+
borrowers.map(b => ` • ${b.schema}.${b.function}()`).join("\n")
|
|
274
|
+
);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
await run(DROP_LEGACY_AUTH_SCHEMA_SQL);
|
|
280
|
+
} catch (err) {
|
|
281
|
+
// Something else depends on it, or the connection does not own it.
|
|
282
|
+
// Not fatal: the schema is inert either way now that no policy calls it.
|
|
283
|
+
report.info(
|
|
284
|
+
`Left the \`${LEGACY_RLS_SCHEMA}\` schema in place: ` +
|
|
285
|
+
(err instanceof Error ? err.message : String(err))
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
}
|