@rebasepro/common 0.1.2 → 0.2.3

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.
@@ -3,8 +3,8 @@ export declare function isReadOnly(property: Property): boolean;
3
3
  export declare function isHidden(property: Property): boolean;
4
4
  export declare function isPropertyBuilder(property?: Property): boolean;
5
5
  export declare function getDefaultValuesFor<M extends Record<string, unknown>>(properties: Properties): Partial<EntityValues<M>>;
6
- export declare function getDefaultValueFor(property?: Property): {} | null | undefined;
7
- export declare function getDefaultValueFortype(type: DataType): {} | null;
6
+ export declare function getDefaultValueFor(property?: Property): unknown;
7
+ export declare function getDefaultValueFortype(type: DataType): unknown;
8
8
  /**
9
9
  * Update the automatic values in an entity before save
10
10
  * @group Driver
@@ -1,5 +1,5 @@
1
1
  import { EntityCollection, Property, Relation } from "@rebasepro/types";
2
- export declare function sanitizeRelation(relation: Partial<Relation>, sourceCollection: EntityCollection): Relation;
2
+ export declare function sanitizeRelation(relation: Partial<Relation>, sourceCollection: EntityCollection, resolveCollection?: (slugOrTable: string) => EntityCollection | undefined): Relation;
3
3
  export declare function resolveCollectionRelations(collection: EntityCollection): Record<string, Relation>;
4
4
  export declare function resolvePropertyRelation({ propertyKey, property, sourceCollection }: {
5
5
  propertyKey: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/common",
3
3
  "type": "module",
4
- "version": "0.1.2",
4
+ "version": "0.2.3",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -32,7 +32,7 @@
32
32
  "exports": {
33
33
  ".": {
34
34
  "types": "./dist/index.d.ts",
35
- "development": "./src/index.ts",
35
+ "development": "./dist/index.es.js",
36
36
  "import": "./dist/index.es.js",
37
37
  "require": "./dist/index.umd.js"
38
38
  },
@@ -41,29 +41,27 @@
41
41
  "dependencies": {
42
42
  "fast-equals": "6.0.0",
43
43
  "json-logic-js": "^2.0.5",
44
- "@rebasepro/types": "0.1.2",
45
- "@rebasepro/utils": "0.1.2"
44
+ "@rebasepro/types": "0.2.3",
45
+ "@rebasepro/utils": "0.2.3"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@jest/globals": "^29.7.0",
49
- "@testing-library/react": "^16.3.0",
49
+ "@testing-library/react": "^16.3.2",
50
50
  "@testing-library/user-event": "^14.6.1",
51
51
  "@types/jest": "^29.5.14",
52
- "@types/node": "^20.17.14",
52
+ "@types/node": "^20.19.41",
53
53
  "@types/object-hash": "^3.0.6",
54
- "@types/react": "^19.0.8",
55
- "@types/react-dom": "^19.0.3",
54
+ "@types/react": "^19.2.15",
55
+ "@types/react-dom": "^19.2.3",
56
56
  "@types/react-measure": "^2.0.12",
57
- "@vitejs/plugin-react": "^4.3.4",
57
+ "@vitejs/plugin-react": "^4.7.0",
58
58
  "babel-plugin-react-compiler": "beta",
59
59
  "cross-env": "^7.0.3",
60
- "eslint-plugin-react-compiler": "beta",
61
60
  "jest": "^29.7.0",
62
- "npm-run-all": "^4.1.5",
63
- "ts-jest": "^29.3.1",
61
+ "ts-jest": "^29.4.10",
64
62
  "tsd": "^0.31.2",
65
- "typescript": "^5.8.3",
66
- "vite": "^5.4.17"
63
+ "typescript": "^5.9.3",
64
+ "vite": "^5.4.21"
67
65
  },
68
66
  "files": [
69
67
  "dist",
@@ -94,7 +92,10 @@
94
92
  ],
95
93
  "testEnvironment": "node",
96
94
  "moduleNameMapper": {
97
- "\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js"
95
+ "\\.(css|less)$": "<rootDir>/test/__mocks__/styleMock.js",
96
+ "^@rebasepro/client$": "<rootDir>/../client/src/index.ts",
97
+ "^@rebasepro/types$": "<rootDir>/../types/src/index.ts",
98
+ "^@rebasepro/utils$": "<rootDir>/../utils/src/index.ts"
98
99
  }
99
100
  },
100
101
  "scripts": {
@@ -9,8 +9,6 @@ import {
9
9
  Relation,
10
10
  RelationProperty,
11
11
  StringProperty,
12
- OnAction,
13
- JoinStep,
14
12
  getDataSourceCapabilities
15
13
  } from "@rebasepro/types";
16
14
  import { deepEqual } from "fast-equals";
@@ -71,8 +69,14 @@ export class CollectionRegistry {
71
69
  return false;
72
70
  }
73
71
 
74
- // Raw input has changed — normalize and register
75
72
  this.reset();
73
+ // Phase 0: Populate maps with raw collections first for string target resolution
74
+ collections.forEach((c) => {
75
+ if (c.slug) {
76
+ this.collectionsBySlug.set(c.slug, c);
77
+ }
78
+ this.collectionsByTableName.set(getTableName(c), c);
79
+ });
76
80
 
77
81
  const normalizedCollections = collections.map(c => this.normalizeCollection({ ...c }));
78
82
 
@@ -164,12 +168,23 @@ export class CollectionRegistry {
164
168
 
165
169
  // 2. Merge with manual relations[] (manual entries win on name conflict)
166
170
  const relResult = result as CollectionWithRelations;
167
- const manualRelations = getDataSourceCapabilities(result.driver).supportsRelations ? ((relResult as any).relations ?? []) : [];
171
+ const manualRelations = getDataSourceCapabilities(result.driver).supportsRelations ? (relResult.relations ?? []) : [];
168
172
  const mergedRelationsRaw = [...extractedRelations];
169
173
  for (const manual of manualRelations) {
170
174
  const name = manual.relationName;
171
- if (!name || !mergedRelationsRaw.find(r => r.relationName === name)) {
175
+ if (!name) {
172
176
  mergedRelationsRaw.push(manual);
177
+ } else {
178
+ const existingIndex = mergedRelationsRaw.findIndex(r => r.relationName === name);
179
+ if (existingIndex === -1) {
180
+ mergedRelationsRaw.push(manual);
181
+ } else {
182
+ // Merge manual into existing, preserving custom fields like 'collection'
183
+ mergedRelationsRaw[existingIndex] = {
184
+ ...manual,
185
+ ...mergedRelationsRaw[existingIndex]
186
+ };
187
+ }
173
188
  }
174
189
  }
175
190
 
@@ -182,7 +197,7 @@ export class CollectionRegistry {
182
197
  if (getDataSourceCapabilities(result.driver).supportsRelations) {
183
198
  mergedRelations = mergedRelationsRaw.map(r => {
184
199
  try {
185
- return sanitizeRelation(r, result);
200
+ return sanitizeRelation(r, result, (slug) => this.get(slug));
186
201
  } catch {
187
202
  // sanitizeRelation may throw for incomplete configs
188
203
  // (e.g. missing target). Keep the raw relation as-is.
@@ -191,7 +206,7 @@ export class CollectionRegistry {
191
206
  });
192
207
 
193
208
  // 3. Set the merged relations on the result copy
194
- (relResult as any).relations = mergedRelations;
209
+ relResult.relations = mergedRelations;
195
210
  }
196
211
 
197
212
  // 4. Normalize properties (which stamps relation on each property)
@@ -202,8 +217,8 @@ export class CollectionRegistry {
202
217
  if (!result.childCollections) {
203
218
  if (getDataSourceCapabilities(result.driver).supportsSubcollections && (result as CollectionWithSubcollections).subcollections) {
204
219
  result.childCollections = (result as CollectionWithSubcollections).subcollections;
205
- } else if (getDataSourceCapabilities(result.driver).supportsRelations && (relResult as any).relations) {
206
- const manyRelations = (relResult as any).relations.filter((r: Relation) => r.cardinality === "many");
220
+ } else if (getDataSourceCapabilities(result.driver).supportsRelations && relResult.relations) {
221
+ const manyRelations = relResult.relations.filter((r: Relation) => r.cardinality === "many");
207
222
  if (manyRelations.length > 0) {
208
223
  result.childCollections = () => manyRelations.map((r: Relation) => {
209
224
  const target = r.target();
@@ -463,31 +478,3 @@ export class CollectionRegistry {
463
478
 
464
479
  }
465
480
 
466
- function areCollectionListsEqual(a: EntityCollection[], b: EntityCollection[]) {
467
- // console.log("Comparing collection lists", a, b);
468
- // return true;
469
- if (a.length !== b.length) {
470
- return false;
471
- }
472
- const aCopy = [...a];
473
- const bCopy = [...b];
474
- const aSorted = aCopy.sort((x, y) => x.slug.localeCompare(y.slug));
475
- const bSorted = bCopy.sort((x, y) => x.slug.localeCompare(y.slug));
476
- return aSorted.every((value, index) => areCollectionsEqual(value, bSorted[index]));
477
- }
478
-
479
- function areCollectionsEqual(a: EntityCollection, b: EntityCollection) {
480
- const subcollectionsA = getSubcollections(a);
481
- const subcollectionsB = getSubcollections(b);
482
- const { driver: _dA, ...restA } = a as EntityCollection & Record<string, unknown>;
483
- const { driver: _dB, ...restB } = b as EntityCollection & Record<string, unknown>;
484
- // Remove subcollections/relations from comparison objects (already handled above)
485
- delete restA.subcollections;
486
- delete restB.subcollections;
487
- delete (restA as any).relations;
488
- delete (restB as any).relations;
489
- if (!areCollectionListsEqual(subcollectionsA, subcollectionsB)) {
490
- return false;
491
- }
492
- return deepEqual(removeFunctions(restA), removeFunctions(restB));
493
- }
@@ -11,6 +11,7 @@ import {
11
11
  WhereFieldValue
12
12
  } from "@rebasepro/types";
13
13
  import { toSnakeCase } from "@rebasepro/utils";
14
+ import { QueryBuilder } from "./query_builder";
14
15
 
15
16
  /**
16
17
  * Convert where-clause filter object to the internal DataDriver FilterValues format.
@@ -129,14 +130,11 @@ function parseOrderBy(orderBy?: string): [string, "asc" | "desc"] | undefined {
129
130
  return [field, direction];
130
131
  }
131
132
 
132
- /**
133
- * Create a CollectionAccessor that delegates to a DataDriver for a given collection slug.
134
- */
135
133
  function createDriverAccessor<M extends Record<string, unknown> = Record<string, unknown>>(
136
134
  driver: DataDriver,
137
135
  slug: string
138
136
  ): CollectionAccessor<M> {
139
- return {
137
+ const accessor: CollectionAccessor<M> = {
140
138
  async find(params?: FindParams): Promise<FindResponse<M>> {
141
139
  const orderParsed = parseOrderBy(params?.orderBy);
142
140
  const entities = await driver.fetchCollection<M>({
@@ -237,8 +235,30 @@ values: {} as Record<string, unknown> }
237
235
  onUpdate: (entity) => onUpdate(entity ?? undefined),
238
236
  onError
239
237
  });
240
- } : undefined
238
+ } : undefined,
239
+
240
+ // Fluent Query Builder
241
+ where(column: keyof M & string, operator: any, value: unknown) {
242
+ return new QueryBuilder<M>(accessor).where(column, operator, value);
243
+ },
244
+ orderBy(column: keyof M & string, ascending?: "asc" | "desc") {
245
+ return new QueryBuilder<M>(accessor).orderBy(column, ascending);
246
+ },
247
+ limit(count: number) {
248
+ return new QueryBuilder<M>(accessor).limit(count);
249
+ },
250
+ offset(count: number) {
251
+ return new QueryBuilder<M>(accessor).offset(count);
252
+ },
253
+ search(searchString: string) {
254
+ return new QueryBuilder<M>(accessor).search(searchString);
255
+ },
256
+ include(...relations: string[]) {
257
+ return new QueryBuilder<M>(accessor).include(...relations);
258
+ }
241
259
  };
260
+
261
+ return accessor;
242
262
  }
243
263
 
244
264
  /**
@@ -0,0 +1,117 @@
1
+ import { FindParams, Entity, FindResponse, CollectionAccessor, QueryBuilderInterface, FilterOperator } from "@rebasepro/types";
2
+
3
+ /**
4
+ * Maps standard operators to Rebase backend's string-based operators
5
+ */
6
+ function mapOperator(op: FilterOperator): string {
7
+ switch (op) {
8
+ case "==": return "eq";
9
+ case "!=": return "neq";
10
+ case ">": return "gt";
11
+ case ">=": return "gte";
12
+ case "<": return "lt";
13
+ case "<=": return "lte";
14
+ case "array-contains": return "cs";
15
+ case "array-contains-any": return "csa";
16
+ case "not-in": return "nin";
17
+ default: return op;
18
+ }
19
+ }
20
+
21
+ export class QueryBuilder<M extends Record<string, unknown> = Record<string, unknown>> implements QueryBuilderInterface<M> {
22
+ private params: FindParams = { where: {} };
23
+
24
+ constructor(private collection: CollectionAccessor<M>) {}
25
+
26
+ /**
27
+ * Add a filter condition to your query.
28
+ * @example
29
+ * client.collection('users').where('age', '>=', 18).find()
30
+ */
31
+ where(column: keyof M & string, operator: FilterOperator, value: unknown): this {
32
+ if (!this.params.where) {
33
+ this.params.where = {};
34
+ }
35
+
36
+ const mappedOp = mapOperator(operator);
37
+ let formattedValue = value;
38
+
39
+ // Handle arrays for in, nin, cs, csa
40
+ if (Array.isArray(value) && ["in", "nin", "cs", "csa"].includes(mappedOp)) {
41
+ formattedValue = `(${value.join(",")})`;
42
+ } else if (value === null) {
43
+ formattedValue = "null";
44
+ }
45
+
46
+ this.params.where[column] = mappedOp === "eq" ? String(formattedValue) : `${mappedOp}.${formattedValue}`;
47
+ return this;
48
+ }
49
+
50
+ /**
51
+ * Order the results by a specific column.
52
+ * @example
53
+ * client.collection('users').orderBy('createdAt', 'desc').find()
54
+ */
55
+ orderBy(column: keyof M & string, ascending: "asc" | "desc" = "asc"): this {
56
+ this.params.orderBy = `${column}:${ascending}`;
57
+ return this;
58
+ }
59
+
60
+ /**
61
+ * Limit the number of results returned.
62
+ */
63
+ limit(count: number): this {
64
+ this.params.limit = count;
65
+ return this;
66
+ }
67
+
68
+ /**
69
+ * Skip the first N results.
70
+ */
71
+ offset(count: number): this {
72
+ this.params.offset = count;
73
+ return this;
74
+ }
75
+
76
+ /**
77
+ * Set a free-text search string if supported by the backend.
78
+ */
79
+ search(searchString: string): this {
80
+ this.params.searchString = searchString;
81
+ return this;
82
+ }
83
+
84
+ /**
85
+ * Include related entities in the response.
86
+ * Relations will be populated with full entity data instead of just IDs.
87
+ *
88
+ * @param relations - Relation names to include, or "*" for all.
89
+ * @example
90
+ * // Include specific relations
91
+ * client.data.posts.include("tags", "author").find()
92
+ *
93
+ * // Include all relations
94
+ * client.data.posts.include("*").find()
95
+ */
96
+ include(...relations: string[]): this {
97
+ this.params.include = relations;
98
+ return this;
99
+ }
100
+
101
+ /**
102
+ * Execute the find query and return the results.
103
+ */
104
+ async find(): Promise<FindResponse<M>> {
105
+ return this.collection.find(this.params) as Promise<FindResponse<M>>;
106
+ }
107
+
108
+ /**
109
+ * Listen to realtime updates matching this query.
110
+ */
111
+ listen(onUpdate: (data: FindResponse<M>) => void, onError?: (error: Error) => void): () => void {
112
+ if (!this.collection.listen) {
113
+ throw new Error("Listen is only available when RebaseClient is configured with a websocketUrl.");
114
+ }
115
+ return this.collection.listen(this.params, onUpdate, onError);
116
+ }
117
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,5 @@
1
1
  export * from "./util";
2
2
  export * from "./collections";
3
3
  export * from "./data/buildRebaseData";
4
+ export * from "./data/query_builder";
5
+
@@ -1,7 +1,7 @@
1
1
  declare module "json-logic-js" {
2
2
  interface JsonLogic {
3
3
  apply(logic: unknown, data?: unknown): unknown;
4
- add_operation<T extends any[]>(name: string, fn: (...args: T) => unknown): void;
4
+ add_operation<T extends unknown[]>(name: string, fn: (...args: T) => unknown): void;
5
5
  }
6
6
  const jsonLogic: JsonLogic;
7
7
  export default jsonLogic;
@@ -50,6 +50,7 @@ export function buildProperty<T, P extends Property = Property>(
50
50
  P extends ArrayProperty ? ArrayProperty :
51
51
  P extends MapProperty ? MapProperty : never {
52
52
 
53
+ // SAFETY: Identity function — P is a subtype of the conditional return type by definition
53
54
  return property as unknown as ReturnType<typeof buildProperty<T, P>>;
54
55
  }
55
56
 
@@ -44,7 +44,7 @@ export function getDefaultValuesFor<M extends Record<string, unknown>>(propertie
44
44
  ...b }), {}) as EntityValues<M>;
45
45
  }
46
46
 
47
- export function getDefaultValueFor(property?: Property) {
47
+ export function getDefaultValueFor(property?: Property): unknown {
48
48
  if (!property) return undefined;
49
49
  if (isPropertyBuilder(property)) return undefined;
50
50
  if (property.defaultValue || property.defaultValue === null) {
@@ -58,7 +58,7 @@ export function getDefaultValueFor(property?: Property) {
58
58
  }
59
59
  }
60
60
 
61
- export function getDefaultValueFortype(type: DataType) {
61
+ export function getDefaultValueFortype(type: DataType): unknown {
62
62
  if (type === "string") {
63
63
  return null;
64
64
  } else if (type === "number") {
@@ -71,6 +71,10 @@ export function getDefaultValueFortype(type: DataType) {
71
71
  return [];
72
72
  } else if (type === "map") {
73
73
  return {};
74
+ } else if (type === "vector") {
75
+ return null;
76
+ } else if (type === "binary") {
77
+ return null;
74
78
  } else {
75
79
  return null;
76
80
  }
@@ -2,14 +2,60 @@ import { CollectionWithRelations, EntityCollection, getDataSourceCapabilities, P
2
2
  import { toSnakeCase } from "@rebasepro/utils";
3
3
  import { generateForeignKeyName } from "@rebasepro/utils";
4
4
 
5
- export function sanitizeRelation(relation: Partial<Relation>, sourceCollection: EntityCollection): Relation {
5
+ export function sanitizeRelation(
6
+ relation: Partial<Relation>,
7
+ sourceCollection: EntityCollection,
8
+ resolveCollection?: (slugOrTable: string) => EntityCollection | undefined
9
+ ): Relation {
6
10
  if (!relation.target) {
7
11
  throw new Error("Relation is missing a `target` collection.");
8
12
  }
9
- const targetCollection = relation.target();
13
+
14
+ const rawTarget = relation.target;
15
+ let targetCollection: EntityCollection | undefined;
16
+
17
+ if (typeof rawTarget === "string") {
18
+ if (resolveCollection) {
19
+ targetCollection = resolveCollection(rawTarget);
20
+ }
21
+ if (!targetCollection) {
22
+ targetCollection = { slug: rawTarget, name: rawTarget } as EntityCollection;
23
+ }
24
+ } else if (typeof rawTarget === "function") {
25
+ const evaluated = rawTarget();
26
+ if (typeof evaluated === "string") {
27
+ if (resolveCollection) {
28
+ targetCollection = resolveCollection(evaluated);
29
+ }
30
+ if (!targetCollection) {
31
+ targetCollection = { slug: evaluated, name: evaluated } as EntityCollection;
32
+ }
33
+ } else {
34
+ targetCollection = evaluated;
35
+ }
36
+ } else if (rawTarget && typeof rawTarget === "object") {
37
+ targetCollection = rawTarget as EntityCollection;
38
+ }
39
+
40
+ if (!targetCollection) {
41
+ throw new Error("Relation is missing a valid `target` collection.");
42
+ }
10
43
 
11
44
  const newRelation: Partial<Relation> = { ...relation };
12
45
 
46
+ newRelation.target = () => {
47
+ if (typeof rawTarget === "string") {
48
+ return (resolveCollection && resolveCollection(rawTarget)) || targetCollection!;
49
+ } else if (typeof rawTarget === "function") {
50
+ const evaluated = rawTarget();
51
+ if (typeof evaluated === "string") {
52
+ return (resolveCollection && resolveCollection(evaluated)) || targetCollection!;
53
+ }
54
+ return evaluated;
55
+ }
56
+ return targetCollection!;
57
+ };
58
+
13
59
  // 1. Default relationName from target collection slug
14
60
  if (!newRelation.relationName) {
15
61
  newRelation.relationName = toSnakeCase(targetCollection.slug);
@@ -41,7 +87,7 @@ export function sanitizeRelation(relation: Partial<Relation>, sourceCollection:
41
87
 
42
88
  try {
43
89
  // Look for an owning relation on the target that points back to this collection
44
- const targetRelations = getDataSourceCapabilities(targetCollection.driver).supportsRelations ? (((targetCollection as any).relations) || []) : [];
90
+ const targetRelations = getDataSourceCapabilities(targetCollection.driver).supportsRelations ? (((targetCollection as CollectionWithRelations).relations) || []) : [];
45
91
  for (const targetRel of targetRelations) {
46
92
  if (targetRel.direction === "owning" &&
47
93
  targetRel.cardinality === "one" &&
@@ -85,16 +131,33 @@ export function sanitizeRelation(relation: Partial<Relation>, sourceCollection:
85
131
  // Note: we intentionally do NOT require `through` here because the raw (unsanitized)
86
132
  // relations won't have `through` populated yet — sanitizeRelation fills it in later.
87
133
  // `cardinality: "many" + direction: "owning"` is sufficient to identify owning M2M.
88
- const targetRelations = getDataSourceCapabilities(targetCollection.driver).supportsRelations ? (((targetCollection as any).relations) || []) : [];
134
+
135
+ // 1. Check the explicit relations[] array
136
+ const targetRelations = getDataSourceCapabilities(targetCollection.driver).supportsRelations ? (((targetCollection as CollectionWithRelations).relations) || []) : [];
89
137
  for (const targetRel of targetRelations) {
90
138
  if (targetRel.cardinality === "many" &&
91
139
  (targetRel.direction === "owning" || !targetRel.direction) &&
92
140
  (targetRel.relationName === newRelation.inverseRelationName)) {
93
- // Found a corresponding owning many-to-many relation
94
141
  isManyToManyInverse = true;
95
142
  break;
96
143
  }
97
144
  }
145
+
146
+ // 2. Also check the target's properties for inline relation definitions
147
+ // (e.g. posts.properties.tags = { type: "relation", cardinality: "many", direction: "owning" })
148
+ if (!isManyToManyInverse && targetCollection.properties) {
149
+ for (const [propKey, prop] of Object.entries(targetCollection.properties)) {
150
+ if ((prop as Property).type !== "relation") continue;
151
+ const relProp = prop as RelationProperty;
152
+ const relName = relProp.relationName || propKey;
153
+ if (relName === newRelation.inverseRelationName &&
154
+ relProp.cardinality === "many" &&
155
+ (relProp.direction === "owning" || !relProp.direction)) {
156
+ isManyToManyInverse = true;
157
+ break;
158
+ }
159
+ }
160
+ }
98
161
  } catch (e) {
99
162
  // If we can't inspect the target collection, assume one-to-many
100
163
  }
@@ -152,13 +215,17 @@ export function resolveCollectionRelations(
152
215
 
153
216
  // 1. Process explicit relations from the `relations` field.
154
217
  // Each relation is stored once under its canonical relationName key.
155
- if ((relCollection as any).relations) {
156
- (relCollection as any).relations.forEach((relation: Relation) => {
157
- const normalizedRelation = sanitizeRelation(relation, collection);
158
- const relationKey = normalizedRelation.relationName;
159
- if (relationKey) {
160
- relations[relationKey] = normalizedRelation;
161
- registeredRelationNames.add(relationKey);
218
+ if (relCollection.relations) {
219
+ relCollection.relations.forEach((relation: Relation) => {
220
+ try {
221
+ const normalizedRelation = sanitizeRelation(relation, collection);
222
+ const relationKey = normalizedRelation.relationName;
223
+ if (relationKey) {
224
+ relations[relationKey] = normalizedRelation;
225
+ registeredRelationNames.add(relationKey);
226
+ }
227
+ } catch (e) {
228
+ // Ignore incomplete or invalid relations (e.g. missing target during registry setup)
162
229
  }
163
230
  });
164
231
  }
@@ -213,7 +280,8 @@ export function resolvePropertyRelation({
213
280
 
214
281
  const relProp = property as RelationProperty;
215
282
 
216
- // 1. If the property has inline config (target set), build a Relation from it
283
+ // If the property has inline config (target set), build a Relation from it.
284
+ // We only support the flat format where properties are directly on the RelationProperty.
217
285
  if (relProp.target) {
218
286
  return {
219
287
  relationName: relProp.relationName || propertyKey,
@@ -231,15 +299,8 @@ export function resolvePropertyRelation({
231
299
  } as Relation;
232
300
  }
233
301
 
234
- // 2. Fall back to lookup from collection.relations[] (backward compat)
235
- const relation = (((sourceCollection as any).relations) ?? []).find((rel: Relation) => rel.relationName === relProp.relationName)
236
- if (!relation) {
237
- console.warn(`Unrecognized relation format for property '${propertyKey}' in collection '${sourceCollection.slug}'`);
238
- return undefined;
239
- }
240
-
241
- return relation as Relation;
242
-
302
+ console.warn(`Unrecognized or missing relation target for property '${propertyKey}' in collection '${sourceCollection.slug}'`);
303
+ return undefined;
243
304
  }
244
305
 
245
306
  export function getTableName(collection: EntityCollection): string {
@@ -21,6 +21,7 @@ import { enumToObjectEntries } from "./enums";
21
21
  import { DEFAULT_ONE_OF_TYPE } from "./common";
22
22
  import { isDefaultFieldConfigId } from "@rebasepro/utils";
23
23
  import { getIn, mergeDeep } from "@rebasepro/utils";
24
+ import { resolveCollectionRelations } from "./relations";
24
25
 
25
26
  /**
26
27
  * Resolve property builders, enums and arrays.
@@ -350,8 +351,10 @@ export function getSubcollections<M extends Record<string, unknown> = Record<str
350
351
  return (collection as CollectionWithSubcollections).subcollections!() ?? [];
351
352
  }
352
353
 
353
- if (getDataSourceCapabilities(collection.driver).supportsRelations && ((collection as any).relations)) {
354
- const manyRelations = ((collection as any).relations)!.filter((r: Relation) => r.cardinality === "many");
354
+ if (getDataSourceCapabilities(collection.driver).supportsRelations) {
355
+ const resolvedRelations = resolveCollectionRelations(collection);
356
+ const manyRelations = Object.values(resolvedRelations).filter((r: Relation) => r.cardinality === "many");
357
+
355
358
  return manyRelations.map((r: Relation) => {
356
359
  const target = r.target();
357
360
  if (!target) return undefined;
@@ -376,7 +379,7 @@ export function getSubcollections<M extends Record<string, unknown> = Record<str
376
379
 
377
380
  const targetWithOverrides = { ...target, ...baseOverrides };
378
381
  return (r.overrides ? mergeDeep(targetWithOverrides, r.overrides) : targetWithOverrides) as EntityCollection<Record<string, unknown>>;
379
- }).filter((c: any): c is EntityCollection<Record<string, unknown>> => Boolean(c));
382
+ }).filter((c: EntityCollection<Record<string, unknown>> | undefined): c is EntityCollection<Record<string, unknown>> => Boolean(c));
380
383
  }
381
384
 
382
385
  return [];