@rebasepro/server-postgres 0.0.1-canary.fc811d7 → 0.9.1-canary.0fce67c

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 (56) hide show
  1. package/dist/PostgresBackendDriver.d.ts +25 -2
  2. package/dist/PostgresBootstrapper.d.ts +10 -0
  3. package/dist/auth/services.d.ts +16 -0
  4. package/dist/collections/buildRegistry.d.ts +27 -0
  5. package/dist/connection.d.ts +21 -0
  6. package/dist/data-transformer.d.ts +9 -2
  7. package/dist/index.es.js +2005 -2583
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/module-dir.d.ts +1 -0
  10. package/dist/schema/doctor.d.ts +1 -1
  11. package/dist/security/policy-drift.d.ts +16 -5
  12. package/dist/security/rls-enforcement.d.ts +27 -2
  13. package/dist/services/FetchService.d.ts +4 -24
  14. package/dist/services/PersistService.d.ts +27 -1
  15. package/dist/services/RelationService.d.ts +34 -1
  16. package/dist/services/collection-helpers.d.ts +79 -14
  17. package/dist/services/dataService.d.ts +3 -1
  18. package/dist/services/index.d.ts +1 -1
  19. package/dist/services/realtimeService.d.ts +7 -0
  20. package/dist/services/row-pipeline.d.ts +63 -0
  21. package/package.json +15 -17
  22. package/src/PostgresBackendDriver.ts +127 -13
  23. package/src/PostgresBootstrapper.ts +68 -26
  24. package/src/auth/ensure-tables.ts +73 -11
  25. package/src/auth/services.ts +49 -19
  26. package/src/cli-helpers.ts +2 -20
  27. package/src/collections/buildRegistry.ts +59 -0
  28. package/src/connection.ts +61 -1
  29. package/src/data-transformer.ts +11 -9
  30. package/src/databasePoolManager.ts +2 -0
  31. package/src/module-dir.ts +7 -0
  32. package/src/schema/doctor-cli.ts +5 -1
  33. package/src/schema/doctor.ts +45 -20
  34. package/src/schema/generate-drizzle-schema-logic.ts +24 -29
  35. package/src/schema/generate-postgres-ddl-logic.ts +76 -28
  36. package/src/schema/introspect-db.ts +19 -2
  37. package/src/security/anonymous-grants.test.ts +71 -0
  38. package/src/security/policy-drift.test.ts +48 -14
  39. package/src/security/policy-drift.ts +72 -10
  40. package/src/security/rls-enforcement.ts +64 -3
  41. package/src/services/BranchService.ts +42 -10
  42. package/src/services/FetchService.ts +65 -270
  43. package/src/services/PersistService.ts +130 -14
  44. package/src/services/RelationService.ts +153 -94
  45. package/src/services/collection-helpers.ts +164 -47
  46. package/src/services/dataService.ts +3 -2
  47. package/src/services/index.ts +1 -0
  48. package/src/services/realtimeService.ts +40 -19
  49. package/src/services/row-pipeline.ts +239 -0
  50. package/src/utils/drizzle-conditions.ts +13 -0
  51. package/src/websocket.ts +4 -1
  52. package/dist/chunk-DSJWtz9O.js +0 -40
  53. package/dist/schema/auth-default-policies.d.ts +0 -10
  54. package/dist/src-Eh-CZosp.js +0 -595
  55. package/dist/src-Eh-CZosp.js.map +0 -1
  56. package/src/schema/auth-default-policies.ts +0 -125
@@ -1,29 +1,11 @@
1
1
  import path from "path";
2
2
  import fs from "fs";
3
3
  import { execSync } from "child_process";
4
- import { fileURLToPath, pathToFileURL } from "url";
4
+ import { pathToFileURL } from "url";
5
5
  import chalk from "chalk";
6
6
  import { logger } from "@rebasepro/server";
7
7
  import type { CollectionConfig, Relation } from "@rebasepro/types";
8
-
9
- const getHelpersDirname = () => {
10
- try {
11
- return eval("__dirname");
12
- } catch {
13
- const err = new Error();
14
- const stackLine = err.stack?.split("\n")[1] || "";
15
- const match = stackLine.match(/\((.*?):\d+:\d+\)/) || stackLine.match(/at (.*?):\d+:\d+/);
16
- if (match && match[1]) {
17
- let cleanPath = match[1];
18
- if (cleanPath.startsWith("file://")) {
19
- cleanPath = fileURLToPath(cleanPath);
20
- }
21
- return path.dirname(cleanPath);
22
- }
23
- return process.cwd();
24
- }
25
- };
26
- const __helpersDirname = getHelpersDirname();
8
+ import { moduleDir as __helpersDirname } from "./module-dir";
27
9
 
28
10
 
29
11
 
@@ -0,0 +1,59 @@
1
+ import { isTable, getTableName, Relations } from "drizzle-orm";
2
+ import { PgEnum, PgTable } from "drizzle-orm/pg-core";
3
+ import { CollectionConfig } from "@rebasepro/types";
4
+ import { logger } from "@rebasepro/server";
5
+ import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
6
+ import { warnOnKeysTheAdminCannotResolve } from "../services/collection-helpers";
7
+
8
+ /**
9
+ * Everything a registry is built from: the collections, and the drizzle schema
10
+ * they are backed by. In BaaS mode all of it is introspected from the live
11
+ * database; in CMS mode it comes from the config and the generated schema.
12
+ */
13
+ export interface RegistrySchema {
14
+ collections?: CollectionConfig[];
15
+ tables?: Record<string, unknown>;
16
+ enums?: Record<string, PgEnum<[string, ...string[]]>>;
17
+ relations?: Record<string, Relations>;
18
+ }
19
+
20
+ /**
21
+ * Build the collection registry for a driver.
22
+ *
23
+ * The order matters and is the reason this is one function rather than a run of
24
+ * statements in the bootstrapper. Keys are resolved from the drizzle schema, so
25
+ * anything that inspects them has to run *after* the tables are registered —
26
+ * and `warnOnKeysTheAdminCannotResolve` fails open if it does not, because a
27
+ * collection whose table it cannot look up is one it has nothing to say about.
28
+ * Warned too early, it would skip every collection and report nothing, which
29
+ * reads exactly like having nothing to report.
30
+ */
31
+ export function buildCollectionRegistry(schema: RegistrySchema): PostgresCollectionRegistry {
32
+ const registry = new PostgresCollectionRegistry();
33
+
34
+ if (schema.collections) {
35
+ registry.registerMultiple(schema.collections);
36
+ logger.info(
37
+ `📋 [PostgresRegistry] Registered ${registry.getCollections().length} collections: ` +
38
+ `[${registry.getCollections().map(c => c.slug).join(", ")}]`
39
+ );
40
+ }
41
+
42
+ if (schema.tables) {
43
+ Object.values(schema.tables).forEach((table) => {
44
+ if (isTable(table)) {
45
+ registry.registerTable(table as PgTable, getTableName(table));
46
+ }
47
+ });
48
+ }
49
+
50
+ if (schema.enums) registry.registerEnums(schema.enums);
51
+ if (schema.relations) registry.registerRelations(schema.relations);
52
+
53
+ // Now that the keys resolve: say which of them the admin cannot see. It
54
+ // compiles the same collection files into its bundle but never the drizzle
55
+ // schema, and nothing serves it one, so only an edit to the config fixes it.
56
+ warnOnKeysTheAdminCannotResolve(registry.getCollections(), registry);
57
+
58
+ return registry;
59
+ }
package/src/connection.ts CHANGED
@@ -27,11 +27,68 @@ const DEFAULT_POOL: Required<PostgresPoolConfig> = {
27
27
  max: 20,
28
28
  idleTimeoutMillis: 30_000,
29
29
  connectionTimeoutMillis: 10_000,
30
- queryTimeout: 30_000,
30
+ // The client-side read timeout MUST be comfortably above the server-side
31
+ // statement_timeout. When the client timer fires first, node-postgres
32
+ // abandons the in-flight statement but keeps the connection — inside a
33
+ // transaction that leaves the tx open (and any pending ROLLBACK is
34
+ // spliced out of the client queue before it ever reaches the wire), so
35
+ // the pooled connection is returned still in-transaction with its RLS
36
+ // GUCs set. The server abort (SQLSTATE 57014) is the clean path; the
37
+ // client timeout is only a backstop for a dead network.
38
+ queryTimeout: 60_000,
31
39
  statementTimeout: 30_000,
32
40
  keepAlive: true
33
41
  };
34
42
 
43
+ /** ReadyForQuery status byte: `I` idle, `T` in transaction, `E` failed transaction. */
44
+ const TX_IDLE = "I";
45
+
46
+ /**
47
+ * Destroy pool clients that are released while still inside a transaction.
48
+ *
49
+ * pg-pool returns a client to the idle list whenever `release()` is called
50
+ * without an error — even if the connection is still mid-transaction (status
51
+ * `T`/`E`). That happens in practice: drizzle's pool transaction releases in
52
+ * a `finally` after attempting ROLLBACK, and if the ROLLBACK itself fails
53
+ * (e.g. it was queued behind a statement that hit the client-side
54
+ * query_timeout), the client goes back dirty. The next checkout then runs
55
+ * its statements inside the zombie transaction — with the previous request's
56
+ * `app.*` RLS GUCs still applied, which turns unrelated queries into
57
+ * RLS-scoped ones (observed in production as registration failing with
58
+ * SQLSTATE 42501 under a leaked anonymous context).
59
+ *
60
+ * pg-pool emits `release` before it consults its private `_expired` set, so
61
+ * marking the client expired here makes `_release()` destroy it instead of
62
+ * pooling it. Both `client._txStatus` (pg ≥ 8.16) and `pool._expired` are
63
+ * private APIs — feature-detect and fall back to loud logging so an upstream
64
+ * change degrades to observability, never to silent corruption.
65
+ */
66
+ export function guardPoolAgainstDirtyRelease(pool: Pool, label: string): void {
67
+ pool.on("release", (err: Error | undefined, client: unknown) => {
68
+ if (err) return; // errored clients are already destroyed by pg-pool
69
+ const txStatus = (client as { _txStatus?: string | null })?._txStatus;
70
+ if (typeof txStatus !== "string" || txStatus === TX_IDLE) return;
71
+
72
+ // pg-pool keeps expired clients in a WeakSet (a plain object works too
73
+ // if upstream ever changes it — duck-type on add/has).
74
+ const expired = (pool as unknown as { _expired?: { add(c: object): unknown; has(c: object): boolean } })._expired;
75
+ if (expired && typeof expired.add === "function" && typeof expired.has === "function" && client && typeof client === "object") {
76
+ expired.add(client);
77
+ logger.error(
78
+ `[${label}] Client released back to the pool while still in a transaction ` +
79
+ `(status '${txStatus}') — destroying it so the open transaction and its ` +
80
+ `session state (RLS GUCs) cannot leak into the next request.`
81
+ );
82
+ } else {
83
+ logger.error(
84
+ `[${label}] Client released mid-transaction (status '${txStatus}') but the ` +
85
+ `pool's internal expiry set is unavailable (pg-pool internals changed?). ` +
86
+ `The connection may leak its open transaction into subsequent requests.`
87
+ );
88
+ }
89
+ });
90
+ }
91
+
35
92
  /**
36
93
  * Create a Drizzle-backed Postgres connection with a production-grade
37
94
  * connection pool.
@@ -75,6 +132,7 @@ export function createPostgresDatabaseConnection(
75
132
  logger.warn("[pg-pool] Connection timeout detected — pool will auto-retry");
76
133
  }
77
134
  });
135
+ guardPoolAgainstDirtyRelease(pool, "pg-pool");
78
136
 
79
137
  // Create drizzle instance — pass schema when available to enable db.query relational API
80
138
  const db = schema ? drizzle(pool, { schema }) : drizzle(pool);
@@ -118,6 +176,7 @@ export function createDirectDatabaseConnection(
118
176
  pool.on("error", (err) => {
119
177
  logger.error("[pg-direct-pool] Unexpected pool error", { detail: err.message });
120
178
  });
179
+ guardPoolAgainstDirtyRelease(pool, "pg-direct-pool");
121
180
 
122
181
  const db = schema ? drizzle(pool, { schema }) : drizzle(pool);
123
182
 
@@ -157,6 +216,7 @@ export function createReadReplicaConnection(
157
216
  pool.on("error", (err) => {
158
217
  logger.error("[pg-replica-pool] Unexpected pool error", { detail: err.message });
159
218
  });
219
+ guardPoolAgainstDirtyRelease(pool, "pg-replica-pool");
160
220
 
161
221
  const db = schema ? drizzle(pool, { schema }) : drizzle(pool);
162
222
 
@@ -20,12 +20,19 @@ import { logger } from "@rebasepro/server";
20
20
  export interface SerializedEntityData {
21
21
  /** Scalar column values ready for INSERT/UPDATE. */
22
22
  scalarData: Record<string, unknown>;
23
- /** Inverse relation updates that must be applied to target tables. */
23
+ /**
24
+ * Inverse relation updates that must be applied to target tables.
25
+ *
26
+ * No address here: the row being written does not know its own. These are
27
+ * applied by `PersistService`, which addresses them with the id it holds —
28
+ * the one it was given for an update, or the one the INSERT returned — and
29
+ * that is the authority. This item used to carry a `currentId` derived from
30
+ * the *input* values, which nothing ever read.
31
+ */
24
32
  inverseRelationUpdates: Array<{
25
33
  relationKey: string;
26
34
  relation: Relation;
27
35
  newValue: unknown;
28
- currentId?: string | number;
29
36
  }>;
30
37
  /** JoinPath relation updates that require multi-hop writes. */
31
38
  joinPathRelationUpdates: Array<{
@@ -105,7 +112,6 @@ joinPathRelationUpdates: [] };
105
112
  relationKey: string;
106
113
  relation: Relation;
107
114
  newValue: unknown;
108
- currentId?: string | number;
109
115
  }> = [];
110
116
  const joinPathRelationUpdates: Array<{
111
117
  relationKey: string;
@@ -146,12 +152,10 @@ joinPathRelationUpdates: [] };
146
152
  } else if (relation.direction === "inverse" && relation.foreignKeyOnTarget) {
147
153
  // Inverse relation: Need to update the target table's FK
148
154
  const serializedValue = serializePropertyToServer(effectiveValue, property);
149
- const pks = getPrimaryKeys(collection, registry!);
150
155
  inverseRelationUpdates.push({
151
156
  relationKey: key,
152
157
  relation,
153
- newValue: serializedValue,
154
- currentId: (row.id as string | number | undefined) || buildCompositeId(row, pks)
158
+ newValue: serializedValue
155
159
  });
156
160
  // Don't add the original relation property to the result
157
161
  continue;
@@ -170,12 +174,10 @@ joinPathRelationUpdates: [] };
170
174
  });
171
175
  } else {
172
176
  // Many inverse joinPath: capture as inverse relation update
173
- const pks = getPrimaryKeys(collection, registry!);
174
177
  inverseRelationUpdates.push({
175
178
  relationKey: key,
176
179
  relation,
177
- newValue: serializedValue,
178
- currentId: (row.id as string | number | undefined) || buildCompositeId(row, pks)
180
+ newValue: serializedValue
179
181
  });
180
182
  }
181
183
  // Don't add the original relation property to the result
@@ -2,6 +2,7 @@ import { Pool } from "pg";
2
2
  import { drizzle } from "drizzle-orm/node-postgres";
3
3
  import { NodePgDatabase } from "drizzle-orm/node-postgres";
4
4
  import { logger } from "@rebasepro/server";
5
+ import { guardPoolAgainstDirtyRelease } from "./connection";
5
6
 
6
7
  export class DatabasePoolManager {
7
8
  private pools: Map<string, Pool> = new Map();
@@ -50,6 +51,7 @@ export class DatabasePoolManager {
50
51
  pool.on("error", (err) => {
51
52
  logger.error(`[DatabasePoolManager] Unexpected error on idle client for db ${databaseName}`, { error: err });
52
53
  });
54
+ guardPoolAgainstDirtyRelease(pool, `pg-pool:${databaseName}`);
53
55
 
54
56
  this.pools.set(databaseName, pool);
55
57
  return pool;
@@ -0,0 +1,7 @@
1
+ import path from "path";
2
+ import { fileURLToPath } from "url";
3
+
4
+ // Isolated in its own module so the jest CommonJS transform can swap it for a
5
+ // __dirname shim via moduleNameMapper — `import.meta` is a syntax error once
6
+ // ts-jest transpiles to CJS.
7
+ export const moduleDir = path.dirname(fileURLToPath(import.meta.url));
@@ -8,7 +8,7 @@ import chalk from "chalk";
8
8
  import fs from "fs";
9
9
  import { runDoctor, loadCollections } from "./doctor";
10
10
  import { checkPolicyDrift, formatPolicyDrift, hasDrift } from "../security/policy-drift";
11
- import { validatePolicyPgRoles } from "../security/rls-enforcement";
11
+ import { validatePolicyPgRoles, warnOnAnonymousGrants } from "../security/rls-enforcement";
12
12
  import { logger } from "@rebasepro/server";
13
13
 
14
14
  /**
@@ -41,6 +41,10 @@ async function runPolicyChecks(collectionsPath: string, databaseUrl?: string): P
41
41
  logger.error(chalk.red(err instanceof Error ? err.message : String(err)));
42
42
  }
43
43
 
44
+ // A rule that reads as a lockdown but is true for every caller compiles
45
+ // to a grant. Report it without booting a server.
46
+ warnOnAnonymousGrants(collections as never);
47
+
44
48
  const drift = await checkPolicyDrift(pool as never, collections);
45
49
  logger.info("");
46
50
  if (hasDrift(drift)) {
@@ -37,7 +37,7 @@ export type IssueSeverity = "error" | "warning" | "info";
37
37
 
38
38
  export interface DoctorIssue {
39
39
  severity: IssueSeverity;
40
- category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale";
40
+ category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale" | "sdk_not_generated";
41
41
  table?: string;
42
42
  column?: string;
43
43
  expected?: string;
@@ -61,19 +61,29 @@ export function getExpectedColumnType(prop: Property): string | null {
61
61
  const sp = prop as StringProperty;
62
62
  if (sp.enum) return "USER-DEFINED"; // pgEnum → USER-DEFINED in information_schema
63
63
  if ("isId" in sp && sp.isId === "uuid") return "uuid";
64
- if (sp.columnType === "text") return "text";
64
+ if (sp.columnType === "uuid") return "uuid";
65
+ // A markdown/multiline string compiles to `text`, not varchar — the
66
+ // generator treats those UI hints as column-type signals, so the
67
+ // expectation here must too or every such column reads as drift.
68
+ if (sp.columnType === "text" || sp.ui?.markdown || sp.ui?.multiline) return "text";
65
69
  if (sp.columnType === "char") return "character";
66
70
  return "character varying";
67
71
  }
68
72
  case "number": {
69
73
  const np = prop as NumberProperty;
70
- if (np.columnType === "double precision") return "double precision";
71
- if (np.columnType === "real") return "real";
72
- if (np.columnType === "bigint") return "bigint";
73
- if (np.columnType === "serial") return "integer"; // serial is integer under the hood
74
- if (np.columnType === "bigserial") return "bigint";
75
- if (np.columnType === "integer") return "integer";
76
- if (np.columnType === "numeric") return "numeric";
74
+ if (np.columnType) {
75
+ // The generator passes any columnType straight through to drizzle,
76
+ // so mirror that rather than enumerating a subset (which reported
77
+ // drift for anything unlisted, e.g. smallint). Serial types are
78
+ // integers with a sequence default; information_schema reports the
79
+ // underlying width.
80
+ const serialWidths: Record<string, string> = {
81
+ serial: "integer",
82
+ bigserial: "bigint",
83
+ smallserial: "smallint"
84
+ };
85
+ return serialWidths[np.columnType] ?? np.columnType;
86
+ }
77
87
  if (np.validation?.integer || ("isId" in np && np.isId)) return "integer";
78
88
  return "numeric";
79
89
  }
@@ -201,15 +211,18 @@ export async function checkCollectionsVsSdk(
201
211
  ): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
202
212
  const issues: DoctorIssue[] = [];
203
213
 
204
- // Check if SDK file exists
214
+ // The typed SDK is opt-in — nothing in a scaffolded project imports it until
215
+ // you choose to. A project that never generated one isn't drifting, so report
216
+ // it as information rather than a warning; otherwise `doctor` can never come
217
+ // back clean on a fresh project and users learn to ignore its output.
205
218
  if (!fs.existsSync(sdkFilePath)) {
206
219
  issues.push({
207
- severity: "warning",
208
- category: "sdk_stale",
209
- message: `Generated SDK typedefs file does not exist at "${sdkFilePath}".`,
210
- fix: "Run `rebase generate-sdk`"
220
+ severity: "info",
221
+ category: "sdk_not_generated",
222
+ message: "Typed SDK not generated (optional).",
223
+ fix: "Run `rebase generate-sdk` if you want typed collection access"
211
224
  });
212
- return { passed: false,
225
+ return { passed: true,
213
226
  issues };
214
227
  }
215
228
 
@@ -631,11 +644,15 @@ export function renderReport(report: DoctorReport): void {
631
644
  }
632
645
 
633
646
  function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): void {
634
- if (passed) {
647
+ const errorCount = issues.filter((i) => i.severity === "error").length;
648
+ const warnCount = issues.filter((i) => i.severity === "warning").length;
649
+ const infoIssues = issues.filter((i) => i.severity === "info");
650
+
651
+ // Informational notes don't make a phase unhealthy, so key the header off
652
+ // real problems rather than `passed` alone.
653
+ if (errorCount === 0 && warnCount === 0) {
635
654
  logger.info(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
636
655
  } else {
637
- const errorCount = issues.filter((i) => i.severity === "error").length;
638
- const warnCount = issues.filter((i) => i.severity === "warning").length;
639
656
  const parts: string[] = [];
640
657
  if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
641
658
  if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
@@ -643,7 +660,14 @@ function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): voi
643
660
  }
644
661
  logger.info("");
645
662
 
646
- for (const issue of issues) {
663
+ // Notes render as a quiet one-liner, not a full drift box.
664
+ for (const issue of infoIssues) {
665
+ const fixPart = issue.fix ? chalk.gray(` — ${issue.fix}`) : "";
666
+ logger.info(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
667
+ logger.info("");
668
+ }
669
+
670
+ for (const issue of issues.filter((i) => i.severity !== "info")) {
647
671
  const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
648
672
  const categoryLabel = formatCategory(issue.category);
649
673
  logger.info(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
@@ -674,7 +698,8 @@ function formatCategory(cat: DoctorIssue["category"]): string {
674
698
  missing_enum: "Missing Enum",
675
699
  enum_value_mismatch: "Enum Value Mismatch",
676
700
  missing_foreign_key: "Missing Foreign Key",
677
- sdk_stale: "Stale SDK Types"
701
+ sdk_stale: "Stale SDK Types",
702
+ sdk_not_generated: "SDK Types Not Generated"
678
703
  };
679
704
  return labels[cat];
680
705
  }
@@ -1,10 +1,8 @@
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 } from "@rebasepro/common";
4
- import { toSnakeCase } from "@rebasepro/utils";
5
- import { createHash } from "crypto";
3
+ import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
4
+ import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
6
5
  import { logger } from "@rebasepro/server";
7
- import { getEffectiveSecurityRules } from "./auth-default-policies";
8
6
  // --- Helper Functions ---
9
7
 
10
8
  /**
@@ -315,22 +313,6 @@ const wrapSql = (clause: string): string => `sql\`${clause}\``;
315
313
  /**
316
314
  * Generates a deterministic hash based on the rule configuration.
317
315
  */
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
- };
334
316
 
335
317
  /**
336
318
  * Generates Drizzle pgPolicy() calls from a declarative SecurityRule definition.
@@ -351,15 +333,11 @@ const generatePolicyCode = (collection: CollectionConfig, rule: SecurityRule, in
351
333
  ? rule.operations
352
334
  : [rule.operation ?? "all"];
353
335
 
354
- const ruleHash = getPolicyNameHash(rule);
336
+ const policyNames = getPolicyNamesForRule(rule, tableName);
355
337
 
356
338
  // Generate one pgPolicy per operation
357
339
  return ops.map((op, opIdx) => {
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);
340
+ return generateSinglePolicyCode(collection, rule, op, policyNames[opIdx], resolveCollection);
363
341
  }).join("");
364
342
  };
365
343
 
@@ -567,6 +545,10 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
567
545
  });
568
546
  schemaContent += "\n";
569
547
 
548
+ // Junction policy derivation needs every declaring side of each junction,
549
+ // not just the first relation that reached it in the walk below.
550
+ const junctionSpecs = resolveJunctionSpecs(collections);
551
+
570
552
  // 2. Identify all tables (collections and junction tables only)
571
553
  for (const collection of collections) {
572
554
  const tableName = getTableName(collection);
@@ -623,9 +605,22 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
623
605
  schemaContent += `export const ${tableVarName} = ${tableCreator}(\"${baseTableName}\", {\n`;
624
606
  schemaContent += ` ${sourceColumn}: ${sourceColType}(\"${sourceColumn}\").notNull().references(() => ${getTableVarName(getTableName(sourceCollection))}.${sourceId}, ${refOptions}),\n`;
625
607
  schemaContent += ` ${targetColumn}: ${targetColType}(\"${targetColumn}\").notNull().references(() => ${getTableVarName(getTableName(targetCollection))}.${targetId}, ${refOptions}),\n`;
626
- schemaContent += "}, (table) => ({\n";
627
- schemaContent += ` pk: primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] })\n`;
628
- schemaContent += "}));\n\n";
608
+ schemaContent += "}, (table) => ([\n";
609
+ schemaContent += ` primaryKey({ columns: [table.${sourceColumn}, table.${targetColumn}] }),\n`;
610
+
611
+ // Junctions are generated tables like any other: locked by default,
612
+ // with derived policies (reads follow the endpoints, writes follow
613
+ // the declaring side's update rules). RLS is enabled regardless of
614
+ // policy stripping — a bare junction must default-deny, not fail open.
615
+ const junctionSpec = junctionSpecs.get(baseTableName);
616
+ if (!stripPolicies && junctionSpec) {
617
+ const junctionCollection = getJunctionCollectionConfig(junctionSpec);
618
+ const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
619
+ getJunctionSecurityRules(junctionSpec).forEach((rule: SecurityRule, idx: number) => {
620
+ schemaContent += generatePolicyCode(junctionCollection, rule, idx, resolveCollection);
621
+ });
622
+ }
623
+ schemaContent += "])).enableRLS();\n\n";
629
624
  } else if (!isJunction) {
630
625
  const schema = isPostgresCollectionConfig(collection) ? collection.schema : undefined;
631
626
  const tableCreator = schema ? `${schema}Schema.table` : "pgTable";