@rebasepro/server-postgres 0.16.0 → 0.16.1-canary.g0d7af95
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/PostgresAdapter.d.ts +1 -1
- package/dist/PostgresBackendDriver.d.ts +75 -12
- package/dist/PostgresBootstrapper.d.ts +6 -6
- package/dist/auth/services.d.ts +1 -1
- package/dist/backup/backup-cron.d.ts +1 -1
- package/dist/backup/backup-service.d.ts +2 -2
- package/dist/backup/index.d.ts +4 -4
- package/dist/{backup-service-BZoixhVl.js → backup-service-BtgHxfFm.js} +5 -4
- package/dist/{backup-service-BZoixhVl.js.map → backup-service-BtgHxfFm.js.map} +1 -1
- package/dist/cli-helpers.d.ts +41 -0
- package/dist/{auth-users-columns-CgyPWQ18.js → collection-index-DxJBvVTH.js} +486 -503
- package/dist/collection-index-DxJBvVTH.js.map +1 -0
- package/dist/collections/PostgresCollectionRegistry.d.ts +1 -1
- package/dist/collections/buildRegistry.d.ts +1 -1
- package/dist/collections/validate-relations.d.ts +1 -1
- package/dist/{connection-BuZ97wsr.js → connection-GOKU3Hu5.js} +34 -7
- package/dist/connection-GOKU3Hu5.js.map +1 -0
- package/dist/connection.d.ts +16 -0
- package/dist/data-transformer.d.ts +1 -1
- package/dist/{ensure-collection-policies-BVFb2olB.js → ensure-collection-policies-DFpOl8SM.js} +4 -4
- package/dist/{ensure-collection-policies-BVFb2olB.js.map → ensure-collection-policies-DFpOl8SM.js.map} +1 -1
- package/dist/ensure-collection-tables-DMjOkeRy.js +1952 -0
- package/dist/ensure-collection-tables-DMjOkeRy.js.map +1 -0
- package/dist/index.d.ts +16 -16
- package/dist/index.es.js +19 -7128
- package/dist/index.es.js.map +1 -1
- package/dist/{rls-bootstrap-sql-B5Sajku6.js → rls-bootstrap-sql-DNzaWd4C.js} +3 -3
- package/dist/{rls-bootstrap-sql-B5Sajku6.js.map → rls-bootstrap-sql-DNzaWd4C.js.map} +1 -1
- package/dist/{rls-enforcement-Ch0T6OwW.js → rls-enforcement-CInuYj1-.js} +14 -4
- package/dist/rls-enforcement-CInuYj1-.js.map +1 -0
- package/dist/schema/classify-change.d.ts +82 -0
- package/dist/schema/collection-index.d.ts +182 -0
- package/dist/schema/dynamic-tables.d.ts +1 -1
- package/dist/schema/ensure-collection-policies.d.ts +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +93 -2
- package/dist/schema/generate-schema-commit.d.ts +136 -0
- package/dist/schema/generated-schema-staleness.d.ts +19 -0
- package/dist/schema/introspect-db-constraints.d.ts +1 -1
- package/dist/schema/introspect-db-logic.d.ts +3 -3
- package/dist/schema/introspect-db-project.d.ts +1 -1
- package/dist/schema/introspect-db-queries.d.ts +1 -1
- package/dist/schema/introspect-db-structure.d.ts +2 -2
- package/dist/schema/introspect-runtime.d.ts +1 -1
- package/dist/schema/vector-index.d.ts +88 -0
- package/dist/services/BranchService.d.ts +2 -2
- package/dist/services/FetchService.d.ts +4 -4
- package/dist/services/PersistService.d.ts +5 -5
- package/dist/services/RelationService.d.ts +3 -3
- package/dist/services/RelationWriteService.d.ts +3 -3
- package/dist/services/cdc/junction-tables.d.ts +1 -1
- package/dist/services/cdc/trigger-cdc.d.ts +1 -1
- package/dist/services/channel-bus/PostgresChannelBus.d.ts +1 -1
- package/dist/services/channel-bus/index.d.ts +2 -2
- package/dist/services/collection-helpers.d.ts +1 -1
- package/dist/services/dataService.d.ts +10 -10
- package/dist/services/index.d.ts +4 -4
- package/dist/services/junction-writes.d.ts +2 -2
- package/dist/services/nested-path.d.ts +1 -1
- package/dist/services/realtimeService.d.ts +3 -3
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/services/write-denial.d.ts +1 -1
- package/dist/{src-BBFsDaeA.js → src-DiDgtX8P.js} +97 -1
- package/dist/src-DiDgtX8P.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +2 -2
- package/dist/websocket-CUnpSe8v.js +8188 -0
- package/dist/websocket-CUnpSe8v.js.map +1 -0
- package/dist/websocket.d.ts +29 -2
- package/package.json +7 -7
- package/src/PostgresBackendDriver.ts +190 -59
- package/src/backup/backup-service.ts +1 -1
- package/src/cli-helpers.ts +117 -2
- package/src/cli.ts +22 -0
- package/src/connection.ts +37 -3
- package/src/databasePoolManager.ts +5 -2
- package/src/schema/classify-change.ts +436 -0
- package/src/schema/collection-index.ts +427 -0
- package/src/schema/ensure-collection-tables.test.ts +168 -1
- package/src/schema/ensure-collection-tables.ts +365 -14
- package/src/schema/generate-drizzle-schema-logic.ts +23 -11
- package/src/schema/generate-drizzle-schema.ts +13 -2
- package/src/schema/generate-postgres-ddl-logic.ts +33 -6
- package/src/schema/generate-postgres-ddl.ts +13 -2
- package/src/schema/generate-schema-commit.ts +242 -0
- package/src/schema/generated-schema-staleness.ts +114 -1
- package/src/schema/vector-index.ts +278 -0
- package/src/services/collection-helpers.ts +3 -2
- package/src/websocket.ts +47 -3
- package/dist/auth-users-columns-CgyPWQ18.js.map +0 -1
- package/dist/connection-BuZ97wsr.js.map +0 -1
- package/dist/data_driver-ULAyJEi9.js +0 -193
- package/dist/data_driver-ULAyJEi9.js.map +0 -1
- package/dist/ensure-collection-tables-BY1pHRD_.js +0 -840
- package/dist/ensure-collection-tables-BY1pHRD_.js.map +0 -1
- package/dist/rls-enforcement-Ch0T6OwW.js.map +0 -1
- package/dist/src-BBFsDaeA.js.map +0 -1
- package/dist/utils/table-classification.d.ts +0 -8
- package/dist/websocket-BVgDVO-V.js +0 -614
- package/dist/websocket-BVgDVO-V.js.map +0 -1
- package/src/utils/table-classification.ts +0 -16
package/dist/websocket.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { RealtimeService } from "./services/realtimeService";
|
|
2
|
-
import { PostgresBackendDriver } from "./PostgresBackendDriver";
|
|
1
|
+
import { RealtimeService } from "./services/realtimeService.js";
|
|
2
|
+
import { PostgresBackendDriver } from "./PostgresBackendDriver.js";
|
|
3
3
|
import type { AuthAdapter } from "@rebasepro/types";
|
|
4
4
|
import { Server } from "http";
|
|
5
5
|
/** Minimal subset of RebaseAuthConfig used by the WebSocket layer. */
|
|
@@ -14,5 +14,32 @@ interface WsAuthConfig {
|
|
|
14
14
|
*/
|
|
15
15
|
serviceKey?: string;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* WebSocket message types that require an admin session.
|
|
19
|
+
*
|
|
20
|
+
* Exported so the test can READ it. It used to be private, and the test that
|
|
21
|
+
* exists to make "a privileged verb added without a role check" impossible held
|
|
22
|
+
* a hand-typed copy of the same nine strings — so it agreed with itself, and
|
|
23
|
+
* `FETCH_APPLICATION_ROLES` was added to neither. That verb runs
|
|
24
|
+
* `SELECT DISTINCT unnest(roles)` over the users table through `executeSql`,
|
|
25
|
+
* which is the owner connection and not subject to RLS, so any authenticated
|
|
26
|
+
* non-admin could enumerate every role in the project — and any anonymous
|
|
27
|
+
* socket could, on a deployment with `requireAuth: false`.
|
|
28
|
+
*
|
|
29
|
+
* The list is no longer the whole guarantee. `PUBLIC_TYPES` below is its
|
|
30
|
+
* counterpart, and a test asserts that every `case` this file handles appears
|
|
31
|
+
* in exactly one of the two — so a verb added to neither fails rather than
|
|
32
|
+
* defaulting to reachable.
|
|
33
|
+
*/
|
|
34
|
+
export declare const ADMIN_ONLY_TYPES: Set<string>;
|
|
35
|
+
/**
|
|
36
|
+
* Message types deliberately reachable by a non-admin session.
|
|
37
|
+
*
|
|
38
|
+
* Data operations, gated per row by RLS and per request by the same write
|
|
39
|
+
* checks the REST layer applies — not by this list. It exists so that "which
|
|
40
|
+
* bucket is this verb in" is a question with an answer for every verb, and
|
|
41
|
+
* adding one without answering it is a test failure.
|
|
42
|
+
*/
|
|
43
|
+
export declare const PUBLIC_TYPES: Set<string>;
|
|
17
44
|
export declare function createPostgresWebSocket(server: Server, realtimeService: RealtimeService, driver: PostgresBackendDriver, authConfig?: WsAuthConfig, authAdapter?: AuthAdapter): void;
|
|
18
45
|
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rebasepro/server-postgres",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.16.
|
|
4
|
+
"version": "0.16.1-canary.g0d7af95",
|
|
5
5
|
"description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
|
|
6
6
|
"funding": {
|
|
7
7
|
"url": "https://github.com/sponsors/rebaseco"
|
|
@@ -47,11 +47,11 @@
|
|
|
47
47
|
"execa": "^9.6.1",
|
|
48
48
|
"pg": "^8.22.0",
|
|
49
49
|
"ws": "^8.21.1",
|
|
50
|
-
"@rebasepro/
|
|
51
|
-
"@rebasepro/server": "0.16.
|
|
52
|
-
"@rebasepro/types": "0.16.
|
|
53
|
-
"@rebasepro/
|
|
54
|
-
"@rebasepro/utils": "0.16.
|
|
50
|
+
"@rebasepro/codegen": "0.16.1-canary.g0d7af95",
|
|
51
|
+
"@rebasepro/server": "0.16.1-canary.g0d7af95",
|
|
52
|
+
"@rebasepro/types": "0.16.1-canary.g0d7af95",
|
|
53
|
+
"@rebasepro/common": "0.16.1-canary.g0d7af95",
|
|
54
|
+
"@rebasepro/utils": "0.16.1-canary.g0d7af95"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@hono/node-server": "^2.0.12",
|
|
@@ -77,7 +77,7 @@
|
|
|
77
77
|
],
|
|
78
78
|
"scripts": {
|
|
79
79
|
"watch": "vite build --watch",
|
|
80
|
-
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../scripts/assert-build-output.mjs",
|
|
80
|
+
"build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../tooling/scripts/add-dts-extensions.mjs dist && node ../../tooling/scripts/assert-build-output.mjs",
|
|
81
81
|
"test:lint": "eslint \"src/**\" --quiet",
|
|
82
82
|
"test": "jest",
|
|
83
83
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
User
|
|
32
32
|
} from "@rebasepro/types";
|
|
33
33
|
import { sql as drizzleSql } from "drizzle-orm";
|
|
34
|
-
import { buildPropertyCallbacks, buildSdkData, resolveCollectionRelations, updateDateAutoValues } from "@rebasepro/common";
|
|
34
|
+
import { buildPropertyCallbacks, buildSdkData, classifyTable, detectJunctionTables, resolveCollectionRelations, updateDateAutoValues } from "@rebasepro/common";
|
|
35
35
|
import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
|
|
36
36
|
import { deriveRowAddress } from "./services/collection-helpers";
|
|
37
37
|
import { HistoryService } from "./history/HistoryService";
|
|
@@ -39,7 +39,69 @@ import { mergeDeep } from "@rebasepro/utils";
|
|
|
39
39
|
import { logger } from "@rebasepro/server";
|
|
40
40
|
import { isRoleSwitchingPermissionError } from "./utils/pg-error-utils";
|
|
41
41
|
import { applyAuthContext } from "./security/rls-enforcement";
|
|
42
|
-
import {
|
|
42
|
+
import { generateSchemaCommit } from "./schema/generate-schema-commit";
|
|
43
|
+
import { readSchemaFactsFor, type Queryable } from "./schema/ensure-collection-tables";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Has an operator opted out of database role switching entirely?
|
|
47
|
+
*
|
|
48
|
+
* `DISABLE_DB_ROLE_SWITCHING=true` is documented (README, the configuration
|
|
49
|
+
* page, the backend skill) as "run Studio SQL Editor queries as the connection
|
|
50
|
+
* owner", for deployments whose application roles have no database role behind
|
|
51
|
+
* them. It is the only sanctioned way a statement that named a role runs
|
|
52
|
+
* without it — every other route now refuses.
|
|
53
|
+
*
|
|
54
|
+
* Exact `"true"` on purpose, matching the check this replaced. `=1` and `=yes`
|
|
55
|
+
* silently do nothing, which `docs/audits/80-config-and-env.md` already records
|
|
56
|
+
* as a finding across the env surface; fixing it here alone would make this one
|
|
57
|
+
* variable disagree with the rest.
|
|
58
|
+
*/
|
|
59
|
+
export function isRoleSwitchingOptedOut(): boolean {
|
|
60
|
+
return process.env.DISABLE_DB_ROLE_SWITCHING === "true";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The role a statement will actually have run as, given the role it asked for.
|
|
65
|
+
*
|
|
66
|
+
* For the audit log, which recorded `options.role` — the *requested* role — and
|
|
67
|
+
* so restated the caller's request as though it were the outcome. The two part
|
|
68
|
+
* company exactly when {@link isRoleSwitchingOptedOut} holds, because every
|
|
69
|
+
* other divergence is now an error rather than a quiet substitution.
|
|
70
|
+
*/
|
|
71
|
+
export function effectiveSqlRole(requestedRole?: string): string {
|
|
72
|
+
if (!requestedRole) return CONNECTION_OWNER;
|
|
73
|
+
return isRoleSwitchingOptedOut() ? CONNECTION_OWNER : requestedRole;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** How {@link effectiveSqlRole} names "whatever role the connection holds". */
|
|
77
|
+
export const CONNECTION_OWNER = "<connection owner>";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A statement named a database role the connection cannot assume.
|
|
81
|
+
*
|
|
82
|
+
* Its own type because the tempting recovery — run it anyway, as the owner — is
|
|
83
|
+
* the one thing that must not happen. Callers that catch this should report it,
|
|
84
|
+
* not retry unscoped.
|
|
85
|
+
*/
|
|
86
|
+
export class RoleSwitchUnavailableError extends Error {
|
|
87
|
+
readonly code = "ROLE_SWITCH_UNAVAILABLE";
|
|
88
|
+
readonly role: string;
|
|
89
|
+
/** The underlying Postgres error, when the refusal came from a live attempt. */
|
|
90
|
+
readonly pgError?: unknown;
|
|
91
|
+
|
|
92
|
+
constructor(role: string, pgError?: unknown) {
|
|
93
|
+
super(
|
|
94
|
+
`Cannot execute SQL as role "${role}": this connection is not permitted to SET ROLE. ` +
|
|
95
|
+
`The statement was NOT executed — running it as the connection owner would return ` +
|
|
96
|
+
`owner-visible rows, which is a different question from the one that was asked. ` +
|
|
97
|
+
`Grant the connection user membership in "${role}", or set DISABLE_DB_ROLE_SWITCHING=true ` +
|
|
98
|
+
`to run SQL Editor queries as the connection owner.`
|
|
99
|
+
);
|
|
100
|
+
this.name = "RoleSwitchUnavailableError";
|
|
101
|
+
this.role = role;
|
|
102
|
+
this.pgError = pgError;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
43
105
|
|
|
44
106
|
export class PostgresBackendDriver implements DataDriver {
|
|
45
107
|
key = "postgres";
|
|
@@ -54,12 +116,18 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
54
116
|
public client?: RebaseClient;
|
|
55
117
|
|
|
56
118
|
/**
|
|
57
|
-
* Auto-set to `true`
|
|
58
|
-
* privileges, so
|
|
59
|
-
*
|
|
60
|
-
*
|
|
119
|
+
* Auto-set to `true` once a `SET LOCAL ROLE` has failed with insufficient
|
|
120
|
+
* privileges, so later statements refuse without spending the round trip
|
|
121
|
+
* to be refused again.
|
|
122
|
+
*
|
|
123
|
+
* Deliberately NOT a mirror of `DISABLE_DB_ROLE_SWITCHING`, which it used
|
|
124
|
+
* to be described as. The env var is an operator saying "run these as the
|
|
125
|
+
* connection owner"; this flag is the database saying "I cannot give you
|
|
126
|
+
* the role you asked for". The first is a decision and permits the
|
|
127
|
+
* fallback, the second is a failure and must not — see the SECURITY note
|
|
128
|
+
* in {@link executeSql}.
|
|
61
129
|
*/
|
|
62
|
-
private
|
|
130
|
+
private _roleSwitchingUnavailable = false;
|
|
63
131
|
|
|
64
132
|
/**
|
|
65
133
|
* Restricted role that authenticated (user-context) requests run as (via
|
|
@@ -119,6 +187,26 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
119
187
|
fetchCurrentDatabase: () => this.fetchCurrentDatabase(),
|
|
120
188
|
fetchUnmappedTables: (...args: Parameters<NonNullable<DatabaseAdmin["fetchUnmappedTables"]>>) => this.fetchUnmappedTables(...args),
|
|
121
189
|
fetchTableMetadata: (...args: Parameters<NonNullable<DatabaseAdmin["fetchTableMetadata"]>>) => this.fetchTableMetadata(...args),
|
|
190
|
+
// Planning a schema change is engine-specific — it renders DDL, a
|
|
191
|
+
// Drizzle schema and the declarative SQL artifacts — so it lives
|
|
192
|
+
// here and the server detects it structurally, the same way it
|
|
193
|
+
// detects SQL. Planning only: applying is `executeSql` above, and
|
|
194
|
+
// committing belongs to whatever holds the repository.
|
|
195
|
+
// Planned against what this database actually has, not against an
|
|
196
|
+
// empty one. Whether a NOT NULL can be added comes down to whether
|
|
197
|
+
// the table holds rows, and whether an enum value will land comes
|
|
198
|
+
// down to the values the type already carries — neither is knowable
|
|
199
|
+
// from the collections, and both decide whether the statements this
|
|
200
|
+
// returns are accepted or rejected by the database they name.
|
|
201
|
+
planSchemaChange: async (before, after, options) => generateSchemaCommit({
|
|
202
|
+
before: before as CollectionConfig[],
|
|
203
|
+
after: after as CollectionConfig[],
|
|
204
|
+
paths: options?.paths,
|
|
205
|
+
existing: await readSchemaFactsFor(
|
|
206
|
+
this.schemaFactsQueryable(),
|
|
207
|
+
after as CollectionConfig[]
|
|
208
|
+
)
|
|
209
|
+
}),
|
|
122
210
|
// Branch operations (only available when poolManager is configured)
|
|
123
211
|
...(this.branchService ? {
|
|
124
212
|
createBranch: this.branchService.createBranch.bind(this.branchService),
|
|
@@ -129,6 +217,24 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
129
217
|
};
|
|
130
218
|
}
|
|
131
219
|
|
|
220
|
+
/**
|
|
221
|
+
* The catalogue-reading shim the schema planner wants.
|
|
222
|
+
*
|
|
223
|
+
* Text in, rows out — every statement it issues is a catalogue read keyed by
|
|
224
|
+
* schema name, and schema names are identifiers rather than bindable values,
|
|
225
|
+
* so there is nothing to parameterise. Runs on the driver's own handle, which
|
|
226
|
+
* is the connection whose privileges are already known to work.
|
|
227
|
+
*/
|
|
228
|
+
private schemaFactsQueryable(): Queryable {
|
|
229
|
+
return {
|
|
230
|
+
query: async <T>(text: string): Promise<{ rows: T[] }> => {
|
|
231
|
+
const result = await this.db.execute(drizzleSql.raw(text));
|
|
232
|
+
const rows = (result as unknown as { rows?: T[] }).rows;
|
|
233
|
+
return { rows: rows ?? (Array.isArray(result) ? (result as T[]) : []) };
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
132
238
|
/**
|
|
133
239
|
* REST-optimised fetch service (include-aware eager-loading).
|
|
134
240
|
* Delegates to the underlying FetchService (include-aware eager loading),
|
|
@@ -1218,6 +1324,27 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
1218
1324
|
return this.poolManager.getDrizzle(databaseName);
|
|
1219
1325
|
}
|
|
1220
1326
|
|
|
1327
|
+
/**
|
|
1328
|
+
* Build one statement, binding `$n` placeholders as real parameters.
|
|
1329
|
+
*
|
|
1330
|
+
* Shared by the role-switched path (inside a transaction) and the
|
|
1331
|
+
* unswitched one. They held byte-identical copies of this loop, which is
|
|
1332
|
+
* exactly the shape where a fix lands in one copy and not the other.
|
|
1333
|
+
*/
|
|
1334
|
+
private buildStatement(sqlText: string, params?: unknown[]) {
|
|
1335
|
+
if (!params || params.length === 0) return drizzleSql.raw(sqlText);
|
|
1336
|
+
const parts = sqlText.split(/\$(\d+)/);
|
|
1337
|
+
const chunks: ReturnType<typeof drizzleSql.raw | typeof drizzleSql.param>[] = [];
|
|
1338
|
+
for (let i = 0; i < parts.length; i++) {
|
|
1339
|
+
if (i % 2 === 0) {
|
|
1340
|
+
if (parts[i].length > 0) chunks.push(drizzleSql.raw(parts[i]));
|
|
1341
|
+
} else {
|
|
1342
|
+
chunks.push(drizzleSql.param(params[Number(parts[i]) - 1]));
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
return drizzleSql.join(chunks, drizzleSql.raw(""));
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1221
1348
|
async executeSql(sqlText: string, options?: {
|
|
1222
1349
|
database?: string,
|
|
1223
1350
|
role?: string,
|
|
@@ -1230,77 +1357,81 @@ export class PostgresBackendDriver implements DataDriver {
|
|
|
1230
1357
|
const targetDb = this.getTargetDb(options?.database);
|
|
1231
1358
|
|
|
1232
1359
|
try {
|
|
1233
|
-
//
|
|
1234
|
-
//
|
|
1235
|
-
//
|
|
1236
|
-
//
|
|
1360
|
+
// Does this actually need a role switch?
|
|
1361
|
+
//
|
|
1362
|
+
// Asking for the role the session already runs as is a no-op, not a
|
|
1363
|
+
// downgrade — the statement really does execute as the requested
|
|
1364
|
+
// role — so it stays allowed even where switching is unavailable.
|
|
1365
|
+
// That is the ordinary Studio path: the role picker defaults to
|
|
1366
|
+
// `current_user`.
|
|
1237
1367
|
let needsRoleSwitch = false;
|
|
1238
|
-
if (options?.role
|
|
1368
|
+
if (options?.role) {
|
|
1239
1369
|
try {
|
|
1240
1370
|
const currentRoleResult = await targetDb.execute(drizzleSql.raw("SELECT current_user AS role"));
|
|
1241
1371
|
const currentRole = (currentRoleResult.rows?.[0] as Record<string, unknown>)?.role as string | undefined;
|
|
1242
1372
|
needsRoleSwitch = !!currentRole && currentRole !== options.role;
|
|
1243
1373
|
} catch {
|
|
1244
|
-
//
|
|
1374
|
+
// Current role unknown. Assume a switch is needed rather
|
|
1375
|
+
// than assume the session already is the requested role:
|
|
1376
|
+
// attempting and refusing beats guessing in our own favour.
|
|
1245
1377
|
needsRoleSwitch = true;
|
|
1246
1378
|
}
|
|
1247
1379
|
}
|
|
1248
1380
|
|
|
1249
1381
|
if (needsRoleSwitch && options?.role) {
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1382
|
+
if (isRoleSwitchingOptedOut()) {
|
|
1383
|
+
// The one sanctioned way to run this unswitched, and a
|
|
1384
|
+
// decision somebody made rather than a failure: the env var
|
|
1385
|
+
// is documented (README, docs/getting-started/configuration)
|
|
1386
|
+
// as "run SQL Editor queries as the connection owner", for
|
|
1387
|
+
// deployments whose application roles have no database role
|
|
1388
|
+
// behind them. `effectiveSqlRole` reads the same switch, so
|
|
1389
|
+
// the audit log records the role that actually applied.
|
|
1390
|
+
logger.debug(
|
|
1391
|
+
`[PostgresBackendDriver] DISABLE_DB_ROLE_SWITCHING=true — running as the ` +
|
|
1392
|
+
`connection owner rather than "${options.role}".`
|
|
1393
|
+
);
|
|
1394
|
+
} else if (this._roleSwitchingUnavailable) {
|
|
1395
|
+
// Already learned this connection cannot SET ROLE; refuse
|
|
1396
|
+
// without spending the round trip to be told again.
|
|
1397
|
+
throw new RoleSwitchUnavailableError(options.role);
|
|
1398
|
+
} else {
|
|
1399
|
+
const safeRole = options.role.replace(/"/g, "\"\"");
|
|
1400
|
+
try {
|
|
1401
|
+
return await targetDb.transaction(async (tx) => {
|
|
1402
|
+
await tx.execute(drizzleSql.raw(`SET LOCAL ROLE "${safeRole}"`));
|
|
1403
|
+
const result = await tx.execute(this.buildStatement(sqlText, options?.params));
|
|
1404
|
+
return result.rows as Record<string, unknown>[];
|
|
1405
|
+
});
|
|
1406
|
+
} catch (roleError: unknown) {
|
|
1407
|
+
if (isRoleSwitchingPermissionError(roleError)) {
|
|
1408
|
+
// SECURITY: do NOT fall through and run this as the
|
|
1409
|
+
// owner.
|
|
1410
|
+
//
|
|
1411
|
+
// The caller asked for a *constrained* execution.
|
|
1412
|
+
// Owner rows are not a degraded answer to that
|
|
1413
|
+
// question, they are a confident wrong one: the only
|
|
1414
|
+
// reason to pass a role is to see what the database
|
|
1415
|
+
// looks like under RLS, and owner output makes a
|
|
1416
|
+
// protected table read as exposed. This used to warn
|
|
1417
|
+
// and continue — and latch, so a single failure
|
|
1418
|
+
// silently unscoped every later call in the process.
|
|
1419
|
+
//
|
|
1420
|
+
// `applyAuthContext` (the user request path) and
|
|
1421
|
+
// `scopeDataDriver` both fail closed. This is the
|
|
1422
|
+
// same question, so it gets the same answer.
|
|
1423
|
+
this._roleSwitchingUnavailable = true;
|
|
1424
|
+
throw new RoleSwitchUnavailableError(options.role, roleError);
|
|
1268
1425
|
}
|
|
1269
|
-
return result.rows as Record<string, unknown>[];
|
|
1270
|
-
});
|
|
1271
|
-
} catch (roleError: unknown) {
|
|
1272
|
-
if (isRoleSwitchingPermissionError(roleError)) {
|
|
1273
|
-
logger.warn(
|
|
1274
|
-
`[PostgresBackendDriver] SET LOCAL ROLE "${safeRole}" failed — ` +
|
|
1275
|
-
`the connection user lacks permission. Falling back to executing ` +
|
|
1276
|
-
`without role switching. To suppress this warning, set ` +
|
|
1277
|
-
`DISABLE_DB_ROLE_SWITCHING=true in your .env file.`
|
|
1278
|
-
);
|
|
1279
|
-
this._roleSwitchingDisabled = true;
|
|
1280
|
-
// Fall through to execute without role switching below
|
|
1281
|
-
} else {
|
|
1282
1426
|
throw roleError;
|
|
1283
1427
|
}
|
|
1284
1428
|
}
|
|
1285
1429
|
}
|
|
1286
1430
|
|
|
1287
|
-
|
|
1288
|
-
if (options?.params && options.params.length > 0) {
|
|
1289
|
-
const parts = sqlText.split(/\$(\d+)/);
|
|
1290
|
-
const chunks: ReturnType<typeof drizzleSql.raw | typeof drizzleSql.param>[] = [];
|
|
1291
|
-
for (let i = 0; i < parts.length; i++) {
|
|
1292
|
-
if (i % 2 === 0) {
|
|
1293
|
-
if (parts[i].length > 0) chunks.push(drizzleSql.raw(parts[i]));
|
|
1294
|
-
} else {
|
|
1295
|
-
chunks.push(drizzleSql.param(options.params[Number(parts[i]) - 1]));
|
|
1296
|
-
}
|
|
1297
|
-
}
|
|
1298
|
-
result = await targetDb.execute(drizzleSql.join(chunks, drizzleSql.raw("")));
|
|
1299
|
-
} else {
|
|
1300
|
-
result = await targetDb.execute(drizzleSql.raw(sqlText));
|
|
1301
|
-
}
|
|
1431
|
+
const result = await targetDb.execute(this.buildStatement(sqlText, options?.params));
|
|
1302
1432
|
return result.rows as Record<string, unknown>[];
|
|
1303
1433
|
} catch (error: unknown) {
|
|
1434
|
+
if (error instanceof RoleSwitchUnavailableError) throw error;
|
|
1304
1435
|
const msg = error instanceof Error ? error.message : String(error);
|
|
1305
1436
|
// Provide a user-friendly message for connection/auth errors
|
|
1306
1437
|
if (msg.includes("pg_hba.conf") || msg.includes("no encryption") || msg.includes("connection refused")) {
|
|
@@ -411,7 +411,7 @@ export async function uploadBackup(
|
|
|
411
411
|
bucket: dest.bucket,
|
|
412
412
|
metadata: { "rebase-backup": "1" }
|
|
413
413
|
});
|
|
414
|
-
return { key, storageUrl: result.storageUrl
|
|
414
|
+
return { key, storageUrl: result.storageUrl };
|
|
415
415
|
}
|
|
416
416
|
|
|
417
417
|
/**
|
package/src/cli-helpers.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { createRequire } from "module";
|
|
|
6
6
|
import readline from "readline";
|
|
7
7
|
import { pathToFileURL } from "url";
|
|
8
8
|
import chalk from "chalk";
|
|
9
|
+
import { isRebaseIndexName } from "./schema/collection-index";
|
|
9
10
|
import { out, outWarn } from "./cli-output";
|
|
10
11
|
import type { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
|
|
11
12
|
import { moduleDir as __helpersDirname } from "./module-dir";
|
|
@@ -306,6 +307,119 @@ export async function seedDevDatabaseSearchHelpers(
|
|
|
306
307
|
* catalogs. Separated from {@link getTableExcludes} so its failure mode can
|
|
307
308
|
* be handled explicitly (fail closed) and so tests can inject a stub.
|
|
308
309
|
*/
|
|
310
|
+
/**
|
|
311
|
+
* Every index Postgres holds on a table Rebase manages, as
|
|
312
|
+
* `schema.table.index`.
|
|
313
|
+
*
|
|
314
|
+
* Constraint-backed indexes are left out: a `PRIMARY KEY` or a `UNIQUE` is a
|
|
315
|
+
* *constraint* to Atlas, diffed from the constraint declaration, and naming its
|
|
316
|
+
* index in an exclude would shield the constraint itself.
|
|
317
|
+
*/
|
|
318
|
+
export async function queryExistingIndexes(databaseUrl: string): Promise<string[]> {
|
|
319
|
+
const { Client } = await import("pg");
|
|
320
|
+
const client = new Client({ connectionString: databaseUrl });
|
|
321
|
+
await client.connect();
|
|
322
|
+
try {
|
|
323
|
+
const res = await client.query(`
|
|
324
|
+
SELECT n.nspname || '.' || t.relname || '.' || i.relname AS full_name
|
|
325
|
+
FROM pg_index x
|
|
326
|
+
JOIN pg_class i ON i.oid = x.indexrelid
|
|
327
|
+
JOIN pg_class t ON t.oid = x.indrelid
|
|
328
|
+
JOIN pg_namespace n ON n.oid = t.relnamespace
|
|
329
|
+
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
|
|
330
|
+
AND t.relkind IN ('r', 'p')
|
|
331
|
+
AND NOT EXISTS (
|
|
332
|
+
SELECT 1 FROM pg_constraint c WHERE c.conindid = i.oid
|
|
333
|
+
);
|
|
334
|
+
`);
|
|
335
|
+
return res.rows.map((row: { full_name: string }) => row.full_name);
|
|
336
|
+
} finally {
|
|
337
|
+
await client.end();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Glob patterns keeping Atlas away from indexes that are not Rebase's.
|
|
343
|
+
*
|
|
344
|
+
* The problem this solves is live and predates the `indexes:` block. `db push`
|
|
345
|
+
* is declarative, so an index on a managed table that is absent from
|
|
346
|
+
* `schema.sql` is drift — and Atlas plans `DROP INDEX` for it. Verified
|
|
347
|
+
* against atlas v1.2.3: create one by hand, re-run an unchanged push, and the
|
|
348
|
+
* plan is a bare drop. `DROP INDEX` is not in `DESTRUCTIVE_PATTERNS`, so the
|
|
349
|
+
* auto-approved apply took it without asking. Until now the only way to have
|
|
350
|
+
* an index at all was to write it by hand, which made that the *only* outcome.
|
|
351
|
+
*
|
|
352
|
+
* Ownership rather than a prompt, because once indexes are declarable a drop
|
|
353
|
+
* is usually correct: removing one from the config should remove it from the
|
|
354
|
+
* database, quietly. What must never be dropped is an index Rebase never
|
|
355
|
+
* created — a hand-written one, or one an introspected database arrived with.
|
|
356
|
+
* {@link isRebaseIndexName} is the test, the same arrangement
|
|
357
|
+
* `isGeneratedPolicyName` uses to let policy reconciliation drop only what it
|
|
358
|
+
* generated.
|
|
359
|
+
*
|
|
360
|
+
* The named-in-the-desired-state check comes first and is what makes removal
|
|
361
|
+
* work: a declared index that the author has just deleted still matches the
|
|
362
|
+
* name pattern, is no longer in the plan, and so is *not* excluded — Atlas
|
|
363
|
+
* drops it, as intended.
|
|
364
|
+
*
|
|
365
|
+
* Three-part `schema.table.index`, never two: a two-part pattern reads as a
|
|
366
|
+
* *table* named `<index>` in a *schema* named `<table>`, matches nothing, and
|
|
367
|
+
* reports no error — the trap already recorded for the search excludes.
|
|
368
|
+
*/
|
|
369
|
+
export async function getForeignIndexExcludes(
|
|
370
|
+
databaseUrl: string,
|
|
371
|
+
collectionsPath: string,
|
|
372
|
+
deps: {
|
|
373
|
+
queryExistingIndexes?: (databaseUrl: string) => Promise<string[]>;
|
|
374
|
+
getManagedIndexNames?: (collectionsPath: string) => Promise<Set<string>>;
|
|
375
|
+
} = {}
|
|
376
|
+
): Promise<string[]> {
|
|
377
|
+
const queryIndexes = deps.queryExistingIndexes ?? queryExistingIndexes;
|
|
378
|
+
const getManaged = deps.getManagedIndexNames ?? managedIndexNames;
|
|
379
|
+
|
|
380
|
+
const managed = await getManaged(collectionsPath);
|
|
381
|
+
|
|
382
|
+
let existing: string[];
|
|
383
|
+
try {
|
|
384
|
+
existing = await queryIndexes(databaseUrl);
|
|
385
|
+
} catch (err) {
|
|
386
|
+
// Fails CLOSED, like getTableExcludes: without the catalogue we cannot
|
|
387
|
+
// tell a foreign index from one of ours, and guessing wrong destroys
|
|
388
|
+
// an index somebody is relying on.
|
|
389
|
+
throw new ExcludeIntrospectionError(
|
|
390
|
+
`Failed to introspect the database for unmanaged indexes: ${err instanceof Error ? err.message : String(err)}`,
|
|
391
|
+
err
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
return existing.filter(full => {
|
|
396
|
+
const indexName = full.slice(full.lastIndexOf(".") + 1);
|
|
397
|
+
if (managed.has(indexName)) return false; // ours, and still declared
|
|
398
|
+
return !isRebaseIndexName(indexName); // ours, but no longer declared -> let it drop
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* The index names the current collections would create — search, vector and
|
|
404
|
+
* declared alike. Anything Atlas would emit is by definition not foreign.
|
|
405
|
+
*/
|
|
406
|
+
async function managedIndexNames(collectionsPath: string): Promise<Set<string>> {
|
|
407
|
+
const collections = await loadCollectionsForCli(collectionsPath);
|
|
408
|
+
const { resolveColumnName, searchExcludePatterns } = await import("./schema/generate-postgres-ddl-logic");
|
|
409
|
+
const { buildCollectionIndexPlan } = await import("./schema/collection-index");
|
|
410
|
+
|
|
411
|
+
const names = new Set<string>(
|
|
412
|
+
buildCollectionIndexPlan(collections, resolveColumnName).map(spec => spec.indexName)
|
|
413
|
+
);
|
|
414
|
+
// Search objects are excluded from Atlas wholesale by their own patterns;
|
|
415
|
+
// listing them here too is harmless and keeps this the single answer to
|
|
416
|
+
// "is this index one of ours".
|
|
417
|
+
for (const pattern of searchExcludePatterns(collections)) {
|
|
418
|
+
names.add(pattern.slice(pattern.lastIndexOf(".") + 1));
|
|
419
|
+
}
|
|
420
|
+
return names;
|
|
421
|
+
}
|
|
422
|
+
|
|
309
423
|
export async function queryExistingTables(databaseUrl: string): Promise<string[]> {
|
|
310
424
|
const { Client } = await import("pg");
|
|
311
425
|
const client = new Client({ connectionString: databaseUrl });
|
|
@@ -366,8 +480,9 @@ export async function getTableExcludes(
|
|
|
366
480
|
// own revision table. Excluding both the schema object AND its contents
|
|
367
481
|
// keeps Atlas from planning a `DROP SCHEMA … CASCADE` — which on a live
|
|
368
482
|
// database would take the user/auth tables with it.
|
|
369
|
-
// `atlas_schema_revisions.*`
|
|
370
|
-
//
|
|
483
|
+
// `atlas_schema_revisions.*` covers a revision table living outside the
|
|
484
|
+
// `rebase` schema — an externally managed Atlas setup — which the apply
|
|
485
|
+
// must not plan changes against either.
|
|
371
486
|
//
|
|
372
487
|
// `auth` stays on this list even though Rebase no longer creates it, and
|
|
373
488
|
// that is the point: on a database still holding the pre-1.0 schema it must
|
package/src/cli.ts
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
readSearchDdl,
|
|
19
19
|
seedDevDatabaseSearchHelpers,
|
|
20
20
|
getTableExcludes,
|
|
21
|
+
getForeignIndexExcludes,
|
|
21
22
|
ExcludeIntrospectionError,
|
|
22
23
|
promptConfirm
|
|
23
24
|
} from "./cli-helpers";
|
|
@@ -872,6 +873,27 @@ async function runAtlas(
|
|
|
872
873
|
for (const exc of excludes) {
|
|
873
874
|
atlasArgs.push("--exclude", exc);
|
|
874
875
|
}
|
|
876
|
+
|
|
877
|
+
// And the indexes on those tables that Rebase did not create. Same
|
|
878
|
+
// fail-closed contract as the table list above, for the same reason: a
|
|
879
|
+
// partial answer here silently drops somebody's index.
|
|
880
|
+
let indexExcludes: string[];
|
|
881
|
+
try {
|
|
882
|
+
indexExcludes = await getForeignIndexExcludes(databaseUrl, collectionsPath);
|
|
883
|
+
} catch (err) {
|
|
884
|
+
if (err instanceof ExcludeIntrospectionError) {
|
|
885
|
+
outError(chalk.red("\n✗ Aborting push: could not determine which indexes to protect."));
|
|
886
|
+
outError(chalk.gray(` ${err.message}`));
|
|
887
|
+
outError(chalk.gray(" Refusing to apply — a partial exclude list could drop an index Rebase does not manage."));
|
|
888
|
+
const hint = diagnoseDbError(err.cause ?? err, databaseUrl);
|
|
889
|
+
if (hint) outError(hint);
|
|
890
|
+
process.exit(1);
|
|
891
|
+
}
|
|
892
|
+
throw err;
|
|
893
|
+
}
|
|
894
|
+
for (const exc of indexExcludes) {
|
|
895
|
+
atlasArgs.push("--exclude", exc);
|
|
896
|
+
}
|
|
875
897
|
}
|
|
876
898
|
|
|
877
899
|
// Stream stdout live but tee stderr so we can inspect Atlas's error text
|
package/src/connection.ts
CHANGED
|
@@ -31,6 +31,40 @@ export interface PostgresPoolConfig {
|
|
|
31
31
|
searchPath?: string | false;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Ceiling on pool size, from the environment.
|
|
36
|
+
*
|
|
37
|
+
* Exists for the managed development database. PGlite is a single session
|
|
38
|
+
* behind a multiplexing socket server, and two pooled clients holding
|
|
39
|
+
* overlapping transactions deadlock there — which a request-per-transaction
|
|
40
|
+
* server produces the moment two requests overlap. One connection turns that
|
|
41
|
+
* deadlock into ordinary queueing.
|
|
42
|
+
*
|
|
43
|
+
* A ceiling rather than a value, so it constrains every pool in the process
|
|
44
|
+
* without any of them having to know why. Ignored when unset or unparseable,
|
|
45
|
+
* because a malformed limit must not silently serialize a production server.
|
|
46
|
+
*/
|
|
47
|
+
export function poolMaxCeiling(env: NodeJS.ProcessEnv = process.env): number | null {
|
|
48
|
+
const raw = env.REBASE_DB_POOL_MAX;
|
|
49
|
+
if (typeof raw !== "string") return null;
|
|
50
|
+
|
|
51
|
+
// Plain decimal digits only. `Number` would also accept `1e3`, `0x10` and
|
|
52
|
+
// ` 4 ` — none of which anyone writes on purpose in a config value, and all
|
|
53
|
+
// of which would mean this silently did something other than it appears to.
|
|
54
|
+
if (!/^[0-9]+$/.test(raw.trim())) return null;
|
|
55
|
+
|
|
56
|
+
const parsed = Number(raw.trim());
|
|
57
|
+
|
|
58
|
+
return parsed >= 1 ? parsed : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Apply {@link poolMaxCeiling} to a requested pool size. */
|
|
62
|
+
export function cappedPoolMax(requested: number, env: NodeJS.ProcessEnv = process.env): number {
|
|
63
|
+
const ceiling = poolMaxCeiling(env);
|
|
64
|
+
|
|
65
|
+
return ceiling === null ? requested : Math.min(requested, ceiling);
|
|
66
|
+
}
|
|
67
|
+
|
|
34
68
|
const DEFAULT_POOL: Required<PostgresPoolConfig> = {
|
|
35
69
|
max: 20,
|
|
36
70
|
searchPath: "public",
|
|
@@ -182,7 +216,7 @@ export function createPostgresDatabaseConnection(
|
|
|
182
216
|
|
|
183
217
|
const pgPoolConfig: PoolConfig = {
|
|
184
218
|
connectionString,
|
|
185
|
-
max: opts.max,
|
|
219
|
+
max: cappedPoolMax(opts.max),
|
|
186
220
|
idleTimeoutMillis: opts.idleTimeoutMillis,
|
|
187
221
|
connectionTimeoutMillis: opts.connectionTimeoutMillis,
|
|
188
222
|
query_timeout: opts.queryTimeout,
|
|
@@ -234,7 +268,7 @@ export function createDirectDatabaseConnection(
|
|
|
234
268
|
|
|
235
269
|
const pgPoolConfig: PoolConfig = {
|
|
236
270
|
connectionString,
|
|
237
|
-
max: opts.max,
|
|
271
|
+
max: cappedPoolMax(opts.max),
|
|
238
272
|
idleTimeoutMillis: opts.idleTimeoutMillis,
|
|
239
273
|
connectionTimeoutMillis: opts.connectionTimeoutMillis,
|
|
240
274
|
query_timeout: opts.queryTimeout,
|
|
@@ -275,7 +309,7 @@ export function createReadReplicaConnection(
|
|
|
275
309
|
|
|
276
310
|
const pgPoolConfig: PoolConfig = {
|
|
277
311
|
connectionString,
|
|
278
|
-
max: opts.max,
|
|
312
|
+
max: cappedPoolMax(opts.max),
|
|
279
313
|
idleTimeoutMillis: opts.idleTimeoutMillis,
|
|
280
314
|
connectionTimeoutMillis: opts.connectionTimeoutMillis,
|
|
281
315
|
query_timeout: opts.queryTimeout,
|