@rebasepro/common 0.13.0 → 0.13.1-canary.g18cfeb7

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
  /**
@@ -18,3 +18,4 @@ export * from "./junction-policies";
18
18
  export * from "./conditions";
19
19
  export * from "./pg-column-to-property";
20
20
  export * from "./string-column-length";
21
+ export * from "./internal-tables";
@@ -0,0 +1,95 @@
1
+ /**
2
+ * The tables Rebase creates for its own bookkeeping, and the SQL that keeps the
3
+ * end-user role away from them.
4
+ *
5
+ * ## Why this exists
6
+ *
7
+ * Authenticated requests run as {@link REBASE_USER_ROLE}, and the boot-time role
8
+ * provisioning grants that role `SELECT, INSERT, UPDATE, DELETE` on every table
9
+ * in the schemas a project uses — including `rebase`, because a project's own
10
+ * collections are allowed to live there (the scaffold puts `users` there). It
11
+ * also sets `ALTER DEFAULT PRIVILEGES`, so a table created *later* by the
12
+ * migrating role inherits the same grant.
13
+ *
14
+ * Every framework-internal table is created later: auth's tables come up during
15
+ * `initializeAuth`, `api_keys` during route mounting, `cron_logs` when the first
16
+ * job registers, `idempotency_keys` on the first request that carries a key. So
17
+ * they all inherited full DML for the end-user role — and none of them enables
18
+ * row-level security, because none of them is a collection with
19
+ * `securityRules`. Measured on a freshly provisioned database, `SET ROLE
20
+ * rebase_user` could read `rebase.refresh_tokens` (session token hashes),
21
+ * `rebase.mfa_factors` (`secret_encrypted`), `rebase.recovery_codes`, and
22
+ * `rebase.api_keys` (including its `admin` flag), and insert into
23
+ * `rebase.app_config`.
24
+ *
25
+ * Nothing routes a user-context query at those tables today, so this was not
26
+ * reachable over the API. That is the wrong thing to depend on: the documented
27
+ * model is that RLS is the authorization boundary, and these tables sat outside
28
+ * it. The boundary is now a privilege boundary instead — the role simply cannot
29
+ * address them.
30
+ *
31
+ * ## Why REVOKE rather than ENABLE ROW LEVEL SECURITY
32
+ *
33
+ * RLS with no policy denies every row, which is the same outcome, but it is the
34
+ * *weaker* statement: it leaves the grant in place, so a later policy — or a
35
+ * `FORCE` flag cleared by some future migration — reopens the table. There is no
36
+ * row of `refresh_tokens` any end user should ever reach, so the honest encoding
37
+ * is "this role has no privilege here at all". It also keeps the owner
38
+ * connection (which auth actually runs on) completely unaffected.
39
+ *
40
+ * ## Keeping it true
41
+ *
42
+ * `packages/rls-check` scans the `rebase` schema — it used to skip it as a
43
+ * "platform" schema — and its `rls-disabled` check fires on exactly the
44
+ * condition this module removes: RLS off *and* a DML grant to a reachable role.
45
+ * So a table added here without a revoke is caught by `pnpm rls:check`, not by
46
+ * someone re-reading this file.
47
+ */
48
+ /**
49
+ * The Postgres role authenticated requests run as.
50
+ *
51
+ * Defined here rather than in the Postgres driver because both the driver (which
52
+ * provisions the role) and this module (which revokes on its behalf) need it,
53
+ * and a second spelling of a role name is a silent no-op waiting to happen.
54
+ */
55
+ export declare const REBASE_USER_ROLE = "rebase_user";
56
+ /**
57
+ * Framework-internal table names, unqualified.
58
+ *
59
+ * Deliberately NOT including `users`: the auth user table is also a collection,
60
+ * with `securityRules`, RLS enabled and policies applied. Users read their own
61
+ * row through it — revoking there would break sign-in.
62
+ *
63
+ * `atlas_schema_revisions` is Atlas's migration ledger, which lands in `rebase`
64
+ * because `db migrate apply` passes `--revisions-schema rebase`.
65
+ */
66
+ export declare const REBASE_INTERNAL_TABLES: readonly string[];
67
+ /**
68
+ * A single statement that takes every privilege on `schema.table` away from the
69
+ * end-user role.
70
+ *
71
+ * Wrapped in a `DO` block guarded on `pg_roles` for two reasons, both of which
72
+ * happen in practice:
73
+ *
74
+ * - the role does not exist when the connection is unprivileged (Rebase then
75
+ * relies on native RLS rather than a role switch), and a bare `REVOKE` on a
76
+ * missing role is an error, not a no-op;
77
+ * - the table may not exist yet — `cron_logs` never appears in a project with
78
+ * no cron jobs — and `to_regclass` returning NULL has to be tolerated too.
79
+ *
80
+ * One command, so it is safe on handles that speak the extended query protocol
81
+ * and reject multi-statement strings.
82
+ */
83
+ export declare function revokeInternalTableSql(schema: string, table: string): string;
84
+ /**
85
+ * Revoke on every internal table in `schema`, one statement at a time.
86
+ *
87
+ * Best-effort per table: a connection that does not own one of them (a
88
+ * pre-provisioned database, a platform-managed ledger) cannot revoke on it, and
89
+ * that must not take down a boot. The caller decides how loud to be — `onError`
90
+ * exists so the driver can warn without this module importing a logger.
91
+ */
92
+ export declare function revokeInternalTableAccess(execute: (sql: string) => Promise<unknown>, schema: string, options?: {
93
+ tables?: readonly string[];
94
+ onError?: (table: string, error: unknown) => void;
95
+ }): Promise<void>;
@@ -12,13 +12,13 @@ export interface AnonymousGrantRisk {
12
12
  /**
13
13
  * Find clauses that read as "signed-in users only" but admit anonymous callers.
14
14
  *
15
- * Both spellings come from the same place — Supabase, where `auth.uid()` really
16
- * is NULL for an anonymous request. Rebase substitutes
15
+ * Both spellings come from the same place — Supabase, where its own `auth.uid()`
16
+ * really is NULL for an anonymous request. Rebase substitutes
17
17
  * {@link ANONYMOUS_USER_ID} instead (a blank id would read back as NULL, which
18
18
  * is how the trusted *server* context is recognised), so:
19
19
  *
20
- * - `auth.uid() IS NOT NULL` is a tautology on the user path, and
21
- * - `auth.uid() != 'anon'` excludes one spelling of anonymous and admits the
20
+ * - `rebase.uid() IS NOT NULL` is a tautology on the user path, and
21
+ * - `rebase.uid() != 'anon'` excludes one spelling of anonymous and admits the
22
22
  * other. This one is not hypothetical and was not only a foreign habit:
23
23
  * rebase's own request path reported `'anon'` while everything that compiled
24
24
  * or checked a policy used `'anonymous'`, so whichever literal an author
@@ -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.g18cfeb7",
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.g18cfeb7",
44
+ "@rebasepro/utils": "0.13.1-canary.g18cfeb7"
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>,
@@ -598,7 +673,16 @@ function toEntityAccessor<M extends Record<string, unknown>>(
598
673
  * admin `RebaseDataContext` — without it the admin renders rows with only their
599
674
  * `id`.
600
675
  */
601
- export function wrapAsEntityData(sdkData: RebaseSdkData, options?: EntityDataOptions): RebaseData {
676
+ /**
677
+ * Only the by-slug accessor is asked for, so only that is required.
678
+ *
679
+ * Taking a whole `RebaseSdkData` meant taking `RebaseSdkData<unknown>`, whose
680
+ * dynamic branch is an index signature — and no `RebaseSdkData<DB>` satisfies
681
+ * it, because its own `collection` method is not a `SDKCollectionClient`. So a
682
+ * caller holding a *typed* client could not pass it to a function that reads
683
+ * one method off it, and that method is identical on every instantiation.
684
+ */
685
+ export function wrapAsEntityData(sdkData: Pick<RebaseSdkData, "collection">, options?: EntityDataOptions): RebaseData {
602
686
  const cache = new Map<string, CollectionAccessor>();
603
687
  const primaryKeysFor = createPrimaryKeyResolver(options);
604
688
 
@@ -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
  * ```