@rebasepro/server-postgres 0.13.0 → 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 (85) hide show
  1. package/dist/PostgresBackendDriver.d.ts +48 -1
  2. package/dist/auth/services.d.ts +19 -0
  3. package/dist/{src-DlPBctw_.js → auth-users-columns-CBEOeYqa.js} +835 -63
  4. package/dist/auth-users-columns-CBEOeYqa.js.map +1 -0
  5. package/dist/{backup-service-CD8o_1Sl.js → backup-service-Bww-Lg0s.js} +2 -2
  6. package/dist/{backup-service-CD8o_1Sl.js.map → backup-service-Bww-Lg0s.js.map} +1 -1
  7. package/dist/cli-helpers.d.ts +57 -1
  8. package/dist/data-transformer.d.ts +7 -2
  9. package/dist/data_driver-ULAyJEi9.js +193 -0
  10. package/dist/data_driver-ULAyJEi9.js.map +1 -0
  11. package/dist/{ensure-collection-policies-ViG8XiPn.js → ensure-collection-policies-B_JMGa5K.js} +2 -2
  12. package/dist/{ensure-collection-policies-ViG8XiPn.js.map → ensure-collection-policies-B_JMGa5K.js.map} +1 -1
  13. package/dist/{ensure-collection-tables-CBQdOETu.js → ensure-collection-tables-DzeTEvMv.js} +170 -20
  14. package/dist/ensure-collection-tables-DzeTEvMv.js.map +1 -0
  15. package/dist/index.es.js +1365 -338
  16. package/dist/index.es.js.map +1 -1
  17. package/dist/rls-bootstrap-sql-Bpv3nUZo.js +244 -0
  18. package/dist/rls-bootstrap-sql-Bpv3nUZo.js.map +1 -0
  19. package/dist/schema/auth-schema.d.ts +102 -0
  20. package/dist/schema/auth-users-columns.d.ts +97 -0
  21. package/dist/schema/doctor-policy-checks.d.ts +28 -0
  22. package/dist/schema/doctor.d.ts +23 -25
  23. package/dist/schema/ensure-collection-tables.d.ts +61 -7
  24. package/dist/schema/generate-drizzle-schema-logic.d.ts +10 -2
  25. package/dist/schema/generate-postgres-ddl-logic.d.ts +53 -5
  26. package/dist/schema/generated-schema-staleness.d.ts +39 -0
  27. package/dist/schema/rls-bootstrap-sql.d.ts +135 -0
  28. package/dist/schema/search-column.d.ts +248 -0
  29. package/dist/security/rls-enforcement.d.ts +61 -5
  30. package/dist/services/FetchService.d.ts +34 -7
  31. package/dist/services/RelationService.d.ts +30 -0
  32. package/dist/services/collection-helpers.d.ts +26 -0
  33. package/dist/services/dataService.d.ts +5 -0
  34. package/dist/services/realtimeService.d.ts +131 -21
  35. package/dist/{src-DoU9yPqq.js → src-C_wvdMnl.js} +91 -2
  36. package/dist/src-C_wvdMnl.js.map +1 -0
  37. package/dist/utils/drizzle-conditions.d.ts +124 -2
  38. package/dist/{websocket-B2LsrINK.js → websocket-D1qbmLZ2.js} +75 -18
  39. package/dist/websocket-D1qbmLZ2.js.map +1 -0
  40. package/package.json +9 -8
  41. package/src/PostgresBackendDriver.ts +172 -6
  42. package/src/PostgresBootstrapper.ts +55 -6
  43. package/src/auth/ensure-tables.ts +207 -86
  44. package/src/auth/services.ts +69 -5
  45. package/src/cli-helpers.ts +129 -10
  46. package/src/cli.ts +232 -30
  47. package/src/collections/validate-relations.ts +124 -17
  48. package/src/data-transformer.ts +120 -17
  49. package/src/history/ensure-history-table.ts +7 -0
  50. package/src/schema/auth-schema.ts +17 -1
  51. package/src/schema/auth-users-columns.ts +131 -0
  52. package/src/schema/doctor-cli.ts +12 -63
  53. package/src/schema/doctor-policy-checks.ts +105 -0
  54. package/src/schema/doctor.ts +135 -76
  55. package/src/schema/ensure-collection-tables.ts +374 -32
  56. package/src/schema/generate-drizzle-schema-logic.ts +132 -42
  57. package/src/schema/generate-postgres-ddl-logic.ts +294 -16
  58. package/src/schema/generate-postgres-ddl.ts +25 -2
  59. package/src/schema/generated-schema-staleness.ts +169 -0
  60. package/src/schema/introspect-db-logic.ts +66 -34
  61. package/src/schema/non-sql-collections.test.ts +131 -0
  62. package/src/schema/rls-bootstrap-sql.ts +288 -0
  63. package/src/schema/search-column.ts +643 -0
  64. package/src/security/anonymous-grants.test.ts +4 -2
  65. package/src/security/rls-enforcement.ts +149 -6
  66. package/src/services/BranchService.ts +5 -0
  67. package/src/services/FetchService.ts +175 -108
  68. package/src/services/PersistService.ts +38 -2
  69. package/src/services/RelationService.ts +110 -67
  70. package/src/services/channel-history.ts +14 -0
  71. package/src/services/channel-presence.ts +13 -0
  72. package/src/services/collection-helpers.ts +54 -1
  73. package/src/services/dataService.ts +5 -0
  74. package/src/services/realtimeService.ts +344 -79
  75. package/src/utils/drizzle-conditions.ts +365 -23
  76. package/src/utils/pg-error-utils.ts +8 -3
  77. package/src/websocket.ts +113 -16
  78. package/dist/ensure-collection-tables-CBQdOETu.js.map +0 -1
  79. package/dist/policy-CeA1JcxP.js +0 -105
  80. package/dist/policy-CeA1JcxP.js.map +0 -1
  81. package/dist/schema/auth-bootstrap-sql.d.ts +0 -24
  82. package/dist/src-DlPBctw_.js.map +0 -1
  83. package/dist/src-DoU9yPqq.js.map +0 -1
  84. package/dist/websocket-B2LsrINK.js.map +0 -1
  85. package/src/schema/auth-bootstrap-sql.ts +0 -47
@@ -5,62 +5,8 @@
5
5
  */
6
6
  import path from "path";
7
7
  import chalk from "chalk";
8
- import fs from "fs";
9
- import { runDoctor, loadCollections } from "./doctor";
10
- import { checkPolicyDrift, formatPolicyDrift, hasDrift } from "../security/policy-drift";
11
- import { validatePolicyPgRoles, warnOnAnonymousGrants } from "../security/rls-enforcement";
12
- import { logger } from "@rebasepro/server";
13
-
14
- /**
15
- * The RLS half of the doctor: policies actually deployed vs the ones the
16
- * collections describe, plus policy roles this server could never satisfy.
17
- *
18
- * Returns true when something is wrong, so callers can set the exit code.
19
- */
20
- async function runPolicyChecks(collectionsPath: string, databaseUrl?: string): Promise<boolean> {
21
- if (!databaseUrl) {
22
- logger.warn(chalk.yellow(" ⚠ No DATABASE_URL — skipping RLS policy checks"));
23
- return false;
24
- }
25
-
26
- let problems = false;
27
- const { Pool } = await import("pg");
28
- const pool = new Pool({ connectionString: databaseUrl });
29
- try {
30
- const collections = await loadCollections(path.resolve(process.cwd(), collectionsPath));
31
- const runSql = async (text: string) => (await pool.query(text)).rows as Record<string, unknown>[];
32
-
33
- // A policy naming a role the server never runs as filters every row, so
34
- // the collection reads as empty. Report it without booting a server.
35
- try {
36
- await validatePolicyPgRoles(runSql, collections as never);
37
- logger.info(chalk.green(" ✓ Policy roles are usable by this server"));
38
- } catch (err) {
39
- problems = true;
40
- logger.info("");
41
- logger.error(chalk.red(err instanceof Error ? err.message : String(err)));
42
- }
43
-
44
- // A rule that reads as a lockdown but is true for every caller compiles
45
- // to a grant. Report it without booting a server.
46
- warnOnAnonymousGrants(collections as never);
47
-
48
- const drift = await checkPolicyDrift(pool as never, collections);
49
- logger.info("");
50
- if (hasDrift(drift)) {
51
- problems = true;
52
- logger.info(chalk.yellow(" RLS policies: database does not match your collections"));
53
- logger.info(formatPolicyDrift(drift));
54
- } else {
55
- logger.info(chalk.green(" ✓ RLS policies match your collections"));
56
- }
57
- } catch (err) {
58
- logger.warn(chalk.yellow(" ⚠ Could not check RLS policies"), { error: err });
59
- } finally {
60
- await pool.end();
61
- }
62
- return problems;
63
- }
8
+ import { runDoctor } from "./doctor";
9
+ import { exitCodeForPolicyGate, runPolicyChecks } from "./doctor-policy-checks";
64
10
 
65
11
  async function main() {
66
12
  const collectionsArg = process.argv.find((a) => a.startsWith("--collections="));
@@ -91,9 +37,9 @@ async function main() {
91
37
  const databaseUrl = process.env.DATABASE_URL || process.env.ADMIN_CONNECTION_STRING;
92
38
 
93
39
  if (policiesOnly) {
94
- // Non-zero so this can gate CI — the whole point of the flag.
95
- if (await runPolicyChecks(collectionsPath, databaseUrl)) process.exit(1);
96
- return;
40
+ // Non-zero so this can gate CI — the whole point of the flag, and a
41
+ // check that could not run has not passed. See exitCodeForPolicyGate.
42
+ process.exit(exitCodeForPolicyGate(await runPolicyChecks(collectionsPath, databaseUrl)));
97
43
  }
98
44
 
99
45
  const report = await runDoctor({
@@ -103,15 +49,18 @@ async function main() {
103
49
  databaseUrl: databaseUrl ?? undefined
104
50
  });
105
51
 
106
- const policiesDrifted = await runPolicyChecks(collectionsPath, databaseUrl);
52
+ const policyStatus = await runPolicyChecks(collectionsPath, databaseUrl);
107
53
 
108
- // Exit with non-zero code if there are errors
109
- if (report.summary.errors > 0 || policiesDrifted) {
54
+ // Exit non-zero if there are errors. A policy run that could not happen is
55
+ // reported loudly above but does not fail the interactive command — the
56
+ // same treatment the skipped database phase gets. `--policies` is the gate,
57
+ // and that one fails closed.
58
+ if (report.summary.errors > 0 || policyStatus === "problems") {
110
59
  process.exit(1);
111
60
  }
112
61
  }
113
62
 
114
63
  main().catch((err) => {
115
- logger.error(chalk.red(" ✗ Doctor failed"), { error: err });
64
+ console.error(chalk.red(" ✗ Doctor failed"), err instanceof Error ? (err.stack ?? err.message) : String(err));
116
65
  process.exit(1);
117
66
  });
@@ -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
+ }
@@ -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
  };