@rebasepro/server-postgres 0.13.0 → 0.13.1-canary.g1822133

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 (117) 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-DlPBctw_.js → auth-users-columns-BfQHf9JE.js} +1222 -86
  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 +57 -1
  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-CBQdOETu.js → ensure-collection-tables-CbvaGuVn.js} +170 -20
  16. package/dist/ensure-collection-tables-CbvaGuVn.js.map +1 -0
  17. package/dist/index.es.js +1815 -1023
  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 +61 -7
  29. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  30. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
  31. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  32. package/dist/schema/introspect-db-inference.d.ts +8 -1
  33. package/dist/schema/introspect-db-logic.d.ts +49 -0
  34. package/dist/schema/introspect-db-project.d.ts +21 -0
  35. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  36. package/dist/schema/search-column.d.ts +248 -0
  37. package/dist/security/policy-drift.d.ts +34 -0
  38. package/dist/security/rls-enforcement.d.ts +61 -5
  39. package/dist/services/FetchService.d.ts +34 -7
  40. package/dist/services/PersistService.d.ts +21 -17
  41. package/dist/services/RelationService.d.ts +9 -57
  42. package/dist/services/RelationWriteService.d.ts +82 -0
  43. package/dist/services/collection-helpers.d.ts +42 -0
  44. package/dist/services/dataService.d.ts +5 -0
  45. package/dist/services/junction-writes.d.ts +82 -0
  46. package/dist/services/realtimeService.d.ts +164 -23
  47. package/dist/services/write-denial.d.ts +36 -0
  48. package/dist/{src-DoU9yPqq.js → src-DCdn3Val.js} +124 -3
  49. package/dist/src-DCdn3Val.js.map +1 -0
  50. package/dist/utils/drizzle-conditions.d.ts +124 -2
  51. package/dist/{websocket-B2LsrINK.js → websocket-C8ZqVBiV.js} +75 -18
  52. package/dist/websocket-C8ZqVBiV.js.map +1 -0
  53. package/package.json +9 -8
  54. package/src/PostgresBackendDriver.ts +172 -6
  55. package/src/PostgresBootstrapper.ts +136 -11
  56. package/src/auth/ensure-tables.ts +212 -91
  57. package/src/auth/services.ts +82 -5
  58. package/src/backup/backup-cli.ts +59 -57
  59. package/src/cli-errors.ts +6 -6
  60. package/src/cli-helpers.ts +132 -13
  61. package/src/cli-output.ts +43 -0
  62. package/src/cli.ts +371 -161
  63. package/src/collections/buildRegistry.ts +3 -1
  64. package/src/collections/validate-relations.ts +124 -17
  65. package/src/data-transformer.ts +142 -28
  66. package/src/history/ensure-history-table.ts +9 -2
  67. package/src/schema/auth-schema.ts +17 -1
  68. package/src/schema/auth-users-columns.ts +131 -0
  69. package/src/schema/doctor-cli.ts +14 -65
  70. package/src/schema/doctor-policy-checks.ts +105 -0
  71. package/src/schema/doctor.ts +156 -77
  72. package/src/schema/ensure-collection-policies.ts +99 -6
  73. package/src/schema/ensure-collection-tables.ts +374 -32
  74. package/src/schema/generate-drizzle-schema-logic.ts +152 -66
  75. package/src/schema/generate-drizzle-schema.ts +11 -10
  76. package/src/schema/generate-postgres-ddl-logic.ts +294 -16
  77. package/src/schema/generate-postgres-ddl.ts +38 -14
  78. package/src/schema/generated-schema-staleness.ts +171 -0
  79. package/src/schema/introspect-db-inference.ts +9 -2
  80. package/src/schema/introspect-db-logic.ts +251 -75
  81. package/src/schema/introspect-db-project.ts +78 -0
  82. package/src/schema/introspect-db.ts +42 -25
  83. package/src/schema/introspect-runtime.ts +14 -2
  84. package/src/schema/non-sql-collections.test.ts +131 -0
  85. package/src/schema/rls-bootstrap-sql.ts +288 -0
  86. package/src/schema/search-column.ts +643 -0
  87. package/src/security/anonymous-grants.test.ts +4 -2
  88. package/src/security/policy-drift.test.ts +104 -3
  89. package/src/security/policy-drift.ts +129 -7
  90. package/src/security/rls-enforcement.ts +150 -7
  91. package/src/services/BranchService.ts +5 -0
  92. package/src/services/FetchService.ts +253 -115
  93. package/src/services/PersistService.ts +82 -43
  94. package/src/services/RelationService.ts +37 -696
  95. package/src/services/RelationWriteService.ts +653 -0
  96. package/src/services/cdc/trigger-cdc.ts +5 -1
  97. package/src/services/channel-history.ts +14 -0
  98. package/src/services/channel-presence.ts +13 -0
  99. package/src/services/collection-helpers.ts +89 -4
  100. package/src/services/dataService.ts +5 -0
  101. package/src/services/junction-writes.ts +295 -0
  102. package/src/services/pg-notify-listener.ts +1 -1
  103. package/src/services/realtimeService.ts +382 -118
  104. package/src/services/write-denial.ts +55 -0
  105. package/src/utils/drizzle-conditions.ts +433 -35
  106. package/src/utils/pg-error-utils.ts +8 -3
  107. package/src/websocket.ts +113 -16
  108. package/dist/ensure-collection-policies-ViG8XiPn.js +0 -57
  109. package/dist/ensure-collection-policies-ViG8XiPn.js.map +0 -1
  110. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  111. package/dist/policy-CeA1JcxP.js +0 -105
  112. package/dist/policy-CeA1JcxP.js.map +0 -1
  113. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  114. package/dist/src-DlPBctw_.js.map +0 -1
  115. package/dist/src-DoU9yPqq.js.map +0 -1
  116. package/dist/websocket-B2LsrINK.js.map +0 -1
  117. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -15,9 +15,11 @@ import chalk from "chalk";
15
15
  import { CollectionConfig, isPostgresCollectionConfig, Property, NumberProperty, StringProperty, DateProperty, ArrayProperty, MapProperty, RelationProperty, type ResolvedManyToMany, type ResolvedBelongsTo, isManyToMany } from "@rebasepro/types";
16
16
  import { generateSchema } from "./generate-drizzle-schema-logic";
17
17
  import { generateTypedefs } from "@rebasepro/codegen";
18
- import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
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
@@ -164,8 +199,10 @@ export async function checkCollectionsVsSchema(
164
199
  issues };
165
200
  }
166
201
 
167
- // Re-generate schema in-memory and compare with file on disk
168
- const postgresCollections = collections.filter(isPostgresCollectionConfig);
202
+ // Re-generate schema in-memory and compare with file on disk. Only the
203
+ // collections the generator itself will emit — a Firestore collection has
204
+ // no table in the generated file and must not be reported as missing one.
205
+ const postgresCollections = relationalCollections(collections);
169
206
  if (postgresCollections.length === 0) {
170
207
  return { passed: true,
171
208
  issues };
@@ -208,7 +245,7 @@ issues };
208
245
  export async function checkCollectionsVsSdk(
209
246
  collections: CollectionConfig[],
210
247
  sdkFilePath: string
211
- ): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
248
+ ): Promise<DoctorPhase> {
212
249
  const issues: DoctorIssue[] = [];
213
250
 
214
251
  // The typed SDK is opt-in — nothing in a scaffolded project imports it until
@@ -223,7 +260,8 @@ export async function checkCollectionsVsSdk(
223
260
  fix: "Run `rebase generate-sdk` if you want typed collection access"
224
261
  });
225
262
  return { passed: true,
226
- issues };
263
+ issues,
264
+ notApplicable: "not generated (optional)" };
227
265
  }
228
266
 
229
267
  try {
@@ -248,12 +286,27 @@ issues };
248
286
  }
249
287
  } catch (err: unknown) {
250
288
  const message = err instanceof Error ? err.message : String(err);
251
- issues.push({
252
- severity: "warning",
253
- category: "sdk_stale",
254
- message: `Could not regenerate SDK types for comparison: ${message}`,
255
- fix: "Run `rebase generate-sdk` to verify"
256
- });
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
+ }
257
310
  }
258
311
 
259
312
  return { passed: issues.length === 0,
@@ -280,7 +333,7 @@ interface DbEnumValue {
280
333
  export async function checkCollectionsVsDatabase(
281
334
  collections: CollectionConfig[],
282
335
  databaseUrl: string
283
- ): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
336
+ ): Promise<DoctorPhase> {
284
337
  const issues: DoctorIssue[] = [];
285
338
 
286
339
  // Dynamic import to avoid loading pg when not needed
@@ -292,7 +345,7 @@ export async function checkCollectionsVsDatabase(
292
345
  const schemas = Array.from(new Set([
293
346
  "public",
294
347
  "rebase",
295
- ...collections
348
+ ...relationalCollections(collections)
296
349
  .filter(isPostgresCollectionConfig)
297
350
  .map(c => c.schema)
298
351
  .filter((s): s is string => !!s)
@@ -383,7 +436,7 @@ export async function checkCollectionsVsDatabase(
383
436
 
384
437
  // ── Compare each collection against the database ─────────────────
385
438
 
386
- const postgresCollections = collections.filter(isPostgresCollectionConfig);
439
+ const postgresCollections = relationalCollections(collections);
387
440
 
388
441
  for (const collection of postgresCollections) {
389
442
  const tableName = getTableName(collection);
@@ -595,96 +648,112 @@ issues };
595
648
  // ── Report Rendering ─────────────────────────────────────────────────────
596
649
 
597
650
  export function renderReport(report: DoctorReport): void {
598
- logger.info("");
599
- logger.info(chalk.bold(" 🩺 Rebase Schema Doctor"));
600
- logger.info(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
601
- logger.info("");
651
+ out();
652
+ out(chalk.bold(" 🩺 Rebase Schema Doctor"));
653
+ out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
654
+ out();
602
655
 
603
656
  // Phase 1
604
- renderPhase(
605
- "Collections → Generated Schema",
606
- report.collectionsToSchema.passed,
607
- report.collectionsToSchema.issues
608
- );
657
+ renderPhase("Collections → Generated Schema", report.collectionsToSchema);
609
658
 
610
659
  // Phase 2
611
- renderPhase(
612
- "Collections → Database",
613
- report.schemaToDatabase.passed,
614
- report.schemaToDatabase.issues
615
- );
660
+ renderPhase("Collections → Database", report.schemaToDatabase);
616
661
 
617
662
  // Phase 3
618
- renderPhase(
619
- "Collections → SDK Types",
620
- report.collectionsToSdk.passed,
621
- report.collectionsToSdk.issues
622
- );
663
+ renderPhase("Collections → SDK Types", report.collectionsToSdk);
623
664
 
624
665
  // Summary
625
- logger.info(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
626
- const { passed, warnings, errors } = report.summary;
666
+ out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
667
+ const { passed, skipped, notApplicable, warnings, errors } = report.summary;
627
668
 
628
669
  const parts: string[] = [];
629
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`));
630
673
  if (warnings > 0) parts.push(chalk.yellow(`${warnings} warnings`));
631
674
  if (errors > 0) parts.push(chalk.red(`${errors} errors`));
632
675
 
633
- logger.info(` Summary: ${parts.join(", ")}`);
634
- logger.info("");
676
+ out(` Summary: ${parts.join(", ")}`);
677
+ out();
635
678
 
636
679
  if (errors > 0) {
637
- 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."));
638
681
  } else if (warnings > 0) {
639
- 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.`));
640
687
  } else {
641
- logger.info(chalk.green.bold(" ✓ All schemas are in sync!"));
688
+ out(chalk.green.bold(" ✓ All schemas are in sync!"));
642
689
  }
643
- logger.info("");
690
+ out();
644
691
  }
645
692
 
646
- 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;
647
716
  const errorCount = issues.filter((i) => i.severity === "error").length;
648
717
  const warnCount = issues.filter((i) => i.severity === "warning").length;
649
718
  const infoIssues = issues.filter((i) => i.severity === "info");
650
719
 
651
720
  // Informational notes don't make a phase unhealthy, so key the header off
652
- // real problems rather than `passed` alone.
721
+ // real problems rather than `phase.passed` alone.
653
722
  if (errorCount === 0 && warnCount === 0) {
654
- logger.info(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
723
+ out(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
655
724
  } else {
656
725
  const parts: string[] = [];
657
726
  if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
658
727
  if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
659
- logger.info(` ${chalk.yellow("⚠️")} ${label}: ${chalk.yellow(parts.join(", "))}`);
728
+ out(` ${chalk.yellow("⚠️")} ${label}: ${chalk.yellow(parts.join(", "))}`);
660
729
  }
661
- logger.info("");
730
+ out();
662
731
 
663
732
  // Notes render as a quiet one-liner, not a full drift box.
664
733
  for (const issue of infoIssues) {
665
734
  const fixPart = issue.fix ? chalk.gray(` — ${issue.fix}`) : "";
666
- logger.info(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
667
- logger.info("");
735
+ out(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
736
+ out();
668
737
  }
669
738
 
670
739
  for (const issue of issues.filter((i) => i.severity !== "info")) {
671
740
  const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
672
741
  const categoryLabel = formatCategory(issue.category);
673
- 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)))}`);
674
743
 
675
744
  if (issue.table) {
676
745
  const colPart = issue.column ? ` │ Column: ${chalk.cyan(issue.column)}` : "";
677
- logger.info(` ${chalk.gray("│")} Table: ${chalk.cyan(issue.table)}${colPart}`);
746
+ out(` ${chalk.gray("│")} Table: ${chalk.cyan(issue.table)}${colPart}`);
678
747
  }
679
748
 
680
749
  if (issue.expected && issue.actual) {
681
- 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)}`);
682
751
  }
683
752
 
684
- logger.info(` ${chalk.gray("│")} ${issue.message}`);
685
- logger.info(` ${chalk.gray("│")} Fix: ${chalk.blue(issue.fix)}`);
686
- logger.info(` ${chalk.gray("└" + "─".repeat(48))}`);
687
- 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();
688
757
  }
689
758
  }
690
759
 
@@ -699,7 +768,8 @@ function formatCategory(cat: DoctorIssue["category"]): string {
699
768
  enum_value_mismatch: "Enum Value Mismatch",
700
769
  missing_foreign_key: "Missing Foreign Key",
701
770
  sdk_stale: "Stale SDK Types",
702
- sdk_not_generated: "SDK Types Not Generated"
771
+ sdk_not_generated: "SDK Types Not Generated",
772
+ sdk_ungeneratable: "SDK Cannot Be Generated"
703
773
  };
704
774
  return labels[cat];
705
775
  }
@@ -712,38 +782,47 @@ export async function runDoctor(options: {
712
782
  sdkPath: string;
713
783
  databaseUrl?: string;
714
784
  }): Promise<DoctorReport> {
715
- logger.info("");
716
- logger.info(chalk.bold(" 🩺 Loading collections..."));
785
+ out();
786
+ out(chalk.bold(" 🩺 Loading collections..."));
717
787
  const collections = await loadCollections(options.collectionsPath);
718
788
  if (collections.length === 0) {
719
- logger.error(chalk.red(" ✗ No collections found."));
789
+ outError(chalk.red(" ✗ No collections found."));
720
790
  process.exit(1);
721
791
  }
722
- logger.info(chalk.gray(` Found ${collections.length} collection(s)`));
723
- logger.info("");
792
+ out(chalk.gray(` Found ${collections.length} collection(s)`));
793
+ out();
724
794
 
725
795
  // Phase 1: Collections ↔ Generated Schema
726
- logger.info(chalk.gray(" Checking Collections → Generated Schema..."));
796
+ out(chalk.gray(" Checking Collections → Generated Schema..."));
727
797
  const collectionsToSchema = await checkCollectionsVsSchema(collections, options.schemaPath);
728
798
 
729
799
  // Phase 2: Collections ↔ Database (only if we have a DATABASE_URL)
730
- let schemaToDatabase: { passed: boolean; issues: DoctorIssue[] } = { passed: true,
731
- issues: [] };
800
+ let schemaToDatabase: DoctorPhase;
732
801
  if (options.databaseUrl) {
733
- logger.info(chalk.gray(" Checking Collections → Database..."));
802
+ out(chalk.gray(" Checking Collections → Database..."));
734
803
  schemaToDatabase = await checkCollectionsVsDatabase(collections, options.databaseUrl);
735
804
  } else {
736
- logger.info(chalk.yellow(" ⚠ DATABASE_URL not set skipping database comparison."));
737
- 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."));
738
811
  }
739
812
 
740
813
  // Phase 3: Collections ↔ SDK Types
741
- logger.info(chalk.gray(" Checking Collections → SDK Types..."));
814
+ out(chalk.gray(" Checking Collections → SDK Types..."));
742
815
  const collectionsToSdk = await checkCollectionsVsSdk(collections, options.sdkPath);
743
816
 
744
- const allIssues = [...collectionsToSchema.issues, ...schemaToDatabase.issues, ...collectionsToSdk.issues];
817
+ const phases = [collectionsToSchema, schemaToDatabase, collectionsToSdk];
818
+ const allIssues = phases.flatMap((p) => p.issues);
745
819
  const summary = {
746
- 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,
747
826
  warnings: allIssues.filter((i) => i.severity === "warning").length,
748
827
  errors: allIssues.filter((i) => i.severity === "error").length
749
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,