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

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 (66) hide show
  1. package/README.md +21 -0
  2. package/dist/PostgresBackendDriver.d.ts +43 -2
  3. package/dist/PostgresBootstrapper.d.ts +17 -1
  4. package/dist/auth/services.d.ts +16 -0
  5. package/dist/collections/buildRegistry.d.ts +27 -0
  6. package/dist/connection.d.ts +21 -0
  7. package/dist/data-transformer.d.ts +9 -2
  8. package/dist/index.es.js +2502 -2624
  9. package/dist/index.es.js.map +1 -1
  10. package/dist/module-dir.d.ts +1 -0
  11. package/dist/schema/doctor.d.ts +1 -1
  12. package/dist/schema/introspect-db-logic.d.ts +0 -5
  13. package/dist/schema/introspect-db-naming.d.ts +10 -0
  14. package/dist/security/policy-drift.d.ts +46 -5
  15. package/dist/security/rls-enforcement.d.ts +28 -3
  16. package/dist/services/FetchService.d.ts +4 -24
  17. package/dist/services/PersistService.d.ts +27 -1
  18. package/dist/services/RelationService.d.ts +34 -1
  19. package/dist/services/channel-history.d.ts +118 -0
  20. package/dist/services/collection-helpers.d.ts +79 -14
  21. package/dist/services/dataService.d.ts +3 -1
  22. package/dist/services/index.d.ts +1 -1
  23. package/dist/services/realtimeService.d.ts +76 -2
  24. package/dist/services/row-pipeline.d.ts +63 -0
  25. package/package.json +15 -40
  26. package/src/PostgresBackendDriver.ts +183 -18
  27. package/src/PostgresBootstrapper.ts +86 -27
  28. package/src/auth/ensure-tables.ts +73 -11
  29. package/src/auth/services.ts +49 -19
  30. package/src/cli-helpers.ts +2 -20
  31. package/src/cli.ts +60 -0
  32. package/src/collections/buildRegistry.ts +59 -0
  33. package/src/connection.ts +61 -1
  34. package/src/data-transformer.ts +11 -9
  35. package/src/databasePoolManager.ts +2 -0
  36. package/src/module-dir.ts +7 -0
  37. package/src/schema/doctor-cli.ts +5 -1
  38. package/src/schema/doctor.ts +45 -20
  39. package/src/schema/generate-drizzle-schema-logic.ts +24 -29
  40. package/src/schema/generate-postgres-ddl-logic.ts +76 -28
  41. package/src/schema/introspect-db-inference.ts +1 -1
  42. package/src/schema/introspect-db-logic.ts +1 -10
  43. package/src/schema/introspect-db-naming.ts +15 -0
  44. package/src/schema/introspect-db.ts +19 -2
  45. package/src/schema/introspect-runtime.ts +1 -1
  46. package/src/security/anonymous-grants.test.ts +71 -0
  47. package/src/security/policy-drift.test.ts +153 -14
  48. package/src/security/policy-drift.ts +128 -10
  49. package/src/security/rls-enforcement.ts +67 -6
  50. package/src/services/BranchService.ts +42 -10
  51. package/src/services/FetchService.ts +65 -270
  52. package/src/services/PersistService.ts +130 -14
  53. package/src/services/RelationService.ts +153 -94
  54. package/src/services/channel-history.ts +343 -0
  55. package/src/services/collection-helpers.ts +164 -47
  56. package/src/services/dataService.ts +3 -2
  57. package/src/services/index.ts +1 -0
  58. package/src/services/realtimeService.ts +237 -28
  59. package/src/services/row-pipeline.ts +239 -0
  60. package/src/utils/drizzle-conditions.ts +13 -0
  61. package/src/websocket.ts +34 -12
  62. package/dist/chunk-DSJWtz9O.js +0 -40
  63. package/dist/schema/auth-default-policies.d.ts +0 -10
  64. package/dist/src-Eh-CZosp.js +0 -595
  65. package/dist/src-Eh-CZosp.js.map +0 -1
  66. package/src/schema/auth-default-policies.ts +0 -125
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";
@@ -1,8 +1,6 @@
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 } from "@rebasepro/common";
3
- import { toSnakeCase } from "@rebasepro/utils";
4
- import { createHash } from "crypto";
5
- import { getEffectiveSecurityRules } from "./auth-default-policies";
2
+ import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
3
+ import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
6
4
 
7
5
  // --- Helper Functions ---
8
6
 
@@ -44,22 +42,6 @@ const isIdProperty = (propName: string, prop: Property, collection: CollectionCo
44
42
  return !hasExplicitId && propName === "id";
45
43
  };
46
44
 
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
- };
63
45
 
64
46
  type ResolveCollection = (slug: string) => CollectionConfig | undefined;
65
47
 
@@ -69,14 +51,10 @@ const generatePolicyDdl = (collection: CollectionConfig, rule: SecurityRule, res
69
51
  ? rule.operations
70
52
  : [rule.operation ?? "all"];
71
53
 
72
- const ruleHash = getPolicyNameHash(rule);
54
+ const policyNames = getPolicyNamesForRule(rule, tableName);
73
55
 
74
56
  return ops.map((op, opIdx) => {
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);
57
+ return generateSinglePolicyDdl(collection, rule, op, policyNames[opIdx], resolveCollection);
80
58
  }).join("");
81
59
  };
82
60
 
@@ -248,6 +226,10 @@ export const generatePostgresDdl = async (
248
226
  });
249
227
  if (ddl.endsWith(";\n")) ddl += "\n";
250
228
 
229
+ // Junction policy derivation needs every declaring side of each junction,
230
+ // not just the first relation that reached it in the walk below.
231
+ const junctionSpecs = resolveJunctionSpecs(collections);
232
+
251
233
  const allTablesToGenerate = new Map<string, {
252
234
  collection: CollectionConfig,
253
235
  isJunction?: boolean,
@@ -283,6 +265,11 @@ export const generatePostgresDdl = async (
283
265
 
284
266
  // 3. Generate tables
285
267
  const fkStatements: string[] = [];
268
+ // Policies are emitted after every CREATE TABLE, like the FK constraints:
269
+ // a policy may reference other tables (a junction's derived policies always
270
+ // reference both endpoints; `policy.existsIn` references a join table), and
271
+ // CREATE POLICY validates those relations at creation time.
272
+ const policyStatements: string[] = [];
286
273
  for (const [tableName, {
287
274
  collection,
288
275
  isJunction,
@@ -315,6 +302,25 @@ export const generatePostgresDdl = async (
315
302
 
316
303
  fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${sourceColumn}_fkey" FOREIGN KEY ("${sourceColumn}") REFERENCES "${sourceSchema}"."${sourceTable}" ("${sourceId}") ON DELETE ${onDelete.toUpperCase()};`);
317
304
  fkStatements.push(`ALTER TABLE "${schema}"."${baseTableName}" ADD CONSTRAINT "${baseTableName}_${targetColumn}_fkey" FOREIGN KEY ("${targetColumn}") REFERENCES "${targetSchema}"."${targetTable}" ("${targetId}") ON DELETE ${onDelete.toUpperCase()};`);
305
+
306
+ if (options.includePolicies) {
307
+ // Junction tables are generated tables like any other: locked by
308
+ // default, with derived policies — reads follow the endpoints'
309
+ // visibility, writes follow the declaring side's update rules.
310
+ // Without this they were the one kind of generated table with no
311
+ // RLS at all, readable and writable by every signed-in user.
312
+ ddl += `ALTER TABLE "${schema}"."${baseTableName}" ENABLE ROW LEVEL SECURITY;\n`;
313
+ ddl += `\n`;
314
+
315
+ const spec = junctionSpecs.get(baseTableName);
316
+ if (spec) {
317
+ const junctionCollection = getJunctionCollectionConfig(spec);
318
+ const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
319
+ getJunctionSecurityRules(spec).forEach((rule: SecurityRule) => {
320
+ policyStatements.push(generatePolicyDdl(junctionCollection, rule, resolveCollection));
321
+ });
322
+ }
323
+ }
318
324
  } else if (!isJunction) {
319
325
  ddl += `CREATE TABLE "${schema}"."${baseTableName}" (\n`;
320
326
  const columns: string[] = [];
@@ -436,9 +442,8 @@ export const generatePostgresDdl = async (
436
442
  if (securityRules.length > 0) {
437
443
  const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
438
444
  securityRules.forEach((rule: SecurityRule) => {
439
- ddl += generatePolicyDdl(collection, rule, resolveCollection);
445
+ policyStatements.push(generatePolicyDdl(collection, rule, resolveCollection));
440
446
  });
441
- ddl += "\n";
442
447
  }
443
448
  }
444
449
  }
@@ -449,6 +454,12 @@ export const generatePostgresDdl = async (
449
454
  ddl += fkStatements.join("\n") + "\n\n";
450
455
  }
451
456
 
457
+ if (policyStatements.length > 0) {
458
+ ddl += "-- Row Level Security Policies\n";
459
+ ddl += policyStatements.join("");
460
+ ddl += "\n";
461
+ }
462
+
452
463
  return ddl;
453
464
  };
454
465
 
@@ -478,13 +489,50 @@ export const generatePostgresPoliciesDdl = (collections: CollectionConfig[]): st
478
489
  const securityRules = getEffectiveSecurityRules(collection);
479
490
  if (securityRules.length > 0) {
480
491
  const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
492
+ const injectedNames = new Set(getInjectedSecurityRules(collection).map((rule) => rule.name));
493
+
481
494
  securityRules.forEach((rule: SecurityRule) => {
495
+ // Say which policies the author did not write. They are permissive,
496
+ // so they OR with the declared rules and widen the final ACL beyond
497
+ // what `securityRules` reads like — and re-appear after any manual
498
+ // DROP, because a push asserts the declared state.
499
+ if (rule.name && injectedNames.has(rule.name)) {
500
+ ddl += `-- Injected by Rebase (not from this collection's securityRules).\n`;
501
+ ddl += `-- Set \`disableDefaultPolicies: true\` on "${collection.slug}" to drop these and own its RLS outright.\n`;
502
+ }
482
503
  ddl += generatePolicyDdl(collection, rule, resolveCollection);
483
504
  });
484
505
  ddl += "\n";
485
506
  }
486
507
  }
487
508
 
509
+ // Junction tables are generated from `through` relations, not declared as
510
+ // collections, so the walk above never sees them. They get the same
511
+ // treatment as any generated table: locked by default, with derived
512
+ // policies — reads follow the endpoints, writes follow the declaring
513
+ // side's update rules.
514
+ const junctionSpecs = resolveJunctionSpecs(collections);
515
+ for (const spec of junctionSpecs.values()) {
516
+ ddl += `ALTER TABLE "${spec.schema}"."${spec.table}" ENABLE ROW LEVEL SECURITY;\n`;
517
+ ddl += `\n`;
518
+
519
+ const junctionRules = getJunctionSecurityRules(spec);
520
+ if (junctionRules.length === 0) continue;
521
+
522
+ const junctionCollection = getJunctionCollectionConfig(spec);
523
+ const resolveCollection: ResolveCollection = (slug) => collections.find(c => c.slug === slug || getTableName(c) === slug);
524
+ const declaringSlugs = spec.declaringSides.map(s => s.collection.slug).join('", "');
525
+
526
+ ddl += `-- Derived by Rebase for the junction "${spec.table}" (no collection declares it).\n`;
527
+ ddl += `-- Reads require both endpoint rows to be visible; writes follow the update\n`;
528
+ ddl += `-- rules of "${declaringSlugs}". Set \`disableDefaultPolicies: true\` on the\n`;
529
+ ddl += `-- declaring collection(s) to drop these and police the junction yourself.\n`;
530
+ junctionRules.forEach((rule: SecurityRule) => {
531
+ ddl += generatePolicyDdl(junctionCollection, rule, resolveCollection);
532
+ });
533
+ ddl += "\n";
534
+ }
535
+
488
536
  return ddl;
489
537
  };
490
538
 
@@ -1,4 +1,4 @@
1
- import { humanize } from "./introspect-db-logic";
1
+ import { humanize } from "./introspect-db-naming";
2
2
 
3
3
  export interface InferenceResult {
4
4
  propType?: string; // If the inference changes the base type
@@ -7,6 +7,7 @@
7
7
  * and consumed directly by tests.
8
8
  */
9
9
  import { inferPropertyFromData } from "./introspect-db-inference";
10
+ import { humanize } from "./introspect-db-naming";
10
11
 
11
12
  // ── Typed interfaces for SQL query results ────────────────────────────
12
13
 
@@ -117,16 +118,6 @@ export function singularize(word: string): string {
117
118
  return word;
118
119
  }
119
120
 
120
- /**
121
- * Convert a snake_case name to a human-readable Title Case label.
122
- * e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
123
- */
124
- export function humanize(snakeName: string): string {
125
- return snakeName
126
- .replace(/_/g, " ")
127
- .replace(/\b\w/g, (c) => c.toUpperCase());
128
- }
129
-
130
121
  /**
131
122
  * Convert a snake_case table name to a camelCase + "Collection" variable name.
132
123
  * e.g. "company_token" -> "companyTokenCollection"
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Naming helpers shared by the introspection modules. These live apart from
3
+ * `introspect-db-logic.ts` because the inference pass needs them too, and
4
+ * importing them from there would close a cycle back through this module.
5
+ */
6
+
7
+ /**
8
+ * Convert a snake_case name to a human-readable Title Case label.
9
+ * e.g. "created_at" -> "Created At", "customer_id" -> "Customer Id"
10
+ */
11
+ export function humanize(snakeName: string): string {
12
+ return snakeName
13
+ .replace(/_/g, " ")
14
+ .replace(/\b\w/g, (c) => c.toUpperCase());
15
+ }