@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 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 +49 -2
- package/dist/PostgresBootstrapper.d.ts +25 -1
- package/dist/auth/services.d.ts +40 -0
- package/dist/{src-BbFOPJ1S.js → auth-users-columns-CBEOeYqa.js} +1118 -220
- package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
- package/dist/backup/backup-service.d.ts +10 -1
- package/dist/backup/pg-tools.d.ts +47 -0
- package/dist/backup-service-Bww-Lg0s.js +8999 -0
- package/dist/backup-service-Bww-Lg0s.js.map +1 -0
- package/dist/cli-helpers.d.ts +96 -1
- package/dist/connection-BuZ97wsr.js +250 -0
- package/dist/connection-BuZ97wsr.js.map +1 -0
- package/dist/connection.d.ts +42 -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-B_JMGa5K.js +57 -0
- package/dist/ensure-collection-policies-B_JMGa5K.js.map +1 -0
- package/dist/ensure-collection-tables-DzeTEvMv.js +800 -0
- package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
- package/dist/index.es.js +2460 -10233
- 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 +185 -144
- 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-policies.d.ts +60 -0
- package/dist/schema/ensure-collection-tables.d.ts +99 -3
- package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
- package/dist/schema/generate-postgres-ddl-logic.d.ts +185 -3
- package/dist/schema/generated-schema-staleness.d.ts +39 -0
- package/dist/schema/introspect-db-constraints.d.ts +57 -0
- package/dist/schema/introspect-db-logic.d.ts +94 -5
- package/dist/schema/introspect-db-queries.d.ts +119 -0
- package/dist/schema/introspect-db-structure.d.ts +263 -0
- package/dist/schema/introspect-db-types.d.ts +11 -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 +38 -8
- package/dist/services/RelationService.d.ts +54 -1
- package/dist/services/channel-bus/index.d.ts +1 -7
- package/dist/services/collection-helpers.d.ts +62 -2
- package/dist/services/dataService.d.ts +8 -1
- package/dist/services/realtimeService.d.ts +131 -21
- package/dist/services/row-pipeline.d.ts +1 -1
- package/dist/{src-Zqwaw3P5.js → src-C_wvdMnl.js} +78 -145
- package/dist/src-C_wvdMnl.js.map +1 -0
- package/dist/utils/connection-string.d.ts +29 -0
- package/dist/utils/drizzle-conditions.d.ts +129 -6
- package/dist/utils/pg-error-utils.d.ts +35 -0
- package/dist/websocket-D1qbmLZ2.js +587 -0
- package/dist/websocket-D1qbmLZ2.js.map +1 -0
- package/package.json +17 -16
- package/src/PostgresAdapter.ts +21 -2
- package/src/PostgresBackendDriver.ts +176 -6
- package/src/PostgresBootstrapper.ts +245 -37
- package/src/auth/ensure-tables.ts +357 -81
- package/src/auth/services.ts +93 -7
- package/src/backup/backup-cli.ts +41 -2
- package/src/backup/backup-service.ts +38 -5
- package/src/backup/pg-tools.ts +96 -3
- package/src/cli-helpers.ts +199 -10
- package/src/cli.ts +276 -56
- package/src/collections/validate-relations.ts +136 -14
- package/src/connection.ts +73 -0
- package/src/data-transformer.ts +129 -20
- package/src/databasePoolManager.ts +5 -2
- package/src/history/ensure-history-table.ts +7 -0
- package/src/schema/auth-schema.ts +46 -19
- 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-policies.ts +105 -0
- package/src/schema/ensure-collection-tables.test.ts +105 -9
- package/src/schema/ensure-collection-tables.ts +572 -42
- package/src/schema/generate-drizzle-schema-logic.ts +169 -43
- package/src/schema/generate-postgres-ddl-logic.ts +670 -29
- package/src/schema/generate-postgres-ddl.ts +25 -2
- package/src/schema/generated-schema-staleness.ts +169 -0
- package/src/schema/introspect-db-constraints.ts +385 -0
- package/src/schema/introspect-db-inference.ts +18 -8
- package/src/schema/introspect-db-logic.ts +446 -100
- package/src/schema/introspect-db-queries.ts +326 -0
- package/src/schema/introspect-db-structure.ts +670 -0
- package/src/schema/introspect-db-types.ts +56 -0
- package/src/schema/introspect-db.ts +37 -80
- package/src/schema/introspect-runtime.test.ts +56 -8
- package/src/schema/introspect-runtime.ts +31 -9
- 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/policy-drift.test.ts +11 -3
- package/src/security/rls-enforcement.ts +149 -6
- package/src/services/BranchService.ts +71 -28
- package/src/services/FetchService.ts +265 -122
- package/src/services/PersistService.ts +57 -7
- package/src/services/RelationService.ts +358 -114
- package/src/services/channel-bus/index.ts +0 -9
- package/src/services/channel-history.ts +14 -0
- package/src/services/channel-presence.ts +13 -0
- package/src/services/collection-helpers.ts +123 -4
- package/src/services/dataService.ts +8 -1
- package/src/services/realtimeService.ts +346 -81
- package/src/services/row-pipeline.ts +1 -1
- package/src/utils/connection-string.ts +58 -0
- package/src/utils/drizzle-conditions.ts +396 -29
- package/src/utils/pg-error-utils.ts +73 -3
- package/src/websocket.ts +130 -24
- package/dist/chunk-DSJWtz9O.js +0 -40
- package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
- package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
- package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
- package/dist/src-BbFOPJ1S.js.map +0 -1
- package/dist/src-Zqwaw3P5.js.map +0 -1
- package/src/schema/auth-bootstrap-sql.ts +0 -47
|
@@ -158,15 +158,23 @@ describe("checkPolicyDrift", () => {
|
|
|
158
158
|
it("also flags the tautology in a WITH CHECK clause", async () => {
|
|
159
159
|
const cols = [collection("posts")];
|
|
160
160
|
const expected = parseExpectedPolicies(generatePostgresPoliciesDdl(cols));
|
|
161
|
+
const withCheck = expected.filter((p) => p.hasWithCheck);
|
|
162
|
+
// Asserted rather than assumed: if the generator ever stopped emitting a
|
|
163
|
+
// WITH CHECK for these rules the fixture would carry no tautology at
|
|
164
|
+
// all, and every assertion below would be vacuously satisfied.
|
|
165
|
+
expect(withCheck.length).toBeGreaterThan(0);
|
|
166
|
+
|
|
161
167
|
const live = expected.map((p) => liveRow(p, {
|
|
162
168
|
with_check: p.hasWithCheck ? "(auth.uid() IS NOT NULL)" : null
|
|
163
169
|
}));
|
|
164
170
|
|
|
165
171
|
const drift = await checkPolicyDrift(dbWith(live), cols);
|
|
166
172
|
|
|
167
|
-
|
|
168
|
-
//
|
|
169
|
-
|
|
173
|
+
// Every WITH CHECK policy carries the tautology and no USING clause
|
|
174
|
+
// does, so anything flagged here came from the WITH CHECK scan — the
|
|
175
|
+
// half that a check reading only `qual` would miss entirely.
|
|
176
|
+
expect(drift.insecure.map((i) => i.policy.name)).toEqual(withCheck.map((p) => p.name));
|
|
177
|
+
expect(drift.insecure.every((i) => /WITH CHECK/.test(i.reason))).toBe(true);
|
|
170
178
|
});
|
|
171
179
|
|
|
172
180
|
it("parses roles when the driver returns the raw {a,b} text form", async () => {
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { sql as drizzleSql, SQL } from "drizzle-orm";
|
|
2
2
|
import { ANONYMOUS_USER_ID, PolicyExpression, SecurityRule } from "@rebasepro/types";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
AnonymousGrantRisk,
|
|
5
|
+
findAnonymousGrants,
|
|
6
|
+
REBASE_USER_ROLE,
|
|
7
|
+
revokeInternalTableAccess,
|
|
8
|
+
securityRuleToConditions
|
|
9
|
+
} from "@rebasepro/common";
|
|
10
|
+
import { REBASE_SCHEMA, usesLegacyRlsFunctions } from "@rebasepro/types";
|
|
4
11
|
import { logger } from "@rebasepro/server";
|
|
5
12
|
|
|
6
13
|
/**
|
|
@@ -16,9 +23,14 @@ import { logger } from "@rebasepro/server";
|
|
|
16
23
|
* are validation/side-effects, not a security boundary.
|
|
17
24
|
*
|
|
18
25
|
* - **Server context** — the base (owner) connection: auth flows, migrations,
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
26
|
+
* and raw `rebase.sql`. As table owner it bypasses RLS. This is the trusted
|
|
27
|
+
* plane, equivalent to Supabase's `service_role`.
|
|
28
|
+
*
|
|
29
|
+
* `rebase.dataAsAdmin` is **not** in it, despite the name. `init.ts` scopes
|
|
30
|
+
* that driver with `withAuth(SERVICE_IDENTITY)`, so it arrives as user
|
|
31
|
+
* context above — `rebase_user`, `app.uid = 'service'`, policies evaluated —
|
|
32
|
+
* and clears the default policies through their admin arm rather than the
|
|
33
|
+
* `auth.uid() IS NULL` one.
|
|
22
34
|
*
|
|
23
35
|
* This module provides the three pieces:
|
|
24
36
|
*
|
|
@@ -36,8 +48,14 @@ import { logger } from "@rebasepro/server";
|
|
|
36
48
|
* not an operator opt-in.
|
|
37
49
|
*/
|
|
38
50
|
|
|
39
|
-
/**
|
|
40
|
-
|
|
51
|
+
/**
|
|
52
|
+
* The restricted role every authenticated (user-context) request runs as.
|
|
53
|
+
*
|
|
54
|
+
* Re-exported, not re-declared: the same name is needed by
|
|
55
|
+
* `@rebasepro/common`'s internal-table revokes, and two spellings of a role name
|
|
56
|
+
* fail as a silent no-op rather than an error.
|
|
57
|
+
*/
|
|
58
|
+
export { REBASE_USER_ROLE };
|
|
41
59
|
|
|
42
60
|
/** Minimal SQL runner so callers can adapt drizzle or pg.Client. */
|
|
43
61
|
export type RawSqlRunner = (sqlText: string) => Promise<Record<string, unknown>[]>;
|
|
@@ -69,6 +87,57 @@ const quoteIdent = (name: string): string => `"${name.replace(/"/g, "\"\"")}"`;
|
|
|
69
87
|
/** DML the user role holds on managed tables (RLS still filters per row). */
|
|
70
88
|
const USER_TABLE_PRIVILEGES = "SELECT, INSERT, UPDATE, DELETE";
|
|
71
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Warn when the connection role shares its name with an existing schema.
|
|
92
|
+
*
|
|
93
|
+
* Postgres resolves unqualified names through `search_path`, which defaults to
|
|
94
|
+
* `"$user", public` — and `$user` is the connection ROLE. When a schema of that
|
|
95
|
+
* name exists it sits ahead of `public`, so every unqualified statement
|
|
96
|
+
* silently operates on it instead:
|
|
97
|
+
*
|
|
98
|
+
* CREATE TABLE posts (...); -- you meant public.posts; you got <role>.posts
|
|
99
|
+
*
|
|
100
|
+
* Nothing errors. You get a second table of the same name in the wrong schema,
|
|
101
|
+
* and reads that pin `public` cannot see it — which reads as "missing table" and
|
|
102
|
+
* sends people to re-run a push that creates a *third* copy. The bootstrapper
|
|
103
|
+
* has a whole branch dedicated to recognising the symptom after the fact.
|
|
104
|
+
*
|
|
105
|
+
* Rebase shipped straight into this: it creates a schema named `rebase` while
|
|
106
|
+
* every template named the database role `rebase` too. The scaffold uses
|
|
107
|
+
* `rebase_app` now, and every pool Rebase opens pins `search_path=public`
|
|
108
|
+
* (`pinSearchPath`), which covers the paths the framework controls. This covers
|
|
109
|
+
* the ones it does not — `psql`, `pg_dump`, drizzle-kit, a colleague's script,
|
|
110
|
+
* a hand-written migration — because the hazard is a property of the two NAMES,
|
|
111
|
+
* not of any one connection.
|
|
112
|
+
*
|
|
113
|
+
* A warning rather than a boot failure: the database works, the framework's own
|
|
114
|
+
* traffic is pinned, and refusing to start over a naming choice a user may have
|
|
115
|
+
* inherited would be worse than the risk.
|
|
116
|
+
*/
|
|
117
|
+
export async function warnOnRoleSchemaCollision(run: RawSqlRunner): Promise<void> {
|
|
118
|
+
try {
|
|
119
|
+
const rows = await run(`
|
|
120
|
+
SELECT current_user AS role,
|
|
121
|
+
EXISTS (
|
|
122
|
+
SELECT 1 FROM pg_namespace n WHERE n.nspname = current_user
|
|
123
|
+
) AS collides
|
|
124
|
+
`);
|
|
125
|
+
if (rows[0]?.collides !== true) return;
|
|
126
|
+
const role = String(rows[0]?.role ?? "the connection role");
|
|
127
|
+
logger.warn(
|
|
128
|
+
`⚠️ The database role "${role}" has the same name as a schema. Postgres resolves unqualified ` +
|
|
129
|
+
`names through \`search_path\`, which defaults to \`"$user", public\` — so "${role}" is searched ` +
|
|
130
|
+
`BEFORE public, and any unqualified \`CREATE TABLE\`/\`SELECT\` from a tool that does not pin the ` +
|
|
131
|
+
`path (psql, pg_dump, drizzle-kit, a hand-written migration) silently lands in "${role}" instead. ` +
|
|
132
|
+
`Rebase's own connections pin \`search_path=public\`, so the server is unaffected. To remove the ` +
|
|
133
|
+
`hazard entirely, connect as a role whose name is not also a schema — the scaffold uses ` +
|
|
134
|
+
`"rebase_app".`
|
|
135
|
+
);
|
|
136
|
+
} catch {
|
|
137
|
+
// A diagnostic must never be the reason a boot fails.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
72
141
|
export async function detectConnectionPosture(run: RawSqlRunner): Promise<ConnectionPosture> {
|
|
73
142
|
const rows = await run(`
|
|
74
143
|
SELECT current_user AS role,
|
|
@@ -180,6 +249,21 @@ export async function ensureAppRole(run: RawSqlRunner, schemas: string[]): Promi
|
|
|
180
249
|
// migrations), so a migrate can never strand the user role.
|
|
181
250
|
await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT ${USER_TABLE_PRIVILEGES} ON TABLES TO ${REBASE_USER_ROLE}`);
|
|
182
251
|
await run(`ALTER DEFAULT PRIVILEGES IN SCHEMA ${s} GRANT USAGE, SELECT ON SEQUENCES TO ${REBASE_USER_ROLE}`);
|
|
252
|
+
|
|
253
|
+
// The grants above are deliberately schema-wide — a project's own
|
|
254
|
+
// collections may live in `rebase`, and future tables must be reachable
|
|
255
|
+
// or a migration strands the role. Rebase's OWN tables are the exception:
|
|
256
|
+
// refresh tokens, MFA secrets, API keys and the rest carry no RLS and no
|
|
257
|
+
// row an end user should ever address. Taking the privilege back here
|
|
258
|
+
// covers every table that already exists; each creator revokes on the
|
|
259
|
+
// table it just made, for the boot that creates them for the first time.
|
|
260
|
+
await revokeInternalTableAccess(async (text) => { await run(text); }, schema, {
|
|
261
|
+
onError: (table, error) => logger.warn(
|
|
262
|
+
`🔐 [rls] Could not revoke "${REBASE_USER_ROLE}" access to "${schema}"."${table}" — ` +
|
|
263
|
+
"it stays reachable by authenticated requests: " +
|
|
264
|
+
(error instanceof Error ? error.message : String(error))
|
|
265
|
+
)
|
|
266
|
+
});
|
|
183
267
|
}
|
|
184
268
|
|
|
185
269
|
logger.info(`🔐 [rls] User role "${REBASE_USER_ROLE}" provisioned (schemas: ${uniqueSchemas.join(", ")})`);
|
|
@@ -293,6 +377,65 @@ export function warnOnAnonymousGrants(
|
|
|
293
377
|
);
|
|
294
378
|
}
|
|
295
379
|
|
|
380
|
+
/**
|
|
381
|
+
* Name the collections whose raw policy SQL still calls the pre-1.0 helpers.
|
|
382
|
+
*
|
|
383
|
+
* The compiler rewrites `auth.uid()` to `rebase.uid()` on the way into the
|
|
384
|
+
* database, so nothing is broken and no policy is wrong — which is exactly why
|
|
385
|
+
* this has to be said out loud. A silent rewrite that works forever is not a
|
|
386
|
+
* migration, it is a second supported spelling nobody wrote down, and the next
|
|
387
|
+
* person to read those rules will copy the old one.
|
|
388
|
+
*
|
|
389
|
+
* Only `raw` expressions can carry it. Structured rules (`policy.authUid()`,
|
|
390
|
+
* `policy.rolesOverlap(...)`) compile from the model and were never affected.
|
|
391
|
+
*/
|
|
392
|
+
export function warnOnLegacyRlsFunctions(
|
|
393
|
+
collections: { slug?: string; securityRules?: readonly SecurityRule[] }[]
|
|
394
|
+
): void {
|
|
395
|
+
const sites: string[] = [];
|
|
396
|
+
|
|
397
|
+
for (const collection of collections) {
|
|
398
|
+
for (const rule of collection.securityRules ?? []) {
|
|
399
|
+
const { usingExpr, withCheckExpr } = securityRuleToConditions(rule);
|
|
400
|
+
const carriesLegacy = [usingExpr, withCheckExpr]
|
|
401
|
+
.filter((e): e is PolicyExpression => e !== null)
|
|
402
|
+
.some(containsLegacyRlsCall);
|
|
403
|
+
if (!carriesLegacy) continue;
|
|
404
|
+
|
|
405
|
+
const site = `${collection.slug ?? "(unnamed)"} → "${rule.name ?? "(unnamed rule)"}"`;
|
|
406
|
+
if (!sites.includes(site)) sites.push(site);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (sites.length === 0) return;
|
|
411
|
+
|
|
412
|
+
logger.warn(
|
|
413
|
+
`These security rules call the pre-1.0 RLS helpers (\`auth.uid()\`, \`auth.roles()\`, \`auth.jwt()\`). ` +
|
|
414
|
+
`They still work — the compiler rewrites them — but the functions now live in the \`rebase\` schema, ` +
|
|
415
|
+
`and the \`auth\` one is Supabase's. Update the raw SQL in these rules to \`${REBASE_SCHEMA}.uid()\` ` +
|
|
416
|
+
`and friends, or switch them to the structured helpers (\`policy.authUid()\`, \`policy.rolesOverlap()\`), ` +
|
|
417
|
+
`which never had to be spelled by hand:\n\n` +
|
|
418
|
+
sites.map(s => ` • ${s}`).join("\n") + "\n"
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** Whether any `raw` expression in the tree calls a pre-1.0 helper. */
|
|
423
|
+
function containsLegacyRlsCall(expr: PolicyExpression): boolean {
|
|
424
|
+
switch (expr.kind) {
|
|
425
|
+
case "raw":
|
|
426
|
+
return usesLegacyRlsFunctions(expr.sql);
|
|
427
|
+
case "and":
|
|
428
|
+
case "or":
|
|
429
|
+
return expr.operands.some(containsLegacyRlsCall);
|
|
430
|
+
case "not":
|
|
431
|
+
return containsLegacyRlsCall(expr.operand);
|
|
432
|
+
case "existsIn":
|
|
433
|
+
return containsLegacyRlsCall(expr.where);
|
|
434
|
+
default:
|
|
435
|
+
return false;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
296
439
|
/**
|
|
297
440
|
* Reject `pgRoles` that this server can never satisfy.
|
|
298
441
|
*
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { sql } from "drizzle-orm";
|
|
11
11
|
import { BranchInfo } from "@rebasepro/types";
|
|
12
|
+
import { revokeInternalTableSql } from "@rebasepro/common";
|
|
12
13
|
import { DrizzleClient } from "../interfaces";
|
|
13
14
|
import { DatabasePoolManager } from "../databasePoolManager";
|
|
14
15
|
import { extractPgError, extractCauseMessage } from "../utils/pg-error-utils";
|
|
@@ -64,20 +65,41 @@ function validateIdentifier(value: string, label: string): void {
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/**
|
|
67
|
-
*
|
|
68
|
-
*
|
|
68
|
+
* Postgres truncates identifiers at NAMEDATALEN-1 = 63 bytes, and does it
|
|
69
|
+
* silently. The prefix comes out of the same budget.
|
|
69
70
|
*/
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
const MAX_BRANCH_NAME_LENGTH = 63 - BRANCH_DB_PREFIX.length;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check a user-provided branch name, and otherwise leave it exactly as given.
|
|
75
|
+
*
|
|
76
|
+
* This used to strip everything outside [a-zA-Z0-9_], so `my-feature` was
|
|
77
|
+
* quietly created as `myfeature`: the name you typed was not the name `list`
|
|
78
|
+
* gave back. Nothing ever needed that. Every identifier this service builds is
|
|
79
|
+
* double-quoted (see `CREATE DATABASE` below), which is what makes a hyphen
|
|
80
|
+
* safe, and `validateIdentifier` has always accepted hyphens for the `--from`
|
|
81
|
+
* source database — the two disagreed about the same character class.
|
|
82
|
+
*
|
|
83
|
+
* Refusing a name we cannot represent is better than representing a different
|
|
84
|
+
* one, which is also why the length is checked here rather than left to
|
|
85
|
+
* Postgres, whose answer to an over-long identifier is a silent rename.
|
|
86
|
+
*/
|
|
87
|
+
function assertValidBranchName(name: string): void {
|
|
88
|
+
validateIdentifier(name, "branch name");
|
|
89
|
+
if (name.length > MAX_BRANCH_NAME_LENGTH) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`Branch name "${name}" is too long: ${name.length} characters, maximum ${MAX_BRANCH_NAME_LENGTH}. ` +
|
|
92
|
+
"Postgres truncates identifiers past 63 bytes, which would give the branch a name you did not choose."
|
|
93
|
+
);
|
|
94
|
+
}
|
|
72
95
|
}
|
|
73
96
|
|
|
74
97
|
/**
|
|
75
98
|
* Convert a user-facing branch name to the actual PostgreSQL database name.
|
|
76
99
|
*/
|
|
77
100
|
function toBranchDbName(name: string): string {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
return `${BRANCH_DB_PREFIX}${sanitized}`;
|
|
101
|
+
assertValidBranchName(name);
|
|
102
|
+
return `${BRANCH_DB_PREFIX}${name}`;
|
|
81
103
|
}
|
|
82
104
|
|
|
83
105
|
export class BranchService {
|
|
@@ -103,6 +125,10 @@ export class BranchService {
|
|
|
103
125
|
metadata JSONB DEFAULT '{}'
|
|
104
126
|
);
|
|
105
127
|
`));
|
|
128
|
+
|
|
129
|
+
// Not a collection, so no RLS — and it names every branch database on
|
|
130
|
+
// this server. The driver's schema-wide grant reaches it, so revoke.
|
|
131
|
+
await this.db.execute(sql.raw(revokeInternalTableSql("rebase", "branches")));
|
|
106
132
|
}
|
|
107
133
|
|
|
108
134
|
/**
|
|
@@ -120,15 +146,14 @@ export class BranchService {
|
|
|
120
146
|
}
|
|
121
147
|
|
|
122
148
|
const dbName = toBranchDbName(name);
|
|
123
|
-
const sanitizedName = sanitizeBranchName(name);
|
|
124
149
|
const sourceDb = options?.source || this.poolManager.defaultDatabaseName;
|
|
125
150
|
|
|
126
151
|
// Check if branch already exists
|
|
127
152
|
const existing = await this.db.execute(
|
|
128
|
-
sql`SELECT name FROM rebase.branches WHERE name = ${
|
|
153
|
+
sql`SELECT name FROM rebase.branches WHERE name = ${name} OR db_name = ${dbName}`
|
|
129
154
|
);
|
|
130
155
|
if ((existing.rows as unknown[]).length > 0) {
|
|
131
|
-
throw new Error(`Branch "${
|
|
156
|
+
throw new Error(`Branch "${name}" already exists.`);
|
|
132
157
|
}
|
|
133
158
|
|
|
134
159
|
// Disconnect any idle pools to the source DB so TEMPLATE works.
|
|
@@ -158,12 +183,12 @@ export class BranchService {
|
|
|
158
183
|
// Record metadata in the default database
|
|
159
184
|
const now = new Date();
|
|
160
185
|
await this.db.execute(
|
|
161
|
-
sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
|
|
162
|
-
VALUES (${
|
|
186
|
+
sql`INSERT INTO rebase.branches (name, db_name, parent_db, created_at)
|
|
187
|
+
VALUES (${name}, ${dbName}, ${sourceDb}, ${now.toISOString()})`
|
|
163
188
|
);
|
|
164
189
|
|
|
165
190
|
return {
|
|
166
|
-
name
|
|
191
|
+
name,
|
|
167
192
|
parentDatabase: sourceDb,
|
|
168
193
|
createdAt: now
|
|
169
194
|
};
|
|
@@ -174,20 +199,35 @@ export class BranchService {
|
|
|
174
199
|
* Cannot delete the main/default database.
|
|
175
200
|
*/
|
|
176
201
|
async deleteBranch(name: string): Promise<void> {
|
|
177
|
-
|
|
178
|
-
const dbName = toBranchDbName(name);
|
|
202
|
+
assertValidBranchName(name);
|
|
179
203
|
|
|
180
|
-
// Safety:
|
|
181
|
-
|
|
204
|
+
// Safety, first pass: a request that would target the default database
|
|
205
|
+
// is refused before any metadata is read, so the answer costs nothing
|
|
206
|
+
// and cannot depend on what a row happens to say.
|
|
207
|
+
if (toBranchDbName(name) === this.poolManager.defaultDatabaseName) {
|
|
182
208
|
throw new Error("Cannot delete the main database.");
|
|
183
209
|
}
|
|
184
210
|
|
|
185
|
-
// Verify the branch exists
|
|
211
|
+
// Verify the branch exists, and take the database name from the row
|
|
212
|
+
// rather than deriving it again. A branch created before names stopped
|
|
213
|
+
// being stripped is recorded as `rb_myfeature` while `my-feature` now
|
|
214
|
+
// derives `rb_my-feature`; re-deriving would drop a database this row
|
|
215
|
+
// never named — or, if that name happened to exist, somebody else's.
|
|
216
|
+
// The stored value is the only one that is true by construction.
|
|
186
217
|
const existing = await this.db.execute(
|
|
187
|
-
sql`SELECT db_name FROM rebase.branches WHERE name = ${
|
|
218
|
+
sql`SELECT db_name FROM rebase.branches WHERE name = ${name}`
|
|
188
219
|
);
|
|
189
|
-
|
|
190
|
-
|
|
220
|
+
const existingRows = existing.rows as Record<string, unknown>[];
|
|
221
|
+
if (existingRows.length === 0) {
|
|
222
|
+
throw new Error(`Branch "${name}" not found.`);
|
|
223
|
+
}
|
|
224
|
+
const dbName = existingRows[0].db_name as string;
|
|
225
|
+
|
|
226
|
+
// Safety, second pass: the first checked a name we derived, this checks
|
|
227
|
+
// the one we are about to drop. They differ for any row written under
|
|
228
|
+
// the old scheme, and it is this one that governs.
|
|
229
|
+
if (dbName === this.poolManager.defaultDatabaseName) {
|
|
230
|
+
throw new Error("Cannot delete the main database.");
|
|
191
231
|
}
|
|
192
232
|
|
|
193
233
|
// Disconnect any pools to this branch before dropping
|
|
@@ -201,7 +241,7 @@ export class BranchService {
|
|
|
201
241
|
const pgError = extractPgError(err);
|
|
202
242
|
if (pgError?.code === PG_OBJECT_IN_USE) {
|
|
203
243
|
throw new Error(
|
|
204
|
-
`Cannot delete branch "${
|
|
244
|
+
`Cannot delete branch "${name}": the database has active connections. ` +
|
|
205
245
|
"Close other clients and try again."
|
|
206
246
|
);
|
|
207
247
|
}
|
|
@@ -210,7 +250,7 @@ export class BranchService {
|
|
|
210
250
|
|
|
211
251
|
// Remove metadata
|
|
212
252
|
await this.db.execute(
|
|
213
|
-
sql`DELETE FROM rebase.branches WHERE name = ${
|
|
253
|
+
sql`DELETE FROM rebase.branches WHERE name = ${name}`
|
|
214
254
|
);
|
|
215
255
|
}
|
|
216
256
|
|
|
@@ -242,15 +282,16 @@ export class BranchService {
|
|
|
242
282
|
* Get info about a specific branch.
|
|
243
283
|
*/
|
|
244
284
|
async getBranchInfo(name: string): Promise<BranchInfo | undefined> {
|
|
245
|
-
|
|
285
|
+
assertValidBranchName(name);
|
|
246
286
|
|
|
247
287
|
const result = await this.db.execute(sql`
|
|
248
|
-
SELECT
|
|
288
|
+
SELECT
|
|
249
289
|
b.name,
|
|
290
|
+
b.db_name,
|
|
250
291
|
b.parent_db,
|
|
251
292
|
b.created_at
|
|
252
293
|
FROM rebase.branches b
|
|
253
|
-
WHERE b.name = ${
|
|
294
|
+
WHERE b.name = ${name}
|
|
254
295
|
`);
|
|
255
296
|
|
|
256
297
|
const rows = result.rows as Record<string, unknown>[];
|
|
@@ -258,10 +299,12 @@ export class BranchService {
|
|
|
258
299
|
|
|
259
300
|
const row = rows[0];
|
|
260
301
|
|
|
261
|
-
// Attempt to get size — may fail if the DB was externally dropped
|
|
302
|
+
// Attempt to get size — may fail if the DB was externally dropped.
|
|
303
|
+
// Same reason as `deleteBranch`: the size of a re-derived name is the
|
|
304
|
+
// size of some other database, or of nothing.
|
|
262
305
|
let sizeBytes: number | undefined;
|
|
263
306
|
try {
|
|
264
|
-
const dbName =
|
|
307
|
+
const dbName = row.db_name as string;
|
|
265
308
|
const sizeResult = await this.db.execute(
|
|
266
309
|
sql`SELECT pg_database_size(${dbName}) as size_bytes`
|
|
267
310
|
);
|