@rebasepro/server-postgres 0.17.3 → 0.18.0

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 (149) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +31 -0
  3. package/dist/backup-argv.d.ts +19 -0
  4. package/dist/branch-argv.d.ts +28 -0
  5. package/dist/branch-prune.d.ts +77 -0
  6. package/dist/cli-collections-path.d.ts +31 -0
  7. package/dist/cli-errors.d.ts +123 -0
  8. package/dist/cli-flags.d.ts +101 -0
  9. package/dist/cli-helpers.d.ts +94 -10
  10. package/dist/collection-index-C7LopMyX.js +247 -0
  11. package/dist/collection-index-C7LopMyX.js.map +1 -0
  12. package/dist/databasePoolManager.d.ts +27 -0
  13. package/dist/{ensure-collection-policies-UCqgv_8c.js → ensure-collection-policies-CagOzEPS.js} +6 -6
  14. package/dist/{ensure-collection-policies-UCqgv_8c.js.map → ensure-collection-policies-CagOzEPS.js.map} +1 -1
  15. package/dist/{ensure-collection-tables-DgVixhX3.js → ensure-collection-tables-DbAhrjnG.js} +614 -52
  16. package/dist/ensure-collection-tables-DbAhrjnG.js.map +1 -0
  17. package/dist/index.d.ts +12 -0
  18. package/dist/index.es.js +19307 -3059
  19. package/dist/index.es.js.map +1 -1
  20. package/dist/{rls-enforcement-DvAbL9YJ.js → rls-enforcement-ClJoTboT.js} +53 -189
  21. package/dist/rls-enforcement-ClJoTboT.js.map +1 -0
  22. package/dist/schema/column-type-drift.d.ts +79 -0
  23. package/dist/schema/doctor.d.ts +42 -0
  24. package/dist/schema/ensure-collection-tables.d.ts +90 -0
  25. package/dist/schema/generate-next-step.d.ts +30 -0
  26. package/dist/schema/generate-postgres-ddl-logic.d.ts +28 -0
  27. package/dist/schema/generated-schema-staleness.d.ts +37 -0
  28. package/dist/schema/introspect-db-queries.d.ts +34 -0
  29. package/dist/security/rls-enforcement.d.ts +63 -2
  30. package/dist/services/BranchService.d.ts +86 -1
  31. package/dist/services/collection-helpers.d.ts +1 -1
  32. package/dist/services/realtimeService.d.ts +30 -2
  33. package/dist/services/row-pipeline.d.ts +12 -1
  34. package/dist/utils/pg-error-utils.d.ts +3 -1
  35. package/dist/utils/pg-numeric-number-patch.d.ts +31 -0
  36. package/package.json +32 -27
  37. package/dist/backup-service-DCk7KhhL.js +0 -9044
  38. package/dist/backup-service-DCk7KhhL.js.map +0 -1
  39. package/dist/collection-index-BRUg10H5.js +0 -5690
  40. package/dist/collection-index-BRUg10H5.js.map +0 -1
  41. package/dist/connection-GOKU3Hu5.js +0 -277
  42. package/dist/connection-GOKU3Hu5.js.map +0 -1
  43. package/dist/ensure-collection-tables-DgVixhX3.js.map +0 -1
  44. package/dist/rls-bootstrap-sql-B5C9LoJ6.js +0 -244
  45. package/dist/rls-bootstrap-sql-B5C9LoJ6.js.map +0 -1
  46. package/dist/rls-enforcement-DvAbL9YJ.js.map +0 -1
  47. package/dist/src-DiB5RP2Z.js +0 -541
  48. package/dist/src-DiB5RP2Z.js.map +0 -1
  49. package/dist/websocket-BZ4H5wUz.js +0 -8198
  50. package/dist/websocket-BZ4H5wUz.js.map +0 -1
  51. package/src/PostgresAdapter.ts +0 -91
  52. package/src/PostgresBackendDriver.ts +0 -1857
  53. package/src/PostgresBootstrapper.ts +0 -1157
  54. package/src/auth/ensure-tables.ts +0 -896
  55. package/src/auth/schema-version.ts +0 -260
  56. package/src/auth/services.ts +0 -1610
  57. package/src/backup/backup-cli.ts +0 -520
  58. package/src/backup/backup-cron.ts +0 -212
  59. package/src/backup/backup-logic.ts +0 -93
  60. package/src/backup/backup-service.ts +0 -485
  61. package/src/backup/index.ts +0 -12
  62. package/src/backup/pg-tools.ts +0 -403
  63. package/src/backup/retention.ts +0 -86
  64. package/src/cli-errors.ts +0 -265
  65. package/src/cli-helpers.ts +0 -569
  66. package/src/cli-output.ts +0 -43
  67. package/src/cli.ts +0 -1339
  68. package/src/collections/PostgresCollectionRegistry.ts +0 -108
  69. package/src/collections/buildRegistry.ts +0 -68
  70. package/src/collections/validate-relations.ts +0 -402
  71. package/src/connection.ts +0 -333
  72. package/src/data-transformer.ts +0 -845
  73. package/src/databasePoolManager.ts +0 -95
  74. package/src/history/HistoryService.ts +0 -254
  75. package/src/history/ensure-history-table.ts +0 -53
  76. package/src/index.ts +0 -19
  77. package/src/interfaces.ts +0 -60
  78. package/src/module-dir.ts +0 -7
  79. package/src/schema/atlas-argv.ts +0 -94
  80. package/src/schema/auth-schema.ts +0 -298
  81. package/src/schema/auth-users-columns.ts +0 -131
  82. package/src/schema/carved-out-migration.ts +0 -404
  83. package/src/schema/classify-change.ts +0 -436
  84. package/src/schema/collection-index.ts +0 -427
  85. package/src/schema/collections-schema-version.ts +0 -103
  86. package/src/schema/destructive-sql.ts +0 -94
  87. package/src/schema/doctor-cli.ts +0 -66
  88. package/src/schema/doctor-policy-checks.ts +0 -105
  89. package/src/schema/doctor.ts +0 -837
  90. package/src/schema/drizzle-ddl.ts +0 -33
  91. package/src/schema/dynamic-tables.test.ts +0 -302
  92. package/src/schema/dynamic-tables.ts +0 -293
  93. package/src/schema/ensure-collection-policies.ts +0 -198
  94. package/src/schema/ensure-collection-tables.test.ts +0 -518
  95. package/src/schema/ensure-collection-tables.ts +0 -1267
  96. package/src/schema/generate-drizzle-schema-logic.ts +0 -957
  97. package/src/schema/generate-drizzle-schema.ts +0 -143
  98. package/src/schema/generate-postgres-ddl-logic.ts +0 -1339
  99. package/src/schema/generate-postgres-ddl.ts +0 -180
  100. package/src/schema/generate-schema-commit.ts +0 -267
  101. package/src/schema/generated-schema-staleness.ts +0 -284
  102. package/src/schema/introspect-db-constraints.ts +0 -385
  103. package/src/schema/introspect-db-inference.ts +0 -255
  104. package/src/schema/introspect-db-logic.ts +0 -1387
  105. package/src/schema/introspect-db-naming.ts +0 -15
  106. package/src/schema/introspect-db-project.ts +0 -78
  107. package/src/schema/introspect-db-queries.ts +0 -326
  108. package/src/schema/introspect-db-structure.ts +0 -670
  109. package/src/schema/introspect-db-types.ts +0 -56
  110. package/src/schema/introspect-db.ts +0 -257
  111. package/src/schema/introspect-runtime.test.ts +0 -260
  112. package/src/schema/introspect-runtime.ts +0 -327
  113. package/src/schema/non-sql-collections.test.ts +0 -131
  114. package/src/schema/rls-bootstrap-sql.ts +0 -288
  115. package/src/schema/search-column.ts +0 -643
  116. package/src/schema/test-schema.ts +0 -11
  117. package/src/schema/vector-index.ts +0 -491
  118. package/src/security/anonymous-grants.test.ts +0 -73
  119. package/src/security/policy-drift.test.ts +0 -435
  120. package/src/security/policy-drift.ts +0 -473
  121. package/src/security/rls-enforcement.ts +0 -505
  122. package/src/services/BranchService.ts +0 -326
  123. package/src/services/FetchService.ts +0 -2115
  124. package/src/services/PersistService.ts +0 -557
  125. package/src/services/RelationService.ts +0 -978
  126. package/src/services/RelationWriteService.ts +0 -653
  127. package/src/services/cdc/CdcListener.ts +0 -103
  128. package/src/services/cdc/junction-tables.ts +0 -91
  129. package/src/services/cdc/trigger-cdc.ts +0 -173
  130. package/src/services/channel-bus/ChannelBus.ts +0 -44
  131. package/src/services/channel-bus/PostgresChannelBus.ts +0 -299
  132. package/src/services/channel-bus/index.ts +0 -114
  133. package/src/services/channel-history.ts +0 -424
  134. package/src/services/channel-presence.ts +0 -185
  135. package/src/services/collection-helpers.ts +0 -420
  136. package/src/services/dataService.ts +0 -256
  137. package/src/services/index.ts +0 -14
  138. package/src/services/junction-writes.ts +0 -295
  139. package/src/services/nested-path.ts +0 -145
  140. package/src/services/pg-notify-listener.ts +0 -151
  141. package/src/services/realtimeService.ts +0 -2545
  142. package/src/services/row-pipeline.ts +0 -240
  143. package/src/services/write-denial.ts +0 -55
  144. package/src/types.ts +0 -4
  145. package/src/utils/connection-string.ts +0 -58
  146. package/src/utils/drizzle-conditions.ts +0 -2743
  147. package/src/utils/pg-array-null-patch.ts +0 -42
  148. package/src/utils/pg-error-utils.ts +0 -391
  149. package/src/websocket.ts +0 -855
@@ -1,837 +0,0 @@
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, type ResolvedManyToMany, type ResolvedBelongsTo, isManyToMany } from "@rebasepro/types";
16
- import { generateSchema } from "./generate-drizzle-schema-logic";
17
- import { generateTypedefs } from "@rebasepro/codegen";
18
- import { getTableName, resolveCollectionRelations, findRelation, relationalCollections } from "@rebasepro/common";
19
- import { toSnakeCase } from "@rebasepro/utils";
20
- import { loadCollectionsFromDirectory } from "@rebasepro/server";
21
- // The report is CLI output, not application logging — see cli-output.ts.
22
- import { out, outError } from "../cli-output";
23
-
24
- /**
25
- * Resolve the SQL column name for a property.
26
- * Uses the explicit `columnName` when set (e.g. from introspection),
27
- * falling back to `toSnakeCase(propName)` for manually-authored collections.
28
- */
29
- const resolveColumnName = (propName: string, prop?: Property | null): string => {
30
- if (prop && "columnName" in prop && typeof prop.columnName === "string") {
31
- return prop.columnName;
32
- }
33
- return toSnakeCase(propName);
34
- };
35
-
36
- // ── Types ────────────────────────────────────────────────────────────────
37
-
38
- export type IssueSeverity = "error" | "warning" | "info";
39
-
40
- export interface DoctorIssue {
41
- severity: IssueSeverity;
42
- category: "missing_table" | "missing_column" | "type_mismatch" | "missing_constraint" | "schema_stale" | "missing_enum" | "enum_value_mismatch" | "missing_foreign_key" | "sdk_stale" | "sdk_not_generated" | "sdk_ungeneratable";
43
- table?: string;
44
- column?: string;
45
- expected?: string;
46
- actual?: string;
47
- message: string;
48
- fix: string;
49
- }
50
-
51
- export interface DoctorPhase {
52
- passed: boolean;
53
- issues: DoctorIssue[];
54
- /**
55
- * Why this phase never ran, when it did not.
56
- *
57
- * A check that did not happen is a third state, not a passing one. While a
58
- * skipped phase initialised to `{ passed: true, issues: [] }` it rendered as
59
- * `✅ Collections → Database: In sync` and counted towards
60
- * `✓ All schemas are in sync!` — so a project whose connection string was
61
- * spelled `POSTGRES_URL`, or a CI job that never exported one, got two green
62
- * ticks and exit 0 against a database with no tables in it.
63
- */
64
- skipped?: string;
65
- /**
66
- * Why this phase had nothing to compare against, when it had nothing.
67
- *
68
- * Distinct from `skipped`, which means "the check could not run, and that
69
- * is probably worth fixing". This one means "the artifact is optional and
70
- * you have not asked for it": no drift is possible, so the run is still a
71
- * clean bill of health.
72
- *
73
- * It exists because the alternative was a contradiction. The typed-SDK
74
- * phase returned `{ passed: true }` when `generated/sdk/database.types.ts`
75
- * did not exist, so a fresh project's report read
76
- * `✅ Collections → SDK Types: In sync` directly above
77
- * `ℹ Typed SDK not generated (optional).` — one line calling a file
78
- * synchronised and the next saying it is absent. "In sync" is a claim about
79
- * a comparison, and no comparison happened.
80
- */
81
- notApplicable?: string;
82
- }
83
-
84
- export interface DoctorReport {
85
- collectionsToSchema: DoctorPhase;
86
- collectionsToSdk: DoctorPhase;
87
- schemaToDatabase: DoctorPhase;
88
- summary: { passed: number; skipped: number; notApplicable: number; warnings: number; errors: number };
89
- }
90
-
91
- // ── Column type mapping (mirrors generate-drizzle-schema-logic.ts) ───────
92
-
93
- export function getExpectedColumnType(prop: Property): string | null {
94
- switch (prop.type) {
95
- case "string": {
96
- const sp = prop as StringProperty;
97
- if (sp.enum) return "USER-DEFINED"; // pgEnum → USER-DEFINED in information_schema
98
- if ("isId" in sp && sp.isId === "uuid") return "uuid";
99
- if (sp.columnType === "uuid") return "uuid";
100
- if (sp.columnType === "char") return "character";
101
- if (sp.columnType === "varchar") return "character varying";
102
- // `text` is the default — see generate-postgres-ddl-logic.
103
- return "text";
104
- }
105
- case "number": {
106
- const np = prop as NumberProperty;
107
- if (np.columnType) {
108
- // The generator passes any columnType straight through to drizzle,
109
- // so mirror that rather than enumerating a subset (which reported
110
- // drift for anything unlisted, e.g. smallint). Serial types are
111
- // integers with a sequence default; information_schema reports the
112
- // underlying width.
113
- const serialWidths: Record<string, string> = {
114
- serial: "integer",
115
- bigserial: "bigint",
116
- smallserial: "smallint"
117
- };
118
- return serialWidths[np.columnType] ?? np.columnType;
119
- }
120
- if (np.validation?.integer || ("isId" in np && np.isId)) return "integer";
121
- return "numeric";
122
- }
123
- case "boolean":
124
- return "boolean";
125
- case "date": {
126
- const dp = prop as DateProperty;
127
- if (dp.columnType === "date") return "date";
128
- if (dp.columnType === "time") return "time without time zone";
129
- return "timestamp with time zone";
130
- }
131
- case "array": {
132
- const ap = prop as ArrayProperty;
133
- let colType = ap.columnType;
134
- if (!colType && ap.of && !Array.isArray(ap.of)) {
135
- const ofProp = ap.of as Property;
136
- if (ofProp.type === "string") {
137
- colType = "text[]";
138
- } else if (ofProp.type === "number") {
139
- colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
140
- } else if (ofProp.type === "boolean") {
141
- colType = "boolean[]";
142
- }
143
- }
144
-
145
- if (colType === "json") return "json";
146
- if (colType === "jsonb") return "jsonb";
147
- if (colType && colType.endsWith("[]")) return "ARRAY";
148
- return "jsonb";
149
- }
150
- case "map": {
151
- const mp = prop as MapProperty;
152
- if (mp.columnType === "json") return "json";
153
- return "jsonb";
154
- }
155
- case "relation":
156
- return null; // FK columns are derived from the relation, not from the property
157
- case "reference":
158
- // A reference FK follows the key it points at, and a string key is
159
- // `text` — see generate-postgres-ddl-logic.
160
- return "text";
161
- case "vector":
162
- return "USER-DEFINED";
163
- case "binary":
164
- return "bytea";
165
- default:
166
- return null;
167
- }
168
- }
169
-
170
- // ── Collection loading ───────────────────────────────────────────────────
171
-
172
- /**
173
- * Re-exported so callers keep importing it from here, but the implementation is
174
- * now shared with the runtime and the generators — a doctor that disagreed with
175
- * the policy generator about which files are collections would compare the
176
- * wrong thing.
177
- */
178
- export async function loadCollections(collectionsPath: string): Promise<CollectionConfig[]> {
179
- return loadCollectionsFromDirectory(collectionsPath);
180
- }
181
-
182
- // ── Phase 1: Collections ↔ Generated Schema ─────────────────────────────
183
-
184
- export async function checkCollectionsVsSchema(
185
- collections: CollectionConfig[],
186
- schemaFilePath: string
187
- ): Promise<DoctorPhase> {
188
- const issues: DoctorIssue[] = [];
189
-
190
- // Check if schema file exists
191
- if (!fs.existsSync(schemaFilePath)) {
192
- issues.push({
193
- severity: "error",
194
- category: "schema_stale",
195
- message: "Generated schema file does not exist.",
196
- fix: "Run `rebase schema generate`"
197
- });
198
- return { passed: false,
199
- issues };
200
- }
201
-
202
- // Re-generate schema in-memory and compare with file on disk. Only the
203
- // collections the generator itself will emit — a Firestore collection has
204
- // no table in the generated file and must not be reported as missing one.
205
- const postgresCollections = relationalCollections(collections);
206
- if (postgresCollections.length === 0) {
207
- return { passed: true,
208
- issues };
209
- }
210
-
211
- try {
212
- const expectedSchema = await generateSchema(postgresCollections);
213
- const actualSchema = await fsPromises.readFile(schemaFilePath, "utf-8");
214
-
215
- // Normalize whitespace for comparison
216
- const normalize = (s: string) =>
217
- s
218
- .replace(/\/\/.*$/gm, "") // strip single-line comments
219
- .replace(/\/\*[\s\S]*?\*\//g, "") // strip multi-line comments
220
- .replace(/\s+/g, " ")
221
- .trim();
222
-
223
- if (normalize(expectedSchema) !== normalize(actualSchema)) {
224
- issues.push({
225
- severity: "warning",
226
- category: "schema_stale",
227
- message: "Generated schema is out of date — collection definitions have changed since last generation.",
228
- fix: "Run `rebase schema generate`"
229
- });
230
- }
231
- } catch (err: unknown) {
232
- const message = err instanceof Error ? err.message : String(err);
233
- issues.push({
234
- severity: "warning",
235
- category: "schema_stale",
236
- message: `Could not regenerate schema for comparison: ${message}`,
237
- fix: "Run `rebase schema generate` to verify"
238
- });
239
- }
240
-
241
- return { passed: issues.length === 0,
242
- issues };
243
- }
244
-
245
- export async function checkCollectionsVsSdk(
246
- collections: CollectionConfig[],
247
- sdkFilePath: string
248
- ): Promise<DoctorPhase> {
249
- const issues: DoctorIssue[] = [];
250
-
251
- // The typed SDK is opt-in — nothing in a scaffolded project imports it until
252
- // you choose to. A project that never generated one isn't drifting, so report
253
- // it as information rather than a warning; otherwise `doctor` can never come
254
- // back clean on a fresh project and users learn to ignore its output.
255
- if (!fs.existsSync(sdkFilePath)) {
256
- issues.push({
257
- severity: "info",
258
- category: "sdk_not_generated",
259
- message: "Typed SDK not generated (optional).",
260
- fix: "Run `rebase generate-sdk` if you want typed collection access"
261
- });
262
- return { passed: true,
263
- issues,
264
- notApplicable: "not generated (optional)" };
265
- }
266
-
267
- try {
268
- const expectedSdk = generateTypedefs(collections);
269
- const actualSdk = await fsPromises.readFile(sdkFilePath, "utf-8");
270
-
271
- // Normalize whitespace for comparison
272
- const normalize = (s: string) =>
273
- s
274
- .replace(/\/\/.*$/gm, "") // strip single-line comments
275
- .replace(/\/\*[\s\S]*?\*\//g, "") // strip multi-line comments
276
- .replace(/\s+/g, " ")
277
- .trim();
278
-
279
- if (normalize(expectedSdk) !== normalize(actualSdk)) {
280
- issues.push({
281
- severity: "warning",
282
- category: "sdk_stale",
283
- message: "Generated SDK types are out of date — collection definitions have changed since last SDK generation.",
284
- fix: "Run `rebase generate-sdk`"
285
- });
286
- }
287
- } catch (err: unknown) {
288
- const message = err instanceof Error ? err.message : String(err);
289
-
290
- // A `CodegenError` is not "the comparison did not run" — it is the
291
- // schema saying it cannot produce a valid typed client at all, most
292
- // often two slugs collapsing onto one accessor. Reporting that as a
293
- // warning about staleness described the wrong problem and pointed at a
294
- // command that would fail the same way.
295
- if (err instanceof Error && err.name === "CodegenError") {
296
- issues.push({
297
- severity: "error",
298
- category: "sdk_ungeneratable",
299
- message: `The typed SDK cannot be generated from these collections: ${message}`,
300
- fix: "Fix the collection definitions named above, then run `rebase generate-sdk`"
301
- });
302
- } else {
303
- issues.push({
304
- severity: "warning",
305
- category: "sdk_stale",
306
- message: `Could not regenerate SDK types for comparison: ${message}`,
307
- fix: "Run `rebase generate-sdk` to verify"
308
- });
309
- }
310
- }
311
-
312
- return { passed: issues.length === 0,
313
- issues };
314
- }
315
-
316
- // ── Phase 2: Collections ↔ Database ──────────────────────────────────────
317
-
318
- interface DbColumn {
319
- table_schema: string;
320
- table_name: string;
321
- column_name: string;
322
- data_type: string;
323
- is_nullable: string;
324
- udt_name: string;
325
- }
326
-
327
-
328
- interface DbEnumValue {
329
- enum_name: string;
330
- enum_value: string;
331
- }
332
-
333
- export async function checkCollectionsVsDatabase(
334
- collections: CollectionConfig[],
335
- databaseUrl: string
336
- ): Promise<DoctorPhase> {
337
- const issues: DoctorIssue[] = [];
338
-
339
- // Dynamic import to avoid loading pg when not needed
340
- const pgModule = await import("pg");
341
- const { Pool } = pgModule.default ?? pgModule;
342
- const pool = new Pool({ connectionString: databaseUrl });
343
-
344
- // Determine all schemas defined by the collections, plus public and rebase
345
- const schemas = Array.from(new Set([
346
- "public",
347
- "rebase",
348
- ...relationalCollections(collections)
349
- .filter(isPostgresCollectionConfig)
350
- .map(c => c.schema)
351
- .filter((s): s is string => !!s)
352
- ]));
353
-
354
- try {
355
- // Fetch all tables in the defined schemas
356
- const tablesResult = await pool.query<{ table_schema: string; table_name: string }>(
357
- `SELECT table_schema, table_name
358
- FROM information_schema.tables
359
- WHERE table_schema = ANY($1) AND table_type = 'BASE TABLE'`,
360
- [schemas]
361
- );
362
- const existingTables = new Set(tablesResult.rows.map((r) =>
363
- r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`
364
- ));
365
-
366
- // Fetch all columns in the defined schemas
367
- const columnsResult = await pool.query<DbColumn>(
368
- `SELECT table_schema, table_name, column_name, data_type, is_nullable, udt_name
369
- FROM information_schema.columns
370
- WHERE table_schema = ANY($1)
371
- ORDER BY table_schema, table_name, ordinal_position`,
372
- [schemas]
373
- );
374
- const columnsByTable = new Map<string, DbColumn[]>();
375
- for (const row of columnsResult.rows) {
376
- const tableSchema = row.table_schema;
377
- const tableName = row.table_name;
378
- const key = tableSchema === "public" ? tableName : `${tableSchema}.${tableName}`;
379
- if (!columnsByTable.has(key)) {
380
- columnsByTable.set(key, []);
381
- }
382
- columnsByTable.get(key)!.push(row);
383
- }
384
-
385
- // Fetch enums
386
- const enumsResult = await pool.query<DbEnumValue>(
387
- `SELECT t.typname as enum_name, e.enumlabel as enum_value
388
- FROM pg_type t
389
- JOIN pg_enum e ON t.oid = e.enumtypid
390
- ORDER BY t.typname, e.enumsortorder`
391
- );
392
- const enumsByName = new Map<string, string[]>();
393
- for (const row of enumsResult.rows) {
394
- if (!enumsByName.has(row.enum_name)) {
395
- enumsByName.set(row.enum_name, []);
396
- }
397
- enumsByName.get(row.enum_name)!.push(row.enum_value);
398
- }
399
-
400
- // Fetch foreign key constraints in the defined schemas
401
- const fksResult = await pool.query<{
402
- constraint_name: string;
403
- table_schema: string;
404
- table_name: string;
405
- column_name: string;
406
- foreign_table_schema: string;
407
- foreign_table_name: string;
408
- foreign_column_name: string;
409
- }>(
410
- `SELECT
411
- tc.constraint_name,
412
- tc.table_schema,
413
- tc.table_name,
414
- kcu.column_name,
415
- ccu.table_schema AS foreign_table_schema,
416
- ccu.table_name AS foreign_table_name,
417
- ccu.column_name AS foreign_column_name
418
- FROM information_schema.table_constraints AS tc
419
- JOIN information_schema.key_column_usage AS kcu
420
- ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
421
- JOIN information_schema.constraint_column_usage AS ccu
422
- ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
423
- WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = ANY($1)`,
424
- [schemas]
425
- );
426
- const fksByTable = new Map<string, typeof fksResult.rows>();
427
- for (const row of fksResult.rows) {
428
- const tableSchema = row.table_schema;
429
- const tableName = row.table_name;
430
- const key = tableSchema === "public" ? tableName : `${tableSchema}.${tableName}`;
431
- if (!fksByTable.has(key)) {
432
- fksByTable.set(key, []);
433
- }
434
- fksByTable.get(key)!.push(row);
435
- }
436
-
437
- // ── Compare each collection against the database ─────────────────
438
-
439
- const postgresCollections = relationalCollections(collections);
440
-
441
- for (const collection of postgresCollections) {
442
- const tableName = getTableName(collection);
443
- const schemaName = (collection as { schema?: string }).schema || "public";
444
- const fullTableName = schemaName === "public" ? tableName : `${schemaName}.${tableName}`;
445
-
446
- // Check table existence
447
- if (!existingTables.has(fullTableName)) {
448
- issues.push({
449
- severity: "error",
450
- category: "missing_table",
451
- table: fullTableName,
452
- message: `Table "${fullTableName}" does not exist in the database.`,
453
- fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
454
- });
455
- continue; // Skip column checks for missing tables
456
- }
457
-
458
- const dbColumns = columnsByTable.get(fullTableName) ?? [];
459
- const dbColumnMap = new Map(dbColumns.map((c) => [c.column_name, c]));
460
-
461
- // System columns that Rebase always creates
462
- const systemColumns = new Set(["id", "created_on", "updated_on"]);
463
-
464
- // Check properties → columns
465
- for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
466
- if (prop.type === "relation") {
467
- // ResolvedRelation columns are derived from localKey
468
- const resolvedRelations = resolveCollectionRelations(collection);
469
- const relation = findRelation(resolvedRelations, (prop as RelationProperty).relation?.relationName ?? propName);
470
- if (relation?.kind === "belongsTo") {
471
- const fkColName = relation.localKey;
472
- if (!dbColumnMap.has(fkColName)) {
473
- issues.push({
474
- severity: "error",
475
- category: "missing_column",
476
- table: fullTableName,
477
- column: fkColName,
478
- message: `Foreign key column "${fkColName}" for relation "${propName}" is missing from table "${fullTableName}".`,
479
- fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
480
- });
481
- }
482
-
483
- // Check FK constraint exists
484
- const tableFks = fksByTable.get(fullTableName) ?? [];
485
- let targetTableName = "unknown";
486
- let targetSchemaName = "public";
487
- try {
488
- const targetColl = relation.target();
489
- targetTableName = getTableName(targetColl);
490
- targetSchemaName = (targetColl as { schema?: string }).schema || "public";
491
- } catch { /* ignore */ }
492
-
493
- const hasFk = tableFks.some((fk) =>
494
- fk.column_name === fkColName &&
495
- fk.foreign_table_name === targetTableName &&
496
- fk.foreign_table_schema === targetSchemaName
497
- );
498
-
499
- if (dbColumnMap.has(fkColName) && !hasFk) {
500
- issues.push({
501
- severity: "warning",
502
- category: "missing_foreign_key",
503
- table: fullTableName,
504
- column: fkColName,
505
- message: `Column "${fkColName}" exists but has no FOREIGN KEY constraint referencing "${targetSchemaName === "public" ? targetTableName : `${targetSchemaName}.${targetTableName}`}".`,
506
- fix: "Run `rebase db push` or add the constraint manually"
507
- });
508
- }
509
- }
510
- continue;
511
- }
512
-
513
- const colName = resolveColumnName(propName, prop);
514
-
515
- // Skip system columns — they're handled automatically
516
- if (systemColumns.has(colName)) continue;
517
-
518
- const dbCol = dbColumnMap.get(colName);
519
- if (!dbCol) {
520
- issues.push({
521
- severity: "error",
522
- category: "missing_column",
523
- table: fullTableName,
524
- column: colName,
525
- message: `Column "${colName}" is defined in collection "${collection.slug}" but missing from table "${fullTableName}".`,
526
- fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
527
- });
528
- continue;
529
- }
530
-
531
- // Type check
532
- const expectedType = getExpectedColumnType(prop);
533
- if (expectedType) {
534
- const actualType = dbCol.data_type;
535
- let isMismatch = actualType !== expectedType;
536
- if (prop.type === "vector" && dbCol.udt_name !== "vector") {
537
- isMismatch = true;
538
- }
539
- if (prop.type === "array") {
540
- const ap = prop as ArrayProperty;
541
- let expectedColType = ap.columnType;
542
- if (!expectedColType && ap.of && !Array.isArray(ap.of)) {
543
- const ofProp = ap.of as Property;
544
- if (ofProp.type === "string") expectedColType = "text[]";
545
- else if (ofProp.type === "number") expectedColType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
546
- else if (ofProp.type === "boolean") expectedColType = "boolean[]";
547
- }
548
- if (expectedColType && expectedColType.endsWith("[]")) {
549
- if (actualType !== "ARRAY") {
550
- isMismatch = true;
551
- } else {
552
- const expectedUdt = expectedColType === "text[]" ? "_text" :
553
- expectedColType === "integer[]" ? "_int4" :
554
- expectedColType === "boolean[]" ? "_bool" :
555
- expectedColType === "numeric[]" ? "_numeric" : "";
556
- if (expectedUdt && dbCol.udt_name !== expectedUdt) {
557
- isMismatch = true;
558
- }
559
- }
560
- }
561
- }
562
- if (isMismatch) {
563
- issues.push({
564
- severity: "warning",
565
- category: "type_mismatch",
566
- table: fullTableName,
567
- column: colName,
568
- expected: prop.type === "vector" ? "vector" : expectedType,
569
- actual: dbCol.udt_name === "vector" ? "vector" : actualType,
570
- message: `Column "${colName}" in table "${fullTableName}": expected type "${prop.type === "vector" ? "vector" : expectedType}" but found "${dbCol.udt_name === "vector" ? "vector" : actualType}".`,
571
- fix: "Review collection property type or run a migration"
572
- });
573
- }
574
- }
575
-
576
- // Enum value check
577
- if (prop.type === "string" && (prop as StringProperty).enum) {
578
- const enumValues = (prop as StringProperty).enum;
579
- if (enumValues) {
580
- const enumName = `${tableName}_${colName}`;
581
- const dbEnumValues = enumsByName.get(enumName);
582
- if (!dbEnumValues) {
583
- issues.push({
584
- severity: "warning",
585
- category: "missing_enum",
586
- table: fullTableName,
587
- column: colName,
588
- expected: enumName,
589
- message: `Enum type "${enumName}" is defined in collection but not found in the database.`,
590
- fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
591
- });
592
- } else {
593
- // Compare enum values
594
- const expectedValues = Array.isArray(enumValues)
595
- ? enumValues.map((v) => (typeof v === "string" ? v : String(v.id)))
596
- : Object.keys(enumValues);
597
-
598
- const missing = expectedValues.filter((v) => !dbEnumValues.includes(v));
599
- const extra = dbEnumValues.filter((v) => !expectedValues.includes(v));
600
-
601
- if (missing.length > 0 || extra.length > 0) {
602
- const parts: string[] = [];
603
- if (missing.length > 0) parts.push(`missing: ${missing.join(", ")}`);
604
- if (extra.length > 0) parts.push(`extra in DB: ${extra.join(", ")}`);
605
- issues.push({
606
- severity: "warning",
607
- category: "enum_value_mismatch",
608
- table: fullTableName,
609
- column: colName,
610
- expected: expectedValues.join(", "),
611
- actual: dbEnumValues.join(", "),
612
- message: `Enum values for "${colName}" in table "${fullTableName}" are out of sync (${parts.join("; ")}).`,
613
- fix: "Run `rebase db push` to update the enum"
614
- });
615
- }
616
- }
617
- }
618
- }
619
- }
620
-
621
- // Also check junction tables for many-to-many relations
622
- const resolvedRelations = resolveCollectionRelations(collection);
623
- for (const relation of Object.values(resolvedRelations)) {
624
- if (isManyToMany(relation)) {
625
- const junctionTable = relation.through.table;
626
- const junctionSchema = (collection as { schema?: string }).schema || "public";
627
- const fullJunctionTable = junctionSchema === "public" ? junctionTable : `${junctionSchema}.${junctionTable}`;
628
- if (!existingTables.has(fullJunctionTable)) {
629
- issues.push({
630
- severity: "error",
631
- category: "missing_table",
632
- table: fullJunctionTable,
633
- message: `Junction table "${fullJunctionTable}" for many-to-many relation "${relation.relationName}" is missing.`,
634
- fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
635
- });
636
- }
637
- }
638
- }
639
- }
640
- } finally {
641
- await pool.end();
642
- }
643
-
644
- return { passed: issues.length === 0,
645
- issues };
646
- }
647
-
648
- // ── Report Rendering ─────────────────────────────────────────────────────
649
-
650
- export function renderReport(report: DoctorReport): void {
651
- out();
652
- out(chalk.bold(" 🩺 Rebase Schema Doctor"));
653
- out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
654
- out();
655
-
656
- // Phase 1
657
- renderPhase("Collections → Generated Schema", report.collectionsToSchema);
658
-
659
- // Phase 2
660
- renderPhase("Collections → Database", report.schemaToDatabase);
661
-
662
- // Phase 3
663
- renderPhase("Collections → SDK Types", report.collectionsToSdk);
664
-
665
- // Summary
666
- out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
667
- const { passed, skipped, notApplicable, warnings, errors } = report.summary;
668
-
669
- const parts: string[] = [];
670
- parts.push(chalk.green(`${passed} passed`));
671
- if (skipped > 0) parts.push(chalk.yellow(`${skipped} skipped`));
672
- if (notApplicable > 0) parts.push(chalk.gray(`${notApplicable} not applicable`));
673
- if (warnings > 0) parts.push(chalk.yellow(`${warnings} warnings`));
674
- if (errors > 0) parts.push(chalk.red(`${errors} errors`));
675
-
676
- out(` Summary: ${parts.join(", ")}`);
677
- out();
678
-
679
- if (errors > 0) {
680
- out(chalk.red.bold(" ✗ Schema drift detected. Run the suggested fixes above."));
681
- } else if (warnings > 0) {
682
- out(chalk.yellow.bold(" ⚠ Minor issues detected. Consider running the suggested fixes."));
683
- } else if (skipped > 0) {
684
- // Never "all schemas are in sync" off the back of a check that did not
685
- // run: a clean bill of health has to come from measurement.
686
- out(chalk.yellow.bold(` ⚠ Nothing wrong in the checks that ran, but ${skipped} did not run — this is not a clean bill of health.`));
687
- } else {
688
- out(chalk.green.bold(" ✓ All schemas are in sync!"));
689
- }
690
- out();
691
- }
692
-
693
- function renderPhase(label: string, phase: DoctorPhase): void {
694
- // A phase that never ran gets its own marker and its reason. It is neither
695
- // "In sync" nor a drift report; conflating it with the former is what let
696
- // doctor certify a database it had not opened.
697
- if (phase.skipped) {
698
- out(` ${chalk.yellow("⏭")} ${label}: ${chalk.yellow(`skipped (${phase.skipped})`)}`);
699
- out();
700
- return;
701
- }
702
-
703
- // Nothing to compare against. Its own marker, and only the remedy under it
704
- // — the phase header already carries the reason, so re-printing the note
705
- // would say the same thing twice. See DoctorPhase.notApplicable.
706
- if (phase.notApplicable) {
707
- out(` ${chalk.gray("➖")} ${label}: ${chalk.gray(phase.notApplicable)}`);
708
- for (const issue of phase.issues.filter((i) => i.severity === "info")) {
709
- out(` ${chalk.gray(issue.fix)}`);
710
- }
711
- out();
712
- return;
713
- }
714
-
715
- const issues = phase.issues;
716
- const errorCount = issues.filter((i) => i.severity === "error").length;
717
- const warnCount = issues.filter((i) => i.severity === "warning").length;
718
- const infoIssues = issues.filter((i) => i.severity === "info");
719
-
720
- // Informational notes don't make a phase unhealthy, so key the header off
721
- // real problems rather than `phase.passed` alone.
722
- if (errorCount === 0 && warnCount === 0) {
723
- out(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
724
- } else {
725
- const parts: string[] = [];
726
- if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
727
- if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
728
- out(` ${chalk.yellow("⚠️")} ${label}: ${chalk.yellow(parts.join(", "))}`);
729
- }
730
- out();
731
-
732
- // Notes render as a quiet one-liner, not a full drift box.
733
- for (const issue of infoIssues) {
734
- const fixPart = issue.fix ? chalk.gray(` — ${issue.fix}`) : "";
735
- out(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
736
- out();
737
- }
738
-
739
- for (const issue of issues.filter((i) => i.severity !== "info")) {
740
- const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
741
- const categoryLabel = formatCategory(issue.category);
742
- out(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
743
-
744
- if (issue.table) {
745
- const colPart = issue.column ? ` │ Column: ${chalk.cyan(issue.column)}` : "";
746
- out(` ${chalk.gray("│")} Table: ${chalk.cyan(issue.table)}${colPart}`);
747
- }
748
-
749
- if (issue.expected && issue.actual) {
750
- out(` ${chalk.gray("│")} Expected: ${chalk.green(issue.expected)} │ Actual: ${chalk.red(issue.actual)}`);
751
- }
752
-
753
- out(` ${chalk.gray("│")} ${issue.message}`);
754
- out(` ${chalk.gray("│")} Fix: ${chalk.blue(issue.fix)}`);
755
- out(` ${chalk.gray("└" + "─".repeat(48))}`);
756
- out();
757
- }
758
- }
759
-
760
- function formatCategory(cat: DoctorIssue["category"]): string {
761
- const labels: Record<DoctorIssue["category"], string> = {
762
- missing_table: "Missing Table",
763
- missing_column: "Missing Column",
764
- type_mismatch: "Type Mismatch",
765
- missing_constraint: "Missing Constraint",
766
- schema_stale: "Stale Schema",
767
- missing_enum: "Missing Enum",
768
- enum_value_mismatch: "Enum Value Mismatch",
769
- missing_foreign_key: "Missing Foreign Key",
770
- sdk_stale: "Stale SDK Types",
771
- sdk_not_generated: "SDK Types Not Generated",
772
- sdk_ungeneratable: "SDK Cannot Be Generated"
773
- };
774
- return labels[cat];
775
- }
776
-
777
- // ── Main entry point ─────────────────────────────────────────────────────
778
-
779
- export async function runDoctor(options: {
780
- collectionsPath: string;
781
- schemaPath: string;
782
- sdkPath: string;
783
- databaseUrl?: string;
784
- }): Promise<DoctorReport> {
785
- out();
786
- out(chalk.bold(" 🩺 Loading collections..."));
787
- const collections = await loadCollections(options.collectionsPath);
788
- if (collections.length === 0) {
789
- outError(chalk.red(" ✗ No collections found."));
790
- process.exit(1);
791
- }
792
- out(chalk.gray(` Found ${collections.length} collection(s)`));
793
- out();
794
-
795
- // Phase 1: Collections ↔ Generated Schema
796
- out(chalk.gray(" Checking Collections → Generated Schema..."));
797
- const collectionsToSchema = await checkCollectionsVsSchema(collections, options.schemaPath);
798
-
799
- // Phase 2: Collections ↔ Database (only if we have a DATABASE_URL)
800
- let schemaToDatabase: DoctorPhase;
801
- if (options.databaseUrl) {
802
- out(chalk.gray(" Checking Collections → Database..."));
803
- schemaToDatabase = await checkCollectionsVsDatabase(collections, options.databaseUrl);
804
- } else {
805
- // Not `{ passed: true }`: see DoctorPhase.skipped.
806
- schemaToDatabase = { passed: false,
807
- issues: [],
808
- skipped: "DATABASE_URL not set" };
809
- out(chalk.yellow(" ⚠ DATABASE_URL not set — skipping database comparison."));
810
- out(chalk.gray(" Set DATABASE_URL in your .env to enable full drift detection."));
811
- }
812
-
813
- // Phase 3: Collections ↔ SDK Types
814
- out(chalk.gray(" Checking Collections → SDK Types..."));
815
- const collectionsToSdk = await checkCollectionsVsSdk(collections, options.sdkPath);
816
-
817
- const phases = [collectionsToSchema, schemaToDatabase, collectionsToSdk];
818
- const allIssues = phases.flatMap((p) => p.issues);
819
- const summary = {
820
- // A skipped phase is not a passing one, however few issues it collected.
821
- // Nor is one with nothing to compare against: "passed" would put a
822
- // never-generated SDK in the same column as a verified one.
823
- passed: phases.filter((p) => p.passed && !p.skipped && !p.notApplicable).length,
824
- skipped: phases.filter((p) => p.skipped).length,
825
- notApplicable: phases.filter((p) => p.notApplicable && !p.skipped).length,
826
- warnings: allIssues.filter((i) => i.severity === "warning").length,
827
- errors: allIssues.filter((i) => i.severity === "error").length
828
- };
829
-
830
- const report: DoctorReport = { collectionsToSchema,
831
- collectionsToSdk,
832
- schemaToDatabase,
833
- summary };
834
- renderReport(report);
835
-
836
- return report;
837
- }