@rebasepro/server-postgres 0.18.0 → 0.19.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.
- package/dist/BranchService-ByLfM_Tt.js +360 -0
- package/dist/BranchService-ByLfM_Tt.js.map +1 -0
- package/dist/__vite-browser-external-BnuHet1e.js +46 -0
- package/dist/__vite-browser-external-BnuHet1e.js.map +1 -0
- package/dist/arg-Dni7MzLB.js +87 -0
- package/dist/arg-Dni7MzLB.js.map +1 -0
- package/dist/auth-users-columns-D2LBFrMH.js +116 -0
- package/dist/auth-users-columns-D2LBFrMH.js.map +1 -0
- package/dist/backup-cli-oM9WQVlV.js +408 -0
- package/dist/backup-cli-oM9WQVlV.js.map +1 -0
- package/dist/backup-service-Br3tqwsy.js +715 -0
- package/dist/backup-service-Br3tqwsy.js.map +1 -0
- package/dist/cli-errors-DxN4yzdH.js +657 -0
- package/dist/cli-errors-DxN4yzdH.js.map +1 -0
- package/dist/cli-output-CNdMql-L.js +47 -0
- package/dist/cli-output-CNdMql-L.js.map +1 -0
- package/dist/cli.js +2020 -0
- package/dist/cli.js.map +1 -0
- package/dist/{collection-index-C7LopMyX.js → collection-index-BHgT_TKd.js} +32 -3
- package/dist/{collection-index-C7LopMyX.js.map → collection-index-BHgT_TKd.js.map} +1 -1
- package/dist/{collections-schema-version-BMeu3cgv.js → collections-schema-version-T6cGvrDi.js} +1 -2
- package/dist/{collections-schema-version-BMeu3cgv.js.map → collections-schema-version-T6cGvrDi.js.map} +1 -1
- package/dist/databasePoolManager-Bj5FbeAs.js +349 -0
- package/dist/databasePoolManager-Bj5FbeAs.js.map +1 -0
- package/dist/doctor-D7HWmGzs.js +974 -0
- package/dist/doctor-D7HWmGzs.js.map +1 -0
- package/dist/{ensure-collection-policies-CagOzEPS.js → ensure-collection-policies-2c2NV8zX.js} +5 -25
- package/dist/ensure-collection-policies-2c2NV8zX.js.map +1 -0
- package/dist/ensure-collection-tables-D4wJzM4y.js +820 -0
- package/dist/ensure-collection-tables-D4wJzM4y.js.map +1 -0
- package/dist/ensure-tables-CIojK-Rx.js +650 -0
- package/dist/ensure-tables-CIojK-Rx.js.map +1 -0
- package/dist/generate-drizzle-schema-CwEgVyjO.js +123 -0
- package/dist/generate-drizzle-schema-CwEgVyjO.js.map +1 -0
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js +528 -0
- package/dist/generate-drizzle-schema-logic-so2Z_lyC.js.map +1 -0
- package/dist/generate-postgres-ddl-logic-CtnpIBVv.js +1312 -0
- package/dist/generate-postgres-ddl-logic-CtnpIBVv.js.map +1 -0
- package/dist/generated-schema-staleness-DQdfJl2A.js +157 -0
- package/dist/generated-schema-staleness-DQdfJl2A.js.map +1 -0
- package/dist/index.es.js +849 -10807
- package/dist/index.es.js.map +1 -1
- package/dist/introspect-db-logic-C6LQdTxj.js +1378 -0
- package/dist/introspect-db-logic-C6LQdTxj.js.map +1 -0
- package/dist/policy-drift-kNJ6X2Uj.js +313 -0
- package/dist/policy-drift-kNJ6X2Uj.js.map +1 -0
- package/dist/rls-bootstrap-sql-DLDnUjtx.js +7092 -0
- package/dist/rls-bootstrap-sql-DLDnUjtx.js.map +1 -0
- package/dist/{rls-enforcement-ClJoTboT.js → rls-enforcement-DeYEjYLt.js} +14 -3
- package/dist/{rls-enforcement-ClJoTboT.js.map → rls-enforcement-DeYEjYLt.js.map} +1 -1
- package/dist/schema/bin/generate-drizzle-schema.d.ts +1 -0
- package/dist/schema/bin/generate-postgres-ddl.d.ts +1 -0
- package/dist/schema/doctor-cli.js +129 -0
- package/dist/schema/doctor-cli.js.map +1 -0
- package/dist/schema/generate-drizzle-schema.d.ts +1 -1
- package/dist/schema/generate-drizzle-schema.js +24 -0
- package/dist/schema/generate-drizzle-schema.js.map +1 -0
- package/dist/schema/generate-postgres-ddl.d.ts +1 -1
- package/dist/schema/generate-postgres-ddl.js +138 -0
- package/dist/schema/generate-postgres-ddl.js.map +1 -0
- package/dist/schema/introspect-db.js +866 -0
- package/dist/schema/introspect-db.js.map +1 -0
- package/dist/source-Br7L7GOI.js +410 -0
- package/dist/source-Br7L7GOI.js.map +1 -0
- package/dist/vector-index-DkWxm2de.js +309 -0
- package/dist/vector-index-DkWxm2de.js.map +1 -0
- package/package.json +7 -7
- package/dist/ensure-collection-policies-CagOzEPS.js.map +0 -1
- package/dist/ensure-collection-tables-DbAhrjnG.js +0 -2732
- package/dist/ensure-collection-tables-DbAhrjnG.js.map +0 -1
|
@@ -0,0 +1,974 @@
|
|
|
1
|
+
import { createRequire as __createRequire } from "module";
|
|
2
|
+
__createRequire(import.meta.url);
|
|
3
|
+
import { r as __exportAll } from "./__vite-browser-external-BnuHet1e.js";
|
|
4
|
+
import { i as diagnoseDbError, n as deepestErrorMessage, o as parseHostInfo } from "./cli-errors-DxN4yzdH.js";
|
|
5
|
+
import { t as generateSchema } from "./generate-drizzle-schema-logic-so2Z_lyC.js";
|
|
6
|
+
import { n as outError, t as out } from "./cli-output-CNdMql-L.js";
|
|
7
|
+
import { t as chalk } from "./source-Br7L7GOI.js";
|
|
8
|
+
import { loadCollectionsFromDirectory } from "@rebasepro/server";
|
|
9
|
+
import { computeSchemaVersion, isManyToMany, isPostgresCollectionConfig } from "@rebasepro/types";
|
|
10
|
+
import { fieldKeyForColumn, findRelation, getTableName, isRelationRequired, relationalCollections, resolveCollectionRelations, sortCollectionsBySlug } from "@rebasepro/common";
|
|
11
|
+
import { toSnakeCase } from "@rebasepro/utils";
|
|
12
|
+
import * as fs$1 from "fs";
|
|
13
|
+
import { promises } from "fs";
|
|
14
|
+
//#region ../codegen/dist/index.es.js
|
|
15
|
+
/**
|
|
16
|
+
* Utility functions for the SDK generator
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Convert a slug/snake_case string to PascalCase
|
|
20
|
+
* e.g. "private_notes" → "PrivateNotes"
|
|
21
|
+
*
|
|
22
|
+
* Capitals already inside a word are meaningful and are kept: lowercasing the
|
|
23
|
+
* tail of every chunk turned "TestEntities" into "Testentities", which is what
|
|
24
|
+
* ended up in the generated type names. SHOUTING_CASE is the one shape where
|
|
25
|
+
* the tail is not meaningful, so it is folded down.
|
|
26
|
+
*/
|
|
27
|
+
function toPascalCase(str) {
|
|
28
|
+
return str.split(/[_\-\s]+/).filter(Boolean).map((word) => {
|
|
29
|
+
const rest = /^[A-Z0-9]+$/.test(word) ? word.slice(1).toLowerCase() : word.slice(1);
|
|
30
|
+
return word.charAt(0).toUpperCase() + rest;
|
|
31
|
+
}).join("");
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Convert a slug/snake_case string to camelCase
|
|
35
|
+
* e.g. "private_notes" → "privateNotes"
|
|
36
|
+
*/
|
|
37
|
+
function toCamelCase(str) {
|
|
38
|
+
if (!/[_\-\s]/.test(str)) return str.charAt(0).toLowerCase() + str.slice(1);
|
|
39
|
+
const pascal = toPascalCase(str);
|
|
40
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Convert a slug to a safe JS identifier
|
|
44
|
+
* e.g. "private-notes" → "privateNotes"
|
|
45
|
+
*/
|
|
46
|
+
function toSafeIdentifier(str) {
|
|
47
|
+
return toCamelCase(str.replace(/[^a-zA-Z0-9_]/g, "_"));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A schema that cannot be expressed as a valid TypeScript file.
|
|
51
|
+
*
|
|
52
|
+
* Thrown rather than emitted. The generator used to concatenate whatever it was
|
|
53
|
+
* given, so a slug that collided with another one, or that was not an
|
|
54
|
+
* identifier, produced a file that either failed to compile or — worse —
|
|
55
|
+
* compiled while quietly routing one collection to another's slug.
|
|
56
|
+
*/
|
|
57
|
+
var CodegenError = class extends Error {
|
|
58
|
+
constructor(message) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = "CodegenError";
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
var IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
64
|
+
/**
|
|
65
|
+
* A property name for the emitted TypeScript: verbatim when it is a valid
|
|
66
|
+
* identifier, quoted otherwise.
|
|
67
|
+
*
|
|
68
|
+
* Every key that reaches the output goes through here. Column names are not
|
|
69
|
+
* required to be identifiers — `"order"`, `"user id"`, a quoted Postgres
|
|
70
|
+
* identifier — and the previous behaviour of camel-casing them into shape
|
|
71
|
+
* renamed the column in the type while the wire kept the original, so the
|
|
72
|
+
* generated `Row` described fields that did not exist.
|
|
73
|
+
*/
|
|
74
|
+
function emitKey(key) {
|
|
75
|
+
return IDENTIFIER.test(key) ? key : JSON.stringify(key);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A string literal, escaped.
|
|
79
|
+
*
|
|
80
|
+
* `"${value}"` was the previous form. A value containing a quote closed the
|
|
81
|
+
* literal early, which at best broke the file and at worst let a slug from a
|
|
82
|
+
* remote contract inject top-level statements into a file the developer
|
|
83
|
+
* compiles and bundles.
|
|
84
|
+
*/
|
|
85
|
+
function emitString(value) {
|
|
86
|
+
return JSON.stringify(value);
|
|
87
|
+
}
|
|
88
|
+
/** The `id`s of an enum declared as an array, an array of `{ id }`, or an object map. */
|
|
89
|
+
function enumIds(raw) {
|
|
90
|
+
if (Array.isArray(raw)) return raw.map((entry) => entry && typeof entry === "object" ? entry.id : entry);
|
|
91
|
+
if (raw && typeof raw === "object") return Object.keys(raw);
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
function propertyToTypeScriptType(prop) {
|
|
95
|
+
switch (prop.type) {
|
|
96
|
+
case "string": {
|
|
97
|
+
const sp = prop;
|
|
98
|
+
if (sp.enum) {
|
|
99
|
+
const ids = enumIds(sp.enum);
|
|
100
|
+
if (ids.length === 0) return "string";
|
|
101
|
+
return ids.map((v) => emitString(String(v))).join(" | ");
|
|
102
|
+
}
|
|
103
|
+
return "string";
|
|
104
|
+
}
|
|
105
|
+
case "number": {
|
|
106
|
+
const np = prop;
|
|
107
|
+
if (np.enum) {
|
|
108
|
+
const ids = enumIds(np.enum);
|
|
109
|
+
const numbers = ids.map(Number);
|
|
110
|
+
if (ids.length === 0 || numbers.some((n) => !Number.isFinite(n))) return "number";
|
|
111
|
+
return numbers.map((n) => String(n)).join(" | ");
|
|
112
|
+
}
|
|
113
|
+
return "number";
|
|
114
|
+
}
|
|
115
|
+
case "boolean": return "boolean";
|
|
116
|
+
case "date": return "string";
|
|
117
|
+
case "geopoint": return "{ latitude: number; longitude: number; }";
|
|
118
|
+
case "reference": return "string | number";
|
|
119
|
+
case "relation": return "string | number";
|
|
120
|
+
case "map": {
|
|
121
|
+
const mapProp = prop;
|
|
122
|
+
if (mapProp.properties) return `{ ${Object.entries(mapProp.properties).map(([k, v]) => {
|
|
123
|
+
const child = v;
|
|
124
|
+
const optional = !child.validation?.required;
|
|
125
|
+
const type = propertyToTypeScriptType(child);
|
|
126
|
+
return `${emitKey(k)}${optional ? "?" : ""}: ${optional ? `${type} | null` : type};`;
|
|
127
|
+
}).join(" ")} }`;
|
|
128
|
+
return "Record<string, unknown>";
|
|
129
|
+
}
|
|
130
|
+
case "array": {
|
|
131
|
+
const arrProp = prop;
|
|
132
|
+
if (arrProp.of) return `Array<${propertyToTypeScriptType(arrProp.of)}>`;
|
|
133
|
+
return "Array<unknown>";
|
|
134
|
+
}
|
|
135
|
+
case "vector": return "number[]";
|
|
136
|
+
case "binary": return "string";
|
|
137
|
+
default: return "unknown";
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Unwrap a relation target that arrived as a module namespace rather than the
|
|
142
|
+
* collection itself — `target: () => import("./authors")` is a common slip.
|
|
143
|
+
*/
|
|
144
|
+
function resolveTargetCollection(relation) {
|
|
145
|
+
try {
|
|
146
|
+
let target = relation.target();
|
|
147
|
+
if (target && (target.default || target.__esModule)) target = target.default ?? target;
|
|
148
|
+
return target;
|
|
149
|
+
} catch {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/** The TypeScript type of a foreign key: whatever the target's primary key is. */
|
|
154
|
+
function foreignKeyType(relation) {
|
|
155
|
+
const target = resolveTargetCollection(relation);
|
|
156
|
+
if (!target?.properties) return "string | number";
|
|
157
|
+
const idProp = Object.entries(target.properties).find(([_, p]) => p.isId);
|
|
158
|
+
if (!idProp) return "string | number";
|
|
159
|
+
return idProp[1].type === "number" ? "number" : "string";
|
|
160
|
+
}
|
|
161
|
+
/** Whether a property is the collection's primary key. */
|
|
162
|
+
function isPrimaryKey(prop) {
|
|
163
|
+
return Boolean(prop.isId);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Whether the server assigns this primary key, so a write does not have to.
|
|
167
|
+
* `true` and `"manual"` both mean the caller supplies it.
|
|
168
|
+
*/
|
|
169
|
+
function isAutoAssignedId(prop) {
|
|
170
|
+
const isId = prop.isId;
|
|
171
|
+
return Boolean(isId) && isId !== "manual" && isId !== true;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The type an *included* relation arrives as: the target's own row, inlined.
|
|
175
|
+
*
|
|
176
|
+
* This is what the read pipeline actually serves — `toRestRow` puts the
|
|
177
|
+
* target's flat columns where the relation was, and the SDK and the HTTP API
|
|
178
|
+
* both go through it. It is deliberately *not* a `{ __type: "relation" }`
|
|
179
|
+
* envelope: that shape is the admin's view-model and never reaches a
|
|
180
|
+
* developer's `find()`.
|
|
181
|
+
*
|
|
182
|
+
* Falls back to an open record when the target is not part of this generation
|
|
183
|
+
* run, since there is no `Row` to point at.
|
|
184
|
+
*/
|
|
185
|
+
function includedRelationType(relation, accessors) {
|
|
186
|
+
const slug = resolveTargetCollection(relation)?.slug ?? relation.targetSlug;
|
|
187
|
+
const accessor = slug ? accessors.get(slug) : void 0;
|
|
188
|
+
const rowType = accessor ? `Database[${emitString(accessor)}]["Row"]` : "Record<string, unknown>";
|
|
189
|
+
return relation.cardinality === "many" ? `Array<${rowType}>` : rowType;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Map every slug to the property name it is reachable under on `client.data`.
|
|
193
|
+
*
|
|
194
|
+
* The accessor is a safe identifier because `client.data.myNotes` is the point
|
|
195
|
+
* of generating this at all, and `collectionsDictionary` maps it back to the
|
|
196
|
+
* slug the wire uses. Two slugs that safe down to the same identifier cannot
|
|
197
|
+
* both have it: the interface would not compile, and the dictionary — an object
|
|
198
|
+
* literal — would silently keep only the last, routing one collection's reads
|
|
199
|
+
* to the other's table. There is no defensible way to pick, so this refuses.
|
|
200
|
+
*/
|
|
201
|
+
function buildAccessors(collections) {
|
|
202
|
+
const accessors = /* @__PURE__ */ new Map();
|
|
203
|
+
const bySafeName = /* @__PURE__ */ new Map();
|
|
204
|
+
for (const collection of collections) {
|
|
205
|
+
const slug = collection.slug;
|
|
206
|
+
if (typeof slug !== "string" || slug.length === 0) throw new CodegenError("A collection has no slug, so it has no name to generate a type for. Every collection needs a unique `slug`.");
|
|
207
|
+
const safe = toSafeIdentifier(slug);
|
|
208
|
+
if (safe.length === 0) throw new CodegenError(`The slug ${emitString(slug)} has no characters that can form a property name, so it cannot be reached as \`client.data.<name>\`. Use a slug containing letters, digits, underscores or dashes.`);
|
|
209
|
+
const existing = bySafeName.get(safe);
|
|
210
|
+
if (existing !== void 0) throw new CodegenError(`The collections ${emitString(existing)} and ${emitString(slug)} both generate the accessor "${safe}", so only one of them could be reached from the generated client and the other's reads would silently go to the wrong table. Rename one of the slugs.`);
|
|
211
|
+
bySafeName.set(safe, slug);
|
|
212
|
+
accessors.set(slug, safe);
|
|
213
|
+
}
|
|
214
|
+
return accessors;
|
|
215
|
+
}
|
|
216
|
+
/** One emitted `key: type;` line, already indented. */
|
|
217
|
+
function line(key, type, optional) {
|
|
218
|
+
return ` ${emitKey(key)}${optional ? "?" : ""}: ${type};`;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The keys `excludeFromApi` takes off the API surface — in *both* directions.
|
|
222
|
+
*
|
|
223
|
+
* `excludeFromApi` means one thing: the API surface does not mention this
|
|
224
|
+
* property. `Row` already honoured that; `Insert` and `Update` deliberately did
|
|
225
|
+
* not, on the reading that the column is stripped from responses rather than
|
|
226
|
+
* from writes. That left the generated types as the one place a password hash
|
|
227
|
+
* was still named, and it invited a client to send one. The server still
|
|
228
|
+
* *accepts* such a field on a write — this describes the surface, it does not
|
|
229
|
+
* add an enforcement point — but nothing generated advertises it.
|
|
230
|
+
*
|
|
231
|
+
* Keyed by the property name *and* by its column name, the same pair the
|
|
232
|
+
* server's `stripExcluded` deletes, so a foreign key or a relation addressed
|
|
233
|
+
* under the column name cannot put the property back.
|
|
234
|
+
*/
|
|
235
|
+
function excludedApiKeys(properties) {
|
|
236
|
+
const excluded = /* @__PURE__ */ new Set();
|
|
237
|
+
for (const [key, rawProp] of Object.entries(properties)) {
|
|
238
|
+
const prop = rawProp;
|
|
239
|
+
if (!prop?.excludeFromApi) continue;
|
|
240
|
+
excluded.add(key);
|
|
241
|
+
if (prop.columnName) excluded.add(prop.columnName);
|
|
242
|
+
}
|
|
243
|
+
return excluded;
|
|
244
|
+
}
|
|
245
|
+
function generateTypedefs(input) {
|
|
246
|
+
const collections = sortCollectionsBySlug(input);
|
|
247
|
+
const accessors = buildAccessors(collections);
|
|
248
|
+
const lines = [
|
|
249
|
+
"/**",
|
|
250
|
+
" * This file was auto-generated by Rebase.",
|
|
251
|
+
" * Do not make direct changes to the file.",
|
|
252
|
+
" */",
|
|
253
|
+
"",
|
|
254
|
+
"export interface Database {"
|
|
255
|
+
];
|
|
256
|
+
for (const collection of collections) {
|
|
257
|
+
const properties = collection.properties ?? {};
|
|
258
|
+
let resolvedRelations = {};
|
|
259
|
+
try {
|
|
260
|
+
resolvedRelations = resolveCollectionRelations(collection);
|
|
261
|
+
} catch (e) {
|
|
262
|
+
console.warn(`[rebase] Could not resolve the relations of "${collection.slug}", so its generated type has no relation fields and none of their foreign-key columns. This is usually a circular import in the collection files — make sure the target is \`() => otherCollection\` and not evaluated at module load.\n ${e instanceof Error ? e.message : String(e)}`);
|
|
263
|
+
}
|
|
264
|
+
const subcollections = collection.subcollections;
|
|
265
|
+
if (Array.isArray(subcollections) && subcollections.length > 0) console.warn(`[rebase] "${collection.slug}" declares ${subcollections.length} subcollection(s), which are not part of the generated Database: they are reached over a nested path (\`data/${collection.slug}/<id>/<relation>\`), not as a top-level accessor. Register a subcollection as a collection of its own if you want a typed accessor for it.`);
|
|
266
|
+
lines.push(` ${emitKey(accessors.get(collection.slug))}: {`);
|
|
267
|
+
lines.push(" Row: {");
|
|
268
|
+
const emittedKeys = /* @__PURE__ */ new Set();
|
|
269
|
+
const excluded = excludedApiKeys(properties);
|
|
270
|
+
for (const key of excluded) emittedKeys.add(key);
|
|
271
|
+
for (const [key, rawProp] of Object.entries(properties)) {
|
|
272
|
+
const prop = rawProp;
|
|
273
|
+
if (prop.type === "relation") continue;
|
|
274
|
+
if (excluded.has(key)) continue;
|
|
275
|
+
const tsType = propertyToTypeScriptType(prop);
|
|
276
|
+
const isRequired = Boolean(prop.validation?.required) || isPrimaryKey(prop);
|
|
277
|
+
lines.push(line(key, isRequired ? tsType : `${tsType} | null`, !isRequired));
|
|
278
|
+
emittedKeys.add(key);
|
|
279
|
+
}
|
|
280
|
+
for (const [relKey, relation] of Object.entries(resolvedRelations)) if (relation.kind === "belongsTo" && relation.localKey) {
|
|
281
|
+
const fkKey = fieldKeyForColumn(collection, relation.localKey);
|
|
282
|
+
if (emittedKeys.has(fkKey)) continue;
|
|
283
|
+
const fkType = foreignKeyType(relation);
|
|
284
|
+
const shadowedByInclude = relKey === fkKey;
|
|
285
|
+
const tsType = shadowedByInclude ? `${fkType} | ${includedRelationType(relation, accessors)}` : fkType;
|
|
286
|
+
const isRequired = isRelationRequired(collection, relation) && !shadowedByInclude;
|
|
287
|
+
lines.push(line(fkKey, isRequired ? tsType : `${tsType} | null`, !isRequired));
|
|
288
|
+
emittedKeys.add(fkKey);
|
|
289
|
+
}
|
|
290
|
+
for (const [key, relation] of Object.entries(resolvedRelations)) {
|
|
291
|
+
if (emittedKeys.has(key)) continue;
|
|
292
|
+
lines.push(line(key, includedRelationType(relation, accessors), true));
|
|
293
|
+
emittedKeys.add(key);
|
|
294
|
+
}
|
|
295
|
+
for (const [key, rawProp] of Object.entries(properties)) {
|
|
296
|
+
if (rawProp.type !== "relation") continue;
|
|
297
|
+
if (emittedKeys.has(key)) continue;
|
|
298
|
+
lines.push(line(key, "Record<string, unknown>", true));
|
|
299
|
+
emittedKeys.add(key);
|
|
300
|
+
}
|
|
301
|
+
lines.push(" };");
|
|
302
|
+
lines.push(" Insert: {");
|
|
303
|
+
emittedKeys.clear();
|
|
304
|
+
for (const key of excluded) emittedKeys.add(key);
|
|
305
|
+
for (const [key, rawProp] of Object.entries(properties)) {
|
|
306
|
+
const prop = rawProp;
|
|
307
|
+
if (prop.type === "relation") continue;
|
|
308
|
+
if (excluded.has(key)) continue;
|
|
309
|
+
const tsType = propertyToTypeScriptType(prop);
|
|
310
|
+
const isOptional = !prop.validation?.required || isAutoAssignedId(prop);
|
|
311
|
+
lines.push(line(key, tsType, isOptional));
|
|
312
|
+
emittedKeys.add(key);
|
|
313
|
+
}
|
|
314
|
+
emitWritableRelations(lines, collection, properties, resolvedRelations, emittedKeys, false);
|
|
315
|
+
lines.push(" };");
|
|
316
|
+
lines.push(" Update: {");
|
|
317
|
+
emittedKeys.clear();
|
|
318
|
+
for (const key of excluded) emittedKeys.add(key);
|
|
319
|
+
for (const [key, rawProp] of Object.entries(properties)) {
|
|
320
|
+
const prop = rawProp;
|
|
321
|
+
if (prop.type === "relation") continue;
|
|
322
|
+
if (isPrimaryKey(prop)) continue;
|
|
323
|
+
if (excluded.has(key)) continue;
|
|
324
|
+
lines.push(line(key, propertyToTypeScriptType(prop), true));
|
|
325
|
+
emittedKeys.add(key);
|
|
326
|
+
}
|
|
327
|
+
emitWritableRelations(lines, collection, properties, resolvedRelations, emittedKeys, true);
|
|
328
|
+
lines.push(" };");
|
|
329
|
+
lines.push(" };");
|
|
330
|
+
}
|
|
331
|
+
lines.push("}");
|
|
332
|
+
lines.push("");
|
|
333
|
+
lines.push("export type CollectionName = keyof Database;");
|
|
334
|
+
lines.push("");
|
|
335
|
+
lines.push("export const collectionsDictionary = {");
|
|
336
|
+
for (const collection of collections) lines.push(` ${emitKey(accessors.get(collection.slug))}: ${emitString(collection.slug)},`);
|
|
337
|
+
lines.push("} as const;");
|
|
338
|
+
lines.push("");
|
|
339
|
+
lines.push("export type CollectionsDictionary = typeof collectionsDictionary;");
|
|
340
|
+
lines.push("");
|
|
341
|
+
return lines.join("\n");
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* The two ways a write can name a `belongsTo` target, both of which the server
|
|
345
|
+
* accepts: the foreign key under its own wire name (`{ authorId: 5 }`, which
|
|
346
|
+
* passes through to the `author_id` column untouched) and the relation
|
|
347
|
+
* *property* (`{ author: 5 }`, which the write transformer maps onto that
|
|
348
|
+
* column).
|
|
349
|
+
*
|
|
350
|
+
* Only the first was generated, so the documented and idiomatic write shape was
|
|
351
|
+
* a type error.
|
|
352
|
+
*
|
|
353
|
+
* The second form is emitted under the **property key**, not the resolved
|
|
354
|
+
* relation name, because that is what the transformer keys off: it looks the
|
|
355
|
+
* payload key up in `properties` and only treats it as a relation if what it
|
|
356
|
+
* finds there is one. A relation whose `relationName` differs from its property
|
|
357
|
+
* key is reachable as the property and not as the name, so emitting the name
|
|
358
|
+
* would have offered a key that writes to a column that does not exist.
|
|
359
|
+
*/
|
|
360
|
+
function emitWritableRelations(lines, collection, properties, resolvedRelations, emittedKeys, allOptional) {
|
|
361
|
+
const emit = (key, relation) => {
|
|
362
|
+
if (emittedKeys.has(key)) return;
|
|
363
|
+
const optional = allOptional || !isRelationRequired(collection, relation);
|
|
364
|
+
lines.push(line(key, foreignKeyType(relation), optional));
|
|
365
|
+
emittedKeys.add(key);
|
|
366
|
+
};
|
|
367
|
+
for (const relation of Object.values(resolvedRelations)) if (relation.kind === "belongsTo" && relation.localKey) emit(fieldKeyForColumn(collection, relation.localKey), relation);
|
|
368
|
+
for (const [key, rawProp] of Object.entries(properties)) {
|
|
369
|
+
if (rawProp.type !== "relation") continue;
|
|
370
|
+
const relation = findRelation(resolvedRelations, key);
|
|
371
|
+
if (relation?.kind === "belongsTo" && relation.localKey) emit(key, relation);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/schema/doctor.ts
|
|
376
|
+
/**
|
|
377
|
+
* Rebase Schema Doctor — Three-way schema drift detection.
|
|
378
|
+
*
|
|
379
|
+
* Compares:
|
|
380
|
+
* 1. Collection definitions → Generated Drizzle schema (staleness check)
|
|
381
|
+
* 2. Collection definitions → Live PostgreSQL database (structural drift)
|
|
382
|
+
*
|
|
383
|
+
* Run via: rebase doctor
|
|
384
|
+
*/
|
|
385
|
+
var doctor_exports = /* @__PURE__ */ __exportAll({
|
|
386
|
+
checkCollectionsVsDatabase: () => checkCollectionsVsDatabase,
|
|
387
|
+
checkCollectionsVsSchema: () => checkCollectionsVsSchema,
|
|
388
|
+
checkCollectionsVsSdk: () => checkCollectionsVsSdk,
|
|
389
|
+
getExpectedColumnType: () => getExpectedColumnType,
|
|
390
|
+
loadCollections: () => loadCollections,
|
|
391
|
+
missingExtensionIssues: () => missingExtensionIssues,
|
|
392
|
+
renderReport: () => renderReport,
|
|
393
|
+
runDoctor: () => runDoctor,
|
|
394
|
+
schemaStampIssues: () => schemaStampIssues
|
|
395
|
+
});
|
|
396
|
+
/**
|
|
397
|
+
* Resolve the SQL column name for a property.
|
|
398
|
+
* Uses the explicit `columnName` when set (e.g. from introspection),
|
|
399
|
+
* falling back to `toSnakeCase(propName)` for manually-authored collections.
|
|
400
|
+
*/
|
|
401
|
+
var resolveColumnName = (propName, prop) => {
|
|
402
|
+
if (prop && "columnName" in prop && typeof prop.columnName === "string") return prop.columnName;
|
|
403
|
+
return toSnakeCase(propName);
|
|
404
|
+
};
|
|
405
|
+
function getExpectedColumnType(prop) {
|
|
406
|
+
switch (prop.type) {
|
|
407
|
+
case "string": {
|
|
408
|
+
const sp = prop;
|
|
409
|
+
if (sp.enum) return "USER-DEFINED";
|
|
410
|
+
if ("isId" in sp && sp.isId === "uuid") return "uuid";
|
|
411
|
+
if (sp.columnType === "uuid") return "uuid";
|
|
412
|
+
if (sp.columnType === "char") return "character";
|
|
413
|
+
if (sp.columnType === "varchar") return "character varying";
|
|
414
|
+
return "text";
|
|
415
|
+
}
|
|
416
|
+
case "number": {
|
|
417
|
+
const np = prop;
|
|
418
|
+
if (np.columnType) return {
|
|
419
|
+
serial: "integer",
|
|
420
|
+
bigserial: "bigint",
|
|
421
|
+
smallserial: "smallint"
|
|
422
|
+
}[np.columnType] ?? np.columnType;
|
|
423
|
+
if (np.validation?.integer || "isId" in np && np.isId) return "integer";
|
|
424
|
+
return "numeric";
|
|
425
|
+
}
|
|
426
|
+
case "boolean": return "boolean";
|
|
427
|
+
case "date": {
|
|
428
|
+
const dp = prop;
|
|
429
|
+
if (dp.columnType === "date") return "date";
|
|
430
|
+
if (dp.columnType === "time") return "time without time zone";
|
|
431
|
+
return "timestamp with time zone";
|
|
432
|
+
}
|
|
433
|
+
case "array": {
|
|
434
|
+
const ap = prop;
|
|
435
|
+
let colType = ap.columnType;
|
|
436
|
+
if (!colType && ap.of && !Array.isArray(ap.of)) {
|
|
437
|
+
const ofProp = ap.of;
|
|
438
|
+
if (ofProp.type === "string") colType = "text[]";
|
|
439
|
+
else if (ofProp.type === "number") colType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
|
|
440
|
+
else if (ofProp.type === "boolean") colType = "boolean[]";
|
|
441
|
+
}
|
|
442
|
+
if (colType === "json") return "json";
|
|
443
|
+
if (colType === "jsonb") return "jsonb";
|
|
444
|
+
if (colType && colType.endsWith("[]")) return "ARRAY";
|
|
445
|
+
return "jsonb";
|
|
446
|
+
}
|
|
447
|
+
case "map":
|
|
448
|
+
if (prop.columnType === "json") return "json";
|
|
449
|
+
return "jsonb";
|
|
450
|
+
case "relation": return null;
|
|
451
|
+
case "reference": return "text";
|
|
452
|
+
case "vector": return "USER-DEFINED";
|
|
453
|
+
case "binary": return "bytea";
|
|
454
|
+
default: return null;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Re-exported so callers keep importing it from here, but the implementation is
|
|
459
|
+
* now shared with the runtime and the generators — a doctor that disagreed with
|
|
460
|
+
* the policy generator about which files are collections would compare the
|
|
461
|
+
* wrong thing.
|
|
462
|
+
*/
|
|
463
|
+
async function loadCollections(collectionsPath) {
|
|
464
|
+
return loadCollectionsFromDirectory(collectionsPath);
|
|
465
|
+
}
|
|
466
|
+
async function checkCollectionsVsSchema(collections, schemaFilePath) {
|
|
467
|
+
const issues = [];
|
|
468
|
+
if (!fs$1.existsSync(schemaFilePath)) {
|
|
469
|
+
issues.push({
|
|
470
|
+
severity: "error",
|
|
471
|
+
category: "schema_stale",
|
|
472
|
+
message: "Generated schema file does not exist.",
|
|
473
|
+
fix: "Run `rebase schema generate`"
|
|
474
|
+
});
|
|
475
|
+
return {
|
|
476
|
+
passed: false,
|
|
477
|
+
issues
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
const postgresCollections = relationalCollections(collections);
|
|
481
|
+
if (postgresCollections.length === 0) return {
|
|
482
|
+
passed: true,
|
|
483
|
+
issues
|
|
484
|
+
};
|
|
485
|
+
try {
|
|
486
|
+
const expectedSchema = await generateSchema(postgresCollections);
|
|
487
|
+
const actualSchema = await promises.readFile(schemaFilePath, "utf-8");
|
|
488
|
+
const normalize = (s) => s.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\s+/g, " ").trim();
|
|
489
|
+
if (normalize(expectedSchema) !== normalize(actualSchema)) issues.push({
|
|
490
|
+
severity: "warning",
|
|
491
|
+
category: "schema_stale",
|
|
492
|
+
message: "Generated schema is out of date — collection definitions have changed since last generation.",
|
|
493
|
+
fix: "Run `rebase schema generate`"
|
|
494
|
+
});
|
|
495
|
+
} catch (err) {
|
|
496
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
497
|
+
issues.push({
|
|
498
|
+
severity: "warning",
|
|
499
|
+
category: "schema_stale",
|
|
500
|
+
message: `Could not regenerate schema for comparison: ${message}`,
|
|
501
|
+
fix: "Run `rebase schema generate` to verify"
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
passed: issues.length === 0,
|
|
506
|
+
issues
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
async function checkCollectionsVsSdk(collections, sdkFilePath) {
|
|
510
|
+
const issues = [];
|
|
511
|
+
if (!fs$1.existsSync(sdkFilePath)) {
|
|
512
|
+
issues.push({
|
|
513
|
+
severity: "info",
|
|
514
|
+
category: "sdk_not_generated",
|
|
515
|
+
message: "Typed SDK not generated (optional).",
|
|
516
|
+
fix: "Run `rebase generate-sdk` if you want typed collection access"
|
|
517
|
+
});
|
|
518
|
+
return {
|
|
519
|
+
passed: true,
|
|
520
|
+
issues,
|
|
521
|
+
notApplicable: "not generated (optional)"
|
|
522
|
+
};
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
const expectedSdk = generateTypedefs(collections);
|
|
526
|
+
const actualSdk = await promises.readFile(sdkFilePath, "utf-8");
|
|
527
|
+
const normalize = (s) => s.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "").replace(/\s+/g, " ").trim();
|
|
528
|
+
if (normalize(expectedSdk) !== normalize(actualSdk)) issues.push({
|
|
529
|
+
severity: "warning",
|
|
530
|
+
category: "sdk_stale",
|
|
531
|
+
message: "Generated SDK types are out of date — collection definitions have changed since last SDK generation.",
|
|
532
|
+
fix: "Run `rebase generate-sdk`"
|
|
533
|
+
});
|
|
534
|
+
} catch (err) {
|
|
535
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
536
|
+
if (err instanceof Error && err.name === "CodegenError") issues.push({
|
|
537
|
+
severity: "error",
|
|
538
|
+
category: "sdk_ungeneratable",
|
|
539
|
+
message: `The typed SDK cannot be generated from these collections: ${message}`,
|
|
540
|
+
fix: "Fix the collection definitions named above, then run `rebase generate-sdk`"
|
|
541
|
+
});
|
|
542
|
+
else issues.push({
|
|
543
|
+
severity: "warning",
|
|
544
|
+
category: "sdk_stale",
|
|
545
|
+
message: `Could not regenerate SDK types for comparison: ${message}`,
|
|
546
|
+
fix: "Run `rebase generate-sdk` to verify"
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
passed: issues.length === 0,
|
|
551
|
+
issues
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
/**
|
|
555
|
+
* Extensions the collections need, that the database does not have installed.
|
|
556
|
+
*
|
|
557
|
+
* Only `vector` today, and only because a `{ type: "vector" }` property plans a
|
|
558
|
+
* column of a type that will not exist. Rebase installs it only where a project
|
|
559
|
+
* has said it may (`database({ extensions: ["vector"] })`), so the absence is
|
|
560
|
+
* often correct configuration and a wrong deployment — which is exactly why the
|
|
561
|
+
* report has to name it rather than the first failing INSERT.
|
|
562
|
+
*
|
|
563
|
+
* Exported for its test.
|
|
564
|
+
*/
|
|
565
|
+
function missingExtensionIssues(collections, installed) {
|
|
566
|
+
if (!collections.some((collection) => Object.values(collection.properties ?? {}).some((property) => property?.type === "vector")) || installed.has("vector")) return [];
|
|
567
|
+
return [{
|
|
568
|
+
severity: "error",
|
|
569
|
+
category: "missing_table",
|
|
570
|
+
message: "A collection declares a `{ type: \"vector\" }` property and the pgvector extension is not installed on this database.",
|
|
571
|
+
fix: "Declare it — `database({ extensions: [\"vector\"] })` in config/resources.ts — and use an image that ships the library (the scaffold's pgvector/pgvector:pg18; a stock postgres:18 does not). Or install it once by hand: CREATE EXTENSION vector;"
|
|
572
|
+
}];
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* The stamp the runtime wrote, against the collections on disk.
|
|
576
|
+
*
|
|
577
|
+
* A stamp that disagrees means this database was provisioned from a different
|
|
578
|
+
* set of collections than the ones in this checkout — a colleague's branch, an
|
|
579
|
+
* older deploy, a tenant somebody else migrated. Everything else in this report
|
|
580
|
+
* is then describing a schema somebody else created, which is worth knowing
|
|
581
|
+
* before acting on any of it.
|
|
582
|
+
*
|
|
583
|
+
* A warning, not an error, and never a direction: the stamp is a hash, so it
|
|
584
|
+
* can say the two disagree and never which is ahead. `null` — never stamped —
|
|
585
|
+
* is not a finding: every database provisioned before the stamp existed reads
|
|
586
|
+
* that way, and so does every fresh one.
|
|
587
|
+
*/
|
|
588
|
+
async function schemaStampIssues(pool, collections) {
|
|
589
|
+
let stamped = null;
|
|
590
|
+
try {
|
|
591
|
+
if (!(await pool.query(`SELECT to_regclass('"rebase"."schema_meta"') IS NOT NULL AS present`)).rows[0]?.present) return [];
|
|
592
|
+
stamped = (await pool.query(`SELECT value FROM "rebase"."schema_meta" WHERE key = 'collections_schema_version' LIMIT 1`)).rows[0]?.value ?? null;
|
|
593
|
+
} catch {
|
|
594
|
+
return [];
|
|
595
|
+
}
|
|
596
|
+
if (!stamped) return [];
|
|
597
|
+
const expected = computeSchemaVersion(collections);
|
|
598
|
+
if (stamped === expected) return [];
|
|
599
|
+
return [{
|
|
600
|
+
severity: "warning",
|
|
601
|
+
category: "schema_stale",
|
|
602
|
+
message: `This database was provisioned from a different set of collections (stamp ${stamped.slice(0, 12)}…, this checkout computes ${expected.slice(0, 12)}…).`,
|
|
603
|
+
fix: "A hash says the two disagree, never which is ahead. Check you are pointed at the database you think you are; then `rebase db push` to bring it to these collections."
|
|
604
|
+
}];
|
|
605
|
+
}
|
|
606
|
+
async function checkCollectionsVsDatabase(collections, databaseUrl) {
|
|
607
|
+
const issues = [];
|
|
608
|
+
const pgModule = await import("pg");
|
|
609
|
+
const { Pool } = pgModule.default ?? pgModule;
|
|
610
|
+
const pool = new Pool({ connectionString: databaseUrl });
|
|
611
|
+
const schemas = Array.from(/* @__PURE__ */ new Set([
|
|
612
|
+
"public",
|
|
613
|
+
"rebase",
|
|
614
|
+
...relationalCollections(collections).filter(isPostgresCollectionConfig).map((c) => c.schema).filter((s) => !!s)
|
|
615
|
+
]));
|
|
616
|
+
try {
|
|
617
|
+
const tablesResult = await pool.query(`SELECT table_schema, table_name
|
|
618
|
+
FROM information_schema.tables
|
|
619
|
+
WHERE table_schema = ANY($1) AND table_type = 'BASE TABLE'`, [schemas]);
|
|
620
|
+
const existingTables = new Set(tablesResult.rows.map((r) => r.table_schema === "public" ? r.table_name : `${r.table_schema}.${r.table_name}`));
|
|
621
|
+
const extensionsResult = await pool.query("SELECT extname FROM pg_extension");
|
|
622
|
+
const installedExtensions = new Set(extensionsResult.rows.map((r) => r.extname));
|
|
623
|
+
issues.push(...missingExtensionIssues(collections, installedExtensions));
|
|
624
|
+
issues.push(...await schemaStampIssues(pool, collections));
|
|
625
|
+
const columnsResult = await pool.query(`SELECT table_schema, table_name, column_name, data_type, is_nullable, udt_name
|
|
626
|
+
FROM information_schema.columns
|
|
627
|
+
WHERE table_schema = ANY($1)
|
|
628
|
+
ORDER BY table_schema, table_name, ordinal_position`, [schemas]);
|
|
629
|
+
const columnsByTable = /* @__PURE__ */ new Map();
|
|
630
|
+
for (const row of columnsResult.rows) {
|
|
631
|
+
const tableSchema = row.table_schema;
|
|
632
|
+
const tableName = row.table_name;
|
|
633
|
+
const key = tableSchema === "public" ? tableName : `${tableSchema}.${tableName}`;
|
|
634
|
+
if (!columnsByTable.has(key)) columnsByTable.set(key, []);
|
|
635
|
+
columnsByTable.get(key).push(row);
|
|
636
|
+
}
|
|
637
|
+
const enumsResult = await pool.query(`SELECT t.typname as enum_name, e.enumlabel as enum_value
|
|
638
|
+
FROM pg_type t
|
|
639
|
+
JOIN pg_enum e ON t.oid = e.enumtypid
|
|
640
|
+
ORDER BY t.typname, e.enumsortorder`);
|
|
641
|
+
const enumsByName = /* @__PURE__ */ new Map();
|
|
642
|
+
for (const row of enumsResult.rows) {
|
|
643
|
+
if (!enumsByName.has(row.enum_name)) enumsByName.set(row.enum_name, []);
|
|
644
|
+
enumsByName.get(row.enum_name).push(row.enum_value);
|
|
645
|
+
}
|
|
646
|
+
const fksResult = await pool.query(`SELECT
|
|
647
|
+
tc.constraint_name,
|
|
648
|
+
tc.table_schema,
|
|
649
|
+
tc.table_name,
|
|
650
|
+
kcu.column_name,
|
|
651
|
+
ccu.table_schema AS foreign_table_schema,
|
|
652
|
+
ccu.table_name AS foreign_table_name,
|
|
653
|
+
ccu.column_name AS foreign_column_name
|
|
654
|
+
FROM information_schema.table_constraints AS tc
|
|
655
|
+
JOIN information_schema.key_column_usage AS kcu
|
|
656
|
+
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
|
|
657
|
+
JOIN information_schema.constraint_column_usage AS ccu
|
|
658
|
+
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
|
|
659
|
+
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = ANY($1)`, [schemas]);
|
|
660
|
+
const fksByTable = /* @__PURE__ */ new Map();
|
|
661
|
+
for (const row of fksResult.rows) {
|
|
662
|
+
const tableSchema = row.table_schema;
|
|
663
|
+
const tableName = row.table_name;
|
|
664
|
+
const key = tableSchema === "public" ? tableName : `${tableSchema}.${tableName}`;
|
|
665
|
+
if (!fksByTable.has(key)) fksByTable.set(key, []);
|
|
666
|
+
fksByTable.get(key).push(row);
|
|
667
|
+
}
|
|
668
|
+
const postgresCollections = relationalCollections(collections);
|
|
669
|
+
for (const collection of postgresCollections) {
|
|
670
|
+
const tableName = getTableName(collection);
|
|
671
|
+
const schemaName = collection.schema || "public";
|
|
672
|
+
const fullTableName = schemaName === "public" ? tableName : `${schemaName}.${tableName}`;
|
|
673
|
+
if (!existingTables.has(fullTableName)) {
|
|
674
|
+
issues.push({
|
|
675
|
+
severity: "error",
|
|
676
|
+
category: "missing_table",
|
|
677
|
+
table: fullTableName,
|
|
678
|
+
message: `Table "${fullTableName}" does not exist in the database.`,
|
|
679
|
+
fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
|
|
680
|
+
});
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
const dbColumns = columnsByTable.get(fullTableName) ?? [];
|
|
684
|
+
const dbColumnMap = new Map(dbColumns.map((c) => [c.column_name, c]));
|
|
685
|
+
const systemColumns = /* @__PURE__ */ new Set([
|
|
686
|
+
"id",
|
|
687
|
+
"created_on",
|
|
688
|
+
"updated_on"
|
|
689
|
+
]);
|
|
690
|
+
for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
|
|
691
|
+
if (prop.type === "relation") {
|
|
692
|
+
const relation = findRelation(resolveCollectionRelations(collection), prop.relation?.relationName ?? propName);
|
|
693
|
+
if (relation?.kind === "belongsTo") {
|
|
694
|
+
const fkColName = relation.localKey;
|
|
695
|
+
if (!dbColumnMap.has(fkColName)) issues.push({
|
|
696
|
+
severity: "error",
|
|
697
|
+
category: "missing_column",
|
|
698
|
+
table: fullTableName,
|
|
699
|
+
column: fkColName,
|
|
700
|
+
message: `Foreign key column "${fkColName}" for relation "${propName}" is missing from table "${fullTableName}".`,
|
|
701
|
+
fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
|
|
702
|
+
});
|
|
703
|
+
const tableFks = fksByTable.get(fullTableName) ?? [];
|
|
704
|
+
let targetTableName = "unknown";
|
|
705
|
+
let targetSchemaName = "public";
|
|
706
|
+
try {
|
|
707
|
+
const targetColl = relation.target();
|
|
708
|
+
targetTableName = getTableName(targetColl);
|
|
709
|
+
targetSchemaName = targetColl.schema || "public";
|
|
710
|
+
} catch {}
|
|
711
|
+
const hasFk = tableFks.some((fk) => fk.column_name === fkColName && fk.foreign_table_name === targetTableName && fk.foreign_table_schema === targetSchemaName);
|
|
712
|
+
if (dbColumnMap.has(fkColName) && !hasFk) issues.push({
|
|
713
|
+
severity: "warning",
|
|
714
|
+
category: "missing_foreign_key",
|
|
715
|
+
table: fullTableName,
|
|
716
|
+
column: fkColName,
|
|
717
|
+
message: `Column "${fkColName}" exists but has no FOREIGN KEY constraint referencing "${targetSchemaName === "public" ? targetTableName : `${targetSchemaName}.${targetTableName}`}".`,
|
|
718
|
+
fix: "Run `rebase db push` or add the constraint manually"
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
const colName = resolveColumnName(propName, prop);
|
|
724
|
+
if (systemColumns.has(colName)) continue;
|
|
725
|
+
const dbCol = dbColumnMap.get(colName);
|
|
726
|
+
if (!dbCol) {
|
|
727
|
+
issues.push({
|
|
728
|
+
severity: "error",
|
|
729
|
+
category: "missing_column",
|
|
730
|
+
table: fullTableName,
|
|
731
|
+
column: colName,
|
|
732
|
+
message: `Column "${colName}" is defined in collection "${collection.slug}" but missing from table "${fullTableName}".`,
|
|
733
|
+
fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
|
|
734
|
+
});
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
const expectedType = getExpectedColumnType(prop);
|
|
738
|
+
if (expectedType) {
|
|
739
|
+
const actualType = dbCol.data_type;
|
|
740
|
+
let isMismatch = actualType !== expectedType;
|
|
741
|
+
if (prop.type === "vector" && dbCol.udt_name !== "vector") isMismatch = true;
|
|
742
|
+
if (prop.type === "array") {
|
|
743
|
+
const ap = prop;
|
|
744
|
+
let expectedColType = ap.columnType;
|
|
745
|
+
if (!expectedColType && ap.of && !Array.isArray(ap.of)) {
|
|
746
|
+
const ofProp = ap.of;
|
|
747
|
+
if (ofProp.type === "string") expectedColType = "text[]";
|
|
748
|
+
else if (ofProp.type === "number") expectedColType = ofProp.validation?.integer ? "integer[]" : "numeric[]";
|
|
749
|
+
else if (ofProp.type === "boolean") expectedColType = "boolean[]";
|
|
750
|
+
}
|
|
751
|
+
if (expectedColType && expectedColType.endsWith("[]")) if (actualType !== "ARRAY") isMismatch = true;
|
|
752
|
+
else {
|
|
753
|
+
const expectedUdt = expectedColType === "text[]" ? "_text" : expectedColType === "integer[]" ? "_int4" : expectedColType === "boolean[]" ? "_bool" : expectedColType === "numeric[]" ? "_numeric" : "";
|
|
754
|
+
if (expectedUdt && dbCol.udt_name !== expectedUdt) isMismatch = true;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
if (isMismatch) issues.push({
|
|
758
|
+
severity: "warning",
|
|
759
|
+
category: "type_mismatch",
|
|
760
|
+
table: fullTableName,
|
|
761
|
+
column: colName,
|
|
762
|
+
expected: prop.type === "vector" ? "vector" : expectedType,
|
|
763
|
+
actual: dbCol.udt_name === "vector" ? "vector" : actualType,
|
|
764
|
+
message: `Column "${colName}" in table "${fullTableName}": expected type "${prop.type === "vector" ? "vector" : expectedType}" but found "${dbCol.udt_name === "vector" ? "vector" : actualType}".`,
|
|
765
|
+
fix: "Review collection property type or run a migration"
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
if (prop.type === "string" && prop.enum) {
|
|
769
|
+
const enumValues = prop.enum;
|
|
770
|
+
if (enumValues) {
|
|
771
|
+
const enumName = `${tableName}_${colName}`;
|
|
772
|
+
const dbEnumValues = enumsByName.get(enumName);
|
|
773
|
+
if (!dbEnumValues) issues.push({
|
|
774
|
+
severity: "warning",
|
|
775
|
+
category: "missing_enum",
|
|
776
|
+
table: fullTableName,
|
|
777
|
+
column: colName,
|
|
778
|
+
expected: enumName,
|
|
779
|
+
message: `Enum type "${enumName}" is defined in collection but not found in the database.`,
|
|
780
|
+
fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
|
|
781
|
+
});
|
|
782
|
+
else {
|
|
783
|
+
const expectedValues = Array.isArray(enumValues) ? enumValues.map((v) => typeof v === "string" ? v : String(v.id)) : Object.keys(enumValues);
|
|
784
|
+
const missing = expectedValues.filter((v) => !dbEnumValues.includes(v));
|
|
785
|
+
const extra = dbEnumValues.filter((v) => !expectedValues.includes(v));
|
|
786
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
787
|
+
const parts = [];
|
|
788
|
+
if (missing.length > 0) parts.push(`missing: ${missing.join(", ")}`);
|
|
789
|
+
if (extra.length > 0) parts.push(`extra in DB: ${extra.join(", ")}`);
|
|
790
|
+
issues.push({
|
|
791
|
+
severity: "warning",
|
|
792
|
+
category: "enum_value_mismatch",
|
|
793
|
+
table: fullTableName,
|
|
794
|
+
column: colName,
|
|
795
|
+
expected: expectedValues.join(", "),
|
|
796
|
+
actual: dbEnumValues.join(", "),
|
|
797
|
+
message: `Enum values for "${colName}" in table "${fullTableName}" are out of sync (${parts.join("; ")}).`,
|
|
798
|
+
fix: "Run `rebase db push` to update the enum"
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const resolvedRelations = resolveCollectionRelations(collection);
|
|
806
|
+
for (const relation of Object.values(resolvedRelations)) if (isManyToMany(relation)) {
|
|
807
|
+
const junctionTable = relation.through.table;
|
|
808
|
+
const junctionSchema = collection.schema || "public";
|
|
809
|
+
const fullJunctionTable = junctionSchema === "public" ? junctionTable : `${junctionSchema}.${junctionTable}`;
|
|
810
|
+
if (!existingTables.has(fullJunctionTable)) issues.push({
|
|
811
|
+
severity: "error",
|
|
812
|
+
category: "missing_table",
|
|
813
|
+
table: fullJunctionTable,
|
|
814
|
+
message: `Junction table "${fullJunctionTable}" for many-to-many relation "${relation.relationName}" is missing.`,
|
|
815
|
+
fix: "Run `rebase db push` or `rebase db generate && rebase db migrate`"
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
} finally {
|
|
820
|
+
await pool.end();
|
|
821
|
+
}
|
|
822
|
+
return {
|
|
823
|
+
passed: issues.length === 0,
|
|
824
|
+
issues
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
function renderReport(report) {
|
|
828
|
+
out();
|
|
829
|
+
out(chalk.bold(" 🩺 Rebase Schema Doctor"));
|
|
830
|
+
out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
|
|
831
|
+
out();
|
|
832
|
+
renderPhase("Collections → Generated Schema", report.collectionsToSchema);
|
|
833
|
+
renderPhase("Collections → Database", report.schemaToDatabase);
|
|
834
|
+
renderPhase("Collections → SDK Types", report.collectionsToSdk);
|
|
835
|
+
out(chalk.gray(" ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"));
|
|
836
|
+
const { passed, skipped, notApplicable, warnings, errors } = report.summary;
|
|
837
|
+
const parts = [];
|
|
838
|
+
parts.push(chalk.green(`${passed} passed`));
|
|
839
|
+
if (skipped > 0) parts.push(chalk.yellow(`${skipped} skipped`));
|
|
840
|
+
if (notApplicable > 0) parts.push(chalk.gray(`${notApplicable} not applicable`));
|
|
841
|
+
if (warnings > 0) parts.push(chalk.yellow(`${warnings} warnings`));
|
|
842
|
+
if (errors > 0) parts.push(chalk.red(`${errors} errors`));
|
|
843
|
+
out(` Summary: ${parts.join(", ")}`);
|
|
844
|
+
out();
|
|
845
|
+
if (errors > 0) out(chalk.red.bold(" ✗ Schema drift detected. Run the suggested fixes above."));
|
|
846
|
+
else if (warnings > 0) out(chalk.yellow.bold(" ⚠ Minor issues detected. Consider running the suggested fixes."));
|
|
847
|
+
else if (skipped > 0) out(chalk.yellow.bold(` ⚠ Nothing wrong in the checks that ran, but ${skipped} did not run — this is not a clean bill of health.`));
|
|
848
|
+
else out(chalk.green.bold(" ✓ All schemas are in sync!"));
|
|
849
|
+
out();
|
|
850
|
+
}
|
|
851
|
+
function renderPhase(label, phase) {
|
|
852
|
+
if (phase.skipped) {
|
|
853
|
+
out(` ${chalk.yellow("⏭")} ${label}: ${chalk.yellow(`skipped (${phase.skipped})`)}`);
|
|
854
|
+
out();
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
if (phase.notApplicable) {
|
|
858
|
+
out(` ${chalk.gray("➖")} ${label}: ${chalk.gray(phase.notApplicable)}`);
|
|
859
|
+
for (const issue of phase.issues.filter((i) => i.severity === "info")) out(` ${chalk.gray(issue.fix)}`);
|
|
860
|
+
out();
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
const issues = phase.issues;
|
|
864
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
865
|
+
const warnCount = issues.filter((i) => i.severity === "warning").length;
|
|
866
|
+
const infoIssues = issues.filter((i) => i.severity === "info");
|
|
867
|
+
if (errorCount === 0 && warnCount === 0) out(` ${chalk.green("✅")} ${label}: ${chalk.green("In sync")}`);
|
|
868
|
+
else {
|
|
869
|
+
const parts = [];
|
|
870
|
+
if (errorCount > 0) parts.push(`${errorCount} error${errorCount > 1 ? "s" : ""}`);
|
|
871
|
+
if (warnCount > 0) parts.push(`${warnCount} warning${warnCount > 1 ? "s" : ""}`);
|
|
872
|
+
out(` ${chalk.yellow("⚠️")} ${label}: ${chalk.yellow(parts.join(", "))}`);
|
|
873
|
+
}
|
|
874
|
+
out();
|
|
875
|
+
for (const issue of infoIssues) {
|
|
876
|
+
const fixPart = issue.fix ? chalk.gray(` — ${issue.fix}`) : "";
|
|
877
|
+
out(` ${chalk.gray("ℹ")} ${chalk.gray(issue.message)}${fixPart}`);
|
|
878
|
+
out();
|
|
879
|
+
}
|
|
880
|
+
for (const issue of issues.filter((i) => i.severity !== "info")) {
|
|
881
|
+
const severityIcon = issue.severity === "error" ? chalk.red("✗") : chalk.yellow("⚠");
|
|
882
|
+
const categoryLabel = formatCategory(issue.category);
|
|
883
|
+
out(` ${chalk.gray("┌─")} ${severityIcon} ${chalk.bold(categoryLabel)} ${chalk.gray("─".repeat(Math.max(0, 42 - categoryLabel.length)))}`);
|
|
884
|
+
if (issue.table) {
|
|
885
|
+
const colPart = issue.column ? ` │ Column: ${chalk.cyan(issue.column)}` : "";
|
|
886
|
+
out(` ${chalk.gray("│")} Table: ${chalk.cyan(issue.table)}${colPart}`);
|
|
887
|
+
}
|
|
888
|
+
if (issue.expected && issue.actual) out(` ${chalk.gray("│")} Expected: ${chalk.green(issue.expected)} │ Actual: ${chalk.red(issue.actual)}`);
|
|
889
|
+
out(` ${chalk.gray("│")} ${issue.message}`);
|
|
890
|
+
out(` ${chalk.gray("│")} Fix: ${chalk.blue(issue.fix)}`);
|
|
891
|
+
out(` ${chalk.gray("└" + "─".repeat(48))}`);
|
|
892
|
+
out();
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
function formatCategory(cat) {
|
|
896
|
+
return {
|
|
897
|
+
missing_table: "Missing Table",
|
|
898
|
+
missing_column: "Missing Column",
|
|
899
|
+
type_mismatch: "Type Mismatch",
|
|
900
|
+
missing_constraint: "Missing Constraint",
|
|
901
|
+
schema_stale: "Stale Schema",
|
|
902
|
+
missing_enum: "Missing Enum",
|
|
903
|
+
enum_value_mismatch: "Enum Value Mismatch",
|
|
904
|
+
missing_foreign_key: "Missing Foreign Key",
|
|
905
|
+
sdk_stale: "Stale SDK Types",
|
|
906
|
+
sdk_not_generated: "SDK Types Not Generated",
|
|
907
|
+
sdk_ungeneratable: "SDK Cannot Be Generated"
|
|
908
|
+
}[cat];
|
|
909
|
+
}
|
|
910
|
+
async function runDoctor(options) {
|
|
911
|
+
out();
|
|
912
|
+
out(chalk.bold(" 🩺 Loading collections..."));
|
|
913
|
+
const collections = await loadCollections(options.collectionsPath);
|
|
914
|
+
if (collections.length === 0) {
|
|
915
|
+
outError(chalk.red(" ✗ No collections found."));
|
|
916
|
+
process.exit(1);
|
|
917
|
+
}
|
|
918
|
+
out(chalk.gray(` Found ${collections.length} collection(s)`));
|
|
919
|
+
out();
|
|
920
|
+
out(chalk.gray(" Checking Collections → Generated Schema..."));
|
|
921
|
+
const collectionsToSchema = await checkCollectionsVsSchema(collections, options.schemaPath);
|
|
922
|
+
let schemaToDatabase;
|
|
923
|
+
if (options.databaseUrl) {
|
|
924
|
+
out(chalk.gray(" Checking Collections → Database..."));
|
|
925
|
+
try {
|
|
926
|
+
schemaToDatabase = await checkCollectionsVsDatabase(collections, options.databaseUrl);
|
|
927
|
+
} catch (err) {
|
|
928
|
+
const banner = diagnoseDbError(err, options.databaseUrl);
|
|
929
|
+
if (banner) outError(banner);
|
|
930
|
+
const reason = deepestErrorMessage(err) ?? (err instanceof Error ? err.message : String(err));
|
|
931
|
+
schemaToDatabase = {
|
|
932
|
+
passed: false,
|
|
933
|
+
issues: [],
|
|
934
|
+
skipped: `could not reach ${parseHostInfo(options.databaseUrl)} — ${reason}`,
|
|
935
|
+
blocked: true
|
|
936
|
+
};
|
|
937
|
+
}
|
|
938
|
+
} else {
|
|
939
|
+
schemaToDatabase = {
|
|
940
|
+
passed: false,
|
|
941
|
+
issues: [],
|
|
942
|
+
skipped: "DATABASE_URL not set"
|
|
943
|
+
};
|
|
944
|
+
out(chalk.yellow(" ⚠ DATABASE_URL not set — skipping database comparison."));
|
|
945
|
+
out(chalk.gray(" Set DATABASE_URL in your .env to enable full drift detection."));
|
|
946
|
+
}
|
|
947
|
+
out(chalk.gray(" Checking Collections → SDK Types..."));
|
|
948
|
+
const collectionsToSdk = await checkCollectionsVsSdk(collections, options.sdkPath);
|
|
949
|
+
const phases = [
|
|
950
|
+
collectionsToSchema,
|
|
951
|
+
schemaToDatabase,
|
|
952
|
+
collectionsToSdk
|
|
953
|
+
];
|
|
954
|
+
const allIssues = phases.flatMap((p) => p.issues);
|
|
955
|
+
const summary = {
|
|
956
|
+
passed: phases.filter((p) => p.passed && !p.skipped && !p.notApplicable).length,
|
|
957
|
+
skipped: phases.filter((p) => p.skipped).length,
|
|
958
|
+
notApplicable: phases.filter((p) => p.notApplicable && !p.skipped).length,
|
|
959
|
+
warnings: allIssues.filter((i) => i.severity === "warning").length,
|
|
960
|
+
errors: allIssues.filter((i) => i.severity === "error").length
|
|
961
|
+
};
|
|
962
|
+
const report = {
|
|
963
|
+
collectionsToSchema,
|
|
964
|
+
collectionsToSdk,
|
|
965
|
+
schemaToDatabase,
|
|
966
|
+
summary
|
|
967
|
+
};
|
|
968
|
+
renderReport(report);
|
|
969
|
+
return report;
|
|
970
|
+
}
|
|
971
|
+
//#endregion
|
|
972
|
+
export { loadCollections as n, runDoctor as r, doctor_exports as t };
|
|
973
|
+
|
|
974
|
+
//# sourceMappingURL=doctor-D7HWmGzs.js.map
|