@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.
- package/dist/data/buildRebaseData.d.ts +8 -4
- package/dist/data/paginate.d.ts +75 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.es.js +466 -23
- package/dist/index.es.js.map +1 -1
- package/dist/util/entities.d.ts +1 -1
- package/dist/util/index.d.ts +1 -0
- package/dist/util/pg-column-to-property.d.ts +25 -0
- package/package.json +3 -3
- package/src/data/buildRebaseData.ts +101 -13
- package/src/data/paginate.ts +290 -0
- package/src/index.ts +1 -0
- package/src/util/auth-default-policies.ts +4 -4
- package/src/util/entities.ts +1 -1
- package/src/util/index.ts +1 -0
- package/src/util/junction-policies.ts +1 -1
- package/src/util/pg-column-to-property.ts +335 -0
- package/src/util/relations.ts +3 -3
package/dist/util/entities.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/dist/util/index.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
44
|
-
"@rebasepro/utils": "0.
|
|
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
|
-
//
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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 **
|
|
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
|
-
*
|
|
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
|
|
580
|
-
*
|
|
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 = [...(
|
|
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 = (
|
|
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);
|
package/src/util/entities.ts
CHANGED
|
@@ -247,7 +247,7 @@ export interface RelationRefWithData extends RelationRef {
|
|
|
247
247
|
}
|
|
248
248
|
|
|
249
249
|
/**
|
|
250
|
-
* Create a lightweight relation stub for
|
|
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
|
@@ -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
|
|