@rebasepro/server-postgres 0.9.1-canary.fd3754b → 0.9.1-canary.ff338b5

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 (54) 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/schema/doctor.d.ts +1 -1
  10. package/dist/security/policy-drift.d.ts +46 -5
  11. package/dist/security/rls-enforcement.d.ts +27 -2
  12. package/dist/services/FetchService.d.ts +4 -24
  13. package/dist/services/PersistService.d.ts +27 -1
  14. package/dist/services/RelationService.d.ts +34 -1
  15. package/dist/services/collection-helpers.d.ts +79 -14
  16. package/dist/services/dataService.d.ts +3 -1
  17. package/dist/services/index.d.ts +1 -1
  18. package/dist/services/realtimeService.d.ts +7 -0
  19. package/dist/services/row-pipeline.d.ts +63 -0
  20. package/package.json +16 -17
  21. package/src/PostgresBackendDriver.ts +127 -13
  22. package/src/PostgresBootstrapper.ts +68 -26
  23. package/src/auth/ensure-tables.ts +73 -11
  24. package/src/auth/services.ts +49 -19
  25. package/src/cli-helpers.ts +2 -20
  26. package/src/cli.ts +60 -0
  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/schema/doctor-cli.ts +5 -1
  32. package/src/schema/doctor.ts +45 -20
  33. package/src/schema/generate-drizzle-schema-logic.ts +24 -29
  34. package/src/schema/generate-postgres-ddl-logic.ts +76 -28
  35. package/src/schema/introspect-db.ts +19 -2
  36. package/src/security/policy-drift.test.ts +153 -14
  37. package/src/security/policy-drift.ts +128 -10
  38. package/src/security/rls-enforcement.ts +64 -3
  39. package/src/services/BranchService.ts +42 -10
  40. package/src/services/FetchService.ts +65 -270
  41. package/src/services/PersistService.ts +130 -14
  42. package/src/services/RelationService.ts +153 -94
  43. package/src/services/collection-helpers.ts +164 -47
  44. package/src/services/dataService.ts +3 -2
  45. package/src/services/index.ts +1 -0
  46. package/src/services/realtimeService.ts +40 -19
  47. package/src/services/row-pipeline.ts +239 -0
  48. package/src/utils/drizzle-conditions.ts +13 -0
  49. package/src/websocket.ts +4 -1
  50. package/dist/chunk-DSJWtz9O.js +0 -40
  51. package/dist/schema/auth-default-policies.d.ts +0 -10
  52. package/dist/src-Eh-CZosp.js +0 -595
  53. package/dist/src-Eh-CZosp.js.map +0 -1
  54. 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
 
package/src/cli.ts CHANGED
@@ -176,6 +176,7 @@ async function dbCommand(subcommand: string, rawArgs: string[]): Promise<void> {
176
176
 
177
177
  if (databaseUrl) {
178
178
  await applyPolicies(databaseUrl);
179
+ await reconcilePolicies(databaseUrl, collectionsPath);
179
180
  await ensureRlsUserRole(databaseUrl);
180
181
  } else {
181
182
  logger.warn(chalk.yellow(" ⚠️ DATABASE_URL not found in environment, skipping RLS policies application."));
@@ -269,6 +270,65 @@ async function applyPolicies(databaseUrl: string): Promise<void> {
269
270
  }
270
271
  }
271
272
 
273
+ /**
274
+ * Remove the policies an earlier push superseded but never dropped.
275
+ *
276
+ * `policies.sql` only DROPs the names it is about to CREATE, and a rule's
277
+ * generated name contains a hash of its own semantics — so editing a rule
278
+ * writes a *new* policy and abandons the old one. Postgres ORs PERMISSIVE
279
+ * policies together, which makes an abandoned grant outrank every tightening
280
+ * that replaced it, and push reported success the whole time.
281
+ *
282
+ * Runs after `applyPolicies` so the current policies are already in place: the
283
+ * drift check then sees exactly the set that should survive, and anything else
284
+ * on a managed table is by definition left over.
285
+ */
286
+ async function reconcilePolicies(databaseUrl: string, collectionsPath: string): Promise<void> {
287
+ try {
288
+ const { checkPolicyDrift, dropOrphanedPolicies, formatPolicyDrift, hasDrift } =
289
+ await import("./security/policy-drift");
290
+ const { loadCollections } = await import("./schema/doctor");
291
+
292
+ const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
293
+ const { Client } = await import("pg");
294
+ const client = new Client({ connectionString: databaseUrl });
295
+ await client.connect();
296
+ try {
297
+ const drift = await checkPolicyDrift(client as never, collections);
298
+ const { dropped, kept } = await dropOrphanedPolicies(client as never, drift, collections);
299
+
300
+ for (const p of dropped) {
301
+ logger.info(chalk.gray(` ✓ Dropped superseded policy "${p.name}" on ${p.schema}.${p.table}`));
302
+ }
303
+ if (dropped.length > 0) {
304
+ logger.info(chalk.green(` ✓ Removed ${dropped.length} superseded RLS ${dropped.length === 1 ? "policy" : "policies"}.`));
305
+ }
306
+
307
+ // Custom-named orphans are indistinguishable from policies someone
308
+ // wrote in SQL deliberately, so they are reported, never dropped.
309
+ if (kept.length > 0) {
310
+ logger.warn(chalk.yellow(" ⚠️ Policies in the database that no collection describes:"));
311
+ for (const p of kept) {
312
+ logger.warn(chalk.yellow(` • ${p.schema}.${p.table} → "${p.name}" (${p.command} TO ${p.roles.join(", ")})`));
313
+ }
314
+ logger.warn(chalk.yellow(" These still grant access. Drop them by hand if they are stale."));
315
+ }
316
+
317
+ // Missing/diverged are not push's to fix, but staying silent about
318
+ // them is how a database ends up not matching its config.
319
+ const remaining = { ...drift, orphaned: kept };
320
+ if (hasDrift(remaining) && (remaining.missing.length > 0 || remaining.diverged.length > 0)) {
321
+ logger.warn(chalk.yellow(" ⚠️ RLS policies do not match your collections:"));
322
+ logger.warn(formatPolicyDrift({ ...remaining, orphaned: [] }));
323
+ }
324
+ } finally {
325
+ await client.end();
326
+ }
327
+ } catch (err) {
328
+ logger.warn(chalk.yellow(` ⚠️ Could not reconcile RLS policies: ${err instanceof Error ? err.message : String(err)}`));
329
+ }
330
+ }
331
+
272
332
  async function branchCommand(rawArgs: string[]): Promise<void> {
273
333
  const branchAction = rawArgs[2]; // create, list, delete, info
274
334
 
@@ -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;
@@ -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";