@rebasepro/server-postgres 0.0.1-canary.4829d6e

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 (121) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +86 -0
  3. package/dist/PostgresAdapter.d.ts +6 -0
  4. package/dist/PostgresBackendDriver.d.ts +150 -0
  5. package/dist/PostgresBootstrapper.d.ts +51 -0
  6. package/dist/auth/ensure-tables.d.ts +10 -0
  7. package/dist/auth/services.d.ts +250 -0
  8. package/dist/backup/backup-cli.d.ts +3 -0
  9. package/dist/backup/backup-cron.d.ts +53 -0
  10. package/dist/backup/backup-service.d.ts +85 -0
  11. package/dist/backup/index.d.ts +12 -0
  12. package/dist/backup/pg-tools.d.ts +110 -0
  13. package/dist/backup/retention.d.ts +35 -0
  14. package/dist/chunk-DSJWtz9O.js +40 -0
  15. package/dist/cli-errors.d.ts +42 -0
  16. package/dist/cli-helpers.d.ts +7 -0
  17. package/dist/cli.d.ts +1 -0
  18. package/dist/collections/PostgresCollectionRegistry.d.ts +47 -0
  19. package/dist/connection.d.ts +65 -0
  20. package/dist/data-transformer.d.ts +55 -0
  21. package/dist/databasePoolManager.d.ts +20 -0
  22. package/dist/history/HistoryService.d.ts +71 -0
  23. package/dist/history/ensure-history-table.d.ts +7 -0
  24. package/dist/index.d.ts +15 -0
  25. package/dist/index.es.js +23535 -0
  26. package/dist/index.es.js.map +1 -0
  27. package/dist/interfaces.d.ts +18 -0
  28. package/dist/schema/auth-bootstrap-sql.d.ts +24 -0
  29. package/dist/schema/auth-default-policies.d.ts +10 -0
  30. package/dist/schema/auth-schema.d.ts +2376 -0
  31. package/dist/schema/doctor-cli.d.ts +2 -0
  32. package/dist/schema/doctor.d.ts +58 -0
  33. package/dist/schema/dynamic-tables.d.ts +31 -0
  34. package/dist/schema/generate-drizzle-schema-logic.d.ts +2 -0
  35. package/dist/schema/generate-drizzle-schema.d.ts +1 -0
  36. package/dist/schema/generate-postgres-ddl-logic.d.ts +5 -0
  37. package/dist/schema/generate-postgres-ddl.d.ts +1 -0
  38. package/dist/schema/introspect-db-inference.d.ts +5 -0
  39. package/dist/schema/introspect-db-logic.d.ts +118 -0
  40. package/dist/schema/introspect-db.d.ts +1 -0
  41. package/dist/schema/introspect-runtime.d.ts +57 -0
  42. package/dist/schema/test-schema.d.ts +24 -0
  43. package/dist/security/policy-drift.d.ts +57 -0
  44. package/dist/security/rls-enforcement.d.ts +122 -0
  45. package/dist/services/BranchService.d.ts +47 -0
  46. package/dist/services/FetchService.d.ts +214 -0
  47. package/dist/services/PersistService.d.ts +39 -0
  48. package/dist/services/RelationService.d.ts +109 -0
  49. package/dist/services/cdc/CdcListener.d.ts +54 -0
  50. package/dist/services/cdc/trigger-cdc.d.ts +64 -0
  51. package/dist/services/collection-helpers.d.ts +38 -0
  52. package/dist/services/dataService.d.ts +110 -0
  53. package/dist/services/index.d.ts +4 -0
  54. package/dist/services/realtimeService.d.ts +298 -0
  55. package/dist/src-Eh-CZosp.js +595 -0
  56. package/dist/src-Eh-CZosp.js.map +1 -0
  57. package/dist/types.d.ts +3 -0
  58. package/dist/utils/drizzle-conditions.d.ts +138 -0
  59. package/dist/utils/pg-array-null-patch.d.ts +16 -0
  60. package/dist/utils/pg-error-utils.d.ts +65 -0
  61. package/dist/utils/table-classification.d.ts +8 -0
  62. package/dist/websocket.d.ts +18 -0
  63. package/package.json +113 -0
  64. package/src/PostgresAdapter.ts +58 -0
  65. package/src/PostgresBackendDriver.ts +1387 -0
  66. package/src/PostgresBootstrapper.ts +581 -0
  67. package/src/auth/ensure-tables.ts +367 -0
  68. package/src/auth/services.ts +1321 -0
  69. package/src/backup/backup-cli.ts +383 -0
  70. package/src/backup/backup-cron.ts +189 -0
  71. package/src/backup/backup-service.ts +299 -0
  72. package/src/backup/index.ts +12 -0
  73. package/src/backup/pg-tools.ts +231 -0
  74. package/src/backup/retention.ts +75 -0
  75. package/src/cli-errors.ts +265 -0
  76. package/src/cli-helpers.ts +196 -0
  77. package/src/cli.ts +786 -0
  78. package/src/collections/PostgresCollectionRegistry.ts +103 -0
  79. package/src/connection.ts +166 -0
  80. package/src/data-transformer.ts +733 -0
  81. package/src/databasePoolManager.ts +87 -0
  82. package/src/history/HistoryService.ts +272 -0
  83. package/src/history/ensure-history-table.ts +46 -0
  84. package/src/index.ts +15 -0
  85. package/src/interfaces.ts +60 -0
  86. package/src/schema/auth-bootstrap-sql.ts +41 -0
  87. package/src/schema/auth-default-policies.ts +125 -0
  88. package/src/schema/auth-schema.ts +233 -0
  89. package/src/schema/doctor-cli.ts +113 -0
  90. package/src/schema/doctor.ts +733 -0
  91. package/src/schema/dynamic-tables.test.ts +302 -0
  92. package/src/schema/dynamic-tables.ts +293 -0
  93. package/src/schema/generate-drizzle-schema-logic.ts +850 -0
  94. package/src/schema/generate-drizzle-schema.ts +131 -0
  95. package/src/schema/generate-postgres-ddl-logic.ts +490 -0
  96. package/src/schema/generate-postgres-ddl.ts +92 -0
  97. package/src/schema/introspect-db-inference.ts +238 -0
  98. package/src/schema/introspect-db-logic.ts +910 -0
  99. package/src/schema/introspect-db.ts +266 -0
  100. package/src/schema/introspect-runtime.test.ts +212 -0
  101. package/src/schema/introspect-runtime.ts +293 -0
  102. package/src/schema/test-schema.ts +11 -0
  103. package/src/security/policy-drift.test.ts +122 -0
  104. package/src/security/policy-drift.ts +159 -0
  105. package/src/security/rls-enforcement.ts +295 -0
  106. package/src/services/BranchService.ts +251 -0
  107. package/src/services/FetchService.ts +1661 -0
  108. package/src/services/PersistService.ts +329 -0
  109. package/src/services/RelationService.ts +1306 -0
  110. package/src/services/cdc/CdcListener.ts +167 -0
  111. package/src/services/cdc/trigger-cdc.ts +169 -0
  112. package/src/services/collection-helpers.ts +151 -0
  113. package/src/services/dataService.ts +246 -0
  114. package/src/services/index.ts +13 -0
  115. package/src/services/realtimeService.ts +1502 -0
  116. package/src/types.ts +4 -0
  117. package/src/utils/drizzle-conditions.ts +1162 -0
  118. package/src/utils/pg-array-null-patch.ts +42 -0
  119. package/src/utils/pg-error-utils.ts +227 -0
  120. package/src/utils/table-classification.ts +16 -0
  121. package/src/websocket.ts +640 -0
@@ -0,0 +1,733 @@
1
+ /**
2
+ * Rebase Schema Doctor — Three-way schema drift detection.
3
+ *
4
+ * Compares:
5
+ * 1. Collection definitions → Generated Drizzle schema (staleness check)
6
+ * 2. Collection definitions → Live PostgreSQL database (structural drift)
7
+ *
8
+ * Run via: rebase doctor
9
+ */
10
+ import { promises as fsPromises } from "fs";
11
+ import * as fs from "fs";
12
+ import path from "path";
13
+ import { pathToFileURL } from "url";
14
+ import chalk from "chalk";
15
+ import { CollectionConfig, isPostgresCollectionConfig, Property, NumberProperty, StringProperty, DateProperty, ArrayProperty, MapProperty, RelationProperty } from "@rebasepro/types";
16
+ import { generateSchema } from "./generate-drizzle-schema-logic";
17
+ import { generateTypedefs } from "@rebasepro/codegen";
18
+ import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
19
+ import { toSnakeCase } from "@rebasepro/utils";
20
+ import { logger, loadCollectionsFromDirectory } from "@rebasepro/server";
21
+
22
+ /**
23
+ * Resolve the SQL column name for a property.
24
+ * Uses the explicit `columnName` when set (e.g. from introspection),
25
+ * falling back to `toSnakeCase(propName)` for manually-authored collections.
26
+ */
27
+ const resolveColumnName = (propName: string, prop?: Property | null): string => {
28
+ if (prop && "columnName" in prop && typeof prop.columnName === "string") {
29
+ return prop.columnName;
30
+ }
31
+ return toSnakeCase(propName);
32
+ };
33
+
34
+ // ── Types ────────────────────────────────────────────────────────────────
35
+
36
+ export type IssueSeverity = "error" | "warning" | "info";
37
+
38
+ export interface DoctorIssue {
39
+ severity: IssueSeverity;
40
+ category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale";
41
+ table?: string;
42
+ column?: string;
43
+ expected?: string;
44
+ actual?: string;
45
+ message: string;
46
+ fix: string;
47
+ }
48
+
49
+ 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 };
54
+ }
55
+
56
+ // ── Column type mapping (mirrors generate-drizzle-schema-logic.ts) ───────
57
+
58
+ export function getExpectedColumnType(prop: Property): string | null {
59
+ switch (prop.type) {
60
+ case "string": {
61
+ const sp = prop as StringProperty;
62
+ if (sp.enum) return "USER-DEFINED"; // pgEnum → USER-DEFINED in information_schema
63
+ if ("isId" in sp && sp.isId === "uuid") return "uuid";
64
+ if (sp.columnType === "text") return "text";
65
+ if (sp.columnType === "char") return "character";
66
+ return "character varying";
67
+ }
68
+ case "number": {
69
+ const np = prop as NumberProperty;
70
+ if (np.columnType === "double precision") return "double precision";
71
+ if (np.columnType === "real") return "real";
72
+ if (np.columnType === "bigint") return "bigint";
73
+ if (np.columnType === "serial") return "integer"; // serial is integer under the hood
74
+ if (np.columnType === "bigserial") return "bigint";
75
+ if (np.columnType === "integer") return "integer";
76
+ if (np.columnType === "numeric") return "numeric";
77
+ if (np.validation?.integer || ("isId" in np && np.isId)) return "integer";
78
+ return "numeric";
79
+ }
80
+ case "boolean":
81
+ return "boolean";
82
+ case "date": {
83
+ const dp = prop as DateProperty;
84
+ if (dp.columnType === "date") return "date";
85
+ if (dp.columnType === "time") return "time without time zone";
86
+ return "timestamp with time zone";
87
+ }
88
+ case "array": {
89
+ const ap = prop as ArrayProperty;
90
+ let colType = ap.columnType;
91
+ if (!colType && ap.of && !Array.isArray(ap.of)) {
92
+ const ofProp = ap.of as Property;
93
+ if (ofProp.type === "string") {
94
+ colType = "text[]";
95
+ } else if (ofProp.type === "number") {
96
+ colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
97
+ } else if (ofProp.type === "boolean") {
98
+ colType = "boolean[]";
99
+ }
100
+ }
101
+
102
+ if (colType === "json") return "json";
103
+ if (colType === "jsonb") return "jsonb";
104
+ if (colType && colType.endsWith("[]")) return "ARRAY";
105
+ return "jsonb";
106
+ }
107
+ case "map": {
108
+ const mp = prop as MapProperty;
109
+ if (mp.columnType === "json") return "json";
110
+ return "jsonb";
111
+ }
112
+ case "relation":
113
+ return null; // FK columns are derived from the relation, not from the property
114
+ case "reference":
115
+ return "character varying"; // References default to varchar FK
116
+ case "vector":
117
+ return "USER-DEFINED";
118
+ case "binary":
119
+ return "bytea";
120
+ default:
121
+ return null;
122
+ }
123
+ }
124
+
125
+ // ── Collection loading ───────────────────────────────────────────────────
126
+
127
+ /**
128
+ * Re-exported so callers keep importing it from here, but the implementation is
129
+ * now shared with the runtime and the generators — a doctor that disagreed with
130
+ * the policy generator about which files are collections would compare the
131
+ * wrong thing.
132
+ */
133
+ export async function loadCollections(collectionsPath: string): Promise<CollectionConfig[]> {
134
+ return loadCollectionsFromDirectory(collectionsPath);
135
+ }
136
+
137
+ // ── Phase 1: Collections ↔ Generated Schema ─────────────────────────────
138
+
139
+ export async function checkCollectionsVsSchema(
140
+ collections: CollectionConfig[],
141
+ schemaFilePath: string
142
+ ): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
143
+ const issues: DoctorIssue[] = [];
144
+
145
+ // Check if schema file exists
146
+ if (!fs.existsSync(schemaFilePath)) {
147
+ issues.push({
148
+ severity: "error",
149
+ category: "schema_stale",
150
+ message: "Generated schema file does not exist.",
151
+ fix: "Run `rebase schema generate`"
152
+ });
153
+ return { passed: false,
154
+ issues };
155
+ }
156
+
157
+ // Re-generate schema in-memory and compare with file on disk
158
+ const postgresCollections = collections.filter(isPostgresCollectionConfig);
159
+ if (postgresCollections.length === 0) {
160
+ return { passed: true,
161
+ issues };
162
+ }
163
+
164
+ try {
165
+ const expectedSchema = await generateSchema(postgresCollections);
166
+ const actualSchema = await fsPromises.readFile(schemaFilePath, "utf-8");
167
+
168
+ // Normalize whitespace for comparison
169
+ const normalize = (s: string) =>
170
+ s
171
+ .replace(/\/\/.*$/gm, "") // strip single-line comments
172
+ .replace(/\/\*[\s\S]*?\*\//g, "") // strip multi-line comments
173
+ .replace(/\s+/g, " ")
174
+ .trim();
175
+
176
+ if (normalize(expectedSchema) !== normalize(actualSchema)) {
177
+ issues.push({
178
+ severity: "warning",
179
+ category: "schema_stale",
180
+ message: "Generated schema is out of date — collection definitions have changed since last generation.",
181
+ fix: "Run `rebase schema generate`"
182
+ });
183
+ }
184
+ } catch (err: unknown) {
185
+ const message = err instanceof Error ? err.message : String(err);
186
+ issues.push({
187
+ severity: "warning",
188
+ category: "schema_stale",
189
+ message: `Could not regenerate schema for comparison: ${message}`,
190
+ fix: "Run `rebase schema generate` to verify"
191
+ });
192
+ }
193
+
194
+ return { passed: issues.length === 0,
195
+ issues };
196
+ }
197
+
198
+ export async function checkCollectionsVsSdk(
199
+ collections: CollectionConfig[],
200
+ sdkFilePath: string
201
+ ): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
202
+ const issues: DoctorIssue[] = [];
203
+
204
+ // Check if SDK file exists
205
+ if (!fs.existsSync(sdkFilePath)) {
206
+ issues.push({
207
+ severity: "warning",
208
+ category: "sdk_stale",
209
+ message: `Generated SDK typedefs file does not exist at "${sdkFilePath}".`,
210
+ fix: "Run `rebase generate-sdk`"
211
+ });
212
+ return { passed: false,
213
+ issues };
214
+ }
215
+
216
+ try {
217
+ const expectedSdk = generateTypedefs(collections);
218
+ const actualSdk = await fsPromises.readFile(sdkFilePath, "utf-8");
219
+
220
+ // Normalize whitespace for comparison
221
+ const normalize = (s: string) =>
222
+ s
223
+ .replace(/\/\/.*$/gm, "") // strip single-line comments
224
+ .replace(/\/\*[\s\S]*?\*\//g, "") // strip multi-line comments
225
+ .replace(/\s+/g, " ")
226
+ .trim();
227
+
228
+ if (normalize(expectedSdk) !== normalize(actualSdk)) {
229
+ issues.push({
230
+ severity: "warning",
231
+ category: "sdk_stale",
232
+ message: "Generated SDK types are out of date — collection definitions have changed since last SDK generation.",
233
+ fix: "Run `rebase generate-sdk`"
234
+ });
235
+ }
236
+ } catch (err: unknown) {
237
+ const message = err instanceof Error ? err.message : String(err);
238
+ issues.push({
239
+ severity: "warning",
240
+ category: "sdk_stale",
241
+ message: `Could not regenerate SDK types for comparison: ${message}`,
242
+ fix: "Run `rebase generate-sdk` to verify"
243
+ });
244
+ }
245
+
246
+ return { passed: issues.length === 0,
247
+ issues };
248
+ }
249
+
250
+ // ── Phase 2: Collections ↔ Database ──────────────────────────────────────
251
+
252
+ interface DbColumn {
253
+ table_schema: string;
254
+ table_name: string;
255
+ column_name: string;
256
+ data_type: string;
257
+ is_nullable: string;
258
+ udt_name: string;
259
+ }
260
+
261
+
262
+ interface DbEnumValue {
263
+ enum_name: string;
264
+ enum_value: string;
265
+ }
266
+
267
+ export async function checkCollectionsVsDatabase(
268
+ collections: CollectionConfig[],
269
+ databaseUrl: string
270
+ ): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
271
+ const issues: DoctorIssue[] = [];
272
+
273
+ // Dynamic import to avoid loading pg when not needed
274
+ const pgModule = await import("pg");
275
+ const { Pool } = pgModule.default ?? pgModule;
276
+ const pool = new Pool({ connectionString: databaseUrl });
277
+
278
+ // Determine all schemas defined by the collections, plus public and rebase
279
+ const schemas = Array.from(new Set([
280
+ "public",
281
+ "rebase",
282
+ ...collections
283
+ .filter(isPostgresCollectionConfig)
284
+ .map(c => c.schema)
285
+ .filter((s): s is string => !!s)
286
+ ]));
287
+
288
+ try {
289
+ // Fetch all tables in the defined schemas
290
+ const tablesResult = await pool.query<{ table_schema: string; table_name: string }>(
291
+ `SELECT table_schema, table_name
292
+ FROM information_schema.tables
293
+ WHERE table_schema = ANY($1) AND table_type = 'BASE TABLE'`,
294
+ [schemas]
295
+ );
296
+ const existingTables = new Set(tablesResult.rows.map((r) =>
297
+ r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`
298
+ ));
299
+
300
+ // Fetch all columns in the defined schemas
301
+ const columnsResult = await pool.query<DbColumn>(
302
+ `SELECT table_schema, table_name, column_name, data_type, is_nullable, udt_name
303
+ FROM information_schema.columns
304
+ WHERE table_schema = ANY($1)
305
+ ORDER BY table_schema, table_name, ordinal_position`,
306
+ [schemas]
307
+ );
308
+ const columnsByTable = new Map<string, DbColumn[]>();
309
+ for (const row of columnsResult.rows) {
310
+ const tableSchema = row.table_schema;
311
+ const tableName = row.table_name;
312
+ const key = tableSchema === "public" ? tableName : `${tableSchema}.${tableName}`;
313
+ if (!columnsByTable.has(key)) {
314
+ columnsByTable.set(key, []);
315
+ }
316
+ columnsByTable.get(key)!.push(row);
317
+ }
318
+
319
+ // Fetch enums
320
+ const enumsResult = await pool.query<DbEnumValue>(
321
+ `SELECT t.typname as enum_name, e.enumlabel as enum_value
322
+ FROM pg_type t
323
+ JOIN pg_enum e ON t.oid = e.enumtypid
324
+ ORDER BY t.typname, e.enumsortorder`
325
+ );
326
+ const enumsByName = new Map<string, string[]>();
327
+ for (const row of enumsResult.rows) {
328
+ if (!enumsByName.has(row.enum_name)) {
329
+ enumsByName.set(row.enum_name, []);
330
+ }
331
+ enumsByName.get(row.enum_name)!.push(row.enum_value);
332
+ }
333
+
334
+ // Fetch foreign key constraints in the defined schemas
335
+ const fksResult = await pool.query<{
336
+ constraint_name: string;
337
+ table_schema: string;
338
+ table_name: string;
339
+ column_name: string;
340
+ foreign_table_schema: string;
341
+ foreign_table_name: string;
342
+ foreign_column_name: string;
343
+ }>(
344
+ `SELECT
345
+ tc.constraint_name,
346
+ tc.table_schema,
347
+ tc.table_name,
348
+ kcu.column_name,
349
+ ccu.table_schema AS foreign_table_schema,
350
+ ccu.table_name AS foreign_table_name,
351
+ ccu.column_name AS foreign_column_name
352
+ FROM information_schema.table_constraints AS tc
353
+ JOIN information_schema.key_column_usage AS kcu
354
+ ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
355
+ JOIN information_schema.constraint_column_usage AS ccu
356
+ ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
357
+ WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = ANY($1)`,
358
+ [schemas]
359
+ );
360
+ const fksByTable = new Map<string, typeof fksResult.rows>();
361
+ for (const row of fksResult.rows) {
362
+ const tableSchema = row.table_schema;
363
+ const tableName = row.table_name;
364
+ const key = tableSchema === "public" ? tableName : `${tableSchema}.${tableName}`;
365
+ if (!fksByTable.has(key)) {
366
+ fksByTable.set(key, []);
367
+ }
368
+ fksByTable.get(key)!.push(row);
369
+ }
370
+
371
+ // ── Compare each collection against the database ─────────────────
372
+
373
+ const postgresCollections = collections.filter(isPostgresCollectionConfig);
374
+
375
+ for (const collection of postgresCollections) {
376
+ const tableName = getTableName(collection);
377
+ const schemaName = collection.schema || "public";
378
+ const fullTableName = schemaName === "public" ? tableName : `${schemaName}.${tableName}`;
379
+
380
+ // Check table existence
381
+ if (!existingTables.has(fullTableName)) {
382
+ issues.push({
383
+ severity: "error",
384
+ category: "missing_table",
385
+ table: fullTableName,
386
+ message: `Table "${fullTableName}" does not exist in the database.`,
387
+ fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
388
+ });
389
+ continue; // Skip column checks for missing tables
390
+ }
391
+
392
+ const dbColumns = columnsByTable.get(fullTableName) ?? [];
393
+ const dbColumnMap = new Map(dbColumns.map((c) => [c.column_name, c]));
394
+
395
+ // System columns that Rebase always creates
396
+ const systemColumns = new Set(["id", "created_on", "updated_on"]);
397
+
398
+ // Check properties → columns
399
+ for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
400
+ if (prop.type === "relation") {
401
+ // Relation columns are derived from localKey
402
+ const resolvedRelations = resolveCollectionRelations(collection);
403
+ const relation = findRelation(resolvedRelations, (prop as RelationProperty).relationName ?? propName);
404
+ if (relation?.direction === "owning" && relation.cardinality === "one" && relation.localKey) {
405
+ const fkColName = relation.localKey;
406
+ if (!dbColumnMap.has(fkColName)) {
407
+ issues.push({
408
+ severity: "error",
409
+ category: "missing_column",
410
+ table: fullTableName,
411
+ column: fkColName,
412
+ message: `Foreign key column "${fkColName}" for relation "${propName}" is missing from table "${fullTableName}".`,
413
+ fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
414
+ });
415
+ }
416
+
417
+ // Check FK constraint exists
418
+ const tableFks = fksByTable.get(fullTableName) ?? [];
419
+ let targetTableName = "unknown";
420
+ let targetSchemaName = "public";
421
+ try {
422
+ const targetColl = relation.target();
423
+ targetTableName = getTableName(targetColl);
424
+ targetSchemaName = targetColl.schema || "public";
425
+ } catch { /* ignore */ }
426
+
427
+ const hasFk = tableFks.some((fk) =>
428
+ fk.column_name === fkColName &&
429
+ fk.foreign_table_name === targetTableName &&
430
+ fk.foreign_table_schema === targetSchemaName
431
+ );
432
+
433
+ if (dbColumnMap.has(fkColName) && !hasFk) {
434
+ issues.push({
435
+ severity: "warning",
436
+ category: "missing_foreign_key",
437
+ table: fullTableName,
438
+ column: fkColName,
439
+ message: `Column "${fkColName}" exists but has no FOREIGN KEY constraint referencing "${targetSchemaName === "public" ? targetTableName : `${targetSchemaName}.${targetTableName}`}".`,
440
+ fix: "Run `rebase db push` or add the constraint manually"
441
+ });
442
+ }
443
+ }
444
+ continue;
445
+ }
446
+
447
+ const colName = resolveColumnName(propName, prop);
448
+
449
+ // Skip system columns — they're handled automatically
450
+ if (systemColumns.has(colName)) continue;
451
+
452
+ const dbCol = dbColumnMap.get(colName);
453
+ if (!dbCol) {
454
+ issues.push({
455
+ severity: "error",
456
+ category: "missing_column",
457
+ table: fullTableName,
458
+ column: colName,
459
+ message: `Column "${colName}" is defined in collection "${collection.slug}" but missing from table "${fullTableName}".`,
460
+ fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
461
+ });
462
+ continue;
463
+ }
464
+
465
+ // Type check
466
+ const expectedType = getExpectedColumnType(prop);
467
+ if (expectedType) {
468
+ const actualType = dbCol.data_type;
469
+ let isMismatch = actualType !== expectedType;
470
+ if (prop.type === "vector" && dbCol.udt_name !== "vector") {
471
+ isMismatch = true;
472
+ }
473
+ if (prop.type === "array") {
474
+ const ap = prop as ArrayProperty;
475
+ let expectedColType = ap.columnType;
476
+ if (!expectedColType && ap.of && !Array.isArray(ap.of)) {
477
+ const ofProp = ap.of as Property;
478
+ if (ofProp.type === "string") expectedColType = "text[]";
479
+ else if (ofProp.type === "number") expectedColType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
480
+ else if (ofProp.type === "boolean") expectedColType = "boolean[]";
481
+ }
482
+ if (expectedColType && expectedColType.endsWith("[]")) {
483
+ if (actualType !== "ARRAY") {
484
+ isMismatch = true;
485
+ } else {
486
+ const expectedUdt = expectedColType === "text[]" ? "_text" :
487
+ expectedColType === "integer[]" ? "_int4" :
488
+ expectedColType === "boolean[]" ? "_bool" :
489
+ expectedColType === "numeric[]" ? "_numeric" : "";
490
+ if (expectedUdt && dbCol.udt_name !== expectedUdt) {
491
+ isMismatch = true;
492
+ }
493
+ }
494
+ }
495
+ }
496
+ if (isMismatch) {
497
+ issues.push({
498
+ severity: "warning",
499
+ category: "type_mismatch",
500
+ table: fullTableName,
501
+ column: colName,
502
+ expected: prop.type === "vector" ? "vector" : expectedType,
503
+ actual: dbCol.udt_name === "vector" ? "vector" : actualType,
504
+ message: `Column "${colName}" in table "${fullTableName}": expected type "${prop.type === "vector" ? "vector" : expectedType}" but found "${dbCol.udt_name === "vector" ? "vector" : actualType}".`,
505
+ fix: "Review collection property type or run a migration"
506
+ });
507
+ }
508
+ }
509
+
510
+ // Enum value check
511
+ if (prop.type === "string" && (prop as StringProperty).enum) {
512
+ const enumValues = (prop as StringProperty).enum;
513
+ if (enumValues) {
514
+ const enumName = `${tableName}_${colName}`;
515
+ const dbEnumValues = enumsByName.get(enumName);
516
+ if (!dbEnumValues) {
517
+ issues.push({
518
+ severity: "warning",
519
+ category: "missing_enum",
520
+ table: fullTableName,
521
+ column: colName,
522
+ expected: enumName,
523
+ message: `Enum type "${enumName}" is defined in collection but not found in the database.`,
524
+ fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
525
+ });
526
+ } else {
527
+ // Compare enum values
528
+ const expectedValues = Array.isArray(enumValues)
529
+ ? enumValues.map((v) => (typeof v === "string" ? v : String(v.id)))
530
+ : Object.keys(enumValues);
531
+
532
+ const missing = expectedValues.filter((v) => !dbEnumValues.includes(v));
533
+ const extra = dbEnumValues.filter((v) => !expectedValues.includes(v));
534
+
535
+ if (missing.length > 0 || extra.length > 0) {
536
+ const parts: string[] = [];
537
+ if (missing.length > 0) parts.push(`missing: ${missing.join(", ")}`);
538
+ if (extra.length > 0) parts.push(`extra in DB: ${extra.join(", ")}`);
539
+ issues.push({
540
+ severity: "warning",
541
+ category: "enum_value_mismatch",
542
+ table: fullTableName,
543
+ column: colName,
544
+ expected: expectedValues.join(", "),
545
+ actual: dbEnumValues.join(", "),
546
+ message: `Enum values for "${colName}" in table "${fullTableName}" are out of sync (${parts.join("; ")}).`,
547
+ fix: "Run `rebase db push` to update the enum"
548
+ });
549
+ }
550
+ }
551
+ }
552
+ }
553
+ }
554
+
555
+ // Also check junction tables for many-to-many relations
556
+ const resolvedRelations = resolveCollectionRelations(collection);
557
+ for (const relation of Object.values(resolvedRelations)) {
558
+ if (relation.cardinality === "many" && relation.direction === "owning" && relation.through) {
559
+ const junctionTable = relation.through.table;
560
+ const junctionSchema = collection.schema || "public";
561
+ const fullJunctionTable = junctionSchema === "public" ? junctionTable : `${junctionSchema}.${junctionTable}`;
562
+ if (!existingTables.has(fullJunctionTable)) {
563
+ issues.push({
564
+ severity: "error",
565
+ category: "missing_table",
566
+ table: fullJunctionTable,
567
+ message: `Junction table "${fullJunctionTable}" for many-to-many relation "${relation.relationName}" is missing.`,
568
+ fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
569
+ });
570
+ }
571
+ }
572
+ }
573
+ }
574
+ } finally {
575
+ await pool.end();
576
+ }
577
+
578
+ return { passed: issues.length === 0,
579
+ issues };
580
+ }
581
+
582
+ // ── Report Rendering ─────────────────────────────────────────────────────
583
+
584
+ export function renderReport(report: DoctorReport): void {
585
+ logger.info("");
586
+ logger.info(chalk.bold(" 🩺 Rebase Schema Doctor"));
587
+ logger.info(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
588
+ logger.info("");
589
+
590
+ // Phase 1
591
+ renderPhase(
592
+ "Collections → Generated Schema",
593
+ report.collectionsToSchema.passed,
594
+ report.collectionsToSchema.issues
595
+ );
596
+
597
+ // Phase 2
598
+ renderPhase(
599
+ "Collections → Database",
600
+ report.schemaToDatabase.passed,
601
+ report.schemaToDatabase.issues
602
+ );
603
+
604
+ // Phase 3
605
+ renderPhase(
606
+ "Collections → SDK Types",
607
+ report.collectionsToSdk.passed,
608
+ report.collectionsToSdk.issues
609
+ );
610
+
611
+ // Summary
612
+ logger.info(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
613
+ const { passed, warnings, errors } = report.summary;
614
+
615
+ const parts: string[] = [];
616
+ parts.push(chalk.green(`${passed} passed`));
617
+ if (warnings > 0) parts.push(chalk.yellow(`${warnings} warnings`));
618
+ if (errors > 0) parts.push(chalk.red(`${errors} errors`));
619
+
620
+ logger.info(` Summary: ${parts.join(", ")}`);
621
+ logger.info("");
622
+
623
+ if (errors > 0) {
624
+ logger.info(chalk.red.bold(" ✗ Schema drift detected. Run the suggested fixes above."));
625
+ } else if (warnings > 0) {
626
+ logger.info(chalk.yellow.bold(" ⚠ Minor issues detected. Consider running the suggested fixes."));
627
+ } else {
628
+ logger.info(chalk.green.bold(" ✓ All schemas are in sync!"));
629
+ }
630
+ logger.info("");
631
+ }
632
+
633
+ function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): void {
634
+ if (passed) {
635
+ logger.info(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
636
+ } else {
637
+ const errorCount = issues.filter((i) => i.severity === "error").length;
638
+ const warnCount = issues.filter((i) => i.severity === "warning").length;
639
+ const parts: string[] = [];
640
+ if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
641
+ if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
642
+ logger.info(` ${chalk.yellow("⚠️")} ${label}: ${chalk.yellow(parts.join(", "))}`);
643
+ }
644
+ logger.info("");
645
+
646
+ for (const issue of issues) {
647
+ const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
648
+ const categoryLabel = formatCategory(issue.category);
649
+ logger.info(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
650
+
651
+ if (issue.table) {
652
+ const colPart = issue.column ? ` │ Column: ${chalk.cyan(issue.column)}` : "";
653
+ logger.info(` ${chalk.gray("│")} Table: ${chalk.cyan(issue.table)}${colPart}`);
654
+ }
655
+
656
+ if (issue.expected && issue.actual) {
657
+ logger.info(` ${chalk.gray("│")} Expected: ${chalk.green(issue.expected)} │ Actual: ${chalk.red(issue.actual)}`);
658
+ }
659
+
660
+ logger.info(` ${chalk.gray("│")} ${issue.message}`);
661
+ logger.info(` ${chalk.gray("│")} Fix: ${chalk.blue(issue.fix)}`);
662
+ logger.info(` ${chalk.gray("└" + "─".repeat(48))}`);
663
+ logger.info("");
664
+ }
665
+ }
666
+
667
+ function formatCategory(cat: DoctorIssue["category"]): string {
668
+ const labels: Record<DoctorIssue["category"], string> = {
669
+ missing_table: "Missing Table",
670
+ missing_column: "Missing Column",
671
+ type_mismatch: "Type Mismatch",
672
+ missing_constraint: "Missing Constraint",
673
+ schema_stale: "Stale Schema",
674
+ missing_enum: "Missing Enum",
675
+ enum_value_mismatch: "Enum Value Mismatch",
676
+ missing_foreign_key: "Missing Foreign Key",
677
+ sdk_stale: "Stale SDK Types"
678
+ };
679
+ return labels[cat];
680
+ }
681
+
682
+ // ── Main entry point ─────────────────────────────────────────────────────
683
+
684
+ export async function runDoctor(options: {
685
+ collectionsPath: string;
686
+ schemaPath: string;
687
+ sdkPath: string;
688
+ databaseUrl?: string;
689
+ }): Promise<DoctorReport> {
690
+ logger.info("");
691
+ logger.info(chalk.bold(" 🩺 Loading collections..."));
692
+ const collections = await loadCollections(options.collectionsPath);
693
+ if (collections.length === 0) {
694
+ logger.error(chalk.red(" ✗ No collections found."));
695
+ process.exit(1);
696
+ }
697
+ logger.info(chalk.gray(` Found ${collections.length} collection(s)`));
698
+ logger.info("");
699
+
700
+ // Phase 1: Collections ↔ Generated Schema
701
+ logger.info(chalk.gray(" Checking Collections → Generated Schema..."));
702
+ const collectionsToSchema = await checkCollectionsVsSchema(collections, options.schemaPath);
703
+
704
+ // Phase 2: Collections ↔ Database (only if we have a DATABASE_URL)
705
+ let schemaToDatabase: { passed: boolean; issues: DoctorIssue[] } = { passed: true,
706
+ issues: [] };
707
+ if (options.databaseUrl) {
708
+ logger.info(chalk.gray(" Checking Collections → Database..."));
709
+ schemaToDatabase = await checkCollectionsVsDatabase(collections, options.databaseUrl);
710
+ } else {
711
+ logger.info(chalk.yellow(" ⚠ DATABASE_URL not set — skipping database comparison."));
712
+ logger.info(chalk.gray(" Set DATABASE_URL in your .env to enable full drift detection."));
713
+ }
714
+
715
+ // Phase 3: Collections ↔ SDK Types
716
+ logger.info(chalk.gray(" Checking Collections → SDK Types..."));
717
+ const collectionsToSdk = await checkCollectionsVsSdk(collections, options.sdkPath);
718
+
719
+ const allIssues = [...collectionsToSchema.issues, ...schemaToDatabase.issues, ...collectionsToSdk.issues];
720
+ const summary = {
721
+ passed: [collectionsToSchema, schemaToDatabase, collectionsToSdk].filter((p) => p.passed).length,
722
+ warnings: allIssues.filter((i) => i.severity === "warning").length,
723
+ errors: allIssues.filter((i) => i.severity === "error").length
724
+ };
725
+
726
+ const report: DoctorReport = { collectionsToSchema,
727
+ collectionsToSdk,
728
+ schemaToDatabase,
729
+ summary };
730
+ renderReport(report);
731
+
732
+ return report;
733
+ }