@rebasepro/common 0.11.1-canary.gfd39654 → 0.12.0

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.
@@ -49,7 +49,7 @@ export interface RelationRefWithData extends RelationRef {
49
49
  readonly data: Entity;
50
50
  }
51
51
  /**
52
- * Create a lightweight relation stub for CMS views.
52
+ * Create a lightweight relation stub for admin views.
53
53
  * Replaces inline `{ id, path, __type: "relation" }` object literals.
54
54
  */
55
55
  export declare function createRelationRef(id: string | number, path: string): RelationRef;
@@ -15,3 +15,4 @@ export * from "./resolve-relation";
15
15
  export * from "./auth-default-policies";
16
16
  export * from "./junction-policies";
17
17
  export * from "./conditions";
18
+ export * from "./pg-column-to-property";
@@ -0,0 +1,25 @@
1
+ import type { PostgresProperties, Relation, SecurityRule, TableMetadata } from "@rebasepro/types";
2
+ /**
3
+ * A collection as introspection can describe it: the table, its columns, the
4
+ * relations its foreign keys imply, and the RLS policies already on it.
5
+ *
6
+ * Deliberately not `Partial<AdminCollection>`, which is what this returned
7
+ * while it lived in `@rebasepro/studio`. `propertiesOrder` is the only admin
8
+ * key it produces, and naming the admin view model for one field would put
9
+ * `@rebasepro/admin-types` on the dependency path of a package the backend
10
+ * loads.
11
+ */
12
+ export interface IntrospectedCollection {
13
+ name: string;
14
+ slug: string;
15
+ table: string;
16
+ properties: PostgresProperties;
17
+ propertiesOrder: string[];
18
+ relations?: Relation[];
19
+ securityRules?: SecurityRule[];
20
+ }
21
+ /**
22
+ * Builds a collection description from PostgreSQL table metadata.
23
+ * This is used when creating a new collection from an existing database table.
24
+ */
25
+ export declare function buildCollectionFromTableMetadata(tableName: string, metadata: TableMetadata): IntrospectedCollection;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/common",
3
3
  "type": "module",
4
- "version": "0.11.1-canary.gfd39654",
4
+ "version": "0.12.0",
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.0",
42
42
  "json-logic-js": "^2.0.5",
43
- "@rebasepro/types": "0.11.1-canary.gfd39654",
44
- "@rebasepro/utils": "0.11.1-canary.gfd39654"
43
+ "@rebasepro/types": "0.12.0",
44
+ "@rebasepro/utils": "0.12.0"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@jest/globals": "^30.4.1",
@@ -3,9 +3,11 @@ import {
3
3
  DataDriver,
4
4
  Entity,
5
5
  EntityValues,
6
+ FindAllParams,
6
7
  FindParams,
7
8
  FindResponse,
8
9
  FindResult,
10
+ IterateParams,
9
11
  LogicalCondition,
10
12
  RebaseData,
11
13
  RebaseSdkData,
@@ -16,6 +18,7 @@ import {
16
18
  } from "@rebasepro/types";
17
19
  import { toSnakeCase } from "@rebasepro/utils";
18
20
  import { QueryBuilder } from "./query_builder";
21
+ import { collectAllPages, paginateFind } from "./paginate";
19
22
  import { deserializeFilter } from "./filter-dialect";
20
23
  import { buildCompositeId, resolvePrimaryKeys, PrimaryKeyInfo } from "../util/identity";
21
24
 
@@ -95,6 +98,52 @@ function rowToEntity<M extends Record<string, unknown>>(
95
98
  };
96
99
  }
97
100
 
101
+ /**
102
+ * The relation envelope `toCmsRow` writes where a relation was:
103
+ * `{ id, path, __type: "relation", data: { id, path, values } }`. It is the
104
+ * admin's view-model, and the only pipeline that produces one is postgres'.
105
+ */
106
+ function isRelationEnvelope(
107
+ value: unknown
108
+ ): value is { __type: "relation"; data?: { values?: Record<string, unknown> } } {
109
+ return typeof value === "object"
110
+ && value !== null
111
+ && !Array.isArray(value)
112
+ && (value as { __type?: unknown }).__type === "relation";
113
+ }
114
+
115
+ /** The target's own columns, as `toRestRow` would have inlined them. */
116
+ function inlineEnvelope(envelope: { data?: { values?: Record<string, unknown> } }): Record<string, unknown> {
117
+ return envelope.data?.values ?? {};
118
+ }
119
+
120
+ /**
121
+ * Replace every relation envelope on a row with the target's flat columns.
122
+ *
123
+ * The SDK serves one relation shape — the inlined one (see
124
+ * {@link RestFetchService}) — and reads that come back through a *driver*
125
+ * method rather than the REST pipeline still carry envelopes. Realtime is the
126
+ * one such read left: there is no `listenForRest`, so the rows arrive shaped
127
+ * for the admin and are flattened here instead.
128
+ *
129
+ * Only applied where the REST pipeline is the contract (see `find`); a driver
130
+ * without a `restFetchService` keeps whatever it returns, so the admin's own
131
+ * path through {@link buildRebaseData} is untouched.
132
+ */
133
+ function inlineRelationRefs(row: Record<string, unknown>): Record<string, unknown> {
134
+ let out: Record<string, unknown> | undefined;
135
+ for (const [key, value] of Object.entries(row)) {
136
+ if (isRelationEnvelope(value)) {
137
+ out = out ?? { ...row };
138
+ out[key] = inlineEnvelope(value);
139
+ } else if (Array.isArray(value) && value.some(isRelationEnvelope)) {
140
+ out = out ?? { ...row };
141
+ out[key] = value.map((item) => isRelationEnvelope(item) ? inlineEnvelope(item) : item);
142
+ }
143
+ }
144
+ return out ?? row;
145
+ }
146
+
98
147
  function createDriverAccessor<M extends Record<string, unknown> = Record<string, unknown>>(
99
148
  driver: DataDriver,
100
149
  slug: string,
@@ -107,9 +156,23 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
107
156
  const limit = params?.limit ?? 20;
108
157
  const offset = params?.offset ?? 0;
109
158
 
110
- // Use the RestFetchService for include-aware queries when available
159
+ // One relation shape, whatever the call looks like.
160
+ //
161
+ // This used to fork on `include`: asking for one ran the REST
162
+ // pipeline, which inlines a relation as the target's own columns;
163
+ // not asking ran the driver's own fetch, which eagerly loaded
164
+ // *every* relation and put a `{ __type: "relation" }` envelope
165
+ // where the foreign key was. The same method answered in two
166
+ // shapes, the generated types described only one, and a column
167
+ // typed `string` arrived as an object.
168
+ //
169
+ // The REST pipeline is the published contract — the shape the HTTP
170
+ // API serves for this same query, and what `RestFetchService`
171
+ // documents — so every read goes through it when the driver has
172
+ // one. Drivers without one (every browser driver, and so the
173
+ // admin's own path through `buildRebaseData`) are untouched.
111
174
  const fetchService = driver.restFetchService;
112
- const rows = (fetchService && params?.include && params.include.length > 0)
175
+ const rows = fetchService
113
176
  ? await fetchService.fetchCollectionForRest(
114
177
  slug,
115
178
  {
@@ -120,7 +183,7 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
120
183
  order: params?.orderBy?.[1],
121
184
  searchString: params?.searchString
122
185
  },
123
- params.include
186
+ params?.include
124
187
  )
125
188
  : await driver.fetchCollection<M>({
126
189
  path: slug,
@@ -147,7 +210,12 @@ function createDriverAccessor<M extends Record<string, unknown> = Record<string,
147
210
  },
148
211
 
149
212
  async findById(id: string | number): Promise<Entity<M> | undefined> {
150
- const row = await driver.fetchOne<M>({ path: slug, id: id });
213
+ // Same contract as `find` above: one row read the same way the
214
+ // collection read is, so `find()[0]` and `findById()` agree.
215
+ const fetchService = driver.restFetchService;
216
+ const row = fetchService
217
+ ? await fetchService.fetchOneForRest(slug, id)
218
+ : await driver.fetchOne<M>({ path: slug, id: id });
151
219
  return row ? rowToEntity<M>(row, slug, getPks()) : undefined;
152
220
  },
153
221
 
@@ -204,6 +272,10 @@ values: {} as Record<string, unknown> }
204
272
  ? (params: FindParams<M> | undefined, onUpdate: (response: FindResponse<M>) => void, onError?: (error: Error) => void) => {
205
273
  const limit = params?.limit ?? 20;
206
274
  const offset = params?.offset ?? 0;
275
+ // Realtime has no REST-pipeline equivalent, so the rows arrive
276
+ // admin-shaped. Flatten them to the one shape the rest of this
277
+ // accessor serves.
278
+ const normalize = driver.restFetchService ? inlineRelationRefs : (row: Record<string, unknown>) => row;
207
279
  return driver.listenCollection!<M>({
208
280
  path: slug,
209
281
  limit: params?.limit,
@@ -214,7 +286,7 @@ values: {} as Record<string, unknown> }
214
286
  searchString: params?.searchString,
215
287
  onUpdate: (entities) => {
216
288
  onUpdate({
217
- data: entities.map((row: Record<string, unknown>) => rowToEntity<M>(row, slug, getPks())),
289
+ data: entities.map((row: Record<string, unknown>) => rowToEntity<M>(normalize(row), slug, getPks())),
218
290
  meta: {
219
291
  total: entities.length,
220
292
  limit,
@@ -229,10 +301,11 @@ values: {} as Record<string, unknown> }
229
301
 
230
302
  listenById: driver.listenOne
231
303
  ? (id: string | number, onUpdate: (entity: Entity<M> | undefined) => void, onError?: (error: Error) => void) => {
304
+ const normalize = driver.restFetchService ? inlineRelationRefs : (row: Record<string, unknown>) => row;
232
305
  return driver.listenOne!<M>({
233
306
  path: slug,
234
307
  id: id,
235
- onUpdate: (entity) => onUpdate(entity ? rowToEntity<M>(entity, slug, getPks()) : undefined),
308
+ onUpdate: (entity) => onUpdate(entity ? rowToEntity<M>(normalize(entity), slug, getPks()) : undefined),
236
309
  onError
237
310
  });
238
311
  } : undefined,
@@ -391,13 +464,24 @@ class SdkQueryBuilder<M extends Record<string, unknown> = Record<string, unknown
391
464
  * so the backend SDK is byte-for-byte the same shape as the frontend client.
392
465
  */
393
466
  function toSdkCollectionClient<M extends Record<string, unknown>>(
394
- snap: CollectionAccessor<M>
467
+ snap: CollectionAccessor<M>,
468
+ slug = "collection"
395
469
  ): SDKCollectionClient<M> {
396
470
  const client: SDKCollectionClient<M> = {
397
471
  async find(params?: FindParams<M>): Promise<FindResult<M>> {
398
472
  const res = await snap.find(params);
399
473
  return { data: res.data.map(entityToRow), meta: res.meta };
400
474
  },
475
+ // Pagination is shared with the HTTP client rather than reimplemented:
476
+ // both transports satisfy the same `SDKCollectionClient`, so a walk that
477
+ // behaved differently in-process than over the wire would be a bug the
478
+ // type system could not see.
479
+ iterate(params?: IterateParams<M>) {
480
+ return paginateFind<M>((p) => client.find(p), params, slug);
481
+ },
482
+ findAll(params?: FindAllParams<M>) {
483
+ return collectAllPages<M>((p) => client.find(p), params, slug);
484
+ },
401
485
  async findById(id: string | number): Promise<M | undefined> {
402
486
  const s = await snap.findById(id);
403
487
  return s ? entityToRow(s) : undefined;
@@ -453,7 +537,7 @@ function toSdkCollectionClient<M extends Record<string, unknown>>(
453
537
  /**
454
538
  * Wrap a flat {@link SDKCollectionClient} into a Entity-shaped
455
539
  * {@link CollectionAccessor}. Every returned row is re-wrapped into the
456
- * `{ id, path, values }` view-model the admin CMS renders.
540
+ * `{ id, path, values }` view-model the admin admin renders.
457
541
  */
458
542
  function toEntityAccessor<M extends Record<string, unknown>>(
459
543
  sdk: SDKCollectionClient<M>,
@@ -508,10 +592,10 @@ function toEntityAccessor<M extends Record<string, unknown>>(
508
592
  /**
509
593
  * Wrap a flat {@link RebaseSdkData} into a Entity-shaped {@link RebaseData}.
510
594
  *
511
- * This is the **CMS boundary**: the SDK client (`client.data`) returns flat
595
+ * This is the **admin boundary**: the SDK client (`client.data`) returns flat
512
596
  * rows, but the admin renders the `Entity` view-model (`entity.values.*`).
513
597
  * `core/Rebase.tsx` wraps `client.data` through this before handing it to the
514
- * CMS `RebaseDataContext` — without it the admin renders rows with only their
598
+ * admin `RebaseDataContext` — without it the admin renders rows with only their
515
599
  * `id`.
516
600
  */
517
601
  export function wrapAsEntityData(sdkData: RebaseSdkData, options?: EntityDataOptions): RebaseData {
@@ -553,7 +637,7 @@ export function wrapAsSdkData(entityData: RebaseData): RebaseSdkData {
553
637
  function getAccessor(slug: string): SDKCollectionClient {
554
638
  let accessor = cache.get(slug);
555
639
  if (!accessor) {
556
- accessor = toSdkCollectionClient(entityData.collection(slug));
640
+ accessor = toSdkCollectionClient(entityData.collection(slug), slug);
557
641
  cache.set(slug, accessor);
558
642
  }
559
643
  return accessor;
@@ -576,8 +660,12 @@ export function wrapAsSdkData(entityData: RebaseData): RebaseSdkData {
576
660
  *
577
661
  * This is the developer-facing SDK data layer used by backend framework
578
662
  * callbacks & scripts (`context.data` / `rebase.data`). It returns flat rows —
579
- * identical in shape to the frontend SDK client so the API is symmetric
580
- * across front and back. The admin CMS uses {@link buildRebaseData} (Entity).
663
+ * identical in shape to the frontend SDK client, down to how a relation is
664
+ * served: a foreign key stays a foreign key, and a relation named in `include`
665
+ * arrives as the target's own columns. The `{ __type: "relation" }` envelope is
666
+ * the admin's view-model and never reaches here.
667
+ *
668
+ * The admin uses {@link buildRebaseData} (Entity) over its own driver.
581
669
  */
582
670
  export function buildSdkData(driver: DataDriver): RebaseSdkData {
583
671
  return wrapAsSdkData(buildRebaseData(driver));
@@ -0,0 +1,290 @@
1
+ import {
2
+ FilterValues,
3
+ FieldPath,
4
+ FindAllParams,
5
+ FindParams,
6
+ FindResult,
7
+ IterateParams,
8
+ WhereFilterOp
9
+ } from "@rebasepro/types";
10
+
11
+ /**
12
+ * The pagination engine behind `iterate()` / `findAll()`.
13
+ *
14
+ * It lives here, above both transports, on purpose: the HTTP client and the
15
+ * in-process accessor implement the same `SDKCollectionClient` contract, and a
16
+ * helper written twice is a helper that drifts. Both call into this file, so
17
+ * "the SDK paginates like *this*" has exactly one definition.
18
+ *
19
+ * Everything below is expressed in terms of a single `find(params)` function,
20
+ * which is all either transport has to supply.
21
+ */
22
+
23
+ /** Rows requested per page when the caller does not say. */
24
+ export const DEFAULT_PAGE_SIZE = 200;
25
+
26
+ /** Rows `findAll()` will materialise before it refuses to continue. */
27
+ export const DEFAULT_FIND_ALL_MAX_ROWS = 10_000;
28
+
29
+ /**
30
+ * Requests one walk may make before it gives up on the server ever saying
31
+ * `hasMore: false`. At the default page size that is two million rows — far
32
+ * past any legitimate walk, and short of running forever.
33
+ */
34
+ export const DEFAULT_MAX_PAGES = 10_000;
35
+
36
+ /** Why a pagination walk refused to continue. */
37
+ export type PaginationErrorCode =
38
+ /** `findAll()` matched more rows than its ceiling allows. */
39
+ | "max-rows"
40
+ /** The walk made its maximum number of requests without the server finishing. */
41
+ | "max-pages"
42
+ /** A cursor row carried no value for the cursor column. */
43
+ | "cursor-missing"
44
+ /** Two consecutive pages ended on the same cursor value, so the walk cannot advance. */
45
+ | "cursor-stalled"
46
+ /** A `cursor` was asked for on one column while `orderBy` sorted by another. */
47
+ | "cursor-order-mismatch";
48
+
49
+ /**
50
+ * Thrown when a walk stops for a reason the caller needs to know about.
51
+ *
52
+ * Every one of these is a case where the alternative would be silent: a
53
+ * truncated array that looks complete, or a loop that never returns. Check
54
+ * {@link code} to tell them apart.
55
+ */
56
+ export class RebasePaginationError extends Error {
57
+ readonly code: PaginationErrorCode;
58
+
59
+ constructor(code: PaginationErrorCode, message: string) {
60
+ super(message);
61
+ this.name = "RebasePaginationError";
62
+ this.code = code;
63
+ // Keeps `instanceof` working when this is compiled down for an older
64
+ // target, where extending a builtin otherwise loses the prototype.
65
+ Object.setPrototypeOf(this, RebasePaginationError.prototype);
66
+ }
67
+ }
68
+
69
+ /** The one thing a transport has to provide to be paginated. */
70
+ export type PageFinder<M extends Record<string, unknown> = Record<string, unknown>> =
71
+ (params: FindParams<M>) => Promise<FindResult<M>>;
72
+
73
+ function normalizePageSize(raw: number | undefined): number {
74
+ if (raw === undefined || !Number.isFinite(raw)) return DEFAULT_PAGE_SIZE;
75
+ return Math.max(1, Math.floor(raw));
76
+ }
77
+
78
+ function normalizeMaxPages(raw: number | undefined): number {
79
+ if (raw === undefined) return DEFAULT_MAX_PAGES;
80
+ if (raw === Number.POSITIVE_INFINITY) return raw;
81
+ if (!Number.isFinite(raw)) return DEFAULT_MAX_PAGES;
82
+ return Math.max(1, Math.floor(raw));
83
+ }
84
+
85
+ function normalizeMaxRows(raw: number | undefined): number {
86
+ if (raw === undefined) return DEFAULT_FIND_ALL_MAX_ROWS;
87
+ if (raw === Number.POSITIVE_INFINITY) return raw;
88
+ if (!Number.isFinite(raw)) return DEFAULT_FIND_ALL_MAX_ROWS;
89
+ return Math.max(0, Math.floor(raw));
90
+ }
91
+
92
+ /**
93
+ * Add one condition to a `where` map without disturbing what is already there.
94
+ *
95
+ * The caller's own filter on the cursor column has to survive — dropping it
96
+ * would widen the query, which is the silent-filter-loss failure mode — so a
97
+ * second condition on the same column becomes the array-of-tuples form that
98
+ * `FindParams.where` already accepts, and both are AND-ed.
99
+ */
100
+ function appendCondition<M extends Record<string, unknown>>(
101
+ where: FilterValues<FieldPath<M>> | undefined,
102
+ column: string,
103
+ condition: [WhereFilterOp, unknown]
104
+ ): FilterValues<FieldPath<M>> {
105
+ const next = { ...(where ?? {}) } as Record<string, unknown>;
106
+ const existing = next[column];
107
+ if (existing === undefined) {
108
+ next[column] = condition;
109
+ } else if (Array.isArray(existing) && existing.length > 0 && Array.isArray(existing[0])) {
110
+ next[column] = [...(existing as [WhereFilterOp, unknown][]), condition];
111
+ } else {
112
+ next[column] = [existing, condition];
113
+ }
114
+ return next as FilterValues<FieldPath<M>>;
115
+ }
116
+
117
+ function cursorEquals(a: unknown, b: unknown): boolean {
118
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
119
+ return Object.is(a, b);
120
+ }
121
+
122
+ /**
123
+ * Walk every row a query matches, yielding one row at a time and fetching the
124
+ * next page only when the consumer asks for it.
125
+ *
126
+ * See {@link SDKCollectionClient.iterate} for the caller-facing contract,
127
+ * including the offset-drift caveat and the `cursor` alternative.
128
+ *
129
+ * @param find the transport's single-page read
130
+ * @param params `find()` parameters minus the window, plus the walk options
131
+ * @param label the collection name, so an error says which walk failed
132
+ */
133
+ export async function* paginateFind<M extends Record<string, unknown> = Record<string, unknown>>(
134
+ find: PageFinder<M>,
135
+ params?: IterateParams<M>,
136
+ label = "collection"
137
+ ): AsyncGenerator<M, void, undefined> {
138
+ const {
139
+ pageSize,
140
+ cursor,
141
+ maxPages,
142
+ ...rest
143
+ } = (params ?? {}) as IterateParams<M> & Record<string, unknown>;
144
+
145
+ const findParams = { ...rest } as FindParams<M>;
146
+ const size = normalizePageSize(pageSize as number | undefined);
147
+ const pageCap = normalizeMaxPages(maxPages as number | undefined);
148
+
149
+ // ── Cursor (keyset) setup ────────────────────────────────────────────────
150
+ const cursorField = typeof cursor === "string" ? cursor : cursor?.field;
151
+ const requestedDirection = (typeof cursor === "object" && cursor !== null)
152
+ ? cursor.direction
153
+ : undefined;
154
+
155
+ let direction: "asc" | "desc" = "asc";
156
+ if (cursorField) {
157
+ const orderBy = findParams.orderBy;
158
+ if (orderBy && orderBy[0] !== cursorField) {
159
+ throw new RebasePaginationError(
160
+ "cursor-order-mismatch",
161
+ `Cannot seek on "${cursorField}" while ordering "${label}" by "${orderBy[0]}": ` +
162
+ `keyset pagination only advances along the column the query is sorted by. ` +
163
+ `Order by "${cursorField}", or drop the cursor and page by offset.`
164
+ );
165
+ }
166
+ direction = requestedDirection ?? orderBy?.[1] ?? "asc";
167
+ findParams.orderBy = [cursorField, direction] as FindParams<M>["orderBy"];
168
+ }
169
+ const seekOp: WhereFilterOp = direction === "desc" ? "<" : ">";
170
+ const baseWhere = findParams.where;
171
+
172
+ let offset = 0;
173
+ let pages = 0;
174
+ let cursorValue: unknown;
175
+ let seeking = false;
176
+
177
+ for (;;) {
178
+ if (pages >= pageCap) {
179
+ throw new RebasePaginationError(
180
+ "max-pages",
181
+ `Iterating "${label}" made ${pages} requests without the server reporting the end of ` +
182
+ `the collection. Stopping rather than looping forever — raise \`maxPages\` if the walk ` +
183
+ `is genuinely this long, or check that the backend sets \`meta.hasMore\`.`
184
+ );
185
+ }
186
+
187
+ const pageParams: FindParams<M> = { ...findParams, limit: size };
188
+ if (cursorField) {
189
+ if (seeking) {
190
+ pageParams.where = appendCondition<M>(baseWhere, cursorField, [seekOp, cursorValue]);
191
+ }
192
+ } else {
193
+ pageParams.offset = offset;
194
+ }
195
+
196
+ const page = await find(pageParams);
197
+ pages += 1;
198
+
199
+ const rows = page?.data ?? [];
200
+ // A page with nothing on it always ends the walk, whatever the server
201
+ // claims about `hasMore` — there is no cursor to advance and no offset
202
+ // that would ever move past it.
203
+ if (rows.length === 0) return;
204
+
205
+ for (const row of rows) {
206
+ yield row;
207
+ }
208
+
209
+ // The server is the only authority on whether more rows exist. Never
210
+ // infer it from `rows.length >= size`: a last page that happens to be
211
+ // exactly full is indistinguishable from a middle one, and guessing
212
+ // there drops every row after it.
213
+ if (page?.meta?.hasMore !== true) return;
214
+
215
+ if (cursorField) {
216
+ const last = rows[rows.length - 1] as Record<string, unknown>;
217
+ const nextValue = last?.[cursorField];
218
+ if (nextValue === undefined || nextValue === null) {
219
+ throw new RebasePaginationError(
220
+ "cursor-missing",
221
+ `Cannot seek past the last row of "${label}": it has no value for the cursor ` +
222
+ `column "${cursorField}". Pick a column that is present and non-null on every row.`
223
+ );
224
+ }
225
+ if (seeking && cursorEquals(nextValue, cursorValue)) {
226
+ throw new RebasePaginationError(
227
+ "cursor-stalled",
228
+ `Iterating "${label}" is stuck: two pages in a row ended at ` +
229
+ `${cursorField}=${String(nextValue)}. The cursor column has to be unique — a ` +
230
+ `repeated value cannot be seeked past, and continuing would either loop forever ` +
231
+ `or skip the duplicates. Use the primary key, or page by offset.`
232
+ );
233
+ }
234
+ cursorValue = nextValue;
235
+ seeking = true;
236
+ } else {
237
+ // Advance by what actually arrived, not by the page size: a server
238
+ // free to return fewer rows than asked for would otherwise leave a
239
+ // hole in the walk.
240
+ offset += rows.length;
241
+ }
242
+ }
243
+ }
244
+
245
+ /**
246
+ * {@link paginateFind}, collected into an array under a ceiling.
247
+ *
248
+ * See {@link SDKCollectionClient.findAll}.
249
+ */
250
+ export async function collectAllPages<M extends Record<string, unknown> = Record<string, unknown>>(
251
+ find: PageFinder<M>,
252
+ params?: FindAllParams<M>,
253
+ label = "collection"
254
+ ): Promise<M[]> {
255
+ const { maxRows, ...rest } = (params ?? {}) as FindAllParams<M> & Record<string, unknown>;
256
+ const cap = normalizeMaxRows(maxRows as number | undefined);
257
+
258
+ const out: M[] = [];
259
+ for await (const row of paginateFind<M>(find, rest as IterateParams<M>, label)) {
260
+ out.push(row);
261
+ if (out.length > cap) {
262
+ throw new RebasePaginationError(
263
+ "max-rows",
264
+ `findAll("${label}") matched more than ${cap} rows. Returning the first ${cap} would ` +
265
+ `look like the whole answer and quietly not be one, so this throws instead. Raise ` +
266
+ `\`maxRows\` if you meant to load them all, or stream with \`iterate()\`.`
267
+ );
268
+ }
269
+ }
270
+ return out;
271
+ }
272
+
273
+ /**
274
+ * Build the `iterate` / `findAll` pair for one collection from its `find`.
275
+ *
276
+ * Both transports call this, which is what keeps the two implementations from
277
+ * being two implementations.
278
+ */
279
+ export function createPaginationHelpers<M extends Record<string, unknown> = Record<string, unknown>>(
280
+ find: PageFinder<M>,
281
+ label: string
282
+ ): {
283
+ iterate: (params?: IterateParams<M>) => AsyncIterableIterator<M>;
284
+ findAll: (params?: FindAllParams<M>) => Promise<M[]>;
285
+ } {
286
+ return {
287
+ iterate: (params?: IterateParams<M>) => paginateFind<M>(find, params, label),
288
+ findAll: (params?: FindAllParams<M>) => collectAllPages<M>(find, params, label)
289
+ };
290
+ }
package/src/index.ts CHANGED
@@ -4,6 +4,7 @@ export * from "./data/buildRebaseData";
4
4
  export * from "./data/buildRoutedRebaseData";
5
5
  export * from "./data/resolveDataSource";
6
6
  export * from "./data/query_builder";
7
+ export * from "./data/paginate";
7
8
  export * from "./data/filter-dialect";
8
9
  export * from "./data/sort-dialect";
9
10
  export * from "./table-classification";
@@ -83,9 +83,9 @@ function getIdPropertyName(collection: CollectionConfig): string {
83
83
  * Collections that opt out via `disableDefaultPolicies` are returned unchanged.
84
84
  */
85
85
  export function getEffectiveSecurityRules(collection: CollectionConfig): SecurityRule[] {
86
- const explicit = [...((isPostgresCollectionConfig(collection) ? collection.securityRules : undefined) ?? [])];
86
+ const explicit = [...(collection.securityRules ?? [])];
87
87
 
88
- if (collection.disableDefaultPolicies) {
88
+ if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) {
89
89
  return explicit;
90
90
  }
91
91
 
@@ -143,9 +143,9 @@ export function getEffectiveSecurityRules(collection: CollectionConfig): Securit
143
143
  * DDL, which policies are injected and how to take them off.
144
144
  */
145
145
  export function getInjectedSecurityRules(collection: CollectionConfig): SecurityRule[] {
146
- if (collection.disableDefaultPolicies) return [];
146
+ if (isPostgresCollectionConfig(collection) && collection.disableDefaultPolicies) return [];
147
147
 
148
- const explicitCount = ((isPostgresCollectionConfig(collection) ? collection.securityRules : undefined) ?? []).length;
148
+ const explicitCount = (collection.securityRules ?? []).length;
149
149
  // getEffectiveSecurityRules appends the defaults after the author's rules,
150
150
  // so everything past the author's count is injected.
151
151
  return getEffectiveSecurityRules(collection).slice(explicitCount);
@@ -247,7 +247,7 @@ export interface RelationRefWithData extends RelationRef {
247
247
  }
248
248
 
249
249
  /**
250
- * Create a lightweight relation stub for CMS views.
250
+ * Create a lightweight relation stub for admin views.
251
251
  * Replaces inline `{ id, path, __type: "relation" }` object literals.
252
252
  */
253
253
  export function createRelationRef(id: string | number, path: string): RelationRef {
package/src/util/index.ts CHANGED
@@ -15,3 +15,4 @@ export * from "./resolve-relation";
15
15
  export * from "./auth-default-policies";
16
16
  export * from "./junction-policies";
17
17
  export * from "./conditions";
18
+ export * from "./pg-column-to-property";
@@ -268,7 +268,7 @@ function coversUpdate(rule: SecurityRule): boolean {
268
268
  * stays locked (RLS is still enabled) until they write policies for it.
269
269
  */
270
270
  export function getJunctionSecurityRules(spec: JunctionSpec): SecurityRule[] {
271
- if (spec.declaringSides.every(side => side.collection.disableDefaultPolicies)) {
271
+ if (spec.declaringSides.every(side => isPostgresCollectionConfig(side.collection) && side.collection.disableDefaultPolicies)) {
272
272
  return [];
273
273
  }
274
274