@rebasepro/server-postgres 0.10.1-canary.d8d45b2 → 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 (45) hide show
  1. package/dist/auth/schema-version.d.ts +106 -0
  2. package/dist/collections/validate-relations.d.ts +53 -0
  3. package/dist/data-transformer.d.ts +3 -3
  4. package/dist/{ensure-collection-tables-CNlIONzj.js → ensure-collection-tables-DGMYK0fr.js} +12 -12
  5. package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
  6. package/dist/index.es.js +913 -391
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/services/FetchService.d.ts +21 -8
  9. package/dist/services/PersistService.d.ts +12 -0
  10. package/dist/services/RelationService.d.ts +39 -8
  11. package/dist/services/cdc/junction-tables.d.ts +38 -0
  12. package/dist/services/nested-path.d.ts +59 -0
  13. package/dist/services/realtimeService.d.ts +19 -0
  14. package/dist/services/row-pipeline.d.ts +2 -2
  15. package/dist/{src-DmsRg8MR.js → src-3VmUJ8Xn.js} +214 -276
  16. package/dist/src-3VmUJ8Xn.js.map +1 -0
  17. package/dist/{src-B0v4IKaI.js → src-D5xBTl32.js} +19 -2
  18. package/dist/src-D5xBTl32.js.map +1 -0
  19. package/dist/utils/drizzle-conditions.d.ts +71 -18
  20. package/package.json +8 -9
  21. package/src/PostgresBootstrapper.ts +15 -2
  22. package/src/auth/ensure-tables.ts +23 -0
  23. package/src/auth/schema-version.ts +260 -0
  24. package/src/cli-errors.ts +1 -1
  25. package/src/cli-helpers.ts +4 -3
  26. package/src/collections/PostgresCollectionRegistry.ts +9 -4
  27. package/src/collections/buildRegistry.ts +7 -0
  28. package/src/collections/validate-relations.ts +280 -0
  29. package/src/data-transformer.ts +28 -38
  30. package/src/schema/doctor.ts +14 -14
  31. package/src/schema/generate-drizzle-schema-logic.ts +62 -110
  32. package/src/schema/generate-postgres-ddl-logic.ts +28 -21
  33. package/src/schema/introspect-db-inference.ts +13 -13
  34. package/src/schema/introspect-db-logic.ts +25 -29
  35. package/src/services/FetchService.ts +116 -126
  36. package/src/services/PersistService.ts +126 -88
  37. package/src/services/RelationService.ts +157 -86
  38. package/src/services/cdc/junction-tables.ts +91 -0
  39. package/src/services/nested-path.ts +145 -0
  40. package/src/services/realtimeService.ts +60 -0
  41. package/src/services/row-pipeline.ts +5 -6
  42. package/src/utils/drizzle-conditions.ts +268 -330
  43. package/dist/ensure-collection-tables-CNlIONzj.js.map +0 -1
  44. package/dist/src-B0v4IKaI.js.map +0 -1
  45. package/dist/src-DmsRg8MR.js.map +0 -1
@@ -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({
@@ -1,4 +1,4 @@
1
- import { CollectionConfig, NumberProperty, Property, Relation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty } from "@rebasepro/types";
1
+ import { CollectionConfig, NumberProperty, Property, ResolvedRelation, RelationProperty, SecurityOperation, SecurityRule, StringProperty, isPostgresCollectionConfig, DateProperty, ArrayProperty, MapProperty, ReferenceProperty, VectorProperty, BinaryProperty, isManyToMany, type ResolvedManyToMany, type ResolvedBelongsTo, type ResolvedForeignKeyOnTarget, hasForeignKeyOnTarget } from "@rebasepro/types";
2
2
  import { getPrimaryKeys } from "../services/collection-helpers";
3
3
  import { getEnumVarName, getTableName, getTableVarName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
4
4
  import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
@@ -97,12 +97,14 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
97
97
  columnDefinition = `uuid("${colName}")`;
98
98
  } else if (stringProp.columnType === "uuid") {
99
99
  columnDefinition = `uuid("${colName}")`;
100
- } else if (stringProp.columnType === "text" || stringProp.ui?.markdown || stringProp.ui?.multiline) {
101
- columnDefinition = `text("${colName}")`;
102
100
  } else if (stringProp.columnType === "char") {
103
101
  columnDefinition = `char("${colName}")`;
104
- } else {
102
+ } else if (stringProp.columnType === "varchar") {
105
103
  columnDefinition = `varchar("${colName}")`;
104
+ } else {
105
+ // `text` is the default, and the only length-unbounded choice.
106
+ // Ask for `varchar` explicitly if you want the length constraint.
107
+ columnDefinition = `text("${colName}")`;
106
108
  }
107
109
  if (isIdProperty(propName, prop, collection)) {
108
110
  columnDefinition += ".primaryKey()";
@@ -222,16 +224,11 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
222
224
  case "relation": {
223
225
  const refProp = prop as RelationProperty;
224
226
  const resolvedRelations = resolveCollectionRelations(collection);
225
- const relation = findRelation(resolvedRelations, refProp.relationName ?? propName);
227
+ const relation = findRelation(resolvedRelations, refProp.relation?.relationName ?? propName);
226
228
 
227
- // Only owning one-to-one/many-to-one relations create a column here.
228
- if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") {
229
- return null;
230
- }
231
-
232
- // The localKey property is the source of truth for the FK column name.
233
- if (!relation.localKey) {
234
- logger.warn(`Could not generate column for owning relation '${relation.relationName}' on '${collection.name}': 'localKey' is not defined.`);
229
+ // Only `belongsTo` puts a column on this table; every other kind
230
+ // is a column on the target, a junction row, or a join chain.
231
+ if (!relation || relation.kind !== "belongsTo") {
235
232
  return null;
236
233
  }
237
234
 
@@ -252,7 +249,7 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
252
249
  const targetTableVar = getTableVarName(getTableName(targetCollection));
253
250
  const pkProp = getPrimaryKeyProp(targetCollection);
254
251
  const targetIdField = pkProp.name;
255
- const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : (pkProp.isUuid ? `uuid("${fkColumnName}")` : `varchar("${fkColumnName}")`);
252
+ const baseColumn = pkProp.type === "number" ? `integer("${fkColumnName}")` : (pkProp.isUuid ? `uuid("${fkColumnName}")` : `text("${fkColumnName}")`);
256
253
 
257
254
  const onUpdate = relation.onUpdate ? `onUpdate: "${relation.onUpdate}"` : "";
258
255
  const required = prop.validation?.required;
@@ -274,14 +271,14 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
274
271
  const refProp = prop as ReferenceProperty;
275
272
  const targetCollection = collections.find(c => c.slug === refProp.path || getTableName(c) === refProp.path);
276
273
  if (!targetCollection) {
277
- columnDefinition = `varchar("${colName}")`;
274
+ columnDefinition = `text("${colName}")`;
278
275
  break;
279
276
  }
280
277
 
281
278
  const pkProp = getPrimaryKeyProp(targetCollection);
282
279
  const targetTableVar = getTableVarName(getTableName(targetCollection));
283
280
  const targetIdField = pkProp.name;
284
- const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : (pkProp.isUuid ? `uuid("${colName}")` : `varchar("${colName}")`);
281
+ const baseColumn = pkProp.type === "number" ? `integer("${colName}")` : (pkProp.isUuid ? `uuid("${colName}")` : `text("${colName}")`);
285
282
 
286
283
  const required = prop.validation?.required;
287
284
  const onDelete = required ? "cascade" : "set null";
@@ -400,26 +397,22 @@ const generateSinglePolicyCode = (collection: CollectionConfig, rule: SecurityRu
400
397
  * Falls back to the local relation name when the counterpart can't be resolved.
401
398
  */
402
399
  const computeSharedRelationName = (
403
- rel: Relation,
400
+ rel: ResolvedRelation,
404
401
  sourceCollection: CollectionConfig,
405
402
  _collections: CollectionConfig[]
406
403
  ): string => {
407
404
  const fallback = rel.relationName ?? toSnakeCase(rel.target().slug);
408
405
 
409
- // --- owning one (belongs-to) ---
410
- if (rel.direction === "owning" && rel.cardinality === "one" && rel.localKey) {
411
- // Normalise the localKey to the actual Drizzle property name so that
412
- // the owning side produces the same relation name as the inverse side
413
- // (which resolves foreignKeyOnTarget via the same helper).
406
+ // Both sides of a link must derive the same name, so each resolves the
407
+ // column to its Drizzle property key and builds the name from the table
408
+ // that actually owns it.
409
+ if (rel.kind === "belongsTo") {
414
410
  const normalisedKey = resolvePropertyKeyForColumn(sourceCollection, rel.localKey);
415
411
  return `${getTableName(sourceCollection)}_${normalisedKey}`;
416
412
  }
417
413
 
418
- // --- inverse many (one-to-many has-many) ---
419
- if (rel.direction === "inverse" && rel.cardinality === "many" && rel.foreignKeyOnTarget) {
420
- // The owning table is the *target*, the FK column is foreignKeyOnTarget.
421
- // Resolve to the Drizzle property key on the target so it matches the
422
- // owning side's normalised localKey.
414
+ if (rel.kind === "hasMany" || rel.kind === "hasOne") {
415
+ // The owning table is the *target*; the column is foreignKeyOnTarget.
423
416
  try {
424
417
  const targetCollection = rel.target();
425
418
  const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
@@ -429,40 +422,8 @@ const computeSharedRelationName = (
429
422
  }
430
423
  }
431
424
 
432
- // --- inverse one (one-to-one inverse) ---
433
- if (rel.direction === "inverse" && rel.cardinality === "one") {
434
- if (rel.foreignKeyOnTarget) {
435
- // FK lives on the target table — resolve to Drizzle property key
436
- try {
437
- const targetCollection = rel.target();
438
- const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
439
- return `${getTableName(targetCollection)}_${normalisedFK}`;
440
- } catch {
441
- return fallback;
442
- }
443
- }
444
- // No explicit foreignKeyOnTarget — try to find the corresponding owning relation
445
- try {
446
- const targetCollection = rel.target();
447
- const targetResolvedRelations = resolveCollectionRelations(targetCollection);
448
- const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
449
- targetRel.direction === "owning" &&
450
- targetRel.cardinality === "one" &&
451
- targetRel.localKey &&
452
- targetRel.target().slug === sourceCollection.slug
453
- );
454
- if (correspondingRelation && correspondingRelation.localKey) {
455
- return `${getTableName(targetCollection)}_${correspondingRelation.localKey}`;
456
- }
457
- } catch {
458
- // ignore
459
- }
460
- return fallback;
461
- }
462
-
463
- // --- M2M owning (through) — keep local name (already shared via junction wiring) ---
464
- // --- M2M inverse — keep local name ---
465
- // --- joinPath — not emitted as Drizzle relations ---
425
+ // manyToMany is named through its junction wiring; `via` is not emitted as
426
+ // a Drizzle relation at all. Both keep the local name.
466
427
  return fallback;
467
428
  };
468
429
 
@@ -519,7 +480,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
519
480
  const allTablesToGenerate = new Map<string, {
520
481
  collection: CollectionConfig,
521
482
  isJunction?: boolean,
522
- relation?: Relation,
483
+ relation?: ResolvedRelation,
523
484
  sourceCollection?: CollectionConfig
524
485
  }>();
525
486
 
@@ -558,7 +519,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
558
519
 
559
520
  const resolvedRelations = resolveCollectionRelations(collection);
560
521
  for (const relation of Object.values(resolvedRelations)) {
561
- if (relation.through) { // Standard M2M junction table
522
+ if (isManyToMany(relation)) { // Standard M2M junction table
562
523
  const junctionTableName = relation.through.table;
563
524
  if (!allTablesToGenerate.has(junctionTableName)) {
564
525
  allTablesToGenerate.set(junctionTableName, {
@@ -584,7 +545,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
584
545
  sourceCollection
585
546
  }] of allTablesToGenerate.entries()) {
586
547
  const tableVarName = getTableVarName(tableName);
587
- if (isJunction && relation && sourceCollection && relation.through) {
548
+ if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
588
549
  const targetCollection = relation.target();
589
550
  const schema = (isPostgresCollectionConfig(targetCollection) ? targetCollection.schema : undefined) || (isPostgresCollectionConfig(sourceCollection) ? sourceCollection.schema : undefined);
590
551
  const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
@@ -597,8 +558,10 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
597
558
  const onDelete = relation.onDelete ?? "cascade";
598
559
  const refOptions = `{ onDelete: \"${onDelete}\" }`;
599
560
 
600
- const sourceColType = isNumericId(sourceCollection) ? "integer" : (getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "varchar");
601
- const targetColType = isNumericId(targetCollection) ? "integer" : (getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "varchar");
561
+ // `text`, matching the string default: a junction column must have the
562
+ // same type as the primary key it references.
563
+ const sourceColType = isNumericId(sourceCollection) ? "integer" : (getPrimaryKeyProp(sourceCollection).isUuid ? "uuid" : "text");
564
+ const targetColType = isNumericId(targetCollection) ? "integer" : (getPrimaryKeyProp(targetCollection).isUuid ? "uuid" : "text");
602
565
  const sourceId = getPrimaryKeyName(sourceCollection);
603
566
  const targetId = getPrimaryKeyName(targetCollection);
604
567
 
@@ -637,7 +600,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
637
600
  // We should generate a basic id column if one was completely omitted.
638
601
  const hasIdColumn = Array.from(columns).some(col => col.includes(".primaryKey()"));
639
602
  if (!hasIdColumn) {
640
- columns.add(" id: varchar(\"id\").primaryKey()");
603
+ columns.add(" id: text(\"id\").primaryKey()");
641
604
  }
642
605
 
643
606
  schemaContent += `${Array.from(columns).join(",\n")}`;
@@ -669,7 +632,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
669
632
 
670
633
  if (isJunction) {
671
634
  const relationInfo = Array.from(allTablesToGenerate.values()).find(v => v.isJunction && getTableName(v.collection) === tableName);
672
- if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && relationInfo.relation.through) {
635
+ if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && isManyToMany(relationInfo.relation)) {
673
636
  const {
674
637
  relation,
675
638
  sourceCollection
@@ -693,9 +656,9 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
693
656
  try {
694
657
  const targetRelations = resolveCollectionRelations(targetCollection);
695
658
  for (const [, targetRel] of Object.entries(targetRelations)) {
696
- if (targetRel.direction === "inverse" &&
659
+ if (targetRel.kind !== "belongsTo" &&
697
660
  targetRel.cardinality === "many" &&
698
- targetRel.inverseRelationName === owningRelationName) {
661
+ targetRel.relationName === owningRelationName) {
699
662
  inverseRelationName = targetRel.relationName ?? null;
700
663
  break;
701
664
  }
@@ -740,54 +703,43 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
740
703
  // Skip if we've already emitted a relation with this drizzleRelationName
741
704
  // for this table — prevents duplicate definitions when
742
705
  // resolveCollectionRelations returns alias entries for the same FK.
743
- const deduplicationKey = `${drizzleRelationName}::${rel.direction}`;
706
+ const deduplicationKey = `${drizzleRelationName}::${rel.kind}`;
744
707
  if (emittedRelationNames.has(deduplicationKey)) continue;
745
708
  emittedRelationNames.add(deduplicationKey);
746
709
 
747
- if (rel.cardinality === "one") {
748
- if (rel.direction === "owning" && rel.localKey) {
710
+ switch (rel.kind) {
711
+ case "belongsTo":
749
712
  tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${rel.localKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
750
- } else if (rel.direction === "inverse") {
751
- // Inverse one-to-one: the FK lives on the TARGET table, not here.
752
- // Drizzle pairs inverse relations via `relationName` alone — specifying
753
- // `fields`/`references` on the inverse side is invalid and causes
754
- // `normalizeRelation` to crash with "Cannot read properties of
713
+ break;
714
+
715
+ case "hasOne":
716
+ // The foreign key lives on the TARGET table. Drizzle pairs
717
+ // the two sides by `relationName` alone — giving
718
+ // `fields`/`references` here is invalid and crashes
719
+ // `normalizeRelation` with "Cannot read properties of
755
720
  // undefined (reading 'referencedTable')".
756
721
  tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n relationName: \"${drizzleRelationName}\"\n })`);
757
- }
758
- } else if (rel.cardinality === "many") {
759
- if (rel.direction === "inverse" && rel.foreignKeyOnTarget) {
760
- // One-to-many inverse relation
722
+ break;
723
+
724
+ case "hasMany":
761
725
  tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
762
- } else if (rel.through) {
763
- // Many-to-many owning relation with explicit junction table
726
+ break;
727
+
728
+ case "manyToMany": {
729
+ // Both sides point at the junction. This used to have a
730
+ // second arm that searched the *target's* relations for an
731
+ // owning many-to-many whose name matched, to borrow its
732
+ // junction table — unnecessary now that each side names
733
+ // its own.
764
734
  const junctionTableVar = getTableVarName(rel.through.table);
765
735
  tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
766
- } else if (rel.direction === "inverse" && rel.inverseRelationName) {
767
- // Many-to-many inverse relation - find the corresponding owning relation's junction table
768
- try {
769
- const targetCollection = rel.target();
770
- const targetResolvedRelations = resolveCollectionRelations(targetCollection);
771
-
772
- // Find the corresponding owning many-to-many relation on the target
773
- const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
774
- targetRel.direction === "owning" &&
775
- targetRel.cardinality === "many" &&
776
- targetRel.through &&
777
- targetRel.relationName === rel.inverseRelationName
778
- );
779
-
780
- if (correspondingRelation && correspondingRelation.through) {
781
- const junctionTableVar = getTableVarName(correspondingRelation.through.table);
782
- tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
783
- } else {
784
- logger.warn(`Could not find corresponding owning many-to-many relation for inverse relation '${relationKey}' on '${collection.name}'`);
785
- }
786
- } catch (e) {
787
- logger.warn(`Could not resolve inverse many-to-many relation '${relationKey}'`, { error: e });
788
- }
736
+ break;
789
737
  }
790
- // joinPath relations don't generate Drizzle relations - they use existing user tables
738
+
739
+ case "via":
740
+ // A join chain is resolved at query time, not modelled
741
+ // as a Drizzle relation.
742
+ break;
791
743
  }
792
744
  } catch (e) {
793
745
  logger.warn(`Could not generate relation ${relationKey} for ${collection.name}`, { error: e });
@@ -800,12 +752,12 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
800
752
 
801
753
  const otherRelations = resolveCollectionRelations(otherCollection);
802
754
  for (const [otherKey, otherRel] of Object.entries(otherRelations)) {
803
- if (otherRel.direction === "inverse" && otherRel.foreignKeyOnTarget) {
755
+ if (hasForeignKeyOnTarget(otherRel)) {
804
756
  try {
805
757
  const otherTarget = otherRel.target();
806
758
  if (otherTarget.slug === collection.slug) {
807
759
  const drizzleRelationName = computeSharedRelationName(otherRel, otherCollection, collections);
808
- const deduplicationKey = `${drizzleRelationName}::owning`;
760
+ const deduplicationKey = `${drizzleRelationName}::belongsTo`;
809
761
 
810
762
  if (!emittedRelationNames.has(deduplicationKey)) {
811
763
  const otherTableVar = getTableVarName(getTableName(otherCollection));