@rebasepro/server-postgres 0.13.1-canary.gef9608c → 0.14.0

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 (114) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/PostgresBootstrapper.d.ts +26 -0
  3. package/dist/auth/services.d.ts +21 -0
  4. package/dist/{src-CU6WZGYV.js → auth-users-columns-BfQHf9JE.js} +1111 -92
  5. package/dist/auth-users-columns-BfQHf9JE.js.map +1 -0
  6. package/dist/{backup-service-CD8o_1Sl.js → backup-service-BH0Dzo_h.js} +2 -3
  7. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-BH0Dzo_h.js.map} +1 -1
  8. package/dist/cli-helpers.d.ts +56 -0
  9. package/dist/cli-output.d.ts +34 -0
  10. package/dist/data-transformer.d.ts +7 -2
  11. package/dist/data_driver-ULAyJEi9.js +193 -0
  12. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  13. package/dist/ensure-collection-policies-8vuu-n4r.js +124 -0
  14. package/dist/ensure-collection-policies-8vuu-n4r.js.map +1 -0
  15. package/dist/{ensure-collection-tables-BLIIACla.js → ensure-collection-tables-CbvaGuVn.js} +162 -16
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1720 -946
  18. package/dist/index.es.js.map +1 -1
  19. package/dist/rls-bootstrap-sql-69hYT8nr.js +244 -0
  20. package/dist/rls-bootstrap-sql-69hYT8nr.js.map +1 -0
  21. package/dist/rls-enforcement-BJ_3wxwg.js +425 -0
  22. package/dist/rls-enforcement-BJ_3wxwg.js.map +1 -0
  23. package/dist/schema/auth-schema.d.ts +102 -0
  24. package/dist/schema/auth-users-columns.d.ts +97 -0
  25. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  26. package/dist/schema/doctor.d.ts +41 -25
  27. package/dist/schema/ensure-collection-policies.d.ts +33 -9
  28. package/dist/schema/ensure-collection-tables.d.ts +60 -6
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +9 -1
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +48 -0
  31. package/dist/schema/introspect-db-inference.d.ts +8 -1
  32. package/dist/schema/introspect-db-logic.d.ts +49 -0
  33. package/dist/schema/introspect-db-project.d.ts +21 -0
  34. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  35. package/dist/schema/search-column.d.ts +248 -0
  36. package/dist/security/policy-drift.d.ts +34 -0
  37. package/dist/security/rls-enforcement.d.ts +61 -5
  38. package/dist/services/FetchService.d.ts +24 -0
  39. package/dist/services/PersistService.d.ts +21 -17
  40. package/dist/services/RelationService.d.ts +9 -57
  41. package/dist/services/RelationWriteService.d.ts +82 -0
  42. package/dist/services/collection-helpers.d.ts +42 -0
  43. package/dist/services/dataService.d.ts +3 -0
  44. package/dist/services/junction-writes.d.ts +82 -0
  45. package/dist/services/realtimeService.d.ts +139 -2
  46. package/dist/services/write-denial.d.ts +36 -0
  47. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  48. package/dist/src-DCdn3Val.js.map +1 -0
  49. package/dist/utils/drizzle-conditions.d.ts +124 -2
  50. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  51. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  52. package/package.json +8 -7
  53. package/src/PostgresBackendDriver.ts +172 -6
  54. package/src/PostgresBootstrapper.ts +136 -11
  55. package/src/auth/ensure-tables.ts +212 -91
  56. package/src/auth/services.ts +82 -5
  57. package/src/backup/backup-cli.ts +59 -57
  58. package/src/cli-errors.ts +6 -6
  59. package/src/cli-helpers.ts +124 -11
  60. package/src/cli-output.ts +43 -0
  61. package/src/cli.ts +299 -168
  62. package/src/collections/buildRegistry.ts +3 -1
  63. package/src/data-transformer.ts +129 -25
  64. package/src/history/ensure-history-table.ts +9 -2
  65. package/src/schema/auth-schema.ts +17 -1
  66. package/src/schema/auth-users-columns.ts +131 -0
  67. package/src/schema/doctor-cli.ts +14 -65
  68. package/src/schema/doctor-policy-checks.ts +105 -0
  69. package/src/schema/doctor.ts +149 -72
  70. package/src/schema/ensure-collection-policies.ts +99 -6
  71. package/src/schema/ensure-collection-tables.ts +366 -30
  72. package/src/schema/generate-drizzle-schema-logic.ts +146 -66
  73. package/src/schema/generate-drizzle-schema.ts +11 -10
  74. package/src/schema/generate-postgres-ddl-logic.ts +277 -10
  75. package/src/schema/generate-postgres-ddl.ts +38 -14
  76. package/src/schema/generated-schema-staleness.ts +14 -7
  77. package/src/schema/introspect-db-inference.ts +9 -2
  78. package/src/schema/introspect-db-logic.ts +251 -75
  79. package/src/schema/introspect-db-project.ts +78 -0
  80. package/src/schema/introspect-db.ts +42 -25
  81. package/src/schema/introspect-runtime.ts +14 -2
  82. package/src/schema/rls-bootstrap-sql.ts +288 -0
  83. package/src/schema/search-column.ts +643 -0
  84. package/src/security/anonymous-grants.test.ts +4 -2
  85. package/src/security/policy-drift.test.ts +104 -3
  86. package/src/security/policy-drift.ts +129 -7
  87. package/src/security/rls-enforcement.ts +150 -7
  88. package/src/services/BranchService.ts +5 -0
  89. package/src/services/FetchService.ts +243 -22
  90. package/src/services/PersistService.ts +68 -42
  91. package/src/services/RelationService.ts +37 -696
  92. package/src/services/RelationWriteService.ts +653 -0
  93. package/src/services/cdc/trigger-cdc.ts +5 -1
  94. package/src/services/channel-history.ts +14 -0
  95. package/src/services/channel-presence.ts +13 -0
  96. package/src/services/collection-helpers.ts +89 -4
  97. package/src/services/dataService.ts +3 -0
  98. package/src/services/junction-writes.ts +295 -0
  99. package/src/services/pg-notify-listener.ts +1 -1
  100. package/src/services/realtimeService.ts +347 -86
  101. package/src/services/write-denial.ts +55 -0
  102. package/src/utils/drizzle-conditions.ts +433 -35
  103. package/src/utils/pg-error-utils.ts +8 -3
  104. package/src/websocket.ts +113 -16
  105. package/dist/ensure-collection-policies-Bck0ky4u.js +0 -57
  106. package/dist/ensure-collection-policies-Bck0ky4u.js.map +0 -1
  107. package/dist/ensure-collection-tables-BLIIACla.js.map +0 -1
  108. package/dist/policy-CeA1JcxP.js +0 -105
  109. package/dist/policy-CeA1JcxP.js.map +0 -1
  110. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  111. package/dist/src-CU6WZGYV.js.map +0 -1
  112. package/dist/src-DoU9yPqq.js.map +0 -1
  113. package/dist/websocket-B2LsrINK.js.map +0 -1
  114. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The RLS half of `rebase doctor`, and the exit code `--policies` gates CI on.
3
+ *
4
+ * Lives beside `doctor-cli.ts` rather than inside it because that file runs
5
+ * `main()` on import: a gate whose failure modes cannot be unit-tested is how
6
+ * this one shipped reporting success for work it never did.
7
+ */
8
+ import path from "path";
9
+ import chalk from "chalk";
10
+ import { CollectionConfig } from "@rebasepro/types";
11
+ import { loadCollections } from "./doctor";
12
+ import { checkPolicyDrift, formatPolicyDrift, hasDrift } from "../security/policy-drift";
13
+ import { validatePolicyPgRoles, warnOnAnonymousGrants } from "../security/rls-enforcement";
14
+
15
+ /**
16
+ * What the RLS checks concluded.
17
+ *
18
+ * `unchecked` exists because "we could not look" and "we looked and it is fine"
19
+ * used to be reported identically: a collections path that did not resolve made
20
+ * the loader return `[]` (it warns, it does not throw), `checkPolicyDrift`
21
+ * early-returned an empty diff, and the gate printed
22
+ * `✓ RLS policies match your collections` having compared zero policies against
23
+ * zero collections. Any exception at all — a collection file that throws on
24
+ * import, a `pg_policies` read the CI role is not granted, a connection reset —
25
+ * did the same thing through a `warn`, and exited 0.
26
+ */
27
+ export type PolicyCheckStatus = "ok" | "problems" | "unchecked";
28
+
29
+ /**
30
+ * The exit code for `rebase doctor --policies`.
31
+ *
32
+ * A gate that could not run has not passed. Only a completed, clean check
33
+ * exits 0 — anything else, including "we never opened a connection", is a
34
+ * failure, or the flag certifies a database nobody looked at.
35
+ */
36
+ export function exitCodeForPolicyGate(status: PolicyCheckStatus): 0 | 1 {
37
+ return status === "ok" ? 0 : 1;
38
+ }
39
+
40
+ /**
41
+ * Policies actually deployed vs the ones the collections describe, plus policy
42
+ * roles this server could never satisfy.
43
+ *
44
+ * Never reports `ok` for work it did not do — see {@link PolicyCheckStatus}.
45
+ */
46
+ export async function runPolicyChecks(collectionsPath: string, databaseUrl?: string): Promise<PolicyCheckStatus> {
47
+ if (!databaseUrl) {
48
+ console.error(chalk.yellow(" ⚠ No DATABASE_URL — RLS policies were NOT checked"));
49
+ return "unchecked";
50
+ }
51
+
52
+ const resolvedPath = path.resolve(process.cwd(), collectionsPath);
53
+ let problems = false;
54
+ const { Pool } = await import("pg");
55
+ const pool = new Pool({ connectionString: databaseUrl });
56
+ try {
57
+ const collections: CollectionConfig[] = await loadCollections(resolvedPath);
58
+
59
+ // Zero collections is not "no drift", it is nothing to compare. The
60
+ // loader returns `[]` for a path that does not exist, so the commonest
61
+ // way to get here is a `--collections` path resolved against the wrong
62
+ // directory — which used to render as a green tick.
63
+ if (collections.length === 0) {
64
+ console.error(chalk.red(` ✗ No collections found in ${resolvedPath}`));
65
+ console.error(chalk.gray(" RLS policies were NOT checked. Pass --collections=<dir> if your collections live elsewhere."));
66
+ return "unchecked";
67
+ }
68
+
69
+ const runSql = async (text: string) => (await pool.query(text)).rows as Record<string, unknown>[];
70
+
71
+ // A policy naming a role the server never runs as filters every row, so
72
+ // the collection reads as empty. Report it without booting a server.
73
+ try {
74
+ await validatePolicyPgRoles(runSql, collections as never);
75
+ console.log(chalk.green(" ✓ Policy roles are usable by this server"));
76
+ } catch (err) {
77
+ problems = true;
78
+ console.log("");
79
+ console.error(chalk.red(err instanceof Error ? err.message : String(err)));
80
+ }
81
+
82
+ // A rule that reads as a lockdown but is true for every caller compiles
83
+ // to a grant. Report it without booting a server.
84
+ warnOnAnonymousGrants(collections as never);
85
+
86
+ const drift = await checkPolicyDrift(pool as never, collections);
87
+ console.log("");
88
+ if (hasDrift(drift)) {
89
+ problems = true;
90
+ console.log(chalk.yellow(" RLS policies: database does not match your collections"));
91
+ console.log(formatPolicyDrift(drift));
92
+ } else {
93
+ console.log(chalk.green(` ✓ RLS policies match your collections (${collections.length} collection(s) checked)`));
94
+ }
95
+ } catch (err) {
96
+ // Fail closed. This catch covers every query and every collection import
97
+ // above, so returning the pre-catch verdict made the documented CI gate
98
+ // green on exactly the runs it exists to catch.
99
+ console.error(chalk.red(" ✗ Could not check RLS policies:"), err instanceof Error ? err.message : String(err));
100
+ return "unchecked";
101
+ } finally {
102
+ await pool.end();
103
+ }
104
+ return problems ? "problems" : "ok";
105
+ }
@@ -17,7 +17,9 @@ import { generateSchema } from "./generate-drizzle-schema-logic";
17
17
  import { generateTypedefs } from "@rebasepro/codegen";
18
18
  import { getTableName, resolveCollectionRelations, findRelation, relationalCollections } from "@rebasepro/common";
19
19
  import { toSnakeCase } from "@rebasepro/utils";
20
- import { logger, loadCollectionsFromDirectory } from "@rebasepro/server";
20
+ import { loadCollectionsFromDirectory } from "@rebasepro/server";
21
+ // The report is CLI output, not application logging — see cli-output.ts.
22
+ import { out, outError } from "../cli-output";
21
23
 
22
24
  /**
23
25
  * Resolve the SQL column name for a property.
@@ -37,7 +39,7 @@ export type IssueSeverity = "error" | "warning" | "info";
37
39
 
38
40
  export interface DoctorIssue {
39
41
  severity: IssueSeverity;
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";
42
+ category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale" | "sdk_not_generated" | "sdk_ungeneratable";
41
43
  table?: string;
42
44
  column?: string;
43
45
  expected?: string;
@@ -46,11 +48,44 @@ export interface DoctorIssue {
46
48
  fix: string;
47
49
  }
48
50
 
51
+ export interface DoctorPhase {
52
+ passed: boolean;
53
+ issues: DoctorIssue[];
54
+ /**
55
+ * Why this phase never ran, when it did not.
56
+ *
57
+ * A check that did not happen is a third state, not a passing one. While a
58
+ * skipped phase initialised to `{ passed: true, issues: [] }` it rendered as
59
+ * `✅ Collections → Database: In sync` and counted towards
60
+ * `✓ All schemas are in sync!` — so a project whose connection string was
61
+ * spelled `POSTGRES_URL`, or a CI job that never exported one, got two green
62
+ * ticks and exit 0 against a database with no tables in it.
63
+ */
64
+ skipped?: string;
65
+ /**
66
+ * Why this phase had nothing to compare against, when it had nothing.
67
+ *
68
+ * Distinct from `skipped`, which means "the check could not run, and that
69
+ * is probably worth fixing". This one means "the artifact is optional and
70
+ * you have not asked for it": no drift is possible, so the run is still a
71
+ * clean bill of health.
72
+ *
73
+ * It exists because the alternative was a contradiction. The typed-SDK
74
+ * phase returned `{ passed: true }` when `generated/sdk/database.types.ts`
75
+ * did not exist, so a fresh project's report read
76
+ * `✅ Collections → SDK Types: In sync` directly above
77
+ * `ℹ Typed SDK not generated (optional).` — one line calling a file
78
+ * synchronised and the next saying it is absent. "In sync" is a claim about
79
+ * a comparison, and no comparison happened.
80
+ */
81
+ notApplicable?: string;
82
+ }
83
+
49
84
  export interface DoctorReport {
50
- collectionsToSchema: { passed: boolean; issues: DoctorIssue[] };
51
- collectionsToSdk: { passed: boolean; issues: DoctorIssue[] };
52
- schemaToDatabase: { passed: boolean; issues: DoctorIssue[] };
53
- summary: { passed: number; warnings: number; errors: number };
85
+ collectionsToSchema: DoctorPhase;
86
+ collectionsToSdk: DoctorPhase;
87
+ schemaToDatabase: DoctorPhase;
88
+ summary: { passed: number; skipped: number; notApplicable: number; warnings: number; errors: number };
54
89
  }
55
90
 
56
91
  // ── Column type mapping (mirrors generate-drizzle-schema-logic.ts) ───────
@@ -149,7 +184,7 @@ export async function loadCollections(collectionsPath: string): Promise<Collecti
149
184
  export async function checkCollectionsVsSchema(
150
185
  collections: CollectionConfig[],
151
186
  schemaFilePath: string
152
- ): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
187
+ ): Promise<DoctorPhase> {
153
188
  const issues: DoctorIssue[] = [];
154
189
 
155
190
  // Check if schema file exists
@@ -210,7 +245,7 @@ issues };
210
245
  export async function checkCollectionsVsSdk(
211
246
  collections: CollectionConfig[],
212
247
  sdkFilePath: string
213
- ): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
248
+ ): Promise<DoctorPhase> {
214
249
  const issues: DoctorIssue[] = [];
215
250
 
216
251
  // The typed SDK is opt-in — nothing in a scaffolded project imports it until
@@ -225,7 +260,8 @@ export async function checkCollectionsVsSdk(
225
260
  fix: "Run `rebase generate-sdk` if you want typed collection access"
226
261
  });
227
262
  return { passed: true,
228
- issues };
263
+ issues,
264
+ notApplicable: "not generated (optional)" };
229
265
  }
230
266
 
231
267
  try {
@@ -250,12 +286,27 @@ issues };
250
286
  }
251
287
  } catch (err: unknown) {
252
288
  const message = err instanceof Error ? err.message : String(err);
253
- issues.push({
254
- severity: "warning",
255
- category: "sdk_stale",
256
- message: `Could not regenerate SDK types for comparison: ${message}`,
257
- fix: "Run `rebase generate-sdk` to verify"
258
- });
289
+
290
+ // A `CodegenError` is not "the comparison did not run" — it is the
291
+ // schema saying it cannot produce a valid typed client at all, most
292
+ // often two slugs collapsing onto one accessor. Reporting that as a
293
+ // warning about staleness described the wrong problem and pointed at a
294
+ // command that would fail the same way.
295
+ if (err instanceof Error && err.name === "CodegenError") {
296
+ issues.push({
297
+ severity: "error",
298
+ category: "sdk_ungeneratable",
299
+ message: `The typed SDK cannot be generated from these collections: ${message}`,
300
+ fix: "Fix the collection definitions named above, then run `rebase generate-sdk`"
301
+ });
302
+ } else {
303
+ issues.push({
304
+ severity: "warning",
305
+ category: "sdk_stale",
306
+ message: `Could not regenerate SDK types for comparison: ${message}`,
307
+ fix: "Run `rebase generate-sdk` to verify"
308
+ });
309
+ }
259
310
  }
260
311
 
261
312
  return { passed: issues.length === 0,
@@ -282,7 +333,7 @@ interface DbEnumValue {
282
333
  export async function checkCollectionsVsDatabase(
283
334
  collections: CollectionConfig[],
284
335
  databaseUrl: string
285
- ): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
336
+ ): Promise<DoctorPhase> {
286
337
  const issues: DoctorIssue[] = [];
287
338
 
288
339
  // Dynamic import to avoid loading pg when not needed
@@ -597,96 +648,112 @@ issues };
597
648
  // ── Report Rendering ─────────────────────────────────────────────────────
598
649
 
599
650
  export function renderReport(report: DoctorReport): void {
600
- logger.info("");
601
- logger.info(chalk.bold(" 🩺 Rebase Schema Doctor"));
602
- logger.info(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
603
- logger.info("");
651
+ out();
652
+ out(chalk.bold(" 🩺 Rebase Schema Doctor"));
653
+ out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
654
+ out();
604
655
 
605
656
  // Phase 1
606
- renderPhase(
607
- "Collections → Generated Schema",
608
- report.collectionsToSchema.passed,
609
- report.collectionsToSchema.issues
610
- );
657
+ renderPhase("Collections → Generated Schema", report.collectionsToSchema);
611
658
 
612
659
  // Phase 2
613
- renderPhase(
614
- "Collections → Database",
615
- report.schemaToDatabase.passed,
616
- report.schemaToDatabase.issues
617
- );
660
+ renderPhase("Collections → Database", report.schemaToDatabase);
618
661
 
619
662
  // Phase 3
620
- renderPhase(
621
- "Collections → SDK Types",
622
- report.collectionsToSdk.passed,
623
- report.collectionsToSdk.issues
624
- );
663
+ renderPhase("Collections → SDK Types", report.collectionsToSdk);
625
664
 
626
665
  // Summary
627
- logger.info(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
628
- const { passed, warnings, errors } = report.summary;
666
+ out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
667
+ const { passed, skipped, notApplicable, warnings, errors } = report.summary;
629
668
 
630
669
  const parts: string[] = [];
631
670
  parts.push(chalk.green(`${passed} passed`));
671
+ if (skipped > 0) parts.push(chalk.yellow(`${skipped} skipped`));
672
+ if (notApplicable > 0) parts.push(chalk.gray(`${notApplicable} not applicable`));
632
673
  if (warnings > 0) parts.push(chalk.yellow(`${warnings} warnings`));
633
674
  if (errors > 0) parts.push(chalk.red(`${errors} errors`));
634
675
 
635
- logger.info(` Summary: ${parts.join(", ")}`);
636
- logger.info("");
676
+ out(` Summary: ${parts.join(", ")}`);
677
+ out();
637
678
 
638
679
  if (errors > 0) {
639
- logger.info(chalk.red.bold(" ✗ Schema drift detected. Run the suggested fixes above."));
680
+ out(chalk.red.bold(" ✗ Schema drift detected. Run the suggested fixes above."));
640
681
  } else if (warnings > 0) {
641
- logger.info(chalk.yellow.bold(" ⚠ Minor issues detected. Consider running the suggested fixes."));
682
+ out(chalk.yellow.bold(" ⚠ Minor issues detected. Consider running the suggested fixes."));
683
+ } else if (skipped > 0) {
684
+ // Never "all schemas are in sync" off the back of a check that did not
685
+ // run: a clean bill of health has to come from measurement.
686
+ out(chalk.yellow.bold(` ⚠ Nothing wrong in the checks that ran, but ${skipped} did not run — this is not a clean bill of health.`));
642
687
  } else {
643
- logger.info(chalk.green.bold(" ✓ All schemas are in sync!"));
688
+ out(chalk.green.bold(" ✓ All schemas are in sync!"));
644
689
  }
645
- logger.info("");
690
+ out();
646
691
  }
647
692
 
648
- function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): void {
693
+ function renderPhase(label: string, phase: DoctorPhase): void {
694
+ // A phase that never ran gets its own marker and its reason. It is neither
695
+ // "In sync" nor a drift report; conflating it with the former is what let
696
+ // doctor certify a database it had not opened.
697
+ if (phase.skipped) {
698
+ out(` ${chalk.yellow("⏭")} ${label}: ${chalk.yellow(`skipped (${phase.skipped})`)}`);
699
+ out();
700
+ return;
701
+ }
702
+
703
+ // Nothing to compare against. Its own marker, and only the remedy under it
704
+ // — the phase header already carries the reason, so re-printing the note
705
+ // would say the same thing twice. See DoctorPhase.notApplicable.
706
+ if (phase.notApplicable) {
707
+ out(` ${chalk.gray("➖")} ${label}: ${chalk.gray(phase.notApplicable)}`);
708
+ for (const issue of phase.issues.filter((i) => i.severity === "info")) {
709
+ out(` ${chalk.gray(issue.fix)}`);
710
+ }
711
+ out();
712
+ return;
713
+ }
714
+
715
+ const issues = phase.issues;
649
716
  const errorCount = issues.filter((i) => i.severity === "error").length;
650
717
  const warnCount = issues.filter((i) => i.severity === "warning").length;
651
718
  const infoIssues = issues.filter((i) => i.severity === "info");
652
719
 
653
720
  // Informational notes don't make a phase unhealthy, so key the header off
654
- // real problems rather than `passed` alone.
721
+ // real problems rather than `phase.passed` alone.
655
722
  if (errorCount === 0 && warnCount === 0) {
656
- logger.info(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
723
+ out(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
657
724
  } else {
658
725
  const parts: string[] = [];
659
726
  if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
660
727
  if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
661
- logger.info(` ${chalk.yellow("⚠️")} ${label}: ${chalk.yellow(parts.join(", "))}`);
728
+ out(` ${chalk.yellow("⚠️")} ${label}: ${chalk.yellow(parts.join(", "))}`);
662
729
  }
663
- logger.info("");
730
+ out();
664
731
 
665
732
  // Notes render as a quiet one-liner, not a full drift box.
666
733
  for (const issue of infoIssues) {
667
734
  const fixPart = issue.fix ? chalk.gray(` — ${issue.fix}`) : "";
668
- logger.info(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
669
- logger.info("");
735
+ out(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
736
+ out();
670
737
  }
671
738
 
672
739
  for (const issue of issues.filter((i) => i.severity !== "info")) {
673
740
  const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
674
741
  const categoryLabel = formatCategory(issue.category);
675
- logger.info(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
742
+ out(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
676
743
 
677
744
  if (issue.table) {
678
745
  const colPart = issue.column ? ` │ Column: ${chalk.cyan(issue.column)}` : "";
679
- logger.info(` ${chalk.gray("│")} Table: ${chalk.cyan(issue.table)}${colPart}`);
746
+ out(` ${chalk.gray("│")} Table: ${chalk.cyan(issue.table)}${colPart}`);
680
747
  }
681
748
 
682
749
  if (issue.expected && issue.actual) {
683
- logger.info(` ${chalk.gray("│")} Expected: ${chalk.green(issue.expected)} │ Actual: ${chalk.red(issue.actual)}`);
750
+ out(` ${chalk.gray("│")} Expected: ${chalk.green(issue.expected)} │ Actual: ${chalk.red(issue.actual)}`);
684
751
  }
685
752
 
686
- logger.info(` ${chalk.gray("│")} ${issue.message}`);
687
- logger.info(` ${chalk.gray("│")} Fix: ${chalk.blue(issue.fix)}`);
688
- logger.info(` ${chalk.gray("└" + "─".repeat(48))}`);
689
- logger.info("");
753
+ out(` ${chalk.gray("│")} ${issue.message}`);
754
+ out(` ${chalk.gray("│")} Fix: ${chalk.blue(issue.fix)}`);
755
+ out(` ${chalk.gray("└" + "─".repeat(48))}`);
756
+ out();
690
757
  }
691
758
  }
692
759
 
@@ -701,7 +768,8 @@ function formatCategory(cat: DoctorIssue["category"]): string {
701
768
  enum_value_mismatch: "Enum Value Mismatch",
702
769
  missing_foreign_key: "Missing Foreign Key",
703
770
  sdk_stale: "Stale SDK Types",
704
- sdk_not_generated: "SDK Types Not Generated"
771
+ sdk_not_generated: "SDK Types Not Generated",
772
+ sdk_ungeneratable: "SDK Cannot Be Generated"
705
773
  };
706
774
  return labels[cat];
707
775
  }
@@ -714,38 +782,47 @@ export async function runDoctor(options: {
714
782
  sdkPath: string;
715
783
  databaseUrl?: string;
716
784
  }): Promise<DoctorReport> {
717
- logger.info("");
718
- logger.info(chalk.bold(" 🩺 Loading collections..."));
785
+ out();
786
+ out(chalk.bold(" 🩺 Loading collections..."));
719
787
  const collections = await loadCollections(options.collectionsPath);
720
788
  if (collections.length === 0) {
721
- logger.error(chalk.red(" ✗ No collections found."));
789
+ outError(chalk.red(" ✗ No collections found."));
722
790
  process.exit(1);
723
791
  }
724
- logger.info(chalk.gray(` Found ${collections.length} collection(s)`));
725
- logger.info("");
792
+ out(chalk.gray(` Found ${collections.length} collection(s)`));
793
+ out();
726
794
 
727
795
  // Phase 1: Collections ↔ Generated Schema
728
- logger.info(chalk.gray(" Checking Collections → Generated Schema..."));
796
+ out(chalk.gray(" Checking Collections → Generated Schema..."));
729
797
  const collectionsToSchema = await checkCollectionsVsSchema(collections, options.schemaPath);
730
798
 
731
799
  // Phase 2: Collections ↔ Database (only if we have a DATABASE_URL)
732
- let schemaToDatabase: { passed: boolean; issues: DoctorIssue[] } = { passed: true,
733
- issues: [] };
800
+ let schemaToDatabase: DoctorPhase;
734
801
  if (options.databaseUrl) {
735
- logger.info(chalk.gray(" Checking Collections → Database..."));
802
+ out(chalk.gray(" Checking Collections → Database..."));
736
803
  schemaToDatabase = await checkCollectionsVsDatabase(collections, options.databaseUrl);
737
804
  } else {
738
- logger.info(chalk.yellow(" ⚠ DATABASE_URL not set skipping database comparison."));
739
- logger.info(chalk.gray(" Set DATABASE_URL in your .env to enable full drift detection."));
805
+ // Not `{ passed: true }`: see DoctorPhase.skipped.
806
+ schemaToDatabase = { passed: false,
807
+ issues: [],
808
+ skipped: "DATABASE_URL not set" };
809
+ out(chalk.yellow(" ⚠ DATABASE_URL not set — skipping database comparison."));
810
+ out(chalk.gray(" Set DATABASE_URL in your .env to enable full drift detection."));
740
811
  }
741
812
 
742
813
  // Phase 3: Collections ↔ SDK Types
743
- logger.info(chalk.gray(" Checking Collections → SDK Types..."));
814
+ out(chalk.gray(" Checking Collections → SDK Types..."));
744
815
  const collectionsToSdk = await checkCollectionsVsSdk(collections, options.sdkPath);
745
816
 
746
- const allIssues = [...collectionsToSchema.issues, ...schemaToDatabase.issues, ...collectionsToSdk.issues];
817
+ const phases = [collectionsToSchema, schemaToDatabase, collectionsToSdk];
818
+ const allIssues = phases.flatMap((p) => p.issues);
747
819
  const summary = {
748
- passed: [collectionsToSchema, schemaToDatabase, collectionsToSdk].filter((p) => p.passed).length,
820
+ // A skipped phase is not a passing one, however few issues it collected.
821
+ // Nor is one with nothing to compare against: "passed" would put a
822
+ // never-generated SDK in the same column as a verified one.
823
+ passed: phases.filter((p) => p.passed && !p.skipped && !p.notApplicable).length,
824
+ skipped: phases.filter((p) => p.skipped).length,
825
+ notApplicable: phases.filter((p) => p.notApplicable && !p.skipped).length,
749
826
  warnings: allIssues.filter((i) => i.severity === "warning").length,
750
827
  errors: allIssues.filter((i) => i.severity === "error").length
751
828
  };
@@ -32,8 +32,10 @@
32
32
  * exactly which collection is not yet servable and why.
33
33
  */
34
34
  import { type CollectionConfig } from "@rebasepro/types";
35
- import { planCollectionPolicies } from "./generate-postgres-ddl-logic";
35
+ import { planCollectionPolicies, type CollectionPolicyPlan } from "./generate-postgres-ddl-logic";
36
+ import { isGeneratedPolicyName } from "../security/policy-drift";
36
37
  import { readExistingSchema, type Queryable } from "./ensure-collection-tables";
38
+ import { REBASE_USER_ROLE } from "../security/rls-enforcement";
37
39
 
38
40
  export interface PolicyEnsureResult {
39
41
  /** `CREATE POLICY` statements that ran successfully. */
@@ -42,8 +44,36 @@ export interface PolicyEnsureResult {
42
44
  tablesSecured: number;
43
45
  /** Declared tables absent from the database — left to a real migration. */
44
46
  skipped: { table: string; reason: string }[];
45
- /** Tables whose RLS could not be fully applied (fail closed). */
47
+ /**
48
+ * Tables that have RLS on but did not get every policy. They deny — RLS
49
+ * with no matching policy is deny-all — so they are safe but not servable.
50
+ */
46
51
  failures: { table: string; error: string }[];
52
+ /**
53
+ * Tables RLS could not be enabled on, whose DML grant was withdrawn instead.
54
+ *
55
+ * This state had no name, and that was the bug: `ENABLE ROW LEVEL SECURITY`
56
+ * failing was recorded as a `failure` and reported with the same "it stays
57
+ * locked (denies)" wording as a failed policy — but the two are opposites.
58
+ * A policy statement failing leaves RLS on and the table denying. `enableRls`
59
+ * failing leaves RLS *off*, and the schema-wide grant to the user role has
60
+ * already been made by `ensureRlsEnforcement`, so the table is readable and
61
+ * writable by every authenticated request with no row filtering at all.
62
+ */
63
+ unsecured: { table: string; error: string; grantWithdrawn: boolean }[];
64
+ /**
65
+ * Generated policies removed because no current rule produces them.
66
+ *
67
+ * A policy's name embeds a hash of the rule's semantics, so editing a rule
68
+ * does not update a policy — it creates a new one and abandons the old.
69
+ * Postgres ORs permissive policies, so the abandoned one keeps granting:
70
+ * a `USING (true)` tightened to an owner check went on admitting everyone,
71
+ * forever, while the deploy logged success.
72
+ *
73
+ * `db push` reconciles this, and cannot reach a managed tenant's in-cluster
74
+ * database — which is the reason this module exists. So boot has to do it.
75
+ */
76
+ orphansDropped: number;
47
77
  }
48
78
 
49
79
  const isCreatePolicy = (statement: string): boolean => /^\s*CREATE POLICY/i.test(statement);
@@ -56,12 +86,46 @@ const isCreatePolicy = (statement: string): boolean => /^\s*CREATE POLICY/i.test
56
86
  * to create (a junction, or a relation left to a migration). Enabling RLS on a
57
87
  * non-existent table would error, so those are recorded as skipped, not failed.
58
88
  */
89
+
90
+ /**
91
+ * Remove generated policies on this table that the current plan does not
92
+ * produce.
93
+ *
94
+ * Scoped hard, because dropping a policy is destructive: only this one table,
95
+ * only names matching the generated `<table>_<op>_<sha1>` shape, and only names
96
+ * absent from the statements just applied. A hand-written policy, or one
97
+ * belonging to another table, is never touched — `isGeneratedPolicyName` is the
98
+ * same predicate `db push` uses to draw that line.
99
+ */
100
+ async function dropOrphanedPoliciesOn(client: Queryable, plan: CollectionPolicyPlan): Promise<number> {
101
+ const expected = new Set<string>();
102
+ for (const statement of plan.policyStatements) {
103
+ const match = /^\s*CREATE POLICY\s+"([^"]+)"/i.exec(statement);
104
+ if (match) expected.add(match[1]);
105
+ }
106
+
107
+ const existing = await client.query<{ policyname: string }>(
108
+ `SELECT policyname FROM pg_policies WHERE schemaname = '${plan.schema.replace(/'/g, "''")}' ` +
109
+ `AND tablename = '${plan.table.replace(/'/g, "''")}'`
110
+ );
111
+
112
+ let dropped = 0;
113
+ for (const row of existing.rows) {
114
+ const name = row.policyname;
115
+ if (expected.has(name)) continue;
116
+ if (!isGeneratedPolicyName(name, plan.table)) continue;
117
+ await client.query(`DROP POLICY IF EXISTS "${name}" ON "${plan.schema}"."${plan.table}"`);
118
+ dropped++;
119
+ }
120
+ return dropped;
121
+ }
122
+
59
123
  export async function ensureCollectionPolicies(
60
124
  client: Queryable,
61
125
  collections: CollectionConfig[],
62
126
  log?: (message: string) => void
63
127
  ): Promise<PolicyEnsureResult> {
64
- const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [] };
128
+ const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [], unsecured: [], orphansDropped: 0 };
65
129
 
66
130
  const plans = planCollectionPolicies(collections);
67
131
  if (plans.length === 0) return result;
@@ -78,12 +142,36 @@ export async function ensureCollectionPolicies(
78
142
  continue;
79
143
  }
80
144
 
145
+ // Enabling RLS is its own step, because its failure is the opposite of
146
+ // every other failure here. Once RLS is on, anything that goes wrong
147
+ // afterwards leaves the table denying; while it is off, the grant made
148
+ // earlier in boot leaves the table wide open. Sharing one `try` meant
149
+ // the dangerous case was reported in the safe case's words.
81
150
  try {
82
- // Enable first: if a later policy statement fails, the table is left
83
- // locked (deny-all for the user role) rather than open.
84
151
  await client.query(plan.enableRls);
85
152
  result.tablesSecured++;
153
+ } catch (err) {
154
+ const error = err instanceof Error ? err.message : String(err);
155
+ // Take the privilege back rather than serve an unprotected table.
156
+ // This is the fail-closed step the old code assumed it already had:
157
+ // per-table, so one collection cannot take the rest of the
158
+ // deployment down, but leaving nothing readable without RLS.
159
+ let grantWithdrawn = false;
160
+ try {
161
+ await client.query(`REVOKE ALL PRIVILEGES ON ${plan.qualified} FROM ${REBASE_USER_ROLE}`);
162
+ grantWithdrawn = true;
163
+ } catch {
164
+ // Fall through: reported below with `grantWithdrawn: false`,
165
+ // which the caller escalates. There is nothing else this
166
+ // function can do to make the table safe.
167
+ }
168
+ result.unsecured.push({ table: plan.qualified,
169
+ error,
170
+ grantWithdrawn });
171
+ continue;
172
+ }
86
173
 
174
+ try {
87
175
  let created = 0;
88
176
  for (const statement of plan.policyStatements) {
89
177
  await client.query(statement);
@@ -92,7 +180,12 @@ export async function ensureCollectionPolicies(
92
180
  created++;
93
181
  }
94
182
  }
95
- log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied`);
183
+
184
+ const orphans = await dropOrphanedPoliciesOn(client, plan);
185
+ result.orphansDropped += orphans;
186
+
187
+ log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied` +
188
+ (orphans > 0 ? `, ${orphans} orphan(s) dropped` : ""));
96
189
  } catch (err) {
97
190
  result.failures.push({
98
191
  table: plan.qualified,