@rebasepro/types 0.13.1-canary.gf57a27e → 0.14.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/call_context.d.ts +17 -8
- package/dist/controllers/client.d.ts +31 -15
- package/dist/controllers/data.d.ts +84 -9
- package/dist/controllers/data_driver.d.ts +58 -8
- package/dist/index.es.js +106 -8
- package/dist/index.es.js.map +1 -1
- package/dist/types/admin_block.d.ts +37 -0
- package/dist/types/auth_adapter.d.ts +7 -0
- package/dist/types/collections.d.ts +10 -5
- package/dist/types/properties.d.ts +9 -2
- package/package.json +1 -1
- package/src/call_context.ts +17 -8
- package/src/controllers/client.ts +31 -15
- package/src/controllers/data.ts +95 -9
- package/src/controllers/data_driver.ts +95 -11
- package/src/types/admin_block.ts +83 -0
- package/src/types/auth_adapter.ts +7 -0
- package/src/types/collections.ts +10 -5
- package/src/types/filter-operators.ts +17 -1
- package/src/types/properties.ts +9 -2
|
@@ -52,3 +52,40 @@ export type AdminCollectionKey = typeof ADMIN_COLLECTION_KEYS[number];
|
|
|
52
52
|
export declare const ADMIN_PROPERTY_KEYS: readonly ["canAddElements", "clearable", "columnWidth", "customProps", "disabled", "expanded", "Field", "Filter", "filterOperators", "fixedFilter", "hideFromCollection", "includeEntityLink", "includeId", "markdown", "minimalistView", "multiline", "Preview", "previewAsTag", "previewProperties", "readOnly", "sortable", "span", "spreadChildren", "urlPreview", "widget"];
|
|
53
53
|
/** A key of a property's `admin` block. @group Models */
|
|
54
54
|
export type AdminPropertyKey = typeof ADMIN_PROPERTY_KEYS[number];
|
|
55
|
+
/**
|
|
56
|
+
* Move flattened admin keys back down into the `admin` block.
|
|
57
|
+
*
|
|
58
|
+
* The admin panel works with a *flat* view model — the block merged onto the
|
|
59
|
+
* collection — so what comes back from a form has `icon` and `defaultViewMode`
|
|
60
|
+
* at the top level while `admin` still holds whatever the file was loaded with.
|
|
61
|
+
* This is the way back.
|
|
62
|
+
*
|
|
63
|
+
* **The top-level value wins.** It is the one the form just wrote; the block is
|
|
64
|
+
* the copy the collection was loaded with, and preferring it resolves every edit
|
|
65
|
+
* in favour of the value the user changed away from.
|
|
66
|
+
*
|
|
67
|
+
* This lives here, next to the key lists, because it had two implementations —
|
|
68
|
+
* `toAdminCollectionConfig` in `@rebasepro/admin-types` and `nestAdminKeys` in
|
|
69
|
+
* `@rebasepro/server`'s schema editor — that agreed on everything except that
|
|
70
|
+
* precedence, which is the only part that decides whether a save is visible.
|
|
71
|
+
*
|
|
72
|
+
* @group Models
|
|
73
|
+
*/
|
|
74
|
+
export declare function nestAdminKeysOf(source: Record<string, unknown>, adminKeys: readonly string[]): Record<string, unknown>;
|
|
75
|
+
/**
|
|
76
|
+
* {@link nestAdminKeysOf} for a collection.
|
|
77
|
+
*
|
|
78
|
+
* @group Models
|
|
79
|
+
*/
|
|
80
|
+
export declare function nestAdminCollectionKeys(collection: Record<string, unknown>): Record<string, unknown>;
|
|
81
|
+
/**
|
|
82
|
+
* {@link nestAdminKeysOf} for a property, applied to its children too.
|
|
83
|
+
*
|
|
84
|
+
* A map property carries `properties`, an array property carries `of`, and both
|
|
85
|
+
* hold properties with `admin` blocks of their own. A flat `readOnly` left on a
|
|
86
|
+
* child is as dead — and as fatal at the next boot — as one left on the parent,
|
|
87
|
+
* so the walk goes all the way down.
|
|
88
|
+
*
|
|
89
|
+
* @group Models
|
|
90
|
+
*/
|
|
91
|
+
export declare function nestAdminPropertyKeys(property: Record<string, unknown>): Record<string, unknown>;
|
|
@@ -104,6 +104,13 @@ export interface AuthAdapterCapabilities {
|
|
|
104
104
|
emailVerification: boolean;
|
|
105
105
|
/** Supports passwordless magic link login. */
|
|
106
106
|
magicLink: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* Whether `POST /auth/anonymous` will mint a credential-less session.
|
|
109
|
+
*
|
|
110
|
+
* Optional so an external adapter that predates the key keeps typechecking;
|
|
111
|
+
* absent means "this adapter does not offer anonymous sign-in".
|
|
112
|
+
*/
|
|
113
|
+
anonymousLogin?: boolean;
|
|
107
114
|
/** List of enabled OAuth provider IDs (e.g. `["google", "github"]`). */
|
|
108
115
|
enabledProviders: string[];
|
|
109
116
|
/**
|
|
@@ -155,11 +155,16 @@ export interface BaseCollectionConfig<M extends Record<string, unknown> = Record
|
|
|
155
155
|
* Whether a write naming a field this collection does not declare is
|
|
156
156
|
* rejected with a 400. Defaults to `true`.
|
|
157
157
|
*
|
|
158
|
-
* Set to `false`
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
158
|
+
* Set to `false` where a column really does exist that the config never
|
|
159
|
+
* declared — populated by a trigger, or introspected rather than declared —
|
|
160
|
+
* and callers need to write it. The column still has to exist: the driver
|
|
161
|
+
* checks the key against the table's own columns whatever this is set to,
|
|
162
|
+
* because a key with no column behind it is not passed to the database and
|
|
163
|
+
* refused, it is dropped from the statement and answered 201.
|
|
164
|
+
*
|
|
165
|
+
* It does not let a typo through to Postgres for Postgres to judge. That is
|
|
166
|
+
* what this flag was documented as doing, and no such judgment ever
|
|
167
|
+
* happened.
|
|
163
168
|
*/
|
|
164
169
|
strictWrites?: boolean;
|
|
165
170
|
}
|
|
@@ -159,12 +159,19 @@ export interface BaseProperty<CustomProps = unknown> {
|
|
|
159
159
|
*/
|
|
160
160
|
validation?: PropertyValidationSchema;
|
|
161
161
|
/**
|
|
162
|
-
* Never
|
|
162
|
+
* Never mention this column on the API surface, in either direction.
|
|
163
163
|
*
|
|
164
164
|
* For secrets the server must store and read but no client should ever
|
|
165
165
|
* receive — password hashes, verification tokens. The value is still
|
|
166
166
|
* written and queryable server-side; it is stripped from every row the API
|
|
167
|
-
* serves, for every caller, including admins and service keys
|
|
167
|
+
* serves, for every caller, including admins and service keys, and it is
|
|
168
|
+
* absent from every generated description of the surface: the SDK's `Row`,
|
|
169
|
+
* `Insert` and `Update` types, and the OpenAPI schemas, filters and
|
|
170
|
+
* parameters.
|
|
171
|
+
*
|
|
172
|
+
* The generated types are a *description*, not a second enforcement point:
|
|
173
|
+
* the server still accepts such a field on a write, because that is how the
|
|
174
|
+
* value gets written in the first place. Nothing generated offers it.
|
|
168
175
|
*
|
|
169
176
|
* This is a server-side guarantee, unlike `admin.hideFromCollection`, which
|
|
170
177
|
* only stops the admin panel from *rendering* a field and leaves it in the
|
package/package.json
CHANGED
package/src/call_context.ts
CHANGED
|
@@ -25,11 +25,13 @@ export type RebaseCallContext<USER extends User = User> = {
|
|
|
25
25
|
* Use it to call backend functions, access storage, send email, etc.
|
|
26
26
|
*
|
|
27
27
|
* ⚠️ **Not the same trust level as {@link data}.** Server-side this is the
|
|
28
|
-
* app singleton, so `client.dataAsAdmin` is **always** the
|
|
29
|
-
* plane —
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
28
|
+
* app singleton, so `client.dataAsAdmin` is **always** the admin-scoped
|
|
29
|
+
* plane — scoped as `{ uid: "service", roles: ["admin"] }`, so policies are
|
|
30
|
+
* evaluated against that identity rather than skipped — while {@link data},
|
|
31
|
+
* one property over, follows whoever triggered the callback. On a user
|
|
32
|
+
* request, reaching for `context.client.dataAsAdmin` silently escalates a
|
|
33
|
+
* user-scoped operation to admin. For queries in a callback use
|
|
34
|
+
* {@link data}; come here for functions, storage and email.
|
|
33
35
|
*
|
|
34
36
|
* @example
|
|
35
37
|
* // In a beforeSave callback:
|
|
@@ -53,14 +55,21 @@ export type RebaseCallContext<USER extends User = User> = {
|
|
|
53
55
|
* user-scoped. The callback runs on the RLS-bound transaction opened for
|
|
54
56
|
* that request, so policies apply to reads *and* writes — a callback
|
|
55
57
|
* cannot see a row its caller could not.
|
|
56
|
-
* - Triggered by
|
|
57
|
-
*
|
|
58
|
+
* - Triggered by **`rebase.dataAsAdmin` or a cron** (the same singleton):
|
|
59
|
+
* admin-scoped, not unscoped. That driver is scoped as
|
|
60
|
+
* `{ uid: "service", roles: ["admin"] }`, so the callback still runs on an
|
|
61
|
+
* RLS-bound transaction — policies are evaluated against that identity.
|
|
62
|
+
* - Triggered by **the base driver** (auth flows, migrations): unscoped, on
|
|
63
|
+
* the owner connection, bypassing RLS.
|
|
58
64
|
*
|
|
59
65
|
* So a callback that reads a sibling row will find it when an admin task
|
|
60
66
|
* saves and may find nothing when an end user saves — without an error,
|
|
61
67
|
* because RLS filters rather than raises. Write callbacks that tolerate
|
|
62
68
|
* that, or reach for {@link client}`.dataAsAdmin` deliberately when the
|
|
63
|
-
* callback genuinely has to see
|
|
69
|
+
* callback genuinely has to see what an admin may see. Note what that does
|
|
70
|
+
* *not* buy you: `policy.serverContext()` (`auth.uid() IS NULL`) is false
|
|
71
|
+
* for the service identity, so a collection whose only rule is
|
|
72
|
+
* `serverContext()` stays closed to it.
|
|
64
73
|
*
|
|
65
74
|
* Verified end-to-end against Postgres rather than asserted — see
|
|
66
75
|
* `"scopes context.data to the caller when a callback runs on a user
|
|
@@ -267,18 +267,31 @@ export interface RebaseClient<DB = unknown> {
|
|
|
267
267
|
data: RebaseSdkData<DB>;
|
|
268
268
|
|
|
269
269
|
/**
|
|
270
|
-
* Admin-scoped
|
|
270
|
+
* Admin-scoped data accessor — **not** an RLS bypass.
|
|
271
271
|
*
|
|
272
272
|
* Present on the **server** singleton only (see {@link RebaseServerClient}).
|
|
273
|
-
* It runs
|
|
274
|
-
*
|
|
275
|
-
*
|
|
273
|
+
* It runs as the service identity `{ uid: "service", roles: ["admin"] }`,
|
|
274
|
+
* and the driver is scoped with `withAuth()` at boot, so every read and
|
|
275
|
+
* write runs in a transaction that has switched to the restricted
|
|
276
|
+
* `rebase_user` role with `app.uid = 'service'`: policies are evaluated,
|
|
277
|
+
* against that identity. This is the correct tool for trusted background
|
|
278
|
+
* work (cron jobs, migrations, service-to-service tasks).
|
|
279
|
+
*
|
|
280
|
+
* Two consequences the name does not suggest:
|
|
281
|
+
*
|
|
282
|
+
* - `policy.serverContext()` compiles to `auth.uid() IS NULL` and is
|
|
283
|
+
* therefore **false** here. A collection with `disableDefaultPolicies:
|
|
284
|
+
* true` whose only rule is `serverContext()` refuses these writes
|
|
285
|
+
* (`42501`) and returns zero rows — HTTP 200, empty — for these reads.
|
|
286
|
+
* - Its reach equals an `admin`-roled application user's reach. It is not a
|
|
287
|
+
* private channel. The true bypass is {@link sql}, which runs on the
|
|
288
|
+
* owner connection and never goes through `withAuth`.
|
|
276
289
|
*
|
|
277
290
|
* ⚠️ **Do NOT use it to serve user-facing data.** Inside a request handler,
|
|
278
291
|
* user-scoped queries must go through the request-scoped driver
|
|
279
|
-
* (`c.var.driver`), which carries the caller's identity
|
|
280
|
-
*
|
|
281
|
-
*
|
|
292
|
+
* (`c.var.driver`), which carries the caller's identity. Reaching for
|
|
293
|
+
* `dataAsAdmin` (or its alias {@link data}) in a request handler serves
|
|
294
|
+
* every caller whatever an admin may see.
|
|
282
295
|
*
|
|
283
296
|
* Undefined in the browser SDK.
|
|
284
297
|
*/
|
|
@@ -397,17 +410,20 @@ export interface RebaseClient<DB = unknown> {
|
|
|
397
410
|
* the admin-scoped {@link dataAsAdmin} accessor, raw {@link sql}, and the
|
|
398
411
|
* {@link email} service are all present (non-optional).
|
|
399
412
|
*
|
|
400
|
-
* **Trust levels.** {@link dataAsAdmin} is the admin-scoped
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
405
|
-
*
|
|
413
|
+
* **Trust levels.** {@link dataAsAdmin} is the admin-scoped driver — scoped as
|
|
414
|
+
* `{ uid: "service", roles: ["admin"] }`, so policies are still evaluated
|
|
415
|
+
* against that identity rather than skipped — and it is the only name for it
|
|
416
|
+
* here: the `data` alias that used to sit beside it is deliberately `Omit`ted
|
|
417
|
+
* from {@link RebaseClient} so the privilege has to be spelled out at every
|
|
418
|
+
* call site. {@link sql} is the unconditional bypass: raw SQL on the owner
|
|
419
|
+
* connection, no policies. For user-scoped queries inside a request handler use
|
|
420
|
+
* the request-scoped driver (`c.var.driver`) instead — never `dataAsAdmin`.
|
|
406
421
|
*/
|
|
407
422
|
export interface RebaseServerClient<DB = unknown> extends Omit<RebaseClient<DB>, "data"> {
|
|
408
423
|
/**
|
|
409
|
-
* Admin-scoped
|
|
410
|
-
* See {@link RebaseClient.dataAsAdmin}
|
|
424
|
+
* Admin-scoped data accessor (RLS is evaluated as the service identity, not
|
|
425
|
+
* skipped). Always present server-side. See {@link RebaseClient.dataAsAdmin}
|
|
426
|
+
* for the full safety contract.
|
|
411
427
|
*/
|
|
412
428
|
dataAsAdmin: RebaseSdkData<DB>;
|
|
413
429
|
|
package/src/controllers/data.ts
CHANGED
|
@@ -3,8 +3,78 @@ import type { ComputedSortField, SearchMatch } from "../types/search";
|
|
|
3
3
|
import { Entity, EntityValues } from "../types/entities";
|
|
4
4
|
import { WhereFilterOp, FieldPath, FilterValues, OrderByTuple } from "../types/filter-operators";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Operator-blind filter value: whatever the column holds, a list of it, or null.
|
|
8
|
+
*
|
|
9
|
+
* @deprecated Superseded by {@link WhereValueFor}, which correlates the value
|
|
10
|
+
* with the operator. Kept exported because it is public API and downstream code
|
|
11
|
+
* annotates with it; every `where()` overload in this file uses `WhereValueFor`.
|
|
12
|
+
*/
|
|
6
13
|
export type WhereValue<T> = T | T[] | null;
|
|
7
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The element type of an array column, and the column's own type otherwise.
|
|
17
|
+
*
|
|
18
|
+
* A generated SDK emits an `array` property as `Array<X>` and a to-many
|
|
19
|
+
* relation as `Array<TargetRow>`, so this is what `array-contains` compares
|
|
20
|
+
* against on either.
|
|
21
|
+
*/
|
|
22
|
+
export type ElementOf<T> = T extends readonly (infer E)[] ? E : T;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The `id` of a row-shaped element, and `never` for anything else.
|
|
26
|
+
*
|
|
27
|
+
* A to-many relation is emitted as `Array<TargetRow>`, but the filter compilers
|
|
28
|
+
* compare a relation by **id** — `buildRelationFilterPredicate` in
|
|
29
|
+
* `@rebasepro/server-postgres` unwraps a relation value down to its id — so
|
|
30
|
+
* `where("tags", "array-contains", tagId)` is the call that works, and the
|
|
31
|
+
* element type alone would refuse it.
|
|
32
|
+
*/
|
|
33
|
+
export type IdOf<E> = E extends { id: infer I } ? I : never;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One member of an array column: its element, or — when the element is a row —
|
|
37
|
+
* that row's id, which is what a relation filter is actually compared against.
|
|
38
|
+
*/
|
|
39
|
+
export type WhereElementOf<T> = ElementOf<T> | IdOf<ElementOf<T>>;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The value a given operator takes on a column of type `T`.
|
|
43
|
+
*
|
|
44
|
+
* `WhereValue<T>` was one value type for all sixteen operators, which made
|
|
45
|
+
* `array-contains` uncallable from a generated SDK — it is the one operator
|
|
46
|
+
* whose value is an *element* of the column rather than the column's own type,
|
|
47
|
+
* so on `tags: string[]` it wanted a `string[]` and the documented
|
|
48
|
+
* `.where("tags", "array-contains", "featured")` was a compile error. The
|
|
49
|
+
* spelling that did compile, `["featured"]`, builds `@> ARRAY[$1]` with the
|
|
50
|
+
* whole array bound as the single element and matches nothing: the correct
|
|
51
|
+
* query rejected, the accepted query silently wrong.
|
|
52
|
+
*
|
|
53
|
+
* The branches mirror `buildSingleFilterCondition` in `@rebasepro/server-postgres`:
|
|
54
|
+
*
|
|
55
|
+
* - `array-contains` → one element of the column (or a related row's id).
|
|
56
|
+
* - `in` / `not-in` / `array-contains-any` → a list of elements; a bare element
|
|
57
|
+
* is read as the one-element list, and `null` is a null check.
|
|
58
|
+
* - `like` / `ilike` / `not-like` / `not-ilike` → a SQL pattern. Always a
|
|
59
|
+
* string, including on numeric and date columns, which the driver casts.
|
|
60
|
+
* - `is-null` / `is-not-null` → nothing; the value is ignored everywhere.
|
|
61
|
+
* - everything else → the column's own type, or `null` for a null comparison.
|
|
62
|
+
*
|
|
63
|
+
* Distributes over `Op`, so a caller holding an unnarrowed `WhereFilterOp`
|
|
64
|
+
* (a dynamic filter UI, say) gets the union of every branch and stays as
|
|
65
|
+
* permissive as it was.
|
|
66
|
+
*/
|
|
67
|
+
export type WhereValueFor<Op extends WhereFilterOp, T> =
|
|
68
|
+
Op extends "array-contains"
|
|
69
|
+
? WhereElementOf<T>
|
|
70
|
+
: Op extends "in" | "not-in" | "array-contains-any"
|
|
71
|
+
? readonly WhereElementOf<T>[] | WhereElementOf<T> | null
|
|
72
|
+
: Op extends "like" | "ilike" | "not-like" | "not-ilike"
|
|
73
|
+
? string
|
|
74
|
+
: Op extends "is-null" | "is-not-null"
|
|
75
|
+
? null | undefined
|
|
76
|
+
: T | null;
|
|
77
|
+
|
|
8
78
|
export interface LogicalCondition {
|
|
9
79
|
type: "and" | "or";
|
|
10
80
|
conditions: (FilterCondition | LogicalCondition)[];
|
|
@@ -49,9 +119,14 @@ export interface FindParams<M extends Record<string, unknown> = Record<string, u
|
|
|
49
119
|
/**
|
|
50
120
|
* Maximum number of items to return.
|
|
51
121
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
122
|
+
* Omit it and the backend applies {@link DEFAULT_LIST_LIMIT}, so a read is
|
|
123
|
+
* never unbounded. Provide it and it must be a whole number between 1 and
|
|
124
|
+
* {@link MAX_LIST_LIMIT}: the backend **rejects** anything else with a 400
|
|
125
|
+
* rather than trimming it to fit, because a page quietly smaller than the
|
|
126
|
+
* one you asked for is indistinguishable from having reached the end of the
|
|
127
|
+
* collection. To read past the ceiling, page with `offset` — or let
|
|
128
|
+
* {@link SDKCollectionClient.iterate} / {@link SDKCollectionClient.findAll}
|
|
129
|
+
* do it for you.
|
|
55
130
|
*/
|
|
56
131
|
limit?: number;
|
|
57
132
|
/**
|
|
@@ -170,7 +245,7 @@ export interface FindResponse<M extends Record<string, unknown> = Record<string,
|
|
|
170
245
|
* @group Data
|
|
171
246
|
*/
|
|
172
247
|
export interface QueryBuilderInterface<M extends Record<string, unknown> = Record<string, unknown>> {
|
|
173
|
-
where<K extends keyof M & string>(column: K, operator:
|
|
248
|
+
where<K extends keyof M & string, Op extends WhereFilterOp>(column: K, operator: Op, value: WhereValueFor<Op, M[K]>): this;
|
|
174
249
|
where(logicalCondition: LogicalCondition): this;
|
|
175
250
|
orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): this;
|
|
176
251
|
limit(count: number): this;
|
|
@@ -283,7 +358,7 @@ export interface CollectionAccessor<M extends Record<string, unknown> = Record<s
|
|
|
283
358
|
count?(params?: FindParams<M>): Promise<number>;
|
|
284
359
|
|
|
285
360
|
// Fluent Query Builder
|
|
286
|
-
where<K extends keyof M & string>(column: K, operator:
|
|
361
|
+
where<K extends keyof M & string, Op extends WhereFilterOp>(column: K, operator: Op, value: WhereValueFor<Op, M[K]>): QueryBuilderInterface<M>;
|
|
287
362
|
where(logicalCondition: LogicalCondition): QueryBuilderInterface<M>;
|
|
288
363
|
orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): QueryBuilderInterface<M>;
|
|
289
364
|
limit(count: number): QueryBuilderInterface<M>;
|
|
@@ -475,7 +550,7 @@ export type FindAllParams<M extends Record<string, unknown> = Record<string, unk
|
|
|
475
550
|
* @group Data
|
|
476
551
|
*/
|
|
477
552
|
export interface SDKQueryBuilderInterface<M extends Record<string, unknown> = Record<string, unknown>> {
|
|
478
|
-
where<K extends keyof M & string>(column: K, operator:
|
|
553
|
+
where<K extends keyof M & string, Op extends WhereFilterOp>(column: K, operator: Op, value: WhereValueFor<Op, M[K]>): this;
|
|
479
554
|
where(logicalCondition: LogicalCondition): this;
|
|
480
555
|
orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): this;
|
|
481
556
|
limit(count: number): this;
|
|
@@ -541,9 +616,20 @@ export interface WriteOptions {
|
|
|
541
616
|
* it performs it again. On a table with a server-assigned id that is a
|
|
542
617
|
* duplicate row, because the id the client chose was never used.
|
|
543
618
|
*
|
|
619
|
+
* A key names **one** request, not a job. It records the method, the path
|
|
620
|
+
* and the body it was claimed for, so re-sending that exact request replays
|
|
621
|
+
* its answer, while the same key on a different one is refused with
|
|
622
|
+
* `IDEMPOTENCY_KEY_REUSED` (422) rather than silently answered with the
|
|
623
|
+
* first request's result. Pass a fresh key — a uuid — per call; a reusable
|
|
624
|
+
* business id shared by the create and the delete of one import means the
|
|
625
|
+
* second of them never runs.
|
|
626
|
+
*
|
|
544
627
|
* Set by the offline queue on every replay. Honoured for 24 hours and scoped
|
|
545
|
-
* to the authenticated user
|
|
546
|
-
*
|
|
628
|
+
* to the authenticated user — an unauthenticated caller has no principal to
|
|
629
|
+
* scope it to, so the key is ignored there. A retry sent while the first
|
|
630
|
+
* attempt is still being answered gets `IDEMPOTENCY_KEY_IN_PROGRESS` (409)
|
|
631
|
+
* and should be sent again. A server that cannot store keys ignores the
|
|
632
|
+
* header rather than refusing the write.
|
|
547
633
|
*/
|
|
548
634
|
idempotencyKey?: string;
|
|
549
635
|
}
|
|
@@ -775,7 +861,7 @@ export interface SDKCollectionClient<
|
|
|
775
861
|
count?(params?: FindParams<M>): Promise<number>;
|
|
776
862
|
|
|
777
863
|
// Fluent Query Builder
|
|
778
|
-
where<K extends keyof M & string>(column: K, operator:
|
|
864
|
+
where<K extends keyof M & string, Op extends WhereFilterOp>(column: K, operator: Op, value: WhereValueFor<Op, M[K]>): SDKQueryBuilderInterface<M>;
|
|
779
865
|
where(logicalCondition: LogicalCondition): SDKQueryBuilderInterface<M>;
|
|
780
866
|
orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): SDKQueryBuilderInterface<M>;
|
|
781
867
|
limit(count: number): SDKQueryBuilderInterface<M>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { RebaseApiError } from "../errors";
|
|
1
2
|
import type { CollectionRegistryController } from "./collection_registry";
|
|
2
3
|
import type { EntityStatus, EntityValues } from "../types/entities";
|
|
3
4
|
import type { CollectionConfig, FilterValues } from "../types/collections";
|
|
@@ -52,12 +53,23 @@ export interface VectorSearchParams {
|
|
|
52
53
|
// server-side callers build fetch options directly and are intentionally NOT
|
|
53
54
|
// bounded here (migrations, admin exports, and CDC refetches may need the full
|
|
54
55
|
// set).
|
|
56
|
+
//
|
|
57
|
+
// A limit the platform will not serve is REFUSED, not quietly shrunk. Clamping
|
|
58
|
+
// answers a request for 100 000 rows with 1 000 of them, and a short page is
|
|
59
|
+
// indistinguishable from "that is all the data there is" — which is how a CSV
|
|
60
|
+
// export shipped 50 rows of a 100 000-row collection under a filename that read
|
|
61
|
+
// like the whole thing. `meta.total`/`meta.hasMore` make truncation *detectable*
|
|
62
|
+
// on the REST list response, but only for a caller who thinks to compare what it
|
|
63
|
+
// asked for against what it got, and the WebSocket `collection_update` frame
|
|
64
|
+
// carries neither — so signalling cannot be the answer on every surface and
|
|
65
|
+
// rejecting is. An ABSENT limit still defaults: naming no window is not the same
|
|
66
|
+
// as asking for one that cannot be served.
|
|
55
67
|
|
|
56
68
|
/** Rows returned for a plain / text-search list read when the client sends no `limit`. */
|
|
57
69
|
export const DEFAULT_LIST_LIMIT = 50;
|
|
58
70
|
/** Rows returned for a vector-search list read when the client sends no `limit`. */
|
|
59
71
|
export const DEFAULT_VECTOR_LIST_LIMIT = 10;
|
|
60
|
-
/**
|
|
72
|
+
/** Largest `limit` a client may ask for on any surface. Above it, the read is refused. */
|
|
61
73
|
export const MAX_LIST_LIMIT = 1000;
|
|
62
74
|
|
|
63
75
|
/** Overridable bounds for {@link resolveClientListLimit}. */
|
|
@@ -66,20 +78,46 @@ export interface ListLimitBounds {
|
|
|
66
78
|
defaultLimit?: number;
|
|
67
79
|
/** Default page size for vector-search reads. */
|
|
68
80
|
vectorDefaultLimit?: number;
|
|
69
|
-
/**
|
|
81
|
+
/** Largest limit a client may ask for. A larger one is rejected, not clamped. */
|
|
70
82
|
maxLimit?: number;
|
|
71
83
|
}
|
|
72
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Thrown by {@link resolveClientListLimit} for a `limit` the platform will not
|
|
87
|
+
* serve. Carries an HTTP status so an ingress that speaks HTTP can forward it
|
|
88
|
+
* verbatim, and `maxLimit` so one can be built without re-deriving the ceiling.
|
|
89
|
+
*
|
|
90
|
+
* @group Errors
|
|
91
|
+
*/
|
|
92
|
+
export class ListLimitError extends RebaseApiError {
|
|
93
|
+
/** The ceiling that was exceeded — what the caller should page by instead. */
|
|
94
|
+
readonly maxLimit: number;
|
|
95
|
+
|
|
96
|
+
constructor(message: string, maxLimit: number) {
|
|
97
|
+
super(message, { status: 400, code: "INVALID_LIMIT" });
|
|
98
|
+
this.name = "ListLimitError";
|
|
99
|
+
this.maxLimit = maxLimit;
|
|
100
|
+
// Keeps `instanceof` working when this is compiled down for an older
|
|
101
|
+
// target, where extending a builtin otherwise loses the prototype.
|
|
102
|
+
Object.setPrototypeOf(this, ListLimitError.prototype);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
73
106
|
/**
|
|
74
107
|
* Resolve a client-supplied list `limit` into a safe, always-defined value.
|
|
75
108
|
*
|
|
76
|
-
* -
|
|
77
|
-
* so `0`, negatives, and absurd values can never bypass the cap.
|
|
78
|
-
* - An absent / blank / non-numeric limit falls back to the mode default:
|
|
109
|
+
* - An absent / blank limit falls back to the mode default:
|
|
79
110
|
* `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
|
|
111
|
+
* - A limit that is present must be an integer in `[1, maxLimit]`. Anything
|
|
112
|
+
* else — `0`, a negative, `1.5`, `abc`, `100000000` — throws
|
|
113
|
+
* {@link ListLimitError} rather than being coerced into range, because every
|
|
114
|
+
* coercion answers a question the caller did not ask with a page it cannot
|
|
115
|
+
* tell apart from the whole collection.
|
|
80
116
|
*
|
|
81
117
|
* The return is never `undefined` — no ingress that routes its client limit
|
|
82
118
|
* through this can produce an unbounded read.
|
|
119
|
+
*
|
|
120
|
+
* @throws {ListLimitError} when a present `limit` is not an integer in range.
|
|
83
121
|
*/
|
|
84
122
|
export function resolveClientListLimit(
|
|
85
123
|
rawLimit: number | string | null | undefined,
|
|
@@ -87,10 +125,24 @@ export function resolveClientListLimit(
|
|
|
87
125
|
): number {
|
|
88
126
|
const maxLimit = opts.maxLimit ?? MAX_LIST_LIMIT;
|
|
89
127
|
if (rawLimit != null && String(rawLimit).trim() !== "") {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
128
|
+
// `Number`, not `parseInt`: `parseInt("50rows")` is 50, which silently
|
|
129
|
+
// reads a typo as a window the caller never wrote.
|
|
130
|
+
const parsed = typeof rawLimit === "number" ? rawLimit : Number(String(rawLimit).trim());
|
|
131
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
132
|
+
throw new ListLimitError(
|
|
133
|
+
`Invalid \`limit\`: ${String(rawLimit)}. Expected a whole number between 1 and ${maxLimit}.`,
|
|
134
|
+
maxLimit
|
|
135
|
+
);
|
|
93
136
|
}
|
|
137
|
+
if (parsed > maxLimit) {
|
|
138
|
+
throw new ListLimitError(
|
|
139
|
+
`\`limit\` ${parsed} is above the maximum of ${maxLimit}. Ask for at most ${maxLimit} rows ` +
|
|
140
|
+
"per read and page through the rest with `offset` — answering with a smaller page would be " +
|
|
141
|
+
"indistinguishable from there being no more rows.",
|
|
142
|
+
maxLimit
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return parsed;
|
|
94
146
|
}
|
|
95
147
|
return opts.vectorSearch
|
|
96
148
|
? (opts.vectorDefaultLimit ?? DEFAULT_VECTOR_LIST_LIMIT)
|
|
@@ -298,9 +350,41 @@ export interface DataDriver {
|
|
|
298
350
|
updateMany?<M extends Record<string, unknown> = Record<string, unknown>>(props: UpdateManyProps<M>): Promise<Record<string, unknown>[]>;
|
|
299
351
|
|
|
300
352
|
/**
|
|
301
|
-
* Delete
|
|
302
|
-
*
|
|
303
|
-
*
|
|
353
|
+
* Delete the row `props.row` addresses.
|
|
354
|
+
*
|
|
355
|
+
* **Resolving means the row is gone because this call removed it.** A
|
|
356
|
+
* delete that matched nothing must reject with a not-found error
|
|
357
|
+
* (`ApiError.notFound`, `statusCode: 404`) rather than resolving quietly.
|
|
358
|
+
*
|
|
359
|
+
* The rule is here rather than in each driver because the two
|
|
360
|
+
* implementations answered differently and each had a test pinning its own
|
|
361
|
+
* habit: Postgres threw, Mongo logged a warning and resolved. Three things
|
|
362
|
+
* decide it in favour of rejecting.
|
|
363
|
+
*
|
|
364
|
+
* The REST layer already says 404 — `DELETE /api/data/<c>/<id>` reads the
|
|
365
|
+
* row before removing it — so a quiet resolve made the driver API disagree
|
|
366
|
+
* with the HTTP API about the same operation, and only in-process
|
|
367
|
+
* `rebase.data` callers could see the difference.
|
|
368
|
+
*
|
|
369
|
+
* A caller cannot tell "deleted" from "there was nothing there" without it,
|
|
370
|
+
* and those are different facts: one means the caller's model of the data
|
|
371
|
+
* was right, the other that it was stale. Silence hands back the wrong one
|
|
372
|
+
* and the caller carries on.
|
|
373
|
+
*
|
|
374
|
+
* And on a driver with row-level security, "matched nothing" is *also* how
|
|
375
|
+
* a policy refusal arrives — Postgres filters `DELETE` through `USING`
|
|
376
|
+
* rather than raising. A driver that resolves on zero rows therefore
|
|
377
|
+
* reports a refused delete as a completed one, which is the defect
|
|
378
|
+
* `explainZeroRowWrite` exists to prevent (see `write-denial.ts`).
|
|
379
|
+
*
|
|
380
|
+
* Conformance for both server drivers lives in
|
|
381
|
+
* `packages/server/test/contract/delete-contract.ts`, run by each driver's
|
|
382
|
+
* own suite against its own database. `packages/firebase`'s Firestore
|
|
383
|
+
* driver does not honour it: `deleteDoc` resolves for a missing document
|
|
384
|
+
* and reporting otherwise would cost a read on every delete. It runs in the
|
|
385
|
+
* browser against Firestore's own semantics rather than behind
|
|
386
|
+
* `rebase.data`, and that exception is stated here rather than left to be
|
|
387
|
+
* discovered.
|
|
304
388
|
*/
|
|
305
389
|
delete<M extends Record<string, unknown> = Record<string, unknown>>(props: DeleteProps<M>): Promise<void>;
|
|
306
390
|
|
package/src/types/admin_block.ts
CHANGED
|
@@ -124,3 +124,86 @@ export const ADMIN_PROPERTY_KEYS = [
|
|
|
124
124
|
|
|
125
125
|
/** A key of a property's `admin` block. @group Models */
|
|
126
126
|
export type AdminPropertyKey = typeof ADMIN_PROPERTY_KEYS[number];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Move flattened admin keys back down into the `admin` block.
|
|
130
|
+
*
|
|
131
|
+
* The admin panel works with a *flat* view model — the block merged onto the
|
|
132
|
+
* collection — so what comes back from a form has `icon` and `defaultViewMode`
|
|
133
|
+
* at the top level while `admin` still holds whatever the file was loaded with.
|
|
134
|
+
* This is the way back.
|
|
135
|
+
*
|
|
136
|
+
* **The top-level value wins.** It is the one the form just wrote; the block is
|
|
137
|
+
* the copy the collection was loaded with, and preferring it resolves every edit
|
|
138
|
+
* in favour of the value the user changed away from.
|
|
139
|
+
*
|
|
140
|
+
* This lives here, next to the key lists, because it had two implementations —
|
|
141
|
+
* `toAdminCollectionConfig` in `@rebasepro/admin-types` and `nestAdminKeys` in
|
|
142
|
+
* `@rebasepro/server`'s schema editor — that agreed on everything except that
|
|
143
|
+
* precedence, which is the only part that decides whether a save is visible.
|
|
144
|
+
*
|
|
145
|
+
* @group Models
|
|
146
|
+
*/
|
|
147
|
+
export function nestAdminKeysOf(
|
|
148
|
+
source: Record<string, unknown>,
|
|
149
|
+
adminKeys: readonly string[]
|
|
150
|
+
): Record<string, unknown> {
|
|
151
|
+
const keys = new Set<string>(adminKeys);
|
|
152
|
+
const top: Record<string, unknown> = {};
|
|
153
|
+
const block: Record<string, unknown> = { ...((source.admin as Record<string, unknown> | undefined) ?? {}) };
|
|
154
|
+
|
|
155
|
+
for (const [key, value] of Object.entries(source)) {
|
|
156
|
+
if (key === "admin") continue;
|
|
157
|
+
if (keys.has(key)) block[key] = value;
|
|
158
|
+
else top[key] = value;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (Object.keys(block).length > 0) top.admin = block;
|
|
162
|
+
return top;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* {@link nestAdminKeysOf} for a collection.
|
|
167
|
+
*
|
|
168
|
+
* @group Models
|
|
169
|
+
*/
|
|
170
|
+
export function nestAdminCollectionKeys(collection: Record<string, unknown>): Record<string, unknown> {
|
|
171
|
+
return nestAdminKeysOf(collection, ADMIN_COLLECTION_KEYS);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* {@link nestAdminKeysOf} for a property, applied to its children too.
|
|
176
|
+
*
|
|
177
|
+
* A map property carries `properties`, an array property carries `of`, and both
|
|
178
|
+
* hold properties with `admin` blocks of their own. A flat `readOnly` left on a
|
|
179
|
+
* child is as dead — and as fatal at the next boot — as one left on the parent,
|
|
180
|
+
* so the walk goes all the way down.
|
|
181
|
+
*
|
|
182
|
+
* @group Models
|
|
183
|
+
*/
|
|
184
|
+
export function nestAdminPropertyKeys(property: Record<string, unknown>): Record<string, unknown> {
|
|
185
|
+
const nested = nestAdminKeysOf(property, ADMIN_PROPERTY_KEYS);
|
|
186
|
+
|
|
187
|
+
const children = nested.properties;
|
|
188
|
+
if (children && typeof children === "object" && !Array.isArray(children)) {
|
|
189
|
+
nested.properties = Object.fromEntries(
|
|
190
|
+
Object.entries(children as Record<string, unknown>).map(([key, child]) => [
|
|
191
|
+
key,
|
|
192
|
+
child && typeof child === "object" && !Array.isArray(child)
|
|
193
|
+
? nestAdminPropertyKeys(child as Record<string, unknown>)
|
|
194
|
+
: child
|
|
195
|
+
])
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const of = nested.of;
|
|
200
|
+
if (Array.isArray(of)) {
|
|
201
|
+
nested.of = of.map(entry => entry && typeof entry === "object" && !Array.isArray(entry)
|
|
202
|
+
? nestAdminPropertyKeys(entry as Record<string, unknown>)
|
|
203
|
+
: entry);
|
|
204
|
+
} else if (of && typeof of === "object") {
|
|
205
|
+
nested.of = nestAdminPropertyKeys(of as Record<string, unknown>);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return nested;
|
|
209
|
+
}
|
|
@@ -112,6 +112,13 @@ export interface AuthAdapterCapabilities {
|
|
|
112
112
|
emailVerification: boolean;
|
|
113
113
|
/** Supports passwordless magic link login. */
|
|
114
114
|
magicLink: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* Whether `POST /auth/anonymous` will mint a credential-less session.
|
|
117
|
+
*
|
|
118
|
+
* Optional so an external adapter that predates the key keeps typechecking;
|
|
119
|
+
* absent means "this adapter does not offer anonymous sign-in".
|
|
120
|
+
*/
|
|
121
|
+
anonymousLogin?: boolean;
|
|
115
122
|
/** List of enabled OAuth provider IDs (e.g. `["google", "github"]`). */
|
|
116
123
|
enabledProviders: string[];
|
|
117
124
|
|