@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/PostgresBackendDriver.d.ts +48 -1
- package/dist/PostgresBootstrapper.d.ts +26 -0
- package/dist/auth/services.d.ts +21 -0
- package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
- package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
- package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
- package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
- package/dist/cli-helpers.d.ts +56 -0
- package/dist/cli-output.d.ts +34 -0
- 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-8vuu-n4r.js +124 -0
- package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
- package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
- package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
- package/dist/index.es.js +1720 -946
- package/dist/index.es.js.map +1 -1
- package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
- package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
- package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
- package/dist/rls-enforcement-BJ_3wxwg.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 +41 -25
- package/dist/schema/ensure-collection-policies.d.ts +33 -9
- package/dist/schema/ensure-collection-tables.d.ts +60 -6
- package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
- package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
- package/dist/schema/introspect-db-inference.d.ts +8 -1
- package/dist/schema/introspect-db-logic.d.ts +49 -0
- package/dist/schema/introspect-db-project.d.ts +21 -0
- package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
- package/dist/schema/search-column.d.ts +248 -0
- package/dist/security/policy-drift.d.ts +34 -0
- package/dist/security/rls-enforcement.d.ts +61 -5
- package/dist/services/FetchService.d.ts +24 -0
- package/dist/services/PersistService.d.ts +21 -17
- package/dist/services/RelationService.d.ts +9 -57
- package/dist/services/RelationWriteService.d.ts +82 -0
- package/dist/services/collection-helpers.d.ts +42 -0
- package/dist/services/dataService.d.ts +3 -0
- package/dist/services/junction-writes.d.ts +82 -0
- package/dist/services/realtimeService.d.ts +139 -2
- package/dist/services/write-denial.d.ts +36 -0
- package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
- package/dist/src-DCdn3Val.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +124 -2
- package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
- package/dist/websocket-C8ZqVBiV.js.map +1 -0
- package/package.json +8 -7
- package/src/PostgresBackendDriver.ts +172 -6
- package/src/PostgresBootstrapper.ts +136 -11
- package/src/auth/ensure-tables.ts +212 -91
- package/src/auth/services.ts +82 -5
- package/src/backup/backup-cli.ts +59 -57
- package/src/cli-errors.ts +6 -6
- package/src/cli-helpers.ts +124 -11
- package/src/cli-output.ts +43 -0
- package/src/cli.ts +299 -168
- package/src/collections/buildRegistry.ts +3 -1
- package/src/data-transformer.ts +129 -25
- package/src/history/ensure-history-table.ts +9 -2
- package/src/schema/auth-schema.ts +17 -1
- package/src/schema/auth-users-columns.ts +131 -0
- package/src/schema/doctor-cli.ts +14 -65
- package/src/schema/doctor-policy-checks.ts +105 -0
- package/src/schema/doctor.ts +149 -72
- package/src/schema/ensure-collection-policies.ts +99 -6
- package/src/schema/ensure-collection-tables.ts +366 -30
- package/src/schema/generate-drizzle-schema-logic.ts +146 -66
- package/src/schema/generate-drizzle-schema.ts +11 -10
- package/src/schema/generate-postgres-ddl-logic.ts +277 -10
- package/src/schema/generate-postgres-ddl.ts +38 -14
- package/src/schema/generated-schema-staleness.ts +14 -7
- package/src/schema/introspect-db-inference.ts +9 -2
- package/src/schema/introspect-db-logic.ts +251 -75
- package/src/schema/introspect-db-project.ts +78 -0
- package/src/schema/introspect-db.ts +42 -25
- package/src/schema/introspect-runtime.ts +14 -2
- 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/policy-drift.test.ts +104 -3
- package/src/security/policy-drift.ts +129 -7
- package/src/security/rls-enforcement.ts +150 -7
- package/src/services/BranchService.ts +5 -0
- package/src/services/FetchService.ts +243 -22
- package/src/services/PersistService.ts +68 -42
- package/src/services/RelationService.ts +37 -696
- package/src/services/RelationWriteService.ts +653 -0
- package/src/services/cdc/trigger-cdc.ts +5 -1
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +89 -4
- package/src/services/dataService.ts +3 -0
- package/src/services/junction-writes.ts +295 -0
- package/src/services/pg-notify-listener.ts +1 -1
- package/src/services/realtimeService.ts +347 -86
- package/src/services/write-denial.ts +55 -0
- package/src/utils/drizzle-conditions.ts +433 -35
- package/src/utils/pg-error-utils.ts +8 -3
- package/src/websocket.ts +113 -16
- package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
- package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
- package/dist/ensure-collection-tables-BLIIACla.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-CU6WZGYV.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
|
@@ -14,10 +14,11 @@ import {
|
|
|
14
14
|
mergeIndexContent,
|
|
15
15
|
safeHostFromUrl
|
|
16
16
|
} from "./introspect-db-logic";
|
|
17
|
+
import { detectCollectionBuilder } from "./introspect-db-project";
|
|
17
18
|
import { countRowsUpTo, readSchemaMetadata } from "./introspect-db-queries";
|
|
18
19
|
import { classifyTables, lookupCandidates, LOOKUP_MAX_ROWS } from "./introspect-db-structure";
|
|
19
20
|
import { parseCheckConstraints } from "./introspect-db-constraints";
|
|
20
|
-
import {
|
|
21
|
+
import { out, outWarn, outError } from "../cli-output";
|
|
21
22
|
|
|
22
23
|
async function main() {
|
|
23
24
|
const args = arg(
|
|
@@ -59,14 +60,14 @@ async function main() {
|
|
|
59
60
|
|
|
60
61
|
for (const p of envPaths) {
|
|
61
62
|
if (fs.existsSync(p)) {
|
|
62
|
-
dotenv.config({ path: p });
|
|
63
|
+
dotenv.config({ path: p, quiet: true });
|
|
63
64
|
break;
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
|
67
68
|
const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
|
|
68
69
|
if (!databaseUrl) {
|
|
69
|
-
|
|
70
|
+
outError(chalk.red("✗ DATABASE_URL is not set. Make sure your .env file is configured."));
|
|
70
71
|
process.exit(1);
|
|
71
72
|
}
|
|
72
73
|
|
|
@@ -75,15 +76,15 @@ async function main() {
|
|
|
75
76
|
try {
|
|
76
77
|
await client.connect();
|
|
77
78
|
} catch (err) {
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
outError(chalk.red(`✗ Failed to connect to database: ${err instanceof Error ? err.message : String(err)}`));
|
|
80
|
+
outError(chalk.gray(" Check your DATABASE_URL and ensure the database is reachable."));
|
|
80
81
|
process.exit(1);
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
// Log the host portion safely — handle URLs without "@"
|
|
84
85
|
const hostPart = safeHostFromUrl(databaseUrl);
|
|
85
|
-
|
|
86
|
-
|
|
86
|
+
out(chalk.gray(`Connected to database: ${hostPart}`));
|
|
87
|
+
out(chalk.gray(`Introspecting schema '${pgSchema}'...`));
|
|
87
88
|
|
|
88
89
|
try {
|
|
89
90
|
const metadata = await readSchemaMetadata(client, pgSchema);
|
|
@@ -101,12 +102,28 @@ async function main() {
|
|
|
101
102
|
} catch (err) {
|
|
102
103
|
// A table this run cannot read is simply not classified as a code
|
|
103
104
|
// list; everything else about it still generates.
|
|
104
|
-
|
|
105
|
+
out(chalk.gray(` (skipped row count for ${table}: ${err instanceof Error ? err.message : String(err)})`));
|
|
105
106
|
}
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
const classifications = classifyTables(metadata, tablesMap);
|
|
109
110
|
const checkFacts = parseCheckConstraints(metadata.checks);
|
|
111
|
+
|
|
112
|
+
// Which builder the generated files may import — read from the manifests
|
|
113
|
+
// above `outDir`, because a project without `@rebasepro/admin-types` cannot
|
|
114
|
+
// resolve that import and has no `admin` block to write into either.
|
|
115
|
+
const builder = detectCollectionBuilder(outDir);
|
|
116
|
+
if (builder === "annotation") {
|
|
117
|
+
outWarn(chalk.yellow(
|
|
118
|
+
"⚠ Neither @rebasepro/admin-types nor @rebasepro/common is declared above " +
|
|
119
|
+
`${outDir}.`
|
|
120
|
+
));
|
|
121
|
+
outWarn(chalk.gray(
|
|
122
|
+
" Generating plain type annotations, which widen the property keys to `string`: " +
|
|
123
|
+
"`propertiesOrder` and friends will not be checked. Add @rebasepro/common to the " +
|
|
124
|
+
"config package to turn that checking on."
|
|
125
|
+
));
|
|
126
|
+
}
|
|
110
127
|
const joinTables = new Set(
|
|
111
128
|
Array.from(classifications.values())
|
|
112
129
|
.filter((c) => c.role === "junction")
|
|
@@ -116,8 +133,8 @@ async function main() {
|
|
|
116
133
|
const roleCount = (role: string) =>
|
|
117
134
|
Array.from(classifications.values()).filter((c) => c.role === role).length;
|
|
118
135
|
|
|
119
|
-
|
|
120
|
-
|
|
136
|
+
out(chalk.blue(`Found ${tablesMap.size} tables.`));
|
|
137
|
+
out(chalk.gray(
|
|
121
138
|
` ${roleCount("entity")} entities, ${joinTables.size} join tables (folded into relations), ` +
|
|
122
139
|
`${roleCount("lookup")} code lists, ${roleCount("owned-child")} owned by another table (hidden from navigation).`
|
|
123
140
|
));
|
|
@@ -130,7 +147,7 @@ async function main() {
|
|
|
130
147
|
} else if (!process.stdin.isTTY) {
|
|
131
148
|
// No terminal to answer the question below (scaffolding scripts, CI,
|
|
132
149
|
// `rebase init --introspect`) — asking would hang forever.
|
|
133
|
-
|
|
150
|
+
out(chalk.gray("Skipping data inference (non-interactive run; pass --data-inference to enable)."));
|
|
134
151
|
} else {
|
|
135
152
|
const rl = readline.createInterface({
|
|
136
153
|
input: process.stdin,
|
|
@@ -142,7 +159,7 @@ async function main() {
|
|
|
142
159
|
}
|
|
143
160
|
|
|
144
161
|
if (runDataInference) {
|
|
145
|
-
|
|
162
|
+
out(chalk.gray("Sampling database rows for data inference..."));
|
|
146
163
|
}
|
|
147
164
|
|
|
148
165
|
// Generate Collections
|
|
@@ -169,7 +186,7 @@ async function main() {
|
|
|
169
186
|
const { rows } = await client.query(`SELECT * FROM "${pgSchema}"."${tableName}" LIMIT 100`);
|
|
170
187
|
sampleData = rows;
|
|
171
188
|
} catch (err) {
|
|
172
|
-
|
|
189
|
+
outError(chalk.yellow(`⚠ Failed to sample data for table ${tableName}: ${err instanceof Error ? err.message : String(err)}`));
|
|
173
190
|
}
|
|
174
191
|
}
|
|
175
192
|
|
|
@@ -181,12 +198,12 @@ async function main() {
|
|
|
181
198
|
tablesMap,
|
|
182
199
|
enumMap,
|
|
183
200
|
sampleData,
|
|
184
|
-
{ metadata, classifications, checkFacts }
|
|
201
|
+
{ metadata, classifications, checkFacts, builder }
|
|
185
202
|
);
|
|
186
203
|
|
|
187
204
|
fs.writeFileSync(filePath, fileContent, "utf-8");
|
|
188
205
|
generatedFiles.push(tableName);
|
|
189
|
-
|
|
206
|
+
out(chalk.green(` ✓ ${filePath}`));
|
|
190
207
|
}));
|
|
191
208
|
}
|
|
192
209
|
|
|
@@ -213,21 +230,21 @@ async function main() {
|
|
|
213
230
|
const indexContent = generateIndexContent(allFiles);
|
|
214
231
|
fs.writeFileSync(indexPath, indexContent, "utf-8");
|
|
215
232
|
}
|
|
216
|
-
|
|
233
|
+
out(chalk.green(` ✓ ${indexPath}`));
|
|
217
234
|
}
|
|
218
235
|
|
|
219
|
-
|
|
236
|
+
out("");
|
|
220
237
|
if (skippedFiles.length > 0) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
238
|
+
out(chalk.yellow(`⚠ Skipped ${skippedFiles.length} existing file(s): ${skippedFiles.join(", ")}`));
|
|
239
|
+
out(chalk.gray(" Use --force to overwrite existing files."));
|
|
240
|
+
out("");
|
|
224
241
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
242
|
+
out(chalk.bold.green(`✓ Introspected ${tablesMap.size} tables — generated ${generatedFiles.length} collection(s).`));
|
|
243
|
+
out(chalk.gray(` Review the generated files in ${outDir} and customize properties as needed.`));
|
|
244
|
+
out("");
|
|
228
245
|
|
|
229
246
|
} catch (e) {
|
|
230
|
-
|
|
247
|
+
outError(chalk.red(`✗ Error introspecting database: ${e instanceof Error ? e.message : String(e)}`));
|
|
231
248
|
process.exit(1);
|
|
232
249
|
} finally {
|
|
233
250
|
await client.end();
|
|
@@ -235,6 +252,6 @@ async function main() {
|
|
|
235
252
|
}
|
|
236
253
|
|
|
237
254
|
main().catch((err) => {
|
|
238
|
-
|
|
255
|
+
outError(String(err));
|
|
239
256
|
process.exit(1);
|
|
240
257
|
});
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
getIconForTable
|
|
29
29
|
} from "./introspect-db-logic";
|
|
30
30
|
import { humanize } from "./introspect-db-naming";
|
|
31
|
+
import { firstFreeKey, toWireKey } from "@rebasepro/utils";
|
|
31
32
|
|
|
32
33
|
export interface IntrospectedSchema {
|
|
33
34
|
tablesMap: Map<string, TableMeta>;
|
|
@@ -169,6 +170,7 @@ function buildProperties(
|
|
|
169
170
|
enumMap: Map<string, string[]>
|
|
170
171
|
): Record<string, Record<string, unknown>> {
|
|
171
172
|
const properties: Record<string, Record<string, unknown>> = {};
|
|
173
|
+
const takenKeys = new Set<string>();
|
|
172
174
|
|
|
173
175
|
for (const col of meta.columns) {
|
|
174
176
|
const isPk = meta.pks.includes(col.column_name);
|
|
@@ -188,7 +190,17 @@ function buildProperties(
|
|
|
188
190
|
type: propType
|
|
189
191
|
};
|
|
190
192
|
|
|
191
|
-
|
|
193
|
+
// The wire name, with `columnName` above carrying the column. The two
|
|
194
|
+
// are different names for different things — this used to serve the
|
|
195
|
+
// column, so a runtime-introspected collection answered `user_id` while
|
|
196
|
+
// every authored one beside it answered `displayName`.
|
|
197
|
+
//
|
|
198
|
+
// `user_id` and `userId` as two real columns camel-case to one key, so
|
|
199
|
+
// the first free candidate wins and the second falls back to its own
|
|
200
|
+
// column name, then to a numbered tail. Never dropped, and stable for a
|
|
201
|
+
// given database: columns arrive in ordinal order.
|
|
202
|
+
const key = firstFreeKey([toWireKey(col.column_name), col.column_name], takenKeys);
|
|
203
|
+
takenKeys.add(key);
|
|
192
204
|
|
|
193
205
|
if (isPk) {
|
|
194
206
|
property.isId = idKindFor(col, propType);
|
|
@@ -239,7 +251,7 @@ function buildRelations(
|
|
|
239
251
|
if (!targetSlug) continue;
|
|
240
252
|
|
|
241
253
|
// Strip the conventional _id suffix: author_id -> author
|
|
242
|
-
let key = fk.column_name.replace(/_id$/, "");
|
|
254
|
+
let key = toWireKey(fk.column_name.replace(/_id$/, ""));
|
|
243
255
|
if (meta.pks.includes(fk.column_name) && key === fk.column_name) {
|
|
244
256
|
// The FK is also the PK and its name doesn't imply a relation (e.g.
|
|
245
257
|
// "id"), so naming the relation after the column would collide with
|
|
@@ -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
|
+
}
|