@rebasepro/server-postgres 0.10.1-canary.ed8caed → 0.10.1-canary.g4db1bb1

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 (42) 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-BVvtkkRm.js → ensure-collection-tables-DGMYK0fr.js} +5 -5
  5. package/dist/ensure-collection-tables-DGMYK0fr.js.map +1 -0
  6. package/dist/index.es.js +610 -313
  7. package/dist/index.es.js.map +1 -1
  8. package/dist/services/RelationService.d.ts +9 -8
  9. package/dist/services/cdc/junction-tables.d.ts +38 -0
  10. package/dist/services/nested-path.d.ts +3 -3
  11. package/dist/services/realtimeService.d.ts +19 -0
  12. package/dist/services/row-pipeline.d.ts +2 -2
  13. package/dist/{src-lcfUP4xg.js → src-3VmUJ8Xn.js} +135 -247
  14. package/dist/src-3VmUJ8Xn.js.map +1 -0
  15. package/dist/{src-CBgtrPhJ.js → src-D5xBTl32.js} +12 -2
  16. package/dist/src-D5xBTl32.js.map +1 -0
  17. package/dist/utils/drizzle-conditions.d.ts +35 -23
  18. package/package.json +6 -6
  19. package/src/PostgresBootstrapper.ts +15 -2
  20. package/src/auth/ensure-tables.ts +23 -0
  21. package/src/auth/schema-version.ts +260 -0
  22. package/src/cli-errors.ts +1 -1
  23. package/src/cli-helpers.ts +4 -3
  24. package/src/collections/PostgresCollectionRegistry.ts +9 -4
  25. package/src/collections/buildRegistry.ts +7 -0
  26. package/src/collections/validate-relations.ts +280 -0
  27. package/src/data-transformer.ts +28 -38
  28. package/src/schema/doctor.ts +8 -8
  29. package/src/schema/generate-drizzle-schema-logic.ts +49 -101
  30. package/src/schema/generate-postgres-ddl-logic.ts +9 -9
  31. package/src/schema/introspect-db-logic.ts +19 -23
  32. package/src/services/FetchService.ts +12 -12
  33. package/src/services/PersistService.ts +23 -27
  34. package/src/services/RelationService.ts +65 -83
  35. package/src/services/cdc/junction-tables.ts +91 -0
  36. package/src/services/nested-path.ts +24 -9
  37. package/src/services/realtimeService.ts +60 -0
  38. package/src/services/row-pipeline.ts +5 -6
  39. package/src/utils/drizzle-conditions.ts +177 -382
  40. package/dist/ensure-collection-tables-BVvtkkRm.js.map +0 -1
  41. package/dist/src-CBgtrPhJ.js.map +0 -1
  42. package/dist/src-lcfUP4xg.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";
@@ -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";
@@ -224,16 +224,11 @@ const getDrizzleColumn = (propName: string, prop: Property, collection: Collecti
224
224
  case "relation": {
225
225
  const refProp = prop as RelationProperty;
226
226
  const resolvedRelations = resolveCollectionRelations(collection);
227
- const relation = findRelation(resolvedRelations, refProp.relationName ?? propName);
227
+ const relation = findRelation(resolvedRelations, refProp.relation?.relationName ?? propName);
228
228
 
229
- // Only owning one-to-one/many-to-one relations create a column here.
230
- if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") {
231
- return null;
232
- }
233
-
234
- // The localKey property is the source of truth for the FK column name.
235
- if (!relation.localKey) {
236
- 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") {
237
232
  return null;
238
233
  }
239
234
 
@@ -402,26 +397,22 @@ const generateSinglePolicyCode = (collection: CollectionConfig, rule: SecurityRu
402
397
  * Falls back to the local relation name when the counterpart can't be resolved.
403
398
  */
404
399
  const computeSharedRelationName = (
405
- rel: Relation,
400
+ rel: ResolvedRelation,
406
401
  sourceCollection: CollectionConfig,
407
402
  _collections: CollectionConfig[]
408
403
  ): string => {
409
404
  const fallback = rel.relationName ?? toSnakeCase(rel.target().slug);
410
405
 
411
- // --- owning one (belongs-to) ---
412
- if (rel.direction === "owning" && rel.cardinality === "one" && rel.localKey) {
413
- // Normalise the localKey to the actual Drizzle property name so that
414
- // the owning side produces the same relation name as the inverse side
415
- // (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") {
416
410
  const normalisedKey = resolvePropertyKeyForColumn(sourceCollection, rel.localKey);
417
411
  return `${getTableName(sourceCollection)}_${normalisedKey}`;
418
412
  }
419
413
 
420
- // --- inverse many (one-to-many has-many) ---
421
- if (rel.direction === "inverse" && rel.cardinality === "many" && rel.foreignKeyOnTarget) {
422
- // The owning table is the *target*, the FK column is foreignKeyOnTarget.
423
- // Resolve to the Drizzle property key on the target so it matches the
424
- // owning side's normalised localKey.
414
+ if (rel.kind === "hasMany" || rel.kind === "hasOne") {
415
+ // The owning table is the *target*; the column is foreignKeyOnTarget.
425
416
  try {
426
417
  const targetCollection = rel.target();
427
418
  const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
@@ -431,40 +422,8 @@ const computeSharedRelationName = (
431
422
  }
432
423
  }
433
424
 
434
- // --- inverse one (one-to-one inverse) ---
435
- if (rel.direction === "inverse" && rel.cardinality === "one") {
436
- if (rel.foreignKeyOnTarget) {
437
- // FK lives on the target table — resolve to Drizzle property key
438
- try {
439
- const targetCollection = rel.target();
440
- const normalisedFK = resolvePropertyKeyForColumn(targetCollection, rel.foreignKeyOnTarget);
441
- return `${getTableName(targetCollection)}_${normalisedFK}`;
442
- } catch {
443
- return fallback;
444
- }
445
- }
446
- // No explicit foreignKeyOnTarget — try to find the corresponding owning relation
447
- try {
448
- const targetCollection = rel.target();
449
- const targetResolvedRelations = resolveCollectionRelations(targetCollection);
450
- const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
451
- targetRel.direction === "owning" &&
452
- targetRel.cardinality === "one" &&
453
- targetRel.localKey &&
454
- targetRel.target().slug === sourceCollection.slug
455
- );
456
- if (correspondingRelation && correspondingRelation.localKey) {
457
- return `${getTableName(targetCollection)}_${correspondingRelation.localKey}`;
458
- }
459
- } catch {
460
- // ignore
461
- }
462
- return fallback;
463
- }
464
-
465
- // --- M2M owning (through) — keep local name (already shared via junction wiring) ---
466
- // --- M2M inverse — keep local name ---
467
- // --- 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.
468
427
  return fallback;
469
428
  };
470
429
 
@@ -521,7 +480,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
521
480
  const allTablesToGenerate = new Map<string, {
522
481
  collection: CollectionConfig,
523
482
  isJunction?: boolean,
524
- relation?: Relation,
483
+ relation?: ResolvedRelation,
525
484
  sourceCollection?: CollectionConfig
526
485
  }>();
527
486
 
@@ -560,7 +519,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
560
519
 
561
520
  const resolvedRelations = resolveCollectionRelations(collection);
562
521
  for (const relation of Object.values(resolvedRelations)) {
563
- if (relation.through) { // Standard M2M junction table
522
+ if (isManyToMany(relation)) { // Standard M2M junction table
564
523
  const junctionTableName = relation.through.table;
565
524
  if (!allTablesToGenerate.has(junctionTableName)) {
566
525
  allTablesToGenerate.set(junctionTableName, {
@@ -586,7 +545,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
586
545
  sourceCollection
587
546
  }] of allTablesToGenerate.entries()) {
588
547
  const tableVarName = getTableVarName(tableName);
589
- if (isJunction && relation && sourceCollection && relation.through) {
548
+ if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
590
549
  const targetCollection = relation.target();
591
550
  const schema = (isPostgresCollectionConfig(targetCollection) ? targetCollection.schema : undefined) || (isPostgresCollectionConfig(sourceCollection) ? sourceCollection.schema : undefined);
592
551
  const tableCreator = schema ? `${schema}Schema.table` : "pgTable";
@@ -673,7 +632,7 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
673
632
 
674
633
  if (isJunction) {
675
634
  const relationInfo = Array.from(allTablesToGenerate.values()).find(v => v.isJunction && getTableName(v.collection) === tableName);
676
- if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && relationInfo.relation.through) {
635
+ if (relationInfo && relationInfo.relation && relationInfo.sourceCollection && isManyToMany(relationInfo.relation)) {
677
636
  const {
678
637
  relation,
679
638
  sourceCollection
@@ -697,9 +656,9 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
697
656
  try {
698
657
  const targetRelations = resolveCollectionRelations(targetCollection);
699
658
  for (const [, targetRel] of Object.entries(targetRelations)) {
700
- if (targetRel.direction === "inverse" &&
659
+ if (targetRel.kind !== "belongsTo" &&
701
660
  targetRel.cardinality === "many" &&
702
- targetRel.inverseRelationName === owningRelationName) {
661
+ targetRel.relationName === owningRelationName) {
703
662
  inverseRelationName = targetRel.relationName ?? null;
704
663
  break;
705
664
  }
@@ -744,54 +703,43 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
744
703
  // Skip if we've already emitted a relation with this drizzleRelationName
745
704
  // for this table — prevents duplicate definitions when
746
705
  // resolveCollectionRelations returns alias entries for the same FK.
747
- const deduplicationKey = `${drizzleRelationName}::${rel.direction}`;
706
+ const deduplicationKey = `${drizzleRelationName}::${rel.kind}`;
748
707
  if (emittedRelationNames.has(deduplicationKey)) continue;
749
708
  emittedRelationNames.add(deduplicationKey);
750
709
 
751
- if (rel.cardinality === "one") {
752
- if (rel.direction === "owning" && rel.localKey) {
710
+ switch (rel.kind) {
711
+ case "belongsTo":
753
712
  tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n fields: [${tableVarName}.${rel.localKey}],\n references: [${targetTableVar}.${getPrimaryKeyName(target)}],\n relationName: \"${drizzleRelationName}\"\n })`);
754
- } else if (rel.direction === "inverse") {
755
- // Inverse one-to-one: the FK lives on the TARGET table, not here.
756
- // Drizzle pairs inverse relations via `relationName` alone — specifying
757
- // `fields`/`references` on the inverse side is invalid and causes
758
- // `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
759
720
  // undefined (reading 'referencedTable')".
760
721
  tableRelations.push(` "${relationKey}": one(${targetTableVar}, {\n relationName: \"${drizzleRelationName}\"\n })`);
761
- }
762
- } else if (rel.cardinality === "many") {
763
- if (rel.direction === "inverse" && rel.foreignKeyOnTarget) {
764
- // One-to-many inverse relation
722
+ break;
723
+
724
+ case "hasMany":
765
725
  tableRelations.push(` "${relationKey}": many(${targetTableVar}, { relationName: \"${drizzleRelationName}\" })`);
766
- } else if (rel.through) {
767
- // 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.
768
734
  const junctionTableVar = getTableVarName(rel.through.table);
769
735
  tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
770
- } else if (rel.direction === "inverse" && rel.inverseRelationName) {
771
- // Many-to-many inverse relation - find the corresponding owning relation's junction table
772
- try {
773
- const targetCollection = rel.target();
774
- const targetResolvedRelations = resolveCollectionRelations(targetCollection);
775
-
776
- // Find the corresponding owning many-to-many relation on the target
777
- const correspondingRelation = Object.values(targetResolvedRelations).find(targetRel =>
778
- targetRel.direction === "owning" &&
779
- targetRel.cardinality === "many" &&
780
- targetRel.through &&
781
- targetRel.relationName === rel.inverseRelationName
782
- );
783
-
784
- if (correspondingRelation && correspondingRelation.through) {
785
- const junctionTableVar = getTableVarName(correspondingRelation.through.table);
786
- tableRelations.push(` "${relationKey}": many(${junctionTableVar}, { relationName: \"${drizzleRelationName}\" })`);
787
- } else {
788
- logger.warn(`Could not find corresponding owning many-to-many relation for inverse relation '${relationKey}' on '${collection.name}'`);
789
- }
790
- } catch (e) {
791
- logger.warn(`Could not resolve inverse many-to-many relation '${relationKey}'`, { error: e });
792
- }
736
+ break;
793
737
  }
794
- // 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;
795
743
  }
796
744
  } catch (e) {
797
745
  logger.warn(`Could not generate relation ${relationKey} for ${collection.name}`, { error: e });
@@ -804,12 +752,12 @@ export const generateSchema = async (collections: CollectionConfig[], stripPolic
804
752
 
805
753
  const otherRelations = resolveCollectionRelations(otherCollection);
806
754
  for (const [otherKey, otherRel] of Object.entries(otherRelations)) {
807
- if (otherRel.direction === "inverse" && otherRel.foreignKeyOnTarget) {
755
+ if (hasForeignKeyOnTarget(otherRel)) {
808
756
  try {
809
757
  const otherTarget = otherRel.target();
810
758
  if (otherTarget.slug === collection.slug) {
811
759
  const drizzleRelationName = computeSharedRelationName(otherRel, otherCollection, collections);
812
- const deduplicationKey = `${drizzleRelationName}::owning`;
760
+ const deduplicationKey = `${drizzleRelationName}::belongsTo`;
813
761
 
814
762
  if (!emittedRelationNames.has(deduplicationKey)) {
815
763
  const otherTableVar = getTableVarName(getTableName(otherCollection));
@@ -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 } from "@rebasepro/types";
2
2
  import { getEnumVarName, getTableName, resolveCollectionRelations, findRelation, securityRuleToConditions, policyToPostgres, getEffectiveSecurityRules, getInjectedSecurityRules, resolveJunctionSpecs, getJunctionSecurityRules, getJunctionCollectionConfig } from "@rebasepro/common";
3
3
  import { toSnakeCase, getPolicyNamesForRule } from "@rebasepro/utils";
4
4
 
@@ -170,9 +170,9 @@ export const getSqlColumnType = (propName: string, prop: Property, collection: C
170
170
  case "relation": {
171
171
  const refProp = prop as RelationProperty;
172
172
  const resolvedRelations = resolveCollectionRelations(collection);
173
- const relation = findRelation(resolvedRelations, refProp.relationName ?? propName);
174
- if (!relation || relation.direction !== "owning" || relation.cardinality !== "one") {
175
- throw new Error(`Relation ${propName} is not an owning one-to-one/many-to-one relation`);
173
+ const relation = findRelation(resolvedRelations, refProp.relation?.relationName ?? propName);
174
+ if (relation?.kind !== "belongsTo") {
175
+ throw new Error(`Relation ${propName} does not put a column on this table (only \`belongsTo\` does)`);
176
176
  }
177
177
  let targetCollection: CollectionConfig;
178
178
  try {
@@ -238,7 +238,7 @@ export const generatePostgresDdl = async (
238
238
  const allTablesToGenerate = new Map<string, {
239
239
  collection: CollectionConfig,
240
240
  isJunction?: boolean,
241
- relation?: Relation,
241
+ relation?: ResolvedRelation,
242
242
  sourceCollection?: CollectionConfig
243
243
  }>();
244
244
 
@@ -251,7 +251,7 @@ export const generatePostgresDdl = async (
251
251
 
252
252
  const resolvedRelations = resolveCollectionRelations(collection);
253
253
  for (const relation of Object.values(resolvedRelations)) {
254
- if (relation.through) {
254
+ if (isManyToMany(relation)) {
255
255
  const junctionTableName = relation.through.table;
256
256
  if (!allTablesToGenerate.has(junctionTableName)) {
257
257
  allTablesToGenerate.set(junctionTableName, {
@@ -284,7 +284,7 @@ export const generatePostgresDdl = async (
284
284
  const schema = isPostgresCollectionConfig(collection) && collection.schema ? collection.schema : "public";
285
285
  const baseTableName = tableName.includes(".") ? tableName.split(".").pop()! : tableName;
286
286
 
287
- if (isJunction && relation && sourceCollection && relation.through) {
287
+ if (isJunction && relation && sourceCollection && isManyToMany(relation)) {
288
288
  const targetCollection = relation.target();
289
289
  const sourceTable = getTableName(sourceCollection);
290
290
  const targetTable = getTableName(targetCollection);
@@ -336,9 +336,9 @@ export const generatePostgresDdl = async (
336
336
  if (prop.type === "relation") {
337
337
  const refProp = prop as RelationProperty;
338
338
  const resolvedRelations = resolveCollectionRelations(collection);
339
- const relInfo = findRelation(resolvedRelations, refProp.relationName ?? propName);
339
+ const relInfo = findRelation(resolvedRelations, refProp.relation?.relationName ?? propName);
340
340
 
341
- if (!relInfo || relInfo.direction !== "owning" || relInfo.cardinality !== "one" || !relInfo.localKey) {
341
+ if (relInfo?.kind !== "belongsTo") {
342
342
  return;
343
343
  }
344
344
 
@@ -700,11 +700,12 @@ export function generateCollectionFile(
700
700
  ${relName}: {
701
701
  name: "${relHumanName}",
702
702
  type: "relation",
703
- target: () => ${targetCollectionCamel},
704
- cardinality: "one",
705
- direction: "owning",
706
- localKey: "${fk.column_name}",
707
703
  // mapped from foreign key: ${fk.column_name} -> ${targetTableName}(${fk.foreign_column_name})
704
+ relation: {
705
+ kind: "belongsTo",
706
+ target: () => ${targetCollectionCamel},
707
+ localKey: "${fk.column_name}"
708
+ }
708
709
  },`);
709
710
  }
710
711
  }
@@ -718,15 +719,12 @@ export function generateCollectionFile(
718
719
  const targetCollectionCamel = toCollectionVarName(sourceTableName);
719
720
  imports.add(`import ${targetCollectionCamel} from "./${sourceTableName}";`);
720
721
 
721
- const inverseRelName = fk.column_name.replace(/_id$/, "");
722
-
723
722
  relationsOutput += `
724
723
  {
724
+ kind: "hasMany",
725
725
  relationName: "${sourceTableName}",
726
726
  target: () => ${targetCollectionCamel},
727
- cardinality: "many",
728
- direction: "inverse",
729
- inverseRelationName: "${inverseRelName}",
727
+ // the ${sourceTableName}.${fk.column_name} FK points back here
730
728
  foreignKeyOnTarget: "${fk.column_name}"
731
729
  },`;
732
730
  }
@@ -755,10 +753,9 @@ export function generateCollectionFile(
755
753
 
756
754
  relationsOutput += `
757
755
  {
756
+ kind: "manyToMany",
758
757
  relationName: "${relPropName}",
759
758
  target: () => ${tableName}Collection,
760
- cardinality: "many",
761
- direction: "owning",
762
759
  through: {
763
760
  table: "${jt}",
764
761
  sourceColumn: "${thisFk.column_name}",
@@ -776,24 +773,23 @@ export function generateCollectionFile(
776
773
  const targetCollectionCamel = toCollectionVarName(targetTableName);
777
774
  imports.add(`import ${targetCollectionCamel} from "./${targetTableName}";`);
778
775
 
779
- // Determine direction (alphabetically first table is owning)
780
- const direction = tableName < targetTableName ? "owning" : "inverse";
781
-
776
+ // Both sides of a many-to-many are `manyToMany`. There is no owning
777
+ // and inverse side to pick between any more, so this no longer
778
+ // guesses one from table-name ordering and no longer emits a
779
+ // half-configured relation on the losing side with a comment asking
780
+ // the reader to finish it by hand. Introspection already knows both
781
+ // junction columns; each side just names them from its own end.
782
782
  const thisFk = joinFks.find((fk) => fk.foreign_table_name === tableName);
783
783
 
784
- let throughCode = "";
785
- if (direction === "owning" && thisFk) {
786
- throughCode = `\n through: {\n table: "${jt}",\n sourceColumn: "${thisFk.column_name}",\n targetColumn: "${otherFk.column_name}"\n },`;
787
- } else if (direction === "inverse") {
788
- throughCode = "\n // Make sure the target collection configures the 'through' property.";
789
- }
784
+ const throughCode = thisFk
785
+ ? `\n through: {\n table: "${jt}",\n sourceColumn: "${thisFk.column_name}",\n targetColumn: "${otherFk.column_name}"\n }`
786
+ : "";
790
787
 
791
788
  relationsOutput += `
792
789
  {
790
+ kind: "manyToMany",
793
791
  relationName: "${targetTableName}",
794
- target: () => ${targetCollectionCamel},
795
- cardinality: "many",
796
- direction: "${direction}",${throughCode}
792
+ target: () => ${targetCollectionCamel},${throughCode}
797
793
  },`;
798
794
  }
799
795
  }