@rebasepro/common 0.13.0 → 0.13.1-canary.g3660bd5

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
  * ```
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.g3660bd5",
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.g3660bd5",
44
+ "@rebasepro/utils": "0.13.1-canary.g3660bd5"
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
 
@@ -261,26 +274,31 @@ values: {} as Record<string, unknown> }
261
274
  count: driver.count
262
275
  ? async (params?: FindParams<M>): Promise<number> => {
263
276
  const filter = params?.where ? deserializeFilter(params.where as Record<string, unknown>) : undefined;
277
+ // Every narrowing `find()` applies has to apply here too, or
278
+ // the count describes a different query than the one it is
279
+ // reported against.
264
280
  return driver.count!({
265
281
  path: slug,
266
- filter
282
+ filter,
283
+ logical: params?.logical,
284
+ searchString: params?.searchString
267
285
  });
268
286
  }
269
287
  : undefined,
270
288
 
271
289
  listen: driver.listenCollection
272
290
  ? (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;
291
+ const { limit, offset, driverOffset } = resolveFindWindow(params);
275
292
  // Realtime has no REST-pipeline equivalent, so the rows arrive
276
293
  // admin-shaped. Flatten them to the one shape the rest of this
277
294
  // accessor serves.
278
295
  const normalize = driver.restFetchService ? inlineRelationRefs : (row: Record<string, unknown>) => row;
279
296
  return driver.listenCollection!<M>({
280
297
  path: slug,
281
- limit: params?.limit,
282
- offset: params?.offset,
298
+ limit,
299
+ offset: driverOffset,
283
300
  filter: params?.where,
301
+ logical: params?.logical,
284
302
  orderBy: params?.orderBy?.[0],
285
303
  order: params?.orderBy?.[1],
286
304
  searchString: params?.searchString,
@@ -288,7 +306,12 @@ values: {} as Record<string, unknown> }
288
306
  onUpdate({
289
307
  data: entities.map((row: Record<string, unknown>) => rowToEntity<M>(normalize(row), slug, getPks())),
290
308
  meta: {
291
- total: entities.length,
309
+ // No count is issued on this path, so the total
310
+ // is unknown; the lower bound is the rows in
311
+ // hand plus the ones paged past to reach them.
312
+ // Reporting `entities.length` claimed a read at
313
+ // offset 100 had found a collection of two.
314
+ total: offset + entities.length,
292
315
  limit,
293
316
  offset,
294
317
  hasMore: entities.length >= limit
@@ -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));
@@ -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
  * ```
@@ -261,11 +261,11 @@ export function resolveArrayProperties<M>({
261
261
  ignoreMissingFields,
262
262
  ...props
263
263
  });
264
- const {
265
- values,
266
- previousValues,
267
- ...rest
268
- } = props;
264
+ // Destructured to be *excluded* from `...rest`, not to be used —
265
+ // see the comment below. Said explicitly so the discarded-value
266
+ // ratchet does not carry a finding that is working as intended.
267
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
268
+ const { values, previousValues, ...rest } = props;
269
269
  const ofProperty = resolveProperty({ // we don't want to pass the values of the parent entity
270
270
  property: of,
271
271
  ignoreMissingFields,