@rebasepro/server-postgres 0.10.1-canary.7609565 → 0.10.1-canary.7801eed
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/auth/schema-version.d.ts +106 -0
- package/dist/chunk-DSJWtz9O.js +40 -0
- package/dist/collections/validate-relations.d.ts +53 -0
- package/dist/data-transformer.d.ts +3 -3
- package/dist/ensure-collection-tables-DGMYK0fr.js +304 -0
- package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
- package/dist/index.es.js +1093 -4941
- package/dist/index.es.js.map +1 -1
- package/dist/schema/ensure-collection-tables.d.ts +79 -0
- package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
- package/dist/services/FetchService.d.ts +21 -8
- package/dist/services/PersistService.d.ts +12 -0
- package/dist/services/RelationService.d.ts +39 -8
- package/dist/services/cdc/junction-tables.d.ts +38 -0
- package/dist/services/nested-path.d.ts +59 -0
- package/dist/services/realtimeService.d.ts +19 -0
- package/dist/services/row-pipeline.d.ts +2 -2
- package/dist/src-3VmUJ8Xn.js +3994 -0
- package/dist/src-3VmUJ8Xn.js.map +1 -0
- package/dist/src-D5xBTl32.js +346 -0
- package/dist/src-D5xBTl32.js.map +1 -0
- package/dist/utils/drizzle-conditions.d.ts +71 -18
- package/package.json +8 -9
- package/src/PostgresBootstrapper.ts +55 -2
- package/src/auth/ensure-tables.ts +23 -0
- package/src/auth/schema-version.ts +260 -0
- package/src/cli-errors.ts +1 -1
- package/src/cli-helpers.ts +4 -3
- package/src/collections/PostgresCollectionRegistry.ts +9 -4
- package/src/collections/buildRegistry.ts +7 -0
- package/src/collections/validate-relations.ts +280 -0
- package/src/data-transformer.ts +28 -38
- package/src/schema/doctor.ts +14 -14
- package/src/schema/ensure-collection-tables.test.ts +156 -0
- package/src/schema/ensure-collection-tables.ts +297 -0
- package/src/schema/generate-drizzle-schema-logic.ts +62 -110
- package/src/schema/generate-postgres-ddl-logic.ts +31 -24
- package/src/schema/introspect-db-inference.ts +13 -13
- package/src/schema/introspect-db-logic.ts +25 -29
- package/src/services/FetchService.ts +116 -126
- package/src/services/PersistService.ts +126 -88
- package/src/services/RelationService.ts +157 -86
- package/src/services/cdc/junction-tables.ts +91 -0
- package/src/services/nested-path.ts +145 -0
- package/src/services/realtimeService.ts +60 -0
- package/src/services/row-pipeline.ts +5 -6
- package/src/utils/drizzle-conditions.ts +268 -330
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { getTableColumns } from "drizzle-orm";
|
|
2
|
+
import { PgTable } from "drizzle-orm/pg-core";
|
|
3
|
+
import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
|
|
4
|
+
import { getTableName, resolveCollectionRelations } from "@rebasepro/common";
|
|
5
|
+
|
|
6
|
+
import { PostgresCollectionRegistry } from "./PostgresCollectionRegistry";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Check every relation against the schema it actually runs on, at boot.
|
|
10
|
+
*
|
|
11
|
+
* The tagged union made the *shape* of a relation impossible to get wrong: a
|
|
12
|
+
* `manyToMany` cannot carry a `foreignKeyOnTarget`, a to-many cannot carry a
|
|
13
|
+
* `localKey`. What it cannot know is whether any of the names are real —
|
|
14
|
+
* whether `posts_tags` is a table, whether `author_id` is a column, whether a
|
|
15
|
+
* `joinPath` connects the tables it claims to. Those are facts about the
|
|
16
|
+
* database, and the type system never sees them.
|
|
17
|
+
*
|
|
18
|
+
* Until now nothing checked them until a query ran, and the failures were the
|
|
19
|
+
* quiet kind. A missing junction table logged a warning and returned no rows,
|
|
20
|
+
* so `posts/1/tags` answered `[]` — indistinguishable from a post with no tags.
|
|
21
|
+
* The relation looked configured, the admin drew the tab, the tab was empty,
|
|
22
|
+
* and nothing anywhere said why.
|
|
23
|
+
*
|
|
24
|
+
* The junction default is the sharp edge this exists for. `through.table`
|
|
25
|
+
* defaults to the two table names sorted and joined, so renaming a table
|
|
26
|
+
* silently re-points the relation at a name that was never created. It is the
|
|
27
|
+
* one default whose output changes when you edit something that looks
|
|
28
|
+
* unrelated.
|
|
29
|
+
*/
|
|
30
|
+
export interface RelationDefect {
|
|
31
|
+
/** Slug of the collection declaring the relation. */
|
|
32
|
+
collection: string;
|
|
33
|
+
relationName: string;
|
|
34
|
+
kind: ResolvedRelation["kind"];
|
|
35
|
+
/** What is wrong, in terms of the schema. */
|
|
36
|
+
problem: string;
|
|
37
|
+
/** The edit that fixes it. */
|
|
38
|
+
fix: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Every name a column answers to: the key in the drizzle schema and the real
|
|
43
|
+
* column name in Postgres. A relation may legitimately be written with either,
|
|
44
|
+
* and reporting a working relation as broken is worse than not checking.
|
|
45
|
+
*/
|
|
46
|
+
function columnNames(table: PgTable): Set<string> {
|
|
47
|
+
const names = new Set<string>();
|
|
48
|
+
for (const [key, col] of Object.entries(getTableColumns(table))) {
|
|
49
|
+
names.add(key);
|
|
50
|
+
const dbName = (col as { name?: string })?.name;
|
|
51
|
+
if (dbName) names.add(dbName);
|
|
52
|
+
}
|
|
53
|
+
return names;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const quote = (xs: Iterable<string>) => Array.from(xs).map(s => `\`${s}\``).join(", ");
|
|
57
|
+
|
|
58
|
+
/** `on.from` / `on.to` accept a single column or a composite tuple. */
|
|
59
|
+
const asColumns = (value: string | string[]): string[] => Array.isArray(value) ? value : [value];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Relations whose names do not resolve against the registered schema.
|
|
63
|
+
*
|
|
64
|
+
* Fails open wherever it cannot see enough to be sure — an unregistered source
|
|
65
|
+
* table, a target belonging to another backend — because a false alarm here
|
|
66
|
+
* costs more than a missed one: it would block boot on a working app.
|
|
67
|
+
*/
|
|
68
|
+
export function findRelationDefects(
|
|
69
|
+
collections: CollectionConfig[],
|
|
70
|
+
registry: PostgresCollectionRegistry
|
|
71
|
+
): RelationDefect[] {
|
|
72
|
+
const defects: RelationDefect[] = [];
|
|
73
|
+
const registeredSlugs = new Set(registry.getCollections().map(c => c.slug));
|
|
74
|
+
|
|
75
|
+
for (const collection of collections) {
|
|
76
|
+
const sourceTableName = getTableName(collection);
|
|
77
|
+
const sourceTable = registry.getTable(sourceTableName);
|
|
78
|
+
// Nothing to check against. Another boot warning already covers this.
|
|
79
|
+
if (!sourceTable) continue;
|
|
80
|
+
|
|
81
|
+
const sourceColumns = columnNames(sourceTable);
|
|
82
|
+
const relations = resolveCollectionRelations(collection);
|
|
83
|
+
|
|
84
|
+
for (const relation of Object.values(relations)) {
|
|
85
|
+
const at = { collection: collection.slug,
|
|
86
|
+
relationName: relation.relationName,
|
|
87
|
+
kind: relation.kind };
|
|
88
|
+
|
|
89
|
+
let targetCollection: CollectionConfig;
|
|
90
|
+
try {
|
|
91
|
+
targetCollection = relation.target();
|
|
92
|
+
} catch (e) {
|
|
93
|
+
defects.push({
|
|
94
|
+
...at,
|
|
95
|
+
problem: `its \`target()\` threw: ${e instanceof Error ? e.message : String(e)}`,
|
|
96
|
+
fix: "a target thunk usually throws because of a circular import — make sure it is `() => otherCollection` and not evaluated at module load"
|
|
97
|
+
});
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A target this registry has never heard of belongs to another
|
|
102
|
+
// backend; its tables are not ours to check.
|
|
103
|
+
if (!registeredSlugs.has(targetCollection.slug)) continue;
|
|
104
|
+
|
|
105
|
+
const targetTableName = getTableName(targetCollection);
|
|
106
|
+
const targetTable = registry.getTable(targetTableName);
|
|
107
|
+
if (!targetTable) {
|
|
108
|
+
defects.push({
|
|
109
|
+
...at,
|
|
110
|
+
problem: `it points at collection \`${targetCollection.slug}\`, which has no table \`${targetTableName}\` in the schema`,
|
|
111
|
+
fix: `create the \`${targetTableName}\` table, or correct \`table\` on the \`${targetCollection.slug}\` collection`
|
|
112
|
+
});
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const targetColumns = columnNames(targetTable);
|
|
116
|
+
|
|
117
|
+
switch (relation.kind) {
|
|
118
|
+
case "belongsTo": {
|
|
119
|
+
if (!sourceColumns.has(relation.localKey)) {
|
|
120
|
+
defects.push({
|
|
121
|
+
...at,
|
|
122
|
+
problem: `\`localKey: "${relation.localKey}"\` is not a column on \`${sourceTableName}\``,
|
|
123
|
+
fix: `add the column, or set \`localKey\` to one of: ${quote(sourceColumns)}`
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
break;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
case "hasOne":
|
|
130
|
+
case "hasMany": {
|
|
131
|
+
if (!targetColumns.has(relation.foreignKeyOnTarget)) {
|
|
132
|
+
defects.push({
|
|
133
|
+
...at,
|
|
134
|
+
problem: `\`foreignKeyOnTarget: "${relation.foreignKeyOnTarget}"\` is not a column on the target table \`${targetTableName}\``,
|
|
135
|
+
fix: `add the column, or set \`foreignKeyOnTarget\` to one of: ${quote(targetColumns)}`
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
case "manyToMany": {
|
|
142
|
+
const { table, sourceColumn, targetColumn } = relation.through;
|
|
143
|
+
const junction = registry.getTable(table);
|
|
144
|
+
if (!junction) {
|
|
145
|
+
defects.push({
|
|
146
|
+
...at,
|
|
147
|
+
problem: `its junction table \`${table}\` does not exist`,
|
|
148
|
+
fix: `create \`${table}\`, or name the real one with \`through: { table: "..." }\`. ` +
|
|
149
|
+
"Note that an omitted `through.table` is derived from the two table names sorted " +
|
|
150
|
+
"and joined, so renaming a table changes it"
|
|
151
|
+
});
|
|
152
|
+
break;
|
|
153
|
+
}
|
|
154
|
+
const junctionColumns = columnNames(junction);
|
|
155
|
+
for (const [label, column] of [["sourceColumn", sourceColumn], ["targetColumn", targetColumn]] as const) {
|
|
156
|
+
if (!junctionColumns.has(column)) {
|
|
157
|
+
defects.push({
|
|
158
|
+
...at,
|
|
159
|
+
problem: `\`through.${label}: "${column}"\` is not a column on the junction table \`${table}\``,
|
|
160
|
+
fix: `set \`through.${label}\` to one of: ${quote(junctionColumns)}` +
|
|
161
|
+
(label === "sourceColumn" ? " — it is the column naming *this* collection" : "")
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
case "via": {
|
|
169
|
+
if (relation.joinPath.length === 0) {
|
|
170
|
+
defects.push({
|
|
171
|
+
...at,
|
|
172
|
+
problem: "its `joinPath` is empty, so it joins nothing",
|
|
173
|
+
fix: "add at least one step, ending at the target's table"
|
|
174
|
+
});
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Walk the chain: each step's `from` names columns on the
|
|
179
|
+
// previous table, its `to` names columns on its own.
|
|
180
|
+
let prevName = sourceTableName;
|
|
181
|
+
let prevColumns = sourceColumns;
|
|
182
|
+
let broken = false;
|
|
183
|
+
|
|
184
|
+
for (const [i, step] of relation.joinPath.entries()) {
|
|
185
|
+
const stepTable = registry.getTable(step.table);
|
|
186
|
+
if (!stepTable) {
|
|
187
|
+
defects.push({
|
|
188
|
+
...at,
|
|
189
|
+
problem: `step ${i + 1} of its \`joinPath\` joins \`${step.table}\`, which is not a table in the schema`,
|
|
190
|
+
fix: `correct \`joinPath[${i}].table\``
|
|
191
|
+
});
|
|
192
|
+
broken = true;
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
const stepColumns = columnNames(stepTable);
|
|
196
|
+
|
|
197
|
+
for (const column of asColumns(step.on.from)) {
|
|
198
|
+
if (!prevColumns.has(column)) {
|
|
199
|
+
defects.push({
|
|
200
|
+
...at,
|
|
201
|
+
problem: `step ${i + 1} joins \`${prevName}.${column}\` → \`${step.table}\`, but \`${column}\` is not a column on \`${prevName}\``,
|
|
202
|
+
fix: `\`joinPath[${i}].on.from\` names columns on ${i === 0 ? "this collection's table" : `the previous step's table (\`${prevName}\`)`}: ${quote(prevColumns)}`
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
for (const column of asColumns(step.on.to)) {
|
|
207
|
+
if (!stepColumns.has(column)) {
|
|
208
|
+
defects.push({
|
|
209
|
+
...at,
|
|
210
|
+
problem: `step ${i + 1} joins into \`${step.table}.${column}\`, but \`${column}\` is not a column on \`${step.table}\``,
|
|
211
|
+
fix: `\`joinPath[${i}].on.to\` names columns on \`${step.table}\`: ${quote(stepColumns)}`
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (asColumns(step.on.from).length !== asColumns(step.on.to).length) {
|
|
217
|
+
defects.push({
|
|
218
|
+
...at,
|
|
219
|
+
problem: `step ${i + 1} compares ${asColumns(step.on.from).length} column(s) against ${asColumns(step.on.to).length}`,
|
|
220
|
+
fix: `\`from\` and \`to\` must name the same number of columns in \`joinPath[${i}]\``
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
prevName = step.table;
|
|
225
|
+
prevColumns = stepColumns;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// The chain has to end where the relation says it points,
|
|
229
|
+
// or the rows it returns are not the target's rows.
|
|
230
|
+
if (!broken && prevName !== targetTableName) {
|
|
231
|
+
defects.push({
|
|
232
|
+
...at,
|
|
233
|
+
problem: `its \`joinPath\` ends at \`${prevName}\`, but it targets \`${targetCollection.slug}\` (table \`${targetTableName}\`)`,
|
|
234
|
+
fix: `make the last step join \`${targetTableName}\`, or point \`target\` at the collection backed by \`${prevName}\``
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
default: {
|
|
241
|
+
const exhaustive: never = relation;
|
|
242
|
+
throw new Error(`Unhandled relation kind: ${JSON.stringify(exhaustive)}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return defects;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Fail boot on any relation that cannot resolve, listing all of them at once.
|
|
253
|
+
*
|
|
254
|
+
* Deliberately fatal rather than a warning. Every one of these produces an
|
|
255
|
+
* empty result at query time and nothing else — an empty tab, an empty
|
|
256
|
+
* `include`, a subcollection that looks like it has no rows. A server that
|
|
257
|
+
* refuses to start is recoverable in a minute; a relation that quietly answers
|
|
258
|
+
* "nothing" is the kind of bug found in production, weeks later, by a user
|
|
259
|
+
* asking where their data went.
|
|
260
|
+
*/
|
|
261
|
+
export function assertRelationsResolve(
|
|
262
|
+
collections: CollectionConfig[],
|
|
263
|
+
registry: PostgresCollectionRegistry
|
|
264
|
+
): void {
|
|
265
|
+
const defects = findRelationDefects(collections, registry);
|
|
266
|
+
if (defects.length === 0) return;
|
|
267
|
+
|
|
268
|
+
const lines = defects.map(d =>
|
|
269
|
+
` • ${d.collection}.${d.relationName} (${d.kind})\n` +
|
|
270
|
+
` ${d.problem}\n` +
|
|
271
|
+
` fix: ${d.fix}`
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
throw new Error(
|
|
275
|
+
`${defects.length} relation${defects.length === 1 ? "" : "s"} cannot resolve against the database schema.\n\n` +
|
|
276
|
+
"Each of these would return no rows at query time rather than reporting an error, " +
|
|
277
|
+
"so they are fatal at boot instead.\n\n" +
|
|
278
|
+
lines.join("\n\n") + "\n"
|
|
279
|
+
);
|
|
280
|
+
}
|
package/src/data-transformer.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { eq, SQL } from "drizzle-orm";
|
|
2
2
|
import { AnyPgColumn } from "drizzle-orm/pg-core";
|
|
3
3
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
4
|
-
import { CollectionConfig, Properties, Property,
|
|
4
|
+
import { CollectionConfig, Properties, Property, ResolvedRelation, RelationProperty, Vector, BinaryProperty, hasForeignKeyOnTarget, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, type ResolvedVia } from "@rebasepro/types";
|
|
5
5
|
import { getTableName, resolveCollectionRelations, findRelation, createRelationRef, DEFAULT_ONE_OF_TYPE, DEFAULT_ONE_OF_VALUE } from "@rebasepro/common";
|
|
6
6
|
import { PostgresCollectionRegistry } from "./collections/PostgresCollectionRegistry";
|
|
7
7
|
import { DrizzleConditionBuilder } from "./utils/drizzle-conditions";
|
|
@@ -31,13 +31,13 @@ export interface SerializedEntityData {
|
|
|
31
31
|
*/
|
|
32
32
|
inverseRelationUpdates: Array<{
|
|
33
33
|
relationKey: string;
|
|
34
|
-
relation:
|
|
34
|
+
relation: ResolvedRelation;
|
|
35
35
|
newValue: unknown;
|
|
36
36
|
}>;
|
|
37
37
|
/** JoinPath relation updates that require multi-hop writes. */
|
|
38
38
|
joinPathRelationUpdates: Array<{
|
|
39
39
|
relationKey: string;
|
|
40
|
-
relation:
|
|
40
|
+
relation: ResolvedVia;
|
|
41
41
|
newTargetId: string | number | null;
|
|
42
42
|
}>;
|
|
43
43
|
}
|
|
@@ -110,19 +110,19 @@ joinPathRelationUpdates: [] };
|
|
|
110
110
|
// Track inverse relations that need to be handled separately
|
|
111
111
|
const inverseRelationUpdates: Array<{
|
|
112
112
|
relationKey: string;
|
|
113
|
-
relation:
|
|
113
|
+
relation: ResolvedRelation;
|
|
114
114
|
newValue: unknown;
|
|
115
115
|
}> = [];
|
|
116
116
|
const joinPathRelationUpdates: Array<{
|
|
117
117
|
relationKey: string;
|
|
118
|
-
relation:
|
|
118
|
+
relation: ResolvedVia;
|
|
119
119
|
newTargetId: string | number | null;
|
|
120
120
|
}> = [];
|
|
121
121
|
|
|
122
122
|
// Pre-calculate all local keys used as foreign keys
|
|
123
123
|
const foreignKeys = new Set<string>();
|
|
124
124
|
Object.values(resolvedRelations).forEach(relation => {
|
|
125
|
-
if (relation.
|
|
125
|
+
if (relation.kind === "belongsTo") foreignKeys.add(relation.localKey);
|
|
126
126
|
});
|
|
127
127
|
|
|
128
128
|
for (const [key, value] of Object.entries(row)) {
|
|
@@ -141,7 +141,7 @@ joinPathRelationUpdates: [] };
|
|
|
141
141
|
if (property.type === "relation" && collection) {
|
|
142
142
|
const relation = findRelation(resolvedRelations, key);
|
|
143
143
|
if (relation) {
|
|
144
|
-
if (relation.
|
|
144
|
+
if (relation.kind === "belongsTo") {
|
|
145
145
|
// Owning relation: Map relation object to FK column on current table
|
|
146
146
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
147
147
|
if (serializedValue !== undefined) {
|
|
@@ -149,7 +149,7 @@ joinPathRelationUpdates: [] };
|
|
|
149
149
|
}
|
|
150
150
|
// Don't add the original relation property to the result
|
|
151
151
|
continue;
|
|
152
|
-
} else if (relation
|
|
152
|
+
} else if (hasForeignKeyOnTarget(relation)) {
|
|
153
153
|
// Inverse relation: Need to update the target table's FK
|
|
154
154
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
155
155
|
inverseRelationUpdates.push({
|
|
@@ -159,38 +159,29 @@ joinPathRelationUpdates: [] };
|
|
|
159
159
|
});
|
|
160
160
|
// Don't add the original relation property to the result
|
|
161
161
|
continue;
|
|
162
|
-
} else if (relation.
|
|
162
|
+
} else if (relation.kind === "via") {
|
|
163
|
+
// A join chain is written through its own machinery, one
|
|
164
|
+
// row at a time for a to-one and as a set for a to-many.
|
|
165
|
+
// There used to be two arms here — "owning" and "inverse"
|
|
166
|
+
// joinPath — but a join chain has no owning side, so they
|
|
167
|
+
// only ever differed by which list they pushed to.
|
|
163
168
|
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
164
169
|
if (relation.cardinality === "one") {
|
|
165
|
-
//
|
|
166
|
-
//
|
|
167
|
-
//
|
|
168
|
-
// This prevents stale values from corrupting related rows when an
|
|
169
|
-
// intermediate FK (e.g. author_id) changes in the same save.
|
|
170
|
+
// Ordering matters: PersistService applies these BEFORE
|
|
171
|
+
// the main UPDATE, so the mapping reads the pre-update
|
|
172
|
+
// foreign key rather than a value the same save changed.
|
|
170
173
|
joinPathRelationUpdates.push({
|
|
171
174
|
relationKey: key,
|
|
172
175
|
relation,
|
|
173
176
|
newTargetId: serializedValue as string | number | null
|
|
174
177
|
});
|
|
175
178
|
} else {
|
|
176
|
-
// Many inverse joinPath: capture as inverse relation update
|
|
177
179
|
inverseRelationUpdates.push({
|
|
178
180
|
relationKey: key,
|
|
179
181
|
relation,
|
|
180
182
|
newValue: serializedValue
|
|
181
183
|
});
|
|
182
184
|
}
|
|
183
|
-
// Don't add the original relation property to the result
|
|
184
|
-
continue;
|
|
185
|
-
} else if (relation.cardinality === "one" && relation.direction === "owning" && relation.joinPath && relation.joinPath.length > 0) {
|
|
186
|
-
// Owning one-to-one via joinPath: capture as a write intent
|
|
187
|
-
const serializedValue = serializePropertyToServer(effectiveValue, property);
|
|
188
|
-
joinPathRelationUpdates.push({
|
|
189
|
-
relationKey: key,
|
|
190
|
-
relation,
|
|
191
|
-
newTargetId: serializedValue as string | number | null
|
|
192
|
-
});
|
|
193
|
-
// Don't include this property directly in payload
|
|
194
185
|
continue;
|
|
195
186
|
}
|
|
196
187
|
}
|
|
@@ -320,7 +311,7 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
320
311
|
// Find the normalized relation for this property
|
|
321
312
|
const relation = findRelation(resolvedRelations, propKey);
|
|
322
313
|
if (relation) {
|
|
323
|
-
if (relation.
|
|
314
|
+
if (relation.kind === "belongsTo" && relation.localKey in data) {
|
|
324
315
|
// Owning relation: FK is in current table
|
|
325
316
|
const fkValue = data[relation.localKey as keyof M];
|
|
326
317
|
if (fkValue !== null && fkValue !== undefined) {
|
|
@@ -331,7 +322,7 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
331
322
|
logger.warn(`Could not resolve target collection for relation property: ${propKey}`, { error: e });
|
|
332
323
|
}
|
|
333
324
|
}
|
|
334
|
-
} else if (relation
|
|
325
|
+
} else if (hasForeignKeyOnTarget(relation) && db && registry) {
|
|
335
326
|
// Inverse relation: FK is in target table, need to query for it
|
|
336
327
|
try {
|
|
337
328
|
const targetCollection = relation.target();
|
|
@@ -368,7 +359,7 @@ export async function parseDataFromServer<M extends Record<string, unknown>>(
|
|
|
368
359
|
} catch (e) {
|
|
369
360
|
logger.warn(`Could not resolve inverse relation property: ${propKey}`, { error: e });
|
|
370
361
|
}
|
|
371
|
-
} else if (relation.
|
|
362
|
+
} else if (relation.kind === "via" && db && registry) {
|
|
372
363
|
// Join path relation: Multi-hop relation using joins
|
|
373
364
|
try {
|
|
374
365
|
const targetCollection = relation.target();
|
|
@@ -547,17 +538,16 @@ export function parsePropertyFromServer(value: unknown, property: Property, coll
|
|
|
547
538
|
case "relation":
|
|
548
539
|
// Transform ID back to relation object with type information
|
|
549
540
|
if (typeof value === "string" || typeof value === "number") {
|
|
550
|
-
|
|
541
|
+
// Normalization stamps the resolved relation onto the property;
|
|
542
|
+
// fall back to the collection's map for a property that never
|
|
543
|
+
// went through the registry.
|
|
544
|
+
let relationDef: ResolvedRelation | undefined = (property as RelationProperty).resolvedRelation;
|
|
551
545
|
if (!relationDef && propertyKey) {
|
|
552
|
-
|
|
553
|
-
relationDef = findRelation(resolvedRelations, propertyKey);
|
|
554
|
-
}
|
|
555
|
-
if (!relationDef) {
|
|
556
|
-
relationDef = collection.relations?.find((rel: Relation) => rel.relationName === (property as RelationProperty).relationName);
|
|
546
|
+
relationDef = findRelation(resolveCollectionRelations(collection), propertyKey);
|
|
557
547
|
}
|
|
558
548
|
|
|
559
549
|
if (!relationDef) {
|
|
560
|
-
logger.warn(`
|
|
550
|
+
logger.warn(`ResolvedRelation not defined in property for key: ${propertyKey || "unknown"}`);
|
|
561
551
|
return value;
|
|
562
552
|
}
|
|
563
553
|
|
|
@@ -683,7 +673,7 @@ function normalizeScalarValues<M extends Record<string, unknown>>(
|
|
|
683
673
|
data: M,
|
|
684
674
|
properties: Properties,
|
|
685
675
|
collection: CollectionConfig,
|
|
686
|
-
resolvedRelations: Record<string,
|
|
676
|
+
resolvedRelations: Record<string, ResolvedRelation>,
|
|
687
677
|
options: { skipRelations: boolean }
|
|
688
678
|
): Record<string, unknown> {
|
|
689
679
|
const result: Record<string, unknown> = {};
|
|
@@ -691,7 +681,7 @@ function normalizeScalarValues<M extends Record<string, unknown>>(
|
|
|
691
681
|
// Identify FK columns used only for relations and not exposed as properties
|
|
692
682
|
const internalFKColumns = new Set<string>();
|
|
693
683
|
Object.values(resolvedRelations).forEach(relation => {
|
|
694
|
-
if (relation.
|
|
684
|
+
if (relation.kind === "belongsTo" && !properties[relation.localKey]) {
|
|
695
685
|
internalFKColumns.add(relation.localKey);
|
|
696
686
|
}
|
|
697
687
|
});
|
package/src/schema/doctor.ts
CHANGED
|
@@ -12,7 +12,7 @@ import * as fs from "fs";
|
|
|
12
12
|
import path from "path";
|
|
13
13
|
import { pathToFileURL } from "url";
|
|
14
14
|
import chalk from "chalk";
|
|
15
|
-
import { CollectionConfig, isPostgresCollectionConfig, Property, NumberProperty, StringProperty, DateProperty, ArrayProperty, MapProperty, RelationProperty } from "@rebasepro/types";
|
|
15
|
+
import { CollectionConfig, isPostgresCollectionConfig, Property, NumberProperty, StringProperty, DateProperty, ArrayProperty, MapProperty, RelationProperty, type ResolvedManyToMany, type ResolvedBelongsTo, isManyToMany } from "@rebasepro/types";
|
|
16
16
|
import { generateSchema } from "./generate-drizzle-schema-logic";
|
|
17
17
|
import { generateTypedefs } from "@rebasepro/codegen";
|
|
18
18
|
import { getTableName, resolveCollectionRelations, findRelation } from "@rebasepro/common";
|
|
@@ -62,12 +62,10 @@ export function getExpectedColumnType(prop: Property): string | null {
|
|
|
62
62
|
if (sp.enum) return "USER-DEFINED"; // pgEnum → USER-DEFINED in information_schema
|
|
63
63
|
if ("isId" in sp && sp.isId === "uuid") return "uuid";
|
|
64
64
|
if (sp.columnType === "uuid") return "uuid";
|
|
65
|
-
// A markdown/multiline string compiles to `text`, not varchar — the
|
|
66
|
-
// generator treats those UI hints as column-type signals, so the
|
|
67
|
-
// expectation here must too or every such column reads as drift.
|
|
68
|
-
if (sp.columnType === "text" || sp.ui?.markdown || sp.ui?.multiline) return "text";
|
|
69
65
|
if (sp.columnType === "char") return "character";
|
|
70
|
-
return "character varying";
|
|
66
|
+
if (sp.columnType === "varchar") return "character varying";
|
|
67
|
+
// `text` is the default — see generate-postgres-ddl-logic.
|
|
68
|
+
return "text";
|
|
71
69
|
}
|
|
72
70
|
case "number": {
|
|
73
71
|
const np = prop as NumberProperty;
|
|
@@ -122,7 +120,9 @@ export function getExpectedColumnType(prop: Property): string | null {
|
|
|
122
120
|
case "relation":
|
|
123
121
|
return null; // FK columns are derived from the relation, not from the property
|
|
124
122
|
case "reference":
|
|
125
|
-
|
|
123
|
+
// A reference FK follows the key it points at, and a string key is
|
|
124
|
+
// `text` — see generate-postgres-ddl-logic.
|
|
125
|
+
return "text";
|
|
126
126
|
case "vector":
|
|
127
127
|
return "USER-DEFINED";
|
|
128
128
|
case "binary":
|
|
@@ -387,7 +387,7 @@ export async function checkCollectionsVsDatabase(
|
|
|
387
387
|
|
|
388
388
|
for (const collection of postgresCollections) {
|
|
389
389
|
const tableName = getTableName(collection);
|
|
390
|
-
const schemaName = collection.schema || "public";
|
|
390
|
+
const schemaName = (collection as { schema?: string }).schema || "public";
|
|
391
391
|
const fullTableName = schemaName === "public" ? tableName : `${schemaName}.${tableName}`;
|
|
392
392
|
|
|
393
393
|
// Check table existence
|
|
@@ -411,10 +411,10 @@ export async function checkCollectionsVsDatabase(
|
|
|
411
411
|
// Check properties → columns
|
|
412
412
|
for (const [propName, prop] of Object.entries(collection.properties ?? {})) {
|
|
413
413
|
if (prop.type === "relation") {
|
|
414
|
-
//
|
|
414
|
+
// ResolvedRelation columns are derived from localKey
|
|
415
415
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
416
|
-
const relation = findRelation(resolvedRelations, (prop as RelationProperty).relationName ?? propName);
|
|
417
|
-
if (relation?.
|
|
416
|
+
const relation = findRelation(resolvedRelations, (prop as RelationProperty).relation?.relationName ?? propName);
|
|
417
|
+
if (relation?.kind === "belongsTo") {
|
|
418
418
|
const fkColName = relation.localKey;
|
|
419
419
|
if (!dbColumnMap.has(fkColName)) {
|
|
420
420
|
issues.push({
|
|
@@ -434,7 +434,7 @@ export async function checkCollectionsVsDatabase(
|
|
|
434
434
|
try {
|
|
435
435
|
const targetColl = relation.target();
|
|
436
436
|
targetTableName = getTableName(targetColl);
|
|
437
|
-
targetSchemaName = targetColl.schema || "public";
|
|
437
|
+
targetSchemaName = (targetColl as { schema?: string }).schema || "public";
|
|
438
438
|
} catch { /* ignore */ }
|
|
439
439
|
|
|
440
440
|
const hasFk = tableFks.some((fk) =>
|
|
@@ -568,9 +568,9 @@ export async function checkCollectionsVsDatabase(
|
|
|
568
568
|
// Also check junction tables for many-to-many relations
|
|
569
569
|
const resolvedRelations = resolveCollectionRelations(collection);
|
|
570
570
|
for (const relation of Object.values(resolvedRelations)) {
|
|
571
|
-
if (relation
|
|
571
|
+
if (isManyToMany(relation)) {
|
|
572
572
|
const junctionTable = relation.through.table;
|
|
573
|
-
const junctionSchema = collection.schema || "public";
|
|
573
|
+
const junctionSchema = (collection as { schema?: string }).schema || "public";
|
|
574
574
|
const fullJunctionTable = junctionSchema === "public" ? junctionTable : `${junctionSchema}.${junctionTable}`;
|
|
575
575
|
if (!existingTables.has(fullJunctionTable)) {
|
|
576
576
|
issues.push({
|