@rebasepro/common 0.13.0 → 0.13.1-canary.g394d868

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,7 +3,7 @@ import { FirebaseCollectionConfig, FirebaseProperties, InferEntityType, MongoDBC
3
3
  * Define a PostgreSQL-backed collection with full type inference.
4
4
  *
5
5
  * The `const P` generic captures literal property types from your
6
- * `properties` object, which enables autocomplete on `titleProperty`,
6
+ * `properties` object, which enables autocomplete on `display.title`,
7
7
  * `sort`, `propertiesOrder`, `fixedFilter`, and entity callbacks.
8
8
  *
9
9
  * @example
@@ -16,7 +16,7 @@ import { FirebaseCollectionConfig, FirebaseProperties, InferEntityType, MongoDBC
16
16
  * name: { name: "Name", type: "string", validation: { required: true } },
17
17
  * price: { name: "Price", type: "number" },
18
18
  * },
19
- * titleProperty: "name", // ✅ autocomplete: "name" | "price"
19
+ * display: { title: "name" }, // ✅ autocomplete: "name" | "price"
20
20
  * sort: ["price", "asc"], // ✅ autocomplete on first element
21
21
  * });
22
22
  * ```
@@ -1,13 +1,17 @@
1
- import { AuthState, ConditionContext, JsonLogicRule } from "@rebasepro/types";
1
+ import { AuthState, ConditionContext, ConditionRule } from "@rebasepro/types";
2
2
  /**
3
3
  * Register custom JSON Logic operations for Rebase.
4
4
  * Call this once at app initialization.
5
5
  */
6
6
  export declare function registerConditionOperations(): void;
7
7
  /**
8
- * Evaluate a JSON Logic rule against the given context.
8
+ * Evaluate a condition against the given context.
9
+ *
10
+ * A condition may be stated as a literal instead of a rule — `hidden: true`
11
+ * rather than `hidden: { "==": [1, 1] }` — and a literal is already its own
12
+ * answer, so it is returned rather than handed to the evaluator.
9
13
  */
10
- export declare function evaluateCondition(rule: JsonLogicRule, context: ConditionContext): unknown;
14
+ export declare function evaluateCondition(rule: ConditionRule, context: ConditionContext): unknown;
11
15
  /**
12
16
  * Build a ConditionContext from the current property resolution context.
13
17
  */
@@ -31,9 +31,16 @@ export declare function getRelationFrom<M extends Record<string, unknown>>(entit
31
31
  * have `id` and `path` fields — these are relation-shaped objects from
32
32
  * edge cases in the data pipeline (REST fallback, stale cache, custom data source).
33
33
  *
34
+ * When `targetPath` is given, also accepts a bare id. A relation column is a
35
+ * foreign key, and the REST layer returns it as the scalar it is; only some
36
+ * fetch paths hydrate it into an object. Which form a caller sees therefore
37
+ * depends on how the row was loaded, and a caller that only accepted objects
38
+ * reported half of its own data as a type error. The declared target is the
39
+ * missing half: with it, an id is a relation that has not been fetched yet.
40
+ *
34
41
  * Returns null if the value cannot be coerced.
35
42
  */
36
- export declare function normalizeToEntityRelation(value: unknown, propertyType?: string): EntityRelation | null;
43
+ export declare function normalizeToEntityRelation(value: unknown, propertyType?: string, targetPath?: string): EntityRelation | null;
37
44
  export declare function traverseValuesProperties<M extends Record<string, unknown>>(inputValues: Partial<EntityValues<M>>, properties: Properties, operation: (value: unknown, property: Property) => unknown): EntityValues<M> | undefined;
38
45
  export declare function traverseValueProperty(inputValue: unknown, property: Property, operation: (value: unknown, property: Property) => unknown): unknown;
39
46
  /**
@@ -1,4 +1,4 @@
1
- import { CollectionConfig, ResolvedRelation } from "@rebasepro/types";
1
+ import { CollectionConfig, ResolvedRelation, RelationProperty } from "@rebasepro/types";
2
2
  /**
3
3
  * Whether the target rows are shared with other parents — a many-to-many, or a
4
4
  * multi-hop `via` chain.
@@ -27,6 +27,23 @@ export declare function isJunctionBackedRelation(relation: ResolvedRelation): bo
27
27
  * which is worth hearing about.
28
28
  */
29
29
  export declare function resolveCollectionRelations(collection: CollectionConfig): Record<string, ResolvedRelation>;
30
+ /**
31
+ * The path of the collection a relation property points at, derived from the
32
+ * property alone.
33
+ *
34
+ * A preview holds a property and a value and no collection, so it cannot call
35
+ * `resolveRelationProperty`. It does not need to: both forms that carry a
36
+ * target — the stamped `resolvedRelation` and the inline `relation` — name it
37
+ * directly. Only the third form, a relation declared by name in the
38
+ * collection's `relations` array, is out of reach, and that one has no target
39
+ * to read without the collection anyway.
40
+ *
41
+ * This is what lets a preview render a relation column that arrived as a bare
42
+ * foreign key: the id says *which* row, the declared target says *which
43
+ * collection*, and `RelationPreview` fetches the rest. Without it a scalar id
44
+ * is indistinguishable from a value of the wrong type.
45
+ */
46
+ export declare function getRelationTargetPath(property: RelationProperty): string | undefined;
30
47
  export declare function getTableName(collection: CollectionConfig): string;
31
48
  export declare function getTableVarName(tableName: string): string;
32
49
  export declare function getEnumVarName(tableName: string, propName: string): string;
@@ -92,6 +92,37 @@ export declare function resolveEnumValues(input: EnumValues): EnumValueConfig[]
92
92
  * 3. many-relations on an engine that has relations (SQL).
93
93
  */
94
94
  export declare function getEntityChildViews<M extends Record<string, unknown> = Record<string, unknown>>(collection: CollectionConfig<M>): EntityChildView[];
95
+ /**
96
+ * Each of `collection`'s tabs paired with the property that declared it, when a
97
+ * property declared it: child view key → property key.
98
+ *
99
+ * A many-relation can only be declared as a property — that is the documented
100
+ * and only mechanism — and {@link getEntityChildViews} promotes it to a tab. So
101
+ * one declaration reaches the panel twice, and neither surface knew about the
102
+ * other. The form rendered a relation picker beside the tab, and the collection
103
+ * table rendered *two* columns under one heading: the relation's own column,
104
+ * showing the child rows, and a jump-to-tab button carrying the same name.
105
+ *
106
+ * The pairing is what lets each surface decide which half is redundant, and it
107
+ * has to be a pairing rather than two sets because the two keys differ whenever
108
+ * a relation is named. The match is on the resolved `relationName` — the
109
+ * identity `getEntityChildViews` itself dedupes on — so a relation declared in
110
+ * `relations` and pointed at by a differently-named property is recognised too.
111
+ *
112
+ * A relation with no property of its own is absent here, which is the point: it
113
+ * has exactly one surface already, and nothing to weigh it against.
114
+ *
115
+ * Only top-level properties: a relation nested inside a `map` gets no tab.
116
+ */
117
+ export declare function getChildViewDeclaringProperties<M extends Record<string, unknown> = Record<string, unknown>>(collection: CollectionConfig<M>): Map<string, string>;
118
+ /**
119
+ * The property keys of `collection` whose relation is already one of its tabs.
120
+ *
121
+ * What a form asks: the tab is the treatment for a list of child rows, so the
122
+ * picker beside it is the redundant half. See
123
+ * {@link getChildViewDeclaringProperties}.
124
+ */
125
+ export declare function getChildViewRelationPropertyKeys<M extends Record<string, unknown> = Record<string, unknown>>(collection: CollectionConfig<M>): Set<string>;
95
126
  /**
96
127
  * The child views of `collection` as bare collections.
97
128
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/common",
3
3
  "type": "module",
4
- "version": "0.13.0",
4
+ "version": "0.13.1-canary.g394d868",
5
5
  "description": "Awesome Firebase/Firestore-based headless open-source CMS",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -40,8 +40,8 @@
40
40
  "dependencies": {
41
41
  "fast-equals": "6.0.2",
42
42
  "json-logic-js": "^2.0.5",
43
- "@rebasepro/types": "0.13.0",
44
- "@rebasepro/utils": "0.13.0"
43
+ "@rebasepro/types": "0.13.1-canary.g394d868",
44
+ "@rebasepro/utils": "0.13.1-canary.g394d868"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@jest/globals": "^30.4.1",
@@ -18,7 +18,7 @@ import {
18
18
  } from "@rebasepro/types";
19
19
  import { toSnakeCase } from "@rebasepro/utils";
20
20
  import { QueryBuilder } from "./query_builder";
21
- import { collectAllPages, paginateFind } from "./paginate";
21
+ import { collectAllPages, paginateFind, resolveFindWindow } from "./paginate";
22
22
  import { deserializeFilter } from "./filter-dialect";
23
23
  import { buildCompositeId, resolvePrimaryKeys, PrimaryKeyInfo } from "../util/identity";
24
24
 
@@ -153,8 +153,7 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
153
153
  async find(params?: FindParams<M>): Promise<FindResponse<M>> {
154
154
  // Ensure filters are in canonical [op, value] format even if passed as PostgREST strings
155
155
  const filter = params?.where ? deserializeFilter(params.where as Record<string, unknown>) : undefined;
156
- const limit = params?.limit ?? 20;
157
- const offset = params?.offset ?? 0;
156
+ const { limit, offset, driverOffset } = resolveFindWindow(params);
158
157
 
159
158
  // One relation shape, whatever the call looks like.
160
159
  //
@@ -177,8 +176,12 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
177
176
  slug,
178
177
  {
179
178
  filter,
180
- limit: params?.limit,
181
- offset: params?.offset,
179
+ // Without this the group was dropped and the read ran
180
+ // unfiltered — every row the caller's policies allow,
181
+ // in place of the ones they asked for.
182
+ logical: params?.logical,
183
+ limit,
184
+ offset: driverOffset,
182
185
  orderBy: params?.orderBy?.[0],
183
186
  order: params?.orderBy?.[1],
184
187
  searchString: params?.searchString
@@ -187,9 +190,10 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
187
190
  )
188
191
  : await driver.fetchCollection<M>({
189
192
  path: slug,
190
- limit: params?.limit,
191
- offset: params?.offset,
193
+ limit,
194
+ offset: driverOffset,
192
195
  filter,
196
+ logical: params?.logical,
193
197
  orderBy: params?.orderBy?.[0],
194
198
  order: params?.orderBy?.[1],
195
199
  searchString: params?.searchString
@@ -199,7 +203,16 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
199
203
  let total = rows.length + offset;
200
204
  let hasMore = rows.length >= limit;
201
205
  if (driver.count) {
202
- total = await driver.count({ path: slug, filter });
206
+ // The same narrowing the rows were read with. Counting only by
207
+ // `filter` reported the whole collection beside a narrowed
208
+ // page, and `hasMore` is derived from it — so the list offered
209
+ // a next page that did not exist.
210
+ total = await driver.count({
211
+ path: slug,
212
+ filter,
213
+ logical: params?.logical,
214
+ searchString: params?.searchString
215
+ });
203
216
  hasMore = offset + rows.length < total;
204
217
  }
205
218
 
@@ -258,29 +271,56 @@ values: {} as Record<string, unknown> }
258
271
  });
259
272
  },
260
273
 
274
+ // Present only when the driver is: exposing these unconditionally and
275
+ // looping single writes underneath would give a caller neither the
276
+ // atomicity nor the single round trip they reached for a batch to get,
277
+ // while looking exactly like it had.
278
+ updateMany: driver.updateMany
279
+ ? async (updates: { id: string | number; data: Partial<EntityValues<M>> }[]): Promise<Entity<M>[]> => {
280
+ const rows = await driver.updateMany!<M>({
281
+ path: slug,
282
+ updates: updates.map(u => ({ id: u.id,
283
+ values: u.data })),
284
+ });
285
+ return rows.map(row => rowToEntity<M>(row, slug, getPks()));
286
+ }
287
+ : undefined,
288
+
289
+ deleteMany: driver.deleteMany
290
+ ? async (ids: (string | number)[]): Promise<void> => {
291
+ await driver.deleteMany!<M>({ path: slug,
292
+ ids });
293
+ }
294
+ : undefined,
295
+
261
296
  count: driver.count
262
297
  ? async (params?: FindParams<M>): Promise<number> => {
263
298
  const filter = params?.where ? deserializeFilter(params.where as Record<string, unknown>) : undefined;
299
+ // Every narrowing `find()` applies has to apply here too, or
300
+ // the count describes a different query than the one it is
301
+ // reported against.
264
302
  return driver.count!({
265
303
  path: slug,
266
- filter
304
+ filter,
305
+ logical: params?.logical,
306
+ searchString: params?.searchString
267
307
  });
268
308
  }
269
309
  : undefined,
270
310
 
271
311
  listen: driver.listenCollection
272
312
  ? (params: FindParams<M> | undefined, onUpdate: (response: FindResponse<M>) => void, onError?: (error: Error) => void) => {
273
- const limit = params?.limit ?? 20;
274
- const offset = params?.offset ?? 0;
313
+ const { limit, offset, driverOffset } = resolveFindWindow(params);
275
314
  // Realtime has no REST-pipeline equivalent, so the rows arrive
276
315
  // admin-shaped. Flatten them to the one shape the rest of this
277
316
  // accessor serves.
278
317
  const normalize = driver.restFetchService ? inlineRelationRefs : (row: Record<string, unknown>) => row;
279
318
  return driver.listenCollection!<M>({
280
319
  path: slug,
281
- limit: params?.limit,
282
- offset: params?.offset,
320
+ limit,
321
+ offset: driverOffset,
283
322
  filter: params?.where,
323
+ logical: params?.logical,
284
324
  orderBy: params?.orderBy?.[0],
285
325
  order: params?.orderBy?.[1],
286
326
  searchString: params?.searchString,
@@ -288,7 +328,12 @@ values: {} as Record<string, unknown> }
288
328
  onUpdate({
289
329
  data: entities.map((row: Record<string, unknown>) => rowToEntity<M>(normalize(row), slug, getPks())),
290
330
  meta: {
291
- total: entities.length,
331
+ // No count is issued on this path, so the total
332
+ // is unknown; the lower bound is the rows in
333
+ // hand plus the ones paged past to reach them.
334
+ // Reporting `entities.length` claimed a read at
335
+ // offset 100 had found a collection of two.
336
+ total: offset + entities.length,
292
337
  limit,
293
338
  offset,
294
339
  hasMore: entities.length >= limit
@@ -506,9 +551,39 @@ function toSdkCollectionClient<M extends Record<string, unknown>>(
506
551
  async update(id: string | number, data: Partial<M>): Promise<M> {
507
552
  return entityToRow(await snap.update(id, data as Partial<EntityValues<M>>));
508
553
  },
554
+ async updateMany(updates: { id: string | number; data: Partial<M> }[]): Promise<M[]> {
555
+ if (!Array.isArray(updates)) {
556
+ throw new TypeError("updateMany expects an array of { id, data } entries.");
557
+ }
558
+ if (updates.length === 0) return [];
559
+ if (!snap.updateMany) {
560
+ throw new Error(
561
+ "Bulk updates are not supported by this collection's data source. " +
562
+ "Fall back to update() per record."
563
+ );
564
+ }
565
+ const rows = await snap.updateMany(
566
+ updates.map(u => ({ id: u.id,
567
+ data: u.data as Partial<EntityValues<M>> }))
568
+ );
569
+ return rows.map(entityToRow);
570
+ },
509
571
  delete(id: string | number): Promise<void> {
510
572
  return snap.delete(id);
511
573
  },
574
+ async deleteMany(ids: (string | number)[]): Promise<void> {
575
+ if (!Array.isArray(ids)) {
576
+ throw new TypeError("deleteMany expects an array of ids.");
577
+ }
578
+ if (ids.length === 0) return;
579
+ if (!snap.deleteMany) {
580
+ throw new Error(
581
+ "Bulk deletes are not supported by this collection's data source. " +
582
+ "Fall back to delete() per record."
583
+ );
584
+ }
585
+ await snap.deleteMany(ids);
586
+ },
512
587
  count: snap.count ? (params?: FindParams<M>) => snap.count!(params) : undefined,
513
588
  listen: snap.listen
514
589
  ? (params: FindParams<M> | undefined, onUpdate: (r: FindResult<M>) => void, onError?: (e: Error) => void) =>
@@ -537,7 +612,7 @@ function toSdkCollectionClient<M extends Record<string, unknown>>(
537
612
  /**
538
613
  * Wrap a flat {@link SDKCollectionClient} into a Entity-shaped
539
614
  * {@link CollectionAccessor}. Every returned row is re-wrapped into the
540
- * `{ id, path, values }` view-model the admin admin renders.
615
+ * `{ id, path, values }` view-model the admin panel renders.
541
616
  */
542
617
  function toEntityAccessor<M extends Record<string, unknown>>(
543
618
  sdk: SDKCollectionClient<M>,
@@ -354,9 +354,34 @@ export function serializeLogicalCondition(
354
354
  * deserializeLogicalCondition("or(status.eq.active,age.gte.18)")
355
355
  * // → { type: "or", conditions: [...] }
356
356
  */
357
+ /**
358
+ * How deeply `or(...)`/`and(...)` groups may nest.
359
+ *
360
+ * This parser recurses once per level, on a value that arrives in a query
361
+ * string. Unbounded, twenty thousand levels reached `RangeError: Maximum call
362
+ * stack size exceeded`, which a caller sees as a 500 about the call stack
363
+ * rather than a 400 about their filter. Node's 16 KB header cap keeps a GET
364
+ * below that in practice, but "the HTTP layer happens to stop it" is not a
365
+ * bound this parser should rely on.
366
+ *
367
+ * Thirty-two is far past anything a real filter expresses; the deepest in this
368
+ * repository's own tests is three.
369
+ */
370
+ export const MAX_LOGICAL_NESTING_DEPTH = 32;
371
+
357
372
  export function deserializeLogicalCondition(
358
- str: string
373
+ str: string,
374
+ // Not `depth`: the body already uses that name for paren tracking, inside a
375
+ // block that shadows a parameter of the same name — so the recursion
376
+ // counter silently became the paren counter and never grew.
377
+ nesting = 0
359
378
  ): LogicalCondition | FilterCondition {
379
+ if (nesting > MAX_LOGICAL_NESTING_DEPTH) {
380
+ throw new Error(
381
+ `Filter groups nest more than ${MAX_LOGICAL_NESTING_DEPTH} levels deep. ` +
382
+ "Flatten the condition — `or(a,or(b,c))` is `or(a,b,c)`."
383
+ );
384
+ }
360
385
  // Check for logical group: "and(...)" or "or(...)"
361
386
  const logicalMatch = str.match(/^(and|or)\((.+)\)$/);
362
387
  if (logicalMatch) {
@@ -371,11 +396,11 @@ export function deserializeLogicalCondition(
371
396
  if (innerStr[i] === "(") depth++;
372
397
  else if (innerStr[i] === ")") depth--;
373
398
  else if (innerStr[i] === "," && depth === 0) {
374
- conditions.push(deserializeLogicalCondition(innerStr.slice(start, i)));
399
+ conditions.push(deserializeLogicalCondition(innerStr.slice(start, i), nesting + 1));
375
400
  start = i + 1;
376
401
  }
377
402
  }
378
- conditions.push(deserializeLogicalCondition(innerStr.slice(start)));
403
+ conditions.push(deserializeLogicalCondition(innerStr.slice(start), nesting + 1));
379
404
 
380
405
  return { type, conditions };
381
406
  }
@@ -1,4 +1,5 @@
1
1
  import {
2
+ DEFAULT_LIST_LIMIT,
2
3
  FilterValues,
3
4
  FieldPath,
4
5
  FindAllParams,
@@ -70,6 +71,35 @@ export class RebasePaginationError extends Error {
70
71
  export type PageFinder<M extends Record<string, unknown> = Record<string, unknown>> =
71
72
  (params: FindParams<M>) => Promise<FindResult<M>>;
72
73
 
74
+ /**
75
+ * Resolve `limit`/`offset`/`page` into the window a read will actually use.
76
+ *
77
+ * Lives here, next to the walk, for the reason at the top of this file: every
78
+ * transport has to mean the same thing by "page two". Four of them did not —
79
+ * the REST layer strode by {@link DEFAULT_LIST_LIMIT}, the local-first
80
+ * evaluator by {@link DEFAULT_PAGE_SIZE}, the in-process accessor by 20, and
81
+ * the published type documented a fourth number. Pages that overlap or skip
82
+ * rows are the mildest of those outcomes.
83
+ *
84
+ * `page` wins over `offset`, as {@link FindParams} documents. `driverOffset`
85
+ * is the value to hand a driver: it stays `undefined` when the caller named no
86
+ * offset, because keyset pagination seeks with a `where` clause and must not
87
+ * look like it is paging by offset.
88
+ */
89
+ export function resolveFindWindow(
90
+ params?: Pick<FindParams, "limit" | "offset" | "page">
91
+ ): { limit: number; offset: number; driverOffset: number | undefined } {
92
+ const limit = params?.limit ?? DEFAULT_LIST_LIMIT;
93
+ const offset = params?.page != null
94
+ ? Math.max(0, (params.page - 1) * limit)
95
+ : (params?.offset ?? 0);
96
+ return {
97
+ limit,
98
+ offset,
99
+ driverOffset: params?.page != null ? offset : params?.offset
100
+ };
101
+ }
102
+
73
103
  function normalizePageSize(raw: number | undefined): number {
74
104
  if (raw === undefined || !Number.isFinite(raw)) return DEFAULT_PAGE_SIZE;
75
105
  return Math.max(1, Math.floor(raw));
@@ -77,3 +77,59 @@ export function resolveDataSource(
77
77
  capabilities: getDataSourceCapabilities(engine)
78
78
  };
79
79
  }
80
+
81
+ /**
82
+ * Does a SQL toolchain own this collection's storage?
83
+ *
84
+ * "Owns the storage" means: something generates a table for it, pushes that
85
+ * table to a database, plans its RLS policies, and reports it as drifted when
86
+ * the two disagree. That is true of a Postgres collection and false of a
87
+ * Firestore or MongoDB one, whose documents live in a store Rebase never
88
+ * migrates — and the two were never told apart. Every stage of the SQL
89
+ * toolchain took "the collections" to mean *all* of them, so a Firestore
90
+ * collection declared next to the Postgres ones got a `pgTable` in the
91
+ * generated schema, a `CREATE TABLE` at boot, RLS policies, and a place in the
92
+ * `db push` include list — where its name shielding a same-named real table
93
+ * from Atlas's exclude list is the one that can lose data.
94
+ *
95
+ * The answer is the resolved engine's {@link DataSourceCapabilities}, not a
96
+ * name check: an engine registered through `registerDataSourceCapabilities`
97
+ * gets the same treatment as the built-in ones.
98
+ *
99
+ * Deliberately answers **true** for an engine nobody has heard of. Build-time
100
+ * tooling (the CLI, the schema generator) has no data-source registry to
101
+ * resolve a `dataSource` key against, so an unknown key resolves to an unknown
102
+ * engine — and the cost of the two mistakes is not symmetric. Wrongly
103
+ * including a collection generates a table nothing writes to; wrongly excluding
104
+ * one silently stops generating a table the app is serving from. Declare
105
+ * `engine` on a collection that is not SQL-backed and this is exact.
106
+ */
107
+ export function isRelationalCollection(
108
+ collection: DataSourceResolvable | undefined,
109
+ registry?: DataSourceRegistry
110
+ ): boolean {
111
+ // The collection's own `engine` wins over a registered definition's. That
112
+ // is the opposite of {@link resolveDataSource}'s precedence, deliberately:
113
+ // there a definition describes where the data *goes*, so it should override;
114
+ // here the question is what the author said this collection is, and a
115
+ // collection declaring `engine: "firestore"` with no `dataSource` must not
116
+ // come back as the default source's engine and be handed a table.
117
+ const engine = collection?.engine
118
+ ?? (collection?.dataSource ? resolveDataSource(collection, registry).engine : undefined);
119
+ return getDataSourceCapabilities(engine).supportsRelations;
120
+ }
121
+
122
+ /**
123
+ * The subset of `collections` a SQL toolchain owns — see
124
+ * {@link isRelationalCollection}.
125
+ *
126
+ * Every stage that generates SQL from collections starts by calling this, so
127
+ * the rule lives in one place rather than being re-decided per generator. It
128
+ * keeps the input order.
129
+ */
130
+ export function relationalCollections<C extends DataSourceResolvable>(
131
+ collections: readonly C[],
132
+ registry?: DataSourceRegistry
133
+ ): C[] {
134
+ return collections.filter(collection => isRelationalCollection(collection, registry));
135
+ }
@@ -14,14 +14,14 @@ import {
14
14
  // ── defineCollection ─────────────────────────────────────────────────────
15
15
  // A smarter builder that uses `const` type-parameter inference (TS 5.0+)
16
16
  // to capture literal property types automatically. This gives you
17
- // autocomplete on `titleProperty`, `sort`, `propertiesOrder`, `fixedFilter`,
17
+ // autocomplete on `display.title`, `sort`, `propertiesOrder`, `fixedFilter`,
18
18
  // callbacks, etc. — without writing `as const` or passing manual generics.
19
19
 
20
20
  /**
21
21
  * Define a PostgreSQL-backed collection with full type inference.
22
22
  *
23
23
  * The `const P` generic captures literal property types from your
24
- * `properties` object, which enables autocomplete on `titleProperty`,
24
+ * `properties` object, which enables autocomplete on `display.title`,
25
25
  * `sort`, `propertiesOrder`, `fixedFilter`, and entity callbacks.
26
26
  *
27
27
  * @example
@@ -34,7 +34,7 @@ import {
34
34
  * name: { name: "Name", type: "string", validation: { required: true } },
35
35
  * price: { name: "Price", type: "number" },
36
36
  * },
37
- * titleProperty: "name", // ✅ autocomplete: "name" | "price"
37
+ * display: { title: "name" }, // ✅ autocomplete: "name" | "price"
38
38
  * sort: ["price", "asc"], // ✅ autocomplete on first element
39
39
  * });
40
40
  * ```
@@ -3,8 +3,8 @@ import {
3
3
  ArrayProperty,
4
4
  AuthState,
5
5
  ConditionContext,
6
+ ConditionRule,
6
7
  EnumValueConfig,
7
- JsonLogicRule,
8
8
  NumberProperty,
9
9
  PropertyConditions,
10
10
  Property,
@@ -66,9 +66,14 @@ export function registerConditionOperations(): void {
66
66
  }
67
67
 
68
68
  /**
69
- * Evaluate a JSON Logic rule against the given context.
69
+ * Evaluate a condition against the given context.
70
+ *
71
+ * A condition may be stated as a literal instead of a rule — `hidden: true`
72
+ * rather than `hidden: { "==": [1, 1] }` — and a literal is already its own
73
+ * answer, so it is returned rather than handed to the evaluator.
70
74
  */
71
- export function evaluateCondition(rule: JsonLogicRule, context: ConditionContext): unknown {
75
+ export function evaluateCondition(rule: ConditionRule, context: ConditionContext): unknown {
76
+ if (typeof rule === "boolean") return rule;
72
77
  // Ensure operations are registered
73
78
  registerConditionOperations();
74
79
  return jsonLogic.apply(rule, context);
@@ -143,10 +143,24 @@ export function getRelationFrom<M extends Record<string, unknown>>(entity: Entit
143
143
  * have `id` and `path` fields — these are relation-shaped objects from
144
144
  * edge cases in the data pipeline (REST fallback, stale cache, custom data source).
145
145
  *
146
+ * When `targetPath` is given, also accepts a bare id. A relation column is a
147
+ * foreign key, and the REST layer returns it as the scalar it is; only some
148
+ * fetch paths hydrate it into an object. Which form a caller sees therefore
149
+ * depends on how the row was loaded, and a caller that only accepted objects
150
+ * reported half of its own data as a type error. The declared target is the
151
+ * missing half: with it, an id is a relation that has not been fetched yet.
152
+ *
146
153
  * Returns null if the value cannot be coerced.
147
154
  */
148
- export function normalizeToEntityRelation(value: unknown, propertyType?: string): EntityRelation | null {
155
+ export function normalizeToEntityRelation(value: unknown, propertyType?: string, targetPath?: string): EntityRelation | null {
149
156
  if (value instanceof EntityRelation) return value;
157
+
158
+ if (targetPath && (typeof value === "string" || typeof value === "number")) {
159
+ // An empty string is an unset foreign key, not row "".
160
+ if (value === "") return null;
161
+ return new EntityRelation(value, targetPath);
162
+ }
163
+
150
164
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
151
165
 
152
166
  const obj = value as Record<string, unknown>;
@@ -66,6 +66,37 @@ export function resolveCollectionRelations(
66
66
  return relations;
67
67
  }
68
68
 
69
+ /**
70
+ * The path of the collection a relation property points at, derived from the
71
+ * property alone.
72
+ *
73
+ * A preview holds a property and a value and no collection, so it cannot call
74
+ * `resolveRelationProperty`. It does not need to: both forms that carry a
75
+ * target — the stamped `resolvedRelation` and the inline `relation` — name it
76
+ * directly. Only the third form, a relation declared by name in the
77
+ * collection's `relations` array, is out of reach, and that one has no target
78
+ * to read without the collection anyway.
79
+ *
80
+ * This is what lets a preview render a relation column that arrived as a bare
81
+ * foreign key: the id says *which* row, the declared target says *which
82
+ * collection*, and `RelationPreview` fetches the rest. Without it a scalar id
83
+ * is indistinguishable from a value of the wrong type.
84
+ */
85
+ export function getRelationTargetPath(property: RelationProperty): string | undefined {
86
+ const stamped = property.resolvedRelation?.targetSlug;
87
+ if (stamped) return stamped;
88
+
89
+ const target = property.relation?.target;
90
+ if (typeof target !== "function") return undefined;
91
+ try {
92
+ return target()?.slug;
93
+ } catch (_e) {
94
+ // A thunk reaching into a module that has not finished initialising:
95
+ // there is no target to name yet, and a preview is not worth throwing over.
96
+ return undefined;
97
+ }
98
+ }
99
+
69
100
  export function getTableName(collection: CollectionConfig): string {
70
101
  if (isRelationalCollectionConfig(collection)) {
71
102
  return collection.table ?? toSnakeCase(collection.slug) ?? toSnakeCase(collection.name);