@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.
Files changed (120) hide show
  1. package/dist/PostgresBackendDriver.d.ts +49 -2
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +40 -0
  4. package/dist/{src-BbFOPJ1S.js → auth-users-columns-CBEOeYqa.js} +1118 -220
  5. package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
  6. package/dist/backup/backup-service.d.ts +10 -1
  7. package/dist/backup/pg-tools.d.ts +47 -0
  8. package/dist/backup-service-Bww-Lg0s.js +8999 -0
  9. package/dist/backup-service-Bww-Lg0s.js.map +1 -0
  10. package/dist/cli-helpers.d.ts +96 -1
  11. package/dist/connection-BuZ97wsr.js +250 -0
  12. package/dist/connection-BuZ97wsr.js.map +1 -0
  13. package/dist/connection.d.ts +42 -0
  14. package/dist/data-transformer.d.ts +7 -2
  15. package/dist/data_driver-ULAyJEi9.js +193 -0
  16. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  17. package/dist/ensure-collection-policies-B_JMGa5K.js +57 -0
  18. package/dist/ensure-collection-policies-B_JMGa5K.js.map +1 -0
  19. package/dist/ensure-collection-tables-DzeTEvMv.js +800 -0
  20. package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
  21. package/dist/index.es.js +2460 -10233
  22. package/dist/index.es.js.map +1 -1
  23. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  24. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  25. package/dist/schema/auth-schema.d.ts +185 -144
  26. package/dist/schema/auth-users-columns.d.ts +97 -0
  27. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  28. package/dist/schema/doctor.d.ts +23 -25
  29. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  30. package/dist/schema/ensure-collection-tables.d.ts +99 -3
  31. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  32. package/dist/schema/generate-postgres-ddl-logic.d.ts +185 -3
  33. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  34. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  35. package/dist/schema/introspect-db-logic.d.ts +94 -5
  36. package/dist/schema/introspect-db-queries.d.ts +119 -0
  37. package/dist/schema/introspect-db-structure.d.ts +263 -0
  38. package/dist/schema/introspect-db-types.d.ts +11 -0
  39. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  40. package/dist/schema/search-column.d.ts +248 -0
  41. package/dist/security/rls-enforcement.d.ts +61 -5
  42. package/dist/services/FetchService.d.ts +38 -8
  43. package/dist/services/RelationService.d.ts +54 -1
  44. package/dist/services/channel-bus/index.d.ts +1 -7
  45. package/dist/services/collection-helpers.d.ts +62 -2
  46. package/dist/services/dataService.d.ts +8 -1
  47. package/dist/services/realtimeService.d.ts +131 -21
  48. package/dist/services/row-pipeline.d.ts +1 -1
  49. package/dist/{src-Zqwaw3P5.js → src-C_wvdMnl.js} +78 -145
  50. package/dist/src-C_wvdMnl.js.map +1 -0
  51. package/dist/utils/connection-string.d.ts +29 -0
  52. package/dist/utils/drizzle-conditions.d.ts +129 -6
  53. package/dist/utils/pg-error-utils.d.ts +35 -0
  54. package/dist/websocket-D1qbmLZ2.js +587 -0
  55. package/dist/websocket-D1qbmLZ2.js.map +1 -0
  56. package/package.json +17 -16
  57. package/src/PostgresAdapter.ts +21 -2
  58. package/src/PostgresBackendDriver.ts +176 -6
  59. package/src/PostgresBootstrapper.ts +245 -37
  60. package/src/auth/ensure-tables.ts +357 -81
  61. package/src/auth/services.ts +93 -7
  62. package/src/backup/backup-cli.ts +41 -2
  63. package/src/backup/backup-service.ts +38 -5
  64. package/src/backup/pg-tools.ts +96 -3
  65. package/src/cli-helpers.ts +199 -10
  66. package/src/cli.ts +276 -56
  67. package/src/collections/validate-relations.ts +136 -14
  68. package/src/connection.ts +73 -0
  69. package/src/data-transformer.ts +129 -20
  70. package/src/databasePoolManager.ts +5 -2
  71. package/src/history/ensure-history-table.ts +7 -0
  72. package/src/schema/auth-schema.ts +46 -19
  73. package/src/schema/auth-users-columns.ts +131 -0
  74. package/src/schema/doctor-cli.ts +12 -63
  75. package/src/schema/doctor-policy-checks.ts +105 -0
  76. package/src/schema/doctor.ts +135 -76
  77. package/src/schema/ensure-collection-policies.ts +105 -0
  78. package/src/schema/ensure-collection-tables.test.ts +105 -9
  79. package/src/schema/ensure-collection-tables.ts +572 -42
  80. package/src/schema/generate-drizzle-schema-logic.ts +169 -43
  81. package/src/schema/generate-postgres-ddl-logic.ts +670 -29
  82. package/src/schema/generate-postgres-ddl.ts +25 -2
  83. package/src/schema/generated-schema-staleness.ts +169 -0
  84. package/src/schema/introspect-db-constraints.ts +385 -0
  85. package/src/schema/introspect-db-inference.ts +18 -8
  86. package/src/schema/introspect-db-logic.ts +446 -100
  87. package/src/schema/introspect-db-queries.ts +326 -0
  88. package/src/schema/introspect-db-structure.ts +670 -0
  89. package/src/schema/introspect-db-types.ts +56 -0
  90. package/src/schema/introspect-db.ts +37 -80
  91. package/src/schema/introspect-runtime.test.ts +56 -8
  92. package/src/schema/introspect-runtime.ts +31 -9
  93. package/src/schema/non-sql-collections.test.ts +131 -0
  94. package/src/schema/rls-bootstrap-sql.ts +288 -0
  95. package/src/schema/search-column.ts +643 -0
  96. package/src/security/anonymous-grants.test.ts +4 -2
  97. package/src/security/policy-drift.test.ts +11 -3
  98. package/src/security/rls-enforcement.ts +149 -6
  99. package/src/services/BranchService.ts +71 -28
  100. package/src/services/FetchService.ts +265 -122
  101. package/src/services/PersistService.ts +57 -7
  102. package/src/services/RelationService.ts +358 -114
  103. package/src/services/channel-bus/index.ts +0 -9
  104. package/src/services/channel-history.ts +14 -0
  105. package/src/services/channel-presence.ts +13 -0
  106. package/src/services/collection-helpers.ts +123 -4
  107. package/src/services/dataService.ts +8 -1
  108. package/src/services/realtimeService.ts +346 -81
  109. package/src/services/row-pipeline.ts +1 -1
  110. package/src/utils/connection-string.ts +58 -0
  111. package/src/utils/drizzle-conditions.ts +396 -29
  112. package/src/utils/pg-error-utils.ts +73 -3
  113. package/src/websocket.ts +130 -24
  114. package/dist/chunk-DSJWtz9O.js +0 -40
  115. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  116. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  117. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  118. package/dist/src-BbFOPJ1S.js.map +0 -1
  119. package/dist/src-Zqwaw3P5.js.map +0 -1
  120. 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
- const flagged = drift.insecure.some((i) => /WITH CHECK/.test(i.reason));
168
- // Only assert when the fixture actually had a WITH CHECK policy to carry it.
169
- if (expected.some((p) => p.hasWithCheck)) expect(flagged).toBe(true);
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 { AnonymousGrantRisk, findAnonymousGrants, securityRuleToConditions } from "@rebasepro/common";
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
- * background jobs, and the explicit `rebase.dataAsAdmin` accessor. As table
20
- * owner it bypasses RLS. This is the trusted plane, equivalent to
21
- * Supabase's `service_role`.
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
- /** The restricted role every authenticated (user-context) request runs as. */
40
- export const REBASE_USER_ROLE = "rebase_user";
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
- * Sanitize a user-provided branch name to a safe PostgreSQL identifier.
68
- * Only allows alphanumeric characters and underscores.
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
- function sanitizeBranchName(name: string): string {
71
- return name.replace(/[^a-zA-Z0-9_]/g, "");
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
- const sanitized = sanitizeBranchName(name);
79
- if (!sanitized) throw new Error("Branch name must contain at least one alphanumeric character.");
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 = ${sanitizedName} OR db_name = ${dbName}`
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 "${sanitizedName}" already exists.`);
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 (${sanitizedName}, ${dbName}, ${sourceDb}, ${now.toISOString()})`
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: sanitizedName,
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
- const sanitizedName = sanitizeBranchName(name);
178
- const dbName = toBranchDbName(name);
202
+ assertValidBranchName(name);
179
203
 
180
- // Safety: never delete the default database
181
- if (dbName === this.poolManager.defaultDatabaseName) {
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 in our metadata
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 = ${sanitizedName}`
218
+ sql`SELECT db_name FROM rebase.branches WHERE name = ${name}`
188
219
  );
189
- if ((existing.rows as unknown[]).length === 0) {
190
- throw new Error(`Branch "${sanitizedName}" not found.`);
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 "${sanitizedName}": the database has active connections. ` +
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 = ${sanitizedName}`
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
- const sanitizedName = sanitizeBranchName(name);
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 = ${sanitizedName}
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 = toBranchDbName(sanitizedName);
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
  );