@rebasepro/server-postgresql 0.0.1-canary.09e5ec5
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.
- package/LICENSE +6 -0
- package/README.md +106 -0
- package/build-errors.txt +37 -0
- package/dist/common/src/collections/CollectionRegistry.d.ts +56 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/data/buildRebaseData.d.ts +14 -0
- package/dist/common/src/index.d.ts +3 -0
- package/dist/common/src/util/builders.d.ts +57 -0
- package/dist/common/src/util/callbacks.d.ts +6 -0
- package/dist/common/src/util/collections.d.ts +11 -0
- package/dist/common/src/util/common.d.ts +2 -0
- package/dist/common/src/util/conditions.d.ts +26 -0
- package/dist/common/src/util/entities.d.ts +58 -0
- package/dist/common/src/util/enums.d.ts +3 -0
- package/dist/common/src/util/index.d.ts +16 -0
- package/dist/common/src/util/navigation_from_path.d.ts +34 -0
- package/dist/common/src/util/navigation_utils.d.ts +20 -0
- package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
- package/dist/common/src/util/paths.d.ts +14 -0
- package/dist/common/src/util/permissions.d.ts +5 -0
- package/dist/common/src/util/references.d.ts +2 -0
- package/dist/common/src/util/relations.d.ts +22 -0
- package/dist/common/src/util/resolutions.d.ts +72 -0
- package/dist/common/src/util/storage.d.ts +24 -0
- package/dist/index.es.js +11298 -0
- package/dist/index.es.js.map +1 -0
- package/dist/index.umd.js +11306 -0
- package/dist/index.umd.js.map +1 -0
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +100 -0
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +40 -0
- package/dist/server-postgresql/src/auth/ensure-tables.d.ts +6 -0
- package/dist/server-postgresql/src/auth/services.d.ts +192 -0
- package/dist/server-postgresql/src/cli.d.ts +1 -0
- package/dist/server-postgresql/src/collections/PostgresCollectionRegistry.d.ts +43 -0
- package/dist/server-postgresql/src/connection.d.ts +40 -0
- package/dist/server-postgresql/src/data-transformer.d.ts +58 -0
- package/dist/server-postgresql/src/databasePoolManager.d.ts +20 -0
- package/dist/server-postgresql/src/history/HistoryService.d.ts +71 -0
- package/dist/server-postgresql/src/history/ensure-history-table.d.ts +7 -0
- package/dist/server-postgresql/src/index.d.ts +13 -0
- package/dist/server-postgresql/src/interfaces.d.ts +18 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +868 -0
- package/dist/server-postgresql/src/schema/doctor-cli.d.ts +2 -0
- package/dist/server-postgresql/src/schema/doctor.d.ts +43 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema-logic.d.ts +2 -0
- package/dist/server-postgresql/src/schema/generate-drizzle-schema.d.ts +1 -0
- package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +82 -0
- package/dist/server-postgresql/src/schema/introspect-db.d.ts +1 -0
- package/dist/server-postgresql/src/schema/test-schema.d.ts +24 -0
- package/dist/server-postgresql/src/services/BranchService.d.ts +47 -0
- package/dist/server-postgresql/src/services/EntityFetchService.d.ts +209 -0
- package/dist/server-postgresql/src/services/EntityPersistService.d.ts +41 -0
- package/dist/server-postgresql/src/services/RelationService.d.ts +98 -0
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +38 -0
- package/dist/server-postgresql/src/services/entityService.d.ts +104 -0
- package/dist/server-postgresql/src/services/index.d.ts +4 -0
- package/dist/server-postgresql/src/services/realtimeService.d.ts +188 -0
- package/dist/server-postgresql/src/utils/drizzle-conditions.d.ts +116 -0
- package/dist/server-postgresql/src/websocket.d.ts +5 -0
- package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
- package/dist/types/src/controllers/auth.d.ts +119 -0
- package/dist/types/src/controllers/client.d.ts +170 -0
- package/dist/types/src/controllers/collection_registry.d.ts +45 -0
- package/dist/types/src/controllers/customization_controller.d.ts +60 -0
- package/dist/types/src/controllers/data.d.ts +168 -0
- package/dist/types/src/controllers/data_driver.d.ts +160 -0
- package/dist/types/src/controllers/database_admin.d.ts +11 -0
- package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
- package/dist/types/src/controllers/effective_role.d.ts +4 -0
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +18 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
- package/dist/types/src/controllers/navigation.d.ts +213 -0
- package/dist/types/src/controllers/registry.d.ts +54 -0
- package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
- package/dist/types/src/controllers/side_entity_controller.d.ts +90 -0
- package/dist/types/src/controllers/snackbar.d.ts +24 -0
- package/dist/types/src/controllers/storage.d.ts +171 -0
- package/dist/types/src/index.d.ts +4 -0
- package/dist/types/src/rebase_context.d.ts +105 -0
- package/dist/types/src/types/backend.d.ts +536 -0
- package/dist/types/src/types/builders.d.ts +15 -0
- package/dist/types/src/types/chips.d.ts +5 -0
- package/dist/types/src/types/collections.d.ts +856 -0
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +64 -0
- package/dist/types/src/types/entities.d.ts +145 -0
- package/dist/types/src/types/entity_actions.d.ts +98 -0
- package/dist/types/src/types/entity_callbacks.d.ts +173 -0
- package/dist/types/src/types/entity_link_builder.d.ts +7 -0
- package/dist/types/src/types/entity_overrides.d.ts +10 -0
- package/dist/types/src/types/entity_views.d.ts +61 -0
- package/dist/types/src/types/export_import.d.ts +21 -0
- package/dist/types/src/types/index.d.ts +23 -0
- package/dist/types/src/types/locales.d.ts +4 -0
- package/dist/types/src/types/modify_collections.d.ts +5 -0
- package/dist/types/src/types/plugins.d.ts +279 -0
- package/dist/types/src/types/properties.d.ts +1176 -0
- package/dist/types/src/types/property_config.d.ts +70 -0
- package/dist/types/src/types/relations.d.ts +336 -0
- package/dist/types/src/types/slots.d.ts +252 -0
- package/dist/types/src/types/translations.d.ts +870 -0
- package/dist/types/src/types/user_management_delegate.d.ts +121 -0
- package/dist/types/src/types/websockets.d.ts +78 -0
- package/dist/types/src/users/index.d.ts +2 -0
- package/dist/types/src/users/roles.d.ts +22 -0
- package/dist/types/src/users/user.d.ts +46 -0
- package/drizzle-test/0000_woozy_junta.sql +6 -0
- package/drizzle-test/0001_youthful_arachne.sql +1 -0
- package/drizzle-test/0002_lively_dragon_lord.sql +2 -0
- package/drizzle-test/0003_mean_king_cobra.sql +2 -0
- package/drizzle-test/meta/0000_snapshot.json +47 -0
- package/drizzle-test/meta/0001_snapshot.json +48 -0
- package/drizzle-test/meta/0002_snapshot.json +38 -0
- package/drizzle-test/meta/0003_snapshot.json +48 -0
- package/drizzle-test/meta/_journal.json +34 -0
- package/drizzle-test-out/0000_tan_trauma.sql +6 -0
- package/drizzle-test-out/0001_rapid_drax.sql +1 -0
- package/drizzle-test-out/meta/0000_snapshot.json +44 -0
- package/drizzle-test-out/meta/0001_snapshot.json +54 -0
- package/drizzle-test-out/meta/_journal.json +20 -0
- package/drizzle.test.config.ts +10 -0
- package/jest-all.log +3128 -0
- package/jest.log +49 -0
- package/package.json +92 -0
- package/scratch.ts +41 -0
- package/src/PostgresBackendDriver.ts +1008 -0
- package/src/PostgresBootstrapper.ts +231 -0
- package/src/auth/ensure-tables.ts +381 -0
- package/src/auth/services.ts +799 -0
- package/src/cli.ts +648 -0
- package/src/collections/PostgresCollectionRegistry.ts +96 -0
- package/src/connection.ts +84 -0
- package/src/data-transformer.ts +608 -0
- package/src/databasePoolManager.ts +85 -0
- package/src/history/HistoryService.ts +248 -0
- package/src/history/ensure-history-table.ts +45 -0
- package/src/index.ts +13 -0
- package/src/interfaces.ts +60 -0
- package/src/schema/auth-schema.ts +169 -0
- package/src/schema/doctor-cli.ts +47 -0
- package/src/schema/doctor.ts +595 -0
- package/src/schema/generate-drizzle-schema-logic.ts +765 -0
- package/src/schema/generate-drizzle-schema.ts +151 -0
- package/src/schema/introspect-db-logic.ts +542 -0
- package/src/schema/introspect-db.ts +211 -0
- package/src/schema/test-schema.ts +11 -0
- package/src/services/BranchService.ts +237 -0
- package/src/services/EntityFetchService.ts +1576 -0
- package/src/services/EntityPersistService.ts +349 -0
- package/src/services/RelationService.ts +1274 -0
- package/src/services/entity-helpers.ts +147 -0
- package/src/services/entityService.ts +211 -0
- package/src/services/index.ts +13 -0
- package/src/services/realtimeService.ts +1034 -0
- package/src/utils/drizzle-conditions.ts +1000 -0
- package/src/websocket.ts +518 -0
- package/test/auth-services.test.ts +661 -0
- package/test/batch-many-to-many-regression.test.ts +573 -0
- package/test/branchService.test.ts +367 -0
- package/test/data-transformer-hardening.test.ts +417 -0
- package/test/data-transformer.test.ts +175 -0
- package/test/doctor.test.ts +182 -0
- package/test/drizzle-conditions.test.ts +895 -0
- package/test/entityService.errors.test.ts +367 -0
- package/test/entityService.relations.test.ts +1008 -0
- package/test/entityService.subcollection-search.test.ts +566 -0
- package/test/entityService.test.ts +1035 -0
- package/test/generate-drizzle-schema.test.ts +988 -0
- package/test/historyService.test.ts +141 -0
- package/test/introspect-db-generation.test.ts +436 -0
- package/test/introspect-db-utils.test.ts +389 -0
- package/test/n-plus-one-regression.test.ts +314 -0
- package/test/postgresDataDriver.test.ts +648 -0
- package/test/realtimeService.test.ts +307 -0
- package/test/relation-pipeline-gaps.test.ts +637 -0
- package/test/relations.test.ts +1115 -0
- package/test/unmapped-tables-safety.test.ts +345 -0
- package/test-drizzle-bug.ts +18 -0
- package/test-drizzle-out/0000_cultured_freak.sql +7 -0
- package/test-drizzle-out/0001_tiresome_professor_monster.sql +1 -0
- package/test-drizzle-out/meta/0000_snapshot.json +55 -0
- package/test-drizzle-out/meta/0001_snapshot.json +63 -0
- package/test-drizzle-out/meta/_journal.json +20 -0
- package/test-drizzle-prompt.sh +2 -0
- package/test-policy-prompt.sh +3 -0
- package/test-programmatic.ts +30 -0
- package/test-programmatic2.ts +59 -0
- package/test-schema-no-policies.ts +12 -0
- package/test_drizzle_mock.js +3 -0
- package/test_find_changed.mjs +32 -0
- package/test_hash.js +14 -0
- package/test_output.txt +3145 -0
- package/tsconfig.json +49 -0
- package/tsconfig.prod.json +20 -0
- package/vite.config.ts +82 -0
|
@@ -0,0 +1,595 @@
|
|
|
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 { EntityCollection, isPostgresCollection, Property, NumberProperty, StringProperty, DateProperty, ArrayProperty, MapProperty, RelationProperty } from "@rebasepro/types";
|
|
16
|
+
import { generateSchema } from "./generate-drizzle-schema-logic";
|
|
17
|
+
import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
18
|
+
import { toSnakeCase } from "@rebasepro/utils";
|
|
19
|
+
|
|
20
|
+
// ── Types ────────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
export type IssueSeverity = "error" | "warning" | "info";
|
|
23
|
+
|
|
24
|
+
export interface DoctorIssue {
|
|
25
|
+
severity: IssueSeverity;
|
|
26
|
+
category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key";
|
|
27
|
+
table?: string;
|
|
28
|
+
column?: string;
|
|
29
|
+
expected?: string;
|
|
30
|
+
actual?: string;
|
|
31
|
+
message: string;
|
|
32
|
+
fix: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface DoctorReport {
|
|
36
|
+
collectionsToSchema: { passed: boolean; issues: DoctorIssue[] };
|
|
37
|
+
schemaToDatabase: { passed: boolean; issues: DoctorIssue[] };
|
|
38
|
+
summary: { passed: number; warnings: number; errors: number };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── Column type mapping (mirrors generate-drizzle-schema-logic.ts) ───────
|
|
42
|
+
|
|
43
|
+
export function getExpectedColumnType(prop: Property): string | null {
|
|
44
|
+
switch (prop.type) {
|
|
45
|
+
case "string": {
|
|
46
|
+
const sp = prop as StringProperty;
|
|
47
|
+
if (sp.enum) return "USER-DEFINED"; // pgEnum → USER-DEFINED in information_schema
|
|
48
|
+
if ("isId" in sp && sp.isId === "uuid") return "uuid";
|
|
49
|
+
if (sp.columnType === "text") return "text";
|
|
50
|
+
if (sp.columnType === "char") return "character";
|
|
51
|
+
return "character varying";
|
|
52
|
+
}
|
|
53
|
+
case "number": {
|
|
54
|
+
const np = prop as NumberProperty;
|
|
55
|
+
if (np.columnType === "double precision") return "double precision";
|
|
56
|
+
if (np.columnType === "real") return "real";
|
|
57
|
+
if (np.columnType === "bigint") return "bigint";
|
|
58
|
+
if (np.columnType === "serial") return "integer"; // serial is integer under the hood
|
|
59
|
+
if (np.columnType === "bigserial") return "bigint";
|
|
60
|
+
if (np.columnType === "integer") return "integer";
|
|
61
|
+
if (np.columnType === "numeric") return "numeric";
|
|
62
|
+
if (np.validation?.integer || ("isId" in np && np.isId)) return "integer";
|
|
63
|
+
return "numeric";
|
|
64
|
+
}
|
|
65
|
+
case "boolean":
|
|
66
|
+
return "boolean";
|
|
67
|
+
case "date": {
|
|
68
|
+
const dp = prop as DateProperty;
|
|
69
|
+
if (dp.columnType === "date") return "date";
|
|
70
|
+
if (dp.columnType === "time") return "time without time zone";
|
|
71
|
+
return "timestamp with time zone";
|
|
72
|
+
}
|
|
73
|
+
case "map":
|
|
74
|
+
case "array": {
|
|
75
|
+
const ap = prop as ArrayProperty | MapProperty;
|
|
76
|
+
if (ap.columnType === "json") return "json";
|
|
77
|
+
return "jsonb";
|
|
78
|
+
}
|
|
79
|
+
case "relation":
|
|
80
|
+
return null; // FK columns are derived from the relation, not from the property
|
|
81
|
+
case "reference":
|
|
82
|
+
return "character varying"; // References default to varchar FK
|
|
83
|
+
default:
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Collection loading ───────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export async function loadCollections(collectionsPath: string): Promise<EntityCollection[]> {
|
|
91
|
+
const resolvedPath = path.resolve(collectionsPath);
|
|
92
|
+
const collections: EntityCollection[] = [];
|
|
93
|
+
|
|
94
|
+
const stats = fs.statSync(resolvedPath);
|
|
95
|
+
|
|
96
|
+
if (stats.isDirectory()) {
|
|
97
|
+
const files = fs.readdirSync(resolvedPath);
|
|
98
|
+
for (const file of files) {
|
|
99
|
+
if (
|
|
100
|
+
(file.endsWith(".ts") || file.endsWith(".js")) &&
|
|
101
|
+
!file.includes(".test.") &&
|
|
102
|
+
!file.endsWith(".d.ts") &&
|
|
103
|
+
file !== "index.ts" &&
|
|
104
|
+
file !== "index.js"
|
|
105
|
+
) {
|
|
106
|
+
const filePath = path.join(resolvedPath, file);
|
|
107
|
+
try {
|
|
108
|
+
const fileUrl = pathToFileURL(filePath).href;
|
|
109
|
+
const dynamicImport = new Function("url", "return import(url)");
|
|
110
|
+
const mod = await dynamicImport(fileUrl);
|
|
111
|
+
if (mod?.default) {
|
|
112
|
+
collections.push(mod.default);
|
|
113
|
+
}
|
|
114
|
+
} catch (err: unknown) {
|
|
115
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
116
|
+
console.error(chalk.yellow(` ⚠ Could not load ${file}: ${message}`));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
const fileUrl = pathToFileURL(resolvedPath).href + `?t=${Date.now()}`;
|
|
122
|
+
const dynamicImport = new Function("url", "return import(url)");
|
|
123
|
+
const imported = await dynamicImport(fileUrl);
|
|
124
|
+
const loaded = imported.backendCollections || imported.collections;
|
|
125
|
+
if (Array.isArray(loaded)) {
|
|
126
|
+
collections.push(...loaded);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return collections;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── Phase 1: Collections ↔ Generated Schema ─────────────────────────────
|
|
134
|
+
|
|
135
|
+
export async function checkCollectionsVsSchema(
|
|
136
|
+
collections: EntityCollection[],
|
|
137
|
+
schemaFilePath: string
|
|
138
|
+
): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
|
|
139
|
+
const issues: DoctorIssue[] = [];
|
|
140
|
+
|
|
141
|
+
// Check if schema file exists
|
|
142
|
+
if (!fs.existsSync(schemaFilePath)) {
|
|
143
|
+
issues.push({
|
|
144
|
+
severity: "error",
|
|
145
|
+
category: "schema_stale",
|
|
146
|
+
message: "Generated schema file does not exist.",
|
|
147
|
+
fix: "Run `rebase schema generate`"
|
|
148
|
+
});
|
|
149
|
+
return { passed: false,
|
|
150
|
+
issues };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Re-generate schema in-memory and compare with file on disk
|
|
154
|
+
const postgresCollections = collections.filter(isPostgresCollection);
|
|
155
|
+
if (postgresCollections.length === 0) {
|
|
156
|
+
return { passed: true,
|
|
157
|
+
issues };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const expectedSchema = await generateSchema(postgresCollections);
|
|
162
|
+
const actualSchema = await fsPromises.readFile(schemaFilePath, "utf-8");
|
|
163
|
+
|
|
164
|
+
// Normalize whitespace for comparison
|
|
165
|
+
const normalize = (s: string) =>
|
|
166
|
+
s
|
|
167
|
+
.replace(/\/\/.*$/gm, "") // strip single-line comments
|
|
168
|
+
.replace(/\/\*[\s\S]*?\*\//g, "") // strip multi-line comments
|
|
169
|
+
.replace(/\s+/g, " ")
|
|
170
|
+
.trim();
|
|
171
|
+
|
|
172
|
+
if (normalize(expectedSchema) !== normalize(actualSchema)) {
|
|
173
|
+
issues.push({
|
|
174
|
+
severity: "warning",
|
|
175
|
+
category: "schema_stale",
|
|
176
|
+
message: "Generated schema is out of date — collection definitions have changed since last generation.",
|
|
177
|
+
fix: "Run `rebase schema generate`"
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
} catch (err: unknown) {
|
|
181
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
182
|
+
issues.push({
|
|
183
|
+
severity: "warning",
|
|
184
|
+
category: "schema_stale",
|
|
185
|
+
message: `Could not regenerate schema for comparison: ${message}`,
|
|
186
|
+
fix: "Run `rebase schema generate` to verify"
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return { passed: issues.length === 0,
|
|
191
|
+
issues };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ── Phase 2: Collections ↔ Database ──────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
interface DbColumn {
|
|
197
|
+
column_name: string;
|
|
198
|
+
data_type: string;
|
|
199
|
+
is_nullable: string;
|
|
200
|
+
udt_name: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
interface DbEnumValue {
|
|
205
|
+
enum_name: string;
|
|
206
|
+
enum_value: string;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export async function checkCollectionsVsDatabase(
|
|
210
|
+
collections: EntityCollection[],
|
|
211
|
+
databaseUrl: string
|
|
212
|
+
): Promise<{ passed: boolean; issues: DoctorIssue[] }> {
|
|
213
|
+
const issues: DoctorIssue[] = [];
|
|
214
|
+
|
|
215
|
+
// Dynamic import to avoid loading pg when not needed
|
|
216
|
+
const pgModule = await import("pg");
|
|
217
|
+
const { Pool } = pgModule.default ?? pgModule;
|
|
218
|
+
const pool = new Pool({ connectionString: databaseUrl });
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
// Fetch all tables in the public schema
|
|
222
|
+
const tablesResult = await pool.query<{ table_name: string }>(
|
|
223
|
+
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE'"
|
|
224
|
+
);
|
|
225
|
+
const existingTables = new Set(tablesResult.rows.map((r) => r.table_name));
|
|
226
|
+
|
|
227
|
+
// Fetch all columns
|
|
228
|
+
const columnsResult = await pool.query<DbColumn>(
|
|
229
|
+
`SELECT table_name, column_name, data_type, is_nullable, udt_name
|
|
230
|
+
FROM information_schema.columns
|
|
231
|
+
WHERE table_schema = 'public'
|
|
232
|
+
ORDER BY table_name, ordinal_position`
|
|
233
|
+
);
|
|
234
|
+
const columnsByTable = new Map<string, DbColumn[]>();
|
|
235
|
+
for (const row of columnsResult.rows) {
|
|
236
|
+
const tableName = (row as unknown as Record<string, string>).table_name;
|
|
237
|
+
if (!columnsByTable.has(tableName)) {
|
|
238
|
+
columnsByTable.set(tableName, []);
|
|
239
|
+
}
|
|
240
|
+
columnsByTable.get(tableName)!.push(row);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Fetch enums
|
|
244
|
+
const enumsResult = await pool.query<DbEnumValue>(
|
|
245
|
+
`SELECT t.typname as enum_name, e.enumlabel as enum_value
|
|
246
|
+
FROM pg_type t
|
|
247
|
+
JOIN pg_enum e ON t.oid = e.enumtypid
|
|
248
|
+
ORDER BY t.typname, e.enumsortorder`
|
|
249
|
+
);
|
|
250
|
+
const enumsByName = new Map<string, string[]>();
|
|
251
|
+
for (const row of enumsResult.rows) {
|
|
252
|
+
if (!enumsByName.has(row.enum_name)) {
|
|
253
|
+
enumsByName.set(row.enum_name, []);
|
|
254
|
+
}
|
|
255
|
+
enumsByName.get(row.enum_name)!.push(row.enum_value);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Fetch foreign key constraints
|
|
259
|
+
const fksResult = await pool.query<{
|
|
260
|
+
constraint_name: string;
|
|
261
|
+
table_name: string;
|
|
262
|
+
column_name: string;
|
|
263
|
+
foreign_table_name: string;
|
|
264
|
+
foreign_column_name: string;
|
|
265
|
+
}>(
|
|
266
|
+
`SELECT
|
|
267
|
+
tc.constraint_name,
|
|
268
|
+
tc.table_name,
|
|
269
|
+
kcu.column_name,
|
|
270
|
+
ccu.table_name AS foreign_table_name,
|
|
271
|
+
ccu.column_name AS foreign_column_name
|
|
272
|
+
FROM information_schema.table_constraints AS tc
|
|
273
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
274
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
275
|
+
JOIN information_schema.constraint_column_usage AS ccu
|
|
276
|
+
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
|
277
|
+
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = 'public'`
|
|
278
|
+
);
|
|
279
|
+
const fksByTable = new Map<string, typeof fksResult.rows>();
|
|
280
|
+
for (const row of fksResult.rows) {
|
|
281
|
+
if (!fksByTable.has(row.table_name)) {
|
|
282
|
+
fksByTable.set(row.table_name, []);
|
|
283
|
+
}
|
|
284
|
+
fksByTable.get(row.table_name)!.push(row);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// ── Compare each collection against the database ─────────────────
|
|
288
|
+
|
|
289
|
+
const postgresCollections = collections.filter(isPostgresCollection);
|
|
290
|
+
|
|
291
|
+
for (const collection of postgresCollections) {
|
|
292
|
+
const tableName = getTableName(collection);
|
|
293
|
+
|
|
294
|
+
// Check table existence
|
|
295
|
+
if (!existingTables.has(tableName)) {
|
|
296
|
+
issues.push({
|
|
297
|
+
severity: "error",
|
|
298
|
+
category: "missing_table",
|
|
299
|
+
table: tableName,
|
|
300
|
+
message: `Table "${tableName}" does not exist in the database.`,
|
|
301
|
+
fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
|
|
302
|
+
});
|
|
303
|
+
continue; // Skip column checks for missing tables
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const dbColumns = columnsByTable.get(tableName) ?? [];
|
|
307
|
+
const dbColumnMap = new Map(dbColumns.map((c) => [c.column_name, c]));
|
|
308
|
+
|
|
309
|
+
// System columns that Rebase always creates
|
|
310
|
+
const systemColumns = new Set(["id", "created_on", "updated_on"]);
|
|
311
|
+
|
|
312
|
+
// Check properties → columns
|
|
313
|
+
for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
|
|
314
|
+
if (prop.type === "relation") {
|
|
315
|
+
// Relation columns are derived from localKey
|
|
316
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
317
|
+
const relation = findRelation(resolvedRelations, (prop as RelationProperty).relationName ?? propName);
|
|
318
|
+
if (relation?.direction === "owning" && relation.cardinality === "one" && relation.localKey) {
|
|
319
|
+
const fkColName = toSnakeCase(relation.localKey);
|
|
320
|
+
if (!dbColumnMap.has(fkColName)) {
|
|
321
|
+
issues.push({
|
|
322
|
+
severity: "error",
|
|
323
|
+
category: "missing_column",
|
|
324
|
+
table: tableName,
|
|
325
|
+
column: fkColName,
|
|
326
|
+
message: `Foreign key column "${fkColName}" for relation "${propName}" is missing from table "${tableName}".`,
|
|
327
|
+
fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Check FK constraint exists
|
|
332
|
+
const tableFks = fksByTable.get(tableName) ?? [];
|
|
333
|
+
const hasFk = tableFks.some((fk) => fk.column_name === fkColName);
|
|
334
|
+
if (dbColumnMap.has(fkColName) && !hasFk) {
|
|
335
|
+
let targetTableName = "unknown";
|
|
336
|
+
try {
|
|
337
|
+
targetTableName = getTableName(relation.target());
|
|
338
|
+
} catch { /* ignore */ }
|
|
339
|
+
issues.push({
|
|
340
|
+
severity: "warning",
|
|
341
|
+
category: "missing_foreign_key",
|
|
342
|
+
table: tableName,
|
|
343
|
+
column: fkColName,
|
|
344
|
+
message: `Column "${fkColName}" exists but has no FOREIGN KEY constraint referencing "${targetTableName}".`,
|
|
345
|
+
fix: "Run `rebase db push` or add the constraint manually"
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const colName = toSnakeCase(propName);
|
|
353
|
+
|
|
354
|
+
// Skip system columns — they're handled automatically
|
|
355
|
+
if (systemColumns.has(colName)) continue;
|
|
356
|
+
|
|
357
|
+
const dbCol = dbColumnMap.get(colName);
|
|
358
|
+
if (!dbCol) {
|
|
359
|
+
issues.push({
|
|
360
|
+
severity: "error",
|
|
361
|
+
category: "missing_column",
|
|
362
|
+
table: tableName,
|
|
363
|
+
column: colName,
|
|
364
|
+
message: `Column "${colName}" is defined in collection "${collection.slug}" but missing from table "${tableName}".`,
|
|
365
|
+
fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
|
|
366
|
+
});
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Type check
|
|
371
|
+
const expectedType = getExpectedColumnType(prop);
|
|
372
|
+
if (expectedType) {
|
|
373
|
+
const actualType = dbCol.data_type;
|
|
374
|
+
if (actualType !== expectedType) {
|
|
375
|
+
issues.push({
|
|
376
|
+
severity: "warning",
|
|
377
|
+
category: "type_mismatch",
|
|
378
|
+
table: tableName,
|
|
379
|
+
column: colName,
|
|
380
|
+
expected: expectedType,
|
|
381
|
+
actual: actualType,
|
|
382
|
+
message: `Column "${colName}" in table "${tableName}": expected type "${expectedType}" but found "${actualType}".`,
|
|
383
|
+
fix: "Review collection property type or run a migration"
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Enum value check
|
|
389
|
+
if (prop.type === "string" && (prop as StringProperty).enum) {
|
|
390
|
+
const enumValues = (prop as StringProperty).enum;
|
|
391
|
+
if (enumValues) {
|
|
392
|
+
const enumName = `${tableName}_${colName}`;
|
|
393
|
+
const dbEnumValues = enumsByName.get(enumName);
|
|
394
|
+
if (!dbEnumValues) {
|
|
395
|
+
issues.push({
|
|
396
|
+
severity: "warning",
|
|
397
|
+
category: "missing_enum",
|
|
398
|
+
table: tableName,
|
|
399
|
+
column: colName,
|
|
400
|
+
expected: enumName,
|
|
401
|
+
message: `Enum type "${enumName}" is defined in collection but not found in the database.`,
|
|
402
|
+
fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
|
|
403
|
+
});
|
|
404
|
+
} else {
|
|
405
|
+
// Compare enum values
|
|
406
|
+
const expectedValues = Array.isArray(enumValues)
|
|
407
|
+
? enumValues.map((v) => (typeof v === "string" ? v : String(v.id)))
|
|
408
|
+
: Object.keys(enumValues);
|
|
409
|
+
|
|
410
|
+
const missing = expectedValues.filter((v) => !dbEnumValues.includes(v));
|
|
411
|
+
const extra = dbEnumValues.filter((v) => !expectedValues.includes(v));
|
|
412
|
+
|
|
413
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
414
|
+
const parts: string[] = [];
|
|
415
|
+
if (missing.length > 0) parts.push(`missing: ${missing.join(", ")}`);
|
|
416
|
+
if (extra.length > 0) parts.push(`extra in DB: ${extra.join(", ")}`);
|
|
417
|
+
issues.push({
|
|
418
|
+
severity: "warning",
|
|
419
|
+
category: "enum_value_mismatch",
|
|
420
|
+
table: tableName,
|
|
421
|
+
column: colName,
|
|
422
|
+
expected: expectedValues.join(", "),
|
|
423
|
+
actual: dbEnumValues.join(", "),
|
|
424
|
+
message: `Enum values for "${colName}" in table "${tableName}" are out of sync (${parts.join("; ")}).`,
|
|
425
|
+
fix: "Run `rebase db push` to update the enum"
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Also check junction tables for many-to-many relations
|
|
434
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
435
|
+
for (const relation of Object.values(resolvedRelations)) {
|
|
436
|
+
if (relation.cardinality === "many" && relation.direction === "owning" && relation.through) {
|
|
437
|
+
const junctionTable = relation.through.table;
|
|
438
|
+
if (!existingTables.has(junctionTable)) {
|
|
439
|
+
issues.push({
|
|
440
|
+
severity: "error",
|
|
441
|
+
category: "missing_table",
|
|
442
|
+
table: junctionTable,
|
|
443
|
+
message: `Junction table "${junctionTable}" for many-to-many relation "${relation.relationName}" is missing.`,
|
|
444
|
+
fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
} finally {
|
|
451
|
+
await pool.end();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return { passed: issues.length === 0,
|
|
455
|
+
issues };
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// ── Report Rendering ─────────────────────────────────────────────────────
|
|
459
|
+
|
|
460
|
+
export function renderReport(report: DoctorReport): void {
|
|
461
|
+
console.log("");
|
|
462
|
+
console.log(chalk.bold(" 🩺 Rebase Schema Doctor"));
|
|
463
|
+
console.log(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
|
|
464
|
+
console.log("");
|
|
465
|
+
|
|
466
|
+
// Phase 1
|
|
467
|
+
renderPhase(
|
|
468
|
+
"Collections → Generated Schema",
|
|
469
|
+
report.collectionsToSchema.passed,
|
|
470
|
+
report.collectionsToSchema.issues
|
|
471
|
+
);
|
|
472
|
+
|
|
473
|
+
// Phase 2
|
|
474
|
+
renderPhase(
|
|
475
|
+
"Collections → Database",
|
|
476
|
+
report.schemaToDatabase.passed,
|
|
477
|
+
report.schemaToDatabase.issues
|
|
478
|
+
);
|
|
479
|
+
|
|
480
|
+
// Summary
|
|
481
|
+
console.log(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
|
|
482
|
+
const { passed, warnings, errors } = report.summary;
|
|
483
|
+
|
|
484
|
+
const parts: string[] = [];
|
|
485
|
+
parts.push(chalk.green(`${passed} passed`));
|
|
486
|
+
if (warnings > 0) parts.push(chalk.yellow(`${warnings} warnings`));
|
|
487
|
+
if (errors > 0) parts.push(chalk.red(`${errors} errors`));
|
|
488
|
+
|
|
489
|
+
console.log(` Summary: ${parts.join(", ")}`);
|
|
490
|
+
console.log("");
|
|
491
|
+
|
|
492
|
+
if (errors > 0) {
|
|
493
|
+
console.log(chalk.red.bold(" ✗ Schema drift detected. Run the suggested fixes above."));
|
|
494
|
+
} else if (warnings > 0) {
|
|
495
|
+
console.log(chalk.yellow.bold(" ⚠ Minor issues detected. Consider running the suggested fixes."));
|
|
496
|
+
} else {
|
|
497
|
+
console.log(chalk.green.bold(" ✓ All schemas are in sync!"));
|
|
498
|
+
}
|
|
499
|
+
console.log("");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function renderPhase(label: string, passed: boolean, issues: DoctorIssue[]): void {
|
|
503
|
+
if (passed) {
|
|
504
|
+
console.log(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
|
|
505
|
+
} else {
|
|
506
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
507
|
+
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
508
|
+
const parts: string[] = [];
|
|
509
|
+
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
|
|
510
|
+
if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
|
|
511
|
+
console.log(` ${chalk.yellow("⚠️")} ${label}: ${chalk.yellow(parts.join(", "))}`);
|
|
512
|
+
}
|
|
513
|
+
console.log("");
|
|
514
|
+
|
|
515
|
+
for (const issue of issues) {
|
|
516
|
+
const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
|
|
517
|
+
const categoryLabel = formatCategory(issue.category);
|
|
518
|
+
console.log(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
|
|
519
|
+
|
|
520
|
+
if (issue.table) {
|
|
521
|
+
const colPart = issue.column ? ` │ Column: ${chalk.cyan(issue.column)}` : "";
|
|
522
|
+
console.log(` ${chalk.gray("│")} Table: ${chalk.cyan(issue.table)}${colPart}`);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (issue.expected && issue.actual) {
|
|
526
|
+
console.log(` ${chalk.gray("│")} Expected: ${chalk.green(issue.expected)} │ Actual: ${chalk.red(issue.actual)}`);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
console.log(` ${chalk.gray("│")} ${issue.message}`);
|
|
530
|
+
console.log(` ${chalk.gray("│")} Fix: ${chalk.blue(issue.fix)}`);
|
|
531
|
+
console.log(` ${chalk.gray("└" + "─".repeat(48))}`);
|
|
532
|
+
console.log("");
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function formatCategory(cat: DoctorIssue["category"]): string {
|
|
537
|
+
const labels: Record<DoctorIssue["category"], string> = {
|
|
538
|
+
missing_table: "Missing Table",
|
|
539
|
+
missing_column: "Missing Column",
|
|
540
|
+
type_mismatch: "Type Mismatch",
|
|
541
|
+
missing_constraint: "Missing Constraint",
|
|
542
|
+
schema_stale: "Stale Schema",
|
|
543
|
+
missing_enum: "Missing Enum",
|
|
544
|
+
enum_value_mismatch: "Enum Value Mismatch",
|
|
545
|
+
missing_foreign_key: "Missing Foreign Key"
|
|
546
|
+
};
|
|
547
|
+
return labels[cat];
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ── Main entry point ─────────────────────────────────────────────────────
|
|
551
|
+
|
|
552
|
+
export async function runDoctor(options: {
|
|
553
|
+
collectionsPath: string;
|
|
554
|
+
schemaPath: string;
|
|
555
|
+
databaseUrl?: string;
|
|
556
|
+
}): Promise<DoctorReport> {
|
|
557
|
+
console.log("");
|
|
558
|
+
console.log(chalk.bold(" 🩺 Loading collections..."));
|
|
559
|
+
const collections = await loadCollections(options.collectionsPath);
|
|
560
|
+
if (collections.length === 0) {
|
|
561
|
+
console.error(chalk.red(" ✗ No collections found."));
|
|
562
|
+
process.exit(1);
|
|
563
|
+
}
|
|
564
|
+
console.log(chalk.gray(` Found ${collections.length} collection(s)`));
|
|
565
|
+
console.log("");
|
|
566
|
+
|
|
567
|
+
// Phase 1: Collections ↔ Generated Schema
|
|
568
|
+
console.log(chalk.gray(" Checking Collections → Generated Schema..."));
|
|
569
|
+
const collectionsToSchema = await checkCollectionsVsSchema(collections, options.schemaPath);
|
|
570
|
+
|
|
571
|
+
// Phase 2: Collections ↔ Database (only if we have a DATABASE_URL)
|
|
572
|
+
let schemaToDatabase: { passed: boolean; issues: DoctorIssue[] } = { passed: true,
|
|
573
|
+
issues: [] };
|
|
574
|
+
if (options.databaseUrl) {
|
|
575
|
+
console.log(chalk.gray(" Checking Collections → Database..."));
|
|
576
|
+
schemaToDatabase = await checkCollectionsVsDatabase(collections, options.databaseUrl);
|
|
577
|
+
} else {
|
|
578
|
+
console.log(chalk.yellow(" ⚠ DATABASE_URL not set — skipping database comparison."));
|
|
579
|
+
console.log(chalk.gray(" Set DATABASE_URL in your .env to enable full drift detection."));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const allIssues = [...collectionsToSchema.issues, ...schemaToDatabase.issues];
|
|
583
|
+
const summary = {
|
|
584
|
+
passed: [collectionsToSchema, schemaToDatabase].filter((p) => p.passed).length,
|
|
585
|
+
warnings: allIssues.filter((i) => i.severity === "warning").length,
|
|
586
|
+
errors: allIssues.filter((i) => i.severity === "error").length
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const report: DoctorReport = { collectionsToSchema,
|
|
590
|
+
schemaToDatabase,
|
|
591
|
+
summary };
|
|
592
|
+
renderReport(report);
|
|
593
|
+
|
|
594
|
+
return report;
|
|
595
|
+
}
|