@rebasepro/server-postgres 0.9.1-canary.ad25bc0 → 0.9.1-canary.ad870eb

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.
@@ -0,0 +1,10 @@
1
+ import { CollectionConfig, SecurityRule } from "@rebasepro/types";
2
+ /**
3
+ * Returns the security rules that should be applied to a collection: the
4
+ * author's explicit `securityRules` plus the framework defaults described in
5
+ * the module doc (baseline server/admin read for all collections; self-read
6
+ * and the admin write gate for auth collections).
7
+ *
8
+ * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
9
+ */
10
+ export declare function getEffectiveSecurityRules(collection: CollectionConfig): SecurityRule[];
@@ -52,12 +52,10 @@ export declare class FetchService {
52
52
  /**
53
53
  * Convert a db.query result row (with nested relation objects) to a flat row.
54
54
  * Handles:
55
+ * - Placing `id` at the top level as a string
55
56
  * - Type normalization (dates, numbers, NaN) via normalizeDbValues
56
57
  * - Converting nested relation objects to { id, path, __type: "relation" } for CMS
57
58
  * - Flattening junction-table many-to-many results
58
- *
59
- * The row's own address is not among them: it is derived by the consumer
60
- * from the collection's primary keys.
61
59
  */
62
60
  private drizzleResultToRow;
63
61
  /**
@@ -77,13 +75,7 @@ export declare class FetchService {
77
75
  */
78
76
  private resolveJoinPathRelationsBatchRest;
79
77
  /**
80
- * Convert a db.query result row to a flat REST-style row with populated relations.
81
- *
82
- * Every column is copied through under its own name, with the value Postgres
83
- * returned. This used to open with a synthesized `id` and then skip the key
84
- * column, which renamed it (a `sku` primary key was served as `id`, and `sku`
85
- * did not appear at all) and restringified it (`42` → `"42"`). Consumers that
86
- * need an address derive it from the collection's primary keys.
78
+ * Convert a db.query result row to a flat REST-style object with populated relations.
87
79
  */
88
80
  private drizzleResultToRestRow;
89
81
  /**
@@ -12,24 +12,6 @@ export declare class PersistService {
12
12
  private relationService;
13
13
  private fetchService;
14
14
  constructor(db: DrizzleClient, registry: PostgresCollectionRegistry);
15
- /**
16
- * Explain a write that matched no rows.
17
- *
18
- * Row-level security filters UPDATE and DELETE through the policy's USING
19
- * clause instead of raising: a denied write is reported by Postgres exactly
20
- * like a successful one that happened to match nothing. Left unchecked, a
21
- * caller cannot tell "denied" from "done" — the write returns 200/204 and
22
- * the row is untouched.
23
- *
24
- * Re-reading the target over the *same* RLS-scoped handle separates the two
25
- * cases. A visible row means the policy rejected the write (403); an
26
- * invisible one means there is nothing there to write for this caller (404,
27
- * matching what a GET would say). The re-read is bound by the caller's own
28
- * policies, so it discloses nothing a plain read wouldn't.
29
- *
30
- * Only reached when zero rows matched, so the happy path pays nothing.
31
- */
32
- private explainZeroRowWrite;
33
15
  /**
34
16
  * Delete an row by ID
35
17
  */
@@ -40,16 +22,8 @@ export declare class PersistService {
40
22
  deleteAll(collectionPath: string, _databaseId?: string): Promise<void>;
41
23
  /**
42
24
  * Save an row (create or update)
43
- *
44
- * With `options.upsert`, the row is written with INSERT ... ON CONFLICT DO
45
- * UPDATE against the primary key rather than a plain UPDATE. That is one
46
- * statement, so it cannot lose a race the way a read-then-write can, and it
47
- * does not care whether the row already exists — which is what a re-runnable
48
- * import needs.
49
25
  */
50
- save<M extends Record<string, unknown>>(collectionPath: string, values: Partial<M>, id?: string | number, databaseId?: string, options?: {
51
- upsert?: boolean;
52
- }): Promise<Record<string, unknown>>;
26
+ save<M extends Record<string, unknown>>(collectionPath: string, values: Partial<M>, id?: string | number, databaseId?: string): Promise<Record<string, unknown>>;
53
27
  /**
54
28
  * Get the RelationService instance for external use
55
29
  */
@@ -1,9 +1,6 @@
1
1
  import { PgTable, AnyPgColumn } from "drizzle-orm/pg-core";
2
2
  import { CollectionConfig } from "@rebasepro/types";
3
3
  import { PostgresCollectionRegistry } from "../collections/PostgresCollectionRegistry";
4
- export { buildCompositeId, parseIdValues, COMPOSITE_ID_SEPARATOR } from "@rebasepro/common";
5
- export type { PrimaryKeyInfo } from "@rebasepro/common";
6
- import type { PrimaryKeyInfo } from "@rebasepro/common";
7
4
  /**
8
5
  * Shared helper functions for row operations.
9
6
  * These are used by FetchService, PersistService, and RelationService.
@@ -24,4 +21,18 @@ export interface DrizzleColumnMeta {
24
21
  export declare function getColumnMeta(col: AnyPgColumn): DrizzleColumnMeta;
25
22
  export declare function getCollectionByPath(collectionPath: string, registry: PostgresCollectionRegistry): CollectionConfig;
26
23
  export declare function getTableForCollection(collection: CollectionConfig, registry: PostgresCollectionRegistry): PgTable<any>;
27
- export declare function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): PrimaryKeyInfo[];
24
+ export declare function getPrimaryKeys(collection: CollectionConfig, registry: PostgresCollectionRegistry): {
25
+ fieldName: string;
26
+ type: "string" | "number";
27
+ isUUID?: boolean;
28
+ }[];
29
+ export declare function parseIdValues(idValue: string | number, primaryKeys: {
30
+ fieldName: string;
31
+ type: "string" | "number";
32
+ isUUID?: boolean;
33
+ }[]): Record<string, string | number>;
34
+ export declare function buildCompositeId(values: Record<string, unknown>, primaryKeys: {
35
+ fieldName: string;
36
+ type: "string" | "number";
37
+ isUUID?: boolean;
38
+ }[]): string;
@@ -82,9 +82,7 @@ export declare class DataService implements DataRepository {
82
82
  /**
83
83
  * Save an row (create or update)
84
84
  */
85
- save<M extends Record<string, unknown>>(collectionPath: string, values: Partial<M>, id?: string | number, databaseId?: string, options?: {
86
- upsert?: boolean;
87
- }): Promise<Record<string, unknown>>;
85
+ save<M extends Record<string, unknown>>(collectionPath: string, values: Partial<M>, id?: string | number, databaseId?: string): Promise<Record<string, unknown>>;
88
86
  /**
89
87
  * Delete an row by ID
90
88
  */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/server-postgres",
3
3
  "type": "module",
4
- "version": "0.9.1-canary.ad25bc0",
4
+ "version": "0.9.1-canary.ad870eb",
5
5
  "description": "PostgreSQL data source backend implementation for Rebase with Drizzle ORM",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -69,13 +69,14 @@
69
69
  "dotenv": "^17.4.2",
70
70
  "drizzle-orm": "^0.45.2",
71
71
  "execa": "^9.6.1",
72
+ "hono": "^4.12.25",
72
73
  "pg": "^8.21.0",
73
74
  "ws": "^8.21.0",
74
- "@rebasepro/codegen": "0.9.1-canary.ad25bc0",
75
- "@rebasepro/types": "0.9.1-canary.ad25bc0",
76
- "@rebasepro/utils": "0.9.1-canary.ad25bc0",
77
- "@rebasepro/server": "0.9.1-canary.ad25bc0",
78
- "@rebasepro/common": "0.9.1-canary.ad25bc0"
75
+ "@rebasepro/common": "0.9.1-canary.ad870eb",
76
+ "@rebasepro/server": "0.9.1-canary.ad870eb",
77
+ "@rebasepro/codegen": "0.9.1-canary.ad870eb",
78
+ "@rebasepro/utils": "0.9.1-canary.ad870eb",
79
+ "@rebasepro/types": "0.9.1-canary.ad870eb"
79
80
  },
80
81
  "devDependencies": {
81
82
  "@types/jest": "^30.0.0",
@@ -99,7 +100,7 @@
99
100
  ],
100
101
  "scripts": {
101
102
  "watch": "vite build --watch",
102
- "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json && node ../../scripts/assert-build-output.mjs",
103
+ "build": "vite build && tsc --emitDeclarationOnly -p tsconfig.prod.json",
103
104
  "test:lint": "eslint \"src/**\" --quiet",
104
105
  "test": "jest --passWithNoTests",
105
106
  "test:e2e": "vitest run --config vitest.e2e.config.ts",
@@ -17,7 +17,6 @@ import {
17
17
  RebaseData,
18
18
  RebaseSdkData,
19
19
  RestFetchService,
20
- SaveManyProps,
21
20
  SaveProps,
22
21
  TableColumnInfo,
23
22
  TableForeignKeyInfo,
@@ -533,8 +532,7 @@ export class PostgresBackendDriver implements DataDriver {
533
532
  id,
534
533
  values,
535
534
  collection,
536
- status,
537
- upsert
535
+ status
538
536
  }: SaveProps<M>): Promise<Record<string, unknown>> {
539
537
 
540
538
  const {
@@ -618,8 +616,7 @@ export class PostgresBackendDriver implements DataDriver {
618
616
  path,
619
617
  updatedValues,
620
618
  id,
621
- resolvedCollection?.databaseId,
622
- { upsert }
619
+ resolvedCollection?.databaseId
623
620
  );
624
621
 
625
622
  if (savedRow && (globalCallbacks?.afterRead || callbacks?.afterRead || propertyCallbacks?.afterRead)) {
@@ -767,76 +764,6 @@ export class PostgresBackendDriver implements DataDriver {
767
764
  }
768
765
  }
769
766
 
770
- /**
771
- * Write many rows through the same pipeline as {@link save}.
772
- *
773
- * The batch runs in one transaction of its own, so a failure part-way leaves
774
- * nothing behind — the point of a batch is that a re-run starts from a known
775
- * state. When this driver is already inside a transaction (the authenticated
776
- * path, via `withTransaction`) the nested call becomes a savepoint, which is
777
- * still atomic and still commits once.
778
- *
779
- * Rows are applied in order, so a batch that touches the same key twice ends
780
- * with the last write winning, exactly as separate calls would.
781
- */
782
- async saveMany<M extends Record<string, unknown>>({
783
- path,
784
- rows,
785
- collection,
786
- upsert
787
- }: SaveManyProps<M>): Promise<Record<string, unknown>[]> {
788
- return this.db.transaction(async (tx) => {
789
- // Bind the whole batch to the transaction handle. Without this the
790
- // rows would be written through `this.db` and survive a rollback.
791
- const txDriver = new PostgresBackendDriver(
792
- tx, this.realtimeService, this.registry, this.user, this.poolManager, this.historyService
793
- );
794
- txDriver.dataService = new DataService(tx, this.registry);
795
- txDriver.client = this.client;
796
- // Carry the caller's notification batching through, so a bulk write
797
- // nested in an outer transaction still holds its events until commit.
798
- txDriver._deferNotifications = this._deferNotifications;
799
- txDriver._pendingNotifications = this._pendingNotifications;
800
-
801
- const saved: Record<string, unknown>[] = [];
802
-
803
- for (let i = 0; i < rows.length; i++) {
804
- const values = rows[i];
805
- const id = (values as Record<string, unknown>)?.id as string | number | undefined;
806
- try {
807
- saved.push(await txDriver.save<M>({
808
- path,
809
- values,
810
- // No `id` argument, deliberately: passing one selects the
811
- // UPDATE path, and an import's rows usually carry a natural
812
- // key for a row that does not exist yet — which would 404 on
813
- // every one. Leaving the key inside `values` is what
814
- // single-row `create(data, id)` does, and it inserts.
815
- // Callers who want existing rows overwritten pass `upsert`.
816
- collection,
817
- status: "new",
818
- upsert
819
- }));
820
- } catch (error) {
821
- // One bad row in ten thousand is impossible to find from a
822
- // message that only says the batch failed. Say which row, and
823
- // keep the original error as the cause so its status survives.
824
- const label = id !== undefined ? `id ${JSON.stringify(id)}` : "no id";
825
- throw Object.assign(
826
- new Error(`Row ${i} of ${rows.length} (${label}) failed: ${(error as Error)?.message ?? error}`, { cause: error }),
827
- {
828
- statusCode: (error as { statusCode?: number })?.statusCode,
829
- code: (error as { code?: string })?.code,
830
- name: (error as Error)?.name
831
- }
832
- );
833
- }
834
- }
835
-
836
- return saved;
837
- });
838
- }
839
-
840
767
  async delete<M extends Record<string, unknown>>({
841
768
  row,
842
769
  collection
@@ -1435,19 +1362,6 @@ export class AuthenticatedPostgresBackendDriver implements DataDriver {
1435
1362
  return this.withTransaction((delegate) => delegate.save(props));
1436
1363
  }
1437
1364
 
1438
- /**
1439
- * One transaction for the whole batch, rather than one per row.
1440
- *
1441
- * This is the point of the method: `save` opens a transaction per call, so
1442
- * importing 10k rows through it means 10k transactions (and, over HTTP, 10k
1443
- * round trips). Here the RLS context is established once and every row lands
1444
- * or none does. Realtime notifications are already deferred to commit by
1445
- * `withTransaction`, so a batch does not flood subscribers mid-flight.
1446
- */
1447
- async saveMany<M extends Record<string, unknown>>(props: SaveManyProps<M>): Promise<Record<string, unknown>[]> {
1448
- return this.withTransaction((delegate) => delegate.saveMany(props));
1449
- }
1450
-
1451
1365
  async delete<M extends Record<string, unknown>>(props: DeleteProps<M>): Promise<void> {
1452
1366
  return this.withTransaction((delegate) => delegate.delete(props));
1453
1367
  }
@@ -0,0 +1,132 @@
1
+ import { CollectionConfig, SecurityRule, SecurityOperation, AuthCollectionConfig, PolicyExpression, isPostgresCollectionConfig, policy } from "@rebasepro/types";
2
+ import { getTableName } from "@rebasepro/common";
3
+
4
+ /**
5
+ * Default RLS policies injected by the schema generator.
6
+ *
7
+ * Rebase's enforcement model is unified: authenticated (user-context) requests
8
+ * run under the restricted `rebase_user` role, so Postgres RLS binds *every*
9
+ * statement — reads and writes. A collection's `securityRules` are the whole
10
+ * authorization model. The server context (auth flows, migrations,
11
+ * `dataAsAdmin`) runs as the owner and bypasses RLS.
12
+ *
13
+ * Because RLS default-denies, every collection is **locked by default**: with
14
+ * no rules, only the server context and admins can touch it. The generator
15
+ * injects that safe baseline:
16
+ *
17
+ * **For every collection**
18
+ * 1. A permissive **server-or-admin SELECT** grant.
19
+ * 2. A permissive **server-or-admin write** grant (insert/update/delete).
20
+ *
21
+ * Author `securityRules` are permissive and OR together, so explicit rules only
22
+ * *broaden* access from this locked baseline (e.g. "users read/write their own
23
+ * rows").
24
+ *
25
+ * **For auth collections additionally**
26
+ * 3. A permissive **self SELECT** grant (`id = auth.uid()`), so users can read
27
+ * their own row (profile, session bootstrap) without every app re-declaring
28
+ * it.
29
+ * 4. A **restrictive** admin write gate. Restrictive policies are AND'd with
30
+ * every other policy, so a write is rejected unless the caller is an admin
31
+ * (or the server context) — even if the author also wrote a permissive rule
32
+ * such as "a user may edit their own row". Without this, a permissive owner
33
+ * rule would let a user change their own `roles`.
34
+ *
35
+ * The server context is recognised as `auth.uid() IS NULL` (`policy.serverContext()`)
36
+ * — the built-in flows that run without a user (signup, migrations) set no user
37
+ * GUC — which also lets the owner connection satisfy these policies even under
38
+ * FORCE RLS. A *user* request never reaches that state: an anonymous one carries
39
+ * `ANONYMOUS_USER_ID`, precisely so it cannot pass for the server here.
40
+ *
41
+ * Opt out with `disableDefaultPolicies: true` to take full responsibility for
42
+ * the collection's RLS.
43
+ */
44
+ // Expressed structurally (not as raw SQL) so the admin UI can evaluate it
45
+ // exactly — the framework's most security-critical policies must be reflected
46
+ // precisely, not left as un-evaluable raw clauses. Compiles to
47
+ // `auth.uid() IS NULL OR (string_to_array(auth.roles(), ',') && ARRAY['admin'])`.
48
+ //
49
+ // `serverContext()`, emphatically not `not(authenticated())`: the server arm of
50
+ // this grant must match the server context and nothing else. Anonymous visitors
51
+ // are not signed in either, so a negated `authenticated()` would hand them the
52
+ // server-or-admin grant on every collection's default policy.
53
+ const SERVER_OR_ADMIN_EXPR: PolicyExpression = policy.or(
54
+ policy.serverContext(),
55
+ policy.rolesOverlap(["admin"])
56
+ );
57
+
58
+ /** Write operations that must be admin-gated by default on auth collections. */
59
+ const DEFAULT_GUARDED_OPS: SecurityOperation[] = ["insert", "update", "delete"];
60
+
61
+ /** Whether a collection is flagged as an authentication collection. */
62
+ function isAuthCollection(collection: CollectionConfig): boolean {
63
+ const auth = collection.auth;
64
+ return auth === true || (typeof auth === "object" && (auth as AuthCollectionConfig)?.enabled === true);
65
+ }
66
+
67
+ /** The property marked as the row id (falls back to `id`). */
68
+ function getIdPropertyName(collection: CollectionConfig): string {
69
+ for (const [name, prop] of Object.entries(collection.properties ?? {})) {
70
+ if (prop && typeof prop === "object" && "isId" in prop && (prop as { isId?: unknown }).isId) {
71
+ return name;
72
+ }
73
+ }
74
+ return "id";
75
+ }
76
+
77
+ /**
78
+ * Returns the security rules that should be applied to a collection: the
79
+ * author's explicit `securityRules` plus the framework defaults described in
80
+ * the module doc (baseline server/admin read for all collections; self-read
81
+ * and the admin write gate for auth collections).
82
+ *
83
+ * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
84
+ */
85
+ export function getEffectiveSecurityRules(collection: CollectionConfig): SecurityRule[] {
86
+ const explicit = [...((isPostgresCollectionConfig(collection) ? collection.securityRules : undefined) ?? [])];
87
+
88
+ if (collection.disableDefaultPolicies) {
89
+ return explicit;
90
+ }
91
+
92
+ const tableName = getTableName(collection);
93
+ const injected: SecurityRule[] = [];
94
+
95
+ // Baseline read + write: the server context and admins can always operate.
96
+ // RLS default-denies under the user role, so without these a rule-less
97
+ // collection would be locked to everyone — including the admin studio.
98
+ // Author rules are permissive and broaden access from here.
99
+ injected.push({
100
+ name: `${tableName}_default_admin_read`,
101
+ operations: ["select"],
102
+ condition: SERVER_OR_ADMIN_EXPR
103
+ });
104
+ injected.push({
105
+ name: `${tableName}_default_admin_write`,
106
+ operations: [...DEFAULT_GUARDED_OPS],
107
+ condition: SERVER_OR_ADMIN_EXPR,
108
+ check: SERVER_OR_ADMIN_EXPR
109
+ });
110
+
111
+ if (isAuthCollection(collection)) {
112
+ // Self-read: a user can always read their own row.
113
+ injected.push({
114
+ name: `${tableName}_default_self_read`,
115
+ operations: ["select"],
116
+ condition: policy.compare(policy.field(getIdPropertyName(collection)), "eq", policy.authUid())
117
+ });
118
+
119
+ // Restrictive gate: AND'd with all other policies, so no permissive rule
120
+ // (e.g. an owner "edit your own row" rule) can let a non-admin change
121
+ // privileged columns like `roles`.
122
+ injected.push({
123
+ name: `${tableName}_require_admin_write`,
124
+ mode: "restrictive",
125
+ operations: [...DEFAULT_GUARDED_OPS],
126
+ condition: SERVER_OR_ADMIN_EXPR,
127
+ check: SERVER_OR_ADMIN_EXPR
128
+ });
129
+ }
130
+
131
+ return [...explicit, ...injected];
132
+ }
@@ -1,8 +1,10 @@
1
1
  import { CollectionConfig, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
2
2
  import { getPrimaryKeys } from "../services/collection-helpers";
3
- import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules } from "@rebasepro/common";
4
- import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
3
+ import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres } from "@rebasepro/common";
4
+ import { toSnakeCase } from "@rebasepro/utils";
5
+ import { createHash } from "crypto";
5
6
  import { logger } from "@rebasepro/server";
7
+ import { getEffectiveSecurityRules } from "./auth-default-policies";
6
8
  // --- Helper Functions ---
7
9
 
8
10
  /**
@@ -313,6 +315,22 @@ const wrapSql = (clause: string): string => `sql\`${clause}\``;
313
315
  /**
314
316
  * Generates a deterministic hash based on the rule configuration.
315
317
  */
318
+ const getPolicyNameHash = (rule: SecurityRule): string => {
319
+ const data = JSON.stringify({
320
+ a: rule.access,
321
+ m: rule.mode,
322
+ op: rule.operation,
323
+ ops: rule.operations?.slice().sort(),
324
+ own: rule.ownerField,
325
+ rol: rule.roles?.slice().sort(),
326
+ pg: rule.pgRoles?.slice().sort(),
327
+ u: rule.using,
328
+ w: rule.withCheck,
329
+ c: rule.condition,
330
+ ch: rule.check
331
+ });
332
+ return createHash("sha1").update(data).digest("hex").substring(0, 7);
333
+ };
316
334
 
317
335
  /**
318
336
  * Generates Drizzle pgPolicy() calls from a declarative SecurityRule definition.
@@ -333,11 +351,15 @@ const generatePolicyCode = (collection: CollectionConfig, rule: SecurityRule, in
333
351
  ? rule.operations
334
352
  : [rule.operation ?? "all"];
335
353
 
336
- const policyNames = getPolicyNamesForRule(rule, tableName);
354
+ const ruleHash = getPolicyNameHash(rule);
337
355
 
338
356
  // Generate one pgPolicy per operation
339
357
  return ops.map((op, opIdx) => {
340
- return generateSinglePolicyCode(collection, rule, op, policyNames[opIdx], resolveCollection);
358
+ const policyName = rule.name
359
+ ? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
360
+ : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`;
361
+
362
+ return generateSinglePolicyCode(collection, rule, op, policyName, resolveCollection);
341
363
  }).join("");
342
364
  };
343
365
 
@@ -1,6 +1,8 @@
1
1
  import { CollectionConfig, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
2
- import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules } from "@rebasepro/common";
3
- import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
2
+ import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres } from "@rebasepro/common";
3
+ import { toSnakeCase } from "@rebasepro/utils";
4
+ import { createHash } from "crypto";
5
+ import { getEffectiveSecurityRules } from "./auth-default-policies";
4
6
 
5
7
  // --- Helper Functions ---
6
8
 
@@ -42,6 +44,22 @@ const isIdProperty = (propName: string, prop: Property, collection: CollectionCo
42
44
  return !hasExplicitId && propName === "id";
43
45
  };
44
46
 
47
+ const getPolicyNameHash = (rule: SecurityRule): string => {
48
+ const data = JSON.stringify({
49
+ a: rule.access,
50
+ m: rule.mode,
51
+ op: rule.operation,
52
+ ops: rule.operations?.slice().sort(),
53
+ own: rule.ownerField,
54
+ rol: rule.roles?.slice().sort(),
55
+ pg: rule.pgRoles?.slice().sort(),
56
+ u: rule.using,
57
+ w: rule.withCheck,
58
+ c: rule.condition,
59
+ ch: rule.check
60
+ });
61
+ return createHash("sha1").update(data).digest("hex").substring(0, 7);
62
+ };
45
63
 
46
64
  type ResolveCollection = (slug: string) => CollectionConfig | undefined;
47
65
 
@@ -51,10 +69,14 @@ const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, res
51
69
  ? rule.operations
52
70
  : [rule.operation ?? "all"];
53
71
 
54
- const policyNames = getPolicyNamesForRule(rule, tableName);
72
+ const ruleHash = getPolicyNameHash(rule);
55
73
 
56
74
  return ops.map((op, opIdx) => {
57
- return generateSinglePolicyDdl(collection, rule, op, policyNames[opIdx], resolveCollection);
75
+ const policyName = rule.name
76
+ ? (ops.length > 1 ? `${rule.name}_${op}` : rule.name)
77
+ : `${tableName}_${op}_${ruleHash}${ops.length > 1 ? `_${opIdx}` : ""}`;
78
+
79
+ return generateSinglePolicyDdl(collection, rule, op, policyName, resolveCollection);
58
80
  }).join("");
59
81
  };
60
82
 
@@ -456,17 +478,7 @@ export const generatePostgresPoliciesDdl = (collections: CollectionConfig[]): st
456
478
  const securityRules = getEffectiveSecurityRules(collection);
457
479
  if (securityRules.length > 0) {
458
480
  const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
459
- const injectedNames = new Set(getInjectedSecurityRules(collection).map((rule) => rule.name));
460
-
461
481
  securityRules.forEach((rule: SecurityRule) => {
462
- // Say which policies the author did not write. They are permissive,
463
- // so they OR with the declared rules and widen the final ACL beyond
464
- // what `securityRules` reads like — and re-appear after any manual
465
- // DROP, because a push asserts the declared state.
466
- if (rule.name && injectedNames.has(rule.name)) {
467
- ddl += `-- Injected by Rebase (not from this collection's securityRules).\n`;
468
- ddl += `-- Set \`disableDefaultPolicies: true\` on "${collection.slug}" to drop these and own its RLS outright.\n`;
469
- }
470
482
  ddl += generatePolicyDdl(collection, rule, resolveCollection);
471
483
  });
472
484
  ddl += "\n";
@@ -11,45 +11,10 @@ import { sql } from "drizzle-orm";
11
11
  import { BranchInfo } from "@rebasepro/types";
12
12
  import { DrizzleClient } from "../interfaces";
13
13
  import { DatabasePoolManager } from "../databasePoolManager";
14
- import { extractPgError, extractCauseMessage } from "../utils/pg-error-utils";
15
14
 
16
15
  /** Internal prefix applied to branch database names to avoid collisions. */
17
16
  const BRANCH_DB_PREFIX = "rb_";
18
17
 
19
- /** `duplicate_database` — the target database name is already taken. */
20
- const PG_DUPLICATE_DATABASE = "42P04";
21
-
22
- /** `object_in_use` — the database still has connections attached. */
23
- const PG_OBJECT_IN_USE = "55006";
24
-
25
- /**
26
- * Describe a failed branch DDL statement in terms a user can act on.
27
- *
28
- * Drizzle reports failures as `Failed query: <sql> params:` and hides the real
29
- * PostgreSQL error in the `cause` chain, so matching on `err.message` never sees
30
- * the actual problem. Match on the PG error code instead — it survives wrapping
31
- * and, unlike the message text, is not locale-dependent.
32
- */
33
- function describeBranchDdlError(err: unknown, fallbackContext: string): Error {
34
- const pgError = extractPgError(err);
35
-
36
- if (pgError?.code === PG_DUPLICATE_DATABASE) {
37
- return new Error(`Database "${fallbackContext}" already exists on the server. Choose a different branch name.`);
38
- }
39
- if (pgError?.code === PG_OBJECT_IN_USE) {
40
- return new Error(
41
- `Cannot complete the operation: the database "${fallbackContext}" has active connections. ` +
42
- "Close other clients or connections and try again."
43
- );
44
- }
45
-
46
- // Unknown failure: surface the real PG message rather than the Drizzle
47
- // wrapper, which would otherwise show the raw SQL and no reason at all.
48
- const detail = pgError?.message ?? extractCauseMessage(err);
49
- if (detail) return new Error(detail);
50
- return err instanceof Error ? err : new Error(String(err));
51
- }
52
-
53
18
  /** Fully-qualified metadata table in the rebase schema. */
54
19
  const BRANCHES_TABLE = "rebase.branches";
55
20
 
@@ -144,15 +109,18 @@ export class BranchService {
144
109
  sql.raw(`CREATE DATABASE "${safeDbName}" TEMPLATE "${safeSourceDb}"`)
145
110
  );
146
111
  } catch (err) {
147
- const pgError = extractPgError(err);
148
- if (pgError?.code === PG_OBJECT_IN_USE) {
149
- // The template not the new database is the one still in use.
112
+ const msg = err instanceof Error ? err.message : String(err);
113
+ if (msg.includes("already exists")) {
114
+ throw new Error(`Database "${dbName}" already exists on the server. Choose a different branch name.`);
115
+ }
116
+ // If template fails due to active connections, provide a helpful error
117
+ if (msg.includes("being accessed by other users")) {
150
118
  throw new Error(
151
119
  `Cannot create branch: the source database "${sourceDb}" has active connections. ` +
152
120
  "Close other clients or connections and try again."
153
121
  );
154
122
  }
155
- throw describeBranchDdlError(err, dbName);
123
+ throw err;
156
124
  }
157
125
 
158
126
  // Record metadata in the default database
@@ -198,14 +166,14 @@ export class BranchService {
198
166
  try {
199
167
  await this.db.execute(sql.raw(`DROP DATABASE "${safeDbName}"`));
200
168
  } catch (err) {
201
- const pgError = extractPgError(err);
202
- if (pgError?.code === PG_OBJECT_IN_USE) {
169
+ const msg = err instanceof Error ? err.message : String(err);
170
+ if (msg.includes("being accessed by other users")) {
203
171
  throw new Error(
204
172
  `Cannot delete branch "${sanitizedName}": the database has active connections. ` +
205
173
  "Close other clients and try again."
206
174
  );
207
175
  }
208
- throw describeBranchDdlError(err, dbName);
176
+ throw err;
209
177
  }
210
178
 
211
179
  // Remove metadata