@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.
@@ -23,11 +23,13 @@ export type RebaseCallContext<USER extends User = User> = {
23
23
  * Use it to call backend functions, access storage, send email, etc.
24
24
  *
25
25
  * ⚠️ **Not the same trust level as {@link data}.** Server-side this is the
26
- * app singleton, so `client.dataAsAdmin` is **always** the RLS-bypassing
27
- * plane — while {@link data}, one property over, follows whoever triggered
28
- * the callback. On a user request, reaching for `context.client.dataAsAdmin`
29
- * silently escalates a user-scoped operation to admin. For queries in a
30
- * callback use {@link data}; come here for functions, storage and email.
26
+ * app singleton, so `client.dataAsAdmin` is **always** the admin-scoped
27
+ * plane — scoped as `{ uid: "service", roles: ["admin"] }`, so policies are
28
+ * evaluated against that identity rather than skipped while {@link data},
29
+ * one property over, follows whoever triggered the callback. On a user
30
+ * request, reaching for `context.client.dataAsAdmin` silently escalates a
31
+ * user-scoped operation to admin. For queries in a callback use
32
+ * {@link data}; come here for functions, storage and email.
31
33
  *
32
34
  * @example
33
35
  * // In a beforeSave callback:
@@ -50,14 +52,21 @@ export type RebaseCallContext<USER extends User = User> = {
50
52
  * user-scoped. The callback runs on the RLS-bound transaction opened for
51
53
  * that request, so policies apply to reads *and* writes — a callback
52
54
  * cannot see a row its caller could not.
53
- * - Triggered by **server-context work** (`rebase.dataAsAdmin`, a cron):
54
- * unscoped, on the owner connection, bypassing RLS.
55
+ * - Triggered by **`rebase.dataAsAdmin` or a cron** (the same singleton):
56
+ * admin-scoped, not unscoped. That driver is scoped as
57
+ * `{ uid: "service", roles: ["admin"] }`, so the callback still runs on an
58
+ * RLS-bound transaction — policies are evaluated against that identity.
59
+ * - Triggered by **the base driver** (auth flows, migrations): unscoped, on
60
+ * the owner connection, bypassing RLS.
55
61
  *
56
62
  * So a callback that reads a sibling row will find it when an admin task
57
63
  * saves and may find nothing when an end user saves — without an error,
58
64
  * because RLS filters rather than raises. Write callbacks that tolerate
59
65
  * that, or reach for {@link client}`.dataAsAdmin` deliberately when the
60
- * callback genuinely has to see past its caller.
66
+ * callback genuinely has to see what an admin may see. Note what that does
67
+ * *not* buy you: `policy.serverContext()` (`auth.uid() IS NULL`) is false
68
+ * for the service identity, so a collection whose only rule is
69
+ * `serverContext()` stays closed to it.
61
70
  *
62
71
  * Verified end-to-end against Postgres rather than asserted — see
63
72
  * `"scopes context.data to the caller when a callback runs on a user
@@ -296,18 +296,31 @@ export interface RebaseClient<DB = unknown> {
296
296
  /** Unified Data access layer */
297
297
  data: RebaseSdkData<DB>;
298
298
  /**
299
- * Admin-scoped, **RLS-bypassing** data accessor.
299
+ * Admin-scoped data accessor — **not** an RLS bypass.
300
300
  *
301
301
  * Present on the **server** singleton only (see {@link RebaseServerClient}).
302
- * It runs with `{ uid: "service", roles: ["admin"] }` — every read and write
303
- * bypasses row-level-security policies. This is the correct tool for trusted
304
- * background work (cron jobs, migrations, service-to-service tasks).
302
+ * It runs as the service identity `{ uid: "service", roles: ["admin"] }`,
303
+ * and the driver is scoped with `withAuth()` at boot, so every read and
304
+ * write runs in a transaction that has switched to the restricted
305
+ * `rebase_user` role with `app.uid = 'service'`: policies are evaluated,
306
+ * against that identity. This is the correct tool for trusted background
307
+ * work (cron jobs, migrations, service-to-service tasks).
308
+ *
309
+ * Two consequences the name does not suggest:
310
+ *
311
+ * - `policy.serverContext()` compiles to `auth.uid() IS NULL` and is
312
+ * therefore **false** here. A collection with `disableDefaultPolicies:
313
+ * true` whose only rule is `serverContext()` refuses these writes
314
+ * (`42501`) and returns zero rows — HTTP 200, empty — for these reads.
315
+ * - Its reach equals an `admin`-roled application user's reach. It is not a
316
+ * private channel. The true bypass is {@link sql}, which runs on the
317
+ * owner connection and never goes through `withAuth`.
305
318
  *
306
319
  * ⚠️ **Do NOT use it to serve user-facing data.** Inside a request handler,
307
320
  * user-scoped queries must go through the request-scoped driver
308
- * (`c.var.driver`), which carries the caller's identity so RLS applies.
309
- * Reaching for `dataAsAdmin` (or its alias {@link data}) in a request handler
310
- * silently exposes every row to every caller.
321
+ * (`c.var.driver`), which carries the caller's identity. Reaching for
322
+ * `dataAsAdmin` (or its alias {@link data}) in a request handler serves
323
+ * every caller whatever an admin may see.
311
324
  *
312
325
  * Undefined in the browser SDK.
313
326
  */
@@ -405,17 +418,20 @@ export interface RebaseClient<DB = unknown> {
405
418
  * the admin-scoped {@link dataAsAdmin} accessor, raw {@link sql}, and the
406
419
  * {@link email} service are all present (non-optional).
407
420
  *
408
- * **Trust levels.** {@link dataAsAdmin} is the admin-scoped, **RLS-bypassing**
409
- * driver, and it is the only name for it here — the `data` alias that used to
410
- * sit beside it is deliberately `Omit`ted from {@link RebaseClient} so the
411
- * privilege has to be spelled out at every call site. For user-scoped queries
412
- * inside a request handler use the request-scoped driver (`c.var.driver`)
413
- * instead never `dataAsAdmin`.
421
+ * **Trust levels.** {@link dataAsAdmin} is the admin-scoped driver — scoped as
422
+ * `{ uid: "service", roles: ["admin"] }`, so policies are still evaluated
423
+ * against that identity rather than skipped and it is the only name for it
424
+ * here: the `data` alias that used to sit beside it is deliberately `Omit`ted
425
+ * from {@link RebaseClient} so the privilege has to be spelled out at every
426
+ * call site. {@link sql} is the unconditional bypass: raw SQL on the owner
427
+ * connection, no policies. For user-scoped queries inside a request handler use
428
+ * the request-scoped driver (`c.var.driver`) instead — never `dataAsAdmin`.
414
429
  */
415
430
  export interface RebaseServerClient<DB = unknown> extends Omit<RebaseClient<DB>, "data"> {
416
431
  /**
417
- * Admin-scoped, **RLS-bypassing** data accessor. Always present server-side.
418
- * See {@link RebaseClient.dataAsAdmin} for the full safety contract.
432
+ * Admin-scoped data accessor (RLS is evaluated as the service identity, not
433
+ * skipped). Always present server-side. See {@link RebaseClient.dataAsAdmin}
434
+ * for the full safety contract.
419
435
  */
420
436
  dataAsAdmin: RebaseSdkData<DB>;
421
437
  /**
@@ -2,7 +2,66 @@ import type { VectorSearchParams } from "./data_driver";
2
2
  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
+ /**
6
+ * Operator-blind filter value: whatever the column holds, a list of it, or null.
7
+ *
8
+ * @deprecated Superseded by {@link WhereValueFor}, which correlates the value
9
+ * with the operator. Kept exported because it is public API and downstream code
10
+ * annotates with it; every `where()` overload in this file uses `WhereValueFor`.
11
+ */
5
12
  export type WhereValue<T> = T | T[] | null;
13
+ /**
14
+ * The element type of an array column, and the column's own type otherwise.
15
+ *
16
+ * A generated SDK emits an `array` property as `Array<X>` and a to-many
17
+ * relation as `Array<TargetRow>`, so this is what `array-contains` compares
18
+ * against on either.
19
+ */
20
+ export type ElementOf<T> = T extends readonly (infer E)[] ? E : T;
21
+ /**
22
+ * The `id` of a row-shaped element, and `never` for anything else.
23
+ *
24
+ * A to-many relation is emitted as `Array<TargetRow>`, but the filter compilers
25
+ * compare a relation by **id** — `buildRelationFilterPredicate` in
26
+ * `@rebasepro/server-postgres` unwraps a relation value down to its id — so
27
+ * `where("tags", "array-contains", tagId)` is the call that works, and the
28
+ * element type alone would refuse it.
29
+ */
30
+ export type IdOf<E> = E extends {
31
+ id: infer I;
32
+ } ? I : never;
33
+ /**
34
+ * One member of an array column: its element, or — when the element is a row —
35
+ * that row's id, which is what a relation filter is actually compared against.
36
+ */
37
+ export type WhereElementOf<T> = ElementOf<T> | IdOf<ElementOf<T>>;
38
+ /**
39
+ * The value a given operator takes on a column of type `T`.
40
+ *
41
+ * `WhereValue<T>` was one value type for all sixteen operators, which made
42
+ * `array-contains` uncallable from a generated SDK — it is the one operator
43
+ * whose value is an *element* of the column rather than the column's own type,
44
+ * so on `tags: string[]` it wanted a `string[]` and the documented
45
+ * `.where("tags", "array-contains", "featured")` was a compile error. The
46
+ * spelling that did compile, `["featured"]`, builds `@> ARRAY[$1]` with the
47
+ * whole array bound as the single element and matches nothing: the correct
48
+ * query rejected, the accepted query silently wrong.
49
+ *
50
+ * The branches mirror `buildSingleFilterCondition` in `@rebasepro/server-postgres`:
51
+ *
52
+ * - `array-contains` → one element of the column (or a related row's id).
53
+ * - `in` / `not-in` / `array-contains-any` → a list of elements; a bare element
54
+ * is read as the one-element list, and `null` is a null check.
55
+ * - `like` / `ilike` / `not-like` / `not-ilike` → a SQL pattern. Always a
56
+ * string, including on numeric and date columns, which the driver casts.
57
+ * - `is-null` / `is-not-null` → nothing; the value is ignored everywhere.
58
+ * - everything else → the column's own type, or `null` for a null comparison.
59
+ *
60
+ * Distributes over `Op`, so a caller holding an unnarrowed `WhereFilterOp`
61
+ * (a dynamic filter UI, say) gets the union of every branch and stays as
62
+ * permissive as it was.
63
+ */
64
+ export type WhereValueFor<Op extends WhereFilterOp, T> = Op extends "array-contains" ? WhereElementOf<T> : Op extends "in" | "not-in" | "array-contains-any" ? readonly WhereElementOf<T>[] | WhereElementOf<T> | null : Op extends "like" | "ilike" | "not-like" | "not-ilike" ? string : Op extends "is-null" | "is-not-null" ? null | undefined : T | null;
6
65
  export interface LogicalCondition {
7
66
  type: "and" | "or";
8
67
  conditions: (FilterCondition | LogicalCondition)[];
@@ -45,9 +104,14 @@ export interface FindParams<M extends Record<string, unknown> = Record<string, u
45
104
  /**
46
105
  * Maximum number of items to return.
47
106
  *
48
- * Defaults to {@link DEFAULT_LIST_LIMIT}, and is clamped to
49
- * {@link MAX_LIST_LIMIT}. Both bounds are applied by the backend, so a
50
- * read is never unbounded whether or not a limit was asked for.
107
+ * Omit it and the backend applies {@link DEFAULT_LIST_LIMIT}, so a read is
108
+ * never unbounded. Provide it and it must be a whole number between 1 and
109
+ * {@link MAX_LIST_LIMIT}: the backend **rejects** anything else with a 400
110
+ * rather than trimming it to fit, because a page quietly smaller than the
111
+ * one you asked for is indistinguishable from having reached the end of the
112
+ * collection. To read past the ceiling, page with `offset` — or let
113
+ * {@link SDKCollectionClient.iterate} / {@link SDKCollectionClient.findAll}
114
+ * do it for you.
51
115
  */
52
116
  limit?: number;
53
117
  /**
@@ -160,7 +224,7 @@ export interface FindResponse<M extends Record<string, unknown> = Record<string,
160
224
  * @group Data
161
225
  */
162
226
  export interface QueryBuilderInterface<M extends Record<string, unknown> = Record<string, unknown>> {
163
- where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): this;
227
+ where<K extends keyof M & string, Op extends WhereFilterOp>(column: K, operator: Op, value: WhereValueFor<Op, M[K]>): this;
164
228
  where(logicalCondition: LogicalCondition): this;
165
229
  orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): this;
166
230
  limit(count: number): this;
@@ -265,7 +329,7 @@ export interface CollectionAccessor<M extends Record<string, unknown> = Record<s
265
329
  * one place the two halves of this API are not interchangeable.
266
330
  */
267
331
  count?(params?: FindParams<M>): Promise<number>;
268
- where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): QueryBuilderInterface<M>;
332
+ where<K extends keyof M & string, Op extends WhereFilterOp>(column: K, operator: Op, value: WhereValueFor<Op, M[K]>): QueryBuilderInterface<M>;
269
333
  where(logicalCondition: LogicalCondition): QueryBuilderInterface<M>;
270
334
  orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): QueryBuilderInterface<M>;
271
335
  limit(count: number): QueryBuilderInterface<M>;
@@ -444,7 +508,7 @@ export type FindAllParams<M extends Record<string, unknown> = Record<string, unk
444
508
  * @group Data
445
509
  */
446
510
  export interface SDKQueryBuilderInterface<M extends Record<string, unknown> = Record<string, unknown>> {
447
- where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): this;
511
+ where<K extends keyof M & string, Op extends WhereFilterOp>(column: K, operator: Op, value: WhereValueFor<Op, M[K]>): this;
448
512
  where(logicalCondition: LogicalCondition): this;
449
513
  orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): this;
450
514
  limit(count: number): this;
@@ -509,9 +573,20 @@ export interface WriteOptions {
509
573
  * it performs it again. On a table with a server-assigned id that is a
510
574
  * duplicate row, because the id the client chose was never used.
511
575
  *
576
+ * A key names **one** request, not a job. It records the method, the path
577
+ * and the body it was claimed for, so re-sending that exact request replays
578
+ * its answer, while the same key on a different one is refused with
579
+ * `IDEMPOTENCY_KEY_REUSED` (422) rather than silently answered with the
580
+ * first request's result. Pass a fresh key — a uuid — per call; a reusable
581
+ * business id shared by the create and the delete of one import means the
582
+ * second of them never runs.
583
+ *
512
584
  * Set by the offline queue on every replay. Honoured for 24 hours and scoped
513
- * to the authenticated user; a server that cannot store keys ignores it
514
- * rather than refusing the write.
585
+ * to the authenticated user an unauthenticated caller has no principal to
586
+ * scope it to, so the key is ignored there. A retry sent while the first
587
+ * attempt is still being answered gets `IDEMPOTENCY_KEY_IN_PROGRESS` (409)
588
+ * and should be sent again. A server that cannot store keys ignores the
589
+ * header rather than refusing the write.
515
590
  */
516
591
  idempotencyKey?: string;
517
592
  }
@@ -729,7 +804,7 @@ export interface SDKCollectionClient<M extends Record<string, unknown> = Record<
729
804
  * Count the number of records matching the given filter.
730
805
  */
731
806
  count?(params?: FindParams<M>): Promise<number>;
732
- where<K extends keyof M & string>(column: K, operator: WhereFilterOp, value: WhereValue<M[K]>): SDKQueryBuilderInterface<M>;
807
+ where<K extends keyof M & string, Op extends WhereFilterOp>(column: K, operator: Op, value: WhereValueFor<Op, M[K]>): SDKQueryBuilderInterface<M>;
733
808
  where(logicalCondition: LogicalCondition): SDKQueryBuilderInterface<M>;
734
809
  orderBy(column: (keyof M & string) | ComputedSortField, direction?: "asc" | "desc"): SDKQueryBuilderInterface<M>;
735
810
  limit(count: number): SDKQueryBuilderInterface<M>;
@@ -1,3 +1,4 @@
1
+ import { RebaseApiError } from "../errors";
1
2
  import type { EntityStatus, EntityValues } from "../types/entities";
2
3
  import type { CollectionConfig, FilterValues } from "../types/collections";
3
4
  import type { RebaseCallContext } from "../call_context";
@@ -37,7 +38,7 @@ export interface VectorSearchParams {
37
38
  export declare const DEFAULT_LIST_LIMIT = 50;
38
39
  /** Rows returned for a vector-search list read when the client sends no `limit`. */
39
40
  export declare const DEFAULT_VECTOR_LIST_LIMIT = 10;
40
- /** Hard ceiling clamped onto any client-supplied `limit`, on every surface. */
41
+ /** Largest `limit` a client may ask for on any surface. Above it, the read is refused. */
41
42
  export declare const MAX_LIST_LIMIT = 1000;
42
43
  /** Overridable bounds for {@link resolveClientListLimit}. */
43
44
  export interface ListLimitBounds {
@@ -45,19 +46,36 @@ export interface ListLimitBounds {
45
46
  defaultLimit?: number;
46
47
  /** Default page size for vector-search reads. */
47
48
  vectorDefaultLimit?: number;
48
- /** Upper bound clamped onto any client-supplied limit. */
49
+ /** Largest limit a client may ask for. A larger one is rejected, not clamped. */
49
50
  maxLimit?: number;
50
51
  }
52
+ /**
53
+ * Thrown by {@link resolveClientListLimit} for a `limit` the platform will not
54
+ * serve. Carries an HTTP status so an ingress that speaks HTTP can forward it
55
+ * verbatim, and `maxLimit` so one can be built without re-deriving the ceiling.
56
+ *
57
+ * @group Errors
58
+ */
59
+ export declare class ListLimitError extends RebaseApiError {
60
+ /** The ceiling that was exceeded — what the caller should page by instead. */
61
+ readonly maxLimit: number;
62
+ constructor(message: string, maxLimit: number);
63
+ }
51
64
  /**
52
65
  * Resolve a client-supplied list `limit` into a safe, always-defined value.
53
66
  *
54
- * - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
55
- * so `0`, negatives, and absurd values can never bypass the cap.
56
- * - An absent / blank / non-numeric limit falls back to the mode default:
67
+ * - An absent / blank limit falls back to the mode default:
57
68
  * `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
69
+ * - A limit that is present must be an integer in `[1, maxLimit]`. Anything
70
+ * else — `0`, a negative, `1.5`, `abc`, `100000000` — throws
71
+ * {@link ListLimitError} rather than being coerced into range, because every
72
+ * coercion answers a question the caller did not ask with a page it cannot
73
+ * tell apart from the whole collection.
58
74
  *
59
75
  * The return is never `undefined` — no ingress that routes its client limit
60
76
  * through this can produce an unbounded read.
77
+ *
78
+ * @throws {ListLimitError} when a present `limit` is not an integer in range.
61
79
  */
62
80
  export declare function resolveClientListLimit(rawLimit: number | string | null | undefined, opts?: ListLimitBounds & {
63
81
  vectorSearch?: boolean;
@@ -250,9 +268,41 @@ export interface DataDriver {
250
268
  */
251
269
  updateMany?<M extends Record<string, unknown> = Record<string, unknown>>(props: UpdateManyProps<M>): Promise<Record<string, unknown>[]>;
252
270
  /**
253
- * Delete a entity
254
- * @param props
255
- * @return was the whole deletion flow successful
271
+ * Delete the row `props.row` addresses.
272
+ *
273
+ * **Resolving means the row is gone because this call removed it.** A
274
+ * delete that matched nothing must reject with a not-found error
275
+ * (`ApiError.notFound`, `statusCode: 404`) rather than resolving quietly.
276
+ *
277
+ * The rule is here rather than in each driver because the two
278
+ * implementations answered differently and each had a test pinning its own
279
+ * habit: Postgres threw, Mongo logged a warning and resolved. Three things
280
+ * decide it in favour of rejecting.
281
+ *
282
+ * The REST layer already says 404 — `DELETE /api/data/<c>/<id>` reads the
283
+ * row before removing it — so a quiet resolve made the driver API disagree
284
+ * with the HTTP API about the same operation, and only in-process
285
+ * `rebase.data` callers could see the difference.
286
+ *
287
+ * A caller cannot tell "deleted" from "there was nothing there" without it,
288
+ * and those are different facts: one means the caller's model of the data
289
+ * was right, the other that it was stale. Silence hands back the wrong one
290
+ * and the caller carries on.
291
+ *
292
+ * And on a driver with row-level security, "matched nothing" is *also* how
293
+ * a policy refusal arrives — Postgres filters `DELETE` through `USING`
294
+ * rather than raising. A driver that resolves on zero rows therefore
295
+ * reports a refused delete as a completed one, which is the defect
296
+ * `explainZeroRowWrite` exists to prevent (see `write-denial.ts`).
297
+ *
298
+ * Conformance for both server drivers lives in
299
+ * `packages/server/test/contract/delete-contract.ts`, run by each driver's
300
+ * own suite against its own database. `packages/firebase`'s Firestore
301
+ * driver does not honour it: `deleteDoc` resolves for a missing document
302
+ * and reporting otherwise would cost a read on every delete. It runs in the
303
+ * browser against Firestore's own semantics rather than behind
304
+ * `rebase.data`, and that exception is stated here rather than left to be
305
+ * discovered.
256
306
  */
257
307
  delete<M extends Record<string, unknown> = Record<string, unknown>>(props: DeleteProps<M>): Promise<void>;
258
308
  /**
package/dist/index.es.js CHANGED
@@ -250,6 +250,19 @@ var ALL_WHERE_FILTER_OPS = [
250
250
  /** All canonical operator strings for runtime validation. */
251
251
  var CANONICAL_OPS = new Set(ALL_WHERE_FILTER_OPS);
252
252
  /**
253
+ * The REST table as a `Map`, because the key `toCanonicalOp` is handed comes
254
+ * off the wire.
255
+ *
256
+ * Indexed as a plain object, every `Object.prototype` member answered:
257
+ * `toCanonicalOp("valueOf")` returned the inherited *function* as though it
258
+ * were a `WhereFilterOp`, and every caller here treats a defined result as
259
+ * "known operator". Same defect the REST codec's own lookup tables were
260
+ * converted away from in `filter-dialect.ts`; this is the copy that survived
261
+ * one package over, and it now sits under the operator validation the REST
262
+ * parser does, which would otherwise have admitted `["constructor", x]`.
263
+ */
264
+ var REST_OP_LOOKUP = new Map(Object.entries(REST_TO_CANONICAL));
265
+ /**
253
266
  * Resolve any operator string (canonical or REST short-code) to its
254
267
  * canonical `WhereFilterOp` form. Returns `undefined` for unknown operators.
255
268
  *
@@ -261,7 +274,7 @@ var CANONICAL_OPS = new Set(ALL_WHERE_FILTER_OPS);
261
274
  */
262
275
  function toCanonicalOp(op) {
263
276
  if (CANONICAL_OPS.has(op)) return op;
264
- return REST_TO_CANONICAL[op];
277
+ return REST_OP_LOOKUP.get(op);
265
278
  }
266
279
  //#endregion
267
280
  //#region src/types/admin_block.ts
@@ -383,6 +396,64 @@ var ADMIN_PROPERTY_KEYS = [
383
396
  "urlPreview",
384
397
  "widget"
385
398
  ];
399
+ /**
400
+ * Move flattened admin keys back down into the `admin` block.
401
+ *
402
+ * The admin panel works with a *flat* view model — the block merged onto the
403
+ * collection — so what comes back from a form has `icon` and `defaultViewMode`
404
+ * at the top level while `admin` still holds whatever the file was loaded with.
405
+ * This is the way back.
406
+ *
407
+ * **The top-level value wins.** It is the one the form just wrote; the block is
408
+ * the copy the collection was loaded with, and preferring it resolves every edit
409
+ * in favour of the value the user changed away from.
410
+ *
411
+ * This lives here, next to the key lists, because it had two implementations —
412
+ * `toAdminCollectionConfig` in `@rebasepro/admin-types` and `nestAdminKeys` in
413
+ * `@rebasepro/server`'s schema editor — that agreed on everything except that
414
+ * precedence, which is the only part that decides whether a save is visible.
415
+ *
416
+ * @group Models
417
+ */
418
+ function nestAdminKeysOf(source, adminKeys) {
419
+ const keys = new Set(adminKeys);
420
+ const top = {};
421
+ const block = { ...source.admin ?? {} };
422
+ for (const [key, value] of Object.entries(source)) {
423
+ if (key === "admin") continue;
424
+ if (keys.has(key)) block[key] = value;
425
+ else top[key] = value;
426
+ }
427
+ if (Object.keys(block).length > 0) top.admin = block;
428
+ return top;
429
+ }
430
+ /**
431
+ * {@link nestAdminKeysOf} for a collection.
432
+ *
433
+ * @group Models
434
+ */
435
+ function nestAdminCollectionKeys(collection) {
436
+ return nestAdminKeysOf(collection, ADMIN_COLLECTION_KEYS);
437
+ }
438
+ /**
439
+ * {@link nestAdminKeysOf} for a property, applied to its children too.
440
+ *
441
+ * A map property carries `properties`, an array property carries `of`, and both
442
+ * hold properties with `admin` blocks of their own. A flat `readOnly` left on a
443
+ * child is as dead — and as fatal at the next boot — as one left on the parent,
444
+ * so the walk goes all the way down.
445
+ *
446
+ * @group Models
447
+ */
448
+ function nestAdminPropertyKeys(property) {
449
+ const nested = nestAdminKeysOf(property, ADMIN_PROPERTY_KEYS);
450
+ const children = nested.properties;
451
+ if (children && typeof children === "object" && !Array.isArray(children)) nested.properties = Object.fromEntries(Object.entries(children).map(([key, child]) => [key, child && typeof child === "object" && !Array.isArray(child) ? nestAdminPropertyKeys(child) : child]));
452
+ const of = nested.of;
453
+ if (Array.isArray(of)) nested.of = of.map((entry) => entry && typeof entry === "object" && !Array.isArray(entry) ? nestAdminPropertyKeys(entry) : entry);
454
+ else if (of && typeof of === "object") nested.of = nestAdminPropertyKeys(of);
455
+ return nested;
456
+ }
386
457
  //#endregion
387
458
  //#region src/types/data_source.ts
388
459
  /**
@@ -1239,24 +1310,51 @@ function computeSchemaVersion(collections) {
1239
1310
  var DEFAULT_LIST_LIMIT = 50;
1240
1311
  /** Rows returned for a vector-search list read when the client sends no `limit`. */
1241
1312
  var DEFAULT_VECTOR_LIST_LIMIT = 10;
1242
- /** Hard ceiling clamped onto any client-supplied `limit`, on every surface. */
1313
+ /** Largest `limit` a client may ask for on any surface. Above it, the read is refused. */
1243
1314
  var MAX_LIST_LIMIT = 1e3;
1244
1315
  /**
1316
+ * Thrown by {@link resolveClientListLimit} for a `limit` the platform will not
1317
+ * serve. Carries an HTTP status so an ingress that speaks HTTP can forward it
1318
+ * verbatim, and `maxLimit` so one can be built without re-deriving the ceiling.
1319
+ *
1320
+ * @group Errors
1321
+ */
1322
+ var ListLimitError = class ListLimitError extends RebaseApiError {
1323
+ /** The ceiling that was exceeded — what the caller should page by instead. */
1324
+ maxLimit;
1325
+ constructor(message, maxLimit) {
1326
+ super(message, {
1327
+ status: 400,
1328
+ code: "INVALID_LIMIT"
1329
+ });
1330
+ this.name = "ListLimitError";
1331
+ this.maxLimit = maxLimit;
1332
+ Object.setPrototypeOf(this, ListLimitError.prototype);
1333
+ }
1334
+ };
1335
+ /**
1245
1336
  * Resolve a client-supplied list `limit` into a safe, always-defined value.
1246
1337
  *
1247
- * - A provided limit is coerced to an integer and clamped to `[1, maxLimit]`,
1248
- * so `0`, negatives, and absurd values can never bypass the cap.
1249
- * - An absent / blank / non-numeric limit falls back to the mode default:
1338
+ * - An absent / blank limit falls back to the mode default:
1250
1339
  * `vectorDefaultLimit` for a vector search, otherwise `defaultLimit`.
1340
+ * - A limit that is present must be an integer in `[1, maxLimit]`. Anything
1341
+ * else — `0`, a negative, `1.5`, `abc`, `100000000` — throws
1342
+ * {@link ListLimitError} rather than being coerced into range, because every
1343
+ * coercion answers a question the caller did not ask with a page it cannot
1344
+ * tell apart from the whole collection.
1251
1345
  *
1252
1346
  * The return is never `undefined` — no ingress that routes its client limit
1253
1347
  * through this can produce an unbounded read.
1348
+ *
1349
+ * @throws {ListLimitError} when a present `limit` is not an integer in range.
1254
1350
  */
1255
1351
  function resolveClientListLimit(rawLimit, opts = {}) {
1256
1352
  const maxLimit = opts.maxLimit ?? 1e3;
1257
1353
  if (rawLimit != null && String(rawLimit).trim() !== "") {
1258
- const parsed = typeof rawLimit === "number" ? rawLimit : parseInt(String(rawLimit), 10);
1259
- if (Number.isFinite(parsed)) return Math.min(Math.max(1, Math.floor(parsed)), maxLimit);
1354
+ const parsed = typeof rawLimit === "number" ? rawLimit : Number(String(rawLimit).trim());
1355
+ if (!Number.isInteger(parsed) || parsed < 1) throw new ListLimitError(`Invalid \`limit\`: ${String(rawLimit)}. Expected a whole number between 1 and ${maxLimit}.`, maxLimit);
1356
+ if (parsed > maxLimit) throw new ListLimitError(`\`limit\` ${parsed} is above the maximum of ${maxLimit}. Ask for at most ${maxLimit} rows per read and page through the rest with \`offset\` — answering with a smaller page would be indistinguishable from there being no more rows.`, maxLimit);
1357
+ return parsed;
1260
1358
  }
1261
1359
  return opts.vectorSearch ? opts.vectorDefaultLimit ?? 10 : opts.defaultLimit ?? 50;
1262
1360
  }
@@ -1288,6 +1386,6 @@ function isPublicStoragePath(path) {
1288
1386
  return p.startsWith("public/") || p.startsWith(`default/public/`);
1289
1387
  }
1290
1388
  //#endregion
1291
- export { ADMIN_COLLECTION_KEYS, ADMIN_PROPERTY_KEYS, ALL_WHERE_FILTER_OPS, ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, BUNDLE_FORMAT_VERSION, CANONICAL_TO_REST, DEFAULT_CAPABILITIES, DEFAULT_DATA_SOURCE_KEY, DEFAULT_FILTERABLE_RELATION_KINDS, DEFAULT_FUZZY_THRESHOLD, DEFAULT_LIST_LIMIT, DEFAULT_SEARCH_COLUMN, DEFAULT_SEARCH_LANGUAGE, DEFAULT_SEARCH_WEIGHT, DEFAULT_STORAGE_SOURCE_KEY, DEFAULT_VECTOR_LIST_LIMIT, EntityReference, EntityRelation, FIREBASE_CAPABILITIES, GeoPoint, LEGACY_RLS_JWT_SQL, LEGACY_RLS_ROLES_SQL, LEGACY_RLS_SCHEMA, LEGACY_RLS_UID_SQL, MAX_LIST_LIMIT, MONGODB_CAPABILITIES, NULL_OPS, POSTGRES_CAPABILITIES, PUBLIC_STORAGE_PREFIX, REBASE_SCHEMA, RELEVANCE_SORT_FIELD, REST_TO_CANONICAL, RLS_JWT_SQL, RLS_ROLES_SQL, RLS_UID_SQL, RUNTIME_CONTRACT_VERSION, RebaseApiError, RebaseClientError, SCHEMA_VERSION_HEADER, Vector, canonicalSchemaPayload, computeSchemaVersion, deserializeCollections, findStorageSuffixCollision, getCollectionDataPath, getDataSourceCapabilities, getDeclaredSubcollections, hasForeignKeyOnTarget, isAnonymousUid, isBranchAdmin, isChannelBusInstance, isDocumentAdmin, isFirebaseCollectionConfig, isLazyComponentRef, isManyToMany, isMongoDBCollectionConfig, isPostgresCollectionConfig, isPublicStoragePath, isRelationalCollectionConfig, isSQLAdmin, isSchemaAdmin, isSerializedCollectionRef, isToMany, normalizeStorageSources, policy, registerDataSourceCapabilities, resolveClientListLimit, rewriteLegacyRlsFunctions, serializeCollections, storageEnvSuffix, toCanonicalOp, usesLegacyRlsFunctions };
1389
+ export { ADMIN_COLLECTION_KEYS, ADMIN_PROPERTY_KEYS, ALL_WHERE_FILTER_OPS, ANONYMOUS_USER_ID, ANONYMOUS_USER_IDS, BUNDLE_FORMAT_VERSION, CANONICAL_TO_REST, DEFAULT_CAPABILITIES, DEFAULT_DATA_SOURCE_KEY, DEFAULT_FILTERABLE_RELATION_KINDS, DEFAULT_FUZZY_THRESHOLD, DEFAULT_LIST_LIMIT, DEFAULT_SEARCH_COLUMN, DEFAULT_SEARCH_LANGUAGE, DEFAULT_SEARCH_WEIGHT, DEFAULT_STORAGE_SOURCE_KEY, DEFAULT_VECTOR_LIST_LIMIT, EntityReference, EntityRelation, FIREBASE_CAPABILITIES, GeoPoint, LEGACY_RLS_JWT_SQL, LEGACY_RLS_ROLES_SQL, LEGACY_RLS_SCHEMA, LEGACY_RLS_UID_SQL, ListLimitError, MAX_LIST_LIMIT, MONGODB_CAPABILITIES, NULL_OPS, POSTGRES_CAPABILITIES, PUBLIC_STORAGE_PREFIX, REBASE_SCHEMA, RELEVANCE_SORT_FIELD, REST_TO_CANONICAL, RLS_JWT_SQL, RLS_ROLES_SQL, RLS_UID_SQL, RUNTIME_CONTRACT_VERSION, RebaseApiError, RebaseClientError, SCHEMA_VERSION_HEADER, Vector, canonicalSchemaPayload, computeSchemaVersion, deserializeCollections, findStorageSuffixCollision, getCollectionDataPath, getDataSourceCapabilities, getDeclaredSubcollections, hasForeignKeyOnTarget, isAnonymousUid, isBranchAdmin, isChannelBusInstance, isDocumentAdmin, isFirebaseCollectionConfig, isLazyComponentRef, isManyToMany, isMongoDBCollectionConfig, isPostgresCollectionConfig, isPublicStoragePath, isRelationalCollectionConfig, isSQLAdmin, isSchemaAdmin, isSerializedCollectionRef, isToMany, nestAdminCollectionKeys, nestAdminKeysOf, nestAdminPropertyKeys, normalizeStorageSources, policy, registerDataSourceCapabilities, resolveClientListLimit, rewriteLegacyRlsFunctions, serializeCollections, storageEnvSuffix, toCanonicalOp, usesLegacyRlsFunctions };
1292
1390
 
1293
1391
  //# sourceMappingURL=index.es.js.map