@rebasepro/server-postgres 0.10.1-canary.b1e3dbf → 0.10.1-canary.ed78a2c

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 (47) hide show
  1. package/dist/auth/schema-version.d.ts +106 -0
  2. package/dist/chunk-DSJWtz9O.js +40 -0
  3. package/dist/collections/validate-relations.d.ts +53 -0
  4. package/dist/data-transformer.d.ts +3 -3
  5. package/dist/ensure-collection-tables-DGMYK0fr.js +304 -0
  6. package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
  7. package/dist/index.es.js +1093 -4941
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/schema/ensure-collection-tables.d.ts +79 -0
  10. package/dist/schema/generate-postgres-ddl-logic.d.ts +4 -1
  11. package/dist/services/FetchService.d.ts +21 -8
  12. package/dist/services/PersistService.d.ts +12 -0
  13. package/dist/services/RelationService.d.ts +39 -8
  14. package/dist/services/cdc/junction-tables.d.ts +38 -0
  15. package/dist/services/nested-path.d.ts +59 -0
  16. package/dist/services/realtimeService.d.ts +19 -0
  17. package/dist/services/row-pipeline.d.ts +2 -2
  18. package/dist/src-3VmUJ8Xn.js +3994 -0
  19. package/dist/src-3VmUJ8Xn.js.map +1 -0
  20. package/dist/src-D5xBTl32.js +346 -0
  21. package/dist/src-D5xBTl32.js.map +1 -0
  22. package/dist/utils/drizzle-conditions.d.ts +71 -18
  23. package/package.json +8 -9
  24. package/src/PostgresBootstrapper.ts +55 -2
  25. package/src/auth/ensure-tables.ts +23 -0
  26. package/src/auth/schema-version.ts +260 -0
  27. package/src/cli-errors.ts +1 -1
  28. package/src/cli-helpers.ts +4 -3
  29. package/src/collections/PostgresCollectionRegistry.ts +9 -4
  30. package/src/collections/buildRegistry.ts +7 -0
  31. package/src/collections/validate-relations.ts +280 -0
  32. package/src/data-transformer.ts +28 -38
  33. package/src/schema/doctor.ts +14 -14
  34. package/src/schema/ensure-collection-tables.test.ts +156 -0
  35. package/src/schema/ensure-collection-tables.ts +297 -0
  36. package/src/schema/generate-drizzle-schema-logic.ts +62 -110
  37. package/src/schema/generate-postgres-ddl-logic.ts +31 -24
  38. package/src/schema/introspect-db-inference.ts +13 -13
  39. package/src/schema/introspect-db-logic.ts +25 -29
  40. package/src/services/FetchService.ts +116 -126
  41. package/src/services/PersistService.ts +126 -88
  42. package/src/services/RelationService.ts +157 -86
  43. package/src/services/cdc/junction-tables.ts +91 -0
  44. package/src/services/nested-path.ts +145 -0
  45. package/src/services/realtimeService.ts +60 -0
  46. package/src/services/row-pipeline.ts +5 -6
  47. 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
+ }
@@ -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, Relation, RelationProperty, Vector, BinaryProperty } from "@rebasepro/types";
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: 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: 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: Relation;
113
+ relation: ResolvedRelation;
114
114
  newValue: unknown;
115
115
  }> = [];
116
116
  const joinPathRelationUpdates: Array<{
117
117
  relationKey: string;
118
- relation: 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.localKey) foreignKeys.add(relation.localKey);
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.direction === "owning" && relation.localKey) {
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.direction === "inverse" && relation.foreignKeyOnTarget) {
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.direction === "inverse" && relation.joinPath && relation.joinPath.length > 0) {
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
- // One-to-one inverse joinPath: route through joinPathRelationUpdates.
166
- // The write ordering in PersistService ensures these are processed
167
- // BEFORE the main UPDATE, so parentSourceCol reads the pre-update FK value.
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.direction === "owning" && relation.localKey && relation.localKey in data) {
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.direction === "inverse" && relation.foreignKeyOnTarget && db && registry) {
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.direction === "inverse" && relation.joinPath && db && registry) {
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
- let relationDef: Relation | undefined = (property as RelationProperty).relation;
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
- const resolvedRelations = resolveCollectionRelations(collection);
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(`Relation not defined in property for key: ${propertyKey || "unknown"}`);
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, Relation>,
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.localKey && !properties[relation.localKey]) {
684
+ if (relation.kind === "belongsTo" && !properties[relation.localKey]) {
695
685
  internalFKColumns.add(relation.localKey);
696
686
  }
697
687
  });
@@ -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
- return "character varying"; // References default to varchar FK
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
- // Relation columns are derived from localKey
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?.direction === "owning" && relation.cardinality === "one" && relation.localKey) {
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.cardinality === "many" && relation.direction === "owning" && relation.through) {
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({