@rebasepro/server-postgres 0.12.1-canary.gf5f1d39 → 0.13.1-canary.g06dbe5b

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 (120) hide show
  1. package/dist/PostgresBackendDriver.d.ts +49 -2
  2. package/dist/PostgresBootstrapper.d.ts +25 -1
  3. package/dist/auth/services.d.ts +40 -0
  4. package/dist/{src-BbFOPJ1S.js → auth-users-columns-CBEOeYqa.js} +1118 -220
  5. package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
  6. package/dist/backup/backup-service.d.ts +10 -1
  7. package/dist/backup/pg-tools.d.ts +47 -0
  8. package/dist/backup-service-Bww-Lg0s.js +8999 -0
  9. package/dist/backup-service-Bww-Lg0s.js.map +1 -0
  10. package/dist/cli-helpers.d.ts +96 -1
  11. package/dist/connection-BuZ97wsr.js +250 -0
  12. package/dist/connection-BuZ97wsr.js.map +1 -0
  13. package/dist/connection.d.ts +42 -0
  14. package/dist/data-transformer.d.ts +7 -2
  15. package/dist/data_driver-ULAyJEi9.js +193 -0
  16. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  17. package/dist/ensure-collection-policies-B_JMGa5K.js +57 -0
  18. package/dist/ensure-collection-policies-B_JMGa5K.js.map +1 -0
  19. package/dist/ensure-collection-tables-DzeTEvMv.js +800 -0
  20. package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
  21. package/dist/index.es.js +2460 -10233
  22. package/dist/index.es.js.map +1 -1
  23. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  24. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  25. package/dist/schema/auth-schema.d.ts +185 -144
  26. package/dist/schema/auth-users-columns.d.ts +97 -0
  27. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  28. package/dist/schema/doctor.d.ts +23 -25
  29. package/dist/schema/ensure-collection-policies.d.ts +60 -0
  30. package/dist/schema/ensure-collection-tables.d.ts +99 -3
  31. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  32. package/dist/schema/generate-postgres-ddl-logic.d.ts +185 -3
  33. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  34. package/dist/schema/introspect-db-constraints.d.ts +57 -0
  35. package/dist/schema/introspect-db-logic.d.ts +94 -5
  36. package/dist/schema/introspect-db-queries.d.ts +119 -0
  37. package/dist/schema/introspect-db-structure.d.ts +263 -0
  38. package/dist/schema/introspect-db-types.d.ts +11 -0
  39. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  40. package/dist/schema/search-column.d.ts +248 -0
  41. package/dist/security/rls-enforcement.d.ts +61 -5
  42. package/dist/services/FetchService.d.ts +38 -8
  43. package/dist/services/RelationService.d.ts +54 -1
  44. package/dist/services/channel-bus/index.d.ts +1 -7
  45. package/dist/services/collection-helpers.d.ts +62 -2
  46. package/dist/services/dataService.d.ts +8 -1
  47. package/dist/services/realtimeService.d.ts +131 -21
  48. package/dist/services/row-pipeline.d.ts +1 -1
  49. package/dist/{src-Zqwaw3P5.js → src-C_wvdMnl.js} +78 -145
  50. package/dist/src-C_wvdMnl.js.map +1 -0
  51. package/dist/utils/connection-string.d.ts +29 -0
  52. package/dist/utils/drizzle-conditions.d.ts +129 -6
  53. package/dist/utils/pg-error-utils.d.ts +35 -0
  54. package/dist/websocket-D1qbmLZ2.js +587 -0
  55. package/dist/websocket-D1qbmLZ2.js.map +1 -0
  56. package/package.json +17 -16
  57. package/src/PostgresAdapter.ts +21 -2
  58. package/src/PostgresBackendDriver.ts +176 -6
  59. package/src/PostgresBootstrapper.ts +245 -37
  60. package/src/auth/ensure-tables.ts +357 -81
  61. package/src/auth/services.ts +93 -7
  62. package/src/backup/backup-cli.ts +41 -2
  63. package/src/backup/backup-service.ts +38 -5
  64. package/src/backup/pg-tools.ts +96 -3
  65. package/src/cli-helpers.ts +199 -10
  66. package/src/cli.ts +276 -56
  67. package/src/collections/validate-relations.ts +136 -14
  68. package/src/connection.ts +73 -0
  69. package/src/data-transformer.ts +129 -20
  70. package/src/databasePoolManager.ts +5 -2
  71. package/src/history/ensure-history-table.ts +7 -0
  72. package/src/schema/auth-schema.ts +46 -19
  73. package/src/schema/auth-users-columns.ts +131 -0
  74. package/src/schema/doctor-cli.ts +12 -63
  75. package/src/schema/doctor-policy-checks.ts +105 -0
  76. package/src/schema/doctor.ts +135 -76
  77. package/src/schema/ensure-collection-policies.ts +105 -0
  78. package/src/schema/ensure-collection-tables.test.ts +105 -9
  79. package/src/schema/ensure-collection-tables.ts +572 -42
  80. package/src/schema/generate-drizzle-schema-logic.ts +169 -43
  81. package/src/schema/generate-postgres-ddl-logic.ts +670 -29
  82. package/src/schema/generate-postgres-ddl.ts +25 -2
  83. package/src/schema/generated-schema-staleness.ts +169 -0
  84. package/src/schema/introspect-db-constraints.ts +385 -0
  85. package/src/schema/introspect-db-inference.ts +18 -8
  86. package/src/schema/introspect-db-logic.ts +446 -100
  87. package/src/schema/introspect-db-queries.ts +326 -0
  88. package/src/schema/introspect-db-structure.ts +670 -0
  89. package/src/schema/introspect-db-types.ts +56 -0
  90. package/src/schema/introspect-db.ts +37 -80
  91. package/src/schema/introspect-runtime.test.ts +56 -8
  92. package/src/schema/introspect-runtime.ts +31 -9
  93. package/src/schema/non-sql-collections.test.ts +131 -0
  94. package/src/schema/rls-bootstrap-sql.ts +288 -0
  95. package/src/schema/search-column.ts +643 -0
  96. package/src/security/anonymous-grants.test.ts +4 -2
  97. package/src/security/policy-drift.test.ts +11 -3
  98. package/src/security/rls-enforcement.ts +149 -6
  99. package/src/services/BranchService.ts +71 -28
  100. package/src/services/FetchService.ts +265 -122
  101. package/src/services/PersistService.ts +57 -7
  102. package/src/services/RelationService.ts +358 -114
  103. package/src/services/channel-bus/index.ts +0 -9
  104. package/src/services/channel-history.ts +14 -0
  105. package/src/services/channel-presence.ts +13 -0
  106. package/src/services/collection-helpers.ts +123 -4
  107. package/src/services/dataService.ts +8 -1
  108. package/src/services/realtimeService.ts +346 -81
  109. package/src/services/row-pipeline.ts +1 -1
  110. package/src/utils/connection-string.ts +58 -0
  111. package/src/utils/drizzle-conditions.ts +396 -29
  112. package/src/utils/pg-error-utils.ts +73 -3
  113. package/src/websocket.ts +130 -24
  114. package/dist/chunk-DSJWtz9O.js +0 -40
  115. package/dist/ensure-collection-tables-CNTcZGvn.js +0 -304
  116. package/dist/ensure-collection-tables-CNTcZGvn.js.map +0 -1
  117. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  118. package/dist/src-BbFOPJ1S.js.map +0 -1
  119. package/dist/src-Zqwaw3P5.js.map +0 -1
  120. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -15,9 +15,9 @@ 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
21
 
22
22
  /**
23
23
  * Resolve the SQL column name for a property.
@@ -33,11 +33,27 @@ const resolveColumnName = (propName: string, prop?: Property | null): string =>
33
33
 
34
34
  // ── Types ────────────────────────────────────────────────────────────────
35
35
 
36
+ /**
37
+ * The report is CLI output, not application logging.
38
+ *
39
+ * `logger.info` is gated by `LOG_LEVEL`, which is a documented setting and
40
+ * ships in the scaffold's own `.env.example`: a developer who quietened their
41
+ * dev server with `LOG_LEVEL=warn` got a `rebase doctor` that printed nothing
42
+ * at all and still exited 1, with no way to tell a failing run from a crashed
43
+ * one. At the default level every line of the ASCII box arrived with
44
+ * `ℹ️ [INFO] ` glued to the front of it, and under `NODE_ENV=production` the
45
+ * whole report became JSON log records with the chalk escapes inside.
46
+ * `packages/cli` writes all of its output this way for the same reason.
47
+ */
48
+ const out = (line = ""): void => {
49
+ console.log(line);
50
+ };
51
+
36
52
  export type IssueSeverity = "error" | "warning" | "info";
37
53
 
38
54
  export interface DoctorIssue {
39
55
  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";
56
+ 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
57
  table?: string;
42
58
  column?: string;
43
59
  expected?: string;
@@ -46,11 +62,27 @@ export interface DoctorIssue {
46
62
  fix: string;
47
63
  }
48
64
 
65
+ export interface DoctorPhase {
66
+ passed: boolean;
67
+ issues: DoctorIssue[];
68
+ /**
69
+ * Why this phase never ran, when it did not.
70
+ *
71
+ * A check that did not happen is a third state, not a passing one. While a
72
+ * skipped phase initialised to `{ passed: true, issues: [] }` it rendered as
73
+ * `✅ Collections → Database: In sync` and counted towards
74
+ * `✓ All schemas are in sync!` — so a project whose connection string was
75
+ * spelled `POSTGRES_URL`, or a CI job that never exported one, got two green
76
+ * ticks and exit 0 against a database with no tables in it.
77
+ */
78
+ skipped?: string;
79
+ }
80
+
49
81
  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 };
82
+ collectionsToSchema: DoctorPhase;
83
+ collectionsToSdk: DoctorPhase;
84
+ schemaToDatabase: DoctorPhase;
85
+ summary: { passed: number; skipped: number; warnings: number; errors: number };
54
86
  }
55
87
 
56
88
  // ── Column type mapping (mirrors generate-drizzle-schema-logic.ts) ───────
@@ -149,7 +181,7 @@ export async function loadCollections(collectionsPath: string): Promise<Collecti
149
181
  export async function checkCollectionsVsSchema(
150
182
  collections: CollectionConfig[],
151
183
  schemaFilePath: string
152
- ): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
184
+ ): Promise<DoctorPhase> {
153
185
  const issues: DoctorIssue[] = [];
154
186
 
155
187
  // Check if schema file exists
@@ -164,8 +196,10 @@ export async function checkCollectionsVsSchema(
164
196
  issues };
165
197
  }
166
198
 
167
- // Re-generate schema in-memory and compare with file on disk
168
- const postgresCollections = collections.filter(isPostgresCollectionConfig);
199
+ // Re-generate schema in-memory and compare with file on disk. Only the
200
+ // collections the generator itself will emit — a Firestore collection has
201
+ // no table in the generated file and must not be reported as missing one.
202
+ const postgresCollections = relationalCollections(collections);
169
203
  if (postgresCollections.length === 0) {
170
204
  return { passed: true,
171
205
  issues };
@@ -208,7 +242,7 @@ issues };
208
242
  export async function checkCollectionsVsSdk(
209
243
  collections: CollectionConfig[],
210
244
  sdkFilePath: string
211
- ): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
245
+ ): Promise<DoctorPhase> {
212
246
  const issues: DoctorIssue[] = [];
213
247
 
214
248
  // The typed SDK is opt-in — nothing in a scaffolded project imports it until
@@ -248,12 +282,27 @@ issues };
248
282
  }
249
283
  } catch (err: unknown) {
250
284
  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
- });
285
+
286
+ // A `CodegenError` is not "the comparison did not run" — it is the
287
+ // schema saying it cannot produce a valid typed client at all, most
288
+ // often two slugs collapsing onto one accessor. Reporting that as a
289
+ // warning about staleness described the wrong problem and pointed at a
290
+ // command that would fail the same way.
291
+ if (err instanceof Error && err.name === "CodegenError") {
292
+ issues.push({
293
+ severity: "error",
294
+ category: "sdk_ungeneratable",
295
+ message: `The typed SDK cannot be generated from these collections: ${message}`,
296
+ fix: "Fix the collection definitions named above, then run `rebase generate-sdk`"
297
+ });
298
+ } else {
299
+ issues.push({
300
+ severity: "warning",
301
+ category: "sdk_stale",
302
+ message: `Could not regenerate SDK types for comparison: ${message}`,
303
+ fix: "Run `rebase generate-sdk` to verify"
304
+ });
305
+ }
257
306
  }
258
307
 
259
308
  return { passed: issues.length === 0,
@@ -280,7 +329,7 @@ interface DbEnumValue {
280
329
  export async function checkCollectionsVsDatabase(
281
330
  collections: CollectionConfig[],
282
331
  databaseUrl: string
283
- ): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
332
+ ): Promise<DoctorPhase> {
284
333
  const issues: DoctorIssue[] = [];
285
334
 
286
335
  // Dynamic import to avoid loading pg when not needed
@@ -292,7 +341,7 @@ export async function checkCollectionsVsDatabase(
292
341
  const schemas = Array.from(new Set([
293
342
  "public",
294
343
  "rebase",
295
- ...collections
344
+ ...relationalCollections(collections)
296
345
  .filter(isPostgresCollectionConfig)
297
346
  .map(c => c.schema)
298
347
  .filter((s): s is string => !!s)
@@ -383,7 +432,7 @@ export async function checkCollectionsVsDatabase(
383
432
 
384
433
  // ── Compare each collection against the database ─────────────────
385
434
 
386
- const postgresCollections = collections.filter(isPostgresCollectionConfig);
435
+ const postgresCollections = relationalCollections(collections);
387
436
 
388
437
  for (const collection of postgresCollections) {
389
438
  const tableName = getTableName(collection);
@@ -595,96 +644,99 @@ issues };
595
644
  // ── Report Rendering ─────────────────────────────────────────────────────
596
645
 
597
646
  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("");
647
+ out();
648
+ out(chalk.bold(" 🩺 Rebase Schema Doctor"));
649
+ out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
650
+ out();
602
651
 
603
652
  // Phase 1
604
- renderPhase(
605
- "Collections → Generated Schema",
606
- report.collectionsToSchema.passed,
607
- report.collectionsToSchema.issues
608
- );
653
+ renderPhase("Collections → Generated Schema", report.collectionsToSchema);
609
654
 
610
655
  // Phase 2
611
- renderPhase(
612
- "Collections → Database",
613
- report.schemaToDatabase.passed,
614
- report.schemaToDatabase.issues
615
- );
656
+ renderPhase("Collections → Database", report.schemaToDatabase);
616
657
 
617
658
  // Phase 3
618
- renderPhase(
619
- "Collections → SDK Types",
620
- report.collectionsToSdk.passed,
621
- report.collectionsToSdk.issues
622
- );
659
+ renderPhase("Collections → SDK Types", report.collectionsToSdk);
623
660
 
624
661
  // Summary
625
- logger.info(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
626
- const { passed, warnings, errors } = report.summary;
662
+ out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
663
+ const { passed, skipped, warnings, errors } = report.summary;
627
664
 
628
665
  const parts: string[] = [];
629
666
  parts.push(chalk.green(`${passed} passed`));
667
+ if (skipped > 0) parts.push(chalk.yellow(`${skipped} skipped`));
630
668
  if (warnings > 0) parts.push(chalk.yellow(`${warnings} warnings`));
631
669
  if (errors > 0) parts.push(chalk.red(`${errors} errors`));
632
670
 
633
- logger.info(` Summary: ${parts.join(", ")}`);
634
- logger.info("");
671
+ out(` Summary: ${parts.join(", ")}`);
672
+ out();
635
673
 
636
674
  if (errors > 0) {
637
- logger.info(chalk.red.bold(" ✗ Schema drift detected. Run the suggested fixes above."));
675
+ out(chalk.red.bold(" ✗ Schema drift detected. Run the suggested fixes above."));
638
676
  } else if (warnings > 0) {
639
- logger.info(chalk.yellow.bold(" ⚠ Minor issues detected. Consider running the suggested fixes."));
677
+ out(chalk.yellow.bold(" ⚠ Minor issues detected. Consider running the suggested fixes."));
678
+ } else if (skipped > 0) {
679
+ // Never "all schemas are in sync" off the back of a check that did not
680
+ // run: a clean bill of health has to come from measurement.
681
+ 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
682
  } else {
641
- logger.info(chalk.green.bold(" ✓ All schemas are in sync!"));
683
+ out(chalk.green.bold(" ✓ All schemas are in sync!"));
642
684
  }
643
- logger.info("");
685
+ out();
644
686
  }
645
687
 
646
- function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): void {
688
+ function renderPhase(label: string, phase: DoctorPhase): void {
689
+ // A phase that never ran gets its own marker and its reason. It is neither
690
+ // "In sync" nor a drift report; conflating it with the former is what let
691
+ // doctor certify a database it had not opened.
692
+ if (phase.skipped) {
693
+ out(` ${chalk.yellow("⏭")} ${label}: ${chalk.yellow(`skipped (${phase.skipped})`)}`);
694
+ out();
695
+ return;
696
+ }
697
+
698
+ const issues = phase.issues;
647
699
  const errorCount = issues.filter((i) => i.severity === "error").length;
648
700
  const warnCount = issues.filter((i) => i.severity === "warning").length;
649
701
  const infoIssues = issues.filter((i) => i.severity === "info");
650
702
 
651
703
  // Informational notes don't make a phase unhealthy, so key the header off
652
- // real problems rather than `passed` alone.
704
+ // real problems rather than `phase.passed` alone.
653
705
  if (errorCount === 0 && warnCount === 0) {
654
- logger.info(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
706
+ out(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
655
707
  } else {
656
708
  const parts: string[] = [];
657
709
  if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
658
710
  if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
659
- logger.info(` ${chalk.yellow("⚠️")} ${label}: ${chalk.yellow(parts.join(", "))}`);
711
+ out(` ${chalk.yellow("⚠️")} ${label}: ${chalk.yellow(parts.join(", "))}`);
660
712
  }
661
- logger.info("");
713
+ out();
662
714
 
663
715
  // Notes render as a quiet one-liner, not a full drift box.
664
716
  for (const issue of infoIssues) {
665
717
  const fixPart = issue.fix ? chalk.gray(` — ${issue.fix}`) : "";
666
- logger.info(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
667
- logger.info("");
718
+ out(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
719
+ out();
668
720
  }
669
721
 
670
722
  for (const issue of issues.filter((i) => i.severity !== "info")) {
671
723
  const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
672
724
  const categoryLabel = formatCategory(issue.category);
673
- logger.info(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
725
+ out(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
674
726
 
675
727
  if (issue.table) {
676
728
  const colPart = issue.column ? ` │ Column: ${chalk.cyan(issue.column)}` : "";
677
- logger.info(` ${chalk.gray("│")} Table: ${chalk.cyan(issue.table)}${colPart}`);
729
+ out(` ${chalk.gray("│")} Table: ${chalk.cyan(issue.table)}${colPart}`);
678
730
  }
679
731
 
680
732
  if (issue.expected && issue.actual) {
681
- logger.info(` ${chalk.gray("│")} Expected: ${chalk.green(issue.expected)} │ Actual: ${chalk.red(issue.actual)}`);
733
+ out(` ${chalk.gray("│")} Expected: ${chalk.green(issue.expected)} │ Actual: ${chalk.red(issue.actual)}`);
682
734
  }
683
735
 
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("");
736
+ out(` ${chalk.gray("│")} ${issue.message}`);
737
+ out(` ${chalk.gray("│")} Fix: ${chalk.blue(issue.fix)}`);
738
+ out(` ${chalk.gray("└" + "─".repeat(48))}`);
739
+ out();
688
740
  }
689
741
  }
690
742
 
@@ -699,7 +751,8 @@ function formatCategory(cat: DoctorIssue["category"]): string {
699
751
  enum_value_mismatch: "Enum Value Mismatch",
700
752
  missing_foreign_key: "Missing Foreign Key",
701
753
  sdk_stale: "Stale SDK Types",
702
- sdk_not_generated: "SDK Types Not Generated"
754
+ sdk_not_generated: "SDK Types Not Generated",
755
+ sdk_ungeneratable: "SDK Cannot Be Generated"
703
756
  };
704
757
  return labels[cat];
705
758
  }
@@ -712,38 +765,44 @@ export async function runDoctor(options: {
712
765
  sdkPath: string;
713
766
  databaseUrl?: string;
714
767
  }): Promise<DoctorReport> {
715
- logger.info("");
716
- logger.info(chalk.bold(" 🩺 Loading collections..."));
768
+ out();
769
+ out(chalk.bold(" 🩺 Loading collections..."));
717
770
  const collections = await loadCollections(options.collectionsPath);
718
771
  if (collections.length === 0) {
719
- logger.error(chalk.red(" ✗ No collections found."));
772
+ console.error(chalk.red(" ✗ No collections found."));
720
773
  process.exit(1);
721
774
  }
722
- logger.info(chalk.gray(` Found ${collections.length} collection(s)`));
723
- logger.info("");
775
+ out(chalk.gray(` Found ${collections.length} collection(s)`));
776
+ out();
724
777
 
725
778
  // Phase 1: Collections ↔ Generated Schema
726
- logger.info(chalk.gray(" Checking Collections → Generated Schema..."));
779
+ out(chalk.gray(" Checking Collections → Generated Schema..."));
727
780
  const collectionsToSchema = await checkCollectionsVsSchema(collections, options.schemaPath);
728
781
 
729
782
  // Phase 2: Collections ↔ Database (only if we have a DATABASE_URL)
730
- let schemaToDatabase: { passed: boolean; issues: DoctorIssue[] } = { passed: true,
731
- issues: [] };
783
+ let schemaToDatabase: DoctorPhase;
732
784
  if (options.databaseUrl) {
733
- logger.info(chalk.gray(" Checking Collections → Database..."));
785
+ out(chalk.gray(" Checking Collections → Database..."));
734
786
  schemaToDatabase = await checkCollectionsVsDatabase(collections, options.databaseUrl);
735
787
  } 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."));
788
+ // Not `{ passed: true }`: see DoctorPhase.skipped.
789
+ schemaToDatabase = { passed: false,
790
+ issues: [],
791
+ skipped: "DATABASE_URL not set" };
792
+ out(chalk.yellow(" ⚠ DATABASE_URL not set — skipping database comparison."));
793
+ out(chalk.gray(" Set DATABASE_URL in your .env to enable full drift detection."));
738
794
  }
739
795
 
740
796
  // Phase 3: Collections ↔ SDK Types
741
- logger.info(chalk.gray(" Checking Collections → SDK Types..."));
797
+ out(chalk.gray(" Checking Collections → SDK Types..."));
742
798
  const collectionsToSdk = await checkCollectionsVsSdk(collections, options.sdkPath);
743
799
 
744
- const allIssues = [...collectionsToSchema.issues, ...schemaToDatabase.issues, ...collectionsToSdk.issues];
800
+ const phases = [collectionsToSchema, schemaToDatabase, collectionsToSdk];
801
+ const allIssues = phases.flatMap((p) => p.issues);
745
802
  const summary = {
746
- passed: [collectionsToSchema, schemaToDatabase, collectionsToSdk].filter((p) => p.passed).length,
803
+ // A skipped phase is not a passing one, however few issues it collected.
804
+ passed: phases.filter((p) => p.passed && !p.skipped).length,
805
+ skipped: phases.filter((p) => p.skipped).length,
747
806
  warnings: allIssues.filter((i) => i.severity === "warning").length,
748
807
  errors: allIssues.filter((i) => i.severity === "error").length
749
808
  };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Applying a bundle's RLS policies to a database at boot, idempotently.
3
+ *
4
+ * ## Why this exists
5
+ *
6
+ * {@link ensureCollectionTables} creates the collection *tables* a managed
7
+ * runtime boots against, but a table with row-level security disabled and no
8
+ * policies is not servable: authenticated requests run as the restricted
9
+ * `rebase_user` role, so a read with no `SELECT` policy returns nothing (a
10
+ * public collection answered 401) and a write with no `INSERT`/`UPDATE` policy
11
+ * is denied. The policies live in the collections' `securityRules`; nothing at
12
+ * boot applied them. `rebase db push` does — but it drives Atlas against a
13
+ * local `DATABASE_URL`, and a managed tenant's database is reachable only from
14
+ * inside the cluster, by the runtime that is already connected to it. So the
15
+ * runtime is the only thing that *can* apply them, and this is where it does.
16
+ *
17
+ * ## Why this is safe to run on every boot
18
+ *
19
+ * Every statement is idempotent: `ENABLE ROW LEVEL SECURITY` is a no-op once
20
+ * enabled, and each policy is a `DROP POLICY IF EXISTS` immediately followed by
21
+ * a `CREATE POLICY`, so re-applying asserts exactly the declared state. It adds
22
+ * and replaces; it never drops data. (It does not *reconcile* — a policy a
23
+ * previous push left behind under an old name is not removed here; that stays a
24
+ * `db push` / `db migrate` concern, alongside destructive schema changes.)
25
+ *
26
+ * Unlike table creation, a failure here is not fatal: RLS stays enabled, so a
27
+ * table whose policies could not be applied fails **closed** (denies) rather
28
+ * than leaking rows. One collection's policy failing (e.g. a rule that
29
+ * references a table a real migration has not created yet) must not crash-loop
30
+ * the whole deployment and take the other collections' working routes down with
31
+ * it. Failures are reported loudly and per-table so the operator can see
32
+ * exactly which collection is not yet servable and why.
33
+ */
34
+ import { type CollectionConfig } from "@rebasepro/types";
35
+ import { planCollectionPolicies } from "./generate-postgres-ddl-logic";
36
+ import { readExistingSchema, type Queryable } from "./ensure-collection-tables";
37
+
38
+ export interface PolicyEnsureResult {
39
+ /** `CREATE POLICY` statements that ran successfully. */
40
+ policiesApplied: number;
41
+ /** Tables that had RLS enabled. */
42
+ tablesSecured: number;
43
+ /** Declared tables absent from the database — left to a real migration. */
44
+ skipped: { table: string; reason: string }[];
45
+ /** Tables whose RLS could not be fully applied (fail closed). */
46
+ failures: { table: string; error: string }[];
47
+ }
48
+
49
+ const isCreatePolicy = (statement: string): boolean => /^\s*CREATE POLICY/i.test(statement);
50
+
51
+ /**
52
+ * Bring the declared collections' RLS policies up to date. Returns what it did.
53
+ *
54
+ * Only tables that already exist are touched: the boot-time table creator runs
55
+ * first, so anything still missing is a table this additive path is not allowed
56
+ * to create (a junction, or a relation left to a migration). Enabling RLS on a
57
+ * non-existent table would error, so those are recorded as skipped, not failed.
58
+ */
59
+ export async function ensureCollectionPolicies(
60
+ client: Queryable,
61
+ collections: CollectionConfig[],
62
+ log?: (message: string) => void
63
+ ): Promise<PolicyEnsureResult> {
64
+ const result: PolicyEnsureResult = { policiesApplied: 0, tablesSecured: 0, skipped: [], failures: [] };
65
+
66
+ const plans = planCollectionPolicies(collections);
67
+ if (plans.length === 0) return result;
68
+
69
+ const schemas = Array.from(new Set(plans.map(p => p.schema)));
70
+ const existing = await readExistingSchema(client, schemas);
71
+
72
+ for (const plan of plans) {
73
+ if (!existing.tables.has(plan.qualified)) {
74
+ result.skipped.push({
75
+ table: plan.qualified,
76
+ reason: "table is not present in the database; create it with `rebase db push` / `rebase db migrate`"
77
+ });
78
+ continue;
79
+ }
80
+
81
+ 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
+ await client.query(plan.enableRls);
85
+ result.tablesSecured++;
86
+
87
+ let created = 0;
88
+ for (const statement of plan.policyStatements) {
89
+ await client.query(statement);
90
+ if (isCreatePolicy(statement)) {
91
+ result.policiesApplied++;
92
+ created++;
93
+ }
94
+ }
95
+ log?.(`${plan.qualified}: RLS enabled, ${created} policy(ies) applied`);
96
+ } catch (err) {
97
+ result.failures.push({
98
+ table: plan.qualified,
99
+ error: err instanceof Error ? err.message : String(err)
100
+ });
101
+ }
102
+ }
103
+
104
+ return result;
105
+ }
@@ -87,16 +87,112 @@ describe("planning an additive schema ensure", () => {
87
87
  expect(sql).not.toMatch(/NOT NULL/i);
88
88
  });
89
89
 
90
- it("leaves relation columns to a real migration rather than adding them without their key", () => {
91
- const withRelation = {
92
- ...posts,
93
- properties: {
94
- ...(posts as unknown as { properties: Record<string, unknown> }).properties,
95
- author: { name: "Author", type: "reference", target: () => posts }
90
+ // ── Relations ────────────────────────────────────────────────────────────
91
+ //
92
+ // These columns were once skipped outright, on the reasoning that a bare
93
+ // column without its foreign key would disagree with what `db push` later
94
+ // generated. On a managed tenant nothing pushes afterwards, so the table
95
+ // arrived without the column its own collection reads: every insert 400ed
96
+ // with `column "author_id" does not exist`. The answer is to emit the key
97
+ // too, from the same planner `db push` uses.
98
+
99
+ const authors = {
100
+ name: "Authors",
101
+ slug: "authors",
102
+ properties: {
103
+ id: { name: "ID", type: "string", isId: "uuid" },
104
+ name: { name: "Name", type: "string" }
105
+ }
106
+ } as unknown as CollectionConfig;
107
+
108
+ const postsWithAuthor = {
109
+ ...posts,
110
+ properties: {
111
+ ...(posts as unknown as { properties: Record<string, unknown> }).properties,
112
+ author: { name: "Author", type: "reference", path: "authors" }
113
+ }
114
+ } as unknown as CollectionConfig;
115
+
116
+ const tags = {
117
+ name: "Tags",
118
+ slug: "tags",
119
+ properties: {
120
+ id: { name: "ID", type: "number", isId: "increment" },
121
+ name: { name: "Name", type: "string" }
122
+ }
123
+ } as unknown as CollectionConfig;
124
+
125
+ const postsWithTags = {
126
+ ...posts,
127
+ properties: {
128
+ ...(posts as unknown as { properties: Record<string, unknown> }).properties,
129
+ tags: {
130
+ name: "Tags",
131
+ type: "relation",
132
+ relation: { kind: "manyToMany", target: () => tags, relationName: "tags" }
96
133
  }
97
- } as unknown as CollectionConfig;
98
- const plan = planCollectionSchemaEnsure([withRelation], withTable("public.posts", ["id"]));
99
- expect(plan.actions.some(a => a.target.endsWith(".author"))).toBe(false);
134
+ }
135
+ } as unknown as CollectionConfig;
136
+
137
+ it("adds a reference column, and the foreign key that makes it one", () => {
138
+ const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], withTable("public.posts", ["id"]));
139
+
140
+ expect(plan.actions.some(a => a.kind === "add-column" && a.target === "public.posts.author")).toBe(true);
141
+ const fk = plan.actions.find(a => a.kind === "add-constraint");
142
+ expect(fk?.target).toBe("public.posts.posts_author_fkey");
143
+ expect(fk?.sql).toMatch(/REFERENCES "public"\."authors" \("id"\)/);
144
+ });
145
+
146
+ it("orders every constraint after the columns and tables it depends on", () => {
147
+ const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], empty());
148
+ const kinds = plan.actions.map(a => a.kind);
149
+ expect(kinds.lastIndexOf("add-column")).toBeLessThan(kinds.indexOf("add-constraint"));
150
+ expect(kinds.lastIndexOf("create-table")).toBeLessThan(kinds.indexOf("add-constraint"));
151
+ });
152
+
153
+ it("skips a foreign key the database already has, since ADD CONSTRAINT has no IF NOT EXISTS", () => {
154
+ const existing: ExistingSchema = {
155
+ tables: new Map([
156
+ ["public.posts", new Set(["id", "author"])],
157
+ ["public.authors", new Set(["id", "name"])]
158
+ ]),
159
+ enums: new Set(["public.posts_status"]),
160
+ constraints: new Set(["public.posts.posts_author_fkey"])
161
+ };
162
+ const plan = planCollectionSchemaEnsure([postsWithAuthor, authors], existing);
163
+ expect(plan.actions.some(a => a.kind === "add-constraint")).toBe(false);
164
+ });
165
+
166
+ it("creates the junction table behind a many-to-many, keyed on both endpoints", () => {
167
+ const plan = planCollectionSchemaEnsure([postsWithTags, tags], empty());
168
+ const junction = plan.actions.find(a => a.kind === "create-table" && a.target === "public.posts_tags");
169
+
170
+ expect(junction).toBeDefined();
171
+ // The endpoint key types have to match the primary keys they reference:
172
+ // posts is a uuid, tags an auto-increment integer.
173
+ expect(junction!.sql).toMatch(/"post_id" UUID NOT NULL/);
174
+ expect(junction!.sql).toMatch(/"tag_id" INTEGER NOT NULL/);
175
+ expect(junction!.sql).toMatch(/PRIMARY KEY \("post_id", "tag_id"\)/);
176
+
177
+ const fks = plan.actions.filter(a => a.kind === "add-constraint").map(a => a.target);
178
+ expect(fks).toContain("public.posts_tags.posts_tags_post_id_fkey");
179
+ expect(fks).toContain("public.posts_tags.posts_tags_tag_id_fkey");
180
+ });
181
+
182
+ it("leaves an existing junction table alone", () => {
183
+ const existing: ExistingSchema = {
184
+ tables: new Map([
185
+ ["public.posts", new Set(["id", "title", "views", "status"])],
186
+ ["public.tags", new Set(["id", "name"])],
187
+ ["public.posts_tags", new Set(["post_id", "tag_id"])]
188
+ ]),
189
+ enums: new Set(["public.posts_status"]),
190
+ constraints: new Set([
191
+ "public.posts_tags.posts_tags_post_id_fkey",
192
+ "public.posts_tags.posts_tags_tag_id_fkey"
193
+ ])
194
+ };
195
+ expect(planCollectionSchemaEnsure([postsWithTags, tags], existing).actions).toEqual([]);
100
196
  });
101
197
  });
102
198