@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.
@@ -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` to let unknown keys through to the database, which is what
159
- * happened before this existed: a typo reached the INSERT and came back as
160
- * a Postgres error about a column, or — where a column really does exist
161
- * that the config never declared, populated by a trigger or a default —
162
- * quietly worked. The second case is the reason for the escape hatch.
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 include this column in an API response.
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@rebasepro/types",
3
3
  "type": "module",
4
- "version": "0.13.1-canary.gf57a27e",
4
+ "version": "0.14.0",
5
5
  "description": "Rebase type definitions — shared interfaces and controller types",
6
6
  "funding": {
7
7
  "url": "https://github.com/sponsors/rebaseco"
@@ -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 RLS-bypassing
29
- * plane — while {@link data}, one property over, follows whoever triggered
30
- * the callback. On a user request, reaching for `context.client.dataAsAdmin`
31
- * silently escalates a user-scoped operation to admin. For queries in a
32
- * callback use {@link data}; come here for functions, storage and email.
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 **server-context work** (`rebase.dataAsAdmin`, a cron):
57
- * unscoped, on the owner connection, bypassing RLS.
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 past its caller.
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, **RLS-bypassing** data accessor.
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 with `{ uid: "service", roles: ["admin"] }` — every read and write
274
- * bypasses row-level-security policies. This is the correct tool for trusted
275
- * background work (cron jobs, migrations, service-to-service tasks).
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 so RLS applies.
280
- * Reaching for `dataAsAdmin` (or its alias {@link data}) in a request handler
281
- * silently exposes every row to every caller.
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, **RLS-bypassing**
401
- * driver, and it is the only name for it here — the `data` alias that used to
402
- * sit beside it is deliberately `Omit`ted from {@link RebaseClient} so the
403
- * privilege has to be spelled out at every call site. For user-scoped queries
404
- * inside a request handler use the request-scoped driver (`c.var.driver`)
405
- * instead — never `dataAsAdmin`.
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, **RLS-bypassing** data accessor. Always present server-side.
410
- * See {@link RebaseClient.dataAsAdmin} for the full safety contract.
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
 
@@ -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
- * Defaults to {@link DEFAULT_LIST_LIMIT}, and is clamped to
53
- * {@link MAX_LIST_LIMIT}. Both bounds are applied by the backend, so a
54
- * read is never unbounded whether or not a limit was asked for.
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: WhereFilterOp, value: WhereValue<M[K]>): this;
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: WhereFilterOp, value: WhereValue<M[K]>): QueryBuilderInterface<M>;
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: WhereFilterOp, value: WhereValue<M[K]>): this;
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; a server that cannot store keys ignores it
546
- * rather than refusing the write.
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: WhereFilterOp, value: WhereValue<M[K]>): SDKQueryBuilderInterface<M>;
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
- /** Hard ceiling clamped onto any client-supplied `limit`, on every surface. */
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
- /** Upper bound clamped onto any client-supplied limit. */
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
- * - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
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
- const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
91
- if (Number.isFinite(parsed)) {
92
- return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
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 a entity
302
- * @param props
303
- * @return was the whole deletion flow successful
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
 
@@ -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